(function () { const MESSAGE_TYPE_CHALLENGE = "challenge"; const MESSAGE_TYPE_CHALLENGE_RESULT = "challenge-result"; const MESSAGE_POLL_INTERVAL_MS = 30000; const ACTIVE_CHALLENGE_STORAGE_KEY = "loremIpsumActiveChallenge"; const CHALLENGE_DATA_PREFIX = "[[loremIpsumChallenge:"; let currentMessages = []; let currentUsers = []; let messagePollingInterval = null; 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; } function normalizeUsername(username) { return String(username || "").trim().toLowerCase(); } function getMessageService() { if (!window.config || !window.MessageService) { return null; } return new window.MessageService(window.config); } function getUserService() { if (!window.config || !window.UserService) { return null; } return new window.UserService(window.config); } function getChallengeService() { if (!window.config || !window.ChallengeService) { return null; } return new window.ChallengeService(window.config); } function normalizeMessage(message) { // Das Backend und unser Nachrichten-Fallback liefern Challenge-Daten leicht unterschiedlich. // Hier werden beide Formen in ein einheitliches Message-Objekt gebracht. const embeddedChallenge = extractEmbeddedChallenge(message.text ?? ""); const type = message.type ?? MESSAGE_TYPE_CHALLENGE; const challenge = message.challenge ?? message.result ?? embeddedChallenge.challenge ?? (type === MESSAGE_TYPE_CHALLENGE || type === MESSAGE_TYPE_CHALLENGE_RESULT ? message : null); return { id: message.id, sender: message.sender ?? message.from ?? "", recipient: message.recipient ?? message.to ?? "", type: type, text: embeddedChallenge.text, read: Boolean(message.read), createdAt: message.createdAt ?? message.time ?? message.date ?? "", challenge: challenge, }; } function extractEmbeddedChallenge(text) { // Fallback fuer Challenge-Daten, die im Nachrichtentext mitgesendet werden. // So koennen wir bestehende Message-Endpunkte nutzen, ohne neue Backend-Felder zu verlangen. const rawText = String(text ?? ""); if (!rawText.startsWith(CHALLENGE_DATA_PREFIX)) { return { text: rawText, challenge: null, }; } const endIndex = rawText.indexOf("]]"); if (endIndex === -1) { return { text: rawText, challenge: null, }; } const json = rawText.slice(CHALLENGE_DATA_PREFIX.length, endIndex); const displayText = rawText.slice(endIndex + 2).trim(); try { return { text: displayText, challenge: JSON.parse(json), }; } catch { return { text: rawText, challenge: null, }; } } function getChallengeId(challenge) { return challenge?.challengeId ?? challenge?.challenge_id ?? challenge?.id ?? null; } function getChallengeChallenger(challenge, fallbackName) { return challenge?.challenger ?? challenge?.challengerName ?? challenge?.sender ?? challenge?.from ?? fallbackName ?? ""; } function getChallengeOpponent(challenge, fallbackName) { return challenge?.opponent ?? challenge?.opponentName ?? challenge?.challengedUser ?? challenge?.recipient ?? challenge?.to ?? fallbackName ?? ""; } function getOpponentScore(challenge) { return challenge?.opponentScore ?? challenge?.challengedScore ?? challenge?.challengedUserScore ?? null; } function hasScore(value) { return value !== null && value !== undefined && value !== ""; } 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; } 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); } function canChallengerPlayChallenge(message) { const role = getChallengeRole(message); const challenge = message.challenge; return role === "challenger" && hasScore(getOpponentScore(challenge)) && !hasScore(challenge.challengerScore); } function getChallengeButtonState(message) { // Bestimmt den sichtbaren Status einer Challenge aus Sicht des eingeloggten Users. // Dadurch gibt es pro Challenge genau einen aktiven oder deaktivierten Button. 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); if (challengerHasScore && opponentHasScore) { return { disabled: true, label: "Challenge erledigt", }; } if (role === "opponent") { if (!opponentHasScore && !challengerHasScore) { return { disabled: false, label: "Challenge annehmen", role: "opponent", }; } return { disabled: true, label: "Schon gespielt", }; } if (role === "challenger") { if (opponentHasScore && !challengerHasScore) { return { disabled: false, label: "Challenge spielen", role: "challenger", }; } return { disabled: true, label: "Warte auf Gegner", }; } return null; } function startChallenge(message, role) { // Der aktive Challenge-Kontext wird nur fuer die naechste Spielrunde im Session Storage abgelegt. // play.js liest diesen Zustand aus und weiss dadurch, ob es die erste oder finale Runde ist. 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; sessionStorage.setItem( ACTIVE_CHALLENGE_STORAGE_KEY, JSON.stringify({ id: getChallengeId(challenge), role: role, challenger: challenger, opponent: otherUser, opponentScore: role === "challenger" ? getOpponentScore(challenge) : null, ownUsername: auth?.username ?? "", }), ); if (typeof window.loadPage === "function") { window.loadPage("play", "nav-play"); } } function normalizeUser(user) { if (typeof user === "string") { return user; } return user?.name ?? user?.username ?? ""; } 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", }); } 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"); } 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; }); } function updateMessagesNavState(messages = currentMessages) { const navLink = document.getElementById("navbar-messages"); if (!navLink) { return; } const auth = getAuth(); const ownName = normalizeUsername(auth?.username); const hasUnreadMessages = messages.some((message) => { const isIncoming = normalizeUsername(message.recipient) === ownName || normalizeUsername(message.sender) !== ownName; return isIncoming && !message.read; }); navLink.classList.toggle("has-unread-messages", hasUnreadMessages); } 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; } 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) { message.read = false; item.classList.add("message-item-unread"); updateMessagesNavState(currentMessages); setFeedback("Nachricht konnte nicht als gelesen markiert werden.", "warning"); } } async function markMessageGroupReadOnClick(messages, item) { // Eine gruppierte Challenge-Karte kann mehrere einzelne Nachrichten enthalten. // Beim Anklicken werden deshalb alle ungelesenen eingehenden Nachrichten der Gruppe markiert. const auth = getAuth(); const messageService = getMessageService(); if (!auth || !messageService) { return; } 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; } unreadIncomingMessages.forEach((message) => { message.read = true; }); item.classList.remove("message-item-unread"); updateMessagesNavState(currentMessages); const results = await Promise.all( unreadIncomingMessages.map((message) => messageService.markMessageAsRead(auth.username, auth.password, message.id), ), ); if (results.some((result) => !result.ok)) { unreadIncomingMessages.forEach((message) => { message.read = false; }); item.classList.add("message-item-unread"); updateMessagesNavState(currentMessages); setFeedback("Challenge-Nachrichten konnten nicht als gelesen markiert werden.", "warning"); } } function getMessageTimeValue(message) { const time = new Date(message.createdAt).getTime(); return Number.isNaN(time) ? 0 : time; } function mergeChallengeData(messages) { // Fuer die Challenge-Karte werden Teildaten aus mehreren Nachrichten zusammengefuehrt // (z. B. zuerst Challenge-ID, spaeter Gegner-Score, am Schluss Gewinner). return messages .slice() .sort((a, b) => getMessageTimeValue(a) - getMessageTimeValue(b)) .reduce((merged, message) => { if (!message.challenge) { return merged; } Object.keys(message.challenge).forEach((key) => { const value = message.challenge[key]; if (value !== null && value !== undefined && value !== "") { merged[key] = value; } }); return merged; }, {}); } function groupMessagesByChallenge(messages) { // Nachrichten mit gleicher Challenge-ID werden zu einer Karte gruppiert, // damit alte Zwischen-Nachrichten nicht einzeln die Inbox ueberladen. const groupsByChallenge = new Map(); const standaloneGroups = []; messages.forEach((message) => { const challengeId = getChallengeId(message.challenge); if (challengeId === null || challengeId === undefined) { standaloneGroups.push({ kind: "message", latest: message, messages: [message], }); return; } const key = String(challengeId); if (!groupsByChallenge.has(key)) { groupsByChallenge.set(key, []); } groupsByChallenge.get(key).push(message); }); const challengeGroups = Array.from(groupsByChallenge.values()).map((groupMessages) => { const sortedMessages = groupMessages .slice() .sort((a, b) => getMessageTimeValue(b) - getMessageTimeValue(a)); const latest = { ...sortedMessages[0] }; latest.challenge = mergeChallengeData(sortedMessages); return { kind: "challenge", latest: latest, messages: sortedMessages, }; }); return standaloneGroups .concat(challengeGroups) .sort((a, b) => getMessageTimeValue(b.latest) - getMessageTimeValue(a.latest)); } 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); 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; } uniqueUsers.forEach((username) => { const userButton = document.createElement("button"); userButton.type = "button"; userButton.className = "messages-user-button"; userButton.textContent = username; 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); }); } function renderMessages(messages = currentMessages) { // Rendert normale Nachrichten einzeln und Challenge-Nachrichten als gruppierte Karten. 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; } groupMessagesByChallenge(messages) .forEach((messageGroup) => { const message = messageGroup.latest; const item = document.createElement("article"); const isOutgoing = normalizeUsername(message.sender) === ownName; 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"); item.addEventListener("click", () => markMessageGroupReadOnClick(messageGroup.messages, item)); } 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); 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(); const isCollapsed = thread.classList.toggle("d-none"); toggleButton.textContent = isCollapsed ? "Verlauf anzeigen" : "Verlauf ausblenden"; }); item.appendChild(toggleButton); item.appendChild(thread); } if (message.challenge && hasScore(message.challenge.challengerScore) && hasScore(message.challenge.opponentScore)) { item.appendChild(createChallengeResultGraphic(message.challenge)); } 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); }); } function createChallengeResultGraphic(result) { // Baut die kompakte Ergebnisgrafik mit passendem Bild aus Sicht des aktuellen Users. 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); 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 = ""; 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 = ""; 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; } 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); } async function loadMessages(options = {}) { // Laedt Nachrichten vom Backend und blendet die Seite bei fehlendem Login in einen Hinweiszustand. const auth = getAuth(); const messageService = getMessageService(); const loggedInDiv = document.getElementById("messages-content"); const loggedOutDiv = document.getElementById("messages-login-placeholder"); if (!auth) { if (loggedInDiv) loggedInDiv.classList.add("d-none"); if (loggedOutDiv) loggedOutDiv.classList.remove("d-none"); currentMessages = []; renderMessages(); updateMessagesNavState(); setFormEnabled(false); return; } 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; } currentMessages = result.body.map(normalizeMessage); renderMessages(); updateMessagesNavState(); setFormEnabled(true); if (options.showFeedback) { setFeedback("Nachrichten wurden aktualisiert.", "success"); } } async function handleChallengeSubmit(event) { // Erstellt eine Challenge. Der Gegner spielt zuerst; der weitere Ablauf wird ueber Nachrichten gesteuert. 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; } const result = await challengeService.postChallenge( auth.username, auth.password, recipient, text, ); if (!result.ok) { setFeedback("Challenge konnte nicht gesendet werden (Status " + result.status + ").", "danger"); return; } sessionStorage.removeItem(ACTIVE_CHALLENGE_STORAGE_KEY); setFeedback( "Challenge an " + recipient + " wurde gesendet. Der Gegner spielt zuerst; danach bekommst du sein Resultat.", "success", ); await loadMessages({ showFeedback: false }); } 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) { 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 }); } 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 }); } window.initMessagesPage = initMessagesPage; window.updateMessagesNavState = function () { loadMessages({ showFeedback: false }).catch((error) => { console.error("Nachrichtenstatus konnte nicht geladen werden:", error); updateMessagesNavState([]); }); }; document.addEventListener("DOMContentLoaded", () => { window.updateMessagesNavState(); if (!messagePollingInterval) { messagePollingInterval = window.setInterval( window.updateMessagesNavState, MESSAGE_POLL_INTERVAL_MS, ); } }); })();