fix: issue in which UI callback listeners were leaking

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2024-11-23 14:14:52 +01:00
parent 20a066dabb
commit 4d1fffe42a
2 changed files with 270 additions and 275 deletions

View File

@ -14,7 +14,6 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateIntAsState
import androidx.compose.animation.fadeIn
@ -65,6 +64,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@ -162,10 +162,7 @@ private fun pxAsDp(px: Int): Dp {
).dp
}
@OptIn(
ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class,
ExperimentalAnimationApi::class
)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun ChannelScreen(
channelId: String,
@ -173,25 +170,30 @@ fun ChannelScreen(
useDrawer: Boolean,
viewModel: ChannelScreenViewModel = hiltViewModel()
) {
// Setup
// <editor-fold desc="State and effects">
val scope = rememberCoroutineScope()
val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.startListening(createUiCallbackListener = true)
viewModel.listenToWsEvents()
}
// Load/switch channel
DisposableEffect(Unit) {
val job = scope.launch { viewModel.listenToUiCallbacks() }
onDispose {
job.cancel()
}
}
// </editor-fold>
// <editor-fold desc="Load/switch channel">
val channelPermissions by rememberChannelPermissions(channelId, viewModel.ensuredSelfMember)
LaunchedEffect(channelId) {
viewModel.switchChannel(channelId)
}
// Keyboard height
// </editor-fold>
// <editor-fold desc="Keyboard height handling">
val imeTarget = WindowInsets.imeAnimationTarget.getBottom(LocalDensity.current)
val navigationBarsInset = WindowInsets.navigationBars.getBottom(LocalDensity.current)
val imeCurrentInset = WindowInsets.ime.getBottom(LocalDensity.current)
@ -211,9 +213,8 @@ fun ChannelScreen(
imeInTransition = false
}
}
// Attachment handling
// </editor-fold>
// <editor-fold desc="Attachment handling">
val processFileUri: (Uri, String?) -> Unit = remember {
{ uri, pickerIdentifier ->
DocumentFile.fromSingleUri(context, uri)?.let { file ->
@ -278,9 +279,8 @@ fun ChannelScreen(
}
}
}
// UI elements
// </editor-fold>
// <editor-fold desc="UI elements">
val lazyListState = rememberLazyListState()
val isScrolledToBottom = remember(lazyListState) {
@ -328,9 +328,8 @@ fun ChannelScreen(
}
}
}
// Sheets
// </editor-fold>
// <editor-fold desc="Sheets">
var channelInfoSheetShown by remember { mutableStateOf(false) }
if (channelInfoSheetShown) {
val channelInfoSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
@ -399,9 +398,8 @@ fun ChannelScreen(
}
}
}
// Begin UI composition
// </editor-fold>
// <editor-fold desc="Begin UI composition">
Scaffold(
contentWindowInsets = WindowInsets(
left = 0,
@ -1033,4 +1031,5 @@ fun ChannelScreen(
}
}
}
// </editor-fold>
}

View File

