lorem_ipsum/js/messages.js

1189 lines
40 KiB
JavaScript

/**
* Nachrichten-Dashboard und Duell-System (Challenges).
* Dieses Modul verwaltet den Abruf und Versand von Textnachrichten, die Gruppierung von
* Nachrichten zu Duell-Konversationen, das Erstellen neuer Herausforderungen mit zufälligen
* Texten und die visuelle Aufbereitung von Spielergebnissen (Gewonnen/Verloren/Unentschieden).
*/
(function () {
// --- Konstanten & Konfiguration ---
const MESSAGE_TYPE_CHALLENGE = "challenge";
const MESSAGE_TYPE_CHALLENGE_RESULT = "challenge-result";
// Abfrage-Intervall für neue Nachrichten (30 Sekunden)
const MESSAGE_POLL_INTERVAL_MS = 30000;
// Key für die aktive Challenge im SessionStorage
const ACTIVE_CHALLENGE_STORAGE_KEY = "loremIpsumActiveChallenge";
// Eindeutiges Kennzeichen für JSON-strukturierte Challenge-Daten innerhalb normaler Textnachrichten
const CHALLENGE_DATA_PREFIX = "[[loremIpsumChallenge:";
// Satzteile zur Generierung der zu merkenden Sätze in Duellen
const CHALLENGE_TEXT_PARTS = {
subjects: [
"Der flinke Entwickler",
"Die neugierige Studentin",
"Ein mueder Professor",
"Das kleine Frontend",
"Der mutige Browser",
"Eine schlaue Funktion",
"Der vergessliche Server",
"Die kreative Gruppe",
],
actions: [
"sortiert leise",
"debuggt geduldig",
"vergleicht heimlich",
"speichert vorsichtig",
"rendert ploetzlich",
"zaehlt konzentriert",
"testet neugierig",
"kompiliert langsam",
],
objects: [
"sieben blaue Buttons",
"drei lange Variablen",
"neun goldene Woerter",
"vier kaputte Formulare",
"acht schnelle Requests",
"zwei leuchtende Karten",
"fuenf stille Fehlermeldungen",
"sechs winzige Icons",
],
places: [
"im hellen Dashboard",
"unter dem dunklen Navbar",
"neben dem alten Footer",
"zwischen Login und Leaderboard",
"vor dem ersten Kaffee",
"waehrend der Lernphase",
"hinter dem lokalen Server",
"mitten im Semesterprojekt",
],
endings: [
"Danach lacht der Code, weil alles endlich funktioniert.",
"Am Ende merkt sich niemand die Semikolons, aber alle die Punkte.",
"Kurz darauf blinkt die Konsole und behauptet, sie sei unschuldig.",
"Spaeter landet der Score im Ranking und wartet auf Applaus.",
"Dabei bleibt die Seite ruhig, obwohl der Timer dramatisch tickt.",
"Zum Schluss gewinnt, wer die Woerter sauber in Reihenfolge bringt.",
],
};
// --- Modulweiter Status ---
let currentMessages = []; // Cache für alle geladenen Nachrichten
let currentUsers = []; // Liste aller anderen Benutzer im System
let messagePollingInterval = null; // ID des Polling-Timers
/**
* Holt ein zufälliges Element aus einem Array.
* @param {Array} items - Das Quell-Array.
*/
function getRandomChallengeTextPart(items) {
return items[Math.floor(Math.random() * items.length)];
}
/**
* Generiert einen grammatikalisch korrekten, zufälligen Text für ein Duell.
* Besteht aus zwei Hauptsätzen und einem Schlusssatz.
* @returns {string} Der generierte Challenge-Text.
*/
function generateChallengeText() {
const firstSentence =
[
getRandomChallengeTextPart(CHALLENGE_TEXT_PARTS.subjects),
getRandomChallengeTextPart(CHALLENGE_TEXT_PARTS.actions),
getRandomChallengeTextPart(CHALLENGE_TEXT_PARTS.objects),
getRandomChallengeTextPart(CHALLENGE_TEXT_PARTS.places),
].join(" ") + ".";
const secondSentence =
[
getRandomChallengeTextPart(CHALLENGE_TEXT_PARTS.subjects),
getRandomChallengeTextPart(CHALLENGE_TEXT_PARTS.actions),
getRandomChallengeTextPart(CHALLENGE_TEXT_PARTS.objects),
getRandomChallengeTextPart(CHALLENGE_TEXT_PARTS.places),
].join(" ") + ".";
return firstSentence +
" " +
secondSentence +
" " +
getRandomChallengeTextPart(CHALLENGE_TEXT_PARTS.endings);
}
/**
* Baut einen Nachrichtentext auf, in den die Spieldaten (JSON) eingebettet sind.
* @param {Object} challengeData - Die strukturierten Spieldaten.
* @param {string} displayText - Der für den Nutzer lesbare Textteil der Nachricht.
* @returns {string} Der präparierte Gesamttext für die API.
*/
function buildEmbeddedChallengeText(challengeData, displayText) {
return CHALLENGE_DATA_PREFIX +
JSON.stringify(challengeData) +
"]]" +
"\n" +
displayText;
}
/**
* Holt die aktuellen Zugangsdaten aus dem AppAuth-Modul.
*/
function getAuth() {
if (!window.AppAuth || typeof window.AppAuth.getAuth !== "function") {
return null;
}
const auth = window.AppAuth.getAuth();
if (!auth || !auth.username || !auth.password) {
return null;
}
return auth;
}
/**
* Bereinigt Benutzernamen für Vergleiche.
*/
function normalizeUsername(username) {
return String(username || "").trim().toLowerCase();
}
/**
* Holt eine Instanz des MessageService.
*/
function getMessageService() {
if (!window.config || !window.MessageService) {
return null;
}
return new window.MessageService(window.config);
}
/**
* Holt eine Instanz des UserService.
*/
function getUserService() {
if (!window.config || !window.UserService) {
return null;
}
return new window.UserService(window.config);
}
/**
* Holt eine Instanz des ChallengeService.
*/
function getChallengeService() {
if (!window.config || !window.ChallengeService) {
return null;
}
return new window.ChallengeService(window.config);
}
/**
* Normalisiert ein vom Server empfangenes Nachrichtenobjekt.
* Extrahiert ggf. eingebettete JSON-Challenge-Daten und vereinheitlicht
* abweichende Backend-Feldnamen (z.B. date/time/createdAt).
* @param {Object} message - Das rohe Nachrichtenobjekt.
* @returns {Object} Die normalisierte Nachricht.
*/
function normalizeMessage(message) {
// Verschiedene mögliche Text-Eigenschaften des Backends prüfen
const textCandidates = [
message.text,
message.content,
message.message,
message.challenge?.text,
message.result?.text,
];
// Findet den Text, der unsere eingebetteten JSON-Daten enthält, oder den ersten nicht-leeren Text
const rawText = textCandidates.find((value) => String(value ?? "").includes(CHALLENGE_DATA_PREFIX))
?? textCandidates.find((value) => value !== null && value !== undefined)
?? "";
// Versuchen, eingebettetes JSON aus dem Text zu parsen
const embeddedChallenge = extractEmbeddedChallenge(rawText);
const type = message.type ?? MESSAGE_TYPE_CHALLENGE;
const backendChallenge = message.challenge
?? message.result
?? (type === MESSAGE_TYPE_CHALLENGE || type === MESSAGE_TYPE_CHALLENGE_RESULT ? message : null);
// Zusammenführen von Backend-Challenge-Daten und lokal extrahierten Daten
const challenge = embeddedChallenge.challenge
? { ...(backendChallenge ?? {}), ...embeddedChallenge.challenge }
: backendChallenge;
return {
id: message.id,
sender: message.sender ?? message.from ?? "",
recipient: message.recipient ?? message.to ?? "",
type: type,
text: embeddedChallenge.text, // Nur der lesbare Teil
read: Boolean(message.read),
createdAt: message.createdAt ?? message.time ?? message.date ?? "",
challenge: challenge,
};
}
/**
* Extrahiert ein eingebettetes JSON-Objekt aus einem Nachrichtentext.
* Sucht nach dem Muster: [[loremIpsumChallenge: {JSON} ]]
* @param {string} text - Der rohe Nachrichtentext.
* @returns {Object} Ein Objekt mit dem bereinigten Anzeigetext und dem extrahierten JSON-Objekt (oder null).
*/
function extractEmbeddedChallenge(text) {
const rawText = String(text ?? "");
const startIndex = rawText.indexOf(CHALLENGE_DATA_PREFIX);
if (startIndex === -1) {
return {
text: rawText,
challenge: null,
};
}
const endIndex = rawText.indexOf("]]", startIndex);
if (endIndex === -1) {
return {
text: rawText,
challenge: null,
};
}
// JSON-String ausschneiden
const json = rawText.slice(startIndex + CHALLENGE_DATA_PREFIX.length, endIndex);
// Den JSON-Teil aus dem Text entfernen, damit der Empfänger ihn nicht sieht
const displayText = (
rawText.slice(0, startIndex) +
rawText.slice(endIndex + 2)
).trim();
try {
return {
text: displayText,
challenge: JSON.parse(json),
};
} catch {
// Falls JSON beschädigt ist, den Text unverändert zurückgeben
return {
text: rawText,
challenge: null,
};
}
}
/**
* Holt die eindeutige ID einer Challenge aus verschiedenen Backend-Datenstrukturen.
*/
function getChallengeId(challenge) {
return challenge?.challengeId ?? challenge?.challenge_id ?? challenge?.id ?? null;
}
/**
* Ermittelt den Namen des Herausforderers.
*/
function getChallengeChallenger(challenge, fallbackName) {
return challenge?.challenger
?? challenge?.challengerName
?? challenge?.sender
?? challenge?.from
?? fallbackName
?? "";
}
/**
* Ermittelt den Namen des Herausgeforderten (Gegners).
*/
function getChallengeOpponent(challenge, fallbackName) {
return challenge?.opponent
?? challenge?.opponentName
?? challenge?.challengedUser
?? challenge?.recipient
?? challenge?.to
?? fallbackName
?? "";
}
/**
* Ermittelt den Score des Gegners.
*/
function getOpponentScore(challenge) {
return challenge?.opponentScore
?? challenge?.challengedScore
?? challenge?.challengedUserScore
?? null;
}
/**
* Ermittelt den vorgegebenen Rundentext der Challenge.
*/
function getChallengeText(challenge) {
return challenge?.challengeText
?? challenge?.roundText
?? challenge?.textToRemember
?? null;
}
/**
* Prüft, ob ein Wert eine gültige Punktzahl darstellt.
*/
function hasScore(value) {
return value !== null && value !== undefined && value !== "";
}
/**
* Bestimmt die Rolle des aktuell angemeldeten Benutzers in einer Challenge.
* @param {Object} message - Die normalisierte Nachricht.
* @returns {string|null} "opponent" (Geforderter), "challenger" (Herausforderer) oder null.
*/
function getChallengeRole(message) {
const auth = getAuth();
const challenge = message.challenge;
if (!auth || !challenge || getChallengeId(challenge) === null) {
return null;
}
const ownName = normalizeUsername(auth.username);
const challenger = normalizeUsername(getChallengeChallenger(challenge, message.sender));
const opponent = normalizeUsername(getChallengeOpponent(challenge, message.recipient));
if (ownName === opponent) {
return "opponent";
}
if (ownName === challenger) {
return "challenger";
}
return null;
}
/**
* Prüft, ob der Geforderte (Opponent) die Challenge annehmen und spielen darf.
* Das ist der Fall, wenn er noch keinen Score hat und auch der Herausforderer noch nicht gespielt hat.
*/
function canOpponentAcceptChallenge(message) {
if (message.type !== MESSAGE_TYPE_CHALLENGE || !message.challenge) {
return false;
}
const role = getChallengeRole(message);
const challenge = message.challenge;
const opponentScore = getOpponentScore(challenge);
return role === "opponent" && !hasScore(opponentScore) && !hasScore(challenge.challengerScore);
}
/**
* Prüft, ob der Herausforderer (Challenger) seine Runde spielen darf.
* Das ist der Fall, wenn der Gegner (Opponent) bereits gespielt hat,
* der Herausforderer selbst aber noch nicht.
*/
function canChallengerPlayChallenge(message) {
const role = getChallengeRole(message);
const challenge = message.challenge;
return role === "challenger"
&& hasScore(getOpponentScore(challenge))
&& !hasScore(challenge.challengerScore);
}
/**
* Ermittelt den Beschriftungs- und Aktivierungsstatus des Buttons für eine Challenge.
* @param {Object} message - Die normalisierte Nachricht.
* @returns {Object|null} Button-Konfiguration mit {disabled, label, role} oder null.
*/
function getChallengeButtonState(message) {
const challenge = message.challenge;
const role = getChallengeRole(message);
if (!challenge || !role) {
return null;
}
const opponentScore = getOpponentScore(challenge);
const challengerHasScore = hasScore(challenge.challengerScore);
const opponentHasScore = hasScore(opponentScore);
// Szenario 1: Beide haben bereits gespielt -> Duell vorbei
if (challengerHasScore && opponentHasScore) {
return {
disabled: true,
label: "Challenge erledigt",
};
}
// Szenario 2: Der angemeldete User wurde herausgefordert (Rolle Opponent)
if (role === "opponent") {
if (!opponentHasScore && !challengerHasScore) {
return {
disabled: false,
label: "Challenge annehmen",
role: "opponent",
};
}
return {
disabled: true,
label: "Schon gespielt",
};
}
// Szenario 3: Der angemeldete User hat das Duell gestartet (Rolle Challenger)
if (role === "challenger") {
// Herausforderer spielt als zweites, nachdem der Gegner vorgelegt hat
if (opponentHasScore && !challengerHasScore) {
return {
disabled: false,
label: "Challenge spielen",
role: "challenger",
};
}
// Gegner hat noch nicht reagiert
return {
disabled: true,
label: "Warte auf Gegner",
};
}
return null;
}
/**
* Startet die Spielrunde für eine Challenge.
* Speichert die Challenge-Daten im SessionStorage ab und leitet auf die Spielseite weiter.
* @param {Object} message - Die Challenge-Nachricht.
* @param {string} role - Die zugewiesene Spielrolle ("opponent" / "challenger").
*/
function startChallenge(message, role) {
const challenge = message.challenge;
const challenger = getChallengeChallenger(challenge, message.sender);
const opponent = getChallengeOpponent(challenge, message.recipient);
const auth = getAuth();
const otherUser = role === "opponent" ? challenger : opponent;
// Challenge-Kontext sichern, damit play.js weiß, in welchem Modus gestartet wird
sessionStorage.setItem(
ACTIVE_CHALLENGE_STORAGE_KEY,
JSON.stringify({
id: getChallengeId(challenge),
role: role,
challenger: challenger,
opponent: otherUser,
opponentScore: role === "challenger" ? getOpponentScore(challenge) : null,
challengeText: getChallengeText(challenge),
ownUsername: auth?.username ?? "",
}),
);
// Dynamisch auf die Spielseite navigieren
if (typeof window.loadPage === "function") {
window.loadPage("play", "nav-play");
}
}
/**
* Normalisiert ein Benutzerobjekt/String zu einem Namen.
*/
function normalizeUser(user) {
if (typeof user === "string") {
return user;
}
return user?.name ?? user?.username ?? "";
}
/**
* Formatiert einen ISO-Zeitstempel in de-CH Lokale Format (z.B. "09.06. 18:07").
*/
function formatMessageTime(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return "";
}
return date.toLocaleString("de-CH", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
/**
* Blendet eine Feedbackmeldung ein.
*/
function setFeedback(message, type) {
const feedback = document.getElementById("messages-feedback");
if (!feedback) {
return;
}
feedback.className = "alert alert-" + type + " mt-3 mb-0";
feedback.textContent = message;
feedback.classList.remove("d-none");
}
/**
* Aktiviert oder deaktiviert alle Formular-Interaktionen in der Inbox (während Ladezeiten).
*/
function setFormEnabled(enabled) {
const formElements = document.querySelectorAll(
"#challenge-form button, #challenge-form select, #challenge-form textarea, #mark-read-button, #refresh-messages-button",
);
formElements.forEach((element) => {
element.disabled = !enabled;
});
}
/**
* Aktualisiert den visuellen Zustand des Nachrichten-Links in der Navbar.
* Falls ungelesene Nachrichten für den Benutzer vorliegen, wird der Link rot hinterlegt.
* @param {Array<Object>} messages - Die Liste der Nachrichten.
*/
function updateMessagesNavState(messages = currentMessages) {
const navLink = document.getElementById("navbar-messages");
if (!navLink) {
return;
}
const auth = getAuth();
const ownName = normalizeUsername(auth?.username);
// Prüfen, ob eine eingehende Nachricht ungelesen ist
const hasUnreadMessages = messages.some((message) => {
const isIncoming = normalizeUsername(message.recipient) === ownName
|| normalizeUsername(message.sender) !== ownName;
return isIncoming && !message.read;
});
// Toggle der CSS-Klasse "has-unread-messages"
navLink.classList.toggle("has-unread-messages", hasUnreadMessages);
}
/**
* Markiert eine Nachricht beim Anklicken als gelesen und sendet dies an den Server.
*/
async function markMessageReadOnClick(message, item) {
const auth = getAuth();
const messageService = getMessageService();
if (!auth || !messageService || !message.id || message.read) {
return;
}
const isIncoming = normalizeUsername(message.recipient) === normalizeUsername(auth.username)
|| normalizeUsername(message.sender) !== normalizeUsername(auth.username);
if (!isIncoming) {
return;
}
// Optimistisches UI-Update: Sofort als gelesen markieren
message.read = true;
item.classList.remove("message-item-unread");
updateMessagesNavState(currentMessages);
const result = await messageService.markMessageAsRead(
auth.username,
auth.password,
message.id,
);
if (!result.ok) {
// Rollback bei Fehler im Backend
message.read = false;
item.classList.add("message-item-unread");
updateMessagesNavState(currentMessages);
setFeedback("Nachricht konnte nicht als gelesen markiert werden.", "warning");
}
}
/**
* Markiert alle ungelesenen Nachrichten einer Challenge-Gruppe beim Klicken auf gelesen.
*/
async function markMessageGroupReadOnClick(messages, item) {
const auth = getAuth();
const messageService = getMessageService();
if (!auth || !messageService) {
return;
}
// Alle ungelesenen eingehenden Nachrichten der Gruppe filtern
const unreadIncomingMessages = messages.filter((message) => {
const isIncoming = normalizeUsername(message.recipient) === normalizeUsername(auth.username)
|| normalizeUsername(message.sender) !== normalizeUsername(auth.username);
return isIncoming && !message.read && message.id;
});
if (unreadIncomingMessages.length === 0) {
return;
}
// Optimistisches Update
unreadIncomingMessages.forEach((message) => {
message.read = true;
});
item.classList.remove("message-item-unread");
updateMessagesNavState(currentMessages);
// Alle Requests parallel an das Backend senden
const results = await Promise.all(
unreadIncomingMessages.map((message) =>
messageService.markMessageAsRead(auth.username, auth.password, message.id),
),
);
if (results.some((result) => !result.ok)) {
// Rollback bei Fehlern
unreadIncomingMessages.forEach((message) => {
message.read = false;
});
item.classList.add("message-item-unread");
updateMessagesNavState(currentMessages);
setFeedback("Challenge-Nachrichten konnten nicht als gelesen markiert werden.", "warning");
}
}
/**
* Hilfsfunktion zur Ermittlung des Zeitstempels einer Nachricht.
*/
function getMessageTimeValue(message) {
const time = new Date(message.createdAt).getTime();
return Number.isNaN(time) ? 0 : time;
}
/**
* Führt die Teildaten aus verschiedenen Nachrichten einer Challenge zusammen.
* Wenn z.B. Nachricht 1 die ID und den Text liefert, und Nachricht 2 den Gegner-Score,
* enthält das Ergebnis-Objekt alle Eigenschaften.
* @param {Array<Object>} messages - Die sortierte Liste der Challenge-Nachrichten.
* @returns {Object} Ein zusammengeführtes Datenobjekt der Challenge.
*/
function mergeChallengeData(messages) {
return messages
.slice()
// Chronologisch aufsteigend sortieren für korrektes Überschreiben
.sort((a, b) => getMessageTimeValue(a) - getMessageTimeValue(b))
.reduce((merged, message) => {
if (!message.challenge) {
return merged;
}
// Alle nicht-leeren Werte in das Sammelobjekt übernehmen
Object.keys(message.challenge).forEach((key) => {
const value = message.challenge[key];
if (value !== null && value !== undefined && value !== "") {
merged[key] = value;
}
});
return merged;
}, {});
}
/**
* Gruppiert eine Liste von Nachrichten.
* Normale Nachrichten bleiben eigenständig, während Nachrichten mit derselben Challenge-ID
* zu einer einzigen "Challenge-Gruppe" zusammengefasst werden. Das verhindert Inbox-Spam.
* @param {Array<Object>} messages - Liste aller Nachrichten.
* @returns {Array<Object>} Die gruppierten Einträge, absteigend nach Aktualität sortiert.
*/
function groupMessagesByChallenge(messages) {
const groupsByChallenge = new Map();
const standaloneGroups = [];
messages.forEach((message) => {
const challengeId = getChallengeId(message.challenge);
// Standardnachrichten ohne Challenge-ID werden direkt verarbeitet
if (challengeId === null || challengeId === undefined) {
standaloneGroups.push({
kind: "message",
latest: message,
messages: [message],
});
return;
}
// Challenge-Nachrichten werden nach ID gruppiert
const key = String(challengeId);
if (!groupsByChallenge.has(key)) {
groupsByChallenge.set(key, []);
}
groupsByChallenge.get(key).push(message);
});
// Die Gruppierungen konsolidieren
const challengeGroups = Array.from(groupsByChallenge.values()).map((groupMessages) => {
// Absteigend sortieren, damit das neueste Element die Basis bildet
const sortedMessages = groupMessages
.slice()
.sort((a, b) => getMessageTimeValue(b) - getMessageTimeValue(a));
const latest = { ...sortedMessages[0] };
// Spieldaten aller Nachrichten der Gruppe verschmelzen
latest.challenge = mergeChallengeData(sortedMessages);
return {
kind: "challenge",
latest: latest,
messages: sortedMessages,
};
});
// Zusammenführen und absteigend nach Datum der neuesten Nachricht sortieren
return standaloneGroups
.concat(challengeGroups)
.sort((a, b) => getMessageTimeValue(b.latest) - getMessageTimeValue(a.latest));
}
/**
* Rendert die Benutzerliste im Sidebar-Bereich der Inbox zum schnellen Herausfordern.
* Befüllt auch das Empfänger-Dropdown im Formular.
* @param {Array<string|Object>} users - Liste aller User.
*/
function renderUserList(users) {
const userList = document.getElementById("messages-user-list");
const recipientSelect = document.getElementById("challenge-recipient");
if (!userList || !recipientSelect) {
return;
}
const auth = getAuth();
const ownName = normalizeUsername(auth?.username);
// Benutzernamen extrahieren, bereinigen, Duplikate entfernen und eigenen Namen filtern
const uniqueUsers = Array.from(new Set(users.map(normalizeUser)))
.filter(Boolean)
.filter((username) => normalizeUsername(username) !== ownName)
.sort((a, b) => a.localeCompare(b, "de-CH"));
currentUsers = uniqueUsers;
userList.innerHTML = "";
recipientSelect.innerHTML = "";
if (uniqueUsers.length === 0) {
const emptyMessage = document.createElement("p");
emptyMessage.className = "messages-empty";
emptyMessage.textContent = "Keine anderen User gefunden.";
userList.appendChild(emptyMessage);
return;
}
// Für jeden Benutzer einen Button in der Liste und eine Option im Select erstellen
uniqueUsers.forEach((username) => {
const userButton = document.createElement("button");
userButton.type = "button";
userButton.className = "messages-user-button";
userButton.textContent = username;
// Bei Klick den User als Empfänger auswählen und Fokus ins Textfeld setzen
userButton.addEventListener("click", () => {
recipientSelect.value = username;
document.getElementById("challenge-text")?.focus();
});
userList.appendChild(userButton);
const option = document.createElement("option");
option.value = username;
option.textContent = username;
recipientSelect.appendChild(option);
});
}
/**
* Rendert die Nachrichtenliste (Inbox) im DOM.
* Erstellt Standard-Nachrichtenkarten oder komplexe Challenge-Karten.
* @param {Array<Object>} messages - Die anzuzeigenden Nachrichten.
*/
function renderMessages(messages = currentMessages) {
const messageList = document.getElementById("message-list");
if (!messageList) {
return;
}
const auth = getAuth();
const ownName = normalizeUsername(auth?.username);
messageList.innerHTML = "";
if (messages.length === 0) {
const emptyMessage = document.createElement("p");
emptyMessage.className = "messages-empty";
emptyMessage.textContent = "Noch keine Nachrichten vorhanden.";
messageList.appendChild(emptyMessage);
return;
}
// Nachrichten nach Gruppierung rendern
groupMessagesByChallenge(messages)
.forEach((messageGroup) => {
const message = messageGroup.latest;
const item = document.createElement("article");
const isOutgoing = normalizeUsername(message.sender) === ownName;
// Prüfen, ob mindestens eine Nachricht in der Gruppe ungelesen ist
const hasUnreadIncoming = messageGroup.messages.some((groupMessage) => {
const isIncoming = normalizeUsername(groupMessage.recipient) === ownName
|| normalizeUsername(groupMessage.sender) !== ownName;
return isIncoming && !groupMessage.read;
});
item.className = "message-item";
if (messageGroup.kind === "challenge") {
item.classList.add("challenge-message-group");
}
if (hasUnreadIncoming) {
item.classList.add("message-item-unread");
// Bei Klick alle ungelesenen Nachrichten dieser Gruppe als gelesen markieren
item.addEventListener("click", () => markMessageGroupReadOnClick(messageGroup.messages, item));
}
// Absender-/Empfängerzeile bestimmen
const fromToText = messageGroup.kind === "challenge"
? "Challenge"
: isOutgoing
? "An " + message.recipient
: "Von " + message.sender;
const meta = document.createElement("div");
meta.className = "message-meta";
const sender = document.createElement("strong");
sender.textContent = fromToText;
const time = document.createElement("span");
time.textContent = formatMessageTime(message.createdAt);
const text = document.createElement("p");
text.textContent = message.text;
meta.append(sender, time);
item.append(meta, text);
// Bei Challenge-Gruppen mit mehreren Nachrichten einen "Verlauf einblenden"-Button anbieten
if (messageGroup.kind === "challenge" && messageGroup.messages.length > 1) {
const toggleButton = document.createElement("button");
toggleButton.type = "button";
toggleButton.className = "challenge-thread-toggle";
toggleButton.textContent = "Verlauf anzeigen";
const thread = document.createElement("div");
thread.className = "challenge-thread d-none";
messageGroup.messages.forEach((threadMessage) => {
const threadItem = document.createElement("div");
threadItem.className = "challenge-thread-item";
const threadMeta = document.createElement("span");
threadMeta.textContent = (normalizeUsername(threadMessage.sender) === ownName
? "Du"
: threadMessage.sender || "System") + " · " + formatMessageTime(threadMessage.createdAt);
const threadText = document.createElement("p");
threadText.textContent = threadMessage.text;
threadItem.append(threadMeta, threadText);
thread.appendChild(threadItem);
});
toggleButton.addEventListener("click", (event) => {
event.stopPropagation(); // Verhindert das Auslösen des Gelesen-Markierens der ganzen Karte
const isCollapsed = thread.classList.toggle("d-none");
toggleButton.textContent = isCollapsed ? "Verlauf anzeigen" : "Verlauf ausblenden";
});
item.appendChild(toggleButton);
item.appendChild(thread);
}
// Falls die Challenge beendet ist (beide Scores vorhanden), eine Ergebnis-Grafik zeichnen
if (message.challenge && hasScore(message.challenge.challengerScore) && hasScore(message.challenge.opponentScore)) {
item.appendChild(createChallengeResultGraphic(message.challenge));
}
// Ggf. Button für "Annehmen" oder "Spielen" einblenden
const challengeButtonState = getChallengeButtonState(message);
if (challengeButtonState) {
const challengeButton = document.createElement("button");
challengeButton.type = "button";
challengeButton.className = "btn btn-sm mt-3 challenge-status-button";
challengeButton.textContent = challengeButtonState.label;
challengeButton.disabled = challengeButtonState.disabled;
if (!challengeButtonState.disabled) {
challengeButton.addEventListener("click", () => startChallenge(message, challengeButtonState.role));
}
item.appendChild(challengeButton);
}
messageList.appendChild(item);
});
}
/**
* Baut die HTML-Ergebnisgrafik für eine beendete Challenge zusammen.
* @param {Object} result - Die zusammengeführten Challenge-Daten.
* @returns {HTMLElement} Die erstellte Ergebnis-Komponente.
*/
function createChallengeResultGraphic(result) {
const graphic = document.createElement("div");
graphic.className = "challenge-result-graphic";
const winnerName = result.winner ?? "Unentschieden";
const isDraw = result.winner === null || result.winner === "draw";
const auth = getAuth();
const isOwnWin = !isDraw && normalizeUsername(winnerName) === normalizeUsername(auth?.username);
// Status bestimmen aus Sicht des eingeloggten Users (Sieg / Niederlage / Draw)
const outcome = isDraw ? "draw" : isOwnWin ? "win" : "loss";
const outcomeText = outcome === "draw"
? "Unentschieden"
: outcome === "win"
? "Gewonnen"
: "Verloren";
const outcomeImage = outcome === "draw"
? "image/unentschieden.png"
: outcome === "win"
? "image/sieg.png"
: "image/verloren.png";
const outcomeHeader = document.createElement("div");
outcomeHeader.className = "challenge-outcome challenge-outcome-" + outcome;
const outcomeImg = document.createElement("img");
outcomeImg.src = outcomeImage;
outcomeImg.alt = outcomeText;
const outcomeLabel = document.createElement("strong");
outcomeLabel.textContent = outcomeText;
outcomeHeader.append(outcomeImg, outcomeLabel);
const headline = document.createElement("div");
headline.className = "challenge-result-headline " + (isDraw ? "challenge-result-draw" : "challenge-result-win");
headline.textContent = isDraw ? "Unentschieden" : "Sieger: " + winnerName;
const scores = document.createElement("div");
scores.className = "challenge-result-scores";
const challenger = document.createElement("div");
challenger.className = "challenge-result-score";
challenger.innerHTML = "<strong></strong><span></span>";
challenger.querySelector("strong").textContent = result.challenger ?? "Herausforderer";
challenger.querySelector("span").textContent = String(result.challengerScore ?? "-") + " Punkte";
const opponent = document.createElement("div");
opponent.className = "challenge-result-score";
opponent.innerHTML = "<strong></strong><span></span>";
opponent.querySelector("strong").textContent = result.opponent ?? "Gegner";
opponent.querySelector("span").textContent = String(result.opponentScore ?? "-") + " Punkte";
scores.append(challenger, opponent);
graphic.append(outcomeHeader, headline, scores);
return graphic;
}
/**
* Lädt die Liste aller registrierten Benutzer vom Server.
*/
async function loadUsers() {
const auth = getAuth();
const userService = getUserService();
if (!auth || !userService || typeof userService.getUsers !== "function") {
renderUserList([]);
return;
}
const result = await userService.getUsers(auth.username, auth.password);
if (!result.ok || !Array.isArray(result.body)) {
renderUserList([]);
setFeedback("User konnten nicht geladen werden. Backend-Endpunkt GET /users fehlt eventuell noch.", "warning");
return;
}
renderUserList(result.body);
}
/**
* Lädt alle Nachrichten vom Backend und aktualisiert die Ansichten.
* Schaltet bei fehlendem Login auf den Platzhalter-Zustand um.
* @param {Object} options - Z. B. { showFeedback: true } für Aktualisierungsmeldung.
*/
async function loadMessages(options = {}) {
const auth = getAuth();
const messageService = getMessageService();
const loggedInDiv = document.getElementById("messages-content");
const loggedOutDiv = document.getElementById("messages-login-placeholder");
// Falls nicht angemeldet: Platzhalter einblenden, Daten leeren
if (!auth) {
if (loggedInDiv) loggedInDiv.classList.add("d-none");
if (loggedOutDiv) loggedOutDiv.classList.remove("d-none");
currentMessages = [];
renderMessages();
updateMessagesNavState();
setFormEnabled(false);
return;
}
// Falls angemeldet: Dashboard zeigen
if (loggedInDiv) loggedInDiv.classList.remove("d-none");
if (loggedOutDiv) loggedOutDiv.classList.add("d-none");
if (!messageService) {
setFormEnabled(false);
setFeedback("Message-Service konnte nicht geladen werden.", "danger");
return;
}
const result = await messageService.getMessages(auth.username, auth.password);
if (!result.ok || !Array.isArray(result.body)) {
currentMessages = [];
renderMessages();
updateMessagesNavState();
setFormEnabled(false);
setFeedback("Nachrichten konnten nicht geladen werden. Backend-Endpunkt GET /messages fehlt eventuell noch.", "warning");
return;
}
// Nachrichten normalisieren, im Cache sichern und rendern
currentMessages = result.body.map(normalizeMessage);
renderMessages();
updateMessagesNavState();
setFormEnabled(true);
if (options.showFeedback) {
setFeedback("Nachrichten wurden aktualisiert.", "success");
}
}
/**
* Erstellt ein neues Duell (Challenge). Generiert den Spieltext,
* bettet ihn als JSON ein und sendet die Nachricht ab.
*/
async function handleChallengeSubmit(event) {
event.preventDefault();
const auth = getAuth();
const challengeService = getChallengeService();
const recipientSelect = document.getElementById("challenge-recipient");
const textInput = document.getElementById("challenge-text");
if (!auth || !challengeService || !recipientSelect || !textInput) {
setFeedback("Bitte zuerst einloggen, um Challenges zu senden.", "warning");
return;
}
const recipient = recipientSelect.value;
const text = textInput.value.trim();
if (!recipient || !text) {
setFeedback("Bitte Empfaenger und Nachricht eingeben.", "warning");
return;
}
// Challenge-Satz generieren
const challengeText = generateChallengeText();
// JSON-Metadaten einbetten
const challengeMessage = buildEmbeddedChallengeText(
{ challengeText: challengeText },
text,
);
const result = await challengeService.postChallenge(
auth.username,
auth.password,
recipient,
challengeMessage,
);
if (!result.ok) {
setFeedback("Challenge konnte nicht gesendet werden (Status " + result.status + ").", "danger");
return;
}
// Reste im Storage säubern
sessionStorage.removeItem(ACTIVE_CHALLENGE_STORAGE_KEY);
setFeedback(
"Challenge an " + recipient + " wurde gesendet. Der Gegner spielt zuerst; danach bekommst du sein Resultat.",
"success",
);
textInput.value = ""; // Textfeld leeren
await loadMessages({ showFeedback: false });
}
/**
* Markiert alle Nachrichten auf einmal als gelesen.
*/
async function handleMarkRead() {
const auth = getAuth();
const messageService = getMessageService();
if (!auth || !messageService) {
setFeedback("Bitte zuerst einloggen, um Nachrichten zu markieren.", "warning");
return;
}
const result = await messageService.markAllMessagesAsRead(auth.username, auth.password);
if (!result.ok) {
// Fallback: Einzeln nacheinander als gelesen markieren, falls die Bulk-API fehlschlägt
const unreadMessages = currentMessages.filter((message) => !message.read);
const readResults = await Promise.all(
unreadMessages.map((message) =>
messageService.markMessageAsRead(auth.username, auth.password, message.id),
),
);
if (readResults.some((readResult) => !readResult.ok)) {
setFeedback("Nachrichten konnten nicht als gelesen markiert werden.", "danger");
return;
}
}
setFeedback("Alle Nachrichten wurden als gelesen markiert.", "info");
await loadMessages({ showFeedback: false });
}
/**
* Initialisiert die Nachrichten-Seite (Events binden).
* Wird von navigation.js nach dem Laden von messages.html aufgerufen.
*/
async function initMessagesPage() {
const challengeForm = document.getElementById("challenge-form");
const markReadButton = document.getElementById("mark-read-button");
const refreshMessagesButton = document.getElementById("refresh-messages-button");
if (challengeForm) {
challengeForm.addEventListener("submit", handleChallengeSubmit);
}
if (markReadButton) {
markReadButton.addEventListener("click", handleMarkRead);
}
if (refreshMessagesButton) {
refreshMessagesButton.addEventListener("click", () => loadMessages({ showFeedback: true }));
}
setFormEnabled(Boolean(getAuth()));
await loadUsers();
await loadMessages({ showFeedback: false });
}
// Initialisierungsfunktion global registrieren
window.initMessagesPage = initMessagesPage;
// Statusaktualisierung global anbieten
window.updateMessagesNavState = function () {
loadMessages({ showFeedback: false }).catch((error) => {
console.error("Nachrichtenstatus konnte nicht geladen werden:", error);
updateMessagesNavState([]);
});
};
// Polling starten, um im Hintergrund alle 30s den Status (z.B. ungelesene Nachrichten) zu prüfen
document.addEventListener("DOMContentLoaded", () => {
window.updateMessagesNavState();
if (!messagePollingInterval) {
messagePollingInterval = window.setInterval(
window.updateMessagesNavState,
MESSAGE_POLL_INTERVAL_MS,
);
}
});
})();
;