Showing rooms works
This commit is contained in:
parent
b0ebeda23a
commit
f93cabb99d
|
@ -10,6 +10,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jitsi/react-sdk": "^1.3.0",
|
"@jitsi/react-sdk": "^1.3.0",
|
||||||
|
"just-curry-it": "^5.3.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0"
|
"react-dom": "^18.2.0"
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,14 +1,29 @@
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
import Meeting from "./components/meeting/Meeting";
|
import Meeting from "./components/meeting/Meeting";
|
||||||
import Sidebar from "./components/sidebar/Sidebar";
|
import Sidebar from "./components/sidebar/Sidebar";
|
||||||
|
import useBackendData from "./hooks/useBackendData";
|
||||||
|
import useConferenceData from "./hooks/useConferenceData";
|
||||||
|
import useLocalUser from "./hooks/useLocalUser";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
const { userInfo, setUserInfo } = useLocalUser();
|
||||||
<div className="App">
|
const { roomData, sendMessage } = useBackendData(userInfo);
|
||||||
<Sidebar />
|
const { conferenceData, setConferenceData } = useConferenceData(
|
||||||
<Meeting />
|
sendMessage,
|
||||||
</div>
|
setUserInfo
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log(roomData);
|
||||||
|
|
||||||
|
if (roomData && userInfo) {
|
||||||
|
return (
|
||||||
|
<div className="App">
|
||||||
|
<Sidebar usersData={roomData} />
|
||||||
|
<Meeting setConferenceData={setConferenceData} userInfo={userInfo} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <h2>🌀 Loading...</h2>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|
|
@ -1,4 +1,14 @@
|
||||||
const JITSI_DOMAIN = "thisisnotajitsi.filefighter.de";
|
const ISPROD = window.location.protocol == "https:";
|
||||||
const WEBSOCKET_URL = "ws" + (window.location.protocol == "https:" ? "s" : "") + "://" + window.location.host + "/ws"
|
const JITSI_DOMAIN = ISPROD
|
||||||
|
? "thisisnotajitsi.filefighter.de"
|
||||||
|
: "localhost:8443";
|
||||||
|
const WEBSOCKET_URL =
|
||||||
|
"ws" +
|
||||||
|
(ISPROD ? "s" : "") +
|
||||||
|
"://" +
|
||||||
|
(ISPROD ? window.location.host : "localhost:9160") +
|
||||||
|
"/ws";
|
||||||
|
|
||||||
export { JITSI_DOMAIN, WEBSOCKET_URL };
|
const USER_COOKIE_NAME = "jitsi-rooms-user";
|
||||||
|
|
||||||
|
export { JITSI_DOMAIN, WEBSOCKET_URL, USER_COOKIE_NAME };
|
||||||
|
|
|
@ -0,0 +1,24 @@
|
||||||
|
function getCookie(cname: string) {
|
||||||
|
let name = cname + "=";
|
||||||
|
let decodedCookie = decodeURIComponent(document.cookie);
|
||||||
|
let ca = decodedCookie.split(";");
|
||||||
|
for (let i = 0; i < ca.length; i++) {
|
||||||
|
let c = ca[i];
|
||||||
|
while (c.charAt(0) == " ") {
|
||||||
|
c = c.substring(1);
|
||||||
|
}
|
||||||
|
if (c.indexOf(name) == 0) {
|
||||||
|
return c.substring(name.length, c.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCookie(cname: string, cvalue: string, exdays = 30) {
|
||||||
|
const d = new Date();
|
||||||
|
d.setTime(d.getTime() + exdays * 24 * 60 * 60 * 1000);
|
||||||
|
let expires = "expires=" + d.toUTCString();
|
||||||
|
document.cookie = cname + "=" + cvalue + ";" + expires + ";path=/";
|
||||||
|
}
|
||||||
|
|
||||||
|
export { getCookie, setCookie };
|
|
@ -0,0 +1,16 @@
|
||||||
|
export interface ConferenceData {
|
||||||
|
roomName: string; // the room name of the conference
|
||||||
|
id: string; // the id of the local participant
|
||||||
|
displayName: string; // the display name of the local participant
|
||||||
|
avatarURL: string; // the avatar URL of the local participant
|
||||||
|
breakoutRoom: boolean; // whether the current room is a breakout room
|
||||||
|
}
|
||||||
|
|
||||||
|
function videoConferenceJoinedListener(
|
||||||
|
setConferenceData: (newData: ConferenceData) => void,
|
||||||
|
videoConferenceJoinedEvent: ConferenceData
|
||||||
|
) {
|
||||||
|
setConferenceData(videoConferenceJoinedEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { videoConferenceJoinedListener };
|
|
@ -0,0 +1,16 @@
|
||||||
|
export interface RoomData {
|
||||||
|
roomName: string;
|
||||||
|
participants: Participant[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Participant {
|
||||||
|
avatarURL: string;
|
||||||
|
displayName: string;
|
||||||
|
jid: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsersData {
|
||||||
|
roomsData: RoomData[];
|
||||||
|
usersWithOutRoom: string[];
|
||||||
|
}
|
|
@ -1,13 +1,19 @@
|
||||||
import { JitsiMeeting } from "@jitsi/react-sdk";
|
import { JitsiMeeting } from "@jitsi/react-sdk";
|
||||||
import { JITSI_DOMAIN } from "../../background/constants";
|
import { JITSI_DOMAIN } from "../../background/constants";
|
||||||
import { UserInfo } from "./types";
|
import { UserInfo } from "./types";
|
||||||
|
import curry from "just-curry-it";
|
||||||
|
import {
|
||||||
|
ConferenceData,
|
||||||
|
videoConferenceJoinedListener,
|
||||||
|
} from "../../background/jitsi/eventListeners";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
roomName: string;
|
roomName: string;
|
||||||
userInfo: UserInfo;
|
userInfo: UserInfo;
|
||||||
|
setConferenceData: (newData: ConferenceData) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function JitsiEntrypoint({ roomName, userInfo }: Props) {
|
function JitsiEntrypoint({ roomName, userInfo, setConferenceData }: Props) {
|
||||||
return (
|
return (
|
||||||
<JitsiMeeting
|
<JitsiMeeting
|
||||||
domain={JITSI_DOMAIN}
|
domain={JITSI_DOMAIN}
|
||||||
|
@ -24,6 +30,11 @@ function JitsiEntrypoint({ roomName, userInfo }: Props) {
|
||||||
onApiReady={(externalApi) => {
|
onApiReady={(externalApi) => {
|
||||||
// here you can attach custom event listeners to the Jitsi Meet External API
|
// here you can attach custom event listeners to the Jitsi Meet External API
|
||||||
// you can also store it locally to execute commands
|
// you can also store it locally to execute commands
|
||||||
|
|
||||||
|
externalApi.addEventListener(
|
||||||
|
"videoConferenceJoined",
|
||||||
|
curry(videoConferenceJoinedListener)(setConferenceData)
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
getIFrameRef={(iframeRef) => {
|
getIFrameRef={(iframeRef) => {
|
||||||
iframeRef.style.height = "100%";
|
iframeRef.style.height = "100%";
|
||||||
|
|
|
@ -1,7 +1,4 @@
|
||||||
|
export interface UserInfo {
|
||||||
interface UserInfo {
|
|
||||||
displayName: string;
|
displayName: string;
|
||||||
email: string
|
email: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { UserInfo }
|
|
||||||
|
|
|
@ -1,23 +1,32 @@
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
|
import { ConferenceData } from "../../background/jitsi/eventListeners";
|
||||||
import { useRoomName } from "../../hooks/useRoomName";
|
import { useRoomName } from "../../hooks/useRoomName";
|
||||||
import JitsiEntrypoint from "../jitsi/JitsiEntrypoint";
|
import JitsiEntrypoint from "../jitsi/JitsiEntrypoint";
|
||||||
import { UserInfo } from "../jitsi/types";
|
import { UserInfo } from "../jitsi/types";
|
||||||
import MeetingNameInput from "./MeetingNameInput";
|
import MeetingNameInput from "./MeetingNameInput";
|
||||||
|
|
||||||
function Meeting() {
|
interface Props {
|
||||||
|
setConferenceData: (newData: ConferenceData) => void;
|
||||||
|
userInfo: UserInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Meeting({ setConferenceData, userInfo }: Props) {
|
||||||
const { roomName, updateRoomName, submitRoomName } = useRoomName();
|
const { roomName, updateRoomName, submitRoomName } = useRoomName();
|
||||||
const [meetingStarted, setMeetingStarted] = useState(false);
|
const [meetingStarted, setMeetingStarted] = useState(false);
|
||||||
|
|
||||||
const userInfo: UserInfo = { displayName: "unknown traveller", email: "" }
|
|
||||||
|
|
||||||
|
|
||||||
const startMeeting = useCallback(() => {
|
const startMeeting = useCallback(() => {
|
||||||
submitRoomName();
|
submitRoomName();
|
||||||
setMeetingStarted(true);
|
setMeetingStarted(true);
|
||||||
}, [submitRoomName, setMeetingStarted]);
|
}, [submitRoomName, setMeetingStarted]);
|
||||||
|
|
||||||
if (meetingStarted) {
|
if (meetingStarted) {
|
||||||
return <JitsiEntrypoint roomName={roomName} userInfo={userInfo} />;
|
return (
|
||||||
|
<JitsiEntrypoint
|
||||||
|
roomName={roomName}
|
||||||
|
userInfo={userInfo}
|
||||||
|
setConferenceData={setConferenceData}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -1,14 +1,37 @@
|
||||||
import SidebarHeader from "./SidebarHeader";
|
import SidebarHeader from "./SidebarHeader";
|
||||||
import useSidebarVisibility from "./useSidebarVisibility";
|
import useSidebarVisibility from "./useSidebarVisibility";
|
||||||
import "./Sidebar.css";
|
import "./Sidebar.css";
|
||||||
|
import { UsersData } from "../../background/types/roomData";
|
||||||
|
|
||||||
function Sidebar() {
|
interface Props {
|
||||||
|
usersData: UsersData;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sidebar(props: Props) {
|
||||||
const { sidebarVisibility, toggleSidebarVisibility, sidebarToggleText } =
|
const { sidebarVisibility, toggleSidebarVisibility, sidebarToggleText } =
|
||||||
useSidebarVisibility();
|
useSidebarVisibility();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`sidebar sidebar-${sidebarVisibility}`}>
|
<div className={`sidebar sidebar-${sidebarVisibility}`}>
|
||||||
<SidebarHeader sidebarVisibility={sidebarVisibility} />
|
<SidebarHeader sidebarVisibility={sidebarVisibility} />
|
||||||
|
<div>
|
||||||
|
<h3> No room</h3>
|
||||||
|
{props.usersData.usersWithOutRoom.map((username) => (
|
||||||
|
<div>{username}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{props.usersData.roomsData.map((roomData) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h3> {roomData.roomName} </h3>
|
||||||
|
{roomData.participants.map((participant) => (
|
||||||
|
<div> {participant.displayName} </div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
<div className="sidebar-footer">
|
<div className="sidebar-footer">
|
||||||
<button onClick={toggleSidebarVisibility}>{sidebarToggleText}</button>
|
<button onClick={toggleSidebarVisibility}>{sidebarToggleText}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,20 +1,26 @@
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { UserInfo } from "../components/jitsi/types";
|
||||||
|
import useRoomData from "./useRoomData";
|
||||||
import useWebSocketConnection from "./useWebSocketConnection";
|
import useWebSocketConnection from "./useWebSocketConnection";
|
||||||
|
|
||||||
function useBackendData() {
|
function useBackendData(userInfo: UserInfo) {
|
||||||
|
console.log("[Rooms] useBackendData");
|
||||||
|
const { onMessage, sendMessage } = useWebSocketConnection(userInfo);
|
||||||
|
|
||||||
const { onMessage, sendMessage } = useWebSocketConnection();
|
const { roomData, setRoomData } = useRoomData();
|
||||||
|
|
||||||
const { roomData, setRoomData } = useRoomData()
|
useEffect(
|
||||||
|
() =>
|
||||||
|
onMessage((messageString) => {
|
||||||
onMessage(
|
console.log("[Rooms] message from ws", messageString);
|
||||||
(messageString) => {
|
const messageObject = JSON.parse(messageString);
|
||||||
const messageObject = JSON.parse(messageString)
|
|
||||||
|
|
||||||
!!messageObject.roomData && setRoomData(messageObject.roomData)
|
|
||||||
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
!!messageObject.roomsData && setRoomData(messageObject);
|
||||||
|
}),
|
||||||
|
[onMessage, setRoomData]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { roomData, sendMessage };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default useBackendData;
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ConferenceData } from "../background/jitsi/eventListeners";
|
||||||
|
import { UserInfo } from "../components/jitsi/types";
|
||||||
|
|
||||||
|
function useConferenceData(
|
||||||
|
sendMessage: (message: string) => void,
|
||||||
|
setUserInfo: (newData: UserInfo) => void
|
||||||
|
) {
|
||||||
|
const [conferenceData, setConferenceDataLocal] = useState<ConferenceData>();
|
||||||
|
|
||||||
|
const setConferenceData = (newData: ConferenceData) => {
|
||||||
|
console.log("[Rooms] set conferenceData");
|
||||||
|
sendMessage(JSON.stringify(newData));
|
||||||
|
setConferenceDataLocal(newData);
|
||||||
|
setUserInfo({ displayName: newData.displayName, email: "" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return { conferenceData, setConferenceData };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useConferenceData;
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { USER_COOKIE_NAME } from "../background/constants";
|
||||||
|
import { getCookie, setCookie } from "../background/cookies";
|
||||||
|
import { UserInfo } from "../components/jitsi/types";
|
||||||
|
|
||||||
|
function useLocalUser() {
|
||||||
|
const [userInfo, setUserInfoLocal] = useState<UserInfo>(() =>
|
||||||
|
getUserInfoFromCookie()
|
||||||
|
);
|
||||||
|
|
||||||
|
const setUserInfo = (newData: UserInfo) => {
|
||||||
|
storeUserInfoInCookie(newData);
|
||||||
|
setUserInfoLocal(newData);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { userInfo, setUserInfo };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserInfoFromCookie(): UserInfo {
|
||||||
|
let cookie = getCookie(USER_COOKIE_NAME);
|
||||||
|
console.log("[Rooms] getUserNameFromCookie 1", cookie);
|
||||||
|
if (cookie) return JSON.parse(cookie);
|
||||||
|
return {
|
||||||
|
displayName: "unknown traveller",
|
||||||
|
email: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeUserInfoInCookie(userInfo: UserInfo) {
|
||||||
|
setCookie(USER_COOKIE_NAME, JSON.stringify(userInfo));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useLocalUser;
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { UsersData } from "../background/types/roomData";
|
||||||
|
|
||||||
|
function useRoomData() {
|
||||||
|
const [roomData, setRoomData] = useState<UsersData>();
|
||||||
|
|
||||||
|
return { roomData, setRoomData };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useRoomData;
|
|
@ -1,7 +1,7 @@
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
function useRoomName() {
|
function useRoomName() {
|
||||||
const [roomName, setRoomName] = useState(getRoomNameFromUrl());
|
const [roomName, setRoomName] = useState(() => getRoomNameFromUrl());
|
||||||
|
|
||||||
const updateRoomName = useCallback(
|
const updateRoomName = useCallback(
|
||||||
(newName: string) => {
|
(newName: string) => {
|
||||||
|
@ -11,13 +11,10 @@ function useRoomName() {
|
||||||
[setRoomName]
|
[setRoomName]
|
||||||
);
|
);
|
||||||
|
|
||||||
const submitRoomName = useCallback(
|
const submitRoomName = useCallback(() => {
|
||||||
() => {
|
setRoomNameInUrl(roomName);
|
||||||
setRoomNameInUrl(roomName);
|
setRoomNameInTitle(roomName);
|
||||||
setRoomNameInTitle(roomName);
|
}, [roomName]);
|
||||||
},
|
|
||||||
[roomName]
|
|
||||||
);
|
|
||||||
|
|
||||||
return { roomName, updateRoomName, submitRoomName };
|
return { roomName, updateRoomName, submitRoomName };
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,34 +1,46 @@
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { WEBSOCKET_URL } from "../background/constants";
|
import { WEBSOCKET_URL } from "../background/constants";
|
||||||
|
import { UserInfo } from "../components/jitsi/types";
|
||||||
|
|
||||||
function useWebSocketConnection() {
|
const createWebSocketConnection = (userInfo: UserInfo) => {
|
||||||
|
const webSocket = new WebSocket(WEBSOCKET_URL);
|
||||||
|
console.log("[Rooms] createWebSocketConnection");
|
||||||
|
webSocket.addEventListener("open", (_: Event) =>
|
||||||
|
webSocket.send(JSON.stringify(userInfo.displayName))
|
||||||
|
);
|
||||||
|
|
||||||
const createWebSocketConnection = () => {
|
return webSocket;
|
||||||
const webSocket = new WebSocket(WEBSOCKET_URL);
|
};
|
||||||
return webSocket
|
|
||||||
}
|
|
||||||
|
|
||||||
const [webSocketConnection] = useState<WebSocket>(createWebSocketConnection())
|
function useWebSocketConnection(userInfo: UserInfo) {
|
||||||
|
console.log("[Rooms] useWebSocketConnection");
|
||||||
|
|
||||||
const sendMessage = useCallback((message: string) => {
|
const [webSocketConnection] = useState<WebSocket>(() =>
|
||||||
//if the socket's open, send a message:
|
createWebSocketConnection(userInfo)
|
||||||
if (webSocketConnection.readyState === WebSocket.OPEN) {
|
);
|
||||||
webSocketConnection.send(message);
|
|
||||||
}
|
const sendMessage = useCallback(
|
||||||
},
|
(message: string) => {
|
||||||
|
//if the socket's open, send a message:
|
||||||
|
if (webSocketConnection.readyState === WebSocket.OPEN) {
|
||||||
|
console.log("[Rooms] sending to ws", message);
|
||||||
|
webSocketConnection.send(message);
|
||||||
|
}
|
||||||
|
},
|
||||||
[webSocketConnection]
|
[webSocketConnection]
|
||||||
)
|
);
|
||||||
|
|
||||||
const onMessage = useCallback(
|
const onMessage = useCallback(
|
||||||
(messageHandler: (messageHandler: string) => void) => {
|
(messageHandler: (messageHandler: string) => void) => {
|
||||||
const wsMessageHandler = (ev: MessageEvent<any>) => { messageHandler(ev.data) }
|
const wsMessageHandler = (ev: MessageEvent<any>) => {
|
||||||
webSocketConnection.addEventListener('message', wsMessageHandler)
|
messageHandler(ev.data);
|
||||||
|
};
|
||||||
|
webSocketConnection.addEventListener("message", wsMessageHandler);
|
||||||
},
|
},
|
||||||
[webSocketConnection]
|
[webSocketConnection]
|
||||||
)
|
);
|
||||||
|
|
||||||
return { onMessage, sendMessage }
|
return { onMessage, sendMessage };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default useWebSocketConnection;
|
export default useWebSocketConnection;
|
||||||
|
|
||||||
|
|
|
@ -558,6 +558,11 @@ json5@^2.2.2:
|
||||||
resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283"
|
resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283"
|
||||||
integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==
|
integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==
|
||||||
|
|
||||||
|
just-curry-it@^5.3.0:
|
||||||
|
version "5.3.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/just-curry-it/-/just-curry-it-5.3.0.tgz#1463602e932c5beb431a2a384dddcd48bb3c9c42"
|
||||||
|
integrity sha512-silMIRiFjUWlfaDhkgSzpuAyQ6EX/o09Eu8ZBfmFwQMbax7+LQzeIU2CBrICT6Ne4l86ITCGvUCBpCubWYy0Yw==
|
||||||
|
|
||||||
loose-envify@^1.1.0:
|
loose-envify@^1.1.0:
|
||||||
version "1.4.0"
|
version "1.4.0"
|
||||||
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
|
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
|
||||||
|
|
Loading…
Reference in New Issue