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 b825c132e..000000000 Binary files a/apps/matrix/apps/mobile/assets/adaptive-icon.png and /dev/null differ diff --git a/apps/matrix/apps/mobile/assets/favicon.png b/apps/matrix/apps/mobile/assets/favicon.png deleted file mode 100644 index c352c323c..000000000 Binary files a/apps/matrix/apps/mobile/assets/favicon.png and /dev/null differ diff --git a/apps/matrix/apps/mobile/assets/icon.png b/apps/matrix/apps/mobile/assets/icon.png deleted file mode 100644 index b825c132e..000000000 Binary files a/apps/matrix/apps/mobile/assets/icon.png and /dev/null differ diff --git a/apps/matrix/apps/mobile/assets/notification-icon.png b/apps/matrix/apps/mobile/assets/notification-icon.png deleted file mode 100644 index dec4ef1d0..000000000 Binary files a/apps/matrix/apps/mobile/assets/notification-icon.png and /dev/null differ diff --git a/apps/matrix/apps/mobile/assets/splash.png b/apps/matrix/apps/mobile/assets/splash.png deleted file mode 100644 index b825c132e..000000000 Binary files a/apps/matrix/apps/mobile/assets/splash.png and /dev/null differ diff --git a/apps/matrix/apps/mobile/babel.config.js b/apps/matrix/apps/mobile/babel.config.js deleted file mode 100644 index d830ae815..000000000 --- a/apps/matrix/apps/mobile/babel.config.js +++ /dev/null @@ -1,7 +0,0 @@ -module.exports = function (api) { - api.cache(true); - return { - presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'], - plugins: ['react-native-reanimated/plugin'], - }; -}; diff --git a/apps/matrix/apps/mobile/eas-build-pre-install.sh b/apps/matrix/apps/mobile/eas-build-pre-install.sh deleted file mode 100755 index 6a507a497..000000000 --- a/apps/matrix/apps/mobile/eas-build-pre-install.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash -# EAS Build pre-install hook -# Creates .npmrc with node-linker=hoisted at the monorepo root -# so pnpm uses a flat node_modules structure compatible with -# React Native / Metro bundler module resolution. - -MONOREPO_ROOT="$EAS_BUILD_WORKINGDIR" -if [ -z "$MONOREPO_ROOT" ]; then - MONOREPO_ROOT=$(cd "$(dirname "$0")/../../../.." && pwd) -fi - -echo "node-linker=hoisted" > "$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% - - -
    %sveltekit.body%
    - - 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 a56722250..000000000 Binary files a/apps/matrix/apps/web/static/apple-touch-icon.png and /dev/null differ diff --git a/apps/matrix/apps/web/static/favicon.png b/apps/matrix/apps/web/static/favicon.png deleted file mode 100644 index c352c323c..000000000 Binary files a/apps/matrix/apps/web/static/favicon.png and /dev/null differ diff --git a/apps/matrix/apps/web/static/favicon.svg b/apps/matrix/apps/web/static/favicon.svg deleted file mode 100644 index 07d21b48e..000000000 --- a/apps/matrix/apps/web/static/favicon.svg +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 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 e69962d63..000000000 Binary files a/apps/matrix/apps/web/static/pwa-192x192.png and /dev/null differ 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 1d596ae7c..000000000 Binary files a/apps/matrix/apps/web/static/pwa-512x512.png and /dev/null differ 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 - }{ - {"