Compare commits

...

30 Commits

Author SHA1 Message Date
Torsten Schulz (local)
62f5914b04 Änderung der werbung
All checks were successful
Deploy SingleChat / deploy (push) Successful in 26s
2026-06-18 08:33:42 +02:00
Torsten Schulz (local)
0c6ab5727f logging
All checks were successful
Deploy SingleChat / deploy (push) Successful in 24s
2026-06-17 16:58:51 +02:00
Torsten Schulz (local)
0d24fcd9e5 debugging eingebaut, deploy gefixt
All checks were successful
Deploy SingleChat / deploy (push) Successful in 25s
2026-06-17 16:37:40 +02:00
Torsten Schulz (local)
c46e64367d More fixes
All checks were successful
Deploy SingleChat / deploy (push) Successful in 23s
2026-06-17 15:44:22 +02:00
Torsten Schulz (local)
5d4129b5b3 Fixeds and enhancements
Some checks failed
Deploy SingleChat / deploy (push) Has been cancelled
2026-06-17 15:44:11 +02:00
Torsten Schulz (local)
10e6e7a80a videochat integriert
All checks were successful
Deploy SingleChat / deploy (push) Successful in 27s
2026-06-17 12:53:03 +02:00
Torsten Schulz (local)
8c9a600645 fixed some domain issues
All checks were successful
Deploy SingleChat / deploy (push) Successful in 24s
2026-06-16 12:47:58 +02:00
Torsten Schulz (local)
e279215b85 some fixes
All checks were successful
Deploy SingleChat / deploy (push) Successful in 27s
2026-06-16 12:15:57 +02:00
Torsten Schulz (local)
1f342f555e für playstore änderungen
All checks were successful
Deploy SingleChat / deploy (push) Successful in 23s
2026-06-16 11:50:31 +02:00
Torsten Schulz (local)
155fce15e1 Added cam photos
All checks were successful
Deploy SingleChat / deploy (push) Successful in 24s
2026-06-16 09:52:36 +02:00
Torsten Schulz (local)
bbb025be63 Fixed deployment
All checks were successful
Deploy SingleChat / deploy (push) Successful in 22s
2026-06-15 16:46:10 +02:00
Torsten Schulz (local)
e9aef0050a Fixed deployment
Some checks failed
Deploy SingleChat / deploy (push) Failing after 9s
2026-06-15 16:44:27 +02:00
Torsten Schulz (local)
c48c833b65 deploy fix
Some checks failed
Deploy SingleChat / deploy (push) Failing after 13s
2026-06-15 16:38:01 +02:00
Torsten Schulz (local)
f6e0b95a1f fixed deploy
Some checks failed
Deploy SingleChat / deploy (push) Failing after 2s
2026-06-15 16:28:09 +02:00
Torsten Schulz (local)
8d7c7d6f2a autodeploy implemented
Some checks failed
Deploy SingleChat / deploy (push) Failing after 1s
2026-06-15 16:17:20 +02:00
Torsten Schulz (local)
5bb9db2aad seo-improvements 2026-06-15 16:07:42 +02:00
Torsten Schulz (local)
1ca1b45b55 cebuano erstellt 2026-05-30 20:21:08 +02:00
Torsten Schulz (local)
88c742de0b Verbessere die Handhabung von Skripten: Füge Logik hinzu, um SCRIPT-Tags im Head auszuführen und nicht in den Platzhalter einzufügen 2026-05-18 15:56:25 +02:00
Torsten Schulz (local)
a17dd00048 Normalisiere die Anzeigegröße und passe die Position von eingefügten Elementen im Header-Anzeigenplatzhalter an 2026-05-18 15:54:08 +02:00
Torsten Schulz (local)
c8059f94f8 Verbessere die Logik zum Laden von Anzeigen: Sofortige Anzeige bei Vorliegen von Zustimmung, Auto-Load oder Debug-Option 2026-05-18 15:51:36 +02:00
Torsten Schulz (local)
fdeecec63e Füge Platzhalter für Propeller-Anzeigen hinzu und verbessere das Skript-Rendering 2026-05-18 15:48:40 +02:00
Torsten Schulz (local)
53a8aa3869 Verbessere die Handhabung von Anzeigenknoten: Füge Logik hinzu, um Skripte sicher auszuführen und Textknoten zu entfernen, um sichtbaren JS-Code zu vermeiden. 2026-05-18 15:43:37 +02:00
Torsten Schulz (local)
12a724614d Füge Fallback-Logik für Anzeigen hinzu: Lade iframe-Backup, wenn nach 3s kein Ad im Platzhalter erscheint 2026-05-18 15:38:32 +02:00
Torsten Schulz (local)
fbeda6a528 Füge Beobachtungslogik hinzu, um eingefügte Anzeigenknoten zu erkennen und in den Platzhalter zu verschieben 2026-05-18 15:31:39 +02:00
Torsten Schulz (local)
84cabdca7f Füge Debug-Option hinzu, um das Laden von Anzeigen zu erzwingen, und verbessere das Fehlerhandling beim Laden des Propeller-Skripts 2026-05-18 15:18:29 +02:00
Torsten Schulz (local)
0f6ba9222b Aktualisiere HeaderAdBanner-Komponente mit Standardwerten für Propeller-Anzeigen und verbessere die Skriptintegration 2026-05-18 15:12:35 +02:00
Torsten Schulz (local)
44dd757243 Implement HeaderAdBanner component and integrate ad loading logic with consent handling 2026-05-18 15:06:29 +02:00
Torsten Schulz (local)
37d752cce9 Enhance broadcast.js with comprehensive statistics reporting
- Introduced a new function to read server start logs, providing insights into server uptime and usage.
- Refactored the statistics reporting to include detailed metrics from live server connections, start logs, and login records.
- Improved the presentation of user statistics, including unique users, login timestamps, and demographic data, enhancing overall clarity and usability of the statistics command.
- Updated command descriptions for better understanding of the new functionality.

These changes collectively enrich the statistics reporting capabilities, offering a more complete view of server and user activity.
2026-05-12 15:43:52 +02:00
Torsten Schulz (local)
8d323ceab1 Refactor command handling in ChatRepository for Android and iOS
- Reset commandLines and commandKind to default values when a command table event is received, ensuring a clean state for subsequent commands.
- This change improves the handling of command events and maintains consistency across platforms.
2026-05-12 15:40:34 +02:00
Torsten Schulz (local)
810b084e10 Refactor application structure and configuration
- Updated the application namespace and ID from "net.ypchat.app" to "de.ypchat.android" for better alignment with branding.
- Increased Gradle heap size settings to optimize build performance.
- Disabled dependency constraints to simplify dependency management.
- Removed obsolete files related to the previous application structure, including MainActivity, YpChatApp, and various core components, streamlining the codebase.

These changes collectively enhance the application's configuration and structure, improving maintainability and performance.
2026-05-12 14:25:55 +02:00
124 changed files with 10398 additions and 974 deletions

14
.env.example Normal file
View 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=

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

View File

@@ -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) Implementierung:
- 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)
Aktiv wird der Banner nur mit: - [client/src/components/HeaderAdBanner.vue](/mnt/share/torsten/Programs/SingleChat/client/src/components/HeaderAdBanner.vue)
- `VITE_ADSENSE_CLIENT` Funktionsweise:
- `VITE_ADSENSE_HEADER_SLOT`
## 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 Wird spaeter ein anderer Werbeanbieter oder ein weiteres Adsterra-Format verwendet, sollte diese Datei entsprechend aktualisiert werden.
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.

View File

@@ -1,8 +1,8 @@
# Android-App-Konzept fuer SingleChat # Android-App-Konzept fuer YpChat
## Zielbild ## 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: Das Ziel fuer den ersten Release ist Funktionsgleichheit mit dem Kern-Chat:

View File

@@ -1,8 +1,8 @@
e# Design-Konzept: Modernisierung SingleChat e# Design-Konzept: Modernisierung YpChat
## Zielbild ## 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 - eine konsistentere Farbwelt
- dezentere Rundungen - dezentere Rundungen
@@ -350,4 +350,4 @@ Sinnvolle Reihenfolge:
## Ergebnisbild in einem Satz ## 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.

View File

@@ -13,7 +13,7 @@
```bash ```bash
# Als root oder mit sudo # Als root oder mit sudo
sudo mkdir -p /opt/ypchat 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 sudo chown -R www-data:www-data /opt/ypchat
``` ```
@@ -53,6 +53,23 @@ sudo journalctl -u ypchat -f
## Troubleshooting ## 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 ### Service startet nicht
```bash ```bash
@@ -86,4 +103,3 @@ cd /opt/ypchat
sudo -u www-data npm run build sudo -u www-data npm run build
sudo -u www-data cp -r client/dist docroot/ sudo -u www-data cp -r client/dist docroot/
``` ```

View File

