Skip to content

Commit afbb57f

Browse files
committed
Add delete confirm prompts
1 parent 7c18536 commit afbb57f

File tree

18 files changed

+726
-107
lines changed

18 files changed

+726
-107
lines changed

composeApp/build.gradle.kts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,8 @@ android {
212212
}
213213
}
214214

215-
dependencies { debugImplementation(compose.uiTooling) }
215+
dependencies { implementation(libs.androidx.foundation.android)
216+
debugImplementation(compose.uiTooling) }
216217

217218
compose.desktop {
218219
application {
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package com.hypergonial.chat.view
2+
3+
actual val modifierStates: ModifierStates = NoopModifierStates
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package com.hypergonial.chat.view
2+
3+
interface ModifierStates {
4+
/** If true, the shift key is currently held down. */
5+
val isShiftHeld: Boolean
6+
7+
/** If true, the control key is currently held down. */
8+
val isCtrlHeld: Boolean
9+
10+
/** If true, the alt key is currently held down. */
11+
val isAltHeld: Boolean
12+
13+
/** If true, the meta (Windows/Command/Super) key is currently held down. */
14+
val isMetaHeld: Boolean
15+
}
16+
17+
object NoopModifierStates : ModifierStates {
18+
override val isShiftHeld: Boolean = false
19+
20+
override val isCtrlHeld: Boolean = false
21+
22+
override val isAltHeld: Boolean = false
23+
24+
override val isMetaHeld: Boolean = false
25+
}
26+
27+
/** The current state of the different modifier keys. */
28+
expect val modifierStates: ModifierStates

composeApp/src/commonMain/kotlin/com/hypergonial/chat/view/components/ChannelComponent.kt

Lines changed: 82 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import com.hypergonial.chat.model.TypingStartEvent
3030
import com.hypergonial.chat.model.exceptions.ClientException
3131
import com.hypergonial.chat.model.getMimeType
3232
import com.hypergonial.chat.model.payloads.Attachment
33+
import com.hypergonial.chat.model.payloads.Channel
3334
import com.hypergonial.chat.model.payloads.Message
3435
import com.hypergonial.chat.model.payloads.Snowflake
3536
import com.hypergonial.chat.model.payloads.User
@@ -46,6 +47,7 @@ import com.hypergonial.chat.view.components.subcomponents.LoadMoreMessagesIndica
4647
import com.hypergonial.chat.view.components.subcomponents.MessageComponent
4748
import com.hypergonial.chat.view.components.subcomponents.MessageEntryComponent
4849
import com.hypergonial.chat.view.content.ChannelContent
50+
import com.hypergonial.chat.view.modifierStates
4951
import io.github.vinceglb.filekit.core.FileKit
5052
import io.github.vinceglb.filekit.core.PickerMode
5153
import io.github.vinceglb.filekit.core.PickerType
@@ -134,9 +136,21 @@ interface ChannelComponent : MainContentComponent, Displayable {
134136
*/
135137
fun onMessageDeleteRequested(messageId: Snowflake)
136138

139+
/**
140+
* Callback called when the user confirms the deletion of a message.
141+
*
142+
* @param messageId The ID of the message to delete.
143+
*/
144+
fun onMessageDeleteConfirmed(messageId: Snowflake)
145+
146+
/** Callback called when the user cancels the deletion of a message. */
147+
fun onMessageDeleteCancelled()
148+
137149
@Composable override fun Display() = ChannelContent(this)
138150

139151
data class State(
152+
/** The channel to display */
153+
val channel: Channel,
140154
/** The value of the chat bar */
141155
val chatBarValue: TextFieldValue = TextFieldValue(),
142156
/** Attachments awaiting upload */
@@ -156,6 +170,8 @@ interface ChannelComponent : MainContentComponent, Displayable {
156170
val isJumpingToBottom: Boolean = false,
157171
/** If true, the file upload dropdown is open */
158172
val isFileUploadDropdownOpen: Boolean = false,
173+
/** The message to be deleted */
174+
val pendingDeleteMessage: MessageComponent? = null,
159175
/**
160176
* If true, the client is currently copying file(s) from the clipboard service The view is expected to show a
161177
* pending attachment in the attachments list
@@ -187,7 +203,7 @@ interface ChannelComponent : MainContentComponent, Displayable {
187203
class DefaultChannelComponent(
188204
private val ctx: ComponentContext,
189205
private val client: Client,
190-
private val channelId: Snowflake,
206+
channel: Channel,
191207
private val guildId: Snowflake? = null,
192208
initialEditorState: TextFieldValue? = null,
193209
private val onReadMessages: (Snowflake) -> Unit,
@@ -196,10 +212,12 @@ class DefaultChannelComponent(
196212
private val scope = ctx.coroutineScope()
197213
private val logger = Logger.withTag("DefaultChannelComponent")
198214
private var refreshTakingTooLongJob: Job? = null
215+
private val channelId: Snowflake = channel.id
199216

200217
override val data =
201218
MutableValue(
202219
ChannelComponent.State(
220+
channel = channel,
203221
chatBarValue = initialEditorState ?: TextFieldValue(),
204222
typingIndicators =
205223
mutableStateMapOf<Snowflake, User>().apply {
@@ -253,7 +271,14 @@ class DefaultChannelComponent(
253271
isPending: Boolean = false,
254272
hasUploadingAttachments: Boolean = false,
255273
): MessageComponent {
256-
return DefaultMessageComponent(ctx, client, message, isPending, hasUploadingAttachments)
274+
return DefaultMessageComponent(
275+
ctx,
276+
client,
277+
message,
278+
isPending,
279+
hasUploadingAttachments,
280+
onDeleteRequest = { id -> onMessageDeleteRequested(id) },
281+
)
257282
}
258283

259284
/**
@@ -701,6 +726,36 @@ class DefaultChannelComponent(
701726
entry.removeMessage(event.id)
702727

703728
if (entry.isEmpty()) {
729+
// Ensure the end indicators are not lost
730+
if (entry.data.value.topEndIndicator != null) {
731+
val index = data.value.messageEntries.indexOf(entry)
732+
733+
// Try the top first, then bottom, or create new entry if needed
734+
val nextEntry = data.value.messageEntries.getOrNull(index + 1)
735+
?: data.value.messageEntries.getOrNull(index - 1)
736+
?: run {
737+
messageEntryComponent(mutableStateListOf()).also { newEntry ->
738+
data.value.messageEntries.add(index, newEntry)
739+
}
740+
}
741+
742+
nextEntry.setTopEndIndicator(entry.data.value.topEndIndicator)
743+
}
744+
if (entry.data.value.bottomEndIndicator != null) {
745+
val index = data.value.messageEntries.indexOf(entry)
746+
747+
// Try the bottom first, then top, or create new entry if needed
748+
val nextEntry = data.value.messageEntries.getOrNull(index - 1)
749+
?: data.value.messageEntries.getOrNull(index + 1)
750+
?: run {
751+
messageEntryComponent(mutableStateListOf()).also { newEntry ->
752+
data.value.messageEntries.add(index, newEntry)
753+
}
754+
}
755+
756+
nextEntry.setBottomEndIndicator(entry.data.value.bottomEndIndicator)
757+
}
758+
704759
data.value.messageEntries.remove(entry)
705760
}
706761
}
@@ -729,11 +784,12 @@ class DefaultChannelComponent(
729784
}
730785
}
731786

732-
/** Callback called when the client has resumed after being paused.
787+
/**
788+
* Callback called when the client has resumed after being paused.
733789
*
734-
* This is notably not identical to the onReady listener,
735-
* as that only handles reconnects that were caused due to connection loss.
736-
* */
790+
* This is notably not identical to the onReady listener, as that only handles reconnects that were caused due to
791+
* connection loss.
792+
*/
737793
@Suppress("UNUSED_PARAMETER")
738794
private suspend fun onLifecycleResume(event: LifecycleResumedEvent) {
739795
client.waitUntilReady()
@@ -793,13 +849,27 @@ class DefaultChannelComponent(
793849

794850
override fun onEditLastMessage() {
795851
val lastMessage =
796-
data.value.messageEntries
797-
.firstOrNull { it.author?.id == client.cache.ownUser?.id }?.lastMessage() ?: return
852+
data.value.messageEntries.firstOrNull { it.author?.id == client.cache.ownUser?.id }?.lastMessage() ?: return
798853

799854
lastMessage.onEditStart()
800855
}
801856

802857
override fun onMessageDeleteRequested(messageId: Snowflake) {
858+
if (modifierStates.isShiftHeld) {
859+
onMessageDeleteConfirmed(messageId)
860+
return
861+
}
862+
863+
data.value =
864+
data.value.copy(
865+
pendingDeleteMessage =
866+
data.value.messageEntries.firstOrNull { it.containsMessage(messageId) }?.getMessage(messageId)
867+
)
868+
}
869+
870+
override fun onMessageDeleteConfirmed(messageId: Snowflake) {
871+
data.value = data.value.copy(pendingDeleteMessage = null)
872+
803873
scope.launch {
804874
try {
805875
client.deleteMessage(channelId, messageId)
@@ -811,6 +881,10 @@ class DefaultChannelComponent(
811881
}
812882
}
813883

884+
override fun onMessageDeleteCancelled() {
885+
data.value = data.value.copy(pendingDeleteMessage = null)
886+
}
887+
814888
override fun onChatBarContentChanged(value: TextFieldValue) {
815889
if (value.text != data.value.chatBarValue.text) {
816890
scope.launch {

0 commit comments

Comments
 (0)