Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/chubby-lights-send.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@matrix-widget-toolkit/testing': minor
'@matrix-widget-toolkit/api': minor
---

Add support for the delayed events
83 changes: 79 additions & 4 deletions example-widget-mui/src/DicePage/DicePage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ import { MockedWidgetApi, mockWidgetApi } from '@matrix-widget-toolkit/testing';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import axe from 'axe-core';
import { EventDirection, WidgetEventCapability } from 'matrix-widget-api';
import {
EventDirection,
UpdateDelayedEventAction,
WidgetEventCapability,
} from 'matrix-widget-api';
import { ComponentType, PropsWithChildren } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
Expand Down Expand Up @@ -55,7 +59,7 @@ describe('<DicePage />', () => {
screen.getByText(/nobody has thrown the dice in this room yet/i),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /throw dice/i }),
screen.getByRole('button', { name: /throw dice$/i }),
).toBeInTheDocument();
});

Expand All @@ -79,7 +83,7 @@ describe('<DicePage />', () => {
),
]);

const button = await screen.findByRole('button', { name: /throw dice/i });
const button = await screen.findByRole('button', { name: /throw dice$/i });
await userEvent.click(button);

expect(widgetApi.requestCapabilities).toHaveBeenCalledWith([
Expand Down Expand Up @@ -124,7 +128,7 @@ describe('<DicePage />', () => {
it('should throw a dice', async () => {
render(<DicePage />, { wrapper });

const button = await screen.findByRole('button', { name: /throw dice/i });
const button = await screen.findByRole('button', { name: /throw dice$/i });
await userEvent.click(button);

await expect(
Expand All @@ -138,4 +142,75 @@ describe('<DicePage />', () => {
},
);
});

it('should throw a dice delayed', async () => {
render(<DicePage />, { wrapper });

const button = await screen.findByRole('button', {
name: /throw dice 10 seconds delayed/i,
});
await userEvent.click(button);

await expect(
screen.findByText(/your last throw: ./i),
).resolves.toBeInTheDocument();
await expect(
screen.findByText(/your last delay id: ./i),
).resolves.toBeInTheDocument();
await expect(
screen.findByText(/throw dice delayed event actions:/i),
).resolves.toBeInTheDocument();

expect(widgetApi.sendDelayedRoomEvent).toHaveBeenCalledWith(
'net.nordeck.throw_dice',
{
pips: expect.any(Number),
},
expect.any(Number),
);
});

it.each([
UpdateDelayedEventAction.Cancel,
UpdateDelayedEventAction.Restart,
UpdateDelayedEventAction.Send,
])('should update (%s) a throw dice delayed', async (action) => {
render(<DicePage />, { wrapper });

const button = await screen.findByRole('button', {
name: /throw dice 10 seconds delayed/i,
});
await userEvent.click(button);

await expect(
screen.findByText(/your last throw: ./i),
).resolves.toBeInTheDocument();
await expect(
screen.findByText(/Your last delay id: ./i),
).resolves.toBeInTheDocument();

expect(widgetApi.sendDelayedRoomEvent).toHaveBeenCalledWith(
'net.nordeck.throw_dice',
{
pips: expect.any(Number),
},
expect.any(Number),
);

const updateButton = await screen.findByRole('button', {
name: new RegExp(action, 'i'),
});
await userEvent.click(updateButton);

expect(widgetApi.updateDelayedEvent).toHaveBeenCalledWith(
'syd_wlGAStYmBRRdjnWiHSDA',
action,
);

if (action === UpdateDelayedEventAction.Restart) {
expect(updateButton).toBeInTheDocument();
} else {
expect(updateButton).not.toBeInTheDocument();
}
});
});
148 changes: 142 additions & 6 deletions example-widget-mui/src/DicePage/DicePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,29 @@ import { MuiCapabilitiesGuard } from '@matrix-widget-toolkit/mui';
import { useWidgetApi } from '@matrix-widget-toolkit/react';
import {
Alert,
AlertTitle,
Box,
Button,
ButtonGroup,
Card,
CardContent,
Typography,
} from '@mui/material';
import { EventDirection, WidgetEventCapability } from 'matrix-widget-api';
import { ReactElement, useEffect, useState } from 'react';
import {
EventDirection,
MatrixCapabilities,
UpdateDelayedEventAction,
WidgetEventCapability,
} from 'matrix-widget-api';
import { ReactElement, useEffect, useRef, useState } from 'react';
import { filter, map } from 'rxjs';
import { NavigationBar } from '../NavigationPage';
import {
isValidThrowDiceEvent,
STATE_EVENT_THROW_DICE,
ThrowDiceEvent,
isValidThrowDiceEvent,
} from '../events';
import { isError } from '../utils';

/**
* A component that reads and writes room events via the widget API.
Expand All @@ -58,11 +66,19 @@ export const DicePage = (): ReactElement => {
);
};

type Timeout = ReturnType<typeof setTimeout>;
const eventDelayMs = 10000;

export const DiceView = (): ReactElement => {
const widgetApi = useWidgetApi();
const [lastOwnDice, setLastOwnDice] = useState<number | undefined>();
const [lastDelayId, setLastDelayId] = useState<string | undefined>();
const [lastDelayIdExpired, setLastDelayIdExpired] = useState<boolean>(false);
const [lastDelayError, setLastDelayError] = useState<string | undefined>();
const [dices, setDices] = useState<number[]>([]);

const lastDelayIdTimeoutRef = useRef<Timeout>();

useEffect(() => {
setDices([]);

Expand All @@ -81,6 +97,12 @@ export const DiceView = (): ReactElement => {
};
}, [widgetApi]);

useEffect(() => {
return () => {
clearTimeout(lastDelayIdTimeoutRef.current);
};
}, []);

async function handleThrowDice() {
await widgetApi.requestCapabilities([
WidgetEventCapability.forRoomEvent(
Expand All @@ -97,9 +119,68 @@ export const DiceView = (): ReactElement => {
setLastOwnDice(result.content.pips);
}

async function handleThrowDiceDelayed() {
await widgetApi.requestCapabilities([
MatrixCapabilities.MSC4157SendDelayedEvent,
WidgetEventCapability.forRoomEvent(
EventDirection.Send,
STATE_EVENT_THROW_DICE,
),
]);

const pips = Math.floor(Math.random() * 6) + 1;
try {
const { delay_id } = await widgetApi.sendDelayedRoomEvent<ThrowDiceEvent>(
STATE_EVENT_THROW_DICE,
{ pips },
eventDelayMs,
);
setLastOwnDice(pips);
setLastDelayId(delay_id);
setLastDelayIdExpired(false);
setLastDelayError(undefined);

clearTimeout(lastDelayIdTimeoutRef.current);
lastDelayIdTimeoutRef.current = setTimeout(() => {
setLastDelayIdExpired(true);
}, eventDelayMs);
} catch {
setLastDelayError(
'Could not send a delayed event. Please check if homeserver supports delayed events.',
);
}
}

async function handleThrowDiceDelayedUpdate(
action: UpdateDelayedEventAction,
) {
if (!lastDelayId) {
return;
}

await widgetApi.requestCapabilities([
MatrixCapabilities.MSC4157UpdateDelayedEvent,
]);

try {
await widgetApi.updateDelayedEvent(lastDelayId, action);
if (action === UpdateDelayedEventAction.Restart) {
clearTimeout(lastDelayIdTimeoutRef.current);
lastDelayIdTimeoutRef.current = setTimeout(() => {
setLastDelayIdExpired(true);
}, eventDelayMs);
} else {
setLastDelayIdExpired(true);
}
setLastDelayError(undefined);
} catch (e) {
setLastDelayError(isError(e) ? e.message : JSON.stringify(e));
}
}

return (
<>
<Card elevation={0} sx={{ my: 2 }}>
<Card elevation={0} sx={{ mt: 2 }}>
<CardContent>
<Typography gutterBottom variant="h5" component="div">
Dice Simulator
Expand All @@ -113,15 +194,66 @@ export const DiceView = (): ReactElement => {
</CardContent>
</Card>

<Button onClick={handleThrowDice} variant="contained" fullWidth>
<Button
sx={{ mt: 2 }}
onClick={handleThrowDice}
variant="contained"
fullWidth
>
Throw dice
</Button>

<Button
sx={{ mt: 2 }}
onClick={handleThrowDiceDelayed}
variant="outlined"
fullWidth
>
Throw dice 10 seconds delayed
</Button>

{lastOwnDice && (
<Alert severity="success" sx={{ my: 2 }}>
<Alert severity="success" sx={{ mt: 2 }}>
Your last throw: <Dice pips={lastOwnDice} />
</Alert>
)}

{lastDelayError && (
<Alert severity="error" sx={{ mt: 2 }}>
<AlertTitle>Error</AlertTitle>
{lastDelayError}
</Alert>
)}

{lastDelayId && (
<Alert severity="success" sx={{ mt: 2 }}>
Your last delay id: {lastDelayId}
</Alert>
)}

{lastDelayId && !lastDelayIdExpired && (
<>
<Typography sx={{ mt: 2 }} variant="h6">
Throw dice delayed event actions:
</Typography>

<ButtonGroup sx={{ mt: 1 }} variant="outlined" fullWidth>
{Object.values(UpdateDelayedEventAction).map((action) => (
<Button
key={action}
color={
action === UpdateDelayedEventAction.Cancel
? 'error'
: undefined
}
onClick={() => handleThrowDiceDelayedUpdate(action)}
>
{getTextForAction(action)}
</Button>
))}
</ButtonGroup>
</>
)}
</>
);
};
Expand All @@ -138,3 +270,7 @@ const pipsEmojis: Record<number, string> = {
export const Dice = ({ pips }: { pips: number }): ReactElement => {
return <>{pipsEmojis[pips] ?? ''}</>;
};

function getTextForAction(action: UpdateDelayedEventAction) {
return action.charAt(0).toUpperCase() + action.slice(1);
}
9 changes: 9 additions & 0 deletions packages/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,12 @@ It returns a promise that resolves with the result of the modal.
Inside the modal widget, you can use `observeModalButtons()` to listen to clicks on the bottom buttons of the modal.
You can use `setModalButtonEnabled()` to disable buttons from within the widget.
Once you are done, you can call `closeModal()` to close the modal and pass the results back to the main widget.

### Delayed events

You can send and update delayed events (MSC4140). The configuration for delayed events on the homeserver
needs to be applied, for example:

```
max_event_delay_duration: 24h
```
Loading
Loading