1189 lines
40 KiB
JavaScript
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,
|
|
);
|
|
}
|
|
});
|
|
})();
|
|
;
|