feat: receive messages
this is honestly half a prototype img: https://autumn.revolt.chat/attachments/GbiNbIf_JysZ10m4ULxGl4SfoOZiK8lj5ZL8fMs39r/image.png
This commit is contained in:
parent
a5288e4ed7
commit
77138cd521
|
|
@ -1 +1,2 @@
|
||||||
/build
|
/build
|
||||||
|
/release
|
||||||
|
|
@ -16,6 +16,7 @@ import chat.revolt.screens.SplashScreen
|
||||||
import chat.revolt.screens.about.AboutScreen
|
import chat.revolt.screens.about.AboutScreen
|
||||||
import chat.revolt.screens.about.AttributionScreen
|
import chat.revolt.screens.about.AttributionScreen
|
||||||
import chat.revolt.screens.about.PlaceholderScreen
|
import chat.revolt.screens.about.PlaceholderScreen
|
||||||
|
import chat.revolt.screens.chat.ChannelScreen
|
||||||
import chat.revolt.screens.chat.HomeScreen
|
import chat.revolt.screens.chat.HomeScreen
|
||||||
import chat.revolt.screens.login.GreeterScreen
|
import chat.revolt.screens.login.GreeterScreen
|
||||||
import chat.revolt.screens.login.LoginScreen
|
import chat.revolt.screens.login.LoginScreen
|
||||||
|
|
@ -92,6 +93,11 @@ fun AppEntrypoint() {
|
||||||
}
|
}
|
||||||
|
|
||||||
composable("chat/home") { HomeScreen(navController) }
|
composable("chat/home") { HomeScreen(navController) }
|
||||||
|
composable("chat/channel/{channelId}") { backStackEntry ->
|
||||||
|
val channelId = backStackEntry.arguments?.getString("channelId") ?: ""
|
||||||
|
|
||||||
|
ChannelScreen(navController, channelId)
|
||||||
|
}
|
||||||
|
|
||||||
composable("about") { AboutScreen(navController) }
|
composable("about") { AboutScreen(navController) }
|
||||||
composable("about/oss") { AttributionScreen(navController) }
|
composable("about/oss") { AttributionScreen(navController) }
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package chat.revolt.api.realtime
|
package chat.revolt.api.realtime
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
import chat.revolt.api.REVOLT_WEBSOCKET
|
import chat.revolt.api.REVOLT_WEBSOCKET
|
||||||
import chat.revolt.api.RevoltAPI
|
import chat.revolt.api.RevoltAPI
|
||||||
import chat.revolt.api.RevoltHttp
|
import chat.revolt.api.RevoltHttp
|
||||||
|
|
@ -94,6 +95,43 @@ object RealtimeSocket {
|
||||||
RevoltAPI.emojiCache[emoji.id!!] = emoji
|
RevoltAPI.emojiCache[emoji.id!!] = emoji
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"Message" -> {
|
||||||
|
val messageFrame = RevoltJson.decodeFromString(MessageFrame.serializer(), rawFrame)
|
||||||
|
Log.d(
|
||||||
|
"RealtimeSocket",
|
||||||
|
"Received message frame for ${messageFrame.id} in channel ${messageFrame.channel}."
|
||||||
|
)
|
||||||
|
|
||||||
|
RevoltAPI.messageCache[messageFrame.id!!] = messageFrame
|
||||||
|
|
||||||
|
channelCallbacks[messageFrame.channel]?.forEach { callback ->
|
||||||
|
callback.onMessage(messageFrame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"ChannelStartTyping" -> {
|
||||||
|
val typingFrame =
|
||||||
|
RevoltJson.decodeFromString(ChannelStartTypingFrame.serializer(), rawFrame)
|
||||||
|
Log.d(
|
||||||
|
"RealtimeSocket",
|
||||||
|
"Received channel start typing frame for ${typingFrame.id} from ${typingFrame.user}."
|
||||||
|
)
|
||||||
|
|
||||||
|
channelCallbacks[typingFrame.id]?.forEach { callback ->
|
||||||
|
callback.onStartTyping(typingFrame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"ChannelStopTyping" -> {
|
||||||
|
val typingFrame =
|
||||||
|
RevoltJson.decodeFromString(ChannelStopTypingFrame.serializer(), rawFrame)
|
||||||
|
Log.d(
|
||||||
|
"RealtimeSocket",
|
||||||
|
"Received channel stop typing frame for ${typingFrame.id} from ${typingFrame.user}."
|
||||||
|
)
|
||||||
|
|
||||||
|
channelCallbacks[typingFrame.id]?.forEach { callback ->
|
||||||
|
callback.onStopTyping(typingFrame)
|
||||||
|
}
|
||||||
|
}
|
||||||
"UserUpdate" -> {
|
"UserUpdate" -> {
|
||||||
val userUpdateFrame =
|
val userUpdateFrame =
|
||||||
RevoltJson.decodeFromString(UserUpdateFrame.serializer(), rawFrame)
|
RevoltJson.decodeFromString(UserUpdateFrame.serializer(), rawFrame)
|
||||||
|
|
@ -105,4 +143,26 @@ object RealtimeSocket {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ChannelCallback {
|
||||||
|
fun onStartTyping(typing: ChannelStartTypingFrame)
|
||||||
|
fun onStopTyping(typing: ChannelStopTypingFrame)
|
||||||
|
fun onMessage(message: MessageFrame)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val channelCallbacks: MutableMap<String, List<ChannelCallback>> = mutableStateMapOf()
|
||||||
|
|
||||||
|
fun registerChannelCallback(channelId: String, callback: ChannelCallback) {
|
||||||
|
val callbacks = channelCallbacks[channelId] ?: emptyList()
|
||||||
|
channelCallbacks[channelId] = callbacks + callback
|
||||||
|
|
||||||
|
Log.d("RealtimeSocket", "Registered channel callback for $channelId.")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unregisterChannelCallback(channelId: String, callback: ChannelCallback) {
|
||||||
|
val callbacks = channelCallbacks[channelId] ?: emptyList()
|
||||||
|
channelCallbacks[channelId] = callbacks - callback
|
||||||
|
|
||||||
|
Log.d("RealtimeSocket", "Unregistered channel callback for $channelId")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -40,7 +40,9 @@ data class Channel(
|
||||||
val active: Boolean? = null,
|
val active: Boolean? = null,
|
||||||
val permissions: Long? = null,
|
val permissions: Long? = null,
|
||||||
val server: String? = null,
|
val server: String? = null,
|
||||||
|
@SerialName("role_permissions")
|
||||||
val rolePermissions: Map<String, DefaultPermissions>? = null,
|
val rolePermissions: Map<String, DefaultPermissions>? = null,
|
||||||
|
@SerialName("default_permissions")
|
||||||
val defaultPermissions: DefaultPermissions? = null,
|
val defaultPermissions: DefaultPermissions? = null,
|
||||||
val nsfw: Boolean? = null,
|
val nsfw: Boolean? = null,
|
||||||
val type: String? = null, // this is _only_ used for websocket events!
|
val type: String? = null, // this is _only_ used for websocket events!
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package chat.revolt.api.schemas
|
package chat.revolt.api.schemas
|
||||||
|
|
||||||
|
import chat.revolt.api.RevoltAPI
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
|
@ -18,7 +19,11 @@ data class Message(
|
||||||
val embeds: List<Embed>? = null,
|
val embeds: List<Embed>? = null,
|
||||||
val mentions: List<String>? = null,
|
val mentions: List<String>? = null,
|
||||||
val type: String? = null, // this is _only_ used for websocket events!
|
val type: String? = null, // this is _only_ used for websocket events!
|
||||||
)
|
) {
|
||||||
|
fun getAuthor(): User? {
|
||||||
|
return author?.let { RevoltAPI.userCache[it] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Embed(
|
data class Embed(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
package chat.revolt.screens.chat
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import chat.revolt.api.RevoltAPI
|
||||||
|
import chat.revolt.api.realtime.RealtimeSocket
|
||||||
|
import chat.revolt.api.realtime.frames.receivable.ChannelStartTypingFrame
|
||||||
|
import chat.revolt.api.realtime.frames.receivable.ChannelStopTypingFrame
|
||||||
|
import chat.revolt.api.realtime.frames.receivable.MessageFrame
|
||||||
|
import chat.revolt.api.schemas.Channel
|
||||||
|
import chat.revolt.api.schemas.Message
|
||||||
|
|
||||||
|
class ChannelScreenViewModel : ViewModel() {
|
||||||
|
private var _channel by mutableStateOf<Channel?>(null)
|
||||||
|
val channel: Channel?
|
||||||
|
get() = _channel
|
||||||
|
|
||||||
|
private var _callbacks = mutableStateOf<RealtimeSocket.ChannelCallback?>(null)
|
||||||
|
val callbacks: RealtimeSocket.ChannelCallback?
|
||||||
|
get() = _callbacks.value
|
||||||
|
|
||||||
|
private var _renderableMessages = mutableStateListOf<Message>()
|
||||||
|
val renderableMessages: List<Message>
|
||||||
|
get() = _renderableMessages
|
||||||
|
|
||||||
|
inner class ChannelScreenCallback : RealtimeSocket.ChannelCallback {
|
||||||
|
override fun onMessage(message: MessageFrame) {
|
||||||
|
Log.d("ChannelScreen", "onMessage: $message")
|
||||||
|
_renderableMessages.add(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartTyping(typing: ChannelStartTypingFrame) {
|
||||||
|
Log.d("ChannelScreen", "onStartTyping: $typing")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStopTyping(typing: ChannelStopTypingFrame) {
|
||||||
|
Log.d("ChannelScreen", "onStopTyping: $typing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerCallback() {
|
||||||
|
_callbacks.value = ChannelScreenCallback()
|
||||||
|
RealtimeSocket.registerChannelCallback(channel!!.id!!, callbacks!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fetchChannel(id: String) {
|
||||||
|
if (id in RevoltAPI.channelCache) {
|
||||||
|
_channel = RevoltAPI.channelCache[id]
|
||||||
|
} else {
|
||||||
|
Log.e("ChannelScreen", "Channel $id not in cache, for now this is fatal!") // FIXME
|
||||||
|
}
|
||||||
|
|
||||||
|
registerCallback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChannelScreen(
|
||||||
|
navController: NavController,
|
||||||
|
channelId: String,
|
||||||
|
viewModel: ChannelScreenViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val channel = viewModel.channel
|
||||||
|
|
||||||
|
LaunchedEffect(channelId) {
|
||||||
|
viewModel.fetchChannel(channelId)
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(channelId) {
|
||||||
|
onDispose {
|
||||||
|
viewModel.callbacks?.let {
|
||||||
|
RealtimeSocket.unregisterChannelCallback(channelId, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channel == null) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
Text(text = channel.name!!)
|
||||||
|
|
||||||
|
viewModel.renderableMessages.forEach {
|
||||||
|
Text(text = "[" + it.getAuthor()!!.username + "] " + it.content!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,13 +6,8 @@ import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Send
|
import androidx.compose.material.icons.filled.Send
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
|
@ -61,52 +56,52 @@ class HomeScreenViewModel @Inject constructor(
|
||||||
messageContent
|
messageContent
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
setMessageContent("")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen(navController: NavController, viewModel: HomeScreenViewModel = hiltViewModel()) {
|
fun HomeScreen(navController: NavController, viewModel: HomeScreenViewModel = hiltViewModel()) {
|
||||||
val user = RevoltAPI.userCache[RevoltAPI.selfId]
|
val user = RevoltAPI.userCache[RevoltAPI.selfId]
|
||||||
|
|
||||||
Column() {
|
val channelDrawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
Text(
|
val scope = rememberCoroutineScope()
|
||||||
text = "Home (placeholder)",
|
|
||||||
style = MaterialTheme.typography.displaySmall.copy(
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
textAlign = TextAlign.Left,
|
|
||||||
fontSize = 24.sp
|
|
||||||
),
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 15.dp, vertical = 15.dp)
|
|
||||||
.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(10.dp)
|
|
||||||
.fillMaxSize()
|
|
||||||
.weight(1f),
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
user?.let {
|
|
||||||
Row {
|
|
||||||
RemoteImage(
|
|
||||||
url = "${REVOLT_FILES}/avatars/${it.avatar?.id}/user.png",
|
|
||||||
modifier = Modifier
|
|
||||||
.size(50.dp)
|
|
||||||
.clip(CircleShape),
|
|
||||||
description = "Avatar for ${it.username} (placeholder!)"
|
|
||||||
)
|
|
||||||
|
|
||||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
|
||||||
it.username?.let { it1 -> Text(text = it1) }
|
|
||||||
it.id?.let { it1 -> Text(text = it1) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
DismissibleNavigationDrawer(drawerState = channelDrawerState, drawerContent = {
|
||||||
|
ModalDrawerSheet {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "User cache",
|
text = "Revolt Lounge",
|
||||||
|
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Black)
|
||||||
|
)
|
||||||
|
Divider()
|
||||||
|
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||||
|
RevoltAPI.channelCache.values
|
||||||
|
.filter { channel ->
|
||||||
|
channel.server == "01F7ZSBSFHQ8TA81725KQCSDDP"
|
||||||
|
}
|
||||||
|
.forEach { channel ->
|
||||||
|
NavigationDrawerItem(
|
||||||
|
selected = false,
|
||||||
|
label = { Text(text = "#" + channel.name) },
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
channelDrawerState.close()
|
||||||
|
navController.navigate("chat/channel/${channel.id}")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Column() {
|
||||||
|
Text(
|
||||||
|
text = "Home (placeholder)",
|
||||||
style = MaterialTheme.typography.displaySmall.copy(
|
style = MaterialTheme.typography.displaySmall.copy(
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
textAlign = TextAlign.Left,
|
textAlign = TextAlign.Left,
|
||||||
|
|
@ -118,42 +113,80 @@ fun HomeScreen(navController: NavController, viewModel: HomeScreenViewModel = hi
|
||||||
)
|
)
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.verticalScroll(rememberScrollState())
|
.padding(10.dp)
|
||||||
.height(200.dp)
|
.fillMaxSize()
|
||||||
|
.weight(1f),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
RevoltAPI.userCache.forEach { (_, user) ->
|
user?.let {
|
||||||
Text(text = user.username ?: user.id ?: "null")
|
Row {
|
||||||
}
|
RemoteImage(
|
||||||
}
|
url = "${REVOLT_FILES}/avatars/${it.avatar?.id}/user.png",
|
||||||
|
modifier = Modifier
|
||||||
|
.size(50.dp)
|
||||||
|
.clip(CircleShape),
|
||||||
|
description = "Avatar for ${it.username} (placeholder!)"
|
||||||
|
)
|
||||||
|
|
||||||
Column() {
|
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||||
FormTextField(
|
it.username?.let { it1 -> Text(text = it1) }
|
||||||
value = viewModel.messageContent,
|
it.id?.let { it1 -> Text(text = it1) }
|
||||||
label = "Content",
|
}
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
onChange = viewModel::setMessageContent
|
|
||||||
)
|
|
||||||
LinkOnHome(
|
|
||||||
heading = "Send",
|
|
||||||
icon = Icons.Filled.Send,
|
|
||||||
onClick = viewModel::sendMessage
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
viewModel.logout()
|
|
||||||
navController.navigate("login/greeting") {
|
|
||||||
popUpTo("chat/home") {
|
|
||||||
inclusive = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
modifier = Modifier
|
Text(
|
||||||
.fillMaxWidth()
|
text = "User cache",
|
||||||
.padding(bottom = 30.dp, top = 5.dp, start = 20.dp, end = 20.dp)
|
style = MaterialTheme.typography.displaySmall.copy(
|
||||||
) {
|
fontWeight = FontWeight.Bold,
|
||||||
Text("Logout")
|
textAlign = TextAlign.Left,
|
||||||
|
fontSize = 24.sp
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 15.dp, vertical = 15.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Column(modifier = Modifier.height(200.dp)) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
RevoltAPI.userCache.forEach { (_, user) ->
|
||||||
|
Text(text = user.username ?: user.id ?: "null")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column() {
|
||||||
|
FormTextField(
|
||||||
|
value = viewModel.messageContent,
|
||||||
|
label = "Content",
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
onChange = viewModel::setMessageContent
|
||||||
|
)
|
||||||
|
LinkOnHome(
|
||||||
|
heading = "Send",
|
||||||
|
icon = Icons.Filled.Send,
|
||||||
|
onClick = viewModel::sendMessage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
viewModel.logout()
|
||||||
|
navController.navigate("login/greeting") {
|
||||||
|
popUpTo("chat/home") {
|
||||||
|
inclusive = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 30.dp, top = 5.dp, start = 20.dp, end = 20.dp)
|
||||||
|
) {
|
||||||
|
Text("Logout")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -28,6 +28,8 @@ val DarkColorScheme = darkColorScheme(
|
||||||
onBackground = Color(FOREGROUND),
|
onBackground = Color(FOREGROUND),
|
||||||
surfaceVariant = Color(0xff172333),
|
surfaceVariant = Color(0xff172333),
|
||||||
onSurfaceVariant = Color(FOREGROUND),
|
onSurfaceVariant = Color(FOREGROUND),
|
||||||
|
surface = Color(0xff111a26),
|
||||||
|
onSurface = Color(FOREGROUND),
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue