Compare commits

..

32 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
Torsten Schulz (notebook)
ec567b32eb Merge branch 'main' of https://git.tsschulz.de/torsten/singlechat 2026-05-12 10:32:14 +02:00
Torsten Schulz (notebook)
84f57facba android version 2026-05-12 10:21:24 +02:00
152 changed files with 14846 additions and 849 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"

12
.gitignore vendored
View File

@@ -7,6 +7,18 @@ logs/chat-users.json
client/dist/ client/dist/
dist/ dist/
docroot/dist/ docroot/dist/
android/.gradle/
android/.gradle-user/
android/.kotlin/
android/.idea/
android/build/
android/app/build/
android/local.properties
android/key.properties
android/*.jks
android/*.keystore
android/*.hprof
*.hprof
# Temporäre Bilder (werden nach 6 Stunden automatisch gelöscht) # Temporäre Bilder (werden nach 6 Stunden automatisch gelöscht)
tmp/ tmp/

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.

396
ANDROID-APP-KONZEPT.md Normal file
View File

@@ -0,0 +1,396 @@
# Android-App-Konzept fuer YpChat
## Zielbild
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:
- Login mit Benutzername, Geschlecht, Alter und Land
- Anzeige aktiver Benutzer
- Suche nach Benutzern
- Einzelchat mit Textnachrichten
- Bildversand ueber bestehenden Upload-Endpunkt
- Inbox mit ungelesenen Chats
- Verlauf geoeffneter Konversationen
- Blockieren und Entblockieren von Benutzern
- Session-Wiederherstellung, Logout und 30-Minuten-Inaktivitaetslogik
- Feedback- und Partnerseiten optional im MVP, aber technisch ueber vorhandene REST-Endpunkte moeglich
## Bestand Im Repo
Das aktuelle System besteht aus:
- Backend: Node.js/Express in `server/index.js`
- REST-Endpunkte in `server/routes.js`
- Socket.IO-Chat-Protokoll in `server/broadcast.js`
- Vue/Pinia-Webclient mit Socket.IO-Client in `client/src/stores/chat.js`
- Bild-Upload im Webclient ueber `client/src/components/ChatInput.vue`
Der Server erlaubt Socket.IO mit `websocket` und `polling`. Das Web-Frontend nutzt aktuell absichtlich nur `polling`, vermutlich wegen Proxy-/WebSocket-Problemen. Fuer Android sollte WebSocket als bevorzugter Transport genutzt werden, mit Polling als Fallback.
## Technische Empfehlung
### App-Technologie
Empfohlen: native Android-App mit Kotlin und Jetpack Compose.
Gruende:
- echte App-Erfahrung statt WebView
- robuste Hintergrund-/Reconnect-Logik
- gute Kontrolle ueber Cookies, Sessions und Uploads
- moderne UI mit Compose schneller wartbar
- bessere Basis fuer spaetere Push Notifications
Alternative: React Native oder Flutter waeren moeglich, bringen aber fuer diese App keinen klaren Vorteil, weil das Protokoll einfach ist und Android explizit das Ziel ist.
### Zielarchitektur
```text
Android App
UI: Jetpack Compose
State: ViewModel + StateFlow
REST: Retrofit/OkHttp
Socket: Socket.IO Android Client
Session: OkHttp CookieJar + EncryptedSharedPreferences/DataStore
Images: Android Photo Picker + Multipart Upload
Existing Backend
Express REST API
Socket.IO Chat Events
express-session Cookie connect.sid
```
## Backend-Anbindung
### Basis-URL
Production:
```text
https://www.ypchat.net
```
Development:
```text
http://10.0.2.2:3300
```
`10.0.2.2` ist im Android Emulator der Host-Rechner. Auf echtem Geraet braucht es die lokale LAN-IP oder einen Dev-Tunnel.
### REST-Endpunkte
Die Android-App kann folgende vorhandene Endpunkte direkt verwenden:
| Zweck | Methode | Pfad | Android-Nutzung |
|---|---:|---|---|
| Health Check | GET | `/api/health` | Diagnose/Verbindungscheck |
| Session holen | GET | `/api/session` | Cookie initialisieren, Session wiederherstellen, Express-Session-ID erhalten |
| Logout | POST | `/api/logout` | serverseitige Session beenden und Socket trennen |
| Laenderliste | GET | `/api/countries` | Login-Land-Auswahl und Flag-Code |
| Bild hochladen | POST | `/api/upload-image` | Multipart-Upload mit Session-Cookie |
| Bild abrufen | GET | `/api/image/:code` | Anzeige empfangener Bilder |
| Partner | GET | `/api/partners` | optionaler App-Screen |
| Feedback laden | GET | `/api/feedback` | optionaler Feedback-Screen |
| Feedback senden | POST | `/api/feedback` | optionaler Feedback-Screen |
| Feedback Admin Login | POST | `/api/feedback/admin-login` | optional, eher nicht MVP |
| Feedback Admin Logout | POST | `/api/feedback/admin-logout` | optional, eher nicht MVP |
| Feedback loeschen | DELETE | `/api/feedback/:id` | optional, eher nicht MVP |
Wichtig: REST und Socket muessen dieselbe Cookie-Session verwenden. Android braucht daher einen gemeinsamen `OkHttpClient` mit persistenter Cookie-Verwaltung.
## Socket.IO-Protokoll
### Verbindungsaufbau
Ablauf analog zum Web-Frontend:
1. `GET /api/session` aufrufen, damit das Backend ein `connect.sid`-Cookie setzt und die `sessionId` zurueckgibt.
2. Socket.IO zu derselben Basis-URL verbinden.
3. Nach `connect` das Event `setSessionId` senden:
```json
{
"expressSessionId": "<sessionId aus /api/session>"
}
```
4. Server sendet `connected`.
5. Wenn `connected.loggedIn === true`, App-State wiederherstellen.
6. Sonst Login-Screen anzeigen.
### Client sendet
| Event | Payload | Zweck |
|---|---|---|
| `setSessionId` | `{ "expressSessionId": string }` | Socket mit Express-Session verknuepfen |
| `login` | `{ "userName": string, "gender": string, "age": number, "country": string, "expressSessionId": string }` | Chat-Login |
| `message` | `{ "toUserName": string, "message": string, "messageId": string }` | Textnachricht |
| `message` | `{ "toUserName": string, "message": imageCode, "messageId": string, "isImage": true, "imageUrl": string }` | Bildnachricht nach REST-Upload |
| `requestConversation` | `{ "withUserName": string }` | Konversation laden und als gelesen markieren |
| `userSearch` | `{ "nameIncludes"?, "minAge"?, "maxAge"?, "countries"?, "genders"? }` | Benutzer suchen |
| `requestHistory` | kein Payload | Chatverlauf-Liste laden |
| `requestOpenConversations` | kein Payload | Inbox/ungelesene Chats laden |
| `blockUser` | `{ "userName": string }` | Benutzer blockieren |
| `unblockUser` | `{ "userName": string }` | Benutzer entblockieren |
### Server sendet
| Event | Payload | Android-State |
|---|---|---|
| `connected` | `{ "sessionId": string, "loggedIn"?: boolean, "user"?: User }` | Session setzen, ggf. Login wiederherstellen |
| `loginSuccess` | `{ "sessionId": string, "user": User }` | Login-State setzen |
| `userList` | `{ "users": User[] }` | Online-Liste aktualisieren |
| `message` | `{ "from": string, "message": string, "messageId": string, "timestamp": string, "isImage"?, "imageUrl"?, "imageCode"? }` | Nachricht empfangen |
| `messageSent` | `{ "messageId": string, "to": string }` | lokale Nachricht bestaetigen |
| `conversation` | `{ "with": string, "messages": Message[] }` | Konversation anzeigen |
| `searchResults` | `{ "results": User[] }` | Suchergebnisse anzeigen |
| `historyResults` | `{ "results": HistoryItem[] }` | Verlauf anzeigen |
| `inboxResults` | `{ "results": InboxItem[] }` | Inbox anzeigen |
| `unreadChats` | `{ "count": number }` | Badge aktualisieren |
| `commandResult` | `{ "lines": string[], "kind": string }` | Admin-/Slash-Command-Hinweise anzeigen |
| `commandTable` | `{ "title": string, "columns": string[], "rows": unknown[][] }` | Admin-/Statistik-Tabelle anzeigen |
| `userBlocked` | `{ "userName": string }` | Blockierstatus setzen |
| `userUnblocked` | `{ "userName": string }` | Blockierstatus entfernen |
| `error` | `{ "message": string }` | Snackbar/Dialog anzeigen |
## Android-Modulstruktur
Vorschlag fuer ein neues Modul oder separates Repo:
```text
android/
app/
src/main/
java/net/ypchat/app/
MainActivity.kt
YpChatApp.kt
core/
Config.kt
SessionCookieJar.kt
NetworkModule.kt
data/
api/
RestApi.kt
SocketClient.kt
model/
UserDto.kt
MessageDto.kt
ConversationDto.kt
repository/
ChatRepository.kt
FeedbackRepository.kt
ui/
login/
chat/
users/
search/
inbox/
history/
feedback/
common/
```
### Verantwortlichkeiten
- `RestApi`: Retrofit-Definitionen fuer `/api/*`
- `SocketClient`: kapselt Socket.IO-Verbindung, Event-Handler und Emits
- `ChatRepository`: verbindet REST, Socket und lokalen App-State
- `ChatViewModel`: bietet `StateFlow<ChatUiState>` fuer Compose
- `SessionCookieJar`: persistiert `connect.sid`, damit REST und Socket dieselbe Session nutzen
- `ImageUploader`: Photo Picker, Komprimierung falls noetig, Multipart Upload
## UI-Konzept
### Navigation
MVP-Screens:
- Login
- Online-Benutzer
- Suche
- Chat
- Inbox
- Verlauf
- Profil/Logout
Optional:
- Feedback
- Partner
- Regeln/Sicherheit/FAQ als native Info-Screens oder WebContent aus statischen Texten
### Mobile UX
Der Webclient ist desktop-/browsernah. Die App sollte mobiler denken:
- Startet in Login oder zuletzt aktiver Chat-Ansicht
- Bottom Navigation fuer `Online`, `Suche`, `Inbox`, `Verlauf`
- Chat-Screen mit fester Eingabezeile unten
- Online-Status und Flagge direkt in User-Zeilen
- Unread-Badge auf Inbox-Tab
- Bildauswahl ueber Android Photo Picker
- Fehlermeldungen als Snackbar, kritische Session-Fehler als Dialog
## Session- und Reconnect-Konzept
### Persistenz
Gespeichert werden lokal:
- `connect.sid` Cookie
- letzter bekannter Login-State fuer UI-Skeleton
- Logout-Marker, analog `singlechat_logged_out` im Web
- keine Chatnachrichten dauerhaft im MVP, weil Backend diese aktuell nur im Arbeitsspeicher haelt
### Reconnect
Empfohlener Ablauf:
1. App startet.
2. Wenn kein Logout-Marker vorhanden: `GET /api/session`.
3. Wenn Session `loggedIn`: Socket verbinden und `setSessionId` senden.
4. Wenn Socket reconnectet: erneut `GET /api/session`, dann `setSessionId`.
5. Bei `connect_error`: exponentielles Retry mit sichtbarem Offline-Banner.
6. Bei Logout: `POST /api/logout`, Socket disconnect, Cookie loeschen, Logout-Marker setzen.
### Inaktivitaet
Backend und Webclient verwenden 30 Minuten. Android sollte dieselbe Regel im UI spiegeln:
- Timer startet nach `loginSuccess` oder Session-Restore.
- Timer wird bei Senden, Empfangen, Suche und Conversation Requests zurueckgesetzt.
- Bei Ablauf: lokaler Logout und optional `POST /api/logout`.
## Bildversand
Ablauf:
1. Benutzer waehlt Bild per Photo Picker.
2. App prueft MIME-Type und Groesse.
3. Optional: Bild auf sinnvolle Chat-Groesse komprimieren, z.B. max. 1600 px Kantenlaenge.
4. `POST /api/upload-image` als Multipart `image`.
5. Backend antwortet mit:
```json
{
"success": true,
"code": "<code>",
"url": "/api/image/<code>"
}
```
6. App sendet Socket-Event `message` mit `isImage: true`, `message: code`, `imageUrl: url`.
7. Anzeige erfolgt ueber volle URL `https://www.ypchat.net/api/image/<code>`.
Hinweis: Der Backend-Endpunkt erlaubt 5 MB Upload und haelt Bilder temporaer fuer 6 Stunden.
## Backend-Anpassungen Vor Android-Release
Die App kann grundsaetzlich mit dem aktuellen Backend starten. Sinnvolle kleine Anpassungen wuerden die Mobile-Integration aber robuster machen:
- CORS ist fuer mobile Apps weniger kritisch, aber Socket.IO-Origin-Handling sollte getestet werden, weil native Clients oft keinen Browser-Origin senden.
- Session-Handling sollte fuer Android explizit dokumentiert werden: `GET /api/session` vor Socket-Verbindung.
- WebSocket ueber Apache sollte sauber funktionieren. Android kann Polling fallbacken, aber echte WebSocket-Verbindung ist fuer Akku und Latenz besser.
- `/api/upload-image` hat aktuell einen Fallback auf den zuletzt aktiven Client. Fuer Mobile waere sauberer: eindeutig ueber `req.sessionID` validieren und keine Aktivitaets-Heuristik verwenden.
- Nachrichten und Konversationen liegen aktuell im Arbeitsspeicher. Fuer eine App mit Reconnect/Background-Nutzung sollte mittelfristig Persistenz ergaenzt werden.
- Optional: API-Versionierung einfuehren, z.B. `/api/v1/session`, bevor App-Versionen langfristig im Umlauf sind.
## Datenschutz Und Store-Themen
Vor Veroeffentlichung im Play Store beachten:
- Datenschutzerklaerung direkt in App verlinken
- klare Alters-/Community-Regeln im Onboarding
- Hinweis, dass Chatnachrichten temporaer serverseitig verarbeitet werden
- Bild-Upload transparent erklaeren
- Melde-/Blockierfunktion prominent erreichbar machen
- Android `INTERNET` Permission erforderlich
- Keine Speicherpermission noetig, wenn Android Photo Picker genutzt wird
Wenn die App spaeter Push Notifications bekommt, braucht es FCM, Datenschutz-Ergaenzung und serverseitige Device-Token-Verwaltung.
## MVP-Backlog
### Phase 1: Android-Projekt
- Gradle/Kotlin/Compose-Projekt anlegen
- App-Theme und Basisnavigation erstellen
- `Config` fuer Dev/Prod-Basis-URL
- OkHttp, Retrofit und Socket.IO-Client einrichten
- Persistente CookieJar implementieren
### Phase 2: Session Und Login
- `GET /api/session`
- Socket-Verbindung mit `setSessionId`
- Login-Screen
- `login` Event
- `connected`, `loginSuccess`, `error`
- Logout mit `/api/logout`
### Phase 3: Chat-Kern
- Online-Userliste via `userList`
- Chat-Screen
- `requestConversation`
- Textnachrichten senden/empfangen
- `messageSent`, `unreadChats`
- Reconnect und Offline-Banner
### Phase 4: Suche, Inbox, Verlauf
- Suchformular und `userSearch`
- Suchergebnisse
- Inbox mit `requestOpenConversations`
- Verlauf mit `requestHistory`
- Blockieren/Entblockieren
### Phase 5: Bilder
- Android Photo Picker
- Multipart Upload zu `/api/upload-image`
- Bildnachricht via Socket senden
- Bildanzeige ueber `/api/image/:code`
- Fehlerbehandlung fuer abgelaufene Bilder
### Phase 6: Release-Haertung
- ProGuard/R8-Regeln fuer Socket.IO/OkHttp testen
- Crash-/Error-Logging entscheiden
- Play Store Icons, Screenshots, App Name
- Datenschutz-/Impressum-/Regeln-Screens
- Test auf Emulator, echtem Android-Geraet, schlechtem Netz und App-Background
## Risiken
| Risiko | Auswirkung | Gegenmassnahme |
|---|---|---|
| Socket.IO Android Client Version passt nicht zum Server | Verbindungsfehler | Version gegen Socket.IO Server 4.x testen, `allowEIO3` ist serverseitig aktiv |
| Session-Cookie wird nicht zwischen REST und Socket geteilt | Login/Upload funktionieren inkonsistent | gemeinsame OkHttp CookieJar und expliziter `setSessionId` Flow |
| Apache WebSocket-Proxy ist instabil | Reconnects/Latenz | WebSocket-Regeln pruefen, Polling als Fallback |
| Backend speichert Chat nur im RAM | Nachrichten nach Server-Restart weg | fuer MVP akzeptieren, spaeter DB-Persistenz |
| App im Hintergrund verliert Socket | Nachrichten kommen nur bei geoeffneter App | fuer MVP akzeptieren, spaeter Push Notifications |
| Bild-Upload-Session-Fallback ist unscharf | falsche Zuordnung theoretisch moeglich | Backend vor Release eindeutiger machen |
## Offene Entscheidungen
- Soll Android zuerst als separates Repo oder als `android/` Ordner in diesem Repo entstehen?
- Soll der MVP nur Chat enthalten oder auch Feedback/Partner/FAQ?
- Soll das bestehende Design exakt nachgebaut oder fuer Mobile bewusst neu interpretiert werden?
- Soll die erste Version ohne Push Notifications starten?
## Empfehlung Fuer Den Naechsten Schritt
Ich wuerde als naechstes ein Android-Projekt im Ordner `android/` scaffolden und zuerst nur den technischen Durchstich bauen:
1. `GET /api/session`
2. Socket.IO connect
3. `setSessionId`
4. Login
5. Empfang von `userList`
6. Senden und Empfangen einer Textnachricht
Wenn dieser Durchstich steht, ist der groesste technische Unsicherheitsblock geloest. Danach ist der Rest vor allem UI- und Zustandsarbeit.

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

@@ -0,0 +1,209 @@
# Data Safety Entwurf
Dies ist eine vorsichtige Arbeitsgrundlage für die Google-Play-Console auf Basis des aktuellen Android-Clients und des Node/Socket-Backends.
Wichtig:
- Das ist kein juristischer Bescheid, sondern eine technische Vorbewertung.
- Vor dem finalen Absenden in Play Console muss geprüft werden, ob Server, Reverse Proxy, Hosting oder Analytics weitere Daten verarbeiten.
- Wenn Drittanbieter-Dienste oder zusätzliche Logs aktiv sind, muss die Erklärung erweitert werden.
## Technische Beobachtung aus dem aktuellen Projekt
Die App überträgt oder verarbeitet aktuell mindestens:
- Nickname
- Alter
- Geschlecht
- Land
- Chat-Nachrichten
- hochgeladene Bilder
- Feedback-Inhalte
- Session-/Cookie-Daten
- technische Verbindungsdaten im Rahmen von HTTP und Socket.IO
Es gibt im aktuellen Android-Projekt keine Hinweise auf:
- Werbe-SDKs
- In-App-Käufe
- Standortberechtigung
- Kontakte
- Telefonbuch
- Mikrofon
- Kamera-Zwang
- präzisen Standort
## Konservative Erstbewertung für Google Play
Empfehlung für die erste Ausfüllung: eher vorsichtig angeben, was tatsächlich serverseitig übertragen wird.
### 1. Personal info
Wahrscheinlich anzugeben:
- `Name`
- Begründung: Der Nickname wird an das Backend übertragen und gegenüber anderen Nutzern angezeigt.
- `Other info`
- Begründung: Alter, Geschlecht und Land werden übertragen und im Chatkontext genutzt.
### 2. Messages
Wahrscheinlich anzugeben:
- `Other in-app messages`
- Begründung: Chat-Nachrichten werden über Socket.IO übertragen und serverseitig verarbeitet.
### 3. Photos and videos
Wahrscheinlich anzugeben:
- `Photos`
- Begründung: Bilder werden aktiv hochgeladen und anderen Nutzern im Chat zugänglich gemacht.
### 4. App info and performance / Diagnostics
Nur angeben, wenn tatsächlich entsprechende Logs dauerhaft erhoben, gespeichert oder ausgewertet werden.
Aktueller Code zeigt:
- Server-Logs in der Konsole
- Session-/Verbindungsprüfung
Das reicht nicht automatisch für jede Play-Kategorie. Hier vorsichtig prüfen, was auf dem Hosting tatsächlich gespeichert wird.
### 5. Device or other IDs
Nicht vorschnell anhaken.
Die Session-ID allein ist nicht automatisch gleichbedeutend mit einer dauerhaft erhobenen Geräte-ID im Sinne der Play-Kategorien. Nur dann angeben, wenn ihr eine solche ID gezielt speichert oder zur Profilbildung nutzt.
## Vorschlag für die einzelnen Play-Fragen
### Werden Daten erhoben?
Voraussichtlich:
- `Ja`
### Werden Daten geteilt?
Technisch nach aktuellem Stand wahrscheinlich:
- `Nein`, sofern keine Weitergabe an Drittanbieter, Werbenetzwerke oder externe Analyseanbieter erfolgt
Wichtig:
- Wenn Webserver, CDN, Anti-Abuse-Dienste oder Hosting-Provider Daten in eigenem Namen auswerten, muss das separat geprüft werden.
### Sind alle Daten verschlüsselt?
Für Release vorgesehen:
- `Ja`, wenn die produktive Android-App ausschließlich über HTTPS/WSS gegen `https://www.ypchat.net` läuft
Wichtig:
- Das setzt voraus, dass produktiv wirklich kein unverschlüsselter Verkehr verwendet wird.
### Können Nutzer die Löschung ihrer Daten beantragen?
Noch offen:
- Das muss mit eurem tatsächlichen Prozess beantwortet werden.
Wenn derzeit keine echte Löschfunktion oder kein dokumentierter Löschprozess existiert, dann nicht voreilig `Ja` angeben.
## Arbeitsmatrix für die Play Console
## Datentyp: Name
- Empfehlung: `Collected`
- Shared: `No`
- Required or optional: `Optional`
- Purpose:
- `App functionality`
- eventuell `Developer communications`, falls Feedback-Antworten darüber organisiert würden
Begründung:
- Nickname wird für Identifikation im Chat benötigt, ist aber vom Nutzer frei gewählt.
## Datentyp: Other personal info
- Empfehlung: `Collected`
- Shared: `No`
- Required or optional: `Optional` bis `Required`
Empfehlung:
- eher `Required`, wenn Alter, Geschlecht und Land für die Nutzung zwingend eingegeben werden müssen
- Purpose:
- `App functionality`
Begründung:
- Diese Angaben sind Bestandteil des Chat-Profils und der Suche.
## Datentyp: Messages
- Empfehlung: `Collected`
- Shared: `No`
- Required or optional: `Required`
- Purpose:
- `App functionality`
Begründung:
- Ohne Nachrichtenfunktion existiert die App inhaltlich nicht.
## Datentyp: Photos
- Empfehlung: `Collected`
- Shared: `No`
- Required or optional: `Optional`
- Purpose:
- `App functionality`
Begründung:
- Bilder sind optional, aber Teil des Chat-Funktionsumfangs.
## Datentyp: User-generated content
Prüfen, ob zusätzlich anzugeben:
- Feedback-Kommentare
Je nach Auslegung kann das in Play Console zusätzlich als nutzergenerierter Inhalt relevant sein. Wenn die Konsole dafür eine passende Kategorie anbietet, ist `Collected`, `No shared`, `Optional`, `App functionality` eine plausible Einstufung.
## Punkte, die vor dem finalen Absenden geklärt werden sollten
1. Werden Chat-Nachrichten dauerhaft gespeichert oder nur teilweise?
2. Werden hochgeladene Bilder nur temporär gespeichert oder zusätzlich archiviert?
3. Werden Server-Logs mit IP-Adressen dauerhaft aufbewahrt?
4. Gibt es einen dokumentierten Löschprozess für Nutzeranfragen?
5. Gibt es irgendeine Form von Drittanbieter-Tracking oder Hosting-Auswertung?
6. Existiert bereits eine öffentliche Datenschutzerklärung mit genau diesen Punkten?
## Empfehlung für die Play-Console-Erstbefüllung
Mit heutigem Stand würde ich technisch von folgendem Startpunkt ausgehen:
- Daten werden erhoben: `Ja`
- Daten werden geteilt: `Nein`
- Erhobene Kategorien:
- Name / Nickname
- sonstige persönliche Angaben
- Nachrichten
- Fotos
- ggf. nutzergenerierte Inhalte
- Zwecke:
- `App functionality`
- Verschlüsselung während der Übertragung:
- `Ja`, sofern Produktion ausschließlich HTTPS/WSS nutzt
- Löschanfrage:
- nur `Ja`, wenn ihr das tatsächlich organisatorisch abdecken könnt
## Was noch fehlt
Für einen wirklich sauberen Play-Store-Release fehlt noch mindestens:
1. finale Datenschutzerklärung-URL
2. Entscheidung zur Store-Kategorie
3. finale Prüfung, ob Server-/Hosting-Logs zusätzliche Datenkategorien auslösen

View File

@@ -0,0 +1,64 @@
# Play Console Checkliste
Diese Liste ist als operative Vorlage für den ersten Eintrag in Google Play gedacht.
## A. App anlegen
- Entwicklerkonto geöffnet
- neue App angelegt
- Standardsprache gewählt
- App-Name eingetragen
- App oder Spiel ausgewählt
- kostenlose oder kostenpflichtige App festgelegt
- Kontakt-E-Mail hinterlegt
## B. Store-Eintrag
Aus [PLAY_STORE_LISTING.md](C:\Users\Torsten\OneDrive\Apps\singlechat\android\PLAY_STORE_LISTING.md) übernehmen:
- App-Name
- Kurzbeschreibung DE
- Kurzbeschreibung EN
- Vollbeschreibung DE
- Vollbeschreibung EN
Zusätzlich hochladen:
- App-Symbol
- Smartphone-Screenshots
- Feature Graphic, falls gewünscht
## C. App-Inhalte
- Kategorie festgelegt
- Zielgruppe geprüft
- Werberichtlinien geprüft
- Data-Safety-Formular ausgefüllt
- Datenschutzerklärung-URL eingetragen:
- `https://www.ypchat.net/datenschutz`
## D. Technische Veröffentlichung
- `versionCode` geprüft
- `versionName` geprüft
- Release-Key vorhanden
- `android/key.properties` vorhanden
- `.aab` gebaut
- `.aab` in `Internal testing` hochgeladen
## E. Test-Track
- internen Test angelegt
- Tester hinzugefügt
- Release Notes eingetragen
- Installationslink geprüft
## F. Vor Produktionsfreigabe
- Login funktioniert
- Chat funktioniert
- Bildversand funktioniert
- Icon korrekt
- Datenschutz-Seite online erreichbar
- Impressum online erreichbar
- keine offensichtlichen UI-Fehler

View File

@@ -0,0 +1,120 @@
# Play Store Texte
Diese Vorschläge sind auf den aktuellen Funktionsumfang der Android-App abgestimmt:
- Login mit Nickname, Alter, Geschlecht, Land
- internationalisierte Oberfläche
- Live-Chat per Socket-Verbindung
- Bildaustausch
- Blockieren / Entsperren
- Feedback-Bereich
## App-Name
Empfehlung:
- `YPChat - Single Chat`
Alternative:
- `YpChat`
- `YPChat`
## Kurzbeschreibung
### Deutsch
Empfehlung:
`International chatten, Bilder teilen und neue Kontakte direkt per Live-Chat finden.`
Alternative:
`Direkter Single-Chat mit Bildaustausch, Länderwahl und schneller Live-Verbindung.`
### Englisch
Recommended:
`Chat worldwide, share images and meet new people instantly in live private chat.`
Alternative:
`Fast live chat with image sharing, country selection and direct private messaging.`
## Vollständige Beschreibung
### Deutsch
`YPChat ist eine schnelle und direkte Chat-App für Menschen, die unkompliziert neue Kontakte knüpfen möchten. Du erstellst in wenigen Sekunden dein Profil mit Nickname, Alter, Geschlecht und Land und bist sofort im Live-Chat aktiv.
Die App verbindet sich in Echtzeit mit dem Chat-Backend und zeigt dir online verfügbare Nutzer, Suchergebnisse, offene Unterhaltungen und deinen Verlauf. Gespräche laufen direkt und ohne Umwege. Zusätzlich kannst du Bilder senden und empfangen, um Unterhaltungen persönlicher zu machen.
Zu den wichtigsten Funktionen gehören:
- Live-Chat mit direkter Socket-Verbindung
- internationale Nutzung mit lokalisierter Oberfläche
- Länder- und Geschlechtsauswahl beim Einstieg
- Suche nach passenden Kontakten
- Posteingang und Verlauf
- Bildaustausch im Chat
- Nutzer blockieren und wieder entsperren
- Feedback-Bereich für Hinweise und Verbesserungsvorschläge
YPChat legt Wert auf einfache Bedienung, schnelle Reaktion und einen kompakten Einstieg ohne unnötige Schritte. Wenn du spontan chatten, neue Menschen kennenlernen und Inhalte direkt austauschen möchtest, bietet dir die App einen klaren und schnellen Zugang.`
### Englisch
`YPChat is a fast and direct chat app for people who want to meet new contacts without unnecessary steps. You create a profile in seconds with nickname, age, gender and country, then enter live chat immediately.
The app connects to the chat backend in real time and shows online users, search results, open conversations and chat history. Conversations are direct and lightweight. You can also send and receive images to make chats more personal.
Main features include:
- live chat with real-time socket connection
- international usage with a localized interface
- country and gender selection during onboarding
- search for matching contacts
- inbox and history
- image sharing in chat
- block and unblock users
- feedback area for reports and suggestions
YPChat focuses on simple interaction, fast response and quick entry into conversation. If you want to chat spontaneously, meet new people and exchange content directly, the app gives you a clear and fast experience.`
## Keywords / Suchbegriffe
Nicht direkt als Google-Play-Feld vorhanden, aber hilfreich für interne Abstimmung:
- Single Chat
- Live Chat
- anonymer Chat
- privater Chat
- international chat
- Bildaustausch
- neue Leute kennenlernen
- online chat
## Screenshots-Empfehlung
Für den ersten Store-Eintrag empfehle ich mindestens diese Smartphone-Screens:
1. Login mit Länder- und Geschlechtsauswahl
2. Startseite / Online-Liste
3. Chatansicht mit echter Unterhaltung
4. Suchansicht
5. Mehr-Bereich mit Feedback / FAQ / Sicherheit
## Offene Punkte vor Store-Eintrag
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` sein?
2. Welche URL wird als Datenschutzerklärung verwendet?
3. Soll die App als `Social`, `Dating` oder `Communication` eingeordnet werden?
## Empfehlung
Wenn du schnell live gehen willst, würde ich für den ersten Store-Eintrag Folgendes verwenden:
- App-Name: `YPChat - Single Chat`
- Kurzbeschreibung DE: `International chatten, Bilder teilen und neue Kontakte direkt per Live-Chat finden.`
- Kurzbeschreibung EN: `Chat worldwide, share images and meet new people instantly in live private chat.`

161
android/PUBLISHING.md Normal file
View File

@@ -0,0 +1,161 @@
# Android-Veröffentlichung
Diese App wird für Google Play als `Android App Bundle` (`.aab`) veröffentlicht.
## Stand des Projekts
- `applicationId`: `net.ypchat.app`
- `minSdk`: `26`
- `targetSdk`: `36`
- Produktiv-Backend standardmäßig: `https://www.ypchat.net`
- Release-Build verwendet standardmäßig `HTTPS only`
Hinweis: Laut Android Developers müssen neue Apps bei Google Play seit dem 31. August 2025 mindestens auf API-Level 35 zielen. Dieses Projekt erfüllt das bereits mit `targetSdk = 36`.
Quelle:
- [Meet Google Play's target API level requirement](https://developer.android.com/distribute/best-practices/develop/target-sdk)
- [Use Play App Signing](https://support.google.com/googleplay/android-developer/answer/9842756)
- [Provide information for Google Play's Data safety section](https://support.google.com/googleplay/android-developer/answer/10787469)
## 1. Upload-Key erzeugen
Im Ordner `android/` einen Upload-Key anlegen, zum Beispiel:
```powershell
keytool -genkeypair `
-v `
-keystore release-upload-key.jks `
-alias upload `
-keyalg RSA `
-keysize 4096 `
-validity 10000
```
Danach `android/key.properties` aus `android/key.properties.example` erzeugen:
```properties
storeFile=release-upload-key.jks
storePassword=DEIN_STORE_PASSWORT
keyAlias=upload
keyPassword=DEIN_KEY_PASSWORT
```
Wichtig:
- `android/key.properties`
- `android/*.jks`
- `android/*.keystore`
sind in `.gitignore` ausgenommen und sollen nicht committed werden.
## 2. Versionsnummer vor Release setzen
Datei:
- `android/app/build.gradle.kts`
Vor jedem Store-Release anpassen:
```kotlin
versionCode = 2
versionName = "0.2.0"
```
Regel:
- `versionCode` muss bei jedem Play-Update höher sein als beim letzten Upload.
- `versionName` ist die sichtbare Versionsnummer im Store.
## 3. Release-Bundle bauen
Im Ordner `android/`:
```powershell
.\gradlew bundleRelease
```
Das Ergebnis liegt hier:
```text
android/app/build/outputs/bundle/release/app-release.aab
```
Falls zuerst ein Clean-Build gewünscht ist:
```powershell
.\gradlew clean bundleRelease
```
## 4. Google Play Console
Für eine neue App in der Play Console:
1. App anlegen
2. Standardsprache wählen
3. App-Name festlegen
4. App-Kategorie auswählen
5. Kontakt-E-Mail hinterlegen
Danach:
1. `Internal testing` oder `Closed testing` anlegen
2. `app-release.aab` hochladen
3. Play App Signing aktivieren
Empfehlung:
- Für die erste Veröffentlichung zuerst `Internal testing`
- Danach `Closed testing`
- Erst anschließend `Production`
## 5. Store-Inhalte vorbereiten
Benötigt werden in der Regel:
- App-Name
- Kurzbeschreibung
- Vollständige Beschreibung
- App-Symbol
- Screenshots vom Smartphone
- Datenschutzerklärung-URL
- Support-/Kontaktadresse
Für diese App zusätzlich sinnvoll:
- Hinweis auf Chat, Bildaustausch und internationale Nutzung
- Hinweis auf Moderation/Blockieren/Feedback
## 6. Data Safety
In der Play Console muss das Formular zur Datensicherheit ausgefüllt werden.
Nach aktuellem Projektstand werden mindestens diese Daten serverseitig verarbeitet oder übertragen:
- Nutzername
- Alter
- Geschlecht
- Land
- Chat-Nachrichten
- Bilder
- Feedback-Inhalte
- Session-/Verbindungsdaten
Das Formular muss anhand des tatsächlichen Serververhaltens final geprüft werden. Wenn Backend oder Logs zusätzliche Daten erfassen, muss das dort ebenfalls deklariert werden.
## 7. Vor dem ersten echten Release prüfen
- Release-Build startet auf echtem Gerät
- Login gegen Produktiv-Backend funktioniert
- Socket-Verbindung stabil
- Bilder senden/empfangen funktioniert
- Länderauswahl/Geschlecht korrekt
- App-Symbol korrekt
- Texte/Internationalisierung korrekt
- Impressum und Datenschutzerklärung vorhanden
- Datenschutzerklärung-URL öffentlich erreichbar
## 8. Empfehlung für den nächsten Schritt
Der sinnvollste nächste operative Schritt ist:
1. Upload-Key erzeugen
2. `android/key.properties` lokal anlegen
3. `versionCode` und `versionName` setzen
4. `bundleRelease` bauen
5. mit dem erzeugten `.aab` in `Internal testing` hochladen

71
android/README.md Normal file
View File

@@ -0,0 +1,71 @@
# YPChat Android
Native Android-App fuer den bestehenden YPChat-Server.
## Stack
- Kotlin
- Jetpack Compose
- Retrofit/OkHttp fuer REST
- Socket.IO Android Client fuer den Chat
- persistente OkHttp `CookieJar`, damit REST und Socket dieselbe `connect.sid`-Session nutzen
## Projekt Oeffnen
Den Ordner `android/` in Android Studio oeffnen. Das Projekt ist auf Android Gradle Plugin `8.13.2`, Gradle `8.13`, JDK 17 und `compileSdk = 36` ausgelegt.
Falls Android Studio keinen Gradle Wrapper erzeugt, kann er in diesem Ordner mit einer lokalen Gradle-Installation nachgezogen werden:
```powershell
gradle wrapper --gradle-version 8.13
```
Danach:
```powershell
.\gradlew.bat :app:assembleDebug
```
## Backend-URLs
Debug und Release verwenden standardmaessig:
```text
https://www.ypchat.net
```
Fuer lokale Tests kann die URL in `android/local.properties` ueberschrieben werden:
```properties
ypchat.baseUrl=http://10.0.2.2:3300
```
`10.0.2.2` zeigt im Android Emulator auf den lokalen Rechner. Auf einem echten Geraet muss stattdessen die LAN-IP des Rechners oder ein Dev-Tunnel verwendet werden, z.B.:
```properties
ypchat.baseUrl=http://192.168.178.42:3300
```
## Implementierter Durchstich
- `GET /api/session`
- Socket.IO Connect mit WebSocket zuerst und Polling-Fallback
- `setSessionId`
- `login`
- `userList`
- `requestConversation`
- Textnachrichten ueber `message`
- `requestOpenConversations`
- `requestHistory`
- `userSearch`
- `blockUser`
- `POST /api/logout`
- Laenderliste im Login ueber `/api/countries`
- Upload-Client fuer `/api/upload-image` im Repository vorbereitet
## Noch Offen
- Android Photo Picker an `uploadImage()` anschliessen
- lokale Logout-Markierung analog Web-Frontend speichern
- Reconnect-Feinschliff nach App-Background testen
- Play-Store-Texte, Datenschutz, Impressum und App-Icons finalisieren

View File

@@ -0,0 +1,50 @@
# Release Upload Checkliste
## Vor dem Build
- Produktiv-Backend ist korrekt
- `versionCode` ist höher als beim letzten Upload
- `versionName` ist gesetzt
- `android/key.properties` ist lokal vorhanden
- Upload-Key funktioniert
## Release-Build
Im Ordner `android/`:
```powershell
.\gradlew bundleRelease
```
## Erwartetes Artefakt
```text
android/app/build/outputs/bundle/release/app-release.aab
```
## Vor Upload kurz prüfen
- App startet auf echtem Gerät
- Login gegen Produktion funktioniert
- Online-Liste lädt
- Chat sendet und empfängt
- Bilder senden und empfangen funktioniert
- Länderauswahl vorhanden
- Geschlechtsauswahl vorhanden
- Datenschutz unter `https://www.ypchat.net/datenschutz` erreichbar
- App-Symbol korrekt
## Upload in Play Console
1. `Internal testing`
2. neues Release anlegen
3. `.aab` hochladen
4. Release Notes ergänzen
5. speichern
6. an Tester ausrollen
## Nach Upload
- Installationslink testen
- App auf mindestens einem echten Gerät aus dem Test-Track installieren
- Upgrade-Fähigkeit für spätere Versionen im Blick behalten

View File

@@ -0,0 +1,119 @@
import java.util.Properties
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.plugin.compose")
}
val localProperties = Properties().apply {
val file = rootProject.file("local.properties")
if (file.exists()) {
file.inputStream().use(::load)
}
}
val keyProperties = Properties().apply {
val file = rootProject.file("key.properties")
if (file.exists()) {
file.inputStream().use(::load)
}
}
val releaseStoreFile = keyProperties.getProperty("storeFile")?.let { rootProject.file(it) }
val defaultBaseUrl = "https://www.ypchat.net"
val appBaseUrl = localProperties.getProperty("ypchat.baseUrl", defaultBaseUrl)
val hasReleaseSigning = releaseStoreFile?.exists() == true
android {
namespace = "de.ypchat.android"
compileSdk = 36
defaultConfig {
applicationId = "de.ypchat.android"
minSdk = 26
targetSdk = 36
versionCode = 3
versionName = "1.2.0"
}
lint {
checkReleaseBuilds = false
abortOnError = false
}
signingConfigs {
if (hasReleaseSigning) {
create("release") {
storeFile = releaseStoreFile
storePassword = keyProperties.getProperty("storePassword")
keyAlias = keyProperties.getProperty("keyAlias")
keyPassword = keyProperties.getProperty("keyPassword")
}
}
}
buildTypes {
debug {
buildConfigField("String", "BASE_URL", "\"$appBaseUrl\"")
manifestPlaceholders["usesCleartextTraffic"] = true
}
release {
buildConfigField("String", "BASE_URL", "\"$appBaseUrl\"")
isMinifyEnabled = true
isShrinkResources = true
manifestPlaceholders["usesCleartextTraffic"] = false
if (hasReleaseSigning) {
signingConfig = signingConfigs.getByName("release")
}
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
buildFeatures {
compose = true
buildConfig = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2026.03.01")
implementation(composeBom)
androidTestImplementation(composeBom)
implementation("androidx.activity:activity-compose:1.13.0")
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.10.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
implementation("com.squareup.okhttp3:okhttp:5.3.2")
implementation("com.squareup.retrofit2:retrofit:3.0.0")
implementation("com.squareup.retrofit2:converter-gson:3.0.0")
implementation("io.socket:socket.io-client:2.1.2") {
exclude(group = "org.json", module = "json")
}
implementation("io.coil-kt.coil3:coil-compose:3.4.0")
implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0")
implementation("io.github.webrtc-sdk:android:125.6422.07")
debugImplementation("androidx.compose.ui:ui-tooling")
}

6
android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,6 @@
# Keep Socket.IO and Engine.IO callback classes reachable in release builds.
-keep class io.socket.** { *; }
-keep class io.socket.engineio.** { *; }
-keep class okhttp3.** { *; }
-dontwarn io.socket.**
-dontwarn okhttp3.**

Binary file not shown.

View File

@@ -0,0 +1,24 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<application
android:name=".YpChatApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher"
android:usesCleartextTraffic="${usesCleartextTraffic}"
android:supportsRtl="true"
android:theme="@style/Theme.YpChat">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,20 @@
package de.ypchat.android
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import de.ypchat.android.ui.ChatViewModel
import de.ypchat.android.ui.ChatViewModelFactory
import de.ypchat.android.ui.YpChatRoot
class MainActivity : ComponentActivity() {
private val viewModel: ChatViewModel by viewModels {
ChatViewModelFactory((application as YpChatApp).container.chatRepository)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { YpChatRoot(viewModel) }
}
}

View File

@@ -0,0 +1,14 @@
package de.ypchat.android
import android.app.Application
import de.ypchat.android.core.AppContainer
class YpChatApp : Application() {
lateinit var container: AppContainer
private set
override fun onCreate() {
super.onCreate()
container = AppContainer(this)
}
}

View File

@@ -0,0 +1,7 @@
package de.ypchat.android.core
import de.ypchat.android.BuildConfig
object AppConfig {
val baseUrl: String = BuildConfig.BASE_URL.trimEnd('/')
}

View File

@@ -0,0 +1,34 @@
package de.ypchat.android.core
import android.content.Context
import de.ypchat.android.data.api.RestApi
import de.ypchat.android.data.api.SocketClient
import de.ypchat.android.data.repository.ChatRepository
import de.ypchat.android.media.AndroidVideoCallManager
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
class AppContainer(context: Context) {
val cookieJar = SessionCookieJar(context)
val profileStore = ProfileStore(context)
val okHttpClient: OkHttpClient = OkHttpClient.Builder()
.cookieJar(cookieJar)
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
private val retrofit: Retrofit = Retrofit.Builder()
.baseUrl(AppConfig.baseUrl + "/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
val restApi: RestApi = retrofit.create(RestApi::class.java)
val socketClient = SocketClient(AppConfig.baseUrl, okHttpClient)
val videoCallManager = AndroidVideoCallManager(context, socketClient)
val chatRepository = ChatRepository(restApi, socketClient, cookieJar, profileStore, videoCallManager)
}

View File

@@ -0,0 +1,41 @@
package de.ypchat.android.core
import android.content.Context
data class SavedProfile(
val nickname: String = "",
val gender: String = "",
val age: Int = 18,
val country: String = "Germany"
)
class ProfileStore(context: Context) {
private val preferences = context.getSharedPreferences("ypchat_profile", Context.MODE_PRIVATE)
fun read(): SavedProfile = SavedProfile(
nickname = preferences.getString(KEY_NICKNAME, "").orEmpty(),
gender = preferences.getString(KEY_GENDER, "").orEmpty(),
age = preferences.getInt(KEY_AGE, 18),
country = preferences.getString(KEY_COUNTRY, "Germany").orEmpty()
)
fun write(profile: SavedProfile) {
preferences.edit()
.putString(KEY_NICKNAME, profile.nickname.trim())
.putString(KEY_GENDER, profile.gender)
.putInt(KEY_AGE, profile.age)
.putString(KEY_COUNTRY, profile.country)
.apply()
}
fun clear() {
preferences.edit().clear().apply()
}
private companion object {
const val KEY_NICKNAME = "nickname"
const val KEY_GENDER = "gender"
const val KEY_AGE = "age"
const val KEY_COUNTRY = "country"
}
}

View File

@@ -0,0 +1,29 @@
package de.ypchat.android.core
import android.content.Context
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
class SessionCookieJar(context: Context) : CookieJar {
private val prefs = context.getSharedPreferences("ypchat_cookies", Context.MODE_PRIVATE)
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
val editor = prefs.edit()
cookies.forEach { cookie ->
editor.putString(cookie.name, cookie.toString())
}
editor.apply()
}
override fun loadForRequest(url: HttpUrl): List<Cookie> {
return prefs.all.values
.mapNotNull { it as? String }
.mapNotNull { Cookie.parse(url, it) }
.filter { cookie -> cookie.expiresAt > System.currentTimeMillis() }
}
fun clear() {
prefs.edit().clear().apply()
}
}

View File

@@ -0,0 +1,54 @@
package de.ypchat.android.data.api
import de.ypchat.android.data.model.CountriesResponse
import de.ypchat.android.data.model.FeedbackAdminLoginRequest
import de.ypchat.android.data.model.FeedbackAdminStatusResponse
import de.ypchat.android.data.model.FeedbackRequest
import de.ypchat.android.data.model.FeedbackResponse
import de.ypchat.android.data.model.ImageUploadResponse
import de.ypchat.android.data.model.LogoutResponse
import de.ypchat.android.data.model.PartnerLinkDto
import de.ypchat.android.data.model.SessionResponse
import okhttp3.MultipartBody
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
interface RestApi {
@GET("api/session")
suspend fun session(): SessionResponse
@POST("api/logout")
suspend fun logout(): LogoutResponse
@GET("api/countries")
suspend fun countries(): CountriesResponse
@GET("api/feedback")
suspend fun feedback(): FeedbackResponse
@GET("api/feedback/admin-status")
suspend fun feedbackAdminStatus(): FeedbackAdminStatusResponse
@POST("api/feedback")
suspend fun submitFeedback(@Body request: FeedbackRequest)
@POST("api/feedback/admin-login")
suspend fun feedbackAdminLogin(@Body request: FeedbackAdminLoginRequest): FeedbackAdminStatusResponse
@POST("api/feedback/admin-logout")
suspend fun feedbackAdminLogout()
@retrofit2.http.DELETE("api/feedback/{id}")
suspend fun deleteFeedback(@retrofit2.http.Path("id") id: String)
@GET("api/partners")
suspend fun partners(): List<PartnerLinkDto>
@Multipart
@POST("api/upload-image")
suspend fun uploadImage(@Part image: MultipartBody.Part): Response<ImageUploadResponse>
}

View File

@@ -0,0 +1,444 @@
package de.ypchat.android.data.api
import io.socket.client.IO
import io.socket.client.Socket
import io.socket.engineio.client.transports.Polling
import io.socket.engineio.client.transports.WebSocket
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.launch
import de.ypchat.android.data.model.ChatMessageDto
import de.ypchat.android.data.model.HistoryItemDto
import de.ypchat.android.data.model.InboxItemDto
import de.ypchat.android.data.model.SocketEvent
import de.ypchat.android.data.model.UserDto
import de.ypchat.android.data.model.VideoCallDto
import de.ypchat.android.data.model.VideoCapacityDto
import de.ypchat.android.data.model.VideoConsentDto
import de.ypchat.android.data.model.VideoIceCandidateDto
import de.ypchat.android.data.model.VideoIceServerDto
import de.ypchat.android.data.model.VideoMediaDto
import de.ypchat.android.data.model.VideoSessionDescriptionDto
import de.ypchat.android.data.model.VideoSignalDto
import okhttp3.OkHttpClient
import org.json.JSONArray
import org.json.JSONObject
class SocketClient(
private val baseUrl: String,
private val okHttpClient: OkHttpClient
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val _events = MutableSharedFlow<SocketEvent>(extraBufferCapacity = 64)
val events: SharedFlow<SocketEvent> = _events
private var socket: Socket? = null
private var pendingExpressSessionId: String? = null
val isConnected: Boolean
get() = socket?.connected() == true
fun connect() {
disconnect()
val options = IO.Options().apply {
transports = arrayOf(WebSocket.NAME, Polling.NAME)
reconnection = true
reconnectionAttempts = Int.MAX_VALUE
reconnectionDelay = 1_000
timeout = 10_000
callFactory = okHttpClient
webSocketFactory = okHttpClient
}
socket = IO.socket(baseUrl, options).also { s ->
s.on(Socket.EVENT_CONNECT) {
pendingExpressSessionId?.let { sessionId ->
s.emit("setSessionId", JSONObject().put("expressSessionId", sessionId))
}
emit(SocketEvent.ConnectionChanged(true))
}
s.on(Socket.EVENT_DISCONNECT) { args ->
emit(SocketEvent.ConnectionChanged(false, args.firstOrNull()?.toString()))
}
s.on(Socket.EVENT_CONNECT_ERROR) { args ->
emit(SocketEvent.Error("Socket-Verbindung fehlgeschlagen: ${args.firstOrNull()?.toString().orEmpty()}"))
}
s.on("connected") { args ->
args.firstJson()?.let { json ->
emit(
SocketEvent.Connected(
sessionId = json.optStringOrNull("sessionId"),
loggedIn = json.optBoolean("loggedIn", false),
user = json.optJSONObject("user")?.toUserDto()
)
)
}
}
s.on("loginSuccess") { args ->
args.firstJson()?.let { json ->
emit(SocketEvent.LoginSuccess(json.optStringOrNull("sessionId"), json.optJSONObject("user")?.toUserDto()))
}
}
s.on("userList") { args ->
args.firstJson()?.optJSONArray("users")?.let { users ->
emit(SocketEvent.UserList(users.toObjectList { it.toUserDto() }))
}
}
s.on("message") { args ->
args.firstJson()?.let { emit(SocketEvent.IncomingMessage(it.toMessageDto())) }
}
s.on("messageSent") { args ->
args.firstJson()?.let { json ->
emit(SocketEvent.MessageSent(json.optStringOrNull("messageId"), json.optStringOrNull("to")))
}
}
s.on("conversation") { args ->
args.firstJson()?.let { json ->
val messages = json.optJSONArray("messages")?.toObjectList { it.toMessageDto() }.orEmpty()
emit(SocketEvent.Conversation(json.optString("with"), messages))
}
}
s.on("searchResults") { args ->
args.firstJson()?.optJSONArray("results")?.let { results ->
emit(SocketEvent.SearchResults(results.toObjectList { it.toUserDto() }))
}
}
s.on("historyResults") { args ->
args.firstJson()?.optJSONArray("results")?.let { results ->
emit(SocketEvent.HistoryResults(results.toObjectList { it.toHistoryItemDto() }))
}
}
s.on("inboxResults") { args ->
args.firstJson()?.optJSONArray("results")?.let { results ->
emit(SocketEvent.InboxResults(results.toObjectList { it.toInboxItemDto() }))
}
}
s.on("unreadChats") { args ->
args.firstJson()?.let { emit(SocketEvent.UnreadChats(it.optInt("count", 0))) }
}
s.on("videoConsent:update") { args ->
args.firstJson()?.let { emit(SocketEvent.VideoConsentUpdate(it.toVideoConsentDto())) }
}
s.on("videoCall:invite") { args ->
args.firstJson()?.let { emit(SocketEvent.VideoCallInvite(it.toVideoCallDto())) }
}
s.on("videoCall:incoming") { args ->
args.firstJson()?.let { emit(SocketEvent.VideoCallIncoming(it.toVideoCallDto())) }
}
s.on("videoCall:start") { args ->
args.firstJson()?.let { emit(SocketEvent.VideoCallStart(it.toVideoCallDto())) }
}
s.on("videoCall:update") { args ->
args.firstJson()?.let { emit(SocketEvent.VideoCallUpdate(it.toVideoCallDto())) }
}
s.on("videoCall:reject") { args ->
args.firstJson()?.let { emit(SocketEvent.VideoCallReject(it.toVideoCallDto())) }
}
s.on("videoCall:cancel") { args ->
args.firstJson()?.let { emit(SocketEvent.VideoCallCancel(it.toVideoCallDto())) }
}
s.on("videoCall:end") { args ->
args.firstJson()?.let { emit(SocketEvent.VideoCallEnd(it.toVideoCallDto())) }
}
s.on("videoCall:muteState") { args ->
args.firstJson()?.let { emit(SocketEvent.VideoCallMuteState(it.toVideoCallDto())) }
}
s.on("videoCall:capacity") { args ->
args.firstJson()?.let { emit(SocketEvent.VideoCallCapacity(it.toVideoCapacityDto())) }
}
s.on("videoCall:signal") { args ->
args.firstJson()?.let { emit(SocketEvent.VideoCallSignal(it.toVideoSignalDto())) }
}
s.on("videoCall:error") { args ->
args.firstJson()?.let { json ->
emit(
SocketEvent.VideoCallError(
code = json.optStringOrNull("code"),
message = json.optString("message", "Video-Fehler"),
withUserName = json.optStringOrNull("withUserName"),
callId = json.optStringOrNull("callId")
)
)
}
}
s.on("userBlocked") { args ->
args.firstJson()?.let { emit(SocketEvent.UserBlocked(it.optString("userName"))) }
}
s.on("userUnblocked") { args ->
args.firstJson()?.let { emit(SocketEvent.UserUnblocked(it.optString("userName"))) }
}
s.on("commandResult") { args ->
args.firstJson()?.let { json ->
emit(SocketEvent.CommandResult(json.optJSONArray("lines").toStringList(), json.optString("kind", "info")))
}
}
s.on("commandTable") { args ->
args.firstJson()?.let { json ->
emit(
SocketEvent.CommandTable(
title = json.optString("title", "Ausgabe"),
columns = json.optJSONArray("columns").toStringList(),
rows = json.optJSONArray("rows").toNestedStringList()
)
)
}
}
s.on("error") { args ->
val message = args.firstJson()?.optString("message") ?: args.firstOrNull()?.toString() ?: "Unbekannter Socket-Fehler"
emit(SocketEvent.Error(message))
}
s.connect()
}
}
fun disconnect() {
socket?.disconnect()
socket?.off()
socket = null
}
fun setSessionId(expressSessionId: String) {
pendingExpressSessionId = expressSessionId
socket?.takeIf { it.connected() }?.emit("setSessionId", JSONObject().put("expressSessionId", expressSessionId))
}
fun login(userName: String, gender: String, age: Int, country: String, expressSessionId: String?) {
socket?.emit(
"login",
JSONObject()
.put("userName", userName)
.put("gender", gender)
.put("age", age)
.put("country", country)
.put("expressSessionId", expressSessionId)
)
}
fun sendMessage(toUserName: String?, message: String, messageId: String = System.currentTimeMillis().toString()) {
val payload = JSONObject()
.put("message", message.trim())
.put("messageId", messageId)
if (!toUserName.isNullOrBlank()) {
payload.put("toUserName", toUserName)
}
socket?.emit("message", payload)
}
fun sendImage(toUserName: String, imageCode: String, imageUrl: String, messageId: String = System.currentTimeMillis().toString()) {
socket?.emit(
"message",
JSONObject()
.put("toUserName", toUserName)
.put("message", imageCode)
.put("messageId", messageId)
.put("isImage", true)
.put("imageUrl", imageUrl)
)
}
fun requestConversation(withUserName: String) {
socket?.emit("requestConversation", JSONObject().put("withUserName", withUserName))
}
fun userSearch(nameIncludes: String?, minAge: Int?, maxAge: Int?, countries: List<String>, genders: List<String>) {
socket?.emit(
"userSearch",
JSONObject()
.put("nameIncludes", nameIncludes)
.put("minAge", minAge)
.put("maxAge", maxAge)
.put("countries", JSONArray(countries))
.put("genders", JSONArray(genders))
)
}
fun requestHistory() = socket?.emit("requestHistory")
fun requestOpenConversations() = socket?.emit("requestOpenConversations")
fun blockUser(userName: String) = socket?.emit("blockUser", JSONObject().put("userName", userName))
fun unblockUser(userName: String) = socket?.emit("unblockUser", JSONObject().put("userName", userName))
fun setVideoConsent(withUserName: String, allowed: Boolean) =
socket?.emit("videoConsent:set", JSONObject().put("withUserName", withUserName).put("allowed", allowed))
fun inviteVideoCall(withUserName: String) =
socket?.emit("videoCall:invite", JSONObject().put("withUserName", withUserName))
fun acceptVideoCall(callId: String) =
socket?.emit("videoCall:accept", JSONObject().put("callId", callId))
fun rejectVideoCall(callId: String) =
socket?.emit("videoCall:reject", JSONObject().put("callId", callId))
fun cancelVideoCall(callId: String) =
socket?.emit("videoCall:cancel", JSONObject().put("callId", callId))
fun endVideoCall(callId: String) =
socket?.emit("videoCall:end", JSONObject().put("callId", callId))
fun setVideoMuteState(callId: String, muted: Boolean) =
socket?.emit("videoCall:muteState", JSONObject().put("callId", callId).put("muted", muted))
fun sendVideoSignal(signal: VideoSignalDto) {
val payload = JSONObject()
.put("callId", signal.callId)
.put("signalType", signal.signalType)
signal.description?.let {
payload.put(
"description",
JSONObject()
.put("type", it.type)
.put("sdp", it.sdp)
)
}
signal.candidate?.let {
payload.put(
"candidate",
JSONObject()
.put("candidate", it.candidate)
.put("sdpMid", it.sdpMid)
.put("sdpMLineIndex", it.sdpMLineIndex)
.put("usernameFragment", it.usernameFragment)
.put("type", it.type)
)
}
socket?.emit("videoCall:signal", payload)
}
fun setVideoConnectionState(callId: String, connectionState: String) =
socket?.emit(
"videoCall:connectionState",
JSONObject()
.put("callId", callId)
.put("connectionState", connectionState)
)
private fun emit(event: SocketEvent) {
scope.launch { _events.emit(event) }
}
}
private fun Array<out Any>.firstJson(): JSONObject? = firstOrNull() as? JSONObject
private fun JSONObject.optStringOrNull(name: String): String? = if (has(name) && !isNull(name)) optString(name) else null
private fun JSONObject.toUserDto(): UserDto = UserDto(
sessionId = optStringOrNull("sessionId"),
userName = optString("userName"),
gender = optString("gender"),
age = optInt("age", 0),
country = optString("country"),
isoCountryCode = optString("isoCountryCode")
)
private fun JSONObject.toMessageDto(): ChatMessageDto = ChatMessageDto(
from = optString("from"),
to = optStringOrNull("to"),
message = optString("message"),
messageId = optStringOrNull("messageId"),
timestamp = optString("timestamp"),
read = optBoolean("read", false),
isImage = optBoolean("isImage", false),
imageType = optStringOrNull("imageType"),
imageUrl = optStringOrNull("imageUrl"),
imageCode = optStringOrNull("imageCode")
)
private fun JSONObject.toHistoryItemDto(): HistoryItemDto = HistoryItemDto(
userName = optString("userName"),
lastMessage = optJSONObject("lastMessage")?.toMessageDto()
)
private fun JSONObject.toInboxItemDto(): InboxItemDto = InboxItemDto(
userName = optString("userName"),
unreadCount = optInt("unreadCount", 0)
)
private fun JSONObject.toVideoConsentDto(): VideoConsentDto = VideoConsentDto(
withUserName = optStringOrNull("withUserName"),
localConsent = optBoolean("localConsent", false),
remoteConsent = optBoolean("remoteConsent", false),
videoVisible = optBoolean("videoVisible", false)
)
private fun JSONObject.toVideoCallDto(): VideoCallDto = VideoCallDto(
callId = optString("callId"),
roomId = optStringOrNull("roomId"),
withUserName = optStringOrNull("withUserName"),
initiatedBy = optStringOrNull("initiatedBy"),
status = optString("status"),
createdAt = optString("createdAt"),
updatedAt = optString("updatedAt"),
endedAt = optStringOrNull("endedAt"),
reason = optStringOrNull("reason"),
localMuted = optBoolean("localMuted", false),
remoteMuted = optBoolean("remoteMuted", false),
connectionState = optString("connectionState", "new"),
remoteConnectionState = optString("remoteConnectionState", "new"),
media = optJSONObject("media")?.toVideoMediaDto()
)
private fun JSONObject.toVideoCapacityDto(): VideoCapacityDto = VideoCapacityDto(
activeConnections = optInt("activeConnections", 0),
maxConnections = optInt("maxConnections", 3),
reachedMax = optBoolean("reachedMax", false)
)
private fun JSONObject.toVideoMediaDto(): VideoMediaDto = VideoMediaDto(
mode = optString("mode"),
relayOnly = optBoolean("relayOnly", false),
iceTransportPolicy = optString("iceTransportPolicy", "relay"),
iceServers = optJSONArray("iceServers")?.toObjectList { it.toVideoIceServerDto() }.orEmpty(),
isCaller = optBoolean("isCaller", false)
)
private fun JSONObject.toVideoIceServerDto(): VideoIceServerDto {
val urlsValue = opt("urls")
val urls = when (urlsValue) {
is JSONArray -> urlsValue.toStringList()
is String -> listOf(urlsValue)
else -> emptyList()
}
return VideoIceServerDto(
urls = urls,
username = optStringOrNull("username"),
credential = optStringOrNull("credential")
)
}
private fun JSONObject.toVideoSignalDto(): VideoSignalDto = VideoSignalDto(
callId = optString("callId"),
fromUserName = optStringOrNull("fromUserName"),
signalType = optString("signalType"),
description = optJSONObject("description")?.toVideoSessionDescriptionDto(),
candidate = optJSONObject("candidate")?.toVideoIceCandidateDto()
)
private fun JSONObject.toVideoSessionDescriptionDto(): VideoSessionDescriptionDto = VideoSessionDescriptionDto(
type = optString("type"),
sdp = optString("sdp")
)
private fun JSONObject.toVideoIceCandidateDto(): VideoIceCandidateDto = VideoIceCandidateDto(
candidate = optString("candidate"),
sdpMid = optStringOrNull("sdpMid"),
sdpMLineIndex = optInt("sdpMLineIndex", 0),
usernameFragment = optStringOrNull("usernameFragment"),
type = optStringOrNull("type")
)
private fun JSONArray?.toStringList(): List<String> {
if (this == null) return emptyList()
return List(length()) { index -> opt(index)?.toString().orEmpty() }
}
private fun JSONArray?.toNestedStringList(): List<List<String>> {
if (this == null) return emptyList()
return List(length()) { index -> optJSONArray(index).toStringList() }
}
private fun <T> JSONArray.toObjectList(mapper: (JSONObject) -> T): List<T> = buildList {
for (index in 0 until length()) {
optJSONObject(index)?.let { add(mapper(it)) }
}
}

View File

@@ -0,0 +1,164 @@
package de.ypchat.android.data.model
import com.google.gson.annotations.SerializedName
data class UserDto(
val sessionId: String? = null,
val userName: String = "",
val gender: String = "",
val age: Int = 0,
val country: String = "",
val isoCountryCode: String = ""
)
data class ChatMessageDto(
val from: String = "",
val to: String? = null,
val message: String = "",
val messageId: String? = null,
val timestamp: String = "",
val read: Boolean = false,
val isImage: Boolean = false,
val imageType: String? = null,
val imageUrl: String? = null,
val imageCode: String? = null
)
data class HistoryItemDto(
val userName: String = "",
val lastMessage: ChatMessageDto? = null
)
data class InboxItemDto(
val userName: String = "",
val unreadCount: Int = 0
)
data class VideoConsentDto(
val withUserName: String? = null,
val localConsent: Boolean = false,
val remoteConsent: Boolean = false,
val videoVisible: Boolean = false
)
data class VideoCallDto(
val callId: String = "",
val roomId: String? = null,
val withUserName: String? = null,
val initiatedBy: String? = null,
val status: String = "",
val createdAt: String = "",
val updatedAt: String = "",
val endedAt: String? = null,
val reason: String? = null,
val localMuted: Boolean = false,
val remoteMuted: Boolean = false,
val connectionState: String = "new",
val remoteConnectionState: String = "new",
val media: VideoMediaDto? = null
)
data class VideoCapacityDto(
val activeConnections: Int = 0,
val maxConnections: Int = 3,
val reachedMax: Boolean = false
)
data class VideoMediaDto(
val mode: String = "",
val relayOnly: Boolean = false,
val iceTransportPolicy: String = "relay",
val iceServers: List<VideoIceServerDto> = emptyList(),
val isCaller: Boolean = false
)
data class VideoIceServerDto(
val urls: List<String> = emptyList(),
val username: String? = null,
val credential: String? = null
)
data class VideoSessionDescriptionDto(
val type: String = "",
val sdp: String = ""
)
data class VideoIceCandidateDto(
val candidate: String = "",
val sdpMid: String? = null,
val sdpMLineIndex: Int = 0,
val usernameFragment: String? = null,
val type: String? = null
)
data class VideoSignalDto(
val callId: String = "",
val fromUserName: String? = null,
val signalType: String = "",
val description: VideoSessionDescriptionDto? = null,
val candidate: VideoIceCandidateDto? = null
)
data class CountryOption(
val englishName: String,
val displayName: String,
val isoCode: String
)
data class SessionResponse(
val loggedIn: Boolean = false,
val sessionId: String? = null,
val user: UserDto? = null
)
data class LogoutResponse(
val success: Boolean = false
)
data class ImageUploadResponse(
val success: Boolean = false,
val code: String? = null,
val url: String? = null,
val error: String? = null
)
data class FeedbackItemDto(
val id: String = "",
val name: String? = null,
val age: Int? = null,
val country: String? = null,
val gender: String? = null,
val comment: String = "",
val createdAt: String = ""
)
data class FeedbackResponse(
val items: List<FeedbackItemDto> = emptyList(),
val admin: Boolean = false
)
data class FeedbackAdminStatusResponse(
val authenticated: Boolean = false,
val username: String? = null
)
data class FeedbackRequest(
val name: String = "",
val age: Int? = null,
val country: String = "",
val gender: String = "",
val comment: String = ""
)
data class FeedbackAdminLoginRequest(
val username: String = "",
val password: String = ""
)
data class PartnerLinkDto(
@SerializedName("Page Name")
val pageName: String = "",
val url: String = ""
)
class CountriesResponse : LinkedHashMap<String, String>()

View File

@@ -0,0 +1,32 @@
package de.ypchat.android.data.model
sealed interface SocketEvent {
data class Connected(val sessionId: String?, val loggedIn: Boolean, val user: UserDto?) : SocketEvent
data class LoginSuccess(val sessionId: String?, val user: UserDto?) : SocketEvent
data class UserList(val users: List<UserDto>) : SocketEvent
data class IncomingMessage(val message: ChatMessageDto) : SocketEvent
data class MessageSent(val messageId: String?, val to: String?) : SocketEvent
data class Conversation(val withUserName: String, val messages: List<ChatMessageDto>) : SocketEvent
data class SearchResults(val results: List<UserDto>) : SocketEvent
data class HistoryResults(val results: List<HistoryItemDto>) : SocketEvent
data class InboxResults(val results: List<InboxItemDto>) : SocketEvent
data class UnreadChats(val count: Int) : SocketEvent
data class VideoConsentUpdate(val consent: VideoConsentDto) : SocketEvent
data class VideoCallInvite(val call: VideoCallDto) : SocketEvent
data class VideoCallIncoming(val call: VideoCallDto) : SocketEvent
data class VideoCallStart(val call: VideoCallDto) : SocketEvent
data class VideoCallUpdate(val call: VideoCallDto) : SocketEvent
data class VideoCallReject(val call: VideoCallDto) : SocketEvent
data class VideoCallCancel(val call: VideoCallDto) : SocketEvent
data class VideoCallEnd(val call: VideoCallDto) : SocketEvent
data class VideoCallMuteState(val call: VideoCallDto) : SocketEvent
data class VideoCallCapacity(val capacity: VideoCapacityDto) : SocketEvent
data class VideoCallSignal(val signal: VideoSignalDto) : SocketEvent
data class VideoCallError(val code: String?, val message: String, val withUserName: String? = null, val callId: String? = null) : SocketEvent
data class UserBlocked(val userName: String) : SocketEvent
data class UserUnblocked(val userName: String) : SocketEvent
data class CommandResult(val lines: List<String>, val kind: String) : SocketEvent
data class CommandTable(val title: String, val columns: List<String>, val rows: List<List<String>>) : SocketEvent
data class Error(val message: String) : SocketEvent
data class ConnectionChanged(val connected: Boolean, val reason: String? = null) : SocketEvent
}

View File

@@ -0,0 +1,628 @@
package de.ypchat.android.data.repository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import de.ypchat.android.core.AppConfig
import de.ypchat.android.core.ProfileStore
import de.ypchat.android.core.SavedProfile
import de.ypchat.android.core.SessionCookieJar
import de.ypchat.android.data.api.RestApi
import de.ypchat.android.data.api.SocketClient
import de.ypchat.android.data.model.ChatMessageDto
import de.ypchat.android.data.model.CountryOption
import de.ypchat.android.data.model.FeedbackAdminLoginRequest
import de.ypchat.android.data.model.FeedbackItemDto
import de.ypchat.android.data.model.FeedbackRequest
import de.ypchat.android.data.model.HistoryItemDto
import de.ypchat.android.data.model.InboxItemDto
import de.ypchat.android.data.model.PartnerLinkDto
import de.ypchat.android.data.model.SocketEvent
import de.ypchat.android.data.model.UserDto
import de.ypchat.android.data.model.VideoCallDto
import de.ypchat.android.data.model.VideoCapacityDto
import de.ypchat.android.data.model.VideoConsentDto
import de.ypchat.android.media.AndroidVideoCallManager
import de.ypchat.android.media.VideoMediaState
import okhttp3.MultipartBody
import org.webrtc.EglBase
import java.util.Locale
class ChatRepository(
private val restApi: RestApi,
private val socketClient: SocketClient,
private val cookieJar: SessionCookieJar,
private val profileStore: ProfileStore,
private val videoCallManager: AndroidVideoCallManager
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val _state = MutableStateFlow(ChatState())
val state: StateFlow<ChatState> = _state.asStateFlow()
val videoMediaState: StateFlow<VideoMediaState> = videoCallManager.state
val videoEglBaseContext: EglBase.Context = videoCallManager.eglBaseContext()
private var timeoutTickerStarted = false
init {
scope.launch {
socketClient.events.collect { event -> reduce(event) }
}
startTimeoutTicker()
}
suspend fun restoreSession() {
_state.value = _state.value.copy(savedProfile = profileStore.read())
loadCountries()
loadFeedbackAdminStatus()
runCatching { restApi.session() }
.onSuccess { session ->
_state.value = _state.value.copy(
expressSessionId = session.sessionId,
isLoggedIn = session.loggedIn && session.user != null,
currentUser = session.user,
errorMessage = null
)
if (session.loggedIn && session.user != null) {
resetTimeout()
}
connectSocket(session.sessionId)
}
.onFailure { error -> _state.value = _state.value.copy(errorMessage = error.message) }
}
suspend fun loadCountries() {
runCatching { restApi.countries() }
.onSuccess { countries ->
val locale = Locale.getDefault()
val options = countries.map { (englishName, code) ->
val normalizedCode = code.uppercase(Locale.US)
val localizedName = Locale.Builder().setRegion(normalizedCode).build().getDisplayCountry(locale)
.takeIf { it.isNotBlank() }
?: englishName
CountryOption(
englishName = englishName,
displayName = localizedName,
isoCode = code
)
}.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.displayName })
_state.value = _state.value.copy(countries = options)
}
.onFailure { error ->
_state.value = _state.value.copy(errorMessage = "Country list could not be loaded: ${error.message}")
}
}
suspend fun login(userName: String, gender: String, age: Int, country: String) {
profileStore.write(SavedProfile(userName, gender, age, country))
val session = runCatching { restApi.session() }.getOrNull()
val sessionId = session?.sessionId ?: _state.value.expressSessionId
_state.value = _state.value.copy(expressSessionId = sessionId)
if (!socketClient.isConnected) {
connectSocket(sessionId)
}
socketClient.login(userName, gender, age, country, sessionId)
resetTimeout()
}
suspend fun logout() {
runCatching { restApi.logout() }
socketClient.disconnect()
cookieJar.clear()
videoCallManager.releaseAll()
_state.value = ChatState(savedProfile = profileStore.read(), countries = _state.value.countries)
}
fun connectSocket(expressSessionId: String? = _state.value.expressSessionId) {
socketClient.connect()
expressSessionId?.let { socketClient.setSessionId(it) }
}
fun openConversation(userName: String) {
_state.value = _state.value.copy(
currentConversation = userName,
messages = emptyList(),
videoConsent = VideoConsentDto(withUserName = userName)
)
socketClient.requestConversation(userName)
resetTimeout()
}
fun closeConversation() {
_state.value = _state.value.copy(
currentConversation = null,
messages = emptyList(),
videoConsent = VideoConsentDto()
)
}
fun sendMessage(text: String) {
val trimmed = text.trim()
if (trimmed.isEmpty()) return
val target = _state.value.currentConversation
val isCommand = trimmed.startsWith("/")
if (target == null && !isCommand) return
socketClient.sendMessage(target, trimmed)
if (!isCommand) {
_state.value = _state.value.copy(
messages = _state.value.messages + ChatMessageDto(
from = _state.value.currentUser?.userName.orEmpty(),
to = target,
message = trimmed,
timestamp = java.time.Instant.now().toString()
)
)
}
resetTimeout()
}
fun sendImage(toUserName: String, imageCode: String, imageUrl: String) {
val absoluteUrl = if (imageUrl.startsWith("http")) imageUrl else AppConfig.baseUrl + imageUrl
socketClient.sendImage(toUserName, imageCode, absoluteUrl)
_state.value = _state.value.copy(
messages = _state.value.messages + ChatMessageDto(
from = _state.value.currentUser?.userName.orEmpty(),
to = toUserName,
message = absoluteUrl,
timestamp = java.time.Instant.now().toString(),
isImage = true,
imageUrl = absoluteUrl,
imageCode = imageCode
)
)
resetTimeout()
}
fun setImageUploadState(inProgress: Boolean, message: String? = null) {
_state.value = _state.value.copy(
isUploadingImage = inProgress,
imageUploadMessage = message
)
}
suspend fun uploadImage(part: MultipartBody.Part) = restApi.uploadImage(part)
suspend fun loadFeedback() {
runCatching { restApi.feedback() }
.onSuccess { response ->
_state.value = _state.value.copy(feedbackItems = response.items, feedbackMessage = null)
}
.onFailure { error ->
_state.value = _state.value.copy(feedbackMessage = error.message)
}
}
suspend fun submitFeedback(comment: String) {
val trimmed = comment.trim()
if (trimmed.isEmpty()) return
val profile = _state.value.savedProfile
runCatching {
restApi.submitFeedback(
FeedbackRequest(
name = profile.nickname,
age = profile.age,
country = profile.country,
gender = profile.gender,
comment = trimmed
)
)
}.onSuccess {
_state.value = _state.value.copy(feedbackMessage = "Feedback saved")
loadFeedback()
}.onFailure { error ->
_state.value = _state.value.copy(feedbackMessage = error.message)
}
}
suspend fun loadFeedbackAdminStatus() {
runCatching { restApi.feedbackAdminStatus() }
.onSuccess { response ->
_state.value = _state.value.copy(
feedbackAdminAuthenticated = response.authenticated,
feedbackAdminUserName = response.username,
feedbackAdminError = null
)
}
.onFailure {
_state.value = _state.value.copy(
feedbackAdminAuthenticated = false,
feedbackAdminUserName = null
)
}
}
suspend fun loginFeedbackAdmin(username: String, password: String) {
runCatching { restApi.feedbackAdminLogin(FeedbackAdminLoginRequest(username, password)) }
.onSuccess { response ->
_state.value = _state.value.copy(
feedbackAdminAuthenticated = true,
feedbackAdminUserName = response.username,
feedbackAdminError = null
)
loadFeedback()
}
.onFailure { error ->
_state.value = _state.value.copy(feedbackAdminError = error.message)
}
}
suspend fun logoutFeedbackAdmin() {
runCatching { restApi.feedbackAdminLogout() }
_state.value = _state.value.copy(
feedbackAdminAuthenticated = false,
feedbackAdminUserName = null,
feedbackAdminError = null
)
}
suspend fun deleteFeedback(id: String) {
runCatching { restApi.deleteFeedback(id) }
.onSuccess { loadFeedback() }
.onFailure { error ->
_state.value = _state.value.copy(feedbackAdminError = error.message)
}
}
suspend fun loadPartners() {
runCatching { restApi.partners() }
.onSuccess { links ->
_state.value = _state.value.copy(partnerLinks = links, partnersError = null)
}
.onFailure { error ->
_state.value = _state.value.copy(partnersError = error.message)
}
}
fun search(nameIncludes: String?, minAge: Int?, maxAge: Int?, countries: List<String>, genders: List<String>) {
socketClient.userSearch(nameIncludes, minAge, maxAge, countries, genders)
resetTimeout()
}
fun requestInbox() {
socketClient.requestOpenConversations()
resetTimeout()
}
fun requestHistory() {
socketClient.requestHistory()
resetTimeout()
}
fun blockUser(userName: String) {
socketClient.blockUser(userName)
resetTimeout()
}
fun unblockUser(userName: String) {
socketClient.unblockUser(userName)
resetTimeout()
}
fun setVideoConsent(allowed: Boolean) {
val target = _state.value.currentConversation ?: return
socketClient.setVideoConsent(target, allowed)
resetTimeout()
}
fun inviteVideoCall() {
val target = _state.value.currentConversation ?: return
socketClient.inviteVideoCall(target)
resetTimeout()
}
fun acceptVideoCall(callId: String) {
socketClient.acceptVideoCall(callId)
resetTimeout()
}
fun rejectVideoCall(callId: String) {
socketClient.rejectVideoCall(callId)
resetTimeout()
}
fun cancelVideoCall(callId: String) {
socketClient.cancelVideoCall(callId)
resetTimeout()
}
fun endVideoCall(callId: String) {
socketClient.endVideoCall(callId)
videoCallManager.endCall(callId)
resetTimeout()
}
fun bringVideoToFront(callId: String) {
_state.value = _state.value.copy(foregroundVideoSessionId = callId)
}
fun minimizeForegroundVideo() {
_state.value = _state.value.copy(foregroundVideoSessionId = null)
}
fun updateFloatingVideoPosition(x: Float, y: Float) {
_state.value = _state.value.copy(
floatingVideoOffsetX = x.coerceAtLeast(0f),
floatingVideoOffsetY = y.coerceAtLeast(0f)
)
}
fun toggleSelfMuted() {
val nextMuted = !_state.value.selfMuted
_state.value = _state.value.copy(selfMuted = nextMuted)
videoCallManager.updateSelfMuted(nextMuted)
_state.value.videoDockSessions.forEach { session ->
socketClient.setVideoMuteState(session.callId, nextMuted)
}
resetTimeout()
}
fun toggleSelfCameraEnabled() {
val nextEnabled = !_state.value.selfCameraEnabled
_state.value = _state.value.copy(selfCameraEnabled = nextEnabled)
videoCallManager.updateSelfCameraEnabled(nextEnabled)
resetTimeout()
}
fun setRuntimeError(message: String) {
_state.value = _state.value.copy(errorMessage = message)
}
private fun startTimeoutTicker() {
if (timeoutTickerStarted) return
timeoutTickerStarted = true
scope.launch {
while (isActive) {
delay(1_000)
val current = _state.value
if (!current.isLoggedIn) continue
val next = (current.remainingSecondsToTimeout - 1).coerceAtLeast(0)
_state.value = current.copy(remainingSecondsToTimeout = next)
if (next == 0) {
logout()
}
}
}
}
private fun resetTimeout() {
if (_state.value.isLoggedIn || _state.value.currentUser != null) {
_state.value = _state.value.copy(remainingSecondsToTimeout = 1800)
}
}
private fun reduce(event: SocketEvent) {
val current = _state.value
_state.value = when (event) {
is SocketEvent.ConnectionChanged -> current.copy(isConnected = event.connected)
is SocketEvent.Connected -> {
event.sessionId?.let { socketClient.setSessionId(it) }
current.copy(
expressSessionId = event.sessionId ?: current.expressSessionId,
isLoggedIn = event.loggedIn || current.isLoggedIn,
currentUser = event.user ?: current.currentUser,
errorMessage = null,
remainingSecondsToTimeout = if (event.loggedIn || current.isLoggedIn) 1800 else current.remainingSecondsToTimeout
)
}
is SocketEvent.LoginSuccess -> current.copy(
expressSessionId = event.sessionId ?: current.expressSessionId,
isLoggedIn = true,
currentUser = event.user,
errorMessage = null,
remainingSecondsToTimeout = 1800
)
is SocketEvent.UserList -> current.copy(users = event.users)
is SocketEvent.IncomingMessage -> {
val active = current.currentConversation == event.message.from
current.copy(
messages = if (active) current.messages + event.message else current.messages,
unreadChatsCount = if (active) current.unreadChatsCount else current.unreadChatsCount + 1,
remainingSecondsToTimeout = 1800
)
}
is SocketEvent.MessageSent -> current
is SocketEvent.Conversation -> current.copy(
currentConversation = event.withUserName,
messages = event.messages,
videoConsent = current.videoConsent.takeIf { it.withUserName == event.withUserName } ?: VideoConsentDto(withUserName = event.withUserName),
unreadChatsCount = maxOf(0, current.unreadChatsCount - 1),
remainingSecondsToTimeout = 1800
)
is SocketEvent.SearchResults -> current.copy(searchResults = event.results)
is SocketEvent.HistoryResults -> current.copy(historyResults = event.results)
is SocketEvent.InboxResults -> current.copy(inboxResults = event.results)
is SocketEvent.UnreadChats -> current.copy(unreadChatsCount = event.count)
is SocketEvent.VideoConsentUpdate -> current.copy(
videoConsent = event.consent.takeIf { it.withUserName == current.currentConversation } ?: current.videoConsent
)
is SocketEvent.VideoCallInvite -> current.withVideoCall(event.call)
is SocketEvent.VideoCallIncoming -> current.withVideoCall(event.call)
is SocketEvent.VideoCallStart -> current.withVideoCall(event.call).also {
scope.launch {
runCatching {
videoCallManager.ensureCall(event.call, it.selfMuted, it.selfCameraEnabled)
}.onFailure { error ->
_state.value = _state.value.copy(errorMessage = error.message)
}
}
}
is SocketEvent.VideoCallUpdate -> current.withVideoCall(event.call).also {
if ((event.call.status == "connecting" || event.call.status == "active") && event.call.media != null) {
scope.launch {
runCatching {
videoCallManager.ensureCall(event.call, it.selfMuted, it.selfCameraEnabled)
}.onFailure { error ->
_state.value = _state.value.copy(errorMessage = error.message)
}
}
}
}
is SocketEvent.VideoCallReject -> current.withVideoCall(event.call).also {
videoCallManager.endCall(event.call.callId)
}
is SocketEvent.VideoCallCancel -> current.withVideoCall(event.call).also {
videoCallManager.endCall(event.call.callId)
}
is SocketEvent.VideoCallEnd -> current.withVideoCall(event.call).also {
videoCallManager.endCall(event.call.callId)
}
is SocketEvent.VideoCallMuteState -> current.withVideoCall(event.call)
is SocketEvent.VideoCallCapacity -> current.copy(
activeVideoConnectionCount = event.capacity.activeConnections,
maxVideoConnections = event.capacity.maxConnections,
maxVideoConnectionsReached = event.capacity.reachedMax
)
is SocketEvent.VideoCallSignal -> current.also {
scope.launch {
runCatching {
videoCallManager.handleSignal(event.signal)
}.onFailure { error ->
_state.value = _state.value.copy(errorMessage = error.message)
}
}
}
is SocketEvent.VideoCallError -> current.copy(errorMessage = event.message)
is SocketEvent.UserBlocked -> current.copy(errorMessage = "${event.userName} blocked")
is SocketEvent.UserUnblocked -> current.copy(errorMessage = "${event.userName} unblocked")
is SocketEvent.CommandResult -> current.copy(
commandLines = event.lines,
commandKind = event.kind,
commandTable = null,
awaitingLoginUsername = event.kind == "loginPromptUsername",
awaitingLoginPassword = event.kind == "loginPromptPassword",
errorMessage = if (event.kind == "info" || event.kind.startsWith("login")) event.lines.joinToString(" | ") else current.errorMessage
)
is SocketEvent.CommandTable -> current.copy(
commandLines = emptyList(),
commandKind = null,
commandTable = CommandTableState(event.title, event.columns, event.rows)
)
is SocketEvent.Error -> current.copy(errorMessage = event.message)
}
}
}
private fun ChatState.withVideoCall(call: VideoCallDto): ChatState {
val normalized = VideoSessionState.fromDto(call)
val updated = videoDockSessions.toMutableList()
val index = updated.indexOfFirst { it.callId == normalized.callId }
if (index >= 0) {
updated[index] = updated[index].copy(
roomId = normalized.roomId,
withUserName = normalized.withUserName,
initiatedBy = normalized.initiatedBy,
status = normalized.status,
createdAt = normalized.createdAt,
updatedAt = normalized.updatedAt,
endedAt = normalized.endedAt,
reason = normalized.reason,
localMuted = normalized.localMuted,
remoteMuted = normalized.remoteMuted,
connectionState = normalized.connectionState,
remoteConnectionState = normalized.remoteConnectionState,
media = normalized.media
)
} else {
updated += normalized
}
val visibleSessions = updated.filterNot { it.status in setOf("rejected", "cancelled", "ended", "failed") }
return copy(
videoDockSessions = updated.sortedByDescending { it.updatedAt }.take(3),
foregroundVideoSessionId = when {
foregroundVideoSessionId == null && normalized.status !in setOf("rejected", "cancelled", "ended", "failed") -> normalized.callId
foregroundVideoSessionId == normalized.callId && normalized.status in setOf("rejected", "cancelled", "ended", "failed") -> visibleSessions.firstOrNull()?.callId
else -> foregroundVideoSessionId
}
)
}
data class CommandTableState(
val title: String,
val columns: List<String>,
val rows: List<List<String>>
)
data class ChatState(
val isConnected: Boolean = false,
val isLoggedIn: Boolean = false,
val expressSessionId: String? = null,
val currentUser: UserDto? = null,
val users: List<UserDto> = emptyList(),
val currentConversation: String? = null,
val messages: List<ChatMessageDto> = emptyList(),
val searchResults: List<UserDto> = emptyList(),
val inboxResults: List<InboxItemDto> = emptyList(),
val historyResults: List<HistoryItemDto> = emptyList(),
val countries: List<CountryOption> = emptyList(),
val feedbackItems: List<FeedbackItemDto> = emptyList(),
val feedbackMessage: String? = null,
val feedbackAdminAuthenticated: Boolean = false,
val feedbackAdminUserName: String? = null,
val feedbackAdminError: String? = null,
val partnerLinks: List<PartnerLinkDto> = emptyList(),
val partnersError: String? = null,
val savedProfile: SavedProfile = SavedProfile(),
val commandLines: List<String> = emptyList(),
val commandKind: String? = null,
val commandTable: CommandTableState? = null,
val awaitingLoginUsername: Boolean = false,
val awaitingLoginPassword: Boolean = false,
val remainingSecondsToTimeout: Int = 1800,
val isUploadingImage: Boolean = false,
val imageUploadMessage: String? = null,
val unreadChatsCount: Int = 0,
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

@@ -0,0 +1,141 @@
package de.ypchat.android.ui
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import de.ypchat.android.data.repository.ChatRepository
import de.ypchat.android.data.repository.ChatState
import de.ypchat.android.media.VideoMediaState
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
import org.webrtc.EglBase
class ChatViewModel(private val repository: ChatRepository) : ViewModel() {
val state: StateFlow<ChatState> = repository.state
val videoMediaState: StateFlow<VideoMediaState> = repository.videoMediaState
val videoEglBaseContext: EglBase.Context = repository.videoEglBaseContext
init {
viewModelScope.launch { repository.restoreSession() }
}
fun login(userName: String, gender: String, age: Int, country: String) {
viewModelScope.launch { repository.login(userName, gender, age, country) }
}
fun loadCountries() {
viewModelScope.launch { repository.loadCountries() }
}
fun logout() {
viewModelScope.launch { repository.logout() }
}
fun openConversation(userName: String) = repository.openConversation(userName)
fun closeConversation() = repository.closeConversation()
fun sendMessage(text: String) = repository.sendMessage(text)
fun setImageUploadMessage(message: String) = repository.setImageUploadState(false, message)
fun sendImage(context: Context, uri: Uri) {
val target = state.value.currentConversation ?: return
viewModelScope.launch {
val resolver = context.applicationContext.contentResolver
val mimeType = resolver.getType(uri) ?: "image/*"
repository.setImageUploadState(true)
val bytes = resolver.openInputStream(uri)?.use { it.readBytes() }
if (bytes == null) {
repository.setImageUploadState(false, "Image could not be opened")
return@launch
}
if (bytes.size > MAX_IMAGE_BYTES) {
repository.setImageUploadState(false, "Image exceeds 5 MB")
return@launch
}
val fileName = resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (cursor.moveToFirst() && nameIndex >= 0) cursor.getString(nameIndex) else null
} ?: "ypchat-image"
val body = bytes.toRequestBody(mimeType.toMediaTypeOrNull())
val part = MultipartBody.Part.createFormData("image", fileName, body)
runCatching { repository.uploadImage(part) }
.onSuccess { response ->
val payload = response.body()
if (response.isSuccessful && payload?.success == true && !payload.code.isNullOrBlank() && !payload.url.isNullOrBlank()) {
repository.sendImage(target, payload.code, payload.url)
repository.setImageUploadState(false, "Image uploaded")
} else {
repository.setImageUploadState(false, payload?.error ?: "Image upload failed")
}
}
.onFailure { error ->
repository.setImageUploadState(false, error.message ?: "Image upload failed")
}
}
}
fun search(name: String, minAge: Int?, maxAge: Int?, countries: List<String>, genders: List<String>) {
repository.search(name.takeIf { it.isNotBlank() }, minAge, maxAge, countries, genders)
}
fun requestInbox() = repository.requestInbox()
fun requestHistory() = repository.requestHistory()
fun loadFeedback() {
viewModelScope.launch { repository.loadFeedback() }
}
fun submitFeedback(comment: String) {
viewModelScope.launch { repository.submitFeedback(comment) }
}
fun loadFeedbackAdminStatus() {
viewModelScope.launch { repository.loadFeedbackAdminStatus() }
}
fun loginFeedbackAdmin(username: String, password: String) {
viewModelScope.launch { repository.loginFeedbackAdmin(username, password) }
}
fun logoutFeedbackAdmin() {
viewModelScope.launch { repository.logoutFeedbackAdmin() }
}
fun deleteFeedback(id: String) {
viewModelScope.launch { repository.deleteFeedback(id) }
}
fun loadPartners() {
viewModelScope.launch { repository.loadPartners() }
}
fun blockCurrentUser() = state.value.currentConversation?.let(repository::blockUser)
fun unblockCurrentUser() = state.value.currentConversation?.let(repository::unblockUser)
fun setVideoConsent(allowed: Boolean) = repository.setVideoConsent(allowed)
fun inviteVideoCall() = repository.inviteVideoCall()
fun acceptVideoCall(callId: String) = repository.acceptVideoCall(callId)
fun rejectVideoCall(callId: String) = repository.rejectVideoCall(callId)
fun cancelVideoCall(callId: String) = repository.cancelVideoCall(callId)
fun endVideoCall(callId: String) = repository.endVideoCall(callId)
fun bringVideoToFront(callId: String) = repository.bringVideoToFront(callId)
fun minimizeForegroundVideo() = repository.minimizeForegroundVideo()
fun updateFloatingVideoPosition(x: Float, y: Float) = repository.updateFloatingVideoPosition(x, y)
fun toggleSelfMuted() = repository.toggleSelfMuted()
fun toggleSelfCameraEnabled() = repository.toggleSelfCameraEnabled()
fun setRuntimeError(message: String) = repository.setRuntimeError(message)
private companion object {
const val MAX_IMAGE_BYTES = 5 * 1024 * 1024
}
}
class ChatViewModelFactory(private val repository: ChatRepository) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return ChatViewModel(repository) as T
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 985 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1,3 @@
<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
android:gravity="center"
android:src="@drawable/app_icon_asset" />

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

@@ -0,0 +1,111 @@
<resources>
<string name="app_name">YPChat</string>
<string name="landing_eyebrow">YpChat</string>
<string name="landing_title">Direkt in den Chat</string>
<string name="landing_copy">Kompakt, schnell und ohne Umwege. Erstelle dein Profil und starte sofort eine Unterhaltung.</string>
<string name="feature_worldwide_chat">Weltweiter Chat</string>
<string name="feature_image_exchange">Bildaustausch</string>
<string name="feature_compact_controls">Kompakte Bedienung</string>
<string name="profile_title">Profil starten</string>
<string name="profile_copy">Wenige Angaben genügen für den Einstieg.</string>
<string name="label_nick">Bitte gib deinen Nicknamen für den Chat ein</string>
<string name="label_gender">Geschlecht</string>
<string name="label_age">Alter</string>
<string name="label_country">Land</string>
<string name="button_start_chat">Chat starten</string>
<string name="gender_female">Weiblich</string>
<string name="gender_male">Männlich</string>
<string name="gender_pair">Paar</string>
<string name="gender_trans_mf">Transgender (M-&gt;F)</string>
<string name="gender_trans_fm">Transgender (F-&gt;M)</string>
<string name="socket_connected">Socket verbunden</string>
<string name="socket_connecting">Socket wird verbunden...</string>
<string name="status_online">online</string>
<string name="status_connecting">verbindet...</string>
<string name="tab_online">Online</string>
<string name="tab_search">Suche</string>
<string name="tab_inbox">Posteingang</string>
<string name="tab_history">Verlauf</string>
<string name="tab_console">Konsole</string>
<string name="tab_more">Mehr</string>
<string name="logout">Verlassen</string>
<string name="timeout_in">Timeout in %1$s</string>
<string name="no_users_online">Noch keine anderen Nutzer online.</string>
<string name="search_username_includes">Benutzername enthält</string>
<string name="search_from_age">Von Alter</string>
<string name="search_to_age">Bis Alter</string>
<string name="search_country">Land</string>
<string name="search_genders">Geschlechter</string>
<string name="search_all">Alle</string>
<string name="search_button">Suchen</string>
<string name="search_no_results">Keine Ergebnisse.</string>
<string name="search_min_age_error">Das Mindestalter darf nicht größer als das Höchstalter sein.</string>
<string name="inbox_empty">Keine ungelesenen Chats.</string>
<string name="inbox_new_count">%1$d neu</string>
<string name="history_empty">Noch kein Verlauf.</string>
<string name="no_message">Keine Nachricht</string>
<string name="back">Zurück</string>
<string name="block">Blockieren</string>
<string name="unblock">Entsperren</string>
<string name="message_placeholder">Nachricht</string>
<string name="button_image">Bild</string>
<string name="button_camera">Foto</string>
<string name="button_send">Senden</string>
<string name="button_smileys">Smileys</string>
<string name="image_message">Bildnachricht</string>
<string name="image_upload_in_progress">Bild wird hochgeladen...</string>
<string name="image_upload_success">Bild wurde hochgeladen.</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_open_failed">Das Bild konnte nicht geöffnet werden.</string>
<string name="camera_permission_denied">Die Kameraberechtigung wurde abgelehnt.</string>
<string name="camera_capture_failed">Das Foto konnte nicht aufgenommen werden.</string>
<string name="feedback_created_at">Eingegangen %1$s</string>
<string name="feedback_meta_separator"></string>
<string name="countries_load_error">Länderliste konnte nicht geladen werden: %1$s</string>
<string name="user_blocked">%1$s wurde blockiert</string>
<string name="user_unblocked">%1$s wurde entsperrt</string>
<string name="feedback_title">Feedback</string>
<string name="feedback_comment">Kommentar</string>
<string name="feedback_send">Feedback senden</string>
<string name="feedback_saved">Feedback wurde gespeichert.</string>
<string name="feedback_empty">Noch kein Feedback vorhanden.</string>
<string name="anonymous">Anonym</string>
<string name="feedback_admin_user">Admin-Benutzer</string>
<string name="feedback_admin_password">Passwort</string>
<string name="feedback_admin_login">Admin-Login</string>
<string name="feedback_admin_logout">Admin abmelden</string>
<string name="feedback_delete">Löschen</string>
<string name="console_title">Konsole</string>
<string name="console_placeholder">/Befehl oder Admin-Login-Eingabe senden</string>
<string name="console_send">Senden</string>
<string name="console_empty">Noch keine Konsolen-Ausgabe.</string>
<string name="more_title">Mehr</string>
<string name="more_feedback">Feedback</string>
<string name="more_partners">Partner</string>
<string name="more_faq">FAQ</string>
<string name="more_rules">Regeln</string>
<string name="more_safety">Sicherheit</string>
<string name="more_privacy">Datenschutz</string>
<string name="more_imprint">Impressum</string>
<string name="more_back">Zur Übersicht</string>
<string name="partners_intro">Empfehlungen und befreundete Projekte für unsere Community.</string>
<string name="faq_intro">Antworten auf häufige Fragen zum Chat.</string>
<string name="rules_intro">Grundregeln für respektvollen Chat.</string>
<string name="safety_intro">Tipps für Privatsphäre und sichere Nutzung.</string>
<string name="privacy_intro">Datenschutzerklärung, verarbeitete Daten und Kontakt für Datenschutzanfragen.</string>
<string name="imprint_intro">Rechtliche Hinweise und Kontaktdaten.</string>
<string name="external_link">Externer Link</string>
<string name="faq_title">Häufige Fragen</string>
<string name="rules_title">Chat-Regeln</string>
<string name="safety_title">Sicherheit und Privatsphäre</string>
<string name="privacy_title">Datenschutzerklärung</string>
<string name="imprint_title">Impressum</string>
<string name="partners_title">Partner</string>
<string name="faq_body">Wähle einen Nicknamen, gib deine Profildaten an und starte den Chat. Teile keine sensiblen Daten wie Telefonnummern, Adressen, Passwörter oder Zahlungsinformationen. Du kannst Bilder senden, Benutzer blockieren und Feedback für ernste Vorfälle nutzen.</string>
<string name="rules_body">Keine Beleidigungen, Hassrede, illegalen Inhalte, Spam oder unerwünschte Belästigung. Sende nur Bilder, die du teilen darfst, und respektiere die Privatsphäre anderer.</string>
<string name="safety_body">Nutze einen Nicknamen, der dich nicht identifiziert. Teile keine privaten Kontakt- oder Zahlungsdaten. Sei vorsichtig mit Links von Unbekannten und beende Gespräche, die sich falsch anfühlen. Nutze Blockieren und Feedback bei schweren Vorfällen.</string>
<string name="privacy_body">YpChat verarbeitet den von dir gewählten Nickname, Profildaten wie Alter, Geschlecht und Land, Chat-Nachrichten, von dir aktiv gesendete Bilder, Feedback-Nachrichten sowie technisch notwendige Sitzungsdaten. Die Android-App fragt den Kamerazugriff nur an, wenn du in der App aktiv ein Foto aufnehmen möchtest. Die vollständige Datenschutzerklärung für Website und App ist auf www.ypchat.net veröffentlicht.</string>
<string name="privacy_open_policy">Datenschutzerklärung öffnen</string>
<string name="imprint_body">Torsten Schulz, Friedrich-Stampfer-Str. 21, 60437 Frankfurt. Kontakt: tsschulz@tsschulz.de. Für externe Links sind deren Betreiber verantwortlich.</string>
</resources>

View File

@@ -0,0 +1,41 @@
<resources>
<string name="landing_title">Entrar al chat</string>
<string name="landing_copy">Compacto, rápido y sin rodeos. Crea tu perfil y empieza una conversación al instante.</string>
<string name="feature_worldwide_chat">Chat mundial</string>
<string name="feature_image_exchange">Intercambio de imágenes</string>
<string name="feature_compact_controls">Uso compacto</string>
<string name="profile_title">Iniciar perfil</string>
<string name="profile_copy">Solo hacen falta unos pocos datos para empezar.</string>
<string name="label_nick">Introduce tu apodo para el chat</string>
<string name="label_gender">Género</string>
<string name="label_age">Edad</string>
<string name="label_country">País</string>
<string name="button_start_chat">Iniciar chat</string>
<string name="gender_female">Mujer</string>
<string name="gender_male">Hombre</string>
<string name="gender_pair">Pareja</string>
<string name="gender_trans_mf">Transgénero (M-&gt;F)</string>
<string name="gender_trans_fm">Transgénero (F-&gt;M)</string>
<string name="tab_search">Buscar</string>
<string name="tab_inbox">Bandeja</string>
<string name="tab_history">Historial</string>
<string name="logout">Salir</string>
<string name="status_online">en línea</string>
<string name="status_connecting">conectando...</string>
<string name="search_username_includes">El nombre de usuario contiene</string>
<string name="search_from_age">Desde la edad</string>
<string name="search_to_age">Hasta la edad</string>
<string name="search_country">País</string>
<string name="search_genders">Géneros</string>
<string name="search_all">Todos</string>
<string name="search_button">Buscar</string>
<string name="search_no_results">Sin resultados.</string>
<string name="search_min_age_error">La edad mínima no debe ser mayor que la edad máxima.</string>
<string name="inbox_empty">No hay chats sin leer.</string>
<string name="inbox_new_count">%1$d nuevos</string>
<string name="back">Atrás</string>
<string name="block">Bloquear</string>
<string name="unblock">Desbloquear</string>
<string name="button_image">Imagen</string>
<string name="button_send">Enviar</string>
</resources>

View File

@@ -0,0 +1,41 @@
<resources>
<string name="landing_title">Entrer dans le chat</string>
<string name="landing_copy">Compact, rapide et sans détour. Crée ton profil et commence tout de suite une conversation.</string>
<string name="feature_worldwide_chat">Chat mondial</string>
<string name="feature_image_exchange">Échange dimages</string>
<string name="feature_compact_controls">Utilisation compacte</string>
<string name="profile_title">Démarrer le profil</string>
<string name="profile_copy">Quelques informations suffisent pour commencer.</string>
<string name="label_nick">Indique ton pseudo pour le chat</string>
<string name="label_gender">Genre</string>
<string name="label_age">Âge</string>
<string name="label_country">Pays</string>
<string name="button_start_chat">Démarrer le chat</string>
<string name="gender_female">Femme</string>
<string name="gender_male">Homme</string>
<string name="gender_pair">Couple</string>
<string name="gender_trans_mf">Transgenre (M-&gt;F)</string>
<string name="gender_trans_fm">Transgenre (F-&gt;M)</string>
<string name="tab_search">Recherche</string>
<string name="tab_inbox">Boîte</string>
<string name="tab_history">Historique</string>
<string name="logout">Quitter</string>
<string name="status_online">en ligne</string>
<string name="status_connecting">connexion...</string>
<string name="search_username_includes">Le nom dutilisateur contient</string>
<string name="search_from_age">À partir de lâge</string>
<string name="search_to_age">Jusquà lâge</string>
<string name="search_country">Pays</string>
<string name="search_genders">Genres</string>
<string name="search_all">Tous</string>
<string name="search_button">Rechercher</string>
<string name="search_no_results">Aucun résultat.</string>
<string name="search_min_age_error">Lâge minimum ne doit pas être supérieur à lâge maximum.</string>
<string name="inbox_empty">Aucun chat non lu.</string>
<string name="inbox_new_count">%1$d nouveaux</string>
<string name="back">Retour</string>
<string name="block">Bloquer</string>
<string name="unblock">Débloquer</string>
<string name="button_image">Image</string>
<string name="button_send">Envoyer</string>
</resources>

View File

@@ -0,0 +1,41 @@
<resources>
<string name="landing_title">Vai direttamente alla chat</string>
<string name="landing_copy">Compatto, veloce e senza passaggi inutili. Crea il tuo profilo e inizia subito una conversazione.</string>
<string name="feature_worldwide_chat">Chat mondiale</string>
<string name="feature_image_exchange">Scambio immagini</string>
<string name="feature_compact_controls">Comandi compatti</string>
<string name="profile_title">Avvia profilo</string>
<string name="profile_copy">Bastano pochi dati per iniziare.</string>
<string name="label_nick">Inserisci il tuo nickname per la chat</string>
<string name="label_gender">Genere</string>
<string name="label_age">Età</string>
<string name="label_country">Paese</string>
<string name="button_start_chat">Avvia chat</string>
<string name="gender_female">Donna</string>
<string name="gender_male">Uomo</string>
<string name="gender_pair">Coppia</string>
<string name="gender_trans_mf">Transgender (M-&gt;F)</string>
<string name="gender_trans_fm">Transgender (F-&gt;M)</string>
<string name="tab_search">Cerca</string>
<string name="tab_inbox">Posta</string>
<string name="tab_history">Cronologia</string>
<string name="logout">Esci</string>
<string name="status_online">online</string>
<string name="status_connecting">connessione...</string>
<string name="search_username_includes">Il nome utente contiene</string>
<string name="search_from_age">Dalletà</string>
<string name="search_to_age">Fino alletà</string>
<string name="search_country">Paese</string>
<string name="search_genders">Generi</string>
<string name="search_all">Tutti</string>
<string name="search_button">Cerca</string>
<string name="search_no_results">Nessun risultato.</string>
<string name="search_min_age_error">Letà minima non deve essere maggiore delletà massima.</string>
<string name="inbox_empty">Nessuna chat non letta.</string>
<string name="inbox_new_count">%1$d nuovi</string>
<string name="back">Indietro</string>
<string name="block">Blocca</string>
<string name="unblock">Sblocca</string>
<string name="button_image">Immagine</string>
<string name="button_send">Invia</string>
</resources>

View File

@@ -0,0 +1,41 @@
<resources>
<string name="landing_title">チャットへ直接</string>
<string name="landing_copy">手早く簡単にプロフィールを作成して、すぐに会話を始められます。</string>
<string name="feature_worldwide_chat">世界中のチャット</string>
<string name="feature_image_exchange">画像交換</string>
<string name="feature_compact_controls">シンプル操作</string>
<string name="profile_title">プロフィールを開始</string>
<string name="profile_copy">開始には少しの情報だけで十分です。</string>
<string name="label_nick">チャット用ニックネーム</string>
<string name="label_gender">性別</string>
<string name="label_age">年齢</string>
<string name="label_country"></string>
<string name="button_start_chat">チャット開始</string>
<string name="gender_female">女性</string>
<string name="gender_male">男性</string>
<string name="gender_pair">カップル</string>
<string name="gender_trans_mf">トランスジェンダー (M-&gt;F)</string>
<string name="gender_trans_fm">トランスジェンダー (F-&gt;M)</string>
<string name="tab_search">検索</string>
<string name="tab_inbox">受信箱</string>
<string name="tab_history">履歴</string>
<string name="logout">退出</string>
<string name="status_online">オンライン</string>
<string name="status_connecting">接続中...</string>
<string name="search_username_includes">ユーザー名に含まれる</string>
<string name="search_from_age">年齢から</string>
<string name="search_to_age">年齢まで</string>
<string name="search_country"></string>
<string name="search_genders">性別</string>
<string name="search_all">すべて</string>
<string name="search_button">検索</string>
<string name="search_no_results">結果がありません。</string>
<string name="search_min_age_error">最小年齢は最大年齢以下でなければなりません。</string>
<string name="inbox_empty">未読チャットはありません。</string>
<string name="inbox_new_count">%1$d 件の新着</string>
<string name="back">戻る</string>
<string name="block">ブロック</string>
<string name="unblock">ブロック解除</string>
<string name="button_image">画像</string>
<string name="button_send">送信</string>
</resources>

View File

@@ -0,0 +1,41 @@
<resources>
<string name="landing_title">เข้าสู่แชททันที</string>
<string name="landing_copy">รวดเร็ว กระชับ และไม่ซับซ้อน สร้างโปรไฟล์แล้วเริ่มคุยได้ทันที</string>
<string name="feature_worldwide_chat">แชททั่วโลก</string>
<string name="feature_image_exchange">แลกเปลี่ยนรูปภาพ</string>
<string name="feature_compact_controls">ใช้งานง่าย</string>
<string name="profile_title">เริ่มโปรไฟล์</string>
<string name="profile_copy">กรอกข้อมูลเพียงไม่กี่อย่างก็เริ่มได้</string>
<string name="label_nick">ชื่อเล่นสำหรับแชท</string>
<string name="label_gender">เพศ</string>
<string name="label_age">อายุ</string>
<string name="label_country">ประเทศ</string>
<string name="button_start_chat">เริ่มแชท</string>
<string name="gender_female">หญิง</string>
<string name="gender_male">ชาย</string>
<string name="gender_pair">คู่</string>
<string name="gender_trans_mf">ข้ามเพศ (ชาย-&gt;หญิง)</string>
<string name="gender_trans_fm">ข้ามเพศ (หญิง-&gt;ชาย)</string>
<string name="tab_search">ค้นหา</string>
<string name="tab_inbox">กล่องข้อความ</string>
<string name="tab_history">ประวัติ</string>
<string name="logout">ออก</string>
<string name="status_online">ออนไลน์</string>
<string name="status_connecting">กำลังเชื่อมต่อ...</string>
<string name="search_username_includes">ชื่อผู้ใช้มีคำว่า</string>
<string name="search_from_age">จากอายุ</string>
<string name="search_to_age">ถึงอายุ</string>
<string name="search_country">ประเทศ</string>
<string name="search_genders">เพศ</string>
<string name="search_all">ทั้งหมด</string>
<string name="search_button">ค้นหา</string>
<string name="search_no_results">ไม่มีผลลัพธ์</string>
<string name="search_min_age_error">อายุขั้นต่ำต้องไม่มากกว่าอายุสูงสุด</string>
<string name="inbox_empty">ไม่มีแชทที่ยังไม่ได้อ่าน</string>
<string name="inbox_new_count">ใหม่ %1$d</string>
<string name="back">กลับ</string>
<string name="block">บล็อก</string>
<string name="unblock">เลิกบล็อก</string>
<string name="button_image">รูปภาพ</string>
<string name="button_send">ส่ง</string>
</resources>

View File

@@ -0,0 +1,41 @@
<resources>
<string name="landing_title">Diretso sa chat</string>
<string name="landing_copy">Mabilis, simple at walang paligoy-ligoy. Gumawa ng profile at magsimula agad ng usapan.</string>
<string name="feature_worldwide_chat">Pandaigdigang chat</string>
<string name="feature_image_exchange">Palitan ng larawan</string>
<string name="feature_compact_controls">Madaling gamitin</string>
<string name="profile_title">Simulan ang profile</string>
<string name="profile_copy">Ilang detalye lang ang kailangan para makapagsimula.</string>
<string name="label_nick">Ilagay ang iyong palayaw sa chat</string>
<string name="label_gender">Kasarian</string>
<string name="label_age">Edad</string>
<string name="label_country">Bansa</string>
<string name="button_start_chat">Simulan ang chat</string>
<string name="gender_female">Babae</string>
<string name="gender_male">Lalaki</string>
<string name="gender_pair">Magkapareha</string>
<string name="gender_trans_mf">Transgender (M-&gt;F)</string>
<string name="gender_trans_fm">Transgender (F-&gt;M)</string>
<string name="tab_search">Maghanap</string>
<string name="tab_inbox">Inbox</string>
<string name="tab_history">Kasaysayan</string>
<string name="logout">Umalis</string>
<string name="status_online">online</string>
<string name="status_connecting">kumokonekta...</string>
<string name="search_username_includes">Kasama sa username</string>
<string name="search_from_age">Mula sa edad</string>
<string name="search_to_age">Hanggang edad</string>
<string name="search_country">Bansa</string>
<string name="search_genders">Kasarian</string>
<string name="search_all">Lahat</string>
<string name="search_button">Maghanap</string>
<string name="search_no_results">Walang resulta.</string>
<string name="search_min_age_error">Ang minimum na edad ay hindi dapat mas mataas kaysa maximum na edad.</string>
<string name="inbox_empty">Walang hindi pa nababasang chat.</string>
<string name="inbox_new_count">%1$d bago</string>
<string name="back">Bumalik</string>
<string name="block">I-block</string>
<string name="unblock">I-unblock</string>
<string name="button_image">Larawan</string>
<string name="button_send">Ipadala</string>
</resources>

View File

@@ -0,0 +1,41 @@
<resources>
<string name="landing_title">直接进入聊天</string>
<string name="landing_copy">简洁、快速、无需复杂步骤。创建个人资料并立即开始聊天。</string>
<string name="feature_worldwide_chat">全球聊天</string>
<string name="feature_image_exchange">图片交换</string>
<string name="feature_compact_controls">简洁操作</string>
<string name="profile_title">开始个人资料</string>
<string name="profile_copy">只需少量信息即可开始。</string>
<string name="label_nick">请输入聊天昵称</string>
<string name="label_gender">性别</string>
<string name="label_age">年龄</string>
<string name="label_country">国家</string>
<string name="button_start_chat">开始聊天</string>
<string name="gender_female">女性</string>
<string name="gender_male">男性</string>
<string name="gender_pair">情侣</string>
<string name="gender_trans_mf">跨性别 (男-&gt;女)</string>
<string name="gender_trans_fm">跨性别 (女-&gt;男)</string>
<string name="tab_search">搜索</string>
<string name="tab_inbox">收件箱</string>
<string name="tab_history">历史</string>
<string name="logout">离开</string>
<string name="status_online">在线</string>
<string name="status_connecting">正在连接...</string>
<string name="search_username_includes">用户名包含</string>
<string name="search_from_age">从年龄</string>
<string name="search_to_age">到年龄</string>
<string name="search_country">国家</string>
<string name="search_genders">性别</string>
<string name="search_all">全部</string>
<string name="search_button">搜索</string>
<string name="search_no_results">没有结果。</string>
<string name="search_min_age_error">最小年龄不能大于最大年龄。</string>
<string name="inbox_empty">没有未读聊天。</string>
<string name="inbox_new_count">%1$d 条新消息</string>
<string name="back">返回</string>
<string name="block">屏蔽</string>
<string name="unblock">取消屏蔽</string>
<string name="button_image">图片</string>
<string name="button_send">发送</string>
</resources>

View File

@@ -0,0 +1,142 @@
<resources>
<string name="app_name">YpChat</string>
<string name="landing_eyebrow">YpChat</string>
<string name="landing_title">Directly into chat</string>
<string name="landing_copy">Compact, fast and without detours. Create your profile and start a conversation right away.</string>
<string name="feature_worldwide_chat">Worldwide chat</string>
<string name="feature_image_exchange">Image exchange</string>
<string name="feature_compact_controls">Compact controls</string>
<string name="profile_title">Start profile</string>
<string name="profile_copy">A few details are enough to get started.</string>
<string name="label_nick">Please enter your chat nickname</string>
<string name="label_gender">Gender</string>
<string name="label_age">Age</string>
<string name="label_country">Country</string>
<string name="button_start_chat">Start chat</string>
<string name="gender_female">Female</string>
<string name="gender_male">Male</string>
<string name="gender_pair">Couple</string>
<string name="gender_trans_mf">Transgender (M-&gt;F)</string>
<string name="gender_trans_fm">Transgender (F-&gt;M)</string>
<string name="socket_connected">Socket connected</string>
<string name="socket_connecting">Connecting socket...</string>
<string name="status_online">online</string>
<string name="status_connecting">connecting...</string>
<string name="tab_online">Online</string>
<string name="tab_search">Search</string>
<string name="tab_inbox">Inbox</string>
<string name="tab_history">History</string>
<string name="tab_console">Console</string>
<string name="tab_more">More</string>
<string name="logout">Logout</string>
<string name="timeout_in">Timeout in %1$s</string>
<string name="no_users_online">No other users online yet.</string>
<string name="search_username_includes">Username contains</string>
<string name="search_from_age">From age</string>
<string name="search_to_age">To age</string>
<string name="search_country">Country</string>
<string name="search_genders">Genders</string>
<string name="search_all">All</string>
<string name="search_button">Search</string>
<string name="search_no_results">No results.</string>
<string name="search_min_age_error">Minimum age must not be greater than maximum age.</string>
<string name="inbox_empty">No unread chats.</string>
<string name="inbox_new_count">%1$d new</string>
<string name="history_empty">No history yet.</string>
<string name="no_message">No message</string>
<string name="back">Back</string>
<string name="block">Block</string>
<string name="unblock">Unblock</string>
<string name="message_placeholder">Message</string>
<string name="button_image">Image</string>
<string name="button_camera">Photo</string>
<string name="button_send">Send</string>
<string name="button_smileys">Smileys</string>
<string name="button_video_allow">Allow video</string>
<string name="button_video_allowed">Video allowed</string>
<string name="button_video_open">Open video chat</string>
<string name="button_video_foreground">Bring forward</string>
<string name="button_video_accept">Accept</string>
<string name="button_video_reject">Reject</string>
<string name="button_video_cancel">Cancel</string>
<string name="button_video_end">End</string>
<string name="button_video_minimize">Minimize</string>
<string name="button_video_mute">Mute microphone</string>
<string name="button_video_unmute">Unmute microphone</string>
<string name="button_video_camera_off">Camera off</string>
<string name="button_video_camera_on">Camera on</string>
<string name="video_status_partner_allowed">Partner allowed video</string>
<string name="video_status_partner_pending">Partner has not allowed video yet</string>
<string name="video_status_capacity_reached">Maximum of three video connections allowed</string>
<string name="video_self_preview">You</string>
<string name="video_self_preview_camera_off">Camera inactive</string>
<string name="video_mic_on">Microphone on</string>
<string name="video_mic_off">Microphone off</string>
<string name="video_partner_mic_on">Partner: microphone on</string>
<string name="video_partner_mic_off">Partner: microphone off</string>
<string name="video_self_mic_on">You: microphone on</string>
<string name="video_self_mic_off">You: microphone off</string>
<string name="video_status_ringing">Ringing</string>
<string name="video_status_connecting">Connecting</string>
<string name="video_status_active">Active</string>
<string name="image_message">Image message</string>
<string name="image_upload_in_progress">Uploading image...</string>
<string name="image_upload_success">Image uploaded.</string>
<string name="image_upload_failed">Image upload failed.</string>
<string name="image_upload_too_large">Image is larger than 5 MB.</string>
<string name="image_upload_open_failed">Image could not be opened.</string>
<string name="camera_permission_denied">Camera permission was denied.</string>
<string name="camera_capture_failed">Photo could not be captured.</string>
<string name="feedback_created_at">Received %1$s</string>
<string name="feedback_meta_separator"></string>
<string name="countries_load_error">Country list could not be loaded: %1$s</string>
<string name="user_blocked">%1$s has been blocked</string>
<string name="user_unblocked">%1$s has been unblocked</string>
<string name="video_consent_required">Video chat becomes visible after both users allow it.</string>
<string name="video_capacity_reached">Maximum of three video connections allowed.</string>
<string name="video_partner_capacity_reached">The partner already reached the maximum number of video connections.</string>
<string name="video_call_exists">A video chat with this partner already exists.</string>
<string name="feedback_title">Feedback</string>
<string name="feedback_comment">Comment</string>
<string name="feedback_send">Send feedback</string>
<string name="feedback_saved">Feedback saved.</string>
<string name="feedback_empty">No feedback yet.</string>
<string name="anonymous">Anonymous</string>
<string name="feedback_admin_user">Admin user</string>
<string name="feedback_admin_password">Password</string>
<string name="feedback_admin_login">Admin login</string>
<string name="feedback_admin_logout">Logout admin</string>
<string name="feedback_delete">Delete</string>
<string name="console_title">Console</string>
<string name="console_placeholder">Enter /command or admin login input</string>
<string name="console_send">Send</string>
<string name="console_empty">No console output yet.</string>
<string name="more_title">More</string>
<string name="more_feedback">Feedback</string>
<string name="more_partners">Partners</string>
<string name="more_faq">FAQ</string>
<string name="more_rules">Rules</string>
<string name="more_safety">Safety</string>
<string name="more_privacy">Privacy</string>
<string name="more_imprint">Imprint</string>
<string name="more_back">Back to overview</string>
<string name="partners_intro">Recommended and friendly projects for our community.</string>
<string name="faq_intro">Answers to common questions about the chat.</string>
<string name="rules_intro">Basic rules for respectful chatting.</string>
<string name="safety_intro">Tips for privacy and safer usage.</string>
<string name="privacy_intro">Privacy policy, processed data and contact for privacy requests.</string>
<string name="imprint_intro">Legal notice and contact details.</string>
<string name="external_link">External link</string>
<string name="faq_title">Frequently Asked Questions</string>
<string name="rules_title">Chat Rules</string>
<string name="safety_title">Safety and Privacy</string>
<string name="privacy_title">Privacy Policy</string>
<string name="imprint_title">Imprint</string>
<string name="partners_title">Partners</string>
<string name="faq_body">Choose a nickname, enter your profile details and start chatting. Do not share sensitive data like phone numbers, addresses, passwords or payment information. You can send images, block users and use feedback for serious issues.</string>
<string name="rules_body">No insults, hate speech, illegal content, spam or unwanted harassment. Only send images you are allowed to share and respect the privacy of others.</string>
<string name="safety_body">Use a nickname that does not identify you. Do not share private contact or payment data. Be careful with links from strangers and end conversations that feel wrong. Use block and feedback for serious incidents.</string>
<string name="privacy_body">YpChat processes the nickname you choose, profile details such as age, gender and country, chat messages, images you actively send, feedback messages and technically necessary session data. The Android app requests camera access only if you actively want to take a photo in the app. The full privacy policy for website and app is published on www.ypchat.net.</string>
<string name="privacy_open_policy">Open privacy policy</string>
<string name="imprint_body">Torsten Schulz, Friedrich-Stampfer-Str. 21, 60437 Frankfurt. Contact: tsschulz@tsschulz.de. External links are the responsibility of their operators.</string>
</resources>

View File

@@ -0,0 +1,7 @@
<resources>
<style name="Theme.YpChat" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:windowNoTitle">true</item>
<item name="android:windowActionBar">false</item>
<item name="android:fontFamily">sans</item>
</style>
</resources>

4
android/build.gradle.kts Normal file
View File

@@ -0,0 +1,4 @@
plugins {
id("com.android.application") version "9.1.1" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.2.20" apply false
}

11
android/gradle.properties Normal file
View File

@@ -0,0 +1,11 @@
org.gradle.jvmargs=-Xmx16g -XX:MaxMetaspaceSize=2g -Dfile.encoding=UTF-8
android.r8.maxHeapSize=8g
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
android.dependency.useConstraints=false
android.r8.strictFullModeForKeepRules=false
android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false
kotlin.daemon.jvmargs=-Xmx2048m
android.lint.workerProcessMaxHeapSize=4g
lint.runInProcess=true

View File

@@ -0,0 +1,13 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/56a19bc915b9ba2eb62ba7554c61b919/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/398ffe3949748bfb1d5636f023d228fd/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/56a19bc915b9ba2eb62ba7554c61b919/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/398ffe3949748bfb1d5636f023d228fd/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/e99bae143b75f9a10ead10248f02055e/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/04e088f8677de3b384108493cc9481d0/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/56a19bc915b9ba2eb62ba7554c61b919/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/398ffe3949748bfb1d5636f023d228fd/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/e55dccbfe27cb97945148c61a39c89c5/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/dbd05c4936d573642f94cd149e1356c8/redirect
toolchainVendor=JETBRAINS
toolchainVersion=21

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
android/gradlew vendored Normal file
View File

@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
android/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -0,0 +1,4 @@
storeFile=release-upload-key.jks
storePassword=CHANGE_ME
keyAlias=upload
keyPassword=CHANGE_ME

BIN
android/production Normal file

Binary file not shown.

View File

@@ -0,0 +1,21 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "YpChatAndroid"
include(":app")

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,47 +194,21 @@ 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);
@@ -164,6 +217,323 @@ async function handleImageUpload(event) {
// 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

@@ -25,6 +25,46 @@
</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"
@@ -41,25 +81,25 @@
/> />
</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' },
@@ -96,15 +135,12 @@ 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};`);
@@ -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

@@ -5,6 +5,7 @@
<a href="/faq">FAQ</a> <a href="/faq">FAQ</a>
<a href="/regeln">Regeln</a> <a href="/regeln">Regeln</a>
<a href="/sicherheit">Sicherheit</a> <a href="/sicherheit">Sicherheit</a>
<a href="/datenschutz">Datenschutz</a>
<a href="#" @click.prevent="showFeedback = true">Feedback</a> <a href="#" @click.prevent="showFeedback = true">Feedback</a>
<a href="#" @click.prevent="showImprint = true">Impressum</a> <a href="#" @click.prevent="showImprint = true">Impressum</a>

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

@@ -4,6 +4,7 @@ import PartnersView from '../views/PartnersView.vue';
import MockupView from '../views/MockupView.vue'; import MockupView from '../views/MockupView.vue';
import FeedbackView from '../views/FeedbackView.vue'; import FeedbackView from '../views/FeedbackView.vue';
import FaqView from '../views/FaqView.vue'; import FaqView from '../views/FaqView.vue';
import PrivacyView from '../views/PrivacyView.vue';
import RulesView from '../views/RulesView.vue'; import RulesView from '../views/RulesView.vue';
import SafetyView from '../views/SafetyView.vue'; import SafetyView from '../views/SafetyView.vue';
import GuideHubView from '../views/GuideHubView.vue'; import GuideHubView from '../views/GuideHubView.vue';
@@ -11,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',
@@ -67,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',
@@ -142,6 +220,20 @@ const safetySchema = {
inLanguage: 'de-DE' inLanguage: 'de-DE'
}; };
const privacySchema = {
'@context': 'https://schema.org',
'@type': 'WebPage',
name: 'Datenschutz - SingleChat',
url: `${SITE_URL}/datenschutz`,
description: 'Datenschutzerklärung für SingleChat und die Android-App mit Informationen zu Profilangaben, Nachrichten, Bildern und Sitzungsdaten.',
isPartOf: {
'@type': 'WebSite',
name: 'SingleChat',
url: `${SITE_URL}/`
},
inLanguage: 'de-DE'
};
const guideHubSchema = { const guideHubSchema = {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'CollectionPage', '@type': 'CollectionPage',
@@ -157,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',
@@ -248,6 +340,22 @@ const routes = [
schema: safetySchema schema: safetySchema
} }
}, },
{
path: '/datenschutz',
name: 'datenschutz',
component: PrivacyView,
meta: {
title: 'Datenschutzerklärung für Website und App - SingleChat',
description: 'Datenschutzerklärung für SingleChat und die Android-App mit Informationen zu Profilangaben, Nachrichten, Bildern und Sitzungsdaten.',
keywords: 'datenschutz ypchat, privacy policy chat app, chat datenschutz, android app datenschutz',
ogTitle: 'Datenschutzerklärung für Website und App - SingleChat',
ogDescription: 'Informationen zur Datenverarbeitung bei SingleChat und in der Android-App.',
ogType: 'website',
image: DEFAULT_IMAGE,
robots: 'index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1',
schema: privacySchema
}
},
{ {
path: '/ratgeber', path: '/ratgeber',
name: 'ratgeber', name: 'ratgeber',
@@ -346,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

@@ -0,0 +1,175 @@
<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>Datenschutz</h1>
</div>
</router-link>
<HeaderAdBanner />
</header>
<main class="content-page">
<h2>Datenschutzerklärung für Website und App</h2>
<p>
Diese Datenschutzerklärung gilt für die Website und die Android-App von YpChat unter
der Domain <strong>www.ypchat.net</strong>.
</p>
<p>
Sie beschreibt die Verarbeitung personenbezogener Daten im Zusammenhang mit der Nutzung der
Chat-Funktionen, der Bildfreigabe, von Feedback-Meldungen und der technisch notwendigen
Sitzungsverwaltung.
</p>
<h3>1. Verantwortlicher</h3>
<p>
Torsten Schulz<br>
Friedrich-Stampfer-Str. 21<br>
60437 Frankfurt<br>
E-Mail: <a href="mailto:tsschulz@tsschulz.de">tsschulz@tsschulz.de</a>
</p>
<h3>2. Welche Daten verarbeitet werden</h3>
<p>Im Rahmen der Nutzung können insbesondere folgende Daten verarbeitet werden:</p>
<ul>
<li>frei gewählter Nickname</li>
<li>Alter, Geschlecht und Land</li>
<li>Chat-Nachrichten</li>
<li>hochgeladene Bilder</li>
<li>Feedback-Nachrichten</li>
<li>technische Sitzungs- und Verbindungsdaten</li>
</ul>
<h3>3. Zweck der Verarbeitung</h3>
<p>Die Verarbeitung erfolgt, um die Chat-Funktionen bereitzustellen und sicher zu betreiben, insbesondere für:</p>
<ul>
<li>Login und Wiedererkennung einer Sitzung</li>
<li>Darstellung von Profilangaben im Chat</li>
<li>Versand und Empfang von Nachrichten</li>
<li>Versand und Abruf von Bildern</li>
<li>Suche, Verlauf, Posteingang und Blockierfunktionen</li>
<li>Bearbeitung von Feedback und Missbrauchshinweisen</li>
</ul>
<h3>4. Rechtsgrundlagen</h3>
<p>
Soweit personenbezogene Daten verarbeitet werden, erfolgt dies in der Regel zur Erfüllung
der angeforderten Chat-Funktionen und auf Grundlage berechtigter Interessen an einem
sicheren, stabilen und missbrauchsarmen Betrieb des Angebots.
</p>
<h3>5. Chat-Nachrichten und Profilangaben</h3>
<p>
Wenn du den Dienst nutzt, werden von dir eingegebene Profilangaben wie Nickname, Alter, Geschlecht und Land für
die Chat-Funktion verwendet. Chat-Nachrichten werden technisch verarbeitet, damit Unterhaltungen in Echtzeit
zugestellt werden können.
</p>
<h3>6. Bilder und Kamerazugriff</h3>
<p>
Bilder werden nur verarbeitet, wenn du sie aktiv auswählst und hochlädst. Nach aktuellem Systemstand werden
hochgeladene Bilder serverseitig temporär gespeichert und nach Ablauf einer begrenzten Zeit wieder entfernt.
</p>
<p>
Die Android-App fordert die Kameraberechtigung nur an, wenn du in der App aktiv ein Foto aufnehmen möchtest.
Ohne deine Auslösung erfolgt kein Kamerazugriff.
</p>
<h3>7. Sitzungen, Cookies und technische Protokolle</h3>
<p>
Für den Betrieb des Dienstes werden Sitzungsdaten verwendet. Dazu gehören insbesondere technisch notwendige
Session-Informationen, damit ein Login erhalten bleibt und Socket- sowie API-Anfragen korrekt zugeordnet werden
können. Zusätzlich können im Rahmen des Serverbetriebs technische Protokolldaten anfallen.
</p>
<h3>8. Feedback und Missbrauchsmeldungen</h3>
<p>
Wenn du Feedback sendest, werden die von dir eingetragenen Inhalte verarbeitet, um Hinweise, Fehlermeldungen oder
Missbrauchsmeldungen zu bearbeiten.
</p>
<h3>9. Weitergabe an Dritte</h3>
<p>
Eine Weitergabe personenbezogener Daten an Dritte erfolgt nicht zu Werbezwecken. Soweit externe technische
Dienstleister oder Hosting-Anbieter eingebunden sind, kann eine Verarbeitung im Rahmen des technischen Betriebs
erforderlich sein.
</p>
<h3>10. Werbung, Standort und weitere sensible Daten</h3>
<p>
Die Android-App verwendet nach aktuellem Stand kein Werbe-SDK und verarbeitet keine Standortdaten, Kontaktlisten,
Gesundheitsdaten oder Zahlungsdaten. Solche Daten werden weder angefordert noch fuer die Kernfunktion des Chats
benoetigt.
</p>
<h3>11. Verschlüsselung</h3>
<p>
Die produktive Bereitstellung der Website und der App erfolgt über verschlüsselte Verbindungen, damit Daten bei der
Übertragung geschützt sind.
</p>
<h3>12. Speicherdauer</h3>
<p>
Personenbezogene Daten werden nicht länger gespeichert, als es für den technischen Betrieb, die Bereitstellung der
Funktionen und die Bearbeitung von Missbrauchs- oder Supportanfragen erforderlich ist. Bilder sind für eine
begrenzte Verfügbarkeit im Chat gedacht und werden nicht dauerhaft als öffentliches Archiv bereitgestellt.
</p>
<h3>13. Deine Rechte</h3>
<p>
Du hast im Rahmen der gesetzlichen Vorschriften insbesondere das Recht auf Auskunft, Berichtigung, Löschung,
Einschränkung der Verarbeitung sowie Beschwerde bei einer zuständigen Aufsichtsbehörde.
</p>
<h3>14. Kontakt zum Datenschutz</h3>
<p>
Bei Fragen zum Datenschutz oder wenn du eine datenschutzbezogene Anfrage stellen möchtest, kontaktiere bitte:
<a href="mailto:tsschulz@tsschulz.de">tsschulz@tsschulz.de</a>.
</p>
<p>
Wenn du die Löschung von Daten anfragen möchtest, teile bitte den verwendeten Nickname, den ungefähren Zeitraum der
Nutzung und - soweit vorhanden - weitere zur Zuordnung notwendige Angaben mit.
</p>
<h3>15. Stand</h3>
<p>Stand dieser Datenschutzerklärung: 16. Juni 2026</p>
</main>
<ImprintContainer />
</div>
</template>
<script setup>
import HeaderAdBanner from '../components/HeaderAdBanner.vue';
import ImprintContainer from '../components/ImprintContainer.vue';
</script>
<style scoped>
.content-page {
max-width: 980px;
margin: 0 auto;
padding: 20px 14px 36px;
line-height: 1.6;
color: #344038;
}
.content-page h2 {
margin: 0 0 10px;
color: #18201b;
}
.content-page h3 {
margin: 18px 0 6px;
color: #18201b;
}
.content-page a {
color: #245c3a;
}
.app-brand-link {
text-decoration: none;
}
</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>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>
@@ -35,8 +35,8 @@
<h3>Datenschutz</h3> <h3>Datenschutz</h3>
<p> <p>
Details findest du im Impressum/Datenschutz-Hinweis unten auf der Seite. Wenn du Fragen hast, kontaktiere uns Details findest du auf der Seite <router-link to="/datenschutz">Datenschutz</router-link>. Wenn du Fragen hast,
gern über Feedback. Für den respektvollen Umgang im Chat beachte zusätzlich unsere kontaktiere uns gern über Feedback. Für den respektvollen Umgang im Chat beachte zusätzlich unsere
<router-link to="/regeln">Chat-Regeln</router-link>. <router-link to="/regeln">Chat-Regeln</router-link>.
</p> </p>
</main> </main>
@@ -77,4 +77,3 @@ import ImprintContainer from '../components/ImprintContainer.vue';
text-decoration: none; text-decoration: none;
} }
</style> </style>

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>

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