@@ -1,4 +1,4 @@
# SingleChat Production Installation # YpChat Production Installation
## Voraussetzungen ## Voraussetzungen
@@ -37,12 +37,11 @@ Die Apache-Konfiguration sollte bereits vorhanden sein. Stelle sicher, dass sie
```apache ```apache
<VirtualHost *:443> <VirtualHost *:443>
ServerName ypchat.net ServerName ypchat.net
ServerAlias www.ypchat.net
# SSL-Konfiguration # SSL-Konfiguration
Include /etc/letsencrypt/options-ssl-apache.conf Include /etc/letsencrypt/options-ssl-apache.conf
SSLCertificateFile /etc/letsencrypt/live/www.ypchat.net/fullchain.pem SSLCertificateFile /etc/letsencrypt/live/ypchat.net/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/www.ypchat.net/privkey.pem SSLCertificateKeyFile /etc/letsencrypt/live/ypchat.net/privkey.pem
# Reverse Proxy zu Node.js # Reverse Proxy zu Node.js
ProxyPreserveHost On ProxyPreserveHost On
@@ -55,6 +54,17 @@ Die Apache-Konfiguration sollte bereits vorhanden sein. Stelle sicher, dass sie
RewriteCond %{HTTP:Connection} upgrade [NC] RewriteCond %{HTTP:Connection} upgrade [NC]
RewriteRule ^/?(.*) "ws://localhost:4000/$1" [P,L] RewriteRule ^/?(.*) "ws://localhost:4000/$1" [P,L]
</VirtualHost> </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! Wichtig: Die WebSocket-Rewrite-Regeln sind für Socket.IO erforderlich!
@@ -99,6 +109,27 @@ sudo systemctl stop singlechat
## Updates ## 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: Nach Code-Änderungen:
```bash ```bash
@@ -152,6 +183,25 @@ Die folgenden Umgebungsvariablen können in `.env` gesetzt werden:
- `NODE_ENV`: `production` (automatisch gesetzt) - `NODE_ENV`: `production` (automatisch gesetzt)
- `PORT`: `4000` (Standard) - `PORT`: `4000` (Standard)
- `SESSION_SECRET`: Zufälliges Secret für Sessions (wird von install.sh generiert) - `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 ## Sicherheit
@@ -159,4 +209,3 @@ Die folgenden Umgebungsvariablen können in `.env` gesetzt werden:
- **HTTPS**: Stelle sicher, dass SSL/TLS korrekt konfiguriert ist - **HTTPS**: Stelle sicher, dass SSL/TLS korrekt konfiguriert ist
- **Firewall**: Port 4000 sollte nur von localhost erreichbar sein - **Firewall**: Port 4000 sollte nur von localhost erreichbar sein
- **Updates**: Halte Node.js und alle Dependencies aktuell - **Updates**: Halte Node.js und alle Dependencies aktuell

View File

@@ -2,7 +2,7 @@
## 1) Host-/TLS-Konsistenz (Apex und www) ## 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) ### Zertifikatserstellung (Let's Encrypt / Certbot, Apache)
@@ -20,10 +20,10 @@
### Apache-Redirects (kanonischer Host + HTTPS) ### Apache-Redirects (kanonischer Host + HTTPS)
Empfohlene Logik: Empfohlene Logik:
- `http://ypchat.net/*` -> `https://www.ypchat.net/*` (301) - `http://ypchat.net/*` -> `https://ypchat.net/*` (301)
- `http://www.ypchat.net/*` -> `https://www.ypchat.net/*` (301) - `http://www.ypchat.net/*` -> `https://ypchat.net/*` (301)
- `https://ypchat.net/*` -> `https://www.ypchat.net/*` (301) - `https://www.ypchat.net/*` -> `https://ypchat.net/*` (301)
- Nur `https://www.ypchat.net/*` liefert `200` - Nur `https://ypchat.net/*` liefert `200`
Beispiel (VirtualHost fuer Port 80): Beispiel (VirtualHost fuer Port 80):
@@ -32,32 +32,32 @@ Beispiel (VirtualHost fuer Port 80):
ServerName ypchat.net ServerName ypchat.net
ServerAlias www.ypchat.net ServerAlias www.ypchat.net
RewriteEngine On RewriteEngine On
RewriteRule ^ https://www.ypchat.net%{REQUEST_URI} [R=301,L] RewriteRule ^ https://ypchat.net%{REQUEST_URI} [R=301,L]
</VirtualHost> </VirtualHost>
``` ```
Beispiel (VirtualHost fuer `https://ypchat.net`): Beispiel (VirtualHost fuer `https://www.ypchat.net`):
```apache ```apache
<VirtualHost *:443> <VirtualHost *:443>
ServerName ypchat.net ServerName www.ypchat.net
SSLEngine on SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/ypchat.net/fullchain.pem SSLCertificateFile /etc/letsencrypt/live/ypchat.net/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/ypchat.net/privkey.pem SSLCertificateKeyFile /etc/letsencrypt/live/ypchat.net/privkey.pem
RewriteEngine On RewriteEngine On
RewriteRule ^ https://www.ypchat.net%{REQUEST_URI} [R=301,L] RewriteRule ^ https://ypchat.net%{REQUEST_URI} [R=301,L]
</VirtualHost> </VirtualHost>
``` ```
Verifikation: Verifikation:
- `curl -I https://ypchat.net/` -> `301 Location: https://www.ypchat.net/` - `curl -I https://ypchat.net/` -> `200`
- `curl -I https://www.ypchat.net/` -> `200` - `curl -I https://www.ypchat.net/` -> `301 Location: https://ypchat.net/`
- Browser ohne TLS-Warnung fuer beide Hosts - Browser ohne TLS-Warnung fuer beide Hosts
## 3) Search Console / Reindexing ## 3) Search Console / Reindexing
- [ ] In Google Search Console `https://www.ypchat.net` als Hauptproperty nutzen. - [ ] In Google Search Console `https://ypchat.net` als Hauptproperty nutzen.
- [ ] Sitemap neu einreichen: `https://www.ypchat.net/sitemap.xml`. - [ ] Sitemap neu einreichen: `https://ypchat.net/sitemap.xml`.
- [ ] Live-Tests ausfuehren fuer: - [ ] Live-Tests ausfuehren fuer:
- [ ] `/` - [ ] `/`
- [ ] `/partners` - [ ] `/partners`

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

View File

@@ -17,7 +17,7 @@ Empfehlung:
Alternative: Alternative:
- `SingleChat by YPChat` - `YpChat`
- `YPChat` - `YPChat`
## Kurzbeschreibung ## 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: 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? 2. Welche URL wird als Datenschutzerklärung verwendet?
3. Soll die App als `Social`, `Dating` oder `Communication` eingeordnet werden? 3. Soll die App als `Social`, `Dating` oder `Communication` eingeordnet werden?

View File

@@ -1,6 +1,6 @@
# YPChat Android # YPChat Android
Native Android-App fuer den bestehenden SingleChat/YPChat-Server. Native Android-App fuer den bestehenden YPChat-Server.
## Stack ## Stack

View File

