From 35b5612950c8fab68779dba076b57897c73d823a Mon Sep 17 00:00:00 2001 From: Janne Mareike Koschinski <janne@kuschku.de> Date: Wed, 2 Mar 2022 00:41:20 +0100 Subject: [PATCH] feat: implement basic message loading --- app/src/main/AndroidManifest.xml | 4 +- .../quasseldroid/messages/MessageStore.kt | 32 ++++--- .../sample/SampleBooleanProvider.kt | 7 ++ .../sample/SampleLocalDateProvider.kt | 10 ++ .../sample/SampleMessageProvider.kt | 87 +++++++++++++++++ .../sample/SampleMessagesProvider.kt | 10 ++ .../sample/SampleSecurityLevelProvider.kt | 2 +- .../service/ClientSessionWrapper.kt | 4 +- .../quasseldroid/service/QuasselBackend.kt | 1 - .../quasseldroid/service/QuasselBinder.kt | 1 - .../quasseldroid/ui/components/LoginView.kt | 13 ++- .../{MessageView.kt => MessageBaseView.kt} | 21 +---- .../ui/components/MessageDayChangeView.kt | 92 ++++++++++++++++++ .../quasseldroid/ui/components/MessageList.kt | 65 +++++++++++++ .../ui/components/NewMessageView.kt | 44 +++++++++ .../ui/components/PasswordTextField.kt | 2 - .../quasseldroid/ui/icons/AvatarIcon.kt | 2 +- .../quasseldroid/ui/routes/HomeRoute.kt | 93 +++++++++++++++---- .../quasseldroid/ui/routes/LoginRoute.kt | 42 ++++++++- .../ui/routes/SampleMessageProvider.kt | 86 ----------------- .../extensions/LazyListStateExtensions.kt | 50 ++++++++++ .../util/extensions/ListExtensions.kt | 11 +++ .../util/irc/SenderColorUtilTest.kt | 2 +- gradle/libs.versions.toml | 2 +- 24 files changed, 522 insertions(+), 161 deletions(-) create mode 100644 app/src/main/kotlin/de/justjanne/quasseldroid/sample/SampleBooleanProvider.kt create mode 100644 app/src/main/kotlin/de/justjanne/quasseldroid/sample/SampleLocalDateProvider.kt create mode 100644 app/src/main/kotlin/de/justjanne/quasseldroid/sample/SampleMessageProvider.kt create mode 100644 app/src/main/kotlin/de/justjanne/quasseldroid/sample/SampleMessagesProvider.kt rename app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/{MessageView.kt => MessageBaseView.kt} (84%) create mode 100644 app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageDayChangeView.kt create mode 100644 app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageList.kt create mode 100644 app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/NewMessageView.kt delete mode 100644 app/src/main/kotlin/de/justjanne/quasseldroid/ui/routes/SampleMessageProvider.kt create mode 100644 app/src/main/kotlin/de/justjanne/quasseldroid/util/extensions/LazyListStateExtensions.kt create mode 100644 app/src/main/kotlin/de/justjanne/quasseldroid/util/extensions/ListExtensions.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9500fe193..fed896206 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -14,8 +14,8 @@ android:name=".MainActivity" android:exported="true" android:launchMode="singleTask" - android:windowSoftInputMode="adjustResize" - android:theme="@style/Theme.AppCompat.DayNight.NoActionBar"> + android:theme="@style/Theme.AppCompat.DayNight.NoActionBar" + android:windowSoftInputMode="adjustResize"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.VIEW" /> diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/messages/MessageStore.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/messages/MessageStore.kt index c9f670522..d72031943 100644 --- a/app/src/main/kotlin/de/justjanne/quasseldroid/messages/MessageStore.kt +++ b/app/src/main/kotlin/de/justjanne/quasseldroid/messages/MessageStore.kt @@ -1,7 +1,11 @@ package de.justjanne.quasseldroid.messages +import androidx.collection.LruCache +import de.justjanne.bitflags.none import de.justjanne.libquassel.client.syncables.ClientBacklogManager import de.justjanne.libquassel.protocol.models.Message +import de.justjanne.libquassel.protocol.models.flags.MessageFlag +import de.justjanne.libquassel.protocol.models.flags.MessageType import de.justjanne.libquassel.protocol.models.ids.BufferId import de.justjanne.libquassel.protocol.models.ids.MsgId import de.justjanne.libquassel.protocol.util.StateHolder @@ -41,51 +45,49 @@ class MessageStore( } }.launchIn(scope) - fun loadAround(bufferId: BufferId, messageId: MsgId) { + fun loadAround(bufferId: BufferId, messageId: MsgId, limit: Int) { scope.launch { state.update { messages -> val (before, after) = listOf( - async { backlogManager.backlog(bufferId, messageId) }, - async { backlogManager.backlogForward(bufferId, messageId) }, - ).awaitAll() + backlogManager.backlog(bufferId, last = messageId, limit = limit) + .mapNotNull { it.into<Message>() }, + backlogManager.backlogForward(bufferId, first = messageId, limit = limit - 1) + .mapNotNull { it.into<Message>() }, + ) val updated = MessageBuffer( atEnd = false, - messages = (before + after) - .mapNotNull { it.into<Message>() } - .sortedBy { it.messageId } + messages = (before + after).distinct().sortedBy { it.messageId } ) messages + Pair(bufferId, updated) } } } - fun loadBefore(bufferId: BufferId) { + fun loadBefore(bufferId: BufferId, limit: Int) { scope.launch { state.update { messages -> val buffer = messages[bufferId] ?: MessageBuffer(true, emptyList()) val messageId = buffer.messages.firstOrNull()?.messageId ?: MsgId(-1) - val data = backlogManager.backlog(bufferId, messageId) + val data = backlogManager.backlog(bufferId, last = messageId, limit = limit) .mapNotNull { it.into<Message>() } val updated = buffer.copy( - messages = (buffer.messages + data) - .sortedBy { it.messageId } + messages = (buffer.messages + data).distinct().sortedBy { it.messageId } ) messages + Pair(bufferId, updated) } } } - fun loadAfter(bufferId: BufferId) { + fun loadAfter(bufferId: BufferId, limit: Int) { scope.launch { state.update { messages -> val buffer = messages[bufferId] ?: MessageBuffer(true, emptyList()) val messageId = buffer.messages.lastOrNull()?.messageId ?: MsgId(-1) - val data = backlogManager.backlogForward(bufferId, messageId) + val data = backlogManager.backlogForward(bufferId, first = messageId, limit = limit) .mapNotNull { it.into<Message>() } val updated = buffer.copy( - messages = (buffer.messages + data) - .sortedBy { it.messageId } + messages = (buffer.messages + data).distinct().sortedBy { it.messageId } ) messages + Pair(bufferId, updated) } diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/sample/SampleBooleanProvider.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/sample/SampleBooleanProvider.kt new file mode 100644 index 000000000..3dca55290 --- /dev/null +++ b/app/src/main/kotlin/de/justjanne/quasseldroid/sample/SampleBooleanProvider.kt @@ -0,0 +1,7 @@ +package de.justjanne.quasseldroid.sample + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +class SampleBooleanProvider : PreviewParameterProvider<Boolean> { + override val values = sequenceOf(false, true) +} diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/sample/SampleLocalDateProvider.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/sample/SampleLocalDateProvider.kt new file mode 100644 index 000000000..89a6de1d7 --- /dev/null +++ b/app/src/main/kotlin/de/justjanne/quasseldroid/sample/SampleLocalDateProvider.kt @@ -0,0 +1,10 @@ +package de.justjanne.quasseldroid.sample + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import org.threeten.bp.LocalDate + +class SampleLocalDateProvider : PreviewParameterProvider<LocalDate> { + override val values = sequenceOf( + LocalDate.of(2022, 2, 28) + ) +} diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/sample/SampleMessageProvider.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/sample/SampleMessageProvider.kt new file mode 100644 index 000000000..5965a4375 --- /dev/null +++ b/app/src/main/kotlin/de/justjanne/quasseldroid/sample/SampleMessageProvider.kt @@ -0,0 +1,87 @@ +package de.justjanne.quasseldroid.sample + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import de.justjanne.bitflags.of +import de.justjanne.libquassel.protocol.models.BufferInfo +import de.justjanne.libquassel.protocol.models.Message +import de.justjanne.libquassel.protocol.models.flags.BufferType +import de.justjanne.libquassel.protocol.models.flags.MessageFlag +import de.justjanne.libquassel.protocol.models.flags.MessageType +import de.justjanne.libquassel.protocol.models.ids.BufferId +import de.justjanne.libquassel.protocol.models.ids.MsgId +import de.justjanne.libquassel.protocol.models.ids.NetworkId +import org.threeten.bp.Instant + +class SampleMessageProvider : PreviewParameterProvider<Message> { + override val values = sequenceOf( + Message( + messageId = MsgId(108062924), + bufferInfo = BufferInfo( + bufferId = BufferId(3746), + bufferName = "#quasseldroid", + networkId = NetworkId(4), + type = BufferType.of(BufferType.Channel) + ), + time = Instant.parse("2022-02-20T18:24:48.891Z"), + type = MessageType.of(MessageType.Quit), + sender = "CrazyBonz!~CrazyBonz@user/CrazyBonz", + senderPrefixes = "", + avatarUrl = "", + realName = "CrazyBonz", + content = "#quasseldroid", + flag = MessageFlag.of() + ), + Message( + messageId = MsgId(108063975), + bufferInfo = BufferInfo( + bufferId = BufferId(3746), + bufferName = "#quasseldroid", + networkId = NetworkId(4), + type = BufferType.of(BufferType.Channel) + ), + time = Instant.parse("2022-02-20T19:56:01.588Z"), + type = MessageType.of(MessageType.Plain), + sender = "winch!~AdminUser@185.14.29.13", + senderPrefixes = "", + avatarUrl = "", + realName = "Wincher,,,", + content = "Can i script some actions like in mIRC?", + flag = MessageFlag.of() + ), + Message( + messageId = MsgId(108064014), + bufferInfo = BufferInfo( + bufferId = BufferId(3746), + bufferName = "#quasseldroid", + networkId = NetworkId(4), + type = BufferType.of(BufferType.Channel) + ), + time = Instant.parse("2022-02-20T20:06:39.159Z"), + type = MessageType.of(MessageType.Quit), + sender = "mavhq!~quassel@mapp-14-b2-v4wan-161519-cust401.vm15.cable.virginm.net", + senderPrefixes = "", + avatarUrl = "", + realName = "mavhc", + content = "Quit: http://quassel-irc.org - Chat comfortably. Anywhere.", + flag = MessageFlag.of() + ), + Message( + messageId = MsgId(108064022), + bufferInfo = BufferInfo( + bufferId = BufferId(3746), + bufferName = "#quasseldroid", + networkId = NetworkId(4), + type = BufferType.of(BufferType.Channel) + ), + time = Instant.parse("2022-02-20T20:07:13.45Z"), + type = MessageType.of(MessageType.Join), + sender = "mavhq!~quassel@mapp-14-b2-v4wan-161519-cust401.vm15.cable.virginm.net", + senderPrefixes = "", + avatarUrl = "", + realName = "mavhc", + content = "#quasseldroid", + flag = MessageFlag.of() + ) + ) +} + diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/sample/SampleMessagesProvider.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/sample/SampleMessagesProvider.kt new file mode 100644 index 000000000..150a45f50 --- /dev/null +++ b/app/src/main/kotlin/de/justjanne/quasseldroid/sample/SampleMessagesProvider.kt @@ -0,0 +1,10 @@ +package de.justjanne.quasseldroid.sample + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import de.justjanne.libquassel.protocol.models.Message + +class SampleMessagesProvider : PreviewParameterProvider<List<Message>> { + override val values = sequenceOf( + SampleMessageProvider().values.toList() + ) +} diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/sample/SampleSecurityLevelProvider.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/sample/SampleSecurityLevelProvider.kt index ef595a103..96cf4cec8 100644 --- a/app/src/main/kotlin/de/justjanne/quasseldroid/sample/SampleSecurityLevelProvider.kt +++ b/app/src/main/kotlin/de/justjanne/quasseldroid/sample/SampleSecurityLevelProvider.kt @@ -3,7 +3,7 @@ package de.justjanne.quasseldroid.sample import androidx.compose.ui.tooling.preview.PreviewParameterProvider import de.justjanne.quasseldroid.model.SecurityLevel -class SampleSecurityLevelProvider: PreviewParameterProvider<SecurityLevel> { +class SampleSecurityLevelProvider : PreviewParameterProvider<SecurityLevel> { override val values = sequenceOf( SecurityLevel.SECURE, SecurityLevel.UNVERIFIED, diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/service/ClientSessionWrapper.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/service/ClientSessionWrapper.kt index a52a88fb6..428e82c52 100644 --- a/app/src/main/kotlin/de/justjanne/quasseldroid/service/ClientSessionWrapper.kt +++ b/app/src/main/kotlin/de/justjanne/quasseldroid/service/ClientSessionWrapper.kt @@ -4,6 +4,6 @@ import de.justjanne.libquassel.client.session.ClientSession import de.justjanne.quasseldroid.messages.MessageStore data class ClientSessionWrapper( - val session: ClientSession, - val messages: MessageStore + val session: ClientSession, + val messages: MessageStore ) diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/service/QuasselBackend.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/service/QuasselBackend.kt index 6f2edb9d0..fc63885b5 100644 --- a/app/src/main/kotlin/de/justjanne/quasseldroid/service/QuasselBackend.kt +++ b/app/src/main/kotlin/de/justjanne/quasseldroid/service/QuasselBackend.kt @@ -5,7 +5,6 @@ import android.content.Context import android.content.ServiceConnection import android.os.IBinder import android.util.Log -import de.justjanne.libquassel.client.session.ClientSession import de.justjanne.libquassel.protocol.util.StateHolder import de.justjanne.libquassel.protocol.util.flatMap import de.justjanne.quasseldroid.BuildConfig diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/service/QuasselBinder.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/service/QuasselBinder.kt index 993619d8c..d3cf4eb3e 100644 --- a/app/src/main/kotlin/de/justjanne/quasseldroid/service/QuasselBinder.kt +++ b/app/src/main/kotlin/de/justjanne/quasseldroid/service/QuasselBinder.kt @@ -1,7 +1,6 @@ package de.justjanne.quasseldroid.service import android.os.Binder -import de.justjanne.libquassel.client.session.ClientSession import de.justjanne.libquassel.protocol.util.StateHolder import kotlinx.coroutines.flow.StateFlow diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/LoginView.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/LoginView.kt index d8a5cd6a2..fa3b1a636 100644 --- a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/LoginView.kt +++ b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/LoginView.kt @@ -26,18 +26,21 @@ import java.net.InetSocketAddress @Preview(name = "Login", showBackground = true) @Composable -fun LoginView(onLogin: (ConnectionData) -> Unit = {}) { +fun LoginView( + default: ConnectionData? = null, + onLogin: (ConnectionData) -> Unit = {} +) { val (host, setHost) = rememberSaveable(stateSaver = TextFieldValueSaver) { - mutableStateOf(TextFieldValue()) + mutableStateOf(TextFieldValue(default?.address?.hostString ?: "")) } val (port, setPort) = rememberSaveable(stateSaver = TextFieldValueSaver) { - mutableStateOf(TextFieldValue("4242")) + mutableStateOf(TextFieldValue(default?.address?.port?.toString() ?: "4242")) } val (username, setUsername) = rememberSaveable(stateSaver = TextFieldValueSaver) { - mutableStateOf(TextFieldValue()) + mutableStateOf(TextFieldValue(default?.username ?: "")) } val (password, setPassword) = rememberSaveable(stateSaver = TextFieldValueSaver) { - mutableStateOf(TextFieldValue()) + mutableStateOf(TextFieldValue(default?.password ?: "")) } val focusManager = LocalFocusManager.current diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageView.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageBaseView.kt similarity index 84% rename from app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageView.kt rename to app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageBaseView.kt index c1424a357..efe03af83 100644 --- a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageView.kt +++ b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageBaseView.kt @@ -15,15 +15,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import de.justjanne.libquassel.protocol.models.Message import de.justjanne.libquassel.protocol.util.irc.HostmaskHelper import de.justjanne.quasseldroid.ui.icons.AvatarIcon -import de.justjanne.quasseldroid.ui.routes.SampleMessageProvider import de.justjanne.quasseldroid.ui.theme.QuasselTheme import de.justjanne.quasseldroid.ui.theme.Typography import irc.SenderColorUtil @@ -33,20 +30,6 @@ import org.threeten.bp.format.FormatStyle private val formatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) -@Preview(name = "Message", showBackground = true) -@Composable -fun MessageView( - @PreviewParameter(SampleMessageProvider::class) - message: Message -) { - MessageBaseView(message, false, 32.dp) { - Text( - message.content, - style = Typography.body2, - ) - } -} - @Composable fun MessageBaseView( message: Message, @@ -57,9 +40,9 @@ fun MessageBaseView( val nick = HostmaskHelper.nick(message.sender) val senderColor = QuasselTheme.sender.colors[SenderColorUtil.senderColor(nick)] - Row { + Row(modifier = Modifier.padding(2.dp)) { if (!followUp) { - AvatarIcon(nick, null, modifier = Modifier.padding(2.dp)) + AvatarIcon(nick, null, modifier = Modifier.padding(vertical = 2.dp)) Spacer(Modifier.width(4.dp)) } else { Spacer(Modifier.width(avatarSize + 8.dp)) diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageDayChangeView.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageDayChangeView.kt new file mode 100644 index 000000000..953f9e9f3 --- /dev/null +++ b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageDayChangeView.kt @@ -0,0 +1,92 @@ +package de.justjanne.quasseldroid.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.ContentAlpha +import androidx.compose.material.Divider +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import de.justjanne.quasseldroid.ui.theme.QuasselTheme +import de.justjanne.quasseldroid.ui.theme.Typography +import org.threeten.bp.LocalDate +import org.threeten.bp.format.DateTimeFormatter +import org.threeten.bp.format.FormatStyle + +private val formatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) + +@Preview(name = "Day Change", showBackground = true) +@Composable +private fun MessageDayChangePreview() { + Column { + MessageDayChangeView(LocalDate.of(2018, 9, 7), isNew = false) + MessageDayChangeView(LocalDate.of(2018, 9, 7), isNew = true) + } +} + +@Composable +fun MessageDayChangeView( + date: LocalDate, + isNew: Boolean +) { + val foregroundColor = + if (isNew) QuasselTheme.security.insecure + else MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled) + + Row(modifier = Modifier.padding(vertical = 4.dp)) { + Spacer(Modifier.width(8.dp)) + Divider( + color = foregroundColor, + modifier = Modifier + .weight(1.0f) + .height(1.dp) + .align(Alignment.CenterVertically) + ) + Spacer(Modifier.width(4.dp)) + Text( + date.format(formatter), + modifier = Modifier + .align(Alignment.CenterVertically), + style = Typography.body2, + fontWeight = FontWeight.Medium, + ) + Spacer(Modifier.width(4.dp)) + Row( + modifier = Modifier + .weight(1.0f) + .align(Alignment.CenterVertically) + ) { + Divider( + color = foregroundColor, + modifier = Modifier + .weight(1.0f) + .height(1.dp) + .align(Alignment.CenterVertically) + ) + if (isNew) { + Spacer(Modifier.width(4.dp)) + Text( + "New", + modifier = Modifier + .align(Alignment.CenterVertically), + color = QuasselTheme.security.insecure, + style = Typography.body2, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + ) + } + } + Spacer(Modifier.width(8.dp)) + } +} + diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageList.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageList.kt new file mode 100644 index 000000000..ae018dfd7 --- /dev/null +++ b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageList.kt @@ -0,0 +1,65 @@ +package de.justjanne.quasseldroid.ui.components + +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import de.justjanne.libquassel.protocol.models.Message +import de.justjanne.libquassel.protocol.models.ids.MsgId +import de.justjanne.quasseldroid.sample.SampleMessagesProvider +import de.justjanne.quasseldroid.ui.theme.Typography +import de.justjanne.quasseldroid.util.extensions.OnBottomReached +import de.justjanne.quasseldroid.util.extensions.OnTopReached +import de.justjanne.quasseldroid.util.extensions.getPrevious +import org.threeten.bp.ZoneId + +@Preview(name = "Messages", showBackground = true) +@Composable +fun MessageList( + @PreviewParameter(SampleMessagesProvider::class) + messages: List<Message>, + listState: LazyListState = rememberLazyListState(), + markerLine: MsgId = MsgId(-1), + buffer: Int = 0, + onLoadAtStart: () -> Unit = { }, + onLoadAtEnd: () -> Unit = { }, +) { + LazyColumn(state = listState) { + itemsIndexed(messages, key = { _, item -> item.messageId }) { index, message -> + val prev = messages.getPrevious(index) + val prevDate = prev?.time?.atZone(ZoneId.systemDefault())?.toLocalDate() + val messageDate = message.time.atZone(ZoneId.systemDefault()).toLocalDate() + + val followUp = prev != null && + message.sender == prev.sender && + message.senderPrefixes == prev.senderPrefixes && + message.realName == prev.realName && + message.avatarUrl == prev.avatarUrl + + val isNew = prev != null && + prev.messageId <= markerLine && + message.messageId > markerLine + + if (prevDate == null || !messageDate.isEqual(prevDate)) { + MessageDayChangeView(messageDate, isNew) + } else if (isNew) { + NewMessageView() + } + + MessageBaseView(message, followUp, 32.dp) { + Text( + message.content, + style = Typography.body2, + ) + } + } + } + + listState.OnTopReached(buffer = buffer, onLoadMore = onLoadAtStart) + listState.OnBottomReached(buffer = buffer, onLoadMore = onLoadAtEnd) +} diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/NewMessageView.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/NewMessageView.kt new file mode 100644 index 000000000..0645ae772 --- /dev/null +++ b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/NewMessageView.kt @@ -0,0 +1,44 @@ +package de.justjanne.quasseldroid.ui.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.Divider +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import de.justjanne.quasseldroid.ui.theme.QuasselTheme +import de.justjanne.quasseldroid.ui.theme.Typography + +@Preview(name = "New Message", showBackground = true) +@Composable +fun NewMessageView() { + Row(modifier = Modifier.padding(vertical = 4.dp)) { + Spacer(Modifier.width(8.dp)) + Divider( + color = QuasselTheme.security.insecure, + modifier = Modifier + .height(1.dp) + .weight(1.0f) + .align(Alignment.CenterVertically), + ) + Spacer(Modifier.width(4.dp)) + Text( + "New", + modifier = Modifier + .align(Alignment.CenterVertically), + color = QuasselTheme.security.insecure, + style = Typography.body2, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.width(8.dp)) + } +} diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/PasswordTextField.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/PasswordTextField.kt index fba9e2fdb..ac3c389fc 100644 --- a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/PasswordTextField.kt +++ b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/PasswordTextField.kt @@ -18,14 +18,12 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.tooling.preview.Preview -import de.justjanne.quasseldroid.R @Preview(name = "PasswordTextField", showBackground = true) @Composable diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/icons/AvatarIcon.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/icons/AvatarIcon.kt index e6ac4bd99..757b3118b 100644 --- a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/icons/AvatarIcon.kt +++ b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/icons/AvatarIcon.kt @@ -35,7 +35,7 @@ fun AvatarIcon( size: Dp = 32.dp ) { val senderColor = QuasselTheme.sender.colors[SenderColorUtil.senderColor(nick)] - val initial = nick.asSequence().map { it.uppercase(Locale.ENGLISH) }.first() + val initial = nick.firstOrNull()?.uppercase(Locale.ENGLISH) ?: "?" val fontSize = with(LocalDensity.current) { (size.toPx() * 0.67f).toSp() } Surface( diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/routes/HomeRoute.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/routes/HomeRoute.kt index 09ee1010f..0b9d1123c 100644 --- a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/routes/HomeRoute.kt +++ b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/routes/HomeRoute.kt @@ -1,25 +1,34 @@ package de.justjanne.quasseldroid.ui.routes +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState import androidx.compose.material.Button import androidx.compose.material.Text +import androidx.compose.material.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.TextFieldValue import androidx.navigation.NavController import de.justjanne.libquassel.protocol.models.Message import de.justjanne.libquassel.protocol.models.ids.BufferId +import de.justjanne.libquassel.protocol.models.ids.MsgId import de.justjanne.libquassel.protocol.util.flatMap +import de.justjanne.quasseldroid.messages.MessageStore import de.justjanne.quasseldroid.service.QuasselBackend -import de.justjanne.quasseldroid.ui.components.MessageView +import de.justjanne.quasseldroid.ui.components.MessageList import de.justjanne.quasseldroid.util.mapNullable import de.justjanne.quasseldroid.util.rememberFlow -import de.justjanne.quasseldroid.util.saver.BufferIdSaver +import de.justjanne.quasseldroid.util.saver.TextFieldValueSaver import kotlinx.coroutines.flow.map +private const val limit = 20 + @Composable fun HomeRoute(backend: QuasselBackend, navController: NavController) { val session = rememberFlow(null) { @@ -27,18 +36,40 @@ fun HomeRoute(backend: QuasselBackend, navController: NavController) { .mapNullable { it.session } } - val (buffer, setBuffer) = rememberSaveable(stateSaver = BufferIdSaver) { - mutableStateOf(BufferId(-1)) + val (buffer, setBuffer) = rememberSaveable(stateSaver = TextFieldValueSaver) { + mutableStateOf(TextFieldValue("3747")) + } + val (position, setPosition) = rememberSaveable(stateSaver = TextFieldValueSaver) { + mutableStateOf(TextFieldValue("108113920")) + } + + val bufferId = BufferId(buffer.text.toIntOrNull() ?: -1) + val positionId = MsgId(position.text.toLongOrNull() ?: -1L) + + val listState = rememberLazyListState() + + val messageStore: MessageStore? = rememberFlow(null) { + backend.flow() + .mapNullable { it.messages } } val messages: List<Message> = rememberFlow(emptyList()) { backend.flow() .mapNullable { it.messages } .flatMap() - .mapNullable { it[buffer] } + .mapNullable { it[bufferId] } .map { it?.messages.orEmpty() } } + val markerLine: MsgId? = rememberFlow(null) { + backend.flow() + .mapNullable { it.session } + .flatMap() + .mapNullable { it.bufferSyncer } + .flatMap() + .mapNullable { it.markerLines[bufferId] } + } + val initStatus = rememberFlow(null) { backend.flow() .mapNullable { it.session } @@ -47,26 +78,50 @@ fun HomeRoute(backend: QuasselBackend, navController: NavController) { } val context = LocalContext.current + val buttonScrollState = rememberScrollState() + Column { Text("Side: ${session?.side}") if (initStatus != null) { val done = initStatus.total - initStatus.waiting.size Text("Init: ${initStatus.started} $done/ ${initStatus.total}") } - Button(onClick = { navController.navigate("coreInfo") }) { - Text("Core Info") - } - Button(onClick = { - backend.disconnect(context) - navController.navigate("login") - }) { - Text("Disconnect") - } - LazyColumn { - items(messages, key = Message::messageId) { - MessageView(it) + Row(modifier = Modifier.horizontalScroll(buttonScrollState)) { + Button(onClick = { navController.navigate("coreInfo") }) { + Text("Core Info") + } + Button(onClick = { + backend.disconnect(context) + navController.navigate("login") + }) { + Text("Disconnect") + } + Button(onClick = { + messageStore?.loadBefore(bufferId, limit) + }) { + Text("↑") + } + Button(onClick = { + messageStore?.loadAfter(bufferId, limit) + }) { + Text("↓") + } + Button(onClick = { + messageStore?.loadAround(bufferId, positionId, limit) + }) { + Text("…") } } + TextField(value = buffer, onValueChange = setBuffer) + TextField(value = position, onValueChange = setPosition) + MessageList( + messages = messages, + listState = listState, + markerLine = markerLine ?: MsgId(-1), + buffer = 5, + onLoadAtStart = { messageStore?.loadBefore(bufferId, limit) }, + onLoadAtEnd = { messageStore?.loadAfter(bufferId, limit) } + ) } } diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/routes/LoginRoute.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/routes/LoginRoute.kt index 789c05079..e40af4961 100644 --- a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/routes/LoginRoute.kt +++ b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/routes/LoginRoute.kt @@ -1,17 +1,49 @@ package de.justjanne.quasseldroid.ui.routes +import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext +import androidx.core.content.edit import androidx.navigation.NavController +import de.justjanne.quasseldroid.service.ConnectionData import de.justjanne.quasseldroid.service.QuasselBackend import de.justjanne.quasseldroid.ui.components.LoginView +import java.net.InetSocketAddress @Composable fun LoginRoute(backend: QuasselBackend, navController: NavController) { val context = LocalContext.current - LoginView(onLogin = { - if (backend.login(context, it)) { - navController.navigate("home") - } - }) + LoginView(default = loadConnectionData(context)) { + if (backend.login(context, it)) { + navController.navigate("home") + saveConnectionData(context, it) + } + } +} + +fun loadConnectionData(context: Context): ConnectionData? { + val sharedPreferences = context.getSharedPreferences("login", Context.MODE_PRIVATE) + return ConnectionData( + address = InetSocketAddress.createUnresolved( + sharedPreferences.getString("host", null) + ?: return null, + sharedPreferences.getInt("port", 0) + .takeIf { it > 0 } + ?: return null, + ), + username = sharedPreferences.getString("username", null) + ?: return null, + password = sharedPreferences.getString("password", null) + ?: return null + ) +} + +fun saveConnectionData(context: Context, connectionData: ConnectionData) { + val sharedPreferences = context.getSharedPreferences("login", Context.MODE_PRIVATE) + sharedPreferences.edit { + putString("host", connectionData.address.hostString) + putInt("port", connectionData.address.port) + putString("username", connectionData.username) + putString("password", connectionData.password) + } } diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/routes/SampleMessageProvider.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/routes/SampleMessageProvider.kt deleted file mode 100644 index ff30e9f8f..000000000 --- a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/routes/SampleMessageProvider.kt +++ /dev/null @@ -1,86 +0,0 @@ -package de.justjanne.quasseldroid.ui.routes - -import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import de.justjanne.bitflags.of -import de.justjanne.libquassel.protocol.models.BufferInfo -import de.justjanne.libquassel.protocol.models.Message -import de.justjanne.libquassel.protocol.models.flags.BufferType -import de.justjanne.libquassel.protocol.models.flags.MessageFlag -import de.justjanne.libquassel.protocol.models.flags.MessageType -import de.justjanne.libquassel.protocol.models.ids.BufferId -import de.justjanne.libquassel.protocol.models.ids.MsgId -import de.justjanne.libquassel.protocol.models.ids.NetworkId -import org.threeten.bp.Instant - -class SampleMessageProvider : PreviewParameterProvider<Message> { - override val values = sequenceOf( - Message( - messageId = MsgId(108062924), - bufferInfo = BufferInfo( - bufferId = BufferId(3746), - bufferName = "#quasseldroid", - networkId = NetworkId(4), - type = BufferType.of(BufferType.Channel) - ), - time = Instant.parse("2022-02-20T18:24:48.891Z"), - type = MessageType.of(MessageType.Quit), - sender = "CrazyBonz!~CrazyBonz@user/CrazyBonz", - senderPrefixes = "", - avatarUrl = "", - realName = "CrazyBonz", - content = "#quasseldroid", - flag = MessageFlag.of() - ), - Message( - messageId = MsgId(108063975), - bufferInfo = BufferInfo( - bufferId = BufferId(3746), - bufferName = "#quasseldroid", - networkId = NetworkId(4), - type = BufferType.of(BufferType.Channel) - ), - time = Instant.parse("2022-02-20T19:56:01.588Z"), - type = MessageType.of(MessageType.Plain), - sender = "winch!~AdminUser@185.14.29.13", - senderPrefixes = "", - avatarUrl = "", - realName = "Wincher,,,", - content = "Can i script some actions like in mIRC?", - flag = MessageFlag.of() - ), - Message( - messageId = MsgId(108064014), - bufferInfo = BufferInfo( - bufferId = BufferId(3746), - bufferName = "#quasseldroid", - networkId = NetworkId(4), - type = BufferType.of(BufferType.Channel) - ), - time = Instant.parse("2022-02-20T20:06:39.159Z"), - type = MessageType.of(MessageType.Quit), - sender = "mavhq!~quassel@mapp-14-b2-v4wan-161519-cust401.vm15.cable.virginm.net", - senderPrefixes = "", - avatarUrl = "", - realName = "mavhc", - content = "Quit: http://quassel-irc.org - Chat comfortably. Anywhere.", - flag = MessageFlag.of() - ), - Message( - messageId = MsgId(108064022), - bufferInfo = BufferInfo( - bufferId = BufferId(3746), - bufferName = "#quasseldroid", - networkId = NetworkId(4), - type = BufferType.of(BufferType.Channel) - ), - time = Instant.parse("2022-02-20T20:07:13.45Z"), - type = MessageType.of(MessageType.Join), - sender = "mavhq!~quassel@mapp-14-b2-v4wan-161519-cust401.vm15.cable.virginm.net", - senderPrefixes = "", - avatarUrl = "", - realName = "mavhc", - content = "#quasseldroid", - flag = MessageFlag.of() - ) - ) -} diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/util/extensions/LazyListStateExtensions.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/util/extensions/LazyListStateExtensions.kt new file mode 100644 index 000000000..1deb227bc --- /dev/null +++ b/app/src/main/kotlin/de/justjanne/quasseldroid/util/extensions/LazyListStateExtensions.kt @@ -0,0 +1,50 @@ +package de.justjanne.quasseldroid.util.extensions + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow + +@Composable +fun LazyListState.OnBottomReached( + buffer: Int = 0, + onLoadMore: () -> Unit +) { + require(buffer >= 0) { "buffer cannot be negative, but was $buffer" } + + val shouldLoadMore = remember { + derivedStateOf { + val lastVisibleItem = layoutInfo.visibleItemsInfo.lastOrNull() + ?: return@derivedStateOf true + lastVisibleItem.index >= layoutInfo.totalItemsCount - 1 - buffer + } + } + + LaunchedEffect(shouldLoadMore) { + snapshotFlow { shouldLoadMore.value } + .collect { if (it) onLoadMore() } + } +} + +@Composable +fun LazyListState.OnTopReached( + buffer: Int = 0, + onLoadMore: () -> Unit +) { + require(buffer >= 0) { "buffer cannot be negative, but was $buffer" } + + val shouldLoadMore = remember { + derivedStateOf { + val lastVisibleItem = layoutInfo.visibleItemsInfo.firstOrNull() + ?: return@derivedStateOf true + lastVisibleItem.index <= buffer + } + } + + LaunchedEffect(shouldLoadMore) { + snapshotFlow { shouldLoadMore.value } + .collect { if (it) onLoadMore() } + } +} diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/util/extensions/ListExtensions.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/util/extensions/ListExtensions.kt new file mode 100644 index 000000000..2ca165b97 --- /dev/null +++ b/app/src/main/kotlin/de/justjanne/quasseldroid/util/extensions/ListExtensions.kt @@ -0,0 +1,11 @@ +package de.justjanne.quasseldroid.util.extensions + +fun <T> List<T>.getSafe(index: Int): T? = + if (index !in 0..size) null + else get(index) + +fun <T> List<T>.getPrevious(index: Int): T? = + getSafe(index - 1) + +fun <T> List<T>.getNext(index: Int): T? = + getSafe(index + 1) diff --git a/app/src/test/kotlin/de/kuschku/justjanne/quasseldroid/util/irc/SenderColorUtilTest.kt b/app/src/test/kotlin/de/kuschku/justjanne/quasseldroid/util/irc/SenderColorUtilTest.kt index 79fc84a54..e26136bb1 100644 --- a/app/src/test/kotlin/de/kuschku/justjanne/quasseldroid/util/irc/SenderColorUtilTest.kt +++ b/app/src/test/kotlin/de/kuschku/justjanne/quasseldroid/util/irc/SenderColorUtilTest.kt @@ -1,7 +1,7 @@ package de.kuschku.justjanne.quasseldroid.util.irc import irc.SenderColorUtil -import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test class SenderColorUtilTest { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4258909f6..6594147bc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -libquassel = "0.9.0" +libquassel = "0.9.2" androidx-activity = "1.4.0" androidx-appcompat = "1.4.1" androidx-compose = "1.1.1" -- GitLab