fix(mana-auth): /api/v1/auth/login mints JWT via auth.handler instead of api.signInEmail

Previous attempt (commit 55cc75e7d) tried to fix the broken JWT mint
in /api/v1/auth/login by switching the cookie name from
`mana.session_token` to `__Secure-mana.session_token` for production.
That was necessary but not sufficient: Better Auth's session cookie
value isn't just the raw session token, it's `<token>.<HMAC>` where
the HMAC is derived from the better-auth secret. Reconstructing the
cookie from auth.api.signInEmail's JSON response only gave us the raw
token, so /api/auth/token's get-session middleware still couldn't
validate it and the JWT mint kept silently failing.

Real fix: do the sign-in via auth.handler (the HTTP path) rather than
auth.api.signInEmail (the SDK path). The handler returns a real fetch
Response with a Set-Cookie header containing the fully signed cookie
envelope. We capture that header verbatim and forward it as the cookie
on the /api/auth/token request, which now passes validation and mints
the JWT correctly.

Verified end-to-end on auth.mana.how:

  $ curl -X POST https://auth.mana.how/api/v1/auth/login \
      -d '{"email":"...","password":"..."}'
  {
    "user": {...},
    "token": "<session token>",
    "accessToken": "eyJhbGciOiJFZERTQSI...",   ← real JWT now
    "refreshToken": "<session token>"
  }

Side benefits:
- The email-not-verified path is now handled by checking
  signInResponse.status === 403 directly, no more catching APIError
  with the comment-noted async-stream footgun.
- X-Forwarded-For is forwarded explicitly so Better Auth's rate limiter
  and our security log see the real client IP.
- The leftover catch block now only handles unexpected exceptions
  (network errors etc); the FORBIDDEN-checking logic in it is dead but
  harmless and left in for defense in depth.
This commit is contained in:
Till JS 2026-04-08 16:25:55 +02:00
parent 4eb5dfe4a0
commit bfa8a0a773
254 changed files with 88 additions and 29437 deletions

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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
</a>
</li>
<li>
<a href="https://element.mana.how" class="footer-link" target="_blank" rel="noopener">
Matrix Chat
</a>
</li>
</ul>
</div>
</div>

View file

@ -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' },
],
},
];
---

View file

@ -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;

View file

@ -163,7 +163,6 @@ export const APP_POSITIONS: Record<string, { x: number; y: number; lakeId: strin
planta: { x: 1115, y: 675, lakeId: 'db-right' },
// Around Bergsee (redis) - lightweight/cache
matrix: { x: 260, y: 375, lakeId: 'redis' },
traces: { x: 400, y: 385, lakeId: 'redis' },
// Around Stausee (minio) - storage-heavy

View file

@ -169,22 +169,6 @@ const APP_DEFINITIONS: AppDefinition[] = [
ux: 60,
},
},
{
id: 'matrix',
displayName: 'Matrix',
score: 68,
status: 'production',
categories: {
backend: 10,
frontend: 78,
database: 20,
testing: 12,
deployment: 92,
documentation: 92,
security: 88,
ux: 82,
},
},
{
id: 'nutriphi',
displayName: 'NutriPhi',

View file

@ -42,7 +42,6 @@
mana: 'https://mana.how',
cards: 'https://cards.mana.how',
planta: 'https://planta.mana.how',
matrix: 'https://element.mana.how',
playground: 'https://playground.mana.how',
};

View file

@ -46,7 +46,6 @@ function registerLocale(lang: SupportedLocale) {
citycorners,
calc,
questions,
matrix,
guides,
help,
cycles,
@ -81,7 +80,6 @@ function registerLocale(lang: SupportedLocale) {
import(`./locales/citycorners/${lang}.json`),
import(`./locales/calc/${lang}.json`),
import(`./locales/questions/${lang}.json`),
import(`./locales/matrix/${lang}.json`),
import(`./locales/guides/${lang}.json`),
import(`./locales/help/${lang}.json`),
import(`./locales/cycles/${lang}.json`),
@ -118,7 +116,6 @@ function registerLocale(lang: SupportedLocale) {
citycorners: citycorners.default,
calc: calc.default,
questions: questions.default,
matrix: matrix.default,
guides: guides.default,
help: help.default,
cycles: cycles.default,

View file

@ -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"
}
}
}

View file

@ -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"
}
}
}

View file

@ -1,46 +0,0 @@
{
"app": {
"name": "Manalink",
"description": "Chat Matrix seguro"
},
"nav": {
"chat": "Chat",
"bots": "Bots",
"settings": "Ajustes"
},
"auth": {
"login": "Iniciar sesión",
"logout": "Cerrar sesión",
"connecting": "Conectando con Matrix...",
"connectionFailed": "Conexión fallida",
"retry": "Reintentar"
},
"chat": {
"newChat": "Nuevo chat",
"createRoom": "Crear sala",
"sendMessage": "Enviar mensaje",
"typeMessage": "Escribe un mensaje...",
"noRooms": "Aún no hay salas",
"noMessages": "Aún no hay mensajes"
},
"bots": {
"title": "Bots",
"subtitle": "Descubre todos los asistentes bot disponibles",
"search": "Buscar bots...",
"startChat": "Iniciar chat",
"commands": "Comandos",
"example": "Ejemplo",
"free": "Gratis",
"requiresLogin": "Requiere inicio de sesión",
"noResults": "No se encontraron bots",
"found": "encontrados",
"categories": {
"all": "Todos",
"productivity": "Productividad",
"ai": "IA y Chat",
"media": "Medios",
"lifestyle": "Estilo de vida",
"tools": "Herramientas"
}
}
}

View file

@ -1,46 +0,0 @@
{
"app": {
"name": "Manalink",
"description": "Chat Matrix sécurisé"
},
"nav": {
"chat": "Chat",
"bots": "Bots",
"settings": "Paramètres"
},
"auth": {
"login": "Se connecter",
"logout": "Se déconnecter",
"connecting": "Connexion à Matrix...",
"connectionFailed": "Connexion échouée",
"retry": "Réessayer"
},
"chat": {
"newChat": "Nouveau chat",
"createRoom": "Créer un salon",
"sendMessage": "Envoyer un message",
"typeMessage": "Écrire un message...",
"noRooms": "Pas encore de salons",
"noMessages": "Pas encore de messages"
},
"bots": {
"title": "Bots",
"subtitle": "Découvrez tous les assistants bot disponibles",
"search": "Rechercher des bots...",
"startChat": "Démarrer le chat",
"commands": "Commandes",
"example": "Exemple",
"free": "Gratuit",
"requiresLogin": "Connexion requise",
"noResults": "Aucun bot trouvé",
"found": "trouvés",
"categories": {
"all": "Tous",
"productivity": "Productivité",
"ai": "IA et Chat",
"media": "Médias",
"lifestyle": "Style de vie",
"tools": "Outils"
}
}
}

View file

@ -1,46 +0,0 @@
{
"app": {
"name": "Manalink",
"description": "Chat Matrix sicura"
},
"nav": {
"chat": "Chat",
"bots": "Bot",
"settings": "Impostazioni"
},
"auth": {
"login": "Accedi",
"logout": "Esci",
"connecting": "Connessione a Matrix...",
"connectionFailed": "Connessione fallita",
"retry": "Riprova"
},
"chat": {
"newChat": "Nuova chat",
"createRoom": "Crea stanza",
"sendMessage": "Invia messaggio",
"typeMessage": "Scrivi un messaggio...",
"noRooms": "Nessuna stanza ancora",
"noMessages": "Nessun messaggio ancora"
},
"bots": {
"title": "Bot",
"subtitle": "Scopri tutti gli assistenti bot disponibili",
"search": "Cerca bot...",
"startChat": "Avvia chat",
"commands": "Comandi",
"example": "Esempio",
"free": "Gratuito",
"requiresLogin": "Richiede l'accesso",
"noResults": "Nessun bot trovato",
"found": "trovati",
"categories": {
"all": "Tutti",
"productivity": "Produttività",
"ai": "IA e Chat",
"media": "Media",
"lifestyle": "Stile di vita",
"tools": "Strumenti"
}
}
}

View file

@ -67,7 +67,6 @@
context: 'Context',
general: 'Allgemein',
cards: 'Cards',
matrix: 'Matrix Bots',
nutriphi: 'NutriPhi',
picture: 'Picture',
planta: 'Planta',

View file

@ -1,259 +0,0 @@
# Manalink
Secure Matrix messaging client - a bridge to decentralized communication.
**Production URL:** https://link.mana.how
## Project Overview
Manalink is a privacy-focused Matrix client built with SvelteKit. It connects to Matrix homeservers (default: matrix.mana.how) and supports PWA installation for mobile devices.
### Tech Stack
| Layer | Technology |
|-------|------------|
| Frontend | SvelteKit 2, Svelte 5 (runes), Tailwind CSS 4 |
| Matrix SDK | matrix-js-sdk + matrix-sdk-crypto-wasm |
| State Management | Svelte 5 runes ($state, $derived) |
| Icons | @mana/shared-icons (Phosphor) |
| PWA | @vite-pwa/sveltekit + Workbox |
| Date Handling | date-fns |
## Project Structure
```
apps/matrix/
├── apps/
│ └── web/ # SvelteKit web client (PWA)
│ ├── src/
│ │ ├── routes/
│ │ │ ├── (auth)/ # Login flow
│ │ │ ├── (app)/ # Protected chat routes
│ │ │ └── health/ # Health check endpoint
│ │ └── lib/
│ │ ├── matrix/ # Matrix SDK integration
│ │ │ ├── store.svelte.ts # Reactive Matrix store
│ │ │ ├── client.ts # Login/auth functions
│ │ │ ├── crypto.ts # E2EE utilities
│ │ │ ├── types.ts # TypeScript types
│ │ │ └── polyfills.ts # Browser polyfills
│ │ └── components/
│ │ ├── chat/ # Chat UI components
│ │ ├── call/ # VoIP call components
│ │ └── crypto/ # E2EE verification UI
│ ├── static/ # PWA icons and assets
│ ├── scripts/
│ │ └── generate-icons.mjs # Icon generation script
│ └── package.json
└── packages/
└── shared/ # Shared types
```
## Development
```bash
# Start the web client
pnpm dev:matrix:web
# Or from monorepo root
pnpm matrix:dev
# Generate PWA icons (after changing favicon.svg)
cd apps/matrix/apps/web && node scripts/generate-icons.mjs
```
The client runs on **http://localhost:5180**
## PWA Features
Manalink is a Progressive Web App with:
- **Installable** on iOS/Android homescreen
- **Offline support** via Service Worker caching
- **Push notifications** (Web Push API)
- **App shortcuts** for quick actions
### Caching Strategy
| Content | Strategy | TTL |
|---------|----------|-----|
| Matrix API | NetworkFirst | 5 min |
| Images/Avatars | CacheFirst | 30 days |
| Fonts | CacheFirst | 1 year |
| App Shell | StaleWhileRevalidate | - |
### Installation
1. Open https://[your-domain] in a mobile browser
2. Tap "Add to Home Screen" (iOS) or install prompt (Android/Chrome)
3. Launch from homescreen for fullscreen app experience
## Key Files
### Matrix Store (`src/lib/matrix/store.svelte.ts`)
Central reactive store using Svelte 5 runes:
```typescript
import { matrixStore } from '$lib/matrix';
// State
matrixStore.syncState // 'STOPPED' | 'PREPARED' | 'SYNCING' | etc.
matrixStore.isReady // boolean - client ready for use
matrixStore.rooms // SimpleRoom[] - all rooms
matrixStore.messages // SimpleMessage[] - current room messages
matrixStore.currentRoom // Room | null - selected room
// Actions
await matrixStore.initialize(credentials);
matrixStore.selectRoom(roomId);
await matrixStore.sendMessage('Hello!');
await matrixStore.sendTyping(true);
matrixStore.logout();
```
### Login Client (`src/lib/matrix/client.ts`)
```typescript
import { loginWithPassword, checkHomeserver } from '$lib/matrix';
const result = await loginWithPassword('matrix.mana.how', 'user', 'password');
if (result.success) {
await matrixStore.initialize(result.credentials);
}
```
## Features
### Phase 1 (Current)
- [x] Password login + SSO (Mana Core)
- [x] Room list (DMs and groups)
- [x] Message timeline with pagination
- [x] Send text messages
- [x] Typing indicators
- [x] Read receipts
- [x] Unread/highlight counts
- [x] Room creation
- [x] Room settings
- [x] Message search
- [x] PWA support
### Phase 2 (In Progress)
- [ ] End-to-end encryption (E2EE)
- [ ] File/image uploads
- [ ] Message editing/deletion
- [ ] User search/invite
- [ ] Message reactions
### Phase 3 (Future)
- [ ] VoIP calls (WebRTC)
- [ ] Video calls
- [ ] Screen sharing
- [ ] Capacitor native wrapper
## Configuration
### Environment Variables
No environment variables required for basic usage. The client stores credentials in localStorage.
### Default Homeserver
The login page defaults to `matrix.mana.how` but any Matrix homeserver can be used.
### Test Account
For testing the SSO login flow:
- **Email:** t@t.de
- **Password:** test1234
- **Auth URL:** https://auth.mana.how
- **Matrix Homeserver:** matrix.mana.how
- **Web Client:** https://link.mana.how
## Matrix SDK Notes
### Browser Polyfills
matrix-js-sdk requires polyfills for browser usage. These are automatically loaded in `src/lib/matrix/polyfills.ts`:
- `Buffer` from buffer package
- `global` mapped to `globalThis`
- `process.env` stub
### Vite Configuration
Special Vite config for Matrix SDK + PWA:
```typescript
// WASM headers for crypto
server: {
headers: {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
},
},
// PWA plugin
plugins: [
SvelteKitPWA({
registerType: 'autoUpdate',
manifest: { ... },
workbox: { ... },
}),
],
```
### Client-Side Only
matrix-js-sdk only works client-side. Always guard with:
```typescript
import { browser } from '$app/environment';
if (browser) {
await matrixStore.initialize();
}
```
## Troubleshooting
### Can't see the login page / stuck on loading
The app auto-logs in if credentials are stored in localStorage. To reset:
1. **Browser console method:**
```javascript
localStorage.removeItem('matrix_credentials');
location.reload();
```
2. **Direct URL:** Navigate to `https://link.mana.how/login` directly
3. **If logged in:** Use the logout button in settings or navigation
### "super.off is not a function"
This is a known issue with typed-event-emitter. Make sure polyfills are loaded before any matrix-js-sdk imports.
### Login fails with network error
1. Check if homeserver is reachable: `curl https://matrix.mana.how/_matrix/client/versions`
2. Verify CORS is configured on Synapse
3. Try without https:// prefix in homeserver field
### Messages not loading
The initial sync can take time depending on room history. Check `matrixStore.syncState` for status.
### PWA not installing
1. Ensure HTTPS is enabled
2. Check manifest.json is served correctly
3. Verify icons exist at specified paths
4. Check DevTools > 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)

View file

@ -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

View file

@ -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"
}
}
}
}

View file

@ -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 (
<View
className="absolute -top-1 -right-2 bg-destructive rounded-full min-w-4 h-4 items-center justify-center px-0.5"
style={{ zIndex: 1 }}
>
<Text className="text-white text-xs font-bold leading-none">
{count > 9 ? '9+' : count}
</Text>
</View>
);
}
export default function AppLayout() {
const invites = useMatrixStore((s) => s.invites);
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarStyle: {
backgroundColor: BG,
borderTopColor: BORDER,
height: 58,
paddingBottom: 8,
},
tabBarActiveTintColor: ACTIVE,
tabBarInactiveTintColor: INACTIVE,
tabBarLabelStyle: { fontSize: 11 },
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Chats',
tabBarIcon: ({ focused }) => (
<ChatCircle size={SIZE} weight={focused ? 'fill' : 'regular'} color={focused ? ACTIVE : INACTIVE} />
),
}}
/>
<Tabs.Screen
name="dms"
options={{
title: 'DMs',
tabBarIcon: ({ focused }) => (
<User size={SIZE} weight={focused ? 'fill' : 'regular'} color={focused ? ACTIVE : INACTIVE} />
),
}}
/>
<Tabs.Screen
name="invites"
options={{
title: 'Invites',
tabBarIcon: ({ focused }) => (
<View>
<Bell size={SIZE} weight={focused ? 'fill' : 'regular'} color={focused ? ACTIVE : INACTIVE} />
<InviteBadge count={invites.length} />
</View>
),
}}
/>
<Tabs.Screen
name="settings"
options={{
title: 'Settings',
tabBarIcon: ({ focused }) => (
<GearSix size={SIZE} weight={focused ? 'fill' : 'regular'} color={focused ? ACTIVE : INACTIVE} />
),
}}
/>
</Tabs>
);
}

View file

@ -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 (
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
<SyncStatusBar syncState={syncState} />
<View className="flex-row items-center justify-between px-4 pt-3 pb-2">
<Text className="text-foreground text-2xl font-bold">Direct Messages</Text>
<Pressable
onPress={() => router.push('/room/new')}
className="w-9 h-9 bg-primary rounded-full items-center justify-center active:opacity-70"
>
<Plus size={18} color="#fff" weight="bold" />
</Pressable>
</View>
{(dmRooms.length > 0 || search.length > 0) && (
<View className="flex-row items-center bg-surface border border-border rounded-xl mx-4 mb-3 px-3 gap-2">
<MagnifyingGlass size={16} color="#6b7280" />
<TextInput
className="flex-1 py-2.5 text-foreground text-sm"
value={search}
onChangeText={setSearch}
placeholder="Search people..."
placeholderTextColor="#6b7280"
/>
</View>
)}
{!isReady && syncState === 'STOPPED' ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator color="#7c6bff" />
</View>
) : (
<FlatList
data={dmRooms}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<RoomListItem room={item} onPress={() => handleRoomPress(item.id)} />
)}
contentContainerStyle={{ paddingBottom: 16 }}
ListHeaderComponent={
dmInvites.length > 0 ? (
<View className="px-4 py-2 bg-primary/10 border-b border-border">
<Text className="text-primary text-sm font-medium">
{dmInvites.length} pending invite{dmInvites.length !== 1 ? 's' : ''}
</Text>
</View>
) : null
}
ListEmptyComponent={
<View className="items-center justify-center py-20">
<Text className="text-muted-foreground text-base">
{search ? 'No people found' : 'No direct messages'}
</Text>
{!search && (
<Text className="text-muted-foreground text-sm mt-1">
Tap + to start a conversation
</Text>
)}
</View>
}
/>
)}
</SafeAreaView>
);
}

View file

@ -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 (
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
<SyncStatusBar syncState={syncState} />
{/* Header */}
<View className="flex-row items-center justify-between px-4 pt-3 pb-2">
<Text className="text-foreground text-2xl font-bold">Chats</Text>
<View className="flex-row gap-2">
<Pressable
onPress={() => router.push('/search')}
className="w-9 h-9 bg-surface border border-border rounded-full items-center justify-center active:opacity-70"
>
<Compass size={18} color="#7c6bff" />
</Pressable>
<Pressable
onPress={() => router.push('/room/new')}
className="w-9 h-9 bg-primary rounded-full items-center justify-center active:opacity-70"
>
<Plus size={18} color="#fff" weight="bold" />
</Pressable>
</View>
</View>
{/* Search */}
{(groupRooms.length > 0 || search.length > 0) && (
<View className="flex-row items-center bg-surface border border-border rounded-xl mx-4 mb-3 px-3 gap-2">
<MagnifyingGlass size={16} color="#6b7280" />
<TextInput
className="flex-1 py-2.5 text-foreground text-sm"
value={search}
onChangeText={setSearch}
placeholder="Search rooms..."
placeholderTextColor="#6b7280"
/>
</View>
)}
{/* Loading state */}
{!isReady && syncState === 'STOPPED' ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator color="#7c6bff" />
<Text className="text-muted-foreground text-sm mt-3">Connecting...</Text>
</View>
) : (
<FlatList
data={groupRooms}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<RoomListItem room={item} onPress={() => handleRoomPress(item.id)} />
)}
contentContainerStyle={{ paddingBottom: 16 }}
ListHeaderComponent={
invites.length > 0 ? (
<View className="px-4 py-2 bg-primary/10 border-b border-border">
<Text className="text-primary text-sm font-medium">
{invites.length} pending invite{invites.length !== 1 ? 's' : ''}
</Text>
</View>
) : null
}
ListEmptyComponent={
<View className="items-center justify-center py-20">
<Text className="text-muted-foreground text-base">
{search ? 'No rooms found' : 'No group chats yet'}
</Text>
{!search && (
<Text className="text-muted-foreground text-sm mt-1">
Tap + to create or join a room
</Text>
)}
</View>
}
/>
)}
</SafeAreaView>
);
}

View file

@ -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 (
<View className="mx-4 mb-3 bg-surface border border-border rounded-2xl overflow-hidden">
<View className="flex-row items-center gap-3 p-4">
{/* Avatar */}
<View className="w-12 h-12 rounded-full bg-background border border-border overflow-hidden items-center justify-center">
{room.avatar ? (
<Image
source={{ uri: room.avatar }}
style={{ width: 48, height: 48 }}
contentFit="cover"
/>
) : (
<Text className="text-foreground text-lg font-semibold">
{(room.name ?? '?')[0].toUpperCase()}
</Text>
)}
</View>
{/* Info */}
<View className="flex-1">
<Text className="text-foreground font-semibold text-base" numberOfLines={1}>
{room.name}
</Text>
{room.topic && (
<Text className="text-muted-foreground text-xs mt-0.5" numberOfLines={1}>
{room.topic}
</Text>
)}
{room.inviter && (
<Text className="text-muted-foreground text-xs mt-0.5">Invited by {room.inviter}</Text>
)}
<View className="flex-row items-center gap-1 mt-1">
<Text className="text-muted-foreground text-xs">
{room.isDirect
? 'Direct message'
: `${room.memberCount} member${room.memberCount !== 1 ? 's' : ''}`}
</Text>
{room.isEncrypted && <Text className="text-green-500 text-xs">· 🔒 Encrypted</Text>}
</View>
</View>
</View>
{/* Actions */}
<View className="flex-row border-t border-border">
<Pressable
onPress={onDecline}
className="flex-1 py-3 items-center border-r border-border active:bg-surface"
>
<Text className="text-destructive font-medium text-sm">Decline</Text>
</Pressable>
<Pressable
onPress={onAccept}
className="flex-1 py-3 items-center bg-primary active:bg-primary/80"
>
<Text className="text-white font-semibold text-sm">Accept</Text>
</Pressable>
</View>
</View>
);
}
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 (
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
<View className="px-4 pt-3 pb-2 flex-row items-center justify-between">
<Text className="text-foreground text-2xl font-bold">Invites</Text>
{invites.length > 0 && (
<View className="bg-primary rounded-full min-w-6 h-6 items-center justify-center px-1.5">
<Text className="text-white text-xs font-bold">{invites.length}</Text>
</View>
)}
</View>
{!isReady ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator color="#7c6bff" />
</View>
) : (
<FlatList
data={invites}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<InviteCard
room={item}
onAccept={() => handleAccept(item.id)}
onDecline={() => handleDecline(item.id, item.name)}
/>
)}
contentContainerStyle={{ paddingTop: 8, paddingBottom: 24 }}
ListEmptyComponent={
<View className="items-center justify-center py-24">
<Text className="text-4xl mb-3"></Text>
<Text className="text-foreground text-base font-medium">No pending invites</Text>
<Text className="text-muted-foreground text-sm mt-1">
Room invites will appear here
</Text>
</View>
}
/>
)}
</SafeAreaView>
);
}

View file

@ -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 (
<View className="w-20 h-20 rounded-full bg-surface border-2 border-border overflow-hidden items-center justify-center">
{avatarUrl ? (
<Image source={{ uri: avatarUrl }} style={{ width: 80, height: 80 }} contentFit="cover" />
) : (
<Text className="text-foreground text-3xl font-semibold">{initial}</Text>
)}
</View>
);
}
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 (
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
<View className="px-4 py-3">
<Text className="text-foreground text-2xl font-bold">Settings</Text>
</View>
<ScrollView className="flex-1" contentContainerStyle={{ padding: 16, gap: 16 }}>
{/* Profile card */}
<View className="bg-surface rounded-2xl border border-border p-4 items-center gap-3">
{/* Avatar */}
<Pressable onPress={handleChangeAvatar} disabled={uploadingAvatar} className="relative">
<ProfileAvatar
displayName={profileInfo.displayName}
avatarUrl={profileInfo.avatarUrl}
/>
<View className="absolute bottom-0 right-0 w-6 h-6 bg-primary rounded-full items-center justify-center border-2 border-background">
{uploadingAvatar ? (
<ActivityIndicator size={10} color="#fff" />
) : (
<PencilSimple size={12} color="#fff" weight="bold" />
)}
</View>
</Pressable>
{/* Display name */}
<View className="items-center">
<View className="flex-row items-center gap-2">
<Text className="text-foreground text-lg font-semibold">
{profileInfo.displayName}
</Text>
<Pressable onPress={handleEditName}>
<PencilSimple size={16} color="#7c6bff" />
</Pressable>
</View>
<Text className="text-muted-foreground text-sm mt-0.5" numberOfLines={1}>
{userId}
</Text>
</View>
</View>
{/* Connection info */}
<View className="bg-surface rounded-2xl overflow-hidden border border-border">
<View className="px-4 py-3 border-b border-border">
<Text className="text-muted-foreground text-xs uppercase tracking-wider">
Connection
</Text>
</View>
<View className="p-4 gap-3">
<View>
<Text className="text-muted-foreground text-xs">Homeserver</Text>
<Text className="text-foreground text-sm mt-0.5" numberOfLines={1}>
{homeserver || '—'}
</Text>
</View>
<View className="flex-row items-center justify-between">
<Text className="text-muted-foreground text-xs">Sync status</Text>
<View className="flex-row items-center gap-1.5">
<View
className={`w-2 h-2 rounded-full ${syncState === 'SYNCING' || syncState === 'PREPARED' ? 'bg-green-500' : syncState === 'ERROR' ? 'bg-destructive' : 'bg-yellow-500'}`}
/>
<Text className="text-foreground text-sm capitalize">
{syncState.toLowerCase()}
</Text>
</View>
</View>
</View>
</View>
{/* About */}
<View className="bg-surface rounded-2xl overflow-hidden border border-border">
<View className="px-4 py-3 border-b border-border">
<Text className="text-muted-foreground text-xs uppercase tracking-wider">About</Text>
</View>
<View className="p-4 gap-2">
<View className="flex-row justify-between">
<Text className="text-muted-foreground text-sm">App</Text>
<Text className="text-foreground text-sm">Manalink</Text>
</View>
<View className="flex-row justify-between">
<Text className="text-muted-foreground text-sm">Version</Text>
<Text className="text-foreground text-sm">1.0.0</Text>
</View>
<View className="flex-row justify-between">
<Text className="text-muted-foreground text-sm">Protocol</Text>
<Text className="text-foreground text-sm">Matrix</Text>
</View>
</View>
</View>
{/* Sign out */}
<Pressable
onPress={handleLogout}
className="bg-destructive/10 border border-destructive/30 rounded-2xl p-4 items-center active:opacity-60"
>
<Text className="text-destructive font-semibold">Sign out</Text>
</Pressable>
</ScrollView>
{/* Edit display name modal */}
<Modal
visible={editingName}
transparent
animationType="fade"
onRequestClose={() => setEditingName(false)}
>
<View className="flex-1 bg-black/60 items-center justify-center p-6">
<View className="bg-surface border border-border rounded-2xl p-5 w-full gap-4">
<View className="flex-row items-center justify-between">
<Text className="text-foreground text-base font-semibold">Display name</Text>
<Pressable onPress={() => setEditingName(false)}>
<X size={20} color="#6b7280" />
</Pressable>
</View>
<TextInput
className="bg-background border border-border rounded-xl px-4 py-3 text-foreground"
value={newDisplayName}
onChangeText={setNewDisplayName}
placeholder="Your display name"
placeholderTextColor="#6b7280"
autoFocus
/>
<Pressable
onPress={handleSaveName}
disabled={savingName || !newDisplayName.trim()}
className="bg-primary rounded-xl py-3 items-center active:opacity-60"
>
{savingName ? (
<ActivityIndicator color="#fff" />
) : (
<Text className="text-white font-semibold">Save</Text>
)}
</Pressable>
</View>
</View>
</Modal>
</SafeAreaView>
);
}

