Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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();
}
});
});
143 changes: 137 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,63 @@ 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) {
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 +189,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 +265,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