diff --git a/docs/maps/api-ui.md b/docs/maps/api-ui.md index 286f2ac7..89d46932 100644 --- a/docs/maps/api-ui.md +++ b/docs/maps/api-ui.md @@ -86,4 +86,51 @@ WA.ui.registerMenuCommand("test", () => {
-
\ No newline at end of file + + + + +### Awaiting User Confirmation (with space bar) + +``` +WA.ui.displayActionMessage({ + message: string, + callback: () => void, + type?: "message"|"warning", +}): ActionMessage +``` + +Displays a message at the bottom of the screen (that will disappear when space bar is pressed). + +
+ +
+ +Example: + +```javascript +const triggerMessage = WA.ui.displayActionMessage({ + message: "press 'space' to confirm", + callback: () => { + WA.chat.sendChatMessage("confirmed", "trigger message logic") + } +}); + +setTimeout(() => { + // later + triggerMessage.remove(); +}, 1000) +``` + +Please note that `displayActionMessage` returns an object of the `ActionMessage` class. + +The `ActionMessage` class contains a single method: `remove(): Promise`. This will obviously remove the message when called. + +```javascript +class ActionMessage { + /** + * Hides the message + */ + remove() {}; +} +``` diff --git a/front/.eslintrc.json b/front/.eslintrc.json index 037fddae..45b44456 100644 --- a/front/.eslintrc.json +++ b/front/.eslintrc.json @@ -26,7 +26,6 @@ "rules": { "no-unused-vars": "off", "@typescript-eslint/no-explicit-any": "error", - // TODO: remove those ignored rules and write a stronger code! "@typescript-eslint/no-floating-promises": "off", "@typescript-eslint/no-unsafe-call": "off", diff --git a/front/src/Api/Events/IframeEvent.ts b/front/src/Api/Events/IframeEvent.ts index 0590939b..ca1d9cc3 100644 --- a/front/src/Api/Events/IframeEvent.ts +++ b/front/src/Api/Events/IframeEvent.ts @@ -9,6 +9,7 @@ import type { OpenCoWebSiteEvent } from "./OpenCoWebSiteEvent"; import type { OpenPopupEvent } from "./OpenPopupEvent"; import type { OpenTabEvent } from "./OpenTabEvent"; import type { UserInputChatEvent } from "./UserInputChatEvent"; +import type { MapDataEvent } from "./MapDataEvent"; import type { LayerEvent } from "./LayerEvent"; import type { SetPropertyEvent } from "./setPropertyEvent"; import type { LoadSoundEvent } from "./LoadSoundEvent"; @@ -23,6 +24,13 @@ import { isMapDataEvent } from "./MapDataEvent"; import { isSetVariableEvent } from "./SetVariableEvent"; import type { LoadTilesetEvent } from "./LoadTilesetEvent"; import { isLoadTilesetEvent } from "./LoadTilesetEvent"; +import type { + MessageReferenceEvent, + removeActionMessage, + triggerActionMessage, + TriggerActionMessageEvent, +} from "./ui/TriggerActionMessageEvent"; +import { isMessageReferenceEvent, isTriggerActionMessageEvent } from "./ui/TriggerActionMessageEvent"; export interface TypedMessageEvent extends MessageEvent { data: T; @@ -73,6 +81,7 @@ export interface IframeResponseEventMap { hasPlayerMoved: HasPlayerMovedEvent; menuItemClicked: MenuItemClickedEvent; setVariable: SetVariableEvent; + messageTriggered: MessageReferenceEvent; } export interface IframeResponseEvent { type: T; @@ -105,6 +114,14 @@ export const iframeQueryMapTypeGuards = { query: isLoadTilesetEvent, answer: tg.isNumber, }, + triggerActionMessage: { + query: isTriggerActionMessageEvent, + answer: tg.isUndefined, + }, + removeActionMessage: { + query: isMessageReferenceEvent, + answer: tg.isUndefined, + }, }; type GuardedType = T extends (x: unknown) => x is infer T ? T : never; diff --git a/front/src/Api/Events/ui/TriggerActionMessageEvent.ts b/front/src/Api/Events/ui/TriggerActionMessageEvent.ts new file mode 100644 index 00000000..48f1cae6 --- /dev/null +++ b/front/src/Api/Events/ui/TriggerActionMessageEvent.ts @@ -0,0 +1,26 @@ +import * as tg from "generic-type-guard"; + +export const triggerActionMessage = "triggerActionMessage"; +export const removeActionMessage = "removeActionMessage"; + +export const isActionMessageType = tg.isSingletonStringUnion("message", "warning"); + +export type ActionMessageType = tg.GuardedType; + +export const isTriggerActionMessageEvent = new tg.IsInterface() + .withProperties({ + message: tg.isString, + uuid: tg.isString, + type: isActionMessageType, + }) + .get(); + +export type TriggerActionMessageEvent = tg.GuardedType; + +export const isMessageReferenceEvent = new tg.IsInterface() + .withProperties({ + uuid: tg.isString, + }) + .get(); + +export type MessageReferenceEvent = tg.GuardedType; diff --git a/front/src/Api/Events/ui/TriggerMessageEventHandler.ts b/front/src/Api/Events/ui/TriggerMessageEventHandler.ts new file mode 100644 index 00000000..fb64c742 --- /dev/null +++ b/front/src/Api/Events/ui/TriggerMessageEventHandler.ts @@ -0,0 +1,24 @@ +import { + isMessageReferenceEvent, + isTriggerActionMessageEvent, + removeActionMessage, + triggerActionMessage, +} from './TriggerActionMessageEvent'; + +import * as tg from 'generic-type-guard'; + +const isTriggerMessageEventObject = new tg.IsInterface() + .withProperties({ + type: tg.isSingletonString(triggerActionMessage), + data: isTriggerActionMessageEvent, + }) + .get(); + +const isTriggerMessageRemoveEventObject = new tg.IsInterface() + .withProperties({ + type: tg.isSingletonString(removeActionMessage), + data: isMessageReferenceEvent, + }) + .get(); + +export const isTriggerMessageHandlerEvent = tg.isUnion(isTriggerMessageEventObject, isTriggerMessageRemoveEventObject); diff --git a/front/src/Api/IframeListener.ts b/front/src/Api/IframeListener.ts index d9286ef0..2ed65f15 100644 --- a/front/src/Api/IframeListener.ts +++ b/front/src/Api/IframeListener.ts @@ -1,4 +1,5 @@ import { Subject } from "rxjs"; +import type * as tg from "generic-type-guard"; import { ChatEvent, isChatEvent } from "./Events/ChatEvent"; import { HtmlUtils } from "../WebRtc/HtmlUtils"; import type { EnterLeaveEvent } from "./Events/EnterLeaveEvent"; @@ -121,7 +122,7 @@ class IframeListener { init() { window.addEventListener( "message", - (message: TypedMessageEvent>) => { + (message: MessageEvent) => { // Do we trust the sender of this message? // Let's only accept messages from the iframe that are allowed. // Note: maybe we could restrict on the domain too for additional security (in case the iframe goes to another domain). @@ -416,6 +417,15 @@ class IframeListener { }); } + sendActionMessageTriggered(uuid: string): void { + this.postMessage({ + type: "messageTriggered", + data: { + uuid, + }, + }); + } + /** * Sends the message... to all allowed iframes. */ diff --git a/front/src/Api/iframe/IframeApiContribution.ts b/front/src/Api/iframe/IframeApiContribution.ts index e4ba089e..96548d5e 100644 --- a/front/src/Api/iframe/IframeApiContribution.ts +++ b/front/src/Api/iframe/IframeApiContribution.ts @@ -1,51 +1,66 @@ import type * as tg from "generic-type-guard"; import type { IframeEvent, - IframeEventMap, IframeQuery, + IframeEventMap, + IframeQuery, IframeQueryMap, - IframeResponseEventMap -} from '../Events/IframeEvent'; -import type {IframeQueryWrapper} from "../Events/IframeEvent"; + IframeResponseEventMap, +} from "../Events/IframeEvent"; +import type { IframeQueryWrapper } from "../Events/IframeEvent"; export function sendToWorkadventure(content: IframeEvent) { - window.parent.postMessage(content, "*") + window.parent.postMessage(content, "*"); } let queryNumber = 0; -export const answerPromises = new Map)) => void, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - reject: (reason?: any) => void -}>(); +export const answerPromises = new Map< + number, + { + resolve: ( + value: + | IframeQueryMap[keyof IframeQueryMap]["answer"] + | PromiseLike + ) => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + reject: (reason?: any) => void; + } +>(); -export function queryWorkadventure(content: IframeQuery): Promise { - return new Promise((resolve, reject) => { - window.parent.postMessage({ - id: queryNumber, - query: content - } as IframeQueryWrapper, "*"); +export function queryWorkadventure( + content: IframeQuery +): Promise { + return new Promise((resolve, reject) => { + window.parent.postMessage( + { + id: queryNumber, + query: content, + } as IframeQueryWrapper, + "*" + ); answerPromises.set(queryNumber, { resolve, - reject + reject, }); queryNumber++; }); } -type GuardedType> = Guard extends tg.TypeGuard ? T : never +type GuardedType> = Guard extends tg.TypeGuard ? T : never; -export interface IframeCallback> { - - typeChecker: Guard, - callback: (payloadData: T) => void +export interface IframeCallback< + Key extends keyof IframeResponseEventMap, + T = IframeResponseEventMap[Key], + Guard = tg.TypeGuard +> { + typeChecker: Guard; + callback: (payloadData: T) => void; } export interface IframeCallbackContribution extends IframeCallback { - - type: Key + type: Key; } /** @@ -54,9 +69,10 @@ export interface IframeCallbackContribution>, -}> { - - abstract callbacks: T["callbacks"] +export abstract class IframeApiContribution< + T extends { + callbacks: Array>; + } +> { + abstract callbacks: T["callbacks"]; } diff --git a/front/src/Api/iframe/Ui/ActionMessage.ts b/front/src/Api/iframe/Ui/ActionMessage.ts new file mode 100644 index 00000000..912603b9 --- /dev/null +++ b/front/src/Api/iframe/Ui/ActionMessage.ts @@ -0,0 +1,56 @@ +import { + ActionMessageType, + MessageReferenceEvent, + removeActionMessage, + triggerActionMessage, + TriggerActionMessageEvent, +} from "../../Events/ui/TriggerActionMessageEvent"; +import { queryWorkadventure } from "../IframeApiContribution"; +import type { ActionMessageOptions } from "../ui"; +function uuidv4() { + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0, + v = c === "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +export class ActionMessage { + public readonly uuid: string; + private readonly type: ActionMessageType; + private readonly message: string; + private readonly callback: () => void; + + constructor(actionMessageOptions: ActionMessageOptions, private onRemove: () => void) { + this.uuid = uuidv4(); + this.message = actionMessageOptions.message; + this.type = actionMessageOptions.type ?? "message"; + this.callback = actionMessageOptions.callback; + this.create(); + } + + private async create() { + await queryWorkadventure({ + type: triggerActionMessage, + data: { + message: this.message, + type: this.type, + uuid: this.uuid, + } as TriggerActionMessageEvent, + }); + } + + async remove() { + await queryWorkadventure({ + type: removeActionMessage, + data: { + uuid: this.uuid, + } as MessageReferenceEvent, + }); + this.onRemove(); + } + + triggerCallback() { + this.callback(); + } +} diff --git a/front/src/Api/iframe/ui.ts b/front/src/Api/iframe/ui.ts index 61c7076e..ab5b2007 100644 --- a/front/src/Api/iframe/ui.ts +++ b/front/src/Api/iframe/ui.ts @@ -1,10 +1,11 @@ import { isButtonClickedEvent } from "../Events/ButtonClickedEvent"; import { isMenuItemClickedEvent } from "../Events/ui/MenuItemClickedEvent"; -import type { MenuItemRegisterEvent } from "../Events/ui/MenuItemRegisterEvent"; import { IframeApiContribution, sendToWorkadventure } from "./IframeApiContribution"; import { apiCallback } from "./registeredCallbacks"; import type { ButtonClickedCallback, ButtonDescriptor } from "./Ui/ButtonDescriptor"; import { Popup } from "./Ui/Popup"; +import { ActionMessage } from "./Ui/ActionMessage"; +import { isMessageReferenceEvent } from "../Events/ui/TriggerActionMessageEvent"; let popupId = 0; const popups: Map = new Map(); @@ -14,6 +15,7 @@ const popupCallbacks: Map> = new Map< >(); const menuCallbacks: Map void> = new Map(); +const actionMessages = new Map(); interface ZonedPopupOptions { zone: string; @@ -23,6 +25,12 @@ interface ZonedPopupOptions { popupOptions: Array; } +export interface ActionMessageOptions { + message: string; + type?: "message" | "warning"; + callback: () => void; +} + export class WorkAdventureUiCommands extends IframeApiContribution { callbacks = [ apiCallback({ @@ -49,6 +57,16 @@ export class WorkAdventureUiCommands extends IframeApiContribution { + const actionMessage = actionMessages.get(event.uuid); + if (actionMessage) { + actionMessage.triggerCallback(); + } + }, + }), ]; openPopup(targetObject: string, message: string, buttons: ButtonDescriptor[]): Popup { @@ -103,6 +121,14 @@ export class WorkAdventureUiCommands extends IframeApiContribution { + actionMessages.delete(actionMessage.uuid); + }); + actionMessages.set(actionMessage.uuid, actionMessage); + return actionMessage; + } } export default new WorkAdventureUiCommands(); diff --git a/front/src/Components/LayoutManager/LayoutManager.svelte b/front/src/Components/LayoutManager/LayoutManager.svelte index ef90a4e3..5bc6e097 100644 --- a/front/src/Components/LayoutManager/LayoutManager.svelte +++ b/front/src/Components/LayoutManager/LayoutManager.svelte @@ -1,26 +1,5 @@