View file

@ -1,9 +0,0 @@
import { Stack } from 'expo-router';
export default function AuthLayout() {
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="login" />
</Stack>
);
}

View file

@ -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<string | null>(null);
const [loading, setLoading] = useState(false);
const [ssoLoading, setSsoLoading] = useState(false);
const [checkingServer, setCheckingServer] = useState(false);
const [serverOk, setServerOk] = useState<boolean | null>(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 (
<SafeAreaView className="flex-1 bg-background">
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
className="flex-1"
>
<ScrollView
contentContainerStyle={{ flexGrow: 1, justifyContent: 'center', padding: 24 }}
keyboardShouldPersistTaps="handled"
>
{/* Logo */}
<View className="items-center mb-10">
<View className="w-20 h-20 bg-primary rounded-3xl items-center justify-center mb-4">
<Text className="text-white text-4xl"></Text>
</View>
<Text className="text-foreground text-4xl font-bold tracking-tight">Manalink</Text>
<Text className="text-muted-foreground text-sm mt-1">Secure Matrix messaging</Text>
</View>
<View className="gap-4">
{/* Homeserver */}
<View>
<Text className="text-muted-foreground text-xs mb-1 uppercase tracking-wider">
Homeserver
</Text>
<View className="flex-row items-center gap-2">
<TextInput
className="flex-1 bg-surface border border-border rounded-xl px-4 py-3 text-foreground"
value={homeserver}
onChangeText={(v) => {
setHomeserver(v);
setServerOk(null);
}}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
placeholder="matrix.example.com"
placeholderTextColor="#6b7280"
onBlur={handleCheckServer}
/>
{checkingServer && <ActivityIndicator size="small" color="#7c6bff" />}
{serverOk === true && <Text className="text-green-500 text-lg"></Text>}
{serverOk === false && <Text className="text-destructive text-lg"></Text>}
</View>
</View>
{/* Username */}
<View>
<Text className="text-muted-foreground text-xs mb-1 uppercase tracking-wider">
Username
</Text>
<TextInput
className="bg-surface border border-border rounded-xl px-4 py-3 text-foreground"
value={username}
onChangeText={setUsername}
autoCapitalize="none"
autoCorrect={false}
placeholder="@user:matrix.org or just user"
placeholderTextColor="#6b7280"
/>
</View>
{/* Password */}
<View>
<Text className="text-muted-foreground text-xs mb-1 uppercase tracking-wider">
Password
</Text>
<TextInput
className="bg-surface border border-border rounded-xl px-4 py-3 text-foreground"
value={password}
onChangeText={setPassword}
secureTextEntry
placeholder="••••••••"
placeholderTextColor="#6b7280"
onSubmitEditing={handleLogin}
returnKeyType="go"
/>
</View>
{error && <Text className="text-destructive text-sm text-center">{error}</Text>}
{/* Password login */}
<Pressable
onPress={handleLogin}
disabled={loading || ssoLoading}
className="bg-primary rounded-xl py-4 items-center mt-1 active:opacity-70"
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text className="text-white font-semibold text-base">Sign in</Text>
)}
</Pressable>
{/* Divider */}
<View className="flex-row items-center gap-3">
<View className="flex-1 h-px bg-border" />
<Text className="text-muted-foreground text-xs">or</Text>
<View className="flex-1 h-px bg-border" />
</View>
{/* SSO */}
<Pressable
onPress={handleSSO}
disabled={loading || ssoLoading}
className="bg-surface border border-border rounded-xl py-4 items-center active:opacity-70"
>
{ssoLoading ? (
<ActivityIndicator color="#7c6bff" />
) : (
<Text className="text-foreground font-medium text-base">Sign in with SSO</Text>
)}
</Pressable>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}

View file

@ -1,13 +0,0 @@
import { View, Text } from 'react-native';
import { Link } from 'expo-router';
export default function NotFound() {
return (
<View className="flex-1 bg-background items-center justify-center p-6">
<Text className="text-foreground text-xl font-semibold mb-2">Screen not found</Text>
<Link href="/(app)" className="text-primary mt-4">
Go home
</Link>
</View>
);
}

View file

@ -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 (
<GestureHandlerRootView style={{ flex: 1 }}>
<StatusBar style="light" />
<AuthGuard>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(auth)" />
<Stack.Screen name="(app)" />
<Stack.Screen
name="room/[id]"
options={{ headerShown: false, animation: 'slide_from_right' }}
/>
<Stack.Screen
name="room/new"
options={{ headerShown: false, animation: 'slide_from_bottom', presentation: 'modal' }}
/>
<Stack.Screen
name="room/settings"
options={{ headerShown: false, animation: 'slide_from_right' }}
/>
<Stack.Screen
name="search"
options={{ headerShown: false, animation: 'slide_from_bottom', presentation: 'modal' }}
/>
<Stack.Screen name="+not-found" />
</Stack>
</AuthGuard>
</GestureHandlerRootView>
);
}

View file

@ -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 (
<>
<Pressable
onPress={() => setShowProfile(true)}
className="flex-row items-center gap-3 px-4 py-3 active:bg-surface/60"
>
<View className="w-10 h-10 rounded-full bg-surface border border-border overflow-hidden items-center justify-center">
{member.avatarUrl ? (
<Image
source={{ uri: member.avatarUrl }}
style={{ width: 40, height: 40 }}
contentFit="cover"
/>
) : (
<Text className="text-foreground font-semibold">
{member.displayName[0]?.toUpperCase() ?? '?'}
</Text>
)}
</View>
<View className="flex-1">
<Text className="text-foreground text-sm font-medium">{member.displayName}</Text>
<Text className="text-muted-foreground text-xs" numberOfLines={1}>
{member.userId}
</Text>
</View>
{member.powerLevel >= 100 && (
<View className="bg-primary/20 rounded-full px-2 py-0.5">
<Text className="text-primary text-xs">Admin</Text>
</View>
)}
{member.powerLevel >= 50 && member.powerLevel < 100 && (
<View className="bg-surface border border-border rounded-full px-2 py-0.5">
<Text className="text-muted-foreground text-xs">Mod</Text>
</View>
)}
</Pressable>
<UserProfileModal
userId={showProfile ? member.userId : null}
onClose={() => {
setShowProfile(false);
onClose();
}}
/>
</>
);
}
export default function RoomScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const listRef = useRef<FlatList<ListItem>>(null);
const [loadingMore, setLoadingMore] = useState(false);
const [uploading, setUploading] = useState(false);
const [showVoiceRecorder, setShowVoiceRecorder] = useState(false);
const [replyTo, setReplyTo] = useState<SimpleMessage | null>(null);
const [editingMessage, setEditingMessage] = useState<SimpleMessage | null>(null);
const [showMembers, setShowMembers] = useState(false);
const [viewingImage, setViewingImage] = useState<string | null>(null);
const [profileUserId, setProfileUserId] = useState<string | null>(null);
const [forwardingMessage, setForwardingMessage] = useState<SimpleMessage | null>(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 <DateSeparator timestamp={item.timestamp} />;
if (item.type === 'unread') return <UnreadSeparator />;
const msgIndex = messages.indexOf(item.data);
return (
<MessageBubble
message={item.data}
prevMessage={messages[msgIndex - 1] ?? null}
onReply={(msg) => {
setEditingMessage(null);
setReplyTo(msg);
}}
onEdit={handleEdit}
onReact={sendReaction}
onDelete={redactMessage}
onForward={handleForward}
onImagePress={setViewingImage}
onAvatarPress={setProfileUserId}
/>
);
};
return (
<SafeAreaView className="flex-1 bg-background" edges={['top', 'bottom']}>
{/* Header */}
<View className="flex-row items-center gap-3 px-4 py-3 border-b border-border">
<Pressable onPress={() => router.back()} className="p-1 active:opacity-50">
<ArrowLeft size={22} color="#7c6bff" />
</Pressable>
<View className="flex-1">
<View className="flex-row items-center gap-1.5">
<Text className="text-foreground font-semibold text-base" numberOfLines={1}>
{room?.name ?? id}
</Text>
{room?.isEncrypted && <Lock size={12} color="#22c55e" weight="fill" />}
</View>
{room?.topic ? (
<Text className="text-muted-foreground text-xs" numberOfLines={1}>
{room.topic}
</Text>
) : room?.memberCount != null ? (
<Text className="text-muted-foreground text-xs">
{room.memberCount} member{room.memberCount !== 1 ? 's' : ''}
</Text>
) : null}
</View>
<Pressable onPress={handleRoomOptions} className="p-1 active:opacity-50">
<DotsThreeVertical size={22} color="#6b7280" />
</Pressable>
</View>
{(loadingMore || uploading) && (
<View className="flex-row items-center justify-center gap-2 py-1.5 bg-primary/10">
<ActivityIndicator size="small" color="#7c6bff" />
<Text className="text-primary text-xs">{uploading ? 'Uploading...' : 'Loading...'}</Text>
</View>
)}
<FlatList
ref={listRef}
data={listItems}
keyExtractor={(item) => (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={
<View className="items-center justify-center py-20">
<Text className="text-muted-foreground text-sm">No messages yet</Text>
</View>
}
/>
{typingUsers.length > 0 && <TypingIndicator users={typingUsers} />}
{showVoiceRecorder ? (
<VoiceRecorder
onSend={async (uri, durationMs) => {
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)}
/>
) : (
<MessageInput
onSend={handleSend}
onEdit={handleEditSave}
onTyping={sendTyping}
onAttach={handleAttach}
onVoiceRecord={() => setShowVoiceRecorder(true)}
replyTo={replyTo}
onCancelReply={() => setReplyTo(null)}
editingMessage={editingMessage}
onCancelEdit={() => setEditingMessage(null)}
/>
)}
{/* Members modal */}
<Modal
visible={showMembers}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={() => setShowMembers(false)}
>
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
<View className="flex-row items-center justify-between px-4 py-3 border-b border-border">
<Text className="text-foreground text-lg font-semibold">
Members{room?.memberCount != null ? ` (${room.memberCount})` : ''}
</Text>
<Pressable onPress={() => setShowMembers(false)} className="p-1 active:opacity-50">
<X size={22} color="#6b7280" />
</Pressable>
</View>
<ScrollView contentContainerStyle={{ paddingVertical: 8 }}>
{roomMembers.length === 0 ? (
<View className="items-center py-10">
<ActivityIndicator color="#7c6bff" />
</View>
) : (
roomMembers.map((member) => (
<MemberRow
key={member.userId}
member={member}
onClose={() => setShowMembers(false)}
/>
))
)}
</ScrollView>
</SafeAreaView>
</Modal>
<ImageViewer uri={viewingImage} onClose={() => setViewingImage(null)} />
<UserProfileModal userId={profileUserId} onClose={() => setProfileUserId(null)} />
{/* Forward message modal */}
<Modal
visible={!!forwardingMessage}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={() => setForwardingMessage(null)}
>
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
<View className="flex-row items-center justify-between px-4 py-3 border-b border-border">
<Text className="text-foreground text-lg font-semibold">Forward to...</Text>
<Pressable onPress={() => setForwardingMessage(null)} className="p-1 active:opacity-50">
<X size={22} color="#6b7280" />
</Pressable>
</View>
<View className="px-4 py-2">
<TextInput
className="bg-surface border border-border rounded-xl px-4 py-2.5 text-foreground text-sm"
placeholder="Search rooms..."
placeholderTextColor="#6b7280"
value={forwardSearch}
onChangeText={setForwardSearch}
autoFocus
/>
</View>
{forwardingMessage && (
<View className="mx-4 mb-2 px-3 py-2 bg-surface border border-border rounded-xl">
<Text className="text-muted-foreground text-xs mb-0.5">Message:</Text>
<Text className="text-foreground text-sm" numberOfLines={2}>
{forwardingMessage.body}
</Text>
</View>
)}
<ScrollView contentContainerStyle={{ paddingVertical: 4 }}>
{rooms
.filter(
(r) => r.id !== id && r.name.toLowerCase().includes(forwardSearch.toLowerCase())
)
.map((r) => (
<Pressable
key={r.id}
onPress={() => handleForwardToRoom(r)}
className="flex-row items-center gap-3 px-4 py-3 active:bg-surface/60"
>
<View className="w-10 h-10 rounded-full bg-surface border border-border overflow-hidden items-center justify-center">
{r.avatar ? (
<Image
source={{ uri: r.avatar }}
style={{ width: 40, height: 40 }}
contentFit="cover"
/>
) : (
<Text className="text-foreground font-semibold">
{r.name[0]?.toUpperCase() ?? '?'}
</Text>
)}
</View>
<View className="flex-1">
<Text className="text-foreground text-sm font-medium" numberOfLines={1}>
{r.name}
</Text>
{r.isDirect && (
<Text className="text-muted-foreground text-xs">Direct message</Text>
)}
</View>
</Pressable>
))}
</ScrollView>
</SafeAreaView>
</Modal>
</SafeAreaView>
);
}

View file

@ -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<Mode>('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<string | null>(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 (
<SafeAreaView className="flex-1 bg-background" edges={['top', 'bottom']}>
{/* Header */}
<View className="flex-row items-center gap-3 px-4 py-3 border-b border-border">
<Pressable onPress={() => router.back()} className="p-1 active:opacity-50">
<ArrowLeft size={22} color="#7c6bff" />
</Pressable>
<Text className="text-foreground text-lg font-semibold">New conversation</Text>
</View>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
className="flex-1"
>
<ScrollView
contentContainerStyle={{ padding: 16, gap: 20 }}
keyboardShouldPersistTaps="handled"
>
{/* Mode toggle */}
<View className="flex-row bg-surface rounded-2xl p-1 border border-border">
{(['dm', 'room'] as Mode[]).map((m) => (
<Pressable
key={m}
onPress={() => {
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' ? (
<ChatCircle size={16} color={mode === m ? '#fff' : '#6b7280'} />
) : (
<Users size={16} color={mode === m ? '#fff' : '#6b7280'} />
)}
<Text
className={`text-sm font-medium ${mode === m ? 'text-white' : 'text-muted-foreground'}`}
>
{m === 'dm' ? 'Direct message' : 'Group room'}
</Text>
</Pressable>
))}
</View>
{/* DM form */}
{mode === 'dm' && (
<View className="gap-4">
<View>
<Text className="text-muted-foreground text-xs mb-1 uppercase tracking-wider">
User ID
</Text>
<TextInput
className="bg-surface border border-border rounded-xl px-4 py-3 text-foreground"
value={dmTarget}
onChangeText={setDmTarget}
autoCapitalize="none"
autoCorrect={false}
placeholder="@user:matrix.org"
placeholderTextColor="#6b7280"
/>
</View>
</View>
)}
{/* Room form */}
{mode === 'room' && (
<View className="gap-4">
<View>
<Text className="text-muted-foreground text-xs mb-1 uppercase tracking-wider">
Room name
</Text>
<TextInput
className="bg-surface border border-border rounded-xl px-4 py-3 text-foreground"
value={name}
onChangeText={setName}
placeholder="My room"
placeholderTextColor="#6b7280"
/>
</View>
<View>
<Text className="text-muted-foreground text-xs mb-1 uppercase tracking-wider">
Topic (optional)
</Text>
<TextInput
className="bg-surface border border-border rounded-xl px-4 py-3 text-foreground"
value={topic}
onChangeText={setTopic}
placeholder="What this room is about"
placeholderTextColor="#6b7280"
/>
</View>
<View className="flex-row items-center justify-between bg-surface border border-border rounded-xl px-4 py-3">
<View>
<Text className="text-foreground text-sm">Private room</Text>
<Text className="text-muted-foreground text-xs mt-0.5">
Only invited members can join
</Text>
</View>
<Switch
value={isPrivate}
onValueChange={setIsPrivate}
trackColor={{ true: '#7c6bff', false: '#2a2a2a' }}
/>
</View>
</View>
)}
{/* Error */}
{error && <Text className="text-destructive text-sm text-center">{error}</Text>}
{/* Create button */}
<Pressable
onPress={handleCreate}
disabled={loading}
className="bg-primary rounded-xl py-4 items-center active:opacity-70"
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text className="text-white font-semibold text-base">
{mode === 'dm' ? 'Start conversation' : 'Create room'}
</Text>
)}
</Pressable>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}

View file

@ -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<string | null>(room?.avatar ?? null);
const [saving, setSaving] = useState(false);
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const [newAvatarMxc, setNewAvatarMxc] = useState<string | null>(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 (
<SafeAreaView className="flex-1 bg-background" edges={['top', 'bottom']}>
{/* Header */}
<View className="flex-row items-center gap-3 px-4 py-3 border-b border-border">
<Pressable onPress={() => router.back()} className="p-1 active:opacity-50">
<ArrowLeft size={22} color="#7c6bff" />
</Pressable>
<Text className="flex-1 text-foreground font-semibold text-base">Room Settings</Text>
<Pressable
onPress={handleSave}
disabled={!hasChanges || saving}
className={`px-4 py-1.5 rounded-full ${hasChanges && !saving ? 'bg-primary' : 'bg-surface border border-border'} active:opacity-60`}
>
{saving ? (
<ActivityIndicator size={14} color="#fff" />
) : (
<Text
className={`text-sm font-semibold ${hasChanges ? 'text-white' : 'text-muted-foreground'}`}
>
Save
</Text>
)}
</Pressable>
</View>
<ScrollView contentContainerStyle={{ paddingHorizontal: 16, paddingVertical: 24, gap: 32 }}>
{/* Avatar */}
<View className="items-center gap-3">
<Pressable onPress={handlePickAvatar} disabled={uploadingAvatar}>
<View className="w-24 h-24 rounded-full bg-surface border border-border overflow-hidden items-center justify-center">
{uploadingAvatar ? (
<ActivityIndicator color="#7c6bff" />
) : avatarUri ? (
<Image
source={{ uri: avatarUri }}
style={{ width: 96, height: 96 }}
contentFit="cover"
/>
) : (
<Text className="text-foreground text-3xl font-bold">
{room?.name?.[0]?.toUpperCase() ?? '#'}
</Text>
)}
</View>
<View className="absolute bottom-0 right-0 w-8 h-8 rounded-full bg-primary items-center justify-center border-2 border-background">
<Camera size={14} color="#fff" weight="fill" />
</View>
</Pressable>
<Text className="text-muted-foreground text-xs">Tap to change room avatar</Text>
</View>
{/* Name */}
<View className="gap-2">
<Text className="text-foreground text-sm font-semibold">Room name</Text>
<TextInput
value={name}
onChangeText={setName}
placeholder="Room name"
placeholderTextColor="#6b7280"
className="bg-surface border border-border rounded-xl px-4 py-3 text-foreground"
maxLength={255}
/>
</View>
{/* Topic */}
<View className="gap-2">
<Text className="text-foreground text-sm font-semibold">Topic</Text>
<TextInput
value={topic}
onChangeText={setTopic}
placeholder="Describe this room…"
placeholderTextColor="#6b7280"
className="bg-surface border border-border rounded-xl px-4 py-3 text-foreground"
multiline
numberOfLines={3}
textAlignVertical="top"
style={{ minHeight: 80 }}
maxLength={1000}
/>
</View>
{/* Room ID info */}
<View className="gap-2">
<Text className="text-foreground text-sm font-semibold">Room ID</Text>
<View className="bg-surface border border-border rounded-xl px-4 py-3">
<Text className="text-muted-foreground text-sm font-mono" selectable>
{id}
</Text>
</View>
</View>
</ScrollView>
</SafeAreaView>
);
}

View file

@ -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<PublicRoom[]>([]);
const [loading, setLoading] = useState(false);
const [joiningId, setJoiningId] = useState<string | null>(null);
const [nextBatch, setNextBatch] = useState<string | undefined>();
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 (
<View className="flex-row items-start gap-3 px-4 py-3 border-b border-border">
{/* Avatar */}
<View className="w-11 h-11 rounded-xl bg-surface border border-border overflow-hidden items-center justify-center shrink-0">
{item.avatar_url ? (
<Image
source={{ uri: item.avatar_url }}
style={{ width: 44, height: 44 }}
contentFit="cover"
/>
) : (
<Text className="text-foreground text-lg font-semibold">{initial}</Text>
)}
</View>
{/* Info */}
<View className="flex-1">
<View className="flex-row items-center gap-1.5 flex-wrap">
<Text className="text-foreground text-sm font-semibold" numberOfLines={1}>
{name}
</Text>
{item.join_rule === 'public' ? null : <Lock size={11} color="#6b7280" />}
</View>
{item.topic && (
<Text className="text-muted-foreground text-xs mt-0.5" numberOfLines={2}>
{item.topic}
</Text>
)}
<View className="flex-row items-center gap-1 mt-1">
<Users size={11} color="#6b7280" />
<Text className="text-muted-foreground text-xs">{item.num_joined_members}</Text>
</View>
</View>
{/* Join button */}
<Pressable
onPress={() => handleJoin(item)}
disabled={isJoining}
className="bg-primary rounded-lg px-3 py-1.5 shrink-0 active:opacity-60"
>
{isJoining ? (
<ActivityIndicator size={14} color="#fff" />
) : (
<Text className="text-white text-xs font-semibold">Join</Text>
)}
</Pressable>
</View>
);
};
return (
<SafeAreaView className="flex-1 bg-background" edges={['top', 'bottom']}>
{/* Header */}
<View className="flex-row items-center gap-3 px-4 py-3 border-b border-border">
<Pressable onPress={() => router.back()} className="p-1 active:opacity-50">
<ArrowLeft size={22} color="#7c6bff" />
</Pressable>
<Text className="text-foreground text-lg font-semibold">Explore rooms</Text>
</View>
{/* Search bar */}
<View className="flex-row items-center gap-2 px-4 py-3 border-b border-border">
<MagnifyingGlass size={18} color="#6b7280" />
<TextInput
className="flex-1 text-foreground text-base"
value={query}
onChangeText={handleSearch}
placeholder="Search public rooms..."
placeholderTextColor="#6b7280"
autoFocus
autoCapitalize="none"
autoCorrect={false}
/>
{loading && <ActivityIndicator size="small" color="#7c6bff" />}
</View>
<FlatList
data={results}
keyExtractor={(item) => item.room_id}
renderItem={renderRoom}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.2}
ListEmptyComponent={
!loading ? (
<View className="items-center justify-center py-20">
<Text className="text-muted-foreground text-base">
{query.length > 0 ? 'No rooms found' : 'Search for public rooms'}
</Text>
</View>
) : null
}
ListFooterComponent={
hasMore && !loading ? (
<Pressable onPress={handleLoadMore} className="py-4 items-center">
<Text className="text-primary text-sm">Load more</Text>
</Pressable>
) : null
}
/>
</SafeAreaView>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 955 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

View file

@ -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'],
};
};

View file

@ -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"

View file

@ -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": {}
}
}

View file

@ -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/**'],
},
]);

View file

@ -1,3 +0,0 @@
/// <reference types="expo/types" />
// NOTE: This file should not be edited and should be in your git ignore

View file

@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View file

@ -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,
});

View file

@ -1,10 +0,0 @@
/// <reference types="nativewind/types" />
import 'react-native';
declare module 'react-native' {
interface PressableProps {
className?: string | ((state: { pressed: boolean }) => string);
cssInterop?: boolean;
}
}

View file

@ -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
}

View file

@ -1,8 +0,0 @@
module.exports = {
semi: true,
singleQuote: true,
trailingComma: 'all',
printWidth: 100,
useTabs: true,
plugins: ['prettier-plugin-tailwindcss'],
};

View file

@ -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 (
<View className="flex-row items-center gap-3 mx-4 my-4">
<View className="flex-1 h-px bg-border" />
<Text className="text-muted-foreground text-xs">{formatDate(timestamp)}</Text>
<View className="flex-1 h-px bg-border" />
</View>
);
}

View file

@ -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 (
<Modal visible={!!uri} transparent animationType="fade" onRequestClose={onClose}>
<StatusBar hidden />
<View className="flex-1 bg-black">
{/* Controls */}
<View className="absolute top-0 left-0 right-0 z-10 flex-row justify-between p-4 pt-12">
<Pressable
onPress={onClose}
className="w-10 h-10 rounded-full bg-black/50 items-center justify-center active:opacity-60"
>
<X size={20} color="#fff" />
</Pressable>
<Pressable
onPress={handleSave}
disabled={saving}
className="w-10 h-10 rounded-full bg-black/50 items-center justify-center active:opacity-60"
>
<DownloadSimple size={20} color="#fff" />
</Pressable>
</View>
{/* Image */}
{uri && (
<Pressable onPress={onClose} className="flex-1 items-center justify-center">
<Image
source={{ uri }}
style={{ width: SCREEN_W, height: SCREEN_H }}
contentFit="contain"
/>
</Pressable>
)}
</View>
</Modal>
);
}

View file

