(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,
);
}
});
})();