Skip to content
This repository has been archived by the owner on Jun 10, 2022. It is now read-only.

Commit

Permalink
[SDK] Dialog system (#448)
Browse files Browse the repository at this point in the history
* Dialog system design and stubs

* Update design

* Tweaks

* Simplify design

* Add implementation

* Add test

* Move design doc

* Handle dialog failure
  • Loading branch information
stevenvergenz authored Sep 30, 2019
1 parent 951ed9e commit aad0178
Show file tree
Hide file tree
Showing 8 changed files with 224 additions and 5 deletions.
59 changes: 59 additions & 0 deletions design/user.prompt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Dialogs

Goal: Display a message to a single user, and return a response.

## Architecture

Create a new method on the `User` object:

```ts
class User {
/**
* Present the user with a modal dialog, and resolve with the response.
* @param text A message presented to the user.
* @param acceptInput Whether or not the dialog should include a text input field.
*/
public prompt(text: string, acceptInput?: boolean): Promise<DialogResponse> { }
}

type DialogResponse = {
submitted: boolean;
text?: string;
};
```

## Network

The network messages getting sent to clients might look like this:

```ts
export type ShowDialog = Payload & {
type: 'show-dialog';
text: string;
acceptInput?: boolean;
};

export type DialogResponse = Payload & {
type: 'dialog-response';
submitted: boolean;
text?: string;
}
```
## Sync layer considerations
None. Since the messages are going to and coming from a single client, no synchronization is necessary.
## Client implementation
The dialog should have a per-client implementation, so it can match the aesthetic of the host client. As such, I
propose adding a new factory type to the `InitializeAPI` call:
```cs
public interface IDialogFactory {
void ShowDialog(string text, bool allowInput, Action<bool,string> callback);
}
```
General recommendation is that these dialogs are queued, so only one is visible at a time, but different clients
may have different solutions to this problem.
2 changes: 2 additions & 0 deletions packages/functional-tests/src/tests/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import LightTest from './light-test';
import LookAtTest from './look-at-test';
import PhysicsSimTest from './physics-sim-test';
import PrimitivesTest from './primitives-test';
import PromptTest from './prompt-test';
import ReparentTest from './reparent-test';
import SoundTest from './sound-test';
import StatsTest from './stats-test';
Expand Down Expand Up @@ -59,6 +60,7 @@ export const Factories = {
'look-at-test': (...args) => new LookAtTest(...args),
'physics-sim-test': (...args) => new PhysicsSimTest(...args),
'primitives-test': (...args) => new PrimitivesTest(...args),
'prompt-test': (...args) => new PromptTest(...args),
'reparent-test': (...args) => new ReparentTest(...args),
'sound-test': (...args) => new SoundTest(...args),
'stats-test': (...args) => new StatsTest(...args),
Expand Down
69 changes: 69 additions & 0 deletions packages/functional-tests/src/tests/prompt-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*!
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/

import * as MRE from '@microsoft/mixed-reality-extension-sdk';

import { Test } from '../test';

export default class PromptTest extends Test {
public expectedResultDescription = "Display a text prompt to a user";

public async run(root: MRE.Actor): Promise<boolean> {
let success = true;

const noTextButton = MRE.Actor.Create(this.app.context, {
actor: {
name: 'noTextButton',
parentId: root.id,
transform: { local: { position: { x: -1, y: 1, z: -1 } } },
collider: { geometry: { shape: 'box', size: { x: 0.5, y: 0.2, z: 0.01 } } },
text: {
contents: "Click for message",
height: 0.1,
anchor: MRE.TextAnchorLocation.MiddleCenter,
justify: MRE.TextJustify.Center
}
}
});
noTextButton.setBehavior(MRE.ButtonBehavior).onClick(user => {
user.prompt(`Hello ${user.name}!`)
.catch(err => {
console.error(err);
success = false;
this.stop();
});
});

const textButton = MRE.Actor.Create(this.app.context, {
actor: {
name: 'textButton',
parentId: root.id,
transform: { local: { position: { x: 1, y: 1, z: -1 } } },
collider: { geometry: { shape: 'box', size: { x: 0.5, y: 0.2, z: 0.01 } } },
text: {
contents: "Click for prompt",
height: 0.1,
anchor: MRE.TextAnchorLocation.MiddleCenter,
justify: MRE.TextJustify.Center
}
}
});
textButton.setBehavior(MRE.ButtonBehavior).onClick(user => {
user.prompt("Who's your favorite musician?", true)
.then(res => {
textButton.text.contents =
`Click for prompt\nLast response: ${res.submitted ? res.text : "<cancelled>"}`;
})
.catch(err => {
console.error(err);
success = false;
this.stop();
});
});

await this.stoppedAsync();
return success;
}
}
20 changes: 20 additions & 0 deletions packages/sdk/src/adapters/multipeer/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,9 @@ export const Rules: { [id in Payloads.PayloadType]: Rule } = {
}
},

// ========================================================================
'dialog-response': ClientOnlyRule,

// ========================================================================
'engine2app-rpc': ClientOnlyRule,

Expand Down Expand Up @@ -1066,6 +1069,23 @@ export const Rules: { [id in Payloads.PayloadType]: Rule } = {
}
},

