Files
chatroom/resources/js/chat-room/chat-events.js
T

597 lines
23 KiB
JavaScript
Raw Normal View History

// 聊天室 WebSocket 事件监听,从 Blade 内联脚本 scripts.blade.php 迁移至 Vite 模块。
// 所有事件委托通过 window.addEventListener 注册,依赖 window.chatState 共享状态。
import { escapeHtml, normalizeSafeChatUrl } from "./html.js";
import { normalizeDailyStatus } from "./preferences-status.js";
import { enqueueChatMessage } from "./message-renderer.js";
2026-04-30 15:19:38 +08:00
import { isAutoScrollEnabled, scrollChatToBottom } from "./message-utils.js";
// ── 事件注册标记 ──
let chatEventsBound = false;
let chatWebSocketInitRetryTimer = null;
const GOMOKU_INVITE_BUTTON_FONT_SIZE = "0.82em";
// ── 辅助函数 ──
function csrf() {
return document.querySelector('meta[name="csrf-token"]')?.content ?? "";
}
function getState() {
return window.chatState;
}
2026-04-30 15:19:38 +08:00
/**
* 在开启自动滚屏时把指定聊天窗格滚动到底部。
*
* @param {HTMLElement|null|undefined} container 聊天消息容器
* @returns {void}
*/
function scrollWhenEnabled(container) {
scrollChatToBottom(container, isAutoScrollEnabled);
}
/**
* 启动 WebSocket 初始化(DOMContentLoaded 之后调用)。
*/
function initChatWebSocket() {
if (chatWebSocketInitRetryTimer) {
window.clearTimeout(chatWebSocketInitRetryTimer);
chatWebSocketInitRetryTimer = null;
}
if (typeof window.initChat === "function" && window.chatContext?.roomId) {
window.initChat(window.chatContext.roomId);
return;
}
// chat.js 会在模块末尾才把 initChat 挂到 window;若这里抢先执行,稍后自动补一次初始化。
chatWebSocketInitRetryTimer = window.setTimeout(() => {
chatWebSocketInitRetryTimer = null;
initChatWebSocket();
}, 100);
}
/**
* 在 DOM 已就绪时立即执行回调,避免 Vite 模块晚于 DOMContentLoaded 执行时漏掉初始化。
*
* @param {() => void} callback 页面就绪后的回调
* @returns {void}
*/
function runWhenDomReady(callback) {
if (typeof callback !== "function") {
return;
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", callback, { once: true });
return;
}
callback();
}
2026-05-05 21:48:51 +08:00
/**
* 判断 Toast 通知是否需要对当前用户隐藏。
*
* @param {Record<string, any>} toastNotification 右下角通知载荷
* @returns {boolean}
*/
function shouldSkipToastForCurrentUser(toastNotification) {
if (!toastNotification?.skip_for_actor) {
return false;
}
return String(toastNotification.actor_username || "") === String(window.chatContext?.username || "");
}
2026-05-05 21:57:24 +08:00
/**
* 判断当前是否为手机浏览器视口。
*
* @returns {boolean}
*/
function isMobileToastViewport() {
return window.matchMedia?.("(max-width: 640px)")?.matches === true;
}
/**
* 手机端只隐藏无本人关联的公屏全局 Toast。
*
* @param {Record<string, any>} message 聊天消息载荷
* @param {Record<string, any>} toastNotification 右下角通知载荷
* @returns {boolean}
*/
function shouldHideGlobalToastOnMobile(message, toastNotification) {
if (!isMobileToastViewport() || message?.to_user !== '大家') {
return false;
}
const currentUsername = String(window.chatContext?.username || "");
const actorUsername = String(toastNotification?.actor_username || "");
const targetUsername = String(toastNotification?.target_username || "");
return actorUsername !== currentUsername && targetUsername !== currentUsername;
}
// ── 禁言逻辑 ──
function handleMutedEvent(e) {
const state = getState();
const d = e.detail;
const now = new Date();
const timeStr = now.getHours().toString().padStart(2, "0") + ":" +
now.getMinutes().toString().padStart(2, "0") + ":" +
now.getSeconds().toString().padStart(2, "0");
const isMe = d.username === window.chatContext?.username;
const div = document.createElement("div");
div.className = "msg-line";
div.innerHTML = `<span style="color: #c00; font-weight: bold;">【系统】${d.message}</span><span class="msg-time">(${timeStr})</span>`;
const targetContainer = isMe
? document.getElementById("say2")
: (state?.container);
if (targetContainer) {
targetContainer.appendChild(div);
2026-04-30 15:19:38 +08:00
scrollWhenEnabled(targetContainer);
}
if (isMe && d.mute_time > 0) {
state.isMutedUntil = Date.now() + d.mute_time * 60 * 1000;
const contentInput = document.getElementById("content");
const operatorName = d.operator || "管理员";
if (contentInput) {
contentInput.placeholder = `${operatorName} 已将您禁言 ${d.mute_time} 分钟,解禁后方可发言...`;
contentInput.disabled = true;
setTimeout(() => {
state.isMutedUntil = 0;
contentInput.placeholder = "在这里输入聊天内容,按 Enter 发送...";
contentInput.disabled = false;
const unmuteDiv = document.createElement("div");
unmuteDiv.className = "msg-line";
unmuteDiv.innerHTML = '<span style="color: #16a34a; font-weight: bold;">【系统】您的禁言已解除,可以继续发言了。</span>';
const say2 = document.getElementById("say2");
if (say2) {
say2.appendChild(unmuteDiv);
2026-04-30 15:19:38 +08:00
scrollWhenEnabled(say2);
}
}, d.mute_time * 60 * 1000);
}
}
}
// ── Echo 级监听器 ──
/**
* 注册全员清屏监听(ScreenCleared)。
*/
function setupScreenClearedListener() {
if (!window.Echo || !window.chatContext) {
setTimeout(setupScreenClearedListener, 500);
return;
}
window.Echo.join(`room.${window.chatContext.roomId}`)
.listen("ScreenCleared", (e) => {
const operator = e.operator;
const safeOperator = escapeHtml(String(operator || ""));
const say1 = document.getElementById("chat-messages-container");
if (say1) say1.innerHTML = "";
const say2 = document.getElementById("chat-messages-container2");
if (say2) {
const items = say2.querySelectorAll(".msg-line");
items.forEach((item) => {
if (!item.querySelector(".msg-secret")) {
item.remove();
}
});
}
const state = getState();
if (state) {
state.lastAutosaveNode = say2?.querySelector('[data-autosave="1"]') || null;
}
const sysDiv = document.createElement("div");
sysDiv.className = "msg-line";
const now = new Date();
const timeStr = now.getHours().toString().padStart(2, "0") + ":" +
now.getMinutes().toString().padStart(2, "0") + ":" +
now.getSeconds().toString().padStart(2, "0");
sysDiv.innerHTML = `<span style="color: #dc2626; font-weight: bold;">🧹 管理员 <b>${safeOperator}</b> 已执行全员清屏</span><span class="msg-time">(${timeStr})</span>`;
if (say1) {
say1.appendChild(sysDiv);
2026-04-30 15:19:38 +08:00
scrollWhenEnabled(say1);
}
});
}
/**
* 注册房间级"刷新全员"监听(BrowserRefreshRequested)。
*/
function setupRoomBrowserRefreshListener() {
if (!window.Echo || !window.chatContext) {
setTimeout(setupRoomBrowserRefreshListener, 500);
return;
}
window.Echo.join(`room.${window.chatContext.roomId}`)
.listen("BrowserRefreshRequested", (e) => {
window.dispatchEvent(
new CustomEvent("chat:browser-refresh-requested", { detail: e })
);
});
}
/**
* 注册开发日志发布通知监听(仅 Room 1)。
*/
function setupChangelogPublishedListener() {
if (!window.Echo || !window.chatContext) {
setTimeout(setupChangelogPublishedListener, 500);
return;
}
if (window.chatContext.roomId !== 1) return;
window.Echo.join("room.1")
.listen(".ChangelogPublished", (e) => {
const now = new Date();
const timeStr = now.getHours().toString().padStart(2, "0") + ":" +
now.getMinutes().toString().padStart(2, "0") + ":" +
now.getSeconds().toString().padStart(2, "0");
const safeVersion = e.safe_version ?? escapeHtml(String(e.version ?? ""));
const safeTitle = e.safe_title ?? escapeHtml(String(e.title ?? ""));
const changelogRoute = window.chatContext?.changelogUrl || "/changelog";
const safeUrl = escapeHtml(normalizeSafeChatUrl(e.url, changelogRoute));
const sysDiv = document.createElement("div");
sysDiv.className = "msg-line";
sysDiv.style.cssText =
"background: #fffbeb; border-left: 3px solid #d97706; border-radius: 4px; padding: 5px 10px; margin: 3px 0;";
sysDiv.innerHTML = `<span style="color: #b45309; font-weight: bold;">
📋 【版本更新】v${safeVersion} · ${safeTitle}
<a href="${safeUrl}" target="_blank" rel="noopener"
style="color: #7c3aed; text-decoration: underline; margin-left: 8px; font-size: 0.85em;">
查看详情 →
</a>
</span><span class="msg-time">(${timeStr})</span>`;
const say1 = document.getElementById("chat-messages-container");
if (say1) {
say1.appendChild(sysDiv);
2026-04-30 15:19:38 +08:00
scrollWhenEnabled(say1);
}
});
}
/**
* 注册五子棋 PvP 邀请通知监听。
*/
function setupGomokuInviteListener() {
if (!window.Echo || !window.chatContext) {
setTimeout(setupGomokuInviteListener, 500);
return;
}
window.Echo.join(`room.${window.chatContext.roomId}`)
.listen(".gomoku.invite", (e) => {
const now = new Date();
const timeStr = now.getHours().toString().padStart(2, "0") + ":" +
now.getMinutes().toString().padStart(2, "0") + ":" +
now.getSeconds().toString().padStart(2, "0");
const isSelf = (e.inviter_name === window.chatContext.username);
const div = document.createElement("div");
div.className = "msg-line";
div.style.cssText =
"background:linear-gradient(135deg,#e8eef8,#f0f4fc); border-left:3px solid #336699; border-radius:4px; padding:6px 10px; margin:3px 0;";
const safeInviterName = escapeHtml(e.inviter_name);
const gomokuGameId = Number.parseInt(e.game_id, 10) || 0;
const acceptBtn = isSelf
? `<button type="button" data-gomoku-open-panel class="gomoku-invite-open"
style="margin-left:10px; padding:3px 12px; border:1.5px solid #2d6096;
border-radius:12px; background:#f0f6ff; color:#2d6096; font-size:${GOMOKU_INVITE_BUTTON_FONT_SIZE};
cursor:pointer; font-family:inherit; transition:all .15s;">
⤴️ 打开面板
</button>`
: `<button type="button" data-gomoku-accept-id="${gomokuGameId}" id="gomoku-accept-${gomokuGameId}" class="gomoku-invite-accept"
style="margin-left:10px; padding:3px 12px; border:1.5px solid #336699;
border-radius:12px; background:#336699; color:#fff; font-size:${GOMOKU_INVITE_BUTTON_FONT_SIZE};
cursor:pointer; font-family:inherit; transition:opacity .15s;">
⚔️ 接受挑战
</button>`;
div.innerHTML = `<span style="color:#1e3a5f; font-weight:bold;">
♟️ 【五子棋】<b>${safeInviterName}</b> 发起了随机对战!${isSelf ? "(等待中)" : ""}
</span>${acceptBtn}
<span class="msg-time">(${timeStr})</span>`;
const say1 = document.getElementById("chat-messages-container");
if (say1) {
say1.appendChild(div);
2026-04-30 15:19:38 +08:00
scrollWhenEnabled(say1);
}
if (!isSelf) {
setTimeout(() => {
const btn = document.getElementById(`gomoku-accept-${e.game_id}`);
if (btn) {
btn.textContent = "已超时";
btn.disabled = true;
btn.style.opacity = ".5";
btn.style.cursor = "not-allowed";
}
}, 60000);
}
})
.listen(".gomoku.finished", (e) => {
if (e.mode !== "pvp") return;
const now = new Date();
const timeStr = now.getHours().toString().padStart(2, "0") + ":" +
now.getMinutes().toString().padStart(2, "0") + ":" +
now.getSeconds().toString().padStart(2, "0");
const div = document.createElement("div");
div.className = "msg-line";
div.style.cssText =
"background:#fffae8; border-left:3px solid #d97706; border-radius:4px; padding:5px 10px; margin:2px 0;";
const reason = { win: "获胜", draw: "平局", resign: "认输", timeout: "超时" }[e.reason] || "结束";
let text = "";
if (e.winner === 0) {
text = `♟️ 五子棋对局以<b>平局</b>结束!`;
} else {
text = `♟️ <b>${e.winner_name}</b> 击败 <b>${e.loser_name}</b>${reason})获得 <b style="color:#b45309;">${e.reward_gold}</b> 金币!`;
}
div.innerHTML = `<span style="color:#92400e;">${text}</span><span class="msg-time">(${timeStr})</span>`;
const say1 = document.getElementById("chat-messages-container");
if (say1) {
say1.appendChild(div);
2026-04-30 15:19:38 +08:00
scrollWhenEnabled(say1);
}
});
}
// ── 主事件绑定 ──
/**
* 绑定所有聊天室 WebSocket 事件监听,仅执行一次。
*/
export function bindChatEvents() {
if (chatEventsBound || typeof document === "undefined") {
return;
}
chatEventsBound = true;
// 页面已完成解析时立刻补做初始化,确保 Presence 连接不会因为错过 DOMContentLoaded 而丢失。
runWhenDomReady(initChatWebSocket);
// chat:here — Presence 初始用户列表
window.addEventListener("chat:here", (e) => {
const state = getState();
if (!state) return;
const users = e.detail;
state.onlineUsers = {};
users.forEach((u) => {
window.hydrateOnlineUserPayload(u.username, u);
});
// 注入 AI 小班长
if (window.chatContext?.chatBotEnabled && window.chatContext.botUser) {
window.hydrateOnlineUserPayload("AI小班长", window.chatContext.botUser);
}
// 同步当前用户状态
if (typeof window.setOnlineUserDailyStatus === "function" && typeof window.getCurrentUserDailyStatus === "function") {
window.setOnlineUserDailyStatus(window.chatContext?.username, window.getCurrentUserDailyStatus());
}
if (typeof window.syncDailyStatusUi === "function") {
window.syncDailyStatusUi();
}
window.scheduleRenderUserList(0);
});
// chat:bot-toggled — AI 小班长动态开关
window.addEventListener("chat:bot-toggled", (e) => {
const detail = e.detail;
if (window.chatContext) {
window.chatContext.chatBotEnabled = detail.isOnline;
}
if (detail.isOnline && detail.user && detail.user.username) {
window.hydrateOnlineUserPayload(detail.user.username, detail.user);
if (window.chatContext) window.chatContext.botUser = detail.user;
} else {
const state = getState();
if (state) delete state.onlineUsers["AI小班长"];
if (window.chatContext) window.chatContext.botUser = null;
}
window.scheduleRenderUserList?.();
});
// chat:user-status-updated — 用户每日状态更新
window.addEventListener("chat:user-status-updated", (e) => {
const username = e.detail?.username;
const payload = e.detail?.user;
if (!username || !payload) return;
window.hydrateOnlineUserPayload(username, payload);
if (username === window.chatContext?.username) {
if (window.chatContext) {
window.chatContext.currentDailyStatus = normalizeDailyStatus(payload);
}
if (typeof window.syncDailyStatusUi === "function") {
window.syncDailyStatusUi();
}
}
window.scheduleRenderUserList?.();
});
// chat:joining — 用户进入
window.addEventListener("chat:joining", (e) => {
const user = e.detail;
window.hydrateOnlineUserPayload(user.username, user);
window.scheduleRenderUserList?.();
});
// chat:leaving — 用户离开
window.addEventListener("chat:leaving", (e) => {
const user = e.detail;
const state = getState();
if (state) delete state.onlineUsers[user.username];
window.scheduleRenderUserList?.();
});
// chat:message — 新消息
window.addEventListener("chat:message", (e) => {
const msg = e.detail;
if (msg.is_secret && msg.from_user !== window.chatContext?.username && msg.to_user !== window.chatContext?.username) {
return;
}
enqueueChatMessage(msg);
if (msg.action === "vip_presence" && typeof window.showVipPresenceBanner === "function") {
window.showVipPresenceBanner(msg);
}
// 若消息携带 toast_notification 字段且当前用户是接收者或为公屏广播或为欢迎动作,弹右下角小卡片
if (msg.toast_notification && (msg.to_user === window.chatContext?.username || msg.to_user === '大家' || msg.action === '欢迎')) {
const t = msg.toast_notification;
2026-05-05 21:48:51 +08:00
if (shouldSkipToastForCurrentUser(t)) {
return;
}
2026-05-05 21:57:24 +08:00
if (shouldHideGlobalToastOnMobile(msg, t)) {
return;
}
window.chatToast?.show({
title: t.title || "通知",
message: t.message || "",
icon: t.icon || "💬",
color: t.color || "#336699",
2026-05-05 22:03:18 +08:00
duration: t.duration ?? 3000,
});
}
});
// chat:kicked — 被踢出房间
window.addEventListener("chat:kicked", (e) => {
if (e.detail.username === window.chatContext?.username) {
const roomsIndexUrl = window.chatContext?.roomsIndexUrl || "/rooms";
window.chatDialog?.alert(
"您已被管理员踢出房间!" + (e.detail.reason ? " 原因:" + e.detail.reason : ""),
"系统通知",
"#cc4444"
);
window.location.href = roomsIndexUrl;
}
});
// chat:muted — 禁言事件
window.addEventListener("chat:muted", handleMutedEvent);
// chat:title-updated — 房间标题更新
window.addEventListener("chat:title-updated", (e) => {
const display = document.getElementById("room-title-display");
if (display) display.innerText = e.detail.title;
});
// chat:browser-refresh-requested — 全员刷新通知
window.addEventListener("chat:browser-refresh-requested", (e) => {
const detail = e.detail || {};
const operatorName = escapeHtml(String(detail.operator || "站长"));
const reasonText = escapeHtml(String(detail.reason || "页面功能已更新,请重新载入。"));
window.chatToast?.show({
title: "页面即将刷新",
message: `<b>${operatorName}</b> 通知全员刷新页面。<br><span style="color:#475569;">${reasonText}</span>`,
icon: "♻️",
color: "#0f766e",
duration: 2200,
});
window.setTimeout(() => {
window.location.reload();
}, 900);
});
// chat:user-browser-refresh-requested — 目标用户定向刷新
window.addEventListener("chat:user-browser-refresh-requested", (e) => {
const detail = e.detail || {};
const operatorName = escapeHtml(String(detail.operator || "管理员"));
const reasonText = escapeHtml(String(detail.reason || "你的权限状态已发生变化,页面即将刷新。"));
window.chatToast?.show({
title: "权限同步中",
message: `<b>${operatorName}</b> 已更新你的职务状态。<br><span style="color:#475569;">${reasonText}</span>`,
icon: "🔄",
color: "#7c3aed",
duration: 2600,
});
window.setTimeout(() => {
window.location.reload();
}, 1000);
});
// chat:effect — 全屏特效事件
window.addEventListener("chat:effect", (e) => {
const type = e.detail?.type;
const target = e.detail?.target_username;
const operator = e.detail?.operator;
const myName = window.chatContext?.username;
2026-04-30 10:29:11 +08:00
const effectOptions = {
effect_title: e.detail?.effect_title,
2026-04-30 11:07:46 +08:00
effect_user_info: e.detail?.effect_user_info,
2026-04-30 10:29:11 +08:00
ride_name: e.detail?.ride_name,
operator,
};
if (type && typeof EffectManager !== "undefined") {
if (!target || target === myName || operator === myName) {
2026-04-30 10:29:11 +08:00
EffectManager.play(type, effectOptions);
}
}
});
// chat:pat — 拍一拍事件
window.addEventListener("chat:pat", (e) => {
const { from_user, target_user, display_text, from_user_headface } = e.detail || {};
if (!display_text) return;
if (typeof window.appendPatMessage === "function") {
window.appendPatMessage(display_text, from_user_headface, from_user, target_user);
}
if (typeof window.triggerPatShake === "function") {
window.triggerPatShake();
}
});
// chat:idiom-started — 猜成语出题
window.addEventListener("chat:idiom-started", (e) => {
if (typeof window.handleRiddleGameStarted === "function") {
window.handleRiddleGameStarted(e);
}
});
// chat:idiom-answered — 猜成语答题结果
window.addEventListener("chat:idiom-answered", (e) => {
if (typeof window.handleRiddleGameAnswered === "function") {
window.handleRiddleGameAnswered(e);
}
});
// Echo 级监听器同样要兼容“脚本加载时页面已完成”的场景。
runWhenDomReady(() => {
setupScreenClearedListener();
setupRoomBrowserRefreshListener();
setupChangelogPublishedListener();
setupGomokuInviteListener();
});
}
export { initChatWebSocket };