WIP
This commit is contained in:
parent
e5c28569c6
commit
9d0162e475
11 changed files with 456 additions and 194 deletions
|
|
@ -23,7 +23,7 @@ import {
|
|||
useLocation,
|
||||
} from "react-router-dom";
|
||||
import { useConferenceCallManager } from "./ConferenceCallManagerHooks";
|
||||
import { JoinOrCreateRoom } from "./JoinOrCreateRoom";
|
||||
import { Home } from "./Home";
|
||||
import { LoginOrRegister } from "./LoginOrRegister";
|
||||
import { Room } from "./Room";
|
||||
import { GridDemo } from "./GridDemo";
|
||||
|
|
@ -37,7 +37,7 @@ export default function App() {
|
|||
|
||||
return (
|
||||
<Router>
|
||||
<div>
|
||||
<>
|
||||
{error && <p>{error.message}</p>}
|
||||
{loading ? (
|
||||
<p>Loading...</p>
|
||||
|
|
@ -45,7 +45,7 @@ export default function App() {
|
|||
<Switch>
|
||||
<Route exact path="/">
|
||||
{authenticated ? (
|
||||
<JoinOrCreateRoom manager={manager} />
|
||||
<Home manager={manager} />
|
||||
) : (
|
||||
<LoginOrRegister onRegister={register} onLogin={login} />
|
||||
)}
|
||||
|
|
@ -61,7 +61,7 @@ export default function App() {
|
|||
</Route>
|
||||
</Switch>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
40
src/Header.jsx
Normal file
40
src/Header.jsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import classNames from "classnames";
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import styles from "./Header.module.css";
|
||||
import { ReactComponent as Logo } from "./Logo.svg";
|
||||
|
||||
export function Header({ children, className, ...rest }) {
|
||||
return (
|
||||
<header className={classNames(styles.header, className)} {...rest}>
|
||||
{children}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function LeftNav({ children, className, ...rest }) {
|
||||
return (
|
||||
<div className={classNames(styles.leftNav, className)} {...rest}>
|
||||
<Link className={styles.logo} to="/">
|
||||
<Logo width={32} height={32} />
|
||||
</Link>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CenterNav({ children, className, ...rest }) {
|
||||
return (
|
||||
<div className={classNames(styles.centerNav, className)} {...rest}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RightNav({ children, className, ...rest }) {
|
||||
return (
|
||||
<div className={classNames(styles.rightNav, className)} {...rest}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
src/Header.module.css
Normal file
25
src/Header.module.css
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
.header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 98px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.leftNav {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.rightNav {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
max-width: 30%;
|
||||
}
|
||||
123
src/Home.jsx
Normal file
123
src/Home.jsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
/*
|
||||
Copyright 2021 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
import { useHistory, Link } from "react-router-dom";
|
||||
import { useRooms } from "./ConferenceCallManagerHooks";
|
||||
import { Header, LeftNav, RightNav } from "./Header";
|
||||
import ColorHash from "color-hash";
|
||||
import styles from "./Home.module.css";
|
||||
import { FieldRow, InputField, Button } from "./Input";
|
||||
|
||||
const colorHash = new ColorHash({ lightness: 0.3 });
|
||||
|
||||
export function Home({ manager }) {
|
||||
const history = useHistory();
|
||||
const roomNameRef = useRef();
|
||||
const [createRoomError, setCreateRoomError] = useState();
|
||||
const rooms = useRooms(manager);
|
||||
|
||||
const onCreateRoom = useCallback(
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
setCreateRoomError(undefined);
|
||||
|
||||
manager.client
|
||||
.createRoom({
|
||||
visibility: "private",
|
||||
preset: "public_chat",
|
||||
name: roomNameRef.current.value,
|
||||
})
|
||||
.then(({ room_id }) => {
|
||||
history.push(`/room/${room_id}`);
|
||||
})
|
||||
.catch(setCreateRoomError);
|
||||
},
|
||||
[manager]
|
||||
);
|
||||
|
||||
const onLogout = useCallback(
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
manager.logout();
|
||||
location.reload();
|
||||
},
|
||||
[manager]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header>
|
||||
<LeftNav />
|
||||
<RightNav>
|
||||
<span className={styles.userName}>
|
||||
{manager.client && manager.client.getUserId()}
|
||||
</span>
|
||||
<button
|
||||
className={styles.signOutButton}
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</RightNav>
|
||||
</Header>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.roomsSidebar}>
|
||||
<h5>Rooms:</h5>
|
||||
<div className={styles.roomList}>
|
||||
{rooms.map((room) => (
|
||||
<Link
|
||||
className={styles.roomListItem}
|
||||
key={room.roomId}
|
||||
to={`/room/${room.roomId}`}
|
||||
>
|
||||
<div
|
||||
className={styles.roomAvatar}
|
||||
style={{ backgroundColor: colorHash.hex(room.name) }}
|
||||
>
|
||||
<span>{room.name.slice(0, 1)}</span>
|
||||
</div>
|
||||
<div className={styles.roomName}>{room.name}</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.center}>
|
||||
<form className={styles.createRoomContainer} onSubmit={onCreateRoom}>
|
||||
<h2>Create New Room</h2>
|
||||
<FieldRow>
|
||||
<InputField
|
||||
id="roomName"
|
||||
name="roomName"
|
||||
label="Room Name"
|
||||
type="text"
|
||||
required
|
||||
autoComplete="off"
|
||||
placeholder="Room Name"
|
||||
ref={roomNameRef}
|
||||
/>
|
||||
</FieldRow>
|
||||
{createRoomError && <p>{createRoomError.message}</p>}
|
||||
<FieldRow rightAlign>
|
||||
<Button type="submit">Create Room</Button>
|
||||
</FieldRow>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
99
src/Home.module.css
Normal file
99
src/Home.module.css
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
.content {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(200px, 320px) 3fr;
|
||||
gap: 20px;
|
||||
margin: 0 20px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.roomsSidebar {
|
||||
padding: 12px;
|
||||
background-color: rgba(33,38,44,.9);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.roomsSidebar h5 {
|
||||
color: rgb(142, 153, 164);
|
||||
font-size: 13px;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.roomList {
|
||||
|
||||
}
|
||||
|
||||
.roomListItem {
|
||||
margin-bottom: 4px;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: white;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.roomListItem:hover {
|
||||
background-color: rgba(141, 151, 165, 0.2);
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.roomAvatar {
|
||||
position: relative;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 32px;
|
||||
}
|
||||
|
||||
.roomAvatar > * {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.roomAvatar span {
|
||||
font-size: 20.8px;
|
||||
width: 32px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.roomName {
|
||||
margin-left: 8px;
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.center {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.createRoomContainer {
|
||||
color: #232f32;
|
||||
border-radius: 8px;
|
||||
padding: 25px 60px;
|
||||
width: 400px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.createRoomContainer h2 {
|
||||
margin: 0 0 20px 0;
|
||||
}
|
||||
|
||||
.userName {
|
||||
font-weight: 600;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.signOutButton {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: rgb(255, 75, 85);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
44
src/Input.jsx
Normal file
44
src/Input.jsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import React, { forwardRef } from "react";
|
||||
import classNames from "classnames";
|
||||
import styles from "./Input.module.css";
|
||||
|
||||
export function FieldRow({ children, rightAlign, className, ...rest }) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
styles.fieldRow,
|
||||
{ [styles.rightAlign]: rightAlign },
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Field({ children, className, ...rest }) {
|
||||
return <div className={classNames(styles.field, className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export const InputField = forwardRef(
|
||||
({ id, label, className, ...rest }, ref) => {
|
||||
return (
|
||||
<Field>
|
||||
<input id={id} {...rest} ref={ref} />
|
||||
<label for={id}>{label}</label>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const Button = forwardRef(({ className, children, ...rest }, ref) => {
|
||||
return (
|
||||
<button
|
||||
className={classNames(styles.button, className)}
|
||||
ref={ref}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
106
src/Input.module.css
Normal file
106
src/Input.module.css
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
.fieldRow {
|
||||
display: flex;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
margin: 1em 0;
|
||||
border-radius: 4px;
|
||||
transition: border-color .25s;
|
||||
border: 1px solid #e7e7e7;
|
||||
}
|
||||
|
||||
.fieldRow.rightAlign {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.fieldRow > .field {
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.fieldRow > .field:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.fieldRow > .field:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.field input {
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 8px 9px;
|
||||
color: #2e2f32;
|
||||
background-color: #fff;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field input::placeholder {
|
||||
transition: color 0.25s ease-in 0s;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.field input:placeholder-shown:focus::placeholder {
|
||||
transition: color .25s ease-in .1s;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.field label {
|
||||
transition: font-size .25s ease-out .1s,color .25s ease-out .1s,top .25s ease-out .1s,background-color .25s ease-out .1s;
|
||||
color: #2e2f32;
|
||||
background-color: transparent;
|
||||
font-size: 14px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
margin: 7px 8px;
|
||||
padding: 2px;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
max-width: calc(100% - 20px);
|
||||
}
|
||||
|
||||
.field input:focus + label {
|
||||
background-color: #fff;
|
||||
color: #238cf5;
|
||||
transition: font-size .25s ease-out 0s,color .25s ease-out 0s,top .25s ease-out 0s,background-color .25s ease-out 0s;
|
||||
font-size: 10px;
|
||||
top: -13px;
|
||||
padding: 0 2px;
|
||||
background-color: #fff;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.button {
|
||||
vertical-align: middle;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #fff;
|
||||
background-color: #0dbd8b;
|
||||
width: auto;
|
||||
padding: 7px 15px;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
outline: none;
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
|
||||
}
|
||||
|
||||
.button:active {
|
||||
|
||||
}
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
/*
|
||||
Copyright 2021 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
import { useHistory, Link } from "react-router-dom";
|
||||
import { useRooms } from "./ConferenceCallManagerHooks";
|
||||
|
||||
export function JoinOrCreateRoom({ manager }) {
|
||||
const history = useHistory();
|
||||
const roomNameRef = useRef();
|
||||
const roomIdRef = useRef();
|
||||
const [createRoomError, setCreateRoomError] = useState();
|
||||
const [joinRoomError, setJoinRoomError] = useState();
|
||||
const rooms = useRooms(manager);
|
||||
|
||||
const onCreateRoom = useCallback(
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
setCreateRoomError(undefined);
|
||||
|
||||
manager.client
|
||||
.createRoom({
|
||||
visibility: "private",
|
||||
preset: "public_chat",
|
||||
name: roomNameRef.current.value,
|
||||
})
|
||||
.then(({ room_id }) => {
|
||||
history.push(`/room/${room_id}`);
|
||||
})
|
||||
.catch(setCreateRoomError);
|
||||
},
|
||||
[manager]
|
||||
);
|
||||
|
||||
const onJoinRoom = useCallback(
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
setJoinRoomError(undefined);
|
||||
|
||||
manager.client
|
||||
.joinRoom(roomIdRef.current.value)
|
||||
.then(({ roomId }) => {
|
||||
history.push(`/room/${roomId}`);
|
||||
})
|
||||
.catch(setJoinRoomError);
|
||||
},
|
||||
[manager]
|
||||
);
|
||||
|
||||
const onLogout = useCallback(
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
manager.logout();
|
||||
location.reload();
|
||||
},
|
||||
[manager]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h1>Matrix Video Chat</h1>
|
||||
<form onSubmit={onCreateRoom}>
|
||||
<h2>Create New Room</h2>
|
||||
<input
|
||||
id="roomName"
|
||||
name="roomName"
|
||||
type="text"
|
||||
required
|
||||
autoComplete="off"
|
||||
placeholder="Room Name"
|
||||
ref={roomNameRef}
|
||||
></input>
|
||||
{createRoomError && <p>{createRoomError.message}</p>}
|
||||
<button type="submit">Create Room</button>
|
||||
</form>
|
||||
<form onSubmit={onJoinRoom}>
|
||||
<h2>Join Existing Room</h2>
|
||||
<input
|
||||
id="roomId"
|
||||
name="roomId"
|
||||
type="text"
|
||||
required
|
||||
autoComplete="off"
|
||||
placeholder="Room ID"
|
||||
ref={roomIdRef}
|
||||
></input>
|
||||
{joinRoomError && <p>{joinRoomError.message}</p>}
|
||||
<button type="submit">Join Room</button>
|
||||
</form>
|
||||
<h2>Rooms:</h2>
|
||||
<ul>
|
||||
{rooms.map((room) => (
|
||||
<li key={room.roomId}>
|
||||
<Link to={`/room/${room.roomId}`}>{room.name}</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button type="button" onClick={onLogout}>
|
||||
Log Out
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
src/Room.jsx
20
src/Room.jsx
|
|
@ -26,7 +26,7 @@ import {
|
|||
MicButton,
|
||||
VideoButton,
|
||||
} from "./RoomButton";
|
||||
import { ReactComponent as Logo } from "./Logo.svg";
|
||||
import { Header, LeftNav, RightNav } from "./Header";
|
||||
|
||||
function useQuery() {
|
||||
const location = useLocation();
|
||||
|
|
@ -69,21 +69,19 @@ export function Room({ manager }) {
|
|||
return (
|
||||
<div className={styles.room}>
|
||||
{!loading && room && (
|
||||
<div className={styles.header}>
|
||||
<div className={styles.backNav}>
|
||||
<Link className={styles.logo} to="/">
|
||||
<Logo width={32} height={32} />
|
||||
</Link>
|
||||
</div>
|
||||
<h3>{room.name}</h3>
|
||||
<div className={styles.userNav}>
|
||||
<Header>
|
||||
<LeftNav />
|
||||
<CenterNav>
|
||||
<h3>{room.name}</h3>
|
||||
</CenterNav>
|
||||
<RightNav>
|
||||
<SettingsButton
|
||||
title={debug ? "Disable DevTools" : "Enable DevTools"}
|
||||
on={debug}
|
||||
onClick={() => setDebug((debug) => !debug)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</RightNav>
|
||||
</Header>
|
||||
)}
|
||||
{loading && (
|
||||
<div className={styles.centerMessage}>
|
||||
|
|
|
|||
|
|
@ -23,37 +23,6 @@ limitations under the License.
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 98px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.header h5 {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.backNav {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.userNav {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
max-width: 30%;
|
||||
}
|
||||
|
||||
.joinRoom {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ limitations under the License.
|
|||
}
|
||||
|
||||
body {
|
||||
background-color: #21262C;
|
||||
background-color: rgb(21, 25, 30);
|
||||
color: #fff;
|
||||
margin: 0;
|
||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
|
|
@ -115,19 +115,13 @@ body {
|
|||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
background-color: #ccc;
|
||||
color: black;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
button:hover, button:active {
|
||||
background-color: #888;
|
||||
#root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
a {
|
||||
|
|
@ -138,23 +132,3 @@ a {
|
|||
a:hover, a:active {
|
||||
color: rgb(76, 134, 173);
|
||||
}
|
||||
|
||||
.page {
|
||||
margin: 0 auto;
|
||||
max-width: 960px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.page input {
|
||||
padding: 8px 4px;
|
||||
font-size: 16px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #888;
|
||||
}
|
||||
|
||||
.page input, .page button {
|
||||
display: block;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue