feat: new reworked notifications

feat: support for world icons in singleplayer menu
This commit is contained in:
Vitaly Turovsky 2024-03-21 07:24:22 +03:00
commit d7bdf3633d
17 changed files with 214 additions and 140 deletions

View file

@ -49,8 +49,9 @@
"esbuild": "^0.19.3",
"esbuild-plugin-polyfill-node": "^0.3.0",
"express": "^4.18.2",
"flying-squid": "npm:@zardoy/flying-squid@^0.0.12",
"flying-squid": "npm:@zardoy/flying-squid@^0.0.13",
"fs-extra": "^11.1.1",
"google-drive-browserfs": "github:zardoy/browserfs#google-drive",
"iconify-icon": "^1.0.8",
"jszip": "^3.10.1",
"lit": "^2.8.0",
@ -61,6 +62,7 @@
"node-gzip": "^1.1.2",
"peerjs": "^1.5.0",
"pretty-bytes": "^6.1.1",
"prismarine-provider-anvil": "github:zardoy/prismarine-provider-anvil#everything",
"qrcode.react": "^3.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@ -74,8 +76,7 @@
"title-case": "3.x",
"ua-parser-js": "^1.0.37",
"valtio": "^1.11.1",
"workbox-build": "^7.0.0",
"google-drive-browserfs": "github:zardoy/browserfs#google-drive"
"workbox-build": "^7.0.0"
},
"devDependencies": {
"@storybook/addon-essentials": "^7.4.6",

17
pnpm-lock.yaml generated
View file

@ -85,8 +85,8 @@ importers:
specifier: ^4.18.2
version: 4.18.2
flying-squid:
specifier: npm:@zardoy/flying-squid@^0.0.12
version: /@zardoy/flying-squid@0.0.12
specifier: npm:@zardoy/flying-squid@^0.0.13
version: /@zardoy/flying-squid@0.0.13
fs-extra:
specifier: ^11.1.1
version: 11.1.1
@ -123,6 +123,9 @@ importers:
pretty-bytes:
specifier: ^6.1.1
version: 6.1.1
prismarine-provider-anvil:
specifier: github:zardoy/prismarine-provider-anvil#everything
version: github.com/zardoy/prismarine-provider-anvil/0ddcd9d48574113308e1fbebef60816aced0846f(minecraft-data@3.62.0)
qrcode.react:
specifier: ^3.1.0
version: 3.1.0(react@18.2.0)
@ -5655,8 +5658,8 @@ packages:
tslib: 1.14.1
dev: true
/@zardoy/flying-squid@0.0.12:
resolution: {integrity: sha512-wFvdROB9iEucdYamBLXhKKGiUdprjxJsSo0Mk4UKMUoau9G3oly1tVfkuVZc9mQm0NSLOx8oSPA3uCNUT9lAgw==}
/@zardoy/flying-squid@0.0.13:
resolution: {integrity: sha512-K4vjMx+pi+Xbmm/m6xb17hml8w+0Bk89SiqGuDiB5zaRcTup7K5iqmZ2STQRrTZkjxSNh+eX26R67x2l0XHBIg==}
engines: {node: '>=8'}
hasBin: true
dependencies:
@ -5671,7 +5674,7 @@ packages:
minecraft-data: 3.62.0
minecraft-protocol: github.com/zardoy/minecraft-protocol/2c14a686bfe7cbd9a5c87b629b402295ee86219f
mkdirp: 2.1.6
moment: 2.29.4
moment: 2.30.1
needle: 2.9.1
node-gzip: 1.1.2
node-rsa: 1.1.1
@ -11149,8 +11152,8 @@ packages:
dependencies:
nearley: 2.20.1
/moment@2.29.4:
resolution: {integrity: sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==}
/moment@2.30.1:
resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==}
dev: false
/moo@0.5.2:

View file

@ -4,7 +4,8 @@ import * as nbt from 'prismarine-nbt'
import RegionFile from 'prismarine-provider-anvil/src/region'
import { versions } from 'minecraft-data'
import { openWorldDirectory, openWorldZip } from './browserfs'
import { isGameActive, showNotification } from './globalState'
import { isGameActive } from './globalState'
import { showNotification } from './react/NotificationProvider'
const parseNbt = promisify(nbt.parse)
const simplifyNbt = nbt.simplify
@ -100,9 +101,7 @@ async function handleDroppedFile (file: File) {
alert('Couldn\'t parse nbt, ensure you are opening .dat or file (or .zip/folder with a world)')
throw err
})
showNotification({
message: `${file.name} data available in browser console`,
})
showNotification(`${file.name} data available in browser console`)
console.log('raw', parsed)
console.log('simplified', nbt.simplify(parsed))
}

