Skip to content

Commit 96b4f43

Browse files
committed
New chat architecture + replace ChatKit with Compose + chat relay via signaling
Most code was coded by myself but it partly contains code that was generated by AI agents: AI-assistant: Claude Code 2.1.80 (Claude Sonnet 4.6) AI-assistant: GitHUb Copilot 1.7.1-243 (Claude Sonnet 4.6) AI-assistant: Gemini bundled 253.30387.90.2532.14935130 (gemini-3-flash-preview) --- - resolve #1552 - resolve #5633 - resolve #2000 ## Chat relay - Use signaling to receive chat messages (instead of long polling) when the HPB supports it - Introduce a regular insurance request if somehow signaling messages did not make it through ## New chat architecture - The ChatKit library is removed. A lot of logic and designs are reimplemented (using MVVM and Jetpack Compose) - Change the chat architecture from an event based message handling to a state based messages handling. - Before: messages were passed to ui directly from responses (as well to DB). This was necessary as the chatkit library required different processing depending on if messages from the past or future were received - Now: Received messages are just written to database no matter where they come from (initial request / request for scrolling into past /long polling / signaling / insurance request). Messages are observed on database to be shown in UI via flows. The database is the single source of truth, so no events are needed to update the UI. To avoid messages gaps in UI, the chat block handling is used: the flow is implemented so that always the messages from the newest chat block are emitted. For this, the database is observed for the newest chat block and whenever it changes, the messages of it are emitted. - Design changes while migrating XML to Compose ## TODO - [x] avatars - [x] determinePreviousMessageIds + handleExpandableSystemMessages + collapseSystemMessages() - [x] quoted messages - [x] chat relay - [x] support multiple message handling via chat-relay https://nextcloud-spreed-signaling.readthedocs.io/en/latest/standalone-signaling-api-v1/#send-chat-room-message - [x] implement signaling handling for chat-relay - [x] add features to HelloWebSocketMessage - [x] implement insurance request - [x] implement new mark as read handling - [x] handle actually shown newest messages on screen - Before: messages were marked as read as soon as they were received. - Now: messages are marked as read until the newest message that was shown on the screen. This is transmitted whenever the user manually scrolls or when the chat is closed/brought to background. - [x] improve scope handling of the flows from database - [x] last read handling - [x] show unread chat item. Add in viewmodel - [x] message context menu - [x] add temporary solution to get ChatMessage for this - [x] show play button for audio/video with preview - [x] reactions - [x] different handling for chatRelay? - [x] nextcloud/spreed#16349 - [x] implement all message types - [x] media files - [x] voice message playback - [x] location - [x] link preview - [x] poll - [x] temp messages - [x] threads handling - [x] reply handling - [x] get single message from offline stored messages - [x] get single message from server if ot stored offline - [x] scroll to message when clicked - [x] make new ui model for chat message immutable to work better with Compose - [x] rewrite handling of message parameters / rich object type handling / "selectedIndividualHashMap"? - [x] reimplement mention chips - [ ] improve it - [x] send chat-relay feature offer to HPB - [x] handle chat-relay feature response from HPB - [x] migrate database for unique reference id's - [x] partly reimplement message editing - [x] add theming to chatComponents by CompositionLocalProvider - [x] implement own stickyHeader for reversed list - [x] Compose only offers stickyHeader when the list is not reverted. But i use "reverseLayout = true" as it fits best for a chatapp. To have a stickyHeader anyway i implemented a stickyHeader for reversed lists - [x] do never show it when chat is scrolled to bottom - [x] reimplement temorary messages - [x] change ChatMessages database table: make referenceIds unique - [x] reimplement message read indicator - [x] reimplement message status icon handling - [x] introduce MessageTypeContent for type-safe rendering - [x] reimplement parent message handling - [x] load parent messages from same flow that are already contained in latest chat block - [x] load missing parent messages from api or older chatBlocks - [x] reimplement markdown - [ ] improve it - [x] reimplement links handling - [x] reimplement unread message marker between chat messages - [x] reimplement unread messages bubble - use latestKnownMessageIdFromSync ? -> move to viewmodel - [x] Set Read marker - [x] add message counter for unread messages bubble - [x] remove chatkit dependency - [x] check New features. Scheduled messaages / fixed messages - [x] Copyright headers - [x] lint + format code - [x] Remove dead code & comments - [x] Refresh chat after file upload - [x] fix incoming bubble width for group chats - [x] comment in openHelperFactory again ## TODOs for followup PRs - [ ] bugfix: sometimes jumping to newest message when scrolling up - [ ] empty list passed?? - [ ] ViewCompositionStrategy ?? - [ ] special system messages handling regarding signaling? - [ ] handle and translate the important ones - [ ] request server via insurance request for others instead to handle the message ref https://github.com/nextcloud/spreed/blob/fd1807d0017d736b3d058a830f720defc670a6a0/lib/Signaling/Listener.php#L80-L101 ref https://github.com/nextcloud/spreed/blob/bf0f5f05e42b9d94449970c09836978650b1a514/lib/Chat/Parser/SystemMessage.php#L118 - [ ] reimplement search feature - [ ] swipe to answer.. - [ ] reimplement/fix lobby - [ ] Performance optimizations - [ ] pagination /Paging3 - [ ] move even more logic out of composables - [ ] Grouping of bubbles - [ ] bubble design - [ ] show only one avatar per bubble-group - [ ] playback voice messages in service. Playback in ConversationList was removed. There mst be an overall solution to play voice messages in OS notification panel. - [ ] reimplement GIF autoplay (#5969 was merged for legacy code) - [ ] "Call started" message? - [ ] Download progress - [ ] Upload progress - [ ] Reactions color improvements - [ ] Quotes must support all message types. Recursive? - [ ] Check click/longclick handling - [ ] use Blurhash - [ ] click on mentions - [ ] checkbox handling - [ ] deck card messages - [ ] modify scheduled messages list to reuse the Composables from the chat. Modify the Composables where needed. Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
1 parent aae1b97 commit 96b4f43