@ -498,258 +498,254 @@ class ChannelScreenViewModel @Inject constructor(
ackChannel(channel?.id ?: return, messageId)
}
suspend fun startListening(createUiCallbackListener: Boolean = true) {
viewModelScope.launch {
withContext(RevoltAPI.realtimeContext) {
flow {
while (true) {
emit(RevoltAPI.wsFrameChannel.receive())
}
}.onEach {
when (it) {
is MessageFrame -> {
if (it.channel != channel?.id) return@onEach
// If we already have the message we are just catching up on the WebSocket connection. Skip
if (items.any { m -> (m is ChannelScreenItem.RegularMessage && m.message.id == it.id) || (m is ChannelScreenItem.SystemMessage && m.message.id == it.id) }) return@onEach
it.author?.let { userId ->
if (RevoltAPI.userCache[userId] == null) {
RevoltAPI.userCache[userId] = fetchUser(userId)
}
}
channel?.server?.let { serverId ->
try {
it.author?.let { userId ->
fetchMember(serverId, userId)
}
} catch (e: Exception) {
Log.e("ChannelScreenViewModel", "Failed to fetch member", e)
}
}
if (didInitialChannelFetch) { // this check is so that we don't end up with a message that arrives at the same time as the initial fetch in front of the loading indicator
val newItem = when {
it.system != null -> ChannelScreenItem.SystemMessage(it)
else -> ChannelScreenItem.RegularMessage(it)
}
updateItems(listOf(newItem) + items.filter { m ->
if (m is ChannelScreenItem.ProspectiveMessage) {
m.message.id != it.nonce
} else {
true
}
})
}
it.id?.let { mid -> ackMessage(mid) }
}
is MessageDeleteFrame -> {
if (it.channel != channel?.id) return@onEach
val newRenderableMessages =
items.filter { m ->
if (m is ChannelScreenItem.RegularMessage) {
m.message.id != it.id
} else {
true
}
}
updateItems(newRenderableMessages)
}
is MessageUpdateFrame -> {
if (it.channel != channel?.id) return@onEach
val messageFrame =
RevoltJson.decodeFromJsonElement(MessageFrame.serializer(), it.data)
val currentMessage = items.find { m ->
m is ChannelScreenItem.RegularMessage && m.message.id == it.id
}
if (currentMessage == null) return@onEach
if (messageFrame.author != null) {
addUserIfUnknown(messageFrame.author)
}
updateItems(
items.map { m ->
if (m is ChannelScreenItem.RegularMessage && m.message.id == it.id) {
ChannelScreenItem.RegularMessage(
m.message.mergeWithPartial(messageFrame)
)
} else {
m
}
}
)
}
is MessageAppendFrame -> {
if (it.channel != channel?.id) return@onEach
val hasMessage = items.any { currentMsg ->
currentMsg is ChannelScreenItem.RegularMessage && currentMsg.message.id == it.id
}
if (!hasMessage) return@onEach
updateItems(
items.map { currentMsg ->
if (currentMsg is ChannelScreenItem.RegularMessage && currentMsg.message.id == it.id) {
RevoltAPI.messageCache[it.id]?.let { m ->
ChannelScreenItem.RegularMessage(m)
} ?: return@map currentMsg
} else {
currentMsg
}
}
)
}
is MessageReactFrame -> {
if (it.channel_id != channel?.id) return@onEach
val hasMessage = items
.filterIsInstance<ChannelScreenItem.RegularMessage>()
.any { msg ->
msg.message.id == it.id
}
if (!hasMessage) return@onEach
updateItems(
items.map { currentMsg ->
if (currentMsg is ChannelScreenItem.RegularMessage && currentMsg.message.id == it.id) {
RevoltAPI.messageCache[it.id]?.let { m ->
ChannelScreenItem.RegularMessage(m)
} ?: return@map currentMsg
} else {
currentMsg
}
}
)
}
is MessageUnreactFrame -> {
if (it.channel_id != channel?.id) return@onEach
val hasMessage = items
.filterIsInstance<ChannelScreenItem.RegularMessage>()
.any { msg ->
msg.message.id == it.id
}
if (!hasMessage) return@onEach
updateItems(
items.map { currentMsg ->
if (currentMsg is ChannelScreenItem.RegularMessage && currentMsg.message.id == it.id) {
RevoltAPI.messageCache[it.id]?.let { m ->
ChannelScreenItem.RegularMessage(m)
} ?: return@map currentMsg
} else {
currentMsg
}
}
)
}
is ChannelStartTypingFrame -> {
if (it.id != channel?.id) return@onEach
if (typingUsers.contains(it.user)) return@onEach
if (it.user == RevoltAPI.selfId) return@onEach
addUserIfUnknown(it.user)
typingUsers.add(it.user)
}
is ChannelStopTypingFrame -> {
if (it.id != channel?.id) return@onEach
if (!typingUsers.contains(it.user)) return@onEach
typingUsers.remove(it.user)
}
is ChannelDeleteFrame -> {
if (it.id != channel?.id) return@onEach
// FIXME This is UI logic from the view model. Too bad!
ActionChannel.send(
Action.ChatNavigate(
ChatRouterDestination.NoCurrentChannel(
channel?.server ?: return@onEach
)
)
)
}
is RealtimeSocketFrames.Reconnected -> {
Log.d("ChannelScreen", "Reconnected to WS.")
loadMessages(50, ignoreExisting = true)
startListening(createUiCallbackListener = false)
}
}
}.catch {
Log.e("ChannelScreen", "Failed to receive WS frame", it)
}.launchIn(this)
}
}
if (createUiCallbackListener) {
viewModelScope.launch {
withContext(Dispatchers.Main) {
UiCallbacks.uiCallbackFlow.onEach {
Log.d("ChannelScreen", "Received UI callback: $it")
when (it) {
is UiCallback.ReplyToMessage -> {
val message = items.find { m ->
m is ChannelScreenItem.RegularMessage && m.message.id == it.messageId
} as? ChannelScreenItem.RegularMessage ?: return@onEach
val shouldMention = kvStorage.getBoolean("mentionOnReply") ?: false
draftReplyTo.add(
SendMessageReply(
message.message.id ?: return@onEach,
shouldMention
)
)
}
is UiCallback.EditMessage -> {
editingMessage = it.messageId
val message = items.find { m ->
m is ChannelScreenItem.RegularMessage && m.message.id == it.messageId
} as? ChannelScreenItem.RegularMessage ?: return@onEach
putDraftContent(message.message.content ?: "")
this@ChannelScreenViewModel.draftAttachments.clear()
draftReplyTo.clear()
}
is UiCallback.ReplyToMessageWithContent -> {
val message = items.find { m ->
m is ChannelScreenItem.RegularMessage && m.message.id == it.messageId
} as? ChannelScreenItem.RegularMessage ?: return@onEach
val shouldMention = kvStorage.getBoolean("mentionOnReply") ?: false
draftReplyTo.add(
SendMessageReply(
message.message.id ?: return@onEach,
shouldMention
)
)
putDraftContent(it.content)
}
}
}.catch {
Log.e("ChannelScreen", "Failed to receive UI callback", it)
}.launchIn(this)
suspend fun listenToWsEvents() {
withContext(RevoltAPI.realtimeContext) {
flow {
while (true) {
emit(RevoltAPI.wsFrameChannel.receive())
}
}
}.onEach {
when (it) {
is MessageFrame -> {
if (it.channel != channel?.id) return@onEach
// If we already have the message we are just catching up on the WebSocket connection. Skip
if (items.any { m -> (m is ChannelScreenItem.RegularMessage && m.message.id == it.id) || (m is ChannelScreenItem.SystemMessage && m.message.id == it.id) }) return@onEach
it.author?.let { userId ->
if (RevoltAPI.userCache[userId] == null) {
RevoltAPI.userCache[userId] = fetchUser(userId)
}
}
channel?.server?.let { serverId ->
try {
it.author?.let { userId ->
fetchMember(serverId, userId)
}
} catch (e: Exception) {
Log.e("ChannelScreenViewModel", "Failed to fetch member", e)
}
}
if (didInitialChannelFetch) { // this check is so that we don't end up with a message that arrives at the same time as the initial fetch in front of the loading indicator
val newItem = when {
it.system != null -> ChannelScreenItem.SystemMessage(it)
else -> ChannelScreenItem.RegularMessage(it)
}
updateItems(listOf(newItem) + items.filter { m ->
if (m is ChannelScreenItem.ProspectiveMessage) {
m.message.id != it.nonce
} else {
true
}
})
}
it.id?.let { mid -> ackMessage(mid) }
}
is MessageDeleteFrame -> {
if (it.channel != channel?.id) return@onEach
val newRenderableMessages =
items.filter { m ->
if (m is ChannelScreenItem.RegularMessage) {
m.message.id != it.id
} else {
true
}
}
updateItems(newRenderableMessages)
}
is MessageUpdateFrame -> {
if (it.channel != channel?.id) return@onEach
val messageFrame =
RevoltJson.decodeFromJsonElement(MessageFrame.serializer(), it.data)
val currentMessage = items.find { m ->
m is ChannelScreenItem.RegularMessage && m.message.id == it.id
}
if (currentMessage == null) return@onEach
if (messageFrame.author != null) {
addUserIfUnknown(messageFrame.author)
}
updateItems(
items.map { m ->
if (m is ChannelScreenItem.RegularMessage && m.message.id == it.id) {
ChannelScreenItem.RegularMessage(
m.message.mergeWithPartial(messageFrame)
)
} else {
m
}
}
)
}
is MessageAppendFrame -> {
if (it.channel != channel?.id) return@onEach
val hasMessage = items.any { currentMsg ->
currentMsg is ChannelScreenItem.RegularMessage && currentMsg.message.id == it.id
}
if (!hasMessage) return@onEach
updateItems(
items.map { currentMsg ->
if (currentMsg is ChannelScreenItem.RegularMessage && currentMsg.message.id == it.id) {
RevoltAPI.messageCache[it.id]?.let { m ->
ChannelScreenItem.RegularMessage(m)
} ?: return@map currentMsg
} else {
currentMsg
}
}
)
}
is MessageReactFrame -> {
if (it.channel_id != channel?.id) return@onEach
val hasMessage = items
.filterIsInstance<ChannelScreenItem.RegularMessage>()
.any { msg ->
msg.message.id == it.id
}
if (!hasMessage) return@onEach
updateItems(
items.map { currentMsg ->
if (currentMsg is ChannelScreenItem.RegularMessage && currentMsg.message.id == it.id) {
RevoltAPI.messageCache[it.id]?.let { m ->
ChannelScreenItem.RegularMessage(m)
} ?: return@map currentMsg
} else {
currentMsg
}
}
)
}
is MessageUnreactFrame -> {
if (it.channel_id != channel?.id) return@onEach
val hasMessage = items
.filterIsInstance<ChannelScreenItem.RegularMessage>()
.any { msg ->
msg.message.id == it.id
}
if (!hasMessage) return@onEach
updateItems(
items.map { currentMsg ->
if (currentMsg is ChannelScreenItem.RegularMessage && currentMsg.message.id == it.id) {
RevoltAPI.messageCache[it.id]?.let { m ->
ChannelScreenItem.RegularMessage(m)
} ?: return@map currentMsg
} else {
currentMsg
}
}
)
}
is ChannelStartTypingFrame -> {
if (it.id != channel?.id) return@onEach
if (typingUsers.contains(it.user)) return@onEach
if (it.user == RevoltAPI.selfId) return@onEach
addUserIfUnknown(it.user)
typingUsers.add(it.user)
}
is ChannelStopTypingFrame -> {
if (it.id != channel?.id) return@onEach
if (!typingUsers.contains(it.user)) return@onEach
typingUsers.remove(it.user)
}
is ChannelDeleteFrame -> {
if (it.id != channel?.id) return@onEach
// FIXME This is UI logic from the view model. Too bad!
ActionChannel.send(
Action.ChatNavigate(
ChatRouterDestination.NoCurrentChannel(
channel?.server ?: return@onEach
)
)
)
}
is RealtimeSocketFrames.Reconnected -> {
Log.d("ChannelScreen", "Reconnected to WS.")
loadMessages(50, ignoreExisting = true)
listenToWsEvents()
}
}
}.catch {
Log.e("ChannelScreen", "Failed to receive WS frame", it)
}.launchIn(this)
}
}
suspend fun listenToUiCallbacks() {
withContext(Dispatchers.Main) {
UiCallbacks.uiCallbackFlow.onEach {
Log.d("ChannelScreen", "Received UI callback: $it")
when (it) {
is UiCallback.ReplyToMessage -> {
val message = items.find { m ->
m is ChannelScreenItem.RegularMessage && m.message.id == it.messageId
} as? ChannelScreenItem.RegularMessage ?: return@onEach
val shouldMention = kvStorage.getBoolean("mentionOnReply") ?: false
draftReplyTo.add(
SendMessageReply(
message.message.id ?: return@onEach,
shouldMention
)
)
}
is UiCallback.EditMessage -> {
editingMessage = it.messageId
val message = items.find { m ->
m is ChannelScreenItem.RegularMessage && m.message.id == it.messageId
} as? ChannelScreenItem.RegularMessage ?: return@onEach
putDraftContent(message.message.content ?: "")
this@ChannelScreenViewModel.draftAttachments.clear()
draftReplyTo.clear()
}
is UiCallback.ReplyToMessageWithContent -> {
val message = items.find { m ->
m is ChannelScreenItem.RegularMessage && m.message.id == it.messageId
} as? ChannelScreenItem.RegularMessage ?: return@onEach
val shouldMention = kvStorage.getBoolean("mentionOnReply") ?: false
draftReplyTo.add(
SendMessageReply(
message.message.id ?: return@onEach,
shouldMention
)
)
putDraftContent(it.content)
}
}
}.catch {
Log.e("ChannelScreen", "Failed to receive UI callback", it)
}.launchIn(this)
}
}