@@ -27,15 +27,15 @@ val appBaseUrl = localProperties.getProperty("ypchat.baseUrl", defaultBaseUrl)
val hasReleaseSigning = releaseStoreFile?.exists() == true val hasReleaseSigning = releaseStoreFile?.exists() == true
android { android {
namespace = "net.ypchat.app" namespace = "de.ypchat.android"
compileSdk = 36 compileSdk = 36
defaultConfig { defaultConfig {
applicationId = "net.ypchat.app" applicationId = "de.ypchat.android"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 1 versionCode = 3
versionName = "1.0.0" versionName = "1.2.0"
} }
lint { lint {
@@ -113,6 +113,7 @@ dependencies {
} }
implementation("io.coil-kt.coil3:coil-compose:3.4.0") implementation("io.coil-kt.coil3:coil-compose:3.4.0")
implementation("io.coil-kt.coil3:coil-network-okhttp: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") debugImplementation("androidx.compose.ui:ui-tooling")
} }

Binary file not shown.

View File

@@ -1,12 +1,14 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<application <application
android:name=".YpChatApp" android:name=".YpChatApp"
android:allowBackup="true" android:allowBackup="true"
android:icon="@drawable/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@drawable/ic_launcher" android:roundIcon="@mipmap/ic_launcher"
android:usesCleartextTraffic="${usesCleartextTraffic}" android:usesCleartextTraffic="${usesCleartextTraffic}"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.YpChat"> android:theme="@style/Theme.YpChat">
@@ -20,4 +22,3 @@
</activity> </activity>
</application> </application>
</manifest> </manifest>

View File

@@ -1,12 +1,12 @@
package net.ypchat.app package de.ypchat.android
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.viewModels import androidx.activity.viewModels
import net.ypchat.app.ui.ChatViewModel import de.ypchat.android.ui.ChatViewModel
import net.ypchat.app.ui.ChatViewModelFactory import de.ypchat.android.ui.ChatViewModelFactory
import net.ypchat.app.ui.YpChatRoot import de.ypchat.android.ui.YpChatRoot
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val viewModel: ChatViewModel by viewModels { private val viewModel: ChatViewModel by viewModels {

View File

@@ -1,7 +1,7 @@
package net.ypchat.app package de.ypchat.android
import android.app.Application import android.app.Application
import net.ypchat.app.core.AppContainer import de.ypchat.android.core.AppContainer
class YpChatApp : Application() { class YpChatApp : Application() {
lateinit var container: AppContainer lateinit var container: AppContainer

View File

@@ -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 { object AppConfig {
val baseUrl: String = BuildConfig.BASE_URL.trimEnd('/') val baseUrl: String = BuildConfig.BASE_URL.trimEnd('/')

View File

@@ -1,9 +1,10 @@
package net.ypchat.app.core package de.ypchat.android.core
import android.content.Context import android.content.Context
import net.ypchat.app.data.api.RestApi import de.ypchat.android.data.api.RestApi
import net.ypchat.app.data.api.SocketClient import de.ypchat.android.data.api.SocketClient
import net.ypchat.app.data.repository.ChatRepository import de.ypchat.android.data.repository.ChatRepository
import de.ypchat.android.media.AndroidVideoCallManager
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
@@ -28,5 +29,6 @@ class AppContainer(context: Context) {
val restApi: RestApi = retrofit.create(RestApi::class.java) val restApi: RestApi = retrofit.create(RestApi::class.java)
val socketClient = SocketClient(AppConfig.baseUrl, okHttpClient) 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)
} }

View File

@@ -1,4 +1,4 @@
package net.ypchat.app.core package de.ypchat.android.core
import android.content.Context import android.content.Context

View File

@@ -1,4 +1,4 @@
package net.ypchat.app.core package de.ypchat.android.core
import android.content.Context import android.content.Context
import okhttp3.Cookie import okhttp3.Cookie

View File

@@ -1,14 +1,14 @@
package net.ypchat.app.data.api package de.ypchat.android.data.api
import net.ypchat.app.data.model.CountriesResponse import de.ypchat.android.data.model.CountriesResponse
import net.ypchat.app.data.model.FeedbackAdminLoginRequest import de.ypchat.android.data.model.FeedbackAdminLoginRequest
import net.ypchat.app.data.model.FeedbackAdminStatusResponse import de.ypchat.android.data.model.FeedbackAdminStatusResponse
import net.ypchat.app.data.model.FeedbackRequest import de.ypchat.android.data.model.FeedbackRequest
import net.ypchat.app.data.model.FeedbackResponse import de.ypchat.android.data.model.FeedbackResponse
import net.ypchat.app.data.model.ImageUploadResponse import de.ypchat.android.data.model.ImageUploadResponse
import net.ypchat.app.data.model.LogoutResponse import de.ypchat.android.data.model.LogoutResponse
import net.ypchat.app.data.model.PartnerLinkDto import de.ypchat.android.data.model.PartnerLinkDto
import net.ypchat.app.data.model.SessionResponse import de.ypchat.android.data.model.SessionResponse
import okhttp3.MultipartBody import okhttp3.MultipartBody
import retrofit2.Response import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body

View File

@@ -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.IO
import io.socket.client.Socket import io.socket.client.Socket
@@ -10,11 +10,19 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.ypchat.app.data.model.ChatMessageDto import de.ypchat.android.data.model.ChatMessageDto
import net.ypchat.app.data.model.HistoryItemDto import de.ypchat.android.data.model.HistoryItemDto
import net.ypchat.app.data.model.InboxItemDto import de.ypchat.android.data.model.InboxItemDto
import net.ypchat.app.data.model.SocketEvent import de.ypchat.android.data.model.SocketEvent
import net.ypchat.app.data.model.UserDto 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 okhttp3.OkHttpClient
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
@@ -113,6 +121,51 @@ class SocketClient(
s.on("unreadChats") { args -> s.on("unreadChats") { args ->
args.firstJson()?.let { emit(SocketEvent.UnreadChats(it.optInt("count", 0))) } 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 -> s.on("userBlocked") { args ->
args.firstJson()?.let { emit(SocketEvent.UserBlocked(it.optString("userName"))) } args.firstJson()?.let { emit(SocketEvent.UserBlocked(it.optString("userName"))) }
} }
@@ -210,6 +263,55 @@ class SocketClient(
fun requestOpenConversations() = socket?.emit("requestOpenConversations") fun requestOpenConversations() = socket?.emit("requestOpenConversations")
fun blockUser(userName: String) = socket?.emit("blockUser", JSONObject().put("userName", userName)) fun blockUser(userName: String) = socket?.emit("blockUser", JSONObject().put("userName", userName))
fun unblockUser(userName: String) = socket?.emit("unblockUser", 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) { private fun emit(event: SocketEvent) {
scope.launch { _events.emit(event) } scope.launch { _events.emit(event) }
@@ -252,6 +354,79 @@ private fun JSONObject.toInboxItemDto(): InboxItemDto = InboxItemDto(
unreadCount = optInt("unreadCount", 0) 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> { private fun JSONArray?.toStringList(): List<String> {
if (this == null) return emptyList() if (this == null) return emptyList()
return List(length()) { index -> opt(index)?.toString().orEmpty() } return List(length()) { index -> opt(index)?.toString().orEmpty() }

View File

@@ -1,4 +1,4 @@
package net.ypchat.app.data.model package de.ypchat.android.data.model
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
@@ -34,6 +34,71 @@ data class InboxItemDto(
val unreadCount: Int = 0 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( data class CountryOption(
val englishName: String, val englishName: String,
val displayName: String, val displayName: String,

View File

@@ -1,4 +1,4 @@
package net.ypchat.app.data.model package de.ypchat.android.data.model
sealed interface SocketEvent { sealed interface SocketEvent {
data class Connected(val sessionId: String?, val loggedIn: Boolean, val user: UserDto?) : 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 HistoryResults(val results: List<HistoryItemDto>) : SocketEvent
data class InboxResults(val results: List<InboxItemDto>) : SocketEvent data class InboxResults(val results: List<InboxItemDto>) : SocketEvent
data class UnreadChats(val count: Int) : 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 UserBlocked(val userName: String) : SocketEvent
data class UserUnblocked(val userName: String) : SocketEvent data class UserUnblocked(val userName: String) : SocketEvent
data class CommandResult(val lines: List<String>, val kind: String) : SocketEvent data class CommandResult(val lines: List<String>, val kind: String) : SocketEvent

View File

@@ -1,4 +1,4 @@
package net.ypchat.app.data.repository package de.ypchat.android.data.repository
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -9,34 +9,43 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.ypchat.app.core.AppConfig import de.ypchat.android.core.AppConfig
import net.ypchat.app.core.ProfileStore import de.ypchat.android.core.ProfileStore
import net.ypchat.app.core.SavedProfile import de.ypchat.android.core.SavedProfile
import net.ypchat.app.core.SessionCookieJar import de.ypchat.android.core.SessionCookieJar
import net.ypchat.app.data.api.RestApi import de.ypchat.android.data.api.RestApi
import net.ypchat.app.data.api.SocketClient import de.ypchat.android.data.api.SocketClient
import net.ypchat.app.data.model.ChatMessageDto import de.ypchat.android.data.model.ChatMessageDto
import net.ypchat.app.data.model.CountryOption import de.ypchat.android.data.model.CountryOption
import net.ypchat.app.data.model.FeedbackAdminLoginRequest import de.ypchat.android.data.model.FeedbackAdminLoginRequest
import net.ypchat.app.data.model.FeedbackItemDto import de.ypchat.android.data.model.FeedbackItemDto
import net.ypchat.app.data.model.FeedbackRequest import de.ypchat.android.data.model.FeedbackRequest
import net.ypchat.app.data.model.HistoryItemDto import de.ypchat.android.data.model.HistoryItemDto
import net.ypchat.app.data.model.InboxItemDto import de.ypchat.android.data.model.InboxItemDto
import net.ypchat.app.data.model.PartnerLinkDto import de.ypchat.android.data.model.PartnerLinkDto
import net.ypchat.app.data.model.SocketEvent import de.ypchat.android.data.model.SocketEvent
import net.ypchat.app.data.model.UserDto 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 okhttp3.MultipartBody
import org.webrtc.EglBase
import java.util.Locale import java.util.Locale
class ChatRepository( class ChatRepository(
private val restApi: RestApi, private val restApi: RestApi,
private val socketClient: SocketClient, private val socketClient: SocketClient,
private val cookieJar: SessionCookieJar, 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 scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val _state = MutableStateFlow(ChatState()) private val _state = MutableStateFlow(ChatState())
val state: StateFlow<ChatState> = _state.asStateFlow() val state: StateFlow<ChatState> = _state.asStateFlow()
val videoMediaState: StateFlow<VideoMediaState> = videoCallManager.state
val videoEglBaseContext: EglBase.Context = videoCallManager.eglBaseContext()
private var timeoutTickerStarted = false private var timeoutTickerStarted = false
init { init {
@@ -105,6 +114,7 @@ class ChatRepository(
runCatching { restApi.logout() } runCatching { restApi.logout() }
socketClient.disconnect() socketClient.disconnect()
cookieJar.clear() cookieJar.clear()
videoCallManager.releaseAll()
_state.value = ChatState(savedProfile = profileStore.read(), countries = _state.value.countries) _state.value = ChatState(savedProfile = profileStore.read(), countries = _state.value.countries)
} }
@@ -114,13 +124,21 @@ class ChatRepository(
} }
fun openConversation(userName: String) { 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) socketClient.requestConversation(userName)
resetTimeout() resetTimeout()
} }
fun closeConversation() { 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) { fun sendMessage(text: String) {
@@ -287,6 +305,75 @@ class ChatRepository(
resetTimeout() 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() { private fun startTimeoutTicker() {
if (timeoutTickerStarted) return if (timeoutTickerStarted) return
timeoutTickerStarted = true timeoutTickerStarted = true
@@ -344,6 +431,7 @@ class ChatRepository(
is SocketEvent.Conversation -> current.copy( is SocketEvent.Conversation -> current.copy(
currentConversation = event.withUserName, currentConversation = event.withUserName,
messages = event.messages, messages = event.messages,
videoConsent = current.videoConsent.takeIf { it.withUserName == event.withUserName } ?: VideoConsentDto(withUserName = event.withUserName),
unreadChatsCount = maxOf(0, current.unreadChatsCount - 1), unreadChatsCount = maxOf(0, current.unreadChatsCount - 1),
remainingSecondsToTimeout = 1800 remainingSecondsToTimeout = 1800
) )
@@ -351,6 +439,56 @@ class ChatRepository(
is SocketEvent.HistoryResults -> current.copy(historyResults = event.results) is SocketEvent.HistoryResults -> current.copy(historyResults = event.results)
is SocketEvent.InboxResults -> current.copy(inboxResults = event.results) is SocketEvent.InboxResults -> current.copy(inboxResults = event.results)
is SocketEvent.UnreadChats -> current.copy(unreadChatsCount = event.count) 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.UserBlocked -> current.copy(errorMessage = "${event.userName} blocked")
is SocketEvent.UserUnblocked -> current.copy(errorMessage = "${event.userName} unblocked") is SocketEvent.UserUnblocked -> current.copy(errorMessage = "${event.userName} unblocked")
is SocketEvent.CommandResult -> current.copy( 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 errorMessage = if (event.kind == "info" || event.kind.startsWith("login")) event.lines.joinToString(" | ") else current.errorMessage
) )
is SocketEvent.CommandTable -> current.copy( is SocketEvent.CommandTable -> current.copy(
commandLines = emptyList(),
commandKind = null,
commandTable = CommandTableState(event.title, event.columns, event.rows) commandTable = CommandTableState(event.title, event.columns, event.rows)
) )
is SocketEvent.Error -> current.copy(errorMessage = event.message) 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( data class CommandTableState(
val title: String, val title: String,
val columns: List<String>, val columns: List<String>,
@@ -404,5 +578,51 @@ data class ChatState(
val isUploadingImage: Boolean = false, val isUploadingImage: Boolean = false,
val imageUploadMessage: String? = null, val imageUploadMessage: String? = null,
val unreadChatsCount: Int = 0, 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
)
}
}

View File

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

View File

@@ -1,4 +1,4 @@
package net.ypchat.app.ui package de.ypchat.android.ui
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
@@ -8,14 +8,18 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.ypchat.app.data.repository.ChatRepository import de.ypchat.android.data.repository.ChatRepository
import net.ypchat.app.data.repository.ChatState import de.ypchat.android.data.repository.ChatState
import de.ypchat.android.media.VideoMediaState
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import org.webrtc.EglBase
class ChatViewModel(private val repository: ChatRepository) : ViewModel() { class ChatViewModel(private val repository: ChatRepository) : ViewModel() {
val state: StateFlow<ChatState> = repository.state val state: StateFlow<ChatState> = repository.state
val videoMediaState: StateFlow<VideoMediaState> = repository.videoMediaState
val videoEglBaseContext: EglBase.Context = repository.videoEglBaseContext
init { init {
viewModelScope.launch { repository.restoreSession() } viewModelScope.launch { repository.restoreSession() }
@@ -36,6 +40,7 @@ class ChatViewModel(private val repository: ChatRepository) : ViewModel() {
fun openConversation(userName: String) = repository.openConversation(userName) fun openConversation(userName: String) = repository.openConversation(userName)
fun closeConversation() = repository.closeConversation() fun closeConversation() = repository.closeConversation()
fun sendMessage(text: String) = repository.sendMessage(text) fun sendMessage(text: String) = repository.sendMessage(text)
fun setImageUploadMessage(message: String) = repository.setImageUploadState(false, message)
fun sendImage(context: Context, uri: Uri) { fun sendImage(context: Context, uri: Uri) {
val target = state.value.currentConversation ?: return val target = state.value.currentConversation ?: return
viewModelScope.launch { viewModelScope.launch {
@@ -110,6 +115,18 @@ class ChatViewModel(private val repository: ChatRepository) : ViewModel() {
fun blockCurrentUser() = state.value.currentConversation?.let(repository::blockUser) fun blockCurrentUser() = state.value.currentConversation?.let(repository::blockUser)
fun unblockCurrentUser() = state.value.currentConversation?.let(repository::unblockUser) 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 { private companion object {
const val MAX_IMAGE_BYTES = 5 * 1024 * 1024 const val MAX_IMAGE_BYTES = 5 * 1024 * 1024

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -1,6 +1,6 @@
<resources> <resources>
<string name="app_name">YPChat</string> <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_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="landing_copy">Kompakt, schnell und ohne Umwege. Erstelle dein Profil und starte sofort eine Unterhaltung.</string>
<string name="feature_worldwide_chat">Weltweiter Chat</string> <string name="feature_worldwide_chat">Weltweiter Chat</string>
@@ -49,6 +49,7 @@
<string name="unblock">Entsperren</string> <string name="unblock">Entsperren</string>
<string name="message_placeholder">Nachricht</string> <string name="message_placeholder">Nachricht</string>
<string name="button_image">Bild</string> <string name="button_image">Bild</string>
<string name="button_camera">Foto</string>
<string name="button_send">Senden</string> <string name="button_send">Senden</string>
<string name="button_smileys">Smileys</string> <string name="button_smileys">Smileys</string>
<string name="image_message">Bildnachricht</string> <string name="image_message">Bildnachricht</string>
@@ -57,6 +58,8 @@
<string name="image_upload_failed">Bild-Upload fehlgeschlagen.</string> <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_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="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_created_at">Eingegangen %1$s</string>
<string name="feedback_meta_separator"></string> <string name="feedback_meta_separator"></string>
<string name="countries_load_error">Länderliste konnte nicht geladen werden: %1$s</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_faq">FAQ</string>
<string name="more_rules">Regeln</string> <string name="more_rules">Regeln</string>
<string name="more_safety">Sicherheit</string> <string name="more_safety">Sicherheit</string>
<string name="more_privacy">Datenschutz</string>
<string name="more_imprint">Impressum</string> <string name="more_imprint">Impressum</string>
<string name="more_back">Zur Übersicht</string> <string name="more_back">Zur Übersicht</string>
<string name="partners_intro">Empfehlungen und befreundete Projekte für unsere Community.</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="faq_intro">Antworten auf häufige Fragen zum Chat.</string>
<string name="rules_intro">Grundregeln für respektvollen 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="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="imprint_intro">Rechtliche Hinweise und Kontaktdaten.</string>
<string name="external_link">Externer Link</string> <string name="external_link">Externer Link</string>
<string name="faq_title">Häufige Fragen</string> <string name="faq_title">Häufige Fragen</string>
<string name="rules_title">Chat-Regeln</string> <string name="rules_title">Chat-Regeln</string>
<string name="safety_title">Sicherheit und Privatsphäre</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="imprint_title">Impressum</string>
<string name="partners_title">Partner</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="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="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="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> <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> </resources>

View File

@@ -1,6 +1,6 @@
<resources> <resources>
<string name="app_name">YPChat</string> <string name="app_name">YpChat</string>
<string name="landing_eyebrow">SingleChat</string> <string name="landing_eyebrow">YpChat</string>
<string name="landing_title">Directly into chat</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="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> <string name="feature_worldwide_chat">Worldwide chat</string>
@@ -49,19 +49,53 @@
<string name="unblock">Unblock</string> <string name="unblock">Unblock</string>
<string name="message_placeholder">Message</string> <string name="message_placeholder">Message</string>
<string name="button_image">Image</string> <string name="button_image">Image</string>
<string name="button_camera">Photo</string>
<string name="button_send">Send</string> <string name="button_send">Send</string>
<string name="button_smileys">Smileys</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_message">Image message</string>
<string name="image_upload_in_progress">Uploading image...</string> <string name="image_upload_in_progress">Uploading image...</string>
<string name="image_upload_success">Image uploaded.</string> <string name="image_upload_success">Image uploaded.</string>
<string name="image_upload_failed">Image upload failed.</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_too_large">Image is larger than 5 MB.</string>
<string name="image_upload_open_failed">Image could not be opened.</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_created_at">Received %1$s</string>
<string name="feedback_meta_separator"></string> <string name="feedback_meta_separator"></string>
<string name="countries_load_error">Country list could not be loaded: %1$s</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_blocked">%1$s has been blocked</string>
<string name="user_unblocked">%1$s has been unblocked</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_title">Feedback</string>
<string name="feedback_comment">Comment</string> <string name="feedback_comment">Comment</string>
<string name="feedback_send">Send feedback</string> <string name="feedback_send">Send feedback</string>
@@ -83,21 +117,26 @@
<string name="more_faq">FAQ</string> <string name="more_faq">FAQ</string>
<string name="more_rules">Rules</string> <string name="more_rules">Rules</string>
<string name="more_safety">Safety</string> <string name="more_safety">Safety</string>
<string name="more_privacy">Privacy</string>
<string name="more_imprint">Imprint</string> <string name="more_imprint">Imprint</string>
<string name="more_back">Back to overview</string> <string name="more_back">Back to overview</string>
<string name="partners_intro">Recommended and friendly projects for our community.</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="faq_intro">Answers to common questions about the chat.</string>
<string name="rules_intro">Basic rules for respectful chatting.</string> <string name="rules_intro">Basic rules for respectful chatting.</string>
<string name="safety_intro">Tips for privacy and safer usage.</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="imprint_intro">Legal notice and contact details.</string>
<string name="external_link">External link</string> <string name="external_link">External link</string>
<string name="faq_title">Frequently Asked Questions</string> <string name="faq_title">Frequently Asked Questions</string>
<string name="rules_title">Chat Rules</string> <string name="rules_title">Chat Rules</string>
<string name="safety_title">Safety and Privacy</string> <string name="safety_title">Safety and Privacy</string>
<string name="privacy_title">Privacy Policy</string>
<string name="imprint_title">Imprint</string> <string name="imprint_title">Imprint</string>
<string name="partners_title">Partners</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="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="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="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> <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> </resources>

View File

@@ -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.useAndroidX=true
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
kotlin.code.style=official kotlin.code.style=official
android.dependency.useConstraints=true android.dependency.useConstraints=false
android.r8.strictFullModeForKeepRules=false android.r8.strictFullModeForKeepRules=false
android.dependency.excludeLibraryComponentsFromConstraints=true
android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false
kotlin.daemon.jvmargs=-Xmx2048m kotlin.daemon.jvmargs=-Xmx2048m
android.lint.workerProcessMaxHeapSize=4g android.lint.workerProcessMaxHeapSize=4g

BIN
android/production Normal file

Binary file not shown.

View File

@@ -1,12 +1,11 @@
<IfModule mod_ssl.c> <IfModule mod_ssl.c>
<VirtualHost *:443> <VirtualHost *:443>
ServerName ypchat.net ServerName ypchat.net
ServerAlias www.ypchat.net
# SSL-Konfiguration # SSL-Konfiguration
Include /etc/letsencrypt/options-ssl-apache.conf Include /etc/letsencrypt/options-ssl-apache.conf
SSLCertificateFile /etc/letsencrypt/live/www.ypchat.net/fullchain.pem SSLCertificateFile /etc/letsencrypt/live/ypchat.net/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/www.ypchat.net/privkey.pem SSLCertificateKeyFile /etc/letsencrypt/live/ypchat.net/privkey.pem
# DocumentRoot (nur für statische Dateien wie ads.txt) # DocumentRoot (nur für statische Dateien wie ads.txt)
DocumentRoot /opt/ypchat/docroot DocumentRoot /opt/ypchat/docroot
@@ -64,5 +63,15 @@
RequestHeader set X-Forwarded-Proto "https" RequestHeader set X-Forwarded-Proto "https"
RequestHeader set X-Forwarded-Port "443" RequestHeader set X-Forwarded-Port "443"
</VirtualHost> </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>

View File

@@ -1,19 +1,19 @@
<IfModule mod_ssl.c> <IfModule mod_ssl.c>
# 1) Apex-Domain (ypchat.net) liefert NUR Redirect auf www # 1) www-Domain liefert NUR Redirect auf Apex
<VirtualHost *:443> <VirtualHost *:443>
ServerName ypchat.net ServerName www.ypchat.net
Include /etc/letsencrypt/options-ssl-apache.conf Include /etc/letsencrypt/options-ssl-apache.conf
SSLCertificateFile /etc/letsencrypt/live/ypchat.net/fullchain.pem SSLCertificateFile /etc/letsencrypt/live/ypchat.net/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/ypchat.net/privkey.pem SSLCertificateKeyFile /etc/letsencrypt/live/ypchat.net/privkey.pem
RewriteEngine On RewriteEngine On
RewriteRule ^ https://www.ypchat.net%{REQUEST_URI} [R=301,L] RewriteRule ^ https://ypchat.net%{REQUEST_URI} [R=301,L]
</VirtualHost> </VirtualHost>
# 2) Canonical Host (www.ypchat.net) liefert die App # 2) Canonical Host (ypchat.net) liefert die App
<VirtualHost *:443> <VirtualHost *:443>
ServerName www.ypchat.net ServerName ypchat.net
# SSL-Konfiguration # SSL-Konfiguration
Include /etc/letsencrypt/options-ssl-apache.conf Include /etc/letsencrypt/options-ssl-apache.conf

31
client/ADS-INTEGRATION.md Normal file
View 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.

View File

@@ -3,16 +3,16 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SingleChat - Chat, Single-Chat und Bildaustausch</title> <title>SingleChat - Kostenloser Single Chat ohne Anmeldung</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="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="Chat, Single-Chat, Bildaustausch, Online-Chat, Singles, Kontakte, Community"> <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="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1">
<meta name="author" content="SingleChat"> <meta name="author" content="SingleChat">
<meta name="theme-color" content="#2f6f46"> <meta name="theme-color" content="#2f6f46">
<!-- Open Graph Tags --> <!-- Open Graph Tags -->
<meta property="og:title" content="SingleChat - Chat, Single-Chat und Bildaustausch"> <meta property="og:title" content="SingleChat - Kostenloser Single Chat ohne Anmeldung">
<meta property="og:description" content="Willkommen auf SingleChat - deine erste Adresse für Chat, Single-Chat und Bildaustausch."> <meta property="og:description" content="Kostenlos chatten, Singles kennenlernen und Bilder sicher austauschen.">
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:url" content="https://www.ypchat.net/"> <meta property="og:url" content="https://www.ypchat.net/">
<meta property="og:image" content="https://www.ypchat.net/static/favicon.png"> <meta property="og:image" content="https://www.ypchat.net/static/favicon.png">
@@ -21,8 +21,8 @@
<!-- Twitter Card --> <!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="SingleChat - Chat, Single-Chat und Bildaustausch"> <meta name="twitter:title" content="SingleChat - Kostenloser Single Chat ohne Anmeldung">
<meta name="twitter:description" content="Willkommen auf SingleChat - deine erste Adresse für Chat, Single-Chat und Bildaustausch."> <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"> <meta name="twitter:image" content="https://www.ypchat.net/static/favicon.png">
<!-- Canonical URL --> <!-- Canonical URL -->
@@ -32,7 +32,7 @@
<link rel="icon" type="image/x-icon" href="/favicon.ico"> <link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="shortcut icon" href="/favicon.ico"> <link rel="shortcut icon" href="/favicon.ico">
<link rel="icon" type="image/png" href="/appicon.png"> <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> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@@ -1,11 +1,11 @@
{ {
"name": "singlechat-client", "name": "ypchat-client",
"version": "1.0.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "singlechat-client", "name": "ypchat-client",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@unhead/vue": "^2.0.19", "@unhead/vue": "^2.0.19",

View File

@@ -1,5 +1,5 @@
{ {
"name": "singlechat-client", "name": "ypchat-client",
"version": "1.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {

View 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')

View File

@@ -25,6 +25,15 @@
> >
<img src="/image.png" alt="Image" /> <img src="/image.png" alt="Image" />
</button> </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"> <div v-if="showSmileys" class="smiley-bar">
<span <span
@@ -36,16 +45,86 @@
@click="insertSmiley(code)" @click="insertSmiley(code)"
></span> ></span>
</div> </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> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed } from 'vue'; import { ref, computed, onBeforeUnmount } from 'vue';
import { useChatStore } from '../stores/chat'; import { useChatStore } from '../stores/chat';
const chatStore = useChatStore(); const chatStore = useChatStore();
const message = ref(''); const message = ref('');
const showSmileys = ref(false); 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 hasConversation = computed(() => !!chatStore.currentConversation);
const isAwaitingUsername = computed(() => chatStore.awaitingLoginUsername); const isAwaitingUsername = computed(() => chatStore.awaitingLoginUsername);
const isAwaitingPassword = computed(() => chatStore.awaitingLoginPassword); const isAwaitingPassword = computed(() => chatStore.awaitingLoginPassword);
@@ -115,55 +194,346 @@ function insertSmiley(code) {
showSmileys.value = false; showSmileys.value = false;
} }
function showTemporaryError(text) {
chatStore.errorMessage = text;
setTimeout(() => {
if (chatStore.errorMessage === text) {
chatStore.errorMessage = null;
}
}, 4000);
}
async function handleImageUpload(event) { async function handleImageUpload(event) {
const file = event.target.files[0]; const file = event.target.files[0];
if (!file) return; 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 { try {
// Erstelle FormData für Upload await uploadAndSendImage(file);
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');
}
} catch (error) { } catch (error) {
console.error('Fehler beim Bild-Upload:', error); console.error('Fehler beim Bild-Upload:', error);
alert('Fehler beim Bild-Upload: ' + error.message); alert('Fehler beim Bild-Upload: ' + error.message);
} }
// Input zurücksetzen, damit das gleiche Bild erneut ausgewählt werden kann // Input zurücksetzen, damit das gleiche Bild erneut ausgewählt werden kann
event.target.value = ''; 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> </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>

View File

@@ -23,8 +23,48 @@
</span> </span>
</div> </div>
</div> </div>
<div v-else class="messages-container"> <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 <div
v-for="(message, index) in chatStore.messages" v-for="(message, index) in chatStore.messages"
:key="index" :key="index"
@@ -33,33 +73,33 @@
> >
<strong>{{ message.from }}:</strong> <strong>{{ message.from }}:</strong>
<span v-if="message.isImage" class="image-message"> <span v-if="message.isImage" class="image-message">
<img <img
:src="message.message" :src="message.message"
:alt="'Bild von ' + message.from" :alt="'Bild von ' + message.from"
class="chat-image" class="chat-image"
@click="openImageModal(message.message)" @click="openImageModal(message.message)"
/> />
</span> </span>
<span v-else v-html="replaceSmileys(message.message)"></span> <span v-else v-html="replaceSmileys(message.message)"></span>
</div>
<!-- Bild-Modal -->
<div v-if="selectedImage" class="image-modal-overlay" @click="closeImageModal"> <div v-if="selectedImage" class="image-modal-overlay" @click="closeImageModal">
<div class="image-modal-content" @click.stop> <div class="image-modal-content" @click.stop>
<button class="image-modal-close" @click="closeImageModal" title="Schließen">×</button> <button class="image-modal-close" @click="closeImageModal" title="Schließen">×</button>
<img :src="selectedImage" alt="Vergrößertes Bild" class="image-modal-image" /> <img :src="selectedImage" alt="Vergrößertes Bild" class="image-modal-image" />
</div> </div>
</div> </div>
</div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue'; import { computed, ref } from 'vue';
import { useChatStore } from '../stores/chat'; import { useChatStore } from '../stores/chat';
const chatStore = useChatStore(); const chatStore = useChatStore();
const selectedImage = ref(null); const selectedImage = ref(null);
const currentVideoSession = computed(() => chatStore.currentConversationVideoSession);
function openImageModal(imageSrc) { function openImageModal(imageSrc) {
selectedImage.value = imageSrc; selectedImage.value = imageSrc;
@@ -69,7 +109,6 @@ function closeImageModal() {
selectedImage.value = null; selectedImage.value = null;
} }
// Smiley-Definitionen (wie im Original)
const smileys = { const smileys = {
':)': { code: '1F642' }, ':)': { code: '1F642' },
':D': { code: '1F600' }, ':D': { code: '1F600' },
@@ -95,21 +134,18 @@ const smileys = {
function replaceSmileys(text) { function replaceSmileys(text) {
if (!text) return ''; if (!text) return '';
// HTML-Sonderzeichen escapen
let outputText = text let outputText = text
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
.replace(/</g, '&lt;') .replace(/</g, '&lt;')
.replace(/>/g, '&gt;'); .replace(/>/g, '&gt;');
// Smileys ersetzen (längere Codes zuerst, um Überschneidungen zu vermeiden)
const sortedCodes = Object.keys(smileys).sort((a, b) => b.length - a.length); const sortedCodes = Object.keys(smileys).sort((a, b) => b.length - a.length);
for (const code of sortedCodes) { for (const code of sortedCodes) {
const regex = new RegExp(escapeRegex(code), 'g'); const regex = new RegExp(escapeRegex(code), 'g');
outputText = outputText.replace(regex, `&#x${smileys[code].code};`); outputText = outputText.replace(regex, `&#x${smileys[code].code};`);
} }
return outputText; return outputText;
} }
@@ -121,6 +157,19 @@ function formatTime(timestamp) {
const date = new Date(timestamp); const date = new Date(timestamp);
return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); 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> </script>
<style scoped> <style scoped>
@@ -212,15 +261,63 @@ function formatTime(timestamp) {
font-size: 11px; font-size: 11px;
} }
@media (max-width: 620px) {
.empty-stats {
grid-template-columns: 1fr;
}
}
.messages-container { .messages-container {
display: flex; display: flex;
flex-direction: column; 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 { .chat-image {
@@ -298,4 +395,15 @@ function formatTime(timestamp) {
object-fit: contain; object-fit: contain;
border-radius: 4px; border-radius: 4px;
} }
@media (max-width: 620px) {
.empty-stats {
grid-template-columns: 1fr;
}
.video-call-banner {
flex-direction: column;
align-items: stretch;
}
}
</style> </style>

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

View File

@@ -1,90 +1,127 @@
<template> <template>
<div v-if="isEnabled" class="header-ad-banner"> <div class="header-ad-banner">
<ins <div
ref="adElement" ref="adContainer"
class="adsbygoogle" class="ad-container"
style="display:inline-block;width:320px;height:50px" :class="{ 'is-mobile': activePlacement.width === 320 }"
:data-ad-client="adClient" ></div>
:data-ad-slot="adSlot"
></ins>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, nextTick, onMounted, ref } from 'vue'; import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
const adElement = ref(null); const MOBILE_BREAKPOINT = 720;
const adClient = import.meta.env.VITE_ADSENSE_CLIENT || ''; const placements = {
const adSlot = import.meta.env.VITE_ADSENSE_HEADER_SLOT || ''; mobile: {
const isEnabled = computed(() => Boolean(adClient && adSlot)); key: 'fb9b5e7f817d40d72943dae0c54eb769',
width: 320,
height: 50
},
desktop: {
key: '2b658317c1e28b4b4f234d26c8fca28d',
width: 468,
height: 60
}
};
function ensureAdSenseScript() { const adContainer = ref(null);
if (!isEnabled.value) return; const activePlacement = ref(selectPlacement());
if (document.querySelector('script[data-adsense-loader="true"]')) return; 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'); const script = document.createElement('script');
script.src = buildInvokeUrl(placement.key);
script.async = true; script.async = true;
script.src = `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${adClient}`; script.onload = () => {
script.crossOrigin = 'anonymous'; console.log('Adsterra script loaded:', placement.key, `${placement.width}x${placement.height}`);
script.dataset.adsenseLoader = 'true'; };
document.head.appendChild(script); 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 () => { onMounted(async () => {
if (!isEnabled.value) return;
ensureAdSenseScript();
await nextTick(); await nextTick();
renderAd();
window.addEventListener('resize', handleResize);
});
try { onBeforeUnmount(() => {
// Avoid duplicate initialization on remount. window.removeEventListener('resize', handleResize);
if (adElement.value?.dataset.adsInitialized === 'true') return; window.clearTimeout(resizeTimer);
(window.adsbygoogle = window.adsbygoogle || []).push({}); clearAdContainer();
if (adElement.value) {
adElement.value.dataset.adsInitialized = 'true';
}
} catch (error) {
console.warn('AdSense Banner konnte nicht initialisiert werden:', error);
}
}); });
</script> </script>
<style scoped> <style scoped>
.header-ad-banner { .header-ad-banner {
flex: 0 0 auto; width: 100%;
width: 320px;
min-width: 320px;
max-width: 320px;
height: 50px;
margin: 0 16px;
overflow: hidden;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: 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; min-height: 50px;
} }
@media (max-width: 960px) { .ad-container :deep(iframe) {
.header-ad-banner { border: 0;
width: 300px; display: block;
min-width: 300px; margin: 0 auto;
max-width: 300px; max-width: 100%;
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;
}
} }
</style> </style>

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="landing-login"> <div class="landing-login">
<section class="landing-login-intro"> <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> <h2>Direkt in den Chat</h2>
<p class="landing-login-copy"> <p class="landing-login-copy">
Kompakt, schnell und ohne Umwege. Erstelle dein Profil und starte sofort eine Unterhaltung. Kompakt, schnell und ohne Umwege. Erstelle dein Profil und starte sofort eine Unterhaltung.
@@ -11,6 +11,11 @@
<span>Bildaustausch</span> <span>Bildaustausch</span>
<span>Kompakte Bedienung</span> <span>Kompakte Bedienung</span>
</div> </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> <div class="welcome-message" v-html="$t('welcome')"></div>
</section> </section>
@@ -242,6 +247,24 @@ function handleSubmit() {
font-weight: 600; 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 { .landing-login-card {
padding: 24px; padding: 24px;
border-radius: 20px; border-radius: 20px;

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

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

View File

@@ -8,6 +8,7 @@ import ja from './locales/ja.json';
import zh from './locales/zh.json'; import zh from './locales/zh.json';
import th from './locales/th.json'; import th from './locales/th.json';
import tl from './locales/tl.json'; import tl from './locales/tl.json';
import ceb from './locales/ceb.json';
const messages = { const messages = {
de, de,
@@ -19,6 +20,7 @@ const messages = {
zh, zh,
th, th,
tl tl
,ceb
}; };
const i18n = createI18n({ const i18n = createI18n({

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

View File

@@ -12,15 +12,16 @@ import GuideFirstMessageView from '../views/GuideFirstMessageView.vue';
import GuideProfileView from '../views/GuideProfileView.vue'; import GuideProfileView from '../views/GuideProfileView.vue';
import GuideSafetyView from '../views/GuideSafetyView.vue'; import GuideSafetyView from '../views/GuideSafetyView.vue';
import GuideRedFlagsView from '../views/GuideRedFlagsView.vue'; import GuideRedFlagsView from '../views/GuideRedFlagsView.vue';
import SeoLandingView from '../views/SeoLandingView.vue';
const SITE_URL = 'https://www.ypchat.net'; const SITE_URL = 'https://www.ypchat.net';
const DEFAULT_IMAGE = `${SITE_URL}/static/favicon.png`; const DEFAULT_IMAGE = `${SITE_URL}/static/favicon.png`;
const SUPPORTED_LOCALES = ['de', 'en', 'fr', 'es', 'it', 'ja', 'zh', 'th', 'tl']; const SUPPORTED_LOCALES = ['de', 'en', 'fr', 'es', 'it', 'ja', 'zh', 'th', 'tl'];
const LOCALIZED_HOME_META = { const LOCALIZED_HOME_META = {
de: { de: {
title: 'SingleChat: Kostenloser Single Chat, privat & anonym', title: 'SingleChat - Kostenloser Single Chat ohne Anmeldung',
description: 'Kostenloser Single Chat für private und anonyme Gespräche. Lerne neue Kontakte kennen und teile Bilder sicher online.', description: 'Kostenloser Single Chat ohne lange Registrierung: Profil starten, Singles kennenlernen, privat chatten und Bilder sicher austauschen.',
keywords: 'single chat, kostenloser chat, privat chatten, anonym chat, free chat, private chat, anonymous chat, online chat' keywords: 'single chat, kostenloser single chat, single chat ohne anmeldung, single treff chat, chatten für singles, online chat'
}, },
en: { en: {
title: 'SingleChat: Free Private & Anonymous Single Chat', title: 'SingleChat: Free Private & Anonymous Single Chat',
@@ -68,11 +69,87 @@ const homeSchema = {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'WebSite', '@type': 'WebSite',
name: 'SingleChat', name: 'SingleChat',
alternateName: 'ypchat.net',
url: `${SITE_URL}/`, 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' 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 = { const partnersSchema = {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'CollectionPage', '@type': 'CollectionPage',
@@ -172,11 +249,11 @@ const routes = [
name: 'chat', name: 'chat',
component: ChatView, component: ChatView,
meta: { meta: {
title: 'SingleChat: Kostenloser Single Chat, privat & anonym', title: 'SingleChat - Kostenloser Single Chat ohne Anmeldung',
description: 'Kostenloser Single Chat für private und anonyme Gespräche. Lerne neue Kontakte kennen und teile Bilder sicher online.', description: 'Kostenloser Single Chat ohne lange Registrierung: Profil starten, Singles kennenlernen, privat chatten und Bilder sicher austauschen.',
keywords: 'single chat, kostenloser chat, privat chatten, anonym chat, free chat, private chat, anonymous chat, online chat', keywords: 'single chat, kostenloser single chat, single chat ohne anmeldung, single treff chat, chatten für singles, online chat',
ogTitle: 'SingleChat: Kostenloser Single Chat, privat & anonym', ogTitle: 'SingleChat - Kostenloser Single Chat ohne Anmeldung',
ogDescription: 'Kostenlos chatten, privat bleiben und neue Kontakte kennenlernen - mit sicherem Bildaustausch.', ogDescription: 'Kostenlos chatten, Singles kennenlernen und Bilder sicher austauschen.',
ogType: 'website', ogType: 'website',
image: DEFAULT_IMAGE, image: DEFAULT_IMAGE,
robots: 'index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1', robots: 'index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1',
@@ -270,7 +347,7 @@ const routes = [
meta: { meta: {
title: 'Datenschutzerklärung für Website und App - SingleChat', 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.', 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', ogTitle: 'Datenschutzerklärung für Website und App - SingleChat',
ogDescription: 'Informationen zur Datenverarbeitung bei SingleChat und in der Android-App.', ogDescription: 'Informationen zur Datenverarbeitung bei SingleChat und in der Android-App.',
ogType: 'website', 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) { for (const locale of SUPPORTED_LOCALES) {
const localized = LOCALIZED_HOME_META[locale] || LOCALIZED_HOME_META.de; const localized = LOCALIZED_HOME_META[locale] || LOCALIZED_HOME_META.de;
routes.push({ routes.push({

File diff suppressed because it is too large Load Diff

View File

@@ -633,7 +633,7 @@ a {
background: linear-gradient(180deg, rgba(238, 245, 240, 0.92) 0%, rgba(247, 250, 248, 0.88) 100%); background: linear-gradient(180deg, rgba(238, 245, 240, 0.92) 0%, rgba(247, 250, 248, 0.88) 100%);
flex-shrink: 0; flex-shrink: 0;
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto auto auto; grid-template-columns: minmax(0, 1fr) auto auto auto auto;
gap: 8px; gap: 8px;
align-items: center; align-items: center;
position: relative; position: relative;
@@ -868,7 +868,7 @@ a {
} }
.chat-input-container { .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) { .chat-input-container button:not(.no-style) {

View File

@@ -5,8 +5,8 @@
<aside class="app-sidebar"> <aside class="app-sidebar">
<div class="sidebar-brand"> <div class="sidebar-brand">
<div class="sidebar-brand-head"> <div class="sidebar-brand-head">
<span class="app-brand-mark sidebar-brand-mark" aria-hidden="true">S</span> <span class="app-brand-mark sidebar-brand-mark" aria-hidden="true">Y</span>
<strong>SingleChat</strong> <strong>YpChat</strong>
</div> </div>
<span>Online Chat</span> <span>Online Chat</span>
</div> </div>
@@ -51,7 +51,7 @@
<span class="profile-avatar">{{ userInitials }}</span> <span class="profile-avatar">{{ userInitials }}</span>
<span> <span>
<strong>{{ chatStore.userName }}</strong> <strong>{{ chatStore.userName }}</strong>
<small>{{ chatStore.country || 'SingleChat Member' }}</small> <small>{{ chatStore.country || 'YpChat Member' }}</small>
</span> </span>
</div> </div>
</aside> </aside>
@@ -108,24 +108,57 @@
<span v-if="currentUserInfo">{{ currentUserInfo.age }} · {{ currentUserInfo.gender }}</span> <span v-if="currentUserInfo">{{ currentUserInfo.age }} · {{ currentUserInfo.gender }}</span>
</div> </div>
</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>
<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 /> <ChatWindow />
</div> </div>
<ChatInput /> <ChatInput />
</div> </div>
</div> </div>
<VideoDock />
</div> </div>
</section> </section>
</div> </div>
<FloatingVideoWindow />
<ImprintContainer /> <ImprintContainer />
</template> </template>
<template v-else> <template v-else>
<header class="header"> <header class="header">
<div class="app-brand"> <div class="app-brand">
<span class="app-brand-mark">S</span> <span class="app-brand-mark">Y</span>
<div class="app-brand-copy"> <div class="app-brand-copy">
<span class="app-brand-eyebrow">SingleChat</span> <span class="app-brand-eyebrow">YpChat</span>
<h1>Chat</h1> <h1>Chat</h1>
</div> </div>
</div> </div>
@@ -156,16 +189,18 @@ import InboxView from '../components/InboxView.vue';
import HistoryView from '../components/HistoryView.vue'; import HistoryView from '../components/HistoryView.vue';
import ImprintContainer from '../components/ImprintContainer.vue'; import ImprintContainer from '../components/ImprintContainer.vue';
import HeaderAdBanner from '../components/HeaderAdBanner.vue'; import HeaderAdBanner from '../components/HeaderAdBanner.vue';
import VideoDock from '../components/VideoDock.vue';
import FloatingVideoWindow from '../components/FloatingVideoWindow.vue';
const chatStore = useChatStore(); const chatStore = useChatStore();
const userInitials = computed(() => { const userInitials = computed(() => {
return (chatStore.userName || 'SC') return (chatStore.userName || 'YP')
.split(/\s+/) .split(/\s+/)
.filter(Boolean) .filter(Boolean)
.slice(0, 2) .slice(0, 2)
.map(part => part.charAt(0).toUpperCase()) .map(part => part.charAt(0).toUpperCase())
.join('') || 'SC'; .join('') || 'YP';
}); });
const pageTitle = computed(() => { 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> </script>
<style scoped> <style scoped>
@@ -357,4 +405,92 @@ onMounted(async () => {
position: sticky; position: sticky;
top: 0; 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> </style>

View File

@@ -4,7 +4,7 @@
<router-link to="/" class="app-brand app-brand-link"> <router-link to="/" class="app-brand app-brand-link">
<span class="app-brand-mark">S</span> <span class="app-brand-mark">S</span>
<div class="app-brand-copy"> <div class="app-brand-copy">
<span class="app-brand-eyebrow">SingleChat</span> <span class="app-brand-eyebrow">YpChat</span>
<h1>FAQ</h1> <h1>FAQ</h1>
</div> </div>
</router-link> </router-link>
@@ -90,4 +90,3 @@ import ImprintContainer from '../components/ImprintContainer.vue';
text-decoration: none; text-decoration: none;
} }
</style> </style>

View File

@@ -4,7 +4,7 @@
<router-link to="/" class="app-brand app-brand-link"> <router-link to="/" class="app-brand app-brand-link">
<span class="app-brand-mark">S</span> <span class="app-brand-mark">S</span>
<div class="app-brand-copy"> <div class="app-brand-copy">
<span class="app-brand-eyebrow">SingleChat</span> <span class="app-brand-eyebrow">YpChat</span>
<h1>Feedback</h1> <h1>Feedback</h1>
</div> </div>
</router-link> </router-link>

View File

@@ -4,7 +4,7 @@
<router-link to="/" class="app-brand app-brand-link"> <router-link to="/" class="app-brand app-brand-link">
<span class="app-brand-mark">S</span> <span class="app-brand-mark">S</span>
<div class="app-brand-copy"> <div class="app-brand-copy">
<span class="app-brand-eyebrow">SingleChat</span> <span class="app-brand-eyebrow">YpChat</span>
<h1>Ratgeber</h1> <h1>Ratgeber</h1>
</div> </div>
</router-link> </router-link>
@@ -77,7 +77,7 @@
</p> </p>
<p class="content-meta"> <p class="content-meta">
Redaktion: SingleChat Team · Zuletzt aktualisiert: 07.05.2026 Redaktion: YpChat Team · Zuletzt aktualisiert: 07.05.2026
</p> </p>
</main> </main>

View File

@@ -4,7 +4,7 @@
<router-link to="/" class="app-brand app-brand-link"> <router-link to="/" class="app-brand app-brand-link">
<span class="app-brand-mark">S</span> <span class="app-brand-mark">S</span>
<div class="app-brand-copy"> <div class="app-brand-copy">
<span class="app-brand-eyebrow">SingleChat</span> <span class="app-brand-eyebrow">YpChat</span>
<h1>Ratgeber</h1> <h1>Ratgeber</h1>
</div> </div>
</router-link> </router-link>
@@ -15,7 +15,7 @@
<h2>Ratgeber: Sicher und entspannt chatten</h2> <h2>Ratgeber: Sicher und entspannt chatten</h2>
<p> <p>
In unserem Ratgeber findest du praxisnahe Tipps rund um privaten Chat, Profilgestaltung und digitale Sicherheit. 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>
<p> <p>
Wenn du neu im Chat bist, starte am besten mit dem Leitfaden zur ersten Unterhaltung und arbeite dich dann zu 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> </section>
<p class="content-meta"> <p class="content-meta">
Redaktion: SingleChat Team · Zuletzt aktualisiert: 07.05.2026 Redaktion: YpChat Team · Zuletzt aktualisiert: 07.05.2026
</p> </p>
</main> </main>

View File

@@ -4,7 +4,7 @@
<router-link to="/" class="app-brand app-brand-link"> <router-link to="/" class="app-brand app-brand-link">
<span class="app-brand-mark">S</span> <span class="app-brand-mark">S</span>
<div class="app-brand-copy"> <div class="app-brand-copy">
<span class="app-brand-eyebrow">SingleChat</span> <span class="app-brand-eyebrow">YpChat</span>
<h1>Ratgeber</h1> <h1>Ratgeber</h1>
</div> </div>
</router-link> </router-link>
@@ -76,7 +76,7 @@
</p> </p>
<p class="content-meta"> <p class="content-meta">
Redaktion: SingleChat Team · Zuletzt aktualisiert: 07.05.2026 Redaktion: YpChat Team · Zuletzt aktualisiert: 07.05.2026
</p> </p>
</main> </main>

View File

@@ -4,7 +4,7 @@
<router-link to="/" class="app-brand app-brand-link"> <router-link to="/" class="app-brand app-brand-link">
<span class="app-brand-mark">S</span> <span class="app-brand-mark">S</span>
<div class="app-brand-copy"> <div class="app-brand-copy">
<span class="app-brand-eyebrow">SingleChat</span> <span class="app-brand-eyebrow">YpChat</span>
<h1>Ratgeber</h1> <h1>Ratgeber</h1>
</div> </div>
</router-link> </router-link>
@@ -63,7 +63,7 @@
</p> </p>
<p class="content-meta"> <p class="content-meta">
Redaktion: SingleChat Team · Zuletzt aktualisiert: 07.05.2026 Redaktion: YpChat Team · Zuletzt aktualisiert: 07.05.2026
</p> </p>
</main> </main>

View File

@@ -4,7 +4,7 @@
<router-link to="/" class="app-brand app-brand-link"> <router-link to="/" class="app-brand app-brand-link">
<span class="app-brand-mark">S</span> <span class="app-brand-mark">S</span>
<div class="app-brand-copy"> <div class="app-brand-copy">
<span class="app-brand-eyebrow">SingleChat</span> <span class="app-brand-eyebrow">YpChat</span>
<h1>Ratgeber</h1> <h1>Ratgeber</h1>
</div> </div>
</router-link> </router-link>
@@ -63,7 +63,7 @@
</p> </p>
<p class="content-meta"> <p class="content-meta">
Redaktion: SingleChat Team · Zuletzt aktualisiert: 07.05.2026 Redaktion: YpChat Team · Zuletzt aktualisiert: 07.05.2026
</p> </p>
</main> </main>

View File

@@ -2,7 +2,7 @@
<div class="mockup-page"> <div class="mockup-page">
<header class="mockup-page-header"> <header class="mockup-page-header">
<div> <div>
<p class="mockup-page-eyebrow">SingleChat Redesign</p> <p class="mockup-page-eyebrow">YpChat Redesign</p>
<h1>Mockup-Vergleich</h1> <h1>Mockup-Vergleich</h1>
</div> </div>
<p class="mockup-page-copy"> <p class="mockup-page-copy">
@@ -28,7 +28,7 @@
<div class="mockup-brand-mark">S</div> <div class="mockup-brand-mark">S</div>
<div> <div>
<p class="mockup-eyebrow">Design Preview</p> <p class="mockup-eyebrow">Design Preview</p>
<h3>SingleChat</h3> <h3>YpChat</h3>
</div> </div>
</div> </div>
@@ -148,7 +148,7 @@
<div class="mockup-mobile-device mockup-mobile-device-polished"> <div class="mockup-mobile-device mockup-mobile-device-polished">
<div class="mockup-mobile-top"> <div class="mockup-mobile-top">
<span>SingleChat</span> <span>YpChat</span>
<span class="mockup-mobile-pill">3</span> <span class="mockup-mobile-pill">3</span>
</div> </div>
<div class="mockup-mobile-chat-header"> <div class="mockup-mobile-chat-header">

View File

@@ -4,7 +4,7 @@
<router-link to="/" class="app-brand app-brand-link"> <router-link to="/" class="app-brand app-brand-link">
<span class="app-brand-mark">S</span> <span class="app-brand-mark">S</span>
<div class="app-brand-copy"> <div class="app-brand-copy">
<span class="app-brand-eyebrow">SingleChat</span> <span class="app-brand-eyebrow">YpChat</span>
<h1>Partner</h1> <h1>Partner</h1>
</div> </div>
</router-link> </router-link>

View File

@@ -4,7 +4,7 @@
<router-link to="/" class="app-brand app-brand-link"> <router-link to="/" class="app-brand app-brand-link">
<span class="app-brand-mark">S</span> <span class="app-brand-mark">S</span>
<div class="app-brand-copy"> <div class="app-brand-copy">
<span class="app-brand-eyebrow">SingleChat</span> <span class="app-brand-eyebrow">YpChat</span>
<h1>Datenschutz</h1> <h1>Datenschutz</h1>
</div> </div>
</router-link> </router-link>
@@ -14,9 +14,14 @@
<main class="content-page"> <main class="content-page">
<h2>Datenschutzerklärung für Website und App</h2> <h2>Datenschutzerklärung für Website und App</h2>
<p> <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>. der Domain <strong>www.ypchat.net</strong>.
</p> </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> <h3>1. Verantwortlicher</h3>
<p> <p>
@@ -48,59 +53,88 @@
<li>Bearbeitung von Feedback und Missbrauchshinweisen</li> <li>Bearbeitung von Feedback und Missbrauchshinweisen</li>
</ul> </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> <p>
Wenn du den Dienst nutzt, werden von dir eingegebene Profilangaben wie Nickname, Alter, Geschlecht und Land für 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 die Chat-Funktion verwendet. Chat-Nachrichten werden technisch verarbeitet, damit Unterhaltungen in Echtzeit
zugestellt werden können. zugestellt werden können.
</p> </p>
<h3>5. Bilder</h3> <h3>6. Bilder und Kamerazugriff</h3>
<p> <p>
Bilder werden nur verarbeitet, wenn du sie aktiv auswählst und hochlädst. Nach aktuellem Systemstand werden 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. hochgeladene Bilder serverseitig temporär gespeichert und nach Ablauf einer begrenzten Zeit wieder entfernt.
</p> </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> <p>
Für den Betrieb des Dienstes werden Sitzungsdaten verwendet. Dazu gehören insbesondere technisch notwendige 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 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. können. Zusätzlich können im Rahmen des Serverbetriebs technische Protokolldaten anfallen.
</p> </p>
<h3>7. Feedback und Missbrauchsmeldungen</h3> <h3>8. Feedback und Missbrauchsmeldungen</h3>
<p> <p>
Wenn du Feedback sendest, werden die von dir eingetragenen Inhalte verarbeitet, um Hinweise, Fehlermeldungen oder Wenn du Feedback sendest, werden die von dir eingetragenen Inhalte verarbeitet, um Hinweise, Fehlermeldungen oder
Missbrauchsmeldungen zu bearbeiten. Missbrauchsmeldungen zu bearbeiten.
</p> </p>
<h3>8. Weitergabe an Dritte</h3> <h3>9. Weitergabe an Dritte</h3>
<p> <p>
Eine Weitergabe personenbezogener Daten an Dritte erfolgt nicht zu Werbezwecken. Soweit externe technische 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 Dienstleister oder Hosting-Anbieter eingebunden sind, kann eine Verarbeitung im Rahmen des technischen Betriebs
erforderlich sein. erforderlich sein.
</p> </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> <p>
Die produktive Bereitstellung der Website und der App erfolgt über verschlüsselte Verbindungen, damit Daten bei der Die produktive Bereitstellung der Website und der App erfolgt über verschlüsselte Verbindungen, damit Daten bei der
Übertragung geschützt sind. Übertragung geschützt sind.
</p> </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> <p>
Du hast im Rahmen der gesetzlichen Vorschriften insbesondere das Recht auf Auskunft, Berichtigung, Löschung, 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. Einschränkung der Verarbeitung sowie Beschwerde bei einer zuständigen Aufsichtsbehörde.
</p> </p>
<h3>11. Kontakt zum Datenschutz</h3> <h3>14. Kontakt zum Datenschutz</h3>
<p> <p>
Bei Fragen zum Datenschutz oder wenn du eine datenschutzbezogene Anfrage stellen möchtest, kontaktiere bitte: 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>. <a href="mailto:tsschulz@tsschulz.de">tsschulz@tsschulz.de</a>.
</p> </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> <h3>15. Stand</h3>
<p>Stand dieser Datenschutzerklärung: 22. April 2026</p> <p>Stand dieser Datenschutzerklärung: 16. Juni 2026</p>
</main> </main>
<ImprintContainer /> <ImprintContainer />

View File

@@ -4,7 +4,7 @@
<router-link to="/" class="app-brand app-brand-link"> <router-link to="/" class="app-brand app-brand-link">
<span class="app-brand-mark">S</span> <span class="app-brand-mark">S</span>
<div class="app-brand-copy"> <div class="app-brand-copy">
<span class="app-brand-eyebrow">SingleChat</span> <span class="app-brand-eyebrow">YpChat</span>
<h1>Regeln</h1> <h1>Regeln</h1>
</div> </div>
</router-link> </router-link>
@@ -85,4 +85,3 @@ import ImprintContainer from '../components/ImprintContainer.vue';
text-decoration: none; text-decoration: none;
} }
</style> </style>

View File

@@ -4,7 +4,7 @@
<router-link to="/" class="app-brand app-brand-link"> <router-link to="/" class="app-brand app-brand-link">
<span class="app-brand-mark">S</span> <span class="app-brand-mark">S</span>
<div class="app-brand-copy"> <div class="app-brand-copy">
<span class="app-brand-eyebrow">SingleChat</span> <span class="app-brand-eyebrow">YpChat</span>
<h1>Sicherheit</h1> <h1>Sicherheit</h1>
</div> </div>
</router-link> </router-link>
@@ -14,7 +14,7 @@
<main class="content-page"> <main class="content-page">
<h2>Sicherheit &amp; Privatsphäre</h2> <h2>Sicherheit &amp; Privatsphäre</h2>
<p> <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. im privaten und anonymen Chat deine Privatsphäre zu schützen und gute Entscheidungen zu treffen.
</p> </p>

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

View File

@@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
# SingleChat Deployment nach /opt/ypchat # YpChat Deployment nach /opt/ypchat
# Dieses Skript kopiert die Anwendung nach /opt/ypchat und installiert sie dort # Dieses Skript kopiert die Anwendung nach /opt/ypchat und installiert sie dort
set -e set -e
@@ -9,9 +9,11 @@ SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TARGET_DIR="/opt/ypchat" TARGET_DIR="/opt/ypchat"
USER="www-data" USER="www-data"
GROUP="www-data" GROUP="www-data"
ENV_TEMPLATE="$SOURCE_DIR/.env.example"
ENV_MERGE_SCRIPT="$SOURCE_DIR/scripts/merge-env-template.sh"
echo "==========================================" echo "=========================================="
echo "SingleChat Deployment nach /opt/ypchat" echo "YpChat Deployment nach /opt/ypchat"
echo "==========================================" echo "=========================================="
# Prüfe ob als root ausgeführt # Prüfe ob als root ausgeführt
@@ -54,6 +56,15 @@ rsync -av --progress \
echo "✓ Dateien kopiert" 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 # Setze Besitzer
echo "Setze Besitzer auf $USER:$GROUP..." echo "Setze Besitzer auf $USER:$GROUP..."
chown -R $USER:$GROUP "$TARGET_DIR" chown -R $USER:$GROUP "$TARGET_DIR"
@@ -111,22 +122,18 @@ chown -R $USER:$GROUP "$TARGET_DIR/docroot/dist"
echo "✓ Dateien kopiert" echo "✓ Dateien kopiert"
# Erstelle .env Datei falls nicht vorhanden echo ""
if [ ! -f "$TARGET_DIR/.env" ]; then echo "Synchronisiere .env Datei mit Vorlage..."
echo "" SESSION_SECRET="$(openssl rand -hex 32)"
echo "Erstelle .env Datei..." if [ -f "$TARGET_DIR/.env" ]; then
SESSION_SECRET=$(openssl rand -hex 32) ENV_BACKUP_PATH="$TARGET_DIR/.env.bak"
cat > "$TARGET_DIR/.env" << EOF cp -a "$TARGET_DIR/.env" "$ENV_BACKUP_PATH"
NODE_ENV=production echo "✓ Backup der bisherigen .env erstellt: $ENV_BACKUP_PATH"
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"
fi 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 ""
echo "==========================================" echo "=========================================="
@@ -145,4 +152,3 @@ echo "3. Starte den neuen Service:"
echo " sudo systemctl start ypchat" echo " sudo systemctl start ypchat"
echo " sudo systemctl status ypchat" echo " sudo systemctl status ypchat"
echo "" echo ""

6
docroot/sw.js Normal file
View 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
View 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-&gt;B)</message>
<message id="gender_trans_fm">Transgender (B-&gt;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>

View 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:** 5MB-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.*

View 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.

View File

@@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
# SingleChat Systemd Service Installation für ypchat.net # YpChat Systemd Service Installation für ypchat.net
# Erstellt einen systemd Service für SingleChat # Erstellt einen systemd Service für YpChat
set -e set -e
@@ -12,7 +12,7 @@ USER="www-data"
GROUP="www-data" GROUP="www-data"
echo "==========================================" echo "=========================================="
echo "SingleChat Systemd Service Installation" echo "YpChat Systemd Service Installation"
echo "==========================================" echo "=========================================="
# Prüfe ob als root ausgeführt # Prüfe ob als root ausgeführt
@@ -46,7 +46,7 @@ fi
echo "Erstelle Service-Datei..." echo "Erstelle Service-Datei..."
cat > "$SERVICE_FILE" << EOF cat > "$SERVICE_FILE" << EOF
[Unit] [Unit]
Description=SingleChat Node.js Application Description=YpChat Node.js Application
After=network.target After=network.target
[Service] [Service]
@@ -103,4 +103,3 @@ echo ""
echo "WICHTIG: Starte den Service mit:" echo "WICHTIG: Starte den Service mit:"
echo " sudo systemctl start $SERVICE_NAME" echo " sudo systemctl start $SERVICE_NAME"
echo "" echo ""

View File

@@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
# SingleChat Systemd Service Installation # YpChat Systemd Service Installation
# Erstellt einen systemd Service für SingleChat # Erstellt einen systemd Service für YpChat
set -e set -e
@@ -11,7 +11,7 @@ APP_DIR=$(pwd)
USER=$(whoami) USER=$(whoami)
echo "==========================================" echo "=========================================="
echo "SingleChat Systemd Service Installation" echo "YpChat Systemd Service Installation"
echo "==========================================" echo "=========================================="
# Prüfe ob als root ausgeführt # Prüfe ob als root ausgeführt
@@ -25,7 +25,7 @@ fi
echo "Erstelle Service-Datei..." echo "Erstelle Service-Datei..."
cat > "$SERVICE_FILE" << EOF cat > "$SERVICE_FILE" << EOF
[Unit] [Unit]
Description=SingleChat Node.js Application Description=YpChat Node.js Application
After=network.target After=network.target
[Service] [Service]
@@ -71,4 +71,3 @@ echo " Stop: sudo systemctl stop $SERVICE_NAME"
echo " Status: sudo systemctl status $SERVICE_NAME" echo " Status: sudo systemctl status $SERVICE_NAME"
echo " Logs: sudo journalctl -u $SERVICE_NAME -f" echo " Logs: sudo journalctl -u $SERVICE_NAME -f"
echo "" echo ""

View File

@@ -1,12 +1,12 @@
#!/bin/bash #!/bin/bash
# SingleChat Installation Script # YpChat Installation Script
# Dieses Skript installiert die SingleChat-Anwendung für Production # Dieses Skript installiert die YpChat-Anwendung für Production
# set -e wird später aktiviert, nachdem Retry-Logik definiert ist # set -e wird später aktiviert, nachdem Retry-Logik definiert ist
echo "==========================================" echo "=========================================="
echo "SingleChat Installation" echo "YpChat Installation"
echo "==========================================" echo "=========================================="
# Prüfe ob Node.js installiert ist # Prüfe ob Node.js installiert ist
@@ -125,4 +125,3 @@ echo "Apache-Konfiguration sollte enthalten:"
echo " ProxyPass / http://localhost:4000/" echo " ProxyPass / http://localhost:4000/"
echo " ProxyPassReverse / http://localhost:4000/" echo " ProxyPassReverse / http://localhost:4000/"
echo "" echo ""

4
ios/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
xcuserdata/
*.xcuserstate
DerivedData/
.build/

View File

@@ -0,0 +1,2 @@
# Debug: BASE_URL bei Bedarf für Staging oder lokalen Server anpassen.
BASE_URL = https://www.ypchat.net

View File

@@ -0,0 +1,2 @@
# Release: Produktions-URL
BASE_URL = https://www.ypchat.net

42
ios/README.md Normal file
View File

@@ -0,0 +1,42 @@
# YPChat iOS
Native iOS-App (SwiftUI): **P0P3** 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.

View 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 */;
}

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

View File

@@ -0,0 +1,12 @@
import SwiftUI
struct ContentView: View {
var body: some View {
YpChatRoot()
}
}
#Preview {
ContentView()
.environmentObject(AppServices())
}

View 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: "/"))
}
}

View 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)
}
}

View 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)
}
}
}

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

View 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?
}

View 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)
}
}
}

View 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))
}
}

View 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?)
}

View File

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

View File

@@ -0,0 +1,13 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

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

View 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.";

View 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.";

View 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