Skip to content

Message threads #5067

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 32 commits into from
Jul 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
6a4f66d
add first handling for threads (WIP)
mahibi Jun 18, 2025
75d3b18
open chat when navigating back from thread
mahibi Jun 20, 2025
a8a82c6
add fields for message threads to DB etc.
mahibi Jun 25, 2025
a48f9cb
clear tables during migration (see comment)
mahibi Jun 27, 2025
6187046
add chatBlock handling for threads
mahibi Jul 3, 2025
1ec18bb
fix SQL handling of threads with null values + add test for it
mahibi Jul 4, 2025
225fe39
move "open thread" to context menu + add threads system message
mahibi Jul 4, 2025
059cf4e
add option to create thread
mahibi Jul 4, 2025
3bb9ea4
add threads overview
mahibi Jul 8, 2025
8bc261c
add avatar
mahibi Jul 9, 2025
2639a22
improve threads overview design
mahibi Jul 9, 2025
4a05a08
show last activity in threads overview
mahibi Jul 9, 2025
c5d3aaa
add chips like for conversation item
mahibi Jul 10, 2025
3031bf8
revert chips (overall concept changed)
mahibi Jul 10, 2025
91cd342
set thread titles for chatview header
mahibi Jul 10, 2025
fe87517
change icon, rename strings, add logic to hide "Start thread"
mahibi Jul 11, 2025
ed488d6
fix to open thread from message context menu
mahibi Jul 11, 2025
65d7203
add error handling
mahibi Jul 11, 2025
3b9ab6e
hide features that are not available in thread view
mahibi Jul 11, 2025
d35a8e2
show message context menu also for longclick on quotes
mahibi Jul 11, 2025
ec9c018
use new endpoint for recent threads
mahibi Jul 11, 2025
8c8bee3
fix to remove temp message in thread after sending
mahibi Jul 11, 2025
d25e85e
remove thread for getTempMessagesForConversation
mahibi Jul 11, 2025
59ced88
fix to not show null if no last message of thread exists
mahibi Jul 11, 2025
57f4e2d
add actor names to thread overview
mahibi Jul 11, 2025
429e38e
add replies amount to threads overview items
mahibi Jul 11, 2025
cd068d4
add spacing in ThreadRow
mahibi Jul 14, 2025
18b5744
implement reply logic for text messages
mahibi Jul 14, 2025
1c985cc
delete childrenCount from message (API changed) + format code
mahibi Jul 17, 2025
993cf0a
delete comments
mahibi Jul 17, 2025
68a33ef
comment in openHelperFactory
mahibi Jul 17, 2025
0e59c95
format code
mahibi Jul 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
746 changes: 746 additions & 0 deletions app/schemas/com.nextcloud.talk.data.source.local.TalkDatabase/18.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ class ChatBlocksDaoTest {
@Test
fun testGetConnectedChatBlocks() =
runTest {
usersDao.saveUser(createUserEntity("account1", "Account 1"))
val user = createUserEntity("account1", "Account 1")
usersDao.saveUser(user)
val account1 = usersDao.getUserWithUserId("account1").blockingGet()

conversationsDao.upsertConversations(
Expand All @@ -77,6 +78,7 @@ class ChatBlocksDaoTest {
internalConversationId = conversation1.internalId,
accountId = conversation1.accountId,
token = conversation1.token,
threadId = null,
oldestMessageId = 50,
newestMessageId = 60,
hasHistory = true
Expand All @@ -86,6 +88,7 @@ class ChatBlocksDaoTest {
internalConversationId = conversation1.internalId,
accountId = conversation1.accountId,
token = conversation1.token,
threadId = null,
oldestMessageId = 10,
newestMessageId = 20,
hasHistory = true
Expand All @@ -95,6 +98,7 @@ class ChatBlocksDaoTest {
internalConversationId = conversation1.internalId,
accountId = conversation1.accountId,
token = conversation1.token,
threadId = null,
oldestMessageId = 45,
newestMessageId = 55,
hasHistory = true
Expand All @@ -104,6 +108,7 @@ class ChatBlocksDaoTest {
internalConversationId = conversation1.internalId,
accountId = conversation1.accountId,
token = conversation1.token,
threadId = null,
oldestMessageId = 52,
newestMessageId = 58,
hasHistory = true
Expand All @@ -113,6 +118,7 @@ class ChatBlocksDaoTest {
internalConversationId = conversation1.internalId,
accountId = conversation1.accountId,
token = conversation1.token,
threadId = null,
oldestMessageId = 1,
newestMessageId = 99,
hasHistory = true
Expand All @@ -122,6 +128,7 @@ class ChatBlocksDaoTest {
internalConversationId = conversation1.internalId,
accountId = conversation1.accountId,
token = conversation1.token,
threadId = null,
oldestMessageId = 59,
newestMessageId = 70,
hasHistory = true
Expand All @@ -131,6 +138,7 @@ class ChatBlocksDaoTest {
internalConversationId = conversation1.internalId,
accountId = conversation1.accountId,
token = conversation1.token,
threadId = null,
oldestMessageId = 80,
newestMessageId = 90,
hasHistory = true
Expand All @@ -140,6 +148,7 @@ class ChatBlocksDaoTest {
internalConversationId = conversation2.internalId,
accountId = conversation2.accountId,
token = conversation2.token,
threadId = null,
oldestMessageId = 53,
newestMessageId = 57,
hasHistory = true
Expand All @@ -156,14 +165,93 @@ class ChatBlocksDaoTest {
chatBlocksDao.upsertChatBlock(chatBlockWithinButOtherConversation)

val results = chatBlocksDao.getConnectedChatBlocks(
conversation1.internalId,
searchedChatBlock.oldestMessageId,
searchedChatBlock.newestMessageId
internalConversationId = conversation1.internalId,
threadId = null,
oldestMessageId = searchedChatBlock.oldestMessageId,
newestMessageId = searchedChatBlock.newestMessageId
)

assertEquals(5, results.first().size)
}

@Test
fun testGetConnectedChatBlocksWithThreadsScenario() =
runTest {
val user = createUserEntity("account1", "Account 1")
usersDao.saveUser(user)
val account1 = usersDao.getUserWithUserId("account1").blockingGet()

conversationsDao.upsertConversations(
listOf(
createConversationEntity(
accountId = account1.id,
"abc",
roomName = "Conversation One"
),
createConversationEntity(
accountId = account1.id,
"def",
roomName = "Conversation Two"
)
)
)

val conversation1 = conversationsDao.getConversationsForUser(account1.id).first()[0]

val searchedChatBlock = ChatBlockEntity(
internalConversationId = conversation1.internalId,
accountId = conversation1.accountId,
token = conversation1.token,
threadId = 123,
oldestMessageId = 50,
newestMessageId = 60,
hasHistory = true
)

val chatBlockOverlap1 = ChatBlockEntity(
internalConversationId = conversation1.internalId,
accountId = conversation1.accountId,
token = conversation1.token,
threadId = null,
oldestMessageId = 45,
newestMessageId = 55,
hasHistory = true
)

val chatBlockOverlap2 = ChatBlockEntity(
internalConversationId = conversation1.internalId,
accountId = conversation1.accountId,
token = conversation1.token,
threadId = 123,
oldestMessageId = 59,
newestMessageId = 70,
hasHistory = true
)

chatBlocksDao.upsertChatBlock(searchedChatBlock)

chatBlocksDao.upsertChatBlock(chatBlockOverlap1)
chatBlocksDao.upsertChatBlock(chatBlockOverlap2)

val resultsForThreadIdNull = chatBlocksDao.getConnectedChatBlocks(
internalConversationId = conversation1.internalId,
threadId = null,
oldestMessageId = searchedChatBlock.oldestMessageId,
newestMessageId = searchedChatBlock.newestMessageId
)

assertEquals(1, resultsForThreadIdNull.first().size)

val resultsForThreadId123 = chatBlocksDao.getConnectedChatBlocks(
internalConversationId = conversation1.internalId,
threadId = 123,
oldestMessageId = searchedChatBlock.oldestMessageId,
newestMessageId = searchedChatBlock.newestMessageId
)

assertEquals(2, resultsForThreadId123.first().size)
}

private fun createUserEntity(userId: String, userName: String) =
UserEntity(
userId = userId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,11 @@ class ChatMessagesDaoTest {
assertEquals("are", conv1chatMessage3.message)

val chatMessagesConv1Since =
chatMessagesDao.getMessagesForConversationSince(conversation1.internalId, conv1chatMessage3.id)
chatMessagesDao.getMessagesForConversationSince(
conversation1.internalId,
conv1chatMessage3.id,
null
)
assertEquals(3, chatMessagesConv1Since.first().size)
assertEquals("are", chatMessagesConv1Since.first()[0].message)
assertEquals("some", chatMessagesConv1Since.first()[1].message)
Expand All @@ -150,7 +154,8 @@ class ChatMessagesDaoTest {
chatMessagesDao.getMessagesForConversationBeforeAndEqual(
conversation1.internalId,
conv1chatMessage3.id,
3
3,
null
)
assertEquals(3, chatMessagesConv1To.first().size)
assertEquals("hello", chatMessagesConv1To.first()[2].message)
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,10 @@
android:name=".lock.LockedActivity"
android:theme="@style/AppTheme" />

<activity
android:name=".threadsoverview.ThreadsOverviewActivity"
android:theme="@style/AppTheme" />

<receiver
android:name=".receivers.PackageReplacedReceiver"
android:exported="false">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,9 @@ class IncomingTextMessageViewHolder(itemView: View, payload: Any) :
}
binding.messageText.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
binding.messageText.text = processedMessageText
// just for debugging:
// binding.messageText.text =
// SpannableStringBuilder(processedMessageText).append(" (" + message.jsonMessageId + ")")
} else {
binding.messageText.visibility = View.GONE
binding.checkboxContainer.visibility = View.VISIBLE
Expand All @@ -159,12 +162,23 @@ class IncomingTextMessageViewHolder(itemView: View, payload: Any) :
binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.timestamp)
}
viewThemeUtils.platform.colorTextView(binding.messageTime, ColorRole.ON_SURFACE_VARIANT)

// parent message handling
if (!message.isDeleted && message.parentMessageId != null) {
processParentMessage(message)
binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE
} else {
binding.messageQuote.quotedChatMessageView.visibility = View.GONE
val chatActivity = commonMessageInterface as ChatActivity
binding.messageQuote.quotedChatMessageView.visibility =
if (!message.isDeleted &&
message.parentMessageId != null &&
message.parentMessageId != chatActivity.threadId
) {
processParentMessage(message)
View.VISIBLE
} else {
View.GONE
}

binding.messageQuote.quotedChatMessageView.setOnLongClickListener { l: View? ->
commonMessageInterface.onOpenMessageActionsDialog(message)
true
}

itemView.setTag(R.string.replyable_message_view_tag, message.replyable)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,9 @@ class OutcomingTextMessageViewHolder(itemView: View) :
binding.messageTime.layoutParams = layoutParams
viewThemeUtils.platform.colorTextView(binding.messageText, ColorRole.ON_SURFACE_VARIANT)
binding.messageText.text = processedMessageText
// just for debugging:
// binding.messageText.text =
// SpannableStringBuilder(processedMessageText).append(" (" + message.jsonMessageId + ")")
} else {
binding.messageText.visibility = View.GONE
binding.checkboxContainer.visibility = View.VISIBLE
Expand All @@ -174,12 +177,23 @@ class OutcomingTextMessageViewHolder(itemView: View) :
}
viewThemeUtils.platform.colorTextView(binding.messageTime, ColorRole.ON_SURFACE_VARIANT)
setBubbleOnChatMessage(message)

// parent message handling
if (!message.isDeleted && message.parentMessageId != null) {
processParentMessage(message)
binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE
} else {
binding.messageQuote.quotedChatMessageView.visibility = View.GONE
val chatActivity = commonMessageInterface as ChatActivity
binding.messageQuote.quotedChatMessageView.visibility =
if (!message.isDeleted &&
message.parentMessageId != null &&
message.parentMessageId != chatActivity.threadId
) {
processParentMessage(message)
View.VISIBLE
} else {
View.GONE
}

binding.messageQuote.quotedChatMessageView.setOnLongClickListener { l: View? ->
commonMessageInterface.onOpenMessageActionsDialog(message)
true
}

binding.checkMark.visibility = View.INVISIBLE
Expand All @@ -195,8 +209,6 @@ class OutcomingTextMessageViewHolder(itemView: View) :
updateStatus(R.drawable.ic_check, context.resources?.getString(R.string.nc_message_sent))
}

val chatActivity = commonMessageInterface as ChatActivity

chatActivity.lifecycleScope.launch {
if (message.isTemporary && !networkMonitor.isOnline.value) {
updateStatus(
Expand Down
11 changes: 11 additions & 0 deletions app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import com.nextcloud.talk.models.json.participants.TalkBan
import com.nextcloud.talk.models.json.participants.TalkBanOverall
import com.nextcloud.talk.models.json.profile.ProfileOverall
import com.nextcloud.talk.models.json.testNotification.TestNotificationOverall
import com.nextcloud.talk.models.json.threads.ThreadOverall
import com.nextcloud.talk.models.json.threads.ThreadsOverall
import com.nextcloud.talk.models.json.userAbsence.UserAbsenceOverall
import okhttp3.MultipartBody
import okhttp3.RequestBody
Expand Down Expand Up @@ -285,4 +287,13 @@ interface NcApiCoroutines {

@DELETE
suspend fun unbindRoom(@Header("Authorization") authorization: String, @Url url: String): GenericOverall

@POST
suspend fun createThread(@Header("Authorization") authorization: String, @Url url: String): ThreadOverall

@GET
suspend fun getThreads(@Header("Authorization") authorization: String, @Url url: String): ThreadsOverall

@GET
suspend fun getThread(@Header("Authorization") authorization: String, @Url url: String): ThreadOverall
}
Loading
Loading