Compare commits
30 Commits
ec567b32eb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62f5914b04 | ||
|
|
0c6ab5727f | ||
|
|
0d24fcd9e5 | ||
|
|
c46e64367d | ||
|
|
5d4129b5b3 | ||
|
|
10e6e7a80a | ||
|
|
8c9a600645 | ||
|
|
e279215b85 | ||
|
|
1f342f555e | ||
|
|
155fce15e1 | ||
|
|
bbb025be63 | ||
|
|
e9aef0050a | ||
|
|
c48c833b65 | ||
|
|
f6e0b95a1f | ||
|
|
8d7c7d6f2a | ||
|
|
5bb9db2aad | ||
|
|
1ca1b45b55 | ||
|
|
88c742de0b | ||
|
|
a17dd00048 | ||
|
|
c8059f94f8 | ||
|
|
fdeecec63e | ||
|
|
53a8aa3869 | ||
|
|
12a724614d | ||
|
|
fbeda6a528 | ||
|
|
84cabdca7f | ||
|
|
0f6ba9222b | ||
|
|
44dd757243 | ||
|
|
37d752cce9 | ||
|
|
8d323ceab1 | ||
|
|
810b084e10 |
14
.env.example
Normal file
14
.env.example
Normal file
@@ -0,0 +1,14 @@
|
||||
NODE_ENV=production
|
||||
PORT=4000
|
||||
SESSION_SECRET=
|
||||
|
||||
# Relay-only Videochat via TURN
|
||||
VIDEO_TURN_URLS=
|
||||
VIDEO_TURN_USERNAME=
|
||||
VIDEO_TURN_CREDENTIAL=
|
||||
|
||||
# Optional zusaetzliche STUN-Server
|
||||
VIDEO_STUN_URLS=
|
||||
|
||||
# Optional: komplette ICE-Serverliste als JSON statt der Einzelvariablen
|
||||
# VIDEO_ICE_SERVERS_JSON=
|
||||
77
.gitea/workflows/deploy.yml
Normal file
77
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,77 @@
|
||||
name: Deploy SingleChat
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
SSH_HOST: ${{ vars.SSH_HOST }}
|
||||
SSH_PORT: ${{ vars.SSH_PORT }}
|
||||
SSH_USER: ${{ vars.SSH_USER }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Show resolved non-secret config
|
||||
run: |
|
||||
echo "SSH_HOST=$SSH_HOST"
|
||||
echo "SSH_PORT=$SSH_PORT"
|
||||
echo "SSH_USER=$SSH_USER"
|
||||
test -n "$SSH_HOST"
|
||||
test -n "$SSH_PORT"
|
||||
test -n "$SSH_USER"
|
||||
echo "DEPLOY_SCRIPT=/usr/local/bin/actualize-singlechat.sh"
|
||||
|
||||
- name: Prepare SSH
|
||||
run: |
|
||||
set -e
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s' "${{ secrets.PROD_SSH_KEY_B64 }}" | base64 -d > ~/.ssh/id_deploy
|
||||
chmod 600 ~/.ssh/id_deploy
|
||||
ssh-keyscan -p "$SSH_PORT" "$SSH_HOST" >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Test SSH connection
|
||||
run: |
|
||||
set -e
|
||||
ssh -i ~/.ssh/id_deploy \
|
||||
-o StrictHostKeyChecking=no \
|
||||
-o BatchMode=yes \
|
||||
-o ConnectTimeout=10 \
|
||||
-p "$SSH_PORT" \
|
||||
"$SSH_USER@$SSH_HOST" \
|
||||
"echo SSH OK"
|
||||
|
||||
- name: Install deployment script
|
||||
run: |
|
||||
set -e
|
||||
scp -i ~/.ssh/id_deploy \
|
||||
-o StrictHostKeyChecking=no \
|
||||
-o BatchMode=yes \
|
||||
-o ConnectTimeout=10 \
|
||||
-P "$SSH_PORT" \
|
||||
scripts/actualize-singlechat.sh \
|
||||
"$SSH_USER@$SSH_HOST:/tmp/actualize-singlechat.sh"
|
||||
ssh -i ~/.ssh/id_deploy \
|
||||
-o StrictHostKeyChecking=no \
|
||||
-o BatchMode=yes \
|
||||
-o ConnectTimeout=10 \
|
||||
-p "$SSH_PORT" \
|
||||
"$SSH_USER@$SSH_HOST" \
|
||||
"sudo install -m 755 /tmp/actualize-singlechat.sh /usr/local/bin/actualize-singlechat.sh"
|
||||
|
||||
- name: Run deployment script
|
||||
run: |
|
||||
set -e
|
||||
ssh -i ~/.ssh/id_deploy \
|
||||
-o StrictHostKeyChecking=no \
|
||||
-o BatchMode=yes \
|
||||
-o ConnectTimeout=10 \
|
||||
-p "$SSH_PORT" \
|
||||
"$SSH_USER@$SSH_HOST" \
|
||||
"sudo /usr/local/bin/actualize-singlechat.sh"
|
||||
136
ADSENSE.md
136
ADSENSE.md
@@ -1,130 +1,22 @@
|
||||
# AdSense in SingleChat
|
||||
# Werbung in YpChat
|
||||
|
||||
## Ziel
|
||||
Die bisherige AdSense-/Propeller-Dokumentation ist veraltet.
|
||||
|
||||
Im Header kann ein Google-AdSense-Banner eingeblendet werden. Die Einbindung ist bereits vorbereitet, aber nur aktiv, wenn die passenden Vite-Variablen gesetzt sind.
|
||||
Aktuell ist im Projekt eine direkte Adsterra-Headerbanner-Loesung aktiv:
|
||||
|
||||
## Bereits im Code vorbereitet
|
||||
- Mobile: `320x50`
|
||||
- Key: `fb9b5e7f817d40d72943dae0c54eb769`
|
||||
- Desktop: `468x60`
|
||||
- Key: `2b658317c1e28b4b4f234d26c8fca28d`
|
||||
|
||||
- Header-Komponente: [HeaderAdBanner.vue](/mnt/share/torsten/Programs/SingleChat/client/src/components/HeaderAdBanner.vue)
|
||||
- Einbindung in die Kopfzeilen:
|
||||
- [ChatView.vue](/mnt/share/torsten/Programs/SingleChat/client/src/views/ChatView.vue)
|
||||
- [PartnersView.vue](/mnt/share/torsten/Programs/SingleChat/client/src/views/PartnersView.vue)
|
||||
- [FeedbackView.vue](/mnt/share/torsten/Programs/SingleChat/client/src/views/FeedbackView.vue)
|
||||
Implementierung:
|
||||
|
||||
Aktiv wird der Banner nur mit:
|
||||
- [client/src/components/HeaderAdBanner.vue](/mnt/share/torsten/Programs/SingleChat/client/src/components/HeaderAdBanner.vue)
|
||||
|
||||
- `VITE_ADSENSE_CLIENT`
|
||||
- `VITE_ADSENSE_HEADER_SLOT`
|
||||
Funktionsweise:
|
||||
|
||||
## Was bei Google AdSense erledigt werden muss
|
||||
- bis `720px` Viewport wird das `320x50`-Banner geladen
|
||||
- ab `721px` Viewport wird das `468x60`-Banner geladen
|
||||
- eingebunden ueber Adsterra `IFRAME SYNC`
|
||||
|
||||
### 1. AdSense-Konto und Website
|
||||
|
||||
In AdSense selbst:
|
||||
|
||||
1. Website `ypchat.net` hinzufügen
|
||||
2. Eigentum/Einbindung abschließen
|
||||
3. Warten, bis die Website von Google geprüft und freigegeben wurde
|
||||
|
||||
Ohne freigegebene Website werden in der Regel keine regulären Anzeigen ausgeliefert.
|
||||
|
||||
### 2. Anzeigenblock anlegen
|
||||
|
||||
Für den Header in AdSense einen normalen responsiven Display-Anzeigenblock anlegen.
|
||||
|
||||
Benötigt werden daraus:
|
||||
|
||||
- Publisher-ID
|
||||
Beispiel: `ca-pub-1234567890123456`
|
||||
- Slot-ID des Header-Anzeigenblocks
|
||||
Beispiel: `1234567890`
|
||||
|
||||
### 3. `ads.txt` korrekt pflegen
|
||||
|
||||
AdSense erwartet in der Regel einen korrekten Eintrag in `/ads.txt`.
|
||||
|
||||
Für Google AdSense ist das Format typischerweise:
|
||||
|
||||
```txt
|
||||
google.com, pub-1234567890123456, DIRECT, f08c47fec0942fa0
|
||||
```
|
||||
|
||||
Wichtig:
|
||||
|
||||
- `pub-...` muss zu deinem echten AdSense-Konto passen
|
||||
- die Datei muss öffentlich unter `https://ypchat.net/ads.txt` erreichbar sein
|
||||
- Änderungen brauchen oft etwas Zeit, bis Google sie erkennt
|
||||
|
||||
Im Projekt liegt aktuell eine Datei unter [docroot/ads.txt](/mnt/share/torsten/Programs/SingleChat/docroot/ads.txt). Diese muss auf deine echte Publisher-ID geprüft und ggf. angepasst werden.
|
||||
|
||||
## Was im Projekt erledigt werden muss
|
||||
|
||||
### 1. Vite-Variablen setzen
|
||||
|
||||
In der Projekt-`.env` die beiden Werte ergänzen:
|
||||
|
||||
```env
|
||||
VITE_ADSENSE_CLIENT=ca-pub-1234567890123456
|
||||
VITE_ADSENSE_HEADER_SLOT=1234567890
|
||||
```
|
||||
|
||||
Hinweis:
|
||||
|
||||
- `VITE_...` ist notwendig, damit die Werte im Client verfügbar sind
|
||||
- ohne diese Werte bleibt der Banner automatisch unsichtbar
|
||||
|
||||
### 2. Frontend neu bauen
|
||||
|
||||
Nach Änderung der `.env`:
|
||||
|
||||
```bash
|
||||
cd client
|
||||
npm run build
|
||||
```
|
||||
|
||||
Danach wie bisher den Build nach `docroot/dist` deployen.
|
||||
|
||||
### 3. Server/Deployment aktualisieren
|
||||
|
||||
Je nach Deploy-Prozess:
|
||||
|
||||
1. neuen Client-Build deployen
|
||||
2. prüfen, dass `docroot/dist` aktuell ist
|
||||
3. Service neu starten oder Deployment neu laden
|
||||
|
||||
## Prüfung nach dem Deploy
|
||||
|
||||
### Technisch
|
||||
|
||||
Prüfen:
|
||||
|
||||
- ist im HTML ein AdSense-Script geladen?
|
||||
- erscheint im Header ein reservierter Anzeigenbereich?
|
||||
- gibt es Fehler in der Browser-Konsole?
|
||||
|
||||
### Extern
|
||||
|
||||
Prüfen:
|
||||
|
||||
- `https://ypchat.net/ads.txt` ist erreichbar
|
||||
- AdSense zeigt keinen `ads.txt`-Fehler mehr
|
||||
- die Website ist in AdSense als bereit/freigegeben markiert
|
||||
|
||||
## Wichtige Hinweise
|
||||
|
||||
- Im lokalen Development erscheinen AdSense-Anzeigen oft nicht sinnvoll oder gar nicht.
|
||||
- Nach dem ersten Einbau kann es dauern, bis Google echte Anzeigen ausliefert.
|
||||
- Wenn Header-Anzeigen die UX zu stark stören, sollte der Banner auf Mobile ausgeblendet bleiben. Das ist im Code bereits berücksichtigt.
|
||||
- Für Consent-/CMP-Themen kann je nach Land eine zusätzliche Einwilligungslösung nötig sein. Das ist aktuell nicht Teil dieser Implementierung.
|
||||
|
||||
## Kurz-Checkliste
|
||||
|
||||
1. In AdSense Website hinzufügen und freigeben lassen.
|
||||
2. Header-Display-Ad-Unit anlegen.
|
||||
3. Publisher-ID und Slot-ID notieren.
|
||||
4. `docroot/ads.txt` auf korrekten Google-Eintrag prüfen.
|
||||
5. `.env` mit `VITE_ADSENSE_CLIENT` und `VITE_ADSENSE_HEADER_SLOT` ergänzen.
|
||||
6. Client neu bauen.
|
||||
7. Deployen.
|
||||
8. Live prüfen, ob `ads.txt` und Banner korrekt ausgeliefert werden.
|
||||
Wird spaeter ein anderer Werbeanbieter oder ein weiteres Adsterra-Format verwendet, sollte diese Datei entsprechend aktualisiert werden.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Android-App-Konzept fuer SingleChat
|
||||
# Android-App-Konzept fuer YpChat
|
||||
|
||||
## Zielbild
|
||||
|
||||
SingleChat soll als echte Android-App verfuegbar werden, nicht nur als WebView-Wrapper. Die Android-App nutzt die bestehenden Backend-Endpunkte und spricht mit dem vorhandenen Socket.IO-Server dasselbe Ereignisprotokoll wie das Vue-Web-Frontend.
|
||||
YpChat soll als echte Android-App verfuegbar werden, nicht nur als WebView-Wrapper. Die Android-App nutzt die bestehenden Backend-Endpunkte und spricht mit dem vorhandenen Socket.IO-Server dasselbe Ereignisprotokoll wie das Vue-Web-Frontend.
|
||||
|
||||
Das Ziel fuer den ersten Release ist Funktionsgleichheit mit dem Kern-Chat:
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
e# Design-Konzept: Modernisierung SingleChat
|
||||
e# Design-Konzept: Modernisierung YpChat
|
||||
|
||||
## Zielbild
|
||||
|
||||
SingleChat soll moderner, ruhiger und effizienter wirken, ohne seinen funktionalen Charakter zu verlieren. Die Oberfläche bleibt kompakt und schnell erfassbar, bekommt aber:
|
||||
YpChat soll moderner, ruhiger und effizienter wirken, ohne seinen funktionalen Charakter zu verlieren. Die Oberfläche bleibt kompakt und schnell erfassbar, bekommt aber:
|
||||
|
||||
- eine konsistentere Farbwelt
|
||||
- dezentere Rundungen
|
||||
@@ -350,4 +350,4 @@ Sinnvolle Reihenfolge:
|
||||
|
||||
## Ergebnisbild in einem Satz
|
||||
|
||||
SingleChat soll nach der Überarbeitung wie ein kompaktes, modernes Chat-Tool wirken: ruhig, klar strukturiert, responsiv, markentreu grün und deutlich hochwertiger, ohne unnötig anders auszusehen.
|
||||
YpChat soll nach der Überarbeitung wie ein kompaktes, modernes Chat-Tool wirken: ruhig, klar strukturiert, responsiv, markentreu grün und deutlich hochwertiger, ohne unnötig anders auszusehen.
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
```bash
|
||||
# Als root oder mit sudo
|
||||
sudo mkdir -p /opt/ypchat
|
||||
sudo cp -r /home/torsten/Programs/SingleChat/* /opt/ypchat/
|
||||
sudo cp -r /home/torsten/Programs/YpChat/* /opt/ypchat/
|
||||
sudo chown -R www-data:www-data /opt/ypchat
|
||||
```
|
||||
|
||||
@@ -53,6 +53,23 @@ sudo journalctl -u ypchat -f
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
## Auto-Rollout mit Gitea
|
||||
|
||||
Der Workflow `.gitea/workflows/deploy.yml` startet bei Push auf `main` per SSH das Server-Skript `/usr/local/bin/actualize-singlechat.sh`. Das Skript wird vom Workflow vor dem Start nach `/usr/local/bin` installiert.
|
||||
|
||||
Gitea-Konfiguration:
|
||||
|
||||
- Variables: `SSH_HOST`, `SSH_PORT`, `SSH_USER`
|
||||
- Secret: `PROD_SSH_KEY_B64` mit dem base64-kodierten privaten Deploy-Key
|
||||
|
||||
Server-Skript manuell testen:
|
||||
|
||||
```bash
|
||||
sudo install -m 755 scripts/actualize-singlechat.sh /usr/local/bin/actualize-singlechat.sh
|
||||
```
|
||||
|
||||
Das Skript aktualisiert `/opt/ypchat`, baut den Client neu und startet `ypchat` per systemd neu.
|
||||
|
||||
### Service startet nicht
|
||||
|
||||
```bash
|
||||
@@ -86,4 +103,3 @@ cd /opt/ypchat
|
||||
sudo -u www-data npm run build
|
||||
sudo -u www-data cp -r client/dist docroot/
|
||||
```
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# SingleChat Production Installation
|
||||
# YpChat Production Installation
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
@@ -37,12 +37,11 @@ Die Apache-Konfiguration sollte bereits vorhanden sein. Stelle sicher, dass sie
|
||||
```apache
|
||||
<VirtualHost *:443>
|
||||
ServerName ypchat.net
|
||||
ServerAlias www.ypchat.net
|
||||
|
||||
# SSL-Konfiguration
|
||||
Include /etc/letsencrypt/options-ssl-apache.conf
|
||||
SSLCertificateFile /etc/letsencrypt/live/www.ypchat.net/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/www.ypchat.net/privkey.pem
|
||||
SSLCertificateFile /etc/letsencrypt/live/ypchat.net/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/ypchat.net/privkey.pem
|
||||
|
||||
# Reverse Proxy zu Node.js
|
||||
ProxyPreserveHost On
|
||||
@@ -55,6 +54,17 @@ Die Apache-Konfiguration sollte bereits vorhanden sein. Stelle sicher, dass sie
|
||||
RewriteCond %{HTTP:Connection} upgrade [NC]
|
||||
RewriteRule ^/?(.*) "ws://localhost:4000/$1" [P,L]
|
||||
</VirtualHost>
|
||||
|
||||
<VirtualHost *:443>
|
||||
ServerName www.ypchat.net
|
||||
|
||||
Include /etc/letsencrypt/options-ssl-apache.conf
|
||||
SSLCertificateFile /etc/letsencrypt/live/ypchat.net/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/ypchat.net/privkey.pem
|
||||
|
||||
RewriteEngine On
|
||||
RewriteRule ^ https://ypchat.net%{REQUEST_URI} [R=301,L]
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
Wichtig: Die WebSocket-Rewrite-Regeln sind für Socket.IO erforderlich!
|
||||
@@ -99,6 +109,27 @@ sudo systemctl stop singlechat
|
||||
|
||||
## Updates
|
||||
|
||||
### Automatisch per Gitea Actions
|
||||
|
||||
Der Workflow `.gitea/workflows/deploy.yml` deployt bei jedem Push auf `main` per SSH auf den Produktionsserver, installiert dort das aktuelle Rollout-Skript und startet es.
|
||||
|
||||
In Gitea müssen dafür gesetzt sein:
|
||||
|
||||
- Repository Variables:
|
||||
- `SSH_HOST`: Produktionsserver, z.B. `rv2756.1blu.de`
|
||||
- `SSH_PORT`: SSH-Port, z.B. `22`
|
||||
- `SSH_USER`: SSH-User für den Deploy
|
||||
- Repository Secret:
|
||||
- `PROD_SSH_KEY_B64`: privater SSH-Key base64-kodiert
|
||||
|
||||
Das Rollout-Skript kann bei Bedarf auch manuell installiert und getestet werden:
|
||||
|
||||
```bash
|
||||
sudo install -m 755 scripts/actualize-singlechat.sh /usr/local/bin/actualize-singlechat.sh
|
||||
```
|
||||
|
||||
Das Skript aktualisiert `/opt/ypchat` aus `ssh://git@tsschulz.de:2222/torsten/singlechat`, installiert Dependencies mit `npm ci`, baut den Client, aktualisiert `docroot/dist` und startet `ypchat` neu. Bei Bedarf können `APP_DIR`, `REPO_URL`, `BRANCH` und `SERVICE_NAME` als Environment-Variablen überschrieben werden.
|
||||
|
||||
Nach Code-Änderungen:
|
||||
|
||||
```bash
|
||||
@@ -152,6 +183,25 @@ Die folgenden Umgebungsvariablen können in `.env` gesetzt werden:
|
||||
- `NODE_ENV`: `production` (automatisch gesetzt)
|
||||
- `PORT`: `4000` (Standard)
|
||||
- `SESSION_SECRET`: Zufälliges Secret für Sessions (wird von install.sh generiert)
|
||||
- `VIDEO_TURN_URLS`: Kommagetrennte `turn:`/`turns:`-URLs für den Relay-Medienserver
|
||||
- `VIDEO_TURN_USERNAME`: TURN-Benutzername
|
||||
- `VIDEO_TURN_CREDENTIAL`: TURN-Passwort
|
||||
- `VIDEO_STUN_URLS`: Optional kommagetrennte `stun:`-URLs
|
||||
- `VIDEO_ICE_SERVERS_JSON`: Optional komplette ICE-Serverliste als JSON statt der Einzelvariablen
|
||||
|
||||
Beispiel:
|
||||
|
||||
```env
|
||||
NODE_ENV=production
|
||||
PORT=4000
|
||||
SESSION_SECRET=bitte-eigenes-starkes-secret-setzen
|
||||
VIDEO_TURN_URLS=turn:turn.ypchat.net:3478?transport=udp,turn:turn.ypchat.net:3478?transport=tcp
|
||||
VIDEO_TURN_USERNAME=ypchat
|
||||
VIDEO_TURN_CREDENTIAL=dein-turn-passwort
|
||||
VIDEO_STUN_URLS=stun:turn.ypchat.net:3478
|
||||
```
|
||||
|
||||
Die Deploy-Skripte synchronisieren `.env` jetzt mit `.env.example`, behalten dabei aber vorhandene Werte aus der bisherigen `.env` bei, statt sie zu überschreiben.
|
||||
|
||||
## Sicherheit
|
||||
|
||||
@@ -159,4 +209,3 @@ Die folgenden Umgebungsvariablen können in `.env` gesetzt werden:
|
||||
- **HTTPS**: Stelle sicher, dass SSL/TLS korrekt konfiguriert ist
|
||||
- **Firewall**: Port 4000 sollte nur von localhost erreichbar sein
|
||||
- **Updates**: Halte Node.js und alle Dependencies aktuell
|
||||
|
||||
|
||||
26
SEO-TODO.md
26
SEO-TODO.md
@@ -2,7 +2,7 @@
|
||||
|
||||
## 1) Host-/TLS-Konsistenz (Apex und www)
|
||||
|
||||
- [x] App-Fallback-Redirect in Node aktiv (`ypchat.net` + HTTP -> `https://www.ypchat.net`).
|
||||
- [x] App-Fallback-Redirect in Node aktiv (kanonischer Host: `https://ypchat.net`).
|
||||
|
||||
### Zertifikatserstellung (Let's Encrypt / Certbot, Apache)
|
||||
|
||||
@@ -20,10 +20,10 @@
|
||||
### Apache-Redirects (kanonischer Host + HTTPS)
|
||||
|
||||
Empfohlene Logik:
|
||||
- `http://ypchat.net/*` -> `https://www.ypchat.net/*` (301)
|
||||
- `http://www.ypchat.net/*` -> `https://www.ypchat.net/*` (301)
|
||||
- `https://ypchat.net/*` -> `https://www.ypchat.net/*` (301)
|
||||
- Nur `https://www.ypchat.net/*` liefert `200`
|
||||
- `http://ypchat.net/*` -> `https://ypchat.net/*` (301)
|
||||
- `http://www.ypchat.net/*` -> `https://ypchat.net/*` (301)
|
||||
- `https://www.ypchat.net/*` -> `https://ypchat.net/*` (301)
|
||||
- Nur `https://ypchat.net/*` liefert `200`
|
||||
|
||||
Beispiel (VirtualHost fuer Port 80):
|
||||
|
||||
@@ -32,32 +32,32 @@ Beispiel (VirtualHost fuer Port 80):
|
||||
ServerName ypchat.net
|
||||
ServerAlias www.ypchat.net
|
||||
RewriteEngine On
|
||||
RewriteRule ^ https://www.ypchat.net%{REQUEST_URI} [R=301,L]
|
||||
RewriteRule ^ https://ypchat.net%{REQUEST_URI} [R=301,L]
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
Beispiel (VirtualHost fuer `https://ypchat.net`):
|
||||
Beispiel (VirtualHost fuer `https://www.ypchat.net`):
|
||||
|
||||
```apache
|
||||
<VirtualHost *:443>
|
||||
ServerName ypchat.net
|
||||
ServerName www.ypchat.net
|
||||
SSLEngine on
|
||||
SSLCertificateFile /etc/letsencrypt/live/ypchat.net/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/ypchat.net/privkey.pem
|
||||
RewriteEngine On
|
||||
RewriteRule ^ https://www.ypchat.net%{REQUEST_URI} [R=301,L]
|
||||
RewriteRule ^ https://ypchat.net%{REQUEST_URI} [R=301,L]
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
Verifikation:
|
||||
- `curl -I https://ypchat.net/` -> `301 Location: https://www.ypchat.net/`
|
||||
- `curl -I https://www.ypchat.net/` -> `200`
|
||||
- `curl -I https://ypchat.net/` -> `200`
|
||||
- `curl -I https://www.ypchat.net/` -> `301 Location: https://ypchat.net/`
|
||||
- Browser ohne TLS-Warnung fuer beide Hosts
|
||||
|
||||
## 3) Search Console / Reindexing
|
||||
|
||||
- [ ] In Google Search Console `https://www.ypchat.net` als Hauptproperty nutzen.
|
||||
- [ ] Sitemap neu einreichen: `https://www.ypchat.net/sitemap.xml`.
|
||||
- [ ] In Google Search Console `https://ypchat.net` als Hauptproperty nutzen.
|
||||
- [ ] Sitemap neu einreichen: `https://ypchat.net/sitemap.xml`.
|
||||
- [ ] Live-Tests ausfuehren fuer:
|
||||
- [ ] `/`
|
||||
- [ ] `/partners`
|
||||
|
||||
BIN
android/Bildschirmfoto_20260512_114053.png
Normal file
BIN
android/Bildschirmfoto_20260512_114053.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
@@ -17,7 +17,7 @@ Empfehlung:
|
||||
|
||||
Alternative:
|
||||
|
||||
- `SingleChat by YPChat`
|
||||
- `YpChat`
|
||||
- `YPChat`
|
||||
|
||||
## Kurzbeschreibung
|
||||
@@ -107,7 +107,7 @@ Für den ersten Store-Eintrag empfehle ich mindestens diese Smartphone-Screens:
|
||||
|
||||
Diese Punkte sind noch nicht in den Texten aufgelöst und müssen von dir final bestätigt werden:
|
||||
|
||||
1. Soll der öffentliche Markenname im Store `YPChat` oder `SingleChat` sein?
|
||||
1. Soll der öffentliche Markenname im Store `YpChat` sein?
|
||||
2. Welche URL wird als Datenschutzerklärung verwendet?
|
||||
3. Soll die App als `Social`, `Dating` oder `Communication` eingeordnet werden?
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# YPChat Android
|
||||
|
||||
Native Android-App fuer den bestehenden SingleChat/YPChat-Server.
|
||||
Native Android-App fuer den bestehenden YPChat-Server.
|
||||
|
||||
## Stack
|
||||
|
||||
|
||||
@@ -27,15 +27,15 @@ val appBaseUrl = localProperties.getProperty("ypchat.baseUrl", defaultBaseUrl)
|
||||
val hasReleaseSigning = releaseStoreFile?.exists() == true
|
||||
|
||||
android {
|
||||
namespace = "net.ypchat.app"
|
||||
namespace = "de.ypchat.android"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "net.ypchat.app"
|
||||
applicationId = "de.ypchat.android"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "1.0.0"
|
||||
versionCode = 3
|
||||
versionName = "1.2.0"
|
||||
}
|
||||
|
||||
lint {
|
||||
@@ -113,6 +113,7 @@ dependencies {
|
||||
}
|
||||
implementation("io.coil-kt.coil3:coil-compose:3.4.0")
|
||||
implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0")
|
||||
implementation("io.github.webrtc-sdk:android:125.6422.07")
|
||||
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
}
|
||||
|
||||
BIN
android/app/release/app-release.aab
Normal file
BIN
android/app/release/app-release.aab
Normal file
Binary file not shown.
@@ -1,12 +1,14 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
||||
<application
|
||||
android:name=".YpChatApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@drawable/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="${usesCleartextTraffic}"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.YpChat">
|
||||
@@ -20,4 +22,3 @@
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package net.ypchat.app
|
||||
package de.ypchat.android
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import net.ypchat.app.ui.ChatViewModel
|
||||
import net.ypchat.app.ui.ChatViewModelFactory
|
||||
import net.ypchat.app.ui.YpChatRoot
|
||||
import de.ypchat.android.ui.ChatViewModel
|
||||
import de.ypchat.android.ui.ChatViewModelFactory
|
||||
import de.ypchat.android.ui.YpChatRoot
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val viewModel: ChatViewModel by viewModels {
|
||||
@@ -1,7 +1,7 @@
|
||||
package net.ypchat.app
|
||||
package de.ypchat.android
|
||||
|
||||
import android.app.Application
|
||||
import net.ypchat.app.core.AppContainer
|
||||
import de.ypchat.android.core.AppContainer
|
||||
|
||||
class YpChatApp : Application() {
|
||||
lateinit var container: AppContainer
|
||||
@@ -1,6 +1,6 @@
|
||||
package net.ypchat.app.core
|
||||
package de.ypchat.android.core
|
||||
|
||||
import net.ypchat.app.BuildConfig
|
||||
import de.ypchat.android.BuildConfig
|
||||
|
||||
object AppConfig {
|
||||
val baseUrl: String = BuildConfig.BASE_URL.trimEnd('/')
|
||||
@@ -1,9 +1,10 @@
|
||||
package net.ypchat.app.core
|
||||
package de.ypchat.android.core
|
||||
|
||||
import android.content.Context
|
||||
import net.ypchat.app.data.api.RestApi
|
||||
import net.ypchat.app.data.api.SocketClient
|
||||
import net.ypchat.app.data.repository.ChatRepository
|
||||
import de.ypchat.android.data.api.RestApi
|
||||
import de.ypchat.android.data.api.SocketClient
|
||||
import de.ypchat.android.data.repository.ChatRepository
|
||||
import de.ypchat.android.media.AndroidVideoCallManager
|
||||
import okhttp3.OkHttpClient
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
@@ -28,5 +29,6 @@ class AppContainer(context: Context) {
|
||||
|
||||
val restApi: RestApi = retrofit.create(RestApi::class.java)
|
||||
val socketClient = SocketClient(AppConfig.baseUrl, okHttpClient)
|
||||
val chatRepository = ChatRepository(restApi, socketClient, cookieJar, profileStore)
|
||||
val videoCallManager = AndroidVideoCallManager(context, socketClient)
|
||||
val chatRepository = ChatRepository(restApi, socketClient, cookieJar, profileStore, videoCallManager)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package net.ypchat.app.core
|
||||
package de.ypchat.android.core
|
||||
|
||||
import android.content.Context
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package net.ypchat.app.core
|
||||
package de.ypchat.android.core
|
||||
|
||||
import android.content.Context
|
||||
import okhttp3.Cookie
|
||||
@@ -1,14 +1,14 @@
|
||||
package net.ypchat.app.data.api
|
||||
package de.ypchat.android.data.api
|
||||
|
||||
import net.ypchat.app.data.model.CountriesResponse
|
||||
import net.ypchat.app.data.model.FeedbackAdminLoginRequest
|
||||
import net.ypchat.app.data.model.FeedbackAdminStatusResponse
|
||||
import net.ypchat.app.data.model.FeedbackRequest
|
||||
import net.ypchat.app.data.model.FeedbackResponse
|
||||
import net.ypchat.app.data.model.ImageUploadResponse
|
||||
import net.ypchat.app.data.model.LogoutResponse
|
||||
import net.ypchat.app.data.model.PartnerLinkDto
|
||||
import net.ypchat.app.data.model.SessionResponse
|
||||
import de.ypchat.android.data.model.CountriesResponse
|
||||
import de.ypchat.android.data.model.FeedbackAdminLoginRequest
|
||||
import de.ypchat.android.data.model.FeedbackAdminStatusResponse
|
||||
import de.ypchat.android.data.model.FeedbackRequest
|
||||
import de.ypchat.android.data.model.FeedbackResponse
|
||||
import de.ypchat.android.data.model.ImageUploadResponse
|
||||
import de.ypchat.android.data.model.LogoutResponse
|
||||
import de.ypchat.android.data.model.PartnerLinkDto
|
||||
import de.ypchat.android.data.model.SessionResponse
|
||||
import okhttp3.MultipartBody
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
@@ -1,4 +1,4 @@
|
||||
package net.ypchat.app.data.api
|
||||
package de.ypchat.android.data.api
|
||||
|
||||
import io.socket.client.IO
|
||||
import io.socket.client.Socket
|
||||
@@ -10,11 +10,19 @@ import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import net.ypchat.app.data.model.ChatMessageDto
|
||||
import net.ypchat.app.data.model.HistoryItemDto
|
||||
import net.ypchat.app.data.model.InboxItemDto
|
||||
import net.ypchat.app.data.model.SocketEvent
|
||||
import net.ypchat.app.data.model.UserDto
|
||||
import de.ypchat.android.data.model.ChatMessageDto
|
||||
import de.ypchat.android.data.model.HistoryItemDto
|
||||
import de.ypchat.android.data.model.InboxItemDto
|
||||
import de.ypchat.android.data.model.SocketEvent
|
||||
import de.ypchat.android.data.model.UserDto
|
||||
import de.ypchat.android.data.model.VideoCallDto
|
||||
import de.ypchat.android.data.model.VideoCapacityDto
|
||||
import de.ypchat.android.data.model.VideoConsentDto
|
||||
import de.ypchat.android.data.model.VideoIceCandidateDto
|
||||
import de.ypchat.android.data.model.VideoIceServerDto
|
||||
import de.ypchat.android.data.model.VideoMediaDto
|
||||
import de.ypchat.android.data.model.VideoSessionDescriptionDto
|
||||
import de.ypchat.android.data.model.VideoSignalDto
|
||||
import okhttp3.OkHttpClient
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
@@ -113,6 +121,51 @@ class SocketClient(
|
||||
s.on("unreadChats") { args ->
|
||||
args.firstJson()?.let { emit(SocketEvent.UnreadChats(it.optInt("count", 0))) }
|
||||
}
|
||||
s.on("videoConsent:update") { args ->
|
||||
args.firstJson()?.let { emit(SocketEvent.VideoConsentUpdate(it.toVideoConsentDto())) }
|
||||
}
|
||||
s.on("videoCall:invite") { args ->
|
||||
args.firstJson()?.let { emit(SocketEvent.VideoCallInvite(it.toVideoCallDto())) }
|
||||
}
|
||||
s.on("videoCall:incoming") { args ->
|
||||
args.firstJson()?.let { emit(SocketEvent.VideoCallIncoming(it.toVideoCallDto())) }
|
||||
}
|
||||
s.on("videoCall:start") { args ->
|
||||
args.firstJson()?.let { emit(SocketEvent.VideoCallStart(it.toVideoCallDto())) }
|
||||
}
|
||||
s.on("videoCall:update") { args ->
|
||||
args.firstJson()?.let { emit(SocketEvent.VideoCallUpdate(it.toVideoCallDto())) }
|
||||
}
|
||||
s.on("videoCall:reject") { args ->
|
||||
args.firstJson()?.let { emit(SocketEvent.VideoCallReject(it.toVideoCallDto())) }
|
||||
}
|
||||
s.on("videoCall:cancel") { args ->
|
||||
args.firstJson()?.let { emit(SocketEvent.VideoCallCancel(it.toVideoCallDto())) }
|
||||
}
|
||||
s.on("videoCall:end") { args ->
|
||||
args.firstJson()?.let { emit(SocketEvent.VideoCallEnd(it.toVideoCallDto())) }
|
||||
}
|
||||
s.on("videoCall:muteState") { args ->
|
||||
args.firstJson()?.let { emit(SocketEvent.VideoCallMuteState(it.toVideoCallDto())) }
|
||||
}
|
||||
s.on("videoCall:capacity") { args ->
|
||||
args.firstJson()?.let { emit(SocketEvent.VideoCallCapacity(it.toVideoCapacityDto())) }
|
||||
}
|
||||
s.on("videoCall:signal") { args ->
|
||||
args.firstJson()?.let { emit(SocketEvent.VideoCallSignal(it.toVideoSignalDto())) }
|
||||
}
|
||||
s.on("videoCall:error") { args ->
|
||||
args.firstJson()?.let { json ->
|
||||
emit(
|
||||
SocketEvent.VideoCallError(
|
||||
code = json.optStringOrNull("code"),
|
||||
message = json.optString("message", "Video-Fehler"),
|
||||
withUserName = json.optStringOrNull("withUserName"),
|
||||
callId = json.optStringOrNull("callId")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
s.on("userBlocked") { args ->
|
||||
args.firstJson()?.let { emit(SocketEvent.UserBlocked(it.optString("userName"))) }
|
||||
}
|
||||
@@ -210,6 +263,55 @@ class SocketClient(
|
||||
fun requestOpenConversations() = socket?.emit("requestOpenConversations")
|
||||
fun blockUser(userName: String) = socket?.emit("blockUser", JSONObject().put("userName", userName))
|
||||
fun unblockUser(userName: String) = socket?.emit("unblockUser", JSONObject().put("userName", userName))
|
||||
fun setVideoConsent(withUserName: String, allowed: Boolean) =
|
||||
socket?.emit("videoConsent:set", JSONObject().put("withUserName", withUserName).put("allowed", allowed))
|
||||
fun inviteVideoCall(withUserName: String) =
|
||||
socket?.emit("videoCall:invite", JSONObject().put("withUserName", withUserName))
|
||||
fun acceptVideoCall(callId: String) =
|
||||
socket?.emit("videoCall:accept", JSONObject().put("callId", callId))
|
||||
fun rejectVideoCall(callId: String) =
|
||||
socket?.emit("videoCall:reject", JSONObject().put("callId", callId))
|
||||
fun cancelVideoCall(callId: String) =
|
||||
socket?.emit("videoCall:cancel", JSONObject().put("callId", callId))
|
||||
fun endVideoCall(callId: String) =
|
||||
socket?.emit("videoCall:end", JSONObject().put("callId", callId))
|
||||
fun setVideoMuteState(callId: String, muted: Boolean) =
|
||||
socket?.emit("videoCall:muteState", JSONObject().put("callId", callId).put("muted", muted))
|
||||
fun sendVideoSignal(signal: VideoSignalDto) {
|
||||
val payload = JSONObject()
|
||||
.put("callId", signal.callId)
|
||||
.put("signalType", signal.signalType)
|
||||
|
||||
signal.description?.let {
|
||||
payload.put(
|
||||
"description",
|
||||
JSONObject()
|
||||
.put("type", it.type)
|
||||
.put("sdp", it.sdp)
|
||||
)
|
||||
}
|
||||
|
||||
signal.candidate?.let {
|
||||
payload.put(
|
||||
"candidate",
|
||||
JSONObject()
|
||||
.put("candidate", it.candidate)
|
||||
.put("sdpMid", it.sdpMid)
|
||||
.put("sdpMLineIndex", it.sdpMLineIndex)
|
||||
.put("usernameFragment", it.usernameFragment)
|
||||
.put("type", it.type)
|
||||
)
|
||||
}
|
||||
|
||||
socket?.emit("videoCall:signal", payload)
|
||||
}
|
||||
fun setVideoConnectionState(callId: String, connectionState: String) =
|
||||
socket?.emit(
|
||||
"videoCall:connectionState",
|
||||
JSONObject()
|
||||
.put("callId", callId)
|
||||
.put("connectionState", connectionState)
|
||||
)
|
||||
|
||||
private fun emit(event: SocketEvent) {
|
||||
scope.launch { _events.emit(event) }
|
||||
@@ -252,6 +354,79 @@ private fun JSONObject.toInboxItemDto(): InboxItemDto = InboxItemDto(
|
||||
unreadCount = optInt("unreadCount", 0)
|
||||
)
|
||||
|
||||
private fun JSONObject.toVideoConsentDto(): VideoConsentDto = VideoConsentDto(
|
||||
withUserName = optStringOrNull("withUserName"),
|
||||
localConsent = optBoolean("localConsent", false),
|
||||
remoteConsent = optBoolean("remoteConsent", false),
|
||||
videoVisible = optBoolean("videoVisible", false)
|
||||
)
|
||||
|
||||
private fun JSONObject.toVideoCallDto(): VideoCallDto = VideoCallDto(
|
||||
callId = optString("callId"),
|
||||
roomId = optStringOrNull("roomId"),
|
||||
withUserName = optStringOrNull("withUserName"),
|
||||
initiatedBy = optStringOrNull("initiatedBy"),
|
||||
status = optString("status"),
|
||||
createdAt = optString("createdAt"),
|
||||
updatedAt = optString("updatedAt"),
|
||||
endedAt = optStringOrNull("endedAt"),
|
||||
reason = optStringOrNull("reason"),
|
||||
localMuted = optBoolean("localMuted", false),
|
||||
remoteMuted = optBoolean("remoteMuted", false),
|
||||
connectionState = optString("connectionState", "new"),
|
||||
remoteConnectionState = optString("remoteConnectionState", "new"),
|
||||
media = optJSONObject("media")?.toVideoMediaDto()
|
||||
)
|
||||
|
||||
private fun JSONObject.toVideoCapacityDto(): VideoCapacityDto = VideoCapacityDto(
|
||||
activeConnections = optInt("activeConnections", 0),
|
||||
maxConnections = optInt("maxConnections", 3),
|
||||
reachedMax = optBoolean("reachedMax", false)
|
||||
)
|
||||
|
||||
private fun JSONObject.toVideoMediaDto(): VideoMediaDto = VideoMediaDto(
|
||||
mode = optString("mode"),
|
||||
relayOnly = optBoolean("relayOnly", false),
|
||||
iceTransportPolicy = optString("iceTransportPolicy", "relay"),
|
||||
iceServers = optJSONArray("iceServers")?.toObjectList { it.toVideoIceServerDto() }.orEmpty(),
|
||||
isCaller = optBoolean("isCaller", false)
|
||||
)
|
||||
|
||||
private fun JSONObject.toVideoIceServerDto(): VideoIceServerDto {
|
||||
val urlsValue = opt("urls")
|
||||
val urls = when (urlsValue) {
|
||||
is JSONArray -> urlsValue.toStringList()
|
||||
is String -> listOf(urlsValue)
|
||||
else -> emptyList()
|
||||
}
|
||||
return VideoIceServerDto(
|
||||
urls = urls,
|
||||
username = optStringOrNull("username"),
|
||||
credential = optStringOrNull("credential")
|
||||
)
|
||||
}
|
||||
|
||||
private fun JSONObject.toVideoSignalDto(): VideoSignalDto = VideoSignalDto(
|
||||
callId = optString("callId"),
|
||||
fromUserName = optStringOrNull("fromUserName"),
|
||||
signalType = optString("signalType"),
|
||||
description = optJSONObject("description")?.toVideoSessionDescriptionDto(),
|
||||
candidate = optJSONObject("candidate")?.toVideoIceCandidateDto()
|
||||
)
|
||||
|
||||
private fun JSONObject.toVideoSessionDescriptionDto(): VideoSessionDescriptionDto = VideoSessionDescriptionDto(
|
||||
type = optString("type"),
|
||||
sdp = optString("sdp")
|
||||
)
|
||||
|
||||
private fun JSONObject.toVideoIceCandidateDto(): VideoIceCandidateDto = VideoIceCandidateDto(
|
||||
candidate = optString("candidate"),
|
||||
sdpMid = optStringOrNull("sdpMid"),
|
||||
sdpMLineIndex = optInt("sdpMLineIndex", 0),
|
||||
usernameFragment = optStringOrNull("usernameFragment"),
|
||||
type = optStringOrNull("type")
|
||||
)
|
||||
|
||||
private fun JSONArray?.toStringList(): List<String> {
|
||||
if (this == null) return emptyList()
|
||||
return List(length()) { index -> opt(index)?.toString().orEmpty() }
|
||||
@@ -1,4 +1,4 @@
|
||||
package net.ypchat.app.data.model
|
||||
package de.ypchat.android.data.model
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
@@ -34,6 +34,71 @@ data class InboxItemDto(
|
||||
val unreadCount: Int = 0
|
||||
)
|
||||
|
||||
data class VideoConsentDto(
|
||||
val withUserName: String? = null,
|
||||
val localConsent: Boolean = false,
|
||||
val remoteConsent: Boolean = false,
|
||||
val videoVisible: Boolean = false
|
||||
)
|
||||
|
||||
data class VideoCallDto(
|
||||
val callId: String = "",
|
||||
val roomId: String? = null,
|
||||
val withUserName: String? = null,
|
||||
val initiatedBy: String? = null,
|
||||
val status: String = "",
|
||||
val createdAt: String = "",
|
||||
val updatedAt: String = "",
|
||||
val endedAt: String? = null,
|
||||
val reason: String? = null,
|
||||
val localMuted: Boolean = false,
|
||||
val remoteMuted: Boolean = false,
|
||||
val connectionState: String = "new",
|
||||
val remoteConnectionState: String = "new",
|
||||
val media: VideoMediaDto? = null
|
||||
)
|
||||
|
||||
data class VideoCapacityDto(
|
||||
val activeConnections: Int = 0,
|
||||
val maxConnections: Int = 3,
|
||||
val reachedMax: Boolean = false
|
||||
)
|
||||
|
||||
data class VideoMediaDto(
|
||||
val mode: String = "",
|
||||
val relayOnly: Boolean = false,
|
||||
val iceTransportPolicy: String = "relay",
|
||||
val iceServers: List<VideoIceServerDto> = emptyList(),
|
||||
val isCaller: Boolean = false
|
||||
)
|
||||
|
||||
data class VideoIceServerDto(
|
||||
val urls: List<String> = emptyList(),
|
||||
val username: String? = null,
|
||||
val credential: String? = null
|
||||
)
|
||||
|
||||
data class VideoSessionDescriptionDto(
|
||||
val type: String = "",
|
||||
val sdp: String = ""
|
||||
)
|
||||
|
||||
data class VideoIceCandidateDto(
|
||||
val candidate: String = "",
|
||||
val sdpMid: String? = null,
|
||||
val sdpMLineIndex: Int = 0,
|
||||
val usernameFragment: String? = null,
|
||||
val type: String? = null
|
||||
)
|
||||
|
||||
data class VideoSignalDto(
|
||||
val callId: String = "",
|
||||
val fromUserName: String? = null,
|
||||
val signalType: String = "",
|
||||
val description: VideoSessionDescriptionDto? = null,
|
||||
val candidate: VideoIceCandidateDto? = null
|
||||
)
|
||||
|
||||
data class CountryOption(
|
||||
val englishName: String,
|
||||
val displayName: String,
|
||||
@@ -1,4 +1,4 @@
|
||||
package net.ypchat.app.data.model
|
||||
package de.ypchat.android.data.model
|
||||
|
||||
sealed interface SocketEvent {
|
||||
data class Connected(val sessionId: String?, val loggedIn: Boolean, val user: UserDto?) : SocketEvent
|
||||
@@ -11,6 +11,18 @@ sealed interface SocketEvent {
|
||||
data class HistoryResults(val results: List<HistoryItemDto>) : SocketEvent
|
||||
data class InboxResults(val results: List<InboxItemDto>) : SocketEvent
|
||||
data class UnreadChats(val count: Int) : SocketEvent
|
||||
data class VideoConsentUpdate(val consent: VideoConsentDto) : SocketEvent
|
||||
data class VideoCallInvite(val call: VideoCallDto) : SocketEvent
|
||||
data class VideoCallIncoming(val call: VideoCallDto) : SocketEvent
|
||||
data class VideoCallStart(val call: VideoCallDto) : SocketEvent
|
||||
data class VideoCallUpdate(val call: VideoCallDto) : SocketEvent
|
||||
data class VideoCallReject(val call: VideoCallDto) : SocketEvent
|
||||
data class VideoCallCancel(val call: VideoCallDto) : SocketEvent
|
||||
data class VideoCallEnd(val call: VideoCallDto) : SocketEvent
|
||||
data class VideoCallMuteState(val call: VideoCallDto) : SocketEvent
|
||||
data class VideoCallCapacity(val capacity: VideoCapacityDto) : SocketEvent
|
||||
data class VideoCallSignal(val signal: VideoSignalDto) : SocketEvent
|
||||
data class VideoCallError(val code: String?, val message: String, val withUserName: String? = null, val callId: String? = null) : SocketEvent
|
||||
data class UserBlocked(val userName: String) : SocketEvent
|
||||
data class UserUnblocked(val userName: String) : SocketEvent
|
||||
data class CommandResult(val lines: List<String>, val kind: String) : SocketEvent
|
||||
@@ -1,4 +1,4 @@
|
||||
package net.ypchat.app.data.repository
|
||||
package de.ypchat.android.data.repository
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -9,34 +9,43 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import net.ypchat.app.core.AppConfig
|
||||
import net.ypchat.app.core.ProfileStore
|
||||
import net.ypchat.app.core.SavedProfile
|
||||
import net.ypchat.app.core.SessionCookieJar
|
||||
import net.ypchat.app.data.api.RestApi
|
||||
import net.ypchat.app.data.api.SocketClient
|
||||
import net.ypchat.app.data.model.ChatMessageDto
|
||||
import net.ypchat.app.data.model.CountryOption
|
||||
import net.ypchat.app.data.model.FeedbackAdminLoginRequest
|
||||
import net.ypchat.app.data.model.FeedbackItemDto
|
||||
import net.ypchat.app.data.model.FeedbackRequest
|
||||
import net.ypchat.app.data.model.HistoryItemDto
|
||||
import net.ypchat.app.data.model.InboxItemDto
|
||||
import net.ypchat.app.data.model.PartnerLinkDto
|
||||
import net.ypchat.app.data.model.SocketEvent
|
||||
import net.ypchat.app.data.model.UserDto
|
||||
import de.ypchat.android.core.AppConfig
|
||||
import de.ypchat.android.core.ProfileStore
|
||||
import de.ypchat.android.core.SavedProfile
|
||||
import de.ypchat.android.core.SessionCookieJar
|
||||
import de.ypchat.android.data.api.RestApi
|
||||
import de.ypchat.android.data.api.SocketClient
|
||||
import de.ypchat.android.data.model.ChatMessageDto
|
||||
import de.ypchat.android.data.model.CountryOption
|
||||
import de.ypchat.android.data.model.FeedbackAdminLoginRequest
|
||||
import de.ypchat.android.data.model.FeedbackItemDto
|
||||
import de.ypchat.android.data.model.FeedbackRequest
|
||||
import de.ypchat.android.data.model.HistoryItemDto
|
||||
import de.ypchat.android.data.model.InboxItemDto
|
||||
import de.ypchat.android.data.model.PartnerLinkDto
|
||||
import de.ypchat.android.data.model.SocketEvent
|
||||
import de.ypchat.android.data.model.UserDto
|
||||
import de.ypchat.android.data.model.VideoCallDto
|
||||
import de.ypchat.android.data.model.VideoCapacityDto
|
||||
import de.ypchat.android.data.model.VideoConsentDto
|
||||
import de.ypchat.android.media.AndroidVideoCallManager
|
||||
import de.ypchat.android.media.VideoMediaState
|
||||
import okhttp3.MultipartBody
|
||||
import org.webrtc.EglBase
|
||||
import java.util.Locale
|
||||
|
||||
class ChatRepository(
|
||||
private val restApi: RestApi,
|
||||
private val socketClient: SocketClient,
|
||||
private val cookieJar: SessionCookieJar,
|
||||
private val profileStore: ProfileStore
|
||||
private val profileStore: ProfileStore,
|
||||
private val videoCallManager: AndroidVideoCallManager
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val _state = MutableStateFlow(ChatState())
|
||||
val state: StateFlow<ChatState> = _state.asStateFlow()
|
||||
val videoMediaState: StateFlow<VideoMediaState> = videoCallManager.state
|
||||
val videoEglBaseContext: EglBase.Context = videoCallManager.eglBaseContext()
|
||||
private var timeoutTickerStarted = false
|
||||
|
||||
init {
|
||||
@@ -105,6 +114,7 @@ class ChatRepository(
|
||||
runCatching { restApi.logout() }
|
||||
socketClient.disconnect()
|
||||
cookieJar.clear()
|
||||
videoCallManager.releaseAll()
|
||||
_state.value = ChatState(savedProfile = profileStore.read(), countries = _state.value.countries)
|
||||
}
|
||||
|
||||
@@ -114,13 +124,21 @@ class ChatRepository(
|
||||
}
|
||||
|
||||
fun openConversation(userName: String) {
|
||||
_state.value = _state.value.copy(currentConversation = userName, messages = emptyList())
|
||||
_state.value = _state.value.copy(
|
||||
currentConversation = userName,
|
||||
messages = emptyList(),
|
||||
videoConsent = VideoConsentDto(withUserName = userName)
|
||||
)
|
||||
socketClient.requestConversation(userName)
|
||||
resetTimeout()
|
||||
}
|
||||
|
||||
fun closeConversation() {
|
||||
_state.value = _state.value.copy(currentConversation = null, messages = emptyList())
|
||||
_state.value = _state.value.copy(
|
||||
currentConversation = null,
|
||||
messages = emptyList(),
|
||||
videoConsent = VideoConsentDto()
|
||||
)
|
||||
}
|
||||
|
||||
fun sendMessage(text: String) {
|
||||
@@ -287,6 +305,75 @@ class ChatRepository(
|
||||
resetTimeout()
|
||||
}
|
||||
|
||||
fun setVideoConsent(allowed: Boolean) {
|
||||
val target = _state.value.currentConversation ?: return
|
||||
socketClient.setVideoConsent(target, allowed)
|
||||
resetTimeout()
|
||||
}
|
||||
|
||||
fun inviteVideoCall() {
|
||||
val target = _state.value.currentConversation ?: return
|
||||
socketClient.inviteVideoCall(target)
|
||||
resetTimeout()
|
||||
}
|
||||
|
||||
fun acceptVideoCall(callId: String) {
|
||||
socketClient.acceptVideoCall(callId)
|
||||
resetTimeout()
|
||||
}
|
||||
|
||||
fun rejectVideoCall(callId: String) {
|
||||
socketClient.rejectVideoCall(callId)
|
||||
resetTimeout()
|
||||
}
|
||||
|
||||
fun cancelVideoCall(callId: String) {
|
||||
socketClient.cancelVideoCall(callId)
|
||||
resetTimeout()
|
||||
}
|
||||
|
||||
fun endVideoCall(callId: String) {
|
||||
socketClient.endVideoCall(callId)
|
||||
videoCallManager.endCall(callId)
|
||||
resetTimeout()
|
||||
}
|
||||
|
||||
fun bringVideoToFront(callId: String) {
|
||||
_state.value = _state.value.copy(foregroundVideoSessionId = callId)
|
||||
}
|
||||
|
||||
fun minimizeForegroundVideo() {
|
||||
_state.value = _state.value.copy(foregroundVideoSessionId = null)
|
||||
}
|
||||
|
||||
fun updateFloatingVideoPosition(x: Float, y: Float) {
|
||||
_state.value = _state.value.copy(
|
||||
floatingVideoOffsetX = x.coerceAtLeast(0f),
|
||||
floatingVideoOffsetY = y.coerceAtLeast(0f)
|
||||
)
|
||||
}
|
||||
|
||||
fun toggleSelfMuted() {
|
||||
val nextMuted = !_state.value.selfMuted
|
||||
_state.value = _state.value.copy(selfMuted = nextMuted)
|
||||
videoCallManager.updateSelfMuted(nextMuted)
|
||||
_state.value.videoDockSessions.forEach { session ->
|
||||
socketClient.setVideoMuteState(session.callId, nextMuted)
|
||||
}
|
||||
resetTimeout()
|
||||
}
|
||||
|
||||
fun toggleSelfCameraEnabled() {
|
||||
val nextEnabled = !_state.value.selfCameraEnabled
|
||||
_state.value = _state.value.copy(selfCameraEnabled = nextEnabled)
|
||||
videoCallManager.updateSelfCameraEnabled(nextEnabled)
|
||||
resetTimeout()
|
||||
}
|
||||
|
||||
fun setRuntimeError(message: String) {
|
||||
_state.value = _state.value.copy(errorMessage = message)
|
||||
}
|
||||
|
||||
private fun startTimeoutTicker() {
|
||||
if (timeoutTickerStarted) return
|
||||
timeoutTickerStarted = true
|
||||
@@ -344,6 +431,7 @@ class ChatRepository(
|
||||
is SocketEvent.Conversation -> current.copy(
|
||||
currentConversation = event.withUserName,
|
||||
messages = event.messages,
|
||||
videoConsent = current.videoConsent.takeIf { it.withUserName == event.withUserName } ?: VideoConsentDto(withUserName = event.withUserName),
|
||||
unreadChatsCount = maxOf(0, current.unreadChatsCount - 1),
|
||||
remainingSecondsToTimeout = 1800
|
||||
)
|
||||
@@ -351,6 +439,56 @@ class ChatRepository(
|
||||
is SocketEvent.HistoryResults -> current.copy(historyResults = event.results)
|
||||
is SocketEvent.InboxResults -> current.copy(inboxResults = event.results)
|
||||
is SocketEvent.UnreadChats -> current.copy(unreadChatsCount = event.count)
|
||||
is SocketEvent.VideoConsentUpdate -> current.copy(
|
||||
videoConsent = event.consent.takeIf { it.withUserName == current.currentConversation } ?: current.videoConsent
|
||||
)
|
||||
is SocketEvent.VideoCallInvite -> current.withVideoCall(event.call)
|
||||
is SocketEvent.VideoCallIncoming -> current.withVideoCall(event.call)
|
||||
is SocketEvent.VideoCallStart -> current.withVideoCall(event.call).also {
|
||||
scope.launch {
|
||||
runCatching {
|
||||
videoCallManager.ensureCall(event.call, it.selfMuted, it.selfCameraEnabled)
|
||||
}.onFailure { error ->
|
||||
_state.value = _state.value.copy(errorMessage = error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
is SocketEvent.VideoCallUpdate -> current.withVideoCall(event.call).also {
|
||||
if ((event.call.status == "connecting" || event.call.status == "active") && event.call.media != null) {
|
||||
scope.launch {
|
||||
runCatching {
|
||||
videoCallManager.ensureCall(event.call, it.selfMuted, it.selfCameraEnabled)
|
||||
}.onFailure { error ->
|
||||
_state.value = _state.value.copy(errorMessage = error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is SocketEvent.VideoCallReject -> current.withVideoCall(event.call).also {
|
||||
videoCallManager.endCall(event.call.callId)
|
||||
}
|
||||
is SocketEvent.VideoCallCancel -> current.withVideoCall(event.call).also {
|
||||
videoCallManager.endCall(event.call.callId)
|
||||
}
|
||||
is SocketEvent.VideoCallEnd -> current.withVideoCall(event.call).also {
|
||||
videoCallManager.endCall(event.call.callId)
|
||||
}
|
||||
is SocketEvent.VideoCallMuteState -> current.withVideoCall(event.call)
|
||||
is SocketEvent.VideoCallCapacity -> current.copy(
|
||||
activeVideoConnectionCount = event.capacity.activeConnections,
|
||||
maxVideoConnections = event.capacity.maxConnections,
|
||||
maxVideoConnectionsReached = event.capacity.reachedMax
|
||||
)
|
||||
is SocketEvent.VideoCallSignal -> current.also {
|
||||
scope.launch {
|
||||
runCatching {
|
||||
videoCallManager.handleSignal(event.signal)
|
||||
}.onFailure { error ->
|
||||
_state.value = _state.value.copy(errorMessage = error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
is SocketEvent.VideoCallError -> current.copy(errorMessage = event.message)
|
||||
is SocketEvent.UserBlocked -> current.copy(errorMessage = "${event.userName} blocked")
|
||||
is SocketEvent.UserUnblocked -> current.copy(errorMessage = "${event.userName} unblocked")
|
||||
is SocketEvent.CommandResult -> current.copy(
|
||||
@@ -362,6 +500,8 @@ class ChatRepository(
|
||||
errorMessage = if (event.kind == "info" || event.kind.startsWith("login")) event.lines.joinToString(" | ") else current.errorMessage
|
||||
)
|
||||
is SocketEvent.CommandTable -> current.copy(
|
||||
commandLines = emptyList(),
|
||||
commandKind = null,
|
||||
commandTable = CommandTableState(event.title, event.columns, event.rows)
|
||||
)
|
||||
is SocketEvent.Error -> current.copy(errorMessage = event.message)
|
||||
@@ -369,6 +509,40 @@ class ChatRepository(
|
||||
}
|
||||
}
|
||||
|
||||
private fun ChatState.withVideoCall(call: VideoCallDto): ChatState {
|
||||
val normalized = VideoSessionState.fromDto(call)
|
||||
val updated = videoDockSessions.toMutableList()
|
||||
val index = updated.indexOfFirst { it.callId == normalized.callId }
|
||||
if (index >= 0) {
|
||||
updated[index] = updated[index].copy(
|
||||
roomId = normalized.roomId,
|
||||
withUserName = normalized.withUserName,
|
||||
initiatedBy = normalized.initiatedBy,
|
||||
status = normalized.status,
|
||||
createdAt = normalized.createdAt,
|
||||
updatedAt = normalized.updatedAt,
|
||||
endedAt = normalized.endedAt,
|
||||
reason = normalized.reason,
|
||||
localMuted = normalized.localMuted,
|
||||
remoteMuted = normalized.remoteMuted,
|
||||
connectionState = normalized.connectionState,
|
||||
remoteConnectionState = normalized.remoteConnectionState,
|
||||
media = normalized.media
|
||||
)
|
||||
} else {
|
||||
updated += normalized
|
||||
}
|
||||
val visibleSessions = updated.filterNot { it.status in setOf("rejected", "cancelled", "ended", "failed") }
|
||||
return copy(
|
||||
videoDockSessions = updated.sortedByDescending { it.updatedAt }.take(3),
|
||||
foregroundVideoSessionId = when {
|
||||
foregroundVideoSessionId == null && normalized.status !in setOf("rejected", "cancelled", "ended", "failed") -> normalized.callId
|
||||
foregroundVideoSessionId == normalized.callId && normalized.status in setOf("rejected", "cancelled", "ended", "failed") -> visibleSessions.firstOrNull()?.callId
|
||||
else -> foregroundVideoSessionId
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
data class CommandTableState(
|
||||
val title: String,
|
||||
val columns: List<String>,
|
||||
@@ -404,5 +578,51 @@ data class ChatState(
|
||||
val isUploadingImage: Boolean = false,
|
||||
val imageUploadMessage: String? = null,
|
||||
val unreadChatsCount: Int = 0,
|
||||
val errorMessage: String? = null
|
||||
val errorMessage: String? = null,
|
||||
val videoConsent: VideoConsentDto = VideoConsentDto(),
|
||||
val videoDockSessions: List<VideoSessionState> = emptyList(),
|
||||
val foregroundVideoSessionId: String? = null,
|
||||
val floatingVideoOffsetX: Float = 24f,
|
||||
val floatingVideoOffsetY: Float = 24f,
|
||||
val activeVideoConnectionCount: Int = 0,
|
||||
val maxVideoConnections: Int = 3,
|
||||
val maxVideoConnectionsReached: Boolean = false,
|
||||
val selfMuted: Boolean = false,
|
||||
val selfCameraEnabled: Boolean = true
|
||||
)
|
||||
|
||||
data class VideoSessionState(
|
||||
val callId: String,
|
||||
val roomId: String? = null,
|
||||
val withUserName: String? = null,
|
||||
val initiatedBy: String? = null,
|
||||
val status: String = "",
|
||||
val createdAt: String = "",
|
||||
val updatedAt: String = "",
|
||||
val endedAt: String? = null,
|
||||
val reason: String? = null,
|
||||
val localMuted: Boolean = false,
|
||||
val remoteMuted: Boolean = false,
|
||||
val connectionState: String = "new",
|
||||
val remoteConnectionState: String = "new",
|
||||
val media: de.ypchat.android.data.model.VideoMediaDto? = null
|
||||
) {
|
||||
companion object {
|
||||
fun fromDto(dto: VideoCallDto) = VideoSessionState(
|
||||
callId = dto.callId,
|
||||
roomId = dto.roomId,
|
||||
withUserName = dto.withUserName,
|
||||
initiatedBy = dto.initiatedBy,
|
||||
status = dto.status,
|
||||
createdAt = dto.createdAt,
|
||||
updatedAt = dto.updatedAt,
|
||||
endedAt = dto.endedAt,
|
||||
reason = dto.reason,
|
||||
localMuted = dto.localMuted,
|
||||
remoteMuted = dto.remoteMuted,
|
||||
connectionState = dto.connectionState,
|
||||
remoteConnectionState = dto.remoteConnectionState,
|
||||
media = dto.media
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
package de.ypchat.android.media
|
||||
|
||||
import android.content.Context
|
||||
import de.ypchat.android.data.api.SocketClient
|
||||
import de.ypchat.android.data.model.VideoCallDto
|
||||
import de.ypchat.android.data.model.VideoIceCandidateDto
|
||||
import de.ypchat.android.data.model.VideoIceServerDto
|
||||
import de.ypchat.android.data.model.VideoSessionDescriptionDto
|
||||
import de.ypchat.android.data.model.VideoSignalDto
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import org.webrtc.AudioSource
|
||||
import org.webrtc.AudioTrack
|
||||
import org.webrtc.Camera1Enumerator
|
||||
import org.webrtc.CameraEnumerator
|
||||
import org.webrtc.Camera2Enumerator
|
||||
import org.webrtc.CameraVideoCapturer
|
||||
import org.webrtc.CandidatePairChangeEvent
|
||||
import org.webrtc.DataChannel
|
||||
import org.webrtc.DefaultVideoDecoderFactory
|
||||
import org.webrtc.DefaultVideoEncoderFactory
|
||||
import org.webrtc.EglBase
|
||||
import org.webrtc.IceCandidate
|
||||
import org.webrtc.Logging
|
||||
import org.webrtc.MediaConstraints
|
||||
import org.webrtc.MediaStream
|
||||
import org.webrtc.PeerConnection
|
||||
import org.webrtc.PeerConnectionFactory
|
||||
import org.webrtc.RtpReceiver
|
||||
import org.webrtc.RtpTransceiver
|
||||
import org.webrtc.SdpObserver
|
||||
import org.webrtc.SessionDescription
|
||||
import org.webrtc.SurfaceTextureHelper
|
||||
import org.webrtc.VideoCapturer
|
||||
import org.webrtc.VideoSource
|
||||
import org.webrtc.VideoTrack
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.regex.Pattern
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
data class VideoMediaState(
|
||||
val localVideoTrack: VideoTrack? = null,
|
||||
val remoteVideoTracks: Map<String, VideoTrack> = emptyMap(),
|
||||
val lastError: String? = null
|
||||
)
|
||||
|
||||
class AndroidVideoCallManager(
|
||||
context: Context,
|
||||
private val socketClient: SocketClient
|
||||
) {
|
||||
private val appContext = context.applicationContext
|
||||
private val eglBase: EglBase = EglBase.create()
|
||||
private val peerConnectionFactory: PeerConnectionFactory
|
||||
private val peerConnections = ConcurrentHashMap<String, PeerConnection>()
|
||||
private val pendingIceCandidates = ConcurrentHashMap<String, MutableList<IceCandidate>>()
|
||||
|
||||
private var audioSource: AudioSource? = null
|
||||
private var audioTrack: AudioTrack? = null
|
||||
private var videoSource: VideoSource? = null
|
||||
private var videoTrack: VideoTrack? = null
|
||||
private var videoCapturer: CameraVideoCapturer? = null
|
||||
private var videoCapturerInitialized = false
|
||||
private var surfaceTextureHelper: SurfaceTextureHelper? = null
|
||||
|
||||
private val _state = MutableStateFlow(VideoMediaState())
|
||||
val state: StateFlow<VideoMediaState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
PeerConnectionFactory.initialize(
|
||||
PeerConnectionFactory.InitializationOptions.builder(appContext)
|
||||
.setEnableInternalTracer(false)
|
||||
.createInitializationOptions()
|
||||
)
|
||||
val options = PeerConnectionFactory.Options()
|
||||
peerConnectionFactory = PeerConnectionFactory.builder()
|
||||
.setOptions(options)
|
||||
.setVideoEncoderFactory(DefaultVideoEncoderFactory(eglBase.eglBaseContext, true, true))
|
||||
.setVideoDecoderFactory(DefaultVideoDecoderFactory(eglBase.eglBaseContext))
|
||||
.createPeerConnectionFactory()
|
||||
}
|
||||
|
||||
fun eglBaseContext() = eglBase.eglBaseContext
|
||||
|
||||
suspend fun ensureCall(call: VideoCallDto, selfMuted: Boolean, selfCameraEnabled: Boolean) {
|
||||
val media = call.media ?: return
|
||||
ensureLocalMedia(selfMuted, selfCameraEnabled)
|
||||
val existing = peerConnections[call.callId]
|
||||
if (existing != null) {
|
||||
if (media.isCaller && existing.localDescription == null) {
|
||||
createOffer(call.callId, existing)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val rtcConfig = PeerConnection.RTCConfiguration(media.iceServers.toNativeIceServers()).apply {
|
||||
iceTransportsType = PeerConnection.IceTransportsType.RELAY
|
||||
sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN
|
||||
bundlePolicy = PeerConnection.BundlePolicy.MAXBUNDLE
|
||||
rtcpMuxPolicy = PeerConnection.RtcpMuxPolicy.REQUIRE
|
||||
continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY
|
||||
}
|
||||
|
||||
val peerConnection = peerConnectionFactory.createPeerConnection(
|
||||
rtcConfig,
|
||||
createPeerConnectionObserver(call.callId)
|
||||
) ?: throw IllegalStateException("PeerConnection konnte nicht erstellt werden.")
|
||||
|
||||
audioTrack?.let { peerConnection.addTrack(it) }
|
||||
videoTrack?.let { peerConnection.addTrack(it) }
|
||||
peerConnections[call.callId] = peerConnection
|
||||
|
||||
if (media.isCaller) {
|
||||
createOffer(call.callId, peerConnection)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun handleSignal(signal: VideoSignalDto) {
|
||||
val peerConnection = peerConnections[signal.callId] ?: return
|
||||
when (signal.signalType) {
|
||||
"description" -> {
|
||||
val description = signal.description ?: return
|
||||
peerConnection.setRemoteDescriptionAwait(
|
||||
SessionDescription(SessionDescription.Type.fromCanonicalForm(description.type), description.sdp)
|
||||
)
|
||||
flushPendingCandidates(signal.callId, peerConnection)
|
||||
if (description.type == "offer") {
|
||||
createAnswer(signal.callId, peerConnection)
|
||||
}
|
||||
}
|
||||
"candidate" -> {
|
||||
val candidate = signal.candidate ?: return
|
||||
val nativeCandidate = candidate.toNativeIceCandidate()
|
||||
if (peerConnection.remoteDescription == null) {
|
||||
pendingIceCandidates.getOrPut(signal.callId) { mutableListOf() }.add(nativeCandidate)
|
||||
} else {
|
||||
peerConnection.addIceCandidate(nativeCandidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSelfMuted(muted: Boolean) {
|
||||
audioTrack?.setEnabled(!muted)
|
||||
}
|
||||
|
||||
fun updateSelfCameraEnabled(enabled: Boolean) {
|
||||
videoTrack?.setEnabled(enabled)
|
||||
}
|
||||
|
||||
fun endCall(callId: String) {
|
||||
peerConnections.remove(callId)?.let { connection ->
|
||||
connection.close()
|
||||
}
|
||||
pendingIceCandidates.remove(callId)
|
||||
val nextTracks = _state.value.remoteVideoTracks.toMutableMap().apply { remove(callId) }
|
||||
_state.value = _state.value.copy(remoteVideoTracks = nextTracks)
|
||||
}
|
||||
|
||||
fun releaseAll() {
|
||||
peerConnections.keys.toList().forEach(::endCall)
|
||||
runCatching { videoCapturer?.stopCapture() }
|
||||
runCatching { videoCapturer?.dispose() }
|
||||
runCatching { surfaceTextureHelper?.dispose() }
|
||||
runCatching { videoSource?.dispose() }
|
||||
runCatching { audioSource?.dispose() }
|
||||
runCatching { videoTrack?.dispose() }
|
||||
runCatching { audioTrack?.dispose() }
|
||||
surfaceTextureHelper = null
|
||||
videoCapturer = null
|
||||
videoSource = null
|
||||
audioSource = null
|
||||
videoTrack = null
|
||||
audioTrack = null
|
||||
videoCapturerInitialized = false
|
||||
_state.value = VideoMediaState()
|
||||
}
|
||||
|
||||
private suspend fun ensureLocalMedia(selfMuted: Boolean, selfCameraEnabled: Boolean) {
|
||||
if (audioTrack == null) {
|
||||
audioSource = peerConnectionFactory.createAudioSource(MediaConstraints())
|
||||
audioTrack = peerConnectionFactory.createAudioTrack("YPCHAT_AUDIO", audioSource).apply {
|
||||
setEnabled(!selfMuted)
|
||||
}
|
||||
}
|
||||
if (videoTrack == null) {
|
||||
val capturer = createVideoCapturer()
|
||||
?: throw IllegalStateException("Keine geeignete Kamera für Videochat gefunden.")
|
||||
videoCapturer = capturer
|
||||
surfaceTextureHelper = SurfaceTextureHelper.create("YPChatVideoCapture", eglBase.eglBaseContext)
|
||||
videoSource = peerConnectionFactory.createVideoSource(capturer.isScreencast)
|
||||
capturer.initialize(surfaceTextureHelper, appContext, videoSource?.capturerObserver)
|
||||
capturer.startCapture(960, 720, 24)
|
||||
videoCapturerInitialized = true
|
||||
videoTrack = peerConnectionFactory.createVideoTrack("YPCHAT_VIDEO", videoSource).apply {
|
||||
setEnabled(selfCameraEnabled)
|
||||
}
|
||||
_state.value = _state.value.copy(localVideoTrack = videoTrack)
|
||||
} else if (videoCapturerInitialized) {
|
||||
videoTrack?.setEnabled(selfCameraEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPeerConnectionObserver(callId: String) = object : PeerConnection.Observer {
|
||||
override fun onSignalingChange(newState: PeerConnection.SignalingState?) = Unit
|
||||
override fun onIceConnectionChange(newState: PeerConnection.IceConnectionState?) {
|
||||
when (newState) {
|
||||
PeerConnection.IceConnectionState.CONNECTED,
|
||||
PeerConnection.IceConnectionState.COMPLETED -> socketClient.setVideoConnectionState(callId, "connected")
|
||||
PeerConnection.IceConnectionState.DISCONNECTED -> socketClient.setVideoConnectionState(callId, "disconnected")
|
||||
PeerConnection.IceConnectionState.FAILED -> socketClient.setVideoConnectionState(callId, "failed")
|
||||
PeerConnection.IceConnectionState.CLOSED -> socketClient.setVideoConnectionState(callId, "closed")
|
||||
PeerConnection.IceConnectionState.CHECKING -> socketClient.setVideoConnectionState(callId, "connecting")
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
override fun onStandardizedIceConnectionChange(newState: PeerConnection.IceConnectionState?) = Unit
|
||||
override fun onConnectionChange(newState: PeerConnection.PeerConnectionState?) {
|
||||
when (newState) {
|
||||
PeerConnection.PeerConnectionState.CONNECTING -> socketClient.setVideoConnectionState(callId, "connecting")
|
||||
PeerConnection.PeerConnectionState.CONNECTED -> socketClient.setVideoConnectionState(callId, "connected")
|
||||
PeerConnection.PeerConnectionState.DISCONNECTED -> socketClient.setVideoConnectionState(callId, "disconnected")
|
||||
PeerConnection.PeerConnectionState.FAILED -> socketClient.setVideoConnectionState(callId, "failed")
|
||||
PeerConnection.PeerConnectionState.CLOSED -> socketClient.setVideoConnectionState(callId, "closed")
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
override fun onIceConnectionReceivingChange(receiving: Boolean) = Unit
|
||||
override fun onIceGatheringChange(newState: PeerConnection.IceGatheringState?) = Unit
|
||||
override fun onIceCandidate(candidate: IceCandidate?) {
|
||||
candidate ?: return
|
||||
socketClient.sendVideoSignal(
|
||||
VideoSignalDto(
|
||||
callId = callId,
|
||||
signalType = "candidate",
|
||||
candidate = candidate.toDto()
|
||||
)
|
||||
)
|
||||
}
|
||||
override fun onIceCandidatesRemoved(candidates: Array<out IceCandidate>?) = Unit
|
||||
override fun onAddStream(stream: MediaStream?) {
|
||||
val remoteTrack = stream?.videoTracks?.firstOrNull() ?: return
|
||||
val nextTracks = _state.value.remoteVideoTracks.toMutableMap().apply { put(callId, remoteTrack) }
|
||||
_state.value = _state.value.copy(remoteVideoTracks = nextTracks)
|
||||
}
|
||||
override fun onRemoveStream(stream: MediaStream?) {
|
||||
val nextTracks = _state.value.remoteVideoTracks.toMutableMap().apply { remove(callId) }
|
||||
_state.value = _state.value.copy(remoteVideoTracks = nextTracks)
|
||||
}
|
||||
override fun onDataChannel(dataChannel: DataChannel?) = Unit
|
||||
override fun onRenegotiationNeeded() = Unit
|
||||
override fun onAddTrack(receiver: RtpReceiver?, mediaStreams: Array<out MediaStream>?) {
|
||||
val remoteTrack = receiver?.track() as? VideoTrack ?: return
|
||||
val nextTracks = _state.value.remoteVideoTracks.toMutableMap().apply { put(callId, remoteTrack) }
|
||||
_state.value = _state.value.copy(remoteVideoTracks = nextTracks)
|
||||
}
|
||||
override fun onTrack(transceiver: RtpTransceiver?) {
|
||||
val remoteTrack = transceiver?.receiver?.track() as? VideoTrack ?: return
|
||||
val nextTracks = _state.value.remoteVideoTracks.toMutableMap().apply { put(callId, remoteTrack) }
|
||||
_state.value = _state.value.copy(remoteVideoTracks = nextTracks)
|
||||
}
|
||||
override fun onSelectedCandidatePairChanged(event: CandidatePairChangeEvent?) = Unit
|
||||
}
|
||||
|
||||
private suspend fun createOffer(callId: String, peerConnection: PeerConnection) {
|
||||
val offer = peerConnection.createOfferAwait(
|
||||
MediaConstraints().apply {
|
||||
mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true"))
|
||||
mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveVideo", "true"))
|
||||
}
|
||||
)
|
||||
peerConnection.setLocalDescriptionAwait(offer)
|
||||
socketClient.sendVideoSignal(
|
||||
VideoSignalDto(
|
||||
callId = callId,
|
||||
signalType = "description",
|
||||
description = VideoSessionDescriptionDto(
|
||||
type = offer.type.canonicalForm(),
|
||||
sdp = offer.description
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun createAnswer(callId: String, peerConnection: PeerConnection) {
|
||||
val answer = peerConnection.createAnswerAwait(MediaConstraints())
|
||||
peerConnection.setLocalDescriptionAwait(answer)
|
||||
socketClient.sendVideoSignal(
|
||||
VideoSignalDto(
|
||||
callId = callId,
|
||||
signalType = "description",
|
||||
description = VideoSessionDescriptionDto(
|
||||
type = answer.type.canonicalForm(),
|
||||
sdp = answer.description
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun flushPendingCandidates(callId: String, peerConnection: PeerConnection) {
|
||||
val candidates = pendingIceCandidates.remove(callId).orEmpty()
|
||||
candidates.forEach { peerConnection.addIceCandidate(it) }
|
||||
}
|
||||
|
||||
private fun createVideoCapturer(): CameraVideoCapturer? {
|
||||
if (Camera2Enumerator.isSupported(appContext)) {
|
||||
createCameraCapturer(Camera2Enumerator(appContext))?.let { return it }
|
||||
}
|
||||
return createCameraCapturer(Camera1Enumerator(false))
|
||||
}
|
||||
|
||||
private fun createCameraCapturer(enumerator: CameraEnumerator): CameraVideoCapturer? {
|
||||
enumerator.deviceNames.firstOrNull(enumerator::isFrontFacing)?.let { deviceName ->
|
||||
enumerator.createCapturer(deviceName, null)?.let { return it }
|
||||
}
|
||||
enumerator.deviceNames.firstOrNull()?.let { deviceName ->
|
||||
enumerator.createCapturer(deviceName, null)?.let { return it }
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun PeerConnection.createOfferAwait(constraints: MediaConstraints): SessionDescription = suspendCoroutine { continuation ->
|
||||
createOffer(object : SdpObserver {
|
||||
override fun onCreateSuccess(desc: SessionDescription?) {
|
||||
if (desc != null) continuation.resume(desc) else continuation.resumeWithException(IllegalStateException("Offer leer"))
|
||||
}
|
||||
override fun onSetSuccess() = Unit
|
||||
override fun onCreateFailure(error: String?) = continuation.resumeWithException(IllegalStateException(error ?: "Offer fehlgeschlagen"))
|
||||
override fun onSetFailure(error: String?) = Unit
|
||||
}, constraints)
|
||||
}
|
||||
|
||||
private suspend fun PeerConnection.createAnswerAwait(constraints: MediaConstraints): SessionDescription = suspendCoroutine { continuation ->
|
||||
createAnswer(object : SdpObserver {
|
||||
override fun onCreateSuccess(desc: SessionDescription?) {
|
||||
if (desc != null) continuation.resume(desc) else continuation.resumeWithException(IllegalStateException("Answer leer"))
|
||||
}
|
||||
override fun onSetSuccess() = Unit
|
||||
override fun onCreateFailure(error: String?) = continuation.resumeWithException(IllegalStateException(error ?: "Answer fehlgeschlagen"))
|
||||
override fun onSetFailure(error: String?) = Unit
|
||||
}, constraints)
|
||||
}
|
||||
|
||||
private suspend fun PeerConnection.setLocalDescriptionAwait(description: SessionDescription): Unit = suspendCoroutine { continuation ->
|
||||
setLocalDescription(object : SdpObserver {
|
||||
override fun onCreateSuccess(desc: SessionDescription?) = Unit
|
||||
override fun onSetSuccess() = continuation.resume(Unit)
|
||||
override fun onCreateFailure(error: String?) = Unit
|
||||
override fun onSetFailure(error: String?) = continuation.resumeWithException(IllegalStateException(error ?: "setLocalDescription fehlgeschlagen"))
|
||||
}, description)
|
||||
}
|
||||
|
||||
private suspend fun PeerConnection.setRemoteDescriptionAwait(description: SessionDescription): Unit = suspendCoroutine { continuation ->
|
||||
setRemoteDescription(object : SdpObserver {
|
||||
override fun onCreateSuccess(desc: SessionDescription?) = Unit
|
||||
override fun onSetSuccess() = continuation.resume(Unit)
|
||||
override fun onCreateFailure(error: String?) = Unit
|
||||
override fun onSetFailure(error: String?) = continuation.resumeWithException(IllegalStateException(error ?: "setRemoteDescription fehlgeschlagen"))
|
||||
}, description)
|
||||
}
|
||||
|
||||
private fun List<VideoIceServerDto>.toNativeIceServers(): List<PeerConnection.IceServer> = mapNotNull { dto ->
|
||||
if (dto.urls.isEmpty()) return@mapNotNull null
|
||||
PeerConnection.IceServer.builder(dto.urls)
|
||||
.apply {
|
||||
dto.username?.let(::setUsername)
|
||||
dto.credential?.let(::setPassword)
|
||||
}
|
||||
.createIceServer()
|
||||
}
|
||||
|
||||
private val relayPattern = Pattern.compile("\\btyp\\s+(\\w+)\\b", Pattern.CASE_INSENSITIVE)
|
||||
|
||||
private fun IceCandidate.toDto(): VideoIceCandidateDto {
|
||||
val type = relayPattern.matcher(sdp).let { matcher ->
|
||||
if (matcher.find()) matcher.group(1) else null
|
||||
}
|
||||
return VideoIceCandidateDto(
|
||||
candidate = sdp,
|
||||
sdpMid = sdpMid,
|
||||
sdpMLineIndex = sdpMLineIndex,
|
||||
usernameFragment = serverUrl,
|
||||
type = type
|
||||
)
|
||||
}
|
||||
|
||||
private fun VideoIceCandidateDto.toNativeIceCandidate(): IceCandidate =
|
||||
IceCandidate(sdpMid, sdpMLineIndex, candidate)
|
||||
@@ -1,4 +1,4 @@
|
||||
package net.ypchat.app.ui
|
||||
package de.ypchat.android.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
@@ -8,14 +8,18 @@ import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import net.ypchat.app.data.repository.ChatRepository
|
||||
import net.ypchat.app.data.repository.ChatState
|
||||
import de.ypchat.android.data.repository.ChatRepository
|
||||
import de.ypchat.android.data.repository.ChatState
|
||||
import de.ypchat.android.media.VideoMediaState
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.webrtc.EglBase
|
||||
|
||||
class ChatViewModel(private val repository: ChatRepository) : ViewModel() {
|
||||
val state: StateFlow<ChatState> = repository.state
|
||||
val videoMediaState: StateFlow<VideoMediaState> = repository.videoMediaState
|
||||
val videoEglBaseContext: EglBase.Context = repository.videoEglBaseContext
|
||||
|
||||
init {
|
||||
viewModelScope.launch { repository.restoreSession() }
|
||||
@@ -36,6 +40,7 @@ class ChatViewModel(private val repository: ChatRepository) : ViewModel() {
|
||||
fun openConversation(userName: String) = repository.openConversation(userName)
|
||||
fun closeConversation() = repository.closeConversation()
|
||||
fun sendMessage(text: String) = repository.sendMessage(text)
|
||||
fun setImageUploadMessage(message: String) = repository.setImageUploadState(false, message)
|
||||
fun sendImage(context: Context, uri: Uri) {
|
||||
val target = state.value.currentConversation ?: return
|
||||
viewModelScope.launch {
|
||||
@@ -110,6 +115,18 @@ class ChatViewModel(private val repository: ChatRepository) : ViewModel() {
|
||||
|
||||
fun blockCurrentUser() = state.value.currentConversation?.let(repository::blockUser)
|
||||
fun unblockCurrentUser() = state.value.currentConversation?.let(repository::unblockUser)
|
||||
fun setVideoConsent(allowed: Boolean) = repository.setVideoConsent(allowed)
|
||||
fun inviteVideoCall() = repository.inviteVideoCall()
|
||||
fun acceptVideoCall(callId: String) = repository.acceptVideoCall(callId)
|
||||
fun rejectVideoCall(callId: String) = repository.rejectVideoCall(callId)
|
||||
fun cancelVideoCall(callId: String) = repository.cancelVideoCall(callId)
|
||||
fun endVideoCall(callId: String) = repository.endVideoCall(callId)
|
||||
fun bringVideoToFront(callId: String) = repository.bringVideoToFront(callId)
|
||||
fun minimizeForegroundVideo() = repository.minimizeForegroundVideo()
|
||||
fun updateFloatingVideoPosition(x: Float, y: Float) = repository.updateFloatingVideoPosition(x, y)
|
||||
fun toggleSelfMuted() = repository.toggleSelfMuted()
|
||||
fun toggleSelfCameraEnabled() = repository.toggleSelfCameraEnabled()
|
||||
fun setRuntimeError(message: String) = repository.setRuntimeError(message)
|
||||
|
||||
private companion object {
|
||||
const val MAX_IMAGE_BYTES = 5 * 1024 * 1024
|
||||
File diff suppressed because it is too large
Load Diff
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
@@ -1,6 +1,6 @@
|
||||
<resources>
|
||||
<string name="app_name">YPChat</string>
|
||||
<string name="landing_eyebrow">SingleChat</string>
|
||||
<string name="landing_eyebrow">YpChat</string>
|
||||
<string name="landing_title">Direkt in den Chat</string>
|
||||
<string name="landing_copy">Kompakt, schnell und ohne Umwege. Erstelle dein Profil und starte sofort eine Unterhaltung.</string>
|
||||
<string name="feature_worldwide_chat">Weltweiter Chat</string>
|
||||
@@ -49,6 +49,7 @@
|
||||
<string name="unblock">Entsperren</string>
|
||||
<string name="message_placeholder">Nachricht</string>
|
||||
<string name="button_image">Bild</string>
|
||||
<string name="button_camera">Foto</string>
|
||||
<string name="button_send">Senden</string>
|
||||
<string name="button_smileys">Smileys</string>
|
||||
<string name="image_message">Bildnachricht</string>
|
||||
@@ -57,6 +58,8 @@
|
||||
<string name="image_upload_failed">Bild-Upload fehlgeschlagen.</string>
|
||||
<string name="image_upload_too_large">Das Bild ist größer als 5 MB.</string>
|
||||
<string name="image_upload_open_failed">Das Bild konnte nicht geöffnet werden.</string>
|
||||
<string name="camera_permission_denied">Die Kameraberechtigung wurde abgelehnt.</string>
|
||||
<string name="camera_capture_failed">Das Foto konnte nicht aufgenommen werden.</string>
|
||||
<string name="feedback_created_at">Eingegangen %1$s</string>
|
||||
<string name="feedback_meta_separator"> • </string>
|
||||
<string name="countries_load_error">Länderliste konnte nicht geladen werden: %1$s</string>
|
||||
@@ -83,21 +86,26 @@
|
||||
<string name="more_faq">FAQ</string>
|
||||
<string name="more_rules">Regeln</string>
|
||||
<string name="more_safety">Sicherheit</string>
|
||||
<string name="more_privacy">Datenschutz</string>
|
||||
<string name="more_imprint">Impressum</string>
|
||||
<string name="more_back">Zur Übersicht</string>
|
||||
<string name="partners_intro">Empfehlungen und befreundete Projekte für unsere Community.</string>
|
||||
<string name="faq_intro">Antworten auf häufige Fragen zum Chat.</string>
|
||||
<string name="rules_intro">Grundregeln für respektvollen Chat.</string>
|
||||
<string name="safety_intro">Tipps für Privatsphäre und sichere Nutzung.</string>
|
||||
<string name="privacy_intro">Datenschutzerklärung, verarbeitete Daten und Kontakt für Datenschutzanfragen.</string>
|
||||
<string name="imprint_intro">Rechtliche Hinweise und Kontaktdaten.</string>
|
||||
<string name="external_link">Externer Link</string>
|
||||
<string name="faq_title">Häufige Fragen</string>
|
||||
<string name="rules_title">Chat-Regeln</string>
|
||||
<string name="safety_title">Sicherheit und Privatsphäre</string>
|
||||
<string name="privacy_title">Datenschutzerklärung</string>
|
||||
<string name="imprint_title">Impressum</string>
|
||||
<string name="partners_title">Partner</string>
|
||||
<string name="faq_body">Wähle einen Nicknamen, gib deine Profildaten an und starte den Chat. Teile keine sensiblen Daten wie Telefonnummern, Adressen, Passwörter oder Zahlungsinformationen. Du kannst Bilder senden, Benutzer blockieren und Feedback für ernste Vorfälle nutzen.</string>
|
||||
<string name="rules_body">Keine Beleidigungen, Hassrede, illegalen Inhalte, Spam oder unerwünschte Belästigung. Sende nur Bilder, die du teilen darfst, und respektiere die Privatsphäre anderer.</string>
|
||||
<string name="safety_body">Nutze einen Nicknamen, der dich nicht identifiziert. Teile keine privaten Kontakt- oder Zahlungsdaten. Sei vorsichtig mit Links von Unbekannten und beende Gespräche, die sich falsch anfühlen. Nutze Blockieren und Feedback bei schweren Vorfällen.</string>
|
||||
<string name="privacy_body">YpChat verarbeitet den von dir gewählten Nickname, Profildaten wie Alter, Geschlecht und Land, Chat-Nachrichten, von dir aktiv gesendete Bilder, Feedback-Nachrichten sowie technisch notwendige Sitzungsdaten. Die Android-App fragt den Kamerazugriff nur an, wenn du in der App aktiv ein Foto aufnehmen möchtest. Die vollständige Datenschutzerklärung für Website und App ist auf www.ypchat.net veröffentlicht.</string>
|
||||
<string name="privacy_open_policy">Datenschutzerklärung öffnen</string>
|
||||
<string name="imprint_body">Torsten Schulz, Friedrich-Stampfer-Str. 21, 60437 Frankfurt. Kontakt: tsschulz@tsschulz.de. Für externe Links sind deren Betreiber verantwortlich.</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<resources>
|
||||
<string name="app_name">YPChat</string>
|
||||
<string name="landing_eyebrow">SingleChat</string>
|
||||
<string name="app_name">YpChat</string>
|
||||
<string name="landing_eyebrow">YpChat</string>
|
||||
<string name="landing_title">Directly into chat</string>
|
||||
<string name="landing_copy">Compact, fast and without detours. Create your profile and start a conversation right away.</string>
|
||||
<string name="feature_worldwide_chat">Worldwide chat</string>
|
||||
@@ -49,19 +49,53 @@
|
||||
<string name="unblock">Unblock</string>
|
||||
<string name="message_placeholder">Message</string>
|
||||
<string name="button_image">Image</string>
|
||||
<string name="button_camera">Photo</string>
|
||||
<string name="button_send">Send</string>
|
||||
<string name="button_smileys">Smileys</string>
|
||||
<string name="button_video_allow">Allow video</string>
|
||||
<string name="button_video_allowed">Video allowed</string>
|
||||
<string name="button_video_open">Open video chat</string>
|
||||
<string name="button_video_foreground">Bring forward</string>
|
||||
<string name="button_video_accept">Accept</string>
|
||||
<string name="button_video_reject">Reject</string>
|
||||
<string name="button_video_cancel">Cancel</string>
|
||||
<string name="button_video_end">End</string>
|
||||
<string name="button_video_minimize">Minimize</string>
|
||||
<string name="button_video_mute">Mute microphone</string>
|
||||
<string name="button_video_unmute">Unmute microphone</string>
|
||||
<string name="button_video_camera_off">Camera off</string>
|
||||
<string name="button_video_camera_on">Camera on</string>
|
||||
<string name="video_status_partner_allowed">Partner allowed video</string>
|
||||
<string name="video_status_partner_pending">Partner has not allowed video yet</string>
|
||||
<string name="video_status_capacity_reached">Maximum of three video connections allowed</string>
|
||||
<string name="video_self_preview">You</string>
|
||||
<string name="video_self_preview_camera_off">Camera inactive</string>
|
||||
<string name="video_mic_on">Microphone on</string>
|
||||
<string name="video_mic_off">Microphone off</string>
|
||||
<string name="video_partner_mic_on">Partner: microphone on</string>
|
||||
<string name="video_partner_mic_off">Partner: microphone off</string>
|
||||
<string name="video_self_mic_on">You: microphone on</string>
|
||||
<string name="video_self_mic_off">You: microphone off</string>
|
||||
<string name="video_status_ringing">Ringing</string>
|
||||
<string name="video_status_connecting">Connecting</string>
|
||||
<string name="video_status_active">Active</string>
|
||||
<string name="image_message">Image message</string>
|
||||
<string name="image_upload_in_progress">Uploading image...</string>
|
||||
<string name="image_upload_success">Image uploaded.</string>
|
||||
<string name="image_upload_failed">Image upload failed.</string>
|
||||
<string name="image_upload_too_large">Image is larger than 5 MB.</string>
|
||||
<string name="image_upload_open_failed">Image could not be opened.</string>
|
||||
<string name="camera_permission_denied">Camera permission was denied.</string>
|
||||
<string name="camera_capture_failed">Photo could not be captured.</string>
|
||||
<string name="feedback_created_at">Received %1$s</string>
|
||||
<string name="feedback_meta_separator"> • </string>
|
||||
<string name="countries_load_error">Country list could not be loaded: %1$s</string>
|
||||
<string name="user_blocked">%1$s has been blocked</string>
|
||||
<string name="user_unblocked">%1$s has been unblocked</string>
|
||||
<string name="video_consent_required">Video chat becomes visible after both users allow it.</string>
|
||||
<string name="video_capacity_reached">Maximum of three video connections allowed.</string>
|
||||
<string name="video_partner_capacity_reached">The partner already reached the maximum number of video connections.</string>
|
||||
<string name="video_call_exists">A video chat with this partner already exists.</string>
|
||||
<string name="feedback_title">Feedback</string>
|
||||
<string name="feedback_comment">Comment</string>
|
||||
<string name="feedback_send">Send feedback</string>
|
||||
@@ -83,21 +117,26 @@
|
||||
<string name="more_faq">FAQ</string>
|
||||
<string name="more_rules">Rules</string>
|
||||
<string name="more_safety">Safety</string>
|
||||
<string name="more_privacy">Privacy</string>
|
||||
<string name="more_imprint">Imprint</string>
|
||||
<string name="more_back">Back to overview</string>
|
||||
<string name="partners_intro">Recommended and friendly projects for our community.</string>
|
||||
<string name="faq_intro">Answers to common questions about the chat.</string>
|
||||
<string name="rules_intro">Basic rules for respectful chatting.</string>
|
||||
<string name="safety_intro">Tips for privacy and safer usage.</string>
|
||||
<string name="privacy_intro">Privacy policy, processed data and contact for privacy requests.</string>
|
||||
<string name="imprint_intro">Legal notice and contact details.</string>
|
||||
<string name="external_link">External link</string>
|
||||
<string name="faq_title">Frequently Asked Questions</string>
|
||||
<string name="rules_title">Chat Rules</string>
|
||||
<string name="safety_title">Safety and Privacy</string>
|
||||
<string name="privacy_title">Privacy Policy</string>
|
||||
<string name="imprint_title">Imprint</string>
|
||||
<string name="partners_title">Partners</string>
|
||||
<string name="faq_body">Choose a nickname, enter your profile details and start chatting. Do not share sensitive data like phone numbers, addresses, passwords or payment information. You can send images, block users and use feedback for serious issues.</string>
|
||||
<string name="rules_body">No insults, hate speech, illegal content, spam or unwanted harassment. Only send images you are allowed to share and respect the privacy of others.</string>
|
||||
<string name="safety_body">Use a nickname that does not identify you. Do not share private contact or payment data. Be careful with links from strangers and end conversations that feel wrong. Use block and feedback for serious incidents.</string>
|
||||
<string name="privacy_body">YpChat processes the nickname you choose, profile details such as age, gender and country, chat messages, images you actively send, feedback messages and technically necessary session data. The Android app requests camera access only if you actively want to take a photo in the app. The full privacy policy for website and app is published on www.ypchat.net.</string>
|
||||
<string name="privacy_open_policy">Open privacy policy</string>
|
||||
<string name="imprint_body">Torsten Schulz, Friedrich-Stampfer-Str. 21, 60437 Frankfurt. Contact: tsschulz@tsschulz.de. External links are the responsibility of their operators.</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
org.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8
|
||||
org.gradle.jvmargs=-Xmx16g -XX:MaxMetaspaceSize=2g -Dfile.encoding=UTF-8
|
||||
android.r8.maxHeapSize=8g
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
kotlin.code.style=official
|
||||
android.dependency.useConstraints=true
|
||||
android.dependency.useConstraints=false
|
||||
android.r8.strictFullModeForKeepRules=false
|
||||
android.dependency.excludeLibraryComponentsFromConstraints=true
|
||||
android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false
|
||||
kotlin.daemon.jvmargs=-Xmx2048m
|
||||
android.lint.workerProcessMaxHeapSize=4g
|
||||
|
||||
BIN
android/production
Normal file
BIN
android/production
Normal file
Binary file not shown.
@@ -1,12 +1,11 @@
|
||||
<IfModule mod_ssl.c>
|
||||
<VirtualHost *:443>
|
||||
ServerName ypchat.net
|
||||
ServerAlias www.ypchat.net
|
||||
|
||||
# SSL-Konfiguration
|
||||
Include /etc/letsencrypt/options-ssl-apache.conf
|
||||
SSLCertificateFile /etc/letsencrypt/live/www.ypchat.net/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/www.ypchat.net/privkey.pem
|
||||
SSLCertificateFile /etc/letsencrypt/live/ypchat.net/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/ypchat.net/privkey.pem
|
||||
|
||||
# DocumentRoot (nur für statische Dateien wie ads.txt)
|
||||
DocumentRoot /opt/ypchat/docroot
|
||||
@@ -64,5 +63,15 @@
|
||||
RequestHeader set X-Forwarded-Proto "https"
|
||||
RequestHeader set X-Forwarded-Port "443"
|
||||
</VirtualHost>
|
||||
</IfModule>
|
||||
|
||||
<VirtualHost *:443>
|
||||
ServerName www.ypchat.net
|
||||
|
||||
Include /etc/letsencrypt/options-ssl-apache.conf
|
||||
SSLCertificateFile /etc/letsencrypt/live/ypchat.net/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/ypchat.net/privkey.pem
|
||||
|
||||
RewriteEngine On
|
||||
RewriteRule ^ https://ypchat.net%{REQUEST_URI} [R=301,L]
|
||||
</VirtualHost>
|
||||
</IfModule>
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
<IfModule mod_ssl.c>
|
||||
# 1) Apex-Domain (ypchat.net) liefert NUR Redirect auf www
|
||||
# 1) www-Domain liefert NUR Redirect auf Apex
|
||||
<VirtualHost *:443>
|
||||
ServerName ypchat.net
|
||||
ServerName www.ypchat.net
|
||||
|
||||
Include /etc/letsencrypt/options-ssl-apache.conf
|
||||
SSLCertificateFile /etc/letsencrypt/live/ypchat.net/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/ypchat.net/privkey.pem
|
||||
|
||||
RewriteEngine On
|
||||
RewriteRule ^ https://www.ypchat.net%{REQUEST_URI} [R=301,L]
|
||||
RewriteRule ^ https://ypchat.net%{REQUEST_URI} [R=301,L]
|
||||
</VirtualHost>
|
||||
|
||||
# 2) Canonical Host (www.ypchat.net) liefert die App
|
||||
# 2) Canonical Host (ypchat.net) liefert die App
|
||||
<VirtualHost *:443>
|
||||
ServerName www.ypchat.net
|
||||
ServerName ypchat.net
|
||||
|
||||
# SSL-Konfiguration
|
||||
Include /etc/letsencrypt/options-ssl-apache.conf
|
||||
|
||||
31
client/ADS-INTEGRATION.md
Normal file
31
client/ADS-INTEGRATION.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Header-Ad Integration
|
||||
|
||||
Aktuell nutzt `YpChat` im Header eine direkte Adsterra-Integration ueber `HeaderAdBanner.vue`.
|
||||
|
||||
Verwendete Placements:
|
||||
|
||||
- Mobile: `320x50`
|
||||
- Key: `fb9b5e7f817d40d72943dae0c54eb769`
|
||||
- Desktop: `468x60`
|
||||
- Key: `2b658317c1e28b4b4f234d26c8fca28d`
|
||||
|
||||
Die Komponente waehlt automatisch anhand der Viewport-Breite:
|
||||
|
||||
- bis `720px`: `320x50`
|
||||
- ab `721px`: `468x60`
|
||||
|
||||
Einbauorte:
|
||||
|
||||
- [client/src/components/HeaderAdBanner.vue](/mnt/share/torsten/Programs/SingleChat/client/src/components/HeaderAdBanner.vue)
|
||||
- [client/src/views/ChatView.vue](/mnt/share/torsten/Programs/SingleChat/client/src/views/ChatView.vue)
|
||||
- weitere SEO-/Info-Seiten mit `HeaderAdBanner`
|
||||
|
||||
Technik:
|
||||
|
||||
- Adsterra `IFRAME SYNC`
|
||||
- Script-Quelle:
|
||||
- `https://www.highperformanceformat.com/<key>/invoke.js`
|
||||
|
||||
Aktuell gibt es keine zusaetzliche Consent-, Provider- oder Fallback-Logik mehr in dieser Komponente.
|
||||
|
||||
Wenn neue Banner-Formate kommen, muessen nur die Keys und Groessen in `HeaderAdBanner.vue` angepasst werden.
|
||||
@@ -3,16 +3,16 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SingleChat - Chat, Single-Chat und Bildaustausch</title>
|
||||
<meta name="description" content="Willkommen auf SingleChat - deine erste Adresse für Chat, Single-Chat und Bildaustausch. Chatte mit Menschen aus aller Welt, finde neue Kontakte und teile Erinnerungen sicher und komfortabel.">
|
||||
<meta name="keywords" content="Chat, Single-Chat, Bildaustausch, Online-Chat, Singles, Kontakte, Community">
|
||||
<title>SingleChat - Kostenloser Single Chat ohne Anmeldung</title>
|
||||
<meta name="description" content="Kostenloser Single Chat ohne lange Registrierung: Profil starten, Singles kennenlernen, privat chatten und Bilder sicher austauschen. Direkt online loslegen.">
|
||||
<meta name="keywords" content="single chat, kostenloser single chat, single chat ohne anmeldung, single treff chat, chatten für singles, online chat">
|
||||
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1">
|
||||
<meta name="author" content="SingleChat">
|
||||
<meta name="theme-color" content="#2f6f46">
|
||||
|
||||
<!-- Open Graph Tags -->
|
||||
<meta property="og:title" content="SingleChat - Chat, Single-Chat und Bildaustausch">
|
||||
<meta property="og:description" content="Willkommen auf SingleChat - deine erste Adresse für Chat, Single-Chat und Bildaustausch.">
|
||||
<meta property="og:title" content="SingleChat - Kostenloser Single Chat ohne Anmeldung">
|
||||
<meta property="og:description" content="Kostenlos chatten, Singles kennenlernen und Bilder sicher austauschen.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://www.ypchat.net/">
|
||||
<meta property="og:image" content="https://www.ypchat.net/static/favicon.png">
|
||||
@@ -21,8 +21,8 @@
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="SingleChat - Chat, Single-Chat und Bildaustausch">
|
||||
<meta name="twitter:description" content="Willkommen auf SingleChat - deine erste Adresse für Chat, Single-Chat und Bildaustausch.">
|
||||
<meta name="twitter:title" content="SingleChat - Kostenloser Single Chat ohne Anmeldung">
|
||||
<meta name="twitter:description" content="Kostenlos chatten, Singles kennenlernen und Bilder sicher austauschen.">
|
||||
<meta name="twitter:image" content="https://www.ypchat.net/static/favicon.png">
|
||||
|
||||
<!-- Canonical URL -->
|
||||
@@ -32,7 +32,7 @@
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||
<link rel="shortcut icon" href="/favicon.ico">
|
||||
<link rel="icon" type="image/png" href="/appicon.png">
|
||||
<script type="application/ld+json" id="seo-json-ld">{"@context":"https://schema.org","@type":"WebSite","name":"SingleChat","url":"https://www.ypchat.net/","description":"Willkommen auf SingleChat - deine erste Adresse für Chat, Single-Chat und Bildaustausch.","inLanguage":"de-DE"}</script>
|
||||
<script type="application/ld+json" id="seo-json-ld">{"@context":"https://schema.org","@type":"WebSite","name":"SingleChat","alternateName":"ypchat.net","url":"https://www.ypchat.net/","description":"Kostenloser Single Chat ohne lange Registrierung: Profil starten, Singles kennenlernen, privat chatten und Bilder sicher austauschen.","inLanguage":"de-DE"}</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
4
client/package-lock.json
generated
4
client/package-lock.json
generated
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "singlechat-client",
|
||||
"name": "ypchat-client",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "singlechat-client",
|
||||
"name": "ypchat-client",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@unhead/vue": "^2.0.19",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "singlechat-client",
|
||||
"name": "ypchat-client",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
6
client/public/scripts/sw.js
Normal file
6
client/public/scripts/sw.js
Normal file
@@ -0,0 +1,6 @@
|
||||
self.options = {
|
||||
"domain": "3nbf4.com",
|
||||
"zoneId": 11023587
|
||||
}
|
||||
self.lary = ""
|
||||
importScripts('https://3nbf4.com/act/files/service-worker.min.js?r=sw')
|
||||
@@ -25,6 +25,15 @@
|
||||
>
|
||||
<img src="/image.png" alt="Image" />
|
||||
</button>
|
||||
<button
|
||||
class="camera-button"
|
||||
type="button"
|
||||
@click="openCamera"
|
||||
title="Foto aufnehmen"
|
||||
:disabled="!hasConversation || isCameraStarting"
|
||||
>
|
||||
<span class="camera-button-icon" aria-hidden="true">📷</span>
|
||||
</button>
|
||||
|
||||
<div v-if="showSmileys" class="smiley-bar">
|
||||
<span
|
||||
@@ -36,16 +45,86 @@
|
||||
@click="insertSmiley(code)"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<div v-if="cameraModalOpen" class="camera-modal-overlay" @click="closeCamera">
|
||||
<div class="camera-modal" @click.stop>
|
||||
<div class="camera-modal-header">
|
||||
<h3>Foto aufnehmen</h3>
|
||||
<button type="button" class="camera-close" @click="closeCamera" title="Schließen">×</button>
|
||||
</div>
|
||||
|
||||
<div v-if="cameraError" class="camera-error">
|
||||
{{ cameraError }}
|
||||
</div>
|
||||
|
||||
<div class="camera-preview">
|
||||
<video
|
||||
v-show="!capturedImageUrl && !cameraError"
|
||||
ref="videoRef"
|
||||
autoplay
|
||||
playsinline
|
||||
muted
|
||||
></video>
|
||||
<img
|
||||
v-if="capturedImageUrl"
|
||||
:src="capturedImageUrl"
|
||||
alt="Aufgenommenes Foto"
|
||||
/>
|
||||
<div v-if="isCameraStarting && !cameraError" class="camera-loading">
|
||||
Kamera wird gestartet ...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<canvas ref="canvasRef" class="camera-canvas" aria-hidden="true"></canvas>
|
||||
|
||||
<div class="camera-actions">
|
||||
<button
|
||||
v-if="!capturedImageUrl"
|
||||
type="button"
|
||||
@click="capturePhoto"
|
||||
:disabled="isCameraStarting || !!cameraError"
|
||||
>
|
||||
Foto machen
|
||||
</button>
|
||||
<button
|
||||
v-if="capturedImageUrl"
|
||||
type="button"
|
||||
class="secondary"
|
||||
@click="retakePhoto"
|
||||
:disabled="isUploadingPhoto"
|
||||
>
|
||||
Neu aufnehmen
|
||||
</button>
|
||||
<button
|
||||
v-if="capturedImageUrl"
|
||||
type="button"
|
||||
@click="sendCapturedPhoto"
|
||||
:disabled="isUploadingPhoto"
|
||||
>
|
||||
{{ isUploadingPhoto ? 'Sende ...' : 'Foto senden' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { ref, computed, onBeforeUnmount } from 'vue';
|
||||
import { useChatStore } from '../stores/chat';
|
||||
|
||||
const chatStore = useChatStore();
|
||||
const message = ref('');
|
||||
const showSmileys = ref(false);
|
||||
const cameraModalOpen = ref(false);
|
||||
const isCameraStarting = ref(false);
|
||||
const isUploadingPhoto = ref(false);
|
||||
const cameraError = ref('');
|
||||
const videoRef = ref(null);
|
||||
const canvasRef = ref(null);
|
||||
const cameraStream = ref(null);
|
||||
const capturedImageUrl = ref('');
|
||||
const capturedPhotoBlob = ref(null);
|
||||
const hasConversation = computed(() => !!chatStore.currentConversation);
|
||||
const isAwaitingUsername = computed(() => chatStore.awaitingLoginUsername);
|
||||
const isAwaitingPassword = computed(() => chatStore.awaitingLoginPassword);
|
||||
@@ -115,55 +194,346 @@ function insertSmiley(code) {
|
||||
showSmileys.value = false;
|
||||
}
|
||||
|
||||
function showTemporaryError(text) {
|
||||
chatStore.errorMessage = text;
|
||||
setTimeout(() => {
|
||||
if (chatStore.errorMessage === text) {
|
||||
chatStore.errorMessage = null;
|
||||
}
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
async function handleImageUpload(event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!chatStore.currentConversation) {
|
||||
console.error('Keine Konversation ausgewählt');
|
||||
return;
|
||||
}
|
||||
|
||||
// Prüfe Dateigröße (max. 5MB)
|
||||
const maxSize = 5 * 1024 * 1024; // 5MB
|
||||
if (file.size > maxSize) {
|
||||
alert('Bild ist zu groß. Maximale Größe: 5MB');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// Erstelle FormData für Upload
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
// Lade Bild hoch
|
||||
const response = await fetch('/api/upload-image', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include' // Wichtig für Session-Cookies
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ error: 'Unbekannter Fehler' }));
|
||||
throw new Error(errorData.error || 'Fehler beim Hochladen des Bildes');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.code) {
|
||||
// Sende nur den Code über Socket.IO
|
||||
chatStore.sendImage(chatStore.currentConversation, data.code, data.url);
|
||||
} else {
|
||||
throw new Error('Ungültige Antwort vom Server');
|
||||
}
|
||||
await uploadAndSendImage(file);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Bild-Upload:', error);
|
||||
alert('Fehler beim Bild-Upload: ' + error.message);
|
||||
}
|
||||
|
||||
|
||||
// Input zurücksetzen, damit das gleiche Bild erneut ausgewählt werden kann
|
||||
event.target.value = '';
|
||||
}
|
||||
|
||||
async function uploadAndSendImage(file) {
|
||||
if (!chatStore.currentConversation) {
|
||||
throw new Error('Keine Konversation ausgewählt');
|
||||
}
|
||||
|
||||
// Prüfe Dateigröße (max. 5MB)
|
||||
const maxSize = 5 * 1024 * 1024; // 5MB
|
||||
if (file.size > maxSize) {
|
||||
throw new Error('Bild ist zu groß. Maximale Größe: 5MB');
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
const response = await fetch('/api/upload-image', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include' // Wichtig für Session-Cookies
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ error: 'Unbekannter Fehler' }));
|
||||
throw new Error(errorData.error || 'Fehler beim Hochladen des Bildes');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.code) {
|
||||
chatStore.sendImage(chatStore.currentConversation, data.code, data.url);
|
||||
} else {
|
||||
throw new Error('Ungültige Antwort vom Server');
|
||||
}
|
||||
}
|
||||
|
||||
async function openCamera() {
|
||||
if (!hasConversation.value) {
|
||||
showTemporaryError('Bitte zuerst eine Unterhaltung auswählen.');
|
||||
return;
|
||||
}
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
showTemporaryError('Kamera wird von diesem Browser nicht unterstützt.');
|
||||
return;
|
||||
}
|
||||
|
||||
cameraModalOpen.value = true;
|
||||
cameraError.value = '';
|
||||
capturedImageUrl.value = '';
|
||||
capturedPhotoBlob.value = null;
|
||||
isCameraStarting.value = true;
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
facingMode: 'user',
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 1280 }
|
||||
},
|
||||
audio: false
|
||||
});
|
||||
|
||||
cameraStream.value = stream;
|
||||
if (videoRef.value) {
|
||||
videoRef.value.srcObject = stream;
|
||||
await videoRef.value.play();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Kamera konnte nicht gestartet werden:', error);
|
||||
cameraError.value = 'Kamera konnte nicht gestartet werden. Bitte Berechtigung prüfen.';
|
||||
stopCameraStream();
|
||||
} finally {
|
||||
isCameraStarting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function stopCameraStream() {
|
||||
if (cameraStream.value) {
|
||||
cameraStream.value.getTracks().forEach(track => track.stop());
|
||||
cameraStream.value = null;
|
||||
}
|
||||
if (videoRef.value) {
|
||||
videoRef.value.srcObject = null;
|
||||
}
|
||||
}
|
||||
|
||||
function closeCamera() {
|
||||
stopCameraStream();
|
||||
cameraModalOpen.value = false;
|
||||
cameraError.value = '';
|
||||
isCameraStarting.value = false;
|
||||
isUploadingPhoto.value = false;
|
||||
clearCapturedPhoto();
|
||||
}
|
||||
|
||||
function clearCapturedPhoto() {
|
||||
if (capturedImageUrl.value) {
|
||||
URL.revokeObjectURL(capturedImageUrl.value);
|
||||
}
|
||||
capturedImageUrl.value = '';
|
||||
capturedPhotoBlob.value = null;
|
||||
}
|
||||
|
||||
function capturePhoto() {
|
||||
if (!videoRef.value || !canvasRef.value) return;
|
||||
|
||||
const video = videoRef.value;
|
||||
const canvas = canvasRef.value;
|
||||
const sourceWidth = video.videoWidth || 1280;
|
||||
const sourceHeight = video.videoHeight || 720;
|
||||
const maxSide = 1280;
|
||||
const scale = Math.min(1, maxSide / Math.max(sourceWidth, sourceHeight));
|
||||
const targetWidth = Math.round(sourceWidth * scale);
|
||||
const targetHeight = Math.round(sourceHeight * scale);
|
||||
|
||||
canvas.width = targetWidth;
|
||||
canvas.height = targetHeight;
|
||||
const context = canvas.getContext('2d');
|
||||
context.drawImage(video, 0, 0, targetWidth, targetHeight);
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
cameraError.value = 'Foto konnte nicht verarbeitet werden.';
|
||||
return;
|
||||
}
|
||||
|
||||
clearCapturedPhoto();
|
||||
capturedPhotoBlob.value = blob;
|
||||
capturedImageUrl.value = URL.createObjectURL(blob);
|
||||
stopCameraStream();
|
||||
}, 'image/jpeg', 0.86);
|
||||
}
|
||||
|
||||
async function retakePhoto() {
|
||||
clearCapturedPhoto();
|
||||
await openCamera();
|
||||
}
|
||||
|
||||
async function sendCapturedPhoto() {
|
||||
if (!capturedPhotoBlob.value) return;
|
||||
|
||||
isUploadingPhoto.value = true;
|
||||
try {
|
||||
const file = new File([capturedPhotoBlob.value], `singlechat-photo-${Date.now()}.jpg`, {
|
||||
type: 'image/jpeg'
|
||||
});
|
||||
await uploadAndSendImage(file);
|
||||
closeCamera();
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Foto-Versand:', error);
|
||||
cameraError.value = 'Foto konnte nicht gesendet werden: ' + error.message;
|
||||
} finally {
|
||||
isUploadingPhoto.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopCameraStream();
|
||||
clearCapturedPhoto();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.camera-button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
border: 1px solid #cdd8d0;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(180deg, #fdfefd 0%, #edf4ef 100%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.camera-button-icon {
|
||||
display: block;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.camera-button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.camera-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1200;
|
||||
padding: 18px;
|
||||
background: rgba(12, 18, 14, 0.78);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.camera-modal {
|
||||
width: min(560px, 100%);
|
||||
max-height: calc(100vh - 36px);
|
||||
border-radius: 10px;
|
||||
border: 1px solid #d7dfd9;
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.camera-modal-header {
|
||||
min-height: 54px;
|
||||
padding: 0 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid #e4ebe6;
|
||||
}
|
||||
|
||||
.camera-modal-header h3 {
|
||||
margin: 0;
|
||||
color: #18201b;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.camera-close {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: #edf2ee;
|
||||
color: #253027;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.camera-error {
|
||||
margin: 14px 14px 0;
|
||||
border: 1px solid #e5b7b7;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
background: #fff0f0;
|
||||
color: #7d2525;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.camera-preview {
|
||||
position: relative;
|
||||
margin: 14px;
|
||||
aspect-ratio: 4 / 3;
|
||||
border-radius: 10px;
|
||||
background: #101510;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.camera-preview video,
|
||||
.camera-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.camera-preview video {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.camera-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #ffffff;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.camera-canvas {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.camera-actions {
|
||||
padding: 0 14px 14px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.camera-actions button {
|
||||
min-height: 40px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: 0 16px;
|
||||
background: #245c3a;
|
||||
color: #ffffff;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.camera-actions button.secondary {
|
||||
background: #edf2ee;
|
||||
color: #245c3a;
|
||||
border: 1px solid #bfd5c4;
|
||||
}
|
||||
|
||||
.camera-actions button:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.camera-modal-overlay {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.camera-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -23,8 +23,48 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div v-else class="messages-container">
|
||||
<div v-if="currentVideoSession" class="video-call-banner">
|
||||
<div class="video-call-banner-copy">
|
||||
<strong>{{ currentVideoSession.withUserName }}</strong>
|
||||
<span>{{ statusLabel(currentVideoSession.status) }} · {{ currentVideoSession.remoteMuted ? 'Mikro aus' : 'Mikro an' }}</span>
|
||||
</div>
|
||||
<div class="video-call-banner-actions">
|
||||
<button
|
||||
v-if="currentVideoSession.status === 'ringing' && currentVideoSession.initiatedBy !== chatStore.userName"
|
||||
type="button"
|
||||
@click="chatStore.acceptVideoCall(currentVideoSession.callId)"
|
||||
>
|
||||
Annehmen
|
||||
</button>
|
||||
<button
|
||||
v-if="currentVideoSession.status === 'ringing' && currentVideoSession.initiatedBy !== chatStore.userName"
|
||||
type="button"
|
||||
class="danger"
|
||||
@click="chatStore.rejectVideoCall(currentVideoSession.callId)"
|
||||
>
|
||||
Ablehnen
|
||||
</button>
|
||||
<button
|
||||
v-if="currentVideoSession.status === 'ringing' && currentVideoSession.initiatedBy === chatStore.userName"
|
||||
type="button"
|
||||
class="secondary"
|
||||
@click="chatStore.cancelVideoCall(currentVideoSession.callId)"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
v-if="currentVideoSession.status === 'connecting' || currentVideoSession.status === 'active'"
|
||||
type="button"
|
||||
class="secondary"
|
||||
@click="chatStore.bringVideoSessionToFront(currentVideoSession.callId)"
|
||||
>
|
||||
Vordergrund
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(message, index) in chatStore.messages"
|
||||
:key="index"
|
||||
@@ -33,33 +73,33 @@
|
||||
>
|
||||
<strong>{{ message.from }}:</strong>
|
||||
<span v-if="message.isImage" class="image-message">
|
||||
<img
|
||||
:src="message.message"
|
||||
:alt="'Bild von ' + message.from"
|
||||
class="chat-image"
|
||||
<img
|
||||
:src="message.message"
|
||||
:alt="'Bild von ' + message.from"
|
||||
class="chat-image"
|
||||
@click="openImageModal(message.message)"
|
||||
/>
|
||||
</span>
|
||||
<span v-else v-html="replaceSmileys(message.message)"></span>
|
||||
|
||||
<!-- Bild-Modal -->
|
||||
</div>
|
||||
|
||||
<div v-if="selectedImage" class="image-modal-overlay" @click="closeImageModal">
|
||||
<div class="image-modal-content" @click.stop>
|
||||
<button class="image-modal-close" @click="closeImageModal" title="Schließen">×</button>
|
||||
<img :src="selectedImage" alt="Vergrößertes Bild" class="image-modal-image" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useChatStore } from '../stores/chat';
|
||||
|
||||
const chatStore = useChatStore();
|
||||
const selectedImage = ref(null);
|
||||
const currentVideoSession = computed(() => chatStore.currentConversationVideoSession);
|
||||
|
||||
function openImageModal(imageSrc) {
|
||||
selectedImage.value = imageSrc;
|
||||
@@ -69,7 +109,6 @@ function closeImageModal() {
|
||||
selectedImage.value = null;
|
||||
}
|
||||
|
||||
// Smiley-Definitionen (wie im Original)
|
||||
const smileys = {
|
||||
':)': { code: '1F642' },
|
||||
':D': { code: '1F600' },
|
||||
@@ -95,21 +134,18 @@ const smileys = {
|
||||
|
||||
function replaceSmileys(text) {
|
||||
if (!text) return '';
|
||||
|
||||
// HTML-Sonderzeichen escapen
|
||||
|
||||
let outputText = text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
// Smileys ersetzen (längere Codes zuerst, um Überschneidungen zu vermeiden)
|
||||
|
||||
const sortedCodes = Object.keys(smileys).sort((a, b) => b.length - a.length);
|
||||
|
||||
for (const code of sortedCodes) {
|
||||
const regex = new RegExp(escapeRegex(code), 'g');
|
||||
outputText = outputText.replace(regex, `&#x${smileys[code].code};`);
|
||||
}
|
||||
|
||||
|
||||
return outputText;
|
||||
}
|
||||
|
||||
@@ -121,6 +157,19 @@ function formatTime(timestamp) {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function statusLabel(status) {
|
||||
switch (status) {
|
||||
case 'ringing':
|
||||
return 'Videoanruf klingelt';
|
||||
case 'connecting':
|
||||
return 'Videoanruf verbindet';
|
||||
case 'active':
|
||||
return 'Videoanruf aktiv';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -212,15 +261,63 @@ function formatTime(timestamp) {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.empty-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.video-call-banner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #d8e1da;
|
||||
border-radius: 10px;
|
||||
background: #f7fbf8;
|
||||
}
|
||||
|
||||
.video-call-banner-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.video-call-banner-copy strong {
|
||||
color: #223026;
|
||||
}
|
||||
|
||||
.video-call-banner-copy span {
|
||||
color: #58685d;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.video-call-banner-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.video-call-banner-actions button {
|
||||
min-height: 34px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: 0 12px;
|
||||
background: #1d6a42;
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.video-call-banner-actions button.secondary {
|
||||
background: #edf2ee;
|
||||
color: #1d6a42;
|
||||
border: 1px solid #ccd9cf;
|
||||
}
|
||||
|
||||
.video-call-banner-actions button.danger {
|
||||
background: #b03737;
|
||||
}
|
||||
|
||||
.chat-image {
|
||||
@@ -298,4 +395,15 @@ function formatTime(timestamp) {
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.empty-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.video-call-banner {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
283
client/src/components/FloatingVideoWindow.vue
Normal file
283
client/src/components/FloatingVideoWindow.vue
Normal file
@@ -0,0 +1,283 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="session"
|
||||
class="floating-video-window"
|
||||
:style="windowStyle"
|
||||
>
|
||||
<header class="floating-video-header" @mousedown="startDrag">
|
||||
<div class="floating-video-title">
|
||||
<strong>{{ session.withUserName }}</strong>
|
||||
<span>{{ session.remoteMuted ? 'Mikro aus' : 'Mikro an' }}</span>
|
||||
</div>
|
||||
<div class="floating-video-header-actions">
|
||||
<button type="button" class="secondary" @click="chatStore.minimizeForegroundVideo()">Minimieren</button>
|
||||
<button type="button" class="danger" @click="chatStore.endVideoCall(session.callId)">Beenden</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="floating-video-body">
|
||||
<div class="floating-video-stage">
|
||||
<VideoSessionSurface v-if="session" :session="session" :muted="false" />
|
||||
</div>
|
||||
|
||||
<div class="floating-self-preview">
|
||||
<video ref="selfVideoRef" autoplay muted playsinline></video>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="floating-video-footer">
|
||||
<div class="floating-video-footer-state">
|
||||
<span>{{ session.remoteMuted ? 'Partner: Mikro aus' : 'Partner: Mikro an' }}</span>
|
||||
<span>{{ chatStore.selfMuted ? 'Du: Mikro aus' : 'Du: Mikro an' }}</span>
|
||||
</div>
|
||||
<div class="floating-video-footer-actions">
|
||||
<button type="button" @click="chatStore.toggleSelfMute()">
|
||||
{{ chatStore.selfMuted ? 'Mikro aktivieren' : 'Mikro stummschalten' }}
|
||||
</button>
|
||||
<button type="button" class="secondary" @click="chatStore.toggleSelfCamera()">
|
||||
{{ chatStore.selfCameraEnabled ? 'Kamera aus' : 'Kamera an' }}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="floating-video-resize-handle"
|
||||
title="Fenstergröße ändern"
|
||||
@mousedown.stop.prevent="startResize"
|
||||
></button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { useChatStore } from '../stores/chat';
|
||||
import VideoSessionSurface from './VideoSessionSurface.vue';
|
||||
|
||||
const chatStore = useChatStore();
|
||||
const session = computed(() => chatStore.foregroundVideoSession);
|
||||
const selfVideoRef = ref(null);
|
||||
|
||||
const windowStyle = computed(() => ({
|
||||
left: `${chatStore.floatingVideoPosition.x}px`,
|
||||
top: `${chatStore.floatingVideoPosition.y}px`,
|
||||
width: `${chatStore.floatingVideoSize.width}px`
|
||||
}));
|
||||
|
||||
watch(
|
||||
() => chatStore.selfPreviewStream,
|
||||
(stream) => {
|
||||
if (selfVideoRef.value) {
|
||||
selfVideoRef.value.srcObject = stream || null;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
function statusLabel(status) {
|
||||
switch (status) {
|
||||
case 'ringing':
|
||||
return 'Klingelt';
|
||||
case 'connecting':
|
||||
return 'Verbindet';
|
||||
case 'active':
|
||||
return 'Aktiv';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function startDrag(event) {
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
const { x, y } = chatStore.floatingVideoPosition;
|
||||
|
||||
const handleMove = (moveEvent) => {
|
||||
chatStore.updateFloatingVideoPosition({
|
||||
x: x + (moveEvent.clientX - startX),
|
||||
y: y + (moveEvent.clientY - startY)
|
||||
});
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
window.removeEventListener('mousemove', handleMove);
|
||||
window.removeEventListener('mouseup', handleUp);
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', handleMove);
|
||||
window.addEventListener('mouseup', handleUp);
|
||||
}
|
||||
|
||||
function startResize(event) {
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
const startWidth = chatStore.floatingVideoSize.width;
|
||||
const measuredHeight = event.currentTarget?.closest('.floating-video-window')?.offsetHeight || 0;
|
||||
const startHeight = chatStore.floatingVideoSize.height || measuredHeight;
|
||||
|
||||
const handleMove = (moveEvent) => {
|
||||
chatStore.updateFloatingVideoSize({
|
||||
width: startWidth + (moveEvent.clientX - startX),
|
||||
height: startHeight + (moveEvent.clientY - startY)
|
||||
});
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
window.removeEventListener('mousemove', handleMove);
|
||||
window.removeEventListener('mouseup', handleUp);
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', handleMove);
|
||||
window.addEventListener('mouseup', handleUp);
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (selfVideoRef.value) {
|
||||
selfVideoRef.value.srcObject = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.floating-video-window {
|
||||
position: fixed;
|
||||
z-index: 1300;
|
||||
min-width: 340px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #cad5ce;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 24px 60px rgba(10, 19, 14, 0.28);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.floating-video-header {
|
||||
min-height: 58px;
|
||||
padding: 0 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #1a211d;
|
||||
color: #eef5f0;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.floating-video-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.floating-video-title strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.floating-video-title span {
|
||||
font-size: 12px;
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.floating-video-header-actions,
|
||||
.floating-video-footer-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.floating-video-header-actions button,
|
||||
.floating-video-footer-actions button {
|
||||
min-height: 34px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: 0 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.floating-video-header-actions button.secondary,
|
||||
.floating-video-footer-actions button.secondary {
|
||||
background: #edf2ee;
|
||||
color: #214f36;
|
||||
}
|
||||
|
||||
.floating-video-header-actions button.danger {
|
||||
background: #b13838;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.floating-video-body {
|
||||
position: relative;
|
||||
background: #08100b;
|
||||
}
|
||||
|
||||
.floating-video-stage {
|
||||
aspect-ratio: 16 / 9;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.floating-self-preview {
|
||||
position: absolute;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
width: min(24%, 150px);
|
||||
aspect-ratio: 4 / 3;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.25);
|
||||
background: #16211a;
|
||||
}
|
||||
|
||||
.floating-self-preview video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.floating-video-footer {
|
||||
padding: 12px 14px 14px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.floating-video-footer-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
color: #4e5d53;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.floating-video-footer-actions button {
|
||||
background: #1d6a42;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.floating-video-resize-handle {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
bottom: 6px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
cursor: nwse-resize;
|
||||
background:
|
||||
linear-gradient(135deg, transparent 0 42%, rgba(29, 106, 66, 0.2) 42% 52%, transparent 52% 62%, rgba(29, 106, 66, 0.45) 62% 72%, transparent 72% 82%, rgba(29, 106, 66, 0.75) 82% 92%, transparent 92% 100%);
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.floating-video-window {
|
||||
width: calc(100vw - 24px) !important;
|
||||
min-width: 0;
|
||||
left: 12px !important;
|
||||
top: 12px !important;
|
||||
}
|
||||
|
||||
.floating-video-footer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,90 +1,127 @@
|
||||
<template>
|
||||
<div v-if="isEnabled" class="header-ad-banner">
|
||||
<ins
|
||||
ref="adElement"
|
||||
class="adsbygoogle"
|
||||
style="display:inline-block;width:320px;height:50px"
|
||||
:data-ad-client="adClient"
|
||||
:data-ad-slot="adSlot"
|
||||
></ins>
|
||||
<div class="header-ad-banner">
|
||||
<div
|
||||
ref="adContainer"
|
||||
class="ad-container"
|
||||
:class="{ 'is-mobile': activePlacement.width === 320 }"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, ref } from 'vue';
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
|
||||
const adElement = ref(null);
|
||||
const adClient = import.meta.env.VITE_ADSENSE_CLIENT || '';
|
||||
const adSlot = import.meta.env.VITE_ADSENSE_HEADER_SLOT || '';
|
||||
const isEnabled = computed(() => Boolean(adClient && adSlot));
|
||||
const MOBILE_BREAKPOINT = 720;
|
||||
const placements = {
|
||||
mobile: {
|
||||
key: 'fb9b5e7f817d40d72943dae0c54eb769',
|
||||
width: 320,
|
||||
height: 50
|
||||
},
|
||||
desktop: {
|
||||
key: '2b658317c1e28b4b4f234d26c8fca28d',
|
||||
width: 468,
|
||||
height: 60
|
||||
}
|
||||
};
|
||||
|
||||
function ensureAdSenseScript() {
|
||||
if (!isEnabled.value) return;
|
||||
if (document.querySelector('script[data-adsense-loader="true"]')) return;
|
||||
const adContainer = ref(null);
|
||||
const activePlacement = ref(selectPlacement());
|
||||
let resizeTimer = null;
|
||||
|
||||
function selectPlacement() {
|
||||
if (typeof window === 'undefined') {
|
||||
return placements.desktop;
|
||||
}
|
||||
return window.innerWidth <= MOBILE_BREAKPOINT ? placements.mobile : placements.desktop;
|
||||
}
|
||||
|
||||
function clearAdContainer() {
|
||||
if (!adContainer.value) return;
|
||||
adContainer.value.innerHTML = '';
|
||||
}
|
||||
|
||||
function buildInvokeUrl(key) {
|
||||
return `https://www.highperformanceformat.com/${key}/invoke.js`;
|
||||
}
|
||||
|
||||
function renderAd() {
|
||||
if (!adContainer.value) return;
|
||||
|
||||
const placement = selectPlacement();
|
||||
activePlacement.value = placement;
|
||||
clearAdContainer();
|
||||
|
||||
window.atOptions = {
|
||||
key: placement.key,
|
||||
format: 'iframe',
|
||||
height: placement.height,
|
||||
width: placement.width,
|
||||
params: {}
|
||||
};
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = buildInvokeUrl(placement.key);
|
||||
script.async = true;
|
||||
script.src = `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${adClient}`;
|
||||
script.crossOrigin = 'anonymous';
|
||||
script.dataset.adsenseLoader = 'true';
|
||||
document.head.appendChild(script);
|
||||
script.onload = () => {
|
||||
console.log('Adsterra script loaded:', placement.key, `${placement.width}x${placement.height}`);
|
||||
};
|
||||
script.onerror = (error) => {
|
||||
console.warn('Adsterra script failed to load:', placement.key, error);
|
||||
};
|
||||
adContainer.value.appendChild(script);
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
window.clearTimeout(resizeTimer);
|
||||
resizeTimer = window.setTimeout(() => {
|
||||
const nextPlacement = selectPlacement();
|
||||
if (nextPlacement.key !== activePlacement.value.key) {
|
||||
renderAd();
|
||||
}
|
||||
}, 120);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!isEnabled.value) return;
|
||||
|
||||
ensureAdSenseScript();
|
||||
await nextTick();
|
||||
renderAd();
|
||||
window.addEventListener('resize', handleResize);
|
||||
});
|
||||
|
||||
try {
|
||||
// Avoid duplicate initialization on remount.
|
||||
if (adElement.value?.dataset.adsInitialized === 'true') return;
|
||||
(window.adsbygoogle = window.adsbygoogle || []).push({});
|
||||
if (adElement.value) {
|
||||
adElement.value.dataset.adsInitialized = 'true';
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('AdSense Banner konnte nicht initialisiert werden:', error);
|
||||
}
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
window.clearTimeout(resizeTimer);
|
||||
clearAdContainer();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header-ad-banner {
|
||||
flex: 0 0 auto;
|
||||
width: 320px;
|
||||
min-width: 320px;
|
||||
max-width: 320px;
|
||||
height: 50px;
|
||||
margin: 0 16px;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.header-ad-banner :deep(ins) {
|
||||
.ad-container {
|
||||
width: 100%;
|
||||
min-height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ad-container.is-mobile {
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.header-ad-banner {
|
||||
width: 300px;
|
||||
min-width: 300px;
|
||||
max-width: 300px;
|
||||
height: 50px;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.header-ad-banner :deep(ins) {
|
||||
width: 300px !important;
|
||||
height: 50px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.header-ad-banner {
|
||||
display: none;
|
||||
}
|
||||
.ad-container :deep(iframe) {
|
||||
border: 0;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="landing-login">
|
||||
<section class="landing-login-intro">
|
||||
<p class="landing-login-eyebrow">SingleChat</p>
|
||||
<p class="landing-login-eyebrow">YpChat</p>
|
||||
<h2>Direkt in den Chat</h2>
|
||||
<p class="landing-login-copy">
|
||||
Kompakt, schnell und ohne Umwege. Erstelle dein Profil und starte sofort eine Unterhaltung.
|
||||
@@ -11,6 +11,11 @@
|
||||
<span>Bildaustausch</span>
|
||||
<span>Kompakte Bedienung</span>
|
||||
</div>
|
||||
<nav class="landing-login-topic-links" aria-label="YpChat Themen">
|
||||
<router-link to="/kostenloser-single-chat">Kostenloser Single Chat</router-link>
|
||||
<router-link to="/single-chat-ohne-anmeldung">Ohne lange Anmeldung</router-link>
|
||||
<router-link to="/single-treff-chat">Single Treff Chat</router-link>
|
||||
</nav>
|
||||
<div class="welcome-message" v-html="$t('welcome')"></div>
|
||||
</section>
|
||||
|
||||
@@ -242,6 +247,24 @@ function handleSubmit() {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.landing-login-topic-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
|
||||
.landing-login-topic-links a {
|
||||
color: #245c3a;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.landing-login-topic-links a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.landing-login-card {
|
||||
padding: 24px;
|
||||
border-radius: 20px;
|
||||
|
||||
216
client/src/components/VideoDock.vue
Normal file
216
client/src/components/VideoDock.vue
Normal file
@@ -0,0 +1,216 @@
|
||||
<template>
|
||||
<aside v-if="chatStore.hasVideoSessions" class="video-dock">
|
||||
<section class="video-dock-card video-dock-card-self">
|
||||
<div class="video-card-frame video-card-frame-self">
|
||||
<video ref="selfVideoRef" autoplay muted playsinline></video>
|
||||
<div v-if="!chatStore.selfPreviewStream" class="video-card-placeholder">
|
||||
<strong>Eigene Vorschau</strong>
|
||||
<span>Kamera nicht aktiv</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="video-card-meta">
|
||||
<strong>Du</strong>
|
||||
<span>{{ chatStore.selfMuted ? 'Mikro aus' : 'Mikro an' }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-for="session in chatStore.dockVideoSessions"
|
||||
:key="session.callId"
|
||||
class="video-dock-card"
|
||||
>
|
||||
<div
|
||||
class="video-card-frame video-card-frame-clickable"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:title="`${session.withUserName} in den Vordergrund holen`"
|
||||
@click="chatStore.bringVideoSessionToFront(session.callId)"
|
||||
@keyup.enter="chatStore.bringVideoSessionToFront(session.callId)"
|
||||
@keyup.space.prevent="chatStore.bringVideoSessionToFront(session.callId)"
|
||||
>
|
||||
<VideoSessionSurface :session="session" :muted="true" />
|
||||
</div>
|
||||
<div class="video-card-meta">
|
||||
<strong>{{ session.withUserName }}</strong>
|
||||
<span>{{ session.remoteMuted ? 'Mikro aus' : 'Mikro an' }}</span>
|
||||
</div>
|
||||
<div class="video-card-actions">
|
||||
<button type="button" @click="chatStore.bringVideoSessionToFront(session.callId)">
|
||||
In den Vordergrund
|
||||
</button>
|
||||
<button
|
||||
v-if="session.status === 'ringing' && session.initiatedBy !== chatStore.userName"
|
||||
type="button"
|
||||
class="secondary"
|
||||
@click="chatStore.acceptVideoCall(session.callId)"
|
||||
>
|
||||
Annehmen
|
||||
</button>
|
||||
<button
|
||||
v-else-if="session.status === 'ringing' && session.initiatedBy === chatStore.userName"
|
||||
type="button"
|
||||
class="secondary"
|
||||
@click="chatStore.cancelVideoCall(session.callId)"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
v-else-if="session.status === 'connecting' || session.status === 'active'"
|
||||
type="button"
|
||||
class="secondary"
|
||||
@click="chatStore.endVideoCall(session.callId)"
|
||||
>
|
||||
Beenden
|
||||
</button>
|
||||
<button
|
||||
v-if="session.status === 'ringing' && session.initiatedBy !== chatStore.userName"
|
||||
type="button"
|
||||
class="danger"
|
||||
@click="chatStore.rejectVideoCall(session.callId)"
|
||||
>
|
||||
Ablehnen
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { useChatStore } from '../stores/chat';
|
||||
import VideoSessionSurface from './VideoSessionSurface.vue';
|
||||
|
||||
const chatStore = useChatStore();
|
||||
const selfVideoRef = ref(null);
|
||||
|
||||
watch(
|
||||
() => chatStore.selfPreviewStream,
|
||||
(stream) => {
|
||||
if (selfVideoRef.value) {
|
||||
selfVideoRef.value.srcObject = stream || null;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (selfVideoRef.value) {
|
||||
selfVideoRef.value.srcObject = null;
|
||||
}
|
||||
});
|
||||
|
||||
function statusLabel(status) {
|
||||
switch (status) {
|
||||
case 'ringing':
|
||||
return 'Klingelt';
|
||||
case 'connecting':
|
||||
return 'Verbindet';
|
||||
case 'active':
|
||||
return 'Aktiv';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.video-dock {
|
||||
width: min(16vw, 240px);
|
||||
min-width: 180px;
|
||||
max-width: 240px;
|
||||
padding: 12px;
|
||||
border-left: 1px solid #dfe6e1;
|
||||
background: linear-gradient(180deg, #f5f8f6 0%, #edf3ef 100%);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.video-dock-card {
|
||||
border: 1px solid #d5dfd8;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 20px rgba(25, 39, 31, 0.07);
|
||||
}
|
||||
|
||||
.video-card-frame {
|
||||
aspect-ratio: 16 / 10;
|
||||
background: #0e1511;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.video-card-frame-clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.video-card-frame video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.video-card-frame-self video {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.video-card-meta {
|
||||
padding: 10px 12px 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.video-card-meta strong {
|
||||
color: #1d2821;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.video-card-meta span {
|
||||
color: #59685e;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.video-card-actions {
|
||||
padding: 0 12px 12px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.video-card-actions button {
|
||||
min-height: 34px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: 0 10px;
|
||||
background: #1d6a42;
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.video-card-actions button.secondary {
|
||||
background: #edf2ee;
|
||||
color: #1d6a42;
|
||||
border: 1px solid #c9d7cd;
|
||||
}
|
||||
|
||||
.video-card-actions button.danger {
|
||||
background: #a23333;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.video-dock {
|
||||
width: 200px;
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.video-dock {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
102
client/src/components/VideoSessionSurface.vue
Normal file
102
client/src/components/VideoSessionSurface.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div class="video-surface">
|
||||
<video
|
||||
v-show="remoteStream"
|
||||
ref="videoRef"
|
||||
autoplay
|
||||
playsinline
|
||||
:muted="muted"
|
||||
></video>
|
||||
<div v-if="!remoteStream" class="video-surface-placeholder">
|
||||
<strong>{{ session.withUserName }}</strong>
|
||||
<span>{{ placeholderText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { useChatStore } from '../stores/chat';
|
||||
|
||||
const props = defineProps({
|
||||
session: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
muted: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const chatStore = useChatStore();
|
||||
const videoRef = ref(null);
|
||||
|
||||
const remoteStream = computed(() => chatStore.getRemoteStream(props.session.callId));
|
||||
const placeholderText = computed(() => {
|
||||
switch (props.session.status) {
|
||||
case 'ringing':
|
||||
return 'Klingelt';
|
||||
case 'connecting':
|
||||
return 'Verbindet';
|
||||
case 'active':
|
||||
return 'Warte auf Videobild';
|
||||
default:
|
||||
return props.session.status || 'Verbinde';
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
remoteStream,
|
||||
(stream) => {
|
||||
if (videoRef.value) {
|
||||
videoRef.value.srcObject = stream || null;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (videoRef.value) {
|
||||
videoRef.value.srcObject = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.video-surface {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
background: #09110d;
|
||||
}
|
||||
|
||||
.video-surface video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
background: #09110d;
|
||||
}
|
||||
|
||||
.video-surface-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #f7fff9;
|
||||
gap: 6px;
|
||||
text-align: center;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.video-surface-placeholder strong {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.video-surface-placeholder span {
|
||||
font-size: 13px;
|
||||
opacity: 0.84;
|
||||
}
|
||||
</style>
|
||||
@@ -8,6 +8,7 @@ import ja from './locales/ja.json';
|
||||
import zh from './locales/zh.json';
|
||||
import th from './locales/th.json';
|
||||
import tl from './locales/tl.json';
|
||||
import ceb from './locales/ceb.json';
|
||||
|
||||
const messages = {
|
||||
de,
|
||||
@@ -19,6 +20,7 @@ const messages = {
|
||||
zh,
|
||||
th,
|
||||
tl
|
||||
,ceb
|
||||
};
|
||||
|
||||
const i18n = createI18n({
|
||||
|
||||
44
client/src/i18n/locales/ceb.json
Normal file
44
client/src/i18n/locales/ceb.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"label_nick": "Palihug i-type ang imong palayaw para sa chat:",
|
||||
"label_gender": "Sekso:",
|
||||
"label_age": "Edad:",
|
||||
"label_country": "Nasod:",
|
||||
"button_start_chat": "Sugdi ang chat",
|
||||
"gender_female": "Babaye",
|
||||
"gender_male": "Lalaki",
|
||||
"gender_pair": "Magpares",
|
||||
"gender_trans_mf": "Transgender (L->B)",
|
||||
"gender_trans_fm": "Transgender (B->L)",
|
||||
"menu_leave": "Gawas",
|
||||
"menu_search": "Pangita",
|
||||
"menu_inbox": "Inbox",
|
||||
"menu_history": "Kasaysayan",
|
||||
"menu_in_chat_for": "Anaa sa chat sulod sa {0}",
|
||||
"menu_timeout_in": "Timeout sa {0}",
|
||||
"history_title": "<h2>Mga panag-istorya uban sa mga user nga naka-login na</h2>",
|
||||
"history_empty": "Walay daan nga panag-istorya nga magamit.",
|
||||
"logged_in_count": "Naka-login: {0}",
|
||||
"button_block_user": "I-block ang tiggamit",
|
||||
"button_unblock_user": "I-unblock ang tiggamit",
|
||||
"button_send": "Padala",
|
||||
"tooltip_send_image": "Ipadala ang usa ka litrato",
|
||||
"dialog_send_image_title": "Ipadala ang litrato sa tiggamit",
|
||||
"dialog_send_image_text": "Palihug pagpili og litrato",
|
||||
"dialog_send_image_ok": "Ipadala ang litrato",
|
||||
"dialog_send_image_cancel": "Kanselahon",
|
||||
"image_uploaded_processed": "Na-upload ug na-proseso ang litrato",
|
||||
"search_title": "<h2>Pangita</h2>",
|
||||
"search_username_includes": "Ang username naglakip",
|
||||
"search_from_age": "Gikan sa edad",
|
||||
"search_to_age": "Hangtod sa edad",
|
||||
"search_country": "Nasod",
|
||||
"search_country_tooltip": "Pili-a ang mga nasod nga imong pangitaon",
|
||||
"search_genders": "Mga sekso",
|
||||
"search_genders_tooltip": "Pili-a ang mga sekso nga imong pangitaon",
|
||||
"search_all": "Tanan",
|
||||
"search_button": "Pangitaa",
|
||||
"search_no_results": "Walay resulta.",
|
||||
"search_min_age_error": "Ang minimum nga edad kinahanglan dili mas dako kaysa maximum nga edad.",
|
||||
"welcome": "<main><header><h2>Maayong pag-abot sa among website — Ang imong piniliang destinasyon alang sa Chat, Single Chat, ug Pagpaambit og Litrato</h2></header><section><h3>Ngano nga pilion kami?</h3><ol><li><strong>Chat:</strong> Sulod sa among mga dinamikanhong chat room diin makig-istorya ka sa mga tawo gikan sa tibuok kalibutan. Bisan pa man nangita ka og kaswal nga estorya o seryosong koneksyon, ang among chat naghatag og hapsay ug malipayong kasinatian.</li><li><strong>Single Chat:</strong> Nangita ba ka og espesyal nga tawo? Ang among single chat nagtanyag og giya nga palibot para sa mga single nga mag-ila-ila, mag-flirt, ug posible makit-an ang ilang angay. Uban sa advanced nga mga filter sa pagpangita ug interactive nga mga feature, ang pag-ilaila og bag-ong mga tawo mas sayon karon.</li><li><strong>Image Exchange:</strong> Ipaambit ang imong mga hinumduman, mga gutlo, ug kasinatian nga walay sabod gamit ang among image exchange feature. Bisan pa mga litrato gikan sa imong bag-ong biyahe o adlaw-adlaw nga kuha, among gi-seguro ang luwas ug hapsay nga pagbahin.</li><li><strong>Privacy:</strong> Ang imong pribasiya among prayoridad. Nasabtan namo ang kahinungdanon sa konfidentialidad ug gisiguro nga ang tanan nimong interaksyon magpabiling pribado ug luwas. Uban sa lig-on nga mga setting sa pribasiya ug encryption protocols, makachat ka ug magpaambit og litrato nga malinawon ang hunahuna.</li><li><strong>Anonymous:</strong> Duawa ang pagka-anonymous sa among plataporma. Bisan gusto nimo nga dili ipadayag ang imong identidad o lingaw-lingaw lang, ang among anonymous feature nagtugot kanimo sa tinuod nga pag-apil samtang nagpabilin nga pribado.</li></ol></section><section><h3>Apil karon!</h3><p>Andam na ba ka mosugod sa imong pagpanukod ug koneksyon? Mag-sign up karon ug sulayi ang labing maayo nga chat, single chat, ug image exchange nga plataporma. Apil sa among buhi nga komunidad ug ablihi ang daghang mga posibilidad karon!</p></section></main>",
|
||||
"introduction": "<main><h2>Maayong pag-abot!</h2><p>Malipayong kami nga ikaw miapil sa among komunidad. Dinhi, ang pagkamatinud-anon, pagkamaabiabihon, ug pagtahod mao ang among giya nga mga prinsipyo.</p><p>Samtang naglibot ka, hinumdumi nga pagpakatao ug pagtratar sa uban uban sa kalooy. Dili namo tugutan ang insulto, pagpanlupig, o dili awtorisadong sulod.</p><p>Palihug hinumdomi nga ayaw pag-ambit sa personal nga impormasyon sama sa numero sa telepono, email address, adres sa balay, ug uban pa.</p><p>Himoon nato kini nga usa ka malipayong lugar diin ang tanan mobati nga bililhong ug luwas. Maayong pag-abot, ug lingawi ang imong panahon dinhi!</p></main>"
|
||||
}
|
||||
@@ -12,15 +12,16 @@ import GuideFirstMessageView from '../views/GuideFirstMessageView.vue';
|
||||
import GuideProfileView from '../views/GuideProfileView.vue';
|
||||
import GuideSafetyView from '../views/GuideSafetyView.vue';
|
||||
import GuideRedFlagsView from '../views/GuideRedFlagsView.vue';
|
||||
import SeoLandingView from '../views/SeoLandingView.vue';
|
||||
|
||||
const SITE_URL = 'https://www.ypchat.net';
|
||||
const DEFAULT_IMAGE = `${SITE_URL}/static/favicon.png`;
|
||||
const SUPPORTED_LOCALES = ['de', 'en', 'fr', 'es', 'it', 'ja', 'zh', 'th', 'tl'];
|
||||
const LOCALIZED_HOME_META = {
|
||||
de: {
|
||||
title: 'SingleChat: Kostenloser Single Chat, privat & anonym',
|
||||
description: 'Kostenloser Single Chat für private und anonyme Gespräche. Lerne neue Kontakte kennen und teile Bilder sicher online.',
|
||||
keywords: 'single chat, kostenloser chat, privat chatten, anonym chat, free chat, private chat, anonymous chat, online chat'
|
||||
title: 'SingleChat - Kostenloser Single Chat ohne Anmeldung',
|
||||
description: 'Kostenloser Single Chat ohne lange Registrierung: Profil starten, Singles kennenlernen, privat chatten und Bilder sicher austauschen.',
|
||||
keywords: 'single chat, kostenloser single chat, single chat ohne anmeldung, single treff chat, chatten für singles, online chat'
|
||||
},
|
||||
en: {
|
||||
title: 'SingleChat: Free Private & Anonymous Single Chat',
|
||||
@@ -68,11 +69,87 @@ const homeSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: 'SingleChat',
|
||||
alternateName: 'ypchat.net',
|
||||
url: `${SITE_URL}/`,
|
||||
description: 'Kostenloser Single Chat für private und anonyme Gespräche. Lerne neue Kontakte kennen und tausche Bilder sicher aus.',
|
||||
description: 'Kostenloser Single Chat ohne lange Registrierung. Lerne Singles kennen, chatte privat und tausche Bilder sicher aus.',
|
||||
inLanguage: 'de-DE'
|
||||
};
|
||||
|
||||
const landingPages = [
|
||||
{
|
||||
path: '/kostenloser-single-chat',
|
||||
name: 'kostenloser-single-chat',
|
||||
title: 'Kostenloser Single Chat - direkt online chatten',
|
||||
description: 'Starte kostenlos im Single Chat: Profil anlegen, Singles finden, privat schreiben und Bilder sicher austauschen.',
|
||||
keywords: 'kostenloser single chat, single chat kostenlos, gratis chat singles, kostenlos chatten',
|
||||
heading: 'Kostenloser Single Chat',
|
||||
intro: 'SingleChat ist fuer alle gedacht, die unkompliziert neue Kontakte finden und direkt online chatten moechten. Du startest mit einem kurzen Profil und kannst danach passende Singles suchen oder in der Lobby ins Gespraech kommen.',
|
||||
sections: [
|
||||
{
|
||||
title: 'Warum kostenlos starten?',
|
||||
text: 'Ein Single Chat funktioniert am besten, wenn der Einstieg niedrig bleibt. Deshalb steht der schnelle Start im Mittelpunkt: Nickname waehlen, Alter und Land angeben, Chat oeffnen.'
|
||||
},
|
||||
{
|
||||
title: 'Privat schreiben und Bilder teilen',
|
||||
text: 'Neben offenen Kontakten sind private Unterhaltungen moeglich. Bilder lassen sich im Chat austauschen, waehrend Regeln und Blockierfunktionen fuer einen respektvollen Rahmen sorgen.'
|
||||
}
|
||||
],
|
||||
links: [
|
||||
{ to: '/', label: 'Jetzt Single Chat starten' },
|
||||
{ to: '/sicherheit', label: 'Sicher chatten' },
|
||||
{ to: '/faq', label: 'FAQ lesen' }
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/single-chat-ohne-anmeldung',
|
||||
name: 'single-chat-ohne-anmeldung',
|
||||
title: 'Single Chat ohne Anmeldung - schnell und privat',
|
||||
description: 'Single Chat ohne lange Anmeldung: Nickname eingeben, Profil starten und direkt mit Singles online chatten.',
|
||||
keywords: 'single chat ohne anmeldung, chat ohne anmeldung, single chat sofort, anonym chatten',
|
||||
heading: 'Single Chat ohne Anmeldung',
|
||||
intro: 'Wenn du nicht erst ein langes Konto erstellen moechtest, passt SingleChat zu diesem Suchwunsch: wenige Angaben reichen fuer den Einstieg, danach kannst du sofort loslegen.',
|
||||
sections: [
|
||||
{
|
||||
title: 'Schneller Einstieg mit Nickname',
|
||||
text: 'Du brauchst keinen langen Registrierungsprozess. Ein Nickname und die noetigen Basisangaben genuegen, damit andere Nutzer dich im Chat einordnen koennen.'
|
||||
},
|
||||
{
|
||||
title: 'Anonym bleiben, respektvoll chatten',
|
||||
text: 'Nutze einen Nickname, der keine privaten Daten verraet. Teile Telefonnummer, Adresse oder Zahlungsdaten nicht im Chat und blockiere Kontakte, wenn Grenzen ueberschritten werden.'
|
||||
}
|
||||
],
|
||||
links: [
|
||||
{ to: '/', label: 'Ohne lange Anmeldung starten' },
|
||||
{ to: '/ratgeber/sicher-chatten', label: 'Datenschutz-Tipps' },
|
||||
{ to: '/regeln', label: 'Chat-Regeln' }
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/single-treff-chat',
|
||||
name: 'single-treff-chat',
|
||||
title: 'Single Treff Chat - neue Kontakte kennenlernen',
|
||||
description: 'Single Treff Chat fuer neue Kontakte: finde Singles, starte private Gespraeche und lerne Menschen online kennen.',
|
||||
keywords: 'single treff chat, single treff, singles kennenlernen chat, chatten fuer singles',
|
||||
heading: 'Single Treff Chat',
|
||||
intro: 'Der Single Treff Chat richtet sich an Nutzer, die online neue Menschen kennenlernen und aus ersten Nachrichten echte Gespraeche machen wollen.',
|
||||
sections: [
|
||||
{
|
||||
title: 'Kontakte finden statt endlos suchen',
|
||||
text: 'Mit Profilangaben wie Land, Alter und Geschlecht kannst du leichter passende Kontakte einschaetzen und Gespraeche beginnen, die mehr als nur Smalltalk sind.'
|
||||
},
|
||||
{
|
||||
title: 'Gute erste Nachrichten',
|
||||
text: 'Persoenliche, kurze Einstiege funktionieren besser als kopierte Standardsaetze. Im Ratgeber findest du Beispiele fuer natuerliche erste Nachrichten.'
|
||||
}
|
||||
],
|
||||
links: [
|
||||
{ to: '/', label: 'Single Treff Chat oeffnen' },
|
||||
{ to: '/ratgeber/erste-nachricht', label: 'Erste Nachricht verbessern' },
|
||||
{ to: '/ratgeber/profil-tipps', label: 'Profil verbessern' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const partnersSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
@@ -172,11 +249,11 @@ const routes = [
|
||||
name: 'chat',
|
||||
component: ChatView,
|
||||
meta: {
|
||||
title: 'SingleChat: Kostenloser Single Chat, privat & anonym',
|
||||
description: 'Kostenloser Single Chat für private und anonyme Gespräche. Lerne neue Kontakte kennen und teile Bilder sicher online.',
|
||||
keywords: 'single chat, kostenloser chat, privat chatten, anonym chat, free chat, private chat, anonymous chat, online chat',
|
||||
ogTitle: 'SingleChat: Kostenloser Single Chat, privat & anonym',
|
||||
ogDescription: 'Kostenlos chatten, privat bleiben und neue Kontakte kennenlernen - mit sicherem Bildaustausch.',
|
||||
title: 'SingleChat - Kostenloser Single Chat ohne Anmeldung',
|
||||
description: 'Kostenloser Single Chat ohne lange Registrierung: Profil starten, Singles kennenlernen, privat chatten und Bilder sicher austauschen.',
|
||||
keywords: 'single chat, kostenloser single chat, single chat ohne anmeldung, single treff chat, chatten für singles, online chat',
|
||||
ogTitle: 'SingleChat - Kostenloser Single Chat ohne Anmeldung',
|
||||
ogDescription: 'Kostenlos chatten, Singles kennenlernen und Bilder sicher austauschen.',
|
||||
ogType: 'website',
|
||||
image: DEFAULT_IMAGE,
|
||||
robots: 'index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1',
|
||||
@@ -270,7 +347,7 @@ const routes = [
|
||||
meta: {
|
||||
title: 'Datenschutzerklärung für Website und App - SingleChat',
|
||||
description: 'Datenschutzerklärung für SingleChat und die Android-App mit Informationen zu Profilangaben, Nachrichten, Bildern und Sitzungsdaten.',
|
||||
keywords: 'datenschutz singlechat, privacy policy chat app, chat datenschutz, android app datenschutz',
|
||||
keywords: 'datenschutz ypchat, privacy policy chat app, chat datenschutz, android app datenschutz',
|
||||
ogTitle: 'Datenschutzerklärung für Website und App - SingleChat',
|
||||
ogDescription: 'Informationen zur Datenverarbeitung bei SingleChat und in der Android-App.',
|
||||
ogType: 'website',
|
||||
@@ -377,6 +454,34 @@ const routes = [
|
||||
}
|
||||
];
|
||||
|
||||
for (const page of landingPages) {
|
||||
routes.push({
|
||||
path: page.path,
|
||||
name: page.name,
|
||||
component: SeoLandingView,
|
||||
meta: {
|
||||
title: `${page.title} - SingleChat`,
|
||||
description: page.description,
|
||||
keywords: page.keywords,
|
||||
ogTitle: `${page.title} - SingleChat`,
|
||||
ogDescription: page.description,
|
||||
ogType: 'website',
|
||||
image: DEFAULT_IMAGE,
|
||||
robots: 'index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1',
|
||||
landing: page,
|
||||
schema: {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
name: `${page.title} - SingleChat`,
|
||||
url: `${SITE_URL}${page.path}`,
|
||||
description: page.description,
|
||||
isPartOf: homeSchema,
|
||||
inLanguage: 'de-DE'
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
const localized = LOCALIZED_HOME_META[locale] || LOCALIZED_HOME_META.de;
|
||||
routes.push({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -633,7 +633,7 @@ a {
|
||||
background: linear-gradient(180deg, rgba(238, 245, 240, 0.92) 0%, rgba(247, 250, 248, 0.88) 100%);
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto auto auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
@@ -868,7 +868,7 @@ a {
|
||||
}
|
||||
|
||||
.chat-input-container {
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto auto auto;
|
||||
}
|
||||
|
||||
.chat-input-container button:not(.no-style) {
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<aside class="app-sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<div class="sidebar-brand-head">
|
||||
<span class="app-brand-mark sidebar-brand-mark" aria-hidden="true">S</span>
|
||||
<strong>SingleChat</strong>
|
||||
<span class="app-brand-mark sidebar-brand-mark" aria-hidden="true">Y</span>
|
||||
<strong>YpChat</strong>
|
||||
</div>
|
||||
<span>Online Chat</span>
|
||||
</div>
|
||||
@@ -51,7 +51,7 @@
|
||||
<span class="profile-avatar">{{ userInitials }}</span>
|
||||
<span>
|
||||
<strong>{{ chatStore.userName }}</strong>
|
||||
<small>{{ chatStore.country || 'SingleChat Member' }}</small>
|
||||
<small>{{ chatStore.country || 'YpChat Member' }}</small>
|
||||
</span>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -108,24 +108,57 @@
|
||||
<span v-if="currentUserInfo">{{ currentUserInfo.age }} · {{ currentUserInfo.gender }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-header-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="video-toggle-button"
|
||||
:class="{ 'is-active': chatStore.videoConsent.localConsent }"
|
||||
@click="chatStore.setVideoConsent(!chatStore.videoConsent.localConsent)"
|
||||
>
|
||||
{{ chatStore.videoConsent.localConsent ? 'Video erlaubt' : 'Video erlauben' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="chatStore.videoConsent.videoVisible"
|
||||
type="button"
|
||||
class="video-call-button"
|
||||
:disabled="!chatStore.canStartVideoCall"
|
||||
@click="chatStore.inviteVideoCall()"
|
||||
>
|
||||
Videochat öffnen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="chatStore.currentConversation" class="chat-video-status">
|
||||
<span>
|
||||
{{ chatStore.videoConsent.remoteConsent ? 'Partner hat Video freigegeben' : 'Partner hat Video noch nicht freigegeben' }}
|
||||
</span>
|
||||
<span v-if="chatStore.maxVideoConnectionsReached" class="chat-video-status-error">
|
||||
Maximal drei Videoverbindungen gleichzeitig erlaubt
|
||||
</span>
|
||||
<span v-else-if="chatStore.currentConversationVideoSession">
|
||||
{{ videoStatusLabel(chatStore.currentConversationVideoSession.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<HeaderAdBanner v-if="chatStore.currentConversation" />
|
||||
<ChatWindow />
|
||||
</div>
|
||||
<ChatInput />
|
||||
</div>
|
||||
</div>
|
||||
<VideoDock />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<FloatingVideoWindow />
|
||||
<ImprintContainer />
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<header class="header">
|
||||
<div class="app-brand">
|
||||
<span class="app-brand-mark">S</span>
|
||||
<span class="app-brand-mark">Y</span>
|
||||
<div class="app-brand-copy">
|
||||
<span class="app-brand-eyebrow">SingleChat</span>
|
||||
<span class="app-brand-eyebrow">YpChat</span>
|
||||
<h1>Chat</h1>
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,16 +189,18 @@ import InboxView from '../components/InboxView.vue';
|
||||
import HistoryView from '../components/HistoryView.vue';
|
||||
import ImprintContainer from '../components/ImprintContainer.vue';
|
||||
import HeaderAdBanner from '../components/HeaderAdBanner.vue';
|
||||
import VideoDock from '../components/VideoDock.vue';
|
||||
import FloatingVideoWindow from '../components/FloatingVideoWindow.vue';
|
||||
|
||||
const chatStore = useChatStore();
|
||||
|
||||
const userInitials = computed(() => {
|
||||
return (chatStore.userName || 'SC')
|
||||
return (chatStore.userName || 'YP')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map(part => part.charAt(0).toUpperCase())
|
||||
.join('') || 'SC';
|
||||
.join('') || 'YP';
|
||||
});
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
@@ -201,6 +236,19 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function videoStatusLabel(status) {
|
||||
switch (status) {
|
||||
case 'ringing':
|
||||
return 'Videoanruf klingelt';
|
||||
case 'connecting':
|
||||
return 'Videoanruf verbindet';
|
||||
case 'active':
|
||||
return 'Videoanruf aktiv';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -357,4 +405,92 @@ onMounted(async () => {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.horizontal-box-app {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.chat-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chat-header-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-header-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.chat-header-actions button {
|
||||
min-height: 38px;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
padding: 0 14px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.video-toggle-button {
|
||||
background: #edf2ee;
|
||||
color: #265437;
|
||||
border: 1px solid #c8d6cd;
|
||||
}
|
||||
|
||||
.video-toggle-button.is-active {
|
||||
background: #dff0e5;
|
||||
color: #1c6037;
|
||||
}
|
||||
|
||||
.video-call-button {
|
||||
background: #1d6a42;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.video-call-button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.chat-video-status {
|
||||
margin: 10px 0 14px;
|
||||
border: 1px solid #d8e0da;
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
background: #f8fbf9;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 16px;
|
||||
color: #516257;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chat-video-status-error {
|
||||
color: #9f2c2c;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.chat-header {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chat-header-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<router-link to="/" class="app-brand app-brand-link">
|
||||
<span class="app-brand-mark">S</span>
|
||||
<div class="app-brand-copy">
|
||||
<span class="app-brand-eyebrow">SingleChat</span>
|
||||
<span class="app-brand-eyebrow">YpChat</span>
|
||||
<h1>FAQ</h1>
|
||||
</div>
|
||||
</router-link>
|
||||
@@ -90,4 +90,3 @@ import ImprintContainer from '../components/ImprintContainer.vue';
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<router-link to="/" class="app-brand app-brand-link">
|
||||
<span class="app-brand-mark">S</span>
|
||||
<div class="app-brand-copy">
|
||||
<span class="app-brand-eyebrow">SingleChat</span>
|
||||
<span class="app-brand-eyebrow">YpChat</span>
|
||||
<h1>Feedback</h1>
|
||||
</div>
|
||||
</router-link>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<router-link to="/" class="app-brand app-brand-link">
|
||||
<span class="app-brand-mark">S</span>
|
||||
<div class="app-brand-copy">
|
||||
<span class="app-brand-eyebrow">SingleChat</span>
|
||||
<span class="app-brand-eyebrow">YpChat</span>
|
||||
<h1>Ratgeber</h1>
|
||||
</div>
|
||||
</router-link>
|
||||
@@ -77,7 +77,7 @@
|
||||
</p>
|
||||
|
||||
<p class="content-meta">
|
||||
Redaktion: SingleChat Team · Zuletzt aktualisiert: 07.05.2026
|
||||
Redaktion: YpChat Team · Zuletzt aktualisiert: 07.05.2026
|
||||
</p>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<router-link to="/" class="app-brand app-brand-link">
|
||||
<span class="app-brand-mark">S</span>
|
||||
<div class="app-brand-copy">
|
||||
<span class="app-brand-eyebrow">SingleChat</span>
|
||||
<span class="app-brand-eyebrow">YpChat</span>
|
||||
<h1>Ratgeber</h1>
|
||||
</div>
|
||||
</router-link>
|
||||
@@ -15,7 +15,7 @@
|
||||
<h2>Ratgeber: Sicher und entspannt chatten</h2>
|
||||
<p>
|
||||
In unserem Ratgeber findest du praxisnahe Tipps rund um privaten Chat, Profilgestaltung und digitale Sicherheit.
|
||||
Alle Inhalte sind speziell fuer SingleChat-Nutzer geschrieben und werden laufend erweitert.
|
||||
Alle Inhalte sind speziell fuer YpChat-Nutzer geschrieben und werden laufend erweitert.
|
||||
</p>
|
||||
<p>
|
||||
Wenn du neu im Chat bist, starte am besten mit dem Leitfaden zur ersten Unterhaltung und arbeite dich dann zu
|
||||
@@ -77,7 +77,7 @@
|
||||
</section>
|
||||
|
||||
<p class="content-meta">
|
||||
Redaktion: SingleChat Team · Zuletzt aktualisiert: 07.05.2026
|
||||
Redaktion: YpChat Team · Zuletzt aktualisiert: 07.05.2026
|
||||
</p>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<router-link to="/" class="app-brand app-brand-link">
|
||||
<span class="app-brand-mark">S</span>
|
||||
<div class="app-brand-copy">
|
||||
<span class="app-brand-eyebrow">SingleChat</span>
|
||||
<span class="app-brand-eyebrow">YpChat</span>
|
||||
<h1>Ratgeber</h1>
|
||||
</div>
|
||||
</router-link>
|
||||
@@ -76,7 +76,7 @@
|
||||
</p>
|
||||
|
||||
<p class="content-meta">
|
||||
Redaktion: SingleChat Team · Zuletzt aktualisiert: 07.05.2026
|
||||
Redaktion: YpChat Team · Zuletzt aktualisiert: 07.05.2026
|
||||
</p>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<router-link to="/" class="app-brand app-brand-link">
|
||||
<span class="app-brand-mark">S</span>
|
||||
<div class="app-brand-copy">
|
||||
<span class="app-brand-eyebrow">SingleChat</span>
|
||||
<span class="app-brand-eyebrow">YpChat</span>
|
||||
<h1>Ratgeber</h1>
|
||||
</div>
|
||||
</router-link>
|
||||
@@ -63,7 +63,7 @@
|
||||
</p>
|
||||
|
||||
<p class="content-meta">
|
||||
Redaktion: SingleChat Team · Zuletzt aktualisiert: 07.05.2026
|
||||
Redaktion: YpChat Team · Zuletzt aktualisiert: 07.05.2026
|
||||
</p>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<router-link to="/" class="app-brand app-brand-link">
|
||||
<span class="app-brand-mark">S</span>
|
||||
<div class="app-brand-copy">
|
||||
<span class="app-brand-eyebrow">SingleChat</span>
|
||||
<span class="app-brand-eyebrow">YpChat</span>
|
||||
<h1>Ratgeber</h1>
|
||||
</div>
|
||||
</router-link>
|
||||
@@ -63,7 +63,7 @@
|
||||
</p>
|
||||
|
||||
<p class="content-meta">
|
||||
Redaktion: SingleChat Team · Zuletzt aktualisiert: 07.05.2026
|
||||
Redaktion: YpChat Team · Zuletzt aktualisiert: 07.05.2026
|
||||
</p>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="mockup-page">
|
||||
<header class="mockup-page-header">
|
||||
<div>
|
||||
<p class="mockup-page-eyebrow">SingleChat Redesign</p>
|
||||
<p class="mockup-page-eyebrow">YpChat Redesign</p>
|
||||
<h1>Mockup-Vergleich</h1>
|
||||
</div>
|
||||
<p class="mockup-page-copy">
|
||||
@@ -28,7 +28,7 @@
|
||||
<div class="mockup-brand-mark">S</div>
|
||||
<div>
|
||||
<p class="mockup-eyebrow">Design Preview</p>
|
||||
<h3>SingleChat</h3>
|
||||
<h3>YpChat</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -148,7 +148,7 @@
|
||||
|
||||
<div class="mockup-mobile-device mockup-mobile-device-polished">
|
||||
<div class="mockup-mobile-top">
|
||||
<span>SingleChat</span>
|
||||
<span>YpChat</span>
|
||||
<span class="mockup-mobile-pill">3</span>
|
||||
</div>
|
||||
<div class="mockup-mobile-chat-header">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<router-link to="/" class="app-brand app-brand-link">
|
||||
<span class="app-brand-mark">S</span>
|
||||
<div class="app-brand-copy">
|
||||
<span class="app-brand-eyebrow">SingleChat</span>
|
||||
<span class="app-brand-eyebrow">YpChat</span>
|
||||
<h1>Partner</h1>
|
||||
</div>
|
||||
</router-link>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<router-link to="/" class="app-brand app-brand-link">
|
||||
<span class="app-brand-mark">S</span>
|
||||
<div class="app-brand-copy">
|
||||
<span class="app-brand-eyebrow">SingleChat</span>
|
||||
<span class="app-brand-eyebrow">YpChat</span>
|
||||
<h1>Datenschutz</h1>
|
||||
</div>
|
||||
</router-link>
|
||||
@@ -14,9 +14,14 @@
|
||||
<main class="content-page">
|
||||
<h2>Datenschutzerklärung für Website und App</h2>
|
||||
<p>
|
||||
Diese Datenschutzerklärung gilt für die Website und die Android-App von SingleChat beziehungsweise YPChat unter
|
||||
Diese Datenschutzerklärung gilt für die Website und die Android-App von YpChat unter
|
||||
der Domain <strong>www.ypchat.net</strong>.
|
||||
</p>
|
||||
<p>
|
||||
Sie beschreibt die Verarbeitung personenbezogener Daten im Zusammenhang mit der Nutzung der
|
||||
Chat-Funktionen, der Bildfreigabe, von Feedback-Meldungen und der technisch notwendigen
|
||||
Sitzungsverwaltung.
|
||||
</p>
|
||||
|
||||
<h3>1. Verantwortlicher</h3>
|
||||
<p>
|
||||
@@ -48,59 +53,88 @@
|
||||
<li>Bearbeitung von Feedback und Missbrauchshinweisen</li>
|
||||
</ul>
|
||||
|
||||
<h3>4. Chat-Nachrichten und Profilangaben</h3>
|
||||
<h3>4. Rechtsgrundlagen</h3>
|
||||
<p>
|
||||
Soweit personenbezogene Daten verarbeitet werden, erfolgt dies in der Regel zur Erfüllung
|
||||
der angeforderten Chat-Funktionen und auf Grundlage berechtigter Interessen an einem
|
||||
sicheren, stabilen und missbrauchsarmen Betrieb des Angebots.
|
||||
</p>
|
||||
|
||||
<h3>5. Chat-Nachrichten und Profilangaben</h3>
|
||||
<p>
|
||||
Wenn du den Dienst nutzt, werden von dir eingegebene Profilangaben wie Nickname, Alter, Geschlecht und Land für
|
||||
die Chat-Funktion verwendet. Chat-Nachrichten werden technisch verarbeitet, damit Unterhaltungen in Echtzeit
|
||||
zugestellt werden können.
|
||||
</p>
|
||||
|
||||
<h3>5. Bilder</h3>
|
||||
<h3>6. Bilder und Kamerazugriff</h3>
|
||||
<p>
|
||||
Bilder werden nur verarbeitet, wenn du sie aktiv auswählst und hochlädst. Nach aktuellem Systemstand werden
|
||||
hochgeladene Bilder serverseitig temporär gespeichert und nach Ablauf einer begrenzten Zeit wieder entfernt.
|
||||
</p>
|
||||
<p>
|
||||
Die Android-App fordert die Kameraberechtigung nur an, wenn du in der App aktiv ein Foto aufnehmen möchtest.
|
||||
Ohne deine Auslösung erfolgt kein Kamerazugriff.
|
||||
</p>
|
||||
|
||||
<h3>6. Sitzungen, Cookies und technische Protokolle</h3>
|
||||
<h3>7. Sitzungen, Cookies und technische Protokolle</h3>
|
||||
<p>
|
||||
Für den Betrieb des Dienstes werden Sitzungsdaten verwendet. Dazu gehören insbesondere technisch notwendige
|
||||
Session-Informationen, damit ein Login erhalten bleibt und Socket- sowie API-Anfragen korrekt zugeordnet werden
|
||||
können. Zusätzlich können im Rahmen des Serverbetriebs technische Protokolldaten anfallen.
|
||||
</p>
|
||||
|
||||
<h3>7. Feedback und Missbrauchsmeldungen</h3>
|
||||
<h3>8. Feedback und Missbrauchsmeldungen</h3>
|
||||
<p>
|
||||
Wenn du Feedback sendest, werden die von dir eingetragenen Inhalte verarbeitet, um Hinweise, Fehlermeldungen oder
|
||||
Missbrauchsmeldungen zu bearbeiten.
|
||||
</p>
|
||||
|
||||
<h3>8. Weitergabe an Dritte</h3>
|
||||
<h3>9. Weitergabe an Dritte</h3>
|
||||
<p>
|
||||
Eine Weitergabe personenbezogener Daten an Dritte erfolgt nicht zu Werbezwecken. Soweit externe technische
|
||||
Dienstleister oder Hosting-Anbieter eingebunden sind, kann eine Verarbeitung im Rahmen des technischen Betriebs
|
||||
erforderlich sein.
|
||||
</p>
|
||||
|
||||
<h3>9. Verschlüsselung</h3>
|
||||
<h3>10. Werbung, Standort und weitere sensible Daten</h3>
|
||||
<p>
|
||||
Die Android-App verwendet nach aktuellem Stand kein Werbe-SDK und verarbeitet keine Standortdaten, Kontaktlisten,
|
||||
Gesundheitsdaten oder Zahlungsdaten. Solche Daten werden weder angefordert noch fuer die Kernfunktion des Chats
|
||||
benoetigt.
|
||||
</p>
|
||||
|
||||
<h3>11. Verschlüsselung</h3>
|
||||
<p>
|
||||
Die produktive Bereitstellung der Website und der App erfolgt über verschlüsselte Verbindungen, damit Daten bei der
|
||||
Übertragung geschützt sind.
|
||||
</p>
|
||||
|
||||
<h3>10. Deine Rechte</h3>
|
||||
<h3>12. Speicherdauer</h3>
|
||||
<p>
|
||||
Personenbezogene Daten werden nicht länger gespeichert, als es für den technischen Betrieb, die Bereitstellung der
|
||||
Funktionen und die Bearbeitung von Missbrauchs- oder Supportanfragen erforderlich ist. Bilder sind für eine
|
||||
begrenzte Verfügbarkeit im Chat gedacht und werden nicht dauerhaft als öffentliches Archiv bereitgestellt.
|
||||
</p>
|
||||
|
||||
<h3>13. Deine Rechte</h3>
|
||||
<p>
|
||||
Du hast im Rahmen der gesetzlichen Vorschriften insbesondere das Recht auf Auskunft, Berichtigung, Löschung,
|
||||
Einschränkung der Verarbeitung sowie Beschwerde bei einer zuständigen Aufsichtsbehörde.
|
||||
</p>
|
||||
|
||||
<h3>11. Kontakt zum Datenschutz</h3>
|
||||
<h3>14. Kontakt zum Datenschutz</h3>
|
||||
<p>
|
||||
Bei Fragen zum Datenschutz oder wenn du eine datenschutzbezogene Anfrage stellen möchtest, kontaktiere bitte:
|
||||
<a href="mailto:tsschulz@tsschulz.de">tsschulz@tsschulz.de</a>.
|
||||
</p>
|
||||
<p>
|
||||
Wenn du die Löschung von Daten anfragen möchtest, teile bitte den verwendeten Nickname, den ungefähren Zeitraum der
|
||||
Nutzung und - soweit vorhanden - weitere zur Zuordnung notwendige Angaben mit.
|
||||
</p>
|
||||
|
||||
<h3>12. Stand</h3>
|
||||
<p>Stand dieser Datenschutzerklärung: 22. April 2026</p>
|
||||
<h3>15. Stand</h3>
|
||||
<p>Stand dieser Datenschutzerklärung: 16. Juni 2026</p>
|
||||
</main>
|
||||
|
||||
<ImprintContainer />
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<router-link to="/" class="app-brand app-brand-link">
|
||||
<span class="app-brand-mark">S</span>
|
||||
<div class="app-brand-copy">
|
||||
<span class="app-brand-eyebrow">SingleChat</span>
|
||||
<span class="app-brand-eyebrow">YpChat</span>
|
||||
<h1>Regeln</h1>
|
||||
</div>
|
||||
</router-link>
|
||||
@@ -85,4 +85,3 @@ import ImprintContainer from '../components/ImprintContainer.vue';
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<router-link to="/" class="app-brand app-brand-link">
|
||||
<span class="app-brand-mark">S</span>
|
||||
<div class="app-brand-copy">
|
||||
<span class="app-brand-eyebrow">SingleChat</span>
|
||||
<span class="app-brand-eyebrow">YpChat</span>
|
||||
<h1>Sicherheit</h1>
|
||||
</div>
|
||||
</router-link>
|
||||
@@ -14,7 +14,7 @@
|
||||
<main class="content-page">
|
||||
<h2>Sicherheit & Privatsphäre</h2>
|
||||
<p>
|
||||
SingleChat ist auf schnellen Einstieg ausgelegt – trotzdem ist uns Sicherheit wichtig. Diese Hinweise helfen dir,
|
||||
YpChat ist auf schnellen Einstieg ausgelegt – trotzdem ist uns Sicherheit wichtig. Diese Hinweise helfen dir,
|
||||
im privaten und anonymen Chat deine Privatsphäre zu schützen und gute Entscheidungen zu treffen.
|
||||
</p>
|
||||
|
||||
|
||||
177
client/src/views/SeoLandingView.vue
Normal file
177
client/src/views/SeoLandingView.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<div class="chat-container">
|
||||
<header class="header">
|
||||
<router-link to="/" class="app-brand app-brand-link">
|
||||
<span class="app-brand-mark">S</span>
|
||||
<div class="app-brand-copy">
|
||||
<span class="app-brand-eyebrow">YpChat</span>
|
||||
<h1>{{ landing.heading }}</h1>
|
||||
</div>
|
||||
</router-link>
|
||||
<HeaderAdBanner />
|
||||
</header>
|
||||
|
||||
<main class="content-page">
|
||||
<section class="landing-hero">
|
||||
<p class="eyebrow">YPChat</p>
|
||||
<h2>{{ landing.heading }}</h2>
|
||||
<p>{{ landing.intro }}</p>
|
||||
<div class="action-row">
|
||||
<router-link
|
||||
v-for="link in landing.links"
|
||||
:key="link.to"
|
||||
:to="link.to"
|
||||
>
|
||||
{{ link.label }}
|
||||
</router-link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="topic-grid" aria-label="YpChat Vorteile">
|
||||
<article
|
||||
v-for="section in landing.sections"
|
||||
:key="section.title"
|
||||
class="topic-card"
|
||||
>
|
||||
<h3>{{ section.title }}</h3>
|
||||
<p>{{ section.text }}</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="support-section">
|
||||
<h3>Passende Themen im Single Chat</h3>
|
||||
<p>
|
||||
Viele Nutzer suchen nach einem kostenlosen Single Chat, einem Chat ohne lange Anmeldung oder einem Single Treff
|
||||
fuer neue Kontakte. YpChat buendelt diese Einstiege auf einer Plattform: schnell starten, privat schreiben,
|
||||
respektvoll bleiben.
|
||||
</p>
|
||||
<ul>
|
||||
<li><router-link to="/kostenloser-single-chat">Kostenloser Single Chat</router-link></li>
|
||||
<li><router-link to="/single-chat-ohne-anmeldung">Single Chat ohne Anmeldung</router-link></li>
|
||||
<li><router-link to="/single-treff-chat">Single Treff Chat</router-link></li>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<ImprintContainer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import HeaderAdBanner from '../components/HeaderAdBanner.vue';
|
||||
import ImprintContainer from '../components/ImprintContainer.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const landing = computed(() => route.meta.landing || {
|
||||
heading: 'YpChat',
|
||||
intro: 'Kostenloser Single Chat fuer private Gespraeche und neue Kontakte.',
|
||||
sections: [],
|
||||
links: [{ to: '/', label: 'Chat starten' }]
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-page {
|
||||
max-width: 1020px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 14px 36px;
|
||||
line-height: 1.6;
|
||||
color: #344038;
|
||||
}
|
||||
|
||||
.landing-hero,
|
||||
.support-section {
|
||||
border: 1px solid #d7dfd9;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 6px;
|
||||
color: #637067;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.landing-hero h2 {
|
||||
margin: 0 0 10px;
|
||||
color: #18201b;
|
||||
}
|
||||
|
||||
.landing-hero p {
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.action-row a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 38px;
|
||||
padding: 0 14px;
|
||||
border-radius: 8px;
|
||||
background: #245c3a;
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.action-row a:not(:first-child) {
|
||||
background: #edf4ef;
|
||||
color: #245c3a;
|
||||
border: 1px solid #bfd5c4;
|
||||
}
|
||||
|
||||
.topic-grid {
|
||||
margin: 16px 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.topic-card {
|
||||
border: 1px solid #d7dfd9;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.topic-card h3,
|
||||
.support-section h3 {
|
||||
margin: 0 0 8px;
|
||||
color: #18201b;
|
||||
}
|
||||
|
||||
.topic-card p,
|
||||
.support-section p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.support-section ul {
|
||||
margin: 12px 0 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.support-section a {
|
||||
color: #245c3a;
|
||||
}
|
||||
|
||||
.app-brand-link {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.topic-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
# SingleChat Deployment nach /opt/ypchat
|
||||
# YpChat Deployment nach /opt/ypchat
|
||||
# Dieses Skript kopiert die Anwendung nach /opt/ypchat und installiert sie dort
|
||||
|
||||
set -e
|
||||
@@ -9,9 +9,11 @@ SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TARGET_DIR="/opt/ypchat"
|
||||
USER="www-data"
|
||||
GROUP="www-data"
|
||||
ENV_TEMPLATE="$SOURCE_DIR/.env.example"
|
||||
ENV_MERGE_SCRIPT="$SOURCE_DIR/scripts/merge-env-template.sh"
|
||||
|
||||
echo "=========================================="
|
||||
echo "SingleChat Deployment nach /opt/ypchat"
|
||||
echo "YpChat Deployment nach /opt/ypchat"
|
||||
echo "=========================================="
|
||||
|
||||
# Prüfe ob als root ausgeführt
|
||||
@@ -54,6 +56,15 @@ rsync -av --progress \
|
||||
|
||||
echo "✓ Dateien kopiert"
|
||||
|
||||
if [ ! -f "$ENV_TEMPLATE" ]; then
|
||||
echo "FEHLER: Env-Vorlage fehlt: $ENV_TEMPLATE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -x "$ENV_MERGE_SCRIPT" ]; then
|
||||
chmod +x "$ENV_MERGE_SCRIPT"
|
||||
fi
|
||||
|
||||
# Setze Besitzer
|
||||
echo "Setze Besitzer auf $USER:$GROUP..."
|
||||
chown -R $USER:$GROUP "$TARGET_DIR"
|
||||
@@ -111,22 +122,18 @@ chown -R $USER:$GROUP "$TARGET_DIR/docroot/dist"
|
||||
|
||||
echo "✓ Dateien kopiert"
|
||||
|
||||
# Erstelle .env Datei falls nicht vorhanden
|
||||
if [ ! -f "$TARGET_DIR/.env" ]; then
|
||||
echo ""
|
||||
echo "Erstelle .env Datei..."
|
||||
SESSION_SECRET=$(openssl rand -hex 32)
|
||||
cat > "$TARGET_DIR/.env" << EOF
|
||||
NODE_ENV=production
|
||||
PORT=4000
|
||||
SESSION_SECRET=$SESSION_SECRET
|
||||
EOF
|
||||
chown $USER:$GROUP "$TARGET_DIR/.env"
|
||||
echo "✓ .env Datei erstellt"
|
||||
echo "SESSION_SECRET wurde generiert: $SESSION_SECRET"
|
||||
else
|
||||
echo "✓ .env Datei existiert bereits"
|
||||
echo ""
|
||||
echo "Synchronisiere .env Datei mit Vorlage..."
|
||||
SESSION_SECRET="$(openssl rand -hex 32)"
|
||||
if [ -f "$TARGET_DIR/.env" ]; then
|
||||
ENV_BACKUP_PATH="$TARGET_DIR/.env.bak"
|
||||
cp -a "$TARGET_DIR/.env" "$ENV_BACKUP_PATH"
|
||||
echo "✓ Backup der bisherigen .env erstellt: $ENV_BACKUP_PATH"
|
||||
fi
|
||||
"$ENV_MERGE_SCRIPT" "$TARGET_DIR/.env.example" "$TARGET_DIR/.env" "$SESSION_SECRET"
|
||||
chown $USER:$GROUP "$TARGET_DIR/.env"
|
||||
chmod 640 "$TARGET_DIR/.env"
|
||||
echo "✓ .env Datei synchronisiert (bestehende Werte beibehalten)"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
@@ -145,4 +152,3 @@ echo "3. Starte den neuen Service:"
|
||||
echo " sudo systemctl start ypchat"
|
||||
echo " sudo systemctl status ypchat"
|
||||
echo ""
|
||||
|
||||
|
||||
6
docroot/sw.js
Normal file
6
docroot/sw.js
Normal file
@@ -0,0 +1,6 @@
|
||||
self.options = {
|
||||
"domain": "3nbf4.com",
|
||||
"zoneId": 11023587
|
||||
}
|
||||
self.lary = ""
|
||||
importScripts('https://3nbf4.com/act/files/service-worker.min.js?r=sw')
|
||||
64
docroot/text_ceb_PH.xml
Normal file
64
docroot/text_ceb_PH.xml
Normal file
@@ -0,0 +1,64 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<messages>
|
||||
<message id="label_nick">Palihug i-type ang imong palayaw para sa chat:</message>
|
||||
<message id="label_gender">Sekso:</message>
|
||||
<message id="label_age">Edad:</message>
|
||||
<message id="label_country">Nasod:</message>
|
||||
<message id="button_start_chat">Sugdi ang chat</message>
|
||||
<message id="gender_female">Babaye</message>
|
||||
<message id="gender_male">Lalaki</message>
|
||||
<message id="gender_pair">Magpares</message>
|
||||
<message id="gender_trans_mf">Transgender (L->B)</message>
|
||||
<message id="gender_trans_fm">Transgender (B->L)</message>
|
||||
<message id="menu_leave">Gawas</message>
|
||||
<message id="menu_search">Pangita</message>
|
||||
<message id="menu_inbox">Inbox</message>
|
||||
<message id="menu_history">Kasaysayan</message>
|
||||
<message id="menu_in_chat_for">Anaa sa chat sulod sa {1}</message>
|
||||
<message id="menu_timeout_in">Timeout sa {1}</message>
|
||||
<message id="history_title"><![CDATA[<h2>Mga panag-istorya uban sa mga user nga naka-login na</h2>]]></message>
|
||||
<message id="history_empty">Walay daan nga panag-istorya nga magamit.</message>
|
||||
<message id="logged_in_count">Naka-login: {1}</message>
|
||||
<message id="search_title"><![CDATA[<h2>Pangita</h2>]]></message>
|
||||
<message id="search_username_includes">Ang username naglakip</message>
|
||||
<message id="search_from_age">Gikan sa edad</message>
|
||||
<message id="search_to_age">Hangtod sa edad</message>
|
||||
<message id="search_country">Nasod</message>
|
||||
<message id="search_country_tooltip">Pili-a ang mga nasod nga imong pangitaon</message>
|
||||
<message id="search_genders">Mga sekso</message>
|
||||
<message id="search_genders_tooltip">Pili-a ang mga sekso nga imong pangitaon</message>
|
||||
<message id="search_all">Tanan</message>
|
||||
<message id="search_button">Pangitaa</message>
|
||||
<message id="search_no_results">Walay resulta.</message>
|
||||
<message id="search_min_age_error">Ang minimum nga edad kinahanglan dili mas dako kaysa maximum nga edad.</message>
|
||||
<message id="welcome"><![CDATA[
|
||||
<main>
|
||||
<header>
|
||||
<h2>Maayong pag-abot sa among website — Ang imong piniliang destinasyon alang sa Chat, Single Chat, ug Pagpaambit og Litrato</h2>
|
||||
</header>
|
||||
<section>
|
||||
<h3>Ngano nga pilion kami?</h3>
|
||||
<ol>
|
||||
<li><strong>Chat:</strong> Sulod sa among mga dinamikanhong chat room diin makig-istorya ka sa mga tawo gikan sa tibuok kalibutan. Bisan pa man nangita ka og kaswal nga estorya o seryosong koneksyon, ang among chat naghatag og hapsay ug malipayong kasinatian.</li>
|
||||
<li><strong>Single Chat:</strong> Nangita ba ka og espesyal nga tawo? Ang among single chat nagtanyag og giya nga palibot para sa mga single nga mag-ila-ila, mag-flirt, ug posible makit-an ang ilang angay.</li>
|
||||
<li><strong>Image Exchange:</strong> Ipaambit ang imong mga hinumduman, mga gutlo, ug kasinatian nga walay sabod gamit ang among image exchange feature. Bisan pa mga litrato gikan sa imong bag-ong biyahe o adlaw-adlaw nga kuha, among gi-seguro ang luwas ug hapsay nga pagbahin.</li>
|
||||
<li><strong>Privacy:</strong> Ang imong pribasiya among prayoridad. Nasabtan namo ang kahinungdanon sa konfidentialidad ug gisiguro nga ang tanan nimong interaksyon magpabiling pribado ug luwas.</li>
|
||||
<li><strong>Anonymous:</strong> Duawa ang pagka-anonymous sa among plataporma. Bisan gusto nimo nga dili ipadayag ang imong identidad, ang among anonymous feature nagtugot kanimo sa tinuod nga pag-apil samtang nagpabilin nga pribado.</li>
|
||||
</ol>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Apil karon!</h3>
|
||||
<p>Andam na ba ka mosugod sa imong pagpanukod ug koneksyon? Mag-sign up karon ug sulayi ang labing maayo nga chat, single chat, ug image exchange nga plataporma. Apil sa among buhi nga komunidad ug ablihi ang daghang mga posibilidad karon!</p>
|
||||
</section>
|
||||
</main>
|
||||
]]></message>
|
||||
<message id="introduction"><![CDATA[
|
||||
<main>
|
||||
<h2>Maayong pag-abot!</h2>
|
||||
<p>Malipayong kami nga ikaw miapil sa among komunidad. Dinhi, ang pagkamatinud-anon, pagkamaabiabihon, ug pagtahod mao ang among giya nga mga prinsipyo.</p>
|
||||
<p>Samtang naglibot ka, hinumdumi nga pagpakatao ug pagtratar sa uban uban sa kalooy. Dili namo tugutan ang insulto, pagpanlupig, o dili awtorisadong sulod.</p>
|
||||
<p>Palihug hinumdomi nga ayaw pag-ambit sa personal nga impormasyon sama sa numero sa telepono, email address, adres sa balay, ug uban pa.</p>
|
||||
<p>Himoon nato kini nga usa ka malipayong lugar diin ang tanan mobati nga bililhong ug luwas. Maayong pag-abot, ug lingawi ang imong panahon dinhi!</p>
|
||||
</main>
|
||||
]]></message>
|
||||
</messages>
|
||||
275
docs/ios-app-umsetzungsplan.md
Normal file
275
docs/ios-app-umsetzungsplan.md
Normal file
@@ -0,0 +1,275 @@
|
||||
# iOS-App für YpChat – Umsetzungsplan
|
||||
|
||||
Dieses Dokument beschreibt die **komplette** Planung einer nativen iOS-App mit **Feature-Parität** zur bestehenden Android-App (`android/app`, Paket `de.ypchat.android`). Die Android-Implementierung dient als fachliche und API-Referenz.
|
||||
|
||||
---
|
||||
|
||||
## 1. Ziele und Abgrenzung
|
||||
|
||||
### 1.1 Produktziel
|
||||
|
||||
- Nutzer können sich wie in der Android-App anmelden, chatten, suchen, Posteingang/Verlauf nutzen, Konsole-Befehle senden, Feedback und Partner-Links einsehen sowie Bilder hochladen und versenden.
|
||||
- Gleiche Backend-URLs, REST-Endpunkte und Socket.IO-Ereignisse wie auf Android.
|
||||
|
||||
### 1.2 Nicht-Ziele (optional später)
|
||||
|
||||
- WatchOS, iPad-spezifisches Layout (erst iPhone-first).
|
||||
- Eigener Push-Benachrichtigungsdienst (nur falls das Backend später APNs unterstützt).
|
||||
|
||||
---
|
||||
|
||||
## 2. Referenz: Android-Architektur (zu spiegeln)
|
||||
|
||||
| Schicht | Android | iOS-Empfehlung |
|
||||
|--------|---------|----------------|
|
||||
| Konfiguration | `BuildConfig.BASE_URL` / `local.properties` | Xcode Build-Konfiguration + `xcconfig` oder Info-Key `BASE_URL` |
|
||||
| DI / Container | `AppContainer` | Eigene `AppServices`-Klasse oder schlankes Factory-Pattern beim App-Start |
|
||||
| HTTP + Cookies | OkHttp + `SessionCookieJar` | `URLSession` mit `HTTPCookieStorage` (shared oder app-group bei Bedarf) |
|
||||
| REST | Retrofit + Gson | `URLSession` + `Codable` (oder optional Alamofire) |
|
||||
| Echtzeit | `io.socket` (Socket.IO) | [socket.io-client-swift](https://github.com/socketio/socket.io-client-swift) (oder vergleichbare aktive Library) |
|
||||
| Zustand | `ChatRepository` + `StateFlow` | `ObservableObject` / `@Observable` + `async` oder Combine |
|
||||
| UI | Jetpack Compose | **SwiftUI** (empfohlen) |
|
||||
| Profil lokal | `ProfileStore` (SharedPreferences) | `UserDefaults` oder kleines Keychain-Wrapper nur wenn sensibel |
|
||||
|
||||
Wichtige Dateien zum Abgleich: `AppContainer.kt`, `RestApi.kt`, `SocketClient.kt`, `ChatRepository.kt`, `ChatViewModel.kt`, `YpChatRoot.kt`, `Models.kt`, `SocketEvent.kt`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Technologie- und Projektentscheidungen
|
||||
|
||||
### 3.1 Sprache und UI
|
||||
|
||||
- **Swift 5.10+**, Deployment **iOS 17+** (oder 16, wenn Gerätebindung es erfordert – dann API prüfen).
|
||||
- **SwiftUI** für alle Screens; Navigation: `TabView` + eingebettete Unternavigation für „Mehr“.
|
||||
|
||||
### 3.2 Abhängigkeiten
|
||||
|
||||
- **Socket.IO-Client** (Swift-Paket via SPM): muss dieselben Transporte unterstützen wie Android (`websocket` + `polling`); Verbindungsoptionen an `SocketClient.kt` anlehnen (Reconnect, Timeout).
|
||||
- **Kein** Retrofit – native `URLSession` reicht für die überschaubare REST-Oberfläche.
|
||||
- Bilder: **PhotosUI** (`PhotosPicker` / `PHPicker`) für die Bildauswahl; Upload als `multipart/form-data` wie Android.
|
||||
|
||||
### 3.3 Bundle-ID und Naming
|
||||
|
||||
- Vorschlag: `de.ypchat.ios` oder konsistent mit Android `de.ypchat.android` → z. B. `de.ypchat.app` (einheitlich mit Marketing/Store).
|
||||
- Anzeigename: wie `R.string.app_name` auf Android.
|
||||
|
||||
---
|
||||
|
||||
## 4. Konfiguration und Build-Varianten
|
||||
|
||||
### 4.1 Base-URL
|
||||
|
||||
- Standard wie Android: `https://www.ypchat.net` (siehe `defaultBaseUrl` in `android/app/build.gradle.kts`).
|
||||
- Pro Build-Konfiguration überschreibbar:
|
||||
- **Debug**: lokale `Config/Debug.xcconfig` mit `BASE_URL = https://…` (oder Staging).
|
||||
- **Release**: feste Produktions-URL.
|
||||
- Zur Laufzeit: `Bundle` / generierte `Info`-Keys auslesen, trailing slash wie `AppConfig.kt` entfernen.
|
||||
|
||||
### 4.2 App Transport Security (ATS)
|
||||
|
||||
- Produktion: HTTPS wie Android Release (`usesCleartextTraffic` false).
|
||||
- Debug: nur bei Bedarf `NSAppTransportSecurity` für HTTP-Testserver – dokumentieren und nicht in Release aktiv lassen.
|
||||
|
||||
### 4.3 Berechtigungen (Info.plist)
|
||||
|
||||
- **Foto-Bibliothek** (Lesen): `NSPhotoLibraryUsageDescription` mit klarer Begründung (Bild im Chat senden).
|
||||
- Keine Kamera zwingend nötig, wenn nur Galerie wie Android `PickVisualMedia`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Datenmodell (Codable)
|
||||
|
||||
Alle DTOs aus `Models.kt` 1:1 als `struct` mit `Codable` abbilden, inkl. Sonderfall **`CountriesResponse`**: auf Android ein `LinkedHashMap<String, String>` – auf iOS als `[String: String]` decodieren oder eigener `Decodable`-Wrapper.
|
||||
|
||||
**PartnerLinkDto**: JSON-Feld `"Page Name"` → `CodingKeys` mit `case pageName = "Page Name"`.
|
||||
|
||||
---
|
||||
|
||||
## 6. REST-API
|
||||
|
||||
Basis-URL + Pfad wie `RestApi.kt`:
|
||||
|
||||
| Methode | Pfad | Zweck |
|
||||
|---------|------|--------|
|
||||
| GET | `api/session` | Session / eingeloggter User |
|
||||
| POST | `api/logout` | Logout |
|
||||
| GET | `api/countries` | Länderliste |
|
||||
| GET | `api/feedback` | Feedback-Liste |
|
||||
| GET | `api/feedback/admin-status` | Admin-Session-Status |
|
||||
| POST | `api/feedback` | Feedback senden |
|
||||
| POST | `api/feedback/admin-login` | Admin-Login |
|
||||
| POST | `api/feedback/admin-logout` | Admin-Logout |
|
||||
| DELETE | `api/feedback/{id}` | Eintrag löschen (Admin) |
|
||||
| GET | `api/partners` | Partner-Links |
|
||||
| POST | `api/upload-image` | Multipart-Feld `image` |
|
||||
|
||||
**Cookie-Handling:** Nach `session`-Call und weiteren Requests müssen Cookies wie im Browser/OkHttp mitgeführt werden – `URLSessionConfiguration.default` mit `httpCookieStorage` und `httpShouldSetCookies` / `httpCookieAcceptPolicy` prüfen; bei Problemen explizit `Cookie`-Header aus Storage für die Domain setzen.
|
||||
|
||||
**Fehlerbehandlung:** HTTP-Status und Body auswerten; Fehlermeldungen in den UI-State wie `ChatState.errorMessage` / `feedbackMessage`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Socket.IO-Client
|
||||
|
||||
### 7.1 Verbindung
|
||||
|
||||
- URL: gleiche `baseUrl` wie REST (ohne zusätzlichen Pfad, sofern Server Root nutzt – mit Android-Verhalten abgleichen).
|
||||
- Optionen analog `SocketClient.kt`: Reconnect, `reconnectionAttempts`, Delays, Timeout; Transports WebSocket + Polling falls die Swift-Library das abbildet.
|
||||
|
||||
### 7.2 Authentifizierung / Session
|
||||
|
||||
- Beim Connect bzw. nach Connect: `setSessionId` mit Payload `{ "expressSessionId": "<id>" }` – identisch zu Android.
|
||||
- `pendingExpressSessionId` bei erneutem Connect erneut senden.
|
||||
|
||||
### 7.3 Client → Server (emit)
|
||||
|
||||
| Event | Payload-Felder (Kern) |
|
||||
|-------|------------------------|
|
||||
| `login` | userName, gender, age, country, expressSessionId |
|
||||
| `message` | message, messageId, optional toUserName; für Bild: toUserName, isImage, imageUrl |
|
||||
| `requestConversation` | withUserName |
|
||||
| `userSearch` | nameIncludes, minAge, maxAge, countries[], genders[] |
|
||||
| `requestHistory` | (leer) |
|
||||
| `requestOpenConversations` | (leer) |
|
||||
| `blockUser` / `unblockUser` | userName |
|
||||
|
||||
### 7.4 Server → Client (on)
|
||||
|
||||
Alle Events aus `SocketClient.kt` abbilden und in ein internes `enum` / sealed Äquivalent übersetzen: `connected`, `loginSuccess`, `userList`, `message`, `messageSent`, `conversation`, `searchResults`, `historyResults`, `inboxResults`, `unreadChats`, `userBlocked`, `userUnblocked`, `commandResult`, `commandTable`, `error`, sowie Verbindungsmetadaten (`connect`, `disconnect`, `connect_error`).
|
||||
|
||||
**JSON-Parsing:** Foundation `JSONSerialization` oder `JSONDecoder` mit `[String: Any]`-Hilfstypen – Feldnamen und Typen strikt an Android-Mapper (`toUserDto`, `toMessageDto`, …) halten.
|
||||
|
||||
### 7.5 HTTP-Stack und Socket
|
||||
|
||||
- Android nutzt **dieselbe** `OkHttpClient`-Instanz für Socket und REST. Auf iOS: wo möglich eine gemeinsame Cookie-Quelle; falls die Socket-Library keinen `URLSession` teilt, nach dem Connect sicherstellen, dass `expressSessionId` per `setSessionId` gesetzt ist (wie Android bei getrenntem Transport).
|
||||
|
||||
---
|
||||
|
||||
## 8. Repository- und App-Logik (`ChatRepository`)
|
||||
|
||||
Die Reducer-Logik aus `ChatRepository.reduce` und die Methoden **funktional kopieren**:
|
||||
|
||||
- **restoreSession:** Profil laden, Länder laden, `feedbackAdminStatus`, `GET api/session`, dann Socket connect + `setSessionId`.
|
||||
- **login:** Profil speichern, Session-ID beschaffen, Socket ggf. verbinden, `login` emit.
|
||||
- **logout:** `POST api/logout`, Socket trennen, Cookies löschen, State reset mit erhaltenem `savedProfile` und `countries`.
|
||||
- **Timeout:** 30 Minuten (`1800` s) – Ticker jede Sekunde, bei 0 automatisch `logout` (wie `startTimeoutTicker` / `resetTimeout`).
|
||||
- **openConversation / closeConversation**, **sendMessage** (inkl. Konsolen-Befehle mit `/`), **search**, **inbox/history**, **block/unblock**.
|
||||
- **Bild:** Upload REST → bei Erfolg `sendImage` mit absoluter URL (Basis-URL voranstellen wenn relativ).
|
||||
|
||||
Zustand als eine **`ChatState`-Struktur** (alle Properties aus `ChatRepository.kt`).
|
||||
|
||||
---
|
||||
|
||||
## 9. Präsentationsschicht (SwiftUI)
|
||||
|
||||
### 9.1 ViewModel
|
||||
|
||||
- `@MainActor` ViewModel (oder Repository auf MainActor publizieren), das `ChatState` published und User-Aktionen an das Repository delegiert.
|
||||
- `task { await repository.restoreSession() }` beim Erscheinen der Root-View.
|
||||
|
||||
### 9.2 Bildschirme (Parität zu `YpChatRoot`)
|
||||
|
||||
1. **Login:** Landing-Karte, Profilfelder, Gender/Country-Picker, Validierung (Nickname ≥ 3 Zeichen), Socket-Status.
|
||||
2. **Haupt-Shell:** Top-Bar (App-Name, User, Online-Status, Timeout-Countdown, Logout).
|
||||
3. **Tabs:** Online | Suche | Posteingang | Verlauf | Konsole | Mehr – wie Android; Posteingang-Badge mit `unreadChatsCount`.
|
||||
4. **Chat:** Zurück, Blockieren/Entblocken, Nachrichtenliste, Eingabe, Smiley-Leiste (gleiche Tokens wie `SmileyItems`), Bild wählen, Senden, Upload-Banner.
|
||||
5. **Suche / Inbox / Verlauf:** Listen wie Android.
|
||||
6. **Konsole:** Eingabe, Senden, Ausgabezeilen + Tabelle (`CommandTableState`).
|
||||
7. **Mehr:** Unterseiten Feedback (inkl. Admin-Login), Partner (Safari öffnen), FAQ, Regeln, Sicherheit, Impressum – Texte aus **lokalisierten Strings** (siehe `res/values/strings.xml` auf Android als Quelle für DE/EN).
|
||||
|
||||
### 9.3 Design
|
||||
|
||||
- Farben und Abstände an die Android-Farbkonstanten in `YpChatRoot.kt` anlehnen für ein konsistentes Erscheinungsbild.
|
||||
- Bilder in Chat: AsyncImage / Kingfisher nur falls nötig; Caching beachten.
|
||||
|
||||
### 9.4 Lokalisierung
|
||||
|
||||
- `Localizable.xcstrings` (oder `.strings`) – mindestens Deutsch; Android-`strings.xml` als Master-Liste.
|
||||
|
||||
---
|
||||
|
||||
## 10. Sicherheit und Datenschutz
|
||||
|
||||
- Passwörter (Feedback-Admin) nur im Speicher, nicht loggen.
|
||||
- TLS in Release erzwingen.
|
||||
- Keychain nur nötig, wenn sensible Tokens dauerhaft ohne Cookies gespeichert werden sollen (aktuell: Session eher cookie-basiert wie Web).
|
||||
|
||||
---
|
||||
|
||||
## 11. Qualitätssicherung
|
||||
|
||||
### 11.1 Tests
|
||||
|
||||
- **Unit-Tests:** JSON-Decodierung der REST- und Socket-Payloads (Fixtures aus echten Server-Responses erfassen).
|
||||
- **Integration:** Manuell gegen Staging/Produktion: Login, Nachricht, Suche, Feedback, Bild-Upload, Logout, Timeout.
|
||||
|
||||
### 11.2 Edge Cases
|
||||
|
||||
- App in den Hintergrund: Socket-Verhalten (Disconnect/Reconnect) und Timeout-Reset gemäß Produktregel klären (Android tickt weiter – iOS analog umsetzen).
|
||||
- Schlechte Netzwerke: Fehlermeldungen aus `SocketEvent.Error` und REST anzeigen.
|
||||
|
||||
---
|
||||
|
||||
## 12. App Store / Vertrieb
|
||||
|
||||
- **Apple Developer Program**, App-ID, Provisioning Profiles.
|
||||
- **Datenschutzerklärung** und **App-Privacy-Labels** (Netzwerk, Fotos, ggf. Nutzerinhalt).
|
||||
- **TestFlight** für interne Tester vor Release.
|
||||
- Versionierung: an `versionName` / `versionCode` der Android-App angleichen oder eigenes Schema dokumentieren.
|
||||
|
||||
---
|
||||
|
||||
## 13. Repository-Struktur (Vorschlag)
|
||||
|
||||
```
|
||||
ios/
|
||||
YpChat.xcodeproj oder YpChat.xcworkspace
|
||||
YpChat/
|
||||
App/
|
||||
Core/ # AppConfig, Cookie/Session storage, ProfileStore
|
||||
Data/ # APIClient, SocketClient, DTOs
|
||||
Features/ # Login, Chat, Tabs, More, …
|
||||
Resources/ # Assets, Localizable
|
||||
Config/
|
||||
Debug.xcconfig
|
||||
Release.xcconfig
|
||||
```
|
||||
|
||||
Root-`README` oder dieses Dokument verlinken, wie man `BASE_URL` setzt.
|
||||
|
||||
---
|
||||
|
||||
## 14. Phasenplan (Meilensteine)
|
||||
|
||||
| Phase | Inhalt | Ergebnis |
|
||||
|-----|--------|----------|
|
||||
| **P0** | Xcode-Projekt, Konfiguration, leere SwiftUI-App, BASE_URL | Laufende Shell-App |
|
||||
| **P1** | REST-Client + Cookie-Speicher, `session` / `countries` / `logout` | Session funktioniert |
|
||||
| **P2** | Socket-Client, `setSessionId`, `login`, `userList`, `message` | Minimaler Chat |
|
||||
| **P3** | Vollständiger `ChatRepository`-State, alle Socket-Events, Timeout | Parität Kernlogik |
|
||||
| **P4** | SwiftUI: Login + Tabs + Chat + Suche/Inbox/Verlauf | UI-Hauptteil |
|
||||
| **P5** | Bild-Upload + Anzeige, Smileys | Medien-Parität |
|
||||
| **P6** | Mehr: Feedback, Partner, statische Seiten | Rest-UI |
|
||||
| **P7** | Lokalisierung, Feinschliff, Dark Mode optional | Polish |
|
||||
| **P8** | TestFlight, Store-Metadaten, Screenshots | Release |
|
||||
|
||||
---
|
||||
|
||||
## 15. Risiken und Abhängigkeiten
|
||||
|
||||
- **Socket.IO-Swift-Version** muss zum **Server-Socket.IO** passen; bei Protokoll-Mismatch Verbindungsfehler – mit Server-Version abgleichen.
|
||||
- **Cookie-Domain / Pfad:** muss mit dem Backend identisch sein, sonst wirkt `session` wie „nicht eingeloggt“.
|
||||
- **Bildgröße:** 5 MB-Limit clientseitig wie Android (`MAX_IMAGE_BYTES`).
|
||||
|
||||
---
|
||||
|
||||
## 16. Nächster konkreter Schritt
|
||||
|
||||
1. Im Repo Ordner `ios/` anlegen und Xcode-Projekt (SwiftUI App) hinzufügen.
|
||||
2. SPM: Socket.IO-Client einbinden, minimales `SocketClient`-Swift gegen Staging testen.
|
||||
3. `GET api/session` mit gemeinsamem Cookie-Storage verifizieren.
|
||||
4. Danach schrittweise `ChatRepository` portieren und UI anbinden.
|
||||
|
||||
---
|
||||
|
||||
*Stand: Abgleich mit Android-Codebase (Compose-App, Mai 2026). Bei Backend-Änderungen diesen Plan und die Event-Liste aktualisieren.*
|
||||
436
docs/videochat-umsetzungsplan.md
Normal file
436
docs/videochat-umsetzungsplan.md
Normal file
@@ -0,0 +1,436 @@
|
||||
# Videochat-Umsetzungsplan
|
||||
|
||||
## Ziel
|
||||
|
||||
YpChat soll Videochat innerhalb einer bestehenden 1:1-Konversation unterstützen.
|
||||
|
||||
Rahmenbedingungen:
|
||||
|
||||
- Videochat ist nur aus einer bestehenden Chat-Konversation heraus erreichbar.
|
||||
- Die Videochat-Funktion soll nur sichtbar sein, wenn beide Gesprächspartner sie für diese Konversation erlaubt haben.
|
||||
- Es darf keine Direktverbindung zwischen den Endgeräten geben.
|
||||
- Sämtliche Videoverbindungen laufen über Server-Infrastruktur, damit keine Peer-IP-Adressen offengelegt werden.
|
||||
|
||||
## Empfohlene Architektur
|
||||
|
||||
### 1. Trennung zwischen Chat-Signaling und Medien-Relay
|
||||
|
||||
Die bestehende Socket.IO-Infrastruktur bleibt für:
|
||||
|
||||
- Sichtbarkeit der Video-Funktion
|
||||
- Freigabe-Status pro Konversation
|
||||
- Einladung / Annahme / Ablehnung
|
||||
- Klingelstatus
|
||||
- Gesprächsstatus
|
||||
- Fehler- und Abbruchsignale
|
||||
|
||||
Der Austausch der Grunddaten und Anfragen darf über Socket.IO laufen.
|
||||
|
||||
Die eigentlichen Audio-/Videoströme sollen ausdrücklich nicht über die bestehenden Chat-Sockets und nicht über die Chat-Message-Logik laufen, sondern über eine dedizierte serverseitige Medienebene.
|
||||
|
||||
### 2. Keine Peer-to-Peer-Verbindung
|
||||
|
||||
Empfehlung:
|
||||
|
||||
- WebRTC weiterverwenden, aber ausschließlich mit server-relaytem Medientransport
|
||||
- kein P2P-Mesh
|
||||
- keine Host-/srflx-Kandidaten verwenden
|
||||
- nur relay-Kandidaten zulassen oder direkt eine SFU/Media-Server-Lösung einsetzen
|
||||
|
||||
Praktisch heißt das:
|
||||
|
||||
- Signaling kommt aus `server/broadcast.js`
|
||||
- Medien laufen über einen separaten Media-Server oder eine integrierte SFU
|
||||
- der Browser bzw. die mobilen Clients sehen nur die Server-Endpunkte, nicht die Gegenstelle
|
||||
- Socket.IO transportiert nur Status, Einladungen, Freigaben und Session-Metadaten
|
||||
- Audio/Video läuft ausschließlich über den Medienpfad
|
||||
|
||||
### 3. Bevorzugte Zielarchitektur
|
||||
|
||||
Für YpChat ist eine SFU-basierte 1:1-Lösung sinnvoller als vollständiges Server-Transcoding:
|
||||
|
||||
- bessere Latenz als kompletter zentraler Decode/Encode-Relay
|
||||
- trotzdem keine direkte Verbindung zwischen Nutzern
|
||||
- sauber erweiterbar für Android, iOS und Web
|
||||
|
||||
Planungsentscheidung:
|
||||
|
||||
- Node-Backend bleibt Orchestrator und Berechtigungsinstanz
|
||||
- Video-Medienserver wird als eigene Komponente vorgesehen
|
||||
- pro Videochat wird ein kurzlebiger Raum erzeugt
|
||||
- Zutritt nur für die beiden durch den Chat verknüpften Nutzer
|
||||
|
||||
## Produktlogik
|
||||
|
||||
### 1. Sichtbarkeit
|
||||
|
||||
Die Videochat-Aktion ist nur sichtbar, wenn:
|
||||
|
||||
- `currentConversation` gesetzt ist
|
||||
- beide Nutzer online bzw. erreichbar sind
|
||||
- keine Blockierung aktiv ist
|
||||
- beide Nutzer für genau diese Konversation `videoAllowed = true` gesetzt haben
|
||||
|
||||
Empfehlung:
|
||||
|
||||
- Freigabe nicht global pro Account, sondern pro Konversation speichern
|
||||
- Freigabe explizit und widerrufbar machen
|
||||
- Sichtbarkeit im Chat-Header und optional zusätzlich im Composer
|
||||
|
||||
### 2. Verbindungsgrenze pro Nutzer
|
||||
|
||||
Pro Nutzer dürfen maximal drei gleichzeitige Videoverbindungen aktiv sein.
|
||||
|
||||
Wenn ein vierter Videochat gestartet oder angenommen werden soll:
|
||||
|
||||
- lehnt der Server den Vorgang ab
|
||||
- der anfragende Client erhält einen klaren Fehlerstatus
|
||||
- die UI zeigt eine verständliche Meldung wie "Maximal drei Videoverbindungen gleichzeitig erlaubt"
|
||||
|
||||
Die Begrenzung muss serverseitig erzwungen werden, nicht nur in der UI.
|
||||
|
||||
### 3. Zustandsmodell pro Konversation
|
||||
|
||||
Pro Benutzerpaar wird zusätzlicher Konversationszustand benötigt:
|
||||
|
||||
- `localVideoConsent`
|
||||
- `remoteVideoConsent`
|
||||
- `videoVisible`
|
||||
- `activeCallState`
|
||||
- `incomingCall`
|
||||
- `outgoingCall`
|
||||
- `callRoomId`
|
||||
|
||||
Empfohlene Server-Zustände:
|
||||
|
||||
- `disabled`
|
||||
- `local_allowed`
|
||||
- `mutual_allowed`
|
||||
- `ringing`
|
||||
- `connecting`
|
||||
- `active`
|
||||
- `ended`
|
||||
|
||||
Zusätzlich pro Nutzer:
|
||||
|
||||
- `activeVideoConnectionCount`
|
||||
- Liste aktiver Video-Sessions
|
||||
- welches Video aktuell im Vordergrund ist
|
||||
|
||||
### 4. Gesprächsablauf
|
||||
|
||||
1. Nutzer A öffnet eine bestehende Konversation.
|
||||
2. Nutzer A aktiviert "Videochat erlauben".
|
||||
3. Nutzer B aktiviert dieselbe Freigabe.
|
||||
4. Erst jetzt wird der Videochat-Button sichtbar.
|
||||
5. Nutzer A startet einen Anruf.
|
||||
6. Server sendet Einladung an Nutzer B.
|
||||
7. Nutzer B nimmt an oder lehnt ab.
|
||||
8. Bei Annahme erzeugt der Server einen kurzlebigen Call-Raum und gibt signierte Join-Daten an beide Clients zurück.
|
||||
9. Beide Clients verbinden sich mit dem Medienserver.
|
||||
10. Auflegen, Timeout oder Disconnect beendet Raum und Status.
|
||||
|
||||
Wenn ein Nutzer bereits drei aktive Videoverbindungen hat, scheitert Schritt 5 oder 7 mit einem Serverfehler.
|
||||
|
||||
## Anzeige- und Layoutkonzept
|
||||
|
||||
### 1. Rechte Preview-Spalte
|
||||
|
||||
Alle aktiven Videoverbindungen eines Nutzers werden rechts als Preview angezeigt.
|
||||
|
||||
Vorgaben:
|
||||
|
||||
- Position rechts im Browserfenster
|
||||
- Breite etwa `1/5` des Browserfensters
|
||||
- ganz oben ein festes Self-Preview des eigenen Video-Streams
|
||||
- darunter maximal drei Video-Previews fremder aktiver Verbindungen
|
||||
- damit insgesamt bis zu vier Previews sichtbar
|
||||
- jede Preview zeigt mindestens:
|
||||
- Videobild
|
||||
- Name des Partners
|
||||
- Mute-Zustand
|
||||
- Status
|
||||
- Aktion zum In-den-Vordergrund-Holen, außer beim Self-Preview
|
||||
- Aktion zum Beenden
|
||||
|
||||
Empfehlung:
|
||||
|
||||
- eigener Container im Web-Layout, nicht im normalen Chat-Message-Flow
|
||||
- auf kleineren Viewports responsiv umschalten, aber Desktop bleibt Referenz
|
||||
- das Self-Preview ist rein informativ und kann nicht in den Vordergrund geholt werden
|
||||
|
||||
### 2. Vordergrund-Video als schwebendes Fenster
|
||||
|
||||
Ein aktives Video kann in den Vordergrund geholt werden.
|
||||
|
||||
Vorgaben:
|
||||
|
||||
- Darstellung als schwebendes Fenster
|
||||
- frei verschiebbar
|
||||
- über der normalen Chat-Oberfläche
|
||||
- pro Zeitpunkt genau ein Vordergrundfenster
|
||||
- Rückweg in die rechte Preview-Leiste möglich
|
||||
|
||||
Das Vordergrundfenster sollte enthalten:
|
||||
|
||||
- großes Videobild der ausgewählten Verbindung
|
||||
- Name des Partners sichtbar im Fenster
|
||||
- Mute-Zustand sichtbar im Fenster
|
||||
- optional eigenes kleines Self-Preview
|
||||
- Drag-Handle
|
||||
- Schließen / minimieren
|
||||
- Mute / Kamera aus
|
||||
- Auflegen
|
||||
|
||||
### 3. Zustände in der UI
|
||||
|
||||
Die UI braucht zusätzlich zu den Call-States:
|
||||
|
||||
- `videoDockSessions`
|
||||
- `selfPreviewStream`
|
||||
- `foregroundVideoSessionId`
|
||||
- `floatingVideoPosition`
|
||||
- `maxVideoConnectionsReached`
|
||||
|
||||
## Technischer Zuschnitt
|
||||
|
||||
## Phasenstatus
|
||||
|
||||
- [x] Phase 1: Signaling und Zustände im Backend
|
||||
- [x] Phase 2: Web-Client
|
||||
- [x] Phase 3: Android
|
||||
- [ ] Phase 4: Medienserver-Integration und End-to-End-Test
|
||||
- [x] Server- und Web-Relaypfad via WebRTC mit Relay-Only-Signaling
|
||||
- [x] Native Android-Medienebene
|
||||
|
||||
### Phase 1: Signaling und Zustände im Backend
|
||||
|
||||
Erweiterung von `server/broadcast.js` um neue Event-Familien:
|
||||
|
||||
- `videoConsent:set`
|
||||
- `videoConsent:update`
|
||||
- `videoCall:invite`
|
||||
- `videoCall:incoming`
|
||||
- `videoCall:accept`
|
||||
- `videoCall:reject`
|
||||
- `videoCall:cancel`
|
||||
- `videoCall:start`
|
||||
- `videoCall:end`
|
||||
- `videoCall:error`
|
||||
- `videoCall:capacity`
|
||||
|
||||
Zusätzliche In-Memory-Strukturen:
|
||||
|
||||
- Konversations-Metadaten getrennt von `conversations`
|
||||
- aktive Call-Sessions
|
||||
- Mapping `conversationKey -> consent/call state`
|
||||
- Mapping `userName -> aktive Video-Sessions`
|
||||
|
||||
Wichtig:
|
||||
|
||||
- Call-Zustand an stabile Benutzeridentitäten koppeln, nicht nur an Socket-IDs
|
||||
- Blocklisten auch für Video-Einladungen durchsetzen
|
||||
- bei Reconnect Call-Zustand defensiv neu synchronisieren
|
||||
- Verbindungsobergrenze von drei aktiven Video-Sessions pro Nutzer serverseitig prüfen
|
||||
|
||||
Status:
|
||||
|
||||
- erledigt
|
||||
|
||||
### Phase 2: Web-Client
|
||||
|
||||
Im Web-Store `client/src/stores/chat.js` ergänzen:
|
||||
|
||||
- Video-Consent-Status pro aktueller Konversation
|
||||
- neue Socket-Listener für Video-Events
|
||||
- Actions zum Setzen der Freigabe
|
||||
- Actions zum Starten, Annehmen, Ablehnen und Beenden eines Calls
|
||||
- UI-Flags für Ringing, Connecting, Active, Failed
|
||||
- Verwaltung von bis zu drei parallelen aktiven Video-Sessions
|
||||
- Auswahl, welches Video im Vordergrund schwebt
|
||||
- Position des schwebenden Fensters
|
||||
- Fehlerzustand für Kapazitätsgrenze
|
||||
|
||||
UI-Einstiegspunkte:
|
||||
|
||||
- `client/src/views/ChatView.vue`
|
||||
- Sichtbarer Videochat-Button im Header nur bei `mutual_allowed`
|
||||
- rechte Preview-Spalte für aktive Videos
|
||||
- `client/src/components/ChatInput.vue`
|
||||
- optionaler Toggle "Video erlauben" oder sekundärer Einstieg
|
||||
- `client/src/components/ChatWindow.vue`
|
||||
- Statusbanner für Einladung, Verbindungsaufbau, aktiv, beendet
|
||||
|
||||
Zusätzliche neue Web-Komponente:
|
||||
|
||||
- `client/src/components/VideoCallPanel.vue`
|
||||
- lokales Vorschaufenster
|
||||
- entferntes Video
|
||||
- Annehmen / Ablehnen
|
||||
- Kamera/Mikro muten
|
||||
- Auflegen
|
||||
- `client/src/components/VideoDock.vue`
|
||||
- rechte Preview-Spalte mit festem Self-Preview plus bis zu drei Partner-Previews
|
||||
- `client/src/components/FloatingVideoWindow.vue`
|
||||
- verschiebbares Vordergrundfenster
|
||||
|
||||
Status:
|
||||
|
||||
- erledigt
|
||||
|
||||
### Phase 3: Android
|
||||
|
||||
Erweiterungen:
|
||||
|
||||
- `android/app/src/main/java/de/ypchat/android/data/model/SocketEvent.kt`
|
||||
- `android/app/src/main/java/de/ypchat/android/data/model/Models.kt`
|
||||
- `android/app/src/main/java/de/ypchat/android/data/api/SocketClient.kt`
|
||||
- `android/app/src/main/java/de/ypchat/android/data/repository/ChatRepository.kt`
|
||||
- `android/app/src/main/java/de/ypchat/android/ui/YpChatRoot.kt`
|
||||
- `android/app/src/main/java/de/ypchat/android/ui/ChatViewModel.kt`
|
||||
|
||||
Benötigt werden:
|
||||
|
||||
- neue Eventklassen für Consent und Call-Status
|
||||
- Repository-State für Sichtbarkeit, Einladung und aktiven Call
|
||||
- UI für Toggle, Rufannahme und laufenden Call
|
||||
- UI für bis zu drei Previews und ein hervorgehobenes Vordergrundvideo
|
||||
- Medienintegration über dieselbe server-relayte Architektur wie im Web
|
||||
|
||||
Status:
|
||||
|
||||
- erledigt
|
||||
|
||||
### Phase 4: Medienserver-Integration und End-to-End-Test
|
||||
|
||||
Umfang:
|
||||
|
||||
- Auswahl und Einbindung der Relay-/SFU-Lösung
|
||||
- Ausgabe serverseitiger Join-Daten für Calls
|
||||
- Medienpfad an Web und Android anbinden
|
||||
- End-to-End-Test aller Zustände
|
||||
- Datenschutz- und Kapazitätsprüfungen abschließen
|
||||
|
||||
Aktueller Stand:
|
||||
|
||||
- Server und Web nutzen jetzt Relay-Only-WebRTC mit Signaling über Socket.IO
|
||||
- tatsächliche Medien laufen nicht über Chat-Sockets
|
||||
- Voraussetzung ist eine konfigurierte TURN-/Relay-Infrastruktur über Umgebungsvariablen
|
||||
- Android nutzt jetzt ebenfalls den Relay-Only-WebRTC-Medienpfad mit nativer Laufzeit und Compose-Rendering
|
||||
- offen bleibt vor allem der End-to-End-Test mit echter TURN-Konfiguration und Mehrgeräte-Verifikation
|
||||
|
||||
Status:
|
||||
|
||||
- offen
|
||||
|
||||
Benötigte Umgebungsvariablen für den Relay-Betrieb:
|
||||
|
||||
- `VIDEO_TURN_URLS`
|
||||
- `VIDEO_TURN_USERNAME`
|
||||
- `VIDEO_TURN_CREDENTIAL`
|
||||
- optional `VIDEO_STUN_URLS`
|
||||
- alternativ `VIDEO_ICE_SERVERS_JSON` als vollständige ICE-Serverliste
|
||||
|
||||
## Server-seitige Persistenzentscheidung
|
||||
|
||||
Für einen ersten Schritt kann der Consent-Zustand im RAM gehalten werden, analog zur aktuellen Chat-Architektur.
|
||||
|
||||
Empfehlung für produktiven Ausbau:
|
||||
|
||||
- Consent pro Benutzerpaar persistent speichern
|
||||
- aktive Calls nur flüchtig speichern
|
||||
|
||||
Begründung:
|
||||
|
||||
- Sichtbarkeit des Video-Buttons soll nach Reconnect nicht zufällig verloren gehen
|
||||
- aktive Calls dürfen bei Server-Neustart beendet werden, aber Consent sollte stabiler sein
|
||||
|
||||
## Sicherheits- und Datenschutzregeln
|
||||
|
||||
- Videochat nur für eingeloggte, aktive Chat-Teilnehmer
|
||||
- keine Join-Daten ohne bestehende Konversation
|
||||
- keine Join-Daten ohne gegenseitige Freigabe
|
||||
- Blockierungen sperren auch Videochat
|
||||
- Call-Räume sind kurzlebig und nur für zwei Teilnehmer gültig
|
||||
- Tokens für Media-Join serverseitig signieren und kurz halten
|
||||
- keine Offenlegung von Peer-IP-Adressen an Clients
|
||||
- Logging nur auf Betriebsniveau, keine Speicherung von Medieninhalten
|
||||
|
||||
## Offene Architekturentscheidung
|
||||
|
||||
Vor der Implementierung sollte genau eine Medienstrategie festgelegt werden:
|
||||
|
||||
### Empfohlene Richtung
|
||||
|
||||
Server-relayter WebRTC-Videochat mit SFU.
|
||||
|
||||
Warum:
|
||||
|
||||
- erfüllt die Anforderung "keine Direktverbindung"
|
||||
- ist für Web, Android und iOS gemeinsam nutzbar
|
||||
- passt besser zu Echtzeit als Datei- oder Socket-Binary-Transfer
|
||||
|
||||
### Nicht empfohlen
|
||||
|
||||
- Video als Upload-/Download-Mechanik wie bei Bildern
|
||||
- vollständiger Eigenbau eines Medienprotokolls über Socket.IO
|
||||
- klassisches P2P-WebRTC mit STUN-only
|
||||
|
||||
## Kritische Dateien für die spätere Umsetzung
|
||||
|
||||
- `server/broadcast.js`
|
||||
- `server/index.js`
|
||||
- `client/src/stores/chat.js`
|
||||
- `client/src/views/ChatView.vue`
|
||||
- `client/src/components/ChatInput.vue`
|
||||
- `client/src/components/ChatWindow.vue`
|
||||
- `android/app/src/main/java/de/ypchat/android/data/model/SocketEvent.kt`
|
||||
- `android/app/src/main/java/de/ypchat/android/data/model/Models.kt`
|
||||
- `android/app/src/main/java/de/ypchat/android/data/api/SocketClient.kt`
|
||||
- `android/app/src/main/java/de/ypchat/android/data/repository/ChatRepository.kt`
|
||||
- `android/app/src/main/java/de/ypchat/android/ui/YpChatRoot.kt`
|
||||
- `android/app/src/main/java/de/ypchat/android/ui/ChatViewModel.kt`
|
||||
|
||||
## Verifikation
|
||||
|
||||
### Funktional
|
||||
|
||||
1. Zwei Nutzer bauen eine normale Konversation auf.
|
||||
2. Nur ein Nutzer erlaubt Videochat.
|
||||
3. Prüfen, dass keine Videochat-Aktion sichtbar ist.
|
||||
4. Zweiter Nutzer erlaubt Videochat.
|
||||
5. Prüfen, dass die Aktion jetzt bei beiden sichtbar ist.
|
||||
6. Nutzer A startet einen Call.
|
||||
7. Nutzer B erhält Einladung.
|
||||
8. Nutzer B lehnt ab, Status muss sauber zurückspringen.
|
||||
9. Nutzer A startet erneut, Nutzer B nimmt an.
|
||||
10. Beide verbinden sich ausschließlich über den Server-Relay-Pfad.
|
||||
11. Drei parallele Videoverbindungen für einen Nutzer aufbauen.
|
||||
12. Vierte Verbindung starten oder annehmen und korrekte Fehlermeldung prüfen.
|
||||
13. Prüfen, dass rechts ein Self-Preview plus maximal drei Partner-Previews sichtbar sind.
|
||||
14. Prüfen, dass das Self-Preview nicht vergrößert werden kann.
|
||||
15. Prüfen, dass unter jedem Partner-Preview der Name und der Mute-Zustand sichtbar sind.
|
||||
16. Ein Partner-Preview in den Vordergrund holen und als schwebendes Fenster verschieben.
|
||||
17. Prüfen, dass auch im großen Fenster Name und Mute-Zustand sichtbar sind.
|
||||
18. Minimieren und Rückkehr in die Preview-Leiste prüfen.
|
||||
19. Auflegen, Browser-Reload, App-Hintergrundwechsel und Reconnect prüfen.
|
||||
|
||||
### Datenschutz
|
||||
|
||||
1. Prüfen, dass keine direkte Peer-Verbindung aufgebaut wird.
|
||||
2. Prüfen, dass nur Relay-/Server-Kandidaten verwendet werden.
|
||||
3. Prüfen, dass Blockierungen auch Video-Einladungen unterbinden.
|
||||
|
||||
### Regression
|
||||
|
||||
1. Textnachrichten weiter senden/empfangen.
|
||||
2. Bildversand weiter senden/empfangen.
|
||||
3. History, Inbox und Conversation-Reload dürfen unverändert funktionieren.
|
||||
|
||||
## Empfohlene Implementierungsreihenfolge
|
||||
|
||||
1. Event- und Zustandsmodell für Consent und Call-Lifecycle definieren.
|
||||
2. Backend-Signaling in `server/broadcast.js` ergänzen.
|
||||
3. Web-Client vollständig integrieren und als Referenzfluss stabilisieren.
|
||||
4. Danach Android und iOS auf denselben Event-Vertrag heben.
|
||||
5. Erst dann Medienserver produktionsnah anbinden und End-to-End testen.
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
# SingleChat Systemd Service Installation für ypchat.net
|
||||
# Erstellt einen systemd Service für SingleChat
|
||||
# YpChat Systemd Service Installation für ypchat.net
|
||||
# Erstellt einen systemd Service für YpChat
|
||||
|
||||
set -e
|
||||
|
||||
@@ -12,7 +12,7 @@ USER="www-data"
|
||||
GROUP="www-data"
|
||||
|
||||
echo "=========================================="
|
||||
echo "SingleChat Systemd Service Installation"
|
||||
echo "YpChat Systemd Service Installation"
|
||||
echo "=========================================="
|
||||
|
||||
# Prüfe ob als root ausgeführt
|
||||
@@ -46,7 +46,7 @@ fi
|
||||
echo "Erstelle Service-Datei..."
|
||||
cat > "$SERVICE_FILE" << EOF
|
||||
[Unit]
|
||||
Description=SingleChat Node.js Application
|
||||
Description=YpChat Node.js Application
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
@@ -103,4 +103,3 @@ echo ""
|
||||
echo "WICHTIG: Starte den Service mit:"
|
||||
echo " sudo systemctl start $SERVICE_NAME"
|
||||
echo ""
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
# SingleChat Systemd Service Installation
|
||||
# Erstellt einen systemd Service für SingleChat
|
||||
# YpChat Systemd Service Installation
|
||||
# Erstellt einen systemd Service für YpChat
|
||||
|
||||
set -e
|
||||
|
||||
@@ -11,7 +11,7 @@ APP_DIR=$(pwd)
|
||||
USER=$(whoami)
|
||||
|
||||
echo "=========================================="
|
||||
echo "SingleChat Systemd Service Installation"
|
||||
echo "YpChat Systemd Service Installation"
|
||||
echo "=========================================="
|
||||
|
||||
# Prüfe ob als root ausgeführt
|
||||
@@ -25,7 +25,7 @@ fi
|
||||
echo "Erstelle Service-Datei..."
|
||||
cat > "$SERVICE_FILE" << EOF
|
||||
[Unit]
|
||||
Description=SingleChat Node.js Application
|
||||
Description=YpChat Node.js Application
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
@@ -71,4 +71,3 @@ echo " Stop: sudo systemctl stop $SERVICE_NAME"
|
||||
echo " Status: sudo systemctl status $SERVICE_NAME"
|
||||
echo " Logs: sudo journalctl -u $SERVICE_NAME -f"
|
||||
echo ""
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
#!/bin/bash
|
||||
|
||||
# SingleChat Installation Script
|
||||
# Dieses Skript installiert die SingleChat-Anwendung für Production
|
||||
# YpChat Installation Script
|
||||
# Dieses Skript installiert die YpChat-Anwendung für Production
|
||||
|
||||
# set -e wird später aktiviert, nachdem Retry-Logik definiert ist
|
||||
|
||||
echo "=========================================="
|
||||
echo "SingleChat Installation"
|
||||
echo "YpChat Installation"
|
||||
echo "=========================================="
|
||||
|
||||
# Prüfe ob Node.js installiert ist
|
||||
@@ -125,4 +125,3 @@ echo "Apache-Konfiguration sollte enthalten:"
|
||||
echo " ProxyPass / http://localhost:4000/"
|
||||
echo " ProxyPassReverse / http://localhost:4000/"
|
||||
echo ""
|
||||
|
||||
|
||||
4
ios/.gitignore
vendored
Normal file
4
ios/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
xcuserdata/
|
||||
*.xcuserstate
|
||||
DerivedData/
|
||||
.build/
|
||||
2
ios/Config/Debug.xcconfig
Normal file
2
ios/Config/Debug.xcconfig
Normal file
@@ -0,0 +1,2 @@
|
||||
# Debug: BASE_URL bei Bedarf für Staging oder lokalen Server anpassen.
|
||||
BASE_URL = https://www.ypchat.net
|
||||
2
ios/Config/Release.xcconfig
Normal file
2
ios/Config/Release.xcconfig
Normal file
@@ -0,0 +1,2 @@
|
||||
# Release: Produktions-URL
|
||||
BASE_URL = https://www.ypchat.net
|
||||
42
ios/README.md
Normal file
42
ios/README.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# YPChat iOS
|
||||
|
||||
Native iOS-App (SwiftUI): **P0–P3** wie zuvor; **P4** vollständige UI wie `YpChatRoot.kt`: Login, Tabs (Online, Suche, Posteingang, Verlauf, Konsole, Mehr), Chat mit Smileys/PhotosPicker, Feedback & Partner, **de/en** `Localizable.strings`.
|
||||
|
||||
Ausführlicher Gesamtplan: [docs/ios-app-umsetzungsplan.md](../docs/ios-app-umsetzungsplan.md).
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- macOS mit **Xcode 15+**
|
||||
- Apple-ID / Developer Team für Geräte-Builds (Simulator geht ohne kostenpflichtiges Programm)
|
||||
|
||||
## Projekt öffnen
|
||||
|
||||
```bash
|
||||
open ios/YpChat.xcodeproj
|
||||
```
|
||||
|
||||
Xcode lädt das Swift-Paket **socket.io-client-swift** (Produkt `SocketIO`) beim ersten Öffnen automatisch (**File → Packages → Resolve Package Versions** bei Problemen).
|
||||
|
||||
1. Target **YpChat** → **Signing & Capabilities**: **Team** wählen (sonst schlägt der Build auf dem Gerät fehl).
|
||||
2. Scheme **YpChat**, Ziel **iPhone-Simulator** oder echtes Gerät.
|
||||
3. **Run** (⌘R).
|
||||
|
||||
## BASE_URL
|
||||
|
||||
Wie unter Android (`ypchat.baseUrl` / Standard `https://www.ypchat.net`):
|
||||
|
||||
- `ios/Config/Debug.xcconfig` bzw. `Release.xcconfig`: Variable `BASE_URL`
|
||||
- Wird per `$(BASE_URL)` in `YpChat/Resources/Info.plist` eingetragen und zur Laufzeit in `AppConfig.baseURL` gelesen.
|
||||
|
||||
## Aktueller Funktionsstand
|
||||
|
||||
- Start lädt automatisch **GET /api/session** (Pull-to-refresh ebenfalls).
|
||||
- **Logout:** Socket trennen, **POST /api/logout**, Cookies leeren, Session erneut laden.
|
||||
- **UI (P4):** `ContentView` → `YpChatRoot` (`UI/YpChatRoot.swift`, `YpChatMoreChatViews.swift`). Steuerung über `services.repository`. Lokalisierung: `Resources/de.lproj` + `en.lproj`.
|
||||
- **Repository:** unverändert zentral; Timeout, Socket-`reduce`, REST wie Android.
|
||||
|
||||
Optional: Feinschliff (Dark Mode, größere Schrift), **P5** bereits durch Bild-Upload in der Chat-Ansicht abgedeckt.
|
||||
|
||||
## App-Icon
|
||||
|
||||
`AppIcon.appiconset` ist als Platzhalter ohne PNG angelegt. Für Archive/TestFlight in Xcode ein **1024×1024**-Icon hinterlegen oder Asset-Generator nutzen.
|
||||
397
ios/YpChat.xcodeproj/project.pbxproj
Normal file
397
ios/YpChat.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,397 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 56;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
2A0000000000000000000401 /* YpChatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A0000000000000000000101 /* YpChatApp.swift */; };
|
||||
2A0000000000000000000402 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A0000000000000000000102 /* ContentView.swift */; };
|
||||
2A0000000000000000000403 /* AppConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A0000000000000000000103 /* AppConfig.swift */; };
|
||||
2A0000000000000000000404 /* AppServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A0000000000000000000104 /* AppServices.swift */; };
|
||||
2A0000000000000000000405 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A0000000000000000000105 /* Models.swift */; };
|
||||
2A0000000000000000000406 /* RestAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A0000000000000000000106 /* RestAPIClient.swift */; };
|
||||
2A0000000000000000000407 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2A0000000000000000000108 /* Assets.xcassets */; };
|
||||
2A0000000000000000000410 /* SocketEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A0000000000000000000110 /* SocketEvent.swift */; };
|
||||
2A0000000000000000000411 /* SocketClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A0000000000000000000111 /* SocketClient.swift */; };
|
||||
2A0000000000000000000412 /* ProfileStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A0000000000000000000112 /* ProfileStore.swift */; };
|
||||
2A0000000000000000000413 /* ChatRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A0000000000000000000113 /* ChatRepository.swift */; };
|
||||
2B0000000000000000000003 /* SocketIO in Frameworks */ = {isa = PBXBuildFile; productRef = 2B0000000000000000000002 /* SocketIO */; };
|
||||
2A0000000000000000000414 /* YpChatTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A0000000000000000000114 /* YpChatTheme.swift */; };
|
||||
2A0000000000000000000415 /* YpChatL10n.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A0000000000000000000115 /* YpChatL10n.swift */; };
|
||||
2A0000000000000000000416 /* YpChatRoot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A0000000000000000000116 /* YpChatRoot.swift */; };
|
||||
2A0000000000000000000417 /* YpChatMoreChatViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A0000000000000000000117 /* YpChatMoreChatViews.swift */; };
|
||||
2A0000000000000000000408 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 2A0000000000000000000701 /* Localizable.strings */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
2A0000000000000000000101 /* YpChatApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YpChatApp.swift; sourceTree = "<group>"; };
|
||||
2A0000000000000000000102 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
2A0000000000000000000103 /* AppConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfig.swift; sourceTree = "<group>"; };
|
||||
2A0000000000000000000104 /* AppServices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppServices.swift; sourceTree = "<group>"; };
|
||||
2A0000000000000000000105 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; };
|
||||
2A0000000000000000000106 /* RestAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestAPIClient.swift; sourceTree = "<group>"; };
|
||||
2A0000000000000000000110 /* SocketEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketEvent.swift; sourceTree = "<group>"; };
|
||||
2A0000000000000000000111 /* SocketClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketClient.swift; sourceTree = "<group>"; };
|
||||
2A0000000000000000000112 /* ProfileStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStore.swift; sourceTree = "<group>"; };
|
||||
2A0000000000000000000113 /* ChatRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatRepository.swift; sourceTree = "<group>"; };
|
||||
2A0000000000000000000114 /* YpChatTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YpChatTheme.swift; sourceTree = "<group>"; };
|
||||
2A0000000000000000000115 /* YpChatL10n.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YpChatL10n.swift; sourceTree = "<group>"; };
|
||||
2A0000000000000000000116 /* YpChatRoot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YpChatRoot.swift; sourceTree = "<group>"; };
|
||||
2A0000000000000000000117 /* YpChatMoreChatViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YpChatMoreChatViews.swift; sourceTree = "<group>"; };
|
||||
2A0000000000000000000702 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
2A0000000000000000000703 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
2A0000000000000000000107 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
2A0000000000000000000108 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
2A0000000000000000000109 /* YpChat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = YpChat.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
2A0000000000000000000601 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
|
||||
2A0000000000000000000602 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
2A0000000000000000000302 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
2B0000000000000000000003 /* SocketIO in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
2A0000000000000000000200 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2A0000000000000000000206 /* Config */,
|
||||
2A0000000000000000000201 /* YpChat */,
|
||||
2A0000000000000000000205 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2A0000000000000000000201 /* YpChat */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2A0000000000000000000101 /* YpChatApp.swift */,
|
||||
2A0000000000000000000102 /* ContentView.swift */,
|
||||
2A0000000000000000000202 /* Core */,
|
||||
2A0000000000000000000203 /* Data */,
|
||||
2A0000000000000000000207 /* UI */,
|
||||
2A0000000000000000000204 /* Resources */,
|
||||
);
|
||||
path = YpChat;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2A0000000000000000000207 /* UI */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2A0000000000000000000114 /* YpChatTheme.swift */,
|
||||
2A0000000000000000000115 /* YpChatL10n.swift */,
|
||||
2A0000000000000000000116 /* YpChatRoot.swift */,
|
||||
2A0000000000000000000117 /* YpChatMoreChatViews.swift */,
|
||||
);
|
||||
path = UI;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2A0000000000000000000202 /* Core */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2A0000000000000000000103 /* AppConfig.swift */,
|
||||
2A0000000000000000000104 /* AppServices.swift */,
|
||||
2A0000000000000000000112 /* ProfileStore.swift */,
|
||||
);
|
||||
path = Core;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2A0000000000000000000203 /* Data */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2A0000000000000000000105 /* Models.swift */,
|
||||
2A0000000000000000000106 /* RestAPIClient.swift */,
|
||||
2A0000000000000000000110 /* SocketEvent.swift */,
|
||||
2A0000000000000000000111 /* SocketClient.swift */,
|
||||
2A0000000000000000000113 /* ChatRepository.swift */,
|
||||
);
|
||||
path = Data;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2A0000000000000000000204 /* Resources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2A0000000000000000000107 /* Info.plist */,
|
||||
2A0000000000000000000108 /* Assets.xcassets */,
|
||||
2A0000000000000000000701 /* Localizable.strings */,
|
||||
);
|
||||
path = Resources;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2A0000000000000000000205 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2A0000000000000000000109 /* YpChat.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2A0000000000000000000206 /* Config */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2A0000000000000000000601 /* Debug.xcconfig */,
|
||||
2A0000000000000000000602 /* Release.xcconfig */,
|
||||
);
|
||||
path = Config;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
2A0000000000000000000701 /* Localizable.strings */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
2A0000000000000000000702 /* de */,
|
||||
2A0000000000000000000703 /* en */,
|
||||
);
|
||||
name = Localizable.strings;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
2A0000000000000000000002 /* YpChat */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 2A0000000000000000000004 /* Build configuration list for PBXNativeTarget "YpChat" */;
|
||||
buildPhases = (
|
||||
2A0000000000000000000301 /* Sources */,
|
||||
2A0000000000000000000302 /* Frameworks */,
|
||||
2A0000000000000000000303 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = YpChat;
|
||||
packageProductDependencies = (
|
||||
2B0000000000000000000002 /* SocketIO */,
|
||||
);
|
||||
productName = YpChat;
|
||||
productReference = 2A0000000000000000000109 /* YpChat.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
2A0000000000000000000001 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 1500;
|
||||
LastUpgradeCheck = 1500;
|
||||
TargetAttributes = {
|
||||
2A0000000000000000000002 = {
|
||||
CreatedOnToolsVersion = 15.0;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 2A0000000000000000000003 /* Build configuration list for PBXProject "YpChat" */;
|
||||
compatibilityVersion = "Xcode 14.0";
|
||||
developmentRegion = de;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
Base,
|
||||
de,
|
||||
en,
|
||||
);
|
||||
mainGroup = 2A0000000000000000000200;
|
||||
packageReferences = (
|
||||
2B0000000000000000000001 /* XCRemoteSwiftPackageReference "socket.io-client-swift" */,
|
||||
);
|
||||
productRefGroup = 2A0000000000000000000205 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
2A0000000000000000000002 /* YpChat */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
2A0000000000000000000303 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
2A0000000000000000000407 /* Assets.xcassets in Resources */,
|
||||
2A0000000000000000000408 /* Localizable.strings in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
2A0000000000000000000301 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
2A0000000000000000000401 /* YpChatApp.swift in Sources */,
|
||||
2A0000000000000000000402 /* ContentView.swift in Sources */,
|
||||
2A0000000000000000000403 /* AppConfig.swift in Sources */,
|
||||
2A0000000000000000000404 /* AppServices.swift in Sources */,
|
||||
2A0000000000000000000405 /* Models.swift in Sources */,
|
||||
2A0000000000000000000406 /* RestAPIClient.swift in Sources */,
|
||||
2A0000000000000000000410 /* SocketEvent.swift in Sources */,
|
||||
2A0000000000000000000411 /* SocketClient.swift in Sources */,
|
||||
2A0000000000000000000412 /* ProfileStore.swift in Sources */,
|
||||
2A0000000000000000000413 /* ChatRepository.swift in Sources */,
|
||||
2A0000000000000000000414 /* YpChatTheme.swift in Sources */,
|
||||
2A0000000000000000000415 /* YpChatL10n.swift in Sources */,
|
||||
2A0000000000000000000416 /* YpChatRoot.swift in Sources */,
|
||||
2A0000000000000000000417 /* YpChatMoreChatViews.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
2A0000000000000000000501 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
2A0000000000000000000502 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
2A0000000000000000000503 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 2A0000000000000000000601 /* Debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = YpChat/Resources/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.ypchat.ios;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
2A0000000000000000000504 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 2A0000000000000000000602 /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = YpChat/Resources/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.ypchat.ios;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
2A0000000000000000000003 /* Build configuration list for PBXProject "YpChat" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
2A0000000000000000000501 /* Debug */,
|
||||
2A0000000000000000000502 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
2A0000000000000000000004 /* Build configuration list for PBXNativeTarget "YpChat" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
2A0000000000000000000503 /* Debug */,
|
||||
2A0000000000000000000504 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
2B0000000000000000000001 /* XCRemoteSwiftPackageReference "socket.io-client-swift" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/socketio/socket.io-client-swift";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 16.1.0;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
2B0000000000000000000002 /* SocketIO */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 2B0000000000000000000001 /* XCRemoteSwiftPackageReference "socket.io-client-swift" */;
|
||||
productName = SocketIO;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 2A0000000000000000000001 /* Project object */;
|
||||
}
|
||||
77
ios/YpChat.xcodeproj/xcshareddata/xcschemes/YpChat.xcscheme
Normal file
77
ios/YpChat.xcodeproj/xcshareddata/xcschemes/YpChat.xcscheme
Normal file
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1500"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "2A0000000000000000000002"
|
||||
BuildableName = "YpChat.app"
|
||||
BlueprintName = "YpChat"
|
||||
ReferencedContainer = "container:YpChat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "2A0000000000000000000002"
|
||||
BuildableName = "YpChat.app"
|
||||
BlueprintName = "YpChat"
|
||||
ReferencedContainer = "container:YpChat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "2A0000000000000000000002"
|
||||
BuildableName = "YpChat.app"
|
||||
BlueprintName = "YpChat"
|
||||
ReferencedContainer = "container:YpChat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
12
ios/YpChat/ContentView.swift
Normal file
12
ios/YpChat/ContentView.swift
Normal file
@@ -0,0 +1,12 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
var body: some View {
|
||||
YpChatRoot()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
.environmentObject(AppServices())
|
||||
}
|
||||
11
ios/YpChat/Core/AppConfig.swift
Normal file
11
ios/YpChat/Core/AppConfig.swift
Normal file
@@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
enum AppConfig {
|
||||
/// Entspricht `AppConfig.kt` / `BuildConfig.BASE_URL` (ohne trailing slash).
|
||||
static var baseURL: String {
|
||||
let raw =
|
||||
Bundle.main.object(forInfoDictionaryKey: "BASE_URL") as? String
|
||||
?? "https://www.ypchat.net"
|
||||
return raw.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
}
|
||||
}
|
||||
38
ios/YpChat/Core/AppServices.swift
Normal file
38
ios/YpChat/Core/AppServices.swift
Normal file
@@ -0,0 +1,38 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
/// DI-Container light – analog `AppContainer.kt` inkl. `ChatRepository`.
|
||||
final class AppServices: ObservableObject {
|
||||
let api: RestAPIClient
|
||||
let socket: SocketClient
|
||||
let profileStore: ProfileStore
|
||||
let repository: ChatRepository
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init() {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.httpCookieStorage = .shared
|
||||
config.httpCookieAcceptPolicy = .always
|
||||
config.httpShouldSetCookies = true
|
||||
config.timeoutIntervalForRequest = 15
|
||||
config.timeoutIntervalForResource = 30
|
||||
let urlSession = URLSession(configuration: config)
|
||||
|
||||
let api = RestAPIClient(baseURLString: AppConfig.baseURL, session: urlSession)
|
||||
let socket = SocketClient(baseURL: AppConfig.baseURL)
|
||||
let profileStore = ProfileStore()
|
||||
|
||||
self.api = api
|
||||
self.socket = socket
|
||||
self.profileStore = profileStore
|
||||
self.repository = ChatRepository(api: api, socket: socket, profileStore: profileStore)
|
||||
|
||||
repository.objectWillChange
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.objectWillChange.send()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
}
|
||||
43
ios/YpChat/Core/ProfileStore.swift
Normal file
43
ios/YpChat/Core/ProfileStore.swift
Normal file
@@ -0,0 +1,43 @@
|
||||
import Foundation
|
||||
|
||||
/// Entspricht `ProfileStore.kt` / `SavedProfile`.
|
||||
struct SavedProfile: Equatable, Sendable {
|
||||
var nickname: String = ""
|
||||
var gender: String = ""
|
||||
var age: Int = 18
|
||||
var country: String = "Germany"
|
||||
}
|
||||
|
||||
final class ProfileStore: @unchecked Sendable {
|
||||
private let defaults: UserDefaults
|
||||
private let prefix = "ypchat_profile."
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
func read() -> SavedProfile {
|
||||
SavedProfile(
|
||||
nickname: defaults.string(forKey: prefix + "nickname") ?? "",
|
||||
gender: defaults.string(forKey: prefix + "gender") ?? "",
|
||||
age: {
|
||||
let k = prefix + "age"
|
||||
return defaults.object(forKey: k) == nil ? 18 : defaults.integer(forKey: k)
|
||||
}(),
|
||||
country: defaults.string(forKey: prefix + "country") ?? "Germany"
|
||||
)
|
||||
}
|
||||
|
||||
func write(_ profile: SavedProfile) {
|
||||
defaults.set(profile.nickname.trimmingCharacters(in: .whitespacesAndNewlines), forKey: prefix + "nickname")
|
||||
defaults.set(profile.gender, forKey: prefix + "gender")
|
||||
defaults.set(profile.age, forKey: prefix + "age")
|
||||
defaults.set(profile.country, forKey: prefix + "country")
|
||||
}
|
||||
|
||||
func clear() {
|
||||
for key in ["nickname", "gender", "age", "country"] {
|
||||
defaults.removeObject(forKey: prefix + key)
|
||||
}
|
||||
}
|
||||
}
|
||||
495
ios/YpChat/Data/ChatRepository.swift
Normal file
495
ios/YpChat/Data/ChatRepository.swift
Normal file
@@ -0,0 +1,495 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
/// Entspricht `CommandTableState` in `ChatRepository.kt`.
|
||||
struct CommandTableState: Equatable, Sendable {
|
||||
var title: String
|
||||
var columns: [String]
|
||||
var rows: [[String]]
|
||||
}
|
||||
|
||||
/// Zentraler UI-Zustand wie `ChatState` in `ChatRepository.kt`.
|
||||
struct ChatState: Equatable, Sendable {
|
||||
var isConnected: Bool = false
|
||||
var isLoggedIn: Bool = false
|
||||
var expressSessionId: String?
|
||||
var currentUser: UserDto?
|
||||
var users: [UserDto] = []
|
||||
var currentConversation: String?
|
||||
var messages: [ChatMessageDto] = []
|
||||
var searchResults: [UserDto] = []
|
||||
var inboxResults: [InboxItemDto] = []
|
||||
var historyResults: [HistoryItemDto] = []
|
||||
var countries: [CountryOption] = []
|
||||
var feedbackItems: [FeedbackItemDto] = []
|
||||
var feedbackMessage: String?
|
||||
var feedbackAdminAuthenticated: Bool = false
|
||||
var feedbackAdminUserName: String?
|
||||
var feedbackAdminError: String?
|
||||
var partnerLinks: [PartnerLinkDto] = []
|
||||
var partnersError: String?
|
||||
var savedProfile: SavedProfile = SavedProfile()
|
||||
var commandLines: [String] = []
|
||||
var commandKind: String?
|
||||
var commandTable: CommandTableState?
|
||||
var awaitingLoginUsername: Bool = false
|
||||
var awaitingLoginPassword: Bool = false
|
||||
var remainingSecondsToTimeout: Int = 1800
|
||||
var isUploadingImage: Bool = false
|
||||
var imageUploadMessage: String?
|
||||
var unreadChatsCount: Int = 0
|
||||
var errorMessage: String?
|
||||
}
|
||||
|
||||
/// Analog `ChatRepository.kt`: REST + Socket + `reduce` + Timeout.
|
||||
final class ChatRepository: ObservableObject {
|
||||
@Published private(set) var state = ChatState()
|
||||
|
||||
private let api: RestAPIClient
|
||||
private let socket: SocketClient
|
||||
private let profileStore: ProfileStore
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var timeoutTickerStarted = false
|
||||
|
||||
init(api: RestAPIClient, socket: SocketClient, profileStore: ProfileStore) {
|
||||
self.api = api
|
||||
self.socket = socket
|
||||
self.profileStore = profileStore
|
||||
|
||||
socket.eventPublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] event in
|
||||
self?.reduce(event)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
startTimeoutTicker()
|
||||
}
|
||||
|
||||
// MARK: - Session / Profil
|
||||
|
||||
func restoreSession() async {
|
||||
var s = state
|
||||
s.savedProfile = profileStore.read()
|
||||
state = s
|
||||
|
||||
await loadCountries()
|
||||
await loadFeedbackAdminStatus()
|
||||
|
||||
do {
|
||||
let session = try await api.sessionStatus()
|
||||
var next = state
|
||||
next.expressSessionId = session.sessionId
|
||||
next.isLoggedIn = session.loggedIn && session.user != nil
|
||||
next.currentUser = session.user
|
||||
next.errorMessage = nil
|
||||
state = next
|
||||
if session.loggedIn, session.user != nil {
|
||||
resetTimeout()
|
||||
}
|
||||
connectSocket(expressSessionId: session.sessionId)
|
||||
} catch {
|
||||
var next = state
|
||||
next.errorMessage = error.localizedDescription
|
||||
state = next
|
||||
}
|
||||
}
|
||||
|
||||
func loadCountries() async {
|
||||
do {
|
||||
let countries = try await api.countries()
|
||||
let locale = Locale.current
|
||||
let options: [CountryOption] = countries.map { englishName, code in
|
||||
let normalized = code.uppercased()
|
||||
let localized = Locale.current.localizedString(forRegionCode: normalized)
|
||||
let display = localized.flatMap { $0.isEmpty ? nil : $0 } ?? englishName
|
||||
return CountryOption(englishName: englishName, displayName: display, isoCode: code)
|
||||
}
|
||||
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
|
||||
|
||||
var next = state
|
||||
next.countries = options
|
||||
state = next
|
||||
} catch {
|
||||
var next = state
|
||||
next.errorMessage = "Country list could not be loaded: \(error.localizedDescription)"
|
||||
state = next
|
||||
}
|
||||
}
|
||||
|
||||
func login(userName: String, gender: String, age: Int, country: String) async {
|
||||
profileStore.write(SavedProfile(nickname: userName, gender: gender, age: age, country: country))
|
||||
|
||||
let session = try? await api.sessionStatus()
|
||||
let sessionId = session?.sessionId ?? state.expressSessionId
|
||||
|
||||
var next = state
|
||||
next.expressSessionId = sessionId
|
||||
state = next
|
||||
|
||||
if !socket.isConnected {
|
||||
connectSocket(expressSessionId: sessionId)
|
||||
}
|
||||
socket.login(userName: userName, gender: gender, age: age, country: country, expressSessionId: sessionId)
|
||||
resetTimeout()
|
||||
}
|
||||
|
||||
func logout() async {
|
||||
try? await api.logout()
|
||||
socket.disconnect()
|
||||
clearCookies()
|
||||
var fresh = ChatState()
|
||||
fresh.savedProfile = profileStore.read()
|
||||
fresh.countries = state.countries
|
||||
state = fresh
|
||||
}
|
||||
|
||||
func connectSocket(expressSessionId: String? = nil) {
|
||||
let sid = expressSessionId ?? state.expressSessionId
|
||||
socket.connect()
|
||||
if let sid {
|
||||
socket.setSessionId(sid)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chat
|
||||
|
||||
func openConversation(userName: String) {
|
||||
var next = state
|
||||
next.currentConversation = userName
|
||||
next.messages = []
|
||||
state = next
|
||||
socket.requestConversation(withUserName: userName)
|
||||
resetTimeout()
|
||||
}
|
||||
|
||||
func closeConversation() {
|
||||
var next = state
|
||||
next.currentConversation = nil
|
||||
next.messages = []
|
||||
state = next
|
||||
}
|
||||
|
||||
func sendMessage(text: String) {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
let target = state.currentConversation
|
||||
let isCommand = trimmed.hasPrefix("/")
|
||||
if target == nil, !isCommand { return }
|
||||
|
||||
socket.sendMessage(toUserName: target, message: trimmed)
|
||||
if !isCommand {
|
||||
let msg = ChatMessageDto(
|
||||
from: state.currentUser?.userName ?? "",
|
||||
to: target,
|
||||
message: trimmed,
|
||||
messageId: nil,
|
||||
timestamp: ISO8601DateFormatter().string(from: Date()),
|
||||
read: false,
|
||||
isImage: false,
|
||||
imageType: nil,
|
||||
imageUrl: nil,
|
||||
imageCode: nil
|
||||
)
|
||||
var next = state
|
||||
next.messages.append(msg)
|
||||
state = next
|
||||
}
|
||||
resetTimeout()
|
||||
}
|
||||
|
||||
func sendImage(toUserName: String, imageCode: String, imageUrl: String) {
|
||||
let absoluteUrl: String =
|
||||
imageUrl.hasPrefix("http") ? imageUrl : AppConfig.baseURL + imageUrl
|
||||
socket.sendImage(toUserName: toUserName, imageCode: imageCode, imageUrl: absoluteUrl)
|
||||
let msg = ChatMessageDto(
|
||||
from: state.currentUser?.userName ?? "",
|
||||
to: toUserName,
|
||||
message: absoluteUrl,
|
||||
messageId: nil,
|
||||
timestamp: ISO8601DateFormatter().string(from: Date()),
|
||||
read: false,
|
||||
isImage: true,
|
||||
imageType: nil,
|
||||
imageUrl: absoluteUrl,
|
||||
imageCode: imageCode
|
||||
)
|
||||
var next = state
|
||||
next.messages.append(msg)
|
||||
state = next
|
||||
resetTimeout()
|
||||
}
|
||||
|
||||
func setImageUploadState(inProgress: Bool, message: String? = nil) {
|
||||
var next = state
|
||||
next.isUploadingImage = inProgress
|
||||
next.imageUploadMessage = message
|
||||
state = next
|
||||
}
|
||||
|
||||
func uploadImage(data: Data, fileName: String, mimeType: String) async throws -> ImageUploadResponse {
|
||||
let result = try await api.uploadImage(data: data, fileName: fileName, mimeType: mimeType)
|
||||
return result.response
|
||||
}
|
||||
|
||||
// MARK: - Feedback / Partner
|
||||
|
||||
func loadFeedback() async {
|
||||
do {
|
||||
let response = try await api.feedback()
|
||||
var next = state
|
||||
next.feedbackItems = response.items
|
||||
next.feedbackMessage = nil
|
||||
state = next
|
||||
} catch {
|
||||
var next = state
|
||||
next.feedbackMessage = error.localizedDescription
|
||||
state = next
|
||||
}
|
||||
}
|
||||
|
||||
func submitFeedback(comment: String) async {
|
||||
let trimmed = comment.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
|
||||
let profile = state.savedProfile
|
||||
do {
|
||||
try await api.submitFeedback(
|
||||
FeedbackRequest(
|
||||
name: profile.nickname,
|
||||
age: profile.age,
|
||||
country: profile.country,
|
||||
gender: profile.gender,
|
||||
comment: trimmed
|
||||
)
|
||||
)
|
||||
var next = state
|
||||
next.feedbackMessage = "Feedback saved"
|
||||
state = next
|
||||
await loadFeedback()
|
||||
} catch {
|
||||
var next = state
|
||||
next.feedbackMessage = error.localizedDescription
|
||||
state = next
|
||||
}
|
||||
}
|
||||
|
||||
func loadFeedbackAdminStatus() async {
|
||||
do {
|
||||
let response = try await api.feedbackAdminStatus()
|
||||
var next = state
|
||||
next.feedbackAdminAuthenticated = response.authenticated
|
||||
next.feedbackAdminUserName = response.username
|
||||
next.feedbackAdminError = nil
|
||||
state = next
|
||||
} catch {
|
||||
var next = state
|
||||
next.feedbackAdminAuthenticated = false
|
||||
next.feedbackAdminUserName = nil
|
||||
state = next
|
||||
}
|
||||
}
|
||||
|
||||
func loginFeedbackAdmin(username: String, password: String) async {
|
||||
do {
|
||||
let response = try await api.feedbackAdminLogin(FeedbackAdminLoginRequest(username: username, password: password))
|
||||
var next = state
|
||||
next.feedbackAdminAuthenticated = true
|
||||
next.feedbackAdminUserName = response.username
|
||||
next.feedbackAdminError = nil
|
||||
state = next
|
||||
await loadFeedback()
|
||||
} catch {
|
||||
var next = state
|
||||
next.feedbackAdminError = error.localizedDescription
|
||||
state = next
|
||||
}
|
||||
}
|
||||
|
||||
func logoutFeedbackAdmin() async {
|
||||
try? await api.feedbackAdminLogout()
|
||||
var next = state
|
||||
next.feedbackAdminAuthenticated = false
|
||||
next.feedbackAdminUserName = nil
|
||||
next.feedbackAdminError = nil
|
||||
state = next
|
||||
}
|
||||
|
||||
func deleteFeedback(id: String) async {
|
||||
do {
|
||||
try await api.deleteFeedback(id: id)
|
||||
await loadFeedback()
|
||||
} catch {
|
||||
var next = state
|
||||
next.feedbackAdminError = error.localizedDescription
|
||||
state = next
|
||||
}
|
||||
}
|
||||
|
||||
func loadPartners() async {
|
||||
do {
|
||||
let links = try await api.partners()
|
||||
var next = state
|
||||
next.partnerLinks = links
|
||||
next.partnersError = nil
|
||||
state = next
|
||||
} catch {
|
||||
var next = state
|
||||
next.partnersError = error.localizedDescription
|
||||
state = next
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Socket actions
|
||||
|
||||
func search(nameIncludes: String?, minAge: Int?, maxAge: Int?, countries: [String], genders: [String]) {
|
||||
socket.userSearch(nameIncludes: nameIncludes, minAge: minAge, maxAge: maxAge, countries: countries, genders: genders)
|
||||
resetTimeout()
|
||||
}
|
||||
|
||||
func requestInbox() {
|
||||
socket.requestOpenConversations()
|
||||
resetTimeout()
|
||||
}
|
||||
|
||||
func requestHistory() {
|
||||
socket.requestHistory()
|
||||
resetTimeout()
|
||||
}
|
||||
|
||||
func blockUser(userName: String) {
|
||||
socket.blockUser(userName: userName)
|
||||
resetTimeout()
|
||||
}
|
||||
|
||||
func unblockUser(userName: String) {
|
||||
socket.unblockUser(userName: userName)
|
||||
resetTimeout()
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func clearCookies() {
|
||||
HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
|
||||
}
|
||||
|
||||
private func startTimeoutTicker() {
|
||||
guard !timeoutTickerStarted else { return }
|
||||
timeoutTickerStarted = true
|
||||
Timer.publish(every: 1, on: .main, in: .common)
|
||||
.autoconnect()
|
||||
.sink { [weak self] _ in
|
||||
self?.runTimeoutTick()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func runTimeoutTick() {
|
||||
guard state.isLoggedIn else { return }
|
||||
let nextSec = max(0, state.remainingSecondsToTimeout - 1)
|
||||
var next = state
|
||||
next.remainingSecondsToTimeout = nextSec
|
||||
state = next
|
||||
if nextSec == 0 {
|
||||
Task { await self.logout() }
|
||||
}
|
||||
}
|
||||
|
||||
private func resetTimeout() {
|
||||
guard state.isLoggedIn || state.currentUser != nil else { return }
|
||||
var next = state
|
||||
next.remainingSecondsToTimeout = 1800
|
||||
state = next
|
||||
}
|
||||
|
||||
private func reduce(_ event: SocketEvent) {
|
||||
state = Self.apply(state: state, event: event, socket: socket)
|
||||
}
|
||||
|
||||
private static func apply(state: ChatState, event: SocketEvent, socket: SocketClient) -> ChatState {
|
||||
var current = state
|
||||
switch event {
|
||||
case .connectionChanged(let connected, _):
|
||||
current.isConnected = connected
|
||||
|
||||
case .connected(let sessionId, let loggedIn, let user):
|
||||
if let sid = sessionId {
|
||||
socket.setSessionId(sid)
|
||||
}
|
||||
current.expressSessionId = sessionId ?? current.expressSessionId
|
||||
current.isLoggedIn = loggedIn || current.isLoggedIn
|
||||
current.currentUser = user ?? current.currentUser
|
||||
current.errorMessage = nil
|
||||
if current.isLoggedIn {
|
||||
current.remainingSecondsToTimeout = 1800
|
||||
}
|
||||
|
||||
case .loginSuccess(let sessionId, let user):
|
||||
current.expressSessionId = sessionId ?? current.expressSessionId
|
||||
current.isLoggedIn = true
|
||||
current.currentUser = user
|
||||
current.errorMessage = nil
|
||||
current.remainingSecondsToTimeout = 1800
|
||||
|
||||
case .userList(let users):
|
||||
current.users = users
|
||||
|
||||
case .incomingMessage(let message):
|
||||
let active = current.currentConversation == message.from
|
||||
if active {
|
||||
current.messages.append(message)
|
||||
}
|
||||
if !active {
|
||||
current.unreadChatsCount += 1
|
||||
}
|
||||
current.remainingSecondsToTimeout = 1800
|
||||
|
||||
case .messageSent:
|
||||
break
|
||||
|
||||
case .conversation(let withUserName, let messages):
|
||||
current.currentConversation = withUserName
|
||||
current.messages = messages
|
||||
current.unreadChatsCount = max(0, current.unreadChatsCount - 1)
|
||||
current.remainingSecondsToTimeout = 1800
|
||||
|
||||
case .searchResults(let results):
|
||||
current.searchResults = results
|
||||
|
||||
case .historyResults(let results):
|
||||
current.historyResults = results
|
||||
|
||||
case .inboxResults(let results):
|
||||
current.inboxResults = results
|
||||
|
||||
case .unreadChats(let count):
|
||||
current.unreadChatsCount = count
|
||||
|
||||
case .userBlocked(let userName):
|
||||
current.errorMessage = "\(userName) blocked"
|
||||
|
||||
case .userUnblocked(let userName):
|
||||
current.errorMessage = "\(userName) unblocked"
|
||||
|
||||
case .commandResult(let lines, let kind):
|
||||
current.commandLines = lines
|
||||
current.commandKind = kind
|
||||
current.commandTable = nil
|
||||
current.awaitingLoginUsername = kind == "loginPromptUsername"
|
||||
current.awaitingLoginPassword = kind == "loginPromptPassword"
|
||||
if kind == "info" || kind.hasPrefix("login") {
|
||||
current.errorMessage = lines.joined(separator: " | ")
|
||||
}
|
||||
|
||||
case .commandTable(let title, let columns, let rows):
|
||||
current.commandLines = []
|
||||
current.commandKind = nil
|
||||
current.commandTable = CommandTableState(title: title, columns: columns, rows: rows)
|
||||
|
||||
case .error(let message):
|
||||
current.errorMessage = message
|
||||
}
|
||||
return current
|
||||
}
|
||||
}
|
||||
105
ios/YpChat/Data/Models.swift
Normal file
105
ios/YpChat/Data/Models.swift
Normal file
@@ -0,0 +1,105 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Session / User (RestApi.kt / Models.kt)
|
||||
|
||||
struct UserDto: Codable, Equatable, Sendable {
|
||||
var sessionId: String?
|
||||
var userName: String = ""
|
||||
var gender: String = ""
|
||||
var age: Int = 0
|
||||
var country: String = ""
|
||||
var isoCountryCode: String = ""
|
||||
}
|
||||
|
||||
struct SessionResponse: Codable, Equatable, Sendable {
|
||||
var loggedIn: Bool = false
|
||||
var sessionId: String?
|
||||
var user: UserDto?
|
||||
}
|
||||
|
||||
struct LogoutResponse: Codable, Equatable, Sendable {
|
||||
var success: Bool = false
|
||||
}
|
||||
|
||||
// MARK: - Chat / Listen (Socket / Models.kt)
|
||||
|
||||
struct ChatMessageDto: Codable, Equatable, Sendable {
|
||||
var from: String = ""
|
||||
var to: String?
|
||||
var message: String = ""
|
||||
var messageId: String?
|
||||
var timestamp: String = ""
|
||||
var read: Bool = false
|
||||
var isImage: Bool = false
|
||||
var imageType: String?
|
||||
var imageUrl: String?
|
||||
var imageCode: String?
|
||||
}
|
||||
|
||||
struct HistoryItemDto: Codable, Equatable, Sendable {
|
||||
var userName: String = ""
|
||||
var lastMessage: ChatMessageDto?
|
||||
}
|
||||
|
||||
struct InboxItemDto: Codable, Equatable, Sendable {
|
||||
var userName: String = ""
|
||||
var unreadCount: Int = 0
|
||||
}
|
||||
|
||||
// MARK: - REST (Models.kt)
|
||||
|
||||
struct CountryOption: Equatable, Sendable {
|
||||
var englishName: String
|
||||
var displayName: String
|
||||
var isoCode: String
|
||||
}
|
||||
|
||||
struct FeedbackItemDto: Codable, Equatable, Sendable {
|
||||
var id: String = ""
|
||||
var name: String?
|
||||
var age: Int?
|
||||
var country: String?
|
||||
var gender: String?
|
||||
var comment: String = ""
|
||||
var createdAt: String = ""
|
||||
}
|
||||
|
||||
struct FeedbackResponse: Codable, Equatable, Sendable {
|
||||
var items: [FeedbackItemDto] = []
|
||||
var admin: Bool = false
|
||||
}
|
||||
|
||||
struct FeedbackAdminStatusResponse: Codable, Equatable, Sendable {
|
||||
var authenticated: Bool = false
|
||||
var username: String?
|
||||
}
|
||||
|
||||
struct FeedbackRequest: Codable, Equatable, Sendable {
|
||||
var name: String = ""
|
||||
var age: Int?
|
||||
var country: String = ""
|
||||
var gender: String = ""
|
||||
var comment: String = ""
|
||||
}
|
||||
|
||||
struct FeedbackAdminLoginRequest: Codable, Equatable, Sendable {
|
||||
var username: String = ""
|
||||
var password: String = ""
|
||||
}
|
||||
|
||||
struct PartnerLinkDto: Codable, Equatable, Sendable {
|
||||
var pageName: String = ""
|
||||
var url: String = ""
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case pageName = "Page Name"
|
||||
case url
|
||||
}
|
||||
}
|
||||
|
||||
struct ImageUploadResponse: Codable, Equatable, Sendable {
|
||||
var success: Bool = false
|
||||
var code: String?
|
||||
var url: String?
|
||||
var error: String?
|
||||
}
|
||||
182
ios/YpChat/Data/RestAPIClient.swift
Normal file
182
ios/YpChat/Data/RestAPIClient.swift
Normal file
@@ -0,0 +1,182 @@
|
||||
import Foundation
|
||||
|
||||
enum RestAPIError: Error, LocalizedError {
|
||||
case invalidURL(String)
|
||||
case badStatus(Int, String?)
|
||||
case decoding(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL(let s): return "Ungültige URL: \(s)"
|
||||
case .badStatus(let code, let body): return "HTTP \(code): \(body ?? "")"
|
||||
case .decoding(let e): return "JSON: \(e.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// REST-Schicht analog `RestApi.kt`.
|
||||
final class RestAPIClient: @unchecked Sendable {
|
||||
private let baseURLString: String
|
||||
private let session: URLSession
|
||||
private let decoder: JSONDecoder
|
||||
private let encoder: JSONEncoder
|
||||
|
||||
init(baseURLString: String, session: URLSession) {
|
||||
self.baseURLString = baseURLString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
self.session = session
|
||||
self.decoder = JSONDecoder()
|
||||
self.encoder = JSONEncoder()
|
||||
}
|
||||
|
||||
func sessionStatus() async throws -> SessionResponse {
|
||||
try await request(path: "api/session", method: "GET", body: nil)
|
||||
}
|
||||
|
||||
func logout() async throws -> LogoutResponse {
|
||||
let url = try url(for: "api/logout")
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
let (data, response) = try await session.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw RestAPIError.badStatus(-1, nil)
|
||||
}
|
||||
guard (200 ... 299).contains(http.statusCode) else {
|
||||
let text = String(data: data, encoding: .utf8)
|
||||
throw RestAPIError.badStatus(http.statusCode, text)
|
||||
}
|
||||
if data.isEmpty {
|
||||
return LogoutResponse(success: true)
|
||||
}
|
||||
do {
|
||||
return try decoder.decode(LogoutResponse.self, from: data)
|
||||
} catch {
|
||||
throw RestAPIError.decoding(error)
|
||||
}
|
||||
}
|
||||
|
||||
func countries() async throws -> [String: String] {
|
||||
try await request(path: "api/countries", method: "GET", body: nil)
|
||||
}
|
||||
|
||||
func feedback() async throws -> FeedbackResponse {
|
||||
try await request(path: "api/feedback", method: "GET", body: nil)
|
||||
}
|
||||
|
||||
func feedbackAdminStatus() async throws -> FeedbackAdminStatusResponse {
|
||||
try await request(path: "api/feedback/admin-status", method: "GET", body: nil)
|
||||
}
|
||||
|
||||
func submitFeedback(_ requestBody: FeedbackRequest) async throws {
|
||||
let data = try encoder.encode(requestBody)
|
||||
try await requestVoid(path: "api/feedback", method: "POST", body: data)
|
||||
}
|
||||
|
||||
func feedbackAdminLogin(_ requestBody: FeedbackAdminLoginRequest) async throws -> FeedbackAdminStatusResponse {
|
||||
let data = try encoder.encode(requestBody)
|
||||
return try await request(path: "api/feedback/admin-login", method: "POST", body: data)
|
||||
}
|
||||
|
||||
func feedbackAdminLogout() async throws {
|
||||
try await requestVoid(path: "api/feedback/admin-logout", method: "POST", body: Data())
|
||||
}
|
||||
|
||||
func deleteFeedback(id: String) async throws {
|
||||
let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id
|
||||
try await requestVoid(path: "api/feedback/\(encoded)", method: "DELETE", body: nil)
|
||||
}
|
||||
|
||||
func partners() async throws -> [PartnerLinkDto] {
|
||||
try await request(path: "api/partners", method: "GET", body: nil)
|
||||
}
|
||||
|
||||
/// Multipart-Feld `image` wie OkHttp `MultipartBody.Part`.
|
||||
func uploadImage(data: Data, fileName: String, mimeType: String) async throws -> (response: ImageUploadResponse, httpStatus: Int) {
|
||||
let url = try url(for: "api/upload-image")
|
||||
let boundary = "Boundary-\(UUID().uuidString)"
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
var body = Data()
|
||||
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
||||
body.append(
|
||||
"Content-Disposition: form-data; name=\"image\"; filename=\"\(fileName)\"\r\n".data(using: .utf8)!
|
||||
)
|
||||
body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!)
|
||||
body.append(data)
|
||||
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
|
||||
req.httpBody = body
|
||||
|
||||
let (respData, response) = try await session.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw RestAPIError.badStatus(-1, nil)
|
||||
}
|
||||
guard (200 ... 299).contains(http.statusCode) else {
|
||||
let text = String(data: respData, encoding: .utf8)
|
||||
throw RestAPIError.badStatus(http.statusCode, text)
|
||||
}
|
||||
do {
|
||||
let decoded = try decoder.decode(ImageUploadResponse.self, from: respData)
|
||||
return (decoded, http.statusCode)
|
||||
} catch {
|
||||
throw RestAPIError.decoding(error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Request
|
||||
|
||||
private func url(for path: String) throws -> URL {
|
||||
let trimmed = path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
guard let url = URL(string: "\(baseURLString)/\(trimmed)") else {
|
||||
throw RestAPIError.invalidURL("\(baseURLString)/\(trimmed)")
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
private func requestVoid(path: String, method: String, body: Data?) async throws {
|
||||
let url = try url(for: path)
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = method
|
||||
req.httpBody = body
|
||||
if let body, !body.isEmpty {
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
}
|
||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
let (data, response) = try await session.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw RestAPIError.badStatus(-1, nil)
|
||||
}
|
||||
guard (200 ... 299).contains(http.statusCode) else {
|
||||
let text = String(data: data, encoding: .utf8)
|
||||
throw RestAPIError.badStatus(http.statusCode, text)
|
||||
}
|
||||
}
|
||||
|
||||
private func request<T: Decodable>(path: String, method: String, body: Data?) async throws -> T {
|
||||
let url = try url(for: path)
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = method
|
||||
req.httpBody = body
|
||||
if let body, !body.isEmpty {
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
}
|
||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
let (data, response) = try await session.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw RestAPIError.badStatus(-1, nil)
|
||||
}
|
||||
guard (200 ... 299).contains(http.statusCode) else {
|
||||
let text = String(data: data, encoding: .utf8)
|
||||
throw RestAPIError.badStatus(http.statusCode, text)
|
||||
}
|
||||
do {
|
||||
return try decoder.decode(T.self, from: data)
|
||||
} catch {
|
||||
throw RestAPIError.decoding(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
348
ios/YpChat/Data/SocketClient.swift
Normal file
348
ios/YpChat/Data/SocketClient.swift
Normal file
@@ -0,0 +1,348 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import SocketIO
|
||||
|
||||
/// Entspricht `SocketClient.kt`: gleiche Events, `setSessionId`, `login`, Emits.
|
||||
final class SocketClient: @unchecked Sendable {
|
||||
private let baseURL: String
|
||||
private var manager: SocketManager?
|
||||
private var socket: SocketIOClient?
|
||||
private var pendingExpressSessionId: String?
|
||||
|
||||
private let eventSubject = PassthroughSubject<SocketEvent, Never>()
|
||||
|
||||
/// Ereignisse vom Server (analog `SharedFlow<SocketEvent>`).
|
||||
var eventPublisher: AnyPublisher<SocketEvent, Never> {
|
||||
eventSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
var isConnected: Bool {
|
||||
socket?.status == .connected
|
||||
}
|
||||
|
||||
init(baseURL: String) {
|
||||
self.baseURL = baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
}
|
||||
|
||||
func connect() {
|
||||
disconnect()
|
||||
|
||||
guard let url = URL(string: baseURL) else {
|
||||
notify(.error("Ungültige Socket-Basis-URL"))
|
||||
return
|
||||
}
|
||||
|
||||
var config: SocketIOClientConfiguration = [
|
||||
.log(false),
|
||||
.compress,
|
||||
.reconnects(true),
|
||||
.reconnectAttempts(-1),
|
||||
.reconnectWait(1),
|
||||
.reconnectWaitMax(30),
|
||||
.version(.three),
|
||||
]
|
||||
|
||||
if let cookies = HTTPCookieStorage.shared.cookies(for: url), !cookies.isEmpty {
|
||||
config.insert(.cookies(cookies))
|
||||
}
|
||||
|
||||
let manager = SocketManager(socketURL: url, config: config)
|
||||
self.manager = manager
|
||||
let socket = manager.defaultSocket
|
||||
self.socket = socket
|
||||
|
||||
socket.on(clientEvent: .connect) { [weak self] _, _ in
|
||||
self?.handleSocketConnect()
|
||||
}
|
||||
socket.on(clientEvent: .disconnect) { [weak self] data, _ in
|
||||
let reason = data.first.map { String(describing: $0) }
|
||||
self?.notify(.connectionChanged(connected: false, reason: reason))
|
||||
}
|
||||
socket.on(clientEvent: .error) { [weak self] data, _ in
|
||||
let msg = data.first.map { String(describing: $0) } ?? ""
|
||||
self?.notify(.error("Socket-Verbindung fehlgeschlagen: \(msg)"))
|
||||
}
|
||||
|
||||
registerServerEvents(socket)
|
||||
manager.connect()
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
socket?.removeAllHandlers()
|
||||
manager?.disconnect()
|
||||
socket = nil
|
||||
manager = nil
|
||||
}
|
||||
|
||||
func setSessionId(_ expressSessionId: String) {
|
||||
pendingExpressSessionId = expressSessionId
|
||||
guard isConnected else { return }
|
||||
socket?.emit("setSessionId", ["expressSessionId": expressSessionId])
|
||||
}
|
||||
|
||||
func login(userName: String, gender: String, age: Int, country: String, expressSessionId: String?) {
|
||||
var payload: [String: Any] = [
|
||||
"userName": userName,
|
||||
"gender": gender,
|
||||
"age": age,
|
||||
"country": country,
|
||||
]
|
||||
if let expressSessionId {
|
||||
payload["expressSessionId"] = expressSessionId
|
||||
} else {
|
||||
payload["expressSessionId"] = NSNull()
|
||||
}
|
||||
socket?.emit("login", payload)
|
||||
}
|
||||
|
||||
func sendMessage(toUserName: String?, message: String, messageId: String = "\(Int(Date().timeIntervalSince1970 * 1000))") {
|
||||
var payload: [String: Any] = [
|
||||
"message": message.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
"messageId": messageId,
|
||||
]
|
||||
if let toUserName, !toUserName.isEmpty {
|
||||
payload["toUserName"] = toUserName
|
||||
}
|
||||
socket?.emit("message", payload)
|
||||
}
|
||||
|
||||
func sendImage(toUserName: String, imageCode: String, imageUrl: String, messageId: String = "\(Int(Date().timeIntervalSince1970 * 1000))") {
|
||||
socket?.emit(
|
||||
"message",
|
||||
[
|
||||
"toUserName": toUserName,
|
||||
"message": imageCode,
|
||||
"messageId": messageId,
|
||||
"isImage": true,
|
||||
"imageUrl": imageUrl,
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func requestConversation(withUserName: String) {
|
||||
socket?.emit("requestConversation", ["withUserName": withUserName])
|
||||
}
|
||||
|
||||
func userSearch(nameIncludes: String?, minAge: Int?, maxAge: Int?, countries: [String], genders: [String]) {
|
||||
var payload: [String: Any] = [
|
||||
"countries": countries,
|
||||
"genders": genders,
|
||||
]
|
||||
payload["nameIncludes"] = nameIncludes ?? NSNull()
|
||||
payload["minAge"] = minAge.map { $0 as Any } ?? NSNull()
|
||||
payload["maxAge"] = maxAge.map { $0 as Any } ?? NSNull()
|
||||
socket?.emit("userSearch", payload)
|
||||
}
|
||||
|
||||
func requestHistory() {
|
||||
socket?.emit("requestHistory")
|
||||
}
|
||||
|
||||
func requestOpenConversations() {
|
||||
socket?.emit("requestOpenConversations")
|
||||
}
|
||||
|
||||
func blockUser(userName: String) {
|
||||
socket?.emit("blockUser", ["userName": userName])
|
||||
}
|
||||
|
||||
func unblockUser(userName: String) {
|
||||
socket?.emit("unblockUser", ["userName": userName])
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func handleSocketConnect() {
|
||||
if let sid = pendingExpressSessionId {
|
||||
socket?.emit("setSessionId", ["expressSessionId": sid])
|
||||
}
|
||||
// Reihenfolge wie `SocketClient.kt` (EVENT_CONNECT): zuerst setSessionId, dann ConnectionChanged.
|
||||
notify(.connectionChanged(connected: true, reason: nil))
|
||||
}
|
||||
|
||||
private func notify(_ event: SocketEvent) {
|
||||
eventSubject.send(event)
|
||||
}
|
||||
|
||||
private func registerServerEvents(_ socket: SocketIOClient) {
|
||||
socket.on("connected") { [weak self] data, _ in
|
||||
guard let json = Self.firstDictionary(from: data) else { return }
|
||||
self?.notify(
|
||||
.connected(
|
||||
sessionId: json.stringOrNil("sessionId"),
|
||||
loggedIn: json.bool("loggedIn", default: false),
|
||||
user: json.userObject("user")
|
||||
)
|
||||
)
|
||||
}
|
||||
socket.on("loginSuccess") { [weak self] data, _ in
|
||||
guard let json = Self.firstDictionary(from: data) else { return }
|
||||
self?.notify(.loginSuccess(sessionId: json.stringOrNil("sessionId"), user: json.userObject("user")))
|
||||
}
|
||||
socket.on("userList") { [weak self] data, _ in
|
||||
guard let json = Self.firstDictionary(from: data),
|
||||
let users = json["users"] as? [[String: Any]]
|
||||
else { return }
|
||||
self?.notify(.userList(users: users.map { UserDto(json: $0) }))
|
||||
}
|
||||
socket.on("message") { [weak self] data, _ in
|
||||
guard let json = Self.firstDictionary(from: data) else { return }
|
||||
self?.notify(.incomingMessage(ChatMessageDto(json: json)))
|
||||
}
|
||||
socket.on("messageSent") { [weak self] data, _ in
|
||||
guard let json = Self.firstDictionary(from: data) else { return }
|
||||
self?.notify(.messageSent(messageId: json.stringOrNil("messageId"), to: json.stringOrNil("to")))
|
||||
}
|
||||
socket.on("conversation") { [weak self] data, _ in
|
||||
guard let json = Self.firstDictionary(from: data) else { return }
|
||||
let withName = json.string("with")
|
||||
let rawMessages = json["messages"] as? [[String: Any]] ?? []
|
||||
let messages = rawMessages.map { ChatMessageDto(json: $0) }
|
||||
self?.notify(.conversation(withUserName: withName, messages: messages))
|
||||
}
|
||||
socket.on("searchResults") { [weak self] data, _ in
|
||||
guard let json = Self.firstDictionary(from: data),
|
||||
let results = json["results"] as? [[String: Any]]
|
||||
else { return }
|
||||
self?.notify(.searchResults(results.map { UserDto(json: $0) }))
|
||||
}
|
||||
socket.on("historyResults") { [weak self] data, _ in
|
||||
guard let json = Self.firstDictionary(from: data),
|
||||
let results = json["results"] as? [[String: Any]]
|
||||
else { return }
|
||||
self?.notify(.historyResults(results.map { HistoryItemDto(json: $0) }))
|
||||
}
|
||||
socket.on("inboxResults") { [weak self] data, _ in
|
||||
guard let json = Self.firstDictionary(from: data),
|
||||
let results = json["results"] as? [[String: Any]]
|
||||
else { return }
|
||||
self?.notify(.inboxResults(results.map { InboxItemDto(json: $0) }))
|
||||
}
|
||||
socket.on("unreadChats") { [weak self] data, _ in
|
||||
guard let json = Self.firstDictionary(from: data) else { return }
|
||||
self?.notify(.unreadChats(count: json.int("count", default: 0)))
|
||||
}
|
||||
socket.on("userBlocked") { [weak self] data, _ in
|
||||
guard let json = Self.firstDictionary(from: data) else { return }
|
||||
self?.notify(.userBlocked(json.string("userName")))
|
||||
}
|
||||
socket.on("userUnblocked") { [weak self] data, _ in
|
||||
guard let json = Self.firstDictionary(from: data) else { return }
|
||||
self?.notify(.userUnblocked(json.string("userName")))
|
||||
}
|
||||
socket.on("commandResult") { [weak self] data, _ in
|
||||
guard let json = Self.firstDictionary(from: data) else { return }
|
||||
let lines = (json["lines"] as? [Any])?.map { String(describing: $0) } ?? []
|
||||
let kind = json.string("kind", default: "info")
|
||||
self?.notify(.commandResult(lines: lines, kind: kind))
|
||||
}
|
||||
socket.on("commandTable") { [weak self] data, _ in
|
||||
guard let json = Self.firstDictionary(from: data) else { return }
|
||||
let title = json.string("title", default: "Ausgabe")
|
||||
let columns = (json["columns"] as? [Any])?.map { String(describing: $0) } ?? []
|
||||
let rows = Self.nestedStringRows(json["rows"])
|
||||
self?.notify(.commandTable(title: title, columns: columns, rows: rows))
|
||||
}
|
||||
socket.on("error") { [weak self] data, _ in
|
||||
let json = Self.firstDictionary(from: data)
|
||||
let message =
|
||||
json?.stringOrNil("message")
|
||||
?? data.first.map { String(describing: $0) }
|
||||
?? "Unbekannter Socket-Fehler"
|
||||
self?.notify(.error(message))
|
||||
}
|
||||
}
|
||||
|
||||
private static func firstDictionary(from data: [Any]) -> [String: Any]? {
|
||||
guard let first = data.first else { return nil }
|
||||
if let d = first as? [String: Any] { return d }
|
||||
if let n = first as? NSDictionary { return n as? [String: Any] }
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func nestedStringRows(_ any: Any?) -> [[String]] {
|
||||
guard let outer = any as? [Any] else { return [] }
|
||||
return outer.map { row in
|
||||
(row as? [Any])?.map { String(describing: $0) } ?? []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - JSON helpers (JSONObject-Äquivalent)
|
||||
|
||||
private extension Dictionary where Key == String, Value == Any {
|
||||
func stringOrNil(_ key: String) -> String? {
|
||||
guard let v = self[key], !(v is NSNull) else { return nil }
|
||||
if let s = v as? String { return s }
|
||||
return String(describing: v)
|
||||
}
|
||||
|
||||
func string(_ key: String, default def: String = "") -> String {
|
||||
stringOrNil(key) ?? def
|
||||
}
|
||||
|
||||
func bool(_ key: String, default def: Bool) -> Bool {
|
||||
guard let v = self[key], !(v is NSNull) else { return def }
|
||||
if let b = v as? Bool { return b }
|
||||
if let n = v as? NSNumber { return n.boolValue }
|
||||
return def
|
||||
}
|
||||
|
||||
func int(_ key: String, default def: Int) -> Int {
|
||||
guard let v = self[key], !(v is NSNull) else { return def }
|
||||
if let i = v as? Int { return i }
|
||||
if let n = v as? NSNumber { return n.intValue }
|
||||
return def
|
||||
}
|
||||
|
||||
func userObject(_ key: String) -> UserDto? {
|
||||
guard let nested = self[key] as? [String: Any] else { return nil }
|
||||
return UserDto(json: nested)
|
||||
}
|
||||
}
|
||||
|
||||
private extension UserDto {
|
||||
init(json: [String: Any]) {
|
||||
self.init(
|
||||
sessionId: json.stringOrNil("sessionId"),
|
||||
userName: json.string("userName"),
|
||||
gender: json.string("gender"),
|
||||
age: json.int("age", default: 0),
|
||||
country: json.string("country"),
|
||||
isoCountryCode: json.string("isoCountryCode")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private extension ChatMessageDto {
|
||||
init(json: [String: Any]) {
|
||||
self.init(
|
||||
from: json.string("from"),
|
||||
to: json.stringOrNil("to"),
|
||||
message: json.string("message"),
|
||||
messageId: json.stringOrNil("messageId"),
|
||||
timestamp: json.string("timestamp"),
|
||||
read: json.bool("read", default: false),
|
||||
isImage: json.bool("isImage", default: false),
|
||||
imageType: json.stringOrNil("imageType"),
|
||||
imageUrl: json.stringOrNil("imageUrl"),
|
||||
imageCode: json.stringOrNil("imageCode")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private extension HistoryItemDto {
|
||||
init(json: [String: Any]) {
|
||||
let last: ChatMessageDto? = {
|
||||
guard let nested = json["lastMessage"] as? [String: Any] else { return nil }
|
||||
return ChatMessageDto(json: nested)
|
||||
}()
|
||||
self.init(userName: json.string("userName"), lastMessage: last)
|
||||
}
|
||||
}
|
||||
|
||||
private extension InboxItemDto {
|
||||
init(json: [String: Any]) {
|
||||
self.init(userName: json.string("userName"), unreadCount: json.int("unreadCount", default: 0))
|
||||
}
|
||||
}
|
||||
21
ios/YpChat/Data/SocketEvent.swift
Normal file
21
ios/YpChat/Data/SocketEvent.swift
Normal file
@@ -0,0 +1,21 @@
|
||||
import Foundation
|
||||
|
||||
/// Entspricht `SocketEvent.kt` (Android).
|
||||
enum SocketEvent: Sendable {
|
||||
case connected(sessionId: String?, loggedIn: Bool, user: UserDto?)
|
||||
case loginSuccess(sessionId: String?, user: UserDto?)
|
||||
case userList(users: [UserDto])
|
||||
case incomingMessage(ChatMessageDto)
|
||||
case messageSent(messageId: String?, to: String?)
|
||||
case conversation(withUserName: String, messages: [ChatMessageDto])
|
||||
case searchResults([UserDto])
|
||||
case historyResults([HistoryItemDto])
|
||||
case inboxResults([InboxItemDto])
|
||||
case unreadChats(count: Int)
|
||||
case userBlocked(String)
|
||||
case userUnblocked(String)
|
||||
case commandResult(lines: [String], kind: String)
|
||||
case commandTable(title: String, columns: [String], rows: [[String]])
|
||||
case error(String)
|
||||
case connectionChanged(connected: Bool, reason: String?)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.329",
|
||||
"green" : "0.435",
|
||||
"red" : "0.184"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
6
ios/YpChat/Resources/Assets.xcassets/Contents.json
Normal file
6
ios/YpChat/Resources/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
47
ios/YpChat/Resources/Info.plist
Normal file
47
ios/YpChat/Resources/Info.plist
Normal file
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BASE_URL</key>
|
||||
<string>$(BASE_URL)</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>YPChat</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Bilder aus deiner Mediathek kannst du im Chat versenden.</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
99
ios/YpChat/Resources/de.lproj/Localizable.strings
Normal file
99
ios/YpChat/Resources/de.lproj/Localizable.strings
Normal file
@@ -0,0 +1,99 @@
|
||||
"app_name" = "YPChat";
|
||||
"landing_eyebrow" = "YpChat";
|
||||
"landing_title" = "Direkt in den Chat";
|
||||
"landing_copy" = "Kompakt, schnell und ohne Umwege. Erstelle dein Profil und starte sofort eine Unterhaltung.";
|
||||
"feature_worldwide_chat" = "Weltweiter Chat";
|
||||
"feature_image_exchange" = "Bildaustausch";
|
||||
"feature_compact_controls" = "Kompakte Bedienung";
|
||||
"profile_title" = "Profil starten";
|
||||
"profile_copy" = "Wenige Angaben genügen für den Einstieg.";
|
||||
"label_nick" = "Bitte gib deinen Nicknamen für den Chat ein";
|
||||
"label_gender" = "Geschlecht";
|
||||
"label_age" = "Alter";
|
||||
"label_country" = "Land";
|
||||
"button_start_chat" = "Chat starten";
|
||||
"gender_female" = "Weiblich";
|
||||
"gender_male" = "Männlich";
|
||||
"gender_pair" = "Paar";
|
||||
"gender_trans_mf" = "Transgender (M->F)";
|
||||
"gender_trans_fm" = "Transgender (F->M)";
|
||||
"socket_connected" = "Socket verbunden";
|
||||
"socket_connecting" = "Socket wird verbunden...";
|
||||
"status_online" = "online";
|
||||
"status_connecting" = "verbindet...";
|
||||
"tab_online" = "Online";
|
||||
"tab_search" = "Suche";
|
||||
"tab_inbox" = "Posteingang";
|
||||
"tab_history" = "Verlauf";
|
||||
"tab_console" = "Konsole";
|
||||
"tab_more" = "Mehr";
|
||||
"logout" = "Verlassen";
|
||||
"timeout_in" = "Timeout in %@";
|
||||
"no_users_online" = "Noch keine anderen Nutzer online.";
|
||||
"search_username_includes" = "Benutzername enthält";
|
||||
"search_from_age" = "Von Alter";
|
||||
"search_to_age" = "Bis Alter";
|
||||
"search_all" = "Alle";
|
||||
"search_button" = "Suchen";
|
||||
"search_no_results" = "Keine Ergebnisse.";
|
||||
"search_min_age_error" = "Das Mindestalter darf nicht größer als das Höchstalter sein.";
|
||||
"inbox_empty" = "Keine ungelesenen Chats.";
|
||||
"inbox_new_count" = "%d neu";
|
||||
"history_empty" = "Noch kein Verlauf.";
|
||||
"no_message" = "Keine Nachricht";
|
||||
"back" = "Zurück";
|
||||
"block" = "Blockieren";
|
||||
"unblock" = "Entsperren";
|
||||
"message_placeholder" = "Nachricht";
|
||||
"button_image" = "Bild";
|
||||
"button_send" = "Senden";
|
||||
"button_smileys" = "Smileys";
|
||||
"image_message" = "Bildnachricht";
|
||||
"image_upload_in_progress" = "Bild wird hochgeladen...";
|
||||
"image_upload_success" = "Bild wurde hochgeladen.";
|
||||
"image_upload_failed" = "Bild-Upload fehlgeschlagen.";
|
||||
"image_upload_too_large" = "Das Bild ist größer als 5 MB.";
|
||||
"image_upload_open_failed" = "Das Bild konnte nicht geöffnet werden.";
|
||||
"feedback_created_at" = "Eingegangen %@";
|
||||
"feedback_meta_separator" = " • ";
|
||||
"countries_load_error" = "Länderliste konnte nicht geladen werden: %@";
|
||||
"user_blocked" = "%@ wurde blockiert";
|
||||
"user_unblocked" = "%@ wurde entsperrt";
|
||||
"feedback_title" = "Feedback";
|
||||
"feedback_comment" = "Kommentar";
|
||||
"feedback_send" = "Feedback senden";
|
||||
"feedback_saved" = "Feedback wurde gespeichert.";
|
||||
"feedback_empty" = "Noch kein Feedback vorhanden.";
|
||||
"anonymous" = "Anonym";
|
||||
"feedback_admin_user" = "Admin-Benutzer";
|
||||
"feedback_admin_password" = "Passwort";
|
||||
"feedback_admin_login" = "Admin-Login";
|
||||
"feedback_admin_logout" = "Admin abmelden";
|
||||
"feedback_delete" = "Löschen";
|
||||
"console_title" = "Konsole";
|
||||
"console_placeholder" = "/Befehl oder Admin-Login-Eingabe senden";
|
||||
"console_send" = "Senden";
|
||||
"console_empty" = "Noch keine Konsolen-Ausgabe.";
|
||||
"more_title" = "Mehr";
|
||||
"more_feedback" = "Feedback";
|
||||
"more_partners" = "Partner";
|
||||
"more_faq" = "FAQ";
|
||||
"more_rules" = "Regeln";
|
||||
"more_safety" = "Sicherheit";
|
||||
"more_imprint" = "Impressum";
|
||||
"more_back" = "Zur Übersicht";
|
||||
"partners_intro" = "Empfehlungen und befreundete Projekte für unsere Community.";
|
||||
"faq_intro" = "Antworten auf häufige Fragen zum Chat.";
|
||||
"rules_intro" = "Grundregeln für respektvollen Chat.";
|
||||
"safety_intro" = "Tipps für Privatsphäre und sichere Nutzung.";
|
||||
"imprint_intro" = "Rechtliche Hinweise und Kontaktdaten.";
|
||||
"external_link" = "Externer Link";
|
||||
"faq_title" = "Häufige Fragen";
|
||||
"rules_title" = "Chat-Regeln";
|
||||
"safety_title" = "Sicherheit und Privatsphäre";
|
||||
"imprint_title" = "Impressum";
|
||||
"partners_title" = "Partner";
|
||||
"faq_body" = "Wähle einen Nicknamen, gib deine Profildaten an und starte den Chat. Teile keine sensiblen Daten wie Telefonnummern, Adressen, Passwörter oder Zahlungsinformationen. Du kannst Bilder senden, Benutzer blockieren und Feedback für ernste Vorfälle nutzen.";
|
||||
"rules_body" = "Keine Beleidigungen, Hassrede, illegalen Inhalte, Spam oder unerwünschte Belästigung. Sende nur Bilder, die du teilen darfst, und respektiere die Privatsphäre anderer.";
|
||||
"safety_body" = "Nutze einen Nicknamen, der dich nicht identifiziert. Teile keine privaten Kontakt- oder Zahlungsdaten. Sei vorsichtig mit Links von Unbekannten und beende Gespräche, die sich falsch anfühlen. Nutze Blockieren und Feedback bei schweren Vorfällen.";
|
||||
"imprint_body" = "Torsten Schulz, Friedrich-Stampfer-Str. 21, 60437 Frankfurt. Kontakt: tsschulz@tsschulz.de. Für externe Links sind deren Betreiber verantwortlich.";
|
||||
99
ios/YpChat/Resources/en.lproj/Localizable.strings
Normal file
99
ios/YpChat/Resources/en.lproj/Localizable.strings
Normal file
@@ -0,0 +1,99 @@
|
||||
"app_name" = "YPChat";
|
||||
"landing_eyebrow" = "YpChat";
|
||||
"landing_title" = "Directly into chat";
|
||||
"landing_copy" = "Compact, fast and without detours. Create your profile and start a conversation right away.";
|
||||
"feature_worldwide_chat" = "Worldwide chat";
|
||||
"feature_image_exchange" = "Image exchange";
|
||||
"feature_compact_controls" = "Compact controls";
|
||||
"profile_title" = "Start profile";
|
||||
"profile_copy" = "A few details are enough to get started.";
|
||||
"label_nick" = "Please enter your chat nickname";
|
||||
"label_gender" = "Gender";
|
||||
"label_age" = "Age";
|
||||
"label_country" = "Country";
|
||||
"button_start_chat" = "Start chat";
|
||||
"gender_female" = "Female";
|
||||
"gender_male" = "Male";
|
||||
"gender_pair" = "Couple";
|
||||
"gender_trans_mf" = "Transgender (M->F)";
|
||||
"gender_trans_fm" = "Transgender (F->M)";
|
||||
"socket_connected" = "Socket connected";
|
||||
"socket_connecting" = "Connecting socket...";
|
||||
"status_online" = "online";
|
||||
"status_connecting" = "connecting...";
|
||||
"tab_online" = "Online";
|
||||
"tab_search" = "Search";
|
||||
"tab_inbox" = "Inbox";
|
||||
"tab_history" = "History";
|
||||
"tab_console" = "Console";
|
||||
"tab_more" = "More";
|
||||
"logout" = "Logout";
|
||||
"timeout_in" = "Timeout in %@";
|
||||
"no_users_online" = "No other users online yet.";
|
||||
"search_username_includes" = "Username contains";
|
||||
"search_from_age" = "From age";
|
||||
"search_to_age" = "To age";
|
||||
"search_all" = "All";
|
||||
"search_button" = "Search";
|
||||
"search_no_results" = "No results.";
|
||||
"search_min_age_error" = "Minimum age must not be greater than maximum age.";
|
||||
"inbox_empty" = "No unread chats.";
|
||||
"inbox_new_count" = "%d new";
|
||||
"history_empty" = "No history yet.";
|
||||
"no_message" = "No message";
|
||||
"back" = "Back";
|
||||
"block" = "Block";
|
||||
"unblock" = "Unblock";
|
||||
"message_placeholder" = "Message";
|
||||
"button_image" = "Image";
|
||||
"button_send" = "Send";
|
||||
"button_smileys" = "Smileys";
|
||||
"image_message" = "Image message";
|
||||
"image_upload_in_progress" = "Uploading image...";
|
||||
"image_upload_success" = "Image uploaded.";
|
||||
"image_upload_failed" = "Image upload failed.";
|
||||
"image_upload_too_large" = "Image is larger than 5 MB.";
|
||||
"image_upload_open_failed" = "Image could not be opened.";
|
||||
"feedback_created_at" = "Received %@";
|
||||
"feedback_meta_separator" = " • ";
|
||||
"countries_load_error" = "Country list could not be loaded: %@";
|
||||
"user_blocked" = "%@ has been blocked";
|
||||
"user_unblocked" = "%@ has been unblocked";
|
||||
"feedback_title" = "Feedback";
|
||||
"feedback_comment" = "Comment";
|
||||
"feedback_send" = "Send feedback";
|
||||
"feedback_saved" = "Feedback saved.";
|
||||
"feedback_empty" = "No feedback yet.";
|
||||
"anonymous" = "Anonymous";
|
||||
"feedback_admin_user" = "Admin user";
|
||||
"feedback_admin_password" = "Password";
|
||||
"feedback_admin_login" = "Admin login";
|
||||
"feedback_admin_logout" = "Logout admin";
|
||||
"feedback_delete" = "Delete";
|
||||
"console_title" = "Console";
|
||||
"console_placeholder" = "Enter /command or admin login input";
|
||||
"console_send" = "Send";
|
||||
"console_empty" = "No console output yet.";
|
||||
"more_title" = "More";
|
||||
"more_feedback" = "Feedback";
|
||||
"more_partners" = "Partners";
|
||||
"more_faq" = "FAQ";
|
||||
"more_rules" = "Rules";
|
||||
"more_safety" = "Safety";
|
||||
"more_imprint" = "Imprint";
|
||||
"more_back" = "Back to overview";
|
||||
"partners_intro" = "Recommended and friendly projects for our community.";
|
||||
"faq_intro" = "Answers to common questions about the chat.";
|
||||
"rules_intro" = "Basic rules for respectful chatting.";
|
||||
"safety_intro" = "Tips for privacy and safer usage.";
|
||||
"imprint_intro" = "Legal notice and contact details.";
|
||||
"external_link" = "External link";
|
||||
"faq_title" = "Frequently Asked Questions";
|
||||
"rules_title" = "Chat Rules";
|
||||
"safety_title" = "Safety and Privacy";
|
||||
"imprint_title" = "Imprint";
|
||||
"partners_title" = "Partners";
|
||||
"faq_body" = "Choose a nickname, enter your profile details and start chatting. Do not share sensitive data like phone numbers, addresses, passwords or payment information. You can send images, block users and use feedback for serious issues.";
|
||||
"rules_body" = "No insults, hate speech, illegal content, spam or unwanted harassment. Only send images you are allowed to share and respect the privacy of others.";
|
||||
"safety_body" = "Use a nickname that does not identify you. Do not share private contact or payment data. Be careful with links from strangers and end conversations that feel wrong. Use block and feedback for serious incidents.";
|
||||
"imprint_body" = "Torsten Schulz, Friedrich-Stampfer-Str. 21, 60437 Frankfurt. Contact: tsschulz@tsschulz.de. External links are the responsibility of their operators.";
|
||||
121
ios/YpChat/UI/YpChatL10n.swift
Normal file
121
ios/YpChat/UI/YpChatL10n.swift
Normal file
@@ -0,0 +1,121 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// Schlüssel wie Android `strings.xml` – Auflösung über `Localizable.strings` (de/en).
|
||||
enum L10n {
|
||||
static func tr(_ key: String) -> String {
|
||||
String(localized: String.LocalizationValue(key))
|
||||
}
|
||||
|
||||
static func timeoutIn(_ time: String) -> String {
|
||||
String(format: tr("timeout_in"), time)
|
||||
}
|
||||
|
||||
static func inboxNew(_ count: Int) -> String {
|
||||
String(format: tr("inbox_new_count"), locale: .current, count)
|
||||
}
|
||||
|
||||
static func feedbackCreatedAt(_ date: String) -> String {
|
||||
String(format: tr("feedback_created_at"), locale: .current, date)
|
||||
}
|
||||
|
||||
static func countriesLoadError(_ detail: String) -> String {
|
||||
String(format: tr("countries_load_error"), locale: .current, detail)
|
||||
}
|
||||
|
||||
static func userBlocked(_ name: String) -> String {
|
||||
String(format: tr("user_blocked"), locale: .current, name)
|
||||
}
|
||||
|
||||
static func userUnblocked(_ name: String) -> String {
|
||||
String(format: tr("user_unblocked"), locale: .current, name)
|
||||
}
|
||||
}
|
||||
|
||||
func formatTimeout(totalSeconds: Int) -> String {
|
||||
let minutes = totalSeconds / 60
|
||||
let seconds = totalSeconds % 60
|
||||
return String(format: "%d:%02d", minutes, seconds)
|
||||
}
|
||||
|
||||
func localizeRuntimeMessage(_ message: String) -> String {
|
||||
if message.hasPrefix("Country list could not be loaded:") {
|
||||
let detail = String(message.dropFirst("Country list could not be loaded:".count)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return L10n.countriesLoadError(detail)
|
||||
}
|
||||
switch message {
|
||||
case "Image uploaded": return L10n.tr("image_upload_success")
|
||||
case "Image upload failed": return L10n.tr("image_upload_failed")
|
||||
case "Image exceeds 5 MB": return L10n.tr("image_upload_too_large")
|
||||
case "Image could not be opened": return L10n.tr("image_upload_open_failed")
|
||||
case "Feedback saved": return L10n.tr("feedback_saved")
|
||||
default:
|
||||
break
|
||||
}
|
||||
if message.hasSuffix(" blocked") {
|
||||
let name = String(message.dropLast(" blocked".count))
|
||||
return L10n.userBlocked(name)
|
||||
}
|
||||
if message.hasSuffix(" unblocked") {
|
||||
let name = String(message.dropLast(" unblocked".count))
|
||||
return L10n.userUnblocked(name)
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
func formatFeedbackTimestamp(_ createdAt: String) -> String? {
|
||||
guard !createdAt.isEmpty else { return nil }
|
||||
return ISO8601DateFormatter().date(from: createdAt).map {
|
||||
DateFormatter.localizedString(from: $0, dateStyle: .medium, timeStyle: .short)
|
||||
}
|
||||
}
|
||||
|
||||
func smileyEmoji(hexCode: String) -> String {
|
||||
guard let v = UInt32(hexCode, radix: 16), let scalar = UnicodeScalar(v) else { return "" }
|
||||
return String(Character(scalar))
|
||||
}
|
||||
|
||||
struct SmileyItem: Identifiable {
|
||||
var id: String { token }
|
||||
let token: String
|
||||
let hexCode: String
|
||||
}
|
||||
|
||||
let ypChatSmileys: [SmileyItem] = [
|
||||
SmileyItem(token: ":)", hexCode: "1F642"),
|
||||
SmileyItem(token: ":D", hexCode: "1F600"),
|
||||
SmileyItem(token: ":(", hexCode: "1F641"),
|
||||
SmileyItem(token: ";)", hexCode: "1F609"),
|
||||
SmileyItem(token: ":p", hexCode: "1F60B"),
|
||||
SmileyItem(token: ";p", hexCode: "1F61C"),
|
||||
SmileyItem(token: "O)", hexCode: "1F607"),
|
||||
SmileyItem(token: ":*", hexCode: "1F617"),
|
||||
SmileyItem(token: "(h)", hexCode: "1FA77"),
|
||||
SmileyItem(token: "xD", hexCode: "1F602"),
|
||||
SmileyItem(token: ":@", hexCode: "1F635"),
|
||||
SmileyItem(token: ":O", hexCode: "1F632"),
|
||||
SmileyItem(token: ":3", hexCode: "1F63A"),
|
||||
SmileyItem(token: ":|", hexCode: "1F610"),
|
||||
SmileyItem(token: ":/", hexCode: "1FAE4"),
|
||||
SmileyItem(token: ":#", hexCode: "1F912"),
|
||||
SmileyItem(token: "#)", hexCode: "1F973"),
|
||||
SmileyItem(token: "%)", hexCode: "1F974"),
|
||||
SmileyItem(token: "(t)", hexCode: "1F44D"),
|
||||
SmileyItem(token: ":'(", hexCode: "1F622"),
|
||||
]
|
||||
|
||||
struct GenderOptionRow: Identifiable {
|
||||
var id: String { value }
|
||||
let value: String
|
||||
let label: String
|
||||
}
|
||||
|
||||
func displayCountryName(user: UserDto, countries: [CountryOption]) -> String {
|
||||
if let byEnglish = countries.first(where: { $0.englishName == user.country }) {
|
||||
return byEnglish.displayName
|
||||
}
|
||||
if let byIso = countries.first(where: { $0.isoCode.caseInsensitiveCompare(user.isoCountryCode) == .orderedSame }) {
|
||||
return byIso.displayName
|
||||
}
|
||||
return user.country
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user