1.6.0 drop 1 (#132)

This commit is contained in:
/home/neo 2026-07-12 20:07:52 +03:00 committed by GitHub
parent 0594d330be
commit 0a32333019
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 207 additions and 144 deletions

View file

@ -1,25 +1,11 @@
![Release Artwork](https://raw.githubusercontent.com/LCE-Hub/LCE-Emerald-Launcher/refs/heads/main/RELEASE_ARTWORK.png)
- Fix fresh installation defaulting to Fullscreen
- Add a "Download to custom path" button to Versions menu
- Fix workshop black screen on server plugins category
- New logo for hellish ends
- Update neoLegacy download link
- (soon!) Partnership with BluerNetwork
- Skip Intro on keypress or click
- Disable skin remodeling for neoLegacy
- Download retry support
- Bring back the custom titlebar on non-macOS systems, and show on fullscreen regardless of OS
- Fix workshop showing extra stuff that don't exist for plugins
- Official external DLC support, used for neoLegacy to make the size smaller (700MiB -> 300MiB)
- Add logos to credits menu
- Partnership with [Minecraft: Moon Edition](https://www.youtube.com/watch?v=6_ZSUjZfBr0)
- Parallel downloads and redesigned download overlay notification
- A little bit of UI polish
- Community Guides for Developer Tools
- (BIG!) End partnership with LCELive. And migration to LCEOnline
- Workshop dependencies support
- Workshop required versions support
- New Model Editor developer tool
- New fun easter egg! try changing your username to `TOUHOU`
- And much much more you should see yourself!
- Fix custom path downloading overwriting non-empty directories ([#127](https://github.com/LCE-Hub/LCE-Emerald-Launcher/issues/127))
- Add support for version channels in Workshop
- Fix LCEOnline Auth on Linux and macOS
- Add `unzip` fallback for Workshop items on Linux
- Fix critical bug in Workshop that deleted worlds and DLCs (thanks LeGamer!)
- Fix random disconnects on Windows
- Show multiplayer status on Discord RPC
- Fix skipping intro, fullscreen and legacy mode not importing in Settings (thanks Ryuzaki!)
- Remove controls menu from Settings

View file

@ -42,7 +42,6 @@ pub async fn workshop_install(app: AppHandle, request: WorkshopInstallRequest) -
}
let bytes = response.bytes().await.map_err(|e| e.to_string())?;
fs::write(&zip_tmp, &bytes).map_err(|e| e.to_string())?;
let dest_dir = if placeholder.is_empty() {
instance_dir.clone()
} else {
@ -55,35 +54,96 @@ pub async fn workshop_install(app: AppHandle, request: WorkshopInstallRequest) -
};
fs::create_dir_all(&dest_dir).map_err(|e| e.to_string())?;
let (extracted_files, extract_ok) = if cfg!(target_os = "linux") {
let bsdtar_list = std::process::Command::new("bsdtar")
.args(["-tf", zip_tmp.to_str().unwrap()])
.output();
if let Ok(out) = bsdtar_list {
if out.status.success() {
let listing = String::from_utf8_lossy(&out.stdout);
let files: Vec<String> = listing.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty() && !l.ends_with('/'))
.map(|l| dest_dir.join(l).to_string_lossy().to_string())
.collect();
let st = std::process::Command::new("bsdtar")
.args(["-xf", zip_tmp.to_str().unwrap(), "-C", dest_dir.to_str().unwrap()])
.status()
.map_err(|e| e.to_string())?;
(files, st.success())
} else {
(Vec::new(), false)
}
} else {
(Vec::new(), false)
}
} else {
(Vec::new(), false)
};
#[cfg(target_os = "linux")]
{
let status = std::process::Command::new("bsdtar")
.args(["-xf", zip_tmp.to_str().unwrap(), "-C", dest_dir.to_str().unwrap()])
let (extracted_files, extract_ok) = if !extract_ok {
let unzip_list = std::process::Command::new("unzip")
.args(["-l", zip_tmp.to_str().unwrap()])
.output()
.map_err(|e| e.to_string())?;
if !unzip_list.status.success() {
let _ = fs::remove_dir_all(&tmp_dir);
return Err(format!("Failed to list contents of {}", zip_name));
}
let listing = String::from_utf8_lossy(&unzip_list.stdout);
let files: Vec<String> = listing.lines()
.filter_map(|l| {
let mut parts = l.trim().split_whitespace();
let size_str = parts.next()?;
size_str.parse::<u64>().ok()?;
parts.next()?;
parts.next()?;
Some(parts.collect::<Vec<&str>>().join(" "))
})
.filter(|l| !l.ends_with('/') && !l.contains('*'))
.map(|l| dest_dir.join(l).to_string_lossy().to_string())
.collect();
let st = std::process::Command::new("unzip")
.args(["-o", zip_tmp.to_str().unwrap(), "-d", dest_dir.to_str().unwrap()])
.status()
.map_err(|e| e.to_string())?;
if !status.success() {
let _ = fs::remove_dir_all(&tmp_dir);
return Err(format!("Extraction failed for {}", zip_name));
}
}
(files, st.success())
} else {
(extracted_files, extract_ok)
};
#[cfg(not(target_os = "linux"))]
{
let status = std::process::Command::new("tar")
let (extracted_files, extract_ok) = {
let st = std::process::Command::new("tar")
.args(["-xf", zip_tmp.to_str().unwrap(), "-C", dest_dir.to_str().unwrap()])
.status()
.output()
.map_err(|e| e.to_string())?;
if !status.success() {
let _ = fs::remove_dir_all(&tmp_dir);
return Err(format!("Extraction failed for {}", zip_name));
}
let listing = std::process::Command::new("tar")
.args(["-tf", zip_tmp.to_str().unwrap()])
.output()
.map_err(|e| e.to_string())?;
let listing_str = String::from_utf8_lossy(&listing.stdout);
let files: Vec<String> = listing_str.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty() && !l.ends_with('/'))
.map(|l| dest_dir.join(l).to_string_lossy().to_string())
.collect();
(files, st.status.success())
};
if !extract_ok {
let _ = fs::remove_dir_all(&tmp_dir);
return Err(format!("Extraction failed for {}", zip_name));
}
let dest_str = dest_dir.to_string_lossy().to_string();
if !workshop_files.contains(&dest_str) {
workshop_files.push(dest_str.clone());
}
if !pkg_dirs.contains(&dest_str) {
pkg_dirs.push(dest_str);
for f in &extracted_files {
if !workshop_files.contains(f) {
workshop_files.push(f.clone());
}
if !pkg_dirs.contains(f) {
pkg_dirs.push(f.clone());
}
}
}
@ -116,11 +176,9 @@ pub async fn workshop_uninstall(app: AppHandle, instance_id: String, package_id:
.unwrap_or_default();
if let Some(pkg) = packages.iter().find(|p| p.id == package_id) {
for dir in &pkg.dirs {
let path = PathBuf::from(dir);
if path.is_dir() {
let _ = fs::remove_dir_all(&path);
} else if path.is_file() {
for file in &pkg.dirs {
let path = PathBuf::from(file);
if path.is_file() {
let _ = fs::remove_file(&path);
}
}

View file

@ -31,6 +31,7 @@ pub fn load_config_raw(app: AppHandle) -> AppConfig {
music_vol: Some(50),
sfx_vol: Some(100),
legacy_mode: Some(false),
skip_intro: Some(false),
mangohud_enabled: None,
saved_servers: None,
extra_launch_args: None,

View file

@ -26,10 +26,12 @@ use commands::workshop;
use networking::relay;
use networking::stun;
use state::{DownloadState, GameState, ProxyGuard};
fn webview_deep_link_interceptor() -> impl tauri::plugin::Plugin<tauri::Wry> { tauri::plugin::Builder::<tauri::Wry>::new("emerald-deep-link-interceptor").on_navigation(|webview, url| { if url.scheme() == "emerald" || url.scheme() == "emeraldlauncher" { let _ = webview.app_handle().emit("deep-link", vec![url.to_string()]); false } else { true }}).build()}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_deep_link::init())
.plugin(webview_deep_link_interceptor())
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
let urls: Vec<String> = args
.iter()

View file

@ -1,6 +1,8 @@
use tauri::State;
use tauri::webview::cookie::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::time::sleep;
use tokio_util::sync::CancellationToken;
use crate::state::ProxyGuard;
const PROXY_ADDR: &str = "proxy.mclegacyedition.xyz:2052"; //neo: yeah bro im hardcoding it
@ -10,7 +12,7 @@ async fn read_line(stream: &mut TcpStream) -> Result<String, String> {
loop {
stream.read_exact(&mut byte).await.map_err(|e| e.to_string())?;
if byte[0] == b'\n' { break; }
buf.push(byte[0]);
if byte[0] != b'\r' { buf.push(byte[0]); }
}
String::from_utf8(buf).map_err(|e| e.to_string())
}
@ -32,18 +34,23 @@ async fn run_host_relay(
.map_err(|e| format!("Proxy connect failed: {}", e))?;
write_line(&mut host_conn, &format!("HOST {} 0", auth_token)).await?;
let game_stream = loop {
match TcpStream::connect(format!("127.0.0.1:{}", game_port)).await {
Ok(s) => break s,
Err(_) => {
tokio::select! {
_ = cancel.cancelled() => return Err("Cancelled".into()),
_ = tokio::time::sleep(std::time::Duration::from_secs(1)) => {}
let game_stream = tokio::time::timeout(
std::time::Duration::from_secs(30),
async {
loop {
match TcpStream::connect(format!("127.0.0.1:{}", game_port)).await {
Ok(s) => return Ok::<_, String>(s),
Err(_) => {
tokio::select! {
_ = cancel.cancelled() => return Err("Cancelled".into()),
_ = tokio::time::sleep(std::time::Duration::from_secs(1)) => {}
}
}
}
}
}
};
).await
.map_err(|_| "Timed out waiting for game to start".to_string())??;
let client_line = read_line(&mut host_conn).await?;
let client_parts: Vec<&str> = client_line.split_whitespace().collect();
if client_parts.len() < 2 || client_parts[0] != "CLIENT" {
@ -167,7 +174,9 @@ pub async fn start_host_relay(
let session_id = "__host__".to_string();
{
let mut tokens = proxy_state.cancel_tokens.lock().await;
tokens.insert(session_id.clone(), cancel.clone());
if let Some(old) = tokens.insert(session_id.clone(), cancel.clone()) {
old.cancel();
}
}
let result = run_host_relay(&proxy_state, &addr, &auth_token, game_port, cancel).await;
@ -205,11 +214,12 @@ pub async fn start_relay_proxy(
#[tauri::command]
pub async fn stop_proxy(proxy_state: State<'_, ProxyGuard>, session_id: String) -> Result<(), String> {
let mut tokens = proxy_state.cancel_tokens.lock().await;
if let Some(token) = tokens.remove(&session_id) {
let found = tokens.remove(&session_id);
if let Some(token) = found {
token.cancel();
let mut port = proxy_state.local_port.lock().await;
*port = None;
}
let mut port = proxy_state.local_port.lock().await;
*port = None;
Ok(())
}
@ -241,5 +251,18 @@ pub async fn join_game(
ip: host_ip,
port: host_port,
};
#[cfg(target_os = "windows")]
{
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(10);
loop {
if tokio::net::TcpStream::connect(format!("127.0.0.1:{}", host_port)).await.is_ok() {
break;
}
if tokio::time::Instant::now() >= deadline {
return Err("Timed out waiting for relay proxy".into());
}
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
}
} //neo: workaround for Windows having a race condition where the game is launched before the relay proxy
crate::commands::game::launch_game(app, game_state, instance_id, vec![server], vec![]).await
}

View file

@ -53,6 +53,7 @@ pub struct AppConfig {
pub music_vol: Option<u32>,
pub sfx_vol: Option<u32>,
pub legacy_mode: Option<bool>,
pub skip_intro: Option<bool>,
pub mangohud_enabled: Option<bool>,
pub saved_servers: Option<Vec<McServer>>,
pub extra_launch_args: Option<Vec<String>>,

View file

@ -281,7 +281,7 @@ export default function ModelEditorView() {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (document.activeElement?.tagName === "INPUT") return;
if (e.key === "Escape" || e.key === "Backspace") {
if (e.key === "Escape") {
playBackSound();
setActiveView("devtools");
}

View file

@ -128,7 +128,13 @@ export default function PckEditorView() {
setExpandedFolders(next);
};
type TreeNode = { name: string; path: string; isFolder: boolean; children: TreeNode[]; asset?: PCKAsset };
type TreeNode = {
name: string;
path: string;
isFolder: boolean;
children: TreeNode[];
asset?: PCKAsset;
};
const renderTree = (nodes: TreeNode[], depth = 0) => {
return nodes.map((node) => {
@ -448,7 +454,7 @@ export default function PckEditorView() {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (document.activeElement?.tagName === "INPUT") return;
if (e.key === "Escape" || e.key === "Backspace") {
if (e.key === "Escape") {
if (isEditingProperty) {
setIsEditingProperty(null);
return;
@ -768,18 +774,14 @@ export default function PckEditorView() {
</div>
</div>
<div className="flex gap-2 shrink-0">
<button
onClick={() => handleMoveAsset("up")}
>
<button onClick={() => handleMoveAsset("up")}>
<img
src="/images/Settings_Arrow_Up.png"
className="w-4 h-4 object-contain"
style={{ imageRendering: "pixelated" }}
/>
</button>
<button
onClick={() => handleMoveAsset("down")}
>
<button onClick={() => handleMoveAsset("down")}>
<img
src="/images/Settings_Arrow_Down.png"
className="w-4 h-4 object-contain"

View file

@ -23,7 +23,6 @@ const SettingsView = memo(function SettingsView() {
sfxVol: sfxVolume,
setSfxVol: setSfxVolume,
layout,
setLayout,
linuxRunner,
setLinuxRunner,
perfBoost,
@ -62,7 +61,7 @@ const SettingsView = memo(function SettingsView() {
const { isLinux, isMac } = usePlatform();
const [focusIndex, setFocusIndex] = useState<number | null>(null);
const [currentSubMenu, setCurrentSubMenu] = useState<
"main" | "audio" | "video" | "controls" | "launcher" | "game" | "plugins"
"main" | "audio" | "video" | "launcher" | "game" | "plugins"
>("main");
const [runners, setRunners] = useState<Runner[]>([]);
const [pluginsInfo, setPluginsInfo] = useState<PluginInfo[]>([]);
@ -74,9 +73,6 @@ const SettingsView = memo(function SettingsView() {
const [showModal, setShowModal] = useState<
"args" | "prefix" | "envVars" | null
>(null);
const layouts = ["KBM", "PLAYSTATION", "XBOX"];
useEffect(() => {
TauriService.getAvailableRunners().then(setRunners);
}, [isRunnerDownloading]);
@ -91,13 +87,6 @@ const SettingsView = memo(function SettingsView() {
return () => PluginManager.instance.setEnabledChangedCallback(null!);
}, [refreshPlugins]);
const handleLayoutToggle = () => {
playPressSound();
const currentIndex = layouts.indexOf(layout);
const nextIndex = (currentIndex + 1) % layouts.length;
setLayout(layouts[nextIndex]);
};
const handleVfxToggle = () => {
playPressSound();
setVfxEnabled(!vfxEnabled);
@ -302,16 +291,6 @@ const SettingsView = memo(function SettingsView() {
setFocusIndex(0);
},
});
items.push({
id: "controls_menu",
label: "Controls",
type: "button",
onClick: () => {
playPressSound();
setCurrentSubMenu("controls");
setFocusIndex(0);
},
});
items.push({
id: "launcher_menu",
label: "Launcher",
@ -395,13 +374,6 @@ const SettingsView = memo(function SettingsView() {
onClick: handlePerfToggle,
});
}
} else if (currentSubMenu === "controls") {
items.push({
id: "layout",
label: `Layout: ${layout}`,
type: "button",
onClick: handleLayoutToggle,
});
} else if (currentSubMenu === "game") {
const envVarsCount = launchEnvVars
? Object.keys(launchEnvVars).length
@ -521,6 +493,7 @@ const SettingsView = memo(function SettingsView() {
playPressSound();
try {
await TauriService.importSettings();
window.location.reload();
} catch (e) {
if (e !== "CANCELED") console.error(e);
}
@ -585,7 +558,6 @@ const SettingsView = memo(function SettingsView() {
handleRpcToggle,
handleLegacyToggle,
handleAnimationsToggle,
handleLayoutToggle,
handleRunnerToggle,
handlePerfToggle,
handleMangohudToggle,
@ -712,13 +684,11 @@ const SettingsView = memo(function SettingsView() {
? "Audio"
: currentSubMenu === "video"
? "Video"
: currentSubMenu === "controls"
? "Controls"
: currentSubMenu === "game"
? "Game"
: currentSubMenu === "plugins"
? "Plugins"
: "Launcher"}
: currentSubMenu === "game"
? "Game"
: currentSubMenu === "plugins"
? "Plugins"
: "Launcher"}
</h2>
{currentSubMenu === "main" ? (

View file

@ -1940,16 +1940,24 @@ function InstallModal({
isPluginTab?: boolean;
}) {
const game = useContext(GameContext);
const allInstalled =
game?.editions.filter((e) => game.installs.includes(e.id)) || [];
const availableEditions =
const matchingEditions =
pkg.required_versions && pkg.required_versions.length > 0
? allInstalled.filter((e) =>
? (game?.editions || []).filter((e) =>
pkg.required_versions!.some(
(rv) => e.id === rv || e.id.startsWith(rv + "_"),
),
)
: allInstalled;
: game?.editions || [];
const editionOptions = matchingEditions.flatMap((ed) => {
const branches =
ed.branches && ed.branches.length > 0 ? ed.branches : ["Stable"];
return branches
.map((branch) => ({
label: branches.length > 1 ? `${ed.name} (${branch})` : ed.name,
instanceId: branch === "Stable" ? ed.id : `${ed.id}_${branch}`,
}))
.filter((opt) => game?.installs.includes(opt.instanceId));
});
const [focusedIdx, setFocusedIdx] = useState(0);
const [status, setStatus] = useState<
"idle" | "installing" | "success" | "error"
@ -2040,16 +2048,16 @@ function InstallModal({
} else if (e.key === "ArrowDown") {
e.preventDefault();
playPressSound();
setFocusedIdx((p) => Math.min(p + 1, availableEditions.length - 1));
setFocusedIdx((p) => Math.min(p + 1, editionOptions.length - 1));
} else if (e.key === "Enter") {
if (availableEditions.length > 0) {
installTo(availableEditions[focusedIdx].id);
if (editionOptions.length > 0) {
installTo(editionOptions[focusedIdx].instanceId);
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [availableEditions, focusedIdx, status, onClose, playPressSound]);
}, [editionOptions, focusedIdx, status, onClose, playPressSound]);
const installDeps = async (instanceId: string) => {
for (const depId of dependencies) {
@ -2216,24 +2224,24 @@ function InstallModal({
{status === "idle" &&
!isPluginTab &&
(availableEditions.length === 0 ? (
(editionOptions.length === 0 ? (
<div className="py-6 flex items-center justify-center">
<span className="text-[#FF5555] mc-text-shadow">
No installed editions found
</span>
</div>
) : (
availableEditions.map((ed, i) => (
editionOptions.map((opt, i) => (
<div
key={ed.id}
onClick={() => installTo(ed.id)}
key={opt.instanceId}
onClick={() => installTo(opt.instanceId)}
onMouseEnter={() => setFocusedIdx(i)}
className={`flex flex-col p-3 cursor-pointer border-2 transition-none ${focusedIdx === i ? "border-[#FFFF55] bg-black/40" : "border-[#444] bg-black/20"}`}
>
<span
className={`text-lg mc-text-shadow ${focusedIdx === i ? "text-[#FFFF55]" : "text-white"}`}
>
{ed.name}
{opt.label}
</span>
</div>
))

View file

@ -215,7 +215,7 @@ export const SPLASHES = [
"I don't know what woke means can someone tell me",
"I HATE WHEN PEOPLE USE UPPERCASE LETTERS",
"Erm.... He's right behind me, isn't he?",
"What does \"the\" even mean man",
'What does "the" even mean man',
"Tip: Bigger, longer and uncut.",
"This is the longest sentence ever",
"This is the shortest sentence scientifically possible, according to me.",
@ -231,4 +231,5 @@ export const SPLASHES = [
"Edgy humor is soo edgy",
"I LOVE YURI!!!!!!! oh and yaoi ig",
"RIP PrismaChunk0's Dog 2010-2026",
];
"Guess who's back, back again!",
];

View file

@ -43,7 +43,10 @@ export function useDiscordRPC({
: `Logged in as ${username}`;
if (isGameRunning) {
details = `Playing ${versionName}`;
details =
activeView === "lceonline"
? `Playing ${versionName} Multiplayer`
: `Playing ${versionName}`;
} else if (downloadingIds.length > 0) {
const firstId = downloadingIds[0];
const pct = downloadProgress[firstId];

View file

@ -30,7 +30,7 @@ export const BASE_EDITIONS = [
id: "legacy_evolved",
name: "neoLegacy",
desc: "Backporting newer title updates and Minigames back to LCE",
url: "https://git.neolegacy.dev/neoStudiosLCE/neoLegacy/releases/download/1.0.8b/neoLegacyWindows64.zip",
url: "https://bucket.ibatv.xyz/neolegacy/Release.zip",
titleImage: "/images/minecraft_title_neoLegacy.png",
supportsSlimSkins: true,
logo: "/images/neoLegacy.png",
@ -500,7 +500,8 @@ export function useGameManager({
? (extraLaunchArgs ?? []).concat([
"-token",
localStorage.getItem("lceonline_session")
? JSON.parse(localStorage.getItem("lceonline_session")!).accessToken
? JSON.parse(localStorage.getItem("lceonline_session")!)
.accessToken
: "",
])
: extraLaunchArgs,

View file

@ -1,9 +1,17 @@
import { useState, useEffect, useRef } from "react";
import { lceOnlineService } from "../services/LceOnlineService";
export function useLceOnlineNotifications() {
const [friendRequestMessage, setFriendRequestMessage] = useState<string | null>(null);
const [friendRequestMessage, setFriendRequestMessage] = useState<
string | null
>(null);
const [InviteMessage, setInviteMessage] = useState<string | null>(null);
const [invites, setInvites] = useState<Array<{ inviteid: string; from: { uuid: string; username: string; }; sessionid: string; }>>([]);
const [invites, setInvites] = useState<
Array<{
inviteid: string;
from: { uuid: string; username: string };
sessionid: string;
}>
>([]);
const seenRequests = useRef<Set<string>>(new Set());
const seenInvites = useRef<Set<string>>(new Set());
useEffect(() => {
@ -24,12 +32,12 @@ export function useLceOnlineNotifications() {
const invitesData = await lceOnlineService.getInvites();
setInvites(invitesData);
invitesData.forEach((i) => {
if (!seenInvites.current.has(i.inviteid)) {
seenInvites.current.add(i.inviteid);
setInviteMessage(`${i.from.username} invited you to play!`);
}
});
} catch { }
if (!seenInvites.current.has(i.inviteid)) {
seenInvites.current.add(i.inviteid);
setInviteMessage(`${i.from.username} invited you to play!`);
}
});
} catch {}
};
const init = async () => {
@ -39,7 +47,6 @@ export function useLceOnlineNotifications() {
lists.requests.forEach((r: string) => {
if (!seenRequests.current.has(r)) {
seenRequests.current.add(r);
setFriendRequestMessage(`${r} wants to be friends!`);
}
});
} catch (e) {}
@ -47,12 +54,12 @@ export function useLceOnlineNotifications() {
const invitesData = await lceOnlineService.getInvites();
setInvites(invitesData);
invitesData.forEach((i) => {
if (!seenInvites.current.has(i.inviteid)) {
seenInvites.current.add(i.inviteid);
setInviteMessage(`${i.from.username} invited you to play!`);
}
});
} catch { }
if (!seenInvites.current.has(i.inviteid)) {
seenInvites.current.add(i.inviteid);
setInviteMessage(`${i.from.username} invited you to play!`);
}
});
} catch {}
}
pollInterval = setInterval(poll, 3000);
};