153 files changed

Lines changed: 6686 additions & 13027 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/build.gradle.kts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,6 @@ dependencies {
268268
implementation("com.github.ddB0515.FlexibleAdapter:flexible-adapter-ui:5.1.1")
269269
implementation("org.apache.commons:commons-lang3:3.20.0")
270270
implementation("com.google.code.findbugs:jsr305:3.0.2")
271-
implementation("com.github.nextcloud-deps:ChatKit:0.4.2")
272271
implementation("joda-time:joda-time:2.14.1")
273272
implementation("io.coil-kt:coil:$coilKtVersion")
274273
implementation("io.coil-kt:coil-gif:$coilKtVersion")

app/src/androidTest/java/com/nextcloud/talk/data/database/dao/ChatMessagesDaoTest.kt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ class ChatMessagesDaoTest {
108108
assertEquals(conversation1, conversation1GotByToken)
109109

110110
// Lets insert some messages to the conversations
111-
chatMessagesDao.upsertChatMessages(
111+
chatMessagesDao.upsertChatMessagesAndDeleteTemp(
112112
listOf(
113113
createChatMessageEntity(conversation1.internalId, "hello"),
114114
createChatMessageEntity(conversation1.internalId, "here"),
@@ -117,22 +117,22 @@ class ChatMessagesDaoTest {
117117
createChatMessageEntity(conversation1.internalId, "messages")
118118
)
119119
)
120-
chatMessagesDao.upsertChatMessages(
120+
chatMessagesDao.upsertChatMessagesAndDeleteTemp(
121121
listOf(
122122
createChatMessageEntity(conversation2.internalId, "first message in conversation 2")
123123
)
124124
)
125125

126-
chatMessagesDao.getMessagesForConversation(conversation1.internalId).first().forEach {
126+
chatMessagesDao.getMessagesForConversation(conversation1.internalId, null).first().forEach {
127127
Log.d(tag, "- next Message for conversation1 (account1)-")
128128
Log.d(tag, "id (PK): " + it.id)
129129
Log.d(tag, "message: " + it.message)
130130
}
131131

132-
val chatMessagesConv1 = chatMessagesDao.getMessagesForConversation(conversation1.internalId)
132+
val chatMessagesConv1 = chatMessagesDao.getMessagesForConversation(conversation1.internalId, null)
133133
assertEquals(5, chatMessagesConv1.first().size)
134134

135-
val chatMessagesConv2 = chatMessagesDao.getMessagesForConversation(conversation2.internalId)
135+
val chatMessagesConv2 = chatMessagesDao.getMessagesForConversation(conversation2.internalId, null)
136136
assertEquals(1, chatMessagesConv2.first().size)
137137

138138
assertEquals("some", chatMessagesConv1.first()[1].message)

app/src/androidTest/java/com/nextcloud/talk/data/database/migrations/MigrationsTest.kt

Lines changed: 92 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77

88
package com.nextcloud.talk.data.database.migrations
99

10-
import androidx.room.Room
1110
import androidx.room.testing.MigrationTestHelper
11+
import androidx.sqlite.db.SupportSQLiteDatabase
1212
import androidx.test.ext.junit.runners.AndroidJUnit4
1313
import androidx.test.platform.app.InstrumentationRegistry
1414
import com.nextcloud.talk.data.source.local.Migrations
@@ -20,10 +20,9 @@ import java.io.IOException
2020

2121
@RunWith(AndroidJUnit4::class)
2222
class MigrationsTest {
23+
2324
companion object {
2425
private const val TEST_DB = "migration-test"
25-
private const val INIT_VERSION = 10 // last version before update to offline first
26-
private val TAG = MigrationsTest::class.java.simpleName
2726
}
2827

2928
@get:Rule
@@ -32,21 +31,96 @@ class MigrationsTest {
3231
TalkDatabase::class.java
3332
)
3433

35-
@Test
36-
@Throws(IOException::class)
37-
@Suppress("SpreadOperator")
38-
fun migrateAll() {
39-
helper.createDatabase(TEST_DB, INIT_VERSION).apply {
40-
close()
41-
}
42-
43-
Room.databaseBuilder(
44-
InstrumentationRegistry.getInstrumentation().targetContext,
45-
TalkDatabase::class.java,
46-
TEST_DB
47-
).addMigrations(*TalkDatabase.MIGRATIONS).build().apply {
48-
openHelper.writableDatabase.close()
49-
}
34+
private fun insertMessage(
35+
db: SupportSQLiteDatabase,
36+
internalId: String,
37+
referenceId: String?,
38+
isTemporary: Int,
39+
timestamp: Long
40+
) {
41+
db.execSQL(
42+
"""
43+
INSERT INTO ChatMessages (
44+
internalId,
45+
accountId,
46+
token,
47+
id,
48+
internalConversationId,
49+
threadId,
50+
isThread,
51+
actorDisplayName,
52+
message,
53+
actorId,
54+
actorType,
55+
deleted,
56+
expirationTimestamp,
57+
isReplyable,
58+
isTemporary,
59+
lastEditActorDisplayName,
60+
lastEditActorId,
61+
lastEditActorType,
62+
lastEditTimestamp,
63+
markdown,
64+
messageParameters,
65+
messageType,
66+
parent,
67+
reactions,
68+
reactionsSelf,
69+
referenceId,
70+
sendStatus,
71+
silent,
72+
systemMessage,
73+
threadTitle,
74+
threadReplies,
75+
timestamp,
76+
pinnedActorType,
77+
pinnedActorId,
78+
pinnedActorDisplayName,
79+
pinnedAt,
80+
pinnedUntil,
81+
sendAt
82+
) VALUES (
83+
'$internalId',
84+
1,
85+
'token',
86+
1,
87+
'conv',
88+
NULL,
89+
0,
90+
'User',
91+
'Hello',
92+
'actor1',
93+
'USER',
94+
0,
95+
0,
96+
0,
97+
$isTemporary,
98+
NULL,
99+
NULL,
100+
NULL,
101+
0,
102+
0,
103+
NULL,
104+
'comment',
105+
NULL,
106+
NULL,
107+
NULL,
108+
${if (referenceId != null) "'$referenceId'" else "NULL"},
109+
NULL,
110+
0,
111+
0,
112+
NULL,
113+
0,
114+
$timestamp,
115+
NULL,
116+
NULL,
117+
NULL,
118+
NULL,
119+
NULL,
120+
0
121+
)
122+
"""
123+
)
50124
}
51125

52126
@Test

app/src/main/java/com/nextcloud/talk/activities/BaseActivity.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import com.nextcloud.talk.utils.adjustUIForAPILevel35
4444
import com.nextcloud.talk.utils.bundle.BundleKeys
4545
import com.nextcloud.talk.utils.database.user.CurrentUserProvider
4646
import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld
47+
import com.nextcloud.talk.utils.message.MessageUtils
4748
import com.nextcloud.talk.utils.preferences.AppPreferences
4849
import com.nextcloud.talk.utils.ssl.TrustManager
4950
import org.greenrobot.eventbus.EventBus
@@ -72,6 +73,9 @@ open class BaseActivity : AppCompatActivity() {
7273
@Inject
7374
lateinit var viewThemeUtils: ViewThemeUtils
7475

76+
@Inject
77+
lateinit var messageUtils: MessageUtils
78+
7579
@Inject
7680
lateinit var context: Context
7781

app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,10 @@ class CallActivity : CallBaseActivity() {
284284
private var isBreakoutRoom = false
285285
private val localParticipantMessageListener = LocalParticipantMessageListener { token ->
286286
switchToRoomToken = token
287-
hangup(true, false)
287+
hangup(
288+
shutDownView = true,
289+
endCallForAll = false
290+
)
288291
}
289292
private val offerMessageListener = OfferMessageListener { sessionId, roomType, sdp, nick ->
290293
getOrCreatePeerConnectionWrapperForSessionIdAndType(
@@ -1900,7 +1903,7 @@ class CallActivity : CallBaseActivity() {
19001903

19011904
when (messageType) {
19021905
"usersInRoom" ->
1903-
internalSignalingMessageReceiver.process(signaling.messageWrapper as List<Map<String?, Any?>?>?)
1906+
internalSignalingMessageReceiver.process(signaling.messageWrapper as List<Map<String?, Any?>>)
19041907

19051908
"message" -> {
19061909
val ncSignalingMessage = LoganSquare.parse(
@@ -2716,11 +2719,11 @@ class CallActivity : CallBaseActivity() {
27162719
* All listeners are called in the main thread.
27172720
*/
27182721
private class InternalSignalingMessageReceiver : SignalingMessageReceiver() {
2719-
fun process(users: List<Map<String?, Any?>?>?) {
2722+
fun process(users: List<Map<String?, Any?>>) {
27202723
processUsersInRoom(users)
27212724
}
27222725

2723-
fun process(message: NCSignalingMessage?) {
2726+
fun process(message: NCSignalingMessage) {
27242727
processSignalingMessage(message)
27252728
}
27262729
}

app/src/main/java/com/nextcloud/talk/activities/ParticipantHandler.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ class ParticipantHandler(
139139
_uiState.update { it.copy(raisedHand = state) }
140140
}
141141

142-
override fun onReaction(reaction: String?) {
142+
override fun onReaction(reaction: String) {
143143
Log.d(TAG, "onReaction")
144144
}
145145

app/src/main/java/com/nextcloud/talk/adapters/messages/AdjustableMessageHolderInterface.kt

Lines changed: 0 additions & 51 deletions
This file was deleted.

app/src/main/java/com/nextcloud/talk/adapters/messages/CommonMessageInterface.kt

Lines changed: 0 additions & 16 deletions
This file was deleted.

0 commit comments

Comments
 (0)