View file

@ -158,17 +158,4 @@ export const gameAdditionalState = proxy({
window.gameAdditionalState = gameAdditionalState
// rename current (non-stackable) notification to one-time (system) notification
const initialNotification = {
show: false,
autoHide: true,
message: '',
type: 'info',
}
export const notification = proxy(initialNotification)
export const showNotification = (newNotification: Partial<typeof notification>) => {
Object.assign(notification, { show: true, ...newNotification }, initialNotification)
}
// todo restore auto-save on interval for player data! (or implement it in flying squid since there is already auto-save for world)

View file

@ -60,7 +60,6 @@ import {
insertActiveModalStack,
isGameActive,
miscUiState,
notification
} from './globalState'
@ -95,6 +94,8 @@ import { downloadSoundsIfNeeded } from './soundSystem'
import { ua } from './react/utils'
import { handleMovementStickDelta, joystickPointer } from './react/TouchAreasControls'
import { possiblyHandleStateVariable } from './googledrive'
import flyingSquidEvents from './flyingSquidEvents'
import { hideNotification, notificationProxy } from './react/NotificationProvider'
window.debug = debug
window.THREE = THREE
@ -366,6 +367,7 @@ async function connect (connectOptions: {
})
}
}
let lastPacket = undefined as string | undefined
const onPossibleErrorDisconnect = () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
if (lastPacket && bot?._client && bot._client.state !== 'play') {
@ -373,6 +375,7 @@ async function connect (connectOptions: {
}
}
const handleError = (err) => {
console.error(err)
errorAbortController.abort()
if (isCypress()) throw err
if (miscUiState.gameLoaded) return
@ -471,6 +474,7 @@ async function connect (connectOptions: {
setLoadingScreenStatus(newStatus, false, false, true)
})
})
flyingSquidEvents()
}
let initialLoadingText: string
@ -570,7 +574,6 @@ async function connect (connectOptions: {
destroyAll()
})
let lastPacket = undefined as string | undefined
const packetBeforePlay = (_, __, ___, fullBuffer) => {
lastPacket = fullBuffer.toString()
}
@ -676,7 +679,9 @@ async function connect (connectOptions: {
}
function changeCallback () {
notification.show = false
if (notificationProxy.id === 'pointerlockchange') {
hideNotification()
}
if (renderer.xr.isPresenting) return // todo
if (!pointerLock.hasPointerLock && activeModalStack.length === 0) {
showModal(pauseMenu)

View file

@ -60,6 +60,7 @@ export const loadSave = async (root = '/world') => {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete forceCachedDataPaths[key]
}
// eslint-disable-next-line guard-for-in
for (const key in forceRedirectPaths) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete forceRedirectPaths[key]

View file

@ -1,81 +0,0 @@
//@ts-check
// create lit element
const { LitElement, html, css } = require('lit')
const { subscribe } = require('valtio')
const { notification } = require('../globalState')
class Notification extends LitElement {
static get properties () {
return {
renderHtml: { type: Boolean },
}
}
constructor () {
super()
this.renderHtml = false
let timeout
subscribe(notification, () => {
if (timeout) clearTimeout(timeout)
this.requestUpdate()
if (!notification.show) return
this.renderHtml = true
if (!notification.autoHide) return
timeout = setTimeout(() => {
notification.show = false
}, 3000)
})
}
render () {
if (!this.renderHtml) return
const show = notification.show && notification.message
return html`
<div @transitionend=${this.ontransitionend} class="notification notification-${notification.type} ${show ? 'notification-show' : ''}">
${notification.message}
</div>
`
}
ontransitionend = (event) => {
if (event.propertyName !== 'opacity') return
if (!notification.show) {
this.renderHtml = false
}
}
static get styles () {
return css`
.notification {
position: absolute;
bottom: 0;
right: 0;
min-width: 200px;
padding: 10px;
white-space: nowrap;
font-size: 12px;
color: #fff;
text-align: center;
background: #000;
opacity: 0;
transition: opacity 0.3s ease-in-out;
}
.notification-info {
background: #000;
}
.notification-error {
background: #d00;
}
.notification-show {
opacity: 1;
}
`
}
}
window.customElements.define('pmui-notification', Notification)

View file

@ -4,7 +4,7 @@ const { LitElement, html, css } = require('lit')
const { subscribe } = require('valtio')
const { subscribeKey } = require('valtio/utils')
const { usedServerPathsV1 } = require('flying-squid/dist/lib/modules/world')
const { hideCurrentModal, showModal, miscUiState, notification, openOptionsMenu } = require('../globalState')
const { hideCurrentModal, showModal, miscUiState, openOptionsMenu } = require('../globalState')
const { fsState } = require('../loadSave')
const { openGithub, setLoadingScreenStatus } = require('../utils')
const { disconnect } = require('../flyingSquidUtils')
@ -143,8 +143,6 @@ class PauseScreen extends LitElement {
show () {
this.focus()
// todo?
notification.show = false
}
onReturnPress () {

View file

@ -0,0 +1,71 @@
import { Transition } from 'react-transition-group'
import PixelartIcon from './PixelartIcon'
// slide up
const startStyle = { opacity: 0, transform: 'translateY(100%)' }
const endExitStyle = { opacity: 0, transform: 'translateY(-100%)' }
const endStyle = { opacity: 1, transform: 'translateY(0)' }
const stateStyles = {
entering: startStyle,
entered: endStyle,
exiting: endExitStyle,
exited: endExitStyle,
}
const duration = 200
const basicStyle = {
transition: `${duration}ms ease-in-out all`,
}
// save pass: login
export default ({ type = 'message', message, subMessage = '', open, icon = '', action = undefined as (() => void) | undefined }) => {
const isError = type === 'error'
icon ||= isError ? 'alert' : 'message'
return <Transition
in={open}
timeout={duration}
mountOnEnter
unmountOnExit
>
{state => {
const addStyles = { ...basicStyle, ...stateStyles[state] }
return <div className={`app-notification ${isError ? 'error-notification' : ''}`} onClick={action} style={{
position: 'fixed',
top: 0,
right: 0,
width: '160px',
whiteSpace: 'nowrap',
fontSize: '9px',
display: 'flex',
gap: 4,
alignItems: 'center',
padding: '3px 8px',
background: isError ? 'rgba(255, 0, 0, 0.7)' : 'rgba(0, 0, 0, 0.7)',
borderRadius: '0 0 0 5px',
pointerEvents: action ? 'auto' : 'none',
zIndex: 1200, // even above stats
...addStyles
}}>
<PixelartIcon iconName={icon} styles={{ fontSize: 10 }} />
<div style={{
display: 'flex',
flexDirection: 'column',
gap: 2,
}}>
<div>
{message}
</div>
<div style={{
fontSize: '7px',
whiteSpace: 'nowrap',
color: 'lightgray',
}}>{subMessage}</div>
</div>
</div>
}}
</Transition>
}

View file

@ -0,0 +1,67 @@
import React, { useEffect } from 'react'
import { proxy, useSnapshot } from 'valtio'
import Notification from './Notification'
type NotificationType = React.ComponentProps<typeof Notification> & {
autoHide: boolean
id: string
}
// todo stacking
export const notificationProxy = proxy({
message: '',
open: false,
type: 'message',
subMessage: '',
icon: '',
autoHide: true,
id: '',
} satisfies NotificationType as NotificationType)
export const showNotification = (
message: string,
subMessage = '',
isError = false,
icon = '',
action = undefined as (() => void) | undefined,
autoHide = true
) => {
notificationProxy.message = message
notificationProxy.subMessage = subMessage
notificationProxy.type = isError ? 'error' : 'message'
notificationProxy.icon = icon
notificationProxy.open = true
notificationProxy.autoHide = autoHide
notificationProxy.action = action
}
export const hideNotification = () => {
// openNotification('') // reset
notificationProxy.open = false
}
export default () => {
const { autoHide, message, open, icon, type, subMessage } = useSnapshot(notificationProxy)
useEffect(() => {
if (autoHide && open) {
setTimeout(() => {
hideNotification()
}, 7000)
}
}, [autoHide, open])
// test
// useEffect(() => {
// setTimeout(() => {
// openNotification('test', 'test', false, 'message')
// }, 1000)
// }, [])
return <Notification
type={type}
message={message}
subMessage={subMessage}
open={open}
icon={icon}
/>
}

View file

@ -14,6 +14,7 @@ import Tabs from './Tabs'
export interface WorldProps {
name: string
title: string
iconBase64?: string
size?: number
lastPlayed?: number
isFocused?: boolean
@ -22,7 +23,7 @@ export interface WorldProps {
onInteraction?(interaction: 'enter' | 'space')
}
const World = ({ name, isFocused, title, lastPlayed, size, detail = '', onFocus, onInteraction }: WorldProps) => {
const World = ({ name, isFocused, title, lastPlayed, size, detail = '', onFocus, onInteraction, iconBase64 }: WorldProps) => {
const timeRelativeFormatted = useMemo(() => {
if (!lastPlayed) return
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
@ -47,7 +48,7 @@ const World = ({ name, isFocused, title, lastPlayed, size, detail = '', onFocus,
onInteraction?.(e.code === 'Enter' ? 'enter' : 'space')
}
}} onDoubleClick={() => onInteraction?.('enter')}>
<img className={styles.world_image} src={missingWorldPreview} />
<img className={`${styles.world_image} ${iconBase64 ? '' : styles.image_missing}`} src={iconBase64 ? `data:image/png;base64,${iconBase64}` : missingWorldPreview} alt='world preview' />
<div className={styles.world_info}>
<div className={styles.world_title} title='level.dat world name'>{title}</div>
<div className='muted'>{timeRelativeFormatted} {detail.slice(-30)}</div>
@ -123,8 +124,8 @@ export default ({ worldData, onGeneralAction, onWorldAction, activeProvider, set
}
{
worldData
? worldData.filter(data => data.title.toLowerCase().includes(search.toLowerCase())).map(({ name, title, size, lastPlayed, detail }) => (
<World title={title} lastPlayed={lastPlayed} size={size} name={name} onFocus={setFocusedWorld} isFocused={focusedWorld === name} key={name} onInteraction={(interaction) => {
? worldData.filter(data => data.title.toLowerCase().includes(search.toLowerCase())).map(({ name, size, detail, ...rest }) => (
<World {...rest} size={size} name={name} onFocus={setFocusedWorld} isFocused={focusedWorld === name} key={name} onInteraction={(interaction) => {
if (interaction === 'enter') onWorldAction('load', name)
else if (interaction === 'space') firstButton.current?.focus()
}} detail={detail} />

View file

@ -30,12 +30,14 @@ const providersEnableFeatures = {
calculateSize: true,
delete: true,
export: true,
icon: true
},
google: {
calculateSize: false,
// TODO
delete: false,
export: false,
icon: true
}
}
@ -67,12 +69,22 @@ export const readWorlds = (abortController: AbortController) => {
size += stat.size
}
}
let iconBase64 = ''
if (providersEnableFeatures[provider].icon) {
const iconPath = `${worldsPath}/${folder}/icon.png`
try {
iconBase64 = await fs.promises.readFile(iconPath, 'base64')
} catch {
// ignore
}
}
const levelName = levelDat.LevelName as string | undefined
return {
name: folder,
title: levelName ?? folder,
lastPlayed: levelDat.LastPlayed && longArrayToNumber(levelDat.LastPlayed),
detail: `${levelDat.Version?.Name ?? 'unknown version'}, ${folder}`,
iconBase64,
size,
} satisfies WorldProps
}))).filter((x, i) => {

View file

@ -36,9 +36,11 @@
}
.world_image {
height: 100%;
filter: grayscale(1);
aspect-ratio: 1;
}
.world_image.image_missing {
filter: grayscale(1);
}
.world_root.world_focused {
border-color: white;
}

View file

@ -1,6 +1,5 @@
//@ts-check
import { renderToDom } from '@zardoy/react-util'
import { renderToDom, ErrorBoundary } from '@zardoy/react-util'
import { useSnapshot } from 'valtio'
import { QRCodeSVG } from 'qrcode.react'
import { createPortal } from 'react-dom'
@ -22,9 +21,10 @@ import widgets from './react/widgets'
import { useIsWidgetActive } from './react/utils'
import GlobalSearchInput from './GlobalSearchInput'
import TouchAreasControlsProvider from './react/TouchAreasControlsProvider'
import NotificationProvider from './react/NotificationProvider'
const Portal = ({ children, to }) => {
return createPortal(children, to)
const RobustPortal = ({ children, to }) => {
return createPortal(<PerComponentErrorBoundary>{children}</PerComponentErrorBoundary>, to)
}
const DisplayQr = () => {
@ -57,7 +57,7 @@ const InGameUi = () => {
if (!gameLoaded) return
return <>
<Portal to={document.querySelector('#ui-root')}>
<RobustPortal to={document.querySelector('#ui-root')}>
{/* apply scaling */}
<DeathScreenProvider />
<ChatProvider />
@ -65,13 +65,13 @@ const InGameUi = () => {
<TitleProvider />
<ScoreboardProvider />
<TouchAreasControlsProvider />
</Portal>
</RobustPortal>
<DisplayQr />
<Portal to={document.body}>
<RobustPortal to={document.body}>
{/* becaues of z-index */}
<TouchControls />
<GlobalSearchInput />
</Portal>
</RobustPortal>
</>
}
@ -90,7 +90,7 @@ const App = () => {
return <div>
<EnterFullscreenButton />
<InGameUi />
<Portal to={document.querySelector('#ui-root')}>
<RobustPortal to={document.querySelector('#ui-root')}>
<AllWidgets />
<SingleplayerProvider />
<CreateWorldProvider />
@ -98,10 +98,20 @@ const App = () => {
<SelectOption />
<OptionsRenderApp />
<MainMenuRenderApp />
</Portal>
<NotificationProvider />
</RobustPortal>
</div>
}
const PerComponentErrorBoundary = ({ children }) => {
return children.map((child, i) => <ErrorBoundary key={i} renderError={(error) => {
// notfic
const componentNameClean = (child.type.name || child.type.displayName || 'Unknown').replaceAll(/__|_COMPONENT/g, '')
console.error(`UI component ${componentNameClean} crashed!`, componentNameClean, error.message)
return null
}}>{child}</ErrorBoundary>)
}
renderToDom(<App />, {
strictMode: false,
selector: '#react-root',

View file

@ -3,9 +3,10 @@ import { Vec3 } from 'vec3'
import { versionToNumber } from 'prismarine-viewer/viewer/prepare/utils'
import { loadScript } from 'prismarine-viewer/viewer/lib/utils'
import type { Block } from 'prismarine-block'
import { miscUiState, showNotification } from './globalState'
import { miscUiState } from './globalState'
import { options } from './optionsStorage'
import { loadOrPlaySound } from './basicSounds'
import { showNotification } from './react/NotificationProvider'
subscribeKey(miscUiState, 'gameLoaded', async () => {
if (!miscUiState.gameLoaded) return
@ -21,9 +22,7 @@ subscribeKey(miscUiState, 'gameLoaded', async () => {
if (!soundsMap) {
console.warn('No sounds map for version', bot.version, 'supported versions are', Object.keys(allSoundsMap).join(', '))
showNotification({
message: 'No sounds map for version ' + bot.version,
})
showNotification('Warning', 'No sounds map for version ' + bot.version)
return
}

View file

@ -9,7 +9,7 @@ import blocksFileNames from '../generated/blocks.json'
import type { BlockStates } from './playerWindows'
import { copyFilesAsync, copyFilesAsyncWithProgress, mkdirRecursive, removeFileRecursiveAsync } from './browserfs'
import { setLoadingScreenStatus } from './utils'
import { showNotification } from './globalState'
import { showNotification } from './react/NotificationProvider'
export const resourcePackState = proxy({
resourcePackInstalled: false,
@ -96,9 +96,7 @@ export const completeTexturePackInstall = async (name?: string) => {
await genTexturePackTextures(viewer.version)
}
setLoadingScreenStatus(undefined)
showNotification({
message: 'Texturepack installed!',
})
showNotification('Texturepack installed')
await updateTexturePackInstalledState()
}

View file

@ -1,6 +1,7 @@
import { hideModal, isGameActive, miscUiState, notification, showModal } from './globalState'
import { hideModal, isGameActive, miscUiState, showModal } from './globalState'
import { options } from './optionsStorage'
import { appStatusState } from './react/AppStatusProvider'
import { notificationProxy, showNotification } from './react/NotificationProvider'
export const goFullscreen = async (doToggle = false) => {
if (!document.fullscreenElement) {
@ -32,8 +33,8 @@ export const pointerLock = {
void goFullscreen()
}
const displayBrowserProblem = () => {
notification.show = true
notification.message = navigator['keyboard'] ? 'Browser Limitation: Click on screen, enable Auto Fullscreen or F11' : 'Browser Limitation: Click on screen or use fullscreen in Chrome'
showNotification('Browser Delay Limitation', navigator['keyboard'] ? 'Click on screen, enable Auto Fullscreen or F11' : 'Click on screen or use fullscreen in Chrome')
notificationProxy.id = 'pointerlockchange'
}
if (!(document.fullscreenElement && navigator['keyboard']) && this.justHitEscape) {
displayBrowserProblem()