@ -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 = (
<View
style={{ width: size, height: size, borderRadius: size / 2 }}
className="bg-surface border border-border overflow-hidden items-center justify-center"
>
{url ? (
<Image source={{ uri: url }} style={{ width: size, height: size }} contentFit="cover" />
) : (
<Text style={{ fontSize: size * 0.42 }} className="text-foreground font-semibold">
{name[0]?.toUpperCase() ?? '?'}
</Text>
)}
</View>
);
if (!onPress) return inner;
return (
<Pressable onPress={onPress} className="active:opacity-60">
{inner}
</Pressable>
);
}
const QUICK_REACTIONS = ['👍', '❤️', '😂', '😮', '😢'];
function SwipeReplyAction({ progress }: { progress: SharedValue<number> }) {
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 (
<View className="justify-center items-center w-16">
<Animated.View
style={style}
className="w-9 h-9 rounded-full bg-primary/20 items-center justify-center"
>
<ArrowBendUpLeft size={18} color="#7c6bff" />
</Animated.View>
</View>
);
}
function ReactionDetailsModal({
reactions,
visible,
onClose,
}: {
reactions: MessageReaction[];
visible: boolean;
onClose: () => void;
}) {
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const selected = selectedKey ? reactions.find((r) => r.key === selectedKey) : reactions[0];
return (
<Modal
visible={visible}
animationType="slide"
presentationStyle="formSheet"
onRequestClose={onClose}
>
<View className="flex-1 bg-background">
<View className="flex-row items-center justify-between px-4 py-3 border-b border-border">
<Text className="text-foreground text-lg font-semibold">Reactions</Text>
<Pressable onPress={onClose} className="p-1 active:opacity-50">
<Text className="text-primary text-base">Done</Text>
</Pressable>
</View>
<ScrollView
horizontal
className="border-b border-border"
contentContainerStyle={{ paddingHorizontal: 12, paddingVertical: 8, gap: 8 }}
>
{reactions.map((r) => (
<Pressable
key={r.key}
onPress={() => 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'
}`}
>
<Text className="text-sm">{r.key}</Text>
<Text
className={`text-sm ${selected?.key === r.key ? 'text-primary' : 'text-muted-foreground'}`}
>
{r.count}
</Text>
</Pressable>
))}
</ScrollView>
<ScrollView contentContainerStyle={{ paddingVertical: 8 }}>
{selected?.users.map((userId) => (
<View key={userId} className="flex-row items-center gap-3 px-4 py-2.5">
<View className="w-8 h-8 rounded-full bg-surface border border-border items-center justify-center">
<Text className="text-foreground font-semibold text-sm">
{userId.replace(/^@/, '')[0]?.toUpperCase() ?? '?'}
</Text>
</View>
<Text className="text-foreground text-sm flex-1" numberOfLines={1}>
{userId}
</Text>
</View>
))}
</ScrollView>
</View>
</Modal>
);
}
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 (
<View className={`flex-row ${isOwn ? 'justify-end' : 'justify-start'} mb-1 px-3`}>
<View className="bg-surface border border-border rounded-2xl px-3 py-2">
<Text className="text-muted-foreground text-sm italic">Message deleted</Text>
</View>
</View>
);
}
const renderLeftActions = isOwn
? undefined
: (progress: SharedValue<number>) => <SwipeReplyAction progress={progress} />;
const renderRightActions = isOwn
? (progress: SharedValue<number>) => <SwipeReplyAction progress={progress} />
: 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 (
<Swipeable
{...swipeableProps}
onSwipeableOpen={(direction) => {
if ((direction === 'left' && !isOwn) || (direction === 'right' && isOwn)) {
onReply?.(message);
}
}}
friction={2}
overshootFriction={8}
>
<View
className={`flex-row items-end gap-2 ${isOwn ? 'justify-end' : 'justify-start'} ${isGrouped ? 'mb-0.5' : 'mb-3'} px-3`}
>
{/* Left avatar */}
{!isOwn && (
<View style={{ width: 28 }} className="mb-0.5">
{showAvatar && (
<AvatarCircle
name={message.senderName}
url={message.senderAvatar}
size={28}
onPress={onAvatarPress ? () => onAvatarPress(message.sender) : undefined}
/>
)}
</View>
)}
<View className={`max-w-[75%] ${isOwn ? 'items-end' : 'items-start'}`}>
{showSenderName && (
<Pressable onPress={onAvatarPress ? () => onAvatarPress(message.sender) : undefined}>
<Text className="text-primary text-xs mb-1 ml-1 font-medium">
{message.senderName}
</Text>
</Pressable>
)}
<Pressable
onLongPress={handleLongPress}
delayLongPress={400}
className={`rounded-2xl overflow-hidden ${
isOwn ? 'bg-primary rounded-br-sm' : 'bg-surface border border-border rounded-bl-sm'
}`}
>
{/* Reply preview */}
{message.replyTo && (
<View
className={`mx-2 mt-2 mb-1 px-2 py-1.5 rounded-xl border-l-2 ${
isOwn ? 'bg-white/10 border-white/40' : 'bg-primary/8 border-primary/40'
}`}
>
<Text
className={`text-xs font-semibold mb-0.5 ${isOwn ? 'text-white/80' : 'text-primary'}`}
numberOfLines={1}
>
{message.replyToSenderName ?? 'Unknown'}
</Text>
<Text
className={`text-xs ${isOwn ? 'text-white/60' : 'text-muted-foreground'}`}
numberOfLines={2}
>
{message.replyToBody ?? '…'}
</Text>
</View>
)}
{message.type === 'm.image' && message.media?.thumbnailUrl && (
<Pressable onPress={() => onImagePress?.(message.media!.thumbnailUrl!)}>
<Image
source={{ uri: message.media.thumbnailUrl }}
style={{ width: 220, height: 165 }}
contentFit="cover"
/>
</Pressable>
)}
{message.type === 'm.file' && (
<View className="flex-row items-center gap-2 px-3 py-2">
<Text className="text-2xl">📎</Text>
<Text
className={`text-sm flex-1 ${isOwn ? 'text-white' : 'text-foreground'}`}
numberOfLines={1}
>
{message.media?.filename ?? message.body}
</Text>
</View>
)}
{message.type === 'm.audio' && message.media?.downloadUrl && (
<VoiceMessage
uri={message.media.downloadUrl}
duration={message.media.duration}
isOwn={isOwn}
/>
)}
{(message.type === 'm.text' ||
message.type === 'm.notice' ||
message.type === 'm.emote') && (
<MessageText
body={
message.type === 'm.emote'
? `* ${message.senderName} ${message.body}`
: message.body
}
isOwn={isOwn}
/>
)}
</Pressable>
{/* Reactions */}
{message.reactions && message.reactions.length > 0 && (
<View className="flex-row flex-wrap gap-1 mt-1 mx-1">
{message.reactions.map((r) => (
<Pressable
key={r.key}
onPress={() => 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'
}`}
>
<Text className="text-xs">{r.key}</Text>
{r.count > 1 && (
<Text
className={`text-xs ${r.includesMe ? 'text-primary' : 'text-muted-foreground'}`}
>
{r.count}
</Text>
)}
</Pressable>
))}
</View>
)}
{/* Timestamp + Read receipts */}
<View className="flex-row items-center gap-1 mt-0.5 mx-1">
<Text className="text-muted-foreground text-xs">
{formatTime(message.timestamp)}
{message.edited && ' · edited'}
</Text>
{message.readBy && message.readBy.length > 0 && (
<View className="flex-row items-center ml-1">
{message.readBy.slice(0, 3).map((r, i) => (
<View
key={r.userId}
className="w-3.5 h-3.5 rounded-full bg-primary/30 items-center justify-center border border-background"
style={i > 0 ? { marginLeft: -3 } : undefined}
>
<Text style={{ fontSize: 7 }} className="text-primary font-bold">
{r.userName[0]?.toUpperCase() ?? '?'}
</Text>
</View>
))}
{message.readBy.length > 3 && (
<Text className="text-muted-foreground text-xs ml-0.5">
+{message.readBy.length - 3}
</Text>
)}
</View>
)}
</View>
</View>
</View>
{/* Reaction details modal */}
{message.reactions && message.reactions.length > 0 && (
<ReactionDetailsModal
reactions={message.reactions}
visible={showReactionDetails}
onClose={() => setShowReactionDetails(false)}
/>
)}
</Swipeable>
);
}

View file

@ -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<void>;
onEdit?: (eventId: string, newBody: string) => Promise<void>;
onTyping: (typing: boolean) => Promise<void>;
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<ReturnType<typeof setTimeout> | 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 (
<View className="border-t border-border bg-background">
{/* Context banner: Reply or Edit */}
{(replyTo || isEditing) && (
<View className="flex-row items-center gap-2 px-3 pt-2 pb-1">
<View
className={`w-0.5 self-stretch rounded-full ${isEditing ? 'bg-yellow-500' : 'bg-primary'}`}
/>
<View className="flex-1">
<Text
className={`text-xs font-medium ${isEditing ? 'text-yellow-500' : 'text-primary'}`}
>
{isEditing ? 'Editing message' : `Reply to ${replyTo!.senderName}`}
</Text>
<Text className="text-muted-foreground text-xs" numberOfLines={1}>
{isEditing ? editingMessage!.body : replyTo!.body}
</Text>
</View>
<Pressable
onPress={isEditing ? onCancelEdit : onCancelReply}
className="p-1 active:opacity-50"
>
<X size={16} color="#6b7280" />
</Pressable>
</View>
)}
{/* Input row */}
<View className="flex-row items-end gap-2 px-3 py-2">
{onAttach && !isEditing && (
<Pressable
onPress={onAttach}
className="w-10 h-10 items-center justify-center rounded-full active:opacity-50"
>
<Paperclip size={20} color="#6b7280" />
</Pressable>
)}
<TextInput
className="flex-1 bg-surface border border-border rounded-2xl px-4 py-3 text-foreground max-h-32"
value={text}
onChangeText={handleChangeText}
placeholder={
isEditing
? 'Edit message...'
: replyTo
? `Reply to ${replyTo.senderName}...`
: 'Message...'
}
placeholderTextColor="#6b7280"
multiline
textAlignVertical="center"
/>
{showMic ? (
<Pressable
onPress={onVoiceRecord}
className="w-10 h-10 rounded-full items-center justify-center bg-surface border border-border active:opacity-60"
>
<Microphone size={20} color="#7c6bff" />
</Pressable>
) : (
<Pressable
onPress={handleSubmit}
disabled={!canSend}
className={`w-10 h-10 rounded-full items-center justify-center ${
canSend
? isEditing
? 'bg-yellow-500'
: 'bg-primary'
: 'bg-surface border border-border'
} active:opacity-60`}
>
{isEditing ? (
<PencilSimple size={16} weight="bold" color={canSend ? '#fff' : '#6b7280'} />
) : (
<ArrowUp size={18} weight="bold" color={canSend ? '#fff' : '#6b7280'} />
)}
</Pressable>
)}
</View>
</View>
);
}

View file

@ -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 (
<Text
selectable
className={`text-sm leading-5 px-3 py-2 ${isOwn ? 'text-white' : 'text-foreground'} ${className ?? ''}`}
>
{segments.map((seg, i) => {
if (seg.type === 'url') {
return (
<Text
key={i}
style={{ color: isOwn ? 'rgba(200,190,255,1)' : '#7c6bff', textDecorationLine: 'underline' }}
onPress={() => Linking.openURL(seg.text).catch(() => {})}
>
{seg.text}
</Text>
);
}
if (seg.type === 'mention') {
return (
<Text
key={i}
style={{ color: isOwn ? 'rgba(200,255,200,1)' : '#22c55e', fontWeight: '600' }}
>
{seg.text}
</Text>
);
}
return (
<Text key={i} style={baseColor ? { color: baseColor } : undefined}>
{seg.text}
</Text>
);
})}
</Text>
);
}

View file

@ -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 (
<View
className={`absolute bottom-0 right-0 w-3 h-3 rounded-full border-2 border-background ${
presence === 'online' ? 'bg-green-500' : 'bg-yellow-500'
}`}
/>
);
}
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 (
<Pressable
onPress={onPress}
className="flex-row items-center px-4 py-3 gap-3 active:bg-surface/60"
>
{/* Avatar */}
<View className="relative">
<View className="w-12 h-12 rounded-full bg-surface border border-border overflow-hidden items-center justify-center">
{room.avatar ? (
<Image
source={{ uri: room.avatar }}
style={{ width: 48, height: 48 }}
contentFit="cover"
transition={200}
/>
) : (
<Text className="text-foreground text-lg font-semibold">{initial}</Text>
)}
</View>
{room.isDirect && <PresenceDot presence={room.presence} />}
</View>
{/* Content */}
<View className="flex-1 min-w-0">
<View className="flex-row items-baseline justify-between">
<Text
className={`text-base flex-1 mr-2 ${hasUnread || hasHighlight ? 'text-foreground font-semibold' : 'text-foreground'}`}
numberOfLines={1}
>
{displayName}
</Text>
<Text className="text-muted-foreground text-xs shrink-0">
{formatTime(room.lastMessageTime)}
</Text>
</View>
<View className="flex-row items-center justify-between mt-0.5">
<Text
className={`text-sm flex-1 mr-2 ${hasUnread ? 'text-foreground' : 'text-muted-foreground'}`}
numberOfLines={1}
>
{room.lastMessage
? (room.lastMessageSender && !room.isDirect
? `${room.lastMessageSender.split(':')[0].slice(1)}: `
: '') + room.lastMessage
: room.isEncrypted
? '🔒 Encrypted'
: 'No messages'}
</Text>
{/* Badge */}
{(hasUnread || hasHighlight) && (
<View
className={`min-w-5 h-5 rounded-full items-center justify-center px-1 ${
hasHighlight ? 'bg-primary' : 'bg-muted'
}`}
>
<Text className="text-white text-xs font-bold leading-none">
{hasHighlight
? room.highlightCount
: room.unreadCount > 99
? '99+'
: room.unreadCount}
</Text>
</View>
)}
</View>
</View>
</Pressable>
);
}

View file

@ -1,26 +0,0 @@
import { View, Text } from 'react-native';
import type { SyncState } from '~/src/matrix/types';
interface Props {
syncState: SyncState;
}
const statusConfig: Record<string, { label: string; color: string } | null> = {
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 (
<View className={`${config.color} px-4 py-1 items-center`}>
<Text className="text-white text-xs font-medium">{config.label}</Text>
</View>
);
}

View file

@ -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 (
<View className="px-4 py-1">
<Text className="text-muted-foreground text-xs italic">{label}</Text>
</View>
);
}

View file

@ -1,11 +0,0 @@
import { View, Text } from 'react-native';
export default function UnreadSeparator() {
return (
<View className="flex-row items-center gap-3 px-4 py-2 my-1">
<View className="flex-1 h-px bg-destructive/40" />
<Text className="text-destructive text-xs font-semibold">New messages</Text>
<View className="flex-1 h-px bg-destructive/40" />
</View>
);
}

View file

@ -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<UserProfile | null>(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 (
<Modal visible={!!userId} transparent animationType="fade" onRequestClose={onClose}>
<Pressable className="flex-1 bg-black/60" onPress={onClose}>
<SafeAreaView className="flex-1 justify-end" edges={['bottom']}>
<Pressable onPress={(e) => e.stopPropagation()}>
<View className="bg-background rounded-t-3xl overflow-hidden border-t border-border">
{/* Handle */}
<View className="items-center pt-3 pb-1">
<View className="w-10 h-1 bg-border rounded-full" />
</View>
{/* Close */}
<View className="absolute top-3 right-4 z-10">
<Pressable onPress={onClose} className="p-1 active:opacity-50">
<X size={20} color="#6b7280" />
</Pressable>
</View>
<ScrollView
contentContainerStyle={{
paddingHorizontal: 24,
paddingTop: 16,
paddingBottom: 32,
alignItems: 'center',
gap: 16,
}}
>
{loading ? (
<ActivityIndicator color="#7c6bff" className="py-10" />
) : profile ? (
<>
{/* Avatar */}
<View className="w-24 h-24 rounded-full bg-surface border-2 border-border overflow-hidden items-center justify-center">
{profile.avatarUrl ? (
<Image
source={{ uri: profile.avatarUrl }}
style={{ width: 96, height: 96 }}
contentFit="cover"
/>
) : (
<Text className="text-foreground text-4xl font-semibold">{initial}</Text>
)}
</View>
{/* Name */}
<View className="items-center gap-1">
<Text className="text-foreground text-xl font-bold">
{profile.displayName}
</Text>
<Text className="text-muted-foreground text-sm" selectable>
{profile.userId}
</Text>
</View>
{/* Actions */}
{profile.userId !== credentials?.userId && (
<Pressable
onPress={handleStartDM}
className="flex-row items-center gap-2 bg-primary rounded-2xl px-6 py-3 active:opacity-70"
>
<ChatCircle size={18} color="#fff" weight="fill" />
<Text className="text-white font-semibold">
{existingDM ? 'Open conversation' : 'Send message'}
</Text>
</Pressable>
)}
</>
) : null}
</ScrollView>
</View>
</Pressable>
</SafeAreaView>
</Pressable>
</Modal>
);
}

View file

@ -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 (
<View className="flex-row items-center gap-3 px-3 py-2.5 min-w-[160px]">
<Pressable
onPress={handleToggle}
className={`w-8 h-8 rounded-full items-center justify-center active:opacity-60 ${isOwn ? 'bg-white/20' : 'bg-primary/10'}`}
>
{status.isBuffering ? (
<ActivityIndicator size={14} color={iconColor} />
) : playing ? (
<Pause size={14} color={iconColor} weight="fill" />
) : (
<Play size={14} color={iconColor} weight="fill" />
)}
</Pressable>
{/* Waveform / progress bar */}
<View
className="flex-1 h-1 rounded-full overflow-hidden"
style={{ backgroundColor: barColor }}
>
<View
style={{ width: `${progress * 100}%`, backgroundColor: fillColor }}
className="h-full rounded-full"
/>
</View>
<Text className={`text-xs tabular-nums ${isOwn ? 'text-white/70' : 'text-muted-foreground'}`}>
{formatDuration(playing || currentTimeMs > 0 ? currentTimeMs : durationMs)}
</Text>
</View>
);
}

View file

@ -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<void>;
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<ReturnType<typeof setInterval> | 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 (
<View className="flex-row items-center gap-4 px-4 py-3 bg-background border-t border-border">
{/* Discard */}
<Pressable
onPress={handleDiscard}
className="w-10 h-10 rounded-full bg-destructive/10 items-center justify-center active:opacity-60"
>
<Trash size={18} color="#ef4444" />
</Pressable>
{/* Recording indicator */}
<View className="flex-1 flex-row items-center gap-3">
<Animated.View
style={{ transform: [{ scale: pulseAnim }] }}
className="w-3 h-3 rounded-full bg-destructive"
/>
<Text className="text-foreground font-mono text-sm">{formatDuration(duration)}</Text>
<Text className="text-muted-foreground text-xs">Recording...</Text>
</View>
{/* Send */}
<Pressable
onPress={handleSend}
disabled={sending || duration < 1}
className={`w-10 h-10 rounded-full items-center justify-center ${duration >= 1 ? 'bg-primary' : 'bg-surface border border-border'} active:opacity-60`}
>
<PaperPlaneRight size={18} color={duration >= 1 ? '#fff' : '#6b7280'} weight="fill" />
</Pressable>
</View>
);
}

View file

@ -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<LoginResult> {
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<LoginResult> {
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<string | null> {
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}`;
}

View file

@ -1,4 +0,0 @@
export * from './types';
export * from './client';
export * from './media';
export { useMatrixStore } from './store';

View file

@ -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}`;
}

View file

@ -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 {};

View file

@ -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<void>;
restoreSession: () => Promise<boolean>;
selectRoom: (roomId: string) => void;
loadRoomMembers: (roomId: string) => void;
sendMessage: (body: string, replyToEventId?: string) => Promise<void>;
sendReaction: (eventId: string, key: string) => Promise<void>;
redactMessage: (eventId: string) => Promise<void>;
sendTyping: (typing: boolean) => Promise<void>;
sendImage: (
fileUri: string,
filename: string,
mimetype: string,
width?: number,
height?: number
) => Promise<void>;
sendFile: (fileUri: string, filename: string, mimetype: string) => Promise<void>;
editMessage: (eventId: string, newBody: string) => Promise<void>;
sendVoice: (fileUri: string, durationMs: number) => Promise<void>;
forwardMessage: (eventId: string, targetRoomId: string) => Promise<void>;
acceptInvite: (roomId: string) => Promise<void>;
declineInvite: (roomId: string) => Promise<void>;
leaveRoom: (roomId: string) => Promise<void>;
logout: () => Promise<void>;
}
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<string, { users: string[]; includesMe: boolean }>();
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<MatrixState>((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<string, any> = { 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,
});
},
}));

View file

@ -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;
}

View file

@ -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<UploadResult> {
// 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<string, string> = {
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';
}

View file

@ -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<boolean> {
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<string | null> {
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<void> {
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<void> {
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<void> {
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();
}

View file

@ -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: [],
};

View file

@ -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"
]
}

View file

@ -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"]

View file

@ -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"
}
}

View file

@ -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();

View file

@ -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);
}

View file

@ -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<string, string> };
}
}
export {};

View file

@ -1,32 +0,0 @@
<!doctype html>
<html lang="de" data-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1, user-scalable=no" />
<!-- PWA Meta Tags -->
<meta name="theme-color" content="#8b5cf6" />
<meta name="application-name" content="Manalink" />
<meta name="description" content="Secure Matrix messaging client" />
<!-- PWA/iOS Meta Tags -->
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Manalink" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/apple-touch-icon.png" />
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="%sveltekit.assets%/favicon.png" />
<!-- Prevent phone number detection -->
<meta name="format-detection" content="telephone=no" />
<title>Manalink</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" class="overscroll-none">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -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);
};

View file

@ -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;
};

View file

