Integrated server emulation. Testing client...
+
+ {isRecording ? 'Recording packets...' : 'Integrated server emulation. Testing client...'}
+
+
@@ -228,4 +239,5 @@ export interface PacketData {
actualVersion?: any
position: number
timestamp: number
+ isCustomChannel?: boolean
}
diff --git a/src/react/Screen.tsx b/src/react/Screen.tsx
index a17ede40..605ec28f 100644
--- a/src/react/Screen.tsx
+++ b/src/react/Screen.tsx
@@ -6,14 +6,15 @@ interface Props {
className?: string
titleSelectable?: boolean
titleMarginTop?: number
+ contentStyle?: React.CSSProperties
}
-export default ({ title, children, backdrop = true, style, className = '', titleSelectable, titleMarginTop }: Props) => {
+export default ({ title, children, backdrop = true, style, className = '', titleSelectable, titleMarginTop, contentStyle }: Props) => {
return (
<>
{backdrop === 'dirt' ?
: backdrop ?
: null}
-
+
diff --git a/src/react/SelectOption.tsx b/src/react/SelectOption.tsx
index 4df471c0..c7b7ab38 100644
--- a/src/react/SelectOption.tsx
+++ b/src/react/SelectOption.tsx
@@ -48,21 +48,33 @@ export const showOptionsModal = async
(
})
}
-type InputOption = {
- type: 'text' | 'checkbox'
+export type InputOption = {
+ type: 'text' | 'checkbox' | 'button'
defaultValue?: string | boolean
label?: string
+ placeholder?: string
+ onButtonClick?: () => void
}
export const showInputsModal = async >(
title: string,
inputs: T,
- { cancel = true, minecraftJsonMessage }: { cancel?: boolean, minecraftJsonMessage? } = {}
+ {
+ cancel = true,
+ minecraftJsonMessage,
+ showConfirm = true
+ }: {
+ cancel?: boolean,
+ minecraftJsonMessage?
+ showConfirm?: boolean
+ } = {}
): Promise<{
[K in keyof T]: T[K] extends { type: 'text' }
? string
: T[K] extends { type: 'checkbox' }
? boolean
- : never
+ : T[K] extends { type: 'button' }
+ ? string
+ : never
}> => {
showModal({ reactType: 'general-select' })
let minecraftJsonMessageParsed
@@ -81,7 +93,7 @@ export const showInputsModal = async >(
showCancel: cancel,
minecraftJsonMessage: minecraftJsonMessageParsed,
options: [],
- inputsConfirmButton: 'Confirm'
+ inputsConfirmButton: showConfirm ? 'Confirm' : ''
})
})
}
@@ -130,6 +142,7 @@ export default () => {
autoFocus
type='text'
defaultValue={input.defaultValue as string}
+ placeholder={input.placeholder}
onChange={(e) => {
inputValues.current[key] = e.target.value
}}
@@ -148,6 +161,15 @@ export default () => {
{label}
)}
+ {input.type === 'button' && (
+ {
+ resolveClose(inputValues.current)
+ input.onButtonClick?.()
+ }}
+ >{label}
+
+ )}
})}
diff --git a/src/react/ServersListProvider.tsx b/src/react/ServersListProvider.tsx
index db1d7d77..cd44a27a 100644
--- a/src/react/ServersListProvider.tsx
+++ b/src/react/ServersListProvider.tsx
@@ -16,6 +16,11 @@ import { showOptionsModal } from './SelectOption'
import { useCopyKeybinding } from './simpleHooks'
import { AuthenticatedAccount, getInitialServersList, getServerConnectionHistory, setNewServersList } from './serversStorage'
import { appStorage, StoreServerItem } from './appStorageProvider'
+import Button from './Button'
+import { pixelartIcons } from './PixelartIcon'
+import { showNotification } from './NotificationProvider'
+
+const EXPLICIT_SHARE_SERVER_MODE = false
if (appQueryParams.lockConnect) {
notHideableModalsWithoutForce.add('editServer')
@@ -350,6 +355,27 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
}}
worldData={serversListSorted.map(server => {
const additional = additionalServerData[server.ip]
+ const handleShare = async () => {
+ try {
+ const qs = new URLSearchParams()
+ qs.set('ip', server.ip)
+ if (server.proxyOverride) qs.set('proxy', server.proxyOverride)
+ if (server.versionOverride) qs.set('version', server.versionOverride)
+ qs.set('username', server.usernameOverride ?? '')
+ const shareUrl = `${window.location.origin}${window.location.pathname}?${qs.toString()}`
+ await navigator.clipboard.writeText(shareUrl)
+ const MESSAGE = 'Server link copied to clipboard'
+ if (EXPLICIT_SHARE_SERVER_MODE) {
+ await showOptionsModal(MESSAGE, [])
+ } else {
+ showNotification(MESSAGE)
+ }
+ } catch (err) {
+ console.error(err)
+ showNotification('Failed to copy server link to clipboard')
+ }
+ }
+
return {
name: server.index.toString(),
title: server.name || server.ip,
@@ -359,6 +385,16 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
worldNameRightGrayed: additional?.textNameRightGrayed ?? '',
iconSrc: additional?.icon,
offline: additional?.offline,
+ afterTitleUi: (
+
{
+ e.stopPropagation()
+ void handleShare()
+ }}
+ />
+ ),
group: customServersList ? 'Provided Servers' : 'Saved Servers'
}
})}
@@ -397,6 +433,8 @@ export default () => {
const modalStack = useSnapshot(activeModalStack)
const hasServersListModal = modalStack.some(x => x.reactType === 'serversList')
const editServerModalActive = useIsModalActive('editServer')
+ const generalSelectActive = useIsModalActive('general-select')
+ // const isServersListModalActive = useIsModalActive('serversList') || (modalStack.some(x => x.reactType === 'serversList') && generalSelectActive)
const isServersListModalActive = useIsModalActive('serversList')
const eitherModal = isServersListModalActive || editServerModalActive
diff --git a/src/react/VoiceMicrophone.tsx b/src/react/VoiceMicrophone.tsx
new file mode 100644
index 00000000..af2c3fc2
--- /dev/null
+++ b/src/react/VoiceMicrophone.tsx
@@ -0,0 +1,77 @@
+import { proxy, useSnapshot } from 'valtio'
+import PixelartIcon, { pixelartIcons } from './PixelartIcon'
+
+export const voiceChatStatus = proxy({
+ active: false,
+ muted: false,
+ hasInputVoice: false,
+ isErrored: false,
+ isConnected: false,
+ isAlone: false,
+
+ isSharingScreen: false,
+})
+
+window.voiceChatStatus = voiceChatStatus
+
+const Icon = () => {
+ return
+
+
+
+}
+
+export default () => {
+ const SIZE = 48
+ const { active, muted, hasInputVoice, isSharingScreen, isConnected, isErrored, isAlone } = useSnapshot(voiceChatStatus)
+ if (!active) return null
+
+ const getRingColor = () => {
+ if (isErrored) return 'rgba(214, 4, 4, 0.5)' // red with opacity
+ if (isConnected) {
+ if (isAlone) return 'rgba(183, 255, 0, 0.5)' // lime yellow
+ return 'rgba(50, 205, 50, 0.5)' // green with opacity
+ }
+ return 'rgba(128, 128, 128, 0.5)' // gray with opacity
+ }
+
+ return (
+ {
+ // toggleMicrophoneMuted()
+ }}
+ >
+
+
+
+ {/* stop sharing screen */}
+ {isSharingScreen &&
}
+
+ )
+}
diff --git a/src/react/appStorageProvider.ts b/src/react/appStorageProvider.ts
index 01420b39..5cef34c1 100644
--- a/src/react/appStorageProvider.ts
+++ b/src/react/appStorageProvider.ts
@@ -7,6 +7,7 @@ import type { BaseServerInfo } from './AddServerOrConnect'
// when opening html file locally in browser, localStorage is shared between all ever opened html files, so we try to avoid conflicts
const localStoragePrefix = process.env?.SINGLE_FILE_BUILD ? 'minecraft-web-client:' : ''
+const { localStorage } = window
export interface SavedProxiesData {
proxies: string[]
@@ -39,6 +40,8 @@ type StorageData = {
serversHistory: ServerHistoryEntry[]
authenticatedAccounts: AuthenticatedAccount[]
serversList: StoreServerItem[] | undefined
+ modsAutoUpdateLastCheck: number | undefined
+ firstModsPageVisit: boolean
}
const oldKeysAliases: Partial> = {
@@ -79,6 +82,8 @@ const defaultStorageData: StorageData = {
serversHistory: [],
authenticatedAccounts: [],
serversList: undefined,
+ modsAutoUpdateLastCheck: undefined,
+ firstModsPageVisit: true,
}
export const setStorageDataOnAppConfigLoad = () => {
@@ -86,7 +91,6 @@ export const setStorageDataOnAppConfigLoad = () => {
}
export const appStorage = proxy({ ...defaultStorageData })
-window.appStorage = appStorage
// Restore data from localStorage
for (const key of Object.keys(defaultStorageData)) {
diff --git a/src/react/components/replay/PacketList.tsx b/src/react/components/replay/PacketList.tsx
index 5183bf3f..ca5b8373 100644
--- a/src/react/components/replay/PacketList.tsx
+++ b/src/react/components/replay/PacketList.tsx
@@ -16,11 +16,12 @@ const formatters: Record string> = {
default: (data) => processPacketDataForLogging(data)
}
-const getPacketIcon = (name: string): string => {
- if (name.includes('position')) return '📍'
- if (name.includes('chat')) return '💬'
- if (name.includes('block') || name.includes('chunk') || name.includes('light')) return '📦'
- if (name.includes('entity') || name.includes('player') || name.includes('passenger')) return '🎯'
+const getPacketIcon = (packet: PacketData): string => {
+ if (packet.isCustomChannel) return '🔗'
+ if (packet.name.includes('position')) return '📍'
+ if (packet.name.includes('chat')) return '💬'
+ if (packet.name.includes('block') || packet.name.includes('chunk') || packet.name.includes('light')) return '📦'
+ if (packet.name.includes('entity') || packet.name.includes('player') || packet.name.includes('passenger')) return '🎯'
return '📄'
}
@@ -102,7 +103,7 @@ export default function PacketList ({ packets, filter, maxHeight = 300 }: Props)
opacity: packet.isUpcoming ? 0.5 : 1
}}
>
- {getPacketIcon(packet.name)}
+ {getPacketIcon(packet)}
#{packet.position}
{timeDiff && {timeDiff} }
diff --git a/src/react/hooks/useScrollBehavior.ts b/src/react/hooks/useScrollBehavior.ts
index c7c4499a..3b9499a6 100644
--- a/src/react/hooks/useScrollBehavior.ts
+++ b/src/react/hooks/useScrollBehavior.ts
@@ -23,6 +23,13 @@ export const useScrollBehavior = (
const scrollToBottom = () => {
if (elementRef.current) {
elementRef.current.scrollTop = elementRef.current.scrollHeight
+ setTimeout(() => {
+ if (!elementRef.current) return
+ elementRef.current.scrollTo({
+ top: elementRef.current.scrollHeight,
+ behavior: 'instant'
+ })
+ }, 0)
}
}
diff --git a/src/react/mods.module.css b/src/react/mods.module.css
new file mode 100644
index 00000000..2b3184bb
--- /dev/null
+++ b/src/react/mods.module.css
@@ -0,0 +1,193 @@
+.root {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ width: 100%;
+ padding: 10px;
+ padding-top: 0;
+ box-sizing: border-box;
+ gap: 10px;
+}
+
+.header {
+ display: flex;
+ gap: 5px;
+}
+
+.statsRow {
+ color: #999;
+ font-size: 10px;
+ margin-bottom: 8px;
+}
+
+.statsRow {
+ color: #999;
+ font-size: 10px;
+ margin-bottom: 8px;
+}
+
+.searchBar {
+ flex: 1;
+}
+
+.content {
+ display: flex;
+ flex: 1;
+ gap: 10px;
+ overflow: hidden;
+ min-height: 0; /* Important for Firefox */
+}
+
+.verticalContent {
+ flex-direction: column;
+}
+
+.verticalContent .modList {
+ height: 50%;
+ min-height: 200px;
+}
+
+.verticalContent .sidebar {
+ height: 50%;
+ width: 100%;
+}
+
+.modList {
+ flex: 1;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ background: rgba(0, 0, 0, 0.2);
+ border-radius: 4px;
+ padding: 5px;
+ min-height: 0; /* Important for Firefox */
+ height: 100%;
+}
+
+.sidebar {
+ width: 200px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 10px;
+ background: rgba(0, 0, 0, 0.3);
+ border-radius: 4px;
+ flex-shrink: 0;
+ height: 100%;
+}
+
+.modInfo {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+}
+
+.modInfoTitle {
+ font-size: 12px;
+ font-weight: bold;
+ color: white;
+}
+
+.modInfoText {
+ font-size: 10px;
+ white-space: pre-wrap;
+ color: #bcbcbc;
+}
+
+.modActions {
+ display: flex;
+ gap: 5px;
+}
+
+.modRow {
+ display: flex;
+ flex-direction: column;
+ padding: 8px;
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+.modRow:hover {
+ background: rgba(0, 0, 0, 0.2);
+}
+
+.modRowTitle {
+ font-size: 12px;
+ color: white;
+ margin-bottom: 4px;
+ display: flex;
+}
+
+.modRowInfo {
+ font-size: 10px;
+ color: #bcbcbc;
+}
+
+.repoHeader {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ color: #bcbcbc;
+ font-size: 8px;
+ cursor: pointer;
+ padding: 4px;
+ border-radius: 4px;
+}
+
+.repoHeader:hover {
+ background: rgba(0, 0, 0, 0.2);
+}
+
+.repoContent {
+ margin-left: 10px;
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+}
+
+/* Mod state styles */
+.modRow[data-enabled="false"] {
+ opacity: 0.5;
+}
+
+.modRow[data-enabled="true"] {
+ color: lime;
+}
+
+.modRow[data-enabled="true"] .modRowTitle {
+ color: lime;
+}
+
+/* Error state styles */
+.modRow[data-has-error="true"] {
+ background: rgba(255, 0, 0, 0.1);
+}
+
+.modRow[data-has-error="true"] .modRowTitle {
+ color: #ff6b6b;
+}
+
+.modErrorList {
+ font-size: 8px;
+ color: #ff6b6b;
+ margin-top: 5px;
+ padding-left: 10px;
+ list-style-type: disc;
+}
+
+.fieldEditorTextarea {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ font-size: 7px;
+}
+
+.fieldEditorTextarea {
+ position: absolute;
+
+ width: 100%;
+ height: 100%;
+ padding: 10px;
+}
diff --git a/src/react/mods.module.css.d.ts b/src/react/mods.module.css.d.ts
new file mode 100644
index 00000000..d72d09a1
--- /dev/null
+++ b/src/react/mods.module.css.d.ts
@@ -0,0 +1,25 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ content: string;
+ fieldEditorTextarea: string;
+ header: string;
+ modActions: string;
+ modErrorList: string;
+ modInfo: string;
+ modInfoText: string;
+ modInfoTitle: string;
+ modList: string;
+ modRow: string;
+ modRowInfo: string;
+ modRowTitle: string;
+ repoContent: string;
+ repoHeader: string;
+ root: string;
+ searchBar: string;
+ sidebar: string;
+ statsRow: string;
+ verticalContent: string;
+}
+declare const cssExports: CssExports;
+export default cssExports;
diff --git a/src/react/state/packetsReplayState.ts b/src/react/state/packetsReplayState.ts
index 5a612f43..94866bd5 100644
--- a/src/react/state/packetsReplayState.ts
+++ b/src/react/state/packetsReplayState.ts
@@ -6,6 +6,7 @@ export const packetsReplayState = proxy({
packetsPlayback: [] as PacketData[],
isOpen: false,
isMinimized: false,
+ isRecording: false,
replayName: '',
isPlaying: false,
progress: {
diff --git a/src/reactUi.tsx b/src/reactUi.tsx
index 15c09939..72aff0ef 100644
--- a/src/reactUi.tsx
+++ b/src/reactUi.tsx
@@ -45,6 +45,7 @@ import SignInMessageProvider from './react/SignInMessageProvider'
import BookProvider from './react/BookProvider'
import { options } from './optionsStorage'
import BossBarOverlayProvider from './react/BossBarOverlayProvider'
+import ModsPage from './react/ModsPage'
import DebugEdges from './react/DebugEdges'
import GameInteractionOverlay from './react/GameInteractionOverlay'
import MineflayerPluginHud from './react/MineflayerPluginHud'
@@ -54,6 +55,11 @@ import { useAppScale } from './scaleInterface'
import PacketsReplayProvider from './react/PacketsReplayProvider'
import TouchInteractionHint from './react/TouchInteractionHint'
import { ua } from './react/utils'
+import VoiceMicrophone from './react/VoiceMicrophone'
+import ConnectOnlyServerUi from './react/ConnectOnlyServerUi'
+import ControDebug from './react/ControDebug'
+import ChunksDebug from './react/ChunksDebug'
+import ChunksDebugScreen from './react/ChunksDebugScreen'
const isFirefox = ua.getBrowser().name === 'Firefox'
if (isFirefox) {
@@ -156,6 +162,8 @@ const InGameUi = () => {
{!disabledUiParts.includes('crosshair') && }
{!disabledUiParts.includes('books') && }
{!disabledUiParts.includes('bossbars') && displayBossBars && }
+
+
@@ -208,6 +216,7 @@ const App = () => {