Files
Haven/public/js/app.js
T

649 lines
46 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ═══════════════════════════════════════════════════════════
// Haven: Main Client Application
// Features: chat, voice, themes, images, multi-server,
// notifications, volume sliders, status bar
// ═══════════════════════════════════════════════════════════
import SocketMethods from './modules/app-socket.js?v=4.21.0';
import SocketChannelMethods from './modules/app-socket-channels.js?v=4.25.8';
import SocketEventMethods from './modules/app-socket-events.js?v=4.24.33';
import UIBindMethods from './modules/app-ui.js?v=4.24.10';
import ComposerMethods from './modules/app-composer.js?v=4.22.22';
import ComposerListMethods from './modules/app-composer-lists.js?v=4.22.22';
import ChannelMenuMethods from './modules/app-channel-menu.js?v=4.25.4';
import VoiceControlMethods from './modules/app-voice-controls.js?v=4.21.0';
import MediaGalleryMethods from './modules/app-media-gallery.js?v=4.24.32';
import MessageActionMethods from './modules/app-message-actions.js?v=4.25.10';
import PipPanelMethods from './modules/app-pip-panels.js?v=4.25.10';
import PeopleMethods from './modules/app-people.js?v=4.23.11';
import SettingsMethods from './modules/app-settings.js?v=4.23.9';
import AdminControlMethods from './modules/app-admin-controls.js?v=4.23.9';
import AdminReauthMethods from './modules/app-admin-reauth.js?v=4.23.8';
import ExtensionUpdateMethods from './modules/app-extension-updates.js?v=4.17.18';
import ServerBarMethods from './modules/app-server-bar.js?v=4.23.9';
import MobileMethods from './modules/app-mobile.js?v=4.24.24';
import MediaMethods from './modules/app-media.js?v=4.24.22';
import ImageZoomMethods from './modules/app-image-zoom.js?v=4.23.12';
import MediaGridMethods from './modules/app-media-grid.js?v=4.25.6';
import FeedAnchorMethods from './modules/app-feed-anchor.js?v=4.24.26';
import AttachmentMethods from './modules/app-attachments.js?v=4.25.6';
import AvatarEditorMethods from './modules/app-avatar-editor.js?v=4.23.5';
import AppearanceMethods from './modules/app-appearance.js?v=4.22.9';
import AccessibilityMethods from './modules/app-accessibility.js?v=4.22.3';
import SoundMethods from './modules/app-sounds.js?v=4.21.0';
import EmojiStickerMethods from './modules/app-emoji-stickers.js?v=4.21.0';
import BotMethods from './modules/app-bots.js?v=4.23.9';
import ContextMethods from './modules/app-context.js?v=4.24.24';
import ChannelMethods from './modules/app-channels.js?v=4.25.8';
import ChannelContextMethods from './modules/app-channel-context.js?v=4.24.9';
import ChannelOrganizeMethods from './modules/app-channel-organize.js?v=4.25.11';
import ChannelUnreadMethods from './modules/app-channel-unread.js?v=4.24.32';
import MessageMethods from './modules/app-messages.js?v=4.25.10';
import LinkPreviewMethods from './modules/app-link-previews.js?v=4.25.13';
import MessageToolMethods from './modules/app-message-tools.js?v=4.25.10';
import MoveToThreadMethods from './modules/app-move-to-thread.js?v=4.24.29';
import UserMethods from './modules/app-users.js?v=4.25.8';
import BirthdayMethods from './modules/app-birthdays.js?v=4.23.11';
import VoiceMethods from './modules/app-voice.js?v=4.24.5';
import ThemePickerMethods from './modules/app-theme-picker.js?v=4.24.18';
import PushToMuteMethods from './modules/app-push-to-mute.js?v=4.24.5';
import ScreenWebcamMethods from './modules/app-screen-webcam.js?v=4.24.20';
import StreamTileMethods from './modules/app-stream-tiles.js?v=4.23.9';
import MusicMethods from './modules/app-music.js?v=4.24.19';
import MusicKeyMethods from './modules/app-music-keys.js?v=4.24.19';
import UtilityMethods from './modules/app-utilities.js?v=4.23.9';
import MessageContentMethods from './modules/app-message-content.js?v=4.24.22';
import EmojiPickerMethods from './modules/app-emoji-picker.js?v=4.25.2';
import GifPickerMethods from './modules/app-gif-picker.js?v=4.25.6';
import ThreadMethods from './modules/app-threads.js?v=4.25.10';
import AdminMethods from './modules/app-admin.js?v=4.24.34';
import BrandingMethods from './modules/app-branding.js?v=4.21.0';
import MembersAdminMethods from './modules/app-members-admin.js?v=4.23.7';
import AutocompleteMethods from './modules/app-autocomplete.js?v=4.21.0';
import StatusMethods from './modules/app-status.js?v=4.21.0';
import DiscordImportMethods from './modules/app-discord-import.js?v=4.24.2';
import RoleMethods from './modules/app-roles.js?v=4.24.15';
import RoleAssignMethods from './modules/app-role-assign.js?v=4.24.15';
import ModerationMethods from './modules/app-moderation.js?v=4.24.32';
import PlatformMethods from './modules/app-platform.js?v=4.24.5';
import SearchMethods from './modules/app-search.js?v=4.24.32';
import FerryMethods from './modules/app-ferry.js?v=4.24.10';
import ForumMethods from './modules/app-forum.js?v=4.25.9';
import ForumBlogMethods from './modules/app-forum-blog.js?v=4.25.6';
import ForumVoteMethods from './modules/app-forum-votes.js?v=4.24.3';
import ForumPictureVoteMethods from './modules/app-forum-picture-votes.js?v=4.24.3';
import RoleToolMethods from './modules/app-role-tools.js?v=4.25.16';
import PermMatrixMethods from './modules/app-perm-matrix.js?v=4.25.18';
import CallMethods from './modules/app-calls.js?v=4.22.24';
import ScalingMethods from './modules/app-scaling.js?v=4.14.3';
import GroupMethods from './modules/app-groups.js?v=4.25.5';
import TemplateMethods from './modules/app-templates.js?v=4.18.7';
import MemberDefaultsMethods from './modules/app-member-defaults.js?v=4.22.9';
import MessageHighlightMethods from './modules/app-message-highlights.js?v=4.22.2';
import MessageGroupingMethods from './modules/message-grouping.js?v=4.25.1';
import ModCaseMethods from './modules/app-mod-cases.js?v=4.22.14';
import MasterVolumeMethods from './modules/app-master-volume.js?v=4.23.6';
import ServerMuteMethods from './modules/app-server-mute.js?v=4.24.24';
import ForumUnreadMethods from './modules/app-forum-unread.js?v=4.21.2';
import ThreadUnreadMethods from './modules/app-thread-unread.js?v=4.25.3';
import ThreadDockMethods from './modules/app-thread-dock.js?v=4.25.7';
import TypingMethods from './modules/app-typing.js?v=4.25.8';
import MessageNsfwMethods from './modules/app-message-nsfw.js?v=4.25.10';
import MediaPlaybackMethods from './modules/app-media-playback.js?v=4.25.13';
import PermDraftMethods from './modules/app-perm-draft.js?v=4.25.17';
import ThreadScrollMethods from './modules/app-thread-scroll.js?v=4.24.26';
import ChannelCategoryMethods from './modules/app-channel-categories.js?v=4.22.2';
import ChannelPersonalOrderMethods from './modules/app-channel-personal-order.js?v=4.22.23';
import BadgeMethods from './modules/app-badges.js?v=4.24.15';
import SilentMethods from './modules/app-silent.js?v=4.24.32';
class HavenApp {
constructor() {
this.token = localStorage.getItem('haven_token');
this.user = JSON.parse(localStorage.getItem('haven_user') || 'null');
this.socket = null;
this.voice = null;
this.currentChannel = null;
this.channels = [];
this.lastTypingEmit = 0;
this.unreadCounts = {};
// Per-channel thread @mention list, persisted to localStorage
try { this._threadMentions = JSON.parse(localStorage.getItem('haven_thread_mentions') || '{}'); }
catch { this._threadMentions = {}; }
this.onlineCount = 0;
this.pingInterval = null;
this.serverManager = new ServerManager();
this.notifications = new NotificationManager();
this.replyingTo = null; // message object being replied to
this._threadReplyingTo = null; // thread message being replied to
this._activeThreadParent = null; // currently open thread parent message ID
this._lastMoveSelectedEl = null; // last clicked message in move-selection mode
this._imageQueue = []; // queued images awaiting send
this.channelMembers = []; // for @mention autocomplete
this.mentionQuery = ''; // current partial @mention being typed
this.mentionStart = -1; // cursor position of the '@'
this.editingMsgId = null; // message currently being edited
this.serverSettings = {}; // server-wide settings
this.adminActionTarget = null; // { userId, username, action } for modal
this.highScores = {}; // { flappy: [{user_id, username, score}] }
this.userStatus = 'online'; // current user's status
this.userStatusText = ''; // custom status text
this.idleTimer = null; // auto-away timer
this.voiceCounts = {}; // { channelCode: count } for sidebar voice indicators
this.voiceChannelUsers = {}; // { channelCode: [{id, username}] } for sidebar voice user lists
this.e2e = null; // HavenE2E instance for DM encryption
this._dmPublicKeys = {}; // { userId → jwk } cache for DM partner public keys
this._e2eListenersAttached = false;
this._e2eInitDone = false;
this._e2eWrappingKey = null; // wrapping key kept in memory for cross-device sync
this._pendingKeyReqs = {}; // userId → [resolve] for promise-based partner key fetch
this._pendingE2ENotice = null; // E2E notice text to re-append after message re-render
this._e2eNoKey = new Set(); // DM partners the server has no public key for
this._e2eKeyNotices = new Map(); // partner id -> key-change note shown in their DM this session
this._plainDmOk = new Set(); // DM codes the user agreed to send unencrypted this session
this._dmGateAsking = new Map(); // DM code -> the send question in progress
this._oldestMsgId = null; // oldest message ID in current view (for pagination)
this._noMoreHistory = false; // true when all history has been loaded
this._loadingHistory = false; // prevent concurrent history requests
this._historyBefore = null; // set when requesting older messages
this._nicknames = JSON.parse(localStorage.getItem('haven_nicknames') || '{}'); // client-side nicknames { oderId: name }
// Slash command definitions for autocomplete
this.slashCommands = [
{ cmd: 'shrug', args: t('commands.args.optional_text'), desc: t('commands.description.shrug') },
{ cmd: 'tableflip', args: t('commands.args.optional_text'), desc: t('commands.description.tableflip') },
{ cmd: 'unflip', args: t('commands.args.optional_text'), desc: t('commands.description.unflip') },
{ cmd: 'lenny', args: t('commands.args.optional_text'), desc: t('commands.description.lenny') },
{ cmd: 'disapprove', args: t('commands.args.optional_text'), desc: t('commands.description.disapprove') },
{ cmd: 'me', args: t('commands.args.action'), desc: t('commands.description.me') },
{ cmd: 'spoiler', args: t('commands.args.text'), desc: t('commands.description.spoiler') },
{ cmd: 'tts', args: t('commands.args.text'), desc: t('commands.description.tts') },
{ cmd: 'tts:stop', args: '', desc: t('commands.description.tts_stop') },
{ cmd: 'break', args: t('commands.args.message'), desc: t('commands.description.break') },
{ cmd: 'bbs', args: '', desc: t('commands.description.bbs') },
{ cmd: 'brb', args: '', desc: t('commands.description.brb') },
{ cmd: 'afk', args: '', desc: t('commands.description.afk') },
{ cmd: 'boobs', args: '', desc: '( . Y . )' },
{ cmd: 'butt', args: '', desc: '( . )( . )' },
{ cmd: 'nick', args: t('commands.args.name'), desc: t('commands.description.nick') },
{ cmd: 'clear', args: '', desc: t('commands.description.clear') },
{ cmd: 'flip', args: '', desc: t('commands.description.flip') },
{ cmd: 'roll', args: '[NdN]', desc: t('commands.description.roll') },
{ cmd: 'hug', args: '<@user>', desc: t('commands.description.hug') },
{ cmd: 'wave', args: t('commands.args.optional_text'), desc: t('commands.description.wave') },
{ cmd: 'play', args: t('commands.args.name_or_url'), desc: t('commands.description.play') },
{ cmd: 'gif', args: t('commands.args.query'), desc: t('commands.description.gif') },
{ cmd: 'poll', args: t('commands.args.optional_question'), desc: t('commands.description.poll') },
{ cmd: 'schedule', args: t('commands.args.optional_text'), desc: t('commands.description.schedule') },
{ cmd: 'time', args: t('commands.args.time'), desc: t('commands.description.time') },
];
// Load bot-registered slash commands for autocomplete
this._loadBotCommands();
// Emoji palette organized by category
this.emojiCategories = {
'Smileys': ['😀','😁','😂','🤣','😃','😄','😅','😆','😉','😊','😋','😎','😍','🥰','😘','🙂','🤗','🤩','🤔','😐','🙄','😏','😣','😥','😮','😯','😴','😛','😜','😝','😒','😔','🙃','😲','😤','😭','😢','😱','🥺','😠','😡','🤬','😈','💀','💩','🤡','👻','😺','😸','🫠','🫣','🫢','🫥','🫤','🥹','🥲','😶‍🌫️','🤭','🫡','🤫','🤥','😬','🫨','😵','😵‍💫','🥴','😮‍💨','😤','🥱','😇','🤠','🤑','🤓','😈','👿','🫶','🤧','😷','🤒','🤕','💅'],
'People': ['👋','🤚','✋','🖖','👌','🤌','✌️','🤞','🤟','🤘','🤙','👈','👉','👆','👇','☝️','👍','👎','✊','👊','🤛','🤜','👏','🙌','🤝','🙏','💪','🫡','🫶','💅','💃','🕺','🤳','🖕','🫰','🫳','🫴','👐','🤲','🫱','🫲','🤷','🤦','🙇','💁','🙆','🙅','🤷‍♂️','🤷‍♀️','🙋','🙋‍♂️','🙋‍♀️','🧏','🧑‍🤝‍🧑','👫','👬','👭'],
'Monkeys': ['🙈','🙉','🙊','🐵','🐒','🦍','🦧'],
'Animals': ['🐶','🐱','🐭','🐹','🐰','🦊','🐻','🐼','🐨','🐯','🦁','🐮','🐷','🐸','🐔','🐧','🐦','🦆','🦅','🦉','🐺','🐴','🦄','🐝','🦋','🐌','🐞','🐢','🐍','🐙','🐬','🐳','🦈','🐊','🦖','🦕','🐋','🦭','🦦','🦫','🦥','🐿️','🦔','🦇','🐓','🦃','🦚','🦜','🦢','🦩','🐕','🐈','🐈‍⬛'],
'Faces': ['👀','👁️','👁️‍🗨️','👅','👄','🫦','💋','🧠','🦷','🦴','👃','👂','🦻','🦶','🦵','💀','☠️','👽','🤖','🎃','😺','😸','😹','😻','😼','😽','🙀','😿','😾'],
'Food': ['🍎','🍐','🍊','🍋','🍌','🍉','🍇','🍓','🫐','🍒','🍑','🥭','🍍','🥝','🍅','🥑','🌽','🌶️','🫑','🥦','🧄','🧅','🥕','🍕','🍔','🍟','🌭','🍿','🧁','🍩','🍪','🍰','🎂','🧀','🥚','🥓','🥩','🍗','🌮','🌯','🫔','🥙','🍜','🍝','🍣','🍱','☕','🍺','🍻','🍷','🥤','🧊','🧋','🍵','🥂','🍾','🥃','🍶','🫗','🍸','🍹'],
'Activities':['⚽','🏀','🏈','⚾','🎾','🏐','🎱','🏓','🎮','🕹️','🎲','🧩','🎯','🎳','🎭','🎨','🎼','🎵','🎶','🎸','🥁','🎹','🏆','🥇','🏅','🎪','🎬','🎤','🎧','🎺','🪘','🎻','🪗','🎉','🎊','🎈','🎀','🎗️','🏋️','🤸','🧗','🏄','🏊','🚴','⛷️','🏂','🤺'],
'Travel': ['🚗','🚕','🚀','✈️','🚁','🛸','🚢','🏠','🏢','🏰','🗼','🗽','⛩️','🌋','🏔️','🌊','🌅','🌄','🌉','🎡','🎢','🗺️','🧭','🏖️','🏕️','🌍','🌎','🌏','🛳️','⛵','🚂','🚇','🏎️','🏍️','🛵','🛶'],
'Objects': ['⌚','📱','💻','⌨️','🖥️','💾','📷','🔭','🔬','💡','🔦','📚','📝','✏️','📎','📌','🔑','🔒','🔓','🛡️','⚔️','🔧','💰','💎','📦','🎁','✉️','🔔','🪙','💸','🏷️','🔨','🪛','🧲','🧪','🧫','💊','🩺','🩹','🧬','💬','💭','🗨️','🗯️','📣','📢','🔊','🔇','📰','🗞️','📋','📁','📂','🗂️','📅','📆','🗓️','🖊️','🖋️','✒️','📏','📐','🗑️','👑','💍','👒','🎩','🧢','👓','🕶️','🧳','🌂','☂️'],
'Symbols': ['❤️','🧡','💛','💚','💙','💜','🖤','🤍','🤎','💔','❣️','💕','💞','💓','💗','💖','💝','✨','⭐','🌟','💫','🔥','💯','✅','❌','❗','❓','❕','❔','‼️','⁉️','!','?',',','.','💤','🚫','⚠️','♻️','🏳️','🏴','🎵','➕','➖','➗','💲','♾️','🔴','🟠','🟡','🟢','🔵','🟣','⚫','⚪','🟤','🔶','🔷','🔺','🔻','💠','🔘','🏳️‍🌈','🏴‍☠️','⚡','☀️','🌙','🌈','☁️','❄️','💨','🌪️','☮️','✝️','☪️','🕉️','☯️','✡️','🔯','♈','♉','♊','♋','♌','♍','♎','♏','♐','♑','♒','♓','⛎','🆔','⚛️','🈶','🈚','🈸','🈺','🈷️','🆚','🉐','🈹','🈲','🉑','🈴','🈳','㊗️','㊙️','🈵','🔅','🔆','🔱','📛','♻️','🔰','⭕','✳️','❇️','🔟','🔠','🔡','🔢','🔣','🔤','🆎','🆑','🆒','🆓','ℹ️','🆕','🆖','🅾️','🆗','🅿️','🆘','🆙','🆚','🈁','🈂️','💱','💲','#️⃣','*️⃣','0️⃣','1️⃣','2️⃣','3️⃣','4️⃣','5️⃣','6️⃣','7️⃣','8️⃣','9️⃣','🔟','©️','®️','™️'],
'Flags': [':flag_us:',':us_betsy_ross:',':gadsden:',':flag_gb:',':flag_ca:',':flag_au:',':flag_nz:',':flag_ie:',':flag_fr:',':flag_de:',':flag_it:',':flag_es:',':flag_pt:',':flag_nl:',':flag_be:',':flag_lu:',':flag_ch:',':flag_at:',':flag_dk:',':flag_no:',':flag_se:',':flag_fi:',':flag_is:',':flag_pl:',':flag_ee:',':flag_lv:',':flag_lt:',':flag_cz:',':flag_sk:',':flag_hu:',':flag_ro:',':flag_bg:',':flag_si:',':flag_hr:',':flag_gr:',':flag_al:',':flag_me:',':flag_mk:',':flag_ua:',':flag_tr:',':flag_jp:',':flag_kr:',':flag_tw:',':flag_ph:',':flag_th:',':flag_sg:',':flag_in:',':flag_id:',':flag_my:',':flag_vn:',':flag_mn:',':flag_bd:',':flag_lk:',':flag_np:',':flag_il:',':flag_sa:',':flag_ae:',':flag_qa:',':flag_bh:',':flag_kw:',':flag_om:',':flag_jo:',':flag_eg:',':flag_ma:',':flag_tn:',':flag_mx:',':flag_br:',':flag_ar:',':flag_cl:',':flag_co:',':flag_pe:',':flag_uy:',':flag_ec:',':flag_cr:',':flag_pa:',':flag_gt:',':flag_do:',':flag_jm:',':flag_bs:',':flag_tt:',':flag_za:',':flag_ke:',':flag_ng:',':flag_gh:',':flag_sn:',':flag_rw:',':flag_bw:',':flag_ci:',':flag_tz:']
};
// Flat list for quick access (used by search)
this.emojis = Object.values(this.emojiCategories).flat();
// Emoji name map for search (emoji → keywords)
this.emojiNames = {
'😀':'grinning happy','😁':'beaming grin','😂':'joy tears laughing lol','🤣':'rofl rolling laughing','😃':'smiley happy','😄':'smile happy','😅':'sweat nervous','😆':'laughing satisfied','😉':'wink','😊':'blush happy shy','😋':'yummy delicious','😎':'cool sunglasses','😍':'heart eyes love','🥰':'loving smiling hearts','😘':'kiss blowing','🙂':'slight smile','🤗':'hug hugging open hands','🤩':'starstruck star eyes','🤔':'thinking hmm','😐':'neutral expressionless','🙄':'eye roll','😏':'smirk','😣':'persevere','😥':'sad relieved disappointed','😮':'open mouth wow surprised','😯':'hushed surprised','😴':'sleeping zzz','😛':'tongue playful','😜':'wink tongue crazy','😝':'squinting tongue','😒':'unamused','😔':'pensive sad','🙃':'upside down','😲':'astonished shocked','😤':'triumph huff angry steam','😭':'crying sob loudly','😢':'cry sad tear','😱':'scream fear horrified','🥺':'pleading puppy eyes please','😠':'angry mad','😡':'rage pouting furious','🤬':'cursing swearing angry','😈':'devil smiling imp','💀':'skull dead','💩':'poop poo','🤡':'clown','👻':'ghost boo','😺':'cat smile','😸':'cat grin','🫠':'melting face','🫣':'peeking eye','🫢':'hand over mouth','🫥':'dotted line face','🫤':'diagonal mouth','🥹':'holding back tears','🥲':'smile tear','😶‍🌫️':'face in clouds','🤭':'giggling hand over mouth','🫡':'salute','🤫':'shush quiet secret','🤥':'lying pinocchio','😬':'grimace awkward','🫨':'shaking face','😵':'dizzy','😵‍💫':'face spiral eyes','🥴':'woozy drunk','😮‍💨':'exhale sigh relief','🥱':'yawn tired boring','😇':'angel innocent halo','🤠':'cowboy yeehaw','🤑':'money face rich','🤓':'nerd glasses','👿':'devil angry imp','🫶':'heart hands','🤧':'sneeze sick','😷':'mask sick','🤒':'thermometer sick','🤕':'bandage hurt','💅':'nail polish sassy',
'👋':'wave hello hi bye','🤚':'raised back hand','✋':'hand stop high five','🖖':'vulcan spock','👌':'ok okay perfect','🤌':'pinched italian','✌️':'peace victory','🤞':'crossed fingers luck','🤟':'love you hand','🤘':'rock on metal','🤙':'call me shaka hang loose','👈':'point left','👉':'point right','👆':'point up','👇':'point down','☝️':'index up','👍':'thumbs up like good yes','👎':'thumbs down dislike bad no','✊':'fist bump','👊':'punch fist bump','🤛':'left fist bump','🤜':'right fist bump','👏':'clap applause','🙌':'raising hands celebrate','🤝':'handshake deal','🙏':'pray please thank you namaste','💪':'strong muscle flex bicep','💃':'dancer dancing woman','🕺':'man dancing','🤳':'selfie','🖕':'middle finger','🫰':'pinch','🫳':'palm down','🫴':'palm up','👐':'open hands','🤲':'palms up','🫱':'right hand','🫲':'left hand','🤷':'shrug idk','🤦':'facepalm','🙇':'bow','💁':'info','🙆':'ok gesture','🙅':'no gesture','🙋':'raising hand hi','🧏':'deaf',
'🐶':'dog puppy','🐱':'cat kitty','🐭':'mouse','🐹':'hamster','🐰':'rabbit bunny','🦊':'fox','🐻':'bear','🐼':'panda','🐨':'koala','🐯':'tiger','🦁':'lion','🐮':'cow','🐷':'pig','🐸':'frog','🐔':'chicken','🐧':'penguin','🐦':'bird','🦆':'duck','🦅':'eagle','🦉':'owl','🐺':'wolf','🐴':'horse','🦄':'unicorn','🐝':'bee','🦋':'butterfly','🐌':'snail','🐞':'ladybug','🐢':'turtle','🐍':'snake','🐙':'octopus','🐬':'dolphin','🐳':'whale','🦈':'shark','🐊':'crocodile alligator','🦖':'trex dinosaur','🦕':'dinosaur brontosaurus',
'🍎':'apple red','🍐':'pear','🍊':'orange tangerine','🍋':'lemon','🍌':'banana','🍉':'watermelon','🍇':'grapes','🍓':'strawberry','🍒':'cherry','🍑':'peach','🍍':'pineapple','🍕':'pizza','🍔':'burger hamburger','🍟':'fries french','🌭':'hotdog','🍿':'popcorn','🧁':'cupcake','🍩':'donut','🍪':'cookie','🍰':'cake','🎂':'birthday cake','🧀':'cheese','🥚':'egg','🥓':'bacon','🌮':'taco','🍜':'noodles ramen','🍝':'spaghetti pasta','🍣':'sushi','☕':'coffee','🍺':'beer','🍻':'clinking beers cheers toast','🍷':'wine','🍾':'champagne','🥂':'clinking glasses cheers toast','🥃':'tumbler whiskey bourbon','🍶':'sake','🫗':'pouring liquid','🍸':'cocktail martini','🍹':'tropical drink',
'⚽':'soccer football','🏀':'basketball','🏈':'football american','🎮':'gaming controller video game','🕹️':'joystick arcade','🎲':'dice','🧩':'puzzle jigsaw','🎯':'bullseye target dart','🎨':'art palette paint','🎵':'music note','🎶':'music notes melody song','🎸':'guitar','🏆':'trophy winner','🎧':'headphones music','🎤':'microphone karaoke sing','🎉':'party popper celebration tada','🎊':'confetti ball celebrate','🎈':'balloon party','🎀':'ribbon bow','🎗️':'reminder ribbon',
'🚗':'car automobile','🚀':'rocket space launch','✈️':'airplane plane travel','🏠':'house home','🏰':'castle','🌊':'wave ocean water','🌅':'sunrise','🌍':'globe earth world','🌈':'rainbow',
'❤️':'red heart love','🧡':'orange heart','💛':'yellow heart','💚':'green heart','💙':'blue heart','💜':'purple heart','🖤':'black heart','🤍':'white heart','💔':'broken heart','✨':'sparkles stars','⭐':'star','🔥':'fire hot lit','💯':'hundred perfect','✅':'check mark yes','❌':'cross mark no wrong','❗':'exclamation mark bang','❓':'question mark','❕':'white exclamation','❔':'white question','‼️':'double exclamation bangbang','⁉️':'exclamation question interrobang','!':'exclamation punctuation bang','?':'question punctuation mark',',':'comma punctuation','.':'period punctuation dot','💤':'sleep zzz','⚠️':'warning caution','⚡':'lightning bolt zap','☀️':'sun sunny','🌙':'moon crescent night','❄️':'snowflake cold winter','🌪️':'tornado','🔴':'red circle','🔵':'blue circle','🟢':'green circle','🟡':'yellow circle','🟠':'orange circle','🟣':'purple circle','⚫':'black circle','⚪':'white circle','©️':'copyright','®️':'registered','™️':'trademark','#️⃣':'hash number sign','*️⃣':'asterisk star keycap',
'🙈':'see no evil monkey','🙉':'hear no evil monkey','🙊':'speak no evil monkey',
'👀':'eyes looking','👅':'tongue','👄':'mouth lips','💋':'kiss lips','🧠':'brain smart','🦷':'tooth','🦴':'bone','💀':'skull dead','☠️':'skull crossbones','👽':'alien','🤖':'robot','🎃':'jack o lantern pumpkin halloween',
'📱':'phone mobile','💻':'laptop computer','📷':'camera photo','📚':'books reading','📝':'memo note write','🔑':'key','🔒':'lock locked','💎':'gem diamond jewel','🎁':'gift present','🔔':'bell notification','💰':'money bag rich','🔨':'hammer tool','💬':'speech bubble chat','💭':'thought bubble thinking','🗨️':'speech balloon','🗯️':'anger bubble','📣':'megaphone announcement','📢':'loudspeaker','👑':'crown king queen royal','💍':'ring diamond wedding','🕶️':'sunglasses cool'
};
if (!this.token || !this.user) {
// Preserve invite param so it survives the redirect to the auth page
const _inv = new URLSearchParams(window.location.search).get('invite');
if (_inv) sessionStorage.setItem('haven_pending_invite', _inv);
window.location.href = '/';
return;
}
// Permission helper: true if user is admin or has mod role
this._canModerate = () => this.user.isAdmin || (this.user.effectiveLevel || 0) >= 25;
this._isServerMod = () => this.user.isAdmin || (this.user.effectiveLevel || 0) >= 50;
this._hasPerm = (p) => this.user.isAdmin || (this.user.permissions || []).includes('*') || (this.user.permissions || []).includes(p);
// Whether Settings should offer the Admin tab at all. One list, shared by
// the tab switch, the admin panel container and the settings nav, so a
// permission added in one place cannot be missed in the others. (#5470:
// invite_users was in the nav but not in the tab gate, so holders saw an
// Admin tab that did nothing when clicked.)
// Mirrors the access table in _syncSettingsNav (app-admin.js): anyone who
// can see at least one admin section must also be allowed onto the tab.
this._hasAnyAdminSettingsAccess = () => this.user.isAdmin || [
'manage_emojis', 'manage_stickers', 'manage_soundboard', 'manage_roles',
'manage_server', 'manage_webhooks', 'view_audit_log', 'invite_users',
'ban_user', 'kick_user', 'view_all_members', 'manage_badges'
].some(p => this._hasPerm(p));
// Global-only variant: excludes permissions granted via a channel-scoped
// role assignment, for gating UI that always performs a server-wide
// action (e.g. the sidebar "Create Channel" section always creates a
// top-level channel, regardless of which channel is active). (#5433)
this._hasGlobalPerm = (p) => this.user.isAdmin || (this.user.globalPermissions || []).includes('*') || (this.user.globalPermissions || []).includes(p);
this.customEmojis = []; // [{name, url}], loaded from server
// Bundled image emoji shipped with Haven (rendered like custom emoji but
// built into the app, so every client on this version resolves them).
// Country flags are image-based on purpose: Windows browsers refuse to
// render Unicode regional-indicator flags and fall back to the bare
// two-letter code ("US", "GB", ...), so SVGs keep them consistent
// everywhere. Flag artwork: flag-icons (MIT). See emoji/flags/ATTRIBUTION.txt.
this.builtinEmojis = [
{ name: 'flag_us', url: '/emoji/flags/us.svg', keywords: 'united states america usa flag' },
{ name: 'flag_gb', url: '/emoji/flags/gb.svg', keywords: 'united kingdom britain uk british england flag' },
{ name: 'flag_ca', url: '/emoji/flags/ca.svg', keywords: 'canada canadian flag' },
{ name: 'flag_au', url: '/emoji/flags/au.svg', keywords: 'australia australian flag' },
{ name: 'flag_nz', url: '/emoji/flags/nz.svg', keywords: 'new zealand flag' },
{ name: 'flag_ie', url: '/emoji/flags/ie.svg', keywords: 'ireland irish flag' },
{ name: 'flag_fr', url: '/emoji/flags/fr.svg', keywords: 'france french flag' },
{ name: 'flag_de', url: '/emoji/flags/de.svg', keywords: 'germany german flag' },
{ name: 'flag_it', url: '/emoji/flags/it.svg', keywords: 'italy italian flag' },
{ name: 'flag_es', url: '/emoji/flags/es.svg', keywords: 'spain spanish flag' },
{ name: 'flag_pt', url: '/emoji/flags/pt.svg', keywords: 'portugal portuguese flag' },
{ name: 'flag_nl', url: '/emoji/flags/nl.svg', keywords: 'netherlands dutch holland flag' },
{ name: 'flag_be', url: '/emoji/flags/be.svg', keywords: 'belgium belgian flag' },
{ name: 'flag_lu', url: '/emoji/flags/lu.svg', keywords: 'luxembourg flag' },
{ name: 'flag_ch', url: '/emoji/flags/ch.svg', keywords: 'switzerland swiss flag' },
{ name: 'flag_at', url: '/emoji/flags/at.svg', keywords: 'austria austrian flag' },
{ name: 'flag_dk', url: '/emoji/flags/dk.svg', keywords: 'denmark danish flag' },
{ name: 'flag_no', url: '/emoji/flags/no.svg', keywords: 'norway norwegian flag' },
{ name: 'flag_se', url: '/emoji/flags/se.svg', keywords: 'sweden swedish flag' },
{ name: 'flag_fi', url: '/emoji/flags/fi.svg', keywords: 'finland finnish flag' },
{ name: 'flag_is', url: '/emoji/flags/is.svg', keywords: 'iceland flag' },
{ name: 'flag_pl', url: '/emoji/flags/pl.svg', keywords: 'poland polish flag' },
{ name: 'flag_ee', url: '/emoji/flags/ee.svg', keywords: 'estonia flag' },
{ name: 'flag_lv', url: '/emoji/flags/lv.svg', keywords: 'latvia flag' },
{ name: 'flag_lt', url: '/emoji/flags/lt.svg', keywords: 'lithuania flag' },
{ name: 'flag_cz', url: '/emoji/flags/cz.svg', keywords: 'czech czechia republic flag' },
{ name: 'flag_sk', url: '/emoji/flags/sk.svg', keywords: 'slovakia flag' },
{ name: 'flag_hu', url: '/emoji/flags/hu.svg', keywords: 'hungary flag' },
{ name: 'flag_ro', url: '/emoji/flags/ro.svg', keywords: 'romania flag' },
{ name: 'flag_bg', url: '/emoji/flags/bg.svg', keywords: 'bulgaria flag' },
{ name: 'flag_si', url: '/emoji/flags/si.svg', keywords: 'slovenia flag' },
{ name: 'flag_hr', url: '/emoji/flags/hr.svg', keywords: 'croatia flag' },
{ name: 'flag_gr', url: '/emoji/flags/gr.svg', keywords: 'greece greek flag' },
{ name: 'flag_al', url: '/emoji/flags/al.svg', keywords: 'albania flag' },
{ name: 'flag_me', url: '/emoji/flags/me.svg', keywords: 'montenegro flag' },
{ name: 'flag_mk', url: '/emoji/flags/mk.svg', keywords: 'north macedonia flag' },
{ name: 'flag_ua', url: '/emoji/flags/ua.svg', keywords: 'ukraine ukrainian flag' },
{ name: 'flag_tr', url: '/emoji/flags/tr.svg', keywords: 'turkey turkish flag' },
{ name: 'flag_jp', url: '/emoji/flags/jp.svg', keywords: 'japan japanese flag' },
{ name: 'flag_kr', url: '/emoji/flags/kr.svg', keywords: 'south korea korean flag' },
{ name: 'flag_tw', url: '/emoji/flags/tw.svg', keywords: 'taiwan flag' },
{ name: 'flag_ph', url: '/emoji/flags/ph.svg', keywords: 'philippines filipino flag' },
{ name: 'flag_th', url: '/emoji/flags/th.svg', keywords: 'thailand thai flag' },
{ name: 'flag_sg', url: '/emoji/flags/sg.svg', keywords: 'singapore flag' },
{ name: 'flag_in', url: '/emoji/flags/in.svg', keywords: 'india indian flag' },
{ name: 'flag_id', url: '/emoji/flags/id.svg', keywords: 'indonesia flag' },
{ name: 'flag_my', url: '/emoji/flags/my.svg', keywords: 'malaysia flag' },
{ name: 'flag_vn', url: '/emoji/flags/vn.svg', keywords: 'vietnam flag' },
{ name: 'flag_mn', url: '/emoji/flags/mn.svg', keywords: 'mongolia flag' },
{ name: 'flag_bd', url: '/emoji/flags/bd.svg', keywords: 'bangladesh flag' },
{ name: 'flag_lk', url: '/emoji/flags/lk.svg', keywords: 'sri lanka flag' },
{ name: 'flag_np', url: '/emoji/flags/np.svg', keywords: 'nepal flag' },
{ name: 'flag_il', url: '/emoji/flags/il.svg', keywords: 'israel israeli flag' },
{ name: 'flag_sa', url: '/emoji/flags/sa.svg', keywords: 'saudi arabia flag' },
{ name: 'flag_ae', url: '/emoji/flags/ae.svg', keywords: 'united arab emirates uae flag' },
{ name: 'flag_qa', url: '/emoji/flags/qa.svg', keywords: 'qatar flag' },
{ name: 'flag_bh', url: '/emoji/flags/bh.svg', keywords: 'bahrain flag' },
{ name: 'flag_kw', url: '/emoji/flags/kw.svg', keywords: 'kuwait flag' },
{ name: 'flag_om', url: '/emoji/flags/om.svg', keywords: 'oman flag' },
{ name: 'flag_jo', url: '/emoji/flags/jo.svg', keywords: 'jordan flag' },
{ name: 'flag_eg', url: '/emoji/flags/eg.svg', keywords: 'egypt egyptian flag' },
{ name: 'flag_ma', url: '/emoji/flags/ma.svg', keywords: 'morocco flag' },
{ name: 'flag_tn', url: '/emoji/flags/tn.svg', keywords: 'tunisia flag' },
{ name: 'flag_mx', url: '/emoji/flags/mx.svg', keywords: 'mexico mexican flag' },
{ name: 'flag_br', url: '/emoji/flags/br.svg', keywords: 'brazil brazilian flag' },
{ name: 'flag_ar', url: '/emoji/flags/ar.svg', keywords: 'argentina flag' },
{ name: 'flag_cl', url: '/emoji/flags/cl.svg', keywords: 'chile flag' },
{ name: 'flag_co', url: '/emoji/flags/co.svg', keywords: 'colombia flag' },
{ name: 'flag_pe', url: '/emoji/flags/pe.svg', keywords: 'peru flag' },
{ name: 'flag_uy', url: '/emoji/flags/uy.svg', keywords: 'uruguay flag' },
{ name: 'flag_ec', url: '/emoji/flags/ec.svg', keywords: 'ecuador flag' },
{ name: 'flag_cr', url: '/emoji/flags/cr.svg', keywords: 'costa rica flag' },
{ name: 'flag_pa', url: '/emoji/flags/pa.svg', keywords: 'panama flag' },
{ name: 'flag_gt', url: '/emoji/flags/gt.svg', keywords: 'guatemala flag' },
{ name: 'flag_do', url: '/emoji/flags/do.svg', keywords: 'dominican republic flag' },
{ name: 'flag_jm', url: '/emoji/flags/jm.svg', keywords: 'jamaica flag' },
{ name: 'flag_bs', url: '/emoji/flags/bs.svg', keywords: 'bahamas flag' },
{ name: 'flag_tt', url: '/emoji/flags/tt.svg', keywords: 'trinidad tobago flag' },
{ name: 'flag_za', url: '/emoji/flags/za.svg', keywords: 'south africa flag' },
{ name: 'flag_ke', url: '/emoji/flags/ke.svg', keywords: 'kenya flag' },
{ name: 'flag_ng', url: '/emoji/flags/ng.svg', keywords: 'nigeria flag' },
{ name: 'flag_gh', url: '/emoji/flags/gh.svg', keywords: 'ghana flag' },
{ name: 'flag_sn', url: '/emoji/flags/sn.svg', keywords: 'senegal flag' },
{ name: 'flag_rw', url: '/emoji/flags/rw.svg', keywords: 'rwanda flag' },
{ name: 'flag_bw', url: '/emoji/flags/bw.svg', keywords: 'botswana flag' },
{ name: 'flag_ci', url: '/emoji/flags/ci.svg', keywords: 'ivory coast cote divoire flag' },
{ name: 'flag_tz', url: '/emoji/flags/tz.svg', keywords: 'tanzania flag' },
{ name: 'us_betsy_ross', url: '/emoji/us-betsy-ross.svg', keywords: 'betsy ross flag united states america usa stars stripes colonies historical patriotic' },
{ name: 'gadsden', url: '/emoji/us-gadsden.svg', keywords: 'gadsden dont tread on me snake rattlesnake flag united states america usa liberty patriotic' },
];
this.stickers = []; // [{id, name, pack_name, url}], loaded from server
this._emojiPickerContext = 'main'; // 'main' | 'thread' | 'dmpip', set by emoji button handlers
this._emojiPickerSection = 'emoji'; // 'emoji' | 'sticker': last-used picker tab
this._init();
}
// ── Initialization ────────────────────────────────────
_init() {
// Kick this off before anything can render a message. Until it resolves,
// _imgSrcAttr parks remote images in data-mp-src rather than emitting a
// raw external src, so there is no window in which an image leaks. (v3.43.0)
this._mediaProxyEnabled = true; // assume on: fail closed, not open
this._mediaToken = null;
this._loadMediaToken?.();
this._startMediaTokenRefresh?.(); // the token expires after ~2 days
this._setupMediaTokenRetry?.(); // and a stale one used to fail silently
// Link policy for decrypted DM content (#5483). Requested once the socket
// exists, below, since it travels over the authenticated connection.
this.socket = io({
// presenceDeltas: this client merges member-list changes
// (online-users-delta) instead of needing every list in full.
auth: { token: this.token, presenceDeltas: 1 },
reconnectionDelay: 1500,
reconnectionDelayMax: 10000,
randomizationFactor: 0.4,
});
// Expose socket globally so plugin-loader can emit set-preference
// when the user activates a published file theme (#5359)
window.havenSocket = this.socket;
this.voice = new VoiceManager(this.socket);
if (this.user && this.user.id) this.voice.localUserId = this.user.id;
// CRITICAL FIX: Run avatar setup first and use delegation to ensure listeners work
this._setupAvatarUpload();
this._setupSocketListeners();
this._initLinkPolicy?.();
this._setupUI();
this._setupThemes();
this._setupThemePicker();
this._setupServerBar();
this._applyGuestMode(); // (#5381) hide DM UI / lock down features for guests
this._setupNotifications();
this._setupMasterVolume();
this._setupPushNotifications();
this._resyncDesktopBadgeOnFocus?.();
this._setupImageUpload();
this._setupGifPicker();
this._startStatusBar();
this._setupMobile();
this._setupMobileSidebarServers();
this._setupCollapsibleSections();
this._setupIOSKeyboard();
this._setupMobileBridge();
this._setupStatusPicker();
this._setupFileUpload();
this._setupIdleDetection();
this._setupSoundManagement();
this._setupEmojiManagement();
this._setupStickerManagement();
this._setupWebhookManagement();
this._setupExtensionUpdates();
this._setupDiscordImport();
this._setupServerTemplates();
this._setupAuditLog();
this._initRoleManagement();
this._initPermMatrix();
this._initServerBranding();
this._setupResizableSidebars();
this.modMode = typeof ModMode === 'function' ? new ModMode() : null;
this.modMode?.init();
this._setupDensityPicker();
this._setupChannelScrollPicker();
this._setupToggleStylePicker();
this._setupAnimatePfpPicker();
this._setupAnimateChatPicker();
this._setupZoomSlider();
this._setupAccessibility();
this._setupEmojiSizePicker();
this._setupImageModePicker();
this._setupLazyMedia();
this._setupFeedAnchor();
this._setupEmbedSizePicker();
this._setupRoleDisplayPicker();
this._setupToolbarIconPicker();
// After the pickers above: the defaults apply through them (#5739).
this._setupMemberDefaults();
this._setupMessageHighlights();
this._setupModCases();
this._setupBadges();
this._setupServerMute();
this._setupForumUnread();
this._setupThreadUnread();
this._setupThreadScroll();
this._setupThreadDock();
this._setupTyping();
this._setupMessageNsfw();
this._setupMediaPlayback();
this._setupSilentToggle();
this._setupCategoryLayoutPicker();
this._setupDebugSection();
this._setupLightbox();
this._setupImageZoom();
this._setupMediaGrid();
this._setupOnlineOverlay();
this._setupModalExpand();
this._checkForUpdates();
this._initDesktopAppBanner();
this._initAndroidBetaBanner();
this._initWelcomePopups();
this._initMoveMessages();
this._initMoveToThread();
// CSP-safe image error handling (no inline onerror attributes)
// For avatar images, hide the broken img and show the letter-initial fallback
const avatarErrorHandler = (e) => {
if (e.target.tagName === 'IMG') {
e.target.style.display = 'none';
const fallback = e.target.nextElementSibling;
if (fallback && (fallback.classList.contains('message-avatar') || fallback.classList.contains('user-item-avatar'))) {
fallback.style.display = 'flex';
}
}
};
document.getElementById('messages')?.addEventListener('error', avatarErrorHandler, true);
document.getElementById('online-users')?.addEventListener('error', avatarErrorHandler, true);
this.socket.emit('get-channels');
this.socket.emit('get-server-settings');
this.socket.emit('get-preferences');
this.socket.emit('get-high-scores', { game: 'flappy' });
// ── Auto-start performance diagnostics after startup settles ──
setTimeout(() => this._startPerfDiagnostics(), 30000);
// E2E init is deferred to 'session-info' handler to ensure
// the socket is fully connected and server-side handlers are registered.
document.getElementById('current-user').textContent = this.user.displayName || this.user.username;
const loginEl = document.getElementById('login-name');
if (loginEl) loginEl.textContent = `@${this.user.username}`;
if (this.user.isAdmin || this._hasGlobalPerm('create_channel')) {
document.getElementById('admin-controls').style.display = 'block';
}
if (this.user.isAdmin || this._hasPerm('manage_roles') || this._hasPerm('manage_server')) {
document.getElementById('admin-mod-panel').style.display = 'block';
}
if (this.user.isAdmin || this._hasGlobalPerm('invite_users') || this._hasPerm('manage_server')) {
document.getElementById('sidebar-invite-panel').style.display = 'block';
}
const organizeBtn = document.getElementById('organize-channels-btn');
if (organizeBtn) organizeBtn.style.display = '';
document.getElementById('mod-mode-settings-toggle')?.addEventListener('click', () => this.modMode?.toggle());
}
async _loadBotCommands() {
try {
const res = await fetch('/api/bot-commands', { headers: { 'Authorization': `Bearer ${this.token}` } });
if (!res.ok) return;
const data = await res.json();
if (!data.commands || !data.commands.length) return;
const known = new Map(this.slashCommands.map(c => [String(c.cmd || '').toLowerCase(), c]));
for (const bc of data.commands) {
const cmd = String(bc.command || '').trim();
if (!cmd) continue;
const key = cmd.toLowerCase();
const channelCode = bc.channel_code || null;
const desc = `${bc.description || t('commands.bot_command')} [${bc.bot_name || t('commands.bot')}]`;
const existing = known.get(key);
if (existing) {
// The same command registered by a second bot in another channel
// keeps the one menu entry and adds its channel to it, so the
// suggestions show in every channel that has a bot for it, each
// naming its own bot. A built-in command of the same name stays as
// it is (#5635).
if (channelCode && Array.isArray(existing.channelCodes) && !existing.channelCodes.includes(channelCode)) {
existing.channelCodes.push(channelCode);
existing.descByChannel[channelCode] = desc;
}
continue;
}
const entry = {
cmd,
// Bot commands can have arbitrary args; a hardcoded "<...>" makes
// subcommand entries look broken and encourages base-command clicks.
args: '',
desc,
// A bot lives in one channel, so its commands are only offered there (#5635).
channelCodes: channelCode ? [channelCode] : null,
descByChannel: channelCode ? { [channelCode]: desc } : {}
};
known.set(key, entry);
this.slashCommands.push(entry);
}
} catch (err) { console.warn('[Commands] could not load bot slash commands', err); }
}
}
// ── Merge all method groups onto the prototype ────────────
Object.assign(HavenApp.prototype,
SocketMethods,
SocketChannelMethods,
SocketEventMethods,
UIBindMethods,
ComposerMethods,
ComposerListMethods,
ChannelMenuMethods,
VoiceControlMethods,
MediaGalleryMethods,
MessageActionMethods,
PipPanelMethods,
PeopleMethods,
SettingsMethods,
AdminControlMethods,
ExtensionUpdateMethods,
ServerBarMethods,
MobileMethods,
MediaMethods,
ImageZoomMethods,
MediaGridMethods,
FeedAnchorMethods,
AttachmentMethods,
AvatarEditorMethods,
AppearanceMethods,
AccessibilityMethods,
SoundMethods,
EmojiStickerMethods,
BotMethods,
ContextMethods,
ChannelMethods,
ChannelContextMethods,
ChannelOrganizeMethods,
ChannelUnreadMethods,
MessageMethods,
LinkPreviewMethods,
MessageToolMethods,
UserMethods,
BirthdayMethods,
VoiceMethods,
ScreenWebcamMethods,
StreamTileMethods,
MusicMethods,
MusicKeyMethods,
UtilityMethods,
MessageContentMethods,
EmojiPickerMethods,
GifPickerMethods,
ThreadMethods,
AdminMethods,
BrandingMethods,
MembersAdminMethods,
AutocompleteMethods,
StatusMethods,
DiscordImportMethods,
RoleMethods,
RoleAssignMethods,
ModerationMethods,
PlatformMethods,
SearchMethods,
FerryMethods,
ForumMethods,
ForumVoteMethods,
ForumBlogMethods,
ForumPictureVoteMethods,
RoleToolMethods,
PermMatrixMethods,
CallMethods,
ScalingMethods,
GroupMethods,
TemplateMethods,
MemberDefaultsMethods,
MessageHighlightMethods,
MessageGroupingMethods,
ModCaseMethods,
ServerMuteMethods,
AdminReauthMethods,
MasterVolumeMethods,
ForumUnreadMethods,
ThreadUnreadMethods,
ThreadDockMethods,
TypingMethods,
MessageNsfwMethods,
MediaPlaybackMethods,
PermDraftMethods,
ThreadScrollMethods,
MoveToThreadMethods,
ChannelCategoryMethods,
ChannelPersonalOrderMethods,
PushToMuteMethods,
BadgeMethods,
ThemePickerMethods,
SilentMethods,
);
// ── Boot ───────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', async () => {
await window.i18n?.init();
window.app = new HavenApp();
});