@ -1,166 +0,0 @@
<script lang="ts">
import type { BotInfo } from '$lib/data/bots';
import {
Lock,
LockOpen,
CaretDown,
ChatCircle,
// Bot icons
Sparkle,
Robot,
CheckSquare,
CalendarBlank,
AddressBook,
Folders,
Image,
SpeakerHigh,
CloudArrowUp,
ForkKnife,
Plant,
Quotes,
TreeStructure,
Clock,
ChartBar,
MagnifyingGlass,
Cards,
PresentationChart,
} from '@mana/shared-icons';
import { slide } from 'svelte/transition';
import { _ as t } from 'svelte-i18n';
import type { Component } from 'svelte';
interface Props {
bot: BotInfo;
onStartChat: () => void;
}
let { bot, onStartChat }: Props = $props();
let expanded = $state(false);
// Map icon names to components
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const iconMap: Record<string, Component<any>> = {
Sparkle,
ChatCircle,
Robot,
CheckSquare,
CalendarBlank,
AddressBook,
Folders,
Image,
SpeakerHigh,
CloudArrowUp,
ForkKnife,
Plant,
Quotes,
TreeStructure,
Clock,
ChartBar,
MagnifyingGlass,
Cards,
PresentationChart,
};
let IconComponent = $derived(iconMap[bot.icon] || Robot);
</script>
<div class="glass-card rounded-xl overflow-hidden border border-border">
<!-- Header (always visible) -->
<button
class="w-full p-4 text-left hover:bg-surface-hover transition-colors cursor-pointer"
onclick={() => (expanded = !expanded)}
>
<div class="flex items-start gap-3">
<div class="p-3 rounded-lg bg-gradient-to-br {bot.color} shadow-lg flex-shrink-0">
<svelte:component this={IconComponent} size={24} class="text-white" weight="fill" />
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<h3 class="font-semibold text-foreground truncate">{bot.name}</h3>
{#if bot.isGateway}
<span class="text-xs bg-violet-500/20 text-violet-400 px-2 py-0.5 rounded-full">
Gateway
</span>
{/if}
</div>
<p class="text-sm text-muted-foreground line-clamp-2 mt-0.5">{bot.description}</p>
</div>
<CaretDown
size={20}
class="text-muted-foreground transition-transform flex-shrink-0 {expanded
? 'rotate-180'
: ''}"
/>
</div>
<div class="mt-3 flex items-center gap-2 flex-wrap">
{#if bot.requiresAuth}
<span
class="text-xs bg-amber-500/20 text-amber-400 px-2 py-0.5 rounded-full flex items-center gap-1"
>
<Lock size={12} /> Login
</span>
{:else}
<span
class="text-xs bg-green-500/20 text-green-400 px-2 py-0.5 rounded-full flex items-center gap-1"
>
<LockOpen size={12} />
{$t('bots.free')}
</span>
{/if}
<span class="text-xs bg-primary/20 text-primary px-2 py-0.5 rounded-full">
{bot.commands.length}
{$t('bots.commands')}
</span>
</div>
</button>
<!-- Expandable Details -->
{#if expanded}
<div transition:slide={{ duration: 200 }} class="border-t border-white/10 p-4 space-y-4">
<!-- Long Description -->
{#if bot.longDescription}
<p class="text-sm text-muted-foreground">{bot.longDescription}</p>
{/if}
<!-- Commands -->
<div>
<h4 class="text-sm font-medium text-foreground mb-2">{$t('bots.commands')}</h4>
<div class="space-y-1.5 max-h-48 overflow-y-auto">
{#each bot.commands as cmd}
<div class="text-xs bg-muted rounded px-2 py-1.5">
<code class="text-primary font-mono">{cmd.command}</code>
{#if cmd.aliases?.length}
<span class="text-muted-foreground"> ({cmd.aliases.join(', ')})</span>
{/if}
<span class="text-muted-foreground ml-2">- {cmd.description}</span>
{#if cmd.example}
<div class="mt-1 text-muted-foreground/70 italic">
{$t('bots.example')}: <code class="text-foreground/60">{cmd.example}</code>
</div>
{/if}
</div>
{/each}
</div>
</div>
<!-- Matrix User ID -->
<div class="text-xs text-muted-foreground">
<span class="font-medium">Matrix:</span>
<code class="ml-1 text-foreground/60">{bot.matrixUserId}</code>
</div>
<!-- Chat Button -->
<button
class="w-full bg-gradient-to-r from-violet-500 to-purple-600 text-white rounded-lg py-2.5 flex items-center justify-center gap-2 font-medium hover:from-violet-600 hover:to-purple-700 transition-all shadow-lg hover:shadow-xl cursor-pointer"
onclick={(e) => {
e.stopPropagation();
onStartChat();
}}
>
<ChatCircle size={18} weight="fill" />
{$t('bots.startChat')}
</button>
</div>
{/if}
</div>

View file

@ -1,224 +0,0 @@
<script lang="ts">
import { matrixStore, type SimpleCall } from '$lib/matrix';
import {
PhoneDisconnect,
Microphone,
MicrophoneSlash,
VideoCamera,
VideoCameraSlash,
Screencast,
User,
} from '@mana/shared-icons';
import { onDestroy } from 'svelte';
interface Props {
call: SimpleCall;
onHangup?: () => void;
}
let { call, onHangup }: Props = $props();
// Video refs need to work with bind:this - not reactive
let localVideoRef: HTMLVideoElement | undefined = $state();
let remoteVideoRef: HTMLVideoElement | undefined = $state();
let callDuration = $state(0);
let durationInterval: ReturnType<typeof setInterval> | null = null;
// Start duration timer when call connects
$effect(() => {
if (call.state === 'connected' && !durationInterval) {
durationInterval = setInterval(() => {
callDuration++;
}, 1000);
}
});
// Attach local stream to video element
$effect(() => {
if (localVideoRef && call.localStream) {
localVideoRef.srcObject = call.localStream;
}
});
// Attach remote stream to video element
$effect(() => {
if (remoteVideoRef && call.remoteStream) {
remoteVideoRef.srcObject = call.remoteStream;
}
});
onDestroy(() => {
if (durationInterval) {
clearInterval(durationInterval);
}
});
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
function handleMicToggle() {
matrixStore.toggleMicMute();
}
function handleCameraToggle() {
matrixStore.toggleCameraMute();
}
async function handleScreenShare() {
await matrixStore.toggleScreenShare();
}
function handleHangup() {
matrixStore.hangupCall();
onHangup?.();
}
function getStateText(state: string): string {
switch (state) {
case 'invite_sent':
return 'Anrufen...';
case 'ringing':
return 'Klingelt...';
case 'connecting':
return 'Verbinden...';
case 'connected':
return formatDuration(callDuration);
case 'ended':
return 'Beendet';
default:
return 'Verbinden...';
}
}
</script>
<div class="fixed inset-0 z-[100] bg-zinc-900 flex flex-col">
<!-- Header -->
<div class="flex items-center justify-between p-4 bg-black/30">
<div class="flex items-center gap-3">
{#if call.opponentAvatar}
<img
src={call.opponentAvatar}
alt={call.opponentName}
class="w-10 h-10 rounded-full object-cover"
/>
{:else}
<div
class="w-10 h-10 rounded-full bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center"
>
<User class="w-5 h-5 text-white" />
</div>
{/if}
<div>
<p class="font-medium text-white">{call.opponentName || 'Unbekannt'}</p>
<p class="text-sm text-white/70 flex items-center gap-2">
<span>{call.type === 'video' ? 'Videoanruf' : 'Sprachanruf'} · {getStateText(call.state)}</span>
{#if call.isScreenSharing}
<span class="flex items-center gap-1 px-2 py-0.5 bg-violet-500/30 rounded-full text-violet-300 text-xs">
<Screencast class="w-3 h-3" />
Bildschirmfreigabe
</span>
{/if}
</p>
</div>
</div>
</div>
<!-- Video area -->
<div class="flex-1 relative">
{#if call.type === 'video'}
<!-- Remote video (full screen) -->
<!-- svelte-ignore a11y_media_has_caption -->
<video bind:this={remoteVideoRef} autoplay playsinline class="w-full h-full object-cover"
></video>
<!-- Local video (picture-in-picture) -->
<div
class="absolute bottom-24 right-4 w-32 h-48 rounded-xl overflow-hidden shadow-xl border-2 border-white/20"
>
<video
bind:this={localVideoRef}
autoplay
playsinline
muted
class="w-full h-full object-cover"
></video>
</div>
{:else}
<!-- Voice call - show avatar -->
<div class="flex flex-col items-center justify-center h-full">
{#if call.opponentAvatar}
<img
src={call.opponentAvatar}
alt={call.opponentName}
class="w-32 h-32 rounded-full object-cover mb-6 ring-4 ring-white/20"
/>
{:else}
<div
class="w-32 h-32 rounded-full bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center mb-6 ring-4 ring-white/20"
>
<User class="w-16 h-16 text-white" />
</div>
{/if}
<p class="text-2xl font-semibold text-white">{call.opponentName || 'Unbekannt'}</p>
<p class="text-lg text-white/70 mt-2">{getStateText(call.state)}</p>
</div>
{/if}
</div>
<!-- Controls -->
<div
class="flex items-center justify-center gap-6 p-8 bg-gradient-to-t from-black/50 to-transparent"
>
<!-- Mute mic -->
<button
class="w-14 h-14 rounded-full flex items-center justify-center transition-colors
{call.isMicMuted ? 'bg-red-500 hover:bg-red-600' : 'bg-white/20 hover:bg-white/30'}"
onclick={handleMicToggle}
title={call.isMicMuted ? 'Mikrofon aktivieren' : 'Mikrofon stumm'}
>
{#if call.isMicMuted}
<MicrophoneSlash class="w-6 h-6 text-white" />
{:else}
<Microphone class="w-6 h-6 text-white" />
{/if}
</button>
<!-- Mute camera (video calls only) -->
{#if call.type === 'video'}
<button
class="w-14 h-14 rounded-full flex items-center justify-center transition-colors
{call.isCameraMuted ? 'bg-red-500 hover:bg-red-600' : 'bg-white/20 hover:bg-white/30'}"
onclick={handleCameraToggle}
title={call.isCameraMuted ? 'Kamera aktivieren' : 'Kamera aus'}
>
{#if call.isCameraMuted}
<VideoCameraSlash class="w-6 h-6 text-white" />
{:else}
<VideoCamera class="w-6 h-6 text-white" />
{/if}
</button>
<!-- Screen share -->
<button
class="w-14 h-14 rounded-full flex items-center justify-center transition-colors
{call.isScreenSharing ? 'bg-violet-500 hover:bg-violet-600' : 'bg-white/20 hover:bg-white/30'}"
onclick={handleScreenShare}
title={call.isScreenSharing ? 'Bildschirmfreigabe beenden' : 'Bildschirm freigeben'}
>
<Screencast class="w-6 h-6 text-white" />
</button>
{/if}
<!-- Hang up -->
<button
class="w-16 h-16 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center transition-colors"
onclick={handleHangup}
title="Auflegen"
>
<PhoneDisconnect class="w-7 h-7 text-white" />
</button>
</div>
</div>

View file

@ -1,123 +0,0 @@
<script lang="ts">
import { matrixStore, type SimpleCall } from '$lib/matrix';
import { Phone, PhoneDisconnect, VideoCamera, User } from '@mana/shared-icons';
interface Props {
call: SimpleCall;
onAnswer?: () => void;
onReject?: () => void;
}
let { call, onAnswer, onReject }: Props = $props();
function handleAnswer() {
matrixStore.answerCall();
onAnswer?.();
}
function handleReject() {
matrixStore.rejectCall();
onReject?.();
}
</script>
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div class="bg-zinc-900 rounded-3xl p-8 shadow-2xl max-w-sm w-full mx-4 animate-bounce-in">
<!-- Caller info -->
<div class="flex flex-col items-center text-center mb-8">
{#if call.opponentAvatar}
<img
src={call.opponentAvatar}
alt={call.opponentName}
class="w-24 h-24 rounded-full object-cover mb-4 ring-4 ring-violet-500/50 animate-pulse"
/>
{:else}
<div
class="w-24 h-24 rounded-full bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center mb-4 ring-4 ring-violet-500/50 animate-pulse"
>
<User class="w-12 h-12 text-white" />
</div>
{/if}
<p class="text-xl font-semibold text-white">{call.opponentName || 'Unbekannt'}</p>
<p class="text-white/70 mt-1">
{call.type === 'video' ? 'Eingehender Videoanruf' : 'Eingehender Sprachanruf'}
</p>
</div>
<!-- Call type indicator -->
<div class="flex items-center justify-center gap-2 mb-8">
{#if call.type === 'video'}
<VideoCamera class="w-5 h-5 text-violet-400" />
<span class="text-violet-400 text-sm">Video</span>
{:else}
<Phone class="w-5 h-5 text-green-400" />
<span class="text-green-400 text-sm">Audio</span>
{/if}
</div>
<!-- Action buttons -->
<div class="flex items-center justify-center gap-8">
<!-- Reject -->
<button
class="w-16 h-16 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center transition-all hover:scale-110 shadow-lg shadow-red-500/30"
onclick={handleReject}
title="Ablehnen"
>
<PhoneDisconnect class="w-7 h-7 text-white" />
</button>
<!-- Answer -->
<button
class="w-16 h-16 rounded-full bg-green-500 hover:bg-green-600 flex items-center justify-center transition-all hover:scale-110 shadow-lg shadow-green-500/30 animate-ring"
onclick={handleAnswer}
title="Annehmen"
>
<Phone class="w-7 h-7 text-white" />
</button>
</div>
</div>
</div>
<style>
@keyframes bounce-in {
0% {
opacity: 0;
transform: scale(0.8);
}
50% {
transform: scale(1.05);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes ring {
0%,
100% {
transform: scale(1);
}
10%,
30%,
50%,
70%,
90% {
transform: scale(1.1);
}
20%,
40%,
60%,
80% {
transform: scale(0.95);
}
}
.animate-bounce-in {
animation: bounce-in 0.4s ease-out;
}
.animate-ring {
animation: ring 2s ease-in-out infinite;
}
</style>

View file

@ -1,2 +0,0 @@
export { default as CallView } from './CallView.svelte';
export { default as IncomingCallDialog } from './IncomingCallDialog.svelte';

View file

@ -1,313 +0,0 @@
<script lang="ts">
import { matrixStore } from '$lib/matrix';
import { X, Users, ChatCircle, Lock, Globe, CircleNotch } from '@mana/shared-icons';
interface Props {
open: boolean;
onClose: () => void;
onCreated?: (roomId: string) => void;
}
let { open, onClose, onCreated }: Props = $props();
let name = $state('');
let topic = $state('');
let isPrivate = $state(true);
let isDirect = $state(false);
let inviteUserId = $state('');
let loading = $state(false);
let error = $state<string | null>(null);
// User search
let searchQuery = $state('');
let searchResults = $state<{ userId: string; displayName?: string; avatarUrl?: string }[]>([]);
let selectedUsers = $state<{ userId: string; displayName?: string }[]>([]);
let searching = $state(false);
let searchTimeout: ReturnType<typeof setTimeout>;
function handleSearchInput() {
clearTimeout(searchTimeout);
if (searchQuery.trim().length < 2) {
searchResults = [];
return;
}
searchTimeout = setTimeout(async () => {
searching = true;
searchResults = await matrixStore.searchUsers(searchQuery);
searching = false;
}, 300);
}
function selectUser(user: { userId: string; displayName?: string }) {
if (!selectedUsers.find((u) => u.userId === user.userId)) {
selectedUsers = [...selectedUsers, user];
}
searchQuery = '';
searchResults = [];
}
function removeUser(userId: string) {
selectedUsers = selectedUsers.filter((u) => u.userId !== userId);
}
async function handleCreate() {
if (!name.trim() && !isDirect) {
error = 'Bitte gib einen Namen ein';
return;
}
if (isDirect && selectedUsers.length === 0) {
error = 'Bitte wähle mindestens einen Benutzer';
return;
}
loading = true;
error = null;
const roomId = await matrixStore.createRoom({
name: isDirect ? undefined : name.trim(),
topic: topic.trim() || undefined,
isDirect,
invite: selectedUsers.map((u) => u.userId),
});
loading = false;
if (roomId) {
onCreated?.(roomId);
resetForm();
onClose();
} else {
error = matrixStore.error || 'Raum konnte nicht erstellt werden';
}
}
function resetForm() {
name = '';
topic = '';
isPrivate = true;
isDirect = false;
searchQuery = '';
searchResults = [];
selectedUsers = [];
error = null;
}
function handleClose() {
resetForm();
onClose();
}
</script>
{#if open}
<!-- Backdrop -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
onclick={handleClose}
>
<!-- Dialog -->
<div
class="w-full max-w-md rounded-xl bg-surface-elevated shadow-xl max-h-[90vh] overflow-y-auto"
onclick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-border px-6 py-4">
<h2 class="text-xl font-semibold text-foreground">Neuer Chat</h2>
<button
class="p-2 rounded-lg hover:bg-surface-hover transition-colors"
onclick={handleClose}
>
<X class="h-5 w-5" />
</button>
</div>
<!-- Content -->
<div class="space-y-4 px-6 py-4">
<!-- Type Selection -->
<div class="flex gap-2">
<button
class="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium transition-colors
{isDirect
? 'bg-primary text-primary-foreground'
: 'bg-surface hover:bg-surface-hover text-foreground border border-border'}"
onclick={() => (isDirect = true)}
>
<ChatCircle class="h-4 w-4" />
Direktnachricht
</button>
<button
class="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium transition-colors
{!isDirect
? 'bg-primary text-primary-foreground'
: 'bg-surface hover:bg-surface-hover text-foreground border border-border'}"
onclick={() => (isDirect = false)}
>
<Users class="h-4 w-4" />
Gruppenraum
</button>
</div>
<!-- Room Name (only for groups) -->
{#if !isDirect}
<div class="space-y-1.5">
<label class="text-sm font-medium text-foreground" for="room-name">Raumname</label>
<input
id="room-name"
type="text"
bind:value={name}
class="w-full px-4 py-2.5 rounded-lg bg-surface border border-border text-foreground
focus:outline-none focus:ring-2 focus:ring-primary placeholder:text-muted-foreground"
placeholder="z.B. Team Chat"
/>
</div>
<div class="space-y-1.5">
<label class="text-sm font-medium text-foreground" for="room-topic"
>Beschreibung (optional)</label
>
<input
id="room-topic"
type="text"
bind:value={topic}
class="w-full px-4 py-2.5 rounded-lg bg-surface border border-border text-foreground
focus:outline-none focus:ring-2 focus:ring-primary placeholder:text-muted-foreground"
placeholder="Worum geht es in diesem Raum?"
/>
</div>
<!-- Privacy -->
<div class="flex items-center justify-between p-3 rounded-lg bg-muted">
<span class="flex items-center gap-2 text-sm text-foreground">
{#if isPrivate}
<Lock class="h-4 w-4" />
Privater Raum
{:else}
<Globe class="h-4 w-4" />
Öffentlicher Raum
{/if}
</span>
<button
class="relative w-11 h-6 rounded-full transition-colors {isPrivate
? 'bg-primary'
: 'bg-muted-foreground/30'}"
onclick={() => (isPrivate = !isPrivate)}
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform
{isPrivate ? 'translate-x-5' : 'translate-x-0'}"
></span>
</button>
</div>
<p class="text-xs text-muted-foreground">
{isPrivate
? 'Nur eingeladene Benutzer können beitreten'
: 'Jeder kann diesen Raum finden und beitreten'}
</p>
{/if}
<!-- User Search -->
<div class="space-y-1.5">
<label class="text-sm font-medium text-foreground" for="user-search">
{isDirect ? 'Mit wem möchtest du chatten?' : 'Benutzer einladen (optional)'}
</label>
<div class="relative">
<input
id="user-search"
type="text"
bind:value={searchQuery}
oninput={handleSearchInput}
class="w-full px-4 py-2.5 rounded-lg bg-surface border border-border text-foreground
focus:outline-none focus:ring-2 focus:ring-primary placeholder:text-muted-foreground"
placeholder="@benutzer:server.de oder Name"
/>
{#if searching}
<CircleNotch class="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin" />
{/if}
</div>
<!-- Search Results -->
{#if searchResults.length > 0}
<div
class="mt-2 rounded-lg bg-surface border border-border overflow-hidden max-h-40 overflow-y-auto"
>
{#each searchResults as user}
<button
class="flex items-center gap-3 w-full px-3 py-2 hover:bg-surface-hover transition-colors"
onclick={() => selectUser(user)}
>
<div
class="w-8 h-8 rounded-full bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center text-white text-sm"
>
{#if user.avatarUrl}
<img src={user.avatarUrl} alt="" class="w-8 h-8 rounded-full object-cover" />
{:else}
{user.displayName?.[0] || user.userId[1]}
{/if}
</div>
<div class="flex-1 text-left min-w-0">
<p class="font-medium text-foreground truncate">
{user.displayName || user.userId}
</p>
{#if user.displayName}
<p class="text-xs text-muted-foreground truncate">{user.userId}</p>
{/if}
</div>
</button>
{/each}
</div>
{/if}
</div>
<!-- Selected Users -->
{#if selectedUsers.length > 0}
<div class="flex flex-wrap gap-2">
{#each selectedUsers as user}
<span
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-primary/10 text-primary text-sm"
>
{user.displayName || user.userId}
<button
class="hover:bg-primary/20 rounded-full p-0.5 transition-colors"
onclick={() => removeUser(user.userId)}
>
<X class="h-3 w-3" />
</button>
</span>
{/each}
</div>
{/if}
<!-- Error -->
{#if error}
<div class="px-4 py-3 rounded-lg bg-error/10 text-error text-sm">
{error}
</div>
{/if}
</div>
<!-- Footer -->
<div class="flex justify-end gap-2 border-t border-border px-6 py-4">
<button
class="px-4 py-2.5 rounded-lg hover:bg-surface-hover text-foreground font-medium transition-colors"
onclick={handleClose}
>
Abbrechen
</button>
<button
class="px-4 py-2.5 rounded-lg bg-primary hover:bg-primary/90 text-primary-foreground font-medium transition-colors
disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
onclick={handleCreate}
disabled={loading}
>
{#if loading}
<CircleNotch class="h-4 w-4 animate-spin" />
{/if}
{isDirect ? 'Chat starten' : 'Raum erstellen'}
</button>
</div>
</div>
</div>
{/if}

View file

@ -1,27 +0,0 @@
<script lang="ts">
import { UploadSimple } from '@mana/shared-icons';
interface Props {
visible: boolean;
}
let { visible }: Props = $props();
</script>
{#if visible}
<div
class="fixed inset-0 z-50 bg-primary/10 backdrop-blur-sm flex items-center justify-center pointer-events-none"
>
<div
class="bg-surface-elevated rounded-2xl p-8 shadow-2xl border-2 border-dashed border-primary flex flex-col items-center gap-4"
>
<div class="p-4 rounded-full bg-primary/10">
<UploadSimple class="h-12 w-12 text-primary" />
</div>
<div class="text-center">
<p class="text-lg font-semibold text-foreground">Datei hier ablegen</p>
<p class="text-sm text-muted-foreground mt-1">Bilder, Videos oder Dateien</p>
</div>
</div>
</div>
{/if}

View file

@ -1,179 +0,0 @@
<script lang="ts">
import { matrixStore, type SimpleMessage, type SimpleRoom } from '$lib/matrix';
import { Check, MagnifyingGlass, PaperPlaneTilt, User, Users, X } from '@mana/shared-icons';
interface Props {
open: boolean;
message: SimpleMessage | null;
onClose: () => void;
}
let { open, message, onClose }: Props = $props();
let search = $state('');
let sending = $state(false);
let selectedRooms = $state<Set<string>>(new Set());
// Filter rooms by search
let filteredRooms = $derived(
matrixStore.rooms
.filter(
(room) =>
room.membership === 'join' &&
room.id !== matrixStore.currentRoomId &&
room.name.toLowerCase().includes(search.toLowerCase())
)
.slice(0, 20)
);
function toggleRoom(roomId: string) {
const newSet = new Set(selectedRooms);
if (newSet.has(roomId)) {
newSet.delete(roomId);
} else {
newSet.add(roomId);
}
selectedRooms = newSet;
}
async function handleForward() {
if (!message || selectedRooms.size === 0) return;
sending = true;
// Forward to each selected room
for (const roomId of selectedRooms) {
// Create forward message with quote
const forwardText = `> ${message.senderName}: ${message.body}\n\nWeitergeleitete Nachricht`;
await matrixStore.sendMessageToRoom(roomId, forwardText);
}
sending = false;
selectedRooms = new Set();
search = '';
onClose();
}
function handleClose() {
selectedRooms = new Set();
search = '';
onClose();
}
</script>
{#if open && message}
<!-- Backdrop -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
role="dialog"
aria-modal="true"
>
<!-- Dialog -->
<div
class="w-full max-w-md rounded-2xl bg-surface-elevated shadow-2xl overflow-hidden"
onclick={(e) => e.stopPropagation()}
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-border px-4 py-3">
<h2 class="text-lg font-semibold">Nachricht weiterleiten</h2>
<button
class="p-1.5 rounded-lg hover:bg-surface-hover transition-colors"
onclick={handleClose}
>
<X class="h-5 w-5" />
</button>
</div>
<!-- Message Preview -->
<div class="px-4 py-3 bg-muted border-b border-border">
<p class="text-xs text-muted-foreground mb-1">Von {message.senderName}</p>
<p class="text-sm line-clamp-3">{message.body}</p>
</div>
<!-- Search -->
<div class="p-4 border-b border-border">
<div class="relative">
<MagnifyingGlass
class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground"
/>
<input
type="text"
bind:value={search}
placeholder="Chat suchen..."
class="w-full pl-10 pr-4 py-2.5 rounded-xl bg-surface border border-border
text-sm focus:outline-none focus:ring-2 focus:ring-violet-500"
/>
</div>
</div>
<!-- Room List -->
<div class="max-h-64 overflow-y-auto">
{#if filteredRooms.length === 0}
<p class="px-4 py-8 text-center text-muted-foreground">Keine Chats gefunden</p>
{:else}
{#each filteredRooms as room (room.id)}
<button
class="flex items-center gap-3 w-full px-4 py-3 transition-colors text-left
{selectedRooms.has(room.id) ? 'bg-violet-500/10' : 'hover:bg-surface-hover'}"
onclick={() => toggleRoom(room.id)}
>
<!-- Checkbox -->
<div
class="w-5 h-5 rounded-md border-2 flex items-center justify-center transition-colors
{selectedRooms.has(room.id)
? 'bg-violet-500 border-violet-500'
: 'border-black/20 dark:border-white/20'}"
>
{#if selectedRooms.has(room.id)}
<Check size={14} class="text-white" />
{/if}
</div>
<!-- Avatar -->
<div
class="w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0
bg-gradient-to-br from-violet-500 to-purple-600 text-white"
>
{#if room.avatar}
<img
src={room.avatar}
alt={room.name}
class="w-10 h-10 rounded-full object-cover"
/>
{:else if room.isDirect}
<User class="w-5 h-5" />
{:else}
<Users class="w-5 h-5" />
{/if}
</div>
<!-- Room info -->
<div class="flex-1 min-w-0">
<p class="font-medium truncate">{room.name}</p>
<p class="text-xs text-muted-foreground">
{room.isDirect ? 'Direktnachricht' : `${room.memberCount} Mitglieder`}
</p>
</div>
</button>
{/each}
{/if}
</div>
<!-- Footer -->
<div class="flex items-center justify-between border-t border-border px-4 py-3">
<p class="text-sm text-muted-foreground">
{selectedRooms.size} ausgewählt
</p>
<button
class="flex items-center gap-2 px-4 py-2 rounded-xl bg-violet-500 hover:bg-violet-600 text-white font-medium transition-colors
disabled:opacity-50 disabled:cursor-not-allowed"
disabled={selectedRooms.size === 0 || sending}
onclick={handleForward}
>
<PaperPlaneTilt class="h-4 w-4" weight="bold" />
{sending ? 'Sende...' : 'Weiterleiten'}
</button>
</div>
</div>
</div>
{/if}

View file

@ -1,845 +0,0 @@
<script lang="ts">
import type { SimpleMessage } from '$lib/matrix';
import { matrixStore } from '$lib/matrix';
import { format, isToday, isYesterday, isValid } from 'date-fns';
import { de } from 'date-fns/locale';
import {
ArrowBendUpLeft,
ArrowBendUpRight,
PencilSimple,
Trash,
DotsThree,
DownloadSimple,
File as FileIcon,
Play,
Pause,
Image as ImageIcon,
Lock,
Warning,
Smiley,
Check,
Checks,
} from '@mana/shared-icons';
interface Props {
message: SimpleMessage;
showAvatar?: boolean;
showTimestamp?: boolean;
showEncryptionBadge?: boolean;
isSameSender?: boolean;
isLastInGroup?: boolean;
onReply?: (message: SimpleMessage) => void;
onEdit?: (message: SimpleMessage) => void;
onForward?: (message: SimpleMessage) => void;
}
let {
message,
showAvatar = true,
showTimestamp = false,
showEncryptionBadge = false,
isSameSender = false,
isLastInGroup = true,
onReply,
onEdit,
onForward,
}: Props = $props();
// Check if message is a decryption error (body starts with "Unable to decrypt:")
let isDecryptionError = $derived(
message.body.startsWith('Unable to decrypt:') || message.body.includes('** Unable to decrypt')
);
// Check if message contains an error/failure
let isErrorMessage = $derived(
!isDecryptionError &&
(message.body.toLowerCase().includes('fehler') ||
message.body.toLowerCase().includes('error') ||
message.body.toLowerCase().includes('failed') ||
message.body.toLowerCase().includes('fehlgeschlagen'))
);
let showActions = $state(false);
let showMobileActions = $state(false);
let showEmojiPicker = $state(false);
let imageLoading = $state(true);
let imageError = $state(false);
// Long-press for mobile
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
let touchMoved = false;
function handleTouchStart() {
touchMoved = false;
longPressTimer = setTimeout(() => {
if (!touchMoved && !message.redacted) {
showMobileActions = true;
// Vibrate if available
if (navigator.vibrate) navigator.vibrate(20);
}
}, 500);
}
function handleTouchMove() {
touchMoved = true;
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
function handleTouchEnd() {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
function closeMobileActions() {
showMobileActions = false;
showEmojiPicker = false;
showFullPicker = false;
}
// Quick reaction emojis (always visible)
const quickEmojis = ['👍', '❤️', '😂', '😮', '😢', '🎉'];
// Extended emoji categories for full picker
const emojiCategories = [
{ name: 'Häufig', emojis: ['👍', '👎', '❤️', '😂', '😮', '😢', '🎉', '🔥', '💯', '✨'] },
{
name: 'Smileys',
emojis: [
'😀',
'😃',
'😄',
'😁',
'😆',
'🥹',
'😅',
'🤣',
'😊',
'😇',
'🙂',
'😉',
'😌',
'😍',
'🥰',
'😘',
],
},
{
name: 'Gesten',
emojis: ['👏', '🙌', '👐', '🤝', '🙏', '✌️', '🤞', '🤟', '🤘', '👌', '🤌', '👋', '💪', '👀'],
},
{
name: 'Symbole',
emojis: ['✅', '❌', '⭐', '💫', '🌟', '💡', '🎯', '🚀', '💎', '🏆', '🔑', '📌', '🔔', '💬'],
},
{
name: 'Tiere',
emojis: ['🐱', '🐶', '🐻', '🦊', '🐼', '🐨', '🦁', '🐸', '🐵', '🦄', '🐝', '🦋'],
},
{
name: 'Essen',
emojis: ['🍕', '🍔', '🍟', '🌮', '🍜', '🍣', '🍦', '🍩', '🍪', '☕', '🍺', '🍷'],
},
];
let showFullPicker = $state(false);
let selectedCategory = $state(0);
async function handleReaction(emoji: string) {
showEmojiPicker = false;
await matrixStore.reactToMessage(message.id, emoji);
}
// URL detection regex
const urlRegex = /(https?:\/\/[^\s<>"']+)/gi;
// Escape HTML entities to prevent XSS
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
// Apply markdown formatting (bold, italic, code, strikethrough)
function applyMarkdown(text: string, isOwn: boolean): string {
const codeColor = isOwn ? 'bg-white/20 text-white' : 'bg-muted';
// Inline code (backticks) - process first to avoid conflicts
text = text.replace(
/`([^`]+)`/g,
`<code class="px-1 py-0.5 rounded text-sm font-mono ${codeColor}">$1</code>`
);
// Bold (**text** or __text__)
text = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
text = text.replace(/__([^_]+)__/g, '<strong>$1</strong>');
// Italic (*text* or _text_) - be careful not to match inside URLs
text = text.replace(/(?<![*_])\*(?!\*)([^*]+)\*(?!\*)/g, '<em>$1</em>');
text = text.replace(/(?<![*_])_(?!_)([^_]+)_(?!_)/g, '<em>$1</em>');
// Strikethrough (~~text~~)
text = text.replace(/~~([^~]+)~~/g, '<del class="opacity-70">$1</del>');
return text;
}
// Convert URLs to clickable links and apply markdown
function formatMessageBody(text: string, isOwn: boolean): string {
const escaped = escapeHtml(text);
const linkClass = isOwn
? 'underline underline-offset-2 hover:opacity-80'
: 'text-primary underline underline-offset-2 hover:opacity-80';
// First apply markdown
let formatted = applyMarkdown(escaped, isOwn);
// Then linkify URLs
formatted = formatted.replace(urlRegex, (url) => {
return `<a href="${url}" target="_blank" rel="noopener noreferrer" class="${linkClass}">${url}</a>`;
});
return formatted;
}
// Extract first URL for preview
let firstUrl = $derived(() => {
const match = message.body.match(urlRegex);
return match ? match[0] : null;
});
// Get domain from URL
function getDomain(url: string): string {
try {
return new URL(url).hostname;
} catch {
return url;
}
}
// Audio player state
let audioElement: HTMLAudioElement | null = $state(null);
let isPlaying = $state(false);
let audioProgress = $state(0);
let audioDuration = $state(0);
function toggleAudio() {
if (!audioElement) return;
if (isPlaying) {
audioElement.pause();
} else {
audioElement.play();
}
}
function handleAudioTimeUpdate() {
if (!audioElement) return;
audioProgress = audioElement.currentTime;
}
function handleAudioLoadedMetadata() {
if (!audioElement) return;
audioDuration = audioElement.duration;
}
function handleAudioEnded() {
isPlaying = false;
audioProgress = 0;
}
function seekAudio(e: MouseEvent) {
if (!audioElement || !audioDuration) return;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const percent = (e.clientX - rect.left) / rect.width;
audioElement.currentTime = percent * audioDuration;
}
function formatAudioTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
let formattedTime = $derived(() => {
const date = new Date(message.timestamp);
if (!isValid(date)) return '--:--';
return format(date, 'HH:mm');
});
let formattedDate = $derived(() => {
const date = new Date(message.timestamp);
if (!isValid(date)) return '';
if (isToday(date)) return 'Heute';
if (isYesterday(date)) return 'Gestern';
return format(date, 'EEEE, d. MMMM', { locale: de });
});
let initials = $derived(
message.senderName
.split(' ')
.map((w) => w[0])
.join('')
.substring(0, 2)
.toUpperCase()
);
// Dynamic bubble rounding based on grouping position
let bubbleRounding = $derived(() => {
if (message.isOwn) {
// Own messages: flat on right side for grouping
if (isSameSender && !showTimestamp && !isLastInGroup)
return 'rounded-2xl rounded-tr-md rounded-br-md';
if (isSameSender && !showTimestamp) return 'rounded-2xl rounded-tr-md';
if (!isLastInGroup) return 'rounded-2xl rounded-br-md';
return 'rounded-2xl rounded-tr-md';
} else {
// Other messages: flat on left side for grouping
if (isSameSender && !showTimestamp && !isLastInGroup)
return 'rounded-2xl rounded-tl-md rounded-bl-md';
if (isSameSender && !showTimestamp) return 'rounded-2xl rounded-tl-md';
if (!isLastInGroup) return 'rounded-2xl rounded-bl-md';
return 'rounded-2xl rounded-tl-md';
}
});
// Get media URL for display
let mediaUrl = $derived(
message.media?.mxcUrl ? matrixStore.getMediaUrl(message.media.mxcUrl) : null
);
let thumbnailUrl = $derived(
message.media?.thumbnailUrl
? matrixStore.getMediaUrl(message.media.thumbnailUrl)
: message.media?.mxcUrl
? matrixStore.getMediaUrl(message.media.mxcUrl, 400, 400)
: null
);
// Format file size
function formatFileSize(bytes?: number): string {
if (!bytes) return '';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
// Handle message deletion
async function handleDelete() {
if (confirm('Nachricht wirklich löschen?')) {
await matrixStore.deleteMessage(message.id);
}
}
</script>
<!-- Date separator -->
{#if showTimestamp}
<div class="my-4 flex items-center gap-4">
<div class="h-px flex-1 bg-border"></div>
<span class="text-xs text-muted-foreground">{formattedDate()}</span>
<div class="h-px flex-1 bg-border"></div>
</div>
{/if}
<!-- Message -->
<div
class="group flex gap-3 animate-fade-in {message.isOwn ? 'flex-row-reverse' : 'flex-row'}
{isSameSender && !showTimestamp ? 'mt-0.5' : 'mt-4'}
{isLastInGroup ? 'mb-1' : 'mb-0'}"
class:opacity-50={message.redacted}
role="article"
onmouseenter={() => (showActions = true)}
onmouseleave={() => (showActions = false)}
ontouchstart={handleTouchStart}
ontouchmove={handleTouchMove}
ontouchend={handleTouchEnd}
ontouchcancel={handleTouchEnd}
>
<!-- Avatar -->
{#if showAvatar}
<div
class="flex-shrink-0 w-9 h-9 rounded-full flex items-center justify-center shadow-md
{message.isOwn
? 'bg-gradient-to-br from-blue-500 to-indigo-600 text-white'
: 'bg-gradient-to-br from-violet-500 to-purple-600 text-white'}"
>
<span class="text-xs font-semibold">{initials}</span>
</div>
{:else}
<div class="w-9 flex-shrink-0"></div>
{/if}
<!-- Message Content -->
<div
class="flex flex-col {message.isOwn
? 'items-end'
: 'items-start'} max-w-[80%] sm:max-w-[75%] relative"
>
<!-- Sender name (for others only) -->
{#if showAvatar && !message.isOwn}
<span class="text-xs text-muted-foreground mb-1 px-1">{message.senderName}</span>
{/if}
<!-- Reply preview -->
{#if message.replyTo && message.replyToBody}
<div
class="mb-1 flex items-center gap-2 rounded-lg glass-card px-3 py-1.5 text-sm max-w-full"
>
<ArrowBendUpLeft class="h-3 w-3 flex-shrink-0 text-muted-foreground" />
<span class="truncate text-muted-foreground text-xs">{message.replyToBody}</span>
</div>
{/if}
<!-- Message Bubble -->
<div
class="relative px-4 py-3 shadow-md {bubbleRounding()}
{isErrorMessage && !message.isOwn
? 'bg-red-500/10 text-foreground border border-red-500/30'
: message.isOwn
? 'bg-gradient-to-br from-blue-500 to-indigo-600 text-white'
: 'bg-surface text-foreground border border-border'}"
>
{#if message.redacted}
<p class="italic text-white/70">Nachricht wurde gelöscht</p>
{:else if isDecryptionError}
<!-- Decryption error -->
<div class="flex items-center gap-2 text-amber-200">
<Warning class="h-4 w-4 flex-shrink-0" />
<span class="text-sm"> Kann nicht entschlüsselt werden </span>
</div>
{:else if message.type === 'm.image' && thumbnailUrl}
<!-- Image message -->
<div class="relative">
{#if imageLoading}
<div class="flex h-48 w-full items-center justify-center rounded-lg bg-muted">
<ImageIcon class="h-8 w-8 animate-pulse text-white/50" />
</div>
{/if}
{#if imageError}
<div class="flex h-32 w-full items-center justify-center rounded-lg bg-muted">
<p class="text-sm text-white/70">Bild konnte nicht geladen werden</p>
</div>
{:else}
<img
src={thumbnailUrl}
alt={message.body}
class="max-h-80 max-w-xs cursor-pointer rounded-lg object-contain"
class:hidden={imageLoading}
onload={() => (imageLoading = false)}
onerror={() => {
imageLoading = false;
imageError = true;
}}
onclick={() => mediaUrl && window.open(mediaUrl, '_blank')}
/>
{/if}
</div>
{:else if message.type === 'm.video' && thumbnailUrl}
<!-- Video message -->
<div class="relative">
<div class="group/video relative">
<img src={thumbnailUrl} alt={message.body} class="rounded-lg max-w-xs" />
<div
class="absolute inset-0 flex items-center justify-center bg-black/30 opacity-0 transition-opacity group-hover/video:opacity-100 rounded-lg"
>
<Play class="h-12 w-12 text-white" />
</div>
</div>
{#if message.media?.duration}
<span
class="absolute bottom-2 right-2 rounded bg-black/60 px-1.5 py-0.5 text-xs text-white"
>
{Math.floor(message.media.duration / 60)}:{(message.media.duration % 60)
.toString()
.padStart(2, '0')}
</span>
{/if}
</div>
{:else if message.type === 'm.audio'}
<!-- Audio message (voice note) -->
<div
class="flex items-center gap-3 rounded-lg {message.isOwn
? 'bg-white/20'
: 'bg-muted'} p-3 min-w-[220px]"
>
<!-- Hidden audio element -->
{#if mediaUrl}
<audio
bind:this={audioElement}
src={mediaUrl}
onplay={() => (isPlaying = true)}
onpause={() => (isPlaying = false)}
ontimeupdate={handleAudioTimeUpdate}
onloadedmetadata={handleAudioLoadedMetadata}
onended={handleAudioEnded}
></audio>
{/if}
<!-- Play/Pause button -->
<button
class="flex-shrink-0 rounded-full {message.isOwn
? 'bg-white/20 hover:bg-white/30'
: 'bg-primary/10 hover:bg-primary/20'} p-2.5 transition-colors"
onclick={toggleAudio}
>
{#if isPlaying}
<Pause
class="h-5 w-5 {message.isOwn ? 'text-white' : 'text-primary'}"
weight="fill"
/>
{:else}
<Play class="h-5 w-5 {message.isOwn ? 'text-white' : 'text-primary'}" weight="fill" />
{/if}
</button>
<!-- Waveform/Progress -->
<div class="flex-1 flex flex-col gap-1">
<!-- Progress bar -->
<button
class="relative h-1.5 w-full rounded-full {message.isOwn
? 'bg-white/20'
: 'bg-muted dark:bg-white/10'} overflow-hidden cursor-pointer"
onclick={seekAudio}
>
<div
class="absolute inset-y-0 left-0 {message.isOwn
? 'bg-white'
: 'bg-primary'} rounded-full transition-all"
style="width: {audioDuration > 0 ? (audioProgress / audioDuration) * 100 : 0}%"
></div>
</button>
<!-- Duration -->
<div
class="flex justify-between text-xs {message.isOwn
? 'text-white/70'
: 'text-muted-foreground'}"
>
<span>{formatAudioTime(audioProgress)}</span>
<span>{formatAudioTime(audioDuration || message.media?.duration || 0)}</span>
</div>
</div>
</div>
{:else if message.type === 'm.file'}
<!-- File message -->
<a
href={mediaUrl}
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-3 rounded-lg {message.isOwn
? 'bg-white/20 hover:bg-white/30'
: 'bg-muted hover:bg-muted dark:hover:bg-white/10'} p-3 transition-colors"
>
<div class="rounded-lg {message.isOwn ? 'bg-white/20' : 'bg-primary/10'} p-2">
<FileIcon class="h-5 w-5 {message.isOwn ? 'text-white' : 'text-primary'}" />
</div>
<div class="min-w-0 flex-1">
<p class="truncate font-medium text-sm">{message.media?.filename || message.body}</p>
<p class="text-xs {message.isOwn ? 'text-white/70' : 'text-muted-foreground'}">
{formatFileSize(message.media?.size)}
{#if message.media?.mimetype}
{message.media.mimetype.split('/')[1]?.toUpperCase()}
{/if}
</p>
</div>
<DownloadSimple
class="h-4 w-4 flex-shrink-0 {message.isOwn
? 'text-white/70'
: 'text-muted-foreground'}"
/>
</a>
{:else if message.type === 'm.emote'}
<p class="italic {message.isOwn ? 'text-white/80' : 'text-muted-foreground'}">
* {message.senderName}
{message.body}
</p>
{:else if message.type === 'm.notice'}
<p class="text-sm {message.isOwn ? 'text-white/80' : 'text-muted-foreground'}">
{message.body}
</p>
{:else}
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<p class="whitespace-pre-wrap break-words text-[15px] leading-relaxed">
{@html formatMessageBody(message.body, message.isOwn)}
</p>
<!-- Link Preview Card -->
{#if firstUrl()}
<a
href={firstUrl()}
target="_blank"
rel="noopener noreferrer"
class="mt-2 flex items-center gap-2 rounded-lg {message.isOwn
? 'bg-white/10 hover:bg-white/20'
: 'bg-muted hover:bg-muted dark:hover:bg-white/10'} p-2 transition-colors"
>
<img
src="https://www.google.com/s2/favicons?domain={getDomain(firstUrl() || '')}&sz=32"
alt=""
class="h-5 w-5 rounded-sm"
onerror={(e) => ((e.currentTarget as HTMLImageElement).style.display = 'none')}
/>
<span
class="text-xs truncate {message.isOwn ? 'text-white/80' : 'text-muted-foreground'}"
>
{getDomain(firstUrl() || '')}
</span>
</a>
{/if}
{/if}
{#if message.edited}
<span class="text-xs {message.isOwn ? 'text-white/60' : 'text-muted-foreground'} mt-1 block"
>(bearbeitet)</span
>
{/if}
{#if showEncryptionBadge}
<Lock class="absolute -bottom-1 -right-1 h-3 w-3 text-green-500" />
{/if}
</div>
<!-- Reactions display -->
{#if message.reactions && message.reactions.length > 0}
<div class="flex flex-wrap gap-1 mt-1.5 {message.isOwn ? 'justify-end' : 'justify-start'}">
{#each message.reactions as reaction}
<button
class="flex items-center gap-1 px-2 py-0.5 rounded-full text-xs transition-colors
{reaction.includesMe
? 'bg-primary/20 border border-primary/40 text-primary'
: 'bg-muted border border-border hover:bg-surface-hover'}"
title={reaction.users.join(', ')}
onclick={() => handleReaction(reaction.key)}
>
<span>{reaction.key}</span>
<span class="font-medium">{reaction.count}</span>
</button>
{/each}
</div>
{/if}
<!-- Time and read status -->
<div class="flex items-center gap-1.5 mt-1.5 px-1 {message.isOwn ? 'justify-end' : ''}">
<span
class="text-xs text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity"
>{formattedTime()}</span
>
<!-- Read receipt indicator (for own messages) -->
{#if message.isOwn}
{#if message.readBy && message.readBy.length > 0}
<span title="Gelesen von: {message.readBy.map((r) => r.userName).join(', ')}">
<Checks class="h-4 w-4 text-blue-500" weight="bold" />
</span>
{:else}
<span title="Gesendet">
<Check class="h-4 w-4 text-muted-foreground/50" weight="bold" />
</span>
{/if}
{/if}
</div>
<!-- Message actions (hover/tap) -->
{#if showActions && !message.redacted}
<div
class="absolute flex items-center gap-0.5 rounded-lg glass px-1 py-0.5 shadow-lg z-20
{message.isOwn ? 'right-0' : 'left-0'}
bottom-full mb-1"
>
<!-- Emoji reaction button -->
<div class="relative">
<button
class="p-1.5 rounded-lg hover:bg-surface-hover transition-colors"
title="Reaktion"
onclick={() => (showEmojiPicker = !showEmojiPicker)}
>
<Smiley class="h-4 w-4 text-muted-foreground" />
</button>
{#if showEmojiPicker}
<!-- Emoji picker backdrop -->
<button
class="fixed inset-0 z-40"
onclick={() => {
showEmojiPicker = false;
showFullPicker = false;
}}
aria-label="Schließen"
></button>
<!-- Emoji picker dropdown -->
<div
class="absolute z-50 rounded-xl bg-surface-elevated border border-border shadow-xl
left-0 top-full mt-2 lg:bottom-full lg:top-auto lg:mt-0 lg:mb-2
{message.isOwn ? 'lg:right-0 lg:left-auto' : ''}
{showFullPicker ? 'w-72' : ''}"
>
{#if showFullPicker}
<!-- Full emoji picker with categories -->
<div class="p-2">
<!-- Category tabs -->
<div class="flex gap-1 mb-2 border-b border-border pb-2 overflow-x-auto">
{#each emojiCategories as category, i}
<button
class="px-2 py-1 text-xs rounded-md whitespace-nowrap transition-colors
{selectedCategory === i
? 'bg-violet-500 text-white'
: 'hover:bg-surface-hover text-muted-foreground'}"
onclick={() => (selectedCategory = i)}
>
{category.name}
</button>
{/each}
</div>
<!-- Emoji grid -->
<div class="grid grid-cols-8 gap-1 max-h-40 overflow-y-auto">
{#each emojiCategories[selectedCategory].emojis as emoji}
<button
class="text-xl hover:scale-110 hover:bg-surface-hover rounded p-1 transition-all"
onclick={() => handleReaction(emoji)}
>
{emoji}
</button>
{/each}
</div>
</div>
{:else}
<!-- Quick emoji bar -->
<div class="flex items-center gap-1 p-2">
{#each quickEmojis as emoji}
<button
class="text-xl hover:scale-125 transition-transform p-1"
onclick={() => handleReaction(emoji)}
>
{emoji}
</button>
{/each}
<!-- Expand button -->
<button
class="ml-1 p-1.5 rounded-lg hover:bg-surface-hover transition-colors"
onclick={() => (showFullPicker = true)}
title="Mehr Emojis"
>
<DotsThree class="h-4 w-4 text-muted-foreground" />
</button>
</div>
{/if}
</div>
{/if}
</div>
<button
class="p-1.5 rounded-lg hover:bg-surface-hover transition-colors"
title="Antworten"
onclick={() => onReply?.(message)}
>
<ArrowBendUpLeft class="h-4 w-4 text-muted-foreground" />
</button>
<button
class="p-1.5 rounded-lg hover:bg-surface-hover transition-colors"
title="Weiterleiten"
onclick={() => onForward?.(message)}
>
<ArrowBendUpRight class="h-4 w-4 text-muted-foreground" />
</button>
{#if message.isOwn && message.type === 'm.text'}
<button
class="p-1.5 rounded-lg hover:bg-surface-hover transition-colors"
title="Bearbeiten"
onclick={() => onEdit?.(message)}
>
<PencilSimple class="h-4 w-4 text-muted-foreground" />
</button>
{/if}
{#if message.isOwn}
<button
class="p-1.5 rounded-lg hover:bg-red-500/10 transition-colors"
title="Löschen"
onclick={handleDelete}
>
<Trash class="h-4 w-4 text-red-500" />
</button>
{/if}
</div>
{/if}
</div>
</div>
<!-- Mobile Action Bottom Sheet -->
{#if showMobileActions}
<button
class="fixed inset-0 z-[100] bg-black/40 backdrop-blur-sm"
onclick={closeMobileActions}
aria-label="Schließen"
></button>
<div
class="fixed bottom-0 left-0 right-0 z-[101] bg-surface-elevated border-t border-border rounded-t-2xl safe-area-bottom animate-slide-up"
>
<!-- Quick reactions row -->
<div class="flex items-center justify-center gap-3 px-4 pt-4 pb-2">
{#each quickEmojis as emoji}
<button
class="text-2xl p-2 rounded-full hover:bg-surface-hover active:scale-90 transition-all"
onclick={() => {
handleReaction(emoji);
closeMobileActions();
}}
>
{emoji}
</button>
{/each}
</div>
<div class="h-px bg-border mx-4"></div>
<!-- Action buttons -->
<div class="p-2">
<button
class="flex items-center gap-3 w-full px-4 py-3 rounded-xl active:bg-surface-hover transition-colors"
onclick={() => {
onReply?.(message);
closeMobileActions();
}}
>
<ArrowBendUpLeft class="h-5 w-5 text-muted-foreground" />
<span class="text-sm font-medium">Antworten</span>
</button>
<button
class="flex items-center gap-3 w-full px-4 py-3 rounded-xl active:bg-surface-hover transition-colors"
onclick={() => {
onForward?.(message);
closeMobileActions();
}}
>
<ArrowBendUpRight class="h-5 w-5 text-muted-foreground" />
<span class="text-sm font-medium">Weiterleiten</span>
</button>
{#if message.isOwn && message.type === 'm.text'}
<button
class="flex items-center gap-3 w-full px-4 py-3 rounded-xl active:bg-surface-hover transition-colors"
onclick={() => {
onEdit?.(message);
closeMobileActions();
}}
>
<PencilSimple class="h-5 w-5 text-muted-foreground" />
<span class="text-sm font-medium">Bearbeiten</span>
</button>
{/if}
{#if message.isOwn}
<button
class="flex items-center gap-3 w-full px-4 py-3 rounded-xl active:bg-surface-hover transition-colors"
onclick={() => {
handleDelete();
closeMobileActions();
}}
>
<Trash class="h-5 w-5 text-red-500" />
<span class="text-sm font-medium text-red-500">Löschen</span>
</button>
{/if}
</div>
</div>
{/if}

View file

@ -1,846 +0,0 @@
<script lang="ts">
import { matrixStore, type SimpleMessage, type RoomMember } from '$lib/matrix';
import { userSettings } from '$lib/stores/userSettings.svelte';
import {
PaperPlaneTilt,
Paperclip,
Smiley,
X,
Image,
File as FileIcon,
CircleNotch,
Microphone,
Stop,
User,
} from '@mana/shared-icons';
interface Props {
replyTo?: SimpleMessage | null;
editMessage?: SimpleMessage | null;
onCancelReply?: () => void;
onCancelEdit?: () => void;
}
let { replyTo = null, editMessage = null, onCancelReply, onCancelEdit }: Props = $props();
let message = $state('');
let textarea: HTMLTextAreaElement;
let fileInput: HTMLInputElement;
let typingTimeout: ReturnType<typeof setTimeout>;
let isTyping = $state(false);
let uploading = $state(false);
let uploadProgress = $state(0);
let showAttachMenu = $state(false);
// Voice recording state
let isRecording = $state(false);
let recordingDuration = $state(0);
let mediaRecorder: MediaRecorder | null = null;
let audioChunks: Blob[] = [];
let recordingInterval: ReturnType<typeof setInterval> | null = null;
// @mention autocomplete state
let showMentionPicker = $state(false);
let mentionQuery = $state('');
let mentionStartPos = $state(0);
let mentionResults = $state<RoomMember[]>([]);
let selectedMentionIndex = $state(0);
// Emoji picker state
let showEmojiPicker = $state(false);
const MAX_RECENT_EMOJIS = 16; // 2 rows of 8
// Recent emojis from user settings (synced across apps)
let recentEmojis = $derived(userSettings.globalSettings?.recentEmojis ?? []);
// Add emoji to recent list (saves to mana-auth)
function addToRecentEmojis(emoji: string) {
const current = userSettings.globalSettings?.recentEmojis ?? [];
// Remove if already exists, then add to front
const filtered = current.filter((e) => e !== emoji);
const updated = [emoji, ...filtered].slice(0, MAX_RECENT_EMOJIS);
// Update server (optimistic update handled by store)
userSettings.updateGlobal({ recentEmojis: updated });
}
const commonEmojis = [
// Smileys
'😀',
'😃',
'😄',
'😁',
'😅',
'😂',
'🤣',
'😊',
'😇',
'🙂',
'😉',
'😌',
'😍',
'🥰',
'😘',
'😗',
'😙',
'😚',
'😋',
'😛',
'😜',
'🤪',
'😝',
'🤗',
'🤭',
'🤫',
'🤔',
'🤐',
'🤨',
'😐',
'😑',
'😶',
'😏',
'😒',
'🙄',
'😬',
'😮',
'🤯',
'😳',
'🥺',
'😢',
'😭',
'😤',
'😠',
'😡',
'🤬',
'😈',
'👿',
// Gestures
'👍',
'👎',
'👌',
'🤌',
'✌️',
'🤞',
'🤟',
'🤘',
'🤙',
'👋',
'🖐️',
'✋',
'👏',
'🙌',
'👐',
'🤲',
'🙏',
'💪',
'🦾',
'❤️',
'🧡',
'💛',
'💚',
'💙',
// Objects & Symbols
'🔥',
'✨',
'💫',
'⭐',
'🌟',
'💯',
'💢',
'💥',
'💦',
'💨',
'🎉',
'🎊',
'🎁',
'🏆',
'🥇',
'🎯',
'💡',
'📌',
'📍',
'✅',
'❌',
'⚠️',
'❗',
'❓',
];
function insertEmoji(emoji: string) {
const cursorPos = textarea?.selectionStart ?? message.length;
const before = message.slice(0, cursorPos);
const after = message.slice(cursorPos);
message = before + emoji + after;
// Add to recent emojis
addToRecentEmojis(emoji);
// Close picker and focus textarea
showEmojiPicker = false;
setTimeout(() => {
textarea?.focus();
const newPos = cursorPos + emoji.length;
textarea?.setSelectionRange(newPos, newPos);
}, 0);
}
function handleEmojiClick() {
// Try to open native emoji picker (works on some browsers/OS)
if ('showPicker' in HTMLInputElement.prototype) {
// This is for date/color inputs, won't work for emoji but we try
}
// Check if we're on mobile - keyboard usually has emoji button
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
if (isMobile) {
// On mobile, just focus the textarea - user can use keyboard emoji button
textarea?.focus();
return;
}
// Desktop fallback: show our emoji picker
showEmojiPicker = !showEmojiPicker;
}
// Set message content when editing
$effect(() => {
if (editMessage) {
message = editMessage.body;
textarea?.focus();
}
});
// Auto-focus input when room changes or component mounts
$effect(() => {
const roomId = matrixStore.currentRoomId;
if (roomId && textarea) {
// Small delay to ensure DOM is ready
setTimeout(() => textarea?.focus(), 50);
}
});
async function handleSend() {
const trimmed = message.trim();
if (!trimmed) return;
let success = false;
if (editMessage) {
// Edit existing message
success = await matrixStore.editMessage(editMessage.id, trimmed);
if (success) {
onCancelEdit?.();
}
} else if (replyTo) {
// Reply to message
success = await matrixStore.replyToMessage(replyTo.id, trimmed);
if (success) {
onCancelReply?.();
}
} else {
// Normal message
success = await matrixStore.sendMessage(trimmed);
}
if (success) {
message = '';
stopTyping();
adjustTextareaHeight();
}
}
function handleInput() {
adjustTextareaHeight();
// Send typing indicator
if (!isTyping && !editMessage) {
isTyping = true;
matrixStore.sendTyping(true);
}
// Reset typing timeout
clearTimeout(typingTimeout);
typingTimeout = setTimeout(stopTyping, 3000);
// Check for @mention trigger
checkForMention();
}
function checkForMention() {
if (!textarea) return;
const cursorPos = textarea.selectionStart;
const textBeforeCursor = message.slice(0, cursorPos);
// Find the last @ before cursor
const lastAtIndex = textBeforeCursor.lastIndexOf('@');
if (lastAtIndex !== -1) {
// Check if there's a space before @ (or it's at the start)
const charBefore = lastAtIndex > 0 ? textBeforeCursor[lastAtIndex - 1] : ' ';
if (charBefore === ' ' || charBefore === '\n' || lastAtIndex === 0) {
const query = textBeforeCursor.slice(lastAtIndex + 1);
// No space in the query = still typing the mention
if (!query.includes(' ') && query.length <= 50) {
mentionStartPos = lastAtIndex;
mentionQuery = query;
showMentionPicker = true;
updateMentionResults(query);
return;
}
}
}
// Close mention picker if conditions not met
showMentionPicker = false;
mentionQuery = '';
}
function updateMentionResults(query: string) {
const members = matrixStore.getRoomMembers();
const lowerQuery = query.toLowerCase();
// Filter members by display name or user ID
mentionResults = members
.filter(
(m) =>
m.membership === 'join' &&
(m.displayName.toLowerCase().includes(lowerQuery) ||
m.userId.toLowerCase().includes(lowerQuery))
)
.slice(0, 6); // Limit to 6 results
selectedMentionIndex = 0;
}
function insertMention(member: RoomMember) {
const beforeMention = message.slice(0, mentionStartPos);
const afterMention = message.slice(textarea.selectionStart);
// Insert pill format: @displayName (the actual Matrix pill is sent as formatted HTML)
const mentionText = `@${member.displayName} `;
message = beforeMention + mentionText + afterMention;
// Close picker
showMentionPicker = false;
mentionQuery = '';
// Focus and set cursor position
setTimeout(() => {
textarea.focus();
const newPos = mentionStartPos + mentionText.length;
textarea.setSelectionRange(newPos, newPos);
}, 0);
}
function stopTyping() {
if (isTyping) {
isTyping = false;
matrixStore.sendTyping(false);
}
clearTimeout(typingTimeout);
}
function handleKeydown(e: KeyboardEvent) {
// Handle mention picker navigation
if (showMentionPicker && mentionResults.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedMentionIndex = (selectedMentionIndex + 1) % mentionResults.length;
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
selectedMentionIndex =
selectedMentionIndex === 0 ? mentionResults.length - 1 : selectedMentionIndex - 1;
return;
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
insertMention(mentionResults[selectedMentionIndex]);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
showMentionPicker = false;
return;
}
}
// Send on Enter (without Shift)
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
// Cancel on Escape
if (e.key === 'Escape') {
if (editMessage) {
onCancelEdit?.();
message = '';
} else if (replyTo) {
onCancelReply?.();
}
}
}
function adjustTextareaHeight() {
if (!textarea) return;
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px';
}
function openFilePicker() {
fileInput?.click();
}
async function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
uploading = true;
uploadProgress = 0;
const success = await matrixStore.sendFile(file, (progress) => {
uploadProgress = progress;
});
uploading = false;
uploadProgress = 0;
input.value = ''; // Reset input
if (!success) {
// Show error toast or notification
console.error('Failed to upload file');
}
}
// Voice recording functions
async function startRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
audioChunks = [];
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunks.push(event.data);
}
};
mediaRecorder.onstop = async () => {
// Stop all tracks
stream.getTracks().forEach((track) => track.stop());
// Create blob and send
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
await sendVoiceMessage(audioBlob);
};
mediaRecorder.start(100); // Collect data every 100ms
isRecording = true;
recordingDuration = 0;
// Start duration counter
recordingInterval = setInterval(() => {
recordingDuration++;
}, 1000);
} catch (err) {
console.error('Failed to start recording:', err);
}
}
function stopRecording() {
if (mediaRecorder && isRecording) {
mediaRecorder.stop();
isRecording = false;
if (recordingInterval) {
clearInterval(recordingInterval);
recordingInterval = null;
}
}
}
function cancelRecording() {
if (mediaRecorder && isRecording) {
// Stop without sending
mediaRecorder.ondataavailable = null;
mediaRecorder.onstop = () => {
// Just clean up, don't send
};
mediaRecorder.stop();
isRecording = false;
if (recordingInterval) {
clearInterval(recordingInterval);
recordingInterval = null;
}
}
}
async function sendVoiceMessage(blob: Blob) {
uploading = true;
uploadProgress = 0;
// Create a File from the Blob
const filename = `voice-${Date.now()}.webm`;
const file = new File([blob], filename, { type: 'audio/webm' });
const success = await matrixStore.sendFile(file, (progress) => {
uploadProgress = progress;
});
uploading = false;
uploadProgress = 0;
if (!success) {
console.error('Failed to send voice message');
}
}
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
</script>
<div class="p-3 pb-4 safe-area-bottom">
<!-- Reply/Edit Preview -->
{#if replyTo || editMessage}
<div class="mb-2 flex items-center gap-2 rounded-xl bg-surface border border-border px-3 py-2">
<div class="flex-1">
{#if editMessage}
<p class="text-xs text-muted-foreground">Nachricht bearbeiten</p>
<p class="truncate text-sm">{editMessage.body}</p>
{:else if replyTo}
<p class="text-xs text-muted-foreground">
Antwort auf <span class="font-medium">{replyTo.senderName}</span>
</p>
<p class="truncate text-sm">{replyTo.body}</p>
{/if}
</div>
<button
class="p-1.5 rounded-lg hover:bg-surface-hover transition-colors"
onclick={() => {
if (editMessage) {
onCancelEdit?.();
message = '';
} else {
onCancelReply?.();
}
}}
>
<X class="h-4 w-4" />
</button>
</div>
{/if}
<!-- Upload Progress -->
{#if uploading}
<div class="mb-2 flex items-center gap-3 rounded-xl bg-surface border border-border px-3 py-2">
<CircleNotch class="h-4 w-4 animate-spin text-primary" />
<div class="flex-1">
<div class="h-1.5 overflow-hidden rounded-full bg-muted">
<div
class="h-full bg-primary transition-all duration-300"
style="width: {uploadProgress}%"
></div>
</div>
</div>
<span class="text-xs text-muted-foreground">{uploadProgress}%</span>
</div>
{/if}
<!-- Recording Indicator -->
{#if isRecording}
<div
class="mb-2 flex items-center gap-3 rounded-xl bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 px-3 py-2"
>
<div class="h-2.5 w-2.5 rounded-full bg-red-500 animate-pulse"></div>
<p class="flex-1 text-sm font-medium text-red-700 dark:text-red-400">Aufnahme...</p>
<span class="text-sm font-mono text-red-600 dark:text-red-400"
>{formatDuration(recordingDuration)}</span
>
<button
class="p-1 rounded-lg hover:bg-red-100 dark:hover:bg-red-500/20 transition-colors"
onclick={cancelRecording}
title="Abbrechen"
>
<X class="h-4 w-4 text-red-500" />
</button>
</div>
{/if}
<!-- @Mention Picker -->
{#if showMentionPicker && mentionResults.length > 0}
<div class="mb-2 rounded-xl bg-surface-elevated border border-border shadow-xl overflow-hidden">
<div class="px-3 py-1.5 text-xs text-muted-foreground border-b border-border">
Erwähne jemanden
</div>
{#each mentionResults as member, i}
<button
class="flex items-center gap-3 w-full px-3 py-2 transition-colors text-left
{i === selectedMentionIndex
? 'bg-violet-500/10 dark:bg-violet-500/20'
: 'hover:bg-surface-hover'}"
onclick={() => insertMention(member)}
>
<!-- Avatar -->
{#if member.avatarUrl}
<img
src={member.avatarUrl}
alt={member.displayName}
class="w-8 h-8 rounded-full object-cover"
/>
{:else}
<div
class="w-8 h-8 rounded-full bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center"
>
<User class="w-4 h-4 text-white" />
</div>
{/if}
<!-- Name and ID -->
<div class="flex-1 min-w-0">
<p class="font-medium text-sm truncate">{member.displayName}</p>
<p class="text-xs text-muted-foreground truncate">{member.userId}</p>
</div>
</button>
{/each}
</div>
{/if}
<!-- Input Area - WhatsApp style -->
<div class="flex items-end gap-2">
<!-- Attachment button (left, outside input) -->
<div class="relative flex-shrink-0">
<button
class="p-2.5 rounded-full hover:bg-surface-hover transition-colors"
title="Datei anhängen"
disabled={uploading}
onclick={() => (showAttachMenu = !showAttachMenu)}
>
<Paperclip size={22} class="text-muted-foreground" />
</button>
{#if showAttachMenu}
<!-- Backdrop -->
<button
class="fixed inset-0 z-40 lg:bg-transparent bg-black/40"
onclick={() => (showAttachMenu = false)}
aria-label="Menü schließen"
></button>
<!-- Desktop: Dropdown above button -->
<div
class="hidden lg:block absolute bottom-full left-0 mb-2 z-50 w-44 rounded-xl bg-surface-elevated border border-border p-1.5 shadow-xl"
>
<button
onclick={() => {
openFilePicker();
showAttachMenu = false;
}}
class="flex items-center gap-2 w-full px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors text-sm"
>
<Image class="h-4 w-4" />
Bild oder Video
</button>
<button
onclick={() => {
openFilePicker();
showAttachMenu = false;
}}
class="flex items-center gap-2 w-full px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors text-sm"
>
<FileIcon class="h-4 w-4" />
Datei
</button>
</div>
<!-- Mobile: Bottom sheet -->
<div
class="lg:hidden fixed bottom-0 left-0 right-0 z-50 bg-surface-elevated border-t border-border rounded-t-2xl safe-area-bottom animate-slide-up"
>
<div class="p-2">
<button
onclick={() => {
openFilePicker();
showAttachMenu = false;
}}
class="flex items-center gap-3 w-full px-4 py-3.5 rounded-xl active:bg-surface-hover transition-colors"
>
<Image class="h-5 w-5 text-muted-foreground" />
<span class="text-sm font-medium">Bild oder Video</span>
</button>
<button
onclick={() => {
openFilePicker();
showAttachMenu = false;
}}
class="flex items-center gap-3 w-full px-4 py-3.5 rounded-xl active:bg-surface-hover transition-colors"
>
<FileIcon class="h-5 w-5 text-muted-foreground" />
<span class="text-sm font-medium">Datei</span>
</button>
</div>
</div>
{/if}
</div>
<!-- Hidden file input -->
<input
bind:this={fileInput}
type="file"
class="hidden"
accept="*/*"
onchange={handleFileSelect}
/>
<!-- Text input with emoji button inside -->
<div
class="relative flex-1 flex items-end rounded-full bg-surface border border-border px-4 py-1"
>
<textarea
bind:this={textarea}
bind:value={message}
oninput={handleInput}
onkeydown={handleKeydown}
onblur={stopTyping}
placeholder={editMessage
? 'Nachricht bearbeiten...'
: replyTo
? 'Antwort schreiben...'
: 'Nachricht schreiben...'}
rows="1"
class="flex-1 resize-none bg-transparent py-2.5 text-sm text-foreground
focus:outline-none placeholder:text-muted-foreground"
style="max-height: 150px; min-height: 40px;"
disabled={uploading}
></textarea>
<!-- Emoji button inside input -->
<button
class="flex-shrink-0 p-1.5 rounded-full hover:bg-surface-hover transition-colors mb-1"
title="Emoji"
onclick={handleEmojiClick}
>
<Smiley size={22} class="text-muted-foreground" />
</button>
<!-- Emoji Picker -->
{#if showEmojiPicker}
<!-- Backdrop -->
<button
class="fixed inset-0 z-40 lg:bg-transparent bg-black/40"
onclick={() => (showEmojiPicker = false)}
aria-label="Emoji-Picker schließen"
></button>
<!-- Desktop: Popup above input -->
<div
class="hidden lg:block absolute bottom-full right-0 mb-2 z-50 w-72 max-h-80 overflow-y-auto rounded-xl bg-surface-elevated border border-border p-2 shadow-xl"
>
{#if recentEmojis.length > 0}
<div class="mb-2">
<p class="text-[10px] text-muted-foreground uppercase font-medium px-1 mb-1">
Häufig benutzt
</p>
<div class="grid grid-cols-8 gap-1">
{#each recentEmojis as emoji}
<button
class="p-1.5 text-xl hover:bg-surface-hover rounded-lg transition-colors"
onclick={() => insertEmoji(emoji)}
>
{emoji}
</button>
{/each}
</div>
</div>
<div class="border-t border-border my-2"></div>
{/if}
<div class="grid grid-cols-8 gap-1">
{#each commonEmojis as emoji}
<button
class="p-1.5 text-xl hover:bg-surface-hover rounded-lg transition-colors"
onclick={() => insertEmoji(emoji)}
>
{emoji}
</button>
{/each}
</div>
</div>
<!-- Mobile: Bottom sheet -->
<div
class="lg:hidden fixed bottom-0 left-0 right-0 z-50 bg-surface-elevated border-t border-border rounded-t-2xl safe-area-bottom animate-slide-up"
>
<div class="p-3 max-h-[50vh] overflow-y-auto">
{#if recentEmojis.length > 0}
<div class="mb-3">
<p class="text-[10px] text-muted-foreground uppercase font-medium px-1 mb-1">
Häufig benutzt
</p>
<div class="grid grid-cols-8 gap-1">
{#each recentEmojis as emoji}
<button
class="p-2 text-2xl active:scale-90 rounded-lg transition-transform"
onclick={() => insertEmoji(emoji)}
>
{emoji}
</button>
{/each}
</div>
</div>
<div class="border-t border-border my-2"></div>
{/if}
<div class="grid grid-cols-8 gap-1">
{#each commonEmojis as emoji}
<button
class="p-2 text-2xl active:scale-90 rounded-lg transition-transform"
onclick={() => insertEmoji(emoji)}
>
{emoji}
</button>
{/each}
</div>
</div>
</div>
{/if}
</div>
<!-- Voice/Send button (right, outside input) -->
{#if isRecording}
<button
class="flex-shrink-0 p-2.5 rounded-full bg-red-500 hover:bg-red-600 text-white transition-colors"
onclick={stopRecording}
title="Aufnahme beenden und senden"
>
<Stop size={22} weight="fill" />
</button>
{:else if message.trim()}
<button
class="flex-shrink-0 p-2.5 rounded-full bg-primary hover:bg-primary/90 text-white transition-colors
disabled:opacity-50 disabled:cursor-not-allowed"
onclick={handleSend}
disabled={uploading}
title={editMessage ? 'Speichern' : 'Senden'}
>
<PaperPlaneTilt size={22} weight="fill" />
</button>
{:else}
<button
class="flex-shrink-0 p-2.5 rounded-full hover:bg-surface-hover text-muted-foreground hover:text-primary transition-colors
disabled:opacity-50 disabled:cursor-not-allowed"
onclick={startRecording}
disabled={uploading}
title="Sprachnotiz aufnehmen"
>
<Microphone size={22} weight="bold" />
</button>
{/if}
</div>
<!-- Hint (desktop only) -->
<p class="hidden lg:block text-[10px] text-muted-foreground/60 text-center mt-1.5">
{#if editMessage}
Enter = Speichern · Escape = Abbrechen
{:else}
Enter = Senden · Shift+Enter = Neue Zeile
{/if}
</p>
</div>

View file

@ -1,203 +0,0 @@
<script lang="ts">
import { matrixStore } from '$lib/matrix';
import {
ArrowLeft,
List,
Phone,
VideoCamera,
Info,
LockOpen,
ShieldCheck,
ShieldWarning,
Users,
MagnifyingGlass,
} from '@mana/shared-icons';
interface Props {
onMenuClick?: () => void;
onInfoClick?: () => void;
onSearchClick?: () => void;
onVoiceCall?: () => void;
onVideoCall?: () => void;
showBackButton?: boolean;
onBackClick?: () => void;
}
let {
onMenuClick,
onInfoClick,
onSearchClick,
onVoiceCall,
onVideoCall,
showBackButton = false,
onBackClick,
}: Props = $props();
// Check if calls are possible (DMs only for now)
let canCall = $derived(matrixStore.currentSimpleRoom?.isDirect ?? false);
let room = $derived(matrixStore.currentSimpleRoom);
let cryptoReady = $derived(matrixStore.cryptoReady);
let encryptionStatus = $state<{
encrypted: boolean;
allDevicesVerified: boolean;
unverifiedDevices: number;
}>({
encrypted: false,
allDevicesVerified: false,
unverifiedDevices: 0,
});
// Load encryption status when room changes
$effect(() => {
if (room && cryptoReady) {
matrixStore.getRoomEncryptionStatus(room.id).then((status) => {
encryptionStatus = status;
});
}
});
// Presence for DMs
let isOnline = $derived(room?.isDirect && room?.presence === 'online');
// Format last active time
let presenceText = $derived(() => {
if (!room?.isDirect) return '';
if (room.presence === 'online') return 'Online';
if (!room.lastActiveAgo) return 'Offline';
const minutes = Math.floor(room.lastActiveAgo / 60000);
if (minutes < 1) return 'Gerade aktiv';
if (minutes < 60) return `Vor ${minutes} Min. aktiv`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `Vor ${hours} Std. aktiv`;
const days = Math.floor(hours / 24);
return `Vor ${days} Tag${days > 1 ? 'en' : ''} aktiv`;
});
</script>
{#if room}
<header class="flex items-center gap-3 border-b border-border bg-surface-elevated px-4 py-3">
<!-- Mobile back button or menu button -->
{#if showBackButton}
<button
class="p-2 rounded-lg hover:bg-surface-hover transition-colors"
onclick={onBackClick}
aria-label="Zurück"
>
<ArrowLeft class="h-5 w-5" />
</button>
{:else}
<button
class="p-2 rounded-lg hover:bg-surface-hover transition-colors lg:hidden"
onclick={onMenuClick}
>
<List class="h-5 w-5" />
</button>
{/if}
<!-- Room avatar with online indicator -->
<div class="relative flex-shrink-0">
<div
class="flex h-8 w-8 lg:h-10 lg:w-10 items-center justify-center rounded-full shadow-md
bg-gradient-to-br from-violet-500 to-purple-600 text-white"
>
{#if room.avatar}
<img
src={room.avatar}
alt={room.name}
class="h-8 w-8 lg:h-10 lg:w-10 rounded-full object-cover"
/>
{:else}
<span class="text-xs lg:text-sm font-semibold">{room.name.charAt(0).toUpperCase()}</span>
{/if}
</div>
<!-- Online indicator for DMs -->
{#if room.isDirect}
<div
class="absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 rounded-full border-2 border-background
{isOnline ? 'bg-green-500' : 'bg-muted-foreground'}"
title={presenceText()}
></div>
{/if}
</div>
<!-- Room info -->
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<h2 class="truncate font-semibold text-foreground">{room.name}</h2>
{#if room.isEncrypted}
{#if encryptionStatus.allDevicesVerified}
<div class="flex-shrink-0" title="Verschlüsselt - Alle Geräte verifiziert">
<ShieldCheck class="h-4 w-4 text-green-500" />
</div>
{:else}
<div
class="flex-shrink-0"
title="Verschlüsselt - {encryptionStatus.unverifiedDevices} unverifizierte Geräte"
>
<ShieldWarning class="h-4 w-4 text-amber-500" />
</div>
{/if}
{:else}
<div class="flex-shrink-0" title="Nicht verschlüsselt">
<LockOpen class="h-4 w-4 text-muted-foreground" />
</div>
{/if}
</div>
<p class="flex items-center gap-1.5 text-sm text-muted-foreground">
{#if room.topic}
<span class="truncate">{room.topic}</span>
{:else if room.isDirect}
<span class="flex items-center gap-1.5">
{#if isOnline}
<span class="w-2 h-2 rounded-full bg-green-500"></span>
<span class="text-green-600 dark:text-green-400">Online</span>
{:else}
<span class="w-2 h-2 rounded-full bg-muted-foreground"></span>
<span>{presenceText() || 'Offline'}</span>
{/if}
</span>
{:else}
<Users class="h-3 w-3" />
<span>{room.memberCount} Mitglieder</span>
{/if}
</p>
</div>
<!-- Actions -->
<div class="flex items-center gap-0.5 lg:gap-1">
<button
class="p-2 lg:p-2.5 rounded-lg lg:rounded-xl lg:glass-button lg:shadow-sm hover:bg-surface-hover transition-colors"
title="Suchen"
onclick={onSearchClick}
>
<MagnifyingGlass class="h-5 w-5 text-muted-foreground" />
</button>
<button
class="hidden sm:flex p-2 lg:p-2.5 rounded-lg lg:rounded-xl lg:glass-button lg:shadow-sm transition-colors
{canCall ? 'hover:bg-green-500/10 hover:text-green-500' : 'opacity-40 cursor-not-allowed'}"
title={canCall ? 'Sprachanruf' : 'Anrufe nur in Direktnachrichten verfügbar'}
disabled={!canCall}
onclick={onVoiceCall}
>
<Phone class="h-5 w-5" />
</button>
<button
class="hidden sm:flex p-2 lg:p-2.5 rounded-lg lg:rounded-xl lg:glass-button lg:shadow-sm transition-colors
{canCall ? 'hover:bg-violet-500/10 hover:text-violet-500' : 'opacity-40 cursor-not-allowed'}"
title={canCall ? 'Videoanruf' : 'Anrufe nur in Direktnachrichten verfügbar'}
disabled={!canCall}
onclick={onVideoCall}
>
<VideoCamera class="h-5 w-5" />
</button>
<button
class="p-2 lg:p-2.5 rounded-lg lg:rounded-xl lg:glass-button lg:shadow-sm hover:bg-surface-hover transition-colors"
title="Rauminfo"
onclick={onInfoClick}
>
<Info class="h-5 w-5 text-muted-foreground" />
</button>
</div>
</header>
{/if}

View file

@ -1,130 +0,0 @@
<script lang="ts">
import type { SimpleRoom } from '$lib/matrix';
import { formatDistanceToNow, isValid } from 'date-fns';
import { de } from 'date-fns/locale';
import { Lock, Users } from '@mana/shared-icons';
interface Props {
room: SimpleRoom;
selected: boolean;
onclick: () => void;
}
let { room, selected, onclick }: Props = $props();
let timeAgo = $derived(() => {
if (!room.lastMessageTime) return '';
const date = new Date(room.lastMessageTime);
if (!isValid(date) || date.getTime() === 0) return '';
// Compact time format
const diffMs = Date.now() - date.getTime();
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return 'jetzt';
if (diffMin < 60) return `${diffMin} Min.`;
const diffH = Math.floor(diffMin / 60);
if (diffH < 24) return `${diffH} Std.`;
const diffD = Math.floor(diffH / 24);
if (diffD < 7) return `${diffD} T.`;
const diffW = Math.floor(diffD / 7);
if (diffW < 5) return `${diffW} Wo.`;
return formatDistanceToNow(date, { addSuffix: false, locale: de });
});
let initials = $derived(
room.name
.split(' ')
.map((w) => w[0])
.join('')
.substring(0, 2)
.toUpperCase()
);
// Online status for DMs
let isOnline = $derived(room.isDirect && room.presence === 'online');
// Format last active time
let lastActiveText = $derived(() => {
if (!room.isDirect || !room.lastActiveAgo) return '';
if (room.presence === 'online') return 'Online';
const minutes = Math.floor(room.lastActiveAgo / 60000);
if (minutes < 1) return 'Gerade aktiv';
if (minutes < 60) return `Vor ${minutes} Min.`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `Vor ${hours} Std.`;
const days = Math.floor(hours / 24);
return `Vor ${days} Tag${days > 1 ? 'en' : ''}`;
});
</script>
<button
class="flex w-full items-center gap-3 px-3 py-2.5 mb-1 rounded-xl transition-all duration-200
{selected
? 'bg-surface-elevated shadow-md border border-border'
: 'hover:bg-surface-hover lg:hover:-translate-y-0.5 active:scale-[0.98]'}"
{onclick}
>
<!-- Avatar with online indicator -->
<div class="relative flex-shrink-0">
<div
class="w-11 h-11 rounded-full flex items-center justify-center shadow-sm
{selected
? 'bg-gradient-to-br from-blue-500 to-indigo-600 text-white'
: 'bg-gradient-to-br from-violet-500 to-purple-600 text-white'}"
>
{#if room.avatar}
<img src={room.avatar} alt={room.name} class="w-11 h-11 rounded-full object-cover" />
{:else}
<span class="text-sm font-semibold">{initials}</span>
{/if}
</div>
<!-- Online indicator dot -->
{#if room.isDirect}
<div
class="absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 rounded-full border-2 border-background
{isOnline ? 'bg-green-500' : 'bg-muted-foreground'}"
title={lastActiveText()}
></div>
{/if}
</div>
<!-- Room Info -->
<div class="flex min-w-0 flex-1 flex-col items-start">
<div class="flex w-full items-center gap-1.5">
<span class="truncate font-medium text-foreground">{room.name}</span>
{#if room.isEncrypted}
<Lock class="h-3 w-3 flex-shrink-0 text-green-500" />
{/if}
{#if !room.isDirect && room.memberCount > 2}
<span class="flex items-center text-xs text-muted-foreground">
<Users class="mr-0.5 h-3 w-3" />
{room.memberCount}
</span>
{/if}
</div>
{#if room.lastMessage}
<p class="w-full truncate text-left text-sm text-muted-foreground">
{#if room.lastMessageSender && !room.isDirect}
<span class="font-medium text-foreground/70">{room.lastMessageSender}:</span>
{/if}
{room.lastMessage}
</p>
{/if}
</div>
<!-- Meta -->
<div class="flex flex-shrink-0 flex-col items-end gap-1">
{#if timeAgo()}
<span class="text-xs text-muted-foreground">{timeAgo()}</span>
{/if}
{#if room.unreadCount > 0}
<span
class="px-1.5 py-0.5 rounded-full text-xs font-medium text-white
{room.highlightCount > 0
? 'bg-gradient-to-r from-red-500 to-rose-600'
: 'bg-gradient-to-r from-blue-500 to-indigo-600'}"
>
{room.unreadCount > 99 ? '99+' : room.unreadCount}
</span>
{/if}
</div>
</button>

View file

@ -1,184 +0,0 @@
<script lang="ts">
import { matrixStore } from '$lib/matrix';
import RoomItem from './RoomItem.svelte';
import { Plus, Users, ChatCircle, Envelope, Check, X } from '@mana/shared-icons';
interface Props {
onCreateRoom?: () => void;
onSelectRoom?: (roomId: string) => void;
search?: string;
}
let { onCreateRoom, onSelectRoom, search = '' }: Props = $props();
function handleSelectRoom(roomId: string) {
if (onSelectRoom) {
onSelectRoom(roomId);
} else {
matrixStore.selectRoom(roomId);
}
}
let filteredDirectRooms = $derived(
matrixStore.directRooms.filter((room) => room.name.toLowerCase().includes(search.toLowerCase()))
);
let filteredGroupRooms = $derived(
matrixStore.groupRooms.filter((room) => room.name.toLowerCase().includes(search.toLowerCase()))
);
let filteredInvites = $derived(
matrixStore.invitedRooms.filter((room) =>
room.name.toLowerCase().includes(search.toLowerCase())
)
);
async function acceptInvite(roomId: string) {
await matrixStore.joinRoom(roomId);
}
async function declineInvite(roomId: string) {
await matrixStore.leaveRoom(roomId);
}
</script>
<div class="flex h-full flex-col">
<!-- Room List with Sections -->
<div class="chat-scrollbar flex-1 overflow-y-auto px-3">
<!-- Header row with room count + new chat (desktop only, mobile has it in page header) -->
<div class="flex items-center justify-between px-2 py-2 mb-1">
<span
class="text-xs font-semibold uppercase text-muted-foreground tracking-wide flex items-center gap-2"
>
<Users class="h-3.5 w-3.5" />
Räume
<span class="px-1.5 py-0.5 rounded-full bg-muted text-[10px]">
{matrixStore.directRooms.length + matrixStore.groupRooms.length}
</span>
</span>
<button
class="hidden lg:flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium
bg-gradient-to-r from-violet-500 to-purple-600 text-white
shadow-sm hover:shadow-md hover:-translate-y-px transition-all duration-200"
onclick={onCreateRoom}
title="Neuer Chat"
>
<Plus class="h-3.5 w-3.5" />
Neu
</button>
</div>
<!-- Invites Section -->
{#if filteredInvites.length > 0}
<div class="mb-4">
<div
class="flex items-center gap-2 px-2 py-2 text-xs font-semibold uppercase text-muted-foreground"
>
<Envelope class="h-3.5 w-3.5" />
Einladungen
<span
class="px-1.5 py-0.5 rounded-full bg-gradient-to-r from-amber-500 to-orange-500 text-white text-[10px]"
>
{filteredInvites.length}
</span>
</div>
{#each filteredInvites as room (room.id)}
<div
class="flex items-center gap-3 px-3 py-2.5 mb-1 rounded-xl bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20"
>
<!-- Avatar -->
<div
class="w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 bg-gradient-to-br from-amber-500 to-orange-500 text-white shadow-sm"
>
<span class="text-sm font-semibold">
{room.name
.split(' ')
.map((w) => w[0])
.join('')
.substring(0, 2)
.toUpperCase()}
</span>
</div>
<!-- Info -->
<div class="flex-1 min-w-0">
<p class="font-medium text-foreground truncate">{room.name}</p>
{#if room.inviter}
<p class="text-xs text-muted-foreground truncate">
Eingeladen von {room.inviter}
</p>
{/if}
</div>
<!-- Actions -->
<div class="flex gap-1.5">
<button
class="p-2 rounded-lg bg-green-500 hover:bg-green-600 text-white transition-colors"
title="Annehmen"
onclick={() => acceptInvite(room.id)}
>
<Check class="h-4 w-4" weight="bold" />
</button>
<button
class="p-2 rounded-lg bg-red-500 hover:bg-red-600 text-white transition-colors"
title="Ablehnen"
onclick={() => declineInvite(room.id)}
>
<X class="h-4 w-4" weight="bold" />
</button>
</div>
</div>
{/each}
</div>
{/if}
<!-- Direct Messages Section -->
{#if matrixStore.directRooms.length > 0}
<div class="mb-2">
<div
class="flex items-center gap-2 px-2 py-2 text-xs font-semibold uppercase text-muted-foreground"
>
<ChatCircle class="h-3.5 w-3.5" />
Direktnachrichten
<span class="px-1.5 py-0.5 rounded-full bg-muted text-[10px]">
{matrixStore.directRooms.length}
</span>
</div>
{#each filteredDirectRooms as room (room.id)}
<RoomItem
{room}
selected={room.id === matrixStore.currentRoomId}
onclick={() => handleSelectRoom(room.id)}
/>
{/each}
</div>
{/if}
<!-- Group Rooms Section -->
{#if matrixStore.groupRooms.length > 0}
<div class="mb-2">
<div
class="flex items-center gap-2 px-2 py-2 text-xs font-semibold uppercase text-muted-foreground"
>
<Users class="h-3.5 w-3.5" />
Gruppen
<span class="px-1.5 py-0.5 rounded-full bg-muted text-[10px]">
{matrixStore.groupRooms.length}
</span>
</div>
{#each filteredGroupRooms as room (room.id)}
<RoomItem
{room}
selected={room.id === matrixStore.currentRoomId}
onclick={() => handleSelectRoom(room.id)}
/>
{/each}
</div>
{/if}
<!-- No search results -->
{#if search && filteredDirectRooms.length === 0 && filteredGroupRooms.length === 0 && filteredInvites.length === 0 && (matrixStore.directRooms.length > 0 || matrixStore.groupRooms.length > 0 || matrixStore.invitedRooms.length > 0)}
<div class="flex flex-col items-center justify-center p-8 text-muted-foreground">
<p class="text-sm">Keine Ergebnisse für "{search}"</p>
</div>
{/if}
</div>
</div>

View file

@ -1,325 +0,0 @@
<script lang="ts">
import { matrixStore } from '$lib/matrix';
import type { RoomWidget } from '$lib/matrix/types';
import {
X,
Users,
Gear,
UserPlus,
SignOut,
Crown,
Shield,
Bell,
BellSlash,
CircleNotch,
SquaresFour,
MagnifyingGlass,
} from '@mana/shared-icons';
interface Props {
open: boolean;
onClose: () => void;
}
let { open, onClose }: Props = $props();
let activeTab = $state<'members' | 'widgets' | 'settings'>('members');
let inviteQuery = $state('');
let searchResults = $state<{ userId: string; displayName?: string; avatarUrl?: string }[]>([]);
let searching = $state(false);
let inviting = $state(false);
let searchTimeout: ReturnType<typeof setTimeout>;
let expandedWidget = $state<string | null>(null);
let room = $derived(matrixStore.currentSimpleRoom);
let members = $derived(matrixStore.getRoomMembers());
let widgets = $derived(matrixStore.getRoomWidgets());
function getWidgetUrl(widget: RoomWidget): string {
return matrixStore.buildWidgetUrl(widget);
}
function toggleWidget(widgetId: string) {
expandedWidget = expandedWidget === widgetId ? null : widgetId;
}
function handleSearchInput() {
clearTimeout(searchTimeout);
if (inviteQuery.trim().length < 2) {
searchResults = [];
return;
}
searchTimeout = setTimeout(async () => {
searching = true;
const results = await matrixStore.searchUsers(inviteQuery);
// Filter out existing members
const memberIds = new Set(members.map((m) => m.userId));
searchResults = results.filter((r) => !memberIds.has(r.userId));
searching = false;
}, 300);
}
async function inviteUser(userId: string) {
if (!matrixStore.currentRoomId) return;
inviting = true;
const success = await matrixStore.inviteUser(matrixStore.currentRoomId, userId);
inviting = false;
if (success) {
inviteQuery = '';
searchResults = [];
}
}
async function leaveRoom() {
if (!matrixStore.currentRoomId) return;
if (!confirm('Möchtest du diesen Raum wirklich verlassen?')) return;
const success = await matrixStore.leaveRoom(matrixStore.currentRoomId);
if (success) {
onClose();
}
}
function getPowerLevelIcon(level: number) {
if (level >= 100) return Crown;
if (level >= 50) return Shield;
return null;
}
</script>
{#if open && room}
<!-- Backdrop for mobile -->
<button
class="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm lg:hidden"
onclick={onClose}
aria-label="Schließen"
></button>
<!-- Slide-in Panel -->
<div
class="fixed inset-y-0 right-0 z-50 flex w-[90vw] max-w-[320px] lg:w-80 flex-col
bg-surface-elevated border-l border-border shadow-xl"
>
<!-- Header -->
<header class="flex items-center justify-between border-b border-border px-4 py-3">
<h2 class="font-semibold text-foreground">Raum-Details</h2>
<button class="p-2 rounded-lg hover:bg-surface-hover transition-colors" onclick={onClose}>
<X class="h-5 w-5 text-foreground" />
</button>
</header>
<!-- Room Info -->
<div class="border-b border-border p-4 text-center">
<div class="mx-auto mb-3 w-20 h-20 rounded-full overflow-hidden">
{#if room.avatar}
<img src={room.avatar} alt={room.name} class="w-full h-full object-cover" />
{:else}
<div
class="w-full h-full flex items-center justify-center bg-gradient-to-br from-violet-500 to-purple-600 text-white"
>
<span class="text-2xl font-semibold">{room.name.charAt(0).toUpperCase()}</span>
</div>
{/if}
</div>
<h3 class="text-lg font-semibold text-foreground">{room.name}</h3>
{#if room.topic}
<p class="mt-1 text-sm text-muted-foreground">{room.topic}</p>
{/if}
<p class="mt-2 text-xs text-muted-foreground">
{room.memberCount} Mitglieder
{#if room.isEncrypted}
• Verschlüsselt
{/if}
</p>
</div>
<!-- Tabs -->
<div class="flex border-b border-border">
<button
class="flex-1 flex items-center justify-center gap-1.5 py-3 text-sm font-medium transition-colors
{activeTab === 'members'
? 'text-primary border-b-2 border-primary'
: 'text-muted-foreground hover:text-foreground'}"
onclick={() => (activeTab = 'members')}
>
<Users class="h-4 w-4" />
Mitglieder
</button>
<button
class="flex-1 flex items-center justify-center gap-1.5 py-3 text-sm font-medium transition-colors
{activeTab === 'widgets'
? 'text-primary border-b-2 border-primary'
: 'text-muted-foreground hover:text-foreground'}"
onclick={() => (activeTab = 'widgets')}
>
<SquaresFour class="h-4 w-4" />
Widgets
{#if widgets.length > 0}
<span
class="px-1.5 py-0.5 rounded-full bg-gradient-to-r from-violet-500 to-purple-600 text-white text-[10px] font-medium"
>
{widgets.length}
</span>
{/if}
</button>
<button
class="flex-1 flex items-center justify-center gap-1.5 py-3 text-sm font-medium transition-colors
{activeTab === 'settings'
? 'text-primary border-b-2 border-primary'
: 'text-muted-foreground hover:text-foreground'}"
onclick={() => (activeTab = 'settings')}
>
<Gear class="h-4 w-4" />
Einstellungen
</button>
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto chat-scrollbar">
{#if activeTab === 'members'}
<!-- Invite User -->
<div class="border-b border-border p-3">
<div class="relative">
<MagnifyingGlass
class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground"
/>
<input
type="text"
bind:value={inviteQuery}
oninput={handleSearchInput}
class="w-full rounded-xl bg-surface border border-border px-4 py-2 pl-10
text-sm text-foreground focus:ring-2 focus:ring-primary focus:outline-none
placeholder:text-muted-foreground"
placeholder="Benutzer einladen..."
/>
{#if searching}
<CircleNotch
class="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin text-muted-foreground"
/>
{/if}
</div>
<!-- Search Results -->
{#if searchResults.length > 0}
<div class="mt-2 rounded-xl bg-surface border border-border overflow-hidden">
{#each searchResults as user}
<button
class="w-full flex items-center gap-2 px-3 py-2 hover:bg-surface-hover transition-colors"
onclick={() => inviteUser(user.userId)}
disabled={inviting}
>
<div
class="w-8 h-8 rounded-full flex items-center justify-center bg-gradient-to-br from-violet-500 to-purple-600 text-white text-xs font-medium"
>
{user.displayName?.[0]?.toUpperCase() || '?'}
</div>
<span class="flex-1 truncate text-sm text-foreground text-left">
{user.displayName || user.userId}
</span>
<UserPlus class="h-4 w-4 text-primary" />
</button>
{/each}
</div>
{/if}
</div>
<!-- Member List -->
<div class="p-2">
{#each members as member}
{@const PowerIcon = getPowerLevelIcon(member.powerLevel)}
<div
class="flex items-center gap-3 px-3 py-2 rounded-xl hover:bg-surface-hover transition-colors"
>
<div class="w-10 h-10 rounded-full overflow-hidden flex-shrink-0">
{#if member.avatarUrl}
<img src={member.avatarUrl} alt="" class="w-full h-full object-cover" />
{:else}
<div
class="w-full h-full flex items-center justify-center bg-gradient-to-br from-violet-500 to-purple-600 text-white text-sm font-medium"
>
{member.displayName.charAt(0).toUpperCase()}
</div>
{/if}
</div>
<div class="flex-1 min-w-0">
<p class="truncate font-medium text-foreground">{member.displayName}</p>
<p class="truncate text-xs text-muted-foreground">{member.userId}</p>
</div>
{#if PowerIcon}
<PowerIcon class="h-4 w-4 text-amber-500" />
{/if}
</div>
{/each}
</div>
{:else if activeTab === 'widgets'}
<!-- Widgets -->
<div class="p-3">
{#if widgets.length === 0}
<div class="text-center py-8">
<SquaresFour class="h-12 w-12 mx-auto mb-2 text-muted-foreground opacity-50" />
<p class="text-muted-foreground">Keine Widgets in diesem Raum</p>
<p class="text-xs mt-1 text-muted-foreground">Bots können Widgets hinzufügen</p>
</div>
{:else}
<div class="space-y-3">
{#each widgets as widget}
<div class="rounded-xl bg-surface border border-border overflow-hidden">
<div class="flex items-center justify-between p-3">
<h3 class="font-medium text-sm text-foreground">{widget.name}</h3>
<button
class="px-3 py-1 text-xs font-medium rounded-lg
bg-muted hover:bg-surface-hover
text-foreground transition-colors"
onclick={() => toggleWidget(widget.id)}
>
{expandedWidget === widget.id ? 'Schließen' : 'Öffnen'}
</button>
</div>
{#if expandedWidget === widget.id}
<div class="border-t border-border">
<iframe
src={getWidgetUrl(widget)}
title={widget.name}
class="w-full border-0 bg-surface"
style="height: 300px;"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
></iframe>
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{:else}
<!-- Settings -->
<div class="p-3 space-y-2">
<!-- Notifications -->
<button
class="w-full flex items-center gap-3 px-4 py-3 rounded-xl
hover:bg-surface-hover transition-colors"
>
<Bell class="h-5 w-5 text-foreground" />
<span class="flex-1 text-left text-foreground">Benachrichtigungen</span>
<span class="px-2 py-0.5 rounded-full bg-success/10 text-success text-xs font-medium">
An
</span>
</button>
<!-- Leave Room -->
<button
class="w-full flex items-center gap-3 px-4 py-3 rounded-xl
text-error hover:bg-error/10 transition-colors"
onclick={leaveRoom}
>
<SignOut class="h-5 w-5" />
<span class="flex-1 text-left">Raum verlassen</span>
</button>
</div>
{/if}
</div>
</div>
{/if}

View file

@ -1,187 +0,0 @@
<script lang="ts">
import { matrixStore } from '$lib/matrix';
import { MagnifyingGlass, X, CircleNotch, ChatText } from '@mana/shared-icons';
import { formatDistanceToNow } from 'date-fns';
import { de } from 'date-fns/locale';
interface Props {
open: boolean;
onClose: () => void;
onSelectResult?: (roomId: string, eventId: string) => void;
}
let { open, onClose, onSelectResult }: Props = $props();
let query = $state('');
let searching = $state(false);
let searchResults = $state<
{
eventId: string;
sender: string;
senderName: string;
body: string;
timestamp: number;
roomId: string;
roomName: string;
}[]
>([]);
let searchScope = $state<'room' | 'all'>('room');
let hasSearched = $state(false);
let inputRef: HTMLInputElement;
$effect(() => {
if (open && inputRef) {
setTimeout(() => inputRef?.focus(), 100);
}
if (!open) {
query = '';
searchResults = [];
hasSearched = false;
}
});
async function handleSearch() {
if (!query.trim() || searching) return;
searching = true;
hasSearched = true;
try {
const roomId = searchScope === 'room' ? matrixStore.currentRoomId : undefined;
searchResults = await matrixStore.searchMessages(query, roomId || undefined);
} catch (e) {
console.error('Search failed:', e);
searchResults = [];
} finally {
searching = false;
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
handleSearch();
} else if (e.key === 'Escape') {
onClose();
}
}
function handleSelectResult(result: (typeof searchResults)[0]) {
matrixStore.selectRoom(result.roomId);
onSelectResult?.(result.roomId, result.eventId);
onClose();
}
function formatTime(timestamp: number): string {
return formatDistanceToNow(new Date(timestamp), { addSuffix: true, locale: de });
}
function highlightMatch(text: string, searchTerm: string): string {
if (!searchTerm.trim()) return text;
const regex = new RegExp(`(${searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
return text.replace(
regex,
'<mark class="bg-yellow-300/50 dark:bg-yellow-500/30 rounded px-0.5">$1</mark>'
);
}
</script>
{#if open}
<!-- Backdrop -->
<div
class="fixed inset-0 z-50 flex items-start justify-center bg-black/50 backdrop-blur-sm pt-20 px-4"
onclick={onClose}
onkeydown={handleKeydown}
role="dialog"
aria-modal="true"
tabindex="-1"
>
<!-- Dialog -->
<div
class="w-full max-w-2xl rounded-2xl bg-surface-elevated shadow-2xl overflow-hidden"
onclick={(e) => e.stopPropagation()}
role="document"
>
<!-- Search Header -->
<div class="flex items-center gap-3 p-4 border-b border-border">
<MagnifyingGlass class="h-5 w-5 text-muted-foreground flex-shrink-0" />
<input
bind:this={inputRef}
bind:value={query}
type="text"
placeholder="Nachrichten durchsuchen..."
class="flex-1 bg-transparent outline-none text-lg placeholder:text-muted-foreground"
onkeydown={handleKeydown}
/>
{#if searching}
<CircleNotch class="h-5 w-5 animate-spin text-muted-foreground" />
{/if}
<button class="p-1.5 rounded-lg hover:bg-surface-hover transition-colors" onclick={onClose}>
<X class="h-5 w-5" />
</button>
</div>
<!-- Scope Toggle -->
<div class="flex gap-2 px-4 py-2 border-b border-border bg-muted/30">
<button
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors
{searchScope === 'room' ? 'bg-primary text-primary-foreground' : 'hover:bg-surface-hover'}"
onclick={() => (searchScope = 'room')}
>
Aktueller Raum
</button>
<button
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors
{searchScope === 'all' ? 'bg-primary text-primary-foreground' : 'hover:bg-surface-hover'}"
onclick={() => (searchScope = 'all')}
>
Alle Räume
</button>
</div>
<!-- Results -->
<div class="max-h-[60vh] overflow-y-auto">
{#if searching}
<div class="flex items-center justify-center gap-2 py-12 text-muted-foreground">
<CircleNotch class="h-5 w-5 animate-spin" />
<span>Suche läuft...</span>
</div>
{:else if searchResults.length > 0}
<div class="divide-y divide-border">
{#each searchResults as result}
<button
class="w-full text-left px-4 py-3 hover:bg-surface-hover transition-colors"
onclick={() => handleSelectResult(result)}
>
<div class="flex items-center gap-2 mb-1">
<span class="font-medium text-sm">{result.senderName}</span>
{#if searchScope === 'all'}
<span class="text-xs text-muted-foreground">in {result.roomName}</span>
{/if}
<span class="text-xs text-muted-foreground ml-auto"
>{formatTime(result.timestamp)}</span
>
</div>
<p class="text-sm text-muted-foreground line-clamp-2">
{@html highlightMatch(result.body, query)}
</p>
</button>
{/each}
</div>
{:else if hasSearched && query.trim()}
<div class="flex flex-col items-center justify-center gap-3 py-12 text-muted-foreground">
<ChatText class="h-10 w-10 opacity-50" />
<p>Keine Nachrichten gefunden</p>
<p class="text-sm">Versuche es mit anderen Suchbegriffen</p>
</div>
{:else}
<div class="flex flex-col items-center justify-center gap-3 py-12 text-muted-foreground">
<MagnifyingGlass class="h-10 w-10 opacity-50" />
<p>Gib einen Suchbegriff ein</p>
<p class="text-sm">Drücke Enter zum Suchen</p>
</div>
{/if}
</div>
</div>
</div>
{/if}

View file

@ -1,186 +0,0 @@
<script lang="ts">
import { matrixStore, type SimpleMessage } from '$lib/matrix';
import Message from './Message.svelte';
import TypingIndicator from './TypingIndicator.svelte';
import { tick } from 'svelte';
import { CircleNotch, ArrowDown } from '@mana/shared-icons';
interface Props {
onReply?: (message: SimpleMessage) => void;
onEdit?: (message: SimpleMessage) => void;
onForward?: (message: SimpleMessage) => void;
}
let { onReply, onEdit, onForward }: Props = $props();
// Check if current room is encrypted
let isRoomEncrypted = $derived(matrixStore.currentSimpleRoom?.isEncrypted ?? false);
let container: HTMLDivElement;
let showScrollButton = $state(false);
let loadingMore = $state(false);
let prevMessageCount = $state(0);
let hasInitiallyScrolled = $state(false);
let currentRoomId = $state<string | null>(null);
// Track if user manually scrolled up (to read history)
let userScrolledUp = $state(false);
// Reset state when room changes
$effect(() => {
const roomId = matrixStore.currentRoomId;
if (roomId !== currentRoomId) {
currentRoomId = roomId;
hasInitiallyScrolled = false;
prevMessageCount = 0;
loadingMore = false;
showScrollButton = false;
userScrolledUp = false;
}
});
// Initial scroll to bottom when messages first load, and auto-scroll on new messages
$effect(() => {
const messageCount = matrixStore.messages.length;
// Initial scroll when messages first appear for this room
if (messageCount > 0 && !hasInitiallyScrolled && container) {
tick().then(() => {
if (container) {
container.scrollTop = container.scrollHeight;
hasInitiallyScrolled = true;
prevMessageCount = messageCount;
}
});
return;
}
// Auto-scroll on new messages (only if user hasn't manually scrolled up)
if (messageCount > prevMessageCount && container && hasInitiallyScrolled) {
if (!userScrolledUp) {
// Use double tick to ensure DOM has rendered the new message
tick().then(() => {
tick().then(() => {
if (container) {
container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
}
});
});
}
}
prevMessageCount = messageCount;
});
function handleScroll() {
if (!container) return;
// Calculate distance from bottom
const distanceFromBottom =
container.scrollHeight - container.scrollTop - container.clientHeight;
// Track if user manually scrolled up (more than 150px from bottom)
// Reset when they scroll back to bottom (within 50px)
if (distanceFromBottom > 150) {
userScrolledUp = true;
} else if (distanceFromBottom < 50) {
userScrolledUp = false;
}
// Show scroll button if not at bottom
showScrollButton = distanceFromBottom > 200;
// Load more when scrolled to top (only after initial scroll and with messages present)
if (
container.scrollTop < 100 &&
!loadingMore &&
hasInitiallyScrolled &&
matrixStore.messages.length > 0
) {
loadMore();
}
}
async function loadMore() {
if (loadingMore) return;
loadingMore = true;
const prevScrollHeight = container.scrollHeight;
await matrixStore.loadMoreMessages(50);
// Maintain scroll position after loading
await tick();
if (container) {
const newScrollHeight = container.scrollHeight;
container.scrollTop = newScrollHeight - prevScrollHeight;
}
loadingMore = false;
}
function scrollToBottom() {
userScrolledUp = false;
container?.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
}
</script>
<div class="relative flex-1 min-h-0 overflow-hidden">
<div
bind:this={container}
onscroll={handleScroll}
class="chat-scrollbar h-full overflow-y-auto p-4"
>
<!-- Loading indicator at top -->
{#if loadingMore}
<div class="flex justify-center py-4">
<CircleNotch class="h-6 w-6 animate-spin text-base-content/50" />
</div>
{/if}
<!-- Messages -->
<div>
{#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)}
<Message
{message}
{showAvatar}
showTimestamp={showDateSeparator}
{isSameSender}
{isLastInGroup}
showEncryptionBadge={isRoomEncrypted}
{onReply}
{onEdit}
{onForward}
/>
{:else}
<div class="flex h-full flex-col items-center justify-center text-base-content/50">
<p>Noch keine Nachrichten</p>
<p class="text-sm">Starte die Konversation!</p>
</div>
{/each}
</div>
<!-- Typing Indicator -->
{#if matrixStore.currentRoomTyping.length > 0}
<TypingIndicator users={matrixStore.currentRoomTyping} />
{/if}
</div>
<!-- Scroll to bottom button -->
{#if showScrollButton}
<button
onclick={scrollToBottom}
class="absolute bottom-4 right-4 btn btn-circle btn-sm shadow-lg"
>
<ArrowDown class="h-4 w-4" />
</button>
{/if}
</div>

View file

@ -1,70 +0,0 @@
<script lang="ts">
import { matrixStore } from '$lib/matrix';
import { User } from '@mana/shared-icons';
interface Props {
users: string[];
}
let { users }: Props = $props();
// Get full user info from room members
let typingUsers = $derived(() => {
const members = matrixStore.getRoomMembers();
return users.map((name) => {
const member = members.find((m) => m.displayName === name);
return {
name,
avatarUrl: member?.avatarUrl,
};
});
});
let text = $derived(() => {
if (users.length === 0) return '';
if (users.length === 1) return `${users[0]} tippt...`;
if (users.length === 2) return `${users[0]} und ${users[1]} tippen...`;
return `${users[0]} und ${users.length - 1} weitere tippen...`;
});
</script>
{#if users.length > 0}
<div class="flex items-center gap-3 px-4 py-2">
<!-- User avatars (stacked) -->
<div class="flex -space-x-2">
{#each typingUsers().slice(0, 3) as user, i}
{#if user.avatarUrl}
<img
src={user.avatarUrl}
alt={user.name}
class="w-6 h-6 rounded-full border-2 border-background object-cover"
style="z-index: {3 - i}"
/>
{:else}
<div
class="w-6 h-6 rounded-full border-2 border-background bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center"
style="z-index: {3 - i}"
>
<User class="w-3 h-3 text-white" />
</div>
{/if}
{/each}
</div>
<!-- Animated dots -->
<div class="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-muted">
<span
class="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms]"
></span>
<span
class="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:150ms]"
></span>
<span
class="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:300ms]"
></span>
</div>
<!-- Text -->
<span class="text-sm text-muted-foreground">{text()}</span>
</div>
{/if}

View file

@ -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';

View file

@ -1,158 +0,0 @@
<script lang="ts">
import { matrixStore } from '$lib/matrix';
import type { VerificationRequest, SasVerification } from '$lib/matrix/types';
import { Check, X, CircleNotch, ShieldCheck } from '@mana/shared-icons';
interface Props {
request: VerificationRequest;
onComplete: () => void;
onCancel: () => void;
}
let { request, onComplete, onCancel }: Props = $props();
let phase = $state<'waiting' | 'emojis' | 'confirming' | 'done' | 'error'>('waiting');
let emojis = $state<{ emoji: string; description: string }[]>([]);
let error = $state<string | null>(null);
// In a real implementation, we would listen to SAS events from the SDK
// For now, this shows the UI flow
$effect(() => {
// Watch verification phase changes
if (request.phase === 'done') {
phase = 'done';
} else if (request.phase === 'cancelled') {
phase = 'error';
error = 'Verifizierung wurde abgebrochen';
} else if (request.phase === 'started') {
// When verification starts, we should receive emoji data
// This would normally come from SDK events
phase = 'emojis';
}
});
// Simulated emoji data for demonstration
// In production, this comes from the verifier.sasEvent
const demoEmojis = [
{ emoji: '🐶', description: 'Dog' },
{ emoji: '🎸', description: 'Guitar' },
{ emoji: '🏠', description: 'House' },
{ emoji: '🎨', description: 'Palette' },
{ emoji: '🔑', description: 'Key' },
{ emoji: '🎯', description: 'Bullseye' },
{ emoji: '🚀', description: 'Rocket' },
];
// Start showing emojis after a delay (simulating the handshake)
$effect(() => {
if (request.phase === 'ready' || request.phase === 'requested') {
const timer = setTimeout(() => {
// In real implementation, emojis come from verifier events
emojis = demoEmojis;
phase = 'emojis';
}, 2000);
return () => clearTimeout(timer);
}
});
async function confirmMatch() {
phase = 'confirming';
try {
const success = await matrixStore.confirmSasVerification(request.requestId);
if (success) {
phase = 'done';
setTimeout(onComplete, 1500);
} else {
phase = 'error';
error = 'Bestätigung fehlgeschlagen';
}
} catch (err) {
phase = 'error';
error = 'Ein Fehler ist aufgetreten';
}
}
function rejectMatch() {
onCancel();
}
</script>
<div class="space-y-6">
{#if phase === 'waiting'}
<div class="flex flex-col items-center gap-4 py-8">
<CircleNotch class="h-12 w-12 animate-spin text-primary" />
<p class="text-center text-muted-foreground">Warte auf Antwort vom anderen Gerät...</p>
<p class="text-sm text-muted-foreground/70">
Öffne die Verifizierungsanfrage auf deinem anderen Gerät.
</p>
</div>
{:else if phase === 'emojis'}
<div class="space-y-4">
<p class="text-center text-muted-foreground">
Vergleiche die folgenden Emojis mit deinem anderen Gerät:
</p>
<!-- Emoji Grid -->
<div class="grid grid-cols-7 gap-2 rounded-lg bg-muted p-4">
{#each emojis as item}
<div class="flex flex-col items-center gap-1">
<span class="text-3xl">{item.emoji}</span>
<span class="text-xs text-muted-foreground text-center">{item.description}</span>
</div>
{/each}
</div>
<p class="text-center text-sm text-muted-foreground">
Stimmen die Emojis auf beiden Geräten überein?
</p>
<!-- Action Buttons -->
<div class="flex gap-3 justify-center">
<button
class="flex items-center gap-2 rounded-lg border border-error px-4 py-2 text-error hover:bg-error/10"
onclick={rejectMatch}
>
<X class="h-4 w-4" />
Nein, stimmen nicht
</button>
<button
class="flex items-center gap-2 rounded-lg bg-success px-4 py-2 text-white hover:brightness-90"
onclick={confirmMatch}
>
<Check class="h-4 w-4" />
Ja, stimmen überein
</button>
</div>
</div>
{:else if phase === 'confirming'}
<div class="flex flex-col items-center gap-4 py-8">
<CircleNotch class="h-12 w-12 animate-spin text-primary" />
<p class="text-center text-muted-foreground">Bestätige Verifizierung...</p>
</div>
{:else if phase === 'done'}
<div class="flex flex-col items-center gap-4 py-8">
<div class="rounded-full bg-success/20 p-4">
<ShieldCheck class="h-12 w-12 text-success" />
</div>
<p class="text-center text-lg font-medium text-success">Verifizierung erfolgreich!</p>
<p class="text-center text-sm text-muted-foreground">
Das Gerät wurde erfolgreich verifiziert.
</p>
</div>
{:else if phase === 'error'}
<div class="flex flex-col items-center gap-4 py-8">
<div class="rounded-full bg-error/20 p-4">
<X class="h-12 w-12 text-error" />
</div>
<p class="text-center text-lg font-medium text-error">Verifizierung fehlgeschlagen</p>
{#if error}
<p class="text-center text-sm text-muted-foreground">
{error}
</p>
{/if}
<button class="btn-ghost" onclick={onCancel}> Schließen </button>
</div>
{/if}
</div>

View file

@ -1,374 +0,0 @@
<script lang="ts">
import { matrixStore } from '$lib/matrix';
import { formatRecoveryKey, isValidRecoveryKey } from '$lib/matrix/crypto';
import {
X,
Key,
DownloadSimple,
Copy,
Check,
CircleNotch,
Warning,
ShieldCheck,
} from '@mana/shared-icons';
interface Props {
open: boolean;
onClose: () => void;
mode?: 'setup' | 'restore';
}
let { open, onClose, mode = 'setup' }: Props = $props();
let currentMode = $state<'setup' | 'restore'>(mode);
let step = $state<'intro' | 'passphrase' | 'show-key' | 'confirm' | 'restore' | 'done'>('intro');
let loading = $state(false);
let error = $state<string | null>(null);
// Setup state
let usePassphrase = $state(false);
let passphrase = $state('');
let passphraseConfirm = $state('');
let recoveryKey = $state('');
let keyCopied = $state(false);
// Restore state
let inputRecoveryKey = $state('');
$effect(() => {
if (open) {
currentMode = mode;
step = 'intro';
resetState();
}
});
function resetState() {
loading = false;
error = null;
usePassphrase = false;
passphrase = '';
passphraseConfirm = '';
recoveryKey = '';
keyCopied = false;
inputRecoveryKey = '';
}
function handleClose() {
resetState();
onClose();
}
async function startSetup() {
if (usePassphrase) {
step = 'passphrase';
} else {
await generateKey();
}
}
async function generateKey() {
if (usePassphrase && passphrase !== passphraseConfirm) {
error = 'Passphrasen stimmen nicht überein';
return;
}
loading = true;
error = null;
try {
const result = await matrixStore.bootstrapSecretStorage(
usePassphrase ? passphrase : undefined
);
if (result) {
recoveryKey = result.recoveryKey;
step = 'show-key';
} else {
error = 'Fehler beim Erstellen der Verschlüsselungsschlüssel';
}
} catch (err) {
error = 'Ein unerwarteter Fehler ist aufgetreten';
console.error('Error bootstrapping secret storage:', err);
}
loading = false;
}
async function copyKey() {
try {
await navigator.clipboard.writeText(recoveryKey);
keyCopied = true;
setTimeout(() => (keyCopied = false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
}
function downloadKey() {
const blob = new Blob(
[
`Matrix Recovery Key\n\n${formatRecoveryKey(recoveryKey)}\n\nBewahre diesen Schlüssel sicher auf!`,
],
{ type: 'text/plain' }
);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'matrix-recovery-key.txt';
a.click();
URL.revokeObjectURL(url);
}
function confirmKeySaved() {
step = 'done';
}
async function restoreKey() {
if (!isValidRecoveryKey(inputRecoveryKey)) {
error = 'Ungültiges Recovery Key Format';
return;
}
loading = true;
error = null;
try {
const success = await matrixStore.restoreFromRecoveryKey(inputRecoveryKey.trim());
if (success) {
step = 'done';
} else {
error = 'Recovery Key konnte nicht wiederhergestellt werden. Bitte prüfe den Schlüssel.';
}
} catch (err) {
error = 'Ein unerwarteter Fehler ist aufgetreten';
console.error('Error restoring from recovery key:', err);
}
loading = false;
}
</script>
{#if open}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onclick={handleClose}
>
<div
class="w-full max-w-lg rounded-xl bg-surface shadow-xl"
onclick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-border px-6 py-4">
<div class="flex items-center gap-3">
<Key class="h-6 w-6 text-primary" />
<h2 class="text-xl font-semibold">
{currentMode === 'setup' ? 'Verschlüsselung einrichten' : 'Schlüssel wiederherstellen'}
</h2>
</div>
<button class="btn-ghost rounded-full p-2" onclick={handleClose}>
<X class="h-5 w-5" />
</button>
</div>
<!-- Content -->
<div class="px-6 py-4">
{#if step === 'intro'}
<div class="space-y-4">
{#if currentMode === 'setup'}
<p class="text-muted-foreground">
Richte einen Recovery Key ein, um deine verschlüsselten Nachrichten auf anderen
Geräten wiederherzustellen.
</p>
<div class="flex items-start gap-3 rounded-lg bg-warning/10 p-3 text-warning">
<Warning class="h-5 w-5 flex-shrink-0 mt-0.5" />
<span class="text-sm">
Ohne Recovery Key verlierst du den Zugriff auf deine verschlüsselten Nachrichten,
wenn du dich abmeldest.
</span>
</div>
<label class="flex cursor-pointer items-start gap-3">
<input
type="checkbox"
class="mt-1 h-4 w-4 rounded border-border"
bind:checked={usePassphrase}
/>
<div>
<span class="font-medium">Mit Passphrase sichern (optional)</span>
<p class="text-xs text-muted-foreground">
Du kannst zusätzlich eine Passphrase festlegen, um den Recovery Key zu schützen.
</p>
</div>
</label>
{:else}
<p class="text-muted-foreground">
Gib deinen Recovery Key ein, um auf deine verschlüsselten Nachrichten zugreifen zu
können.
</p>
<div class="space-y-2">
<label class="text-sm font-medium" for="recovery-key-input"> Recovery Key </label>
<textarea
id="recovery-key-input"
class="input h-24 w-full resize-none font-mono text-sm"
placeholder="Gib hier deinen Recovery Key ein..."
bind:value={inputRecoveryKey}
></textarea>
</div>
{/if}
{#if error}
<div class="rounded-lg bg-error/10 p-3 text-error text-sm">
{error}
</div>
{/if}
</div>
{:else if step === 'passphrase'}
<div class="space-y-4">
<p class="text-muted-foreground">
Gib eine sichere Passphrase ein, die du dir merken kannst.
</p>
<div class="space-y-2">
<label class="text-sm font-medium" for="passphrase"> Passphrase </label>
<input
id="passphrase"
type="password"
class="input w-full"
bind:value={passphrase}
placeholder="Sichere Passphrase eingeben"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium" for="passphrase-confirm">
Passphrase bestätigen
</label>
<input
id="passphrase-confirm"
type="password"
class="input w-full"
bind:value={passphraseConfirm}
placeholder="Passphrase wiederholen"
/>
</div>
{#if error}
<div class="rounded-lg bg-error/10 p-3 text-error text-sm">
{error}
</div>
{/if}
</div>
{:else if step === 'show-key'}
<div class="space-y-4">
<div class="flex items-start gap-3 rounded-lg bg-warning/10 p-3 text-warning">
<Warning class="h-5 w-5 flex-shrink-0 mt-0.5" />
<span class="text-sm">
Speichere diesen Schlüssel an einem sicheren Ort. Du benötigst ihn, um deine
Nachrichten wiederherzustellen.
</span>
</div>
<div class="rounded-lg bg-muted p-4">
<p class="mb-2 text-sm font-medium">Dein Recovery Key:</p>
<div class="rounded bg-surface p-3 font-mono text-sm break-all border border-border">
{formatRecoveryKey(recoveryKey)}
</div>
</div>
<div class="flex gap-2">
<button
class="btn-secondary flex-1 flex items-center justify-center gap-2"
onclick={copyKey}
>
{#if keyCopied}
<Check class="h-4 w-4 text-success" />
Kopiert!
{:else}
<Copy class="h-4 w-4" />
Kopieren
{/if}
</button>
<button
class="btn-secondary flex-1 flex items-center justify-center gap-2"
onclick={downloadKey}
>
<DownloadSimple class="h-4 w-4" />
Herunterladen
</button>
</div>
</div>
{:else if step === 'done'}
<div class="flex flex-col items-center gap-4 py-8">
<div class="rounded-full bg-success/20 p-4">
<ShieldCheck class="h-12 w-12 text-success" />
</div>
<p class="text-center text-lg font-medium text-success">
{currentMode === 'setup'
? 'Verschlüsselung eingerichtet!'
: 'Schlüssel wiederhergestellt!'}
</p>
<p class="text-center text-sm text-muted-foreground">
{currentMode === 'setup'
? 'Deine Nachrichten sind jetzt sicher verschlüsselt.'
: 'Du kannst jetzt auf deine verschlüsselten Nachrichten zugreifen.'}
</p>
</div>
{/if}
</div>
<!-- Footer -->
<div class="flex justify-end gap-2 border-t border-border px-6 py-4">
{#if step === 'intro'}
<button class="btn-ghost" onclick={handleClose}>Abbrechen</button>
{#if currentMode === 'setup'}
<button
class="btn-primary flex items-center gap-2"
onclick={startSetup}
disabled={loading}
>
{#if loading}
<CircleNotch class="h-4 w-4 animate-spin" />
{/if}
Weiter
</button>
{:else}
<button
class="btn-primary flex items-center gap-2"
onclick={restoreKey}
disabled={loading || !inputRecoveryKey.trim()}
>
{#if loading}
<CircleNotch class="h-4 w-4 animate-spin" />
{/if}
Wiederherstellen
</button>
{/if}
{:else if step === 'passphrase'}
<button class="btn-ghost" onclick={() => (step = 'intro')}>Zurück</button>
<button
class="btn-primary flex items-center gap-2"
onclick={generateKey}
disabled={loading || !passphrase || passphrase !== passphraseConfirm}
>
{#if loading}
<CircleNotch class="h-4 w-4 animate-spin" />
{/if}
Schlüssel erstellen
</button>
{:else if step === 'show-key'}
<button class="btn-primary" onclick={confirmKeySaved}>
Ich habe den Schlüssel gespeichert
</button>
{:else if step === 'done'}
<button class="btn-primary" onclick={handleClose}> Fertig </button>
{/if}
</div>
</div>
</div>
{/if}

View file

@ -1,244 +0,0 @@
<script lang="ts">
import { matrixStore } from '$lib/matrix';
import type { DeviceInfo, VerificationRequest } from '$lib/matrix/types';
import { formatDeviceName } from '$lib/matrix/crypto';
import {
X,
Shield,
ShieldCheck,
ShieldWarning,
DeviceMobile,
Monitor,
CircleNotch,
ArrowsClockwise,
} from '@mana/shared-icons';
import EmojiVerification from './EmojiVerification.svelte';
interface Props {
open: boolean;
onClose: () => void;
}
let { open, onClose }: Props = $props();
let devices = $state<DeviceInfo[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let verificationStarted = $state(false);
let selectedDevice = $state<DeviceInfo | null>(null);
// Subscribe to active verification
let activeVerification = $derived(matrixStore.activeVerification);
$effect(() => {
if (open) {
loadDevices();
}
});
async function loadDevices() {
loading = true;
error = null;
try {
devices = await matrixStore.getDevices();
} catch (err) {
error = 'Geräte konnten nicht geladen werden';
console.error('Error loading devices:', err);
}
loading = false;
}
async function startVerification(device: DeviceInfo) {
if (device.isCurrentDevice) return;
selectedDevice = device;
verificationStarted = true;
const success = await matrixStore.startVerification(
matrixStore.userId || undefined,
device.deviceId
);
if (!success) {
error = 'Verifizierung konnte nicht gestartet werden';
verificationStarted = false;
selectedDevice = null;
}
}
async function handleVerificationComplete() {
verificationStarted = false;
selectedDevice = null;
await loadDevices();
}
function handleVerificationCancel() {
if (activeVerification) {
matrixStore.cancelVerification(activeVerification.requestId);
}
verificationStarted = false;
selectedDevice = null;
}
function handleClose() {
if (verificationStarted && activeVerification) {
matrixStore.cancelVerification(activeVerification.requestId);
}
verificationStarted = false;
selectedDevice = null;
error = null;
onClose();
}
function getDeviceIcon(device: DeviceInfo) {
const name = (device.displayName || '').toLowerCase();
if (
name.includes('mobile') ||
name.includes('phone') ||
name.includes('android') ||
name.includes('ios')
) {
return DeviceMobile;
}
return Monitor;
}
</script>
{#if open}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onclick={handleClose}
>
<div
class="w-full max-w-lg rounded-xl bg-surface shadow-xl"
onclick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-border px-6 py-4">
<div class="flex items-center gap-3">
<Shield class="h-6 w-6 text-primary" />
<h2 class="text-xl font-semibold">Geräte-Verifizierung</h2>
</div>
<button class="btn-ghost rounded-full p-2" onclick={handleClose}>
<X class="h-5 w-5" />
</button>
</div>
<!-- Content -->
<div class="px-6 py-4">
{#if verificationStarted && activeVerification}
<!-- Verification in progress -->
<EmojiVerification
request={activeVerification}
onComplete={handleVerificationComplete}
onCancel={handleVerificationCancel}
/>
{:else}
<!-- Device list -->
<div class="space-y-4">
<p class="text-muted-foreground">
Verifiziere deine Geräte um sicherzustellen, dass du der einzige bist, der auf deine
verschlüsselten Nachrichten zugreifen kann.
</p>
{#if error}
<div class="rounded-lg bg-error/10 p-3 text-error">
<span>{error}</span>
</div>
{/if}
{#if loading}
<div class="flex justify-center py-8">
<CircleNotch class="h-8 w-8 animate-spin text-primary" />
</div>
{:else if devices.length === 0}
<div class="py-8 text-center text-muted-foreground">
<p>Keine Geräte gefunden</p>
</div>
{:else}
<div class="space-y-2">
{#each devices as device}
{@const DeviceIcon = getDeviceIcon(device)}
<div
class="flex items-center gap-4 rounded-lg border border-border p-4 {device.isCurrentDevice
? 'bg-muted'
: ''}"
>
<div class="flex-shrink-0">
{#if device.verified}
<div class="relative">
<DeviceIcon class="h-10 w-10 text-muted-foreground" />
<ShieldCheck class="absolute -right-1 -bottom-1 h-5 w-5 text-success" />
</div>
{:else if device.blocked}
<div class="relative">
<DeviceIcon class="h-10 w-10 text-muted-foreground" />
<ShieldWarning class="absolute -right-1 -bottom-1 h-5 w-5 text-error" />
</div>
{:else}
<div class="relative">
<DeviceIcon class="h-10 w-10 text-muted-foreground" />
<ShieldWarning class="absolute -right-1 -bottom-1 h-5 w-5 text-warning" />
</div>
{/if}
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium truncate">
{formatDeviceName(device.displayName, device.deviceId)}
</span>
{#if device.isCurrentDevice}
<span class="badge badge-primary text-xs">Dieses Gerät</span>
{/if}
</div>
<div class="text-sm text-muted-foreground">
{device.deviceId}
</div>
<div class="text-xs mt-1">
{#if device.verified}
<span class="text-success">Verifiziert</span>
{:else if device.blocked}
<span class="text-error">Blockiert</span>
{:else}
<span class="text-warning">Nicht verifiziert</span>
{/if}
</div>
</div>
{#if !device.isCurrentDevice && !device.verified}
<button class="btn-primary text-sm" onclick={() => startVerification(device)}>
Verifizieren
</button>
{/if}
</div>
{/each}
</div>
{/if}
<!-- Refresh button -->
<div class="flex justify-center pt-2">
<button
class="btn-ghost flex items-center gap-2 text-sm"
onclick={loadDevices}
disabled={loading}
>
<ArrowsClockwise class={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
Aktualisieren
</button>
</div>
</div>
{/if}
</div>
<!-- Footer -->
<div class="flex justify-end border-t border-border px-6 py-4">
<button class="btn-ghost" onclick={handleClose}>
{verificationStarted ? 'Abbrechen' : 'Schließen'}
</button>
</div>
</div>
</div>
{/if}

View file

@ -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';

View file

@ -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);
});
});

View file

@ -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
? '<p>Manalink ist ein sicherer Messenger auf Basis des <strong>Matrix-Protokolls</strong> — einem dezentralen, offenen Standard für Kommunikation:</p><ul><li>Ende-zu-Ende-verschlüsselt</li><li>Dezentral — kein einzelner Server kontrolliert deine Daten</li><li>Kompatibel mit anderen Matrix-Clients (Element, FluffyChat, etc.)</li><li>Der Standard-Homeserver ist <code>matrix.mana.how</code></li></ul>'
: '<p>Manalink is a secure messenger based on the <strong>Matrix protocol</strong> — a decentralized, open standard for communication:</p><ul><li>End-to-end encrypted</li><li>Decentralized — no single server controls your data</li><li>Compatible with other Matrix clients (Element, FluffyChat, etc.)</li><li>The default homeserver is <code>matrix.mana.how</code></li></ul>',
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
? '<p>Du kannst dich auf zwei Wegen anmelden:</p><ul><li><strong>Matrix-Konto</strong> — Benutzername und Passwort eines Matrix-Homeservers</li><li><strong>SSO über Mana Core</strong> — Anmeldung mit deinem Mana-Konto</li></ul><p>Der Standard-Homeserver ist <code>matrix.mana.how</code>, aber du kannst jeden beliebigen Matrix-Homeserver verwenden.</p>'
: '<p>You can log in two ways:</p><ul><li><strong>Matrix account</strong> — Username and password from a Matrix homeserver</li><li><strong>SSO via Mana Core</strong> — Login with your Mana account</li></ul><p>The default homeserver is <code>matrix.mana.how</code>, but you can use any Matrix homeserver.</p>',
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
? '<p>Räume sind Chatgruppen in Matrix:</p><ul><li><strong>Direktnachrichten</strong> — 1:1 Gespräche</li><li><strong>Gruppenräume</strong> — Mehrere Teilnehmer</li><li>Du kannst Räume erstellen, beitreten und verwalten</li><li>Räume zeigen ungelesene Nachrichten und Highlight-Zähler an</li></ul>'
: '<p>Rooms are chat groups in Matrix:</p><ul><li><strong>Direct messages</strong> — 1:1 conversations</li><li><strong>Group rooms</strong> — Multiple participants</li><li>You can create, join, and manage rooms</li><li>Rooms show unread message and highlight counters</li></ul>',
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
? '<p>Manalink bietet umfangreiche Nachrichtenfunktionen:</p><ul><li><strong>Textnachrichten</strong> senden und empfangen</li><li><strong>Tipp-Indikatoren</strong> — Sieh, wenn jemand gerade tippt</li><li><strong>Lesebestätigungen</strong> — Wisse, wann deine Nachricht gelesen wurde</li><li><strong>Nachrichtensuche</strong> — Durchsuche den Chatverlauf</li><li><strong>Paginierung</strong> — Lade ältere Nachrichten nach</li></ul>'
: '<p>Manalink offers comprehensive messaging features:</p><ul><li><strong>Text messages</strong> — send and receive</li><li><strong>Typing indicators</strong> — See when someone is typing</li><li><strong>Read receipts</strong> — Know when your message was read</li><li><strong>Message search</strong> — Search through chat history</li><li><strong>Pagination</strong> — Load older messages</li></ul>',
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
? '<p>Ja, Manalink unterstützt <strong>Ende-zu-Ende-Verschlüsselung (E2EE)</strong> über das Matrix-Protokoll:</p><ul><li>Verschlüsselte Räume sind nur für die Teilnehmer lesbar</li><li>Nicht einmal der Server kann die Nachrichten lesen</li><li>Die Verschlüsselung verwendet bewährte kryptographische Verfahren (Olm/Megolm)</li></ul>'
: '<p>Yes, Manalink supports <strong>end-to-end encryption (E2EE)</strong> via the Matrix protocol:</p><ul><li>Encrypted rooms are only readable by participants</li><li>Not even the server can read the messages</li><li>Encryption uses proven cryptographic methods (Olm/Megolm)</li></ul>',
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
? '<p>Jeder Raum hat eigene Einstellungen:</p><ul><li><strong>Name und Thema</strong> des Raums ändern</li><li><strong>Mitglieder</strong> einladen und verwalten</li><li><strong>Benachrichtigungen</strong> pro Raum konfigurieren</li></ul>'
: '<p>Each room has its own settings:</p><ul><li>Change the <strong>name and topic</strong> of the room</li><li><strong>Invite and manage</strong> members</li><li>Configure <strong>notifications</strong> per room</li></ul>',
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
? '<p>Unter <strong>Bots</strong> findest du automatisierte Assistenten, die in Räumen helfen können. Bots können Aufgaben automatisieren, Informationen bereitstellen und den Chat bereichern.</p>'
: '<p>Under <strong>Bots</strong> you can find automated assistants that can help in rooms. Bots can automate tasks, provide information, and enrich the chat experience.</p>',
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
? '<p>Ja! Manalink ist eine <strong>Progressive Web App (PWA)</strong>:</p><ul><li>Öffne die App im Browser und tippe auf <strong>"Zum Startbildschirm hinzufügen"</strong></li><li>Funktioniert auch offline dank Service Worker Caching</li><li>Push-Benachrichtigungen für neue Nachrichten</li><li>Vollbild-App-Erfahrung ohne Browser-Leiste</li></ul>'
: '<p>Yes! Manalink is a <strong>Progressive Web App (PWA)</strong>:</p><ul><li>Open the app in your browser and tap <strong>"Add to Home Screen"</strong></li><li>Works offline thanks to service worker caching</li><li>Push notifications for new messages</li><li>Fullscreen app experience without browser bar</li></ul>',
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
? '<p>Dein Feedback hilft uns, Manalink zu verbessern:</p><ul><li>Nutze die <strong>Feedback-Seite</strong> im Menü, um Verbesserungsvorschläge, Fehlermeldungen oder Feature-Wünsche einzureichen</li><li>Wir lesen jedes Feedback und arbeiten kontinuierlich an Verbesserungen</li></ul>'
: '<p>Your feedback helps us improve Manalink:</p><ul><li>Use the <strong>Feedback page</strong> in the menu to submit improvement suggestions, bug reports, or feature requests</li><li>We read every piece of feedback and continuously work on improvements</li></ul>',
category: 'general',
order: 9,
language: isDE ? 'de' : 'en',
tags: isDE
? ['feedback', 'verbesserung', 'kontakt']
: ['feedback', 'improvement', 'contact'],
},
...getPrivacyFAQs(locale, {
dataTypeDE: 'Nachrichten',
dataTypeEN: 'messages',
extraBulletsDE: [
'<strong>Ende-zu-Ende-Verschlüsselung</strong>: Verschlüsselte Räume sind nur für Teilnehmer lesbar — nicht einmal der Server kann mitlesen',
],
extraBulletsEN: [
'<strong>End-to-end encryption</strong>: 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
? '<p>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.</p>'
: '<p>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.</p>',
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',
},
};
}

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