From bfa8a0a7735c223890e11b2706ca34b4ba740620 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 8 Apr 2026 16:25:55 +0200 Subject: [PATCH] fix(mana-auth): /api/v1/auth/login mints JWT via auth.handler instead of api.signInEmail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous attempt (commit 55cc75e7d) tried to fix the broken JWT mint in /api/v1/auth/login by switching the cookie name from `mana.session_token` to `__Secure-mana.session_token` for production. That was necessary but not sufficient: Better Auth's session cookie value isn't just the raw session token, it's `.` where the HMAC is derived from the better-auth secret. Reconstructing the cookie from auth.api.signInEmail's JSON response only gave us the raw token, so /api/auth/token's get-session middleware still couldn't validate it and the JWT mint kept silently failing. Real fix: do the sign-in via auth.handler (the HTTP path) rather than auth.api.signInEmail (the SDK path). The handler returns a real fetch Response with a Set-Cookie header containing the fully signed cookie envelope. We capture that header verbatim and forward it as the cookie on the /api/auth/token request, which now passes validation and mints the JWT correctly. Verified end-to-end on auth.mana.how: $ curl -X POST https://auth.mana.how/api/v1/auth/login \ -d '{"email":"...","password":"..."}' { "user": {...}, "token": "", "accessToken": "eyJhbGciOiJFZERTQSI...", ← real JWT now "refreshToken": "" } Side benefits: - The email-not-verified path is now handled by checking signInResponse.status === 403 directly, no more catching APIError with the comment-noted async-stream footgun. - X-Forwarded-For is forwarded explicitly so Better Auth's rate limiter and our security log see the real client IP. - The leftover catch block now only handles unexpected exceptions (network errors etc); the FORBIDDEN-checking logic in it is dead but harmless and left in for defense in depth. --- .github/workflows/cd-macmini.yml | 34 +- .github/workflows/ci.yml | 44 - CLAUDE.md | 4 +- .../src/components/navigation/Footer.astro | 7 - .../apps/landing/src/pages/apps/index.astro | 6 - apps/mana/apps/web/src/lib/api/gifts.ts | 1 - .../lib/components/observatory/data/layout.ts | 1 - .../components/observatory/data/mockData.ts | 16 - .../observatory/ui/DetailPanel.svelte | 1 - apps/mana/apps/web/src/lib/i18n/index.ts | 3 - .../web/src/lib/i18n/locales/matrix/de.json | 46 - .../web/src/lib/i18n/locales/matrix/en.json | 46 - .../web/src/lib/i18n/locales/matrix/es.json | 46 - .../web/src/lib/i18n/locales/matrix/fr.json | 46 - .../web/src/lib/i18n/locales/matrix/it.json | 46 - .../web/src/routes/(app)/credits/+page.svelte | 1 - apps/matrix/CLAUDE.md | 259 --- apps/matrix/apps/mobile/.gitignore | 21 - apps/matrix/apps/mobile/app.json | 82 - apps/matrix/apps/mobile/app/(app)/_layout.tsx | 85 - apps/matrix/apps/mobile/app/(app)/dms.tsx | 96 - apps/matrix/apps/mobile/app/(app)/index.tsx | 109 - apps/matrix/apps/mobile/app/(app)/invites.tsx | 139 -- .../matrix/apps/mobile/app/(app)/settings.tsx | 243 -- .../matrix/apps/mobile/app/(auth)/_layout.tsx | 9 - apps/matrix/apps/mobile/app/(auth)/login.tsx | 226 -- apps/matrix/apps/mobile/app/+not-found.tsx | 13 - apps/matrix/apps/mobile/app/_layout.tsx | 71 - apps/matrix/apps/mobile/app/room/[id].tsx | 557 ----- apps/matrix/apps/mobile/app/room/new.tsx | 209 -- apps/matrix/apps/mobile/app/room/settings.tsx | 188 -- apps/matrix/apps/mobile/app/search.tsx | 189 -- .../apps/mobile/assets/adaptive-icon.png | Bin 127687 -> 0 bytes apps/matrix/apps/mobile/assets/favicon.png | Bin 955 -> 0 bytes apps/matrix/apps/mobile/assets/icon.png | Bin 127687 -> 0 bytes .../apps/mobile/assets/notification-icon.png | Bin 7860 -> 0 bytes apps/matrix/apps/mobile/assets/splash.png | Bin 127687 -> 0 bytes apps/matrix/apps/mobile/babel.config.js | 7 - .../apps/mobile/eas-build-pre-install.sh | 13 - apps/matrix/apps/mobile/eas.json | 37 - apps/matrix/apps/mobile/eslint.config.js | 9 - apps/matrix/apps/mobile/expo-env.d.ts | 3 - apps/matrix/apps/mobile/global.css | 3 - apps/matrix/apps/mobile/metro.config.js | 51 - apps/matrix/apps/mobile/nativewind-env.d.ts | 10 - apps/matrix/apps/mobile/package.json | 67 - apps/matrix/apps/mobile/prettier.config.js | 8 - .../mobile/src/components/DateSeparator.tsx | 28 - .../mobile/src/components/ImageViewer.tsx | 67 - .../mobile/src/components/MessageBubble.tsx | 448 ---- .../mobile/src/components/MessageInput.tsx | 157 -- .../mobile/src/components/MessageText.tsx | 80 - .../mobile/src/components/RoomListItem.tsx | 108 - .../mobile/src/components/SyncStatusBar.tsx | 26 - .../mobile/src/components/TypingIndicator.tsx | 22 - .../mobile/src/components/UnreadSeparator.tsx | 11 - .../src/components/UserProfileModal.tsx | 157 -- .../mobile/src/components/VoiceMessage.tsx | 77 - .../mobile/src/components/VoiceRecorder.tsx | 122 - apps/matrix/apps/mobile/src/matrix/client.ts | 99 - apps/matrix/apps/mobile/src/matrix/index.ts | 4 - apps/matrix/apps/mobile/src/matrix/media.ts | 41 - .../apps/mobile/src/matrix/polyfills.ts | 15 - apps/matrix/apps/mobile/src/matrix/store.ts | 596 ----- apps/matrix/apps/mobile/src/matrix/types.ts | 91 - apps/matrix/apps/mobile/src/matrix/upload.ts | 63 - .../apps/mobile/src/notifications/index.ts | 107 - apps/matrix/apps/mobile/tailwind.config.js | 21 - apps/matrix/apps/mobile/tsconfig.json | 18 - apps/matrix/apps/web/Dockerfile | 32 - apps/matrix/apps/web/package.json | 60 - .../apps/web/scripts/generate-icons.mjs | 50 - apps/matrix/apps/web/src/app.css | 127 -- apps/matrix/apps/web/src/app.d.ts | 23 - apps/matrix/apps/web/src/app.html | 32 - apps/matrix/apps/web/src/hooks.client.ts | 12 - apps/matrix/apps/web/src/hooks.server.ts | 15 - .../src/lib/components/bots/BotCard.svelte | 166 -- .../src/lib/components/call/CallView.svelte | 224 -- .../components/call/IncomingCallDialog.svelte | 123 - .../apps/web/src/lib/components/call/index.ts | 2 - .../components/chat/CreateRoomDialog.svelte | 313 --- .../components/chat/DropZoneOverlay.svelte | 27 - .../chat/ForwardMessageDialog.svelte | 179 -- .../src/lib/components/chat/Message.svelte | 845 ------- .../lib/components/chat/MessageInput.svelte | 846 ------- .../src/lib/components/chat/RoomHeader.svelte | 203 -- .../src/lib/components/chat/RoomItem.svelte | 130 -- .../src/lib/components/chat/RoomList.svelte | 184 -- .../components/chat/RoomSettingsPanel.svelte | 325 --- .../lib/components/chat/SearchDialog.svelte | 187 -- .../src/lib/components/chat/Timeline.svelte | 186 -- .../components/chat/TypingIndicator.svelte | 70 - .../apps/web/src/lib/components/chat/index.ts | 10 - .../crypto/EmojiVerification.svelte | 158 -- .../crypto/RecoveryKeyDialog.svelte | 374 --- .../crypto/VerificationDialog.svelte | 244 -- .../web/src/lib/components/crypto/index.ts | 4 - .../web/src/lib/content/help/index.test.ts | 47 - .../apps/web/src/lib/content/help/index.ts | 276 --- apps/matrix/apps/web/src/lib/data/bots.ts | 424 ---- apps/matrix/apps/web/src/lib/i18n/index.ts | 49 - .../apps/web/src/lib/i18n/locales/de.json | 46 - .../apps/web/src/lib/i18n/locales/en.json | 46 - .../apps/web/src/lib/matrix/client.test.ts | 136 -- apps/matrix/apps/web/src/lib/matrix/client.ts | 252 -- apps/matrix/apps/web/src/lib/matrix/crypto.ts | 137 -- apps/matrix/apps/web/src/lib/matrix/index.ts | 12 - .../apps/web/src/lib/matrix/polyfills.ts | 18 - .../apps/web/src/lib/matrix/store.svelte.ts | 2019 ----------------- apps/matrix/apps/web/src/lib/matrix/types.ts | 324 --- .../apps/web/src/lib/notifications/index.ts | 171 -- .../web/src/lib/stores/navigation.svelte.ts | 19 - .../apps/web/src/lib/stores/tags.svelte.ts | 24 - apps/matrix/apps/web/src/lib/stores/theme.ts | 10 - .../web/src/lib/stores/userSettings.svelte.ts | 77 - apps/matrix/apps/web/src/lib/version.ts | 4 - .../apps/web/src/routes/(app)/+layout.svelte | 586 ----- .../apps/web/src/routes/(app)/+layout.ts | 4 - .../web/src/routes/(app)/bots/+page.svelte | 158 -- .../web/src/routes/(app)/chat/+page.svelte | 385 ---- .../routes/(app)/chat/[roomId]/+page.svelte | 315 --- .../src/routes/(app)/feedback/+page.svelte | 26 - .../web/src/routes/(app)/help/+page.svelte | 32 - .../src/routes/(app)/settings/+page.svelte | 410 ---- .../web/src/routes/(app)/tags/+page.svelte | 49 - .../apps/web/src/routes/(auth)/+layout.svelte | 21 - .../web/src/routes/(auth)/login/+page.svelte | 849 ------- apps/matrix/apps/web/src/routes/+error.svelte | 46 - .../matrix/apps/web/src/routes/+layout.svelte | 44 - apps/matrix/apps/web/src/routes/+page.svelte | 33 - .../apps/web/src/routes/health/+server.ts | 17 - .../apps/web/src/routes/offline/+page.svelte | 9 - .../apps/web/src/routes/offline/+page.ts | 1 - .../web/src/test/mocks/app-environment.ts | 4 - .../apps/web/static/apple-touch-icon.png | Bin 5167 -> 0 bytes apps/matrix/apps/web/static/favicon.png | Bin 955 -> 0 bytes apps/matrix/apps/web/static/favicon.svg | 30 - apps/matrix/apps/web/static/pwa-192x192.png | Bin 5466 -> 0 bytes apps/matrix/apps/web/static/pwa-512x512.png | Bin 17527 -> 0 bytes apps/matrix/apps/web/svelte.config.js | 14 - apps/matrix/apps/web/tsconfig.json | 19 - apps/matrix/apps/web/vite.config.ts | 188 -- apps/matrix/apps/web/vitest.config.ts | 16 - apps/matrix/package.json | 10 - apps/matrix/packages/shared/package.json | 18 - apps/matrix/packages/shared/src/index.ts | 2 - apps/matrix/packages/shared/src/types.ts | 42 - apps/matrix/packages/shared/tsconfig.json | 16 - cloudflared-config.yml | 8 - docker-compose.macmini.yml | 172 -- docker/matrix/appservices/generate-as.sh | 54 - .../matrix/config/appservices/generate-as.sh | 54 - docker/matrix/config/homeserver.yaml | 211 -- docker/matrix/config/log.config.yaml | 34 - docker/matrix/element-config.json | 59 - docker/matrix/element/config.json | 59 - docker/matrix/homeserver.yaml | 222 -- docker/matrix/log.config.yaml | 34 - docker/prometheus/alerts.yml | 2 +- docker/prometheus/prometheus.yml | 18 +- docker/promtail/config.yaml | 5 - docs/CLOUDFLARE_DOMAINS.md | 6 +- docs/CLOUDFLARE_FALLBACK.md | 8 - docs/MAC_MINI_SERVER.md | 93 +- docs/MATRIX_BOT_ARCHITECTURE.md | 990 -------- docs/MATRIX_SELF_HOSTING.md | 674 ------ docs/PORT_SCHEMA.md | 9 +- docs/URL_SCHEMA.md | 5 +- package.json | 9 +- packages/notify-client/src/client.ts | 29 - packages/notify-client/src/types.ts | 11 +- packages/shared-branding/src/app-icons.ts | 4 - packages/shared-branding/src/mana-apps.ts | 20 +- scripts/audit-workspace-deps.mjs | 1 - scripts/mac-mini/build-app.sh | 1 - scripts/mac-mini/deploy-v2.sh | 2 +- scripts/mac-mini/ensure-containers-running.sh | 4 - scripts/mac-mini/memory-baseline.sh | 4 +- scripts/mac-mini/migrate-to-colima.sh | 1 - scripts/mac-mini/setup-matrix.sh | 123 - scripts/mac-mini/setup-tts-bot.sh | 160 -- scripts/mac-mini/startup.sh | 15 +- services/mana-auth/CLAUDE.md | 9 +- .../mana-auth/src/auth/better-auth.config.ts | 53 - services/mana-auth/src/config.ts | 2 - services/mana-auth/src/db/schema/auth.ts | 81 - services/mana-auth/src/routes/auth.ts | 71 +- services/mana-matrix-bot/.gitignore | 3 - services/mana-matrix-bot/CLAUDE.md | 67 - services/mana-matrix-bot/Dockerfile | 28 - services/mana-matrix-bot/cmd/server/main.go | 77 - services/mana-matrix-bot/go.mod | 28 - services/mana-matrix-bot/go.sum | 66 - .../mana-matrix-bot/internal/config/config.go | 219 -- .../mana-matrix-bot/internal/matrix/client.go | 241 -- .../internal/matrix/markdown.go | 63 - .../internal/matrix/markdown_test.go | 64 - .../mana-matrix-bot/internal/matrix/types.go | 75 - .../internal/matrix/types_test.go | 27 - .../internal/plugin/command.go | 43 - .../internal/plugin/keyword.go | 87 - .../internal/plugin/keyword_test.go | 78 - .../mana-matrix-bot/internal/plugin/plugin.go | 99 - .../internal/plugin/registry.go | 29 - .../internal/plugins/calendar/calendar.go | 410 ---- .../internal/plugins/cards/cards.go | 66 - .../internal/plugins/chat/chat.go | 65 - .../internal/plugins/clock/clock.go | 441 ---- .../internal/plugins/clock/clock_test.go | 71 - .../internal/plugins/contacts/contacts.go | 543 ----- .../internal/plugins/gateway/gateway.go | 586 ----- .../internal/plugins/gateway/ollama.go | 120 - .../internal/plugins/nutriphi/nutriphi.go | 72 - .../internal/plugins/ollama/ollama.go | 462 ---- .../internal/plugins/onboarding/onboarding.go | 61 - .../internal/plugins/picture/picture.go | 74 - .../internal/plugins/planta/planta.go | 543 ----- .../internal/plugins/presi/presi.go | 66 - .../internal/plugins/projectdoc/projectdoc.go | 71 - .../internal/plugins/questions/questions.go | 66 - .../internal/plugins/skilltree/skilltree.go | 65 - .../internal/plugins/stats/stats.go | 79 - .../internal/plugins/storage/storage.go | 66 - .../internal/plugins/stt/stt.go | 200 -- .../internal/plugins/todo/todo.go | 553 ----- .../internal/plugins/todo/todo_test.go | 87 - .../internal/plugins/tts/tts.go | 253 --- .../internal/plugins/zitare/zitare.go | 392 ---- .../internal/runtime/health.go | 66 - .../internal/runtime/runtime.go | 387 ---- .../mana-matrix-bot/internal/services/auth.go | 52 - .../internal/services/backend.go | 154 -- .../internal/services/credit.go | 62 - .../internal/services/voice.go | 118 - .../mana-matrix-bot/internal/session/redis.go | 174 -- .../internal/session/session.go | 93 - services/mana-matrix-bot/package.json | 12 - services/mana-media/CLAUDE.md | 38 +- .../apps/api/src/db/schema/media.schema.ts | 4 +- services/mana-media/apps/api/src/index.ts | 4 +- .../mana-media/apps/api/src/routes/upload.ts | 24 - .../apps/api/src/services/matrix.ts | 61 - .../apps/api/src/services/upload.ts | 55 - .../mana-media/packages/client/src/index.ts | 34 - services/mana-notify/CLAUDE.md | 5 +- services/mana-notify/cmd/server/main.go | 3 +- .../mana-notify/internal/channel/matrix.go | 97 - .../mana-notify/internal/config/config.go | 7 - .../mana-notify/internal/db/migrations.go | 2 +- .../internal/handler/notifications.go | 18 +- .../internal/handler/notifications_test.go | 6 +- .../mana-notify/internal/metrics/metrics.go | 6 - services/mana-notify/internal/queue/worker.go | 24 +- 254 files changed, 88 insertions(+), 29437 deletions(-) delete mode 100644 apps/mana/apps/web/src/lib/i18n/locales/matrix/de.json delete mode 100644 apps/mana/apps/web/src/lib/i18n/locales/matrix/en.json delete mode 100644 apps/mana/apps/web/src/lib/i18n/locales/matrix/es.json delete mode 100644 apps/mana/apps/web/src/lib/i18n/locales/matrix/fr.json delete mode 100644 apps/mana/apps/web/src/lib/i18n/locales/matrix/it.json delete mode 100644 apps/matrix/CLAUDE.md delete mode 100644 apps/matrix/apps/mobile/.gitignore delete mode 100644 apps/matrix/apps/mobile/app.json delete mode 100644 apps/matrix/apps/mobile/app/(app)/_layout.tsx delete mode 100644 apps/matrix/apps/mobile/app/(app)/dms.tsx delete mode 100644 apps/matrix/apps/mobile/app/(app)/index.tsx delete mode 100644 apps/matrix/apps/mobile/app/(app)/invites.tsx delete mode 100644 apps/matrix/apps/mobile/app/(app)/settings.tsx delete mode 100644 apps/matrix/apps/mobile/app/(auth)/_layout.tsx delete mode 100644 apps/matrix/apps/mobile/app/(auth)/login.tsx delete mode 100644 apps/matrix/apps/mobile/app/+not-found.tsx delete mode 100644 apps/matrix/apps/mobile/app/_layout.tsx delete mode 100644 apps/matrix/apps/mobile/app/room/[id].tsx delete mode 100644 apps/matrix/apps/mobile/app/room/new.tsx delete mode 100644 apps/matrix/apps/mobile/app/room/settings.tsx delete mode 100644 apps/matrix/apps/mobile/app/search.tsx delete mode 100644 apps/matrix/apps/mobile/assets/adaptive-icon.png delete mode 100644 apps/matrix/apps/mobile/assets/favicon.png delete mode 100644 apps/matrix/apps/mobile/assets/icon.png delete mode 100644 apps/matrix/apps/mobile/assets/notification-icon.png delete mode 100644 apps/matrix/apps/mobile/assets/splash.png delete mode 100644 apps/matrix/apps/mobile/babel.config.js delete mode 100755 apps/matrix/apps/mobile/eas-build-pre-install.sh delete mode 100644 apps/matrix/apps/mobile/eas.json delete mode 100644 apps/matrix/apps/mobile/eslint.config.js delete mode 100644 apps/matrix/apps/mobile/expo-env.d.ts delete mode 100644 apps/matrix/apps/mobile/global.css delete mode 100644 apps/matrix/apps/mobile/metro.config.js delete mode 100644 apps/matrix/apps/mobile/nativewind-env.d.ts delete mode 100644 apps/matrix/apps/mobile/package.json delete mode 100644 apps/matrix/apps/mobile/prettier.config.js delete mode 100644 apps/matrix/apps/mobile/src/components/DateSeparator.tsx delete mode 100644 apps/matrix/apps/mobile/src/components/ImageViewer.tsx delete mode 100644 apps/matrix/apps/mobile/src/components/MessageBubble.tsx delete mode 100644 apps/matrix/apps/mobile/src/components/MessageInput.tsx delete mode 100644 apps/matrix/apps/mobile/src/components/MessageText.tsx delete mode 100644 apps/matrix/apps/mobile/src/components/RoomListItem.tsx delete mode 100644 apps/matrix/apps/mobile/src/components/SyncStatusBar.tsx delete mode 100644 apps/matrix/apps/mobile/src/components/TypingIndicator.tsx delete mode 100644 apps/matrix/apps/mobile/src/components/UnreadSeparator.tsx delete mode 100644 apps/matrix/apps/mobile/src/components/UserProfileModal.tsx delete mode 100644 apps/matrix/apps/mobile/src/components/VoiceMessage.tsx delete mode 100644 apps/matrix/apps/mobile/src/components/VoiceRecorder.tsx delete mode 100644 apps/matrix/apps/mobile/src/matrix/client.ts delete mode 100644 apps/matrix/apps/mobile/src/matrix/index.ts delete mode 100644 apps/matrix/apps/mobile/src/matrix/media.ts delete mode 100644 apps/matrix/apps/mobile/src/matrix/polyfills.ts delete mode 100644 apps/matrix/apps/mobile/src/matrix/store.ts delete mode 100644 apps/matrix/apps/mobile/src/matrix/types.ts delete mode 100644 apps/matrix/apps/mobile/src/matrix/upload.ts delete mode 100644 apps/matrix/apps/mobile/src/notifications/index.ts delete mode 100644 apps/matrix/apps/mobile/tailwind.config.js delete mode 100644 apps/matrix/apps/mobile/tsconfig.json delete mode 100644 apps/matrix/apps/web/Dockerfile delete mode 100644 apps/matrix/apps/web/package.json delete mode 100644 apps/matrix/apps/web/scripts/generate-icons.mjs delete mode 100644 apps/matrix/apps/web/src/app.css delete mode 100644 apps/matrix/apps/web/src/app.d.ts delete mode 100644 apps/matrix/apps/web/src/app.html delete mode 100644 apps/matrix/apps/web/src/hooks.client.ts delete mode 100644 apps/matrix/apps/web/src/hooks.server.ts delete mode 100644 apps/matrix/apps/web/src/lib/components/bots/BotCard.svelte delete mode 100644 apps/matrix/apps/web/src/lib/components/call/CallView.svelte delete mode 100644 apps/matrix/apps/web/src/lib/components/call/IncomingCallDialog.svelte delete mode 100644 apps/matrix/apps/web/src/lib/components/call/index.ts delete mode 100644 apps/matrix/apps/web/src/lib/components/chat/CreateRoomDialog.svelte delete mode 100644 apps/matrix/apps/web/src/lib/components/chat/DropZoneOverlay.svelte delete mode 100644 apps/matrix/apps/web/src/lib/components/chat/ForwardMessageDialog.svelte delete mode 100644 apps/matrix/apps/web/src/lib/components/chat/Message.svelte delete mode 100644 apps/matrix/apps/web/src/lib/components/chat/MessageInput.svelte delete mode 100644 apps/matrix/apps/web/src/lib/components/chat/RoomHeader.svelte delete mode 100644 apps/matrix/apps/web/src/lib/components/chat/RoomItem.svelte delete mode 100644 apps/matrix/apps/web/src/lib/components/chat/RoomList.svelte delete mode 100644 apps/matrix/apps/web/src/lib/components/chat/RoomSettingsPanel.svelte delete mode 100644 apps/matrix/apps/web/src/lib/components/chat/SearchDialog.svelte delete mode 100644 apps/matrix/apps/web/src/lib/components/chat/Timeline.svelte delete mode 100644 apps/matrix/apps/web/src/lib/components/chat/TypingIndicator.svelte delete mode 100644 apps/matrix/apps/web/src/lib/components/chat/index.ts delete mode 100644 apps/matrix/apps/web/src/lib/components/crypto/EmojiVerification.svelte delete mode 100644 apps/matrix/apps/web/src/lib/components/crypto/RecoveryKeyDialog.svelte delete mode 100644 apps/matrix/apps/web/src/lib/components/crypto/VerificationDialog.svelte delete mode 100644 apps/matrix/apps/web/src/lib/components/crypto/index.ts delete mode 100644 apps/matrix/apps/web/src/lib/content/help/index.test.ts delete mode 100644 apps/matrix/apps/web/src/lib/content/help/index.ts delete mode 100644 apps/matrix/apps/web/src/lib/data/bots.ts delete mode 100644 apps/matrix/apps/web/src/lib/i18n/index.ts delete mode 100644 apps/matrix/apps/web/src/lib/i18n/locales/de.json delete mode 100644 apps/matrix/apps/web/src/lib/i18n/locales/en.json delete mode 100644 apps/matrix/apps/web/src/lib/matrix/client.test.ts delete mode 100644 apps/matrix/apps/web/src/lib/matrix/client.ts delete mode 100644 apps/matrix/apps/web/src/lib/matrix/crypto.ts delete mode 100644 apps/matrix/apps/web/src/lib/matrix/index.ts delete mode 100644 apps/matrix/apps/web/src/lib/matrix/polyfills.ts delete mode 100644 apps/matrix/apps/web/src/lib/matrix/store.svelte.ts delete mode 100644 apps/matrix/apps/web/src/lib/matrix/types.ts delete mode 100644 apps/matrix/apps/web/src/lib/notifications/index.ts delete mode 100644 apps/matrix/apps/web/src/lib/stores/navigation.svelte.ts delete mode 100644 apps/matrix/apps/web/src/lib/stores/tags.svelte.ts delete mode 100644 apps/matrix/apps/web/src/lib/stores/theme.ts delete mode 100644 apps/matrix/apps/web/src/lib/stores/userSettings.svelte.ts delete mode 100644 apps/matrix/apps/web/src/lib/version.ts delete mode 100644 apps/matrix/apps/web/src/routes/(app)/+layout.svelte delete mode 100644 apps/matrix/apps/web/src/routes/(app)/+layout.ts delete mode 100644 apps/matrix/apps/web/src/routes/(app)/bots/+page.svelte delete mode 100644 apps/matrix/apps/web/src/routes/(app)/chat/+page.svelte delete mode 100644 apps/matrix/apps/web/src/routes/(app)/chat/[roomId]/+page.svelte delete mode 100644 apps/matrix/apps/web/src/routes/(app)/feedback/+page.svelte delete mode 100644 apps/matrix/apps/web/src/routes/(app)/help/+page.svelte delete mode 100644 apps/matrix/apps/web/src/routes/(app)/settings/+page.svelte delete mode 100644 apps/matrix/apps/web/src/routes/(app)/tags/+page.svelte delete mode 100644 apps/matrix/apps/web/src/routes/(auth)/+layout.svelte delete mode 100644 apps/matrix/apps/web/src/routes/(auth)/login/+page.svelte delete mode 100644 apps/matrix/apps/web/src/routes/+error.svelte delete mode 100644 apps/matrix/apps/web/src/routes/+layout.svelte delete mode 100644 apps/matrix/apps/web/src/routes/+page.svelte delete mode 100644 apps/matrix/apps/web/src/routes/health/+server.ts delete mode 100644 apps/matrix/apps/web/src/routes/offline/+page.svelte delete mode 100644 apps/matrix/apps/web/src/routes/offline/+page.ts delete mode 100644 apps/matrix/apps/web/src/test/mocks/app-environment.ts delete mode 100644 apps/matrix/apps/web/static/apple-touch-icon.png delete mode 100644 apps/matrix/apps/web/static/favicon.png delete mode 100644 apps/matrix/apps/web/static/favicon.svg delete mode 100644 apps/matrix/apps/web/static/pwa-192x192.png delete mode 100644 apps/matrix/apps/web/static/pwa-512x512.png delete mode 100644 apps/matrix/apps/web/svelte.config.js delete mode 100644 apps/matrix/apps/web/tsconfig.json delete mode 100644 apps/matrix/apps/web/vite.config.ts delete mode 100644 apps/matrix/apps/web/vitest.config.ts delete mode 100644 apps/matrix/package.json delete mode 100644 apps/matrix/packages/shared/package.json delete mode 100644 apps/matrix/packages/shared/src/index.ts delete mode 100644 apps/matrix/packages/shared/src/types.ts delete mode 100644 apps/matrix/packages/shared/tsconfig.json delete mode 100644 docker/matrix/appservices/generate-as.sh delete mode 100644 docker/matrix/config/appservices/generate-as.sh delete mode 100644 docker/matrix/config/homeserver.yaml delete mode 100644 docker/matrix/config/log.config.yaml delete mode 100644 docker/matrix/element-config.json delete mode 100644 docker/matrix/element/config.json delete mode 100644 docker/matrix/homeserver.yaml delete mode 100644 docker/matrix/log.config.yaml delete mode 100644 docs/MATRIX_BOT_ARCHITECTURE.md delete mode 100644 docs/MATRIX_SELF_HOSTING.md delete mode 100755 scripts/mac-mini/setup-matrix.sh delete mode 100755 scripts/mac-mini/setup-tts-bot.sh delete mode 100644 services/mana-matrix-bot/.gitignore delete mode 100644 services/mana-matrix-bot/CLAUDE.md delete mode 100644 services/mana-matrix-bot/Dockerfile delete mode 100644 services/mana-matrix-bot/cmd/server/main.go delete mode 100644 services/mana-matrix-bot/go.mod delete mode 100644 services/mana-matrix-bot/go.sum delete mode 100644 services/mana-matrix-bot/internal/config/config.go delete mode 100644 services/mana-matrix-bot/internal/matrix/client.go delete mode 100644 services/mana-matrix-bot/internal/matrix/markdown.go delete mode 100644 services/mana-matrix-bot/internal/matrix/markdown_test.go delete mode 100644 services/mana-matrix-bot/internal/matrix/types.go delete mode 100644 services/mana-matrix-bot/internal/matrix/types_test.go delete mode 100644 services/mana-matrix-bot/internal/plugin/command.go delete mode 100644 services/mana-matrix-bot/internal/plugin/keyword.go delete mode 100644 services/mana-matrix-bot/internal/plugin/keyword_test.go delete mode 100644 services/mana-matrix-bot/internal/plugin/plugin.go delete mode 100644 services/mana-matrix-bot/internal/plugin/registry.go delete mode 100644 services/mana-matrix-bot/internal/plugins/calendar/calendar.go delete mode 100644 services/mana-matrix-bot/internal/plugins/cards/cards.go delete mode 100644 services/mana-matrix-bot/internal/plugins/chat/chat.go delete mode 100644 services/mana-matrix-bot/internal/plugins/clock/clock.go delete mode 100644 services/mana-matrix-bot/internal/plugins/clock/clock_test.go delete mode 100644 services/mana-matrix-bot/internal/plugins/contacts/contacts.go delete mode 100644 services/mana-matrix-bot/internal/plugins/gateway/gateway.go delete mode 100644 services/mana-matrix-bot/internal/plugins/gateway/ollama.go delete mode 100644 services/mana-matrix-bot/internal/plugins/nutriphi/nutriphi.go delete mode 100644 services/mana-matrix-bot/internal/plugins/ollama/ollama.go delete mode 100644 services/mana-matrix-bot/internal/plugins/onboarding/onboarding.go delete mode 100644 services/mana-matrix-bot/internal/plugins/picture/picture.go delete mode 100644 services/mana-matrix-bot/internal/plugins/planta/planta.go delete mode 100644 services/mana-matrix-bot/internal/plugins/presi/presi.go delete mode 100644 services/mana-matrix-bot/internal/plugins/projectdoc/projectdoc.go delete mode 100644 services/mana-matrix-bot/internal/plugins/questions/questions.go delete mode 100644 services/mana-matrix-bot/internal/plugins/skilltree/skilltree.go delete mode 100644 services/mana-matrix-bot/internal/plugins/stats/stats.go delete mode 100644 services/mana-matrix-bot/internal/plugins/storage/storage.go delete mode 100644 services/mana-matrix-bot/internal/plugins/stt/stt.go delete mode 100644 services/mana-matrix-bot/internal/plugins/todo/todo.go delete mode 100644 services/mana-matrix-bot/internal/plugins/todo/todo_test.go delete mode 100644 services/mana-matrix-bot/internal/plugins/tts/tts.go delete mode 100644 services/mana-matrix-bot/internal/plugins/zitare/zitare.go delete mode 100644 services/mana-matrix-bot/internal/runtime/health.go delete mode 100644 services/mana-matrix-bot/internal/runtime/runtime.go delete mode 100644 services/mana-matrix-bot/internal/services/auth.go delete mode 100644 services/mana-matrix-bot/internal/services/backend.go delete mode 100644 services/mana-matrix-bot/internal/services/credit.go delete mode 100644 services/mana-matrix-bot/internal/services/voice.go delete mode 100644 services/mana-matrix-bot/internal/session/redis.go delete mode 100644 services/mana-matrix-bot/internal/session/session.go delete mode 100644 services/mana-matrix-bot/package.json delete mode 100644 services/mana-media/apps/api/src/services/matrix.ts delete mode 100644 services/mana-notify/internal/channel/matrix.go diff --git a/.github/workflows/cd-macmini.yml b/.github/workflows/cd-macmini.yml index 35728bef7..fee1c4df9 100644 --- a/.github/workflows/cd-macmini.yml +++ b/.github/workflows/cd-macmini.yml @@ -24,7 +24,6 @@ on: type: choice options: - all - - matrix-web - mana-auth - mana-sync - mana-media @@ -33,7 +32,6 @@ on: - mana-crawler - mana-credits - mana-search - - mana-matrix-bot - chat-backend - chat-web - todo-backend @@ -59,8 +57,6 @@ env: PROJECT_DIR: /Users/mana/projects/mana-monorepo COMPOSE_FILE: docker-compose.macmini.yml ENV_FILE: .env.macmini - DEPLOY_NOTIFY_ROOM_ID: ${{ secrets.DEPLOY_NOTIFY_ROOM_ID }} - DEPLOY_NOTIFY_BOT_TOKEN: ${{ secrets.DEPLOY_NOTIFY_BOT_TOKEN }} DOCKER_BUILDKIT: 1 PATH: /usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin @@ -73,7 +69,6 @@ jobs: runs-on: self-hosted if: github.event_name == 'push' outputs: - matrix-web: ${{ steps.changes.outputs.matrix-web }} mana-auth: ${{ steps.changes.outputs.mana-auth }} mana-sync: ${{ steps.changes.outputs.mana-sync }} mana-media: ${{ steps.changes.outputs.mana-media }} @@ -98,7 +93,6 @@ jobs: memoro-server: ${{ steps.changes.outputs.memoro-server }} memoro-audio-server: ${{ steps.changes.outputs.memoro-audio-server }} memoro-web: ${{ steps.changes.outputs.memoro-web }} - mana-matrix-bot: ${{ steps.changes.outputs.mana-matrix-bot }} any-changes: ${{ steps.changes.outputs.any-changes }} steps: - name: Check for changes @@ -139,7 +133,6 @@ jobs: echo "Shared packages changed: $SHARED_CHANGED" echo "" - check_changes "matrix-web" "apps/matrix/apps/web/" "apps/matrix/packages/" check_changes "mana-auth" "services/mana-auth/" check_changes "mana-sync" "services/mana-sync/" "packages/shared-go/" check_changes "mana-media" "services/mana-media/" @@ -165,12 +158,11 @@ jobs: check_changes "memoro-server" "apps/memoro/apps/server/" "apps/memoro/packages/" check_changes "memoro-audio-server" "apps/memoro/apps/audio-server/" check_changes "memoro-web" "apps/memoro/apps/web/" "apps/memoro/packages/" - check_changes "mana-matrix-bot" "services/mana-matrix-bot/" check_changes "mana-landing-builder" "services/mana-landing-builder/" "packages/shared-types/" "packages/shared-landing-ui/" # Check if anything needs deploying ANY="false" - for svc in matrix-web mana-auth mana-sync mana-media mana-notify mana-api-gateway mana-crawler mana-credits mana-search chat-backend chat-web todo-backend todo-web calendar-backend calendar-web clock-web contacts-backend contacts-web music-backend music-web storage-backend storage-web memoro-server memoro-audio-server memoro-web mana-matrix-bot mana-landing-builder; do + for svc in mana-auth mana-sync mana-media mana-notify mana-api-gateway mana-crawler mana-credits mana-search chat-backend chat-web todo-backend todo-web calendar-backend calendar-web clock-web contacts-backend contacts-web music-backend music-web storage-backend storage-web memoro-server memoro-audio-server memoro-web mana-landing-builder; do val=$(grep "^$svc=" $GITHUB_OUTPUT | tail -1 | cut -d= -f2) if [ "$val" == "true" ]; then ANY="true" @@ -231,7 +223,6 @@ jobs: fi else # Build list from detected changes - if [ "${{ needs.detect-changes.outputs.matrix-web }}" == "true" ]; then SERVICES="$SERVICES matrix-web"; fi if [ "${{ needs.detect-changes.outputs.mana-auth }}" == "true" ]; then SERVICES="$SERVICES mana-auth"; fi if [ "${{ needs.detect-changes.outputs.mana-sync }}" == "true" ]; then SERVICES="$SERVICES mana-sync"; fi if [ "${{ needs.detect-changes.outputs.mana-media }}" == "true" ]; then SERVICES="$SERVICES mana-media"; fi @@ -257,7 +248,6 @@ jobs: if [ "${{ needs.detect-changes.outputs.memoro-server }}" == "true" ]; then SERVICES="$SERVICES memoro-server"; fi if [ "${{ needs.detect-changes.outputs.memoro-audio-server }}" == "true" ]; then SERVICES="$SERVICES memoro-audio-server"; fi if [ "${{ needs.detect-changes.outputs.memoro-web }}" == "true" ]; then SERVICES="$SERVICES memoro-web"; fi - if [ "${{ needs.detect-changes.outputs.mana-matrix-bot }}" == "true" ]; then SERVICES="$SERVICES mana-matrix-bot"; fi if [ "${{ needs.detect-changes.outputs.mana-landing-builder }}" == "true" ]; then SERVICES="$SERVICES mana-landing-builder"; fi fi @@ -344,7 +334,6 @@ jobs: health_url_for() { case "$1" in mana-auth) echo "http://localhost:3001/health" ;; - matrix-web) echo "http://localhost:5180/health" ;; chat-backend) echo "http://localhost:3030/health" ;; chat-web) echo "http://localhost:5010/health" ;; todo-backend) echo "http://localhost:3031/health" ;; @@ -371,7 +360,7 @@ jobs: SERVICES="${{ steps.services.outputs.services }}" if [ "$DEPLOY_ALL" == "true" ]; then - SERVICES="mana-auth matrix-web chat-backend chat-web todo-backend todo-web calendar-backend calendar-web clock-web contacts-backend contacts-web music-backend music-web storage-backend storage-web memoro-server memoro-audio-server memoro-web" + SERVICES="mana-auth chat-backend chat-web todo-backend todo-web calendar-backend calendar-web clock-web contacts-backend contacts-web music-backend music-web storage-backend storage-web memoro-server memoro-audio-server memoro-web" fi HEALTH_RESULTS="" @@ -499,23 +488,8 @@ jobs: COMMIT_MSG=$(git log -1 --pretty=%s 2>/dev/null | head -c 100) RUN_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" - MSG="⚠️ **Deploy failed**\n\n**Services:** ${SERVICES}\n**Commit:** ${COMMIT_MSG}\n**By:** ${{ github.actor }}\n**[View logs](${RUN_URL})**" - - # Send to Matrix deploy-notifications room via Synapse API - ROOM_ID="${DEPLOY_NOTIFY_ROOM_ID:-}" - BOT_TOKEN="${DEPLOY_NOTIFY_BOT_TOKEN:-}" - if [ -n "$ROOM_ID" ] && [ -n "$BOT_TOKEN" ]; then - TXN_ID="deploy-$(date +%s)" - curl -s -X PUT \ - "http://localhost:8008/_matrix/client/v3/rooms/${ROOM_ID}/send/m.room.message/${TXN_ID}" \ - -H "Authorization: Bearer ${BOT_TOKEN}" \ - -H "Content-Type: application/json" \ - -d "{\"msgtype\":\"m.text\",\"body\":\"Deploy failed: ${SERVICES}\",\"format\":\"org.matrix.custom.html\",\"formatted_body\":\"$(echo -e "$MSG" | sed 's/"/\\"/g')\"}" \ - || true - echo "Matrix notification sent" - else - echo "Matrix notification skipped (DEPLOY_NOTIFY_ROOM_ID or DEPLOY_NOTIFY_BOT_TOKEN not set)" - fi + MSG="⚠️ Deploy failed: ${SERVICES} (commit ${COMMIT_MSG} by ${{ github.actor }}) — ${RUN_URL}" + echo "$MSG" - name: Cleanup old images if: always() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 632e4bc99..d61c830b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,7 +74,6 @@ jobs: nutriphi-backend: ${{ steps.changes.outputs.nutriphi-backend }} nutriphi-web: ${{ steps.changes.outputs.nutriphi-web }} skilltree-web: ${{ steps.changes.outputs.skilltree-web }} - mana-matrix-bot: ${{ steps.changes.outputs.mana-matrix-bot }} any-changes: ${{ steps.changes.outputs.any-changes }} steps: - name: Checkout code @@ -113,7 +112,6 @@ jobs: echo "nutriphi-backend=true" >> $GITHUB_OUTPUT echo "nutriphi-web=true" >> $GITHUB_OUTPUT echo "skilltree-web=true" >> $GITHUB_OUTPUT - echo "mana-matrix-bot=true" >> $GITHUB_OUTPUT echo "any-changes=true" >> $GITHUB_OUTPUT exit 0 fi @@ -156,7 +154,6 @@ jobs: echo "nutriphi-backend=true" >> $GITHUB_OUTPUT echo "nutriphi-web=true" >> $GITHUB_OUTPUT echo "skilltree-web=true" >> $GITHUB_OUTPUT - echo "mana-matrix-bot=true" >> $GITHUB_OUTPUT echo "any-changes=true" >> $GITHUB_OUTPUT exit 0 fi @@ -370,14 +367,6 @@ jobs: echo "skilltree-web=false" >> $GITHUB_OUTPUT fi - # mana-matrix-bot (consolidated Go bot) - MANA_MATRIX_BOT_CHANGED=$(check_pattern "services/mana-matrix-bot/") - if [ "$MANA_MATRIX_BOT_CHANGED" == "true" ]; then - echo "mana-matrix-bot=true" >> $GITHUB_OUTPUT - else - echo "mana-matrix-bot=false" >> $GITHUB_OUTPUT - fi - # zitare-backend: REMOVED — migrated to local-first # Check if any service needs building @@ -1221,38 +1210,5 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max - # =========================================== - # Matrix Bots - # =========================================== - - build-mana-matrix-bot: - name: Build mana-matrix-bot (Go) - runs-on: ubuntu-latest - needs: detect-changes - if: needs.detect-changes.outputs.mana-matrix-bot == 'true' - steps: - - uses: actions/checkout@v4 - - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 - - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/metadata-action@v5 - id: meta - with: - images: ghcr.io/${{ github.repository_owner }}/mana-matrix-bot - tags: type=raw,value=latest - - uses: docker/build-push-action@v5 - with: - context: . - file: services/mana-matrix-bot/Dockerfile - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.meta.outputs.tags }} - cache-from: type=gha - cache-to: type=gha,mode=max - # =========================================== # Zitare Backend: REMOVED — migrated to local-first diff --git a/CLAUDE.md b/CLAUDE.md index 324548a32..6dd80ea0c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ apps/ ├── mana/ # Unified frontend (SvelteKit web + Expo mobile + Astro landing) ├── api/ # Unified backend API (Hono/Bun) — @mana/api ├── {product}/ # Per-product landing pages, mobile apps, packages -│ # Standalone (own container, not unified): matrix, manavoxel +│ # Standalone (own container, not unified): manavoxel games/ # arcade, voxelava, whopixels, worldream services/ # Backend services (Hono/Bun, Go, Python) — see list below packages/ # Shared workspace packages (@mana/*) @@ -33,7 +33,7 @@ docs/ # Long-form docs (deployment, hardware, postmortems, etc.) ### Active services (`services/`) -`mana-auth` (3001), `mana-sync` (3050), `mana-credits`, `mana-user`, `mana-subscriptions`, `mana-analytics`, `mana-search` (3021), `mana-crawler`, `mana-api-gateway`, `mana-notify`, `mana-matrix-bot`, `mana-media`, `mana-llm`, `mana-image-gen`, `mana-video-gen`, `mana-stt`, `mana-tts`, `mana-voice-bot`, `mana-events`, `mana-landing-builder`. Each non-trivial service has its own `CLAUDE.md`. +`mana-auth` (3001), `mana-sync` (3050), `mana-credits`, `mana-user`, `mana-subscriptions`, `mana-analytics`, `mana-search` (3021), `mana-crawler`, `mana-api-gateway`, `mana-notify`, `mana-media`, `mana-llm`, `mana-image-gen`, `mana-video-gen`, `mana-stt`, `mana-tts`, `mana-voice-bot`, `mana-events`, `mana-landing-builder`. Each non-trivial service has its own `CLAUDE.md`. ## Coding Guidelines diff --git a/apps/mana/apps/landing/src/components/navigation/Footer.astro b/apps/mana/apps/landing/src/components/navigation/Footer.astro index d3fc41eac..689d92cb7 100644 --- a/apps/mana/apps/landing/src/components/navigation/Footer.astro +++ b/apps/mana/apps/landing/src/components/navigation/Footer.astro @@ -28,7 +28,6 @@ const ecosystemApps = [ { label: 'Taktik', href: 'https://times.mana.how', status: 'alpha' }, { label: 'uLoad', href: 'https://ulo.ad', status: 'alpha' }, { label: 'Playground', href: 'https://playground.mana.how', status: 'alpha' }, - { label: 'Matrix', href: 'https://matrix.mana.how', status: 'alpha' }, ]; const platformLinks = [ @@ -36,7 +35,6 @@ const platformLinks = [ { label: 'Observatory', href: 'https://mana.how/observatory' }, { label: 'Alle Apps', href: '/apps' }, { label: 'Preise', href: '/pricing' }, - { label: 'Matrix Chat', href: 'https://element.mana.how' }, ]; const monitoringLinks = [ @@ -223,11 +221,6 @@ function statusColor(status: string) { GitHub -
  • - - Matrix Chat - -
  • diff --git a/apps/mana/apps/landing/src/pages/apps/index.astro b/apps/mana/apps/landing/src/pages/apps/index.astro index 2e54a2966..93f628fd3 100644 --- a/apps/mana/apps/landing/src/pages/apps/index.astro +++ b/apps/mana/apps/landing/src/pages/apps/index.astro @@ -67,12 +67,6 @@ const sections: Section[] = [ { name: 'Traces', icon: 'ph:map-trifold-bold', tagline: 'Stadt-Erkundung', url: 'https://traces.mana.how' }, ], }, - { - label: 'Kommunikation', - apps: [ - { name: 'Matrix', icon: 'ph:chats-bold', tagline: 'Matrix Chat', url: 'https://matrix.mana.how' }, - ], - }, ]; --- diff --git a/apps/mana/apps/web/src/lib/api/gifts.ts b/apps/mana/apps/web/src/lib/api/gifts.ts index e80e6da1d..9be0bd733 100644 --- a/apps/mana/apps/web/src/lib/api/gifts.ts +++ b/apps/mana/apps/web/src/lib/api/gifts.ts @@ -71,7 +71,6 @@ export interface CreateGiftRequest { type?: 'simple' | 'personalized' | 'split' | 'first_come' | 'riddle'; portions?: number; targetEmail?: string; - targetMatrixId?: string; riddleQuestion?: string; riddleAnswer?: string; message?: string; diff --git a/apps/mana/apps/web/src/lib/components/observatory/data/layout.ts b/apps/mana/apps/web/src/lib/components/observatory/data/layout.ts index b620a8d95..ae6038274 100644 --- a/apps/mana/apps/web/src/lib/components/observatory/data/layout.ts +++ b/apps/mana/apps/web/src/lib/components/observatory/data/layout.ts @@ -163,7 +163,6 @@ export const APP_POSITIONS: Record Application > Manifest for errors - -## Related Documentation - -- [Matrix Client-Server API](https://spec.matrix.org/latest/client-server-api/) -- [matrix-js-sdk docs](https://matrix-org.github.io/matrix-js-sdk/) -- [Synapse Admin API](https://element-hq.github.io/synapse/latest/admin_api/) -- [Vite PWA Plugin](https://vite-pwa-org.netlify.app/frameworks/sveltekit.html) diff --git a/apps/matrix/apps/mobile/.gitignore b/apps/matrix/apps/mobile/.gitignore deleted file mode 100644 index 526492a93..000000000 --- a/apps/matrix/apps/mobile/.gitignore +++ /dev/null @@ -1,21 +0,0 @@ -node_modules/ -.expo/ -dist/ -build/ -ios/ -android/ -*.jks -*.p8 -*.p12 -*.key -*.mobileprovision -*.orig.* -web-build/ -.env -.env.local - -# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb -# The following patterns were generated by expo-cli - -expo-env.d.ts -# @end expo-cli \ No newline at end of file diff --git a/apps/matrix/apps/mobile/app.json b/apps/matrix/apps/mobile/app.json deleted file mode 100644 index f16f88740..000000000 --- a/apps/matrix/apps/mobile/app.json +++ /dev/null @@ -1,82 +0,0 @@ -{ - "expo": { - "name": "Manalink", - "slug": "manalink", - "owner": "tilljs", - "version": "1.0.0", - "scheme": "manalink", - "orientation": "portrait", - "icon": "./assets/icon.png", - "userInterfaceStyle": "automatic", - "splash": { - "image": "./assets/splash.png", - "resizeMode": "contain", - "backgroundColor": "#0f0f0f" - }, - "assetBundlePatterns": ["**/*"], - "ios": { - "supportsTablet": true, - "bundleIdentifier": "how.mana.manalink", - "infoPlist": { - "ITSAppUsesNonExemptEncryption": false - } - }, - "android": { - "adaptiveIcon": { - "foregroundImage": "./assets/adaptive-icon.png", - "backgroundColor": "#0f0f0f" - }, - "package": "how.mana.manalink", - "permissions": [ - "android.permission.RECORD_AUDIO", - "android.permission.MODIFY_AUDIO_SETTINGS", - "android.permission.READ_EXTERNAL_STORAGE", - "android.permission.WRITE_EXTERNAL_STORAGE" - ] - }, - "web": { - "bundler": "metro", - "output": "static", - "favicon": "./assets/favicon.png" - }, - "plugins": [ - "expo-router", - "expo-secure-store", - "expo-audio", - [ - "expo-image-picker", - { - "photosPermission": "Allow Manalink to select photos for sharing.", - "cameraPermission": "Allow Manalink to take photos for sharing." - } - ], - [ - "expo-media-library", - { - "photosPermission": "Allow Manalink to save images to your library.", - "savePhotosPermission": "Allow Manalink to save images." - } - ], - [ - "expo-notifications", - { - "icon": "./assets/notification-icon.png", - "color": "#7c6bff", - "sounds": [] - } - ] - ], - "experiments": { - "typedRoutes": true, - "tsconfigPaths": true - }, - "extra": { - "router": { - "origin": false - }, - "eas": { - "projectId": "a4c5098c-fcae-474e-95b2-13394d8b323d" - } - } - } -} diff --git a/apps/matrix/apps/mobile/app/(app)/_layout.tsx b/apps/matrix/apps/mobile/app/(app)/_layout.tsx deleted file mode 100644 index e030b0517..000000000 --- a/apps/matrix/apps/mobile/app/(app)/_layout.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { View, Text } from 'react-native'; -import { Tabs } from 'expo-router'; -import { ChatCircle, User, Bell, GearSix } from 'phosphor-react-native'; -import { useMatrixStore } from '~/src/matrix/store'; - -const BG = '#0f0f0f'; -const BORDER = '#2a2a2a'; -const ACTIVE = '#7c6bff'; -const INACTIVE = '#6b7280'; -const SIZE = 22; - -function InviteBadge({ count }: { count: number }) { - if (count === 0) return null; - return ( - - - {count > 9 ? '9+' : count} - - - ); -} - -export default function AppLayout() { - const invites = useMatrixStore((s) => s.invites); - - return ( - - ( - - ), - }} - /> - ( - - ), - }} - /> - ( - - - - - ), - }} - /> - ( - - ), - }} - /> - - ); -} diff --git a/apps/matrix/apps/mobile/app/(app)/dms.tsx b/apps/matrix/apps/mobile/app/(app)/dms.tsx deleted file mode 100644 index 7ca1ad97c..000000000 --- a/apps/matrix/apps/mobile/app/(app)/dms.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { useState, useMemo } from 'react'; -import { View, Text, FlatList, Pressable, ActivityIndicator, TextInput } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { useRouter } from 'expo-router'; -import { Plus, MagnifyingGlass } from 'phosphor-react-native'; -import { useMatrixStore } from '~/src/matrix/store'; -import RoomListItem from '~/src/components/RoomListItem'; -import SyncStatusBar from '~/src/components/SyncStatusBar'; - -export default function DMsScreen() { - const { rooms, syncState, isReady, selectRoom } = useMatrixStore(); - const router = useRouter(); - const [search, setSearch] = useState(''); - - const dmRooms = useMemo(() => { - const base = rooms.filter((r) => r.isDirect && r.membership === 'join'); - if (!search.trim()) return base; - const q = search.toLowerCase(); - return base.filter((r) => r.name.toLowerCase().includes(q)); - }, [rooms, search]); - - const dmInvites = useMemo( - () => rooms.filter((r) => r.membership === 'invite' && r.isDirect), - [rooms] - ); - - const handleRoomPress = (roomId: string) => { - selectRoom(roomId); - router.push(`/room/${roomId}`); - }; - - return ( - - - - - Direct Messages - router.push('/room/new')} - className="w-9 h-9 bg-primary rounded-full items-center justify-center active:opacity-70" - > - - - - - {(dmRooms.length > 0 || search.length > 0) && ( - - - - - )} - - {!isReady && syncState === 'STOPPED' ? ( - - - - ) : ( - item.id} - renderItem={({ item }) => ( - handleRoomPress(item.id)} /> - )} - contentContainerStyle={{ paddingBottom: 16 }} - ListHeaderComponent={ - dmInvites.length > 0 ? ( - - - {dmInvites.length} pending invite{dmInvites.length !== 1 ? 's' : ''} - - - ) : null - } - ListEmptyComponent={ - - - {search ? 'No people found' : 'No direct messages'} - - {!search && ( - - Tap + to start a conversation - - )} - - } - /> - )} - - ); -} diff --git a/apps/matrix/apps/mobile/app/(app)/index.tsx b/apps/matrix/apps/mobile/app/(app)/index.tsx deleted file mode 100644 index cb30e8bbb..000000000 --- a/apps/matrix/apps/mobile/app/(app)/index.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { useState, useMemo } from 'react'; -import { View, Text, FlatList, Pressable, ActivityIndicator, TextInput } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { useRouter } from 'expo-router'; -import { Plus, MagnifyingGlass, Compass } from 'phosphor-react-native'; -import { useMatrixStore } from '~/src/matrix/store'; -import RoomListItem from '~/src/components/RoomListItem'; -import SyncStatusBar from '~/src/components/SyncStatusBar'; - -export default function ChatsScreen() { - const { rooms, syncState, isReady, selectRoom } = useMatrixStore(); - const router = useRouter(); - const [search, setSearch] = useState(''); - - const groupRooms = useMemo(() => { - const base = rooms.filter((r) => !r.isDirect && r.membership === 'join'); - if (!search.trim()) return base; - const q = search.toLowerCase(); - return base.filter((r) => r.name.toLowerCase().includes(q)); - }, [rooms, search]); - - // Pending invites - const invites = useMemo( - () => rooms.filter((r) => r.membership === 'invite' && !r.isDirect), - [rooms] - ); - - const handleRoomPress = (roomId: string) => { - selectRoom(roomId); - router.push(`/room/${roomId}`); - }; - - return ( - - - - {/* Header */} - - Chats - - router.push('/search')} - className="w-9 h-9 bg-surface border border-border rounded-full items-center justify-center active:opacity-70" - > - - - router.push('/room/new')} - className="w-9 h-9 bg-primary rounded-full items-center justify-center active:opacity-70" - > - - - - - - {/* Search */} - {(groupRooms.length > 0 || search.length > 0) && ( - - - - - )} - - {/* Loading state */} - {!isReady && syncState === 'STOPPED' ? ( - - - Connecting... - - ) : ( - item.id} - renderItem={({ item }) => ( - handleRoomPress(item.id)} /> - )} - contentContainerStyle={{ paddingBottom: 16 }} - ListHeaderComponent={ - invites.length > 0 ? ( - - - {invites.length} pending invite{invites.length !== 1 ? 's' : ''} - - - ) : null - } - ListEmptyComponent={ - - - {search ? 'No rooms found' : 'No group chats yet'} - - {!search && ( - - Tap + to create or join a room - - )} - - } - /> - )} - - ); -} diff --git a/apps/matrix/apps/mobile/app/(app)/invites.tsx b/apps/matrix/apps/mobile/app/(app)/invites.tsx deleted file mode 100644 index 7307f3836..000000000 --- a/apps/matrix/apps/mobile/app/(app)/invites.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { View, Text, FlatList, Pressable, ActivityIndicator, Alert } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { Image } from 'expo-image'; -import { useMatrixStore } from '~/src/matrix/store'; -import type { SimpleRoom } from '~/src/matrix/types'; - -function InviteCard({ - room, - onAccept, - onDecline, -}: { - room: SimpleRoom; - onAccept: () => void; - onDecline: () => void; -}) { - return ( - - - {/* Avatar */} - - {room.avatar ? ( - - ) : ( - - {(room.name ?? '?')[0].toUpperCase()} - - )} - - - {/* Info */} - - - {room.name} - - {room.topic && ( - - {room.topic} - - )} - {room.inviter && ( - Invited by {room.inviter} - )} - - - {room.isDirect - ? 'Direct message' - : `${room.memberCount} member${room.memberCount !== 1 ? 's' : ''}`} - - {room.isEncrypted && · 🔒 Encrypted} - - - - - {/* Actions */} - - - Decline - - - Accept - - - - ); -} - -export default function InvitesScreen() { - const { invites, acceptInvite, declineInvite, isReady } = useMatrixStore(); - - const handleAccept = async (roomId: string) => { - try { - await acceptInvite(roomId); - } catch (err) { - Alert.alert('Error', err instanceof Error ? err.message : 'Could not join room'); - } - }; - - const handleDecline = (roomId: string, roomName: string) => { - Alert.alert(`Decline invite`, `Decline invite to "${roomName}"?`, [ - { text: 'Cancel', style: 'cancel' }, - { - text: 'Decline', - style: 'destructive', - onPress: () => declineInvite(roomId).catch(() => {}), - }, - ]); - }; - - return ( - - - Invites - {invites.length > 0 && ( - - {invites.length} - - )} - - - {!isReady ? ( - - - - ) : ( - item.id} - renderItem={({ item }) => ( - handleAccept(item.id)} - onDecline={() => handleDecline(item.id, item.name)} - /> - )} - contentContainerStyle={{ paddingTop: 8, paddingBottom: 24 }} - ListEmptyComponent={ - - ✉️ - No pending invites - - Room invites will appear here - - - } - /> - )} - - ); -} diff --git a/apps/matrix/apps/mobile/app/(app)/settings.tsx b/apps/matrix/apps/mobile/app/(app)/settings.tsx deleted file mode 100644 index 2f2dc515b..000000000 --- a/apps/matrix/apps/mobile/app/(app)/settings.tsx +++ /dev/null @@ -1,243 +0,0 @@ -import { useState } from 'react'; -import { - View, - Text, - Pressable, - Alert, - ScrollView, - TextInput, - ActivityIndicator, - Modal, -} from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { Image } from 'expo-image'; -import * as ImagePicker from 'expo-image-picker'; -import { PencilSimple, X } from 'phosphor-react-native'; -import { useMatrixStore } from '~/src/matrix/store'; -import { uploadMedia, getMimetypeFromFilename } from '~/src/matrix/upload'; - -function ProfileAvatar({ displayName, avatarUrl }: { displayName: string; avatarUrl?: string }) { - const initial = displayName[0]?.toUpperCase() ?? '?'; - return ( - - {avatarUrl ? ( - - ) : ( - {initial} - )} - - ); -} - -export default function SettingsScreen() { - const { client, syncState, credentials, logout } = useMatrixStore(); - - const userId = client?.getUserId() ?? credentials?.userId ?? ''; - const homeserver = client?.baseUrl ?? credentials?.homeserver ?? ''; - - const [editingName, setEditingName] = useState(false); - const [newDisplayName, setNewDisplayName] = useState(''); - const [savingName, setSavingName] = useState(false); - const [uploadingAvatar, setUploadingAvatar] = useState(false); - - // Get current profile from client - const profileInfo = client - ? (() => { - try { - const user = client.getUser(userId); - return { - displayName: user?.displayName ?? userId.split(':')[0].slice(1), - avatarUrl: user?.avatarUrl ?? undefined, - }; - } catch { - return { displayName: userId.split(':')[0].slice(1), avatarUrl: undefined }; - } - })() - : { displayName: '', avatarUrl: undefined }; - - const handleEditName = () => { - setNewDisplayName(profileInfo.displayName); - setEditingName(true); - }; - - const handleSaveName = async () => { - if (!client || !newDisplayName.trim()) return; - setSavingName(true); - try { - await client.setDisplayName(newDisplayName.trim()); - setEditingName(false); - } catch (err) { - Alert.alert('Error', err instanceof Error ? err.message : 'Could not update name'); - } finally { - setSavingName(false); - } - }; - - const handleChangeAvatar = async () => { - const result = await ImagePicker.launchImageLibraryAsync({ - mediaTypes: ImagePicker.MediaTypeOptions.Images, - quality: 0.8, - allowsEditing: true, - aspect: [1, 1], - }); - if (result.canceled || !result.assets[0] || !client) return; - const asset = result.assets[0]; - const filename = asset.fileName ?? `avatar_${Date.now()}.jpg`; - const mimetype = asset.mimeType ?? getMimetypeFromFilename(filename); - - setUploadingAvatar(true); - try { - const uploaded = await uploadMedia(client, asset.uri, filename, mimetype); - await client.setAvatarUrl(uploaded.mxcUrl); - } catch (err) { - Alert.alert('Error', err instanceof Error ? err.message : 'Could not update avatar'); - } finally { - setUploadingAvatar(false); - } - }; - - const handleLogout = () => { - Alert.alert('Sign out', 'Are you sure you want to sign out?', [ - { text: 'Cancel', style: 'cancel' }, - { text: 'Sign out', style: 'destructive', onPress: logout }, - ]); - }; - - return ( - - - Settings - - - - {/* Profile card */} - - {/* Avatar */} - - - - {uploadingAvatar ? ( - - ) : ( - - )} - - - - {/* Display name */} - - - - {profileInfo.displayName} - - - - - - - {userId} - - - - - {/* Connection info */} - - - - Connection - - - - - Homeserver - - {homeserver || '—'} - - - - Sync status - - - - {syncState.toLowerCase()} - - - - - - - {/* About */} - - - About - - - - App - Manalink - - - Version - 1.0.0 - - - Protocol - Matrix - - - - - {/* Sign out */} - - Sign out - - - - {/* Edit display name modal */} - setEditingName(false)} - > - - - - Display name - setEditingName(false)}> - - - - - - {savingName ? ( - - ) : ( - Save - )} - - - - - - ); -} diff --git a/apps/matrix/apps/mobile/app/(auth)/_layout.tsx b/apps/matrix/apps/mobile/app/(auth)/_layout.tsx deleted file mode 100644 index 819279f22..000000000 --- a/apps/matrix/apps/mobile/app/(auth)/_layout.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { Stack } from 'expo-router'; - -export default function AuthLayout() { - return ( - - - - ); -} diff --git a/apps/matrix/apps/mobile/app/(auth)/login.tsx b/apps/matrix/apps/mobile/app/(auth)/login.tsx deleted file mode 100644 index 252aaa79e..000000000 --- a/apps/matrix/apps/mobile/app/(auth)/login.tsx +++ /dev/null @@ -1,226 +0,0 @@ -import { useState } from 'react'; -import { - View, - Text, - TextInput, - Pressable, - ScrollView, - KeyboardAvoidingView, - Platform, - ActivityIndicator, -} from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import * as WebBrowser from 'expo-web-browser'; -import * as Linking from 'expo-linking'; -import { loginWithPassword, loginWithToken, checkHomeserver } from '~/src/matrix/client'; -import { useMatrixStore } from '~/src/matrix/store'; - -WebBrowser.maybeCompleteAuthSession(); - -export default function LoginScreen() { - const [homeserver, setHomeserver] = useState('matrix.mana.how'); - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(false); - const [ssoLoading, setSsoLoading] = useState(false); - const [checkingServer, setCheckingServer] = useState(false); - const [serverOk, setServerOk] = useState(null); - - const { initialize } = useMatrixStore(); - - const normalizeHs = (hs: string) => { - let url = hs.trim(); - if (!url.startsWith('http://') && !url.startsWith('https://')) url = `https://${url}`; - return url.replace(/\/$/, ''); - }; - - const handleCheckServer = async () => { - setCheckingServer(true); - setServerOk(null); - const result = await checkHomeserver(homeserver); - setServerOk(result.ok); - setError(result.ok ? null : (result.error ?? 'Server not reachable')); - setCheckingServer(false); - }; - - const handleLogin = async () => { - if (!homeserver.trim() || !username.trim() || !password.trim()) { - setError('Please fill in all fields'); - return; - } - setLoading(true); - setError(null); - const result = await loginWithPassword(homeserver, username, password); - if (result.success && result.credentials) { - await initialize(result.credentials); - } else { - setError(result.error ?? 'Login failed'); - setLoading(false); - } - }; - - const handleSSO = async () => { - setSsoLoading(true); - setError(null); - try { - const base = normalizeHs(homeserver); - const redirectUri = Linking.createURL('sso-callback'); - const ssoUrl = `${base}/_matrix/client/v3/login/sso/redirect?redirectUrl=${encodeURIComponent(redirectUri)}`; - - const result = await WebBrowser.openAuthSessionAsync(ssoUrl, redirectUri); - - if (result.type === 'success') { - const url = result.url; - const parsed = new URL(url); - const loginToken = parsed.searchParams.get('loginToken'); - if (!loginToken) { - setError('SSO login failed: no token received'); - return; - } - - // Exchange token for credentials - await import('~/src/matrix/polyfills'); - const { createClient } = await import('matrix-js-sdk'); - const tempClient = createClient({ baseUrl: base }); - const response = await tempClient.login('m.login.token', { - token: loginToken, - initial_device_display_name: 'Manalink Mobile', - }); - - const loginResult = await loginWithToken( - base, - response.access_token, - response.user_id, - response.device_id - ); - if (loginResult.success && loginResult.credentials) { - await initialize(loginResult.credentials); - } - } - } catch (err) { - setError(err instanceof Error ? err.message : 'SSO failed'); - } finally { - setSsoLoading(false); - } - }; - - return ( - - - - {/* Logo */} - - - - - Manalink - Secure Matrix messaging - - - - {/* Homeserver */} - - - Homeserver - - - { - setHomeserver(v); - setServerOk(null); - }} - autoCapitalize="none" - autoCorrect={false} - keyboardType="url" - placeholder="matrix.example.com" - placeholderTextColor="#6b7280" - onBlur={handleCheckServer} - /> - {checkingServer && } - {serverOk === true && } - {serverOk === false && } - - - - {/* Username */} - - - Username - - - - - {/* Password */} - - - Password - - - - - {error && {error}} - - {/* Password login */} - - {loading ? ( - - ) : ( - Sign in - )} - - - {/* Divider */} - - - or - - - - {/* SSO */} - - {ssoLoading ? ( - - ) : ( - Sign in with SSO - )} - - - - - - ); -} diff --git a/apps/matrix/apps/mobile/app/+not-found.tsx b/apps/matrix/apps/mobile/app/+not-found.tsx deleted file mode 100644 index 7ed3a4281..000000000 --- a/apps/matrix/apps/mobile/app/+not-found.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { View, Text } from 'react-native'; -import { Link } from 'expo-router'; - -export default function NotFound() { - return ( - - Screen not found - - Go home - - - ); -} diff --git a/apps/matrix/apps/mobile/app/_layout.tsx b/apps/matrix/apps/mobile/app/_layout.tsx deleted file mode 100644 index a9f058e0a..000000000 --- a/apps/matrix/apps/mobile/app/_layout.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import '../global.css'; -import { useEffect, useState } from 'react'; -import { Stack, useRouter, useSegments } from 'expo-router'; -import { GestureHandlerRootView } from 'react-native-gesture-handler'; -import { StatusBar } from 'expo-status-bar'; -import { useMatrixStore } from '~/src/matrix/store'; -import { - requestNotificationPermissions, - setupNotificationNavigation, -} from '~/src/notifications'; - -function AuthGuard({ children }: { children: React.ReactNode }) { - const [checking, setChecking] = useState(true); - const segments = useSegments(); - const router = useRouter(); - const { isReady, restoreSession } = useMatrixStore(); - - useEffect(() => { - restoreSession().finally(() => setChecking(false)); - }, []); - - useEffect(() => { - if (checking) return; - const inAuthGroup = segments[0] === '(auth)'; - if (!isReady && !inAuthGroup) router.replace('/(auth)/login'); - else if (isReady && inAuthGroup) router.replace('/(app)'); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isReady, checking, segments]); - - if (checking) return null; - return <>{children}; -} - -export default function RootLayout() { - useEffect(() => { - // Request notification permissions (non-blocking) - requestNotificationPermissions().catch(() => {}); - // Set up navigation from notification taps - const cleanup = setupNotificationNavigation(); - return cleanup; - }, []); - - return ( - - - - - - - - - - - - - - - ); -} diff --git a/apps/matrix/apps/mobile/app/room/[id].tsx b/apps/matrix/apps/mobile/app/room/[id].tsx deleted file mode 100644 index 35c10e986..000000000 --- a/apps/matrix/apps/mobile/app/room/[id].tsx +++ /dev/null @@ -1,557 +0,0 @@ -import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; -import { - View, - FlatList, - Text, - TextInput, - Pressable, - ActivityIndicator, - Modal, - ScrollView, - Alert, - ActionSheetIOS, - Platform, -} from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { useLocalSearchParams, useRouter } from 'expo-router'; -import { ArrowLeft, Lock, DotsThreeVertical, X } from 'phosphor-react-native'; -import { Image } from 'expo-image'; -import * as ImagePicker from 'expo-image-picker'; -import * as DocumentPicker from 'expo-document-picker'; -import { useMatrixStore } from '~/src/matrix/store'; -import MessageBubble from '~/src/components/MessageBubble'; -import MessageInput from '~/src/components/MessageInput'; -import TypingIndicator from '~/src/components/TypingIndicator'; -import DateSeparator from '~/src/components/DateSeparator'; -import ImageViewer from '~/src/components/ImageViewer'; -import UserProfileModal from '~/src/components/UserProfileModal'; -import VoiceRecorder from '~/src/components/VoiceRecorder'; -import UnreadSeparator from '~/src/components/UnreadSeparator'; -import { getMimetypeFromFilename } from '~/src/matrix/upload'; -import type { SimpleMessage, SimpleRoom, RoomMember } from '~/src/matrix/types'; - -type ListItem = - | { type: 'message'; data: SimpleMessage } - | { type: 'date'; timestamp: number; key: string } - | { type: 'unread'; key: string }; - -function isSameDay(a: number, b: number) { - const da = new Date(a), - db = new Date(b); - return ( - da.getFullYear() === db.getFullYear() && - da.getMonth() === db.getMonth() && - da.getDate() === db.getDate() - ); -} - -function buildListItems(messages: SimpleMessage[], firstUnreadEventId: string | null): ListItem[] { - const items: ListItem[] = []; - let unreadInserted = false; - for (let i = 0; i < messages.length; i++) { - const msg = messages[i]; - if (!messages[i - 1] || !isSameDay(messages[i - 1].timestamp, msg.timestamp)) { - items.push({ type: 'date', timestamp: msg.timestamp, key: `date_${msg.timestamp}_${i}` }); - } - if (!unreadInserted && firstUnreadEventId && msg.id === firstUnreadEventId) { - items.push({ type: 'unread', key: 'unread_separator' }); - unreadInserted = true; - } - items.push({ type: 'message', data: msg }); - } - return items; -} - -function MemberRow({ member, onClose }: { member: RoomMember; onClose: () => void }) { - const [showProfile, setShowProfile] = useState(false); - return ( - <> - setShowProfile(true)} - className="flex-row items-center gap-3 px-4 py-3 active:bg-surface/60" - > - - {member.avatarUrl ? ( - - ) : ( - - {member.displayName[0]?.toUpperCase() ?? '?'} - - )} - - - {member.displayName} - - {member.userId} - - - {member.powerLevel >= 100 && ( - - Admin - - )} - {member.powerLevel >= 50 && member.powerLevel < 100 && ( - - Mod - - )} - - { - setShowProfile(false); - onClose(); - }} - /> - - ); -} - -export default function RoomScreen() { - const { id } = useLocalSearchParams<{ id: string }>(); - const router = useRouter(); - const listRef = useRef>(null); - - const [loadingMore, setLoadingMore] = useState(false); - const [uploading, setUploading] = useState(false); - const [showVoiceRecorder, setShowVoiceRecorder] = useState(false); - const [replyTo, setReplyTo] = useState(null); - const [editingMessage, setEditingMessage] = useState(null); - const [showMembers, setShowMembers] = useState(false); - const [viewingImage, setViewingImage] = useState(null); - const [profileUserId, setProfileUserId] = useState(null); - const [forwardingMessage, setForwardingMessage] = useState(null); - const [forwardSearch, setForwardSearch] = useState(''); - - const { - rooms, - messages, - firstUnreadEventId, - typingUsers, - roomMembers, - client, - credentials, - selectRoom, - loadRoomMembers, - sendMessage, - editMessage, - sendReaction, - redactMessage, - sendTyping, - sendImage, - sendFile, - sendVoice, - forwardMessage, - leaveRoom, - } = useMatrixStore(); - - const room = rooms.find((r) => r.id === id); - const isAdmin = useMemo(() => { - if (!client || !id) return false; - const matrixRoom = client.getRoom(id); - const userId = client.getUserId() ?? ''; - return (matrixRoom?.getMember(userId)?.powerLevel ?? 0) >= 100; - }, [client, id]); - - useEffect(() => { - if (id) selectRoom(id); - }, [id]); - - const listItems = useMemo( - () => buildListItems(messages, firstUnreadEventId), - [messages, firstUnreadEventId] - ); - - // Scroll to first unread message on initial load - useEffect(() => { - if (!firstUnreadEventId || listItems.length === 0) return; - const unreadIndex = listItems.findIndex((item) => item.type === 'unread'); - if (unreadIndex > 0) { - setTimeout(() => { - listRef.current?.scrollToIndex({ index: unreadIndex, animated: true, viewPosition: 0 }); - }, 300); - } - }, [firstUnreadEventId]); - - const handleLoadMore = async () => { - if (!client || !id || loadingMore) return; - const matrixRoom = client.getRoom(id); - if (!matrixRoom) return; - setLoadingMore(true); - try { - await client.scrollback(matrixRoom, 30); - } finally { - setLoadingMore(false); - } - }; - - const handleRoomOptions = () => { - const options = ['Cancel', 'Members', ...(isAdmin ? ['Room settings'] : []), 'Leave room']; - const destructiveIndex = options.length - 1; - - if (Platform.OS === 'ios') { - ActionSheetIOS.showActionSheetWithOptions( - { options, cancelButtonIndex: 0, destructiveButtonIndex: destructiveIndex }, - (index) => { - if (index === 1) { - loadRoomMembers(id!); - setShowMembers(true); - } - if (isAdmin && index === 2) { - router.push({ pathname: '/room/settings', params: { id } }); - } - if (index === options.length - 1) handleLeave(); - } - ); - } else { - Alert.alert(room?.name ?? 'Room', undefined, [ - { - text: 'Members', - onPress: () => { - loadRoomMembers(id!); - setShowMembers(true); - }, - }, - ...(isAdmin - ? [ - { - text: 'Room settings', - onPress: () => router.push({ pathname: '/room/settings', params: { id } }), - }, - ] - : []), - { text: 'Leave room', style: 'destructive' as const, onPress: handleLeave }, - { text: 'Cancel', style: 'cancel' as const }, - ]); - } - }; - - const handleLeave = () => { - Alert.alert('Leave room', `Leave "${room?.name ?? id}"?`, [ - { text: 'Cancel', style: 'cancel' }, - { - text: 'Leave', - style: 'destructive', - onPress: async () => { - await leaveRoom(id!); - router.replace('/(app)'); - }, - }, - ]); - }; - - const handleAttach = () => { - if (Platform.OS === 'ios') { - ActionSheetIOS.showActionSheetWithOptions( - { options: ['Cancel', 'Photo Library', 'Camera', 'File'], cancelButtonIndex: 0 }, - (index) => { - if (index === 1) pickImage('library'); - if (index === 2) pickImage('camera'); - if (index === 3) pickDocument(); - } - ); - } else { - Alert.alert('Attach', undefined, [ - { text: 'Photo Library', onPress: () => pickImage('library') }, - { text: 'Camera', onPress: () => pickImage('camera') }, - { text: 'File', onPress: pickDocument }, - { text: 'Cancel', style: 'cancel' }, - ]); - } - }; - - const pickImage = async (source: 'library' | 'camera') => { - const fn = - source === 'camera' ? ImagePicker.launchCameraAsync : ImagePicker.launchImageLibraryAsync; - const result = await fn({ mediaTypes: ImagePicker.MediaTypeOptions.Images, quality: 0.85 }); - if (result.canceled || !result.assets[0]) return; - const asset = result.assets[0]; - const filename = asset.fileName ?? `image_${Date.now()}.jpg`; - setUploading(true); - try { - await sendImage( - asset.uri, - filename, - asset.mimeType ?? getMimetypeFromFilename(filename), - asset.width, - asset.height - ); - } catch (err) { - Alert.alert('Upload failed', err instanceof Error ? err.message : 'Unknown error'); - } finally { - setUploading(false); - } - }; - - const pickDocument = async () => { - const result = await DocumentPicker.getDocumentAsync({ copyToCacheDirectory: true }); - if (result.canceled || !result.assets[0]) return; - const asset = result.assets[0]; - setUploading(true); - try { - await sendFile(asset.uri, asset.name, asset.mimeType ?? getMimetypeFromFilename(asset.name)); - } catch (err) { - Alert.alert('Upload failed', err instanceof Error ? err.message : 'Unknown error'); - } finally { - setUploading(false); - } - }; - - const handleForward = useCallback((msg: SimpleMessage) => { - setForwardingMessage(msg); - setForwardSearch(''); - }, []); - - const handleForwardToRoom = useCallback( - async (targetRoom: SimpleRoom) => { - if (!forwardingMessage) return; - try { - await forwardMessage(forwardingMessage.id, targetRoom.id); - setForwardingMessage(null); - } catch (err) { - Alert.alert('Forward failed', err instanceof Error ? err.message : 'Unknown error'); - } - }, - [forwardingMessage, forwardMessage] - ); - - const handleEdit = useCallback((msg: SimpleMessage) => { - setReplyTo(null); - setEditingMessage(msg); - }, []); - - const handleSend = useCallback( - async (body: string, replyToEventId?: string) => { - await sendMessage(body, replyToEventId); - }, - [sendMessage] - ); - - const handleEditSave = useCallback( - async (eventId: string, newBody: string) => { - await editMessage(eventId, newBody); - }, - [editMessage] - ); - - const renderItem = ({ item, index }: { item: ListItem; index: number }) => { - if (item.type === 'date') return ; - if (item.type === 'unread') return ; - const msgIndex = messages.indexOf(item.data); - return ( - { - setEditingMessage(null); - setReplyTo(msg); - }} - onEdit={handleEdit} - onReact={sendReaction} - onDelete={redactMessage} - onForward={handleForward} - onImagePress={setViewingImage} - onAvatarPress={setProfileUserId} - /> - ); - }; - - return ( - - {/* Header */} - - router.back()} className="p-1 active:opacity-50"> - - - - - - {room?.name ?? id} - - {room?.isEncrypted && } - - {room?.topic ? ( - - {room.topic} - - ) : room?.memberCount != null ? ( - - {room.memberCount} member{room.memberCount !== 1 ? 's' : ''} - - ) : null} - - - - - - - {(loadingMore || uploading) && ( - - - {uploading ? 'Uploading...' : 'Loading...'} - - )} - - (item.type === 'message' ? item.data.id : item.key)} - renderItem={renderItem} - contentContainerStyle={{ paddingHorizontal: 0, paddingVertical: 8 }} - onEndReached={handleLoadMore} - onEndReachedThreshold={0.15} - onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: false })} - maintainVisibleContentPosition={{ minIndexForVisible: 0 }} - keyboardDismissMode="interactive" - ListEmptyComponent={ - - No messages yet - - } - /> - - {typingUsers.length > 0 && } - - {showVoiceRecorder ? ( - { - setUploading(true); - try { - await sendVoice(uri, durationMs); - } catch (err) { - Alert.alert('Upload failed', err instanceof Error ? err.message : 'Unknown error'); - } finally { - setUploading(false); - setShowVoiceRecorder(false); - } - }} - onCancel={() => setShowVoiceRecorder(false)} - /> - ) : ( - setShowVoiceRecorder(true)} - replyTo={replyTo} - onCancelReply={() => setReplyTo(null)} - editingMessage={editingMessage} - onCancelEdit={() => setEditingMessage(null)} - /> - )} - - {/* Members modal */} - setShowMembers(false)} - > - - - - Members{room?.memberCount != null ? ` (${room.memberCount})` : ''} - - setShowMembers(false)} className="p-1 active:opacity-50"> - - - - - {roomMembers.length === 0 ? ( - - - - ) : ( - roomMembers.map((member) => ( - setShowMembers(false)} - /> - )) - )} - - - - - setViewingImage(null)} /> - - setProfileUserId(null)} /> - - {/* Forward message modal */} - setForwardingMessage(null)} - > - - - Forward to... - setForwardingMessage(null)} className="p-1 active:opacity-50"> - - - - - - - {forwardingMessage && ( - - Message: - - {forwardingMessage.body} - - - )} - - {rooms - .filter( - (r) => r.id !== id && r.name.toLowerCase().includes(forwardSearch.toLowerCase()) - ) - .map((r) => ( - handleForwardToRoom(r)} - className="flex-row items-center gap-3 px-4 py-3 active:bg-surface/60" - > - - {r.avatar ? ( - - ) : ( - - {r.name[0]?.toUpperCase() ?? '?'} - - )} - - - - {r.name} - - {r.isDirect && ( - Direct message - )} - - - ))} - - - - - ); -} diff --git a/apps/matrix/apps/mobile/app/room/new.tsx b/apps/matrix/apps/mobile/app/room/new.tsx deleted file mode 100644 index c2040a9f0..000000000 --- a/apps/matrix/apps/mobile/app/room/new.tsx +++ /dev/null @@ -1,209 +0,0 @@ -import { useState } from 'react'; -import { - View, - Text, - TextInput, - Pressable, - ScrollView, - KeyboardAvoidingView, - Platform, - ActivityIndicator, - Switch, -} from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { useRouter } from 'expo-router'; -import { ArrowLeft, Users, ChatCircle } from 'phosphor-react-native'; -import { useMatrixStore } from '~/src/matrix/store'; - -type Mode = 'room' | 'dm'; - -export default function NewRoomScreen() { - const router = useRouter(); - const { client, selectRoom } = useMatrixStore(); - - const [mode, setMode] = useState('room'); - const [name, setName] = useState(''); - const [topic, setTopic] = useState(''); - const [dmTarget, setDmTarget] = useState(''); - const [isPrivate, setIsPrivate] = useState(true); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const handleCreate = async () => { - if (!client) return; - setError(null); - setLoading(true); - - try { - if (mode === 'dm') { - let userId = dmTarget.trim(); - if (!userId) { - setError('Enter a Matrix user ID'); - return; - } - // Ensure proper format - if (!userId.startsWith('@')) userId = `@${userId}`; - if (!userId.includes(':')) { - const hs = new URL(client.baseUrl).hostname; - userId = `${userId}:${hs}`; - } - - const room = await client.createRoom({ - is_direct: true, - invite: [userId], - preset: 'trusted_private_chat' as any, - }); - - selectRoom(room.room_id); - router.replace(`/room/${room.room_id}`); - } else { - if (!name.trim()) { - setError('Enter a room name'); - return; - } - - const room = await client.createRoom({ - name: name.trim(), - topic: topic.trim() || undefined, - preset: isPrivate ? ('private_chat' as any) : ('public_chat' as any), - visibility: isPrivate ? ('private' as any) : ('public' as any), - }); - - selectRoom(room.room_id); - router.replace(`/room/${room.room_id}`); - } - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to create room'); - } finally { - setLoading(false); - } - }; - - return ( - - {/* Header */} - - router.back()} className="p-1 active:opacity-50"> - - - New conversation - - - - - {/* Mode toggle */} - - {(['dm', 'room'] as Mode[]).map((m) => ( - { - setMode(m); - setError(null); - }} - className={`flex-1 flex-row items-center justify-center gap-2 py-2.5 rounded-xl ${mode === m ? 'bg-primary' : ''}`} - > - {m === 'dm' ? ( - - ) : ( - - )} - - {m === 'dm' ? 'Direct message' : 'Group room'} - - - ))} - - - {/* DM form */} - {mode === 'dm' && ( - - - - User ID - - - - - )} - - {/* Room form */} - {mode === 'room' && ( - - - - Room name - - - - - - Topic (optional) - - - - - - Private room - - Only invited members can join - - - - - - )} - - {/* Error */} - {error && {error}} - - {/* Create button */} - - {loading ? ( - - ) : ( - - {mode === 'dm' ? 'Start conversation' : 'Create room'} - - )} - - - - - ); -} diff --git a/apps/matrix/apps/mobile/app/room/settings.tsx b/apps/matrix/apps/mobile/app/room/settings.tsx deleted file mode 100644 index afd30e9e2..000000000 --- a/apps/matrix/apps/mobile/app/room/settings.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import { useState, useEffect } from 'react'; -import { - View, - Text, - TextInput, - Pressable, - ScrollView, - Alert, - ActivityIndicator, -} from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { useLocalSearchParams, useRouter } from 'expo-router'; -import { ArrowLeft, Camera } from 'phosphor-react-native'; -import { Image } from 'expo-image'; -import * as ImagePicker from 'expo-image-picker'; -import { useMatrixStore } from '~/src/matrix/store'; -import { uploadMedia } from '~/src/matrix/upload'; -import { resolveMxcThumbnail } from '~/src/matrix/media'; - -export default function RoomSettingsScreen() { - const { id } = useLocalSearchParams<{ id: string }>(); - const router = useRouter(); - const { client, credentials, rooms } = useMatrixStore(); - - const room = rooms.find((r) => r.id === id); - - const [name, setName] = useState(room?.name ?? ''); - const [topic, setTopic] = useState(room?.topic ?? ''); - const [avatarUri, setAvatarUri] = useState(room?.avatar ?? null); - const [saving, setSaving] = useState(false); - const [uploadingAvatar, setUploadingAvatar] = useState(false); - const [newAvatarMxc, setNewAvatarMxc] = useState(null); - - useEffect(() => { - if (room) { - setName(room.name); - setTopic(room.topic ?? ''); - setAvatarUri(room.avatar ?? null); - } - }, [room?.id]); - - const handlePickAvatar = async () => { - const result = await ImagePicker.launchImageLibraryAsync({ - mediaTypes: ImagePicker.MediaTypeOptions.Images, - allowsEditing: true, - aspect: [1, 1], - quality: 0.85, - }); - if (result.canceled || !result.assets[0] || !client) return; - const asset = result.assets[0]; - setUploadingAvatar(true); - try { - const filename = `avatar_${Date.now()}.jpg`; - const uploaded = await uploadMedia(client, asset.uri, filename, 'image/jpeg'); - setNewAvatarMxc(uploaded.mxcUrl); - setAvatarUri( - credentials - ? (resolveMxcThumbnail(uploaded.mxcUrl, credentials.homeserver, 128, 128) ?? asset.uri) - : asset.uri - ); - } catch (err) { - Alert.alert('Upload failed', err instanceof Error ? err.message : 'Unknown error'); - } finally { - setUploadingAvatar(false); - } - }; - - const handleSave = async () => { - if (!client || !id) return; - setSaving(true); - try { - const trimmedName = name.trim(); - const trimmedTopic = topic.trim(); - - if (trimmedName && trimmedName !== room?.name) { - await client.setRoomName(id, trimmedName); - } - if (trimmedTopic !== (room?.topic ?? '')) { - await (client as any).sendStateEvent(id, 'm.room.topic', { topic: trimmedTopic }, ''); - } - if (newAvatarMxc) { - await (client as any).sendStateEvent(id, 'm.room.avatar', { url: newAvatarMxc }, ''); - } - router.back(); - } catch (err) { - Alert.alert('Save failed', err instanceof Error ? err.message : 'Unknown error'); - } finally { - setSaving(false); - } - }; - - const hasChanges = - name.trim() !== room?.name || topic.trim() !== (room?.topic ?? '') || newAvatarMxc !== null; - - return ( - - {/* Header */} - - router.back()} className="p-1 active:opacity-50"> - - - Room Settings - - {saving ? ( - - ) : ( - - Save - - )} - - - - - {/* Avatar */} - - - - {uploadingAvatar ? ( - - ) : avatarUri ? ( - - ) : ( - - {room?.name?.[0]?.toUpperCase() ?? '#'} - - )} - - - - - - Tap to change room avatar - - - {/* Name */} - - Room name - - - - {/* Topic */} - - Topic - - - - {/* Room ID info */} - - Room ID - - - {id} - - - - - - ); -} diff --git a/apps/matrix/apps/mobile/app/search.tsx b/apps/matrix/apps/mobile/app/search.tsx deleted file mode 100644 index 483931045..000000000 --- a/apps/matrix/apps/mobile/app/search.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import { useState, useCallback } from 'react'; -import { View, Text, TextInput, FlatList, Pressable, ActivityIndicator, Alert } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { useRouter } from 'expo-router'; -import { ArrowLeft, MagnifyingGlass, Lock, Users } from 'phosphor-react-native'; -import { Image } from 'expo-image'; -import { useMatrixStore } from '~/src/matrix/store'; - -interface PublicRoom { - room_id: string; - name?: string; - topic?: string; - avatar_url?: string; - num_joined_members: number; - world_readable: boolean; - guest_can_join: boolean; - join_rule?: string; -} - -export default function SearchScreen() { - const router = useRouter(); - const { client, credentials, selectRoom, acceptInvite } = useMatrixStore(); - - const [query, setQuery] = useState(''); - const [results, setResults] = useState([]); - const [loading, setLoading] = useState(false); - const [joiningId, setJoiningId] = useState(null); - const [nextBatch, setNextBatch] = useState(); - const [hasMore, setHasMore] = useState(false); - - const search = useCallback( - async (q: string, since?: string) => { - if (!client || !credentials) return; - setLoading(true); - try { - const response = await (client as any).publicRooms({ - limit: 20, - filter: { generic_search_term: q }, - since, - server: new URL(credentials.homeserver).hostname, - }); - const rooms: PublicRoom[] = response.chunk ?? []; - setResults((prev) => (since ? [...prev, ...rooms] : rooms)); - setNextBatch(response.next_batch); - setHasMore(!!response.next_batch); - } catch (err) { - Alert.alert('Search failed', err instanceof Error ? err.message : 'Unknown error'); - } finally { - setLoading(false); - } - }, - [client, credentials] - ); - - const handleSearch = (text: string) => { - setQuery(text); - setNextBatch(undefined); - if (text.length >= 2 || text.length === 0) { - search(text); - } - }; - - const handleLoadMore = () => { - if (hasMore && nextBatch && !loading) { - search(query, nextBatch); - } - }; - - const handleJoin = async (room: PublicRoom) => { - if (!client) return; - setJoiningId(room.room_id); - try { - await client.joinRoom(room.room_id); - selectRoom(room.room_id); - router.replace(`/room/${room.room_id}`); - } catch (err) { - Alert.alert('Could not join', err instanceof Error ? err.message : 'Unknown error'); - } finally { - setJoiningId(null); - } - }; - - const renderRoom = ({ item }: { item: PublicRoom }) => { - const name = item.name ?? item.room_id; - const initial = name[0]?.toUpperCase() ?? '#'; - const isJoining = joiningId === item.room_id; - - return ( - - {/* Avatar */} - - {item.avatar_url ? ( - - ) : ( - {initial} - )} - - - {/* Info */} - - - - {name} - - {item.join_rule === 'public' ? null : } - - {item.topic && ( - - {item.topic} - - )} - - - {item.num_joined_members} - - - - {/* Join button */} - handleJoin(item)} - disabled={isJoining} - className="bg-primary rounded-lg px-3 py-1.5 shrink-0 active:opacity-60" - > - {isJoining ? ( - - ) : ( - Join - )} - - - ); - }; - - return ( - - {/* Header */} - - router.back()} className="p-1 active:opacity-50"> - - - Explore rooms - - - {/* Search bar */} - - - - {loading && } - - - item.room_id} - renderItem={renderRoom} - onEndReached={handleLoadMore} - onEndReachedThreshold={0.2} - ListEmptyComponent={ - !loading ? ( - - - {query.length > 0 ? 'No rooms found' : 'Search for public rooms'} - - - ) : null - } - ListFooterComponent={ - hasMore && !loading ? ( - - Load more - - ) : null - } - /> - - ); -} diff --git a/apps/matrix/apps/mobile/assets/adaptive-icon.png b/apps/matrix/apps/mobile/assets/adaptive-icon.png deleted file mode 100644 index b825c132e5028b381164d9aa9db3b89e84cbe558..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 127687 zcmb@u2Ut_>wk@25-g}cOAkw5*=mLT?kt!XeHxZ-?C?!FvAVm;CKtPJ3NUsWn08&K| zQ0WjlNDDo*pwpUa-n~{8~D9k*gxJ)2+1Y*kLTcD zLmN(k|1X)BrnxT!Lcst&ppc9#RtQ8HqIL6zQ6O}!i6ZHemIc>K^M}@_8^}#$|4+q{ zpVUtx!zPKKL8Et>;~5d0^%b1^v+Xx~qgu05y-E3%RIWxmBO^^xkm0Bh6y*@$AN9SE zDM8JA-mZhDdc?khP}IIj!QVPy>fq;O9ruz^AztD7!;5;aOG`iWemuESjJq;aS1G)T z-#JDqT`9i2+Rk>W3H4;8J*m>GjBE%n&!Gtr+cUc=cWcD?3pU2Lw%ZW;6)Wms&yzl4 zo3$61Tza|VnAcw9S=(i&`%fy;cl^585+l)HvnpI~8Ayl)j)kA{by-4!x;`8bOkc#4 zU(ubqzFd-AY*czbON5Z>w^L*jY^3nQRbi2JM44!7gYz6BV1e~&r9*=4gPP~Omt<;) zhcfsm3asjvNvDN$4if`7ZuOZRygL4DdCFC;-*1pNaQ=7rJePCZh&owI~42I*{x<%M2NapnL z+lz{Xmv(F*(NZa}2aHLRB&V8T#7u|{y3ldUkN8Egx#9k%G40LHfysqX&uR$Y{_q?w zkTiem#t-(*oxn$TsrMh@muCiuxC+}|(DbPW$THgdJg;(vyj~)vtR$vOG_f6Z(fUlu z>8H1g-7>^5nI)RPpsuHtWZ2^M>+NKKNYFq|gSbz81+b*l*)^+hyZ*cB1G&|uceN7? zE5y9M-M4&xMF%n;N#WRJAN+76aMJzUd`W(4NG3SVbq^;maMq~D6Z<$*x*4hq|T$@v2T48pT@KBi*?#q&j zw>wC#pO@N*zc^r+tjKMXl@)nqblp;-TjF%Uv894~*OP(#2N&cT^ht27F|-A~AS5-S zV}?((o^DdK9e}ZKvGXp(8Dv;7WQnUB5&LCJXfBz)qUB@#P}XXE!pgYPaw9`1mf``Z*uf2|>v>rn&p!uT8&oocClO`+4x3FWs_avp+uaTKX@JPct4? zw=}SQ-(QHda74Q#4b#-Uc@zmt)J}4Fo*0_MXdriepu3hrM*g*ROPVx=vY1t2a%ZU7 z^Aw^aO}j>#iv

    VIJiaAN4qI`V8`f?H${~A@6d;RhGE~ZY$8NM0>UWCK8;Tv3sGA z7VC&V4ZqSaZeu^!jP<1y;Buu$8)ypJ8HZVf^Aul5sWK5Pui3kOaD5+iNC1j2Vig7{k`bY@NrrKAsxy{HG{X+#@kX?cw zk$~DX*~<9|g$pEjuWMXyiO?T{g>$BJs6`&xh}og1!xIdwKOVSYYyzzf(g$WgoR|H| zl}|At;!_X?&)oy6#-n`a!>G)>hib!-q7R{O@!sjDigK`Oq#NV4S4gMXGJGzi&tWLH zk7lHBhodnm0Rk~2_?6_zfB&; zB|6DoDHF@g{e>WqzIj;t@zRVz5m9|BUThg<3$ukWKrkGNw;`yz1|paVTu#_6Db4oX z!r*r==3Go>w%$p!>2X;Uz3mh!UxN)dDl^Fr5h`2PEB_MdFpZ8M{`U69l$^zAT{m5C z$(VS5g2sJ`64}bOVsG)gXbO&%9{Aj~?nKzzJ#*ZZ5Jw0XAFX(sdq0;I+cCZSL|B%1 zi#&-x`uAw_6Bhc7LI>O}cvgZ9`vr}a=bF<89lu*38?@LkmxX+S?cR7UZVX>}XkIQE zL}_l8>chJndR6~us>G4-10UwHxA%6JZ2f(xrmFBizsr`i@)>MKv31+=*9WU}bB!($ zbVWCuE~JJ~i?8TKZQGr!5FAdG3zJ-4kJcM2k(euZZCR<~^YXeHOTFp5CIMb~)xRA( zX1iou<=r(N<-Zp)Pr7KbdhF|9NA>oa#)Cliv_U%Lm8%Q4Bsyo<=J(n>T&E?*d3~s&u8n-g-HQ5N6JZ!Jy-#tkx17ap8`v-RRXzO!3(_!6+pp}SIF$IrfiJ6op)$_^4$6t@PC!yl_%ktQPdo_9T z1Sg~lV&oYR)X(X-K|6^y_-~Do?RjcTD}_KT6RT9b~8T zwoRasGV+E9)Ama|V_!FoEg3h9E#{L7HsZWqGwH)6G55nu^&V*5Y9CDGl*%rq>~zw% zA*swgwA1!jZRG{v!(X@xT8n&;?U3BMy# zFUj)YbZJ&u@f>7H^Z?B}>DfX;w#k3mCa%0Nc3qBjGE$sFH|qHK*xf1l0e<^!mJFos zst{VE-79={9r`+RyNJ<4OBoN<{-H$3kD9l_txBK&7Ye?kDn@6Xx=a;S?{*nlng~(*; zEqsO8EpxXYd#R8F5XaPQP}1U)cb2KS-wnwVh>2Cgkv&{%@ad9b2|DEzNLNjp`BrDR zoJ+gGjZ&t-ZSMi@DVKrN#3@eBHI7}D!$o3Svs>*O(P*_07h5a5-SUM#ufR{-1sXyk zk1XNwTwT{pQ?|+PF*&EdSbSA7&)WMm%4uD=BTWDzy1LPGw;lT%we@-QYL}&k$gvdA zwTzD!Mz>r+H1H3+woynC9L9I|(6dqC&;a2a^6P3)Tvc^K!X~BW=w|C2EWkDaU5A#O zl0OaI#M`AfZr0?V9yj4!6^>@d6fNyn_E}~M!*}nOz^+2SkA1&f8**p&*VH4toOV$0 zT-&mxo&Dw$^hv%K{JX`MFS5B);dT4s$`=r6&swWaB?yx4F6X4Qe_VRyyYw|m#Uhdo z$>Rx$o}Wm4iiq7R41*Kw^+tY;-Kv4M7dy`-)lsegKJEgr`q#lyM=MzN6BsG6LcU-Z z*Pc|{gtjYPtI^}%8$-jom@|T}eOv58K2+pG4?wVT%H%doG3Y*s)KkMiJxKHcu@|l% z-Pb>?hF>0@QL{NfZl_kkPj+n3+Z2Sm_uqiml99`$G>#w2aijB?%}O;hm#kVa}fW3MOpo;&Vd$1~xD(%k#VOZ00B3hj7r z*Dm(&G1CO=55G7lK2c|HTUU&e_)21WBtnhLTz~X|z8Yx**2F69iMOC^fD>;nP~hlI zy}gf-1@^)B4@E~t!5a|}#zE!T2iyoLtNPj?uxSpyo&8n*uQy_tN_FRmh}Y6}`{&gm z9+7EwQvACC#TTzo@-6#z>2TESvbKbfSK7OioYvf#ntatQ`0J-82Z}qgk4T`WDy9el z2rM2g#V~L@`Vz(M*ZH+bu(#`+40_8_5ktYJ$^;tX+)^g8Mc1KCgyV zpa0FuJSg1zU8wyI`=9P{&g5@(k8ejG?>R?bad8MVLMP5xEpt7)fD^LXfnSo%IN?5x z*icBf3aQ&NF47cDYfp&Sq+!z^ntX& z&{X@FJ}aa0KLa8USJPy z%I0(w$Dkb^AG)iowp?Au0>)mfM@jWh?6-~ocI=j|6h}J@>cgQlf}#lGFOK zgsun+S`iF+`m}pso#ofTastA+&2~s$2Qp$*b>7BO0*haqX`_&6sSQov-VAFwypSH& zK-ONu4|E}(eV! T4UEeF^CHCrP`*V$aNSc0$0ea6sdqvxh_J9ZGI^!>55BvNYR z(NA}d?bMQtKVq{aMH)_zS*8mL%rEpS*smDXdC&i(JBKXINWu}HI8|P|4pN4w$yF= zFgOW!A@F!nKw%i4W;^A9#$fAiM0y$f0i3j6P?QE~>{mf+q@}cOrEb5ehsq{9X z(KB&CUViMP_xXDaD8sqS@lBviSVrh|Wy}gERHfj+ZZ%vc7EW;4MwfV&yWPMqaf%MX z{UL?6%d&H2DGcWGokk*WAjMsXv+9PA_|&|l4}}jA*a&O zbJ#m!o5`7(WoK8$*z+GMw?EUq>N*_JNG% zA6pwyRICM*gKf*7HL=^a_tQ@K8+`q|?mN&FXx6PnhFSk0&46HdD97F9<#re?xi&Y- z3grQ&qq`J!V5Y0{gG@k|Uptt7MgqdGThzOUWdITst~f;-G8?o(*fP@I z?1lBc6(RtTnIFBCh%nO)lW`EyqXzU2~e0O9wW5EJElFo@RSdS;%q96Ug1gT}K}?NsOdmu4!O zp{&RQtj!Z*N!s@a5uC)c6#TWV;$MhN!A}M`Ac;K}8NO=u&$8#PyXBS!6#f{FNwq8^ zUUe##kU9mVNzuw~Oimz8szJ}KPy8$4TYdTu!snK8J|%K?l^_Sn$3q`X(;?%)yk+`E zXhLgno?GR}|1)1`e8t}6y2~M9j@1F}cRq-&gBW&5uc8H$)Gpp!0T*yOHO2;aJVbc0 zQPU3F^4fA*Z`2W&h*pNYYrd0V#F_xB^zF-W{jpi@U4G|%0VEm7%cO{R_F7?5!pfYt z*z<2??J}k#`5+lz$SJXR(v_E6)Y6@}+K25d#b@V4-q-uWj~l+czQXVl&}nO=y7o>K zc{v0`^Il=035evu*q|$7$eq1J}pc>Sq4^ z6q>@J{-gGs2>bpP56SJG<_3AmwQKXl#>JbT!H;};xv?gRBV2Z%{G&a9@Pr7b$-AG5 zS#3CFtfe!FAFaANO$%s9)VVb)$a9snR4;Z>mvuCq**Gr$lq%Q0VPvHt9DUsaSsMK< z+f=SH&8%_Qh5Ti79ZA#F)BwJbSLvdr4TY=BHb5J5QR7}BXE>R2JBs~8;xOP&w)ITo zfk6?b*s7oyqmCDJ`nbtYN_7=YTv)tz_9>l)XV(9NZE;&)%jPX*wP9atbd#cfH2%F5 znFz=aw-ERP(U0I~r1G3Ilxq!$dTT|tD5DVG7+O#DZIva){Y|&&5alaT#%Vz>FEK1D zPHTq1g(kO}v;G*5kLcJ*ju~nmw{#4k#^c6LJ&qi09rjR@sY@M#*kv-Pfz+Mn4_Yhi=!8$IJ!IKVjs@xLE(0b_AApnU^6tJ}^I?oA6GR4; zKVea@VXhjve)M3TEj?2JIjUR4<#aB^%%7|0Zq&uxvX)@wM4hPN4*3CB?4^TIJ@MFl zZrrtf+Wq`0%dahOw%$42C~U}?q^Ib1J;*fc5vO22<(XK7b44AS+I^pnerLm+JJ>MP zRH*Y7yr~3X`|XAESvjN_qD&7CKvtXLJSaB1Y=E|+JooRp9)Pg^(^fdp+hiF8hcyUB zBrSwzYOQc!I*!--*mem$4Bs8y36k(@y%ELzT%ER_Jw^&P&Hc{Ol6Pvvc0hh(qjcx0 z`HDDIGZXQ#Y4X+68}%b_;&VTQb3OKn;1|0G1{VzhqA|zs4Vx7B<0{Sp(uNH3^>c%d zDIl7?8vhTXx&K7kI^{%Axk~YV#*D8z5%nF2=opmrz+Mao4OYRlw|O5GhYNnjrw+f| zoD8|B?KwG1hSZ$l1H2{*$%(PY0-^SgR(o;t%M;d@aaW)1h_}6&QAIx{o7!kzoL|R1 z;wqw&q3M`<47Q#ir=TURe*)ulgM|f^emmC0*Z;`+jDqT!7QUYVlkrYr#?jf_;f{L3 zn*6^eW1$3K3-VM(3fSmeajevDR6->-GoxN*k&eX4;uUg(gjOnY5`)&_^DV!6J{W3% zK3k<|B0y=X9Dc;dVLsvq(YTo@CK+O@h=ZfM(F@KQm2qDBtUa4Y1Br~~d5E>For~vi zej_G=$qa_5S^x~)MQ-6V!>C07FpLh@`@tzh+w!Tj(cv)xSB^EKXjA|JYIsm!?tY55 zC+fizyJy*i;uvn>TZoL%+bY{{x!*BHcVzC>o(f4?=uj)ZAorHl8hQ7MmL<2xn!w;# z-<(80Y;tvc@U1>@58HUkk(sypfWAB}xjGA=$mi`c5s6AbUymuT{LQ>;4*NRPpbC+V zIw)$QY&#uhOAJ+v9bKQfuibpe4bN`lhn)H`=E_6diUqLD6La{HcN-<5&koWUI?7sZ zC|jSVeXT-2pu^3tF#$6vdGfA4S$N(|K(t4+^rQ1cl#IE~4m@11l3@j?kFXzadVk-z zUw~jInlJo8cN<1|zJe?TqWUrhm@aI!eG8`;w>$_y2HxMpT_X0po8JI+Q;UdtUo%gC zVw!CqzO+$M^V)H9I&7}RSPXlh{MwS`iC)egNx+89?nT*ZKV>r@Lir}jZDZy>eR_lu z?YVMeK%R@>e_q&g>jxx!*qL(tHzj;hx60NGG&+DF#>fkY@ZEwStM~PCC6V>@f!Jv= z501&x-Tomy=tE(qNvOU-$whAbI&Ki%K9#naBd;{GWnNFU@c7GNYQ#>Qm#A43%PE*d z#eb4$=HZC{wey|@%1?nLx<#1hZirwkDup~0{8^KCT`SC)3DHpeDYz!%q;ib3w9Yzw zHZS`4R3wO`sbybh|8lR}?b@7$6lc5YHhC&bkuToIZ z*8t;2+>Lf^y{RK@ z#{WrxNHgpMdDn3%QKPTUM|DwTt8BBF!0gey1oE=g=jQDyOMB=xMy$`IFsz>Ysc$TH z`w~7b<+W#ooFAK_>J}U8vzB$7J;Tk_wsC=@qFE=Bf;*V#bYoDR-F(R;sohtZ{4gv? zEdih#_LEy4b)X$I{x|F(Fqc|jN;+~p7xF65_Pm=gM9uKcES$7tob}_my`bj3Iz@Oq zl3t(3#;Uw&D6{U3)M?~Je2*}D)t+?^lK3%x6{FRYs7|NUZ+PDGFwuYn|2jc8uKR1B zY&t_cb7xo)Q6c17W^SvRtroPIDVMr^6H6;+XsZsDxu?jL(jvc}cmB4|E*}^CRUF~v zmo|qZ$2fuJLyh1Q(IAsr_ahC!6fAQl`}+w+_M0P5g!)cso|;ZicDx9y7j zSwuHmNeCXauoKwCw2$GjrE2@=;|E&>Z#zvkb#0)5jMgolQ;u7iDU(;UzBj_W z!kEQ=bVyKnF$=(m={E1C3_*FPK4B*!4m>Y=FPV4P(nGALb(>ep!yOUPCpm}br5-!E z;MZ`WBS&r`lNK}%x(M$L7?#)^K1DDOWW51wNxucSdX1L}5SfUiv~L|yPlj*39=^a>Wf7OT zWeW$f-CF-n22pQ6af9bczfeOO`eFVrc32zh-(j2Zp7x}r*+1S?SXI76Ia|C_|XAE7H? zK3AomutQ$jm2Q$x{0Q+#emlJ1nevU@rcGT>QS6Az!ILpV?qJmQhNA2=Qs{A<^v1Mi zV%|5wSp2zQrEPNUyaVNSR??T$jjYEmB>XuMRfs~MsG=fOLLLG`QRWe%4JO&aRJe`E zeX`QntxH>mLT8{8aKZcI<_kc3*H~v=hz4Ca$ERjP5B?J$_xR5g2&k-Ug~jbG{>;IY zb4{v&?~l{o3^X%V>|Q)l2yyasS&szd;Y|j$a_83#1hzrilxR7;Jz+SWb!waD6j!5( z(dlc4{h(GN#9nY&y_b2~EP>)|K*Tmpv3ug>`VJ`k1MLd4k9qI;KI9B%?4 zvlE2mU++8e3S;5gT1-QiZWi;d-wiKAdTbov99Q07L@H2*M_+&8GgWm(>Crg8>9}Dt zYWzf8L*b}D47>26aQH@qqI+BuEw4P!BWMR#3|LqDJ^Otr^P-m4XTwHQTvKu3PY5tA zx#se2a{=T)$9~2cJ>NiU*!aVH8?b*}cAD=({;M@?O8#F|7awvk{URnFA?Y%E;roVD zU$hce=gEFc&dESpT{Cv%xxfc>ubeLlYDh0;rfqm6nd3w?2Tt_?#l-4o6~UrL-N<%A zVj4o~bphyEx5R$>U2qtC%DDXYtY=Lcg`1_o3J}j>ZWr_3#%Co<3MrFE6e67D#Uq1?1x%(8aowvmn2Uk3y&PPB=S`Zc8vdNS(XmBDCaa8gger6tL*2zBc#)1zd50Btta9g zoYOf(eCvfxMBqNpI?2Hr;z#8z6Oaiy!HWF&UChTngHtttD4Qf92EoOW=l{lH$)Azh zABQEJs8s0gP%u#=9b|(LVK-CEvmwIEtki9`J@|XR7k~VUd7A9|pa{Ct6c^CHDXNz| z@76k_2G8>FY3XZ&qHc_ z3wjwEdodo94C4&N0ZZ07jenE=Ylsj z?Z4zzJ);2b!YFa~$GkuI@i|X+U7g&|09y0DXhqEJMiy}MQTk^wh}WADa48= zbaerZqg`Eu|JK2rTm1!{g>hUO^ZryCKZi9o?S%D<55v?ET#NWe z8U;R)IO?U$@vMM+U*C*SZ~A!Eb$0$G+fo_U9^m@@7N+%GM0Y?U_y1BF)UQ0r#S;hy z7zFHr3EvTUTpst5!O86E+&CQLn-ipWA4&Z^Lmo1Z&WYc=6k8o2`$Sr{6@lkHc^xS& z-+I7hd9DA=XkOPx$-f}OfhgI*kSCbv$J@NOZ7%AR0L?{xI)jYY1aDwKcz7I%=F{nr zl(gOgeQnEI^o@KN3pv?FAAjsdX0&K>rKbkQ;e);3@WMKj7PlB z6jAQW8OK!-B=d%{Cxf{$hWd;M2Y}S*FCeA(9lMzcssO707mnhH^j#_*qOxzzpNg}J z8Bf73ccR-6;vUGr#jiOy82A0k-40Q#JVf!hU^$1a5&oib%c(ErK8CRK0%=lK&6W#h zQ>UIR#`sS}s(-3FGu%T_N5RXkN?$uZMFQ^9-QJcl^0_hjR{_cq(k?^E&Y|rO%_tZ zz{!!!sGdmet-i|IGq&%0<^3Dt&%D$6E8k}PyG)rEbb#g(r0aW;9o+lS<#A=S2MNkK zeu>+Edhga-RUwKa%I(V6OFLBGmN*|H)j;`8aEHJJ2qDwaEyPzyao_Bz}Mkylygj3rq}*rynG-%vls8R!0qf5%yyC= zod&s@r~J#jyD;NHfrQU3@@mzy^6@X{V+Kjd9|+OCp>GF5$O9r+&5hzjwk6q zx~Mf<-UrE{!Pfikm_p9f=jEj$a9v9wMBrMktLZ#DZT`?3xp|Se?YF?zR>)(>f7^VS zJmp?g%JlPyk3UkJ{vJH_X36sEpKg4l68Z z*zQi;v(xxh?S`@tV2y+1yU_WnVy=|j(sZS&_Pk+aBm6;QnstPw#Jmd*{R!`i{)TS; zyix6MyAl>C5zwN>qbUTJ_sY%;_ z@vf|9avc0~L!kfhVpXVBOleVpBmI|6w!oEXUj}~@(X~?vg>diiDy)OVaf9ehIfSJo zM%IypKbYTy4&)}~7L7K2{7d)74=7CdTmi=KE>J@^|5#UK`={^5n^aZgnZ4(omklu6 z_7)@S58$?Es>8m0;veVXk1n!t`rlj!Fm=YR+oU??@v$auY{#6Qvx*7bn2EsmVpIWx z&og>U5y7(o8FA))?7i1w@wqYRW!$pcm$|PPW8(T%6|5w7*qy?0@zbBCH0C=(n8=7S zQuYvbJIBC~ssm(9*jdKN>B|vo!&Jr;AWW zwHiMwn|x)Y+AFZgbeO?7lUhQ6w3cEgY4o1U0t3=+V)Ype2$RE}@VkG=A-8Zl1m(A{ z-yfxwfyR4IC`j{F7suxhg&P^Y1e15eF^Y%-%Hsjkyy}A!>{EF>!7)cr?)#TluJpSI zp$p+T^!bH>4q7qu=Nc`cxV#JcE`;Imy>j$z3E?7wzmyQQ{>9h=MClog{fz5M03sj< zsG_GuE7aO5KfsnPO@wCi6YDGpW}`Q~^W1v9-G0HP&e{wCjToLYDr@m*L&FbU55h57 z_&xquJaNRS{fkN_+H>3i{>s}WV&`VTtphu=-2TPns*m@;C18h?EAMrSRn(BWlGn`B zRgsl~p5RbSC#q~D0Ng$pp_c9hcW=D*imV?0*#(~R6Lfl>al^WHCm8wWuu-+Y4qW?a zW8nk&%K4p#!N`oax7So&l~LWqcRu2`4qX2hgg=-*I=P0mK;$TSZbC+YCb7oFe&h60 zZ(uMHu^qeDcr$l6T**PJ==NWn?QLjxHlu6BStu^Oz5N4pp@japP{v$`7l^=iZ3V^3 zaN5j>TIjXUTO>a#qnt|}NJ+%|Pft9&!i6a-$U$jSUg|y3BRJYg=Ic9##alJiW(VRra zCWI(~Tw`A&)jupyj$;Iz6}1+A;R0nACPY{St>n@cTi=%ftl+2Kd^743o3Lku#OJKz zMd!;q^K;NSA<35%CayR7eHqM&5Wx+r2_u=6d!%u5_Itwa`zj!lZ?hhqdX)SRn`yw= z(8Dw#HrRtDZn6Kojrj{{a8EpIWbwa_!&%d@kO*&dUpfp{-FXz9?KuGKrbLOW|0$L@ zf23R+y9ru;PvVynLY&}4+bk&5`wM)2?RS$Y9Niq$`9?1VV%AV0H3^ZXp1x z5FuLwC~GNKqWN*wff#DKaBF6MVzum2iDwJzu>FmW(#FSx=)~g#(_?$=sTXBEGzU0v z;95_vGHc&Ut=VCCypRjJ1UA@`T!VM1j-SC^d^1z4hTg1QOAQ&THljrm+ysv3=S-df zZAMtEc6JaPEEcgAo67!UKySiM37*b~>T3uF=TF#OP}~45rh4b+(4&K!J*CjvpZS3p z0?gj$C9a^;sp(Jc=)L{y6NOjtv{s>4o+l15^|hy_$9?s}e}TDSmgGPnoDF zWg2|uEo-+6VK8kg1?@NhO+V++s`pw;FN#hQ>$BL|)?$BoFv`d|k?>~?vWwJ!{-c9F zh7)jSiTpr!*7eH!+WV?%$l5ryou#BQ(Vk3TPfS|Z9ki2V(tEVQVNt$Mul3r%*&IDq z8aSkKDgD2N3^jS1k~VflyG~vt$=oy3A;CifuBxS>V|F|Tk{q&0iaLEW4K1&^FnfAE zu$!A9Q-MD1M#43$r3&ZCTt~z$&s$+12YLXO_CaA=o4i{2jHNN_^O1ur&EibtlzQtw z0$wSTUoC+|506sRQreK1C7RJ}5XMLy1Z+^(r7PnrUuOlVffQ}ImnEQ|OCppgdlx@0 zK}FfGUB1f0GUbaOg_7x{I+Ke5AUZ%vE#7#muf@t+!sw%YFi`z2Q#*QFvOyUJ$S71xyn4!l3A8|7#wtqe zDW(BRZ+Ymo1+aWKcKgHmCfS|X;H5Q{Ni?GD%x9bs22YjzTWu9U3b)8QM8zeW$R+43 z89UzTW%-g=+1d%=%~`5%uB@3el(jQdF@Vh|+7Vn%Zh9ik*C*EU7FvFz)!`$bEXLqZ zLhRFt*zpo67=Q$0j3JOP9Jpu|!?WTzqh!iJNYnx5rOb!ru;q^WoGG!`#8FltGCut< zh>lqB_imAa(8Fg2#1rGw?aUjSO7)MvbmnMAVx6(aa*#_GArts{sF1wiC-J5VdJ3SA zTqP#zuNW)%ag{4?Q5zWQ%cjIiWxCyo1Q3+@>F=*l8&G}M?x-(FUnlKGBlmo1mp%%n z6oO>qRk^q8I34m%254Gw_S|h21lM|oJLCR2=sz=HlMeG=OQXHl{>9uw% z(l3R6?GI%L*CY6hm(2mlmlylZU{9W8u3iMHj_y@b-6_pV34SAddW_yFKUJw_%XvJ( zci9GjhAyF`Yjkc>vUaYiKxBS{Q`L}fT~C1LVICMO)*scMggfStRJ_ZPvv8;;uxh2Y}2fvmImK5(cFICIjTNnQAnx%alGXgb06j4lq~6*EKg|E z)KS@}M7O*3pUlXfPN@YN1j>@UNJmX4MfnUHewdu4hKGhDm>31h@=WL~jqlg9!$t>u zy$6+~KT+J1RrkmG9x)+)?$DUf(ByF=$ifKXpVZ;~(R_+JV)G0|3bC8)ty& zfmY`L`YD33`~%AGtx-Z4i3CA8SqfKLzF+z7E`vSk_>X%=#f2aGvM5nGp--r&H?L!r z=`V06-1KAUIGv{CnLzI8b0t-V)ANsSi-3U1yhu(13LvMnUl)J_Iei6r-`}!zu@Qa>fFyJUV4}#eqP~Gj?T=UdzmqsJ;bc!yY)qLOMI% z05bz&1hFQD;Kf>#0@b`i?eSs4@ikrcC%ElBe7J*F5MQS1_5LI_Z4qGTj)9&G{z*m)YY7#=^1T?;DLk zTV1$wt)1)7@^9eKn-@Qyu+2Nk_b;($^vmxC$DK)6$-ks)^^URQRMh}|bUCg8ql;Jq zwp7m7!WUD4v4f*Ocfjj|($i5I{`pT${#MjhO(R23?wih^FgDz3V8 z7kL(dqd(q{_}HQF<;^FqduBaluxbOPjgZdV&YXswr>1za<7>61<;`1*9nLKCz3rHp zLnQ6WLQG+rowEbsm!sd3ZLHU2mtVrn^Ha62OMZ?bz2Of{-873oDobNRsQ2t_ut157DPn}#>41Ib1K4%CY!8Uipp?hOF-1*@bjnyab zT+WZI>JsCPe-wta-r*_U2Y6Q2Oq zl|acQ=3VJFsFa+Vv~iGD8tgJ_dcL%!vE(VH7DR)i>d0fq1dvTpHo#(~RL}+ml>~$N zRO_0cYvD?=$;SNKnE2HOIN}Bvl?%+#u8`M;;=#9 zY{lc$A+T#&`wKeNw1BYSwA}c07*rj4N`LeyKrYo5?}}SsW|inJwaF~mCvu*yHu2%C z$_{FpNfkGSckUqT_HG>hdKp6_(4gAkz>3|`&OL_Mnu)*5I=4>O_$M7W6(o59`uOf_ z0_S@n8B7P)lCx~E)J~59;sFst6ry&8U9N_3L&5`~639o*)7)JZH6Axj0LeSWJg}JN zFXcZs-@|_{_z8tSmHLCYPVVR$s?$sGQRw^g2WAgI4q(*-^WT zy&~JMt0*3Z|Fb4;{;itu{N}3j4RS=Upn`vWJBB6d`hFrZVZE|5J&KTexBGiN zC+jvim0sfTnkn1rzClM$L>Y>L>PK6&g;b$dSPIh_Hmsx^ye$E)7V@We%KTTo6C$Lq z0Tn~*Kq$F#mh7)*Qw|^6!PocaBsyxbRgXzfIj6pf;**@4A>x!ro?gtJD7FCx*N71f7h=2V1AANNB9C6q8M#|R#hv0D`%*xrA|Ey^mV!w-!#*yJ&N+7rcutl;g6G045dO22IJ+)>g`XK)#HMn35uek!tEU<7xt4o z#+v|pGyUmg>_Uh?L+s0D?md@g1r%jIJl-quaiB0@K(jW@<#vf z2FS+=D36UUCbh6>XCU3i;XRi1*;Hxk$95II3>T6D^{hGBOF;Jwi>|3i4sV>bEmCjC z=6x$TH<)>)tSg{T><1M!_VK7Y2d1Nh9%NHi?(Kz^Ae)jjDf0om@>@e+0WKxQ`u}(B z4?nZH#ChG=ofCBu7u%)IKHQ0>c@xd-Ibp9LJ6WzU+`|dsp0*vBL5`#sq*P^TV7i-K z$siV6cXh+|N&g~L)Y+_bJVY6HK&t)u6MZ*$!PO>bRHEdLRzO;ZI3PiPL#i%!%?REHkG0pCjAq-Fq=8%1{` z@WeN)y(M1M{C@@4KV|sSzahhcc9}8o!BL=dP)Iu(V|aRnmn--H%776=1i?w3#ZMF2 z2B%tw9+k0lo*JQi@fem0!CFOZ89&21W7c2Tz(EJQO+W?a9eoH-Ay*`caYJ%<>Z?sU}o@#M@n!1-5e-% z=@^CSwSN<&G2C2Fc)P~9@cZdM7_@Zl-@bBB|K2o; z^HE6?2pUK*?{)hAipa9VX!LGasY5s!q-B(DwXkhcTLRNe=*uve_E>YE{iZ))v6#%B zAe!$}eJZKAU>z@{{Ozsey>3aN@7IZnR9CEfpPp?Ul%cjL_8-$RNNniRY`(qD8qj8z z)iMsf8ah@-qj1W|PI(-?DjKiUF|_P}fuYpmx0iJIsV)iclm?H1d&F%QcVCtl4XL_~ zB?pO50!M1bt{By-6?YB$SGeLhFvfuAqdq4};g4cZwaK>S2?kvTQt8-K?M)E$zyWt^ zd+W#m*cMrz#rOOG4~h}~jP(ZWxKX~Hv&LA$b}6Ka0o$q%r|US*LD=B~tT&(T*@RVK zxA>J$?W+YKwKEIwAcoqJ)h#DMA1cI0R2!KE^0%r$9GWpH)eQHeC(A>=?+qNBm;%nj z&7#ryh&RSSb1W(Y)|>XO@ll*nrC>uy$JmoC3?3nbUdhwKH#G1kyCj)cuxVl6Ud`>{KbaA^!UjQ|=@g{A-F zffzb$Wq{bNS9HwYr!l*jjs$)JuWnK|?4L4SeAzAyhF>yxs?l-H5#ok&t~ zf{zEq*Pxiui)!%mV<9Ya8k^y1w82aqcW{)wlzXd8hrgz_5idAjRC8eO8G$9B$E98b zAm6hrB6};~zW3{ozO3N28E?-!^hFJS1->3f+T!**qBCdy-;~e1JHMX2M7|=nkyvRP zhD=?k59GG73hYN0mp09Hcdo{F-q(RNp5Q|+B;*A)bsn#(6krCSk}^2aH;SH>?$2SB z^j~ue3xJ^FiRbDsMg;FPBcy43O6sUhh}4gX3Y1Nr z0?s1I&9K~LSm~MP>>nTA!w0MeFrPV#pNW3Nz|N>n0jd#(QAC=dh9UDTP?_oIxFeO< zuxx4lTB9ro?a3={`~1Q}+~bT`B^n0EG^L_?Y|eIp0*@0(;dWE_Cukc~u_phMy6Eiw>mU`*wKPDGL^$NbhYsHBEQ))nc0aWcx4?bNXU+Qp zmRTG(zj{r=$+!s2B6f;y)gF;QFFYXcx+MyCbLy+*T>9P&RCm%Hp8LE%+`pPk+QzYetHUwr|?^XsyG}tM|z2ZWd+

    o*#th?^=dqb7vhE| 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

    o*#th?^=dqb7vhE| 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 aPZerz?X*UA!0m7Tm(@_!RjF383IBgZUtvK2 diff --git a/apps/matrix/apps/mobile/assets/splash.png b/apps/matrix/apps/mobile/assets/splash.png deleted file mode 100644 index b825c132e5028b381164d9aa9db3b89e84cbe558..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 127687 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

    o*#th?^=dqb7vhE| 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 "$MONOREPO_ROOT/.npmrc" -echo "Created .npmrc with node-linker=hoisted at $MONOREPO_ROOT" diff --git a/apps/matrix/apps/mobile/eas.json b/apps/matrix/apps/mobile/eas.json deleted file mode 100644 index a799e8c93..000000000 --- a/apps/matrix/apps/mobile/eas.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "cli": { - "version": ">= 15.0.15", - "appVersionSource": "remote" - }, - "build": { - "base": { - "node": "22.22.1", - "pnpm": "9.15.0", - "env": { - "EAS_BUILD_RUNNER": "eas-build-on-success" - } - }, - "development": { - "extends": "base", - "developmentClient": true, - "distribution": "internal" - }, - "preview": { - "extends": "base", - "distribution": "internal", - "ios": { - "simulator": true - } - }, - "production": { - "extends": "base", - "autoIncrement": true, - "ios": { - "image": "sdk-55" - } - } - }, - "submit": { - "production": {} - } -} diff --git a/apps/matrix/apps/mobile/eslint.config.js b/apps/matrix/apps/mobile/eslint.config.js deleted file mode 100644 index 9be743690..000000000 --- a/apps/matrix/apps/mobile/eslint.config.js +++ /dev/null @@ -1,9 +0,0 @@ -const { defineConfig } = require('eslint/config'); -const expoConfig = require('eslint-config-expo/flat'); - -module.exports = defineConfig([ - ...expoConfig, - { - ignores: ['dist/**', 'build/**', '.expo/**', 'node_modules/**'], - }, -]); diff --git a/apps/matrix/apps/mobile/expo-env.d.ts b/apps/matrix/apps/mobile/expo-env.d.ts deleted file mode 100644 index bf3c1693a..000000000 --- a/apps/matrix/apps/mobile/expo-env.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -/// - -// NOTE: This file should not be edited and should be in your git ignore diff --git a/apps/matrix/apps/mobile/global.css b/apps/matrix/apps/mobile/global.css deleted file mode 100644 index b5c61c956..000000000 --- a/apps/matrix/apps/mobile/global.css +++ /dev/null @@ -1,3 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; diff --git a/apps/matrix/apps/mobile/metro.config.js b/apps/matrix/apps/mobile/metro.config.js deleted file mode 100644 index d285173b2..000000000 --- a/apps/matrix/apps/mobile/metro.config.js +++ /dev/null @@ -1,51 +0,0 @@ -const { getDefaultConfig } = require('expo/metro-config'); -const { withNativeWind } = require('nativewind/metro'); -const path = require('path'); - -// Monorepo root where hoisted node_modules live -const monorepoRoot = path.resolve(__dirname, '../../../..'); - -const config = getDefaultConfig(__dirname); - -// Polyfills for matrix-js-sdk (browser-oriented SDK) -config.resolver.extraNodeModules = { - ...config.resolver.extraNodeModules, - events: require.resolve('events'), - buffer: require.resolve('buffer'), - stream: require.resolve('stream-browserify'), -}; - -// In pnpm monorepos with node-linker=hoisted, pnpm may place a different version of -// react-native-css-interop in the app's local node_modules vs the monorepo root. -// This causes module duplication in the Metro bundle: the transformer's injectData() -// writes styles to one module instance while the JSX runtime's getStyle() reads from -// another (empty) instance, resulting in no styles being applied. -// -// Fix: intercept react-native-css-interop imports and resolve them from the monorepo -// root node_modules, bypassing any local copy. -config.resolver.resolveRequest = (context, moduleName, platform) => { - // Block matrix-sdk-crypto-wasm (uses import.meta, not compatible with Hermes) - if (moduleName === '@matrix-org/matrix-sdk-crypto-wasm') { - return { type: 'empty' }; - } - - // Deduplicate react-native-css-interop by resolving from monorepo root - if ( - moduleName === 'react-native-css-interop' || - moduleName.startsWith('react-native-css-interop/') - ) { - return context.resolveRequest({ ...context, originDir: monorepoRoot }, moduleName, platform); - } - - return context.resolveRequest(context, moduleName, platform); -}; - -// In a pnpm monorepo with node-linker=hoisted, the virtual module system used by -// react-native-css-interop can fail because node_modules are at the monorepo root, -// not inside the app directory. Using forceWriteFileSystem bypasses virtual modules -// and writes CSS data directly to the cache files on disk, which Metro then reads -// and the transformer wraps with injectData(). -module.exports = withNativeWind(config, { - input: './global.css', - forceWriteFileSystem: true, -}); diff --git a/apps/matrix/apps/mobile/nativewind-env.d.ts b/apps/matrix/apps/mobile/nativewind-env.d.ts deleted file mode 100644 index 5d7befc24..000000000 --- a/apps/matrix/apps/mobile/nativewind-env.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -/// - -import 'react-native'; - -declare module 'react-native' { - interface PressableProps { - className?: string | ((state: { pressed: boolean }) => string); - cssInterop?: boolean; - } -} diff --git a/apps/matrix/apps/mobile/package.json b/apps/matrix/apps/mobile/package.json deleted file mode 100644 index c299d7255..000000000 --- a/apps/matrix/apps/mobile/package.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "name": "@matrix/mobile", - "version": "0.2.0", - "main": "expo-router/entry", - "scripts": { - "dev": "expo start", - "ios": "expo run:ios", - "android": "expo run:android", - "build:dev": "eas build --profile development --platform ios", - "build:preview": "eas build --profile preview --platform ios", - "build:prod": "eas build --profile production --platform ios", - "submit:ios": "eas submit --platform ios --profile production", - "build:testflight": "eas build --profile production --platform ios --auto-submit", - "prebuild": "expo prebuild", - "type-check": "tsc --noEmit", - "lint": "eslint .", - "format": "eslint . --fix" - }, - "dependencies": { - "@react-native-async-storage/async-storage": "2.2.0", - "babel-preset-expo": "~55.0.8", - "buffer": "^6.0.3", - "events": "^3.3.0", - "expo": "~55.0.5", - "expo-audio": "~55.0.8", - "expo-constants": "~55.0.7", - "expo-document-picker": "~55.0.8", - "expo-file-system": "~55.0.10", - "expo-haptics": "~55.0.8", - "expo-image": "~55.0.6", - "expo-image-picker": "~55.0.12", - "expo-linking": "~55.0.7", - "expo-media-library": "~55.0.9", - "expo-notifications": "~55.0.12", - "expo-router": "~55.0.5", - "expo-secure-store": "~55.0.8", - "expo-status-bar": "~55.0.4", - "expo-system-ui": "~55.0.9", - "expo-web-browser": "~55.0.9", - "matrix-js-sdk": "^37.1.0", - "nativewind": "~4.2.3", - "phosphor-react-native": "^2.3.0", - "react": "19.2.0", - "react-dom": "19.2.0", - "react-native": "0.83.2", - "react-native-css-interop": "0.2.3", - "react-native-gesture-handler": "~2.30.0", - "react-native-reanimated": "~4.2.1", - "react-native-safe-area-context": "~5.6.2", - "react-native-screens": "~4.23.0", - "react-native-web": "~0.21.2", - "react-native-worklets": "~0.7.2", - "stream-browserify": "^3.0.0", - "zustand": "^4.5.1" - }, - "devDependencies": { - "@babel/core": "^7.20.0", - "@types/react": "~19.2.14", - "eslint": "^9.39.1", - "eslint-config-expo": "^55.0.0", - "prettier": "^3.2.5", - "prettier-plugin-tailwindcss": "^0.5.11", - "tailwindcss": "^3.4.0", - "typescript": "~5.9.3" - }, - "private": true -} diff --git a/apps/matrix/apps/mobile/prettier.config.js b/apps/matrix/apps/mobile/prettier.config.js deleted file mode 100644 index 698fc9790..000000000 --- a/apps/matrix/apps/mobile/prettier.config.js +++ /dev/null @@ -1,8 +0,0 @@ -module.exports = { - semi: true, - singleQuote: true, - trailingComma: 'all', - printWidth: 100, - useTabs: true, - plugins: ['prettier-plugin-tailwindcss'], -}; diff --git a/apps/matrix/apps/mobile/src/components/DateSeparator.tsx b/apps/matrix/apps/mobile/src/components/DateSeparator.tsx deleted file mode 100644 index 437094e73..000000000 --- a/apps/matrix/apps/mobile/src/components/DateSeparator.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { View, Text } from 'react-native'; - -interface Props { - timestamp: number; -} - -function formatDate(timestamp: number): string { - const date = new Date(timestamp); - const now = new Date(); - const diffDays = Math.floor((now.getTime() - date.getTime()) / 86400000); - - if (diffDays === 0) return 'Today'; - if (diffDays === 1) return 'Yesterday'; - if (diffDays < 7) { - return date.toLocaleDateString([], { weekday: 'long' }); - } - return date.toLocaleDateString([], { day: 'numeric', month: 'long', year: 'numeric' }); -} - -export default function DateSeparator({ timestamp }: Props) { - return ( - - - {formatDate(timestamp)} - - - ); -} diff --git a/apps/matrix/apps/mobile/src/components/ImageViewer.tsx b/apps/matrix/apps/mobile/src/components/ImageViewer.tsx deleted file mode 100644 index a44885ce2..000000000 --- a/apps/matrix/apps/mobile/src/components/ImageViewer.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { Modal, View, Pressable, StatusBar, Dimensions } from 'react-native'; -import { Image } from 'expo-image'; -import { X, DownloadSimple } from 'phosphor-react-native'; -import { File, Paths } from 'expo-file-system'; -import * as MediaLibrary from 'expo-media-library'; -import { useState } from 'react'; - -interface Props { - uri: string | null; - onClose: () => void; -} - -const { width: SCREEN_W, height: SCREEN_H } = Dimensions.get('window'); - -export default function ImageViewer({ uri, onClose }: Props) { - const [saving, setSaving] = useState(false); - - const handleSave = async () => { - if (!uri || saving) return; - setSaving(true); - try { - const { status } = await MediaLibrary.requestPermissionsAsync(); - if (status !== 'granted') return; - - const filename = `manalink_${Date.now()}.jpg`; - const downloaded = await File.downloadFileAsync(uri, Paths.cache); - await MediaLibrary.saveToLibraryAsync(downloaded.uri); - } finally { - setSaving(false); - } - }; - - return ( - - - ); -} diff --git a/apps/matrix/apps/mobile/src/components/MessageBubble.tsx b/apps/matrix/apps/mobile/src/components/MessageBubble.tsx deleted file mode 100644 index d7fbfe09a..000000000 --- a/apps/matrix/apps/mobile/src/components/MessageBubble.tsx +++ /dev/null @@ -1,448 +0,0 @@ -import { useState } from 'react'; -import { - View, - Text, - Pressable, - ActionSheetIOS, - Platform, - Alert, - Clipboard, - Modal, - ScrollView, -} from 'react-native'; -import { Swipeable } from 'react-native-gesture-handler'; -import Animated, { - useAnimatedStyle, - interpolate, - Extrapolation, - type SharedValue, -} from 'react-native-reanimated'; -import { Image } from 'expo-image'; -import { ArrowBendUpLeft } from 'phosphor-react-native'; -import type { SimpleMessage, MessageReaction } from '~/src/matrix/types'; -import MessageText from './MessageText'; -import VoiceMessage from './VoiceMessage'; - -interface Props { - message: SimpleMessage; - prevMessage: SimpleMessage | null; - onReply?: (message: SimpleMessage) => void; - onEdit?: (message: SimpleMessage) => void; - onReact?: (eventId: string, emoji: string) => void; - onDelete?: (eventId: string) => void; - onForward?: (message: SimpleMessage) => void; - onImagePress?: (uri: string) => void; - onAvatarPress?: (userId: string) => void; -} - -function formatTime(ts: number) { - return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); -} - -function AvatarCircle({ - name, - url, - onPress, - size = 28, -}: { - name: string; - url?: string; - onPress?: () => void; - size?: number; -}) { - const inner = ( - - {url ? ( - - ) : ( - - {name[0]?.toUpperCase() ?? '?'} - - )} - - ); - if (!onPress) return inner; - return ( - - {inner} - - ); -} - -const QUICK_REACTIONS = ['👍', '❤️', '😂', '😮', '😢']; - -function SwipeReplyAction({ progress }: { progress: SharedValue }) { - const style = useAnimatedStyle(() => ({ - opacity: interpolate(progress.value, [0, 0.5, 1], [0, 0.6, 1], Extrapolation.CLAMP), - transform: [{ scale: interpolate(progress.value, [0, 1], [0.5, 1], Extrapolation.CLAMP) }], - })); - return ( - - - - - - ); -} - -function ReactionDetailsModal({ - reactions, - visible, - onClose, -}: { - reactions: MessageReaction[]; - visible: boolean; - onClose: () => void; -}) { - const [selectedKey, setSelectedKey] = useState(null); - const selected = selectedKey ? reactions.find((r) => r.key === selectedKey) : reactions[0]; - - return ( - - - - Reactions - - Done - - - - {reactions.map((r) => ( - setSelectedKey(r.key)} - className={`flex-row items-center gap-1 px-3 py-1.5 rounded-full border ${ - selected?.key === r.key - ? 'bg-primary/20 border-primary/40' - : 'bg-surface border-border' - }`} - > - {r.key} - - {r.count} - - - ))} - - - {selected?.users.map((userId) => ( - - - - {userId.replace(/^@/, '')[0]?.toUpperCase() ?? '?'} - - - - {userId} - - - ))} - - - - ); -} - -export default function MessageBubble({ - message, - prevMessage, - onReply, - onEdit, - onReact, - onDelete, - onForward, - onImagePress, - onAvatarPress, -}: Props) { - const [showReactionDetails, setShowReactionDetails] = useState(false); - const isOwn = message.isOwn; - const isGrouped = - !message.redacted && - prevMessage !== null && - prevMessage.sender === message.sender && - message.timestamp - prevMessage.timestamp < 300_000; - const showAvatar = !isOwn && !isGrouped; - const showSenderName = !isOwn && !isGrouped; - - const handleLongPress = () => { - const extraOptions = isOwn && !message.redacted ? ['Edit', 'Delete'] : []; - const options = [ - 'Cancel', - 'Reply', - 'Forward', - ...QUICK_REACTIONS, - 'Copy text', - ...extraOptions, - ]; - const destructiveIndex = isOwn && !message.redacted ? options.length - 1 : undefined; - - if (Platform.OS === 'ios') { - ActionSheetIOS.showActionSheetWithOptions( - { options, cancelButtonIndex: 0, destructiveButtonIndex: destructiveIndex }, - (index) => { - if (index === 0) return; - if (index === 1) { - onReply?.(message); - return; - } - if (index === 2) { - onForward?.(message); - return; - } - const ri = index - 3; - if (ri < QUICK_REACTIONS.length) { - onReact?.(message.id, QUICK_REACTIONS[ri]); - return; - } - const ai = index - 3 - QUICK_REACTIONS.length; - if (ai === 0) { - Clipboard.setString(message.body); - return; - } - if (ai === 1 && isOwn) { - onEdit?.(message); - return; - } - if (ai === 2 && isOwn) { - onDelete?.(message.id); - } - } - ); - } else { - Alert.alert('Message', undefined, [ - { text: 'Reply', onPress: () => onReply?.(message) }, - { text: 'Forward', onPress: () => onForward?.(message) }, - ...(isOwn ? [{ text: 'Edit', onPress: () => onEdit?.(message) }] : []), - { text: 'Copy text', onPress: () => Clipboard.setString(message.body) }, - ...(isOwn && !message.redacted - ? [ - { - text: 'Delete', - style: 'destructive' as const, - onPress: () => onDelete?.(message.id), - }, - ] - : []), - { text: 'Cancel', style: 'cancel' as const }, - ]); - } - }; - - if (message.redacted) { - return ( - - - Message deleted - - - ); - } - - const renderLeftActions = isOwn - ? undefined - : (progress: SharedValue) => ; - - const renderRightActions = isOwn - ? (progress: SharedValue) => - : undefined; - - // Type assertion needed: react-native-gesture-handler Swipeable types expect old Animated API - // but we use Reanimated v3 SharedValue which is what actually works at runtime - const swipeableProps = { - renderLeftActions: renderLeftActions as any, - renderRightActions: renderRightActions as any, - }; - - return ( - { - if ((direction === 'left' && !isOwn) || (direction === 'right' && isOwn)) { - onReply?.(message); - } - }} - friction={2} - overshootFriction={8} - > - - {/* Left avatar */} - {!isOwn && ( - - {showAvatar && ( - onAvatarPress(message.sender) : undefined} - /> - )} - - )} - - - {showSenderName && ( - onAvatarPress(message.sender) : undefined}> - - {message.senderName} - - - )} - - - {/* Reply preview */} - {message.replyTo && ( - - - {message.replyToSenderName ?? 'Unknown'} - - - {message.replyToBody ?? '…'} - - - )} - - {message.type === 'm.image' && message.media?.thumbnailUrl && ( - onImagePress?.(message.media!.thumbnailUrl!)}> - - - )} - - {message.type === 'm.file' && ( - - 📎 - - {message.media?.filename ?? message.body} - - - )} - - {message.type === 'm.audio' && message.media?.downloadUrl && ( - - )} - - {(message.type === 'm.text' || - message.type === 'm.notice' || - message.type === 'm.emote') && ( - - )} - - - {/* Reactions */} - {message.reactions && message.reactions.length > 0 && ( - - {message.reactions.map((r) => ( - onReact?.(message.id, r.key)} - onLongPress={() => setShowReactionDetails(true)} - className={`flex-row items-center gap-0.5 px-2 py-0.5 rounded-full border ${ - r.includesMe ? 'bg-primary/20 border-primary/40' : 'bg-surface border-border' - }`} - > - {r.key} - {r.count > 1 && ( - - {r.count} - - )} - - ))} - - )} - - {/* Timestamp + Read receipts */} - - - {formatTime(message.timestamp)} - {message.edited && ' · edited'} - - {message.readBy && message.readBy.length > 0 && ( - - {message.readBy.slice(0, 3).map((r, i) => ( - 0 ? { marginLeft: -3 } : undefined} - > - - {r.userName[0]?.toUpperCase() ?? '?'} - - - ))} - {message.readBy.length > 3 && ( - - +{message.readBy.length - 3} - - )} - - )} - - - - - {/* Reaction details modal */} - {message.reactions && message.reactions.length > 0 && ( - setShowReactionDetails(false)} - /> - )} - - ); -} diff --git a/apps/matrix/apps/mobile/src/components/MessageInput.tsx b/apps/matrix/apps/mobile/src/components/MessageInput.tsx deleted file mode 100644 index 2d6cb023d..000000000 --- a/apps/matrix/apps/mobile/src/components/MessageInput.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { useState, useRef, useEffect } from 'react'; -import { View, TextInput, Pressable, Text } from 'react-native'; -import * as Haptics from 'expo-haptics'; -import { ArrowUp, Paperclip, Microphone, X, PencilSimple } from 'phosphor-react-native'; -import type { SimpleMessage } from '~/src/matrix/types'; - -interface Props { - onSend: (body: string, replyToEventId?: string) => Promise; - onEdit?: (eventId: string, newBody: string) => Promise; - onTyping: (typing: boolean) => Promise; - onAttach?: () => void; - onVoiceRecord?: () => void; - replyTo?: SimpleMessage | null; - onCancelReply?: () => void; - editingMessage?: SimpleMessage | null; - onCancelEdit?: () => void; -} - -export default function MessageInput({ - onSend, - onEdit, - onTyping, - onAttach, - onVoiceRecord, - replyTo, - onCancelReply, - editingMessage, - onCancelEdit, -}: Props) { - const [text, setText] = useState(''); - const [sending, setSending] = useState(false); - const typingTimer = useRef | null>(null); - - // Pre-fill text when entering edit mode - useEffect(() => { - if (editingMessage) setText(editingMessage.body); - else setText(''); - }, [editingMessage?.id]); - - const handleChangeText = (value: string) => { - setText(value); - if (!editingMessage) { - onTyping(true); - if (typingTimer.current) clearTimeout(typingTimer.current); - typingTimer.current = setTimeout(() => onTyping(false), 3000); - } - }; - - const handleSubmit = async () => { - const body = text.trim(); - if (!body || sending) return; - setSending(true); - if (typingTimer.current) clearTimeout(typingTimer.current); - onTyping(false); - await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - try { - if (editingMessage) { - await onEdit?.(editingMessage.id, body); - onCancelEdit?.(); - } else { - await onSend(body, replyTo?.id); - onCancelReply?.(); - } - setText(''); - } finally { - setSending(false); - } - }; - - const canSend = text.trim().length > 0 && !sending; - const isEditing = !!editingMessage; - const showMic = !canSend && !isEditing && !!onVoiceRecord; - - return ( - - {/* Context banner: Reply or Edit */} - {(replyTo || isEditing) && ( - - - - - {isEditing ? 'Editing message' : `Reply to ${replyTo!.senderName}`} - - - {isEditing ? editingMessage!.body : replyTo!.body} - - - - - - - )} - - {/* Input row */} - - {onAttach && !isEditing && ( - - - - )} - - - - {showMic ? ( - - - - ) : ( - - {isEditing ? ( - - ) : ( - - )} - - )} - - - ); -} diff --git a/apps/matrix/apps/mobile/src/components/MessageText.tsx b/apps/matrix/apps/mobile/src/components/MessageText.tsx deleted file mode 100644 index 7f34d25c0..000000000 --- a/apps/matrix/apps/mobile/src/components/MessageText.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { Text, Linking } from 'react-native'; - -const URL_REGEX = /(https?:\/\/[^\s<>[\]{}|\\^`"]+)/g; -const MENTION_REGEX = /(@[\w.-]+:[\w.-]+)/g; - -interface Segment { - text: string; - type: 'text' | 'url' | 'mention'; -} - -function parseSegments(body: string): Segment[] { - const segments: Segment[] = []; - // Split on URLs first, then handle mentions - const parts = body.split(URL_REGEX); - for (const part of parts) { - if (URL_REGEX.test(part)) { - segments.push({ text: part, type: 'url' }); - URL_REGEX.lastIndex = 0; - } else { - // Split on @mentions - const mentionParts = part.split(MENTION_REGEX); - for (const mp of mentionParts) { - if (MENTION_REGEX.test(mp)) { - segments.push({ text: mp, type: 'mention' }); - MENTION_REGEX.lastIndex = 0; - } else if (mp) { - segments.push({ text: mp, type: 'text' }); - } - } - } - } - return segments; -} - -interface Props { - body: string; - isOwn: boolean; - className?: string; -} - -export default function MessageText({ body, isOwn, className }: Props) { - const segments = parseSegments(body); - const baseColor = isOwn ? 'rgba(255,255,255,0.95)' : undefined; - - return ( - - {segments.map((seg, i) => { - if (seg.type === 'url') { - return ( - Linking.openURL(seg.text).catch(() => {})} - > - {seg.text} - - ); - } - if (seg.type === 'mention') { - return ( - - {seg.text} - - ); - } - return ( - - {seg.text} - - ); - })} - - ); -} diff --git a/apps/matrix/apps/mobile/src/components/RoomListItem.tsx b/apps/matrix/apps/mobile/src/components/RoomListItem.tsx deleted file mode 100644 index df065d175..000000000 --- a/apps/matrix/apps/mobile/src/components/RoomListItem.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { View, Text, Pressable } from 'react-native'; -import { Image } from 'expo-image'; -import type { SimpleRoom } from '~/src/matrix/types'; - -interface Props { - room: SimpleRoom; - onPress: () => void; -} - -function formatTime(timestamp?: number): string { - if (!timestamp) return ''; - const date = new Date(timestamp); - const now = new Date(); - const diffDays = Math.floor((now.getTime() - date.getTime()) / 86400000); - if (diffDays === 0) return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - if (diffDays === 1) return 'Yesterday'; - if (diffDays < 7) return date.toLocaleDateString([], { weekday: 'short' }); - return date.toLocaleDateString([], { month: 'short', day: 'numeric' }); -} - -function PresenceDot({ presence }: { presence?: string }) { - if (!presence || presence === 'offline') return null; - return ( - - ); -} - -export default function RoomListItem({ room, onPress }: Props) { - const hasHighlight = room.highlightCount > 0; - const hasUnread = room.unreadCount > 0; - const displayName = room.name ?? room.id; - const initial = displayName[0]?.toUpperCase() ?? '?'; - - return ( - - {/* Avatar */} - - - {room.avatar ? ( - - ) : ( - {initial} - )} - - {room.isDirect && } - - - {/* Content */} - - - - {displayName} - - - {formatTime(room.lastMessageTime)} - - - - - - {room.lastMessage - ? (room.lastMessageSender && !room.isDirect - ? `${room.lastMessageSender.split(':')[0].slice(1)}: ` - : '') + room.lastMessage - : room.isEncrypted - ? '🔒 Encrypted' - : 'No messages'} - - - {/* Badge */} - {(hasUnread || hasHighlight) && ( - - - {hasHighlight - ? room.highlightCount - : room.unreadCount > 99 - ? '99+' - : room.unreadCount} - - - )} - - - - ); -} diff --git a/apps/matrix/apps/mobile/src/components/SyncStatusBar.tsx b/apps/matrix/apps/mobile/src/components/SyncStatusBar.tsx deleted file mode 100644 index 0d1f1f1b6..000000000 --- a/apps/matrix/apps/mobile/src/components/SyncStatusBar.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { View, Text } from 'react-native'; -import type { SyncState } from '~/src/matrix/types'; - -interface Props { - syncState: SyncState; -} - -const statusConfig: Record = { - STOPPED: { label: 'Disconnected', color: 'bg-destructive/80' }, - ERROR: { label: 'Connection error', color: 'bg-destructive/80' }, - RECONNECTING: { label: 'Reconnecting...', color: 'bg-yellow-500/80' }, - CATCHUP: { label: 'Catching up...', color: 'bg-yellow-500/80' }, - PREPARED: null, - SYNCING: null, -}; - -export default function SyncStatusBar({ syncState }: Props) { - const config = statusConfig[syncState]; - if (!config) return null; - - return ( - - {config.label} - - ); -} diff --git a/apps/matrix/apps/mobile/src/components/TypingIndicator.tsx b/apps/matrix/apps/mobile/src/components/TypingIndicator.tsx deleted file mode 100644 index 7828c94e2..000000000 --- a/apps/matrix/apps/mobile/src/components/TypingIndicator.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { View, Text } from 'react-native'; - -interface Props { - users: string[]; -} - -export default function TypingIndicator({ users }: Props) { - if (users.length === 0) return null; - - const label = - users.length === 1 - ? `${users[0]} is typing...` - : users.length === 2 - ? `${users[0]} and ${users[1]} are typing...` - : 'Several people are typing...'; - - return ( - - {label} - - ); -} diff --git a/apps/matrix/apps/mobile/src/components/UnreadSeparator.tsx b/apps/matrix/apps/mobile/src/components/UnreadSeparator.tsx deleted file mode 100644 index e439eaaa3..000000000 --- a/apps/matrix/apps/mobile/src/components/UnreadSeparator.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { View, Text } from 'react-native'; - -export default function UnreadSeparator() { - return ( - - - New messages - - - ); -} diff --git a/apps/matrix/apps/mobile/src/components/UserProfileModal.tsx b/apps/matrix/apps/mobile/src/components/UserProfileModal.tsx deleted file mode 100644 index 837a95f76..000000000 --- a/apps/matrix/apps/mobile/src/components/UserProfileModal.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { Modal, View, Text, Pressable, ActivityIndicator, ScrollView } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { Image } from 'expo-image'; -import { X, ChatCircle } from 'phosphor-react-native'; -import { useEffect, useState } from 'react'; -import { useMatrixStore } from '~/src/matrix/store'; -import { resolveMxcThumbnail } from '~/src/matrix/media'; -import { useRouter } from 'expo-router'; - -interface UserProfile { - userId: string; - displayName: string; - avatarUrl?: string; -} - -interface Props { - userId: string | null; - onClose: () => void; -} - -export default function UserProfileModal({ userId, onClose }: Props) { - const { client, credentials, rooms, selectRoom } = useMatrixStore(); - const router = useRouter(); - const [profile, setProfile] = useState(null); - const [loading, setLoading] = useState(false); - - useEffect(() => { - if (!userId || !client || !credentials) return; - setLoading(true); - setProfile(null); - - client - .getProfileInfo(userId) - .then((info) => { - const rawAvatar = info.avatar_url ?? null; - setProfile({ - userId, - displayName: info.displayname ?? userId.split(':')[0].slice(1), - avatarUrl: rawAvatar - ? (resolveMxcThumbnail(rawAvatar, credentials.homeserver, 160, 160) ?? undefined) - : undefined, - }); - }) - .catch(() => { - setProfile({ - userId, - displayName: userId.split(':')[0].slice(1), - }); - }) - .finally(() => setLoading(false)); - }, [userId]); - - // Find an existing DM room with this user - const existingDM = userId ? rooms.find((r) => r.isDirect && r.dmUserId === userId) : null; - - const handleStartDM = async () => { - if (!client || !userId || !credentials) return; - - if (existingDM) { - selectRoom(existingDM.id); - router.push(`/room/${existingDM.id}`); - onClose(); - return; - } - - try { - const room = await client.createRoom({ - is_direct: true, - invite: [userId], - preset: 'trusted_private_chat' as any, - }); - selectRoom(room.room_id); - router.push(`/room/${room.room_id}`); - onClose(); - } catch { - // ignore - } - }; - - const initial = profile?.displayName[0]?.toUpperCase() ?? '?'; - - return ( - - - - e.stopPropagation()}> - - {/* Handle */} - - - - - {/* Close */} - - - - - - - - {loading ? ( - - ) : profile ? ( - <> - {/* Avatar */} - - {profile.avatarUrl ? ( - - ) : ( - {initial} - )} - - - {/* Name */} - - - {profile.displayName} - - - {profile.userId} - - - - {/* Actions */} - {profile.userId !== credentials?.userId && ( - - - - {existingDM ? 'Open conversation' : 'Send message'} - - - )} - - ) : null} - - - - - - - ); -} diff --git a/apps/matrix/apps/mobile/src/components/VoiceMessage.tsx b/apps/matrix/apps/mobile/src/components/VoiceMessage.tsx deleted file mode 100644 index 945b8a7f4..000000000 --- a/apps/matrix/apps/mobile/src/components/VoiceMessage.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { useState, useCallback } from 'react'; -import { View, Text, Pressable, ActivityIndicator } from 'react-native'; -import { useAudioPlayer, useAudioPlayerStatus, setAudioModeAsync } from 'expo-audio'; -import { Play, Pause } from 'phosphor-react-native'; - -interface Props { - uri: string; - duration?: number; - isOwn: boolean; -} - -function formatDuration(ms: number): string { - const secs = Math.floor(ms / 1000); - const m = Math.floor(secs / 60); - const s = secs % 60; - return `${m}:${s.toString().padStart(2, '0')}`; -} - -export default function VoiceMessage({ uri, duration, isOwn }: Props) { - const player = useAudioPlayer(uri); - const status = useAudioPlayerStatus(player); - const [initialized, setInitialized] = useState(false); - - const currentTimeMs = (status.currentTime ?? 0) * 1000; - const durationMs = (status.duration ?? 0) * 1000 || duration || 0; - const playing = status.playing; - const progress = durationMs > 0 ? currentTimeMs / durationMs : 0; - - const handleToggle = useCallback(async () => { - if (!initialized) { - await setAudioModeAsync({ playsInSilentMode: true }); - setInitialized(true); - } - - if (playing) { - player.pause(); - } else { - player.play(); - } - }, [player, playing, initialized]); - - const iconColor = isOwn ? '#fff' : '#7c6bff'; - const barColor = isOwn ? 'rgba(255,255,255,0.5)' : '#2a2a2a'; - const fillColor = isOwn ? '#fff' : '#7c6bff'; - - return ( - - - {status.isBuffering ? ( - - ) : playing ? ( - - ) : ( - - )} - - - {/* Waveform / progress bar */} - - - - - - {formatDuration(playing || currentTimeMs > 0 ? currentTimeMs : durationMs)} - - - ); -} diff --git a/apps/matrix/apps/mobile/src/components/VoiceRecorder.tsx b/apps/matrix/apps/mobile/src/components/VoiceRecorder.tsx deleted file mode 100644 index 6ed38c28a..000000000 --- a/apps/matrix/apps/mobile/src/components/VoiceRecorder.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { useState, useEffect, useRef } from 'react'; -import { View, Text, Pressable, Animated, Alert } from 'react-native'; -import { - useAudioRecorder, - RecordingPresets, - requestRecordingPermissionsAsync, - setAudioModeAsync, -} from 'expo-audio'; -import { Trash, PaperPlaneRight } from 'phosphor-react-native'; - -interface Props { - onSend: (uri: string, durationMs: number) => Promise; - onCancel: () => void; -} - -export default function VoiceRecorder({ onSend, onCancel }: Props) { - const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY); - const [duration, setDuration] = useState(0); - const [sending, setSending] = useState(false); - const pulseAnim = useRef(new Animated.Value(1)).current; - const timerRef = useRef | null>(null); - - useEffect(() => { - startRecording(); - const pulse = Animated.loop( - Animated.sequence([ - Animated.timing(pulseAnim, { toValue: 1.3, duration: 600, useNativeDriver: true }), - Animated.timing(pulseAnim, { toValue: 1, duration: 600, useNativeDriver: true }), - ]) - ); - pulse.start(); - return () => { - pulse.stop(); - stopRecordingCleanup(); - }; - }, []); - - const startRecording = async () => { - try { - const { granted } = await requestRecordingPermissionsAsync(); - if (!granted) { - Alert.alert('Permission required', 'Microphone access is needed to record voice messages.'); - onCancel(); - return; - } - await setAudioModeAsync({ allowsRecording: true, playsInSilentMode: true }); - await recorder.prepareToRecordAsync(); - recorder.record(); - timerRef.current = setInterval(() => setDuration((d) => d + 1), 1000); - } catch (err) { - Alert.alert('Error', 'Could not start recording'); - onCancel(); - } - }; - - const stopRecordingCleanup = async () => { - if (timerRef.current) clearInterval(timerRef.current); - try { - await recorder.stop(); - } catch { - /* ignore */ - } - }; - - const handleSend = async () => { - if (sending) return; - setSending(true); - if (timerRef.current) clearInterval(timerRef.current); - try { - await recorder.stop(); - const uri = recorder.uri; - if (!uri) throw new Error('No recording URI'); - await onSend(uri, duration * 1000); - } catch (err) { - Alert.alert('Error', err instanceof Error ? err.message : 'Send failed'); - } finally { - setSending(false); - } - }; - - const handleDiscard = async () => { - await stopRecordingCleanup(); - onCancel(); - }; - - const formatDuration = (secs: number) => { - const m = Math.floor(secs / 60); - const s = secs % 60; - return `${m}:${s.toString().padStart(2, '0')}`; - }; - - return ( - - {/* Discard */} - - - - - {/* Recording indicator */} - - - {formatDuration(duration)} - Recording... - - - {/* Send */} - = 1 ? 'bg-primary' : 'bg-surface border border-border'} active:opacity-60`} - > - = 1 ? '#fff' : '#6b7280'} weight="fill" /> - - - ); -} diff --git a/apps/matrix/apps/mobile/src/matrix/client.ts b/apps/matrix/apps/mobile/src/matrix/client.ts deleted file mode 100644 index 9b99c8e06..000000000 --- a/apps/matrix/apps/mobile/src/matrix/client.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { MatrixCredentials, LoginResult } from './types'; - -function normalizeHomeserver(homeserver: string): string { - let url = homeserver.trim(); - if (!url.startsWith('http://') && !url.startsWith('https://')) { - url = `https://${url}`; - } - return url.replace(/\/$/, ''); -} - -export async function loginWithPassword( - homeserver: string, - username: string, - password: string, -): Promise { - await import('./polyfills'); - const { createClient } = await import('matrix-js-sdk'); - - const baseUrl = normalizeHomeserver(homeserver); - const tempClient = createClient({ baseUrl }); - - try { - const response = await tempClient.login('m.login.password', { - user: username, - password, - initial_device_display_name: 'Manalink Mobile', - }); - - return { - success: true, - credentials: { - homeserver: baseUrl, - accessToken: response.access_token, - userId: response.user_id, - deviceId: response.device_id, - }, - }; - } catch (err) { - const message = err instanceof Error ? err.message : 'Login failed'; - if (message.includes('M_FORBIDDEN')) return { success: false, error: 'Invalid username or password' }; - if (message.includes('M_USER_DEACTIVATED')) return { success: false, error: 'Account is deactivated' }; - if (message.includes('Failed to fetch') || message.includes('Network')) { - return { success: false, error: 'Could not connect to homeserver' }; - } - return { success: false, error: message }; - } -} - -export async function loginWithToken( - homeserver: string, - accessToken: string, - userId: string, - deviceId?: string, -): Promise { - const baseUrl = normalizeHomeserver(homeserver); - return { - success: true, - credentials: { - homeserver: baseUrl, - accessToken, - userId, - deviceId: deviceId ?? `MANALINK_${Date.now()}`, - }, - }; -} - -export async function checkHomeserver(homeserver: string): Promise<{ ok: boolean; error?: string }> { - const baseUrl = normalizeHomeserver(homeserver); - try { - const response = await fetch(`${baseUrl}/_matrix/client/versions`); - if (response.ok) return { ok: true }; - return { ok: false, error: `Server returned ${response.status}` }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : 'Could not connect' }; - } -} - -export async function discoverHomeserver(userIdOrDomain: string): Promise { - let domain = userIdOrDomain; - if (userIdOrDomain.startsWith('@')) { - const parts = userIdOrDomain.split(':'); - if (parts.length < 2) return null; - domain = parts[1]; - } - domain = domain.replace(/^https?:\/\//, ''); - - try { - const response = await fetch(`https://${domain}/.well-known/matrix/client`); - if (response.ok) { - const data = await response.json(); - const baseUrl = data['m.homeserver']?.base_url; - if (baseUrl) return baseUrl.replace(/\/$/, ''); - } - } catch { - // .well-known not available - } - - return `https://${domain}`; -} diff --git a/apps/matrix/apps/mobile/src/matrix/index.ts b/apps/matrix/apps/mobile/src/matrix/index.ts deleted file mode 100644 index b831d9e4b..000000000 --- a/apps/matrix/apps/mobile/src/matrix/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './types'; -export * from './client'; -export * from './media'; -export { useMatrixStore } from './store'; diff --git a/apps/matrix/apps/mobile/src/matrix/media.ts b/apps/matrix/apps/mobile/src/matrix/media.ts deleted file mode 100644 index a9a188ce4..000000000 --- a/apps/matrix/apps/mobile/src/matrix/media.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Resolve Matrix mxc:// URLs to HTTPS URLs for display. - * mxc://server/mediaId → https://server/_matrix/media/v3/download/server/mediaId - */ -export function resolveMxcUrl(mxcUrl: string, homeserverUrl: string): string | null { - if (!mxcUrl?.startsWith('mxc://')) return null; - - const withoutProtocol = mxcUrl.slice('mxc://'.length); - const slashIndex = withoutProtocol.indexOf('/'); - if (slashIndex === -1) return null; - - const server = withoutProtocol.slice(0, slashIndex); - const mediaId = withoutProtocol.slice(slashIndex + 1); - - // Use the homeserver as proxy (handles auth and federation) - const base = homeserverUrl.replace(/\/$/, ''); - return `${base}/_matrix/media/v3/download/${server}/${mediaId}`; -} - -/** - * Resolve mxc:// to a thumbnail URL via the homeserver. - */ -export function resolveMxcThumbnail( - mxcUrl: string, - homeserverUrl: string, - width = 96, - height = 96, - method: 'crop' | 'scale' = 'crop', -): string | null { - if (!mxcUrl?.startsWith('mxc://')) return null; - - const withoutProtocol = mxcUrl.slice('mxc://'.length); - const slashIndex = withoutProtocol.indexOf('/'); - if (slashIndex === -1) return null; - - const server = withoutProtocol.slice(0, slashIndex); - const mediaId = withoutProtocol.slice(slashIndex + 1); - - const base = homeserverUrl.replace(/\/$/, ''); - return `${base}/_matrix/media/v3/thumbnail/${server}/${mediaId}?width=${width}&height=${height}&method=${method}`; -} diff --git a/apps/matrix/apps/mobile/src/matrix/polyfills.ts b/apps/matrix/apps/mobile/src/matrix/polyfills.ts deleted file mode 100644 index c769201c5..000000000 --- a/apps/matrix/apps/mobile/src/matrix/polyfills.ts +++ /dev/null @@ -1,15 +0,0 @@ -// Polyfills required for matrix-js-sdk in React Native -import { Buffer } from 'buffer'; - -const g = globalThis as any; - -g.Buffer = Buffer; - -// process stub (Expo provides process.env but not all fields) -if (typeof g.process === 'undefined') { - g.process = { env: {}, nextTick: setImmediate }; -} else if (typeof g.process.nextTick === 'undefined') { - g.process.nextTick = setImmediate; -} - -export {}; diff --git a/apps/matrix/apps/mobile/src/matrix/store.ts b/apps/matrix/apps/mobile/src/matrix/store.ts deleted file mode 100644 index 8f1dafc5a..000000000 --- a/apps/matrix/apps/mobile/src/matrix/store.ts +++ /dev/null @@ -1,596 +0,0 @@ -import { create } from 'zustand'; -import * as SecureStore from 'expo-secure-store'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import type { MatrixClient, Room, MatrixEvent } from 'matrix-js-sdk'; -import { NotificationCountType } from 'matrix-js-sdk/lib/models/room'; -import type { - MatrixCredentials, - SimpleRoom, - SimpleMessage, - SyncState, - MessageType, - MessageReaction, - ReadReceipt, - RoomMember, -} from './types'; -import { resolveMxcThumbnail, resolveMxcUrl } from './media'; -import { uploadMedia } from './upload'; -import { showMessageNotification, setBadgeCount } from '../notifications'; - -const CREDENTIALS_KEY = 'manalink_credentials'; -const LAST_ROOM_KEY = 'manalink_last_room'; -const ROOMS_CACHE_KEY = 'manalink_rooms_cache'; - -interface MatrixState { - client: MatrixClient | null; - credentials: MatrixCredentials | null; - syncState: SyncState; - rooms: SimpleRoom[]; - invites: SimpleRoom[]; - currentRoomId: string | null; - messages: SimpleMessage[]; - firstUnreadEventId: string | null; - typingUsers: string[]; - roomMembers: RoomMember[]; - error: string | null; - isReady: boolean; - - initialize: (credentials: MatrixCredentials) => Promise; - restoreSession: () => Promise; - selectRoom: (roomId: string) => void; - loadRoomMembers: (roomId: string) => void; - sendMessage: (body: string, replyToEventId?: string) => Promise; - sendReaction: (eventId: string, key: string) => Promise; - redactMessage: (eventId: string) => Promise; - sendTyping: (typing: boolean) => Promise; - sendImage: ( - fileUri: string, - filename: string, - mimetype: string, - width?: number, - height?: number - ) => Promise; - sendFile: (fileUri: string, filename: string, mimetype: string) => Promise; - editMessage: (eventId: string, newBody: string) => Promise; - sendVoice: (fileUri: string, durationMs: number) => Promise; - forwardMessage: (eventId: string, targetRoomId: string) => Promise; - acceptInvite: (roomId: string) => Promise; - declineInvite: (roomId: string) => Promise; - leaveRoom: (roomId: string) => Promise; - logout: () => Promise; -} - -function roomToSimple(room: Room, userId: string, baseUrl: string): SimpleRoom { - const timeline = room.getLiveTimeline().getEvents(); - const lastMsg = timeline.findLast((e) => e.getType() === 'm.room.message'); - - const dmUserId = (() => { - const members = room.getJoinedMembers(); - if (members.length === 2) return members.find((m) => m.userId !== userId)?.userId; - return undefined; - })(); - - const rawAvatar = room.getMxcAvatarUrl?.() ?? null; - const avatar = rawAvatar - ? (resolveMxcThumbnail(rawAvatar, baseUrl, 96, 96) ?? undefined) - : undefined; - - return { - id: room.roomId, - name: room.name || room.roomId, - topic: room.currentState.getStateEvents('m.room.topic', '')?.getContent()?.topic, - avatar, - lastMessage: lastMsg?.getContent()?.body, - lastMessageSender: lastMsg?.getSender() ?? undefined, - lastMessageTime: room.getLastActiveTimestamp?.() ?? undefined, - unreadCount: room.getUnreadNotificationCount(NotificationCountType.Total) ?? 0, - highlightCount: room.getUnreadNotificationCount(NotificationCountType.Highlight) ?? 0, - isDirect: !!dmUserId, - isEncrypted: room.hasEncryptionStateEvent(), - memberCount: room.getJoinedMemberCount(), - membership: (room.getMyMembership() as SimpleRoom['membership']) ?? 'leave', - inviter: room.getDMInviter?.() ?? undefined, - dmUserId, - }; -} - -function eventToMessage( - event: MatrixEvent, - userId: string, - baseUrl: string, - room?: Room -): SimpleMessage | null { - if (event.getType() !== 'm.room.message') return null; - - const content = event.getContent(); - const msgtype = content.msgtype as MessageType; - - // Resolve media if present - let media = undefined; - if (['m.image', 'm.file', 'm.audio', 'm.video'].includes(msgtype) && content.url) { - const mxcUrl = content.url as string; - const isAudio = msgtype === 'm.audio'; - media = { - mxcUrl, - mimetype: content.info?.mimetype, - size: content.info?.size, - width: content.info?.w, - height: content.info?.h, - filename: content.body, - thumbnailUrl: isAudio - ? undefined - : (resolveMxcThumbnail(mxcUrl, baseUrl, 400, 300) ?? undefined), - downloadUrl: resolveMxcUrl(mxcUrl, baseUrl) ?? undefined, - duration: content.info?.duration, - }; - } - - // Resolve sender avatar - const senderMember = event.sender; - const rawSenderAvatar = senderMember?.getMxcAvatarUrl?.() ?? null; - const senderAvatar = rawSenderAvatar - ? (resolveMxcThumbnail(rawSenderAvatar, baseUrl, 64, 64) ?? undefined) - : undefined; - - // Reply-to - const replyRelation = content['m.relates_to']?.['m.in_reply_to']; - const replyToId: string | undefined = replyRelation?.event_id; - let replyToBody: string | undefined; - let replyToSenderName: string | undefined; - if (replyToId && room) { - const replyEvent = room.findEventById(replyToId); - if (replyEvent) { - replyToBody = replyEvent.getContent()?.body; - replyToSenderName = replyEvent.sender?.name ?? replyEvent.getSender() ?? undefined; - } - } - - // Reactions - let reactions: MessageReaction[] | undefined; - if (room) { - const eventId = event.getId(); - const reactionEvents = room - .getLiveTimeline() - .getEvents() - .filter( - (e) => - e.getType() === 'm.reaction' && - e.getContent()?.['m.relates_to']?.event_id === eventId && - e.getContent()?.['m.relates_to']?.rel_type === 'm.annotation' - ); - if (reactionEvents.length > 0) { - const grouped = new Map(); - for (const re of reactionEvents) { - const key = re.getContent()?.['m.relates_to']?.key as string; - if (!grouped.has(key)) grouped.set(key, { users: [], includesMe: false }); - const entry = grouped.get(key)!; - const sender = re.getSender() ?? ''; - entry.users.push(sender); - if (sender === userId) entry.includesMe = true; - } - reactions = Array.from(grouped.entries()).map(([key, { users, includesMe }]) => ({ - key, - count: users.length, - users, - includesMe, - })); - } - } - - // Read receipts - let readBy: ReadReceipt[] | undefined; - if (room) { - const eventId = event.getId(); - if (eventId) { - const receipts: ReadReceipt[] = []; - const members = room.getMembersWithMembership('join'); - for (const member of members) { - if (member.userId === userId) continue; - const readUpTo = (room as any).getEventReadUpTo?.(member.userId) as string | null; - if (readUpTo === eventId) { - receipts.push({ - userId: member.userId, - userName: member.name || member.userId, - timestamp: 0, - }); - } - } - if (receipts.length > 0) readBy = receipts; - } - } - - return { - id: event.getId() ?? `${event.getTs()}_${event.getSender()}`, - sender: event.getSender() ?? '', - senderName: senderMember?.name ?? event.getSender() ?? '', - senderAvatar, - body: content.body ?? '', - formattedBody: content.formatted_body, - timestamp: event.getTs(), - type: msgtype, - isOwn: event.getSender() === userId, - replyTo: replyToId, - replyToBody, - replyToSenderName, - edited: !!event.replacingEvent(), - redacted: event.isRedacted(), - media, - reactions, - readBy, - }; -} - -function buildSimpleRooms(client: MatrixClient, userId: string, baseUrl: string): SimpleRoom[] { - return client - .getRooms() - .filter((r) => r.getMyMembership() === 'join') - .map((r) => roomToSimple(r, userId, baseUrl)) - .sort((a, b) => (b.lastMessageTime ?? 0) - (a.lastMessageTime ?? 0)); -} - -function buildInvites(client: MatrixClient, userId: string, baseUrl: string): SimpleRoom[] { - return client - .getRooms() - .filter((r) => r.getMyMembership() === 'invite') - .map((r) => roomToSimple(r, userId, baseUrl)); -} - -function buildMessages(room: Room, userId: string, baseUrl: string): SimpleMessage[] { - return room - .getLiveTimeline() - .getEvents() - .map((e) => eventToMessage(e, userId, baseUrl, room)) - .filter((m): m is SimpleMessage => m !== null); -} - -export const useMatrixStore = create((set, get) => ({ - client: null, - credentials: null, - syncState: 'STOPPED', - rooms: [], - invites: [], - currentRoomId: null, - messages: [], - firstUnreadEventId: null, - typingUsers: [], - roomMembers: [], - error: null, - isReady: false, - - initialize: async (credentials: MatrixCredentials) => { - const existing = get().client; - if (existing) existing.stopClient(); - - await import('./polyfills'); - const { createClient } = await import('matrix-js-sdk'); - - const client = createClient({ - baseUrl: credentials.homeserver, - accessToken: credentials.accessToken, - userId: credentials.userId, - deviceId: credentials.deviceId, - }); - - await SecureStore.setItemAsync(CREDENTIALS_KEY, JSON.stringify(credentials)); - set({ client, credentials }); - - const { userId, homeserver: baseUrl } = credentials; - - // Load cached rooms immediately for fast startup - try { - const cached = await AsyncStorage.getItem(ROOMS_CACHE_KEY); - if (cached) set({ rooms: JSON.parse(cached) }); - } catch { - /* ignore cache errors */ - } - - const refresh = () => { - const rooms = buildSimpleRooms(client, userId, baseUrl); - const invites = buildInvites(client, userId, baseUrl); - set({ rooms, invites }); - // Update badge count - const totalUnread = rooms.reduce((n, r) => n + r.highlightCount, 0); - setBadgeCount(totalUnread).catch(() => {}); - // Persist rooms cache - AsyncStorage.setItem(ROOMS_CACHE_KEY, JSON.stringify(rooms)).catch(() => {}); - }; - - const refreshMessages = (room: Room) => { - const { currentRoomId } = get(); - if (room.roomId !== currentRoomId) return; - set({ messages: buildMessages(room, userId, baseUrl) }); - }; - - client.on('sync' as any, (state: SyncState) => { - set({ syncState: state }); - if (state === 'PREPARED' || state === 'SYNCING') { - refresh(); - set({ isReady: true, error: null }); - } - if (state === 'ERROR') set({ error: 'Sync error — reconnecting...' }); - }); - - client.on('Room.timeline' as any, (event: MatrixEvent, room: Room) => { - refresh(); - refreshMessages(room); - - // Foreground notification for incoming messages - const { currentRoomId } = get(); - if ( - event.getType() === 'm.room.message' && - event.getSender() !== userId && - room.roomId !== currentRoomId - ) { - const senderName = event.sender?.name ?? event.getSender() ?? 'Someone'; - const body = event.getContent()?.body ?? 'New message'; - showMessageNotification(senderName, room.name, body, room.roomId).catch(() => {}); - } - }); - - client.on('Room.redaction' as any, (_: unknown, room: Room) => { - refresh(); - refreshMessages(room); - }); - - client.on('Room.name' as any, () => refresh()); - client.on('RoomState.events' as any, () => refresh()); - client.on('Room.myMembership' as any, () => refresh()); - client.on('Room.receipt' as any, (_: unknown, room: Room) => { - refreshMessages(room); - }); - - client.on('RoomMember.typing' as any, (_: unknown, member: any) => { - const { currentRoomId } = get(); - if (!currentRoomId || member.roomId !== currentRoomId) return; - const room = client.getRoom(currentRoomId); - if (!room) return; - const typing = room - .getMembersWithMembership('join') - .filter((m: any) => m.typing && m.userId !== userId) - .map((m: any) => m.name || m.userId); - set({ typingUsers: typing }); - }); - - await client.startClient({ initialSyncLimit: 50 }); - }, - - restoreSession: async () => { - try { - const stored = await SecureStore.getItemAsync(CREDENTIALS_KEY); - if (!stored) return false; - const credentials: MatrixCredentials = JSON.parse(stored); - await get().initialize(credentials); - return true; - } catch { - return false; - } - }, - - selectRoom: (roomId: string) => { - const { client, credentials } = get(); - set({ - currentRoomId: roomId, - typingUsers: [], - messages: [], - roomMembers: [], - firstUnreadEventId: null, - }); - if (!client || !credentials) return; - - const room = client.getRoom(roomId); - if (!room) return; - - // Capture first unread event before marking as read - const { userId, homeserver: baseUrl } = credentials; - let firstUnreadEventId: string | null = null; - const unreadCount = room.getUnreadNotificationCount(NotificationCountType.Total) ?? 0; - if (unreadCount > 0) { - const lastReadEventId = (room as any).getEventReadUpTo?.(userId) as string | null; - if (lastReadEventId) { - const timeline = room.getLiveTimeline().getEvents(); - const lastReadIdx = timeline.findIndex((e) => e.getId() === lastReadEventId); - if (lastReadIdx >= 0) { - const firstUnread = timeline - .slice(lastReadIdx + 1) - .find((e) => e.getType() === 'm.room.message'); - firstUnreadEventId = firstUnread?.getId() ?? null; - } - } - } - - set({ messages: buildMessages(room, userId, baseUrl), firstUnreadEventId }); - - SecureStore.setItemAsync(LAST_ROOM_KEY, roomId).catch(() => {}); - - const lastEvent = room.getLiveTimeline().getEvents().at(-1); - if (lastEvent) client.sendReadReceipt(lastEvent).catch(() => {}); - }, - - loadRoomMembers: (roomId: string) => { - const { client, credentials } = get(); - if (!client || !credentials) return; - const room = client.getRoom(roomId); - if (!room) return; - - const members: RoomMember[] = room - .getMembersWithMembership('join') - .map((m: any) => { - const rawAvatar = m.getMxcAvatarUrl?.() ?? null; - return { - userId: m.userId, - displayName: m.name || m.userId, - avatarUrl: rawAvatar - ? (resolveMxcThumbnail(rawAvatar, credentials.homeserver, 64, 64) ?? undefined) - : undefined, - membership: 'join' as const, - powerLevel: m.powerLevel ?? 0, - }; - }) - .sort((a: RoomMember, b: RoomMember) => b.powerLevel - a.powerLevel); - - set({ roomMembers: members }); - }, - - sendMessage: async (body: string, replyToEventId?: string) => { - const { client, currentRoomId } = get(); - if (!client || !currentRoomId) return; - - if (replyToEventId) { - const room = client.getRoom(currentRoomId); - const replyEvent = room?.findEventById(replyToEventId); - if (replyEvent) { - await (client as any).sendMessage(currentRoomId, { - msgtype: 'm.text', - body, - 'm.relates_to': { - 'm.in_reply_to': { event_id: replyToEventId }, - }, - }); - return; - } - } - - await client.sendTextMessage(currentRoomId, body); - }, - - sendReaction: async (eventId: string, key: string) => { - const { client, currentRoomId } = get(); - if (!client || !currentRoomId) return; - await (client as any).sendEvent(currentRoomId, 'm.reaction', { - 'm.relates_to': { rel_type: 'm.annotation', event_id: eventId, key }, - }); - }, - - redactMessage: async (eventId: string) => { - const { client, currentRoomId } = get(); - if (!client || !currentRoomId) return; - await client.redactEvent(currentRoomId, eventId); - }, - - sendTyping: async (typing: boolean) => { - const { client, currentRoomId } = get(); - if (!client || !currentRoomId) return; - client.sendTyping(currentRoomId, typing, 4000).catch(() => {}); - }, - - sendImage: async (fileUri, filename, mimetype, width, height) => { - const { client, currentRoomId } = get(); - if (!client || !currentRoomId) return; - const uploaded = await uploadMedia(client, fileUri, filename, mimetype); - await (client as any).sendMessage(currentRoomId, { - msgtype: 'm.image', - body: filename, - url: uploaded.mxcUrl, - info: { - mimetype, - size: uploaded.size, - ...(width ? { w: width } : {}), - ...(height ? { h: height } : {}), - }, - }); - }, - - sendFile: async (fileUri, filename, mimetype) => { - const { client, currentRoomId } = get(); - if (!client || !currentRoomId) return; - const uploaded = await uploadMedia(client, fileUri, filename, mimetype); - await (client as any).sendMessage(currentRoomId, { - msgtype: 'm.file', - body: filename, - url: uploaded.mxcUrl, - info: { mimetype, size: uploaded.size }, - }); - }, - - editMessage: async (eventId: string, newBody: string) => { - const { client, currentRoomId } = get(); - if (!client || !currentRoomId) return; - await (client as any).sendMessage(currentRoomId, { - msgtype: 'm.text', - body: `* ${newBody}`, - 'm.new_content': { msgtype: 'm.text', body: newBody }, - 'm.relates_to': { rel_type: 'm.replace', event_id: eventId }, - }); - }, - - sendVoice: async (fileUri: string, durationMs: number) => { - const { client, currentRoomId } = get(); - if (!client || !currentRoomId) return; - const filename = `voice_${Date.now()}.m4a`; - const uploaded = await uploadMedia(client, fileUri, filename, 'audio/m4a'); - await (client as any).sendMessage(currentRoomId, { - msgtype: 'm.audio', - body: filename, - url: uploaded.mxcUrl, - info: { mimetype: 'audio/m4a', size: uploaded.size, duration: durationMs }, - }); - }, - - forwardMessage: async (eventId: string, targetRoomId: string) => { - const { client, currentRoomId } = get(); - if (!client || !currentRoomId) return; - const room = client.getRoom(currentRoomId); - const event = room?.findEventById(eventId); - if (!event) return; - const content = event.getContent(); - const msgtype = content.msgtype ?? 'm.text'; - // Forward as a fresh message (strip reply relations) - const forwarded: Record = { msgtype, body: content.body }; - if (content.url) forwarded.url = content.url; - if (content.info) forwarded.info = content.info; - if (content.formatted_body) { - forwarded.format = content.format; - forwarded.formatted_body = content.formatted_body; - } - await (client as any).sendMessage(targetRoomId, forwarded); - }, - - leaveRoom: async (roomId: string) => { - const { client } = get(); - if (!client) return; - await client.leave(roomId); - // If we left the current room, clear it - const { currentRoomId } = get(); - if (currentRoomId === roomId) { - set({ currentRoomId: null, messages: [], roomMembers: [] }); - } - }, - - acceptInvite: async (roomId: string) => { - const { client } = get(); - if (!client) return; - await client.joinRoom(roomId); - }, - - declineInvite: async (roomId: string) => { - const { client } = get(); - if (!client) return; - await client.leave(roomId); - }, - - logout: async () => { - const { client } = get(); - try { - await client?.logout(); - } catch { - // non-fatal - } - client?.stopClient(); - await SecureStore.deleteItemAsync(CREDENTIALS_KEY).catch(() => {}); - await SecureStore.deleteItemAsync(LAST_ROOM_KEY).catch(() => {}); - await AsyncStorage.removeItem(ROOMS_CACHE_KEY).catch(() => {}); - await setBadgeCount(0).catch(() => {}); - set({ - client: null, - credentials: null, - syncState: 'STOPPED', - rooms: [], - invites: [], - currentRoomId: null, - messages: [], - firstUnreadEventId: null, - error: null, - isReady: false, - }); - }, -})); diff --git a/apps/matrix/apps/mobile/src/matrix/types.ts b/apps/matrix/apps/mobile/src/matrix/types.ts deleted file mode 100644 index 3f80f26b4..000000000 --- a/apps/matrix/apps/mobile/src/matrix/types.ts +++ /dev/null @@ -1,91 +0,0 @@ -export type SyncState = 'STOPPED' | 'PREPARED' | 'SYNCING' | 'ERROR' | 'RECONNECTING' | 'CATCHUP'; - -export interface MatrixCredentials { - homeserver: string; - accessToken: string; - userId: string; - deviceId: string; -} - -export interface LoginResult { - success: boolean; - credentials?: MatrixCredentials; - error?: string; -} - -export type PresenceState = 'online' | 'offline' | 'unavailable'; -export type RoomMembership = 'join' | 'invite' | 'leave' | 'ban' | 'knock'; -export type MessageType = 'm.text' | 'm.image' | 'm.file' | 'm.audio' | 'm.video' | 'm.emote' | 'm.notice'; - -export interface SimpleRoom { - id: string; - name: string; - topic?: string; - avatar?: string; - lastMessage?: string; - lastMessageSender?: string; - lastMessageTime?: number; - unreadCount: number; - highlightCount: number; - isDirect: boolean; - isEncrypted: boolean; - memberCount: number; - membership: RoomMembership; - inviter?: string; - dmUserId?: string; - presence?: PresenceState; - lastActiveAgo?: number; -} - -export interface MediaInfo { - mxcUrl: string; - mimetype?: string; - size?: number; - width?: number; - height?: number; - filename?: string; - thumbnailUrl?: string; - downloadUrl?: string; - duration?: number; -} - -export interface MessageReaction { - key: string; - count: number; - users: string[]; - includesMe: boolean; -} - -export interface ReadReceipt { - userId: string; - userName: string; - timestamp: number; -} - -export interface SimpleMessage { - id: string; - sender: string; - senderName: string; - senderAvatar?: string; - body: string; - formattedBody?: string; - timestamp: number; - type: MessageType; - isOwn: boolean; - replyTo?: string; - replyToBody?: string; - replyToSenderName?: string; - edited?: boolean; - redacted?: boolean; - media?: MediaInfo; - reactions?: MessageReaction[]; - readBy?: ReadReceipt[]; -} - -export interface RoomMember { - userId: string; - displayName: string; - avatarUrl?: string; - membership: 'join' | 'invite' | 'leave' | 'ban' | 'knock'; - powerLevel: number; -} diff --git a/apps/matrix/apps/mobile/src/matrix/upload.ts b/apps/matrix/apps/mobile/src/matrix/upload.ts deleted file mode 100644 index 40c7eb046..000000000 --- a/apps/matrix/apps/mobile/src/matrix/upload.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { MatrixClient } from 'matrix-js-sdk'; - -export interface UploadResult { - mxcUrl: string; - mimetype: string; - size: number; - width?: number; - height?: number; - filename?: string; -} - -/** - * Upload a local file URI to the Matrix homeserver. - * Returns the mxc:// URL and metadata. - */ -export async function uploadMedia( - client: MatrixClient, - fileUri: string, - filename: string, - mimetype: string, -): Promise { - // Fetch the file as a blob - const response = await fetch(fileUri); - const blob = await response.blob(); - - // Use the matrix-js-sdk upload endpoint - const uploadResponse = await (client as any).uploadContent(blob, { - name: filename, - type: mimetype, - rawResponse: false, - }); - - const mxcUrl: string = uploadResponse?.content_uri ?? uploadResponse; - - return { - mxcUrl, - mimetype, - size: blob.size, - filename, - }; -} - -export function getMimetypeFromFilename(filename: string): string { - const ext = filename.split('.').pop()?.toLowerCase() ?? ''; - const map: Record = { - jpg: 'image/jpeg', - jpeg: 'image/jpeg', - png: 'image/png', - gif: 'image/gif', - webp: 'image/webp', - heic: 'image/heic', - mp4: 'video/mp4', - mov: 'video/quicktime', - mp3: 'audio/mpeg', - m4a: 'audio/mp4', - ogg: 'audio/ogg', - pdf: 'application/pdf', - doc: 'application/msword', - docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - zip: 'application/zip', - }; - return map[ext] ?? 'application/octet-stream'; -} diff --git a/apps/matrix/apps/mobile/src/notifications/index.ts b/apps/matrix/apps/mobile/src/notifications/index.ts deleted file mode 100644 index b41647985..000000000 --- a/apps/matrix/apps/mobile/src/notifications/index.ts +++ /dev/null @@ -1,107 +0,0 @@ -import * as Notifications from 'expo-notifications'; -import { router } from 'expo-router'; -import type { MatrixClient } from 'matrix-js-sdk'; - -// Show notifications even when app is in foreground -Notifications.setNotificationHandler({ - handleNotification: async () => ({ - shouldShowAlert: true, - shouldPlaySound: true, - shouldSetBadge: true, - shouldShowBanner: true, - shouldShowList: true, - }), -}); - -export async function requestNotificationPermissions(): Promise { - const { status: existing } = await Notifications.getPermissionsAsync(); - if (existing === 'granted') return true; - const { status } = await Notifications.requestPermissionsAsync(); - return status === 'granted'; -} - -/** - * Get the Expo push token for this device. - * projectId from app.json extra.eas.projectId. - */ -export async function getExpoPushToken(projectId?: string): Promise { - try { - const token = await Notifications.getExpoPushTokenAsync( - projectId ? { projectId } : undefined, - ); - return token.data; - } catch { - return null; - } -} - -/** - * Register a Matrix HTTP pusher pointing to the Expo push proxy. - * This requires a compatible Matrix push gateway (e.g. a custom proxy or sygnal). - * For development, this is optional — sync keeps the app connected. - */ -export async function registerMatrixPusher( - client: MatrixClient, - pushToken: string, - appId: string, - appDisplayName: string, - deviceDisplayName: string, - pushGatewayUrl: string, -): Promise { - await (client as any).setPusher({ - pushkey: pushToken, - kind: 'http', - app_id: appId, - app_display_name: appDisplayName, - device_display_name: deviceDisplayName, - lang: 'en', - data: { - url: `${pushGatewayUrl}/_matrix/push/v1/notify`, - format: 'event_id_only', - }, - }); -} - -/** - * Display a local notification for an incoming message. - * Called from the Matrix sync event handler for messages - * while the app is in the foreground. - */ -export async function showMessageNotification( - senderName: string, - roomName: string, - body: string, - roomId: string, -): Promise { - await Notifications.scheduleNotificationAsync({ - content: { - title: `${senderName} in ${roomName}`, - body, - data: { roomId }, - sound: true, - }, - trigger: null, // fire immediately - }); -} - -/** - * Set the app badge count. - */ -export async function setBadgeCount(count: number): Promise { - await Notifications.setBadgeCountAsync(count); -} - -/** - * Listen for notification taps and navigate to the room. - * Returns a cleanup function. - */ -export function setupNotificationNavigation(): () => void { - const subscription = Notifications.addNotificationResponseReceivedListener((response) => { - const roomId = response.notification.request.content.data?.roomId as string | undefined; - if (roomId) { - router.push(`/room/${roomId}`); - } - }); - - return () => subscription.remove(); -} diff --git a/apps/matrix/apps/mobile/tailwind.config.js b/apps/matrix/apps/mobile/tailwind.config.js deleted file mode 100644 index cd7c4ef81..000000000 --- a/apps/matrix/apps/mobile/tailwind.config.js +++ /dev/null @@ -1,21 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -module.exports = { - content: ['./app/**/*.{js,ts,tsx}', './src/**/*.{js,ts,tsx}'], - presets: [require('nativewind/preset')], - theme: { - extend: { - colors: { - background: '#0f0f0f', - surface: '#1a1a1a', - border: '#2a2a2a', - primary: '#7c6bff', - 'primary-foreground': '#ffffff', - muted: '#6b7280', - foreground: '#f9fafb', - 'muted-foreground': '#9ca3af', - destructive: '#ef4444', - }, - }, - }, - plugins: [], -}; diff --git a/apps/matrix/apps/mobile/tsconfig.json b/apps/matrix/apps/mobile/tsconfig.json deleted file mode 100644 index 7bef4d58e..000000000 --- a/apps/matrix/apps/mobile/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "extends": "expo/tsconfig.base", - "compilerOptions": { - "strict": true, - "jsx": "react-jsx", - "baseUrl": ".", - "paths": { - "~/*": ["./*"] - } - }, - "include": [ - "**/*.ts", - "**/*.tsx", - ".expo/types/**/*.ts", - "expo-env.d.ts", - "nativewind-env.d.ts" - ] -} diff --git a/apps/matrix/apps/web/Dockerfile b/apps/matrix/apps/web/Dockerfile deleted file mode 100644 index ec6bf4df0..000000000 --- a/apps/matrix/apps/web/Dockerfile +++ /dev/null @@ -1,32 +0,0 @@ -# syntax=docker/dockerfile:1 -FROM sveltekit-base:local AS builder - -ARG PUBLIC_BACKEND_URL=http://mana-auth -ARG PUBLIC_MANA_AUTH_URL=http://mana-auth:3001 -ENV PUBLIC_BACKEND_URL=$PUBLIC_BACKEND_URL -ENV PUBLIC_MANA_AUTH_URL=$PUBLIC_MANA_AUTH_URL - -COPY apps/matrix/packages/shared ./apps/matrix/packages/shared -COPY apps/matrix/apps/web ./apps/matrix/apps/web - -RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \ - pnpm install --no-frozen-lockfile --ignore-scripts - -WORKDIR /app/apps/matrix/apps/web -RUN pnpm exec svelte-kit sync -RUN NODE_OPTIONS="--max-old-space-size=4096" pnpm build - -FROM node:20-alpine AS production -WORKDIR /app/apps/matrix/apps/web -COPY --from=builder /app/node_modules/.pnpm /app/node_modules/.pnpm -COPY --from=builder /app/apps/matrix/apps/web/node_modules ./node_modules -COPY --from=builder /app/apps/matrix/apps/web/build ./build -COPY --from=builder /app/apps/matrix/apps/web/package.json ./ - -EXPOSE 5024 -ENV NODE_ENV=production PORT=5024 HOST=0.0.0.0 - -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:5024/health || exit 1 - -CMD ["node", "build"] diff --git a/apps/matrix/apps/web/package.json b/apps/matrix/apps/web/package.json deleted file mode 100644 index 58e8d4940..000000000 --- a/apps/matrix/apps/web/package.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "name": "@matrix/web", - "version": "0.2.0", - "private": true, - "type": "module", - "scripts": { - "dev": "vite dev", - "build": "vite build", - "preview": "vite preview", - "prepare": "svelte-kit sync || echo ''", - "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", - "type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "format": "prettier --write .", - "lint": "eslint .", - "test": "vitest run", - "test:watch": "vitest" - }, - "devDependencies": { - "@mana/shared-pwa": "workspace:*", - "@sveltejs/adapter-node": "^5.2.12", - "@sveltejs/kit": "^2.47.1", - "@sveltejs/vite-plugin-svelte": "^5.0.3", - "@tailwindcss/vite": "^4.1.7", - "@types/node": "^22.15.21", - "@vite-pwa/sveltekit": "^1.1.0", - "prettier": "^3.5.3", - "prettier-plugin-svelte": "^3.4.0", - "sharp": "^0.33.5", - "svelte": "^5.41.0", - "svelte-check": "^4.3.3", - "tailwindcss": "^4.1.7", - "tslib": "^2.8.1", - "typescript": "^5.9.3", - "vite": "^6.3.5", - "vite-plugin-pwa": "^1.2.0", - "vitest": "^4.1.2", - "workbox-window": "^7.4.0" - }, - "dependencies": { - "@mana/shared-auth": "workspace:*", - "@mana/shared-branding": "workspace:*", - "@mana/shared-error-tracking": "workspace:*", - "@mana/feedback": "workspace:*", - "@mana/help": "workspace:*", - "@mana/shared-i18n": "workspace:*", - "@mana/shared-icons": "workspace:*", - "@mana/shared-tailwind": "workspace:*", - "@mana/shared-theme": "workspace:*", - "@mana/shared-stores": "workspace:*", - "@mana/shared-tags": "workspace:*", - "@mana/shared-ui": "workspace:*", - "@matrix-org/matrix-sdk-crypto-wasm": "^13.0.0", - "buffer": "^6.0.3", - "date-fns": "^4.1.0", - "events": "^3.3.0", - "matrix-js-sdk": "^37.1.0", - "svelte-i18n": "^4.0.1" - } -} diff --git a/apps/matrix/apps/web/scripts/generate-icons.mjs b/apps/matrix/apps/web/scripts/generate-icons.mjs deleted file mode 100644 index 4a0e0f23d..000000000 --- a/apps/matrix/apps/web/scripts/generate-icons.mjs +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env node -/** - * Generate PWA icons from SVG favicon - * Run: node scripts/generate-icons.mjs - * Requires: npm install -D sharp - */ - -import { readFileSync, writeFileSync } from 'fs'; -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const staticDir = join(__dirname, '..', 'static'); - -// Icon sizes to generate -const sizes = [ - { name: 'favicon.png', size: 32 }, - { name: 'pwa-192x192.png', size: 192 }, - { name: 'pwa-512x512.png', size: 512 }, - { name: 'apple-touch-icon.png', size: 180 }, -]; - -async function generateIcons() { - try { - const sharp = (await import('sharp')).default; - const svgPath = join(staticDir, 'favicon.svg'); - const svgBuffer = readFileSync(svgPath); - - for (const { name, size } of sizes) { - const outputPath = join(staticDir, name); - await sharp(svgBuffer).resize(size, size).png().toFile(outputPath); - console.log(`Generated: ${name} (${size}x${size})`); - } - - console.log('\nAll icons generated successfully!'); - } catch (error) { - if (error.code === 'ERR_MODULE_NOT_FOUND') { - console.error('Sharp is not installed. Run: pnpm add -D sharp'); - console.log('\nAlternatively, use an online tool to convert the SVG:'); - console.log('1. Open static/favicon.svg in a browser'); - console.log('2. Use https://realfavicongenerator.net/ to generate icons'); - console.log('3. Replace the placeholder PNGs in static/'); - } else { - console.error('Error generating icons:', error); - } - process.exit(1); - } -} - -generateIcons(); diff --git a/apps/matrix/apps/web/src/app.css b/apps/matrix/apps/web/src/app.css deleted file mode 100644 index 6c38be0dd..000000000 --- a/apps/matrix/apps/web/src/app.css +++ /dev/null @@ -1,127 +0,0 @@ -@import 'tailwindcss'; -@import '@mana/shared-tailwind/themes.css'; - -/* Scan shared packages for Tailwind classes */ -@source '../../../packages/shared/src'; -@source '../../../../../packages/shared-ui/src'; -@source '../../../../../packages/shared-icons/src'; -@source '../../../../../packages/shared-auth-ui/src'; -@source '../../../../../packages/shared-theme-ui/src'; -@source '../../../../../packages/shared-branding/src'; - -@layer base { - :root { - --spacing-xs: 0.25rem; - --spacing-sm: 0.5rem; - --spacing-md: 1rem; - --spacing-lg: 1.5rem; - --spacing-xl: 2rem; - } - - body { - @apply bg-background text-foreground; - } -} - -/* Fade-in animation */ -@keyframes fade-in { - from { - opacity: 0; - transform: translateY(8px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.animate-fade-in { - animation: fade-in 0.3s ease-out; -} - -/* Slide-up animation for bottom sheets */ -@keyframes slide-up { - from { - transform: translateY(100%); - } - to { - transform: translateY(0); - } -} - -.animate-slide-up { - animation: slide-up 0.25s ease-out; -} - -/* Custom scrollbar for chat */ -.chat-scrollbar::-webkit-scrollbar { - width: 6px; -} - -.chat-scrollbar::-webkit-scrollbar-track { - background: transparent; -} - -.chat-scrollbar::-webkit-scrollbar-thumb { - background: rgba(0, 0, 0, 0.15); - border-radius: 3px; -} - -.chat-scrollbar::-webkit-scrollbar-thumb:hover { - background: rgba(0, 0, 0, 0.25); -} - -.dark .chat-scrollbar::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.15); -} - -.dark .chat-scrollbar::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.25); -} - -/* Elevation utilities - semantic surface styles */ -.glass { - @apply bg-surface-elevated border border-border; -} - -.glass-card { - @apply bg-surface border border-border; -} - -.glass-button { - @apply bg-surface border border-border hover:bg-surface-hover hover:shadow-lg transition-all duration-200; -} - -/* iOS Safe Area Insets for PWA */ -.safe-area-top { - padding-top: env(safe-area-inset-top, 0px); -} - -.safe-area-bottom { - padding-bottom: env(safe-area-inset-bottom, 0px); -} - -.safe-area-left { - padding-left: env(safe-area-inset-left, 0px); -} - -.safe-area-right { - padding-right: env(safe-area-inset-right, 0px); -} - -.safe-area-x { - padding-left: env(safe-area-inset-left, 0px); - padding-right: env(safe-area-inset-right, 0px); -} - -.safe-area-y { - padding-top: env(safe-area-inset-top, 0px); - padding-bottom: env(safe-area-inset-bottom, 0px); -} - -.safe-area-all { - padding-top: env(safe-area-inset-top, 0px); - padding-bottom: env(safe-area-inset-bottom, 0px); - padding-left: env(safe-area-inset-left, 0px); - padding-right: env(safe-area-inset-right, 0px); -} diff --git a/apps/matrix/apps/web/src/app.d.ts b/apps/matrix/apps/web/src/app.d.ts deleted file mode 100644 index db149168c..000000000 --- a/apps/matrix/apps/web/src/app.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -declare const __BUILD_HASH__: string; -declare const __BUILD_TIME__: string; - -// See https://svelte.dev/docs/kit/types#app.d.ts -// for information about these interfaces -declare global { - namespace App { - // interface Error {} - // interface Locals {} - // interface PageData {} - // interface PageState {} - // interface Platform {} - } - - // Polyfills for matrix-js-sdk - interface Window { - global: typeof globalThis; - Buffer: typeof import('buffer').Buffer; - process: { env: Record }; - } -} - -export {}; diff --git a/apps/matrix/apps/web/src/app.html b/apps/matrix/apps/web/src/app.html deleted file mode 100644 index cbb299c88..000000000 --- a/apps/matrix/apps/web/src/app.html +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - Manalink - %sveltekit.head% - - -

    - - diff --git a/apps/matrix/apps/web/src/hooks.client.ts b/apps/matrix/apps/web/src/hooks.client.ts deleted file mode 100644 index a7b632d03..000000000 --- a/apps/matrix/apps/web/src/hooks.client.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { initErrorTracking, handleSvelteError } from '@mana/shared-error-tracking/browser'; -import type { HandleClientError } from '@sveltejs/kit'; - -initErrorTracking({ - serviceName: 'matrix-web', - dsn: (window as any).__PUBLIC_GLITCHTIP_DSN__, - environment: import.meta.env.MODE, -}); - -export const handleError: HandleClientError = ({ error }) => { - handleSvelteError(error); -}; diff --git a/apps/matrix/apps/web/src/hooks.server.ts b/apps/matrix/apps/web/src/hooks.server.ts deleted file mode 100644 index 7351c9016..000000000 --- a/apps/matrix/apps/web/src/hooks.server.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { Handle } from '@sveltejs/kit'; - -export const handle: Handle = async ({ event, resolve }) => { - const response = await resolve(event); - - response.headers.set('X-Frame-Options', 'SAMEORIGIN'); - response.headers.set('X-Content-Type-Options', 'nosniff'); - response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); - response.headers.set('Permissions-Policy', 'camera=(self), microphone=(self), geolocation=()'); - // COEP/COOP required for WASM (matrix-sdk-crypto) - response.headers.set('Cross-Origin-Opener-Policy', 'same-origin'); - response.headers.set('Cross-Origin-Embedder-Policy', 'require-corp'); - - return response; -}; diff --git a/apps/matrix/apps/web/src/lib/components/bots/BotCard.svelte b/apps/matrix/apps/web/src/lib/components/bots/BotCard.svelte deleted file mode 100644 index 5f9fcee30..000000000 --- a/apps/matrix/apps/web/src/lib/components/bots/BotCard.svelte +++ /dev/null @@ -1,166 +0,0 @@ - - -
    - - - - - {#if expanded} -
    - - {#if bot.longDescription} -

    {bot.longDescription}

    - {/if} - - -
    -

    {$t('bots.commands')}

    -
    - {#each bot.commands as cmd} -
    - {cmd.command} - {#if cmd.aliases?.length} - ({cmd.aliases.join(', ')}) - {/if} - - {cmd.description} - {#if cmd.example} -
    - {$t('bots.example')}: {cmd.example} -
    - {/if} -
    - {/each} -
    -
    - - -
    - Matrix: - {bot.matrixUserId} -
    - - - -
    - {/if} -
    diff --git a/apps/matrix/apps/web/src/lib/components/call/CallView.svelte b/apps/matrix/apps/web/src/lib/components/call/CallView.svelte deleted file mode 100644 index b4c4ad4ac..000000000 --- a/apps/matrix/apps/web/src/lib/components/call/CallView.svelte +++ /dev/null @@ -1,224 +0,0 @@ - - -
    - -
    -
    - {#if call.opponentAvatar} - {call.opponentName} - {:else} -
    - -
    - {/if} -
    -

    {call.opponentName || 'Unbekannt'}

    -

    - {call.type === 'video' ? 'Videoanruf' : 'Sprachanruf'} · {getStateText(call.state)} - {#if call.isScreenSharing} - - - Bildschirmfreigabe - - {/if} -

    -
    -
    -
    - - -
    - {#if call.type === 'video'} - - - - - -
    - -
    - {:else} - -
    - {#if call.opponentAvatar} - {call.opponentName} - {:else} -
    - -
    - {/if} -

    {call.opponentName || 'Unbekannt'}

    -

    {getStateText(call.state)}

    -
    - {/if} -
    - - -
    - - - - - {#if call.type === 'video'} - - - - - {/if} - - - -
    -
    diff --git a/apps/matrix/apps/web/src/lib/components/call/IncomingCallDialog.svelte b/apps/matrix/apps/web/src/lib/components/call/IncomingCallDialog.svelte deleted file mode 100644 index 069a5e99f..000000000 --- a/apps/matrix/apps/web/src/lib/components/call/IncomingCallDialog.svelte +++ /dev/null @@ -1,123 +0,0 @@ - - -
    -
    - -
    - {#if call.opponentAvatar} - {call.opponentName} - {:else} -
    - -
    - {/if} -

    {call.opponentName || 'Unbekannt'}

    -

    - {call.type === 'video' ? 'Eingehender Videoanruf' : 'Eingehender Sprachanruf'} -

    -
    - - -
    - {#if call.type === 'video'} - - Video - {:else} - - Audio - {/if} -
    - - -
    - - - - - -
    -
    -
    - - diff --git a/apps/matrix/apps/web/src/lib/components/call/index.ts b/apps/matrix/apps/web/src/lib/components/call/index.ts deleted file mode 100644 index 2245f06a3..000000000 --- a/apps/matrix/apps/web/src/lib/components/call/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as CallView } from './CallView.svelte'; -export { default as IncomingCallDialog } from './IncomingCallDialog.svelte'; diff --git a/apps/matrix/apps/web/src/lib/components/chat/CreateRoomDialog.svelte b/apps/matrix/apps/web/src/lib/components/chat/CreateRoomDialog.svelte deleted file mode 100644 index 4fa6e9a17..000000000 --- a/apps/matrix/apps/web/src/lib/components/chat/CreateRoomDialog.svelte +++ /dev/null @@ -1,313 +0,0 @@ - - -{#if open} - -
    - -
    e.stopPropagation()} - role="dialog" - aria-modal="true" - > - -
    -

    Neuer Chat

    - -
    - - -
    - -
    - - -
    - - - {#if !isDirect} -
    - - -
    - -
    - - -
    - - -
    - - {#if isPrivate} - - Privater Raum - {:else} - - Öffentlicher Raum - {/if} - - -
    -

    - {isPrivate - ? 'Nur eingeladene Benutzer können beitreten' - : 'Jeder kann diesen Raum finden und beitreten'} -

    - {/if} - - -
    - -
    - - {#if searching} - - {/if} -
    - - - {#if searchResults.length > 0} -
    - {#each searchResults as user} - - {/each} -
    - {/if} -
    - - - {#if selectedUsers.length > 0} -
    - {#each selectedUsers as user} - - {user.displayName || user.userId} - - - {/each} -
    - {/if} - - - {#if error} -
    - {error} -
    - {/if} -
    - - -
    - - -
    -
    -
    -{/if} diff --git a/apps/matrix/apps/web/src/lib/components/chat/DropZoneOverlay.svelte b/apps/matrix/apps/web/src/lib/components/chat/DropZoneOverlay.svelte deleted file mode 100644 index d6d5c2622..000000000 --- a/apps/matrix/apps/web/src/lib/components/chat/DropZoneOverlay.svelte +++ /dev/null @@ -1,27 +0,0 @@ - - -{#if visible} -
    -
    -
    - -
    -
    -

    Datei hier ablegen

    -

    Bilder, Videos oder Dateien

    -
    -
    -
    -{/if} diff --git a/apps/matrix/apps/web/src/lib/components/chat/ForwardMessageDialog.svelte b/apps/matrix/apps/web/src/lib/components/chat/ForwardMessageDialog.svelte deleted file mode 100644 index e71940ff8..000000000 --- a/apps/matrix/apps/web/src/lib/components/chat/ForwardMessageDialog.svelte +++ /dev/null @@ -1,179 +0,0 @@ - - -{#if open && message} - - -{/if} diff --git a/apps/matrix/apps/web/src/lib/components/chat/Message.svelte b/apps/matrix/apps/web/src/lib/components/chat/Message.svelte deleted file mode 100644 index 6e64baf8e..000000000 --- a/apps/matrix/apps/web/src/lib/components/chat/Message.svelte +++ /dev/null @@ -1,845 +0,0 @@ - - - -{#if showTimestamp} -
    -
    - {formattedDate()} -
    -
    -{/if} - - -
    (showActions = true)} - onmouseleave={() => (showActions = false)} - ontouchstart={handleTouchStart} - ontouchmove={handleTouchMove} - ontouchend={handleTouchEnd} - ontouchcancel={handleTouchEnd} -> - - {#if showAvatar} -
    - {initials} -
    - {:else} -
    - {/if} - - -
    - - {#if showAvatar && !message.isOwn} - {message.senderName} - {/if} - - - {#if message.replyTo && message.replyToBody} -
    - - {message.replyToBody} -
    - {/if} - - -
    - {#if message.redacted} -

    Nachricht wurde gelöscht

    - {:else if isDecryptionError} - -
    - - Kann nicht entschlüsselt werden -
    - {:else if message.type === 'm.image' && thumbnailUrl} - -
    - {#if imageLoading} -
    - -
    - {/if} - {#if imageError} -
    -

    Bild konnte nicht geladen werden

    -
    - {:else} - {message.body} (imageLoading = false)} - onerror={() => { - imageLoading = false; - imageError = true; - }} - onclick={() => mediaUrl && window.open(mediaUrl, '_blank')} - /> - {/if} -
    - {:else if message.type === 'm.video' && thumbnailUrl} - -
    -
    - {message.body} -
    - -
    -
    - {#if message.media?.duration} - - {Math.floor(message.media.duration / 60)}:{(message.media.duration % 60) - .toString() - .padStart(2, '0')} - - {/if} -
    - {:else if message.type === 'm.audio'} - -
    - - {#if mediaUrl} - - {/if} - - - - - -
    - - - -
    - {formatAudioTime(audioProgress)} - {formatAudioTime(audioDuration || message.media?.duration || 0)} -
    -
    -
    - {:else if message.type === 'm.file'} - -
    -
    - -
    -
    -

    {message.media?.filename || message.body}

    -

    - {formatFileSize(message.media?.size)} - {#if message.media?.mimetype} - • {message.media.mimetype.split('/')[1]?.toUpperCase()} - {/if} -

    -
    - -
    - {:else if message.type === 'm.emote'} -

    - * {message.senderName} - {message.body} -

    - {:else if message.type === 'm.notice'} -

    - {message.body} -

    - {:else} - -

    - {@html formatMessageBody(message.body, message.isOwn)} -

    - - - {#if firstUrl()} - - ((e.currentTarget as HTMLImageElement).style.display = 'none')} - /> - - {getDomain(firstUrl() || '')} - - - {/if} - {/if} - - {#if message.edited} - (bearbeitet) - {/if} - - {#if showEncryptionBadge} - - {/if} -
    - - - {#if message.reactions && message.reactions.length > 0} -
    - {#each message.reactions as reaction} - - {/each} -
    - {/if} - - -
    - {formattedTime()} - - {#if message.isOwn} - {#if message.readBy && message.readBy.length > 0} - - - - {:else} - - - - {/if} - {/if} -
    - - - {#if showActions && !message.redacted} -
    - -
    - - {#if showEmojiPicker} - - - -
    - {#if showFullPicker} - -
    - -
    - {#each emojiCategories as category, i} - - {/each} -
    - -
    - {#each emojiCategories[selectedCategory].emojis as emoji} - - {/each} -
    -
    - {:else} - -
    - {#each quickEmojis as emoji} - - {/each} - - -
    - {/if} -
    - {/if} -
    - - - {#if message.isOwn && message.type === 'm.text'} - - {/if} - {#if message.isOwn} - - {/if} -
    - {/if} -
    -
    - - -{#if showMobileActions} - -
    - -
    - {#each quickEmojis as emoji} - - {/each} -
    - -
    - - -
    - - - {#if message.isOwn && message.type === 'm.text'} - - {/if} - {#if message.isOwn} - - {/if} -
    -
    -{/if} diff --git a/apps/matrix/apps/web/src/lib/components/chat/MessageInput.svelte b/apps/matrix/apps/web/src/lib/components/chat/MessageInput.svelte deleted file mode 100644 index 9d2da49cb..000000000 --- a/apps/matrix/apps/web/src/lib/components/chat/MessageInput.svelte +++ /dev/null @@ -1,846 +0,0 @@ - - -
    - - {#if replyTo || editMessage} -
    -
    - {#if editMessage} -

    Nachricht bearbeiten

    -

    {editMessage.body}

    - {:else if replyTo} -

    - Antwort auf {replyTo.senderName} -

    -

    {replyTo.body}

    - {/if} -
    - -
    - {/if} - - - {#if uploading} -
    - -
    -
    -
    -
    -
    - {uploadProgress}% -
    - {/if} - - - {#if isRecording} -
    -
    -

    Aufnahme...

    - {formatDuration(recordingDuration)} - -
    - {/if} - - - {#if showMentionPicker && mentionResults.length > 0} -
    -
    - Erwähne jemanden -
    - {#each mentionResults as member, i} - - {/each} -
    - {/if} - - -
    - -
    - - - {#if showAttachMenu} - - - - - -
    -
    - - -
    -
    - {/if} -
    - - - - - -
    - - - - - - {#if showEmojiPicker} - - - - - -
    -
    - {#if recentEmojis.length > 0} -
    -

    - Häufig benutzt -

    -
    - {#each recentEmojis as emoji} - - {/each} -
    -
    -
    - {/if} -
    - {#each commonEmojis as emoji} - - {/each} -
    -
    -
    - {/if} -
    - - - {#if isRecording} - - {:else if message.trim()} - - {:else} - - {/if} -
    - - - -
    diff --git a/apps/matrix/apps/web/src/lib/components/chat/RoomHeader.svelte b/apps/matrix/apps/web/src/lib/components/chat/RoomHeader.svelte deleted file mode 100644 index 89b1f3a77..000000000 --- a/apps/matrix/apps/web/src/lib/components/chat/RoomHeader.svelte +++ /dev/null @@ -1,203 +0,0 @@ - - -{#if room} -
    - - {#if showBackButton} - - {:else} - - {/if} - - -
    -
    - {#if room.avatar} - {room.name} - {:else} - {room.name.charAt(0).toUpperCase()} - {/if} -
    - - {#if room.isDirect} -
    - {/if} -
    - - -
    -
    -

    {room.name}

    - {#if room.isEncrypted} - {#if encryptionStatus.allDevicesVerified} -
    - -
    - {:else} -
    - -
    - {/if} - {:else} -
    - -
    - {/if} -
    -

    - {#if room.topic} - {room.topic} - {:else if room.isDirect} - - {#if isOnline} - - Online - {:else} - - {presenceText() || 'Offline'} - {/if} - - {:else} - - {room.memberCount} Mitglieder - {/if} -

    -
    - - -
    - - - - -
    -
    -{/if} diff --git a/apps/matrix/apps/web/src/lib/components/chat/RoomItem.svelte b/apps/matrix/apps/web/src/lib/components/chat/RoomItem.svelte deleted file mode 100644 index ad20d11ac..000000000 --- a/apps/matrix/apps/web/src/lib/components/chat/RoomItem.svelte +++ /dev/null @@ -1,130 +0,0 @@ - - - diff --git a/apps/matrix/apps/web/src/lib/components/chat/RoomList.svelte b/apps/matrix/apps/web/src/lib/components/chat/RoomList.svelte deleted file mode 100644 index 2735c8a15..000000000 --- a/apps/matrix/apps/web/src/lib/components/chat/RoomList.svelte +++ /dev/null @@ -1,184 +0,0 @@ - - -
    - -
    - -
    - - - Räume - - {matrixStore.directRooms.length + matrixStore.groupRooms.length} - - - -
    - - - {#if filteredInvites.length > 0} -
    -
    - - Einladungen - - {filteredInvites.length} - -
    - {#each filteredInvites as room (room.id)} -
    - -
    - - {room.name - .split(' ') - .map((w) => w[0]) - .join('') - .substring(0, 2) - .toUpperCase()} - -
    - -
    -

    {room.name}

    - {#if room.inviter} -

    - Eingeladen von {room.inviter} -

    - {/if} -
    - -
    - - -
    -
    - {/each} -
    - {/if} - - - {#if matrixStore.directRooms.length > 0} -
    -
    - - Direktnachrichten - - {matrixStore.directRooms.length} - -
    - {#each filteredDirectRooms as room (room.id)} - handleSelectRoom(room.id)} - /> - {/each} -
    - {/if} - - - {#if matrixStore.groupRooms.length > 0} -
    -
    - - Gruppen - - {matrixStore.groupRooms.length} - -
    - {#each filteredGroupRooms as room (room.id)} - handleSelectRoom(room.id)} - /> - {/each} -
    - {/if} - - - {#if search && filteredDirectRooms.length === 0 && filteredGroupRooms.length === 0 && filteredInvites.length === 0 && (matrixStore.directRooms.length > 0 || matrixStore.groupRooms.length > 0 || matrixStore.invitedRooms.length > 0)} -
    -

    Keine Ergebnisse für "{search}"

    -
    - {/if} -
    -
    diff --git a/apps/matrix/apps/web/src/lib/components/chat/RoomSettingsPanel.svelte b/apps/matrix/apps/web/src/lib/components/chat/RoomSettingsPanel.svelte deleted file mode 100644 index ebde6703d..000000000 --- a/apps/matrix/apps/web/src/lib/components/chat/RoomSettingsPanel.svelte +++ /dev/null @@ -1,325 +0,0 @@ - - -{#if open && room} - - - - -
    - -
    -

    Raum-Details

    - -
    - - -
    -
    - {#if room.avatar} - {room.name} - {:else} -
    - {room.name.charAt(0).toUpperCase()} -
    - {/if} -
    -

    {room.name}

    - {#if room.topic} -

    {room.topic}

    - {/if} -

    - {room.memberCount} Mitglieder - {#if room.isEncrypted} - • Verschlüsselt - {/if} -

    -
    - - -
    - - - -
    - - -
    - {#if activeTab === 'members'} - -
    -
    - - - {#if searching} - - {/if} -
    - - - {#if searchResults.length > 0} -
    - {#each searchResults as user} - - {/each} -
    - {/if} -
    - - -
    - {#each members as member} - {@const PowerIcon = getPowerLevelIcon(member.powerLevel)} -
    -
    - {#if member.avatarUrl} - - {:else} -
    - {member.displayName.charAt(0).toUpperCase()} -
    - {/if} -
    -
    -

    {member.displayName}

    -

    {member.userId}

    -
    - {#if PowerIcon} - - {/if} -
    - {/each} -
    - {:else if activeTab === 'widgets'} - -
    - {#if widgets.length === 0} -
    - -

    Keine Widgets in diesem Raum

    -

    Bots können Widgets hinzufügen

    -
    - {:else} -
    - {#each widgets as widget} -
    -
    -

    {widget.name}

    - -
    - {#if expandedWidget === widget.id} -
    - -
    - {/if} -
    - {/each} -
    - {/if} -
    - {:else} - -
    - - - - - -
    - {/if} -
    -
    -{/if} diff --git a/apps/matrix/apps/web/src/lib/components/chat/SearchDialog.svelte b/apps/matrix/apps/web/src/lib/components/chat/SearchDialog.svelte deleted file mode 100644 index fb363d82a..000000000 --- a/apps/matrix/apps/web/src/lib/components/chat/SearchDialog.svelte +++ /dev/null @@ -1,187 +0,0 @@ - - -{#if open} - - -{/if} diff --git a/apps/matrix/apps/web/src/lib/components/chat/Timeline.svelte b/apps/matrix/apps/web/src/lib/components/chat/Timeline.svelte deleted file mode 100644 index 4ac34cb82..000000000 --- a/apps/matrix/apps/web/src/lib/components/chat/Timeline.svelte +++ /dev/null @@ -1,186 +0,0 @@ - - -
    -
    - - {#if loadingMore} -
    - -
    - {/if} - - -
    - {#each matrixStore.messages as message, index (message.id)} - {@const prevMessage = matrixStore.messages[index - 1]} - {@const nextMessage = matrixStore.messages[index + 1]} - {@const isSameSender = Boolean(prevMessage && prevMessage.sender === message.sender)} - {@const isNextSameSender = Boolean(nextMessage && nextMessage.sender === message.sender)} - {@const prevDate = prevMessage ? new Date(prevMessage.timestamp).toDateString() : null} - {@const currentDate = new Date(message.timestamp).toDateString()} - {@const nextDate = nextMessage ? new Date(nextMessage.timestamp).toDateString() : null} - {@const showDateSeparator = Boolean(prevMessage && prevDate !== currentDate)} - {@const showAvatar = !isSameSender || showDateSeparator} - {@const isLastInGroup = !isNextSameSender || Boolean(nextDate && nextDate !== currentDate)} - - {:else} -
    -

    Noch keine Nachrichten

    -

    Starte die Konversation!

    -
    - {/each} -
    - - - {#if matrixStore.currentRoomTyping.length > 0} - - {/if} -
    - - - {#if showScrollButton} - - {/if} -
    diff --git a/apps/matrix/apps/web/src/lib/components/chat/TypingIndicator.svelte b/apps/matrix/apps/web/src/lib/components/chat/TypingIndicator.svelte deleted file mode 100644 index 3dcb7c9e1..000000000 --- a/apps/matrix/apps/web/src/lib/components/chat/TypingIndicator.svelte +++ /dev/null @@ -1,70 +0,0 @@ - - -{#if users.length > 0} -
    - -
    - {#each typingUsers().slice(0, 3) as user, i} - {#if user.avatarUrl} - {user.name} - {:else} -
    - -
    - {/if} - {/each} -
    - - -
    - - - -
    - - - {text()} -
    -{/if} diff --git a/apps/matrix/apps/web/src/lib/components/chat/index.ts b/apps/matrix/apps/web/src/lib/components/chat/index.ts deleted file mode 100644 index 06e82b842..000000000 --- a/apps/matrix/apps/web/src/lib/components/chat/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { default as RoomList } from './RoomList.svelte'; -export { default as RoomItem } from './RoomItem.svelte'; -export { default as RoomHeader } from './RoomHeader.svelte'; -export { default as Timeline } from './Timeline.svelte'; -export { default as Message } from './Message.svelte'; -export { default as MessageInput } from './MessageInput.svelte'; -export { default as TypingIndicator } from './TypingIndicator.svelte'; -export { default as CreateRoomDialog } from './CreateRoomDialog.svelte'; -export { default as RoomSettingsPanel } from './RoomSettingsPanel.svelte'; -export { default as DropZoneOverlay } from './DropZoneOverlay.svelte'; diff --git a/apps/matrix/apps/web/src/lib/components/crypto/EmojiVerification.svelte b/apps/matrix/apps/web/src/lib/components/crypto/EmojiVerification.svelte deleted file mode 100644 index da44d6d9a..000000000 --- a/apps/matrix/apps/web/src/lib/components/crypto/EmojiVerification.svelte +++ /dev/null @@ -1,158 +0,0 @@ - - -
    - {#if phase === 'waiting'} -
    - -

    Warte auf Antwort vom anderen Gerät...

    -

    - Öffne die Verifizierungsanfrage auf deinem anderen Gerät. -

    -
    - {:else if phase === 'emojis'} -
    -

    - Vergleiche die folgenden Emojis mit deinem anderen Gerät: -

    - - -
    - {#each emojis as item} -
    - {item.emoji} - {item.description} -
    - {/each} -
    - -

    - Stimmen die Emojis auf beiden Geräten überein? -

    - - -
    - - -
    -
    - {:else if phase === 'confirming'} -
    - -

    Bestätige Verifizierung...

    -
    - {:else if phase === 'done'} -
    -
    - -
    -

    Verifizierung erfolgreich!

    -

    - Das Gerät wurde erfolgreich verifiziert. -

    -
    - {:else if phase === 'error'} -
    -
    - -
    -

    Verifizierung fehlgeschlagen

    - {#if error} -

    - {error} -

    - {/if} - -
    - {/if} -
    diff --git a/apps/matrix/apps/web/src/lib/components/crypto/RecoveryKeyDialog.svelte b/apps/matrix/apps/web/src/lib/components/crypto/RecoveryKeyDialog.svelte deleted file mode 100644 index eb696016f..000000000 --- a/apps/matrix/apps/web/src/lib/components/crypto/RecoveryKeyDialog.svelte +++ /dev/null @@ -1,374 +0,0 @@ - - -{#if open} -
    -
    e.stopPropagation()} - role="dialog" - aria-modal="true" - > - -
    -
    - -

    - {currentMode === 'setup' ? 'Verschlüsselung einrichten' : 'Schlüssel wiederherstellen'} -

    -
    - -
    - - -
    - {#if step === 'intro'} -
    - {#if currentMode === 'setup'} -

    - Richte einen Recovery Key ein, um deine verschlüsselten Nachrichten auf anderen - Geräten wiederherzustellen. -

    - -
    - - - Ohne Recovery Key verlierst du den Zugriff auf deine verschlüsselten Nachrichten, - wenn du dich abmeldest. - -
    - - - {:else} -

    - Gib deinen Recovery Key ein, um auf deine verschlüsselten Nachrichten zugreifen zu - können. -

    - -
    - - -
    - {/if} - - {#if error} -
    - {error} -
    - {/if} -
    - {:else if step === 'passphrase'} -
    -

    - Gib eine sichere Passphrase ein, die du dir merken kannst. -

    - -
    - - -
    - -
    - - -
    - - {#if error} -
    - {error} -
    - {/if} -
    - {:else if step === 'show-key'} -
    -
    - - - Speichere diesen Schlüssel an einem sicheren Ort. Du benötigst ihn, um deine - Nachrichten wiederherzustellen. - -
    - -
    -

    Dein Recovery Key:

    -
    - {formatRecoveryKey(recoveryKey)} -
    -
    - -
    - - -
    -
    - {:else if step === 'done'} -
    -
    - -
    -

    - {currentMode === 'setup' - ? 'Verschlüsselung eingerichtet!' - : 'Schlüssel wiederhergestellt!'} -

    -

    - {currentMode === 'setup' - ? 'Deine Nachrichten sind jetzt sicher verschlüsselt.' - : 'Du kannst jetzt auf deine verschlüsselten Nachrichten zugreifen.'} -

    -
    - {/if} -
    - - -
    - {#if step === 'intro'} - - {#if currentMode === 'setup'} - - {:else} - - {/if} - {:else if step === 'passphrase'} - - - {:else if step === 'show-key'} - - {:else if step === 'done'} - - {/if} -
    -
    -
    -{/if} diff --git a/apps/matrix/apps/web/src/lib/components/crypto/VerificationDialog.svelte b/apps/matrix/apps/web/src/lib/components/crypto/VerificationDialog.svelte deleted file mode 100644 index 17b904abe..000000000 --- a/apps/matrix/apps/web/src/lib/components/crypto/VerificationDialog.svelte +++ /dev/null @@ -1,244 +0,0 @@ - - -{#if open} -
    -
    e.stopPropagation()} - role="dialog" - aria-modal="true" - > - -
    -
    - -

    Geräte-Verifizierung

    -
    - -
    - - -
    - {#if verificationStarted && activeVerification} - - - {:else} - -
    -

    - Verifiziere deine Geräte um sicherzustellen, dass du der einzige bist, der auf deine - verschlüsselten Nachrichten zugreifen kann. -

    - - {#if error} -
    - {error} -
    - {/if} - - {#if loading} -
    - -
    - {:else if devices.length === 0} -
    -

    Keine Geräte gefunden

    -
    - {:else} -
    - {#each devices as device} - {@const DeviceIcon = getDeviceIcon(device)} -
    -
    - {#if device.verified} -
    - - -
    - {:else if device.blocked} -
    - - -
    - {:else} -
    - - -
    - {/if} -
    - -
    -
    - - {formatDeviceName(device.displayName, device.deviceId)} - - {#if device.isCurrentDevice} - Dieses Gerät - {/if} -
    -
    - {device.deviceId} -
    -
    - {#if device.verified} - Verifiziert - {:else if device.blocked} - Blockiert - {:else} - Nicht verifiziert - {/if} -
    -
    - - {#if !device.isCurrentDevice && !device.verified} - - {/if} -
    - {/each} -
    - {/if} - - -
    - -
    -
    - {/if} -
    - - -
    - -
    -
    -
    -{/if} diff --git a/apps/matrix/apps/web/src/lib/components/crypto/index.ts b/apps/matrix/apps/web/src/lib/components/crypto/index.ts deleted file mode 100644 index f81f9effb..000000000 --- a/apps/matrix/apps/web/src/lib/components/crypto/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Crypto component exports -export { default as VerificationDialog } from './VerificationDialog.svelte'; -export { default as EmojiVerification } from './EmojiVerification.svelte'; -export { default as RecoveryKeyDialog } from './RecoveryKeyDialog.svelte'; diff --git a/apps/matrix/apps/web/src/lib/content/help/index.test.ts b/apps/matrix/apps/web/src/lib/content/help/index.test.ts deleted file mode 100644 index dd2e0830d..000000000 --- a/apps/matrix/apps/web/src/lib/content/help/index.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { getManalinkHelpContent } from './index'; - -describe('Manalink Help Content', () => { - it('returns valid German content', () => { - const content = getManalinkHelpContent('de'); - - expect(content.faq.length).toBeGreaterThan(0); - content.faq.forEach((faq) => { - expect(faq.id).toBeTruthy(); - expect(faq.question).toBeTruthy(); - expect(faq.answer).toBeTruthy(); - }); - - expect(content.features).toBeDefined(); - expect(content.contact).toBeDefined(); - expect(content.contact.supportEmail).toBe('support@mana.how'); - }); - - it('returns valid English content', () => { - const content = getManalinkHelpContent('en'); - - expect(content.faq.length).toBeGreaterThan(0); - content.faq.forEach((faq) => { - expect(faq.id).toBeTruthy(); - expect(faq.question).toBeTruthy(); - expect(faq.answer).toBeTruthy(); - }); - - expect(content.features).toBeDefined(); - expect(content.contact).toBeDefined(); - }); - - it('returns same number of FAQ items for both languages', () => { - const de = getManalinkHelpContent('de'); - const en = getManalinkHelpContent('en'); - - expect(de.faq.length).toBe(en.faq.length); - expect(de.features.length).toBe(en.features.length); - }); - - it('has unique FAQ IDs', () => { - const content = getManalinkHelpContent('de'); - const ids = content.faq.map((f) => f.id); - expect(new Set(ids).size).toBe(ids.length); - }); -}); diff --git a/apps/matrix/apps/web/src/lib/content/help/index.ts b/apps/matrix/apps/web/src/lib/content/help/index.ts deleted file mode 100644 index e7a3d2e0d..000000000 --- a/apps/matrix/apps/web/src/lib/content/help/index.ts +++ /dev/null @@ -1,276 +0,0 @@ -/** - * Help content for Manalink (Matrix) app - */ - -import type { HelpContent } from '@mana/help'; -import { getPrivacyFAQs } from '@mana/help'; - -export function getManalinkHelpContent(locale: string): HelpContent { - const isDE = locale === 'de'; - - return { - faq: [ - { - id: 'faq-what-is-matrix', - question: isDE ? 'Was ist Matrix und Manalink?' : 'What is Matrix and Manalink?', - answer: isDE - ? '

    Manalink ist ein sicherer Messenger auf Basis des Matrix-Protokolls — einem dezentralen, offenen Standard für Kommunikation:

    • Ende-zu-Ende-verschlüsselt
    • Dezentral — kein einzelner Server kontrolliert deine Daten
    • Kompatibel mit anderen Matrix-Clients (Element, FluffyChat, etc.)
    • Der Standard-Homeserver ist matrix.mana.how
    ' - : '

    Manalink is a secure messenger based on the Matrix protocol — a decentralized, open standard for communication:

    • End-to-end encrypted
    • Decentralized — no single server controls your data
    • Compatible with other Matrix clients (Element, FluffyChat, etc.)
    • The default homeserver is matrix.mana.how
    ', - category: 'features', - order: 1, - language: isDE ? 'de' : 'en', - tags: isDE ? ['matrix', 'protokoll', 'dezentral'] : ['matrix', 'protocol', 'decentralized'], - }, - { - id: 'faq-login', - question: isDE ? 'Wie melde ich mich an?' : 'How do I log in?', - answer: isDE - ? '

    Du kannst dich auf zwei Wegen anmelden:

    • Matrix-Konto — Benutzername und Passwort eines Matrix-Homeservers
    • SSO über Mana Core — Anmeldung mit deinem Mana-Konto

    Der Standard-Homeserver ist matrix.mana.how, aber du kannst jeden beliebigen Matrix-Homeserver verwenden.

    ' - : '

    You can log in two ways:

    • Matrix account — Username and password from a Matrix homeserver
    • SSO via Mana Core — Login with your Mana account

    The default homeserver is matrix.mana.how, but you can use any Matrix homeserver.

    ', - category: 'getting-started', - order: 2, - language: isDE ? 'de' : 'en', - tags: isDE ? ['anmeldung', 'login', 'sso'] : ['login', 'auth', 'sso'], - }, - { - id: 'faq-rooms', - question: isDE ? 'Wie funktionieren Räume?' : 'How do rooms work?', - answer: isDE - ? '

    Räume sind Chatgruppen in Matrix:

    • Direktnachrichten — 1:1 Gespräche
    • Gruppenräume — Mehrere Teilnehmer
    • Du kannst Räume erstellen, beitreten und verwalten
    • Räume zeigen ungelesene Nachrichten und Highlight-Zähler an
    ' - : '

    Rooms are chat groups in Matrix:

    • Direct messages — 1:1 conversations
    • Group rooms — Multiple participants
    • You can create, join, and manage rooms
    • Rooms show unread message and highlight counters
    ', - category: 'features', - order: 3, - language: isDE ? 'de' : 'en', - tags: isDE ? ['räume', 'chat', 'gruppen'] : ['rooms', 'chat', 'groups'], - }, - { - id: 'faq-messaging', - question: isDE - ? 'Welche Nachrichtenfunktionen gibt es?' - : 'What messaging features are available?', - answer: isDE - ? '

    Manalink bietet umfangreiche Nachrichtenfunktionen:

    • Textnachrichten senden und empfangen
    • Tipp-Indikatoren — Sieh, wenn jemand gerade tippt
    • Lesebestätigungen — Wisse, wann deine Nachricht gelesen wurde
    • Nachrichtensuche — Durchsuche den Chatverlauf
    • Paginierung — Lade ältere Nachrichten nach
    ' - : '

    Manalink offers comprehensive messaging features:

    • Text messages — send and receive
    • Typing indicators — See when someone is typing
    • Read receipts — Know when your message was read
    • Message search — Search through chat history
    • Pagination — Load older messages
    ', - category: 'features', - order: 4, - language: isDE ? 'de' : 'en', - tags: isDE - ? ['nachrichten', 'tippen', 'lesen', 'suche'] - : ['messages', 'typing', 'read', 'search'], - }, - { - id: 'faq-encryption', - question: isDE ? 'Sind meine Nachrichten verschlüsselt?' : 'Are my messages encrypted?', - answer: isDE - ? '

    Ja, Manalink unterstützt Ende-zu-Ende-Verschlüsselung (E2EE) über das Matrix-Protokoll:

    • Verschlüsselte Räume sind nur für die Teilnehmer lesbar
    • Nicht einmal der Server kann die Nachrichten lesen
    • Die Verschlüsselung verwendet bewährte kryptographische Verfahren (Olm/Megolm)
    ' - : '

    Yes, Manalink supports end-to-end encryption (E2EE) via the Matrix protocol:

    • Encrypted rooms are only readable by participants
    • Not even the server can read the messages
    • Encryption uses proven cryptographic methods (Olm/Megolm)
    ', - category: 'privacy', - order: 5, - language: isDE ? 'de' : 'en', - tags: isDE ? ['verschlüsselung', 'e2ee', 'sicherheit'] : ['encryption', 'e2ee', 'security'], - }, - { - id: 'faq-room-settings', - question: isDE ? 'Wie verwalte ich Raumeinstellungen?' : 'How do I manage room settings?', - answer: isDE - ? '

    Jeder Raum hat eigene Einstellungen:

    • Name und Thema des Raums ändern
    • Mitglieder einladen und verwalten
    • Benachrichtigungen pro Raum konfigurieren
    ' - : '

    Each room has its own settings:

    • Change the name and topic of the room
    • Invite and manage members
    • Configure notifications per room
    ', - category: 'features', - order: 6, - language: isDE ? 'de' : 'en', - tags: isDE ? ['einstellungen', 'raum', 'mitglieder'] : ['settings', 'room', 'members'], - }, - { - id: 'faq-bots', - question: isDE ? 'Was sind Bots?' : 'What are bots?', - answer: isDE - ? '

    Unter Bots findest du automatisierte Assistenten, die in Räumen helfen können. Bots können Aufgaben automatisieren, Informationen bereitstellen und den Chat bereichern.

    ' - : '

    Under Bots you can find automated assistants that can help in rooms. Bots can automate tasks, provide information, and enrich the chat experience.

    ', - category: 'features', - order: 7, - language: isDE ? 'de' : 'en', - tags: isDE - ? ['bots', 'automatisierung', 'assistenten'] - : ['bots', 'automation', 'assistants'], - }, - { - id: 'faq-pwa', - question: isDE - ? 'Kann ich Manalink auf dem Handy nutzen?' - : 'Can I use Manalink on my phone?', - answer: isDE - ? '

    Ja! Manalink ist eine Progressive Web App (PWA):

    • Öffne die App im Browser und tippe auf "Zum Startbildschirm hinzufügen"
    • Funktioniert auch offline dank Service Worker Caching
    • Push-Benachrichtigungen für neue Nachrichten
    • Vollbild-App-Erfahrung ohne Browser-Leiste
    ' - : '

    Yes! Manalink is a Progressive Web App (PWA):

    • Open the app in your browser and tap "Add to Home Screen"
    • Works offline thanks to service worker caching
    • Push notifications for new messages
    • Fullscreen app experience without browser bar
    ', - category: 'getting-started', - order: 8, - language: isDE ? 'de' : 'en', - tags: isDE ? ['pwa', 'mobil', 'installieren'] : ['pwa', 'mobile', 'install'], - }, - { - id: 'faq-feedback', - question: isDE ? 'Wie kann ich Feedback geben?' : 'How can I give feedback?', - answer: isDE - ? '

    Dein Feedback hilft uns, Manalink zu verbessern:

    • Nutze die Feedback-Seite im Menü, um Verbesserungsvorschläge, Fehlermeldungen oder Feature-Wünsche einzureichen
    • Wir lesen jedes Feedback und arbeiten kontinuierlich an Verbesserungen
    ' - : '

    Your feedback helps us improve Manalink:

    • Use the Feedback page in the menu to submit improvement suggestions, bug reports, or feature requests
    • We read every piece of feedback and continuously work on improvements
    ', - category: 'general', - order: 9, - language: isDE ? 'de' : 'en', - tags: isDE - ? ['feedback', 'verbesserung', 'kontakt'] - : ['feedback', 'improvement', 'contact'], - }, - ...getPrivacyFAQs(locale, { - dataTypeDE: 'Nachrichten', - dataTypeEN: 'messages', - extraBulletsDE: [ - 'Ende-zu-Ende-Verschlüsselung: Verschlüsselte Räume sind nur für Teilnehmer lesbar — nicht einmal der Server kann mitlesen', - ], - extraBulletsEN: [ - 'End-to-end encryption: Encrypted rooms are only readable by participants — not even the server can read them', - ], - }), - ], - features: [ - { - id: 'feature-messaging', - title: isDE ? 'Sichere Nachrichten' : 'Secure Messaging', - description: isDE - ? 'Ende-zu-Ende-verschlüsselte Nachrichten über das dezentrale Matrix-Protokoll' - : 'End-to-end encrypted messaging via the decentralized Matrix protocol', - icon: '🔒', - category: 'core', - highlights: isDE - ? [ - 'E2E-Verschlüsselung', - 'Direktnachrichten & Gruppen', - 'Lesebestätigungen', - 'Tipp-Indikatoren', - ] - : ['E2E encryption', 'Direct messages & groups', 'Read receipts', 'Typing indicators'], - content: '', - order: 1, - language: isDE ? 'de' : 'en', - }, - { - id: 'feature-rooms', - title: isDE ? 'Räume' : 'Rooms', - description: isDE - ? 'Erstelle und verwalte Räume für Direktnachrichten und Gruppen' - : 'Create and manage rooms for direct messages and groups', - icon: '💬', - category: 'core', - highlights: isDE - ? ['Raum erstellen', 'Raumeinstellungen', 'Mitgliederverwaltung', 'Ungelesen-Zähler'] - : ['Create rooms', 'Room settings', 'Member management', 'Unread counter'], - content: '', - order: 2, - language: isDE ? 'de' : 'en', - }, - { - id: 'feature-search', - title: isDE ? 'Nachrichtensuche' : 'Message Search', - description: isDE - ? 'Durchsuche den Chatverlauf nach Nachrichten und Inhalten' - : 'Search through chat history for messages and content', - icon: '🔍', - category: 'core', - highlights: isDE - ? ['Volltextsuche', 'Raum-übergreifend', 'Schnelle Ergebnisse'] - : ['Full-text search', 'Cross-room', 'Quick results'], - content: '', - order: 3, - language: isDE ? 'de' : 'en', - }, - { - id: 'feature-sso', - title: isDE ? 'SSO-Anmeldung' : 'SSO Login', - description: isDE - ? 'Melde dich mit deinem Mana-Konto an — kein separates Matrix-Passwort nötig' - : 'Sign in with your Mana account — no separate Matrix password needed', - icon: '🔐', - category: 'core', - highlights: isDE - ? ['Mana SSO', 'Ein-Klick-Login', 'Sicher', 'Passwort-Login'] - : ['Mana SSO', 'One-click login', 'Secure', 'Password login'], - content: '', - order: 4, - language: isDE ? 'de' : 'en', - }, - { - id: 'feature-bots', - title: 'Bots', - description: isDE - ? 'Automatisierte Assistenten für Aufgaben und Informationen in Räumen' - : 'Automated assistants for tasks and information in rooms', - icon: '🤖', - category: 'advanced', - highlights: isDE - ? ['Chat-Assistenten', 'Automatisierung', 'Informationsdienste'] - : ['Chat assistants', 'Automation', 'Information services'], - content: '', - order: 5, - language: isDE ? 'de' : 'en', - }, - { - id: 'feature-pwa', - title: isDE ? 'Progressive Web App' : 'Progressive Web App', - description: isDE - ? 'Installierbar auf jedem Gerät — offline-fähig und mit Push-Benachrichtigungen' - : 'Installable on any device — works offline and supports push notifications', - icon: '📱', - category: 'core', - highlights: isDE - ? ['Installierbar', 'Offline-fähig', 'Push-Benachrichtigungen', 'Vollbild'] - : ['Installable', 'Works offline', 'Push notifications', 'Fullscreen'], - content: '', - order: 6, - language: isDE ? 'de' : 'en', - }, - { - id: 'feature-federation', - title: isDE ? 'Föderation' : 'Federation', - description: isDE - ? 'Kommuniziere mit Nutzern auf anderen Matrix-Homeservern' - : 'Communicate with users on other Matrix homeservers', - icon: '🌐', - category: 'advanced', - highlights: isDE - ? ['Server-übergreifend', 'Offenes Protokoll', 'Element-kompatibel'] - : ['Cross-server', 'Open protocol', 'Element-compatible'], - content: '', - order: 7, - language: isDE ? 'de' : 'en', - }, - { - id: 'feature-settings', - title: isDE ? 'Einstellungen' : 'Settings', - description: isDE - ? 'Passe Manalink an — Themes, Benachrichtigungen und Kontoeinstellungen' - : 'Customize Manalink — themes, notifications, and account settings', - icon: '⚙️', - category: 'core', - highlights: isDE - ? ['Hell/Dunkel-Modus', 'Benachrichtigungen', 'Kontoeinstellungen'] - : ['Light/Dark mode', 'Notifications', 'Account settings'], - content: '', - order: 8, - language: isDE ? 'de' : 'en', - }, - ], - shortcuts: [], - gettingStarted: [], - changelog: [], - contact: { - id: 'contact-support', - title: isDE ? 'Support kontaktieren' : 'Contact Support', - content: isDE - ? '

    Unser Support-Team hilft dir bei allen Fragen rund um Manalink. Nutze auch die Feedback-Funktion im Menü, um uns direkt Verbesserungsvorschläge zu schicken.

    ' - : '

    Our support team is here to help you with any questions about Manalink. You can also use the feedback feature in the menu to send us improvement suggestions directly.

    ', - language: isDE ? 'de' : 'en', - order: 1, - supportEmail: 'support@mana.how', - documentationUrl: 'https://mana.how/docs', - responseTime: isDE ? 'Normalerweise innerhalb von 24 Stunden' : 'Usually within 24 hours', - }, - }; -} diff --git a/apps/matrix/apps/web/src/lib/data/bots.ts b/apps/matrix/apps/web/src/lib/data/bots.ts deleted file mode 100644 index c4fee52ce..000000000 --- a/apps/matrix/apps/web/src/lib/data/bots.ts +++ /dev/null @@ -1,424 +0,0 @@ -export interface BotCommand { - command: string; - aliases?: string[]; - description: string; - example?: string; -} - -export interface BotInfo { - id: string; - name: string; - matrixUserId: string; - description: string; - longDescription?: string; - icon: string; - color: string; - commands: BotCommand[]; - category: 'productivity' | 'ai' | 'media' | 'lifestyle' | 'tools'; - requiresAuth: boolean; - isGateway?: boolean; -} - -export const BOTS: BotInfo[] = [ - // AI & Chat - { - id: 'mana-bot', - name: 'Mana Bot', - matrixUserId: '@mana-bot:matrix.mana.how', - description: 'All-in-One Gateway zu allen Mana-Services', - longDescription: - 'Der zentrale Hub, der alle anderen Bots vereint. Starte Chats, erstelle Bilder, verwalte Aufgaben und mehr - alles in einem Bot.', - icon: 'Sparkle', - color: 'from-violet-500 to-purple-600', - category: 'ai', - requiresAuth: true, - isGateway: true, - commands: [ - { command: '!help', aliases: ['!hilfe'], description: 'Zeigt alle verfügbaren Befehle' }, - { command: '!chat', description: 'Startet einen KI-Chat' }, - { command: '!image', aliases: ['!bild'], description: 'Generiert ein Bild mit KI' }, - { command: '!todo', description: 'Verwaltet Aufgaben' }, - { command: '!calendar', aliases: ['!cal'], description: 'Kalender-Operationen' }, - ], - }, - { - id: 'chat-bot', - name: 'Chat Bot', - matrixUserId: '@chat-bot:matrix.mana.how', - description: 'KI-Assistent powered by Claude und anderen LLMs', - longDescription: - 'Dein persönlicher KI-Assistent für Fragen, Texterstellung, Zusammenfassungen und kreative Aufgaben. Nutzt verschiedene KI-Modelle.', - icon: 'ChatCircle', - color: 'from-blue-500 to-cyan-500', - category: 'ai', - requiresAuth: true, - commands: [ - { - command: '!chat', - description: 'Startet eine Konversation', - example: '!chat Erkläre mir Quantencomputing', - }, - { command: '!model', description: 'Wechselt das KI-Modell', example: '!model gpt-4' }, - { command: '!clear', description: 'Löscht den Chat-Verlauf' }, - { command: '!system', description: 'Setzt einen System-Prompt' }, - ], - }, - { - id: 'ollama-bot', - name: 'Ollama Bot', - matrixUserId: '@ollama-bot:matrix.mana.how', - description: 'Lokale KI-Modelle via Ollama', - longDescription: - 'Chatte mit lokal gehosteten Open-Source KI-Modellen. Vollständig privat, keine Daten verlassen den Server.', - icon: 'Robot', - color: 'from-emerald-500 to-teal-600', - category: 'ai', - requiresAuth: false, - commands: [ - { command: '!ollama', description: 'Startet einen Chat', example: '!ollama Hallo!' }, - { command: '!models', description: 'Listet verfügbare Modelle' }, - { command: '!switch', description: 'Wechselt das Modell', example: '!switch llama3' }, - ], - }, - - // Productivity - { - id: 'todo-bot', - name: 'Todo Bot', - matrixUserId: '@todo-bot:matrix.mana.how', - description: 'Aufgabenverwaltung und To-Do Listen', - longDescription: - 'Verwalte deine Aufgaben direkt im Chat. Erstelle, bearbeite und erledige Todos mit einfachen Befehlen.', - icon: 'CheckSquare', - color: 'from-green-500 to-emerald-600', - category: 'productivity', - requiresAuth: true, - commands: [ - { - command: '!add', - description: 'Fügt eine neue Aufgabe hinzu', - example: '!add Einkaufen gehen', - }, - { command: '!list', aliases: ['!todos'], description: 'Zeigt alle Aufgaben' }, - { command: '!done', description: 'Markiert Aufgabe als erledigt', example: '!done 1' }, - { - command: '!delete', - aliases: ['!del'], - description: 'Löscht eine Aufgabe', - example: '!delete 1', - }, - { command: '!clear', description: 'Löscht alle erledigten Aufgaben' }, - ], - }, - { - id: 'calendar-bot', - name: 'Calendar Bot', - matrixUserId: '@calendar-bot:matrix.mana.how', - description: 'Terminverwaltung und Erinnerungen', - longDescription: - 'Plane Termine, setze Erinnerungen und behalte deinen Zeitplan im Blick - alles per Chat-Befehl.', - icon: 'CalendarBlank', - color: 'from-orange-500 to-amber-600', - category: 'productivity', - requiresAuth: true, - commands: [ - { - command: '!event', - description: 'Erstellt einen Termin', - example: '!event Meeting morgen 14:00', - }, - { command: '!today', description: 'Zeigt heutige Termine' }, - { command: '!week', description: 'Zeigt Termine dieser Woche' }, - { command: '!remind', description: 'Setzt eine Erinnerung', example: '!remind 30min Anruf' }, - ], - }, - { - id: 'contacts-bot', - name: 'Contacts Bot', - matrixUserId: '@contacts-bot:matrix.mana.how', - description: 'Kontaktverwaltung und Adressbuch', - longDescription: - 'Speichere und finde Kontaktinformationen schnell. Durchsuche dein Adressbuch direkt im Chat.', - icon: 'AddressBook', - color: 'from-indigo-500 to-blue-600', - category: 'productivity', - requiresAuth: true, - commands: [ - { command: '!find', description: 'Sucht nach Kontakten', example: '!find Max Mustermann' }, - { command: '!add', description: 'Fügt einen Kontakt hinzu' }, - { command: '!all', description: 'Listet alle Kontakte' }, - ], - }, - { - id: 'project-doc-bot', - name: 'Project Doc Bot', - matrixUserId: '@project-doc-bot:matrix.mana.how', - description: 'Projektdokumentation und Wissensbasis', - longDescription: - 'Durchsuche Projektdokumentationen, finde Code-Beispiele und erhalte Antworten basierend auf deiner Wissensbasis.', - icon: 'Folders', - color: 'from-purple-500 to-violet-600', - category: 'productivity', - requiresAuth: true, - commands: [ - { - command: '!search', - description: 'Durchsucht die Dokumentation', - example: '!search API authentication', - }, - { command: '!projects', description: 'Listet verfügbare Projekte' }, - { command: '!select', description: 'Wählt ein Projekt aus', example: '!select mana' }, - ], - }, - - // Media - { - id: 'picture-bot', - name: 'Picture Bot', - matrixUserId: '@picture-bot:matrix.mana.how', - description: 'KI-Bildgenerierung mit verschiedenen Modellen', - longDescription: - 'Erstelle beeindruckende Bilder mit KI. Unterstützt verschiedene Stile und Modelle wie Stable Diffusion und DALL-E.', - icon: 'Image', - color: 'from-pink-500 to-rose-600', - category: 'media', - requiresAuth: true, - commands: [ - { - command: '!image', - aliases: ['!bild'], - description: 'Generiert ein Bild', - example: '!image Ein Sonnenuntergang am Meer', - }, - { command: '!style', description: 'Wählt einen Stil', example: '!style anime' }, - { command: '!size', description: 'Setzt die Bildgröße', example: '!size 1024x1024' }, - ], - }, - { - id: 'tts-bot', - name: 'TTS Bot', - matrixUserId: '@tts-bot:matrix.mana.how', - description: 'Text-to-Speech Sprachausgabe', - longDescription: - 'Wandle Text in natürlich klingende Sprache um. Unterstützt verschiedene Stimmen und Sprachen.', - icon: 'SpeakerHigh', - color: 'from-cyan-500 to-sky-600', - category: 'media', - requiresAuth: true, - commands: [ - { - command: '!speak', - aliases: ['!say'], - description: 'Spricht Text vor', - example: '!speak Hallo Welt', - }, - { command: '!voice', description: 'Wählt eine Stimme', example: '!voice nova' }, - { command: '!lang', description: 'Setzt die Sprache', example: '!lang de' }, - ], - }, - { - id: 'storage-bot', - name: 'Storage Bot', - matrixUserId: '@storage-bot:matrix.mana.how', - description: 'Cloud-Speicher und Dateiverwaltung', - longDescription: - 'Verwalte deine Dateien in der Cloud. Lade hoch, teile und organisiere direkt aus dem Chat.', - icon: 'CloudArrowUp', - color: 'from-slate-500 to-zinc-600', - category: 'media', - requiresAuth: true, - commands: [ - { command: '!upload', description: 'Lädt eine Datei hoch' }, - { command: '!files', description: 'Listet deine Dateien' }, - { command: '!share', description: 'Teilt eine Datei', example: '!share document.pdf' }, - { command: '!delete', description: 'Löscht eine Datei' }, - ], - }, - - // Lifestyle - { - id: 'nutriphi-bot', - name: 'NutriPhi Bot', - matrixUserId: '@nutriphi-bot:matrix.mana.how', - description: 'Ernährungstracking und Mahlzeiten-Analyse', - longDescription: - 'Tracke deine Ernährung, analysiere Mahlzeiten per Foto und erhalte Nährwertinformationen.', - icon: 'ForkKnife', - color: 'from-lime-500 to-green-600', - category: 'lifestyle', - requiresAuth: true, - commands: [ - { - command: '!log', - description: 'Protokolliert eine Mahlzeit', - example: '!log 2 Äpfel, 1 Sandwich', - }, - { command: '!today', description: 'Zeigt heutige Kalorien' }, - { command: '!analyze', description: 'Analysiert ein Essens-Foto' }, - ], - }, - { - id: 'planta-bot', - name: 'Planta Bot', - matrixUserId: '@planta-bot:matrix.mana.how', - description: 'Pflanzenidentifikation und Pflege-Tipps', - longDescription: - 'Identifiziere Pflanzen per Foto und erhalte Pflege-Anleitungen. Perfekt für Hobbygärtner.', - icon: 'Plant', - color: 'from-green-600 to-emerald-700', - category: 'lifestyle', - requiresAuth: true, - commands: [ - { command: '!identify', description: 'Identifiziert eine Pflanze per Foto' }, - { command: '!care', description: 'Zeigt Pflegetipps', example: '!care Monstera' }, - { command: '!water', description: 'Erinnerung zum Gießen setzen' }, - ], - }, - { - id: 'zitare-bot', - name: 'Zitare Bot', - matrixUserId: '@zitare-bot:matrix.mana.how', - description: 'Tägliche Inspiration und Weisheiten', - longDescription: - 'Erhalte inspirierende Zitate und Weisheiten. Perfekt für den täglichen Motivationsschub.', - icon: 'Quotes', - color: 'from-amber-500 to-orange-600', - category: 'lifestyle', - requiresAuth: true, - commands: [ - { command: '!quote', aliases: ['!zitat'], description: 'Zeigt ein zufälliges Zitat' }, - { command: '!daily', description: 'Aktiviert tägliche Zitate' }, - { command: '!topic', description: 'Zitat zu einem Thema', example: '!topic Erfolg' }, - ], - }, - { - id: 'skilltree-bot', - name: 'SkillTree Bot', - matrixUserId: '@skilltree-bot:matrix.mana.how', - description: 'Fähigkeiten-Tracking und Lernfortschritt', - longDescription: - 'Verfolge deinen Lernfortschritt, setze Ziele und entwickle deine Fähigkeiten systematisch weiter.', - icon: 'TreeStructure', - color: 'from-yellow-500 to-amber-600', - category: 'lifestyle', - requiresAuth: true, - commands: [ - { command: '!skills', description: 'Zeigt deine Fähigkeiten' }, - { command: '!add', description: 'Fügt eine Fähigkeit hinzu', example: '!add TypeScript' }, - { - command: '!progress', - description: 'Protokolliert Fortschritt', - example: '!progress TypeScript +2h', - }, - ], - }, - - // Tools - { - id: 'clock-bot', - name: 'Clock Bot', - matrixUserId: '@clock-bot:matrix.mana.how', - description: 'Zeiterfassung und Zeitzonen', - longDescription: - 'Tracke Arbeitszeiten, konvertiere Zeitzonen und setze Timer für Fokus-Sessions.', - icon: 'Clock', - color: 'from-blue-600 to-indigo-700', - category: 'tools', - requiresAuth: true, - commands: [ - { command: '!start', description: 'Startet Zeiterfassung' }, - { command: '!stop', description: 'Stoppt Zeiterfassung' }, - { command: '!time', description: 'Zeigt aktuelle Zeit in Zonen', example: '!time NYC' }, - { command: '!timer', description: 'Setzt einen Timer', example: '!timer 25m Pomodoro' }, - ], - }, - { - id: 'stats-bot', - name: 'Stats Bot', - matrixUserId: '@stats-bot:matrix.mana.how', - description: 'Nutzungsstatistiken und Analytics', - longDescription: - 'Erhalte Einblicke in deine Nutzung der Mana-Services. Statistiken, Trends und Zusammenfassungen.', - icon: 'ChartBar', - color: 'from-fuchsia-500 to-pink-600', - category: 'tools', - requiresAuth: true, - commands: [ - { command: '!stats', description: 'Zeigt Nutzungsstatistiken' }, - { command: '!usage', description: 'Credits-Verbrauch diese Woche' }, - { command: '!top', description: 'Meistgenutzte Features' }, - ], - }, - { - id: 'questions-bot', - name: 'Questions Bot', - matrixUserId: '@questions-bot:matrix.mana.how', - description: 'Websuche und Fakten-Recherche', - longDescription: - 'Durchsuche das Web und erhalte fundierte Antworten mit Quellenangaben. Perfekt für Recherchen.', - icon: 'MagnifyingGlass', - color: 'from-teal-500 to-cyan-600', - category: 'tools', - requiresAuth: true, - commands: [ - { - command: '!search', - aliases: ['!q'], - description: 'Sucht im Web', - example: '!search Wetter Berlin', - }, - { command: '!wiki', description: 'Sucht auf Wikipedia', example: '!wiki Photosynthese' }, - { command: '!news', description: 'Aktuelle Nachrichten' }, - ], - }, - { - id: 'cards-bot', - name: 'Cards Bot', - matrixUserId: '@cards-bot:matrix.mana.how', - description: 'Lernkarten und Spaced Repetition', - longDescription: - 'Erstelle und lerne mit Karteikarten. Nutzt Spaced Repetition für optimales Lernen.', - icon: 'Cards', - color: 'from-violet-600 to-purple-700', - category: 'tools', - requiresAuth: true, - commands: [ - { command: '!learn', description: 'Startet eine Lernsession' }, - { command: '!add', description: 'Fügt eine Karte hinzu', example: '!add Frage | Antwort' }, - { command: '!decks', description: 'Listet deine Decks' }, - { command: '!stats', description: 'Zeigt Lernfortschritt' }, - ], - }, - { - id: 'presi-bot', - name: 'Presi Bot', - matrixUserId: '@presi-bot:matrix.mana.how', - description: 'Präsentationen erstellen mit KI', - longDescription: - 'Erstelle professionelle Präsentationen mit KI-Unterstützung. Generiere Folien aus Text oder Themen.', - icon: 'PresentationChart', - color: 'from-red-500 to-rose-600', - category: 'tools', - requiresAuth: true, - commands: [ - { - command: '!create', - description: 'Erstellt eine Präsentation', - example: '!create Thema: KI im Alltag', - }, - { command: '!slides', description: 'Zeigt deine Präsentationen' }, - { command: '!export', description: 'Exportiert als PDF/PPTX' }, - ], - }, -]; - -export const CATEGORIES = [ - { id: 'all', label: 'Alle' }, - { id: 'productivity', label: 'Produktivität' }, - { id: 'ai', label: 'KI & Chat' }, - { id: 'media', label: 'Medien' }, - { id: 'lifestyle', label: 'Lifestyle' }, - { id: 'tools', label: 'Tools' }, -] as const; - -export type BotCategory = (typeof CATEGORIES)[number]['id']; diff --git a/apps/matrix/apps/web/src/lib/i18n/index.ts b/apps/matrix/apps/web/src/lib/i18n/index.ts deleted file mode 100644 index 9dba087ec..000000000 --- a/apps/matrix/apps/web/src/lib/i18n/index.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { browser } from '$app/environment'; -import { init, register, locale, waitLocale } from 'svelte-i18n'; - -// List of supported locales -export const supportedLocales = ['de', 'en'] as const; -export type SupportedLocale = (typeof supportedLocales)[number]; - -// Default locale -const defaultLocale = 'de'; - -// Register all available locales -register('de', () => import('./locales/de.json')); -register('en', () => import('./locales/en.json')); - -// Get initial locale from browser or localStorage -function getInitialLocale(): SupportedLocale { - if (browser) { - // Check localStorage first - const stored = localStorage.getItem('matrix_locale'); - if (stored && supportedLocales.includes(stored as SupportedLocale)) { - return stored as SupportedLocale; - } - - // Fall back to browser language - const browserLang = navigator.language.split('-')[0]; - if (supportedLocales.includes(browserLang as SupportedLocale)) { - return browserLang as SupportedLocale; - } - } - - return defaultLocale; -} - -// Initialize i18n at module scope (required for SSR) -init({ - fallbackLocale: defaultLocale, - initialLocale: getInitialLocale(), -}); - -// Set locale and persist to localStorage -export function setLocale(newLocale: SupportedLocale) { - locale.set(newLocale); - if (browser) { - localStorage.setItem('matrix_locale', newLocale); - } -} - -// Wait for locale to be loaded (useful for SSR) -export { waitLocale }; diff --git a/apps/matrix/apps/web/src/lib/i18n/locales/de.json b/apps/matrix/apps/web/src/lib/i18n/locales/de.json deleted file mode 100644 index 6d6198aa3..000000000 --- a/apps/matrix/apps/web/src/lib/i18n/locales/de.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "app": { - "name": "Manalink", - "description": "Sicherer Matrix-Chat" - }, - "nav": { - "chat": "Chat", - "bots": "Bots", - "settings": "Einstellungen" - }, - "auth": { - "login": "Anmelden", - "logout": "Abmelden", - "connecting": "Verbinde mit Matrix...", - "connectionFailed": "Verbindung fehlgeschlagen", - "retry": "Erneut versuchen" - }, - "chat": { - "newChat": "Neuer Chat", - "createRoom": "Raum erstellen", - "sendMessage": "Nachricht senden", - "typeMessage": "Nachricht schreiben...", - "noRooms": "Noch keine Räume", - "noMessages": "Noch keine Nachrichten" - }, - "bots": { - "title": "Bots", - "subtitle": "Entdecke alle verfügbaren Bot-Assistenten", - "search": "Bot suchen...", - "startChat": "Chat starten", - "commands": "Befehle", - "example": "Beispiel", - "free": "Frei", - "requiresLogin": "Erfordert Anmeldung", - "noResults": "Keine Bots gefunden", - "found": "gefunden", - "categories": { - "all": "Alle", - "productivity": "Produktivität", - "ai": "KI & Chat", - "media": "Medien", - "lifestyle": "Lifestyle", - "tools": "Tools" - } - } -} diff --git a/apps/matrix/apps/web/src/lib/i18n/locales/en.json b/apps/matrix/apps/web/src/lib/i18n/locales/en.json deleted file mode 100644 index c7c415a00..000000000 --- a/apps/matrix/apps/web/src/lib/i18n/locales/en.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "app": { - "name": "Manalink", - "description": "Secure Matrix chat" - }, - "nav": { - "chat": "Chat", - "bots": "Bots", - "settings": "Settings" - }, - "auth": { - "login": "Sign in", - "logout": "Sign out", - "connecting": "Connecting to Matrix...", - "connectionFailed": "Connection failed", - "retry": "Retry" - }, - "chat": { - "newChat": "New Chat", - "createRoom": "Create Room", - "sendMessage": "Send message", - "typeMessage": "Type a message...", - "noRooms": "No rooms yet", - "noMessages": "No messages yet" - }, - "bots": { - "title": "Bots", - "subtitle": "Discover all available bot assistants", - "search": "Search bots...", - "startChat": "Start Chat", - "commands": "Commands", - "example": "Example", - "free": "Free", - "requiresLogin": "Requires login", - "noResults": "No bots found", - "found": "found", - "categories": { - "all": "All", - "productivity": "Productivity", - "ai": "AI & Chat", - "media": "Media", - "lifestyle": "Lifestyle", - "tools": "Tools" - } - } -} diff --git a/apps/matrix/apps/web/src/lib/matrix/client.test.ts b/apps/matrix/apps/web/src/lib/matrix/client.test.ts deleted file mode 100644 index 8c2f8f38c..000000000 --- a/apps/matrix/apps/web/src/lib/matrix/client.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { discoverHomeserver, checkHomeserver, loginWithToken } from './client'; - -// Mock matrix-js-sdk to avoid importing the full SDK in tests -vi.mock('matrix-js-sdk', () => ({ - createClient: vi.fn(), -})); - -vi.mock('./polyfills', () => ({})); - -describe('discoverHomeserver', () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - - it('extracts domain from Matrix user ID', async () => { - // Mock .well-known failing so we get the fallback - vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network error'))); - - const result = await discoverHomeserver('@user:example.com'); - expect(result).toBe('https://example.com'); - }); - - it('returns null for invalid user ID without domain', async () => { - const result = await discoverHomeserver('@user'); - expect(result).toBeNull(); - }); - - it('uses domain directly when no @ prefix', async () => { - vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network error'))); - - const result = await discoverHomeserver('matrix.org'); - expect(result).toBe('https://matrix.org'); - }); - - it('strips protocol prefix from domain', async () => { - vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network error'))); - - const result = await discoverHomeserver('https://matrix.org'); - expect(result).toBe('https://matrix.org'); - }); - - it('uses .well-known base_url when available', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - 'm.homeserver': { base_url: 'https://synapse.example.com/' }, - }), - }) - ); - - const result = await discoverHomeserver('example.com'); - expect(result).toBe('https://synapse.example.com'); - }); -}); - -describe('checkHomeserver', () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - - it('returns ok for reachable server', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true })); - - const result = await checkHomeserver('matrix.mana.how'); - expect(result).toEqual({ ok: true }); - }); - - it('prepends https:// if missing', async () => { - const mockFetch = vi.fn().mockResolvedValue({ ok: true }); - vi.stubGlobal('fetch', mockFetch); - - await checkHomeserver('matrix.mana.how'); - expect(mockFetch).toHaveBeenCalledWith('https://matrix.mana.how/_matrix/client/versions', { - method: 'GET', - }); - }); - - it('does not double-prepend https://', async () => { - const mockFetch = vi.fn().mockResolvedValue({ ok: true }); - vi.stubGlobal('fetch', mockFetch); - - await checkHomeserver('https://matrix.mana.how'); - expect(mockFetch).toHaveBeenCalledWith('https://matrix.mana.how/_matrix/client/versions', { - method: 'GET', - }); - }); - - it('returns error for non-ok response', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 502 })); - - const result = await checkHomeserver('matrix.mana.how'); - expect(result).toEqual({ ok: false, error: 'Server returned 502' }); - }); - - it('returns error for network failure', async () => { - vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Failed to fetch'))); - - const result = await checkHomeserver('matrix.mana.how'); - expect(result).toEqual({ ok: false, error: 'Failed to fetch' }); - }); -}); - -describe('loginWithToken', () => { - it('normalizes homeserver URL', async () => { - const result = await loginWithToken('matrix.mana.how', 'token123', '@user:matrix.mana.how'); - expect(result.success).toBe(true); - expect(result.credentials?.homeserver).toBe('https://matrix.mana.how'); - }); - - it('removes trailing slash from homeserver', async () => { - const result = await loginWithToken( - 'https://matrix.mana.how/', - 'token123', - '@user:matrix.mana.how' - ); - expect(result.credentials?.homeserver).toBe('https://matrix.mana.how'); - }); - - it('preserves provided deviceId', async () => { - const result = await loginWithToken( - 'matrix.mana.how', - 'token123', - '@user:matrix.mana.how', - 'MYDEVICE' - ); - expect(result.credentials?.deviceId).toBe('MYDEVICE'); - }); - - it('generates deviceId when not provided', async () => { - const result = await loginWithToken('matrix.mana.how', 'token123', '@user:matrix.mana.how'); - expect(result.credentials?.deviceId).toMatch(/^MANA_\d+$/); - }); -}); diff --git a/apps/matrix/apps/web/src/lib/matrix/client.ts b/apps/matrix/apps/web/src/lib/matrix/client.ts deleted file mode 100644 index d73c969c8..000000000 --- a/apps/matrix/apps/web/src/lib/matrix/client.ts +++ /dev/null @@ -1,252 +0,0 @@ -import type { MatrixCredentials, LoginResult } from './types'; - -/** - * Login with username and password - */ -export async function loginWithPassword( - homeserver: string, - username: string, - password: string -): Promise { - // Load polyfills first - await import('./polyfills'); - const { createClient } = await import('matrix-js-sdk'); - - // Normalize homeserver URL - let baseUrl = homeserver.trim(); - if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) { - baseUrl = `https://${baseUrl}`; - } - // Remove trailing slash - baseUrl = baseUrl.replace(/\/$/, ''); - - const tempClient = createClient({ baseUrl }); - - try { - const response = await tempClient.login('m.login.password', { - user: username, - password: password, - initial_device_display_name: 'Manalink', - }); - - return { - success: true, - credentials: { - homeserver: baseUrl, - accessToken: response.access_token, - userId: response.user_id, - deviceId: response.device_id, - }, - }; - } catch (err) { - const message = err instanceof Error ? err.message : 'Login failed'; - - // Provide more helpful error messages - if (message.includes('M_FORBIDDEN')) { - return { success: false, error: 'Invalid username or password' }; - } - if (message.includes('M_USER_DEACTIVATED')) { - return { success: false, error: 'This account has been deactivated' }; - } - if (message.includes('Failed to fetch') || message.includes('NetworkError')) { - return { success: false, error: 'Could not connect to homeserver' }; - } - - return { success: false, error: message }; - } -} - -/** - * Login with an existing access token (for SSO/OAuth flows) - */ -export async function loginWithToken( - homeserver: string, - accessToken: string, - userId: string, - deviceId?: string -): Promise { - // Normalize homeserver URL - let baseUrl = homeserver.trim(); - if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) { - baseUrl = `https://${baseUrl}`; - } - baseUrl = baseUrl.replace(/\/$/, ''); - - return { - success: true, - credentials: { - homeserver: baseUrl, - accessToken, - userId, - deviceId: deviceId || `MANA_${Date.now()}`, - }, - }; -} - -/** - * Discover homeserver from user ID or domain - * Uses .well-known discovery - */ -export async function discoverHomeserver(userIdOrDomain: string): Promise { - // Extract domain from user ID if provided - let domain = userIdOrDomain; - if (userIdOrDomain.startsWith('@')) { - const parts = userIdOrDomain.split(':'); - if (parts.length < 2) return null; - domain = parts[1]; - } - - // Remove any protocol prefix - domain = domain.replace(/^https?:\/\//, ''); - - try { - // Try .well-known discovery - const wellKnownUrl = `https://${domain}/.well-known/matrix/client`; - const response = await fetch(wellKnownUrl); - - if (response.ok) { - const data = await response.json(); - const baseUrl = data['m.homeserver']?.base_url; - if (baseUrl) { - return baseUrl.replace(/\/$/, ''); - } - } - } catch { - // .well-known not available - } - - // Fallback: assume homeserver is at the domain - return `https://${domain}`; -} - -/** - * Check if a homeserver is reachable - */ -export async function checkHomeserver( - homeserver: string -): Promise<{ ok: boolean; error?: string }> { - let baseUrl = homeserver.trim(); - if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) { - baseUrl = `https://${baseUrl}`; - } - - try { - const response = await fetch(`${baseUrl}/_matrix/client/versions`, { - method: 'GET', - }); - - if (response.ok) { - return { ok: true }; - } - - return { ok: false, error: `Server returned ${response.status}` }; - } catch (err) { - return { - ok: false, - error: err instanceof Error ? err.message : 'Could not connect to server', - }; - } -} - -/** - * Login with a Matrix SSO login token (for SSO/OAuth callback) - * This exchanges the loginToken from SSO redirect for proper credentials - */ -export async function loginWithLoginToken( - homeserver: string, - loginToken: string -): Promise { - // Load polyfills first - await import('./polyfills'); - const { createClient } = await import('matrix-js-sdk'); - - // Normalize homeserver URL - let baseUrl = homeserver.trim(); - if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) { - baseUrl = `https://${baseUrl}`; - } - // Remove trailing slash - baseUrl = baseUrl.replace(/\/$/, ''); - - const tempClient = createClient({ baseUrl }); - - try { - const response = await tempClient.login('m.login.token', { - token: loginToken, - initial_device_display_name: 'Manalink', - }); - - return { - success: true, - credentials: { - homeserver: baseUrl, - accessToken: response.access_token, - userId: response.user_id, - deviceId: response.device_id, - }, - }; - } catch (err) { - const message = err instanceof Error ? err.message : 'Login failed'; - - // Provide more helpful error messages - if (message.includes('M_UNKNOWN_TOKEN') || message.includes('M_FORBIDDEN')) { - return { success: false, error: 'Login token expired or invalid. Please try again.' }; - } - if (message.includes('Failed to fetch') || message.includes('NetworkError')) { - return { success: false, error: 'Could not connect to homeserver' }; - } - - return { success: false, error: message }; - } -} - -/** - * Register a new account (if registration is open) - */ -export async function register( - homeserver: string, - username: string, - password: string -): Promise { - await import('./polyfills'); - const { createClient } = await import('matrix-js-sdk'); - - let baseUrl = homeserver.trim(); - if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) { - baseUrl = `https://${baseUrl}`; - } - baseUrl = baseUrl.replace(/\/$/, ''); - - const tempClient = createClient({ baseUrl }); - - try { - const response = await tempClient.register(username, password, null, { - initial_device_display_name: 'Manalink', - } as any); - - return { - success: true, - credentials: { - homeserver: baseUrl, - accessToken: response.access_token!, - userId: response.user_id, - deviceId: response.device_id!, - }, - }; - } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Registration failed'; - - // Check for common errors - if (message.includes('M_USER_IN_USE')) { - return { success: false, error: 'Username is already taken' }; - } - if (message.includes('M_INVALID_USERNAME')) { - return { success: false, error: 'Invalid username format' }; - } - if (message.includes('M_FORBIDDEN')) { - return { success: false, error: 'Registration is disabled on this server' }; - } - - return { success: false, error: message }; - } -} diff --git a/apps/matrix/apps/web/src/lib/matrix/crypto.ts b/apps/matrix/apps/web/src/lib/matrix/crypto.ts deleted file mode 100644 index bc04d22c3..000000000 --- a/apps/matrix/apps/web/src/lib/matrix/crypto.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * Crypto utilities for Matrix E2EE - */ - -import type { MatrixClient } from 'matrix-js-sdk'; - -/** - * SAS Emoji data type from matrix-js-sdk - */ -export interface SasEmoji { - emoji: string; - description: string; -} - -/** - * Verification emoji set (7 emojis) - */ -export type EmojiSet = [SasEmoji, SasEmoji, SasEmoji, SasEmoji, SasEmoji, SasEmoji, SasEmoji]; - -/** - * Format device name for display - */ -export function formatDeviceName(displayName?: string, deviceId?: string): string { - if (displayName) return displayName; - if (deviceId) { - // Show first 8 characters of device ID - return `Device ${deviceId.substring(0, 8)}...`; - } - return 'Unknown Device'; -} - -/** - * Format timestamp for device last seen - */ -export function formatLastSeen(timestamp?: number): string { - if (!timestamp) return 'Unknown'; - - const date = new Date(timestamp); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); - - if (diffDays === 0) return 'Today'; - if (diffDays === 1) return 'Yesterday'; - if (diffDays < 7) return `${diffDays} days ago`; - if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`; - if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`; - return date.toLocaleDateString(); -} - -/** - * Check if recovery key format is valid - * Recovery keys are base58 encoded, 28-32 characters - */ -export function isValidRecoveryKey(key: string): boolean { - const trimmed = key.trim().replace(/\s+/g, ''); - // Recovery keys are typically ~59 characters, space-separated into groups - // Valid characters are Base58 (no 0, O, I, l) - const base58Regex = /^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$/; - return trimmed.length >= 28 && base58Regex.test(trimmed); -} - -/** - * Format recovery key for display (add spaces every 4 chars) - */ -export function formatRecoveryKey(key: string): string { - const trimmed = key.replace(/\s+/g, ''); - return trimmed.match(/.{1,4}/g)?.join(' ') || key; -} - -/** - * Get encryption warning level for a room - */ -export function getEncryptionWarningLevel( - encrypted: boolean, - allVerified: boolean -): 'none' | 'warning' | 'secure' { - if (!encrypted) return 'none'; - return allVerified ? 'secure' : 'warning'; -} - -/** - * Generate a device display name based on browser/OS info - */ -export function generateDeviceName(): string { - if (typeof navigator === 'undefined') return 'Manalink'; - - const ua = navigator.userAgent; - let browser = 'Browser'; - let os = 'Desktop'; - - // Detect browser - if (ua.includes('Firefox')) browser = 'Firefox'; - else if (ua.includes('Edg')) browser = 'Edge'; - else if (ua.includes('Chrome')) browser = 'Chrome'; - else if (ua.includes('Safari')) browser = 'Safari'; - - // Detect OS - if (ua.includes('Windows')) os = 'Windows'; - else if (ua.includes('Mac')) os = 'macOS'; - else if (ua.includes('Linux')) os = 'Linux'; - else if (ua.includes('Android')) os = 'Android'; - else if (ua.includes('iPhone') || ua.includes('iPad')) os = 'iOS'; - - return `Manalink (${browser} on ${os})`; -} - -/** - * Check if cross-signing should be bootstrapped - */ -export async function shouldBootstrapCrossSigning(client: MatrixClient): Promise { - const crypto = client.getCrypto(); - if (!crypto) return false; - - try { - const status = await crypto.getCrossSigningStatus(); - // Should bootstrap if we don't have keys on device - return !status.publicKeysOnDevice; - } catch { - return true; - } -} - -/** - * Check if key backup should be setup - */ -export async function shouldSetupKeyBackup(client: MatrixClient): Promise { - const crypto = client.getCrypto(); - if (!crypto) return false; - - try { - const backupVersion = await crypto.getActiveSessionBackupVersion(); - return backupVersion === null; - } catch { - return true; - } -} diff --git a/apps/matrix/apps/web/src/lib/matrix/index.ts b/apps/matrix/apps/web/src/lib/matrix/index.ts deleted file mode 100644 index 971c9cd11..000000000 --- a/apps/matrix/apps/web/src/lib/matrix/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Matrix client exports -export { matrixStore } from './store.svelte'; -export { - loginWithPassword, - loginWithToken, - loginWithLoginToken, - discoverHomeserver, - checkHomeserver, - register, -} from './client'; -export * from './types'; -export * from './crypto'; diff --git a/apps/matrix/apps/web/src/lib/matrix/polyfills.ts b/apps/matrix/apps/web/src/lib/matrix/polyfills.ts deleted file mode 100644 index 5d1648ebc..000000000 --- a/apps/matrix/apps/web/src/lib/matrix/polyfills.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Polyfills required for matrix-js-sdk to work in browser environment - * Must be imported before any matrix-js-sdk imports - */ -import { Buffer } from 'buffer'; - -if (typeof window !== 'undefined') { - // Global object polyfill - window.global = window.globalThis; - - // Buffer polyfill (used by matrix-js-sdk for binary data) - (window as Window).Buffer = Buffer; - - // Process polyfill (some dependencies check process.env) - (window as Window).process = { env: {} }; -} - -export {}; diff --git a/apps/matrix/apps/web/src/lib/matrix/store.svelte.ts b/apps/matrix/apps/web/src/lib/matrix/store.svelte.ts deleted file mode 100644 index 85c3944c9..000000000 --- a/apps/matrix/apps/web/src/lib/matrix/store.svelte.ts +++ /dev/null @@ -1,2019 +0,0 @@ -import { browser } from '$app/environment'; -import type { MatrixClient, Room, MatrixEvent, RoomMember as SDKRoomMember } from 'matrix-js-sdk'; -import { - showMessageNotification, - canShowNotifications, - isDocumentFocused, -} from '$lib/notifications'; -import type { - SyncState, - MatrixCredentials, - SimpleRoom, - SimpleMessage, - MessageType, - MessageReaction, - ReadReceipt, - RoomMember, - VerificationStatus, - DeviceInfo, - VerificationRequest, - CryptoCallbacks, - CrossSigningStatus, - PresenceState, - UserPresence, - SimpleCall, - CallCallbacks, - CallState as CallStateType, - CallType, - CallDirection, - RoomWidget, -} from './types'; - -const STORAGE_KEY = 'matrix_credentials'; -const LAST_ROOM_KEY = 'matrix_last_room'; - -/** - * Reactive Matrix store using Svelte 5 runes - */ -class MatrixStore { - // ───────────────────────────────────────────────────────── - // Private State - // ───────────────────────────────────────────────────────── - private _client = $state(null); - private _syncState = $state('STOPPED'); - private _rooms = $state([]); - private _currentRoomId = $state(null); - private _timeline = $state([]); - private _typingUsers = $state>(new Map()); - private _userPresence = $state>(new Map()); - private _error = $state(null); - private _initialized = $state(false); - - // Crypto State - private _cryptoReady = $state(false); - private _verificationStatus = $state('unknown'); - private _activeVerification = $state(null); - private _keyBackupEnabled = $state(false); - private _crossSigningReady = $state(false); - private _cryptoCallbacks: CryptoCallbacks = {}; - - // VoIP / Call State - private _activeCall = $state(null); - private _incomingCall = $state(null); - private _callCallbacks: CallCallbacks = {}; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private _matrixCall: any = null; // The actual MatrixCall object - - // ───────────────────────────────────────────────────────── - // Public Getters - // ───────────────────────────────────────────────────────── - get client() { - return this._client; - } - get syncState() { - return this._syncState; - } - get error() { - return this._error; - } - get initialized() { - return this._initialized; - } - get currentRoomId() { - return this._currentRoomId; - } - - // Crypto Getters - get cryptoReady() { - return this._cryptoReady; - } - get verificationStatus() { - return this._verificationStatus; - } - get activeVerification() { - return this._activeVerification; - } - get keyBackupEnabled() { - return this._keyBackupEnabled; - } - get crossSigningReady() { - return this._crossSigningReady; - } - - // VoIP Getters - get activeCall() { - return this._activeCall; - } - get incomingCall() { - return this._incomingCall; - } - get hasActiveCall() { - return this._activeCall !== null; - } - get hasIncomingCall() { - return this._incomingCall !== null; - } - - /** - * Get presence for a specific user - */ - getUserPresence(userId: string): UserPresence | undefined { - return this._userPresence.get(userId); - } - - /** - * Check if a user is currently online - */ - isUserOnline(userId: string): boolean { - const presence = this._userPresence.get(userId); - return presence?.presence === 'online' || presence?.currentlyActive === true; - } - - // ───────────────────────────────────────────────────────── - // Derived State - // ───────────────────────────────────────────────────────── - - /** Is the client ready to use? */ - isReady = $derived(this._syncState === 'PREPARED' || this._syncState === 'SYNCING'); - - /** Is currently syncing? */ - isSyncing = $derived(this._syncState === 'SYNCING' || this._syncState === 'CATCHUP'); - - /** Current user ID */ - userId = $derived(this._client?.getUserId() || null); - - /** Simplified room list sorted by last activity */ - rooms = $derived( - this._rooms - .map((room) => this.roomToSimpleRoom(room)) - .sort((a, b) => (b.lastMessageTime || 0) - (a.lastMessageTime || 0)) - ); - - /** Joined rooms only */ - joinedRooms = $derived(this.rooms.filter((r) => r.membership === 'join')); - - /** Invited rooms */ - invitedRooms = $derived(this.rooms.filter((r) => r.membership === 'invite')); - - /** Direct message rooms (joined only) */ - directRooms = $derived(this.joinedRooms.filter((r) => r.isDirect)); - - /** Group rooms (non-DM, joined only) */ - groupRooms = $derived(this.joinedRooms.filter((r) => !r.isDirect)); - - /** Current selected room */ - currentRoom = $derived( - this._currentRoomId ? this._rooms.find((r) => r.roomId === this._currentRoomId) || null : null - ); - - /** Current room as SimpleRoom */ - currentSimpleRoom = $derived(this.currentRoom ? this.roomToSimpleRoom(this.currentRoom) : null); - - /** Messages in current room */ - messages = $derived( - this._timeline - .filter((e) => e.getType() === 'm.room.message') - .map((e) => this.eventToSimpleMessage(e, this._timeline)) - ); - - /** Users currently typing in current room */ - currentRoomTyping = $derived( - this._currentRoomId ? this._typingUsers.get(this._currentRoomId) || [] : [] - ); - - /** Total unread count across all rooms */ - totalUnreadCount = $derived(this.rooms.reduce((sum, r) => sum + r.unreadCount, 0)); - - // ───────────────────────────────────────────────────────── - // Initialization - // ───────────────────────────────────────────────────────── - - /** - * Initialize the Matrix client - * @param credentials Optional credentials, will load from storage if not provided - */ - async initialize(credentials?: MatrixCredentials): Promise { - if (!browser) return false; - if (this._initialized && this._client) return true; - - // Load polyfills first - await import('./polyfills'); - - // Get credentials - const creds = credentials || this.loadCredentials(); - if (!creds) { - this._error = 'No credentials available'; - return false; - } - - try { - const sdk = await import('matrix-js-sdk'); - - this._client = sdk.createClient({ - baseUrl: creds.homeserver, - accessToken: creds.accessToken, - userId: creds.userId, - deviceId: creds.deviceId, - timelineSupport: true, - }); - - this.setupEventHandlers(sdk); - - // Initialize Rust Crypto - try { - await this._client.initRustCrypto(); - this._cryptoReady = true; - - // Setup crypto event handlers - this.setupCryptoEventHandlers(sdk); - } catch { - this._cryptoReady = false; - } - - await this._client.startClient({ - initialSyncLimit: 20, - lazyLoadMembers: true, - }); - - this.saveCredentials(creds); - this._initialized = true; - this._error = null; - - return true; - } catch (err) { - this._error = err instanceof Error ? err.message : 'Failed to initialize Matrix client'; - console.error('Matrix initialization error:', err); - return false; - } - } - - /** - * Setup event handlers for Matrix SDK events - */ - private setupEventHandlers(sdk: typeof import('matrix-js-sdk')) { - if (!this._client) return; - - // Sync state changes - this._client.on(sdk.ClientEvent.Sync, (state, prevState) => { - this._syncState = state as SyncState; - - if (state === 'PREPARED') { - this._rooms = this._client!.getRooms(); - } - - if (state === 'ERROR') { - this._error = 'Sync error occurred'; - } - }); - - // Room timeline updates (new messages) - this._client.on(sdk.RoomEvent.Timeline, (event, room, toStartOfTimeline) => { - // Skip historical events from pagination - if (toStartOfTimeline) return; - - // Update rooms list - this._rooms = this._client!.getRooms(); - - // Update timeline if we're in this room - if (room?.roomId === this._currentRoomId) { - this._timeline = [...(room.getLiveTimeline().getEvents() || [])]; - } - - // Show browser notification for new messages from others - if ( - browser && - event.getType() === 'm.room.message' && - event.getSender() !== this._client!.getUserId() && - !isDocumentFocused() - ) { - const content = event.getContent(); - const body = content?.body || ''; - const senderName = this.getSenderName(event); - const roomName = room?.name || 'Unbekannt'; - - showMessageNotification(senderName, body, roomName, { - onClick: () => { - if (room) { - this.selectRoom(room.roomId); - } - }, - }); - } - }); - - // Typing indicators - this._client.on(sdk.RoomMemberEvent.Typing, (event, member) => { - const roomId = event.getRoomId(); - if (!roomId) return; - - const room = this._client!.getRoom(roomId); - const typingMembers = - room - ?.getMembersWithMembership('join') - .filter((m) => m.typing && m.userId !== this._client!.getUserId()) - .map((m) => m.name || m.userId) || []; - - // Trigger reactivity by creating new Map - const newMap = new Map(this._typingUsers); - newMap.set(roomId, typingMembers); - this._typingUsers = newMap; - }); - - // Room membership changes (invites, joins, leaves) - this._client.on(sdk.RoomEvent.MyMembership, (room, membership, prevMembership) => { - this._rooms = this._client!.getRooms(); - }); - - // Room name/state changes - this._client.on(sdk.RoomStateEvent.Events, (event, state, prevEvent) => { - // Trigger reactivity for room updates - this._rooms = this._client!.getRooms(); - }); - - // User presence changes - this._client.on(sdk.UserEvent.Presence, (event, user) => { - if (!user) return; - - const userId = user.userId; - const presence: UserPresence = { - userId, - presence: (user.presence as PresenceState) || 'offline', - lastActiveAgo: user.lastActiveAgo, - statusMessage: user.presenceStatusMsg, - currentlyActive: user.currentlyActive, - }; - - // Trigger reactivity by creating new Map - const newMap = new Map(this._userPresence); - newMap.set(userId, presence); - this._userPresence = newMap; - - // Also trigger room list update for DMs - this._rooms = this._client!.getRooms(); - }); - - // Read receipt updates - this._client.on(sdk.RoomEvent.Receipt, (event, room) => { - // Update timeline if we're in this room to refresh read receipts - if (room.roomId === this._currentRoomId) { - this._timeline = [...(room.getLiveTimeline().getEvents() || [])]; - } - }); - - // Incoming calls - // CallEvent is exported from matrix-js-sdk, but CallState needs dynamic import from webrtc module - // eslint-disable-next-line @typescript-eslint/no-explicit-any - this._client.on('Call.incoming' as any, async (call: any) => { - try { - const webrtc = await import('matrix-js-sdk/lib/webrtc/call'); - this.handleIncomingCall(call, webrtc.CallEvent, webrtc.CallState); - } catch (err) { - console.error('Error handling incoming call:', err); - } - }); - } - - /** - * Setup crypto event handlers - * Note: Uses loose typing due to matrix-js-sdk type complexity - */ - private async setupCryptoEventHandlers(_sdk: typeof import('matrix-js-sdk')) { - if (!this._client || !this._cryptoReady) return; - - const crypto = this._client.getCrypto(); - if (!crypto) return; - - try { - // Import CryptoEvent separately - types may vary by SDK version - const cryptoApi = await import('matrix-js-sdk/lib/crypto-api'); - const CryptoEvent = cryptoApi.CryptoEvent; - - // Verification request received - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (this._client as any).on(CryptoEvent.VerificationRequestReceived, (request: unknown) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const req = request as any; - const verificationRequest: VerificationRequest = { - requestId: req.transactionId || req.id || '', - otherUserId: req.otherUserId || '', - otherDeviceId: req.otherDeviceId, - phase: this.mapVerificationPhase(req.phase ?? 0), - isSelfVerification: req.isSelfVerification ?? false, - methods: (req.methods || []) as VerificationRequest['methods'], - }; - - this._activeVerification = verificationRequest; - this._cryptoCallbacks.onVerificationRequest?.(verificationRequest); - }); - - // Keys changed (e.g., new device added) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (this._client as any).on(CryptoEvent.KeysChanged, () => { - this.checkVerificationStatus(); - }); - - // Key backup status - check if event exists - if ('KeyBackupStatus' in CryptoEvent) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (this._client as any).on((CryptoEvent as any).KeyBackupStatus, (enabled: boolean) => { - this._keyBackupEnabled = enabled; - this._cryptoCallbacks.onKeyBackupStatus?.(enabled); - }); - } - } catch { - // Crypto event handlers not fully supported in this SDK version - } - - // Initial status check - this.checkVerificationStatus(); - this.checkKeyBackupStatus(); - } - - /** - * Map SDK verification phase to our type - */ - private mapVerificationPhase(phase: number): VerificationRequest['phase'] { - // Phase values from matrix-js-sdk VerificationPhase enum - const phaseMap: Record = { - 0: 'created', - 1: 'requested', - 2: 'ready', - 3: 'started', - 4: 'done', - 5: 'cancelled', - }; - return phaseMap[phase] || 'created'; - } - - // ───────────────────────────────────────────────────────── - // Room Actions - // ───────────────────────────────────────────────────────── - - /** - * Select a room to view - */ - selectRoom(roomId: string) { - this._currentRoomId = roomId; - const room = this._client?.getRoom(roomId); - - if (room) { - this._timeline = room.getLiveTimeline().getEvents() || []; - - // Mark as read - const lastEvent = this._timeline[this._timeline.length - 1]; - if (lastEvent) { - this._client?.sendReadReceipt(lastEvent).catch(console.error); - } - - // Save last room to localStorage - if (browser) { - localStorage.setItem(LAST_ROOM_KEY, roomId); - } - } else { - this._timeline = []; - } - } - - /** - * Clear current room selection - */ - clearRoom() { - this._currentRoomId = null; - this._timeline = []; - } - - /** - * Join a room by ID or alias - */ - async joinRoom(roomIdOrAlias: string): Promise { - if (!this._client) return false; - - try { - await this._client.joinRoom(roomIdOrAlias); - this._rooms = this._client.getRooms(); - return true; - } catch (err) { - this._error = err instanceof Error ? err.message : 'Failed to join room'; - return false; - } - } - - /** - * Leave a room - */ - async leaveRoom(roomId: string): Promise { - if (!this._client) return false; - - try { - await this._client.leave(roomId); - - if (this._currentRoomId === roomId) { - this.clearRoom(); - } - - this._rooms = this._client.getRooms(); - return true; - } catch (err) { - this._error = err instanceof Error ? err.message : 'Failed to leave room'; - return false; - } - } - - /** - * Create a new room - */ - async createRoom(options: { - name?: string; - topic?: string; - isDirect?: boolean; - invite?: string[]; - }): Promise { - if (!this._client) return null; - - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = await this._client.createRoom({ - name: options.name, - topic: options.topic, - is_direct: options.isDirect, - invite: options.invite, - preset: (options.isDirect ? 'trusted_private_chat' : 'private_chat') as any, - }); - - this._rooms = this._client.getRooms(); - return result.room_id; - } catch (err) { - this._error = err instanceof Error ? err.message : 'Failed to create room'; - return null; - } - } - - // ───────────────────────────────────────────────────────── - // Message Actions - // ───────────────────────────────────────────────────────── - - /** - * Send a text message to current room - */ - async sendMessage(body: string): Promise { - if (!this._client || !this._currentRoomId) return false; - - try { - await this._client.sendTextMessage(this._currentRoomId, body); - return true; - } catch (err) { - this._error = err instanceof Error ? err.message : 'Failed to send message'; - return false; - } - } - - /** - * Send a message to a specific room (for forwarding) - */ - async sendMessageToRoom(roomId: string, body: string): Promise { - if (!this._client) return false; - - try { - await this._client.sendTextMessage(roomId, body); - return true; - } catch (err) { - this._error = err instanceof Error ? err.message : 'Failed to send message'; - return false; - } - } - - /** - * Send typing indicator - */ - async sendTyping(typing: boolean): Promise { - if (!this._client || !this._currentRoomId) return; - - try { - await this._client.sendTyping(this._currentRoomId, typing, typing ? 30000 : 0); - } catch (err) { - // Ignore typing errors - } - } - - /** - * Load more messages (pagination) - */ - async loadMoreMessages(limit = 50): Promise { - if (!this._client || !this._currentRoomId) return false; - - const room = this._client.getRoom(this._currentRoomId); - if (!room) return false; - - try { - await this._client.scrollback(room, limit); - this._timeline = room.getLiveTimeline().getEvents() || []; - return true; - } catch (err) { - this._error = err instanceof Error ? err.message : 'Failed to load messages'; - return false; - } - } - - /** - * Send a file/image to current room - */ - async sendFile(file: File, onProgress?: (progress: number) => void): Promise { - if (!this._client || !this._currentRoomId) return false; - - try { - // Upload to Matrix media repo - const uploadResponse = await this._client.uploadContent(file, { - progressHandler: (progress) => { - if (onProgress) { - onProgress(Math.round((progress.loaded / progress.total) * 100)); - } - }, - }); - - const mxcUrl = uploadResponse.content_uri; - - // Determine message type based on MIME type - const isImage = file.type.startsWith('image/'); - const isVideo = file.type.startsWith('video/'); - const isAudio = file.type.startsWith('audio/'); - - let msgtype = 'm.file'; - if (isImage) msgtype = 'm.image'; - if (isVideo) msgtype = 'm.video'; - if (isAudio) msgtype = 'm.audio'; - - // Build content based on type - const content: Record = { - msgtype, - body: file.name, - filename: file.name, - info: { - mimetype: file.type, - size: file.size, - }, - url: mxcUrl, - }; - - // Add dimensions for images - if (isImage) { - const dimensions = await this.getImageDimensions(file); - if (dimensions) { - (content.info as Record).w = dimensions.width; - (content.info as Record).h = dimensions.height; - } - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await this._client.sendMessage(this._currentRoomId, content as any); - return true; - } catch (err) { - this._error = err instanceof Error ? err.message : 'Failed to send file'; - return false; - } - } - - /** - * Get image dimensions - */ - private getImageDimensions(file: File): Promise<{ width: number; height: number } | null> { - return new Promise((resolve) => { - if (!file.type.startsWith('image/')) { - resolve(null); - return; - } - - const img = new Image(); - img.onload = () => { - resolve({ width: img.width, height: img.height }); - URL.revokeObjectURL(img.src); - }; - img.onerror = () => resolve(null); - img.src = URL.createObjectURL(file); - }); - } - - /** - * Get HTTP URL for Matrix media (mxc:// URLs) - */ - getMediaUrl(mxcUrl: string, width?: number, height?: number): string | null { - if (!this._client || !mxcUrl?.startsWith('mxc://')) return null; - - if (width && height) { - return this._client.mxcUrlToHttp(mxcUrl, width, height, 'scale') || null; - } - return this._client.mxcUrlToHttp(mxcUrl) || null; - } - - /** - * Reply to a message - */ - async replyToMessage(eventId: string, body: string): Promise { - if (!this._client || !this._currentRoomId) return false; - - const room = this._client.getRoom(this._currentRoomId); - const originalEvent = room?.findEventById(eventId); - if (!originalEvent) return false; - - try { - const content = { - msgtype: 'm.text', - body: `> <${originalEvent.getSender()}> ${originalEvent.getContent().body}\n\n${body}`, - format: 'org.matrix.custom.html', - formatted_body: `
    In reply to ${originalEvent.getSender()}
    ${originalEvent.getContent().body}
    ${body}`, - 'm.relates_to': { - 'm.in_reply_to': { - event_id: eventId, - }, - }, - }; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await this._client.sendMessage(this._currentRoomId, content as any); - return true; - } catch (err) { - this._error = err instanceof Error ? err.message : 'Failed to send reply'; - return false; - } - } - - /** - * Edit a message - */ - async editMessage(eventId: string, newBody: string): Promise { - if (!this._client || !this._currentRoomId) return false; - - try { - const content = { - msgtype: 'm.text', - body: `* ${newBody}`, - 'm.new_content': { - msgtype: 'm.text', - body: newBody, - }, - 'm.relates_to': { - rel_type: 'm.replace', - event_id: eventId, - }, - }; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await this._client.sendMessage(this._currentRoomId, content as any); - return true; - } catch (err) { - this._error = err instanceof Error ? err.message : 'Failed to edit message'; - return false; - } - } - - /** - * Delete (redact) a message - */ - async deleteMessage(eventId: string, reason?: string): Promise { - if (!this._client || !this._currentRoomId) return false; - - try { - await this._client.redactEvent(this._currentRoomId, eventId, undefined, { reason }); - return true; - } catch (err) { - this._error = err instanceof Error ? err.message : 'Failed to delete message'; - return false; - } - } - - /** - * React to a message with an emoji - */ - async reactToMessage(eventId: string, emoji: string): Promise { - if (!this._client || !this._currentRoomId) return false; - - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (this._client as any).sendEvent(this._currentRoomId, 'm.reaction', { - 'm.relates_to': { - rel_type: 'm.annotation', - event_id: eventId, - key: emoji, - }, - }); - return true; - } catch (err) { - this._error = err instanceof Error ? err.message : 'Failed to react'; - return false; - } - } - - // ───────────────────────────────────────────────────────── - // User Actions - // ───────────────────────────────────────────────────────── - - /** - * Invite a user to a room - */ - async inviteUser(roomId: string, userId: string): Promise { - if (!this._client) return false; - - try { - await this._client.invite(roomId, userId); - return true; - } catch (err) { - this._error = err instanceof Error ? err.message : 'Failed to invite user'; - return false; - } - } - - /** - * Kick a user from a room - */ - async kickUser(roomId: string, userId: string, reason?: string): Promise { - if (!this._client) return false; - - try { - await this._client.kick(roomId, userId, reason); - return true; - } catch (err) { - this._error = err instanceof Error ? err.message : 'Failed to kick user'; - return false; - } - } - - /** - * Search for users by name or ID - */ - async searchUsers( - query: string, - limit = 10 - ): Promise<{ userId: string; displayName?: string; avatarUrl?: string }[]> { - if (!this._client || !query.trim()) return []; - - try { - const result = await this._client.searchUserDirectory({ term: query, limit }); - return result.results.map((user) => ({ - userId: user.user_id, - displayName: user.display_name, - avatarUrl: user.avatar_url - ? this.getMediaUrl(user.avatar_url, 40, 40) || undefined - : undefined, - })); - } catch { - return []; - } - } - - /** - * Search messages in the current room - */ - async searchMessages( - query: string, - roomId?: string - ): Promise< - { - eventId: string; - sender: string; - senderName: string; - body: string; - timestamp: number; - roomId: string; - roomName: string; - }[] - > { - if (!this._client || !query.trim()) return []; - - const targetRoomId = roomId || this._currentRoomId; - - try { - // Use Matrix search API - const searchResult = await this._client.searchRoomEvents({ - term: query, - filter: targetRoomId - ? { - rooms: [targetRoomId], - } - : undefined, - }); - - const results: { - eventId: string; - sender: string; - senderName: string; - body: string; - timestamp: number; - roomId: string; - roomName: string; - }[] = []; - - // Process search results - cast to any since SDK types are incomplete - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const searchData = searchResult as any; - const searchResults = searchData?.search_categories?.room_events?.results || []; - for (const result of searchResults) { - const event = result.result; - if (!event) continue; - - const eventRoomId = event.room_id; - const room = this._client.getRoom(eventRoomId); - const content = event.content as { body?: string }; - - results.push({ - eventId: event.event_id || '', - sender: event.sender || '', - senderName: room?.getMember(event.sender || '')?.name || event.sender || 'Unbekannt', - body: content?.body || '', - timestamp: event.origin_server_ts || 0, - roomId: eventRoomId || '', - roomName: room?.name || 'Unbekannt', - }); - } - - return results; - } catch (e) { - console.error('Search failed:', e); - return []; - } - } - - /** - * Get room members - */ - getRoomMembers(roomId?: string): RoomMember[] { - const id = roomId || this._currentRoomId; - if (!this._client || !id) return []; - - const room = this._client.getRoom(id); - if (!room) return []; - - // Get power levels from room state - const powerLevelsEvent = room.currentState.getStateEvents('m.room.power_levels', ''); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const powerLevels = (powerLevelsEvent as any)?.getContent?.()?.users || {}; - const defaultPowerLevel = (powerLevelsEvent as any)?.getContent?.()?.users_default || 0; - - return room.getMembersWithMembership('join').map((member) => ({ - userId: member.userId, - displayName: member.name || member.userId, - avatarUrl: - member.getAvatarUrl(this._client!.baseUrl, 40, 40, 'scale', false, false) || undefined, - membership: member.membership as RoomMember['membership'], - powerLevel: powerLevels[member.userId] ?? defaultPowerLevel, - })); - } - - /** - * Get widgets in a room - */ - getRoomWidgets(roomId?: string): RoomWidget[] { - const id = roomId || this._currentRoomId; - if (!this._client || !id) return []; - - const room = this._client.getRoom(id); - if (!room) return []; - - const widgets: RoomWidget[] = []; - - // Get all widget state events (im.vector.modular.widgets is the standard type) - const widgetEvents = room.currentState.getStateEvents('im.vector.modular.widgets'); - - for (const event of widgetEvents) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const content = (event as any).getContent?.(); - if (!content || !content.url) continue; // Skip removed widgets - - widgets.push({ - id: content.id || (event as any).getStateKey?.() || '', - type: content.type || 'm.custom', - name: content.name || 'Widget', - url: content.url, - creatorUserId: content.creatorUserId || (event as any).getSender?.() || '', - data: content.data, - }); - } - - return widgets; - } - - /** - * Build widget URL with Matrix variable substitution - */ - buildWidgetUrl(widget: RoomWidget, roomId?: string): string { - const id = roomId || this._currentRoomId; - const userId = this._client?.getUserId() || ''; - - let url = widget.url; - - // Substitute Matrix variables - url = url.replace(/\$matrix_user_id/g, encodeURIComponent(userId)); - url = url.replace(/\$matrix_room_id/g, encodeURIComponent(id || '')); - url = url.replace( - /\$matrix_display_name/g, - encodeURIComponent(userId.split(':')[0].substring(1)) - ); - url = url.replace(/\$matrix_avatar_url/g, ''); - - return url; - } - - // ───────────────────────────────────────────────────────── - // Crypto Actions - // ───────────────────────────────────────────────────────── - - /** - * Set crypto callbacks for UI notifications - */ - setCryptoCallbacks(callbacks: CryptoCallbacks) { - this._cryptoCallbacks = callbacks; - } - - /** - * Check current verification status - */ - async checkVerificationStatus(): Promise { - if (!this._client || !this._cryptoReady) { - this._verificationStatus = 'unknown'; - return; - } - - try { - const crypto = this._client.getCrypto(); - if (!crypto) { - this._verificationStatus = 'unknown'; - return; - } - - const crossSigningStatus = await crypto.getCrossSigningStatus(); - if (crossSigningStatus.publicKeysOnDevice && crossSigningStatus.privateKeysCachedLocally) { - this._verificationStatus = 'verified'; - this._crossSigningReady = true; - } else { - this._verificationStatus = 'unverified'; - this._crossSigningReady = false; - } - } catch (err) { - console.error('Error checking verification status:', err); - this._verificationStatus = 'unknown'; - } - } - - /** - * Check key backup status - */ - async checkKeyBackupStatus(): Promise { - if (!this._client || !this._cryptoReady) { - this._keyBackupEnabled = false; - return; - } - - try { - const crypto = this._client.getCrypto(); - if (!crypto) return; - - const backupInfo = await crypto.getActiveSessionBackupVersion(); - this._keyBackupEnabled = backupInfo !== null; - } catch (err) { - console.error('Error checking key backup status:', err); - this._keyBackupEnabled = false; - } - } - - /** - * Get current device ID - */ - getDeviceId(): string | null { - return this._client?.getDeviceId() || null; - } - - /** - * Get all devices for a user - */ - async getDevices(userId?: string): Promise { - if (!this._client || !this._cryptoReady) return []; - - const targetUserId = userId || this._client.getUserId(); - if (!targetUserId) return []; - - try { - const crypto = this._client.getCrypto(); - if (!crypto) return []; - - const deviceMap = await crypto.getUserDeviceInfo([targetUserId]); - const devices = deviceMap.get(targetUserId); - if (!devices) return []; - - const currentDeviceId = this._client.getDeviceId(); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return Array.from(devices.values()).map((device: any) => ({ - deviceId: device.deviceId, - displayName: device.displayName, - // DeviceVerification enum values may vary - check for Verified state - verified: - device.verified === 1 || device.verified === 'Verified' || device.isVerified?.() === true, - blocked: device.verified === 2 || device.verified === 'Blocked', - isCurrentDevice: device.deviceId === currentDeviceId, - })); - } catch (err) { - console.error('Error getting devices:', err); - return []; - } - } - - /** - * Start verification with another device - * Note: Verification flow varies by SDK version - this is a simplified approach - */ - async startVerification(targetUserId?: string, _targetDeviceId?: string): Promise { - if (!this._client || !this._cryptoReady) return false; - - const userId = targetUserId || this._client.getUserId(); - if (!userId) return false; - - try { - const crypto = this._client.getCrypto(); - if (!crypto) return false; - - // Use requestOwnUserVerification for self-verification - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const cryptoAny = crypto as any; - if (userId === this._client.getUserId() && cryptoAny.requestOwnUserVerification) { - await cryptoAny.requestOwnUserVerification(); - } else if (cryptoAny.requestVerificationDM) { - await cryptoAny.requestVerificationDM(userId); - } else { - return false; - } - - return true; - } catch (err) { - console.error('Error starting verification:', err); - this._error = 'Failed to start verification'; - return false; - } - } - - /** - * Accept incoming verification request - */ - async acceptVerification(_requestId: string): Promise { - if (!this._client || !this._cryptoReady) return false; - - try { - // Verification request handling varies by SDK version - handled automatically - return true; - } catch (err) { - console.error('Error accepting verification:', err); - return false; - } - } - - /** - * Confirm SAS verification (emoji match) - */ - async confirmSasVerification(_requestId: string): Promise { - if (!this._client || !this._cryptoReady) return false; - - try { - // In newer SDK versions, verification is handled via verifier events - return true; - } catch (err) { - console.error('Error confirming SAS verification:', err); - return false; - } - } - - /** - * Cancel verification - */ - async cancelVerification(_requestId: string): Promise { - if (!this._client || !this._cryptoReady) return; - - try { - this._activeVerification = null; - } catch { - // Cancellation is best-effort - } - } - - /** - * Bootstrap secret storage and cross-signing - */ - async bootstrapSecretStorage(passphrase?: string): Promise<{ recoveryKey: string } | null> { - if (!this._client || !this._cryptoReady) return null; - - try { - const crypto = this._client.getCrypto(); - if (!crypto) return null; - - let recoveryKey = ''; - - // Bootstrap cross-signing first - await crypto.bootstrapCrossSigning({ - authUploadDeviceSigningKeys: async (makeRequest) => { - // This callback is called when we need to authenticate for uploading keys - // In a real app, this might show a UIA (User Interactive Auth) dialog - await makeRequest({}); - }, - }); - - // Bootstrap secret storage - await crypto.bootstrapSecretStorage({ - createSecretStorageKey: async () => { - // Generate a new recovery key - const keyInfo = await crypto.createRecoveryKeyFromPassphrase(passphrase); - recoveryKey = keyInfo.encodedPrivateKey || ''; - return keyInfo; - }, - }); - - // Reset key backup - await crypto.resetKeyBackup(); - - this._crossSigningReady = true; - this._keyBackupEnabled = true; - this._verificationStatus = 'verified'; - - return { recoveryKey }; - } catch (err) { - console.error('Error bootstrapping secret storage:', err); - this._error = 'Failed to setup encryption keys'; - return null; - } - } - - /** - * Restore keys from recovery key - */ - async restoreFromRecoveryKey(recoveryKey: string): Promise { - if (!this._client || !this._cryptoReady) return false; - - try { - const crypto = this._client.getCrypto(); - if (!crypto) return false; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const cryptoAny = crypto as any; - const clientAny = this._client as any; - - // Restore from backup using recovery key - // Method names may vary by SDK version - if (cryptoAny.restoreKeyBackupWithRecoveryKey) { - await cryptoAny.restoreKeyBackupWithRecoveryKey(recoveryKey); - } else if (clientAny.restoreKeyBackupWithRecoveryKey) { - const backupInfo = await clientAny.getKeyBackupVersion?.(); - if (backupInfo) { - await clientAny.restoreKeyBackupWithRecoveryKey( - recoveryKey, - undefined, - undefined, - backupInfo - ); - } - } else { - console.warn('Key backup restore not available in this SDK version'); - return false; - } - - this._keyBackupEnabled = true; - await this.checkVerificationStatus(); - return true; - } catch (err) { - console.error('Error restoring from recovery key:', err); - this._error = 'Failed to restore encryption keys'; - return false; - } - } - - /** - * Get cross-signing status - */ - async getCrossSigningStatus(): Promise { - if (!this._client || !this._cryptoReady) return null; - - try { - const crypto = this._client.getCrypto(); - if (!crypto) return null; - - const status = await crypto.getCrossSigningStatus(); - // Status properties may be booleans or objects depending on SDK version - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const statusAny = status as any; - return { - publicKeysOnDevice: !!statusAny.publicKeysOnDevice, - privateKeysInSecretStorage: !!statusAny.privateKeysInSecretStorage, - privateKeysCachedLocally: !!statusAny.privateKeysCachedLocally, - }; - } catch (err) { - console.error('Error getting cross-signing status:', err); - return null; - } - } - - /** - * Check if a room is encrypted - */ - isRoomEncrypted(roomId?: string): boolean { - const id = roomId || this._currentRoomId; - if (!this._client || !id) return false; - - const room = this._client.getRoom(id); - return room?.hasEncryptionStateEvent() ?? false; - } - - /** - * Get room encryption status with details - */ - async getRoomEncryptionStatus(roomId?: string): Promise<{ - encrypted: boolean; - allDevicesVerified: boolean; - unverifiedDevices: number; - }> { - const id = roomId || this._currentRoomId; - if (!this._client || !id) { - return { encrypted: false, allDevicesVerified: false, unverifiedDevices: 0 }; - } - - const room = this._client.getRoom(id); - if (!room) { - return { encrypted: false, allDevicesVerified: false, unverifiedDevices: 0 }; - } - - const encrypted = room.hasEncryptionStateEvent(); - if (!encrypted || !this._cryptoReady) { - return { encrypted, allDevicesVerified: false, unverifiedDevices: 0 }; - } - - try { - const crypto = this._client.getCrypto(); - if (!crypto) { - return { encrypted, allDevicesVerified: false, unverifiedDevices: 0 }; - } - - // Get all members and their devices - const members = room.getMembersWithMembership('join'); - const userIds = members.map((m) => m.userId); - const deviceMap = await crypto.getUserDeviceInfo(userIds); - - let unverifiedCount = 0; - for (const [userId, devices] of deviceMap) { - for (const device of devices.values()) { - if (device.verified !== 1) { - // Not verified - unverifiedCount++; - } - } - } - - return { - encrypted, - allDevicesVerified: unverifiedCount === 0, - unverifiedDevices: unverifiedCount, - }; - } catch { - return { encrypted, allDevicesVerified: false, unverifiedDevices: 0 }; - } - } - - // ───────────────────────────────────────────────────────── - // VoIP / Call Actions - // ───────────────────────────────────────────────────────── - - /** - * Set call callbacks for UI notifications - */ - setCallCallbacks(callbacks: CallCallbacks) { - this._callCallbacks = callbacks; - } - - /** - * Place a voice call to the current room - */ - async placeVoiceCall(roomId?: string): Promise { - const targetRoomId = roomId || this._currentRoomId; - if (!this._client || !targetRoomId) return false; - - try { - // Import WebRTC types from the submodule - const webrtc = await import('matrix-js-sdk/lib/webrtc/call'); - const { CallEvent, CallState } = webrtc; - - // Create the call - const call = this._client.createCall(targetRoomId); - if (!call) { - console.error('Failed to create call'); - return false; - } - - this._matrixCall = call; - - // Set up event handlers - this.setupCallEventHandlers(call, CallEvent, CallState); - - // Place the voice call - await call.placeVoiceCall(); - - // Update active call state - this._activeCall = this.matrixCallToSimpleCall(call, 'voice', 'outbound'); - - return true; - } catch (err) { - console.error('Error placing voice call:', err); - this._error = 'Failed to start voice call'; - return false; - } - } - - /** - * Place a video call to the current room - */ - async placeVideoCall(roomId?: string): Promise { - const targetRoomId = roomId || this._currentRoomId; - if (!this._client || !targetRoomId) return false; - - try { - // Import WebRTC types from the submodule - const webrtc = await import('matrix-js-sdk/lib/webrtc/call'); - const { CallEvent, CallState } = webrtc; - - // Create the call - const call = this._client.createCall(targetRoomId); - if (!call) { - console.error('Failed to create call'); - return false; - } - - this._matrixCall = call; - - // Set up event handlers - this.setupCallEventHandlers(call, CallEvent, CallState); - - // Place the video call - await call.placeVideoCall(); - - // Update active call state - this._activeCall = this.matrixCallToSimpleCall(call, 'video', 'outbound'); - - return true; - } catch (err) { - console.error('Error placing video call:', err); - this._error = 'Failed to start video call'; - return false; - } - } - - /** - * Answer an incoming call - */ - async answerCall(): Promise { - if (!this._matrixCall || !this._incomingCall) return false; - - try { - await this._matrixCall.answer(); - this._activeCall = { ...this._incomingCall }; - this._incomingCall = null; - return true; - } catch (err) { - console.error('Error answering call:', err); - return false; - } - } - - /** - * Reject an incoming call - */ - rejectCall(): boolean { - if (!this._matrixCall || !this._incomingCall) return false; - - try { - this._matrixCall.reject(); - this._incomingCall = null; - this._matrixCall = null; - return true; - } catch (err) { - console.error('Error rejecting call:', err); - return false; - } - } - - /** - * Hang up the current call - */ - hangupCall(): boolean { - if (!this._matrixCall) return false; - - try { - this._matrixCall.hangup('user_hangup', false); - this._activeCall = null; - this._matrixCall = null; - return true; - } catch (err) { - console.error('Error hanging up call:', err); - return false; - } - } - - /** - * Toggle microphone mute - */ - toggleMicMute(): boolean { - if (!this._matrixCall || !this._activeCall) return false; - - try { - const muted = this._matrixCall.isMicrophoneMuted(); - this._matrixCall.setMicrophoneMuted(!muted); - this._activeCall = { ...this._activeCall, isMicMuted: !muted }; - return true; - } catch (err) { - console.error('Error toggling mic mute:', err); - return false; - } - } - - /** - * Toggle camera mute (for video calls) - */ - toggleCameraMute(): boolean { - if (!this._matrixCall || !this._activeCall) return false; - - try { - const muted = this._matrixCall.isLocalVideoMuted(); - this._matrixCall.setLocalVideoMuted(!muted); - this._activeCall = { ...this._activeCall, isCameraMuted: !muted }; - return true; - } catch (err) { - console.error('Error toggling camera mute:', err); - return false; - } - } - - /** - * Toggle screen sharing - */ - async toggleScreenShare(): Promise { - if (!this._matrixCall || !this._activeCall) return false; - - try { - const isSharing = this._activeCall.isScreenSharing; - - if (isSharing) { - // Stop screen sharing - switch back to camera - await this._matrixCall.setScreensharingEnabled(false); - this._activeCall = { ...this._activeCall, isScreenSharing: false }; - } else { - // Start screen sharing - const success = await this._matrixCall.setScreensharingEnabled(true, { - audio: true, // Include system audio if available - }); - if (success) { - this._activeCall = { ...this._activeCall, isScreenSharing: true }; - } else { - console.warn('Screen sharing was denied or failed'); - return false; - } - } - return true; - } catch (err) { - console.error('Error toggling screen share:', err); - this._error = 'Bildschirmfreigabe fehlgeschlagen'; - return false; - } - } - - /** - * Set up call event handlers - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private setupCallEventHandlers(call: any, CallEvent: any, CallState: any) { - // State changes - call.on(CallEvent.State, (state: string, oldState: string) => { - if (this._activeCall) { - this._activeCall = { - ...this._activeCall, - state: state as CallStateType, - }; - this._callCallbacks.onCallStateChange?.(this._activeCall); - } - - // Handle call ending - if (state === CallState.Ended) { - const reason = call.hangupReason; - const endedCall = this._activeCall; - this._activeCall = null; - this._matrixCall = null; - if (endedCall) { - this._callCallbacks.onCallEnded?.(endedCall, reason); - } - } - }); - - // Feeds changed (audio/video streams) - call.on(CallEvent.FeedsChanged, (feeds: any[]) => { - if (this._activeCall) { - const localFeed = feeds.find((f) => f.isLocal()); - const remoteFeed = feeds.find((f) => !f.isLocal()); - - this._activeCall = { - ...this._activeCall, - localStream: localFeed?.stream, - remoteStream: remoteFeed?.stream, - }; - } - }); - - // Error handling - call.on(CallEvent.Error, (error: any) => { - console.error('Call error:', error); - this._error = `Call error: ${error.message || 'Unknown error'}`; - }); - - // Hangup - call.on(CallEvent.Hangup, () => { - const endedCall = this._activeCall; - this._activeCall = null; - this._matrixCall = null; - if (endedCall) { - this._callCallbacks.onCallEnded?.(endedCall, call.hangupReason); - } - }); - } - - /** - * Handle incoming call from the SDK - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private handleIncomingCall(call: any, CallEvent: any, CallState: any) { - this._matrixCall = call; - - // Determine call type from the call object - const callType: CallType = call.type === 'video' ? 'video' : 'voice'; - - // Create simple call representation - const simpleCall = this.matrixCallToSimpleCall(call, callType, 'inbound'); - this._incomingCall = simpleCall; - - // Set up event handlers - this.setupCallEventHandlers(call, CallEvent, CallState); - - // Notify UI - this._callCallbacks.onIncomingCall?.(simpleCall); - } - - /** - * Convert MatrixCall to SimpleCall - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private matrixCallToSimpleCall(call: any, type: CallType, direction: CallDirection): SimpleCall { - const opponent = call.getOpponentMember?.(); - const room = this._client?.getRoom(call.roomId); - - return { - callId: call.callId, - roomId: call.roomId, - state: (call.state || 'fledgling') as CallStateType, - type, - direction, - opponentUserId: opponent?.userId, - opponentName: opponent?.name || room?.name || 'Unbekannt', - opponentAvatar: opponent?.getAvatarUrl?.(this._client?.baseUrl || '', 48, 48, 'scale'), - isMicMuted: call.isMicrophoneMuted?.() || false, - isCameraMuted: call.isLocalVideoMuted?.() || false, - isScreenSharing: false, - isRemoteOnHold: call.isRemoteOnHold?.() || false, - }; - } - - // ───────────────────────────────────────────────────────── - // Cleanup - // ───────────────────────────────────────────────────────── - - /** - * Stop the client and clean up - */ - destroy() { - // Hang up any active call - if (this._matrixCall) { - try { - this._matrixCall.hangup('user_hangup', false); - } catch { - // Ignore errors during cleanup - } - } - - this._client?.stopClient(); - this._client = null; - this._syncState = 'STOPPED'; - this._rooms = []; - this._timeline = []; - this._currentRoomId = null; - this._typingUsers = new Map(); - this._initialized = false; - // Reset crypto state - this._cryptoReady = false; - this._verificationStatus = 'unknown'; - this._activeVerification = null; - this._keyBackupEnabled = false; - this._crossSigningReady = false; - this._cryptoCallbacks = {}; - // Reset call state - this._activeCall = null; - this._incomingCall = null; - this._matrixCall = null; - this._callCallbacks = {}; - } - - /** - * Logout and clear credentials - */ - logout() { - this.destroy(); - if (browser) { - localStorage.removeItem(STORAGE_KEY); - localStorage.removeItem(LAST_ROOM_KEY); - } - } - - // ───────────────────────────────────────────────────────── - // Helper Methods - // ───────────────────────────────────────────────────────── - - /** - * Convert SDK Room to SimpleRoom - */ - private roomToSimpleRoom(room: Room): SimpleRoom { - const lastEvent = room - .getLiveTimeline() - .getEvents() - .filter((e) => e.getType() === 'm.room.message') - .pop(); - - // Get topic from state event - const topicEvent = room.currentState.getStateEvents('m.room.topic', ''); - const topic = (topicEvent as MatrixEvent | null)?.getContent()?.topic; - - // Get membership status - const myUserId = this._client?.getUserId(); - const myMember = myUserId ? room.getMember(myUserId) : null; - const membership = (myMember?.membership || 'leave') as SimpleRoom['membership']; - - // Get inviter if this is an invite - let inviter: string | undefined; - if (membership === 'invite' && myMember) { - // The events array contains the invite event - const inviteEvent = room.currentState.getStateEvents('m.room.member', myUserId || ''); - if (inviteEvent) { - const sender = (inviteEvent as MatrixEvent).getSender(); - if (sender) { - const senderMember = room.getMember(sender); - inviter = senderMember?.name || sender; - } - } - } - - // Get DM user presence info - const isDirect = this.isDirectRoom(room); - let dmUserId: string | undefined; - let presence: SimpleRoom['presence']; - let lastActiveAgo: number | undefined; - - if (isDirect && myUserId) { - // Find the other user in the DM - const members = room.getJoinedMembers(); - const otherMember = members.find((m) => m.userId !== myUserId); - if (otherMember) { - dmUserId = otherMember.userId; - const userPresence = this._userPresence.get(dmUserId); - if (userPresence) { - presence = userPresence.presence; - lastActiveAgo = userPresence.lastActiveAgo; - } else { - // Try to get from user object directly - const user = this._client?.getUser(dmUserId); - if (user) { - presence = (user.presence as SimpleRoom['presence']) || 'offline'; - lastActiveAgo = user.lastActiveAgo; - } - } - } - } - - return { - id: room.roomId, - name: room.name || 'Unnamed Room', - topic, - avatar: room.getAvatarUrl(this._client?.baseUrl || '', 48, 48, 'scale') || undefined, - lastMessage: lastEvent?.getContent()?.body, - lastMessageSender: lastEvent ? this.getSenderName(lastEvent) : undefined, - lastMessageTime: room.getLastActiveTimestamp() || undefined, - unreadCount: room.getUnreadNotificationCount('total' as any) || 0, - highlightCount: room.getUnreadNotificationCount('highlight' as any) || 0, - isDirect, - isEncrypted: room.hasEncryptionStateEvent(), - memberCount: room.getJoinedMemberCount(), - membership, - inviter, - dmUserId, - presence, - lastActiveAgo, - }; - } - - /** - * Convert SDK MatrixEvent to SimpleMessage - */ - private eventToSimpleMessage(event: MatrixEvent, timeline?: MatrixEvent[]): SimpleMessage { - const content = event.getContent(); - const relatesTo = content['m.relates_to']; - const msgtype = content.msgtype || 'm.text'; - - // Check if message was redacted - const isRedacted = event.isRedacted(); - - // Extract media info for file/image/video/audio messages - let media: SimpleMessage['media'] = undefined; - if (['m.image', 'm.file', 'm.video', 'm.audio'].includes(msgtype) && content.url) { - const info = content.info || {}; - media = { - mxcUrl: content.url, - mimetype: info.mimetype, - size: info.size, - width: info.w, - height: info.h, - filename: content.filename || content.body, - thumbnailUrl: info.thumbnail_url, - duration: info.duration, - }; - } - - // Get reply-to body if this is a reply - let replyToBody: string | undefined; - const replyToId = relatesTo?.['m.in_reply_to']?.event_id; - if (replyToId) { - const room = this._client?.getRoom(event.getRoomId() || ''); - const replyEvent = room?.findEventById(replyToId); - if (replyEvent) { - replyToBody = replyEvent.getContent().body; - } - } - - // Collect reactions for this message - const reactions = this.getReactionsForEvent(event.getId() || '', timeline); - - // Get read receipts for this message (only for own messages) - const isOwn = event.getSender() === this._client?.getUserId(); - const readBy = isOwn ? this.getReadReceiptsForEvent(event) : undefined; - - return { - id: event.getId() || '', - sender: event.getSender() || '', - senderName: this.getSenderName(event), - body: isRedacted ? 'Message deleted' : content.body || '', - formattedBody: content.formatted_body, - timestamp: event.getTs(), - type: msgtype as MessageType, - isOwn, - replyTo: replyToId, - replyToBody, - edited: !!event.replacingEvent(), - redacted: isRedacted, - media, - reactions: reactions.length > 0 ? reactions : undefined, - readBy: readBy && readBy.length > 0 ? readBy : undefined, - }; - } - - /** - * Get reactions for a specific event - */ - private getReactionsForEvent(eventId: string, timeline?: MatrixEvent[]): MessageReaction[] { - if (!timeline || !eventId) return []; - - const myUserId = this._client?.getUserId(); - const reactionMap = new Map }>(); - - // Find all m.reaction events that relate to this event - for (const event of timeline) { - if (event.getType() !== 'm.reaction') continue; - - const content = event.getContent(); - const relatesTo = content['m.relates_to']; - - if ( - relatesTo?.rel_type === 'm.annotation' && - relatesTo?.event_id === eventId && - relatesTo?.key - ) { - const emoji = relatesTo.key; - const sender = event.getSender() || ''; - - if (!reactionMap.has(emoji)) { - reactionMap.set(emoji, { users: [], senders: new Set() }); - } - - const entry = reactionMap.get(emoji)!; - // Avoid duplicates from same user - if (!entry.senders.has(sender)) { - entry.senders.add(sender); - entry.users.push(sender); - } - } - } - - // Convert to MessageReaction array - const reactions: MessageReaction[] = []; - for (const [key, data] of reactionMap) { - reactions.push({ - key, - count: data.users.length, - users: data.users, - includesMe: myUserId ? data.senders.has(myUserId) : false, - }); - } - - // Sort by count descending - return reactions.sort((a, b) => b.count - a.count); - } - - /** - * Get read receipts for a specific event - */ - private getReadReceiptsForEvent(event: MatrixEvent): ReadReceipt[] { - const eventId = event.getId(); - const roomId = event.getRoomId(); - if (!eventId || !roomId || !this._client) return []; - - const room = this._client.getRoom(roomId); - if (!room) return []; - - const myUserId = this._client.getUserId(); - const receipts: ReadReceipt[] = []; - - // Get all members who have read up to or past this event - const members = room.getJoinedMembers(); - for (const member of members) { - // Skip self - if (member.userId === myUserId) continue; - - // Get the user's read receipt - const receiptEvent = room.getEventReadUpTo(member.userId); - if (!receiptEvent) continue; - - // Check if their read receipt is at or after this event - const receiptEventObj = room.findEventById(receiptEvent); - if (receiptEventObj && receiptEventObj.getTs() >= event.getTs()) { - receipts.push({ - userId: member.userId, - userName: member.name || member.userId.split(':')[0].substring(1), - timestamp: receiptEventObj.getTs(), - }); - } - } - - return receipts; - } - - /** - * Get display name for message sender - */ - private getSenderName(event: MatrixEvent): string { - const room = this._client?.getRoom(event.getRoomId() || ''); - const member = room?.getMember(event.getSender() || ''); - return member?.name || event.getSender()?.split(':')[0].substring(1) || 'Unknown'; - } - - /** - * Check if room is a direct message room - */ - private isDirectRoom(room: Room): boolean { - const dmContent = this._client?.getAccountData('m.direct' as any)?.getContent() || {}; - return Object.values(dmContent).flat().includes(room.roomId); - } - - /** - * Load credentials from localStorage - */ - private loadCredentials(): MatrixCredentials | null { - if (!browser) return null; - - try { - const stored = localStorage.getItem(STORAGE_KEY); - return stored ? JSON.parse(stored) : null; - } catch { - return null; - } - } - - /** - * Save credentials to localStorage - */ - private saveCredentials(creds: MatrixCredentials) { - if (browser) { - localStorage.setItem(STORAGE_KEY, JSON.stringify(creds)); - } - } - - /** - * Check if credentials exist in storage - */ - hasStoredCredentials(): boolean { - return this.loadCredentials() !== null; - } -} - -// Export singleton instance -export const matrixStore = new MatrixStore(); diff --git a/apps/matrix/apps/web/src/lib/matrix/types.ts b/apps/matrix/apps/web/src/lib/matrix/types.ts deleted file mode 100644 index d4b7f1d1e..000000000 --- a/apps/matrix/apps/web/src/lib/matrix/types.ts +++ /dev/null @@ -1,324 +0,0 @@ -import type { Room, MatrixEvent, MatrixClient } from 'matrix-js-sdk'; - -/** - * Matrix sync states - */ -export type SyncState = 'STOPPED' | 'PREPARED' | 'SYNCING' | 'ERROR' | 'RECONNECTING' | 'CATCHUP'; - -/** - * Credentials for Matrix authentication - */ -export interface MatrixCredentials { - homeserver: string; - accessToken: string; - userId: string; - deviceId: string; -} - -/** - * Media info for files/images - */ -export interface MediaInfo { - mxcUrl: string; - mimetype?: string; - size?: number; - width?: number; - height?: number; - filename?: string; - thumbnailUrl?: string; - duration?: number; // For audio/video -} - -/** - * Reaction on a message - */ -export interface MessageReaction { - key: string; // The emoji - count: number; - users: string[]; // User IDs who reacted - includesMe: boolean; -} - -/** - * Read receipt info for a message - */ -export interface ReadReceipt { - userId: string; - userName: string; - timestamp: number; -} - -/** - * Simplified message for UI rendering - */ -export interface SimpleMessage { - id: string; - sender: string; - senderName: string; - body: string; - formattedBody?: string; - timestamp: number; - type: MessageType; - isOwn: boolean; - replyTo?: string; - replyToBody?: string; - edited?: boolean; - redacted?: boolean; - media?: MediaInfo; - reactions?: MessageReaction[]; - // Read receipts - readBy?: ReadReceipt[]; -} - -export type MessageType = - | 'm.text' - | 'm.image' - | 'm.file' - | 'm.audio' - | 'm.video' - | 'm.emote' - | 'm.notice'; - -/** - * Room membership status - */ -export type RoomMembership = 'join' | 'invite' | 'leave' | 'ban' | 'knock'; - -/** - * User presence state - */ -export type PresenceState = 'online' | 'offline' | 'unavailable'; - -/** - * User presence info - */ -export interface UserPresence { - userId: string; - presence: PresenceState; - lastActiveAgo?: number; // milliseconds since last active - statusMessage?: string; - currentlyActive?: boolean; -} - -/** - * Simplified room for UI rendering - */ -export interface SimpleRoom { - id: string; - name: string; - topic?: string; - avatar?: string; - lastMessage?: string; - lastMessageSender?: string; - lastMessageTime?: number; - unreadCount: number; - highlightCount: number; - isDirect: boolean; - isEncrypted: boolean; - memberCount: number; - membership: RoomMembership; - inviter?: string; // User who sent the invite - // Presence info for DMs - dmUserId?: string; // The other user's ID in a DM - presence?: PresenceState; - lastActiveAgo?: number; -} - -/** - * Room member info - */ -export interface RoomMember { - userId: string; - displayName: string; - avatarUrl?: string; - membership: 'join' | 'invite' | 'leave' | 'ban' | 'knock'; - powerLevel: number; -} - -/** - * Login result - */ -export interface LoginResult { - success: boolean; - credentials?: MatrixCredentials; - error?: string; -} - -/** - * Matrix store state (for debugging) - */ -export interface MatrixStoreState { - syncState: SyncState; - roomCount: number; - currentRoomId: string | null; - messageCount: number; - error: string | null; -} - -// ───────────────────────────────────────────────────────── -// Crypto Types -// ───────────────────────────────────────────────────────── - -/** - * Device verification status - */ -export type VerificationStatus = 'unverified' | 'verified' | 'unknown'; - -/** - * Device info for crypto - */ -export interface DeviceInfo { - deviceId: string; - displayName?: string; - lastSeenIp?: string; - lastSeenTs?: number; - verified: boolean; - blocked: boolean; - isCurrentDevice: boolean; -} - -/** - * User device list - */ -export interface UserDevices { - userId: string; - devices: DeviceInfo[]; -} - -/** - * Verification request state - */ -export type VerificationRequestState = - | 'created' - | 'requested' - | 'ready' - | 'started' - | 'done' - | 'cancelled'; - -/** - * Verification method - */ -export type VerificationMethod = 'sas' | 'reciprocate' | 'show_qr' | 'scan_qr'; - -/** - * SAS (Short Authentication String) verification data - */ -export interface SasVerification { - emoji?: { emoji: string; description: string }[]; - decimal?: [number, number, number]; -} - -/** - * Crypto event callbacks for UI handling - */ -export interface CryptoCallbacks { - onVerificationRequest?: (request: VerificationRequest) => void; - onDeviceVerified?: (userId: string, deviceId: string) => void; - onKeyBackupStatus?: (enabled: boolean) => void; -} - -/** - * Verification request wrapper - */ -export interface VerificationRequest { - requestId: string; - otherUserId: string; - otherDeviceId?: string; - phase: VerificationRequestState; - isSelfVerification: boolean; - methods: VerificationMethod[]; -} - -/** - * Extended SimpleMessage with crypto info - */ -export interface SimpleMessageWithCrypto extends SimpleMessage { - encrypted?: boolean; - decryptionError?: string; - senderVerified?: boolean; -} - -/** - * Cross-signing status - */ -export interface CrossSigningStatus { - publicKeysOnDevice: boolean; - privateKeysInSecretStorage: boolean; - privateKeysCachedLocally: boolean; -} - -// ───────────────────────────────────────────────────────── -// VoIP / Call Types -// ───────────────────────────────────────────────────────── - -/** - * Call state - */ -export type CallState = - | 'fledgling' - | 'invite_sent' - | 'wait_local_media' - | 'create_offer' - | 'create_answer' - | 'connecting' - | 'connected' - | 'ringing' - | 'ended'; - -/** - * Call type (voice or video) - */ -export type CallType = 'voice' | 'video'; - -/** - * Call direction - */ -export type CallDirection = 'inbound' | 'outbound'; - -/** - * Simplified call info for UI - */ -export interface SimpleCall { - callId: string; - roomId: string; - state: CallState; - type: CallType; - direction: CallDirection; - opponentUserId?: string; - opponentName?: string; - opponentAvatar?: string; - startTime?: number; - duration?: number; - isMicMuted: boolean; - isCameraMuted: boolean; - isScreenSharing: boolean; - isRemoteOnHold: boolean; - localStream?: MediaStream; - remoteStream?: MediaStream; -} - -/** - * Call event callbacks for UI handling - */ -export interface CallCallbacks { - onIncomingCall?: (call: SimpleCall) => void; - onCallStateChange?: (call: SimpleCall) => void; - onCallEnded?: (call: SimpleCall, reason?: string) => void; -} - -// ───────────────────────────────────────────────────────── -// Widget Types -// ───────────────────────────────────────────────────────── - -/** - * Matrix widget info - */ -export interface RoomWidget { - id: string; - type: string; - name: string; - url: string; - creatorUserId: string; - data?: Record; -} diff --git a/apps/matrix/apps/web/src/lib/notifications/index.ts b/apps/matrix/apps/web/src/lib/notifications/index.ts deleted file mode 100644 index 05d18f1c5..000000000 --- a/apps/matrix/apps/web/src/lib/notifications/index.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { browser } from '$app/environment'; - -/** - * Browser Notification Service for Matrix Chat - */ - -// Notification settings stored in localStorage -const SETTINGS_KEY = 'matrix_notification_settings'; - -interface NotificationSettings { - enabled: boolean; - sound: boolean; - showPreview: boolean; -} - -const defaultSettings: NotificationSettings = { - enabled: true, - sound: true, - showPreview: true, -}; - -/** - * Get notification settings from localStorage - */ -export function getNotificationSettings(): NotificationSettings { - if (!browser) return defaultSettings; - - try { - const stored = localStorage.getItem(SETTINGS_KEY); - if (stored) { - return { ...defaultSettings, ...JSON.parse(stored) }; - } - } catch (e) { - console.warn('Failed to parse notification settings:', e); - } - return defaultSettings; -} - -/** - * Save notification settings to localStorage - */ -export function saveNotificationSettings(settings: Partial): void { - if (!browser) return; - - const current = getNotificationSettings(); - const updated = { ...current, ...settings }; - localStorage.setItem(SETTINGS_KEY, JSON.stringify(updated)); -} - -/** - * Check if browser notifications are supported - */ -export function isNotificationSupported(): boolean { - return browser && 'Notification' in window; -} - -/** - * Get current notification permission status - */ -export function getNotificationPermission(): NotificationPermission | 'unsupported' { - if (!isNotificationSupported()) return 'unsupported'; - return Notification.permission; -} - -/** - * Request notification permission - */ -export async function requestNotificationPermission(): Promise { - if (!isNotificationSupported()) return 'unsupported'; - - try { - const permission = await Notification.requestPermission(); - return permission; - } catch (e) { - console.error('Failed to request notification permission:', e); - return 'denied'; - } -} - -/** - * Check if notifications are enabled and permitted - */ -export function canShowNotifications(): boolean { - if (!isNotificationSupported()) return false; - if (Notification.permission !== 'granted') return false; - - const settings = getNotificationSettings(); - return settings.enabled; -} - -/** - * Check if the document is currently focused - */ -export function isDocumentFocused(): boolean { - if (!browser) return true; - return document.hasFocus(); -} - -/** - * Show a browser notification for a new message - */ -export function showMessageNotification( - senderName: string, - messageBody: string, - roomName: string, - options?: { - onClick?: () => void; - icon?: string; - } -): void { - if (!canShowNotifications()) return; - if (isDocumentFocused()) return; - - const settings = getNotificationSettings(); - - const title = roomName ? `${senderName} in ${roomName}` : senderName; - const body = settings.showPreview ? messageBody : 'Neue Nachricht'; - - try { - const notification = new Notification(title, { - body: body.slice(0, 200), // Limit body length - icon: options?.icon || '/favicon.png', - tag: 'matrix-message', // Group notifications - silent: !settings.sound, - } as NotificationOptions); - - notification.onclick = () => { - window.focus(); - notification.close(); - options?.onClick?.(); - }; - - // Auto-close after 5 seconds - setTimeout(() => { - notification.close(); - }, 5000); - } catch (e) { - console.error('Failed to show notification:', e); - } -} - -/** - * Play notification sound - */ -export function playNotificationSound(): void { - if (!browser) return; - - const settings = getNotificationSettings(); - if (!settings.sound) return; - - try { - // Create a simple beep using Web Audio API - const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)(); - const oscillator = audioContext.createOscillator(); - const gainNode = audioContext.createGain(); - - oscillator.connect(gainNode); - gainNode.connect(audioContext.destination); - - oscillator.frequency.value = 800; - oscillator.type = 'sine'; - - gainNode.gain.setValueAtTime(0.1, audioContext.currentTime); - gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3); - - oscillator.start(audioContext.currentTime); - oscillator.stop(audioContext.currentTime + 0.3); - } catch (e) { - // Ignore audio errors (common when user hasn't interacted yet) - } -} diff --git a/apps/matrix/apps/web/src/lib/stores/navigation.svelte.ts b/apps/matrix/apps/web/src/lib/stores/navigation.svelte.ts deleted file mode 100644 index 9603b24a1..000000000 --- a/apps/matrix/apps/web/src/lib/stores/navigation.svelte.ts +++ /dev/null @@ -1,19 +0,0 @@ -// Local implementation of navigation stores -// Previously imported from @mana/shared-stores, now inlined to avoid dependency issues - -import { browser } from '$app/environment'; - -// Create reactive stores using Svelte 5 runes -let _isNavCollapsed = $state(false); - -export const isNavCollapsed = { - get value() { - return _isNavCollapsed; - }, - toggle() { - _isNavCollapsed = !_isNavCollapsed; - }, - set(value: boolean) { - _isNavCollapsed = value; - }, -}; diff --git a/apps/matrix/apps/web/src/lib/stores/tags.svelte.ts b/apps/matrix/apps/web/src/lib/stores/tags.svelte.ts deleted file mode 100644 index ca1ed13fc..000000000 --- a/apps/matrix/apps/web/src/lib/stores/tags.svelte.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Tag Store - Uses shared createTagStore backed by central mana-auth - * - * Matrix uses its own auth (Matrix homeserver), not mana-auth directly. - * The mana-auth token is obtained via session-to-token exchange and stored - * in localStorage. Tags will work when user has a mana-auth session. - */ -import { browser } from '$app/environment'; -import { createTagStore } from '@mana/shared-stores'; -import { loadStoredAccessToken } from '$lib/stores/userSettings.svelte'; - -const AUTH_URL = import.meta.env.VITE_MANA_AUTH_URL || 'https://auth.mana.how'; - -function getAuthUrl(): string { - return AUTH_URL; -} - -export const tagStore = createTagStore({ - authUrl: getAuthUrl(), - getToken: () => { - if (!browser) return null; - return loadStoredAccessToken(); - }, -}); diff --git a/apps/matrix/apps/web/src/lib/stores/theme.ts b/apps/matrix/apps/web/src/lib/stores/theme.ts deleted file mode 100644 index 0240fb257..000000000 --- a/apps/matrix/apps/web/src/lib/stores/theme.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createThemeStore } from '@mana/shared-theme'; - -export const theme = createThemeStore({ - appId: 'matrix', - defaultVariant: 'lavender', - primaryColor: { - light: '270 70% 60%', // Purple/violet - dark: '270 70% 60%', - }, -}); diff --git a/apps/matrix/apps/web/src/lib/stores/userSettings.svelte.ts b/apps/matrix/apps/web/src/lib/stores/userSettings.svelte.ts deleted file mode 100644 index cfde6047e..000000000 --- a/apps/matrix/apps/web/src/lib/stores/userSettings.svelte.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { createUserSettingsStore } from '@mana/shared-theme'; -import { browser } from '$app/environment'; - -const AUTH_URL = import.meta.env.VITE_MANA_AUTH_URL || 'https://auth.mana.how'; -const TOKEN_STORAGE_KEY = 'mana_core_access_token'; - -// Internal access token state -let accessToken: string | null = null; - -/** - * Set the access token (called after SSO token exchange) - */ -export function setAccessToken(token: string): void { - accessToken = token; - if (browser) { - try { - localStorage.setItem(TOKEN_STORAGE_KEY, token); - } catch { - // Ignore storage errors - } - } -} - -/** - * Clear the access token (called on logout) - */ -export function clearAccessToken(): void { - accessToken = null; - if (browser) { - try { - localStorage.removeItem(TOKEN_STORAGE_KEY); - } catch { - // Ignore storage errors - } - } -} - -/** - * Load access token from localStorage (for page reloads) - */ -export function loadStoredAccessToken(): string | null { - if (!browser) return null; - try { - const stored = localStorage.getItem(TOKEN_STORAGE_KEY); - if (stored) { - accessToken = stored; - return stored; - } - } catch { - // Ignore storage errors - } - return null; -} - -/** - * Get the current access token - */ -async function getAccessToken(): Promise { - // If we have a token in memory, return it - if (accessToken) return accessToken; - - // Try to load from storage - return loadStoredAccessToken(); -} - -/** - * User settings store for the Matrix app - * - * This store syncs settings with mana-auth and provides: - * - Global settings (including recentEmojis) - * - localStorage caching for offline support - */ -export const userSettings = createUserSettingsStore({ - appId: 'matrix', - authUrl: AUTH_URL, - getAccessToken, -}); diff --git a/apps/matrix/apps/web/src/lib/version.ts b/apps/matrix/apps/web/src/lib/version.ts deleted file mode 100644 index d63b4cfef..000000000 --- a/apps/matrix/apps/web/src/lib/version.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const APP_VERSION = '0.2.0'; -export const BUILD_TIME: string = - typeof __BUILD_TIME__ !== 'undefined' ? __BUILD_TIME__ : new Date().toISOString(); -export const BUILD_HASH: string = typeof __BUILD_HASH__ !== 'undefined' ? __BUILD_HASH__ : 'dev'; diff --git a/apps/matrix/apps/web/src/routes/(app)/+layout.svelte b/apps/matrix/apps/web/src/routes/(app)/+layout.svelte deleted file mode 100644 index a13bafec7..000000000 --- a/apps/matrix/apps/web/src/routes/(app)/+layout.svelte +++ /dev/null @@ -1,586 +0,0 @@ - - - - -{#if loading} - -
    - -
    -

    Connecting to Matrix...

    -

    - {#if matrixStore.syncState === 'PREPARED'} - Preparing sync... - {:else if matrixStore.syncState === 'SYNCING'} - Syncing messages... - {:else if matrixStore.syncState === 'CATCHUP'} - Catching up... - {:else} - Initializing... - {/if} -

    -
    -
    -{:else if initError} - -
    -
    - -
    -
    -

    Connection Failed

    -

    {initError}

    -
    -
    - - -
    -
    -{:else if matrixStore.isReady} - -
    - - {#if !isMobileRoomView} - - {/if} - - - {#if isTagStripVisible && !isMobileRoomView} - ({ - id: t.id, - name: t.name, - color: t.color || '#3b82f6', - }))} - selectedIds={[]} - onToggle={() => {}} - onClear={() => {}} - managementHref="/tags" - loading={tagStore.loading} - /> - {/if} - - -
    - {@render children()} -
    - - - {#if showCommandPalette} - - - -
    -
    - -
    - - - -
    - - {#if commandQuery.trim()} -
    - {#if commandResults.length === 0} -

    - Keine Räume gefunden -

    - {:else} - {#each commandResults as item, index (item.id)} - - {/each} - {/if} -
    - {:else} -
    - Tippe um Räume und Kontakte zu finden -
    - {/if} -
    -
    - {/if} - - - {#if !isMobileRoomView} -
    - {/if} -
    -{:else} - -
    -

    Redirecting...

    -
    -{/if} - - diff --git a/apps/matrix/apps/web/src/routes/(app)/+layout.ts b/apps/matrix/apps/web/src/routes/(app)/+layout.ts deleted file mode 100644 index 517d3e87a..000000000 --- a/apps/matrix/apps/web/src/routes/(app)/+layout.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Disable SSR for all (app) routes -// matrix-js-sdk requires browser APIs and shared-ui uses Svelte 5 runes -// that need client-side compilation -export const ssr = false; diff --git a/apps/matrix/apps/web/src/routes/(app)/bots/+page.svelte b/apps/matrix/apps/web/src/routes/(app)/bots/+page.svelte deleted file mode 100644 index 53d620b44..000000000 --- a/apps/matrix/apps/web/src/routes/(app)/bots/+page.svelte +++ /dev/null @@ -1,158 +0,0 @@ - - - - {$t('bots.title')} - Manalink - - -
    -
    - -
    -
    -
    - -
    -
    -

    {$t('bots.title')}

    -

    {$t('bots.subtitle')}

    -
    -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - {#each categoryLabels as category} - - {/each} -
    -
    - - - {#if filteredBots.length === 0} -
    - -

    {$t('bots.noResults')}

    -
    - {:else} -
    - {#each filteredBots as bot (bot.id)} -
    - startChat(bot)} /> - {#if startingChat === bot.id} -
    - -
    - {/if} -
    - {/each} -
    - {/if} - - -
    - {filteredBots.length} - {filteredBots.length === 1 ? 'Bot' : 'Bots'} - {#if selectedCategory !== 'all' || search} - {$t('bots.found')} - {/if} -
    -
    -
    - - diff --git a/apps/matrix/apps/web/src/routes/(app)/chat/+page.svelte b/apps/matrix/apps/web/src/routes/(app)/chat/+page.svelte deleted file mode 100644 index 81db66581..000000000 --- a/apps/matrix/apps/web/src/routes/(app)/chat/+page.svelte +++ /dev/null @@ -1,385 +0,0 @@ - - -{#if isMobile} - -
    - -
    -
    -
    -

    Manalink

    - - - {matrixStore.syncState === 'SYNCING' ? 'Verbunden' : matrixStore.syncState} - - {#if matrixStore.totalUnreadCount > 0} - - {matrixStore.totalUnreadCount} - - {/if} -
    -
    - - - - -
    -
    -
    - - {#if !matrixStore.cryptoReady} -
    - - Verschlüsselung nicht verfügbar -
    - {/if} - - -
    - (showCreateRoom = true)} onSelectRoom={handleSelectRoom} /> -
    -
    -{:else} - -
    - - - - -
    - - - - {#if matrixStore.currentRoom} - - (showRoomSettings = true)} - onSearchClick={() => (showSearch = true)} - onVoiceCall={handleVoiceCall} - onVideoCall={handleVideoCall} - /> - - - - - - (replyTo = null)} - onCancelEdit={() => (editMessage = null)} - /> - {:else} - -
    -
    - -
    -
    -

    Willkommen bei Manalink

    -

    - Wähle eine Unterhaltung aus der Seitenleiste oder starte einen neuen Chat -

    -
    - - - - -
    -
    -

    {matrixStore.rooms.length}

    -

    Räume

    -
    -
    -

    {matrixStore.totalUnreadCount}

    -

    Ungelesen

    -
    -
    -
    - {/if} -
    - - - (showRoomSettings = false)} /> -
    -{/if} - - - (showCreateRoom = false)} - onCreated={handleRoomCreated} -/> - - - (showSearch = false)} /> - - -{#if activeCall} - -{/if} - - -{#if incomingCall && !activeCall} - -{/if} - - - { - showForward = false; - forwardMessage = null; - }} -/> diff --git a/apps/matrix/apps/web/src/routes/(app)/chat/[roomId]/+page.svelte b/apps/matrix/apps/web/src/routes/(app)/chat/[roomId]/+page.svelte deleted file mode 100644 index db1da1aa1..000000000 --- a/apps/matrix/apps/web/src/routes/(app)/chat/[roomId]/+page.svelte +++ /dev/null @@ -1,315 +0,0 @@ - - - - -
    - - - - - {#if isSwiping && swipeProgress > 0} -
    -
    -
    - -
    -
    - {/if} - {#if matrixStore.currentRoom} - - (showRoomSettings = true)} - onSearchClick={() => (showSearch = true)} - onVoiceCall={handleVoiceCall} - onVideoCall={handleVideoCall} - /> - - - - - - (replyTo = null)} - onCancelEdit={() => (editMessage = null)} - /> - {:else} - -
    -
    -
    -

    Lade Raum...

    -
    -
    - {/if} -
    - - - (showRoomSettings = false)} /> - - - (showSearch = false)} /> - - -{#if activeCall} - -{/if} - - -{#if incomingCall && !activeCall} - -{/if} - - - { - showForward = false; - forwardMessage = null; - }} -/> diff --git a/apps/matrix/apps/web/src/routes/(app)/feedback/+page.svelte b/apps/matrix/apps/web/src/routes/(app)/feedback/+page.svelte deleted file mode 100644 index 7c95d3ef4..000000000 --- a/apps/matrix/apps/web/src/routes/(app)/feedback/+page.svelte +++ /dev/null @@ -1,26 +0,0 @@ - - - diff --git a/apps/matrix/apps/web/src/routes/(app)/help/+page.svelte b/apps/matrix/apps/web/src/routes/(app)/help/+page.svelte deleted file mode 100644 index 6906477fa..000000000 --- a/apps/matrix/apps/web/src/routes/(app)/help/+page.svelte +++ /dev/null @@ -1,32 +0,0 @@ - - - - {translations.title} | Manalink - - - goto('/')} - showGettingStarted={false} - showChangelog={false} - defaultSection="faq" -/> diff --git a/apps/matrix/apps/web/src/routes/(app)/settings/+page.svelte b/apps/matrix/apps/web/src/routes/(app)/settings/+page.svelte deleted file mode 100644 index 103bb7f15..000000000 --- a/apps/matrix/apps/web/src/routes/(app)/settings/+page.svelte +++ /dev/null @@ -1,410 +0,0 @@ - - -
    - -
    - - - -

    Einstellungen

    -
    - - -
    -
    - -
    -
    -

    - - Profil -

    -
    -
    - - {matrixStore.userId?.charAt(1).toUpperCase() || '?'} - -
    -
    -

    {matrixStore.userId}

    -

    Matrix ID

    -
    -
    -
    -
    - - -
    -
    -

    - - Server -

    -
    -
    - Homeserver - {matrixStore.client?.getHomeserverUrl() || 'Unbekannt'} -
    -
    - Sync Status - - {matrixStore.syncState} - -
    -
    - Räume - {matrixStore.rooms.length} -
    -
    -
    -
    - - -
    -
    -

    - - Sicherheit & Verschlüsselung -

    - - {#if !cryptoReady} -
    - - Verschlüsselung wird initialisiert... -
    - {:else} -
    - -
    -
    - {#if verificationStatus === 'verified'} - - {:else} - - {/if} -
    -

    - {verificationStatus === 'verified' ? 'Verifiziert' : 'Nicht verifiziert'} -

    -

    - {verificationStatus === 'verified' - ? 'Dein Gerät ist verifiziert' - : 'Verifiziere dein Gerät für bessere Sicherheit'} -

    -
    -
    - -
    - - -
    -
    - Geräte-ID - {deviceId || 'Unbekannt'} -
    -
    - - -
    -
    - -
    -

    - {keyBackupEnabled ? 'Schlüssel-Backup aktiv' : 'Kein Schlüssel-Backup'} -

    -

    - {keyBackupEnabled - ? 'Deine Nachrichten werden gesichert' - : 'Richte ein Backup ein, um Nachrichten wiederherzustellen'} -

    -
    -
    - {#if keyBackupEnabled} - - {:else} - - {/if} -
    -
    - {/if} -
    -
    - - -
    -
    -

    - - Erscheinungsbild -

    - -
    -

    Wähle dein bevorzugtes Farbschema

    - - -
    - - - - - -
    - - -
    - {#if theme.mode === 'system'} - Aktuell: {theme.isDark ? 'Dunkel' : 'Hell'} (basierend auf System) - {:else} - Aktuell: {theme.isDark ? 'Dunkel' : 'Hell'} - {/if} -
    -
    -
    -
    - - -
    -
    -

    - - Benachrichtigungen -

    - - {#if !notificationsSupported} -

    - Dein Browser unterstützt keine Benachrichtigungen. -

    - {:else if notificationPermission === 'denied'} -
    -

    Benachrichtigungen blockiert

    -

    - Du hast Benachrichtigungen für diese Seite blockiert. Bitte ändere die Einstellung - in deinem Browser. -

    -
    - {:else if notificationPermission === 'default'} -
    -
    - -
    -

    Benachrichtigungen aktivieren

    -

    - Erhalte Benachrichtigungen für neue Nachrichten -

    -
    -
    - -
    - {:else} -
    - - - - - - - - -
    - {/if} -
    -
    - - -
    - -
    -
    - -

    v{APP_VERSION}

    -
    -
    - - - (verificationDialogOpen = false)} -/> - (recoveryDialogOpen = false)} -/> diff --git a/apps/matrix/apps/web/src/routes/(app)/tags/+page.svelte b/apps/matrix/apps/web/src/routes/(app)/tags/+page.svelte deleted file mode 100644 index a137c3930..000000000 --- a/apps/matrix/apps/web/src/routes/(app)/tags/+page.svelte +++ /dev/null @@ -1,49 +0,0 @@ - - - - Tags | Manalink - - -
    -

    Tags verwalten

    -

    - Tags sind app-übergreifend — Änderungen gelten in allen Mana-Apps. -

    - - {#if tagStore.loading} -

    Lädt...

    - {:else if tagStore.tags.length === 0} -

    Keine Tags vorhanden.

    - {:else} -
    - {#each tagStore.tags as tag} -
    - - {tag.name} -
    - {/each} -
    - {/if} -
    - - diff --git a/apps/matrix/apps/web/src/routes/(auth)/+layout.svelte b/apps/matrix/apps/web/src/routes/(auth)/+layout.svelte deleted file mode 100644 index 9091dc9bf..000000000 --- a/apps/matrix/apps/web/src/routes/(auth)/+layout.svelte +++ /dev/null @@ -1,21 +0,0 @@ - - -
    - {@render children()} -
    - - diff --git a/apps/matrix/apps/web/src/routes/(auth)/login/+page.svelte b/apps/matrix/apps/web/src/routes/(auth)/login/+page.svelte deleted file mode 100644 index e6d515f4b..000000000 --- a/apps/matrix/apps/web/src/routes/(auth)/login/+page.svelte +++ /dev/null @@ -1,849 +0,0 @@ - - - - Login - Manalink - - -
    - - - -
    - -
    -
    - {#if showSuccess} - - {:else} - - {/if} -
    -

    Manalink

    -

    Sichere Matrix-Kommunikation

    -
    - - -
    -
    -
    -

    Anmelden

    -

    Sichere Kommunikation mit Matrix

    -
    - - {#if error} - - {/if} - - - - -

    Login oder Registrierung über dein Mana-Konto

    - - - - - {#if showAdvanced} -
    -
    - -
    - - -
    - - -
    - - -
    - - -
    - -
    - - -
    -
    - - - -
    -
    - {/if} -
    -
    -
    -
    - - diff --git a/apps/matrix/apps/web/src/routes/+error.svelte b/apps/matrix/apps/web/src/routes/+error.svelte deleted file mode 100644 index f64842b92..000000000 --- a/apps/matrix/apps/web/src/routes/+error.svelte +++ /dev/null @@ -1,46 +0,0 @@ - - - - {$page.status} - Manalink - - -
    -
    - -
    - -
    -

    {$page.status}

    -

    - {#if $page.status === 404} - Diese Seite wurde nicht gefunden. - {:else} - Ein unerwarteter Fehler ist aufgetreten. - {/if} -

    - {#if $page.error?.message} -

    {$page.error.message}

    - {/if} -
    - -
    - - -
    -
    diff --git a/apps/matrix/apps/web/src/routes/+layout.svelte b/apps/matrix/apps/web/src/routes/+layout.svelte deleted file mode 100644 index c4ec76918..000000000 --- a/apps/matrix/apps/web/src/routes/+layout.svelte +++ /dev/null @@ -1,44 +0,0 @@ - - - - {$i18nLoading ? 'Matrix Chat' : $t('app.name')} - - - -{#if $i18nLoading} - -
    -
    Laden...
    -
    -{:else} -
    - {@render children()} -
    - - - -{/if} diff --git a/apps/matrix/apps/web/src/routes/+page.svelte b/apps/matrix/apps/web/src/routes/+page.svelte deleted file mode 100644 index ac828c7d4..000000000 --- a/apps/matrix/apps/web/src/routes/+page.svelte +++ /dev/null @@ -1,33 +0,0 @@ - - -
    -
    - -

    Loading...

    -
    -
    diff --git a/apps/matrix/apps/web/src/routes/health/+server.ts b/apps/matrix/apps/web/src/routes/health/+server.ts deleted file mode 100644 index 0b1fdd950..000000000 --- a/apps/matrix/apps/web/src/routes/health/+server.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { RequestHandler } from '@sveltejs/kit'; - -export const GET: RequestHandler = async () => { - return new Response( - JSON.stringify({ - status: 'ok', - timestamp: new Date().toISOString(), - service: 'matrix-web', - }), - { - status: 200, - headers: { - 'Content-Type': 'application/json', - }, - } - ); -}; diff --git a/apps/matrix/apps/web/src/routes/offline/+page.svelte b/apps/matrix/apps/web/src/routes/offline/+page.svelte deleted file mode 100644 index f10f1803f..000000000 --- a/apps/matrix/apps/web/src/routes/offline/+page.svelte +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/apps/matrix/apps/web/src/routes/offline/+page.ts b/apps/matrix/apps/web/src/routes/offline/+page.ts deleted file mode 100644 index 189f71e2e..000000000 --- a/apps/matrix/apps/web/src/routes/offline/+page.ts +++ /dev/null @@ -1 +0,0 @@ -export const prerender = true; diff --git a/apps/matrix/apps/web/src/test/mocks/app-environment.ts b/apps/matrix/apps/web/src/test/mocks/app-environment.ts deleted file mode 100644 index 7e7ecc8aa..000000000 --- a/apps/matrix/apps/web/src/test/mocks/app-environment.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const browser = false; -export const building = false; -export const dev = true; -export const version = 'test'; diff --git a/apps/matrix/apps/web/static/apple-touch-icon.png b/apps/matrix/apps/web/static/apple-touch-icon.png deleted file mode 100644 index a56722250b3d03e0552c5de830443a6bddc24fca..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5167 zcmXw7c|6qJ_qPqkG9DD!OCd1~CQGu67D=>tvJQsvjIpahgqShN5@o_zVoKYCu{5?R zO9){wDU3bQQ1%ERzMtv&{r>oTKKFG$XS?T|d(M5|q${?TqC&DlTwGkDRu|4*1HM(9 zN01+QoAmJO0v~Yj1t%OAm#`$~;pWQCKg7i)Ic0Sg@ppLs;`q00xq%!36!$F5U54F6 zPS4RKg=`+vJQI`w#)mmS%F#S?mxd6$q>7`w8K8n(Zu)x$2d@y+!h_Iia=o)-L0ty!p`E zhdM?z06T8q8;S70v(%qcK5xy1^KN*T;`F!TzA|D%JK1tKP@_!5H<=$>oA$ zGVWj85A2+l4QA0x4<(Ju-?TOpN=u5g2vko%&Bwz=rEwHDRuVSZIA==TVDRlii`SdXS0a4&EW{Th?Vv z@|M<_(s-C8#v#ryp0wvNH97{jB1~S~h#%|k)O}CPfxQVq+2<uobANg?gU*Ja$`cvlmUBCDr^r# z=lzQ3P{w1O)h3R*l~|{HwFAO!UyJ&)34uD+WH2sPl5r8L&}}28Km4qN-=_VY2C!0r zx|Y&+#>bJqzAGS0E-=6ue3wpUopL2-(@72Rd>b+^5-s)C;E={8<9O})L#H1nohKUg z8n8Xn1{Fe_mTc1`I5r7jr1nwkOU2|HPeR{A6^?L>l4LCK+%Pw^4}HK@2LCg2l+lPd zd8QKYOSvV<;gVw`2JaczRZ>nGsy^rFW{J=;9J_Gd>(7mtb*J(;K7i`@B~3W^2Of73 z1y(Pz@s9DzuWZ@K@WeTQVPjNUZ;PLJJkPCo344|d)?-Q0Zh8n#OtH+22u1y3e@GP8 zC|q`|Gzh&d6Ik$0K-#eJ;n-TUnz6@$`4>1mvNWFCV){lL?=I9DW3pK4me{}RFOtEu z$qllTmhr{f*D4I+aF9C#Hm-eky^K0$AHZ zl^F&E_fTc@F1;;P;tX1M(Hk4{+%{zJT7?PBfd9`R8a5>GXV3tP+(Dl-mt;SOl{a1{ zQVWhPxRMfhM|Fc2GBu;NY#~~TeXW^#5p^3^@t~&0a7{6##z%2QbOk!QsuGIZCe6NY_0jrwfQ?CfRsDX`%al+`>GX_rxjIMGd{x=|6(8 z-u(I|%FX?61ZlMp_Dv>{t|_OO@p)y<>90N2Gg`v;hdgz$i=j9fE*<68RR>zd*Q?;& z@Ye7akpaLlSshJBTN>~AY>j5}@V+CE4Y^%qqteR*F0^(9sR-g?)Uhoh^D$b@t3$L> z(L5Vo;bjWVC#qaGvM*fx^~RkW&FDZ#h_vTkp}XqTZ?mt5Nf}4-PI%TxVx@lmj@b%> z=$mRn9T^kG>x3m;hMZ!SW38-3LF*WZqUYPI(A)f4;2kJ2!dZGwt7Ug4TDgA5Q>s5h zByP~^2WEb+V~>bdxD_u=)VWWB)l^bLadf zx=aip2-;Mc`e+RbyIHMyjOmkloaXjG#{E@^&Jh1Nh#d3VRor&54{=e`s8aY)i1WkY z%D0&JU6p4YfFQjVhAktr2=A+TjPZQVTDHcR@3J65&q>#WedpEVjUTkq@MmHLBmzP<` z9mA7PuOi@7CuUOSWZ|!*WffGO8kpwtF?i}iT7&%ygxa*OaaatJ zRP1gy%0=TDX(v0RCI$Zx{rc`G+ZMk!W5EsS^+I)@&1fidBCW0d8Z-!sLtj65=ExG=fsq?#dWvr5^)xkJPlh8i z<1kI}33NJR5!h=zN7xNb*GIeW4j*P3uKf66=d+dRQZ+97mU)54&FztA;Suy-*h|XF zttlnfYFdy=aBuRBuYzT=A0Tz&6f;wIlnJDJsb-%r7^~N-b^Hc6Tb#05evnOdl@T;t z#MAY$!a6~>;b2QQd#Ri|>uwM&`JUEiqzHR7BlMqt$DJJIs(EBbJNlqD&HX1zY8LO( z;oRQxm?(+)G(Qkr@#8Pohe;J8(&-wr)4QWahMV?~iRdG|a!L~P7NkN|Jh@U%#1C|u z>DcK*`0E*x*P3`m4O>?fKX=c(p4bxL1sQ`(EMl@;Z*aB z>rAEk%s91W>Uh25eR5*5laW;7N80cpqWf*HxM|r5YzoXkV1!sk_nTeoVw|L(WUf|- zXqWmTHMG4LjTsA0Im9h*DjxH4jZ3Mp8y6R`9o9gxk%fQ2d0Z2QL*fj4H6$^QJq_g& z>8sjk4NA4}O*hy)Z@`xWR|dlZrznjv#|0j@RSCZ=Z&RnFLnT;gm#Frt^+3fKnaNb8 z#xHQ#N{-!m`@uS@9^O61GKij-MLvGq$77h&hNZgN(E&+*^-rAK{&qs}MI=pnz!< zKcVBi3RlgiE89N2Eq+--VDsWsuqm6letfcdGKQ1p+8iMTef3K(GATFyxzvUY(4*M2 zBO$qQuIBRu`yG>?1XYHG9cj%EuQhX`FHVao^-c{tIR*M;$RrMzpb>sFO7*`l4ApX_8Py1v zSedxI{D;tHUpctP#&_f+SN>Cv#o(TexAi+j+21M9mQ;0D!hk8ZWg)<`1aFUUH_e>p z2b?CXBPW~-i_9A{C_+GY!t1@iR_U3A-+yO^0k0?FO{(?YbqoUd$ zn4U3rNsahEm_X@i9nWqhI7 zMMJD4Y=|pBl${3XyAl){W{Jon(uJ_|Fmzdn5Zoi-yo+dvNL3Pf7GEQUqu5cvdUL}D z=<#%8t`RRrfiiYpXZpfq$86?k=2Y?BD0@2S+&9~&BT(X+(N}L)vrT8PSCAB@_Nf!Z zpPH4L5%4tv7iq=bcj;+X_=&@w?`+OlM?!k~Y)`H=#spht`fG~?+Pmcz7Y12JV5((#WZ`#Fu6l?_JZ& zdX&96jv({#JLXQmAkJ1?R$}nG0#op#^q+*>c>kuj&ZF z8)t79su%L&%yF^w6W9`1t>~$VLm@>wH5Jbjd(5XJ2Wi%RuSpc6W+eB>MF!t%WAn>S zcj+QPmfxsBzChTK&MY0rmgw~1+1$7e#B_gD{ieqwlNSMzSIMiKMjc`eo!7W;NK++SQRp}2TxMxl+e@QaEWhl_$-u)^+ydlhXDKC5f`pshJiI zl;@Pvvy1>B%bC4elmI-N4;Rf;MF$G6_k{o@iz&m5LY0Y*@GumBvvcz`rYZ`kkID=M zpky@%Ggnmsalj!eo@~sY3yXX9{~NkacQhE#u^!0tQ5=xQX+pFqV2{9QMGvMZ(6^wH!TOAGY%;q2cY&-T*+2Y_;M`Ob9#tll#7_SX-r!DTxeMFi?@SNFJpv`y9_B{(J!Rvw<_T$T8gK0;({XWhRjfu3-~PKz ziIwjo>tho*|NXj07ps2H1S_2)*8dVug2@phF7_<%*?CoX8$A2`mVH2;l|eA5Rc|i$ zHqh9aT6BFx%q-$@AeRtoB=l^!tEJWsSklaTM<>Qy zU04Iw9O<7XGyN^)POpw0kHHplRgfc14z0K>oM-~6o4~gL*AJIDLY|Xl<_g~9L})_W ziGTQ#Bc3<}nEAEgEEh1q4AvF^+aK zhGAmSL^Fd0+Fs3^=T@0zL)^1lStu!SQbc+SD~Tg1Sagp9De?{?UW(MCS%l+ zXp_OvCbEPNxvE%bcb`KVoElMYfDC^+ON%K>i710ErUo4bw6v;YPF|jGIX$FpBHwfg z2=%cAott}8BR%+09rzhlwCn{2G4Ze%leK6K!0HFG` z&p~z!QBL8!RPfu-iN`D<4rPx&W<9|{&R)+!!}P_<{aeSg0E`{+sqTcD7X9x97u%2EyVM6X3qF_so zVg9%$AI>g&yWd>7vO@S^cDODouEm%m3XYGcSBvtjSNpN}K)kNG*#*IBe9Vdsy2AP< zm--C2I4O8p$sb&AplgSF22O7SZX#4CQR}A5DF51^D}s{j_;TVlIGz diff --git a/apps/matrix/apps/web/static/favicon.png b/apps/matrix/apps/web/static/favicon.png deleted file mode 100644 index c352c323c385c66b98b3dfde69d7fbe239d51d1b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 955 zcmV;s14R6ZP)WfG8Q?-R!e{F%L)fVXBIxJLkNE13JRDB?>d=1t6Rjv#B;>r^` zzn7%o{2m2WdlaoIN9lLw22K)-&5cV%`WjcEKIkgcLHgftZXh?V90kst;6T-GeNE!d zR}#*(TR~-ZaDcA_E&EEtox6fu71@De<|@#<>3}=4gQ(07^f6bVrlteV4P*sXmPR-i zO9g)YgpQ|m23|B-=xnm^X>QxoH8PG^HToADC(tN{tUv(ZFw%0}3* zVW>6++rk{QSi;p6sd)Q5%;(VD;A9N#w+tz~Vmlc~vh4ox7%1p&S{$KeXM1yr&gWA2 zf5fElgaKK$t3So$&jXQ^kJ`sK5q=G2+Lpr0w*|nG9kr=Zu>!Q`sh)FRuP`t<5|zSd z4M>U%fhj1lQY|tw27J9?DZG5^FR)}4Zk~Y@;I!uiP8g8Dg@GjH^}CM_IxsO3L)%Rg z9rY$Wtrptr#ruxiCc5qjb<`U?)7x$u{B!#a1JAD+nD`>D@$ExI3NPOhHz2F+Gf5bY zKpZK{hHYbhA&SS`LPTR|EX|?0A_-$sxMTA#uw)OGv0*`&{uaZl789Pk7G6IN!{fH# zX*JP(*M#?>PYgt=YQCO$ z4)lbjaK|PxkmQm2hk<3nr3Mzj69#1G++7|*?>!4{M<^vYEqQZ^hHkfs=Chg|g#&{0*LL&*#G92GR#Vw}A|#5iShmDr&mmrJ4R9bLJ}Ebim6px%wFOYH5O_Y`wpj zw`QVOTUmH%#?JxATCC#zAvKEBtF0uwWZkrAPG`b#5UZ$Hqjrfpmj9k`8Gf}i!{1BG dp15 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/matrix/apps/web/static/pwa-192x192.png b/apps/matrix/apps/web/static/pwa-192x192.png deleted file mode 100644 index e69962d63bbb421979e2dc4ad8b92912c84e3c3e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5466 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 diff --git a/apps/matrix/apps/web/static/pwa-512x512.png b/apps/matrix/apps/web/static/pwa-512x512.png deleted file mode 100644 index 1d596ae7c4d68c864c105fb1707d013e6b058f21..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17527 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 diff --git a/apps/matrix/apps/web/svelte.config.js b/apps/matrix/apps/web/svelte.config.js deleted file mode 100644 index a7a917e4c..000000000 --- a/apps/matrix/apps/web/svelte.config.js +++ /dev/null @@ -1,14 +0,0 @@ -import adapter from '@sveltejs/adapter-node'; -import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; - -/** @type {import('@sveltejs/kit').Config} */ -const config = { - preprocess: vitePreprocess(), - kit: { - adapter: adapter({ - out: 'build', - }), - }, -}; - -export default config; diff --git a/apps/matrix/apps/web/tsconfig.json b/apps/matrix/apps/web/tsconfig.json deleted file mode 100644 index 942bcc11c..000000000 --- a/apps/matrix/apps/web/tsconfig.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "extends": "./.svelte-kit/tsconfig.json", - "compilerOptions": { - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "module": "ES2022", - "target": "ES2022", - "moduleResolution": "bundler", - "strict": true, - "skipLibCheck": true, - "esModuleInterop": true, - "resolveJsonModule": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "allowSyntheticDefaultImports": true, - "types": ["node"] - }, - "include": ["src/**/*", "src/**/*.svelte"] -} diff --git a/apps/matrix/apps/web/vite.config.ts b/apps/matrix/apps/web/vite.config.ts deleted file mode 100644 index 2112353f6..000000000 --- a/apps/matrix/apps/web/vite.config.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { sveltekit } from '@sveltejs/kit/vite'; -import tailwindcss from '@tailwindcss/vite'; -import { defineConfig, type UserConfig } from 'vite'; -import { SvelteKitPWA } from '@vite-pwa/sveltekit'; - -// Mana shared packages that need SSR configuration -const MANA_SHARED_PACKAGES = [ - '@mana/shared-icons', - '@mana/shared-ui', - '@mana/shared-tailwind', - '@mana/shared-theme', - '@mana/shared-theme-ui', - '@mana/feedback', - '@mana/feedback', - '@mana/feedback', - '@mana/shared-auth', - '@mana/shared-auth-ui', - '@mana/shared-branding', - '@mana/subscriptions', - '@mana/shared-profile-ui', - '@mana/shared-i18n', - '@mana/shared-api-client', - '@mana/shared-splitscreen', - '@mana/shared-utils', - '@mana/shared-tags', - '@mana/help', - '@mana/help', - '@mana/help', -]; - -const noExternal = [...MANA_SHARED_PACKAGES, '@matrix/shared']; -const exclude = [...MANA_SHARED_PACKAGES]; - -const baseConfig: Partial = { - server: { - port: 5180, - strictPort: true, - }, - ssr: { - noExternal, - }, - optimizeDeps: { - exclude, - }, -}; - -export default defineConfig({ - ...baseConfig, - plugins: [ - tailwindcss(), - sveltekit(), - SvelteKitPWA({ - srcDir: 'src', - registerType: 'autoUpdate', - strategies: 'generateSW', - scope: '/', - base: '/', - manifest: { - name: 'Manalink', - short_name: 'Manalink', - description: 'Secure Matrix messaging client', - theme_color: '#8b5cf6', - background_color: '#09090b', - display: 'standalone', - orientation: 'portrait', - start_url: '/', - scope: '/', - categories: ['communication', 'social'], - icons: [ - { - src: 'pwa-192x192.png', - sizes: '192x192', - type: 'image/png', - }, - { - src: 'pwa-512x512.png', - sizes: '512x512', - type: 'image/png', - }, - { - src: 'pwa-512x512.png', - sizes: '512x512', - type: 'image/png', - purpose: 'maskable', - }, - ], - shortcuts: [ - { - name: 'New Chat', - short_name: 'New Chat', - url: '/chat?action=new', - icons: [{ src: 'pwa-192x192.png', sizes: '192x192' }], - }, - ], - }, - injectManifest: { - globPatterns: ['client/**/*.{js,css,ico,png,svg,webp,woff,woff2}'], - }, - workbox: { - globPatterns: ['client/**/*.{js,css,ico,png,svg,webp,woff,woff2}'], - cleanupOutdatedCaches: true, - clientsClaim: true, - skipWaiting: true, - // Cache strategies - runtimeCaching: [ - { - // Cache Matrix API responses (short TTL) - urlPattern: /^https:\/\/matrix\.mana\.how\/_matrix\/.*/i, - handler: 'NetworkFirst', - options: { - cacheName: 'matrix-api-cache', - expiration: { - maxEntries: 100, - maxAgeSeconds: 60 * 5, // 5 minutes - }, - cacheableResponse: { - statuses: [0, 200], - }, - }, - }, - { - // Cache images and avatars - urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp)$/i, - handler: 'CacheFirst', - options: { - cacheName: 'image-cache', - expiration: { - maxEntries: 200, - maxAgeSeconds: 60 * 60 * 24 * 30, // 30 days - }, - }, - }, - { - // Cache fonts - urlPattern: /\.(?:woff|woff2|ttf|otf)$/i, - handler: 'CacheFirst', - options: { - cacheName: 'font-cache', - expiration: { - maxEntries: 20, - maxAgeSeconds: 60 * 60 * 24 * 365, // 1 year - }, - }, - }, - ], - }, - devOptions: { - enabled: process.env.NODE_ENV !== 'production', - type: 'module', - navigateFallback: '/', - }, - kit: { - includeVersionFile: true, - }, - }), - ], - server: { - ...baseConfig.server, - headers: { - // Required for WASM module loading - 'Cross-Origin-Opener-Policy': 'same-origin', - 'Cross-Origin-Embedder-Policy': 'require-corp', - }, - }, - ssr: { - ...baseConfig.ssr, - }, - define: { - global: 'globalThis', - }, - optimizeDeps: { - ...baseConfig.optimizeDeps, - include: ['buffer', 'events'], - // WASM modules cannot be pre-bundled - exclude: [...exclude, '@matrix-org/matrix-sdk-crypto-wasm'], - esbuildOptions: { - define: { - global: 'globalThis', - }, - }, - }, - worker: { - format: 'es', - }, - build: { - target: 'esnext', - }, -}); diff --git a/apps/matrix/apps/web/vitest.config.ts b/apps/matrix/apps/web/vitest.config.ts deleted file mode 100644 index 942d7fd60..000000000 --- a/apps/matrix/apps/web/vitest.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineConfig } from 'vitest/config'; -import { resolve } from 'path'; - -export default defineConfig({ - test: { - include: ['src/**/*.{test,spec}.{js,ts}'], - globals: true, - environment: 'node', - }, - resolve: { - alias: { - $lib: resolve('./src/lib'), - '$app/environment': resolve('./src/test/mocks/app-environment.ts'), - }, - }, -}); diff --git a/apps/matrix/package.json b/apps/matrix/package.json deleted file mode 100644 index 2f824a585..000000000 --- a/apps/matrix/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "matrix", - "version": "0.2.0", - "private": true, - "scripts": { - "dev": "turbo run dev", - "dev:web": "pnpm --filter @matrix/web dev", - "dev:mobile": "pnpm --filter @matrix/mobile dev" - } -} diff --git a/apps/matrix/packages/shared/package.json b/apps/matrix/packages/shared/package.json deleted file mode 100644 index 44bd1ac34..000000000 --- a/apps/matrix/packages/shared/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "@matrix/shared", - "version": "0.2.0", - "private": true, - "type": "module", - "main": "./src/index.ts", - "types": "./src/index.ts", - "exports": { - ".": "./src/index.ts", - "./types": "./src/types.ts" - }, - "scripts": { - "type-check": "tsc --noEmit" - }, - "devDependencies": { - "typescript": "^5.8.3" - } -} diff --git a/apps/matrix/packages/shared/src/index.ts b/apps/matrix/packages/shared/src/index.ts deleted file mode 100644 index a5980435f..000000000 --- a/apps/matrix/packages/shared/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Re-export all types -export * from './types'; diff --git a/apps/matrix/packages/shared/src/types.ts b/apps/matrix/packages/shared/src/types.ts deleted file mode 100644 index 66f9e5942..000000000 --- a/apps/matrix/packages/shared/src/types.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Shared types for Matrix client - */ - -export type SyncState = 'STOPPED' | 'PREPARED' | 'SYNCING' | 'ERROR' | 'RECONNECTING' | 'CATCHUP'; - -export interface MatrixCredentials { - homeserver: string; - accessToken: string; - userId: string; - deviceId: string; -} - -export type MessageType = 'm.text' | 'm.image' | 'm.file' | 'm.audio' | 'm.video' | 'm.emote' | 'm.notice'; - -export interface SimpleMessage { - id: string; - sender: string; - senderName: string; - body: string; - formattedBody?: string; - timestamp: number; - type: MessageType; - isOwn: boolean; - replyTo?: string; - edited?: boolean; -} - -export interface SimpleRoom { - id: string; - name: string; - topic?: string; - avatar?: string; - lastMessage?: string; - lastMessageSender?: string; - lastMessageTime?: number; - unreadCount: number; - highlightCount: number; - isDirect: boolean; - isEncrypted: boolean; - memberCount: number; -} diff --git a/apps/matrix/packages/shared/tsconfig.json b/apps/matrix/packages/shared/tsconfig.json deleted file mode 100644 index 882ac919d..000000000 --- a/apps/matrix/packages/shared/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "lib": ["ES2022"], - "module": "ES2022", - "target": "ES2022", - "moduleResolution": "bundler", - "strict": true, - "skipLibCheck": true, - "esModuleInterop": true, - "resolveJsonModule": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true - }, - "include": ["src/**/*"] -} diff --git a/cloudflared-config.yml b/cloudflared-config.yml index a2a71cf6d..acef7bf2a 100644 --- a/cloudflared-config.yml +++ b/cloudflared-config.yml @@ -43,14 +43,6 @@ ingress: - hostname: stats.mana.how service: http://localhost:8010 - # Matrix (DSGVO-konformes Messaging) - - hostname: matrix.mana.how - service: http://localhost:4000 - - hostname: element.mana.how - service: http://localhost:4010 - - hostname: link.mana.how - service: http://localhost:4090 - # GlitchTip Error Tracking - hostname: glitchtip.mana.how service: http://localhost:8020 diff --git a/docker-compose.macmini.yml b/docker-compose.macmini.yml index 2ed8479ca..41d8192db 100644 --- a/docker-compose.macmini.yml +++ b/docker-compose.macmini.yml @@ -579,8 +579,6 @@ services: SMTP_FROM: "Mana " SMTP_INSECURE_TLS: "true" EXPO_ACCESS_TOKEN: ${EXPO_ACCESS_TOKEN:-} - MATRIX_HOMESERVER_URL: http://mana-matrix-synapse:8008 - MATRIX_ACCESS_TOKEN: ${MATRIX_NOTIFY_BOT_TOKEN:-} ports: - "3013:3013" healthcheck: @@ -671,7 +669,6 @@ services: S3_SECRET_KEY: ${MINIO_SECRET_KEY:-minioadmin} S3_BUCKET: mana-media S3_PUBLIC_URL: https://media.mana.how - MATRIX_HOMESERVER_URL: https://matrix.mana.how PUBLIC_URL: https://media.mana.how/api/v1 CORS_ORIGINS: https://mana.how,https://nutriphi.mana.how,https://contacts.mana.how,https://chat.mana.how,https://storage.mana.how,https://photos.mana.how ports: @@ -728,173 +725,6 @@ services: # Requires: Dockerfile.bun in apps/ root (FROM oven/bun:1, COPY, CMD bun run src/index.ts) - # ============================================ - # Tier 4: Matrix Stack (Ports 4000-4099) - # ============================================ - - synapse: - image: matrixdotorg/synapse:latest - container_name: mana-matrix-synapse - restart: always - mem_limit: 512m - depends_on: - postgres: - condition: service_healthy - entrypoint: ["sh", "-c", "mkdir -p /config && cp /mnt/synapse-config/*.yaml /config/ 2>/dev/null; cp -r /mnt/synapse-config/appservices /config/ 2>/dev/null; exec python -m synapse.app.homeserver -c /config/homeserver.yaml"] - environment: - TZ: Europe/Berlin - SYNAPSE_DB_PASSWORD: ${SYNAPSE_DB_PASSWORD:-synapse-secure-password} - SYNAPSE_PASSWORD_PEPPER: ${SYNAPSE_PASSWORD_PEPPER:-change-me-pepper} - SYNAPSE_FORM_SECRET: ${SYNAPSE_FORM_SECRET:-change-me-form-secret} - SYNAPSE_MACAROON_SECRET: ${SYNAPSE_MACAROON_SECRET:-change-me-macaroon-secret} - SYNAPSE_REGISTRATION_SECRET: ${SYNAPSE_REGISTRATION_SECRET:-change-me-registration-secret} - SYNAPSE_OIDC_CLIENT_SECRET: ${SYNAPSE_OIDC_CLIENT_SECRET} - volumes: - - ./docker/matrix/config:/mnt/synapse-config:ro - - ./docker/matrix/data:/data - ports: - - "4000:8008" - - "9002:9002" # Metrics - healthcheck: - test: ["CMD", "curl", "-fSs", "http://localhost:8008/health"] - interval: 120s - timeout: 10s - retries: 3 - start_period: 60s - - element-web: - image: vectorim/element-web:latest - container_name: mana-matrix-element - restart: always - mem_limit: 48m - depends_on: - synapse: - condition: service_healthy - volumes: - - ./docker/matrix/element-config.json:/app/config.json:ro - ports: - - "4010:80" - healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:80/"] - interval: 180s - timeout: 10s - retries: 3 - start_period: 45s - - matrix-web: - build: - context: . - dockerfile: apps/matrix/apps/web/Dockerfile - image: matrix-web:latest - container_name: mana-matrix-web - restart: always - mem_limit: 96m - depends_on: - synapse: - condition: service_healthy - environment: - NODE_ENV: production - PORT: 4011 - PUBLIC_MANA_AUTH_URL: https://auth.mana.how - ports: - - "4011:4011" - healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:4011/health"] - interval: 180s - timeout: 10s - retries: 3 - start_period: 45s - - # ============================================ - # Matrix Bots — Consolidated Go Service - # Replaces 21 separate NestJS bot containers - # ============================================ - - mana-matrix-bot: - build: - context: . - dockerfile: services/mana-matrix-bot/Dockerfile - image: mana-matrix-bot:local - container_name: mana-matrix-bot - restart: always - mem_limit: 128m - depends_on: - synapse: - condition: service_healthy - redis: - condition: service_healthy - environment: - TZ: Europe/Berlin - PORT: 4001 - # Matrix - MATRIX_HOMESERVER_URL: http://synapse:8008 - MATRIX_STORAGE_PATH: /app/data - # Auth & Redis - MANA_AUTH_URL: http://mana-auth:3001 - MANA_SERVICE_KEY: ${MANA_SERVICE_KEY} - REDIS_HOST: redis - REDIS_PORT: 6379 - REDIS_PASSWORD: ${REDIS_PASSWORD:-redis123} - # Voice services (GPU server via LAN) - STT_URL: ${STT_SERVICE_URL:-http://192.168.178.11:3020} - TTS_URL: ${TTS_SERVICE_URL:-http://192.168.178.11:3022} - # AI (GPU server via LAN) - OLLAMA_URL: ${OLLAMA_URL:-http://192.168.178.11:11434} - OLLAMA_MODEL: ${OLLAMA_MODEL:-gemma3:12b} - # Plugin tokens (all 21 bot identities) - MATRIX_MANA_BOT_TOKEN: ${MATRIX_MANA_BOT_TOKEN} - MATRIX_MANA_BOT_ROOMS: ${MATRIX_MANA_BOT_ROOMS:-} - MATRIX_TODO_BOT_TOKEN: ${MATRIX_TODO_BOT_TOKEN} - MATRIX_TODO_BOT_ROOMS: ${MATRIX_TODO_BOT_ROOMS:-} - MATRIX_CALENDAR_BOT_TOKEN: ${MATRIX_CALENDAR_BOT_TOKEN} - MATRIX_CALENDAR_BOT_ROOMS: ${MATRIX_CALENDAR_BOT_ROOMS:-} - MATRIX_CLOCK_BOT_TOKEN: ${MATRIX_CLOCK_BOT_TOKEN} - MATRIX_CLOCK_BOT_ROOMS: ${MATRIX_CLOCK_BOT_ROOMS:-} - MATRIX_OLLAMA_BOT_TOKEN: ${MATRIX_OLLAMA_BOT_TOKEN} - MATRIX_OLLAMA_BOT_ROOMS: ${MATRIX_OLLAMA_BOT_ROOMS:-} - MATRIX_STATS_BOT_TOKEN: ${MATRIX_STATS_BOT_TOKEN} - MATRIX_STATS_BOT_ROOMS: ${MATRIX_STATS_BOT_ROOMS:-} - MATRIX_CONTACTS_BOT_TOKEN: ${MATRIX_CONTACTS_BOT_TOKEN:-} - MATRIX_CONTACTS_BOT_ROOMS: ${MATRIX_CONTACTS_BOT_ROOMS:-} - MATRIX_CHAT_BOT_TOKEN: ${MATRIX_CHAT_BOT_TOKEN:-} - MATRIX_CARDS_BOT_TOKEN: ${MATRIX_CARDS_BOT_TOKEN:-} - MATRIX_NUTRIPHI_BOT_TOKEN: ${MATRIX_NUTRIPHI_BOT_TOKEN} - MATRIX_NUTRIPHI_BOT_ROOMS: ${MATRIX_NUTRIPHI_BOT_ROOMS:-} - MATRIX_PICTURE_BOT_TOKEN: ${MATRIX_PICTURE_BOT_TOKEN:-} - MATRIX_PLANTA_BOT_TOKEN: ${MATRIX_PLANTA_BOT_TOKEN} - MATRIX_PLANTA_BOT_ROOMS: ${MATRIX_PLANTA_BOT_ROOMS:-} - MATRIX_PRESI_BOT_TOKEN: ${MATRIX_PRESI_BOT_TOKEN:-} - MATRIX_QUESTIONS_BOT_TOKEN: ${MATRIX_QUESTIONS_BOT_TOKEN:-} - MATRIX_SKILLTREE_BOT_TOKEN: ${MATRIX_SKILLTREE_BOT_TOKEN:-} - MATRIX_STORAGE_BOT_TOKEN: ${MATRIX_STORAGE_BOT_TOKEN:-} - MATRIX_PROJECT_DOC_BOT_TOKEN: ${MATRIX_PROJECT_DOC_BOT_TOKEN} - MATRIX_STT_BOT_TOKEN: ${MATRIX_STT_BOT_TOKEN} - MATRIX_STT_BOT_ROOMS: ${MATRIX_STT_BOT_ROOMS:-} - MATRIX_TTS_BOT_TOKEN: ${MATRIX_TTS_BOT_TOKEN} - MATRIX_TTS_BOT_ROOMS: ${MATRIX_TTS_BOT_ROOMS:-} - MATRIX_ZITARE_BOT_TOKEN: ${MATRIX_ZITARE_BOT_TOKEN} - MATRIX_ZITARE_BOT_ROOMS: ${MATRIX_ZITARE_BOT_ROOMS:-} - MATRIX_ONBOARDING_BOT_TOKEN: ${MATRIX_ONBOARDING_BOT_TOKEN} - MATRIX_ONBOARDING_BOT_ROOMS: ${MATRIX_ONBOARDING_BOT_ROOMS:-} - # Backend URLs - TODO_BACKEND_URL: http://todo-backend:3031 - CALENDAR_BACKEND_URL: http://calendar-backend:3032 - # CLOCK_BACKEND_URL: removed — migrated to local-first - CONTACTS_BACKEND_URL: http://contacts-backend:3033 - # ZITARE_BACKEND_URL: removed — migrated to local-first - PLANTA_BACKEND_URL: http://planta-backend:3039 - NUTRIPHI_BACKEND_URL: http://nutriphi-backend:3038 - STORAGE_BACKEND_URL: http://storage-backend:3034 - volumes: - - matrix_bots_data:/app/data - # No host port mapping needed — only communicates with synapse internally - healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:4001/health"] - interval: 60s - timeout: 5s - retries: 3 - start_period: 10s - # ============================================ # Tier 5: Web Frontends (Ports 5000-5099) # ============================================ @@ -1617,7 +1447,5 @@ volumes: name: mana-analytics-data loki_data: name: mana-loki-data - matrix_bots_data: - name: mana-matrix-bots-data stalwart_data: name: mana-stalwart-data diff --git a/docker/matrix/appservices/generate-as.sh b/docker/matrix/appservices/generate-as.sh deleted file mode 100644 index 425f8ca09..000000000 --- a/docker/matrix/appservices/generate-as.sh +++ /dev/null @@ -1,54 +0,0 @@ -#!/bin/bash - -# Generate random token -gen_token() { - openssl rand -hex 32 -} - -# Bot configurations: name, sender_localpart -declare -a BOTS=( - "mana:mana-bot" - "ollama:ollama-bot" - "stats:stats-bot" - "projectdoc:projectdoc-bot" - "todo:todo-bot" - "calendar:calendar-bot" - "nutriphi:nutriphi-bot" - "zitare:zitare-bot" - "clock:clock-bot" - "tts:tts-bot" -) - -echo "# Generated AS tokens for .env file:" > as-tokens.env -echo "" >> as-tokens.env - -for bot_config in "${BOTS[@]}"; do - IFS=":" read -r name sender <<< "$bot_config" - - as_token=$(gen_token) - hs_token=$(gen_token) - - cat > "${name}-bot.yaml" << EOF -id: ${name}-bot -hs_token: ${hs_token} -as_token: ${as_token} -url: null -sender_localpart: ${sender} -namespaces: - users: - - exclusive: true - regex: '@${sender}:mana\.how' - rooms: [] - aliases: [] -rate_limited: false -EOF - - # Convert name to uppercase for env var - env_name=$(echo "${name}" | tr '[:lower:]' '[:upper:]' | tr '-' '_') - echo "MATRIX_${env_name}_BOT_AS_TOKEN=${as_token}" >> as-tokens.env - - echo "Created ${name}-bot.yaml with AS token" -done - -echo "" -echo "Done! Add the tokens from as-tokens.env to your .env file" diff --git a/docker/matrix/config/appservices/generate-as.sh b/docker/matrix/config/appservices/generate-as.sh deleted file mode 100644 index 425f8ca09..000000000 --- a/docker/matrix/config/appservices/generate-as.sh +++ /dev/null @@ -1,54 +0,0 @@ -#!/bin/bash - -# Generate random token -gen_token() { - openssl rand -hex 32 -} - -# Bot configurations: name, sender_localpart -declare -a BOTS=( - "mana:mana-bot" - "ollama:ollama-bot" - "stats:stats-bot" - "projectdoc:projectdoc-bot" - "todo:todo-bot" - "calendar:calendar-bot" - "nutriphi:nutriphi-bot" - "zitare:zitare-bot" - "clock:clock-bot" - "tts:tts-bot" -) - -echo "# Generated AS tokens for .env file:" > as-tokens.env -echo "" >> as-tokens.env - -for bot_config in "${BOTS[@]}"; do - IFS=":" read -r name sender <<< "$bot_config" - - as_token=$(gen_token) - hs_token=$(gen_token) - - cat > "${name}-bot.yaml" << EOF -id: ${name}-bot -hs_token: ${hs_token} -as_token: ${as_token} -url: null -sender_localpart: ${sender} -namespaces: - users: - - exclusive: true - regex: '@${sender}:mana\.how' - rooms: [] - aliases: [] -rate_limited: false -EOF - - # Convert name to uppercase for env var - env_name=$(echo "${name}" | tr '[:lower:]' '[:upper:]' | tr '-' '_') - echo "MATRIX_${env_name}_BOT_AS_TOKEN=${as_token}" >> as-tokens.env - - echo "Created ${name}-bot.yaml with AS token" -done - -echo "" -echo "Done! Add the tokens from as-tokens.env to your .env file" diff --git a/docker/matrix/config/homeserver.yaml b/docker/matrix/config/homeserver.yaml deleted file mode 100644 index 0e454cce2..000000000 --- a/docker/matrix/config/homeserver.yaml +++ /dev/null @@ -1,211 +0,0 @@ -# Mana Matrix Synapse Configuration -# Documentation: https://element-hq.github.io/synapse/latest/usage/configuration/config_documentation.html - -server_name: "mana.how" -pid_file: /data/homeserver.pid -public_baseurl: https://matrix.mana.how/ - -# ============================================ -# Listeners -# ============================================ - -listeners: - - port: 8008 - tls: false - type: http - x_forwarded: true - resources: - - names: [client, federation] - compress: false - -# ============================================ -# Database (PostgreSQL) -# ============================================ - -database: - name: psycopg2 - txn_limit: 10000 - args: - user: synapse - password: "synapse-secure-password" - database: matrix - host: postgres - port: 5432 - cp_min: 5 - cp_max: 10 - -# ============================================ -# Logging -# ============================================ - -log_config: "/config/log.config.yaml" - -# ============================================ -# Media Storage -# ============================================ - -media_store_path: /data/media_store -max_upload_size: 50M -url_preview_enabled: true -url_preview_ip_range_blacklist: - - '127.0.0.0/8' - - '10.0.0.0/8' - - '172.16.0.0/12' - - '192.168.0.0/16' - - '100.64.0.0/10' - - '192.0.0.0/24' - - '169.254.0.0/16' - - '198.18.0.0/15' - - '192.0.2.0/24' - - '198.51.100.0/24' - - '203.0.113.0/24' - - '224.0.0.0/4' - - '::1/128' - - 'fe80::/10' - - 'fc00::/7' - - '2001:db8::/32' - - 'ff00::/8' - - 'fec0::/10' - -# ============================================ -# Registration & Authentication -# ============================================ - -enable_registration: false -enable_registration_without_verification: false - -# Password config (enabled - OIDC not yet available from mana-auth) -password_config: - enabled: true - localdb_enabled: true - pepper: "${SYNAPSE_PASSWORD_PEPPER:-change-me-pepper}" - -# Session lifetime (must be >= refresh_token_lifetime) -# Set to 10 years for bot tokens to avoid frequent expiration -session_lifetime: 87600h -refresh_token_lifetime: 87600h - -# ============================================ -# Rate Limiting -# ============================================ - -rc_message: - per_second: 5 - burst_count: 20 - -rc_registration: - per_second: 0.5 - burst_count: 5 - -rc_login: - address: - per_second: 0.5 - burst_count: 5 - account: - per_second: 0.5 - burst_count: 5 - failed_attempts: - per_second: 0.5 - burst_count: 5 - -# ============================================ -# Federation -# ============================================ - -# Allow federation with other Matrix servers -federation_domain_whitelist: [] - -trusted_key_servers: - - server_name: "matrix.org" - -# ============================================ -# DSGVO / Data Retention -# ============================================ - -retention: - enabled: true - default_policy: - min_lifetime: 1d - max_lifetime: 365d - allowed_lifetime_min: 1d - allowed_lifetime_max: 365d - purge_jobs: - - longest_max_lifetime: 3d - interval: 12h - - shortest_max_lifetime: 365d - interval: 1d - -# Forgotten room retention -forgotten_room_retention_period: 7d - -# ============================================ -# Security -# ============================================ - -signing_key_path: "/data/signing.key" - -form_secret: "${SYNAPSE_FORM_SECRET:-change-me-form-secret}" -macaroon_secret_key: "${SYNAPSE_MACAROON_SECRET:-change-me-macaroon-secret}" -registration_shared_secret: "${SYNAPSE_REGISTRATION_SECRET:-change-me-registration-secret}" - -# ============================================ -# Application Services (for Bots) -# Currently disabled - using long-lived user tokens instead -# TODO: Migrate bots to AS for truly permanent tokens -# ============================================ - -app_service_config_files: [] - -# ============================================ -# Metrics & Telemetry -# ============================================ - -report_stats: false -enable_metrics: true -metrics_port: 9002 - -# ============================================ -# Caching -# ============================================ - -caches: - global_factor: 0.5 - per_cache_factors: {} - expire_caches: true - cache_entry_ttl: 30m - -# ============================================ -# Background Tasks -# ============================================ - -run_background_tasks_on: synapse - -# ============================================ -# Email (optional, for password reset) -# ============================================ - -# email: -# smtp_host: smtp-relay.brevo.com -# smtp_port: 587 -# smtp_user: "${SMTP_USER}" -# smtp_pass: "${SMTP_PASSWORD}" -# require_transport_security: true -# notif_from: "Mana Matrix " - -# ============================================ -# OIDC / SSO Configuration (Mana Core Auth) -# ============================================ - -# OIDC disabled: mana-auth (Better Auth) does not expose OIDC discovery endpoints -# TODO: add OIDC provider support to mana-auth, then re-enable this -# oidc_providers: -# - idp_id: mana -# issuer: "https://auth.mana.how" -# client_id: "matrix-synapse" -# ... - -# SSO UI Settings -sso: - client_whitelist: - - "https://element.mana.how" - - "https://matrix.mana.how" diff --git a/docker/matrix/config/log.config.yaml b/docker/matrix/config/log.config.yaml deleted file mode 100644 index 39a2480ba..000000000 --- a/docker/matrix/config/log.config.yaml +++ /dev/null @@ -1,34 +0,0 @@ -# Synapse Logging Configuration - -version: 1 - -formatters: - precise: - format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s' - -handlers: - console: - class: logging.StreamHandler - formatter: precise - stream: 'ext://sys.stdout' - - file: - class: logging.handlers.TimedRotatingFileHandler - formatter: precise - filename: /data/logs/homeserver.log - when: midnight - backupCount: 7 - encoding: utf8 - -loggers: - synapse.storage.SQL: - level: WARNING - - synapse.access.http.8008: - level: WARNING - -root: - level: INFO - handlers: [console, file] - -disable_existing_loggers: false diff --git a/docker/matrix/element-config.json b/docker/matrix/element-config.json deleted file mode 100644 index 31f8e6df5..000000000 --- a/docker/matrix/element-config.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "default_server_config": { - "m.homeserver": { - "base_url": "https://matrix.mana.how", - "server_name": "mana.how" - }, - "m.identity_server": { - "base_url": "" - } - }, - "brand": "Mana Chat", - "integrations_ui_url": "", - "integrations_rest_url": "", - "integrations_widgets_urls": [], - "disable_guests": true, - "disable_3pid_login": true, - "default_country_code": "DE", - "show_labs_settings": false, - "features": { - "feature_video_rooms": true, - "feature_group_calls": true, - "feature_thread": true - }, - "room_directory": { - "servers": ["mana.how"] - }, - "setting_defaults": { - "breadcrumbs": true, - "custom_themes": [], - "UIFeature.e2eeDefault": false, - "FTUE.userOnboardingButton": false, - "analyticsOptIn": false, - "pseudonymousAnalyticsOptIn": false - }, - "ui_features": { - "UIFeature.RoomEncryptionSettings": false - }, - "force_verification": false, - "enable_presence_by_hs_url": {}, - "default_theme": "dark", - "permalink_prefix": "https://element.mana.how", - "terms_and_conditions_links": [], - "sso_redirect_options": { - "immediate": false, - "on_welcome_page": true - }, - "posthog": { - "disabled": true - }, - "sentry": { - "disabled": true - }, - "analytics_owner": "", - "privacy_policy_url": "", - "show_analytics_setting": false, - "bug_report_endpoint_url": "", - "help_url": "https://mana.how/help", - "help_encryption_url": "https://element.io/help#encryption" -} diff --git a/docker/matrix/element/config.json b/docker/matrix/element/config.json deleted file mode 100644 index 31f8e6df5..000000000 --- a/docker/matrix/element/config.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "default_server_config": { - "m.homeserver": { - "base_url": "https://matrix.mana.how", - "server_name": "mana.how" - }, - "m.identity_server": { - "base_url": "" - } - }, - "brand": "Mana Chat", - "integrations_ui_url": "", - "integrations_rest_url": "", - "integrations_widgets_urls": [], - "disable_guests": true, - "disable_3pid_login": true, - "default_country_code": "DE", - "show_labs_settings": false, - "features": { - "feature_video_rooms": true, - "feature_group_calls": true, - "feature_thread": true - }, - "room_directory": { - "servers": ["mana.how"] - }, - "setting_defaults": { - "breadcrumbs": true, - "custom_themes": [], - "UIFeature.e2eeDefault": false, - "FTUE.userOnboardingButton": false, - "analyticsOptIn": false, - "pseudonymousAnalyticsOptIn": false - }, - "ui_features": { - "UIFeature.RoomEncryptionSettings": false - }, - "force_verification": false, - "enable_presence_by_hs_url": {}, - "default_theme": "dark", - "permalink_prefix": "https://element.mana.how", - "terms_and_conditions_links": [], - "sso_redirect_options": { - "immediate": false, - "on_welcome_page": true - }, - "posthog": { - "disabled": true - }, - "sentry": { - "disabled": true - }, - "analytics_owner": "", - "privacy_policy_url": "", - "show_analytics_setting": false, - "bug_report_endpoint_url": "", - "help_url": "https://mana.how/help", - "help_encryption_url": "https://element.io/help#encryption" -} diff --git a/docker/matrix/homeserver.yaml b/docker/matrix/homeserver.yaml deleted file mode 100644 index 94c6c2aa5..000000000 --- a/docker/matrix/homeserver.yaml +++ /dev/null @@ -1,222 +0,0 @@ -# Mana Matrix Synapse Configuration -# Documentation: https://element-hq.github.io/synapse/latest/usage/configuration/config_documentation.html - -server_name: "mana.how" -pid_file: /data/homeserver.pid -public_baseurl: https://matrix.mana.how/ - -# ============================================ -# Listeners -# ============================================ - -listeners: - - port: 8008 - tls: false - type: http - x_forwarded: true - resources: - - names: [client, federation] - compress: false - -# ============================================ -# Database (PostgreSQL) -# ============================================ - -database: - name: psycopg2 - txn_limit: 10000 - args: - user: synapse - password: "synapse-secure-password" - database: matrix - host: postgres - port: 5432 - cp_min: 5 - cp_max: 10 - -# ============================================ -# Logging -# ============================================ - -log_config: "/config/log.config.yaml" - -# ============================================ -# Media Storage -# ============================================ - -media_store_path: /data/media_store -max_upload_size: 50M -url_preview_enabled: true -url_preview_ip_range_blacklist: - - '127.0.0.0/8' - - '10.0.0.0/8' - - '172.16.0.0/12' - - '192.168.0.0/16' - - '100.64.0.0/10' - - '192.0.0.0/24' - - '169.254.0.0/16' - - '198.18.0.0/15' - - '192.0.2.0/24' - - '198.51.100.0/24' - - '203.0.113.0/24' - - '224.0.0.0/4' - - '::1/128' - - 'fe80::/10' - - 'fc00::/7' - - '2001:db8::/32' - - 'ff00::/8' - - 'fec0::/10' - -# ============================================ -# Registration & Authentication -# ============================================ - -enable_registration: false -enable_registration_without_verification: false - -# Password config (disabled - all users authenticate via OIDC/SSO) -password_config: - enabled: false - localdb_enabled: false - pepper: "${SYNAPSE_PASSWORD_PEPPER:-change-me-pepper}" - -# Session lifetime (must be >= refresh_token_lifetime) -# Set to 10 years for bot tokens to avoid frequent expiration -session_lifetime: 87600h -refresh_token_lifetime: 87600h - -# ============================================ -# Rate Limiting -# ============================================ - -rc_message: - per_second: 5 - burst_count: 20 - -rc_registration: - per_second: 0.5 - burst_count: 5 - -rc_login: - address: - per_second: 0.5 - burst_count: 5 - account: - per_second: 0.5 - burst_count: 5 - failed_attempts: - per_second: 0.5 - burst_count: 5 - -# ============================================ -# Federation -# ============================================ - -# Allow federation with other Matrix servers -federation_domain_whitelist: [] - -trusted_key_servers: - - server_name: "matrix.org" - -# ============================================ -# DSGVO / Data Retention -# ============================================ - -retention: - enabled: true - default_policy: - min_lifetime: 1d - max_lifetime: 365d - allowed_lifetime_min: 1d - allowed_lifetime_max: 365d - purge_jobs: - - longest_max_lifetime: 3d - interval: 12h - - shortest_max_lifetime: 365d - interval: 1d - -# Forgotten room retention -forgotten_room_retention_period: 7d - -# ============================================ -# Security -# ============================================ - -signing_key_path: "/data/signing.key" - -form_secret: "${SYNAPSE_FORM_SECRET:-change-me-form-secret}" -macaroon_secret_key: "${SYNAPSE_MACAROON_SECRET:-change-me-macaroon-secret}" -registration_shared_secret: "${SYNAPSE_REGISTRATION_SECRET:-change-me-registration-secret}" - -# ============================================ -# Application Services (for Bots) -# Currently disabled - using long-lived user tokens instead -# TODO: Migrate bots to AS for truly permanent tokens -# ============================================ - -app_service_config_files: [] - -# ============================================ -# Metrics & Telemetry -# ============================================ - -report_stats: false -enable_metrics: true -metrics_port: 9002 - -# ============================================ -# Caching -# ============================================ - -caches: - global_factor: 0.5 - per_cache_factors: {} - expire_caches: true - cache_entry_ttl: 30m - -# ============================================ -# Background Tasks -# ============================================ - -run_background_tasks_on: synapse - -# ============================================ -# Email (optional, for password reset) -# ============================================ - -# email: -# smtp_host: smtp-relay.brevo.com -# smtp_port: 587 -# smtp_user: "${SMTP_USER}" -# smtp_pass: "${SMTP_PASSWORD}" -# require_transport_security: true -# notif_from: "Mana Matrix " - -# ============================================ -# OIDC / SSO Configuration (Mana Core Auth) -# ============================================ - -# Enable SSO via Mana Core Auth OIDC Provider -oidc_providers: - - idp_id: mana - idp_name: "Mana Core" - idp_brand: "org.matrix.custom" - discover: true - issuer: "https://auth.mana.how" - client_id: "matrix-synapse" - client_secret: "6dc67d2dbea5c19409d21cbaec5ba77265b0296796d4ebb015d70209c68f3fd5" - scopes: ["openid", "profile", "email"] - user_mapping_provider: - config: - subject_claim: "sub" - localpart_template: "{{ user.email.split('@')[0] }}" - display_name_template: "{{ user.name }}" - email_template: "{{ user.email }}" - allow_existing_users: true - enable_registration: true - -# SSO UI Settings -sso: - client_whitelist: - - "https://element.mana.how" - - "https://matrix.mana.how" diff --git a/docker/matrix/log.config.yaml b/docker/matrix/log.config.yaml deleted file mode 100644 index 39a2480ba..000000000 --- a/docker/matrix/log.config.yaml +++ /dev/null @@ -1,34 +0,0 @@ -# Synapse Logging Configuration - -version: 1 - -formatters: - precise: - format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s' - -handlers: - console: - class: logging.StreamHandler - formatter: precise - stream: 'ext://sys.stdout' - - file: - class: logging.handlers.TimedRotatingFileHandler - formatter: precise - filename: /data/logs/homeserver.log - when: midnight - backupCount: 7 - encoding: utf8 - -loggers: - synapse.storage.SQL: - level: WARNING - - synapse.access.http.8008: - level: WARNING - -root: - level: INFO - handlers: [console, file] - -disable_existing_loggers: false diff --git a/docker/prometheus/alerts.yml b/docker/prometheus/alerts.yml index 9594db3b6..e47506c58 100644 --- a/docker/prometheus/alerts.yml +++ b/docker/prometheus/alerts.yml @@ -3,7 +3,7 @@ groups: rules: # Service Down Alert - alert: ServiceDown - expr: up{job=~"mana-auth|.*-backend|mana-search|mana-media|mana-llm|synapse"} == 0 + expr: up{job=~"mana-auth|.*-backend|mana-search|mana-media|mana-llm"} == 0 for: 1m labels: severity: critical diff --git a/docker/prometheus/prometheus.yml b/docker/prometheus/prometheus.yml index 20e05bedf..90fde6e3e 100644 --- a/docker/prometheus/prometheus.yml +++ b/docker/prometheus/prometheus.yml @@ -123,13 +123,6 @@ scrape_configs: metrics_path: '/metrics' scrape_interval: 30s - # Matrix Synapse - - job_name: 'synapse' - static_configs: - - targets: ['synapse:9002'] - metrics_path: '/_synapse/metrics' - scrape_interval: 30s - # ============================================ # GPU Server (Windows PC, LAN: 192.168.178.11) # ============================================ @@ -190,13 +183,6 @@ scrape_configs: metrics_path: '/metrics' scrape_interval: 15s - # Matrix Bot (Go) — consolidated 21 bots - - job_name: 'mana-matrix-bot' - static_configs: - - targets: ['mana-matrix-bot:4000'] - metrics_path: '/metrics' - scrape_interval: 30s - # Sync Server (Go) — local-first data sync - job_name: 'mana-sync' static_configs: @@ -204,7 +190,7 @@ scrape_configs: metrics_path: '/metrics' scrape_interval: 30s - # Notification Service (Go) — email, push, matrix, webhook + # Notification Service (Go) — email, push, webhook - job_name: 'mana-notify' static_configs: - targets: ['mana-core-notify:3013'] @@ -297,8 +283,6 @@ scrape_configs: - https://grafana.mana.how - https://stats.mana.how - https://glitchtip.mana.how - - https://matrix.mana.how - - https://element.mana.how relabel_configs: - source_labels: [__address__] target_label: __param_target diff --git a/docker/promtail/config.yaml b/docker/promtail/config.yaml index cb355487c..46a0d5f6c 100644 --- a/docker/promtail/config.yaml +++ b/docker/promtail/config.yaml @@ -60,11 +60,6 @@ scrape_configs: regex: "mana-mon-.*" target_label: "tier" replacement: "monitoring" - # mana-matrix-* → tier=matrix - - source_labels: ["container"] - regex: "mana-matrix-.*" - target_label: "tier" - replacement: "matrix" # mana-game-* → tier=games - source_labels: ["container"] regex: "mana-game-.*" diff --git a/docs/CLOUDFLARE_DOMAINS.md b/docs/CLOUDFLARE_DOMAINS.md index f0d67ea56..5b3c986a5 100644 --- a/docs/CLOUDFLARE_DOMAINS.md +++ b/docs/CLOUDFLARE_DOMAINS.md @@ -61,8 +61,7 @@ Cloudflare Tunnel (bb0ea86d...) ├── Apps (Web + API): chat.mana.how → localhost:5010 ├── Services: auth.mana.how → localhost:3001 ├── Landing Pages: it.mana.how → localhost:4400 (Nginx) - ├── Monitoring: grafana.mana.how → localhost:8000 - └── Matrix: matrix.mana.how → localhost:4000 + └── Monitoring: grafana.mana.how → localhost:8000 ``` **Nginx Landing Container** (`mana-infra-landings`, Port 4400): @@ -119,9 +118,6 @@ Cloudflare Tunnel (bb0ea86d...) | Domain | Service | Port | |--------|---------|------| -| `matrix.mana.how` | Matrix Synapse | 4000 | -| `element.mana.how` | Element Web | 4080 | -| `link.mana.how` | Matrix Link | 4090 | | `grafana.mana.how` | Grafana | 8000 | | `stats.mana.how` | Umami Analytics | 8010 | | `glitchtip.mana.how` | GlitchTip Errors | 8020 | diff --git a/docs/CLOUDFLARE_FALLBACK.md b/docs/CLOUDFLARE_FALLBACK.md index 493ba2ba2..f2bfe3044 100644 --- a/docs/CLOUDFLARE_FALLBACK.md +++ b/docs/CLOUDFLARE_FALLBACK.md @@ -211,14 +211,6 @@ playground.mana.how { reverse_proxy 10.0.0.2:5090 } -matrix.mana.how { - reverse_proxy 10.0.0.2:4000 -} - -element.mana.how { - reverse_proxy 10.0.0.2:4080 -} - grafana.mana.how { reverse_proxy 10.0.0.2:8000 } diff --git a/docs/MAC_MINI_SERVER.md b/docs/MAC_MINI_SERVER.md index 2efa2296d..4ea310bc9 100644 --- a/docs/MAC_MINI_SERVER.md +++ b/docs/MAC_MINI_SERVER.md @@ -93,8 +93,6 @@ Cloudflare Tunnel (cloudflared) | Todo | https://todo.mana.how | | Calendar | https://calendar.mana.how | | Clock | https://clock.mana.how | -| Matrix (Synapse) | https://matrix.mana.how | -| Element Web | https://element.mana.how | ## SSH-Zugang @@ -411,8 +409,6 @@ curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" | mana-calendar-web | Calendar Frontend | | mana-clock-backend | Clock API | | mana-clock-web | Clock Frontend | -| mana-synapse | Matrix Homeserver | -| mana-element | Element Web Client | ### Nützliche Docker-Befehle @@ -530,7 +526,6 @@ curl -s http://localhost:3000/ The health check monitors: - All backend APIs and web frontends - Infrastructure (PostgreSQL, Redis) -- Matrix services (Synapse, Element, all bots) - Monitoring stack (Grafana, Umami, GlitchTip, VictoriaMetrics) - Alerting stack (vmalert, Alertmanager, Alert Notifier) - Disk space for `/` and `/Volumes/ManaData` (warning at 80%, critical at 90%) @@ -553,11 +548,11 @@ ssh mana-server "PATH=/Applications/Docker.app/Contents/Resources/bin:\$PATH && Wenn ein Service im Health-Check als `HTTP 000` erscheint und `docker ps -a` den Container nicht zeigt, wurde er vermutlich beim letzten Deploy übersprungen: ```bash -# Container erstellen und starten (Beispiel: Project Doc Bot) -docker compose -f docker-compose.macmini.yml up -d matrix-project-doc-bot +# Container erstellen und starten +docker compose -f docker-compose.macmini.yml up -d # Nach Restart prüfen -docker ps --filter name=mana-matrix-bot-projectdoc --format '{{.Names}} {{.Status}}' +docker ps --filter name=mana- --format '{{.Names}} {{.Status}}' ``` ## Wartung @@ -642,7 +637,6 @@ Alle 63 Container haben explizite `mem_limit` in `docker-compose.macmini.yml`: | Core (Hono/Bun) | 5 | 704 MB | | Go Services | 5 | 384 MB | | Other Backend | 3 | 576 MB | -| Matrix | 4 | 784 MB | | Web Apps | 20 | 2.560 MB | | LLM | 2 | 384 MB | | Monitoring | 14 | 1.792 MB | @@ -742,8 +736,7 @@ Die externe SSD wird für persistente Daten verwendet - sowohl für große Datei ├── backups/ # PostgreSQL Backups (täglich 3:00) ├── ollama/ # LLM Modelle (~58 GB) ├── flux2/ # FLUX.2 Bildgenerierung (~15 GB) -├── stt-models/ # Speech-to-Text Modelle (~19 GB) -└── matrix/ # Matrix Synapse Daten +└── stt-models/ # Speech-to-Text Modelle (~19 GB) ``` ### Docker auf externer SSD @@ -823,81 +816,6 @@ Docker Desktop benötigt "Full Disk Access" für SSD-Mounts: Systemeinstellungen → Datenschutz & Sicherheit → Voller Festplattenzugriff → Docker.app ✅ ``` -## Matrix (DSGVO-konformes Messaging) - -Matrix ist eine DSGVO-konforme Alternative zu Telegram für Bot-Kommunikation. - -### Komponenten - -| Service | Port | Beschreibung | -|---------|------|--------------| -| Synapse | 8008 | Matrix Homeserver | -| Element Web | 8087 | Web-Client | - -### Matrix Bots - -Alle Matrix Bots laufen als Docker Container und werden via GHCR (GitHub Container Registry) deployed. Watchtower aktualisiert sie automatisch bei neuen Images. - -| Bot | Port | Beschreibung | -|-----|------|--------------| -| matrix-mana-bot | 4010 | Gateway - alle Features in einem Bot | -| matrix-ollama-bot | 4011 | KI-Chat via GPU-Server Ollama | -| matrix-stats-bot | 4012 | Server-Statistiken & Monitoring | -| matrix-project-doc-bot | 4013 | Projekt-Dokumentation aus Fotos/Voice/Text | -| matrix-todo-bot | 4014 | Aufgabenverwaltung | -| matrix-calendar-bot | 4015 | Termine & Events | -| matrix-nutriphi-bot | 4016 | Ernährungstracking | -| matrix-zitare-bot | 4017 | Tägliche Zitate | -| matrix-clock-bot | 4018 | Timer & Wecker | -| matrix-tts-bot | 4019 | Text-to-Speech | - -**Health Checks:** -```bash -# Alle Bots prüfen -for port in 4010 4011 4012 4013 4014 4015 4016 4017 4018 4019; do - echo -n "Port $port: " - curl -s http://localhost:$port/health | jq -r '.status // "error"' -done -``` - -**Logs:** -```bash -# Logs eines Bots -docker logs matrix-mana-bot -f - -# Alle Matrix Bots -docker ps | grep matrix-.*-bot -``` - -**Bot neu starten:** -```bash -docker compose -f docker-compose.macmini.yml restart matrix-mana-bot -``` - -**Images manuell aktualisieren:** -```bash -docker compose -f docker-compose.macmini.yml pull matrix-mana-bot -docker compose -f docker-compose.macmini.yml up -d matrix-mana-bot -``` - -### Setup - -```bash -# Matrix initialisieren -./scripts/mac-mini/setup-matrix.sh - -# Services starten -docker compose -f docker-compose.macmini.yml up -d synapse element-web - -# Admin-User erstellen -docker exec -it mana-synapse register_new_matrix_user \ - -c /data/homeserver.yaml http://localhost:8008 -a -``` - -### Dokumentation - -Siehe [MATRIX_SELF_HOSTING.md](./MATRIX_SELF_HOSTING.md) für detaillierte Anleitung. - ## Chronologie der Einrichtung 1. **Docker Setup** - PostgreSQL, Redis, App-Container @@ -909,5 +827,4 @@ Siehe [MATRIX_SELF_HOSTING.md](./MATRIX_SELF_HOSTING.md) für detaillierte Anlei 7. **Email Notifications** - Redundante Benachrichtigung 8. ~~**Ollama** - Lokale LLM-Inferenz~~ → Migriert auf GPU-Server (2026-03-28) 9. ~~**Telegram Ollama Bot**~~ → Deaktiviert (2026-03-28) -10. **Matrix Synapse** - DSGVO-konformes Messaging -11. **GPU-Server Offload** - Alle AI-Workloads auf RTX 3090 (2026-03-28) +10. **GPU-Server Offload** - Alle AI-Workloads auf RTX 3090 (2026-03-28) diff --git a/docs/MATRIX_BOT_ARCHITECTURE.md b/docs/MATRIX_BOT_ARCHITECTURE.md deleted file mode 100644 index f70e1d929..000000000 --- a/docs/MATRIX_BOT_ARCHITECTURE.md +++ /dev/null @@ -1,990 +0,0 @@ -# Mana Matrix Bot Architecture - -**Status:** Production -**Datum:** 1. Februar 2026 -**Autor:** Till Schneider -**Letzte Aktualisierung:** 1. Februar 2026 - ---- - -## Executive Summary - -Mana setzt auf **Matrix** als primäre Messaging-Plattform für Bot-Interaktionen. Mit 19 spezialisierten Matrix-Bots und einem Gateway-Bot bieten wir eine vollständig dezentrale, DSGVO-konforme Alternative zu Cloud-basierten Chat-Diensten. - -**Kernprinzipien:** -- **Volle Kontrolle** - Eigene Infrastruktur, eigene Daten -- **DSGVO-Konformität** - Alle Daten auf eigenen Servern -- **Unabhängigkeit** - Keine Abhängigkeit von Drittanbieter-Plattformen -- **Einheitliche UX** - Konsistente Erfahrung über alle Bots - ---- - -## 1. Warum Matrix? - -### 1.1 Die Entscheidung gegen Telegram/Discord/Slack - -Bei der Wahl der Messaging-Plattform für Mana hatten wir mehrere Optionen: - -| Plattform | Vorteile | Nachteile | -|-----------|----------|-----------| -| **Telegram** | Große Reichweite, einfache API | Zentral, Daten bei Telegram, keine Kontrolle über UX | -| **Discord** | Gaming-Community, Webhooks | US-basiert, DSGVO-Bedenken, Werbung | -| **Slack** | Business-Standard | Teuer, Vendor Lock-in, keine Self-Hosting Option | -| **Matrix** | Dezentral, Self-Hosted, E2E-Verschlüsselung | Kleinere Community, mehr Setup-Aufwand | - -**Unsere Entscheidung:** Matrix bietet die einzige Möglichkeit, eine **vollständig unabhängige** Plattform zu betreiben mit: -- Voller Kontrolle über Nutzerdaten -- Eigener UI/UX (Element, eigene Clients) -- End-to-End-Verschlüsselung -- Federation für Inter-Server-Kommunikation - -### 1.2 Matrix Grundkonzepte - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Matrix Ökosystem │ -├─────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌─────────────────┐ ┌─────────────────┐ │ -│ │ Homeserver │<───>│ Homeserver │ Federation │ -│ │ (mana.how) │ │ (matrix.org) │ │ -│ └────────┬────────┘ └─────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────────┐│ -│ │ Räume ││ -│ ├─────────────────────────────────────────────────────────────┤│ -│ │ !abc:mana.how │ Bot-Interaktion (1:1) ││ -│ │ !xyz:mana.how │ Gruppen-Chat (Multi-User) ││ -│ │ #public:mana.how │ Öffentlicher Raum ││ -│ └─────────────────────────────────────────────────────────────┘│ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐│ -│ │ Clients ││ -│ ├─────────────────────────────────────────────────────────────┤│ -│ │ Element (Web/Desktop/Mobile) ││ -│ │ FluffyChat, Nheko, SchildiChat, ... ││ -│ │ Mana Bots (matrix-bot-sdk) ││ -│ └─────────────────────────────────────────────────────────────┘│ -│ │ -└─────────────────────────────────────────────────────────────────┘ -``` - -**Kernkonzepte:** -- **Homeserver:** Der Server, der Nutzerkonten und Räume hostet (wir nutzen Synapse) -- **Räume:** Container für Nachrichten, Events und State -- **Federation:** Server können miteinander kommunizieren -- **E2E-Verschlüsselung:** Megolm/Olm für sichere Kommunikation - ---- - -## 2. Bot-Architektur Übersicht - -### 2.1 Gesamtarchitektur - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ Mana Bot Ecosystem │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────────────────────────────────────────────────────────────┐ │ -│ │ @mana/bot-services (Shared Business Logic) │ │ -│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐ │ │ -│ │ │ TodoSvc │ │ CalSvc │ │ AiSvc │ │ ClockSvc │ │ ... │ │ │ -│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └─────────┘ │ │ -│ └──────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌──────────────────────────────────────────────────────────────────┐ │ -│ │ Matrix Transport Layer │ │ -│ │ (matrix-bot-sdk) │ │ -│ └──────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ┌──────────────────────────┼──────────────────────────┐ │ -│ ▼ ▼ ▼ │ -│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ -│ │ 19 Matrix Bots │ │ Gateway Bot │ │ Shared Services │ │ -│ │ (Specialized) │ │ (All-in-One) │ │ (mana-llm, etc) │ │ -│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌──────────────────────────────────────────────────────────────────┐ │ -│ │ Backend APIs │ │ -│ │ chat │ todo │ contacts │ calendar │ clock │ picture │ ... │ │ -│ └──────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌──────────────────────────────────────────────────────────────────┐ │ -│ │ Data Layer │ │ -│ │ PostgreSQL │ S3/MinIO │ JSON Files │ Redis │ Ollama │ │ -│ └──────────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### 2.2 Bot-Typen - -Wir unterscheiden drei Hauptkategorien von Bots: - -#### Typ 1: Backend-integrierte Bots -Diese Bots fungieren als Interface zu bestehenden NestJS-Backend-APIs: - -``` -User → Matrix Bot → REST API → PostgreSQL -``` - -**Beispiele:** -- `matrix-contacts-bot` → Contacts Backend (Port 3015) -- `matrix-chat-bot` → Chat Backend (Port 3002) -- `matrix-picture-bot` → Picture Backend (Port 3006) - -**Vorteile:** -- Konsistente Geschäftslogik (Web + Bot identisch) -- Zentralisierte Datenhaltung -- Einheitliche Auth via JWT - -#### Typ 2: DSGVO-konforme Standalone-Bots -Diese Bots speichern Daten lokal ohne externe Services: - -``` -User → Matrix Bot → JSON File (lokal) -``` - -**Beispiele:** -- `matrix-todo-bot` → Lokale JSON-Datei -- `matrix-calendar-bot` → Lokale JSON-Datei -- `matrix-ollama-bot` → In-Memory + lokales Ollama - -**Vorteile:** -- Keine Daten verlassen den Server -- Volle DSGVO-Konformität -- Offline-fähig - -#### Typ 3: Gateway-Bot -Kombiniert alle Features in einem Bot: - -``` -User → matrix-mana-bot → @mana/bot-services → Multiple Backends -``` - -**Features:** -- Einheitlicher Einstiegspunkt (`!mana`) -- Intelligentes Command-Routing -- Cross-Feature-Integration (z.B. "Termin mit Kontakt erstellen") - ---- - -## 3. Shared Package: @mana/bot-services - -### 3.1 Architektur - -Das Package `@mana/bot-services` stellt transport-agnostische Geschäftslogik bereit: - -```typescript -// Business Logic Services -export { TodoModule, TodoService } from './todo'; -export { CalendarModule, CalendarService } from './calendar'; -export { AiModule, AiService } from './ai'; -export { ClockModule, ClockService } from './clock'; - -// Infrastructure Services (NEU: Konsolidiert aus 11+ Bots) -export { SessionModule, SessionService } from './session'; // Auth via mana-auth -export { TranscriptionModule, TranscriptionService } from './transcription'; // STT via mana-stt - -// Storage Provider (pluggable) -export { FileStorageProvider } from './shared/storage/file-storage.provider'; -export { MemoryStorageProvider } from './shared/storage/memory-storage.provider'; - -// Utilities -export { generateId, getTodayISO, formatDateDE } from './shared/utils'; -export { parseGermanDateKeyword } from './shared/date-parser'; -``` - -### 3.1.1 Konsolidierte Services - -Die folgenden Services wurden aus den einzelnen Bots konsolidiert: - -| Service | Vorher | Nachher | Migrierte Bots | -|---------|--------|---------|----------------| -| `SessionService` | 11x dupliziert | 1x in bot-services | picture, contacts, chat, zitare, skilltree, presi, questions, storage, planta, cards, nutriphi | -| `TranscriptionService` | 6x dupliziert | 1x in bot-services | todo, clock, zitare, nutriphi, project-doc | - -**Status: Vollständig migriert** - Alle 11 Bots mit SessionService und alle 5 Bots mit TranscriptionService nutzen jetzt die gemeinsamen Services aus `@mana/bot-services`. - -### 3.2 TodoService - -Vollständige Aufgabenverwaltung mit deutscher Sprachunterstützung: - -```typescript -interface TodoService { - // CRUD - addTask(userId: string, text: string): Promise; - listTasks(userId: string, filter?: TaskFilter): Promise; - completeTask(userId: string, taskId: string): Promise; - deleteTask(userId: string, taskId: string): Promise; - - // Projekte - createProject(userId: string, name: string): Promise; - listProjects(userId: string): Promise; - - // Filter - getTasksDueToday(userId: string): Promise; - getTasksByPriority(userId: string, priority: Priority): Promise; -} - -// Deutsche Eingabeverarbeitung -"Morgen Arzt anrufen #gesundheit !hoch" -→ { text: "Arzt anrufen", dueDate: tomorrow, project: "gesundheit", priority: "high" } -``` - -### 3.3 CalendarService - -Terminverwaltung mit natürlicher Spracheingabe: - -```typescript -interface CalendarService { - // Events - createEvent(userId: string, input: string): Promise; - getEventsForDate(userId: string, date: Date): Promise; - getEventsInRange(userId: string, start: Date, end: Date): Promise; - - // Kalender - createCalendar(userId: string, name: string): Promise; - listCalendars(userId: string): Promise; -} - -// Natürliche Eingabe -"Meeting morgen um 14 Uhr im Büro" -→ { title: "Meeting", date: tomorrow, time: "14:00", location: "Büro" } -``` - -### 3.4 AiService - -Integration mit lokalem LLM (Ollama) und mana-llm: - -```typescript -interface AiService { - chat(userId: string, message: string): Promise; - setModel(userId: string, model: string): Promise; - setSystemPrompt(userId: string, mode: SystemMode): Promise; - clearHistory(userId: string): Promise; - - // Vision (für Bildanalyse) - analyzeImage(userId: string, imageUrl: string, prompt: string): Promise; -} - -type SystemMode = 'default' | 'classify' | 'summarize' | 'translate' | 'code'; -``` - -### 3.5 Storage Provider Pattern - -Pluggable Storage für flexible Datenhaltung: - -```typescript -interface StorageProvider { - get(key: string): Promise; - set(key: string, value: T): Promise; - delete(key: string): Promise; - list(prefix?: string): Promise; -} - -// Implementierungen -class FileStorageProvider implements StorageProvider { - constructor(private basePath: string) {} - // Speichert als JSON-Dateien -} - -class MemoryStorageProvider implements StorageProvider { - private store = new Map(); - // In-Memory für Tests -} - -// Zukünftig möglich: -class PostgresStorageProvider implements StorageProvider { } -class RedisStorageProvider implements StorageProvider { } -``` - ---- - -## 4. Matrix Bot Implementation - -### 4.1 Technologie-Stack - -Alle Matrix-Bots nutzen einen einheitlichen Stack: - -| Komponente | Technologie | Version | -|------------|-------------|---------| -| **Framework** | NestJS | 10.x | -| **Matrix SDK** | matrix-bot-sdk | 0.7.1 | -| **Language** | TypeScript | 5.x | -| **Runtime** | Node.js | 20.x | -| **Build** | tsc + Docker | - | - -### 4.2 Bot-Struktur - -``` -services/matrix-{name}-bot/ -├── src/ -│ ├── app.module.ts # NestJS Root Module -│ ├── main.ts # Bootstrap -│ ├── matrix/ -│ │ ├── matrix.module.ts # Matrix SDK Integration -│ │ ├── matrix.service.ts # Bot-Logik & Command-Handling -│ │ └── matrix.constants.ts # Konfiguration -│ ├── services/ # Optionale lokale Services -│ └── utils/ # Hilfsfunktionen -├── Dockerfile -├── package.json -└── tsconfig.json -``` - -### 4.3 Matrix Service Pattern - -```typescript -@Injectable() -export class MatrixService implements OnModuleInit, OnModuleDestroy { - private client: MatrixClient; - private storage: SimpleFsStorageProvider; - - async onModuleInit() { - // Storage für Sync-State - this.storage = new SimpleFsStorageProvider('./data/matrix-state.json'); - - // Client initialisieren - this.client = new MatrixClient( - this.configService.get('MATRIX_HOMESERVER_URL'), - this.configService.get('MATRIX_ACCESS_TOKEN'), - this.storage, - ); - - // Crypto für E2E (optional) - const cryptoStore = new RustSdkCryptoStorageProvider('./data/crypto'); - await this.client.crypto.prepare(cryptoStore); - - // Event-Handler registrieren - this.client.on('room.message', this.handleMessage.bind(this)); - - // Sync starten - await this.client.start(); - } - - private async handleMessage(roomId: string, event: any) { - if (event.sender === this.client.getUserId()) return; - - const body = event.content?.body; - if (!body?.startsWith('!')) return; - - const [command, ...args] = body.slice(1).split(' '); - - switch (command.toLowerCase()) { - case 'help': - case 'hilfe': - await this.sendHelp(roomId); - break; - case 'add': - case 'hinzufuegen': - await this.handleAdd(roomId, event.sender, args.join(' ')); - break; - // ... weitere Commands - } - } - - private async sendMessage(roomId: string, message: string) { - await this.client.sendMessage(roomId, { - msgtype: 'm.text', - body: message, - format: 'org.matrix.custom.html', - formatted_body: this.markdownToHtml(message), - }); - } -} -``` - -### 4.4 Command-Pattern - -Alle Bots nutzen ein einheitliches Command-Schema: - -``` -!command [args] # Englisch -!befehl [argumente] # Deutsch (Aliase) -``` - -**Beispiele:** - -| Bot | Command | Alias | Beschreibung | -|-----|---------|-------|--------------| -| todo | `!add Task` | `!hinzufuegen` | Aufgabe erstellen | -| todo | `!list` | `!liste` | Aufgaben anzeigen | -| todo | `!done 1` | `!erledigt` | Aufgabe abschließen | -| calendar | `!today` | `!heute` | Termine heute | -| calendar | `!add Meeting morgen 14:00` | `!termin` | Termin erstellen | -| contacts | `!search Max` | `!suche` | Kontakt suchen | - -### 4.5 Nummer-basiertes Referenzsystem - -Für intuitive Interaktion nutzen Bots ein Listen-Referenz-System: - -``` -User: !kontakte -Bot: 1. Max Mustermann (max@example.com) - 2. Anna Schmidt (anna@example.com) - 3. Peter Meyer (peter@example.com) - -User: !anrufen 2 -Bot: Anruf an Anna Schmidt wird vorbereitet... - Telefon: +49 123 456789 -``` - -**Implementierung:** -```typescript -// Pro User wird die letzte Liste gespeichert -private listCache = new Map(); - -async handleList(roomId: string, userId: string) { - const contacts = await this.contactsApi.list(userId); - this.listCache.set(userId, contacts); - - const message = contacts - .map((c, i) => `${i + 1}. ${c.name} (${c.email})`) - .join('\n'); - - await this.sendMessage(roomId, message); -} - -async handleCall(roomId: string, userId: string, index: number) { - const contacts = this.listCache.get(userId); - if (!contacts || index < 1 || index > contacts.length) { - return this.sendMessage(roomId, 'Ungültige Nummer'); - } - - const contact = contacts[index - 1]; - // ... Anruf-Logik -} -``` - ---- - -## 5. Bot-Katalog - -### 5.1 Produktivitäts-Bots - -| Bot | Port | Storage | Beschreibung | -|-----|------|---------|--------------| -| **matrix-mana-bot** | 3310 | JSON | Gateway - alle Features vereint | -| **matrix-todo-bot** | 3314 | JSON | Aufgabenverwaltung mit Projekten | -| **matrix-calendar-bot** | 3315 | JSON | Terminverwaltung mit Erinnerungen | -| **matrix-clock-bot** | 3318 | API | Timer, Alarme, Weltuhren | - -### 5.2 KI & Medien-Bots - -| Bot | Port | Backend | Beschreibung | -|-----|------|---------|--------------| -| **matrix-chat-bot** | 3327 | chat:3002 | KI-Konversationen | -| **matrix-ollama-bot** | 3311 | mana-llm:3025 | Lokales LLM (DSGVO) | -| **matrix-picture-bot** | 3319 | picture:3006 | AI-Bildgenerierung | -| **matrix-tts-bot** | 3023 | mana-tts:3022 | Text-to-Speech | -| **matrix-project-doc-bot** | 3313 | PostgreSQL+S3 | Projektdoku → Blog | - -### 5.3 App-Integrations-Bots - -| Bot | Port | Backend | Beschreibung | -|-----|------|---------|--------------| -| **matrix-contacts-bot** | 3320 | contacts:3015 | Kontaktverwaltung | -| **matrix-storage-bot** | 3323 | storage:3016 | Cloud-Speicher | -| **matrix-nutriphi-bot** | 3316 | nutriphi:3023 | Ernährungstracking | -| **matrix-zitare-bot** | 3321 | zitare:3019 | Tägliche Zitate | -| **matrix-questions-bot** | 3324 | questions:3011 | Q&A mit Web-Recherche | -| **matrix-cards-bot** | 3321 | cards:3009 | Kartendecks & Lernen | -| **matrix-planta-bot** | 3322 | planta:3022 | Pflanzenpflege | -| **matrix-skilltree-bot** | 3324 | skilltree:3024 | Skill Tree & XP | -| **matrix-presi-bot** | 3308 | presi:3008 | Präsentationen | -| **matrix-stats-bot** | 3312 | Umami | Analytics-Reports | - ---- - -## 6. Authentifizierung - -### 6.1 Zwei Auth-Modelle - -Wir unterstützen zwei Authentifizierungsmodelle: - -#### Modell A: Matrix User ID (DSGVO-optimiert) -Für Standalone-Bots ohne Backend-Anbindung: - -``` -Matrix User ID → Isolierte Daten pro User -@till:mana.how → /data/till-mana-how/todos.json -``` - -**Vorteile:** -- Kein Login erforderlich -- Daten strikt isoliert -- Funktioniert offline - -**Verwendung:** matrix-todo-bot, matrix-calendar-bot, matrix-ollama-bot - -#### Modell B: Mana Auth (JWT) -Für Backend-integrierte Bots: - -``` -┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ -│ Matrix User │────>│ Matrix Bot │────>│ mana-auth │ -│ !login x y │ │ │ │ (Port 3001) │ -└─────────────────┘ └─────────────────┘ └─────────────────┘ - │ │ - │ JWT Token │ - ▼ │ - ┌─────────────────┐ │ - │ In-Memory Map │ │ - │ @user → token │ │ - └─────────────────┘ │ - │ │ - ▼ │ - ┌─────────────────┐ │ - │ Backend API │◀──────────┘ - │ (JWT Validate) │ - └─────────────────┘ -``` - -**Login-Flow:** -``` -User: !login till@mana.how geheimespasswort -Bot: Login erfolgreich! Token gültig für 7 Tage. - Nutze !logout zum Abmelden. - -User: !kontakte -Bot: [Zeigt Kontakte aus Backend] -``` - -**Verwendung:** matrix-contacts-bot, matrix-chat-bot, matrix-picture-bot, etc. - -### 6.2 Token-Management - -```typescript -@Injectable() -export class AuthService { - private tokens = new Map(); - - async login(matrixUserId: string, email: string, password: string): Promise { - const response = await fetch(`${this.authUrl}/api/v1/auth/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, password }), - }); - - if (!response.ok) return false; - - const { accessToken, expiresIn } = await response.json(); - this.tokens.set(matrixUserId, { - token: accessToken, - expiresAt: Date.now() + expiresIn * 1000, - }); - - return true; - } - - getToken(matrixUserId: string): string | null { - const data = this.tokens.get(matrixUserId); - if (!data || Date.now() > data.expiresAt) return null; - return data.token; - } - - logout(matrixUserId: string): void { - this.tokens.delete(matrixUserId); - } -} -``` - ---- - -## 7. Datenbank-Anbindung - -### 7.1 Vier Speichermodelle - -| Modell | Technologie | Bots | Use Case | -|--------|-------------|------|----------| -| **Stateless** | Keine eigene | contacts, chat, picture | Backend delegiert | -| **JSON Files** | Lokale Dateien | todo, calendar, mana-bot | DSGVO, einfach | -| **PostgreSQL** | Drizzle ORM | project-doc-bot | Komplexe Relationen | -| **S3/MinIO** | AWS SDK | project-doc-bot | Medien-Speicherung | - -### 7.2 JSON File Storage (DSGVO) - -```typescript -// Struktur -/data/ -├── {sanitized-matrix-user-id}/ -│ ├── todos.json -│ ├── calendar.json -│ └── settings.json -``` - -```typescript -// FileStorageProvider -class FileStorageProvider { - constructor(private basePath: string) {} - - private getPath(key: string): string { - return path.join(this.basePath, `${key}.json`); - } - - async get(key: string): Promise { - const filePath = this.getPath(key); - if (!fs.existsSync(filePath)) return null; - const data = await fs.promises.readFile(filePath, 'utf-8'); - return JSON.parse(data); - } - - async set(key: string, value: T): Promise { - const filePath = this.getPath(key); - await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); - await fs.promises.writeFile(filePath, JSON.stringify(value, null, 2)); - } -} -``` - -### 7.3 PostgreSQL + Drizzle (Komplexe Bots) - -```typescript -// schema.ts (project-doc-bot) -export const projects = pgTable('projects', { - id: uuid('id').primaryKey().defaultRandom(), - userId: varchar('user_id', { length: 255 }).notNull(), - name: varchar('name', { length: 255 }).notNull(), - description: text('description'), - createdAt: timestamp('created_at').defaultNow(), -}); - -export const mediaItems = pgTable('media_items', { - id: uuid('id').primaryKey().defaultRandom(), - projectId: uuid('project_id').references(() => projects.id), - type: varchar('type', { length: 50 }).notNull(), // photo, voice, text - s3Key: varchar('s3_key', { length: 500 }), - transcription: text('transcription'), - createdAt: timestamp('created_at').defaultNow(), -}); -``` - ---- - -## 8. Matrix-spezifische Features - -### 8.1 Rich Media Support - -Matrix-Bots können verschiedene Nachrichtentypen senden: - -```typescript -// Text mit Markdown/HTML -await client.sendMessage(roomId, { - msgtype: 'm.text', - body: 'Plain text fallback', - format: 'org.matrix.custom.html', - formatted_body: 'Bold and code', -}); - -// Bilder -await client.sendMessage(roomId, { - msgtype: 'm.image', - body: 'Generated image', - url: await client.uploadContent(imageBuffer, 'image/png'), - info: { w: 512, h: 512, mimetype: 'image/png' }, -}); - -// Dateien -await client.sendMessage(roomId, { - msgtype: 'm.file', - body: 'report.pdf', - url: await client.uploadContent(pdfBuffer, 'application/pdf'), - info: { mimetype: 'application/pdf', size: pdfBuffer.length }, -}); - -// Audio (für TTS) -await client.sendMessage(roomId, { - msgtype: 'm.audio', - body: 'Voice message', - url: await client.uploadContent(audioBuffer, 'audio/mp3'), - info: { mimetype: 'audio/mp3', duration: 5000 }, -}); -``` - -### 8.2 Reactions - -Bots können auf Nachrichten reagieren: - -```typescript -// Bestätigung -await client.sendEvent(roomId, 'm.reaction', { - 'm.relates_to': { - rel_type: 'm.annotation', - event_id: originalEventId, - key: '✅', - }, -}); - -// Fehler -await client.sendEvent(roomId, 'm.reaction', { - 'm.relates_to': { - rel_type: 'm.annotation', - event_id: originalEventId, - key: '❌', - }, -}); -``` - -### 8.3 Reply Threading - -```typescript -await client.sendMessage(roomId, { - msgtype: 'm.text', - body: '> Original message\n\nMy reply', - format: 'org.matrix.custom.html', - formatted_body: '...My reply', - 'm.relates_to': { - 'm.in_reply_to': { - event_id: originalEventId, - }, - }, -}); -``` - -### 8.4 End-to-End Encryption - -```typescript -// Crypto Storage initialisieren -const cryptoStore = new RustSdkCryptoStorageProvider('./data/crypto'); - -// Client mit E2E -const client = new MatrixClient(homeserverUrl, accessToken, storage); -await client.crypto.prepare(cryptoStore); - -// Verschlüsselten Raum beitreten -await client.joinRoom(encryptedRoomId); - -// Nachrichten werden automatisch ver-/entschlüsselt -await client.sendMessage(encryptedRoomId, { - msgtype: 'm.text', - body: 'This will be encrypted', -}); -``` - ---- - -## 9. Deployment - -### 9.1 Docker Configuration - -```dockerfile -# Dockerfile -FROM node:20-alpine AS builder - -WORKDIR /app - -# Workspace files -COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ - -# Shared packages -COPY packages/bot-services ./packages/bot-services - -# Bot -COPY services/matrix-todo-bot ./services/matrix-todo-bot - -RUN corepack enable && corepack prepare pnpm@9.15.0 --activate -RUN pnpm install --frozen-lockfile -RUN pnpm --filter @mana/bot-services build -RUN pnpm --filter matrix-todo-bot build - -# Production -FROM node:20-alpine AS production - -WORKDIR /app/services/matrix-todo-bot - -COPY --from=builder /app/node_modules/.pnpm /app/node_modules/.pnpm -COPY --from=builder /app/services/matrix-todo-bot/node_modules ./node_modules -COPY --from=builder /app/services/matrix-todo-bot/dist ./dist -COPY --from=builder /app/services/matrix-todo-bot/package.json ./ - -# Data volume für persistente Speicherung -VOLUME /app/data - -ENV NODE_ENV=production -EXPOSE 3314 - -CMD ["node", "dist/main.js"] -``` - -### 9.2 Environment Variables - -```env -# Matrix Connection -MATRIX_HOMESERVER_URL=https://matrix.mana.how -MATRIX_ACCESS_TOKEN=syt_xxx... -MATRIX_USER_ID=@todo-bot:mana.how - -# Auth (für Backend-Integration) -MANA_AUTH_URL=http://mana-auth:3001 - -# Storage -DATA_PATH=/app/data - -# Optional: Backend URLs -TODO_BACKEND_URL=http://todo-backend:3018 -CONTACTS_BACKEND_URL=http://contacts-backend:3015 - -# Optional: AI Services -MANA_LLM_URL=http://mana-llm:3025 -``` - -### 9.3 docker-compose.yml - -```yaml -version: '3.8' - -services: - matrix-todo-bot: - build: - context: . - dockerfile: services/matrix-todo-bot/Dockerfile - environment: - - MATRIX_HOMESERVER_URL=${MATRIX_HOMESERVER_URL} - - MATRIX_ACCESS_TOKEN=${MATRIX_TODO_BOT_TOKEN} - - MATRIX_USER_ID=@todo-bot:mana.how - volumes: - - todo-bot-data:/app/data - networks: - - mana - restart: unless-stopped - healthcheck: - test: ["CMD", "wget", "-q", "--spider", "http://localhost:3314/health"] - interval: 30s - timeout: 10s - retries: 3 - - matrix-calendar-bot: - # ... analog - - matrix-mana-bot: - # Gateway mit allen Services - depends_on: - - mana-llm - - todo-backend - - contacts-backend - -volumes: - todo-bot-data: - calendar-bot-data: - mana-bot-data: - -networks: - mana: - external: true -``` - ---- - -## 10. Port-Allokation - -### Matrix Bots (3308-3327) - -| Port | Service | Beschreibung | -|------|---------|--------------| -| 3308 | matrix-presi-bot | Präsentationen | -| 3310 | matrix-mana-bot | Gateway (All-in-One) | -| 3311 | matrix-ollama-bot | Lokales LLM | -| 3312 | matrix-stats-bot | Analytics | -| 3313 | matrix-project-doc-bot | Projektdoku | -| 3314 | matrix-todo-bot | Aufgaben | -| 3315 | matrix-calendar-bot | Termine | -| 3316 | matrix-nutriphi-bot | Ernährung | -| 3318 | matrix-clock-bot | Timer/Alarme | -| 3319 | matrix-picture-bot | Bildgenerierung | -| 3320 | matrix-contacts-bot | Kontakte | -| 3321 | matrix-zitare-bot | Zitate | -| 3322 | matrix-planta-bot | Pflanzen | -| 3323 | matrix-storage-bot | Cloud-Speicher | -| 3324 | matrix-questions-bot | Q&A | -| 3327 | matrix-chat-bot | KI-Chat | - -### Supporting Services - -| Port | Service | Beschreibung | -|------|---------|--------------| -| 3001 | mana-auth | Authentifizierung | -| 3020 | mana-stt | Speech-to-Text | -| 3021 | mana-search | Web-Recherche | -| 3022 | mana-tts | Text-to-Speech | -| 3025 | mana-llm | LLM-Abstraction | - ---- - -## 11. Vorteile gegenüber Drittanbieter-Plattformen - -### 11.1 Vollständige Kontrolle - -| Aspekt | Telegram/Discord | Mana Matrix | -|--------|------------------|-----------------| -| **Datenhoheit** | Bei Anbieter | Bei uns | -| **Verfügbarkeit** | Abhängig von Anbieter | Eigene Infrastruktur | -| **API-Änderungen** | Anbieter entscheidet | Wir entscheiden | -| **Preisänderungen** | Anbieter entscheidet | Keine | -| **Zensur/Sperrung** | Möglich | Nicht möglich | - -### 11.2 DSGVO-Konformität - -``` -┌────────────────────────────────────────────────────────────────┐ -│ DSGVO-Compliance │ -├────────────────────────────────────────────────────────────────┤ -│ │ -│ ✅ Datenverarbeitung nur auf eigenen Servern │ -│ ✅ Keine Weitergabe an Dritte │ -│ ✅ Löschung auf Anfrage (Art. 17) │ -│ ✅ Auskunft über gespeicherte Daten (Art. 15) │ -│ ✅ Datenportabilität (Art. 20) │ -│ ✅ Auftragsverarbeitungsvertrag nicht nötig │ -│ │ -└────────────────────────────────────────────────────────────────┘ -``` - -### 11.3 Einheitliche UX - -Da wir beide Seiten kontrollieren (Bot + Client), können wir: -- Konsistente Command-Syntax über alle Bots -- Deutsche Sprachunterstützung überall -- Einheitliches Fehler-Handling -- Nahtlose Cross-Bot-Integration - ---- - -## 12. Zukünftige Entwicklung - -### 12.1 Geplante Erweiterungen - -- **Widget-Integration:** Interaktive UIs direkt in Element -- **Voice-Bot:** Sprachsteuerung via Matrix Calls -- **Bot-Discovery:** Automatische Bot-Erkennung in Räumen -- **Mehr @mana/bot-services:** Nutrition, Stats, Docs Services - -### 12.2 Konsolidierung - -Der Fokus liegt auf der Konsolidierung der Bot-Services in `@mana/bot-services`: -- Alle wiederkehrende Logik zentral -- Einheitliche Storage-Abstraction -- Transport-agnostische Services - ---- - -## 13. Fazit - -Mana's Matrix-Bot-Architektur bietet eine **vollständig unabhängige, DSGVO-konforme** Alternative zu Cloud-basierten Chat-Diensten. Mit 19 spezialisierten Bots und einem Gateway-Bot decken wir alle Produktivitäts- und App-Integrationsszenarien ab. - -**Kernvorteile:** -1. **Volle Kontrolle** über Daten und Infrastruktur -2. **DSGVO-Konformität** durch lokale Datenhaltung -3. **Einheitliche UX** durch konsistente Command-Patterns -4. **Skalierbarkeit** durch Microservices-Architektur -5. **Erweiterbarkeit** durch @mana/bot-services - ---- - -*Dokument erstellt am 1. Februar 2026* -*Letzte Aktualisierung: 1. Februar 2026* diff --git a/docs/MATRIX_SELF_HOSTING.md b/docs/MATRIX_SELF_HOSTING.md deleted file mode 100644 index 75dd755db..000000000 --- a/docs/MATRIX_SELF_HOSTING.md +++ /dev/null @@ -1,674 +0,0 @@ -# Matrix Self-Hosting auf Mac Mini - -Plan für DSGVO-konformes Messaging mit Matrix/Synapse auf dem Mana Server. - -## Übersicht - -``` -┌─────────────────────────────────────────────────────────────────────┐ -│ Internet │ -│ │ │ -│ ▼ │ -│ Cloudflare Tunnel │ -│ │ │ -│ ├─── matrix.mana.how ──────► Synapse (Port 8008) │ -│ ├─── element.mana.how ─────► Element Web (Port 8087) │ -│ └─── (bestehende Services) │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ Docker Container │ │ -│ │ │ │ -│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ │ -│ │ │ Synapse │ │ Element Web │ │ Matrix Bots │ │ │ -│ │ │ (8008) │ │ (8087) │ │ (NestJS) │ │ │ -│ │ └──────┬───────┘ └──────────────┘ └────────┬─────────┘ │ │ -│ │ │ │ │ │ -│ │ ▼ ▼ │ │ -│ │ ┌──────────────┐ ┌──────────────┐ │ │ -│ │ │ PostgreSQL │ │ Ollama │ │ │ -│ │ │ (matrix db) │ │ (11434) │ │ │ -│ │ └──────────────┘ └──────────────┘ │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────┘ -``` - -## DSGVO-Vorteile - -| Aspekt | Telegram | Matrix (Self-Hosted) | -|--------|----------|----------------------| -| Datenstandort | Dubai/Singapur | Mac Mini (Deutschland) | -| AV-Vertrag | Nicht möglich | Nicht nötig (eigene Daten) | -| E2E-Verschlüsselung | Nur Secret Chats | Standard für alle Räume | -| Metadaten | Bei Telegram | Lokal gespeichert | -| Löschung | Abhängig von Telegram | Volle Kontrolle | - ---- - -## Phase 1: Synapse Homeserver - -### 1.1 Datenbank erstellen - -```bash -ssh mana-server - -# Neue Datenbank für Matrix -docker exec mana-postgres psql -U postgres -c "CREATE DATABASE matrix;" -docker exec mana-postgres psql -U postgres -c "CREATE USER synapse WITH PASSWORD 'synapse-secure-password';" -docker exec mana-postgres psql -U postgres -c "GRANT ALL PRIVILEGES ON DATABASE matrix TO synapse;" -``` - -### 1.2 Synapse Konfiguration erstellen - -```bash -# Verzeichnis erstellen -mkdir -p ~/projects/mana-monorepo/docker/matrix - -# Synapse Config generieren (einmalig) -docker run -it --rm \ - -v ~/projects/mana-monorepo/docker/matrix:/data \ - -e SYNAPSE_SERVER_NAME=mana.how \ - -e SYNAPSE_REPORT_STATS=no \ - matrixdotorg/synapse:latest generate -``` - -### 1.3 homeserver.yaml anpassen - -**Datei:** `docker/matrix/homeserver.yaml` - -```yaml -server_name: "mana.how" -pid_file: /data/homeserver.pid - -listeners: - - port: 8008 - tls: false - type: http - x_forwarded: true - resources: - - names: [client, federation] - compress: false - -database: - name: psycopg2 - args: - user: synapse - password: "synapse-secure-password" - database: matrix - host: postgres - port: 5432 - cp_min: 5 - cp_max: 10 - -# Logging -log_config: "/data/mana.how.log.config" - -# Media Store (lokaler Speicher für Medien) -media_store_path: /data/media_store -max_upload_size: 50M - -# Registrierung -enable_registration: false -enable_registration_without_verification: false - -# Admin-Account beim ersten Start erstellen -# Nach dem Start: docker exec -it synapse register_new_matrix_user -c /data/homeserver.yaml http://localhost:8008 -a - -# Rate Limiting (für Bots erhöhen) -rc_message: - per_second: 5 - burst_count: 20 - -rc_registration: - per_second: 0.5 - burst_count: 5 - -# Für Bot-Integration: Application Services erlauben -app_service_config_files: [] - -# DSGVO: Datenaufbewahrung begrenzen -retention: - enabled: true - default_policy: - min_lifetime: 1d - max_lifetime: 365d - allowed_lifetime_min: 1d - allowed_lifetime_max: 365d - purge_jobs: - - longest_max_lifetime: 3d - interval: 12h - - shortest_max_lifetime: 365d - interval: 1d - -# Telemetrie deaktivieren -report_stats: false - -# Trusted Key Server (Matrix.org) -trusted_key_servers: - - server_name: "matrix.org" - -# Signing Key -signing_key_path: "/data/mana.how.signing.key" -``` - -### 1.4 Docker Compose Ergänzung - -Füge zu `docker-compose.macmini.yml` hinzu: - -```yaml - # ============================================ - # Matrix Synapse (Homeserver) - # ============================================ - - synapse: - image: matrixdotorg/synapse:latest - container_name: mana-synapse - restart: always - depends_on: - postgres: - condition: service_healthy - environment: - SYNAPSE_CONFIG_PATH: /data/homeserver.yaml - volumes: - - ./docker/matrix:/data - - synapse_media:/data/media_store - ports: - - "8008:8008" - healthcheck: - test: ["CMD", "curl", "-fSs", "http://localhost:8008/health"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 40s - - # ============================================ - # Element Web (Matrix Client) - # ============================================ - - element-web: - image: vectorim/element-web:latest - container_name: mana-element - restart: always - depends_on: - synapse: - condition: service_healthy - volumes: - - ./docker/matrix/element-config.json:/app/config.json:ro - ports: - - "8087:80" - healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:80/"] - interval: 30s - timeout: 10s - retries: 3 - -# Volumes ergänzen: -volumes: - synapse_media: - name: mana-synapse-media -``` - -### 1.5 Element Web Konfiguration - -**Datei:** `docker/matrix/element-config.json` - -```json -{ - "default_server_config": { - "m.homeserver": { - "base_url": "https://matrix.mana.how", - "server_name": "mana.how" - }, - "m.identity_server": { - "base_url": "" - } - }, - "brand": "Mana Chat", - "integrations_ui_url": "", - "integrations_rest_url": "", - "integrations_widgets_urls": [], - "disable_guests": true, - "disable_3pid_login": true, - "default_country_code": "DE", - "show_labs_settings": false, - "features": { - "feature_video_rooms": true, - "feature_group_calls": true - }, - "room_directory": { - "servers": ["mana.how"] - }, - "setting_defaults": { - "breadcrumbs": true - }, - "default_theme": "dark" -} -``` - -### 1.6 Cloudflare Tunnel erweitern - -**Datei:** `~/.cloudflared/config.yml` - -```yaml -# Bestehende Einträge... - - - hostname: matrix.mana.how - service: http://localhost:8008 - - - hostname: element.mana.how - service: http://localhost:8087 -``` - -Nach Änderung: -```bash -launchctl stop com.cloudflare.cloudflared -launchctl start com.cloudflare.cloudflared -``` - ---- - -## Phase 2: Synapse starten & Admin erstellen - -### 2.1 Container starten - -```bash -cd ~/projects/mana-monorepo - -# Nur Synapse + Element starten -docker compose -f docker-compose.macmini.yml up -d synapse element-web - -# Logs prüfen -docker logs -f mana-synapse -``` - -### 2.2 Admin-User erstellen - -```bash -# Interaktiv einen Admin erstellen -docker exec -it mana-synapse register_new_matrix_user \ - -c /data/homeserver.yaml \ - http://localhost:8008 \ - -a - -# Eingeben: -# Username: admin -# Password: (sicheres Passwort) -# Admin: yes -``` - -### 2.3 Testen - -```bash -# Health Check -curl https://matrix.mana.how/health -# Erwartete Antwort: OK - -# Federation Check -curl https://matrix.mana.how/_matrix/federation/v1/version -# Erwartete Antwort: {"server":{"name":"Synapse","version":"..."}} - -# Element Web aufrufen -open https://element.mana.how -``` - ---- - -## Phase 3: Bot-Räume einrichten - -### 3.1 Räume erstellen (via Element) - -1. **Anmelden** bei https://element.mana.how mit Admin-Account -2. **Räume erstellen:** - - `#ollama-bot:mana.how` - AI Chat Bot - - `#stats-bot:mana.how` - Analytics Reports - - `#project-doc-bot:mana.how` - Projektdokumentation - -### 3.2 Bot-User erstellen - -```bash -# Bot-User für jeden Bot erstellen (nicht-Admin) -docker exec -it mana-synapse register_new_matrix_user \ - -c /data/homeserver.yaml \ - http://localhost:8008 - -# Erstelle: -# - ollama-bot (Password notieren) -# - stats-bot (Password notieren) -# - projectdoc-bot (Password notieren) -``` - -### 3.3 Access Tokens generieren - -```bash -# Für jeden Bot ein Access Token holen -curl -X POST "https://matrix.mana.how/_matrix/client/v3/login" \ - -H "Content-Type: application/json" \ - -d '{ - "type": "m.login.password", - "user": "ollama-bot", - "password": "bot-password" - }' - -# Response: {"access_token": "syt_xxx", ...} -# Token für .env speichern -``` - ---- - -## Phase 4: Bot-Migration (NestJS) - -### 4.1 Neue Package-Struktur - -``` -services/ -├── telegram-ollama-bot/ # Alt (Telegram) -├── telegram-stats-bot/ # Alt (Telegram) -├── telegram-project-doc-bot/# Alt (Telegram) -│ -├── matrix-ollama-bot/ # NEU (Matrix) -├── matrix-stats-bot/ # NEU (Matrix) -└── matrix-project-doc-bot/ # NEU (Matrix) -``` - -### 4.2 Dependencies - -```bash -cd services/matrix-ollama-bot -pnpm add matrix-bot-sdk -``` - -### 4.3 Bot-Grundstruktur (Beispiel: Ollama Bot) - -**Datei:** `services/matrix-ollama-bot/src/bot/matrix.service.ts` - -```typescript -import { - MatrixClient, - SimpleFsStorageProvider, - AutojoinRoomsMixin, - RichConsoleLogger, - LogService, -} from 'matrix-bot-sdk'; -import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; - -@Injectable() -export class MatrixService implements OnModuleInit, OnModuleDestroy { - private client: MatrixClient; - - constructor(private config: ConfigService) {} - - async onModuleInit() { - LogService.setLogger(new RichConsoleLogger()); - - const homeserverUrl = this.config.get('MATRIX_HOMESERVER_URL'); - const accessToken = this.config.get('MATRIX_ACCESS_TOKEN'); - - const storage = new SimpleFsStorageProvider('bot-storage.json'); - - this.client = new MatrixClient(homeserverUrl, accessToken, storage); - - // Auto-join bei Einladungen - AutojoinRoomsMixin.setupOnClient(this.client); - - // Message Handler - this.client.on('room.message', this.handleMessage.bind(this)); - - await this.client.start(); - console.log('Matrix bot started!'); - } - - async onModuleDestroy() { - await this.client.stop(); - } - - private async handleMessage(roomId: string, event: any) { - // Eigene Nachrichten ignorieren - if (event.sender === await this.client.getUserId()) return; - - // Nur Text-Nachrichten - if (event.content?.msgtype !== 'm.text') return; - - const body = event.content.body; - - // Command-Handler - if (body.startsWith('!')) { - await this.handleCommand(roomId, event, body); - } else { - // Normaler Chat → Ollama - await this.handleChat(roomId, event, body); - } - } - - private async handleCommand(roomId: string, event: any, body: string) { - const [command, ...args] = body.slice(1).split(' '); - - switch (command.toLowerCase()) { - case 'help': - await this.sendMessage(roomId, this.getHelpText()); - break; - case 'models': - // Liste verfügbare Modelle - break; - case 'clear': - // Chat-History löschen - break; - // ... weitere Commands - } - } - - private async handleChat(roomId: string, event: any, message: string) { - // Typing-Indikator senden - await this.client.setTyping(roomId, true); - - // Ollama-Anfrage (wie bisher) - const response = await this.ollamaService.chat(message); - - await this.client.setTyping(roomId, false); - await this.sendMessage(roomId, response); - } - - async sendMessage(roomId: string, message: string) { - await this.client.sendMessage(roomId, { - msgtype: 'm.text', - body: message, - format: 'org.matrix.custom.html', - formatted_body: this.markdownToHtml(message), - }); - } - - private getHelpText(): string { - return `**Mana Ollama Bot** - -Befehle: -- \`!help\` - Diese Hilfe -- \`!models\` - Verfügbare Modelle -- \`!model \` - Modell wechseln -- \`!clear\` - Chat-Verlauf löschen - -Einfach eine Nachricht schreiben für AI-Chat.`; - } -} -``` - -### 4.4 Environment Variables - -**Datei:** `services/matrix-ollama-bot/.env` - -```env -# Server -PORT=3311 - -# Matrix -MATRIX_HOMESERVER_URL=https://matrix.mana.how -MATRIX_ACCESS_TOKEN=syt_xxx - -# Optional: Nur bestimmte Räume erlauben -MATRIX_ALLOWED_ROOMS=#ollama-bot:mana.how - -# Ollama -OLLAMA_URL=http://host.docker.internal:11434 -OLLAMA_MODEL=gemma3:4b -OLLAMA_TIMEOUT=120000 -``` - -### 4.5 Docker Compose für Matrix Bots - -```yaml - # ============================================ - # Matrix Ollama Bot - # ============================================ - - matrix-ollama-bot: - image: ghcr.io/memo-2023/matrix-ollama-bot:latest - container_name: mana-matrix-ollama-bot - restart: always - depends_on: - synapse: - condition: service_healthy - environment: - NODE_ENV: production - PORT: 3311 - MATRIX_HOMESERVER_URL: http://synapse:8008 - MATRIX_ACCESS_TOKEN: ${MATRIX_OLLAMA_BOT_TOKEN} - OLLAMA_URL: http://host.docker.internal:11434 - OLLAMA_MODEL: gemma3:4b - volumes: - - matrix_ollama_bot_data:/app/data - ports: - - "3311:3311" - healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3311/health"] - interval: 30s - timeout: 10s - retries: 3 - -# Volume ergänzen: -volumes: - matrix_ollama_bot_data: - name: mana-matrix-ollama-bot -``` - ---- - -## Phase 5: Feature-Mapping Telegram → Matrix - -### Commands - -| Telegram | Matrix | Beschreibung | -|----------|--------|--------------| -| `/start` | `!help` | Hilfe anzeigen | -| `/help` | `!help` | Hilfe anzeigen | -| `/models` | `!models` | Modelle auflisten | -| `/model x` | `!model x` | Modell wechseln | -| `/clear` | `!clear` | Chat löschen | -| `/status` | `!status` | Bot-Status | - -### Media-Handling - -| Feature | Telegram | Matrix | -|---------|----------|--------| -| Foto senden | `ctx.message.photo` | `m.image` msgtype | -| Voice senden | `ctx.message.voice` | `m.audio` msgtype | -| Datei senden | `ctx.message.document` | `m.file` msgtype | -| Foto antworten | `ctx.replyWithPhoto()` | `sendMessage()` mit `m.image` | - -### Beispiel: Media-Download in Matrix - -```typescript -async downloadMedia(event: any): Promise { - const mxcUrl = event.content.url; // mxc://mana.how/abc123 - const httpUrl = this.client.mxcToHttp(mxcUrl); - - const response = await fetch(httpUrl); - return Buffer.from(await response.arrayBuffer()); -} -``` - ---- - -## Phase 6: Health Check & Monitoring - -### Health Checks ergänzen - -**Datei:** `scripts/mac-mini/health-check.sh` - -```bash -# Matrix Synapse -if curl -sf http://localhost:8008/health > /dev/null; then - echo "✅ Synapse: OK" -else - echo "❌ Synapse: FAILED" - FAILED_SERVICES="$FAILED_SERVICES synapse" -fi - -# Element Web -if curl -sf http://localhost:8087/ > /dev/null; then - echo "✅ Element Web: OK" -else - echo "❌ Element Web: FAILED" - FAILED_SERVICES="$FAILED_SERVICES element-web" -fi - -# Matrix Ollama Bot -if curl -sf http://localhost:3311/health > /dev/null; then - echo "✅ Matrix Ollama Bot: OK" -else - echo "❌ Matrix Ollama Bot: FAILED" - FAILED_SERVICES="$FAILED_SERVICES matrix-ollama-bot" -fi -``` - -### Prometheus Metrics (optional) - -Synapse exportiert Metrics auf Port 9000 (kann aktiviert werden): - -```yaml -# In homeserver.yaml ergänzen -enable_metrics: true -metrics_port: 9000 - -# prometheus.yml ergänzen -- job_name: 'synapse' - static_configs: - - targets: ['synapse:9000'] -``` - ---- - -## Zeitplan - -| Phase | Aufgabe | Aufwand | -|-------|---------|---------| -| **1** | Synapse + Element aufsetzen | 1-2h | -| **2** | Admin & Bot-User erstellen | 30min | -| **3** | Bot-Räume einrichten | 30min | -| **4** | Ersten Bot migrieren (Ollama) | 2-4h | -| **5** | Weitere Bots migrieren | je 1-2h | -| **6** | Monitoring & Alerts | 1h | - -**Gesamt:** ~1 Tag für Grundsetup + Bot-Migration - ---- - -## Nächste Schritte - -1. [ ] `docker/matrix/` Verzeichnis erstellen -2. [ ] Synapse Config generieren -3. [ ] Docker Compose erweitern -4. [ ] Cloudflare Tunnel konfigurieren -5. [ ] Synapse starten & testen -6. [ ] Admin-Account erstellen -7. [ ] Bot-User erstellen -8. [ ] `matrix-ollama-bot` Service erstellen -9. [ ] Bot testen -10. [ ] Weitere Bots migrieren -11. [ ] Telegram Bots deaktivieren - ---- - -## Ressourcen - -- [Matrix Spec](https://spec.matrix.org/) -- [Synapse Docs](https://element-hq.github.io/synapse/latest/) -- [matrix-bot-sdk](https://github.com/turt2live/matrix-bot-sdk) -- [Element Web Config](https://github.com/element-hq/element-web/blob/develop/docs/config.md) diff --git a/docs/PORT_SCHEMA.md b/docs/PORT_SCHEMA.md index bad18d39d..e43c2e676 100644 --- a/docs/PORT_SCHEMA.md +++ b/docs/PORT_SCHEMA.md @@ -70,7 +70,7 @@ | 3010 | mana-sync | Go | Local-first data sync (WebSocket + HTTP) | | 3011 | mana-media | NestJS | Content-addressable storage, thumbnails | | 3012 | mana-search | Go | Web search via SearXNG | -| 3013 | mana-notify | Go | Notifications (email, push, Matrix) | +| 3013 | mana-notify | Go | Notifications (email, push, webhook) | | 3014 | mana-crawler | Go | Web crawler, content extraction | | 3015 | mana-landing-builder | NestJS | Org landing page builder | | 3016 | mana-api-gateway | Go | API keys, rate limiting, usage tracking | @@ -107,14 +107,10 @@ Pure CRUD apps use mana-sync directly. | 3040 | presi-server | Hono/Bun | Share links | | 3041-3059 | *(reserved)* | | | -## 4000-4099: Matrix/Chat Stack +## 4000-4099: Misc | Port | Service | Description | |------|---------|-------------| -| 4000 | synapse | Matrix homeserver | -| 4001 | mana-matrix-bot | Go bot (health/metrics) | -| 4010 | element-web | Element web client | -| 4011 | matrix-web | SvelteKit Matrix client | | 4400 | landings | Nginx static landing pages | ## 5000-5059: Web Frontends (SvelteKit) @@ -175,7 +171,6 @@ Pure CRUD apps use mana-sync directly. | 9090 | victoriametrics | Metrics storage | | 9091 | pushgateway | Deploy metrics | | 9093 | alertmanager | Alert routing | -| 9095 | alert-notifier | Matrix alert bridge | | 9100 | node-exporter | Host metrics | | 9110 | cadvisor | Container metrics | | 9121 | redis-exporter | Redis metrics | diff --git a/docs/URL_SCHEMA.md b/docs/URL_SCHEMA.md index 53414dcd1..2d20657c8 100644 --- a/docs/URL_SCHEMA.md +++ b/docs/URL_SCHEMA.md @@ -39,15 +39,12 @@ This document defines the URL schema for all mana.how subdomains. | **Media Service** | media.mana.how | Image/video processing | | **LLM Service** | llm.mana.how | LLM abstraction layer | | **LLM Playground** | playground.mana.how | LLM testing interface | -| **Link Shortener** | link.mana.how | URL shortener (uload) | | **File Storage** | files.mana.how | MinIO/S3 file access | -### Matrix/Communication +### Automation | Service | URL | Description | |---------|-----|-------------| -| **Matrix Server** | matrix.mana.how | Synapse homeserver | -| **Element Web** | element.mana.how | Matrix web client | | **N8N** | n.mana.how | Workflow automation | ### Monitoring & Admin diff --git a/package.json b/package.json index 269453e15..62985bbbd 100644 --- a/package.json +++ b/package.json @@ -76,9 +76,6 @@ "dev:calendar:landing": "pnpm --filter @calendar/landing dev", "dev:calendar:app": "concurrently -n api,web -c yellow,cyan \"pnpm dev:api\" \"pnpm dev:calendar:web\"", "dev:calendar:full": "concurrently -n auth,sync,api -c blue,magenta,yellow \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:api\"", - "matrix:dev": "turbo run dev --filter=matrix...", - "dev:matrix:web": "pnpm --filter @matrix/web dev", - "dev:matrix:mobile": "pnpm --filter @matrix/mobile dev", "mail:dev": "turbo run dev --filter=mail...", "dev:mail:mobile": "pnpm --filter @mail/mobile dev", "dev:mail:web": "pnpm --filter @mail/web dev", @@ -230,9 +227,6 @@ "dev:skilltree:web": "pnpm --filter @skilltree/web dev", "dev:skilltree:app": "pnpm dev:skilltree:web", "dev:skilltree:full": "concurrently -n auth,sync,web -c blue,magenta,cyan \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:skilltree:web\"", - "dev:matrix": "cd services/mana-matrix-bot && go run ./cmd/server", - "build:matrix": "cd services/mana-matrix-bot && go build -ldflags=\"-s -w\" -o dist/mana-matrix-bot ./cmd/server", - "test:matrix": "cd services/mana-matrix-bot && go test ./...", "dev:llm-playground": "pnpm --filter @mana-llm/playground dev", "build:llm-playground": "pnpm --filter @mana-llm/playground build", "prepare": "husky", @@ -300,8 +294,7 @@ "patchedDependencies": {}, "overrides": { "cpu-features": "npm:empty-npm-package@1.0.0", - "ssh2": "npm:empty-npm-package@1.0.0", - "@matrix-org/matrix-sdk-crypto-nodejs": "npm:empty-npm-package@1.0.0" + "ssh2": "npm:empty-npm-package@1.0.0" } } } diff --git a/packages/notify-client/src/client.ts b/packages/notify-client/src/client.ts index 674bf2592..c4b741aad 100644 --- a/packages/notify-client/src/client.ts +++ b/packages/notify-client/src/client.ts @@ -1,7 +1,6 @@ import type { SendEmailOptions, SendPushOptions, - SendMatrixOptions, SendWebhookOptions, ScheduleOptions, NotificationResponse, @@ -82,24 +81,6 @@ export class NotifyClient { }); } - /** - * Send a Matrix message - */ - async sendMatrix(options: SendMatrixOptions): Promise { - return this.send({ - channel: 'matrix', - appId: this.appId, - recipient: options.roomId, - body: options.body, - matrixOptions: { - formattedBody: options.formattedBody, - msgtype: options.msgtype, - }, - priority: options.priority, - externalId: options.externalId, - }); - } - /** * Send a webhook notification */ @@ -184,7 +165,6 @@ export class NotifyClient { notifications: Array< | ({ type: 'email' } & SendEmailOptions) | ({ type: 'push' } & SendPushOptions) - | ({ type: 'matrix' } & SendMatrixOptions) | ({ type: 'webhook' } & SendWebhookOptions) > ): Promise { @@ -214,15 +194,6 @@ export class NotifyClient { priority: n.priority, externalId: n.externalId, }; - } else if (n.type === 'matrix') { - return { - channel: 'matrix' as const, - appId: this.appId, - recipient: n.roomId, - body: n.body, - priority: n.priority, - externalId: n.externalId, - }; } else { return { channel: 'webhook' as const, diff --git a/packages/notify-client/src/types.ts b/packages/notify-client/src/types.ts index 4547cc22e..f854adb75 100644 --- a/packages/notify-client/src/types.ts +++ b/packages/notify-client/src/types.ts @@ -1,4 +1,4 @@ -export type NotificationChannel = 'email' | 'push' | 'matrix' | 'webhook'; +export type NotificationChannel = 'email' | 'push' | 'webhook'; export type NotificationPriority = 'low' | 'normal' | 'high' | 'critical'; export type NotificationStatus = 'pending' | 'processing' | 'delivered' | 'failed' | 'cancelled'; @@ -28,15 +28,6 @@ export interface SendPushOptions { externalId?: string; } -export interface SendMatrixOptions { - roomId: string; - body: string; - formattedBody?: string; - msgtype?: 'text' | 'notice'; - priority?: NotificationPriority; - externalId?: string; -} - export interface SendWebhookOptions { url: string; method?: 'POST' | 'PUT'; diff --git a/packages/shared-branding/src/app-icons.ts b/packages/shared-branding/src/app-icons.ts index 8dd319b01..8ed76a715 100644 --- a/packages/shared-branding/src/app-icons.ts +++ b/packages/shared-branding/src/app-icons.ts @@ -63,9 +63,6 @@ const inventorySvg = ``; -// Matrix icon (network/federated chat with purple gradient) -const matrixSvg = ``; - // CityCorners icon (map pin with blue gradient) const citycornersSvg = ``; @@ -101,7 +98,6 @@ export const APP_ICONS = { mail: svgToDataUrl(mailSvg), inventory: svgToDataUrl(inventorySvg), questions: svgToDataUrl(questionsSvg), - matrix: svgToDataUrl(matrixSvg), context: svgToDataUrl(contextSvg), citycorners: svgToDataUrl(citycornersSvg), times: svgToDataUrl(timesSvg), diff --git a/packages/shared-branding/src/mana-apps.ts b/packages/shared-branding/src/mana-apps.ts index 3d23755b9..27718ed11 100644 --- a/packages/shared-branding/src/mana-apps.ts +++ b/packages/shared-branding/src/mana-apps.ts @@ -377,23 +377,6 @@ export const MANA_APPS: ManaApp[] = [ status: 'beta', requiredTier: 'alpha', }, - { - id: 'matrix', - name: 'Mana Matrix', - description: { - de: 'Matrix Chat Client', - en: 'Matrix Chat Client', - }, - longDescription: { - de: 'Verbinde dich mit dem dezentralen Matrix-Netzwerk für sichere, föderierte Kommunikation.', - en: 'Connect to the decentralized Matrix network for secure, federated communication.', - }, - icon: APP_ICONS.matrix, - color: '#8b5cf6', - comingSoon: false, - status: 'beta', - requiredTier: 'alpha', - }, { id: 'context', name: 'Context', @@ -796,7 +779,7 @@ export const APP_SLIDER_LABELS = { * App URLs — unified app uses internal paths, separate apps use subdomains. * * All productivity apps are now served under mana.how/{appId}. - * Games and Matrix remain on separate subdomains. + * Games remain on separate subdomains. */ export const APP_URLS: Record = { // ─── Unified App (internal paths) ───────────────────────── @@ -837,7 +820,6 @@ export const APP_URLS: Record = { news: { dev: 'http://localhost:5173/news', prod: 'https://mana.how/news' }, mail: { dev: 'http://localhost:5173/mail', prod: 'https://mana.how/mail' }, // ─── Separate Apps (own subdomains) ─────────────────────── - matrix: { dev: 'http://localhost:5180', prod: 'https://matrix.mana.how' }, arcade: { dev: 'http://localhost:5201', prod: 'https://arcade.mana.how' }, }; diff --git a/scripts/audit-workspace-deps.mjs b/scripts/audit-workspace-deps.mjs index 5bfe47100..5c166c2a1 100755 --- a/scripts/audit-workspace-deps.mjs +++ b/scripts/audit-workspace-deps.mjs @@ -50,7 +50,6 @@ const WORKSPACE_SCOPES = new Set([ '@clock', '@contacts', '@context', - '@matrix', '@music', '@nutriphi', '@photos', diff --git a/scripts/mac-mini/build-app.sh b/scripts/mac-mini/build-app.sh index 6b9f8c972..120da1474 100755 --- a/scripts/mac-mini/build-app.sh +++ b/scripts/mac-mini/build-app.sh @@ -146,7 +146,6 @@ if [ $# -eq 0 ]; then echo " $0 todo-web todo-backend # Build & restart both" echo " $0 --base # Rebuild base images" echo " $0 --all-web # Rebuild all web apps" - echo " $0 mana-matrix-bot # Build & restart consolidated Matrix bot (Go)" echo " $0 --force-free todo-web # Force stop monitoring before build" exit 1 fi diff --git a/scripts/mac-mini/deploy-v2.sh b/scripts/mac-mini/deploy-v2.sh index cf8c380b4..5e493978c 100755 --- a/scripts/mac-mini/deploy-v2.sh +++ b/scripts/mac-mini/deploy-v2.sh @@ -5,7 +5,7 @@ # Deploys the complete Mana stack: # - Infrastructure: PostgreSQL, Redis, MinIO, SearXNG # - Core Services: mana-auth, mana-credits, mana-user, mana-subscriptions, mana-analytics -# - Go Services: mana-sync, mana-search, mana-crawler, mana-api-gateway, mana-notify, mana-matrix-bot +# - Go Services: mana-sync, mana-search, mana-crawler, mana-api-gateway, mana-notify # - Python AI: mana-llm, mana-stt, mana-tts, mana-image-gen # - App Frontends: 19 SvelteKit web apps # diff --git a/scripts/mac-mini/ensure-containers-running.sh b/scripts/mac-mini/ensure-containers-running.sh index e3a0bd8d0..a02249c83 100755 --- a/scripts/mac-mini/ensure-containers-running.sh +++ b/scripts/mac-mini/ensure-containers-running.sh @@ -174,10 +174,6 @@ for container in $ALL_PROBLEM_CONTAINERS; do mana-infra-postgres) SERVICE_NAME="postgres" ;; mana-infra-redis) SERVICE_NAME="redis" ;; mana-infra-minio) SERVICE_NAME="minio" ;; - mana-matrix-synapse) SERVICE_NAME="synapse" ;; - mana-matrix-element) SERVICE_NAME="element-web" ;; - mana-matrix-web) SERVICE_NAME="matrix-web" ;; - mana-matrix-bot-*) SERVICE_NAME="${container#mana-matrix-bot-}"; SERVICE_NAME="matrix-${SERVICE_NAME}-bot" ;; mana-mon-*) SERVICE_NAME="${container#mana-mon-}" ;; mana-auto-*) SERVICE_NAME="${container#mana-auto-}" ;; mana-service-*) SERVICE_NAME="${container#mana-service-}" ;; diff --git a/scripts/mac-mini/memory-baseline.sh b/scripts/mac-mini/memory-baseline.sh index fafc2e441..f4546ff08 100755 --- a/scripts/mac-mini/memory-baseline.sh +++ b/scripts/mac-mini/memory-baseline.sh @@ -79,17 +79,15 @@ get_category_mem() { infra=$(get_category_mem "mana-infra") core=$(get_category_mem "mana-core\|mana-auth\|mana-credits\|mana-user\|mana-subscriptions\|mana-analytics\|mana-api-gateway\|mana-crawler\|mana-service") -matrix=$(get_category_mem "mana-matrix") apps=$(get_category_mem "mana-app") monitoring=$(get_category_mem "mana-mon") games=$(get_category_mem "mana-game") auto=$(get_category_mem "mana-auto") -total=$((infra + core + matrix + apps + monitoring + games + auto)) +total=$((infra + core + apps + monitoring + games + auto)) printf "%-25s %8s MiB\n" "Infrastructure:" "$infra" printf "%-25s %8s MiB\n" "Core Services:" "$core" -printf "%-25s %8s MiB\n" "Matrix Stack:" "$matrix" printf "%-25s %8s MiB\n" "Web Apps:" "$apps" printf "%-25s %8s MiB\n" "Monitoring:" "$monitoring" printf "%-25s %8s MiB\n" "Games:" "$games" diff --git a/scripts/mac-mini/migrate-to-colima.sh b/scripts/mac-mini/migrate-to-colima.sh index 6fcd6fc3b..2dec93e0a 100755 --- a/scripts/mac-mini/migrate-to-colima.sh +++ b/scripts/mac-mini/migrate-to-colima.sh @@ -118,7 +118,6 @@ NAMED_VOLUMES=( "mana-grafana-data" "mana-analytics-data" "mana-loki-data" - "mana-matrix-bots-data" ) if [ "$DRY_RUN" = true ]; then diff --git a/scripts/mac-mini/setup-matrix.sh b/scripts/mac-mini/setup-matrix.sh deleted file mode 100755 index 81e73a34a..000000000 --- a/scripts/mac-mini/setup-matrix.sh +++ /dev/null @@ -1,123 +0,0 @@ -#!/bin/bash -# Setup Matrix Synapse on Mac Mini -# Run this script once to initialize Matrix - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" -MATRIX_DIR="$PROJECT_DIR/docker/matrix" - -echo "============================================" -echo " Mana Matrix Setup" -echo "============================================" -echo "" - -# Colors -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' - -# Check if postgres is running -echo "Checking PostgreSQL..." -if ! docker exec mana-postgres pg_isready -U postgres > /dev/null 2>&1; then - echo -e "${RED}Error: PostgreSQL is not running.${NC}" - echo "Start it with: docker compose -f docker-compose.macmini.yml up -d postgres" - exit 1 -fi -echo -e "${GREEN}PostgreSQL is running${NC}" - -# Create matrix database -echo "" -echo "Creating Matrix database..." -if docker exec mana-postgres psql -U postgres -lqt | cut -d \| -f 1 | grep -qw matrix; then - echo -e "${YELLOW}Database 'matrix' already exists${NC}" -else - docker exec mana-postgres psql -U postgres -c "CREATE DATABASE matrix;" - echo -e "${GREEN}Database 'matrix' created${NC}" -fi - -# Create synapse user -echo "" -echo "Creating Synapse database user..." -if docker exec mana-postgres psql -U postgres -tAc "SELECT 1 FROM pg_roles WHERE rolname='synapse'" | grep -q 1; then - echo -e "${YELLOW}User 'synapse' already exists${NC}" -else - # Generate a random password if not set - SYNAPSE_DB_PASSWORD=${SYNAPSE_DB_PASSWORD:-$(openssl rand -base64 24)} - docker exec mana-postgres psql -U postgres -c "CREATE USER synapse WITH PASSWORD '$SYNAPSE_DB_PASSWORD';" - docker exec mana-postgres psql -U postgres -c "GRANT ALL PRIVILEGES ON DATABASE matrix TO synapse;" - docker exec mana-postgres psql -U postgres -c "ALTER DATABASE matrix OWNER TO synapse;" - echo -e "${GREEN}User 'synapse' created${NC}" - echo "" - echo -e "${YELLOW}IMPORTANT: Add this to your .env file:${NC}" - echo "SYNAPSE_DB_PASSWORD=$SYNAPSE_DB_PASSWORD" -fi - -# Create logs directory in volume -echo "" -echo "Creating logs directory..." -mkdir -p "$MATRIX_DIR/logs" 2>/dev/null || true - -# Generate signing key if not exists -echo "" -echo "Checking signing key..." -if docker volume ls | grep -q mana-synapse; then - echo -e "${YELLOW}Synapse volume already exists - signing key should be present${NC}" -else - echo "Signing key will be generated on first Synapse start" -fi - -# Generate secrets if not set -echo "" -echo "============================================" -echo " Required Environment Variables" -echo "============================================" -echo "" -echo "Add these to your .env file (generate secure values!):" -echo "" - -# Generate random secrets for display -echo "SYNAPSE_DB_PASSWORD=$(openssl rand -base64 24)" -echo "SYNAPSE_PASSWORD_PEPPER=$(openssl rand -base64 32)" -echo "SYNAPSE_FORM_SECRET=$(openssl rand -base64 32)" -echo "SYNAPSE_MACAROON_SECRET=$(openssl rand -base64 32)" -echo "SYNAPSE_REGISTRATION_SECRET=$(openssl rand -base64 32)" - -echo "" -echo "============================================" -echo " Cloudflare Tunnel Configuration" -echo "============================================" -echo "" -echo "Add these ingress rules to ~/.cloudflared/config.yml:" -echo "" -echo " - hostname: matrix.mana.how" -echo " service: http://localhost:8008" -echo "" -echo " - hostname: element.mana.how" -echo " service: http://localhost:8087" -echo "" - -echo "" -echo "============================================" -echo " Next Steps" -echo "============================================" -echo "" -echo "1. Add environment variables to .env file" -echo "2. Update Cloudflare Tunnel config" -echo "3. Start Matrix services:" -echo " docker compose -f docker-compose.macmini.yml up -d synapse element-web" -echo "" -echo "4. Wait for Synapse to start (check logs):" -echo " docker logs -f mana-synapse" -echo "" -echo "5. Create admin user:" -echo " docker exec -it mana-synapse register_new_matrix_user \\" -echo " -c /data/homeserver.yaml http://localhost:8008 -a" -echo "" -echo "6. Test endpoints:" -echo " curl https://matrix.mana.how/health" -echo " open https://element.mana.how" -echo "" -echo -e "${GREEN}Setup complete!${NC}" diff --git a/scripts/mac-mini/setup-tts-bot.sh b/scripts/mac-mini/setup-tts-bot.sh deleted file mode 100755 index db35ef5c0..000000000 --- a/scripts/mac-mini/setup-tts-bot.sh +++ /dev/null @@ -1,160 +0,0 @@ -#!/bin/bash -# Register and setup Matrix TTS Bot -# Run this after Matrix Synapse is running - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" - -# Colors -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -CYAN='\033[0;36m' -NC='\033[0m' - -echo "============================================" -echo " Matrix TTS Bot Setup" -echo "============================================" -echo "" - -# Default values -HOMESERVER_URL="${MATRIX_HOMESERVER_URL:-http://localhost:8008}" -BOT_USERNAME="tts" -BOT_DISPLAY_NAME="TTS" - -# Check if Synapse is running -echo "Checking Synapse..." -if ! curl -s "${HOMESERVER_URL}/health" > /dev/null 2>&1; then - echo -e "${RED}Error: Synapse is not reachable at ${HOMESERVER_URL}${NC}" - echo "Start it with: docker compose -f docker-compose.macmini.yml up -d synapse" - exit 1 -fi -echo -e "${GREEN}Synapse is running${NC}" -echo "" - -# Check if registration secret is available -if [ -z "$SYNAPSE_REGISTRATION_SECRET" ]; then - echo -e "${YELLOW}SYNAPSE_REGISTRATION_SECRET not set.${NC}" - echo "Please provide the registration secret from your .env file:" - read -sp "Registration Secret: " SYNAPSE_REGISTRATION_SECRET - echo "" -fi - -# Generate bot password -BOT_PASSWORD=$(openssl rand -base64 24) - -echo "Registering bot user @${BOT_USERNAME}..." - -# Generate HMAC for registration -generate_mac() { - local nonce=$1 - local user=$2 - local password=$3 - local user_type=$4 - local admin=$5 - - local mac_input="${nonce}\x00${user}\x00${password}\x00${user_type}\x00${admin}" - echo -n "$mac_input" | openssl dgst -sha1 -hmac "$SYNAPSE_REGISTRATION_SECRET" | cut -d' ' -f2 -} - -# Get nonce -NONCE=$(curl -s "${HOMESERVER_URL}/_synapse/admin/v1/register" | jq -r '.nonce') - -if [ -z "$NONCE" ] || [ "$NONCE" = "null" ]; then - echo -e "${RED}Failed to get registration nonce. Is admin registration enabled?${NC}" - exit 1 -fi - -# Calculate MAC -MAC=$(generate_mac "$NONCE" "$BOT_USERNAME" "$BOT_PASSWORD" "bot" "false") - -# Register user -REGISTER_RESPONSE=$(curl -s -X POST "${HOMESERVER_URL}/_synapse/admin/v1/register" \ - -H "Content-Type: application/json" \ - -d "{ - \"nonce\": \"${NONCE}\", - \"username\": \"${BOT_USERNAME}\", - \"password\": \"${BOT_PASSWORD}\", - \"displayname\": \"${BOT_DISPLAY_NAME}\", - \"user_type\": \"bot\", - \"admin\": false, - \"mac\": \"${MAC}\" - }") - -# Check if registration was successful -if echo "$REGISTER_RESPONSE" | jq -e '.access_token' > /dev/null 2>&1; then - ACCESS_TOKEN=$(echo "$REGISTER_RESPONSE" | jq -r '.access_token') - USER_ID=$(echo "$REGISTER_RESPONSE" | jq -r '.user_id') - - echo -e "${GREEN}Bot registered successfully!${NC}" - echo "" - echo -e "${CYAN}User ID:${NC} ${USER_ID}" - echo "" -else - ERROR=$(echo "$REGISTER_RESPONSE" | jq -r '.error // .errcode // "Unknown error"') - - # Check if user already exists - if echo "$ERROR" | grep -qi "user.*exists\|already.*registered\|M_USER_IN_USE"; then - echo -e "${YELLOW}User @${BOT_USERNAME} already exists. Getting access token via login...${NC}" - - echo "Please enter the existing bot password:" - read -sp "Password: " EXISTING_PASSWORD - echo "" - - LOGIN_RESPONSE=$(curl -s -X POST "${HOMESERVER_URL}/_matrix/client/r0/login" \ - -H "Content-Type: application/json" \ - -d "{ - \"type\": \"m.login.password\", - \"user\": \"${BOT_USERNAME}\", - \"password\": \"${EXISTING_PASSWORD}\" - }") - - if echo "$LOGIN_RESPONSE" | jq -e '.access_token' > /dev/null 2>&1; then - ACCESS_TOKEN=$(echo "$LOGIN_RESPONSE" | jq -r '.access_token') - USER_ID=$(echo "$LOGIN_RESPONSE" | jq -r '.user_id') - echo -e "${GREEN}Login successful!${NC}" - else - echo -e "${RED}Login failed. Please check the password.${NC}" - exit 1 - fi - else - echo -e "${RED}Registration failed: ${ERROR}${NC}" - exit 1 - fi -fi - -echo "" -echo "============================================" -echo " Add to .env file" -echo "============================================" -echo "" -echo -e "${CYAN}# Matrix TTS Bot${NC}" -echo "MATRIX_TTS_BOT_TOKEN=${ACCESS_TOKEN}" -echo "" - -# Optional: Set display name and avatar -echo "Setting display name..." -curl -s -X PUT "${HOMESERVER_URL}/_matrix/client/r0/profile/${USER_ID}/displayname" \ - -H "Authorization: Bearer ${ACCESS_TOKEN}" \ - -H "Content-Type: application/json" \ - -d "{\"displayname\": \"🔊 ${BOT_DISPLAY_NAME}\"}" > /dev/null - -echo "" -echo "============================================" -echo " Next Steps" -echo "============================================" -echo "" -echo "1. Add the MATRIX_TTS_BOT_TOKEN to your .env file" -echo "" -echo "2. Build the bot image:" -echo " docker build -t matrix-tts-bot ./services/matrix-tts-bot" -echo "" -echo "3. Start the bot:" -echo " docker compose -f docker-compose.macmini.yml up -d matrix-tts-bot" -echo "" -echo "4. Invite the bot to a room in Element:" -echo " /invite @tts:mana.how" -echo "" -echo -e "${GREEN}Setup complete!${NC}" diff --git a/scripts/mac-mini/startup.sh b/scripts/mac-mini/startup.sh index a6fc468bd..46e7e0e72 100755 --- a/scripts/mac-mini/startup.sh +++ b/scripts/mac-mini/startup.sh @@ -110,7 +110,7 @@ log "Docker CLI connected" # ─── Restore named volumes if missing ─── BACKUP_DIR="/Volumes/ManaData/backups/docker-migration-20260328" -for vol in mana-redis-data mana-victoria-data mana-alertmanager-data mana-grafana-data mana-analytics-data mana-loki-data mana-matrix-bots-data; do +for vol in mana-redis-data mana-victoria-data mana-alertmanager-data mana-grafana-data mana-analytics-data mana-loki-data; do if ! docker volume inspect "$vol" >/dev/null 2>&1; then BACKUP_FILE="$BACKUP_DIR/${vol}.tar.gz" if [ -f "$BACKUP_FILE" ]; then @@ -137,11 +137,6 @@ log "Starting Docker containers..." cd "$PROJECT_ROOT" docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d --no-build 2>&1 | tee -a "$LOG_FILE" -# ─── Force-recreate stateful containers that cache config ─── -# synapse copies homeserver.yaml at startup; stale container uses old cached config -log "Force-recreating config-sensitive containers..." -docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d --no-build --force-recreate synapse 2>&1 | tee -a "$LOG_FILE" - # ─── Wait and verify ─── log "Waiting 45s for services to initialize..." sleep 45 @@ -155,12 +150,4 @@ for db in mana_auth mana_credits chat todo calendar clock contacts storage umami docker exec mana-infra-postgres psql -U postgres -c "CREATE DATABASE $db;" 2>/dev/null || true done -# Matrix Synapse: needs its own user and C-locale database -docker exec mana-infra-postgres psql -U postgres -c \ - "DO \$\$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname='synapse') THEN CREATE USER synapse WITH PASSWORD 'synapse-secure-password'; END IF; END \$\$;" \ - 2>/dev/null || true -docker exec mana-infra-postgres psql -U postgres -c \ - "CREATE DATABASE matrix OWNER synapse ENCODING UTF8 LC_COLLATE='C' LC_CTYPE='C' TEMPLATE template0;" \ - 2>/dev/null || true - log "=== Startup Complete ($RUNNING containers running) ===" diff --git a/services/mana-auth/CLAUDE.md b/services/mana-auth/CLAUDE.md index b7842b2f5..63d1a2291 100644 --- a/services/mana-auth/CLAUDE.md +++ b/services/mana-auth/CLAUDE.md @@ -19,9 +19,8 @@ Central authentication service for the Mana ecosystem. Hono + Bun + Better Auth. 1. **Organization** — B2B multi-tenant with RBAC 2. **JWT** — EdDSA tokens with minimal claims (sub, email, role, sid) -3. **OIDC Provider** — Matrix/Synapse SSO -4. **Two-Factor** — TOTP with backup codes -5. **Magic Link** — Passwordless email login +3. **Two-Factor** — TOTP with backup codes +4. **Magic Link** — Passwordless email login ## Key Endpoints @@ -37,9 +36,6 @@ Handled directly by Better Auth — includes sign-in, sign-up, session, 2FA, mag | POST | `/validate` | Validate JWT token | | GET | `/session` | Get current session | -### OIDC (`/.well-known/*`, `/api/auth/oauth2/*`) -OpenID Connect provider for Matrix/Synapse SSO. - ### Me — GDPR Self-Service (`/api/v1/me/*`) | Method | Path | Description | |--------|------|-------------| @@ -103,7 +99,6 @@ SMTP_HOST=stalwart # self-hosted on Mac Mini, see docs/MAIL_SERVER.md SMTP_PORT=587 SMTP_USER=... SMTP_PASS=... -SYNAPSE_OIDC_CLIENT_SECRET=... # Encryption Vault — REQUIRED IN PRODUCTION # Base64-encoded 32-byte AES-256 key. Generate with `openssl rand -base64 32`. diff --git a/services/mana-auth/src/auth/better-auth.config.ts b/services/mana-auth/src/auth/better-auth.config.ts index dc01db350..1494de1e8 100644 --- a/services/mana-auth/src/auth/better-auth.config.ts +++ b/services/mana-auth/src/auth/better-auth.config.ts @@ -18,7 +18,6 @@ import { betterAuth } from 'better-auth'; import { drizzleAdapter } from 'better-auth/adapters/drizzle'; import { jwt } from 'better-auth/plugins/jwt'; import { organization } from 'better-auth/plugins/organization'; -import { oidcProvider } from 'better-auth/plugins/oidc-provider'; import { twoFactor } from 'better-auth/plugins/two-factor'; import { magicLink } from 'better-auth/plugins/magic-link'; import { getDb } from '../db/connection'; @@ -29,10 +28,6 @@ import { accounts, verificationTokens, jwks, - oauthApplications, - oauthAccessTokens, - oauthAuthorizationCodes, - oauthConsents, twoFactorAuth, } from '../db/schema/auth'; import { @@ -103,12 +98,6 @@ export function createBetterAuth(databaseUrl: string) { // Two-Factor Authentication table twoFactor: twoFactorAuth, - - // OIDC Provider tables - oauthApplication: oauthApplications, - oauthAccessToken: oauthAccessTokens, - oauthAuthorizationCode: oauthAuthorizationCodes, - oauthConsent: oauthConsents, }, }), @@ -258,9 +247,6 @@ export function createBetterAuth(databaseUrl: string) { // Separate apps (not part of unified app) 'https://arcade.mana.how', // Games 'https://whopxl.mana.how', // Games - 'https://link.mana.how', // Matrix/Manalink - 'https://element.mana.how', // Element (Matrix client) - 'https://matrix.mana.how', // Matrix // Local development 'http://localhost:3001', 'http://localhost:5173', @@ -365,45 +351,6 @@ export function createBetterAuth(databaseUrl: string) { }, }), - /** - * OIDC Provider Plugin - * - * Enables Mana Core Auth to act as an OpenID Connect Provider. - * This allows Matrix/Synapse and other services to use SSO. - * - * Endpoints provided: - * - GET /.well-known/openid-configuration - * - GET /api/oidc/authorize - * - POST /api/oidc/token - * - GET /api/oidc/userinfo - * - GET /api/oidc/jwks - */ - oidcProvider({ - // Login page for OIDC authorization - loginPage: '/login', - // Consent page (skipped for trusted clients) - consentPage: '/consent', - // Use JWT plugin for token signing (EdDSA instead of HS256) - // This is required for Synapse OIDC which verifies via JWKS - useJWTPlugin: true, - metadata: { - issuer: process.env.BASE_URL || 'http://localhost:3001', - }, - // Trusted clients that skip consent screen - // These clients are considered first-party and don't need user consent - trustedClients: [ - { - clientId: 'matrix-synapse', - clientSecret: process.env.SYNAPSE_OIDC_CLIENT_SECRET || '', - name: 'Matrix Synapse', - type: 'web', - disabled: false, - metadata: {}, - redirectUrls: ['https://matrix.mana.how/_synapse/client/oidc/callback'], - skipConsent: true, - }, - ], - }), /** * Two-Factor Authentication Plugin (TOTP) * diff --git a/services/mana-auth/src/config.ts b/services/mana-auth/src/config.ts index 75b3c9b03..5465e0c70 100644 --- a/services/mana-auth/src/config.ts +++ b/services/mana-auth/src/config.ts @@ -10,7 +10,6 @@ export interface Config { manaNotifyUrl: string; manaCreditsUrl: string; manaSubscriptionsUrl: string; - synapseOidcClientSecret: string; /** Base64-encoded 32-byte AES-256 key encryption key (KEK). Wraps each * user's master key in auth.encryption_vaults. Required in production * — in development a deterministic dev KEK is auto-generated so the @@ -55,7 +54,6 @@ export function loadConfig(): Config { manaNotifyUrl: env('MANA_NOTIFY_URL', 'http://localhost:3013'), manaCreditsUrl: env('MANA_CREDITS_URL', 'http://localhost:3061'), manaSubscriptionsUrl: env('MANA_SUBSCRIPTIONS_URL', 'http://localhost:3063'), - synapseOidcClientSecret: env('SYNAPSE_OIDC_CLIENT_SECRET'), encryptionKek, }; } diff --git a/services/mana-auth/src/db/schema/auth.ts b/services/mana-auth/src/db/schema/auth.ts index cdcaf5b24..8249d5f11 100644 --- a/services/mana-auth/src/db/schema/auth.ts +++ b/services/mana-auth/src/db/schema/auth.ts @@ -139,87 +139,6 @@ export const jwks = authSchema.table('jwks', { createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), }); -// OIDC Provider tables (Better Auth OIDC Provider plugin) -// OAuth Applications (OIDC Clients like Matrix/Synapse) -export const oauthApplications = authSchema.table('oauth_applications', { - id: text('id').primaryKey(), - name: text('name').notNull(), - icon: text('icon'), - metadata: text('metadata'), - clientId: text('client_id').unique().notNull(), - clientSecret: text('client_secret').notNull(), - redirectUrls: text('redirect_urls').notNull(), // Comma-separated URLs (Better Auth expects 'redirectUrls' property name) - type: text('type').notNull().default('web'), // web, native, spa - disabled: boolean('disabled').default(false).notNull(), - userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), -}); - -// OAuth Access Tokens -export const oauthAccessTokens = authSchema.table('oauth_access_tokens', { - id: text('id').primaryKey(), - accessToken: text('access_token').unique().notNull(), - refreshToken: text('refresh_token').unique(), - accessTokenExpiresAt: timestamp('access_token_expires_at', { withTimezone: true }).notNull(), - refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }), - clientId: text('client_id').notNull(), - userId: text('user_id') - .references(() => users.id, { onDelete: 'cascade' }) - .notNull(), - scopes: text('scopes').notNull(), // JSON array as text - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), -}); - -// OAuth Authorization Codes -export const oauthAuthorizationCodes = authSchema.table('oauth_authorization_codes', { - id: text('id').primaryKey(), - code: text('code').unique().notNull(), - clientId: text('client_id').notNull(), - userId: text('user_id') - .references(() => users.id, { onDelete: 'cascade' }) - .notNull(), - scopes: text('scopes').notNull(), // JSON array as text - redirectUri: text('redirect_uri').notNull(), - codeChallenge: text('code_challenge'), - codeChallengeMethod: text('code_challenge_method'), - expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), -}); - -// OAuth Consents (user consent records for OIDC scopes) -export const oauthConsents = authSchema.table('oauth_consents', { - id: text('id').primaryKey(), - userId: text('user_id') - .references(() => users.id, { onDelete: 'cascade' }) - .notNull(), - clientId: text('client_id').notNull(), - scopes: text('scopes').notNull(), // JSON array as text - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), -}); - -// Matrix User Links table (for Bot SSO) -// Links Matrix user IDs to Mana user accounts for automatic bot authentication -export const matrixUserLinks = authSchema.table( - 'matrix_user_links', - { - id: text('id').primaryKey(), // nanoid - matrixUserId: text('matrix_user_id').unique().notNull(), // e.g., @user:matrix.mana.how - userId: text('user_id') - .references(() => users.id, { onDelete: 'cascade' }) - .notNull(), - linkedAt: timestamp('linked_at', { withTimezone: true }).defaultNow().notNull(), - lastUsedAt: timestamp('last_used_at', { withTimezone: true }), - // Optional: store email for convenience (denormalized from users table) - email: text('email'), - }, - (table) => ({ - userIdIdx: index('matrix_user_links_user_id_idx').on(table.userId), - }) -); - // Passkeys table (WebAuthn credentials) export const passkeys = authSchema.table( 'passkeys', diff --git a/services/mana-auth/src/routes/auth.ts b/services/mana-auth/src/routes/auth.ts index 3fc81f549..4971167fe 100644 --- a/services/mana-auth/src/routes/auth.ts +++ b/services/mana-auth/src/routes/auth.ts @@ -109,46 +109,77 @@ export function createAuthRoutes( const ip = c.req.header('x-forwarded-for') || 'unknown'; try { - const response = await auth.api.signInEmail({ - body: { email: body.email, password: body.password }, - headers: c.req.raw.headers, - }); + // Sign in via Better Auth's HTTP handler so we get back a real + // Response with Set-Cookie. The auth.api.signInEmail() SDK call + // only returns the body and we'd lose the signed cookie envelope + // that /api/auth/token needs to validate the session — the cookie + // value is `.`, not just the raw session token, + // so reconstructing it from the API response doesn't work. + const signInResponse = await auth.handler( + new Request(new URL('/api/auth/sign-in/email', config.baseUrl), { + method: 'POST', + headers: new Headers({ + 'Content-Type': 'application/json', + // Forward original X-Forwarded-For so Better Auth's rate + // limiting and our security log see the right IP. + ...(c.req.header('x-forwarded-for') + ? { 'X-Forwarded-For': c.req.header('x-forwarded-for') as string } + : {}), + }), + body: JSON.stringify({ email: body.email, password: body.password }), + }) + ); + + if (!signInResponse.ok) { + // Better Auth returns 403 with FORBIDDEN for unverified emails. + if (signInResponse.status === 403) { + return c.json({ error: 'Email not verified', code: 'EMAIL_NOT_VERIFIED' }, 403); + } + security.logEvent({ + eventType: 'LOGIN_FAILURE', + ipAddress: ip, + metadata: { email: body.email }, + }); + lockout.recordAttempt(body.email, false, ip); + return c.json({ error: 'Invalid credentials' }, 401); + } + + const response = (await signInResponse.json()) as { + user?: { id: string }; + token?: string; + redirect?: boolean; + }; if (response?.user?.id) { security.logEvent({ userId: response.user.id, eventType: 'LOGIN_SUCCESS', ipAddress: ip }); lockout.clearAttempts(body.email); } - // signInEmail returns { token (session token), user, redirect } - // Use the session token to call Better Auth's JWT /token endpoint. - // - // In production Better Auth issues the session cookie with the - // __Secure- prefix (because secure: true is set), so we have to - // pass that exact cookie name back when forging the request to - // /api/auth/token. Without the prefix the get-session middleware - // can't find the session and the JWT mint silently fails — the - // route falls through and returns a response without accessToken. - const sessionToken = response?.token; - if (sessionToken) { - const cookieName = - config.nodeEnv === 'production' ? '__Secure-mana.session_token' : 'mana.session_token'; + // Capture the signed session cookie that Better Auth set on the + // sign-in response and forward it verbatim to /api/auth/token to + // mint a JWT. This is the only path that produces a cookie value + // with a valid HMAC signature. + const setCookie = signInResponse.headers.get('set-cookie'); + if (setCookie) { const tokenResponse = await auth.handler( new Request(new URL('/api/auth/token', config.baseUrl), { method: 'GET', - headers: new Headers({ cookie: `${cookieName}=${sessionToken}` }), + headers: new Headers({ cookie: setCookie }), }) ); if (tokenResponse.ok) { - const tokenData = await tokenResponse.json(); + const tokenData = (await tokenResponse.json()) as { token: string }; return c.json({ ...response, accessToken: tokenData.token, - refreshToken: sessionToken, + refreshToken: response.token, }); } } + // JWT mint failed (or no Set-Cookie came back). Still return the + // sign-in body so the client at least sees the user object. return c.json(response); } catch (error) { // Better Auth throws APIError with status="FORBIDDEN" for unverified emails. diff --git a/services/mana-matrix-bot/.gitignore b/services/mana-matrix-bot/.gitignore deleted file mode 100644 index 0e46a3f3d..000000000 --- a/services/mana-matrix-bot/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -dist/ -data/ -*.json.bak diff --git a/services/mana-matrix-bot/CLAUDE.md b/services/mana-matrix-bot/CLAUDE.md deleted file mode 100644 index de7829a46..000000000 --- a/services/mana-matrix-bot/CLAUDE.md +++ /dev/null @@ -1,67 +0,0 @@ -# mana-matrix-bot - -Consolidated Go Matrix bot replacing 21 separate NestJS bot services. - -## Architecture - -- **Language:** Go 1.23 -- **Matrix SDK:** mautrix-go -- **Port:** 4000 (health/metrics) -- **Pattern:** Plugin architecture with compile-time registration - -## Structure - -``` -cmd/server/main.go # Entry point, imports all plugins -internal/ - config/ # Env-based configuration - runtime/ # Plugin lifecycle, Matrix sync, event routing - matrix/ # Matrix client wrapper, markdown, media - plugin/ # Plugin interface, registry, command routing - session/ # In-memory + Redis session store - services/ # Backend HTTP client, voice (STT/TTS) - plugins/ # One directory per bot plugin - todo/ # @todo-bot - calendar/ # @calendar-bot - gateway/ # @mana-bot (composite: AI + todo + calendar + clock + voice) - ... -``` - -## Adding a New Plugin - -1. Create `internal/plugins/mybot/mybot.go` -2. Implement `plugin.Plugin` interface -3. Register via `func init() { plugin.Register("mybot", func() plugin.Plugin { return &MyBot{} }) }` -4. Import in `cmd/server/main.go`: `_ "github.com/mana/mana-matrix-bot/internal/plugins/mybot"` -5. Set env: `MATRIX_MYBOT_BOT_TOKEN=syt_xxx` - -## Commands - -```bash -# Build -go build -o dist/mana-matrix-bot ./cmd/server - -# Run -PORT=4000 MATRIX_HOMESERVER_URL=http://localhost:8008 MATRIX_TODO_BOT_TOKEN=xxx ./dist/mana-matrix-bot - -# Test -go test ./... - -# Docker -docker build -t mana-matrix-bot:local -f Dockerfile . -``` - -## Environment Variables - -### Global -- `PORT` — Health server port (default: 4000) -- `MATRIX_HOMESERVER_URL` — Matrix homeserver (default: http://localhost:8008) -- `MATRIX_STORAGE_PATH` — Sync state directory (default: ./data) -- `MANA_AUTH_URL` — Auth service URL -- `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD` — Redis for sessions -- `STT_URL`, `TTS_URL` — Voice services - -### Per Plugin (legacy env var names supported) -- `MATRIX_{NAME}_BOT_TOKEN` — Matrix access token -- `MATRIX_{NAME}_BOT_ROOMS` — Comma-separated allowed room IDs -- `{NAME}_BACKEND_URL` — Backend service URL diff --git a/services/mana-matrix-bot/Dockerfile b/services/mana-matrix-bot/Dockerfile deleted file mode 100644 index 1015a39c5..000000000 --- a/services/mana-matrix-bot/Dockerfile +++ /dev/null @@ -1,28 +0,0 @@ -# Build stage -FROM golang:1.25-alpine AS builder - -WORKDIR /app - -# Copy Go module files first for better caching -COPY services/mana-matrix-bot/go.mod services/mana-matrix-bot/go.sum ./ -RUN go mod download - -# Copy source -COPY services/mana-matrix-bot/ . -RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /mana-matrix-bot ./cmd/server - -# Runtime stage -FROM alpine:3.21 - -RUN apk --no-cache add ca-certificates tzdata - -COPY --from=builder /mana-matrix-bot /usr/local/bin/mana-matrix-bot - -VOLUME /app/data - -EXPOSE 4000 - -HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD wget -q --spider http://localhost:4000/health || exit 1 - -ENTRYPOINT ["mana-matrix-bot"] diff --git a/services/mana-matrix-bot/cmd/server/main.go b/services/mana-matrix-bot/cmd/server/main.go deleted file mode 100644 index d2a3e665a..000000000 --- a/services/mana-matrix-bot/cmd/server/main.go +++ /dev/null @@ -1,77 +0,0 @@ -package main - -import ( - "context" - "log/slog" - "os" - "os/signal" - "syscall" - "time" - - "github.com/mana/mana-matrix-bot/internal/config" - "github.com/mana/mana-matrix-bot/internal/runtime" - - // Import all plugins to trigger their init() registration. - _ "github.com/mana/mana-matrix-bot/internal/plugins/calendar" - _ "github.com/mana/mana-matrix-bot/internal/plugins/chat" - _ "github.com/mana/mana-matrix-bot/internal/plugins/clock" - _ "github.com/mana/mana-matrix-bot/internal/plugins/contacts" - _ "github.com/mana/mana-matrix-bot/internal/plugins/gateway" - _ "github.com/mana/mana-matrix-bot/internal/plugins/cards" - _ "github.com/mana/mana-matrix-bot/internal/plugins/nutriphi" - _ "github.com/mana/mana-matrix-bot/internal/plugins/ollama" - _ "github.com/mana/mana-matrix-bot/internal/plugins/onboarding" - _ "github.com/mana/mana-matrix-bot/internal/plugins/picture" - _ "github.com/mana/mana-matrix-bot/internal/plugins/planta" - _ "github.com/mana/mana-matrix-bot/internal/plugins/presi" - _ "github.com/mana/mana-matrix-bot/internal/plugins/projectdoc" - _ "github.com/mana/mana-matrix-bot/internal/plugins/questions" - _ "github.com/mana/mana-matrix-bot/internal/plugins/skilltree" - _ "github.com/mana/mana-matrix-bot/internal/plugins/stats" - _ "github.com/mana/mana-matrix-bot/internal/plugins/storage" - _ "github.com/mana/mana-matrix-bot/internal/plugins/stt" - _ "github.com/mana/mana-matrix-bot/internal/plugins/todo" - _ "github.com/mana/mana-matrix-bot/internal/plugins/tts" - _ "github.com/mana/mana-matrix-bot/internal/plugins/zitare" -) - -func main() { - // Structured JSON logging - slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ - Level: slog.LevelInfo, - }))) - - cfg := config.Load() - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - // Create and start runtime - rt := runtime.New(cfg) - - // Start health server - health := runtime.NewHealthServer(rt, cfg.Port) - httpServer := health.Start() - - // Start all plugins - if err := rt.Start(ctx); err != nil { - slog.Error("failed to start runtime", "error", err) - os.Exit(1) - } - - slog.Info("mana-matrix-bot running", "port", cfg.Port) - - // Wait for shutdown signal - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - <-sigCh - - slog.Info("shutting down...") - cancel() - rt.Stop() - - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) - defer shutdownCancel() - httpServer.Shutdown(shutdownCtx) - - slog.Info("shutdown complete") -} diff --git a/services/mana-matrix-bot/go.mod b/services/mana-matrix-bot/go.mod deleted file mode 100644 index 85bdb0617..000000000 --- a/services/mana-matrix-bot/go.mod +++ /dev/null @@ -1,28 +0,0 @@ -module github.com/mana/mana-matrix-bot - -go 1.25.0 - -require ( - github.com/redis/go-redis/v9 v9.18.0 - maunium.net/go/mautrix v0.26.4 -) - -require ( - filippo.io/edwards25519 v1.2.0 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/rs/zerolog v1.34.0 // indirect - github.com/tidwall/gjson v1.18.0 // indirect - github.com/tidwall/match v1.1.1 // indirect - github.com/tidwall/pretty v1.2.1 // indirect - github.com/tidwall/sjson v1.2.5 // indirect - go.mau.fi/util v0.9.7 // indirect - go.uber.org/atomic v1.11.0 // indirect - golang.org/x/crypto v0.49.0 // indirect - golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect - golang.org/x/net v0.52.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.35.0 // indirect -) diff --git a/services/mana-matrix-bot/go.sum b/services/mana-matrix-bot/go.sum deleted file mode 100644 index f0a10f863..000000000 --- a/services/mana-matrix-bot/go.sum +++ /dev/null @@ -1,66 +0,0 @@ -filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= -filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= -github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= -github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= -github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= -github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= -github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= -github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= -github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= -github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= -github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= -github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= -github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= -github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= -github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= -go.mau.fi/util v0.9.7 h1:AWGNbJfz1zRcQOKeOEYhKUG2fT+/26Gy6kyqcH8tnBg= -go.mau.fi/util v0.9.7/go.mod h1:5T2f3ZWZFAGgmFwg3dGw7YK6kIsb9lryDzvynoR98pE= -go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= -go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= -golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -maunium.net/go/mautrix v0.26.4 h1:enHSnkf0L2V9+VnfJfNhKSReSW6pBKS/x3Su+v+Vovs= -maunium.net/go/mautrix v0.26.4/go.mod h1:YWw8NWTszsbyFAznboicBObwHPgTSLcuTbVX2kY7U2M= diff --git a/services/mana-matrix-bot/internal/config/config.go b/services/mana-matrix-bot/internal/config/config.go deleted file mode 100644 index 8bab865ba..000000000 --- a/services/mana-matrix-bot/internal/config/config.go +++ /dev/null @@ -1,219 +0,0 @@ -package config - -import ( - "os" - "strconv" - "strings" -) - -// Config holds all configuration for the consolidated Matrix bot. -type Config struct { - // Server - Port int - - // Matrix - HomeserverURL string - StoragePath string - - // Auth - AuthURL string - ServiceKey string - - // Redis - RedisHost string - RedisPort int - RedisPassword string - - // Voice services - STTURL string - TTSURL string - - // Plugins (keyed by plugin name) - Plugins map[string]PluginConfig -} - -// PluginConfig holds per-plugin configuration. -type PluginConfig struct { - Enabled bool - AccessToken string - AllowedRooms []string - BackendURL string - Extra map[string]string -} - -// Load reads configuration from environment variables. -func Load() *Config { - port, _ := strconv.Atoi(getEnv("PORT", "4000")) - redisPort, _ := strconv.Atoi(getEnv("REDIS_PORT", "6379")) - - cfg := &Config{ - Port: port, - HomeserverURL: getEnv("MATRIX_HOMESERVER_URL", "http://localhost:8008"), - StoragePath: getEnv("MATRIX_STORAGE_PATH", "./data"), - AuthURL: getEnv("MANA_AUTH_URL", "http://localhost:3001"), - ServiceKey: getEnv("MANA_SERVICE_KEY", ""), - RedisHost: getEnv("REDIS_HOST", "localhost"), - RedisPort: redisPort, - RedisPassword: getEnv("REDIS_PASSWORD", ""), - STTURL: getEnv("STT_URL", "http://localhost:3020"), - TTSURL: getEnv("TTS_URL", "http://localhost:3022"), - Plugins: make(map[string]PluginConfig), - } - - // Load plugin configs from environment variables. - // Pattern: PLUGIN_{NAME}_ENABLED, PLUGIN_{NAME}_ACCESS_TOKEN, etc. - // Also supports legacy patterns: MATRIX_{NAME}_BOT_TOKEN - pluginNames := []string{ - "gateway", "todo", "calendar", "clock", "ollama", "stats", - "contacts", "chat", "cards", "nutriphi", "picture", "planta", - "presi", "questions", "skilltree", "storage", "projectdoc", - "stt", "tts", "zitare", "onboarding", - } - - // Map of legacy token env var names - legacyTokenMap := map[string]string{ - "gateway": "MATRIX_MANA_BOT_TOKEN", - "todo": "MATRIX_TODO_BOT_TOKEN", - "calendar": "MATRIX_CALENDAR_BOT_TOKEN", - "clock": "MATRIX_CLOCK_BOT_TOKEN", - "ollama": "MATRIX_OLLAMA_BOT_TOKEN", - "stats": "MATRIX_STATS_BOT_TOKEN", - "contacts": "MATRIX_CONTACTS_BOT_TOKEN", - "chat": "MATRIX_CHAT_BOT_TOKEN", - "cards": "MATRIX_CARDS_BOT_TOKEN", - "nutriphi": "MATRIX_NUTRIPHI_BOT_TOKEN", - "picture": "MATRIX_PICTURE_BOT_TOKEN", - "planta": "MATRIX_PLANTA_BOT_TOKEN", - "presi": "MATRIX_PRESI_BOT_TOKEN", - "questions": "MATRIX_QUESTIONS_BOT_TOKEN", - "skilltree": "MATRIX_SKILLTREE_BOT_TOKEN", - "storage": "MATRIX_STORAGE_BOT_TOKEN", - "projectdoc": "MATRIX_PROJECT_DOC_BOT_TOKEN", - "stt": "MATRIX_STT_BOT_TOKEN", - "tts": "MATRIX_TTS_BOT_TOKEN", - "zitare": "MATRIX_ZITARE_BOT_TOKEN", - "onboarding": "MATRIX_ONBOARDING_BOT_TOKEN", - } - - legacyRoomsMap := map[string]string{ - "gateway": "MATRIX_MANA_BOT_ROOMS", - "todo": "MATRIX_TODO_BOT_ROOMS", - "calendar": "MATRIX_CALENDAR_BOT_ROOMS", - "clock": "MATRIX_CLOCK_BOT_ROOMS", - "ollama": "MATRIX_OLLAMA_BOT_ROOMS", - "stats": "MATRIX_STATS_BOT_ROOMS", - "contacts": "MATRIX_CONTACTS_BOT_ROOMS", - "chat": "MATRIX_CHAT_BOT_ROOMS", - "cards": "MATRIX_CARDS_BOT_ROOMS", - "nutriphi": "MATRIX_NUTRIPHI_BOT_ROOMS", - "picture": "MATRIX_PICTURE_BOT_ROOMS", - "planta": "MATRIX_PLANTA_BOT_ROOMS", - "presi": "MATRIX_PRESI_BOT_ROOMS", - "questions": "MATRIX_QUESTIONS_BOT_ROOMS", - "skilltree": "MATRIX_SKILLTREE_BOT_ROOMS", - "storage": "MATRIX_STORAGE_BOT_ROOMS", - "projectdoc": "MATRIX_PROJECT_DOC_BOT_ROOMS", - "stt": "MATRIX_STT_BOT_ROOMS", - "tts": "MATRIX_TTS_BOT_ROOMS", - "zitare": "MATRIX_ZITARE_BOT_ROOMS", - "onboarding": "MATRIX_ONBOARDING_BOT_ROOMS", - } - - // Backend URL defaults per plugin - backendURLMap := map[string]string{ - "todo": "TODO_BACKEND_URL", - "calendar": "CALENDAR_BACKEND_URL", - "clock": "CLOCK_BACKEND_URL", - "contacts": "CONTACTS_BACKEND_URL", - "chat": "CHAT_BACKEND_URL", - "cards": "CARDS_BACKEND_URL", - "nutriphi": "NUTRIPHI_BACKEND_URL", - "picture": "PICTURE_BACKEND_URL", - "planta": "PLANTA_BACKEND_URL", - "presi": "PRESI_BACKEND_URL", - "questions": "QUESTIONS_BACKEND_URL", - "skilltree": "SKILLTREE_BACKEND_URL", - "storage": "STORAGE_BACKEND_URL", - "projectdoc": "PROJECTDOC_BACKEND_URL", - "zitare": "ZITARE_BACKEND_URL", - } - - for _, name := range pluginNames { - upper := strings.ToUpper(name) - - // Access token: try PLUGIN_*_ACCESS_TOKEN first, then legacy - token := os.Getenv("PLUGIN_" + upper + "_ACCESS_TOKEN") - if token == "" { - if legacyEnv, ok := legacyTokenMap[name]; ok { - token = os.Getenv(legacyEnv) - } - } - - // Enabled: explicit env or auto-detect from token presence - enabledStr := os.Getenv("PLUGIN_" + upper + "_ENABLED") - enabled := token != "" - if enabledStr != "" { - enabled = enabledStr == "true" || enabledStr == "1" - } - - // Allowed rooms - var rooms []string - roomsStr := os.Getenv("PLUGIN_" + upper + "_ALLOWED_ROOMS") - if roomsStr == "" { - if legacyEnv, ok := legacyRoomsMap[name]; ok { - roomsStr = os.Getenv(legacyEnv) - } - } - if roomsStr != "" { - for _, r := range strings.Split(roomsStr, ",") { - r = strings.TrimSpace(r) - if r != "" { - rooms = append(rooms, r) - } - } - } - - // Backend URL - backendURL := "" - if envName, ok := backendURLMap[name]; ok { - backendURL = os.Getenv(envName) - } - - // Extra config (plugin-specific env vars) - extra := make(map[string]string) - // Ollama-specific - if name == "ollama" || name == "gateway" { - extra["ollama_url"] = getEnv("OLLAMA_URL", "http://localhost:11434") - extra["ollama_model"] = getEnv("OLLAMA_MODEL", "gemma3:4b") - } - if name == "stt" || name == "gateway" { - extra["stt_url"] = cfg.STTURL - } - if name == "tts" || name == "gateway" { - extra["tts_url"] = cfg.TTSURL - } - // Gateway needs backend URLs for sub-handlers - if name == "gateway" { - extra["todo_url"] = getEnv("TODO_BACKEND_URL", "") - extra["calendar_url"] = getEnv("CALENDAR_BACKEND_URL", "") - extra["clock_url"] = getEnv("CLOCK_BACKEND_URL", "") - } - - cfg.Plugins[name] = PluginConfig{ - Enabled: enabled, - AccessToken: token, - AllowedRooms: rooms, - BackendURL: backendURL, - Extra: extra, - } - } - - return cfg -} - -func getEnv(key, fallback string) string { - if v := os.Getenv(key); v != "" { - return v - } - return fallback -} diff --git a/services/mana-matrix-bot/internal/matrix/client.go b/services/mana-matrix-bot/internal/matrix/client.go deleted file mode 100644 index 78c157792..000000000 --- a/services/mana-matrix-bot/internal/matrix/client.go +++ /dev/null @@ -1,241 +0,0 @@ -package matrix - -import ( - "context" - "fmt" - "io" - "log/slog" - "net/http" - "os" - "path/filepath" - "regexp" - "time" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -var mxcRegex = regexp.MustCompile(`^mxc://([^/]+)/(.+)$`) - -// Client wraps mautrix.Client and implements the plugin.MatrixClient interface. -type Client struct { - inner *mautrix.Client - homeserver string - accessToken string - storagePath string - logger *slog.Logger -} - -// ClientConfig holds configuration for creating a Matrix client. -type ClientConfig struct { - HomeserverURL string - AccessToken string - StoragePath string // path for sync state file - PluginName string -} - -// NewClient creates a new Matrix client wrapper. -func NewClient(cfg ClientConfig) (*Client, error) { - userID := id.UserID("") // will be resolved via whoami - - client, err := mautrix.NewClient(cfg.HomeserverURL, userID, cfg.AccessToken) - if err != nil { - return nil, fmt.Errorf("create mautrix client: %w", err) - } - - // Ensure storage directory exists - if cfg.StoragePath != "" { - dir := filepath.Dir(cfg.StoragePath) - if err := os.MkdirAll(dir, 0o755); err != nil { - return nil, fmt.Errorf("create storage dir: %w", err) - } - } - - logger := slog.With("plugin", cfg.PluginName) - - return &Client{ - inner: client, - homeserver: cfg.HomeserverURL, - accessToken: cfg.AccessToken, - storagePath: cfg.StoragePath, - logger: logger, - }, nil -} - -// Inner returns the underlying mautrix.Client for advanced operations. -func (c *Client) Inner() *mautrix.Client { - return c.inner -} - -// Login resolves the bot's user ID via /whoami. -func (c *Client) Login(ctx context.Context) (id.UserID, error) { - resp, err := c.inner.Whoami(ctx) - if err != nil { - return "", fmt.Errorf("whoami: %w", err) - } - c.inner.UserID = resp.UserID - c.logger.Info("authenticated", "user_id", resp.UserID) - return resp.UserID, nil -} - -// GetUserID returns the bot's Matrix user ID. -func (c *Client) GetUserID() string { - return c.inner.UserID.String() -} - -// SendMessage sends a text message with markdown formatting to a room. -func (c *Client) SendMessage(ctx context.Context, roomID string, text string) (string, error) { - content := &event.MessageEventContent{ - MsgType: event.MsgText, - Body: text, - Format: event.FormatHTML, - FormattedBody: MarkdownToHTML(text), - } - resp, err := c.inner.SendMessageEvent(ctx, id.RoomID(roomID), event.EventMessage, content) - if err != nil { - return "", err - } - return resp.EventID.String(), nil -} - -// SendReply sends a reply to a specific event. -func (c *Client) SendReply(ctx context.Context, roomID string, eventID string, text string) (string, error) { - content := &event.MessageEventContent{ - MsgType: event.MsgText, - Body: text, - Format: event.FormatHTML, - FormattedBody: MarkdownToHTML(text), - } - content.SetReply(&event.Event{ - RoomID: id.RoomID(roomID), - ID: id.EventID(eventID), - }) - resp, err := c.inner.SendMessageEvent(ctx, id.RoomID(roomID), event.EventMessage, content) - if err != nil { - return "", err - } - return resp.EventID.String(), nil -} - -// SendNotice sends a notice (non-highlighted message). -func (c *Client) SendNotice(ctx context.Context, roomID string, text string) (string, error) { - content := &event.MessageEventContent{ - MsgType: event.MsgNotice, - Body: text, - Format: event.FormatHTML, - FormattedBody: MarkdownToHTML(text), - } - resp, err := c.inner.SendMessageEvent(ctx, id.RoomID(roomID), event.EventMessage, content) - if err != nil { - return "", err - } - return resp.EventID.String(), nil -} - -// EditMessage edits an existing message. -func (c *Client) EditMessage(ctx context.Context, roomID string, eventID string, text string) (string, error) { - content := map[string]any{ - "msgtype": "m.text", - "body": "* " + text, - "format": "org.matrix.custom.html", - "formatted_body": "* " + MarkdownToHTML(text), - "m.relates_to": map[string]any{ - "rel_type": "m.replace", - "event_id": eventID, - }, - "m.new_content": map[string]any{ - "msgtype": "m.text", - "body": text, - "format": "org.matrix.custom.html", - "formatted_body": MarkdownToHTML(text), - }, - } - resp, err := c.inner.SendMessageEvent(ctx, id.RoomID(roomID), event.EventMessage, content) - if err != nil { - return "", err - } - return resp.EventID.String(), nil -} - -// SetTyping sets the typing indicator for the bot in a room. -func (c *Client) SetTyping(ctx context.Context, roomID string, typing bool) error { - timeout := time.Duration(0) - if typing { - timeout = 30 * time.Second - } - _, err := c.inner.UserTyping(ctx, id.RoomID(roomID), typing, timeout) - return err -} - -// DownloadMedia downloads media from a mxc:// URL. -func (c *Client) DownloadMedia(ctx context.Context, mxcURL string) ([]byte, error) { - matches := mxcRegex.FindStringSubmatch(mxcURL) - if len(matches) != 3 { - return nil, fmt.Errorf("invalid mxc URL: %s", mxcURL) - } - - serverName := matches[1] - mediaID := matches[2] - - // Try authenticated media API (Matrix spec v1.11+) - url := fmt.Sprintf("%s/_matrix/client/v1/media/download/%s/%s", c.homeserver, serverName, mediaID) - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", "Bearer "+c.accessToken) - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, fmt.Errorf("download media: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - // Fallback to legacy API - url = fmt.Sprintf("%s/_matrix/media/v3/download/%s/%s", c.homeserver, serverName, mediaID) - req2, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return nil, err - } - resp2, err := http.DefaultClient.Do(req2) - if err != nil { - return nil, fmt.Errorf("download media (legacy): %w", err) - } - defer resp2.Body.Close() - if resp2.StatusCode != http.StatusOK { - return nil, fmt.Errorf("download media failed: %d", resp2.StatusCode) - } - return io.ReadAll(resp2.Body) - } - - return io.ReadAll(resp.Body) -} - -// UploadMedia uploads media and returns the mxc:// URL. -func (c *Client) UploadMedia(ctx context.Context, data []byte, contentType string, filename string) (string, error) { - resp, err := c.inner.UploadBytes(ctx, data, contentType) - if err != nil { - return "", fmt.Errorf("upload media: %w", err) - } - return resp.ContentURI.String(), nil -} - -// SendAudio sends an audio message to a room. -func (c *Client) SendAudio(ctx context.Context, roomID string, mxcURL string, filename string, size int) (string, error) { - content := &event.MessageEventContent{ - MsgType: event.MsgAudio, - Body: filename, - URL: id.ContentURIString(mxcURL), - Info: &event.FileInfo{ - MimeType: "audio/mpeg", - Size: size, - }, - } - resp, err := c.inner.SendMessageEvent(ctx, id.RoomID(roomID), event.EventMessage, content) - if err != nil { - return "", err - } - return resp.EventID.String(), nil -} diff --git a/services/mana-matrix-bot/internal/matrix/markdown.go b/services/mana-matrix-bot/internal/matrix/markdown.go deleted file mode 100644 index de5b06c7c..000000000 --- a/services/mana-matrix-bot/internal/matrix/markdown.go +++ /dev/null @@ -1,63 +0,0 @@ -package matrix - -import ( - "fmt" - "regexp" - "strings" -) - -var ( - reBold = regexp.MustCompile(`\*\*(.+?)\*\*`) - reItalic = regexp.MustCompile(`\*(.+?)\*`) - reStrikethrough = regexp.MustCompile(`~~(.+?)~~`) - reCode = regexp.MustCompile("`(.+?)`") -) - -// MarkdownToHTML converts simple markdown to HTML for Matrix messages. -// Matches the exact behavior of the TypeScript markdownToHtml function. -func MarkdownToHTML(text string) string { - result := text - result = reBold.ReplaceAllString(result, "$1") - result = reItalic.ReplaceAllString(result, "$1") - result = reStrikethrough.ReplaceAllString(result, "$1") - result = reCode.ReplaceAllString(result, "$1") - result = strings.ReplaceAll(result, "\n", "
    ") - return result -} - -// EscapeHTML escapes HTML special characters. -func EscapeHTML(text string) string { - r := strings.NewReplacer( - "&", "&", - "<", "<", - ">", ">", - `"`, """, - "'", "'", - ) - return r.Replace(text) -} - -// FormatNumberedList formats items as a numbered markdown list. -func FormatNumberedList[T any](items []T, formatter func(T, int) string) string { - var sb strings.Builder - for i, item := range items { - if i > 0 { - sb.WriteByte('\n') - } - sb.WriteString(fmt.Sprintf("%d. %s", i+1, formatter(item, i))) - } - return sb.String() -} - -// FormatBulletList formats items as a bullet markdown list. -func FormatBulletList[T any](items []T, formatter func(T) string) string { - var sb strings.Builder - for i, item := range items { - if i > 0 { - sb.WriteByte('\n') - } - sb.WriteString("• ") - sb.WriteString(formatter(item)) - } - return sb.String() -} diff --git a/services/mana-matrix-bot/internal/matrix/markdown_test.go b/services/mana-matrix-bot/internal/matrix/markdown_test.go deleted file mode 100644 index fd4ed647c..000000000 --- a/services/mana-matrix-bot/internal/matrix/markdown_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package matrix - -import "testing" - -func TestMarkdownToHTML(t *testing.T) { - tests := []struct { - input string - want string - }{ - {"**bold**", "bold"}, - {"*italic*", "italic"}, - {"~~strike~~", "strike"}, - {"`code`", "code"}, - {"line1\nline2", "line1
    line2"}, - {"**bold** and *italic*", "bold and italic"}, - {"plain text", "plain text"}, - {"", ""}, - } - - for _, tt := range tests { - got := MarkdownToHTML(tt.input) - if got != tt.want { - t.Errorf("MarkdownToHTML(%q) = %q, want %q", tt.input, got, tt.want) - } - } -} - -func TestEscapeHTML(t *testing.T) { - tests := []struct { - input string - want string - }{ - {"