Initial commit

This commit is contained in:
Francesco Lorenzo D'Amico
2026-08-10 15:18:25 +02:00
parent 8fdb425402
commit a5d116358e
15 changed files with 700 additions and 165 deletions
+25 -1
View File
@@ -1,23 +1,47 @@
# Multi-Stage Build: Zwei separate Phasen in einem Dockerfile.
# Vorteil: Der finale Container enthält nur das Minimum — kein Node.js, kein Source-Code, keine node_modules.
# Kleineres und sichereres Image als wenn alles in einem Stage wäre.
# Stage 1: Builder — zuständig für das Bauen der React-App.
# "AS builder" gibt diesem Stage einen Namen damit Stage 2 darauf zugreifen kann.
FROM node:20-alpine AS builder
# Arbeitsverzeichnis setzen — alle folgenden Befehle laufen hier.
WORKDIR /app
# Zuerst nur package.json kopieren für optimales Layer Caching.
# npm ci läuft nur neu wenn sich die Dependencies ändern, nicht bei jedem Code-Change.
COPY package*.json ./
RUN npm ci
# Code kopieren — nach npm ci damit der Cache optimal genutzt wird.
COPY . .
# VITE_API_URL als Build-Argument — wird von docker-compose.yml übergeben.
# ARG ist nur während des Builds verfügbar, ENV macht sie für Vite lesbar.
# Warum beide: Vite läuft als Node.js-Prozess und liest ENV-Variablen — nicht ARG direkt.
# Leerer Default "" bedeutet relative URLs werden verwendet — Nginx leitet intern weiter.
ARG VITE_API_URL=""
ENV VITE_API_URL=$VITE_API_URL
# Vite baut die React-App in den /app/dist Ordner — optimiertes, minifiziertes Bundle.
RUN npm run build
# Stage 2: Nginx — zuständig für das Ausliefern der gebauten Dateien.
# Node.js aus Stage 1 wird komplett weggeworfen — nur der dist-Ordner wird übernommen.
FROM nginx:alpine
# Nur den fertigen dist-Ordner aus Stage 1 kopieren — kein Source-Code, kein Node.js.
# --from=builder verweist auf Stage 1 per Name.
COPY --from=builder /app/dist /usr/share/nginx/html
# Eigene Nginx-Konfiguration kopieren — überschreibt die Standard-Konfiguration.
# Enthält Reverse Proxy Regeln für /rooms und /socket.io sowie SPA-Fallback.
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Port 80 dokumentieren — Standard HTTP-Port.
EXPOSE 80
# Nginx im Vordergrund starten — ohne "daemon off" würde Nginx in den Hintergrund gehen
# und Docker würde denken der Container ist fertig und ihn stoppen.
CMD ["nginx", "-g", "daemon off;"]
+28 -5
View File
@@ -1,37 +1,60 @@
server {
# Nginx lauscht auf Port 80 (Standard HTTP-Port).
# Der Browser spricht Nginx an — Nginx entscheidet dann wohin die Anfrage geht (Reverse Proxy).
listen 80;
# Serve the static React build
# Statische React-Dateien ausliefern (HTML, CSS, JS).
# Vite baut die App in einen dist-Ordner — dieser wird beim Docker-Build nach /usr/share/nginx/html kopiert.
location / {
root /usr/share/nginx/html;
index index.html;
# Fallback to index.html for client-side routing
# try_files: Nginx sucht zuerst nach einer echten Datei ($uri),
# dann nach einem Ordner ($uri/), und fällt schliesslich auf index.html zurück.
# Warum nötig: React ist eine Single Page Application (SPA) — alle Routen
# existieren nur im Browser, nicht als echte Dateien auf dem Server.
# Ohne diesen Fallback würde ein direkter Aufruf von z.B. /chat einen 404 liefern.
try_files $uri $uri/ /index.html;
}
# Proxy REST API requests to the backend
# Reverse Proxy: Alle Anfragen an /rooms werden an das Backend weitergeleitet.
# Warum Reverse Proxy statt direkter Backend-URL: Der Browser kennt nur Nginx auf Port 80.
# Das Backend ist im Docker-Netzwerk unter dem Hostnamen "backend" erreichbar — nicht von aussen.
# Vorteil: Backend muss keinen öffentlichen Port exponieren, alles läuft über Nginx.
location /rooms {
proxy_pass http://backend:4000;
proxy_http_version 1.1;
# Host-Header weiterleiten damit das Backend weiss von welcher Domain die Anfrage kommt.
proxy_set_header Host $host;
# X-Real-IP: Echte IP-Adresse des Clients weitergeben — sonst sieht das Backend nur Nginx als Absender.
proxy_set_header X-Real-IP $remote_addr;
# X-Forwarded-For: Liste aller Proxys durch die die Anfrage gelaufen ist (Audit Trail).
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Proxy the health check endpoint to the backend
# Health-Check-Endpoint wird ebenfalls ans Backend weitergeleitet.
# Wird von Docker genutzt um zu prüfen ob das Backend läuft (siehe healthcheck in docker-compose.yml).
location /health {
proxy_pass http://backend:4000;
proxy_http_version 1.1;
}
# Proxy Socket.IO — requires WebSocket upgrade headers
# WebSocket-Proxy für Socket.IO — braucht spezielle Upgrade-Header.
# HTTP und WebSocket sind unterschiedliche Protokolle. Ein WebSocket startet als HTTP-Anfrage
# und wird dann per "Upgrade" auf eine dauerhafte bidirektionale Verbindung umgestellt.
# Ohne diese Header würde Nginx die Verbindung als normale HTTP-Anfrage behandeln
# und der WebSocket-Handshake würde fehlschlagen.
location /socket.io/ {
proxy_pass http://backend:4000;
proxy_http_version 1.1;
# Siehe Zeile 72 in App.jsx
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# proxy_cache_bypass: Verhindert dass Nginx WebSocket-Verbindungen cached —
# eine dauerhafte Verbindung darf nicht gecacht werden.
# da websocket keine fixe Antwort gibt und es ein laufender Kommunikationskanal ist, kann man es nicht sinnvoll cachen
proxy_cache_bypass $http_upgrade;
}
}
+59
View File
@@ -131,6 +131,65 @@ button:disabled {
margin-top: 0.75rem;
}
.rooms-list {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid rgba(148, 163, 184, 0.15);
}
.rooms-list__label {
font-size: 0.8rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
margin: 0 0 0.5rem;
}
.rooms-list__chips {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.room-chip {
background: rgba(15, 23, 42, 0.6);
border: 1px solid rgba(148, 163, 184, 0.3);
border-radius: 999px;
padding: 0.3rem 0.85rem;
font-size: 0.85rem;
font-weight: 500;
color: inherit;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.room-chip:hover {
border-color: rgba(94, 234, 212, 0.6);
}
.room-chip--active {
border-color: rgba(94, 234, 212, 0.8);
color: #5eead4;
}
.room-chip__count {
background: rgba(148, 163, 184, 0.2);
border-radius: 999px;
padding: 0.05rem 0.45rem;
font-size: 0.75rem;
}
.typing-indicator {
padding: 0.4rem 1.5rem;
font-size: 0.82rem;
color: var(--muted);
font-style: italic;
min-height: 1.6rem;
margin: 0;
}
/* Centralised error style classes */
/* Inline text error (e.g. below a field) */
+164 -16
View File
@@ -5,32 +5,47 @@ import { ERROR_MESSAGES } from './constants/errorMessages'
import { validateJoinForm } from './utils/validators'
import './App.css'
// VITE_API_URL wird beim Build von Vite eingebettet — so muss die URL nicht hardcoded stehen.
// ?? (Nullish Coalescing): Fallback auf localhost:4000 nur wenn die Variable nicht gesetzt ist.
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4000'
function App() {
// t('key') gibt den übersetzten Text aus der aktiven Sprachdatei zurück (z.B. de/translation.json).
const { t } = useTranslation()
// Jede State-Variable ist einzeln deklariert statt in einem Objekt.
// So rendert React nur neu wenn sich der jeweilige Wert ändert — nicht bei jeder State-Änderung.
const [username, setUsername] = useState('')
const [room, setRoom] = useState('general')
const [session, setSession] = useState(null)
const [status, setStatus] = useState('idle')
const [room, setRoom] = useState('general') // Standardroom beim ersten Laden
const [session, setSession] = useState(null) // null = nicht eingeloggt; sonst { id, username, room }
const [status, setStatus] = useState('idle') // idle | connecting | connected | reconnecting | error
const [messages, setMessages] = useState([])
const [participants, setParticipants] = useState([])
const [messageInput, setMessageInput] = useState('')
const [error, setError] = useState('')
const [typingUsers, setTypingUsers] = useState([]) // Usernamen der User die gerade tippen
const [rooms, setRooms] = useState([]) // Alle aktiven Rooms für die Chip-Auswahl
const socketRef = useRef(null)
const messagesEndRef = useRef(null)
// useRef speichert Werte ohne ein Re-Render auszulösen — ideal für Socket, DOM-Elemente und Timer.
const socketRef = useRef(null) // Aktive Socket.IO-Verbindung
const messagesEndRef = useRef(null) // Leeres div am Ende der Nachrichtenliste für Auto-Scroll
const typingTimerRef = useRef(null) // Timer-ID für den typing:stop Debounce
// Abgeleitete Werte direkt aus State berechnet — kein eigener useState nötig.
const connectionLabel = t(`status.${status}`, { defaultValue: status })
const isConnected = status === 'connected'
// Dieser Effekt verwaltet den gesamten WebSocket-Lebenszyklus.
// Dependency-Array [session?.id, session?.room]: Effekt läuft neu wenn User oder Room wechselt.
useEffect(() => {
// Kein Session → Verbindung trennen. Die Cleanup-Funktion am Ende erledigt das.
if (!session?.id || !session?.room) {
setStatus('idle')
return
}
// isCancelled verhindert State-Updates nach dem Unmount der Komponente (Memory Leak).
let isCancelled = false
let retryCount = 0
let reconnectTimer
@@ -38,6 +53,11 @@ function App() {
const connect = () => {
if (isCancelled) return
setStatus(retryCount === 0 ? 'connecting' : 'reconnecting')
// auth wird beim WebSocket-Handshake mitgeschickt — der Server prüft ob userId in der DB existiert
// und ob sie zum angegebenen Room gehört. Wer vorher nicht POST /join aufgerufen hat,
// hat keine gültige userId und wird mit "User not authorized for this room" abgelehnt.
// transports: ['websocket'] erzwingt reines WebSocket, kein HTTP-Polling als Fallback.
const socket = io(API_URL, {
auth: {
room: session.room,
@@ -46,6 +66,8 @@ function App() {
reconnectionAttempts: 5,
transports: ['websocket'],
})
// Socket in Ref speichern damit handleSendMessage und andere Handler darauf zugreifen können.
socketRef.current = socket
socket.on('connect', () => {
@@ -53,16 +75,15 @@ function App() {
setStatus('connected')
})
// socket.io ist der Manager (Verbindungsebene), socket der Namespace (Anwendungsebene).
// Reconnect-Events kommen vom Manager, nicht vom Socket selbst.
socket.io.on('reconnect_attempt', () => {
if (!isCancelled) {
setStatus('reconnecting')
}
if (!isCancelled) setStatus('reconnecting')
})
// Alle 5 Reconnect-Versuche gescheitert — User muss manuell neu verbinden.
socket.io.on('reconnect_failed', () => {
if (!isCancelled) {
setStatus('error')
}
if (!isCancelled) setStatus('error')
})
socket.on('connect_error', (socketError) => {
@@ -73,6 +94,9 @@ function App() {
socket.on('disconnect', (reason) => {
if (isCancelled) return
setTypingUsers([])
// 'io client disconnect': Der Client hat selbst getrennt (handleLeave) → kein Reconnect.
// Bei allen anderen Gründen (Netzwerkfehler, Server-Neustart) → erneut versuchen.
if (reason !== 'io client disconnect' && retryCount < 5) {
retryCount += 1
setStatus('reconnecting')
@@ -81,25 +105,36 @@ function App() {
}
})
// Server schickt beim Verbinden die letzten N Nachrichten des Rooms.
socket.on('history:init', (payload) => {
setMessages(Array.isArray(payload) ? payload : [])
})
// [...prev, payload] erstellt ein neues Array — React erkennt Änderungen nur per Referenzvergleich.
// prev.push() würde dieselbe Referenz behalten → kein Re-Render.
socket.on('message:new', (payload) => {
if (payload) {
setMessages((prev) => [...prev, payload])
}
})
// Wird bei jedem Join/Leave eines Users im Room gesendet.
socket.on('participants:update', (payload) => {
setParticipants(Array.isArray(payload) ? payload : [])
})
// Eigener User wird herausgefiltert — man sieht sich selbst nicht als "tippt gerade".
socket.on('typing:update', (payload) => {
if (!Array.isArray(payload)) return
setTypingUsers(payload.filter((u) => u.userId !== session.id).map((u) => u.username))
})
socket.on('connection:ready', () => {
setError('')
})
}
// setTimeout 0ms: Gibt React Zeit das Rendering abzuschliessen bevor der Status gesetzt wird.
reconnectTimer = setTimeout(() => {
if (!socketRef.current?.connected && !isCancelled) {
setStatus('connecting')
@@ -108,22 +143,55 @@ function App() {
connect()
// Cleanup: Wird ausgeführt wenn die Komponente unmountet oder der Effekt neu startet.
// Ohne Cleanup würden mehrere Sockets gleichzeitig aktiv sein → Memory Leak.
return () => {
isCancelled = true
clearTimeout(reconnectTimer)
clearTimeout(typingTimerRef.current)
socketRef.current?.disconnect()
socketRef.current = null
setTypingUsers([])
}
}, [session?.id, session?.room])
// Scrollt nach jeder neuen Nachricht ans Ende der Liste — smooth für sanfte Animation.
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
// Leeres Dependency-Array []: Läuft nur einmal beim ersten Laden der Seite.
useEffect(() => {
fetchRooms()
}, [])
const fetchRooms = async () => {
try {
const res = await fetch(`${API_URL}/rooms`)
if (res.ok) {
const { rooms: list } = await res.json()
setRooms(list)
}
} catch {
// Rooms sind nicht kritisch — App funktioniert auch ohne die Chip-Navigation.
}
}
const participantCount = participants.length
// useMemo: Text wird nur neu berechnet wenn sich typingUsers ändert — nicht bei jedem Render.
const typingLabel = useMemo(() => {
if (typingUsers.length === 0) return ''
if (typingUsers.length === 1) return `${typingUsers[0]} schreibt…`
if (typingUsers.length === 2) return `${typingUsers[0]} und ${typingUsers[1]} schreiben…`
return `${typingUsers.length} Personen schreiben…`
}, [typingUsers])
const handleJoin = async (event) => {
// Verhindert das Standard-Browserverhalten: Seite neu laden beim Formular-Submit.
event.preventDefault()
// Client-seitige Validierung vor dem API-Call — spart einen unnötigen Netzwerk-Request.
const validationError = validateJoinForm(username, room)
if (validationError) {
setError(validationError)
@@ -133,12 +201,14 @@ function App() {
setError('')
setStatus(session ? 'reconnecting' : 'connecting')
try {
// encodeURIComponent kodiert Sonderzeichen im Room-Namen — z.B. Leerzeichen → %20.
// Ohne Kodierung wäre die URL ungültig und der Request würde scheitern.
const response = await fetch(`${API_URL}/rooms/${encodeURIComponent(room.trim())}/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: username.trim(),
userId: session?.id,
userId: session?.id, // Bei Room-Wechsel: bestehende userId mitschicken statt neue zu erstellen.
}),
})
@@ -150,10 +220,19 @@ function App() {
if (!data?.user) {
throw new Error(ERROR_MESSAGES.api.noUserReturned)
}
// setSession ändert session?.id → WebSocket-useEffect läuft neu → baut neue Verbindung auf.
setSession(data.user)
setUsername(data.user.username)
setRoom(data.user.room)
// Snapshot per HTTP laden bevor der WebSocket verbunden ist — UI ist nicht leer.
// Der WebSocket überschreibt den Snapshot später mit history:init.
await fetchRoomSnapshots(data.user.room)
fetchRooms()
if (socketRef.current?.connected) {
setStatus('connected')
}
} catch (joinError) {
console.error(joinError)
setError(joinError.message)
@@ -161,6 +240,7 @@ function App() {
}
}
// Promise.all führt beide Requests parallel aus — Gesamtdauer = max(t1, t2) statt t1 + t2.
const fetchRoomSnapshots = async (targetRoom) => {
const encodedRoom = encodeURIComponent(targetRoom)
try {
@@ -177,20 +257,38 @@ function App() {
setParticipants(list)
}
} catch (snapshotError) {
// Nicht kritisch — WebSocket liefert history:init und participants:update nach.
console.warn(t(ERROR_MESSAGES.api.snapshotFailed), snapshotError)
}
}
// Debounce-Muster: typing:start sofort senden, typing:stop erst nach 1500ms Pause.
// clearTimeout vor jedem neuen Timeout — Timer wird bei jedem Tastendruck zurückgesetzt.
const handleMessageInputChange = (event) => {
setMessageInput(event.target.value)
const socket = socketRef.current
if (!socket?.connected) return
socket.emit('typing:start')
clearTimeout(typingTimerRef.current)
typingTimerRef.current = setTimeout(() => {
socket.emit('typing:stop')
}, 1500)
}
// Nachricht per WebSocket senden — nicht per HTTP.
// Vorteil: Keine neue Verbindung pro Nachricht, Server broadcastet direkt an alle im Room.
// Datenfluss: emit('message:send') → Server speichert → Server sendet 'message:new' an alle.
const handleSendMessage = (event) => {
event.preventDefault()
if (!session || !messageInput.trim()) {
return
}
if (!session || !messageInput.trim()) return
const socket = socketRef.current
if (!socket || !socket.connected) {
setError(ERROR_MESSAGES.connection.noActiveConnection)
return
}
// Tipp-Indikator sofort beenden — nicht auf die 1500ms warten.
clearTimeout(typingTimerRef.current)
socket.emit('typing:stop')
try {
socket.emit('message:send', {
text: messageInput.trim(),
@@ -203,8 +301,12 @@ function App() {
}
}
// finally läuft immer — egal ob HTTP-Request erfolgreich war oder nicht.
// setSession(null) löst den WebSocket-useEffect aus → Verbindung wird sauber getrennt.
const handleLeave = async () => {
if (!session) return
clearTimeout(typingTimerRef.current)
socketRef.current?.emit('typing:stop')
try {
await fetch(`${API_URL}/rooms/${encodeURIComponent(session.room)}/leave`, {
method: 'POST',
@@ -212,15 +314,19 @@ function App() {
body: JSON.stringify({ userId: session.id }),
})
} catch (leaveError) {
// HTTP-Fehler ignorieren — lokaler State wird in finally trotzdem zurückgesetzt.
console.warn(t(ERROR_MESSAGES.api.leaveFailed), leaveError)
} finally {
setSession(null)
setMessages([])
setParticipants([])
setTypingUsers([])
setStatus('idle')
fetchRooms()
}
}
// useMemo: Kontextsensitiver Hilfstext — wird nur neu berechnet wenn session, status oder Sprache wechselt.
const connectionHint = useMemo(() => {
if (!session) return t('hint.noSession')
switch (status) {
@@ -239,6 +345,7 @@ function App() {
<h1>{t('app.headline')}</h1>
<p>{connectionHint}</p>
</div>
{/* CSS-Klasse wird dynamisch gesetzt: status-connected, status-error etc. */}
<div className={`status-badge status-${status}`}>
<span className="status-dot" aria-hidden="true"></span>
{connectionLabel}
@@ -249,6 +356,7 @@ function App() {
<form className="session-form" onSubmit={handleJoin}>
<div className="field-group">
<label htmlFor="username">{t('form.username')}</label>
{/* Controlled Input: value kommt aus State, onChange aktualisiert State. */}
<input
id="username"
value={username}
@@ -263,12 +371,15 @@ function App() {
value={room}
placeholder={t('form.roomPlaceholder')}
onChange={(event) => setRoom(event.target.value.toLowerCase())}
// toLowerCase() verhindert doppelte Rooms durch unterschiedliche Schreibweise ("General" vs "general").
/>
</div>
<div className="actions">
<button type="submit">
{/* Button-Text wechselt: "Verbinden" ohne Session, "Aktualisieren" wenn bereits eingeloggt. */}
{session ? t('form.update') : t('form.connect')}
</button>
{/* Leave-Button nur anzeigen wenn eine Session aktiv ist (Conditional Rendering). */}
{session ? (
<button type="button" className="ghost" onClick={handleLeave}>
{t('form.leave')}
@@ -277,6 +388,25 @@ function App() {
</div>
</form>
{error ? <p className="form-error">{t(error)}</p> : null}
{rooms.length > 0 && (
<div className="rooms-list">
<p className="rooms-list__label">{t('rooms.title')}</p>
<div className="rooms-list__chips">
{rooms.map((r) => (
<button
key={r.name}
// key ist Pflicht bei React-Listen React nutzt es beim Re-Render um Elemente zu identifizieren.
type="button"
className={`room-chip ${session?.room === r.name ? 'room-chip--active' : ''}`}
onClick={() => setRoom(r.name)}
>
#{r.name}
<span className="room-chip__count">{r.participantCount}</span>
</button>
))}
</div>
</div>
)}
</section>
<main className="chat-grid">
@@ -284,6 +414,7 @@ function App() {
<header className="chat-panel__header">
<div>
<h2>{t('chat.title')}</h2>
{/* ?? zeigt session?.room wenn vorhanden, sonst den Wert aus dem Input-Feld. */}
<p>{t('chat.liveFeed', { room: session?.room ?? room })}</p>
</div>
<span className="muted">{t('chat.messageCount', { count: messages.length })}</span>
@@ -296,6 +427,7 @@ function App() {
<article
key={message.id}
className={`message ${message.userId === session?.id ? 'me' : ''}`}
// CSS-Klasse 'me': Eigene Nachrichten werden rechts ausgerichtet (Chat-Bubble-Stil).
>
<header>
<strong>{message.username}</strong>
@@ -305,23 +437,31 @@ function App() {
</article>
))
)}
{/* Leeres div als Scroll-Anker — scrollIntoView() hier = ans Ende scrollen. */}
<div ref={messagesEndRef} />
</div>
{/* Tipp-Indikator nur anzeigen wenn typingLabel nicht leer ist. */}
{typingLabel && (
<p className="typing-indicator">{typingLabel}</p>
)}
<form className="composer" onSubmit={handleSendMessage}>
<input
value={messageInput}
onChange={(event) => setMessageInput(event.target.value)}
onChange={handleMessageInputChange}
placeholder={session ? t('chat.placeholder') : t('chat.placeholderDisabled')}
disabled={!session || !isConnected}
// Deaktiviert wenn kein Session oder WebSocket nicht verbunden kein Senden ohne Verbindung.
/>
<button
type="submit"
disabled={!session || !messageInput.trim() || !isConnected}
// Aktiv nur wenn: Session vorhanden + Text eingegeben + WebSocket verbunden.
>
{t('chat.send')}
</button>
</form>
</section>
<aside className="participants-panel">
<header>
<h2>{t('participants.title')}</h2>
@@ -333,6 +473,7 @@ function App() {
) : (
participants.map((participant) => (
<li key={participant.id}>
{/* Presence-Punkt: CSS-Klasse 'online' oder 'away' je nach isOnline-Status. */}
<span
className={`presence ${participant.isOnline ? 'online' : 'away'}`}
aria-label={participant.isOnline ? t('participants.online') : 'offline'}
@@ -355,6 +496,10 @@ function App() {
)
}
// Ausserhalb der Komponente — wird nicht bei jedem Render neu erstellt.
// Intl.DateTimeFormat: Eingebaute Browser-API, kein extra Paket nötig.
// 'de-DE' formatiert im 24-Stunden-Format (13:45).
function formatTime(timestamp) {
if (!timestamp) return ''
return new Intl.DateTimeFormat('de-DE', {
@@ -363,6 +508,9 @@ function formatTime(timestamp) {
}).format(new Date(timestamp))
}
// Intl.RelativeTimeFormat: Gibt "vor 5 Minuten", "gestern" etc. aus.
// numeric: 'auto' → "gestern" statt "vor 1 Tag".
// Berechnung: Minuten → Stunden → Tage (von klein nach gross).
function formatRelativeTime(timestamp) {
if (!timestamp) return ''
const formatter = new Intl.RelativeTimeFormat('de', { numeric: 'auto' })
+3
View File
@@ -35,6 +35,9 @@
"placeholderDisabled": "Verbinde dich, um zu schreiben",
"send": "Senden"
},
"rooms": {
"title": "Aktive Räume"
},
"participants": {
"title": "Aktive Teilnehmer",
"empty": "Niemand ist aktiv",