// ========================================================================
'show-dialog': {
...DefaultRule,
synchronization: {
stage: 'never',
before: 'ignore',
during: 'ignore',
after: 'ignore'
},
client: {
...DefaultRule.client,
shouldSendToUser: (message: Message<Payloads.ShowDialog>, userId: string) => {
return message.payload.userId === userId;
}
}
},

// ========================================================================
'sync-animations': {
...DefaultRule,
Expand Down
5 changes: 3 additions & 2 deletions packages/sdk/src/types/internal/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { log } from '../../log';
import * as Protocols from '../../protocols';
import { Execution } from '../../protocols/execution';
import { Handshake } from '../../protocols/handshake';
import { ExportedPromise } from '../../utils/exportedPromise';
import resolveJsonValues from '../../utils/resolveJsonValues';
import safeGet from '../../utils/safeAccessPath';
import { OperatingModel } from '../network/operatingModel';
Expand Down Expand Up @@ -442,8 +443,8 @@ export class InternalContext {
});
}

public sendPayload(payload: Payloads.Payload): void {
this.protocol.sendPayload(payload);
public sendPayload(payload: Payloads.Payload, promise?: ExportedPromise): void {
this.protocol.sendPayload(payload, promise);
}

public receiveRPC(payload: Payloads.EngineToAppRPC) {
Expand Down
29 changes: 27 additions & 2 deletions packages/sdk/src/types/internal/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
* Licensed under the MIT License.
*/

import { User, UserLike } from '../..';
import { DialogResponse, User, UserLike } from '../..';
import * as Payloads from '../network/payloads';
import { InternalPatchable } from '../patchable';
import { InternalContext } from './context';

/**
* @hidden
Expand All @@ -15,7 +17,7 @@ export class InternalUser implements InternalPatchable<UserLike> {
public observing = true;
public patch: UserLike;

constructor(public user: User) {
constructor(public user: User, public context: InternalContext) {
}

public getPatchAndReset(): UserLike {
Expand All @@ -26,4 +28,27 @@ export class InternalUser implements InternalPatchable<UserLike> {
}
return patch;
}

public prompt(text: string, acceptInput: boolean): Promise<DialogResponse> {
const payload = {
type: 'show-dialog',
userId: this.user.id,
text,
acceptInput
} as Payloads.ShowDialog;

return new Promise<Payloads.DialogResponse>((resolve, reject) => {
this.context.sendPayload(payload, { resolve, reject });
})
.then(response => {
if (response.failureMessage) {
return Promise.reject(response.failureMessage);
} else {
return Promise.resolve({
submitted: response.submitted,
text: response.text
} as DialogResponse);
}
});
}
}
24 changes: 24 additions & 0 deletions packages/sdk/src/types/network/payloads/payloads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export type PayloadType
| 'create-empty'
| 'create-from-library'
| 'destroy-actors'
| 'dialog-response'
| 'engine2app-rpc'
| 'handshake'
| 'handshake-complete'
Expand All @@ -49,6 +50,7 @@ export type PayloadType
| 'set-authoritative'
| 'set-behavior'
| 'set-media-state'
| 'show-dialog'
| 'sync-animations'
| 'sync-complete'
| 'sync-request'
Expand Down Expand Up @@ -356,3 +358,25 @@ export type InterpolateActor = Payload & {
curve: number[];
enabled: boolean;
};

/**
* @hidden
* App to engine. Prompt to show modal dialog box.
*/
export type ShowDialog = Payload & {
type: 'show-dialog';
userId: string;
text: string;
acceptInput?: boolean;
};

/**
* @hidden
* Engine to app. Acknowledgement of modal dialog.
*/
export type DialogResponse = Payload & {
type: 'dialog-response';
failureMessage: string;
submitted: boolean;
text?: string;
};
21 changes: 20 additions & 1 deletion packages/sdk/src/types/runtime/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ export interface UserSet {
[id: string]: User;
}

/**
* The structure returned from [[User.prompt]].
*/
export type DialogResponse = {
/** Whether the user replied in the positive (OK/Accept) or in the negative (Cancel). */
submitted: boolean;
/** The string provided by the user in the dialog text input field. */
text?: string;
};

export class User implements UserLike, Patchable<UserLike> {
// tslint:disable:variable-name
private _internal: InternalUser;
Expand Down Expand Up @@ -71,7 +81,16 @@ export class User implements UserLike, Patchable<UserLike> {

// tslint:disable-next-line:variable-name
constructor(private _context: Context, private _id: string) {
this._internal = new InternalUser(this);
this._internal = new InternalUser(this, this._context.internal);
}

/**
* Present the user with a modal dialog, and resolve with the response.
* @param text A message presented to the user.
* @param acceptInput Whether or not the dialog should include a text input field.
*/
public prompt(text: string, acceptInput = false): Promise<DialogResponse> {
return this.internal.prompt(text, acceptInput);
}

public copy(from: Partial<UserLike>): this {
Expand Down

0 comments on commit aad0178

Please sign in to comment.