Implement basic presence server functionality
This commit is contained in:
parent
1cf1ebec37
commit
68125c7c92
5 changed files with 211 additions and 71 deletions
|
@ -13,7 +13,7 @@ import javafx.stage.Stage;
|
||||||
|
|
||||||
public class MainApp extends Application {
|
public class MainApp extends Application {
|
||||||
|
|
||||||
private UserList userList;
|
private MainController mainController;
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
launch(args);
|
launch(args);
|
||||||
|
@ -26,9 +26,7 @@ public class MainApp extends Application {
|
||||||
|
|
||||||
final FXMLLoader mainLoader = new FXMLLoader(getClass().getResource("ui/scene.fxml"));
|
final FXMLLoader mainLoader = new FXMLLoader(getClass().getResource("ui/scene.fxml"));
|
||||||
final Parent content = mainLoader.load();
|
final Parent content = mainLoader.load();
|
||||||
MainController mainController = mainLoader.getController();
|
mainController = mainLoader.getController();
|
||||||
|
|
||||||
userList = mainController.getUserList();
|
|
||||||
|
|
||||||
Scene scene = new Scene(content);
|
Scene scene = new Scene(content);
|
||||||
|
|
||||||
|
@ -43,8 +41,8 @@ public class MainApp extends Application {
|
||||||
@Override
|
@Override
|
||||||
public void stop() throws Exception {
|
public void stop() throws Exception {
|
||||||
// Stop all threads and active connections before exiting
|
// Stop all threads and active connections before exiting
|
||||||
if (userList != null) {
|
if (mainController != null) {
|
||||||
userList.destroy();
|
mainController.stop();
|
||||||
}
|
}
|
||||||
super.stop();
|
super.stop();
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,16 @@
|
||||||
package fr.insa.clavardator.server;
|
package fr.insa.clavardator.server;
|
||||||
|
|
||||||
import fr.insa.clavardator.network.TcpConnection;
|
import fr.insa.clavardator.network.TcpConnection;
|
||||||
|
import fr.insa.clavardator.users.CurrentUser;
|
||||||
import fr.insa.clavardator.users.UserInformation;
|
import fr.insa.clavardator.users.UserInformation;
|
||||||
import fr.insa.clavardator.util.ErrorCallback;
|
import fr.insa.clavardator.util.ErrorCallback;
|
||||||
|
import fr.insa.clavardator.util.Log;
|
||||||
import fr.insa.clavardator.util.ParametrizedCallback;
|
import fr.insa.clavardator.util.ParametrizedCallback;
|
||||||
import fr.insa.clavardator.util.SimpleCallback;
|
import fr.insa.clavardator.util.SimpleCallback;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import java.net.InetAddress;
|
||||||
|
import java.net.UnknownHostException;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -29,6 +34,10 @@ public class InsaPresence implements Presence {
|
||||||
private final int presencePort;
|
private final int presencePort;
|
||||||
private final int proxyPort;
|
private final int proxyPort;
|
||||||
|
|
||||||
|
private TcpConnection presenceConnection;
|
||||||
|
private TcpConnection proxyConnection;
|
||||||
|
|
||||||
|
|
||||||
public InsaPresence(String path, int presencePort, int proxyPort) {
|
public InsaPresence(String path, int presencePort, int proxyPort) {
|
||||||
this.path = path;
|
this.path = path;
|
||||||
this.presencePort = presencePort;
|
this.presencePort = presencePort;
|
||||||
|
@ -37,21 +46,131 @@ public class InsaPresence implements Presence {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void subscribe(ParametrizedCallback<ArrayList<UserInformation>> callback, ErrorCallback errorCallback) {
|
public void subscribe(ParametrizedCallback<ArrayList<UserInformation>> callback, ErrorCallback errorCallback) {
|
||||||
|
if (!isConnected()) {
|
||||||
|
connectToPresence(
|
||||||
|
() -> connectToProxy(() -> {
|
||||||
|
sendSubscribeMessage(errorCallback);
|
||||||
|
receiveSubscribeNotifications(callback, errorCallback);
|
||||||
|
}, errorCallback),
|
||||||
|
errorCallback);
|
||||||
|
} else {
|
||||||
|
Log.v(getClass().getSimpleName(), "Already subscribed to presence server");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send current user information to tell the server who is subscribing
|
||||||
|
*
|
||||||
|
* @param errorCallback Called on connection error
|
||||||
|
*/
|
||||||
|
private void sendSubscribeMessage(ErrorCallback errorCallback) {
|
||||||
|
presenceConnection.send(new UserInformation(CurrentUser.getInstance()), null, errorCallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waits for presence server response to the subscribe request
|
||||||
|
*
|
||||||
|
* @param callback Called when the message is successfully received
|
||||||
|
* @param errorCallback Called on connection error
|
||||||
|
*/
|
||||||
|
private void receiveSubscribeNotifications(ParametrizedCallback<ArrayList<UserInformation>> callback, @Nullable ErrorCallback errorCallback) {
|
||||||
|
presenceConnection.receive(
|
||||||
|
msg -> {
|
||||||
|
// TODO decide what we should receive
|
||||||
|
final ArrayList<UserInformation> test = new ArrayList<>();
|
||||||
|
test.add(new UserInformation("test", "test"));
|
||||||
|
Log.v(getClass().getSimpleName(), "Receive subscribe response: " + msg);
|
||||||
|
if (callback != null) {
|
||||||
|
callback.call(test);
|
||||||
|
}
|
||||||
|
}, errorCallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void unsubscribe(SimpleCallback callback, ErrorCallback errorCallback) {
|
public void unsubscribe(SimpleCallback callback, @Nullable ErrorCallback errorCallback) {
|
||||||
|
if (isConnected()) {
|
||||||
|
disconnect();
|
||||||
|
} else {
|
||||||
|
Log.v(getClass().getSimpleName(), "Not subscribed to presence server");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
/**
|
||||||
public void publish(boolean connected, SimpleCallback callback, ErrorCallback errorCallback) {
|
* Connects to the presence server by TCP
|
||||||
|
*
|
||||||
|
* @param callback Called when connection is successful
|
||||||
|
* @param errorCallback Called on connection error
|
||||||
|
*/
|
||||||
|
private void connectToPresence(SimpleCallback callback, ErrorCallback errorCallback) {
|
||||||
|
try {
|
||||||
|
presenceConnection = new TcpConnection(InetAddress.getByName(path),
|
||||||
|
presencePort,
|
||||||
|
(newConnection) -> {
|
||||||
|
if (callback != null) {
|
||||||
|
callback.call();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
errorCallback);
|
||||||
|
} catch (UnknownHostException e) {
|
||||||
|
Log.e(getClass().getSimpleName(), "Could not connect to presence server", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connects to the presence proxy by TCP
|
||||||
|
*
|
||||||
|
* @param callback Called when connection is successful
|
||||||
|
* @param errorCallback Called on connection error
|
||||||
|
*/
|
||||||
|
private void connectToProxy(SimpleCallback callback, ErrorCallback errorCallback) {
|
||||||
|
try {
|
||||||
|
proxyConnection = new TcpConnection(InetAddress.getByName(path),
|
||||||
|
proxyPort,
|
||||||
|
(newConnection) -> {
|
||||||
|
if (callback != null) {
|
||||||
|
callback.call();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
errorCallback);
|
||||||
|
} catch (UnknownHostException e) {
|
||||||
|
Log.e(getClass().getSimpleName(), "Could not connect to presence proxy", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the given connection
|
||||||
|
*/
|
||||||
|
private void closeConnection(@Nullable TcpConnection c) {
|
||||||
|
if (c != null && c.isOpen()) {
|
||||||
|
c.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if we are connected to the presence server and proxy
|
||||||
|
*
|
||||||
|
* @return True only if we are connected to both presence server and proxy
|
||||||
|
*/
|
||||||
|
private boolean isConnected() {
|
||||||
|
return presenceConnection != null &&
|
||||||
|
presenceConnection.isOpen() &&
|
||||||
|
proxyConnection != null &&
|
||||||
|
proxyConnection.isOpen();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnects from both presence server and proxy
|
||||||
|
*/
|
||||||
|
private void disconnect() {
|
||||||
|
Log.v(this.getClass().getSimpleName(), "Disconnecting presence server");
|
||||||
|
closeConnection(presenceConnection);
|
||||||
|
closeConnection(proxyConnection);
|
||||||
|
presenceConnection = null;
|
||||||
|
proxyConnection = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public TcpConnection getProxyConnection() {
|
public TcpConnection getProxyConnection() {
|
||||||
return null;
|
return proxyConnection;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ import fr.insa.clavardator.users.UserInformation;
|
||||||
import fr.insa.clavardator.util.ErrorCallback;
|
import fr.insa.clavardator.util.ErrorCallback;
|
||||||
import fr.insa.clavardator.util.ParametrizedCallback;
|
import fr.insa.clavardator.util.ParametrizedCallback;
|
||||||
import fr.insa.clavardator.util.SimpleCallback;
|
import fr.insa.clavardator.util.SimpleCallback;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
|
||||||
|
@ -23,7 +24,7 @@ public interface Presence {
|
||||||
*
|
*
|
||||||
* @param callback Called when subscription completes
|
* @param callback Called when subscription completes
|
||||||
*/
|
*/
|
||||||
void subscribe(ParametrizedCallback<ArrayList<UserInformation>> callback, ErrorCallback errorCallback);
|
void subscribe(ParametrizedCallback<ArrayList<UserInformation>> callback, @Nullable ErrorCallback errorCallback);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stops subscription to the presence server by closing TCP connections.
|
* Stops subscription to the presence server by closing TCP connections.
|
||||||
|
@ -32,17 +33,7 @@ public interface Presence {
|
||||||
* @implNote Call this before exiting the app.
|
* @implNote Call this before exiting the app.
|
||||||
* If not, the presence server will wait for a tcp timeout before marking this user as disconnected.
|
* If not, the presence server will wait for a tcp timeout before marking this user as disconnected.
|
||||||
*/
|
*/
|
||||||
void unsubscribe(SimpleCallback callback, ErrorCallback errorCallback);
|
void unsubscribe(SimpleCallback callback, @Nullable ErrorCallback errorCallback);
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the current user state on the server.
|
|
||||||
* This function must be called on app exit,
|
|
||||||
* or the server won't know this user is inactive.
|
|
||||||
*
|
|
||||||
* @param connected The new user state
|
|
||||||
*/
|
|
||||||
void publish(boolean connected, SimpleCallback callback, ErrorCallback errorCallback);
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets a connection to the proxy.
|
* Gets a connection to the proxy.
|
||||||
|
|
|
@ -2,6 +2,7 @@ package fr.insa.clavardator.ui;
|
||||||
|
|
||||||
import com.jfoenix.controls.JFXSnackbar;
|
import com.jfoenix.controls.JFXSnackbar;
|
||||||
import fr.insa.clavardator.config.Config;
|
import fr.insa.clavardator.config.Config;
|
||||||
|
import fr.insa.clavardator.config.ConfigLoader;
|
||||||
import fr.insa.clavardator.db.DatabaseController;
|
import fr.insa.clavardator.db.DatabaseController;
|
||||||
import fr.insa.clavardator.server.Presence;
|
import fr.insa.clavardator.server.Presence;
|
||||||
import fr.insa.clavardator.server.PresenceFactory;
|
import fr.insa.clavardator.server.PresenceFactory;
|
||||||
|
@ -14,7 +15,6 @@ import fr.insa.clavardator.ui.dialogs.SnackbarController;
|
||||||
import fr.insa.clavardator.ui.users.UserListController;
|
import fr.insa.clavardator.ui.users.UserListController;
|
||||||
import fr.insa.clavardator.users.CurrentUser;
|
import fr.insa.clavardator.users.CurrentUser;
|
||||||
import fr.insa.clavardator.users.UserList;
|
import fr.insa.clavardator.users.UserList;
|
||||||
import fr.insa.clavardator.config.ConfigLoader;
|
|
||||||
import fr.insa.clavardator.util.Log;
|
import fr.insa.clavardator.util.Log;
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
import javafx.fxml.FXML;
|
import javafx.fxml.FXML;
|
||||||
|
@ -180,6 +180,7 @@ public class MainController implements Initializable {
|
||||||
Log.v(this.getClass().getSimpleName(), "Chat started");
|
Log.v(this.getClass().getSimpleName(), "Chat started");
|
||||||
discoverActiveUsers();
|
discoverActiveUsers();
|
||||||
startListening();
|
startListening();
|
||||||
|
subscribeToPresenceServer();
|
||||||
Platform.runLater(this::showChat);
|
Platform.runLater(this::showChat);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -190,9 +191,7 @@ public class MainController implements Initializable {
|
||||||
online = false;
|
online = false;
|
||||||
listController.setRefreshButtonEnabled(false);
|
listController.setRefreshButtonEnabled(false);
|
||||||
Log.v(this.getClass().getSimpleName(), "Chat ended");
|
Log.v(this.getClass().getSimpleName(), "Chat ended");
|
||||||
if (userList != null) {
|
stop();
|
||||||
userList.destroy();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -251,23 +250,71 @@ public class MainController implements Initializable {
|
||||||
private void initBackend() {
|
private void initBackend() {
|
||||||
ConfigLoader.load(
|
ConfigLoader.load(
|
||||||
(Config config) -> new DatabaseController().initTables(
|
(Config config) -> new DatabaseController().initTables(
|
||||||
() -> userList.retrievedPreviousUsers(
|
() -> userList.retrievedPreviousUsers(
|
||||||
() -> currentUser.init(
|
() -> currentUser.init(
|
||||||
() -> userList.initPresenceServer(config),
|
() -> initPresenceServer(config),
|
||||||
this::onInitError),
|
this::onInitError),
|
||||||
this::onInitError
|
this::onInitError
|
||||||
), this::onInitError));
|
), this::onInitError));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the current user list.
|
* Initializes the presence server based on user config
|
||||||
*
|
*
|
||||||
* @implNote BE SURE TO CALL .destroy() BEFORE EXITING THE APP.
|
* @param config The user config
|
||||||
*
|
|
||||||
* @return The current user list
|
|
||||||
*/
|
*/
|
||||||
public UserList getUserList() {
|
public void initPresenceServer(Config config) {
|
||||||
return userList;
|
final Config.ServerConfig serverConfig = config.getServerConfig();
|
||||||
|
if (serverConfig.isEnabled()) {
|
||||||
|
try {
|
||||||
|
final PresenceType type = serverConfig.getType();
|
||||||
|
final String uri = serverConfig.getUri();
|
||||||
|
final int presencePort = serverConfig.getPresencePort();
|
||||||
|
final int proxyPort = serverConfig.getProxyPort();
|
||||||
|
presenceServer = PresenceFactory.create(type, uri, presencePort, proxyPort);
|
||||||
|
Log.v(getClass().getSimpleName(), "Presence server support enabled: " + type + "@" + uri + ':' + presencePort + " / proxy:" + proxyPort);
|
||||||
|
subscribeToPresenceServer();
|
||||||
|
} catch (UnknownPresenceException e) {
|
||||||
|
Log.e(getClass().getSimpleName(), "Presence server type not found", e);
|
||||||
|
presenceServer = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.v(getClass().getSimpleName(), "Presence server support disabled.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void subscribeToPresenceServer() {
|
||||||
|
if (presenceServer != null && online) {
|
||||||
|
presenceServer.subscribe(
|
||||||
|
param -> userList.onReceivePresenceNotification(
|
||||||
|
param,
|
||||||
|
presenceServer.getProxyConnection()),
|
||||||
|
(e) -> Log.v(
|
||||||
|
getClass().getSimpleName(),
|
||||||
|
"Error subscribing to presence server",
|
||||||
|
e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void unsubscribeToPresenceServer() {
|
||||||
|
if (presenceServer != null) {
|
||||||
|
presenceServer.unsubscribe(
|
||||||
|
null,
|
||||||
|
(e) -> Log.v(
|
||||||
|
getClass().getSimpleName(),
|
||||||
|
"Error unsubscribing to presence server",
|
||||||
|
e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop all threads and active connections before exiting
|
||||||
|
*/
|
||||||
|
public void stop() {
|
||||||
|
if (userList != null) {
|
||||||
|
userList.destroy();
|
||||||
|
}
|
||||||
|
unsubscribeToPresenceServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
@ -1,20 +1,17 @@
|
||||||
package fr.insa.clavardator.users;
|
package fr.insa.clavardator.users;
|
||||||
|
|
||||||
import fr.insa.clavardator.config.Config;
|
|
||||||
import fr.insa.clavardator.db.DatabaseController;
|
import fr.insa.clavardator.db.DatabaseController;
|
||||||
import fr.insa.clavardator.network.TcpListener;
|
|
||||||
import fr.insa.clavardator.network.NetDiscoverer;
|
import fr.insa.clavardator.network.NetDiscoverer;
|
||||||
import fr.insa.clavardator.network.PeerHandshake;
|
import fr.insa.clavardator.network.PeerHandshake;
|
||||||
import fr.insa.clavardator.server.Presence;
|
import fr.insa.clavardator.network.TcpConnection;
|
||||||
import fr.insa.clavardator.server.PresenceFactory;
|
import fr.insa.clavardator.network.TcpListener;
|
||||||
import fr.insa.clavardator.server.PresenceType;
|
|
||||||
import fr.insa.clavardator.server.UnknownPresenceException;
|
|
||||||
import fr.insa.clavardator.util.ErrorCallback;
|
import fr.insa.clavardator.util.ErrorCallback;
|
||||||
import fr.insa.clavardator.util.Log;
|
import fr.insa.clavardator.util.Log;
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
import javafx.collections.FXCollections;
|
import javafx.collections.FXCollections;
|
||||||
import javafx.collections.ObservableList;
|
import javafx.collections.ObservableList;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.function.Predicate;
|
import java.util.function.Predicate;
|
||||||
|
@ -29,8 +26,6 @@ public class UserList {
|
||||||
private final NetDiscoverer netDiscoverer = new NetDiscoverer();
|
private final NetDiscoverer netDiscoverer = new NetDiscoverer();
|
||||||
private final TcpListener tcpListener = new TcpListener();
|
private final TcpListener tcpListener = new TcpListener();
|
||||||
|
|
||||||
private Presence presenceServer;
|
|
||||||
|
|
||||||
public UserList() {
|
public UserList() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -50,6 +45,20 @@ public class UserList {
|
||||||
}, errorCallback);
|
}, errorCallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void onReceivePresenceNotification(ArrayList<UserInformation> newPresenceUsers, TcpConnection proxyConnection) {
|
||||||
|
newPresenceUsers.forEach((userInfo -> {
|
||||||
|
final PeerUser savedUser = userHashmap.get(userInfo.id);
|
||||||
|
if (savedUser != null) {
|
||||||
|
Log.v(getClass().getSimpleName(), "Received user from presence server already known");
|
||||||
|
} else {
|
||||||
|
final PeerUser user = new PeerUser();
|
||||||
|
user.init(proxyConnection, userInfo.id, userInfo.getUsername(), null);
|
||||||
|
userHashmap.put(user.id, user);
|
||||||
|
Platform.runLater(() -> userObservableList.add(user));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts listening for other broadcasts.
|
* Starts listening for other broadcasts.
|
||||||
* Only answers and waits for them to initiate the connection.
|
* Only answers and waits for them to initiate the connection.
|
||||||
|
@ -132,30 +141,6 @@ public class UserList {
|
||||||
userHashmap.forEach((id, user) -> user.sendCurrentUser(Throwable::printStackTrace));
|
userHashmap.forEach((id, user) -> user.sendCurrentUser(Throwable::printStackTrace));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the presence server based on user config
|
|
||||||
*
|
|
||||||
* @param config The user config
|
|
||||||
*/
|
|
||||||
public void initPresenceServer(Config config) {
|
|
||||||
final Config.ServerConfig serverConfig = config.getServerConfig();
|
|
||||||
if (serverConfig.isEnabled()) {
|
|
||||||
try {
|
|
||||||
final PresenceType type = serverConfig.getType();
|
|
||||||
final String uri = serverConfig.getUri();
|
|
||||||
final int presencePort = serverConfig.getPresencePort();
|
|
||||||
final int proxyPort = serverConfig.getProxyPort();
|
|
||||||
presenceServer = PresenceFactory.create(type, uri, presencePort, proxyPort);
|
|
||||||
Log.v(getClass().getSimpleName(), "Presence server support enabled: " + type + "@" + uri + ':' + presencePort + " / proxy:" + proxyPort);
|
|
||||||
} catch (UnknownPresenceException e) {
|
|
||||||
Log.e(getClass().getSimpleName(), "Presence server type not found", e);
|
|
||||||
presenceServer = null;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Log.v(getClass().getSimpleName(), "Presence server support disabled.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Closes all running threads, sockets and db connection.
|
* Closes all running threads, sockets and db connection.
|
||||||
* Must be called before exiting the app.
|
* Must be called before exiting the app.
|
||||||
|
|
Loading…
Reference in a new issue