mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
fix(mana-auth) + chore: rewrite /api/v1/auth/login JWT mint, remove Matrix stack
This commit bundles two unrelated changes that were swept together by an
accidental `git add -A` in another working session. Documented here so the
history reflects what's actually inside.
═══════════════════════════════════════════════════════════════════════
1. 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:
- 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.
═══════════════════════════════════════════════════════════════════════
2. chore: remove the entire self-hosted Matrix stack (Synapse, Element,
Manalink, mana-matrix-bot)
═══════════════════════════════════════════════════════════════════════
The Matrix subsystem ran parallel to the main Mana product without any
load-bearing integration: the unified web app never imported matrix-js-sdk,
the chat module uses mana-sync (local-first), and mana-matrix-bot's
plugins duplicated features the unified app already ships natively.
Keeping it alive cost a Synapse + Element + matrix-web + bot container
quartet, three Cloudflare routes, an OIDC provider plugin in mana-auth,
and a steady drip of devlog/dependency churn.
Removed:
- apps/matrix (Manalink web + mobile, ~150 files)
- services/mana-matrix-bot (Go bot with ~20 plugins)
- docker/matrix configs (Synapse + Element)
- synapse/element-web/matrix-web/mana-matrix-bot services in
docker-compose.macmini.yml
- matrix.mana.how/element.mana.how/link.mana.how Cloudflare tunnel routes
- OIDC provider plugin + matrix-synapse trustedClient + matrixUserLinks
table from mana-auth (oauth_* schema definitions also removed)
- MatrixService import path in mana-media (importFromMatrix endpoint)
- Matrix notification channel in mana-notify (worker, metrics, config,
channel_type enum, MatrixOptions handler)
- Matrix entries from shared-branding (mana-apps + app-icons),
notify-client, the i18n bundle, the observatory map, the credits
app-label list, the landing footer/apps page, the prometheus + alerts
+ promtail tier mappings, and the matrix-related deploy paths in
cd-macmini.yml + ci.yml
Devlog/manascore/blueprint entries that mention Matrix are left intact
as historical record. The oauth_* + matrix_user_links Postgres tables
stay on existing prod databases — code can no longer write to them, drop
them in a follow-up migration if you want them gone for real.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4eb5dfe4a0
commit
8e8b6ac65f
254 changed files with 88 additions and 29437 deletions
34
.github/workflows/cd-macmini.yml
vendored
34
.github/workflows/cd-macmini.yml
vendored
|
|
@ -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()
|
||||
|
|
|
|||
44
.github/workflows/ci.yml
vendored
44
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
],
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -67,7 +67,6 @@
|
|||
context: 'Context',
|
||||
general: 'Allgemein',
|
||||
cards: 'Cards',
|
||||
matrix: 'Matrix Bots',
|
||||
nutriphi: 'NutriPhi',
|
||||
picture: 'Picture',
|
||||
planta: 'Planta',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
21
apps/matrix/apps/mobile/.gitignore
vendored
21
apps/matrix/apps/mobile/.gitignore
vendored
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function AuthLayout() {
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="login" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 |
|
|
@ -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'],
|
||||
};
|
||||
};
|
||||
|
|
@ -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"
|
||||
|
|
@ -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": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -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/**'],
|
||||
},
|
||||
]);
|
||||
3
apps/matrix/apps/mobile/expo-env.d.ts
vendored
3
apps/matrix/apps/mobile/expo-env.d.ts
vendored
|
|
@ -1,3 +0,0 @@
|
|||
/// <reference types="expo/types" />
|
||||
|
||||
// NOTE: This file should not be edited and should be in your git ignore
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
|
@ -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,
|
||||
});
|
||||
10
apps/matrix/apps/mobile/nativewind-env.d.ts
vendored
10
apps/matrix/apps/mobile/nativewind-env.d.ts
vendored
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
module.exports = {
|
||||
semi: true,
|
||||
singleQuote: true,
|
||||
trailingComma: 'all',
|
||||
printWidth: 100,
|
||||
useTabs: true,
|
||||
plugins: ['prettier-plugin-tailwindcss'],
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export * from './types';
|
||||
export * from './client';
|
||||
export * from './media';
|
||||
export { useMatrixStore } from './store';
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
@ -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 {};
|
||||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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: [],
|
||||
};
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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);
|
||||
}
|
||||
23
apps/matrix/apps/web/src/app.d.ts
vendored
23
apps/matrix/apps/web/src/app.d.ts
vendored
|
|
@ -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 {};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export { default as CallView } from './CallView.svelte';
|
||||
export { default as IncomingCallDialog } from './IncomingCallDialog.svelte';
|
||||
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// 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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue