diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 00fbf423d11d1c8a3f25e40398a3056a0ad79b76..f58f94adac4af3b86947786d85d25bd8ba462e95 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -25,7 +25,6 @@ plugins { android { defaultConfig { vectorDrawables.useSupportLibrary = true - testInstrumentationRunner = "de.justjanne.quasseldroid.util.TestRunner" } buildTypes { @@ -72,8 +71,20 @@ dependencies { implementation(libs.androidx.compose.runtime) implementation(libs.androidx.compose.ui) + implementation(libs.androidx.collection.ktx) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.paging.runtime) + testImplementation(libs.androidx.paging.test) + implementation(libs.androidx.paging.compose) + + implementation(libs.androidx.room.runtime) + ksp(libs.androidx.room.compiler) + implementation(libs.androidx.room.ktx) + implementation(libs.androidx.room.paging) + implementation(libs.libquassel.client) implementation(libs.libquassel.irc) diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/messages/MessageBuffer.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/messages/MessageBuffer.kt deleted file mode 100644 index 6df763cceae4c48b0c18bb4523bf3f9daeec6ea0..0000000000000000000000000000000000000000 --- a/app/src/main/kotlin/de/justjanne/quasseldroid/messages/MessageBuffer.kt +++ /dev/null @@ -1,12 +0,0 @@ -package de.justjanne.quasseldroid.messages - -import de.justjanne.libquassel.protocol.models.Message - -data class MessageBuffer( - /** - * Whether the chronologically latest message for a given buffer id is in the buffer. - * If yes, new messages that arrive for this buffer should be appened to the end. - */ - val atEnd: Boolean, - val messages: List<Message> -) diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/messages/MessageStore.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/messages/MessageStore.kt deleted file mode 100644 index fe3faa8ba12ba320cd6fc525ba7d02a549e6255a..0000000000000000000000000000000000000000 --- a/app/src/main/kotlin/de/justjanne/quasseldroid/messages/MessageStore.kt +++ /dev/null @@ -1,110 +0,0 @@ -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 -import de.justjanne.libquassel.protocol.variant.into -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import java.io.Closeable - -class MessageStore( - incoming: Flow<Message>, - private val backlogManager: ClientBacklogManager -) : Closeable, StateHolder<Map<BufferId, MessageBuffer>> { - private val state = MutableStateFlow(mapOf<BufferId, MessageBuffer>()) - override fun state() = state.value - override fun flow() = state - - private val scope = CoroutineScope(Dispatchers.IO) - private val disposable = incoming.onEach { message -> - val bufferId = message.bufferInfo.bufferId - state.update { messages -> - val buffer = messages[bufferId] ?: MessageBuffer(true, emptyList()) - if (buffer.atEnd) { - messages + Pair(bufferId, buffer.copy(messages = buffer.messages + message)) - } else { - messages - } - } - }.launchIn(scope) - - fun loadAround(bufferId: BufferId, messageId: MsgId, limit: Int) { - scope.launch { - state.update { messages -> - val (before, after) = listOf( - 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).distinct().sortedBy { it.messageId } - ) - messages + Pair(bufferId, updated) - } - } - } - - 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, last = messageId, limit = limit) - .mapNotNull { it.into<Message>() } - val updated = buffer.copy( - messages = (buffer.messages + data).distinct().sortedBy { it.messageId } - ) - messages + Pair(bufferId, updated) - } - } - } - - 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, first = messageId, limit = limit) - .mapNotNull { it.into<Message>() } - val updated = buffer.copy( - messages = (buffer.messages + data).distinct().sortedBy { it.messageId } - ) - messages + Pair(bufferId, updated) - } - } - } - - fun clear(bufferId: BufferId) { - scope.launch { - state.update { messages -> - messages - bufferId - } - } - } - - override fun close() { - runBlocking { - disposable.cancelAndJoin() - } - } -} diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/persistence/AppDatabase.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/persistence/AppDatabase.kt new file mode 100644 index 0000000000000000000000000000000000000000..8baadc9eec3ac01fcb353672e33b7bd43da243f2 --- /dev/null +++ b/app/src/main/kotlin/de/justjanne/quasseldroid/persistence/AppDatabase.kt @@ -0,0 +1,198 @@ +package de.justjanne.quasseldroid.persistence + +import androidx.paging.LoadType +import androidx.paging.PagingSource +import androidx.paging.PagingState +import androidx.paging.RemoteMediator +import androidx.room.ColumnInfo +import androidx.room.Dao +import androidx.room.Database +import androidx.room.Entity +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.PrimaryKey +import androidx.room.Query +import androidx.room.RoomDatabase +import androidx.room.TypeConverter +import androidx.room.TypeConverters +import androidx.room.withTransaction +import de.justjanne.bitflags.of +import de.justjanne.bitflags.toBits +import de.justjanne.libquassel.client.syncables.ClientBacklogManager +import de.justjanne.libquassel.protocol.models.BufferInfo +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.models.ids.SignedId64Type +import de.justjanne.libquassel.protocol.models.ids.SignedIdType +import de.justjanne.libquassel.protocol.models.ids.isValid +import de.justjanne.libquassel.protocol.variant.QVariant_ +import de.justjanne.libquassel.protocol.variant.into +import org.intellij.lang.annotations.Language +import org.threeten.bp.Instant + +class QuasselRemoteMediator<Key : Any>( + private val bufferId: BufferId, + private val database: AppDatabase, + private val backlogManager: ClientBacklogManager, + private val pageSize: Int = 50 +) : RemoteMediator<Key, MessageModel>() { + + private suspend fun loadAround(bufferId: BufferId, messageId: MsgId): List<Message> = + loadBefore(bufferId, messageId) + loadAfter(bufferId, messageId) + + private suspend fun loadBefore(bufferId: BufferId, messageId: MsgId) = + backlogManager.backlog(bufferId, last = messageId, limit = pageSize) + .mapNotNull<QVariant_, Message>(QVariant_::into) + + private suspend fun loadAfter(bufferId: BufferId, messageId: MsgId) = + backlogManager.backlogForward(bufferId, first = messageId, limit = pageSize) + .mapNotNull<QVariant_, Message>(QVariant_::into) + + override suspend fun load( + loadType: LoadType, + state: PagingState<Key, MessageModel> + ): MediatorResult { + val loadKey: MsgId = when (loadType) { + LoadType.REFRESH -> + state.anchorPosition?.let { anchorPosition -> + state.closestItemToPosition(anchorPosition)?.messageId?.let(::MsgId) + } ?: MsgId(-1) + LoadType.PREPEND -> + state.firstItemOrNull()?.messageId?.let(::MsgId) + ?: return MediatorResult.Success(endOfPaginationReached = true) + LoadType.APPEND -> + state.lastItemOrNull()?.messageId?.let(::MsgId) + ?: return MediatorResult.Success(endOfPaginationReached = true) + } + + val newMessages: List<Message> = when (loadType) { + LoadType.REFRESH -> + if (loadKey.isValid()) loadAround(bufferId, loadKey) + else loadBefore(bufferId, loadKey) + LoadType.PREPEND -> loadBefore(bufferId, loadKey) + LoadType.APPEND -> loadAfter(bufferId, loadKey) + } + + database.withTransaction { + if (loadType == LoadType.REFRESH) { + database.messageDao().delete(bufferId.id) + } + + database.messageDao().insert(newMessages.map(::MessageModel)) + } + + return MediatorResult.Success( + endOfPaginationReached = newMessages.isEmpty() + ) + } +} + +@Database(entities = [MessageModel::class], version = 1) +@TypeConverters(Converters::class) +abstract class AppDatabase : RoomDatabase() { + abstract fun messageDao(): MessageDao +} + +object Converters { + @TypeConverter + fun fromInstant(value: Instant): Long = value.toEpochMilli() + + @TypeConverter + fun toInstant(value: Long): Instant = Instant.ofEpochMilli(value) +} + +@Dao +interface MessageDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(vararg models: MessageModel) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(models: Collection<MessageModel>) + + @Query("SELECT * FROM message WHERE bufferId = :bufferId") + fun pagingSource(bufferId: SignedIdType): PagingSource<Int, MessageModel> + + @Language("RoomSql") + @Query("DELETE FROM message WHERE bufferId = :bufferId") + suspend fun delete(bufferId: SignedIdType) + + @Query("DELETE FROM message") + suspend fun delete() +} + +@Entity(tableName = "message") +data class MessageModel( + /** + * Id of the message + */ + @PrimaryKey + val messageId: SignedId64Type, + /** + * Timestamp at which the message was sent + */ + val time: Instant, + /** + * Message type + */ + val type: Int, + /** + * Set flags on the message + */ + val flag: Int, + /** + * Metadata of the buffer the message was received in + */ + @ColumnInfo(index = true) + val bufferId: SignedIdType, + /** + * `nick!ident@host` of the sender + */ + val sender: String, + /** + * Channel role prefixes of the sender + */ + val senderPrefixes: String, + /** + * Realname of the sender + */ + val realName: String, + /** + * Avatar of the sender + */ + val avatarUrl: String, + /** + * Message content + */ + val content: String +) { + constructor(message: Message) : this( + messageId = message.messageId.id, + time = message.time, + type = message.type.toBits().toInt(), + flag = message.flag.toBits().toInt(), + bufferId = message.bufferInfo.bufferId.id, + sender = message.sender, + senderPrefixes = message.senderPrefixes, + realName = message.realName, + avatarUrl = message.avatarUrl, + content = message.content + ) + + fun toMessage() = Message( + messageId = MsgId(messageId), + time = time, + type = MessageType.of(type.toUInt()), + flag = MessageFlag.of(flag.toUInt()), + bufferInfo = BufferInfo( + bufferId = BufferId(bufferId) + ), + sender = sender, + senderPrefixes = senderPrefixes, + realName = realName, + avatarUrl = avatarUrl, + content = content + ) +} diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/service/ClientSessionWrapper.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/service/ClientSessionWrapper.kt deleted file mode 100644 index 428e82c52cc87b0d04700e81c0361d0e89aac04f..0000000000000000000000000000000000000000 --- a/app/src/main/kotlin/de/justjanne/quasseldroid/service/ClientSessionWrapper.kt +++ /dev/null @@ -1,9 +0,0 @@ -package de.justjanne.quasseldroid.service - -import de.justjanne.libquassel.client.session.ClientSession -import de.justjanne.quasseldroid.messages.MessageStore - -data class ClientSessionWrapper( - 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 fc63885b58f8d18f0036b76597bc85807b40f00a..5d8ebb5b0c6caba48b744e146e101da168777f08 100644 --- a/app/src/main/kotlin/de/justjanne/quasseldroid/service/QuasselBackend.kt +++ b/app/src/main/kotlin/de/justjanne/quasseldroid/service/QuasselBackend.kt @@ -5,6 +5,7 @@ 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 @@ -14,7 +15,7 @@ import de.justjanne.quasseldroid.util.lifecycle.LifecycleStatus import kotlinx.coroutines.flow.MutableStateFlow class QuasselBackend : DefaultContextualLifecycleObserver(), ServiceConnection, - StateHolder<ClientSessionWrapper?> { + StateHolder<ClientSession?> { private var connectionData: ConnectionData? = null override fun flow() = state.flatMap() 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 d3cf4eb3e76a7c861a9653438f2faa56b0b5de71..db890aef00a95a7a0c526d37815565a7208cea01 100644 --- a/app/src/main/kotlin/de/justjanne/quasseldroid/service/QuasselBinder.kt +++ b/app/src/main/kotlin/de/justjanne/quasseldroid/service/QuasselBinder.kt @@ -1,12 +1,13 @@ 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 class QuasselBinder( - private val state: StateFlow<ClientSessionWrapper?> -) : Binder(), StateHolder<ClientSessionWrapper?> { + private val state: StateFlow<ClientSession?> +) : Binder(), StateHolder<ClientSession?> { constructor(runner: QuasselRunner) : this(runner.flow()) override fun flow() = state diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/service/QuasselRunner.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/service/QuasselRunner.kt index 93512a477f82ef4eb28f1238accddebb359181e1..5cca4178b6e193dd1dfebf16692744c5f4d4a235 100644 --- a/app/src/main/kotlin/de/justjanne/quasseldroid/service/QuasselRunner.kt +++ b/app/src/main/kotlin/de/justjanne/quasseldroid/service/QuasselRunner.kt @@ -8,11 +8,20 @@ import de.justjanne.libquassel.protocol.connection.ProtocolVersion import de.justjanne.libquassel.protocol.features.FeatureSet import de.justjanne.libquassel.protocol.io.CoroutineChannel import de.justjanne.libquassel.protocol.util.StateHolder -import de.justjanne.quasseldroid.messages.MessageStore +import de.justjanne.quasseldroid.persistence.AppDatabase +import de.justjanne.quasseldroid.persistence.MessageModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectIndexed +import kotlinx.coroutines.launch +import kotlinx.coroutines.newSingleThreadContext import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withTimeout import java.io.Closeable import java.net.InetSocketAddress @@ -20,55 +29,52 @@ import javax.net.ssl.SSLContext class QuasselRunner( private val address: InetSocketAddress, - private val auth: Pair<String, String> -) : Thread("Quassel Runner"), Closeable, StateHolder<ClientSessionWrapper?> { + private val auth: Pair<String, String>, + private val database: AppDatabase +) : Closeable, StateHolder<ClientSession?>, CoroutineScope { private val channel = CoroutineChannel() - override fun state(): ClientSessionWrapper? = state.value - override fun flow(): StateFlow<ClientSessionWrapper?> = state + override fun state(): ClientSession? = state.value + override fun flow(): StateFlow<ClientSession?> = state - private val state = MutableStateFlow<ClientSessionWrapper?>(null) + private val state = MutableStateFlow<ClientSession?>(null) - init { - start() - } + override val coroutineContext = newSingleThreadContext("Quassel Runner") - override fun run() { - runBlocking(Dispatchers.IO) { - Log.d("QuasselRunner", "Resolving URL") - val address = InetSocketAddress(address.hostString, address.port) - Log.d("QuasselRunner", "Connecting") - channel.connect(address) - Log.d("QuasselRunner", "Handshake") - val session = ClientSession( - channel, - ProtocolFeature.all, - listOf( - ProtocolMeta( - ProtocolVersion.Datastream, - 0x0000u - ) - ), - SSLContext.getDefault() - ) - state.value = ClientSessionWrapper( - session, - messages = MessageStore( - session.rpcHandler.messages(), - session.backlogManager + private val job = launch { + Log.d("QuasselRunner", "Resolving URL") + val address = InetSocketAddress(address.hostString, address.port) + Log.d("QuasselRunner", "Connecting") + channel.connect(address) + Log.d("QuasselRunner", "Handshake") + val session = ClientSession( + channel, + ProtocolFeature.all, + listOf( + ProtocolMeta( + ProtocolVersion.Datastream, + 0x0000u ) - ) - session.handshakeHandler.init( + ), + SSLContext.getDefault() + ) + state.value = session + + with(session) { + handshakeHandler.init( "Quasseltest v0.1", "2022-02-24", FeatureSet.all() ) val (username, password) = auth Log.d("QuasselRunner", "Authenticating") - session.handshakeHandler.login(username, password) + handshakeHandler.login(username, password) Log.d("QuasselRunner", "Waiting for init") - session.baseInitHandler.waitForInitDone() + baseInitHandler.waitForInitDone() Log.d("QuasselRunner", "Init Done") + rpcHandler.messages().collectIndexed { _, message -> + database.messageDao().insert(MessageModel(message)) + } } } @@ -76,7 +82,11 @@ class QuasselRunner( Log.d("QuasselRunner", "Stopping Quassel Runner") runBlocking(Dispatchers.IO) { withTimeout(2000L) { - channel.close() + job.cancelAndJoin() + runInterruptible { + coroutineContext.cancel() + channel.close() + } } } } diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/service/QuasselService.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/service/QuasselService.kt index 7d21c9148c1338d5fb0ce8fdd2232a9018784150..7b7ce32cced867876c0ca01f3d04b9579192d736 100644 --- a/app/src/main/kotlin/de/justjanne/quasseldroid/service/QuasselService.kt +++ b/app/src/main/kotlin/de/justjanne/quasseldroid/service/QuasselService.kt @@ -5,11 +5,21 @@ import android.content.Context import android.content.Intent import android.os.IBinder import android.util.Log +import androidx.room.Room +import de.justjanne.quasseldroid.persistence.AppDatabase import java.net.InetSocketAddress class QuasselService : Service() { private var runner: QuasselRunner? = null + private val database: AppDatabase by lazy { + Room.databaseBuilder( + this.applicationContext, + AppDatabase::class.java, + "app" + ).build() + } + private fun newRunner(intent: Intent): QuasselRunner { Log.w("QuasselService", "Creating new quassel runner") val address = InetSocketAddress.createUnresolved( @@ -26,7 +36,7 @@ class QuasselService : Service() { "Required argument 'password' missing" }, ) - return QuasselRunner(address, auth) + return QuasselRunner(address, auth, database) } override fun onCreate() { diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/Constants.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/Constants.kt new file mode 100644 index 0000000000000000000000000000000000000000..77689815e380851aa616ef8cf0850c1182fb96ef --- /dev/null +++ b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/Constants.kt @@ -0,0 +1,10 @@ +package de.justjanne.quasseldroid.ui + +import org.threeten.bp.format.DateTimeFormatter +import org.threeten.bp.format.FormatStyle + +object Constants { + val timeFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) + val dateFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) + val dateTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM) +} diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/BuildNick.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/BuildNick.kt new file mode 100644 index 0000000000000000000000000000000000000000..a6e5e97e37a0af530654b74a57c8489e6e5ca31e --- /dev/null +++ b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/BuildNick.kt @@ -0,0 +1,23 @@ +package de.justjanne.quasseldroid.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import de.justjanne.quasseldroid.ui.theme.QuasselTheme +import de.justjanne.quasseldroid.util.irc.SenderColorUtil + +@Composable +fun buildNick(nick: String, senderPrefixes: String): AnnotatedString { + val senderColor = QuasselTheme.sender.colors[SenderColorUtil.senderColor(nick)] + + return buildAnnotatedString { + if (senderPrefixes.isNotEmpty()) { + append(senderPrefixes) + } + pushStyle(SpanStyle(color = senderColor, fontWeight = FontWeight.Bold)) + append(nick) + pop() + } +} diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/ConnectedClientCard.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/ConnectedClientCard.kt index 4092eeee0cd3351075d8bee72d5d9aeb6b7ef369..ac44730d08f5b6469e6f636b1432307568b02081 100644 --- a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/ConnectedClientCard.kt +++ b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/ConnectedClientCard.kt @@ -20,12 +20,9 @@ import de.charlex.compose.HtmlText import de.justjanne.libquassel.protocol.models.ConnectedClient import de.justjanne.quasseldroid.model.SecurityLevel import de.justjanne.quasseldroid.sample.SampleConnectedClientProvider +import de.justjanne.quasseldroid.ui.Constants import de.justjanne.quasseldroid.ui.theme.Typography import org.threeten.bp.ZoneId -import org.threeten.bp.format.DateTimeFormatter -import org.threeten.bp.format.FormatStyle - -private val formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM) @Preview(name = "Connected Client") @Composable @@ -51,7 +48,7 @@ fun ConnectedClientCard( Text( client.connectedSince .atZone(ZoneId.systemDefault()) - .format(formatter), + .format(Constants.dateTimeFormatter), style = Typography.body2 ) } diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/CoreInfoView.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/CoreInfoView.kt index c25cdce54c448f33da3ae384d1bd2992745bff55..f0c4947046d3498a2bc548c941329ddc2904f714 100644 --- a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/CoreInfoView.kt +++ b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/CoreInfoView.kt @@ -14,12 +14,9 @@ import de.charlex.compose.HtmlText import de.justjanne.libquassel.protocol.models.ConnectedClient import de.justjanne.libquassel.protocol.syncables.state.CoreInfoState import de.justjanne.quasseldroid.sample.SampleCoreInfoProvider +import de.justjanne.quasseldroid.ui.Constants import de.justjanne.quasseldroid.ui.theme.Typography import org.threeten.bp.ZoneId -import org.threeten.bp.format.DateTimeFormatter -import org.threeten.bp.format.FormatStyle - -private val formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM) @Preview(name = "Core Info", showBackground = true) @Composable @@ -36,14 +33,14 @@ fun CoreInfoView( Text( text = coreInfo.versionDate ?.atZone(ZoneId.systemDefault()) - ?.format(formatter) + ?.format(Constants.dateTimeFormatter) ?: "Unknown", style = Typography.body2 ) Text( coreInfo.startTime .atZone(ZoneId.systemDefault()) - .format(formatter), + .format(Constants.dateTimeFormatter), style = Typography.body2 ) } diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageBase.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageBase.kt index 49fef615739ecd39a441e2b3c605db812333cac4..eaa81fc557116d43d365ebc54fabec175396f64c 100644 --- a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageBase.kt +++ b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageBase.kt @@ -1,6 +1,13 @@ package de.justjanne.quasseldroid.ui.components -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.paddingFromBaseline +import androidx.compose.foundation.layout.width import androidx.compose.material.ContentAlpha import androidx.compose.material.LocalContentAlpha import androidx.compose.material.MaterialTheme @@ -9,12 +16,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString -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 @@ -22,29 +25,10 @@ import androidx.compose.ui.unit.sp import de.justjanne.libquassel.irc.HostmaskHelper import de.justjanne.libquassel.protocol.models.Message import de.justjanne.quasseldroid.sample.SampleMessageProvider +import de.justjanne.quasseldroid.ui.Constants import de.justjanne.quasseldroid.ui.icons.AvatarIcon -import de.justjanne.quasseldroid.ui.theme.QuasselTheme import de.justjanne.quasseldroid.ui.theme.Typography -import de.justjanne.quasseldroid.util.irc.SenderColorUtil import org.threeten.bp.ZoneId -import org.threeten.bp.format.DateTimeFormatter -import org.threeten.bp.format.FormatStyle - -private val formatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) - -@Composable -fun buildNick(nick: String, senderPrefixes: String): AnnotatedString { - val senderColor = QuasselTheme.sender.colors[SenderColorUtil.senderColor(nick)] - - return buildAnnotatedString { - if (senderPrefixes.isNotEmpty()) { - append(senderPrefixes) - } - pushStyle(SpanStyle(color = senderColor, fontWeight = FontWeight.Bold)) - append(nick) - pop() - } -} @Preview(name = "Message Base", showBackground = true) @Composable @@ -53,7 +37,6 @@ fun MessageBase( message: Message, followUp: Boolean = false, // avatarSize: Dp = 32.dp - backgroundColor: Color = MaterialTheme.colors.surface, content: @Composable () -> Unit = { Text(message.content, style = Typography.body2) } ) { val avatarSize = 32.dp @@ -79,20 +62,16 @@ fun MessageBase( } Column(modifier = Modifier.align(Alignment.CenterVertically)) { if (!followUp) { - Row { - Text( - buildAnnotatedString { - append(buildNick(nick, message.senderPrefixes)) - append(' ') - pushStyle(SpanStyle(color = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.medium))) - append(message.realName) - pop() - }, - style = Typography.body2, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } + Text( + buildAnnotatedString { + append(buildNick(nick, message.senderPrefixes)) + append(' ') + pushStyle(SpanStyle(color = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.medium))) + append(message.realName) + pop() + }, + style = Typography.body2 + ) } Row { Box(modifier = Modifier.weight(1.0f)) { @@ -102,7 +81,7 @@ fun MessageBase( Text( message.time .atZone(ZoneId.systemDefault()) - .format(formatter), + .format(Constants.timeFormatter), style = Typography.body2, fontSize = 12.sp, modifier = Modifier.align(Alignment.Bottom) @@ -112,56 +91,3 @@ fun MessageBase( } } } - -@Preview(name = "Message Small", showBackground = true) -@Composable -fun MessageBaseSmall( - @PreviewParameter(SampleMessageProvider::class) - message: Message, - followUp: Boolean = false, - // avatarSize: Dp = 32.dp, - backgroundColor: Color = MaterialTheme.colors.surface, - content: @Composable () -> Unit = { - val nick = HostmaskHelper.nick(message.sender) - - Text(buildAnnotatedString { - append("— ") - append(buildNick(nick, message.senderPrefixes)) - append(" ") - append(message.content) - }, style = Typography.body2) - } -) { - val avatarSize = 16.dp - val nick = HostmaskHelper.nick(message.sender) - - Row( - modifier = Modifier - .padding(2.dp) - .fillMaxWidth() - ) { - Spacer(Modifier.width(20.dp)) - AvatarIcon( - nick, - modifier = Modifier - .align(Alignment.Top) - .paddingFromBaseline(top = 14.sp), - size = avatarSize - ) - Spacer(Modifier.width(4.dp)) - Box(modifier = Modifier.weight(1.0f)) { - content() - } - Spacer(Modifier.width(4.dp)) - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { - Text( - message.time - .atZone(ZoneId.systemDefault()) - .format(formatter), - style = Typography.body2, - fontSize = 12.sp, - modifier = Modifier.align(Alignment.Bottom) - ) - } - } -} diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageBaseSmall.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageBaseSmall.kt new file mode 100644 index 0000000000000000000000000000000000000000..5d1181e4291fcea89d640d47fdac0db498b502e3 --- /dev/null +++ b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageBaseSmall.kt @@ -0,0 +1,78 @@ +package de.justjanne.quasseldroid.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.paddingFromBaseline +import androidx.compose.foundation.layout.width +import androidx.compose.material.ContentAlpha +import androidx.compose.material.LocalContentAlpha +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.buildAnnotatedString +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.sp +import de.justjanne.libquassel.protocol.models.Message +import de.justjanne.libquassel.irc.HostmaskHelper +import de.justjanne.quasseldroid.sample.SampleMessageProvider +import de.justjanne.quasseldroid.ui.Constants +import de.justjanne.quasseldroid.ui.icons.AvatarIcon +import de.justjanne.quasseldroid.ui.theme.Typography +import org.threeten.bp.ZoneId + +@Preview(name = "Message Small", showBackground = true) +@Composable +fun MessageBaseSmall( + @PreviewParameter(SampleMessageProvider::class) + message: Message, + content: @Composable () -> Unit = { + val nick = HostmaskHelper.nick(message.sender) + + Text(buildAnnotatedString { + append("— ") + append(buildNick(nick, message.senderPrefixes)) + append(" ") + append(message.content) + }, style = Typography.body2) + } +) { + val avatarSize = 16.dp + val nick = HostmaskHelper.nick(message.sender) + + Row( + modifier = Modifier + .padding(2.dp) + .fillMaxWidth() + ) { + Spacer(Modifier.width(20.dp)) + AvatarIcon( + nick, + modifier = Modifier + .align(Alignment.Top) + .paddingFromBaseline(top = 14.sp), + size = avatarSize + ) + Spacer(Modifier.width(4.dp)) + Box(modifier = Modifier.weight(1.0f)) { + content() + } + Spacer(Modifier.width(4.dp)) + CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { + Text( + message.time + .atZone(ZoneId.systemDefault()) + .format(Constants.timeFormatter), + style = Typography.body2, + fontSize = 12.sp, + modifier = Modifier.align(Alignment.Bottom) + ) + } + } +} 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 index 953f9e9f3d11c9fc2411a945a5dac6ec63f9c994..b2e2708137ee9cef10add67fff0a0a59e559e5d4 100644 --- a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageDayChangeView.kt +++ b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageDayChangeView.kt @@ -17,13 +17,10 @@ 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.Constants 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 @@ -54,7 +51,7 @@ fun MessageDayChangeView( ) Spacer(Modifier.width(4.dp)) Text( - date.format(formatter), + date.format(Constants.dateFormatter), modifier = Modifier .align(Alignment.CenterVertically), style = Typography.body2, 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 deleted file mode 100644 index fd6d5e7d23d9defd6a318b45d1c4de895df1222a..0000000000000000000000000000000000000000 --- a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageList.kt +++ /dev/null @@ -1,97 +0,0 @@ -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.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import de.justjanne.bitflags.of -import de.justjanne.libquassel.irc.HostmaskHelper -import de.justjanne.libquassel.irc.IrcFormat -import de.justjanne.libquassel.irc.IrcFormatDeserializer -import de.justjanne.libquassel.protocol.models.Message -import de.justjanne.libquassel.protocol.models.flags.MessageType -import de.justjanne.libquassel.protocol.models.ids.MsgId -import de.justjanne.quasseldroid.R -import de.justjanne.quasseldroid.sample.SampleMessagesProvider -import de.justjanne.quasseldroid.ui.theme.QuasselTheme -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.format -import de.justjanne.quasseldroid.util.extensions.getPrevious -import de.justjanne.quasseldroid.util.format.IrcFormatRenderer -import de.justjanne.quasseldroid.util.format.TextFormatter -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 - - val parsed = IrcFormatDeserializer.parse(message.content) - - if (prevDate == null || !messageDate.isEqual(prevDate)) { - MessageDayChangeView(messageDate, isNew) - } else if (isNew) { - NewMessageView() - } - - when (message.type) { - MessageType.of(MessageType.Plain) -> { - MessageBase(message, followUp) { - Text(IrcFormatRenderer.render(parsed), style = Typography.body2) - } - } - MessageType.of(MessageType.Action) -> { - MessageBaseSmall(message, backgroundColor = QuasselTheme.chat.action) { - val nick = HostmaskHelper.nick(message.sender) - - Text( - TextFormatter.format( - AnnotatedString(stringResource(R.string.message_format_action)), - buildNick(nick, message.senderPrefixes), - IrcFormatRenderer.render( - data = parsed.map { it.copy(style = it.style.flipFlag(IrcFormat.Flag.ITALIC)) }, - textColor = QuasselTheme.chat.onAction, - backgroundColor = QuasselTheme.chat.action - ) - ), - style = Typography.body2, - color = QuasselTheme.chat.onAction - ) - } - } - } - } - } - - listState.OnTopReached(buffer = buffer, onLoadMore = onLoadAtStart) - listState.OnBottomReached(buffer = buffer, onLoadMore = onLoadAtEnd) -} diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessagePlaceholder.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessagePlaceholder.kt new file mode 100644 index 0000000000000000000000000000000000000000..074a505415c9173cb02f07aa5bbe63206feeebf6 --- /dev/null +++ b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessagePlaceholder.kt @@ -0,0 +1,96 @@ +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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Preview(name = "Message Placeholder", showBackground = true) +@Composable +fun MessagePlaceholder() { + val density = LocalDensity.current + fun TextUnit.toDp() = with(density) { toPx().toDp() } + + Row( + modifier = Modifier + .padding(2.dp) + .fillMaxWidth() + ) { + Spacer(Modifier.width(4.dp)) + Surface( + shape = RoundedCornerShape(2.dp), + color = Color.Gray, + modifier = Modifier + .padding(2.dp) + .size(32.dp) + ) {} + Spacer(Modifier.width(4.dp)) + Column( + modifier = Modifier + .align(Alignment.CenterVertically) + .padding(vertical = 2.dp) + ) { + Row( + modifier = Modifier + .height(12.sp.toDp()) + .fillMaxWidth() + ) { + Surface( + modifier = Modifier + .width(62.sp.toDp()) + .fillMaxHeight(), + color = Color.Gray, + ) {} + Spacer(modifier = Modifier.width(7.dp)) + Surface( + modifier = Modifier + .width(163.sp.toDp()) + .fillMaxHeight(), + color = Color.LightGray, + ) {} + } + Spacer(modifier = Modifier.height(4.dp)) + Row { + Column { + Surface( + modifier = Modifier + .width(280.sp.toDp()) + .height(14.sp.toDp()), + color = Color.Gray, + ) {} + Spacer(modifier = Modifier.height(2.dp)) + Surface( + modifier = Modifier + .width(160.sp.toDp()) + .height(14.sp.toDp()), + color = Color.Gray, + ) {} + } + Spacer(modifier = Modifier.weight(1.0f)) + Surface( + modifier = Modifier + .size(width = 34.sp.toDp(), height = 12.sp.toDp()) + .padding(end = 2.dp) + .align(Alignment.Bottom), + color = Color.LightGray + ) {} + } + } + } +} 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 6134f07b677bef414eae153bb2246b42ca2d8d20..3df717983be0a370f5653e8fc99480438a1f188e 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 @@ -1,6 +1,5 @@ package de.justjanne.quasseldroid.ui.icons -import android.graphics.Bitmap import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape @@ -29,7 +28,7 @@ fun AvatarIcon( @PreviewParameter(SampleNickProvider::class) nick: String, modifier: Modifier = Modifier, - avatar: Bitmap? = null, + //avatar: Bitmap? = null, size: Dp = 32.dp ) { val senderColor = QuasselTheme.sender.colors[SenderColorUtil.senderColor(nick)] diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/routes/CoreInfoRoute.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/routes/CoreInfoRoute.kt index cc17d865f7f54618ed6d0b1641cd00cb26a5df69..825e2fa81d7100b6a9ff1c19557e303e9e8b62fb 100644 --- a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/routes/CoreInfoRoute.kt +++ b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/routes/CoreInfoRoute.kt @@ -18,7 +18,6 @@ import de.justjanne.quasseldroid.util.rememberFlow fun CoreInfoRoute(backend: QuasselBackend, navController: NavController) { val coreInfo = rememberFlow(null) { backend.flow() - .mapNullable { it.session } .flatMap() .mapNullable { it.coreInfo } .flatMap() 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 940fd34f9f860ee4d57d754855d454b6bfad5e26..3fb32c4ae368b9d9c17ed65b3feadf93e07bdfde 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 @@ -34,7 +34,7 @@ import kotlinx.coroutines.flow.map @Composable fun HomeRoute(backend: QuasselBackend, navController: NavController) { val side = rememberFlow(null) { - backend.flow().mapNullable { it.session.side } + backend.flow().mapNullable { it.side } } val (buffer, setBuffer) = rememberSaveable(stateSaver = TextFieldValueSaver) { @@ -43,14 +43,12 @@ fun HomeRoute(backend: QuasselBackend, navController: NavController) { val initStatus = rememberFlow(null) { backend.flow() - .mapNullable { it.session } .mapNullable { it.baseInitHandler } .flatMap() } val buffers: List<Pair<NetworkInfo?, BufferInfo>> = rememberFlow(emptyList()) { val sessions = backend.flow() - .mapNullable { it.session } .flatMap() val networks: Flow<Map<NetworkId, Network>> = sessions diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/routes/MessageRoute.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/routes/MessageRoute.kt index c05908e85dc6ff5195358958085983bfe996044e..aa979e60e5a5ade643c9815e1642462dc1ad191a 100644 --- a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/routes/MessageRoute.kt +++ b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/routes/MessageRoute.kt @@ -1,33 +1,50 @@ package de.justjanne.quasseldroid.ui.routes -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.lazy.LazyColumn 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.runtime.remember import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString import androidx.navigation.NavController -import de.justjanne.libquassel.protocol.models.Message +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemsIndexed +import androidx.room.Room +import de.justjanne.bitflags.of +import de.justjanne.libquassel.irc.HostmaskHelper +import de.justjanne.libquassel.irc.IrcFormat +import de.justjanne.libquassel.irc.IrcFormatDeserializer +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.flatMap -import de.justjanne.quasseldroid.messages.MessageStore +import de.justjanne.quasseldroid.R +import de.justjanne.quasseldroid.persistence.AppDatabase +import de.justjanne.quasseldroid.persistence.QuasselRemoteMediator import de.justjanne.quasseldroid.service.QuasselBackend -import de.justjanne.quasseldroid.ui.components.MessageList +import de.justjanne.quasseldroid.ui.components.MessageBase +import de.justjanne.quasseldroid.ui.components.MessageBaseSmall +import de.justjanne.quasseldroid.ui.components.MessageDayChangeView +import de.justjanne.quasseldroid.ui.components.MessagePlaceholder +import de.justjanne.quasseldroid.ui.components.NewMessageView +import de.justjanne.quasseldroid.ui.components.buildNick +import de.justjanne.quasseldroid.ui.theme.QuasselTheme +import de.justjanne.quasseldroid.ui.theme.Typography +import de.justjanne.quasseldroid.util.extensions.format +import de.justjanne.quasseldroid.util.extensions.getPrevious +import de.justjanne.quasseldroid.util.format.IrcFormatRenderer +import de.justjanne.quasseldroid.util.format.TextFormatter import de.justjanne.quasseldroid.util.mapNullable import de.justjanne.quasseldroid.util.rememberFlow -import de.justjanne.quasseldroid.util.saver.TextFieldValueSaver -import kotlinx.coroutines.flow.map - -private const val limit = 20 +import kotlinx.coroutines.flow.flatMapLatest +import org.threeten.bp.ZoneId @Composable fun MessageRoute( @@ -37,22 +54,46 @@ fun MessageRoute( ) { val listState = rememberLazyListState() - val messageStore: MessageStore? = rememberFlow(null) { - backend.flow() - .mapNullable { it.messages } + val context = LocalContext.current.applicationContext + + val database = remember { + Room.databaseBuilder( + context, + AppDatabase::class.java, + "app" + ).build() } - val messages: List<Message> = rememberFlow(emptyList()) { + val pageSize = 50 + val limit = 200 + + val messages = remember { backend.flow() - .mapNullable { it.messages } .flatMap() - .mapNullable { it[buffer] } - .map { it?.messages.orEmpty() } - } + .mapNullable { it.backlogManager } + .flatMapLatest { backlogManager -> + Pager( + PagingConfig( + pageSize = pageSize, + enablePlaceholders = true, + maxSize = limit + ), + remoteMediator = backlogManager?.let { + QuasselRemoteMediator( + bufferId = buffer, + database = database, + backlogManager = it, + pageSize = pageSize + ) + } + ) { + database.messageDao().pagingSource(buffer.id) + }.flow + } + }.collectAsLazyPagingItems() val markerLine: MsgId? = rememberFlow(null) { backend.flow() - .mapNullable { it.session } .flatMap() .mapNullable { it.bufferSyncer } .flatMap() @@ -64,35 +105,63 @@ fun MessageRoute( Button(onClick = { navController.navigate("home") }) { Text("Back") } - Button(onClick = { - messageStore?.loadBefore(buffer, limit) - }) { - Text("↑") - } - Button(onClick = { - messageStore?.loadAfter(buffer, limit) - }) { - Text("↓") - } - Button(onClick = { - messageStore?.loadAround(buffer, markerLine ?: MsgId(-1), limit) - }) { - Text("N") - } - Button(onClick = { - messageStore?.clear(buffer) - }) { - Text("Clr") + } + LazyColumn(state = listState) { + itemsIndexed(messages, key = { _, item -> item.messageId }) { index, model -> + if (model == null) { + MessagePlaceholder() + } else { + val message = model.toMessage() + val prev = messages.itemSnapshotList.getPrevious(index)?.toMessage() + 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 = (markerLine != null && (prev == null || prev.messageId <= markerLine)) && + message.messageId > markerLine + + val parsed = IrcFormatDeserializer.parse(message.content) + + if (prevDate == null || !messageDate.isEqual(prevDate)) { + MessageDayChangeView(messageDate, isNew) + } else if (isNew) { + NewMessageView() + } + + when (message.type) { + MessageType.of(MessageType.Plain) -> { + MessageBase(message, followUp) { + Text(IrcFormatRenderer.render(parsed), style = Typography.body2) + } + } + MessageType.of(MessageType.Action) -> { + MessageBaseSmall(message) { + val nick = HostmaskHelper.nick(message.sender) + + Text( + TextFormatter.format( + AnnotatedString(stringResource(R.string.message_format_action)), + buildNick(nick, message.senderPrefixes), + IrcFormatRenderer.render( + data = parsed.map { + it.copy(style = it.style.flipFlag(IrcFormat.Flag.ITALIC)) + } + ) + ), + style = Typography.body2, + color = QuasselTheme.chat.onAction + ) + } + } + } + } } } - MessageList( - messages = messages, - listState = listState, - markerLine = markerLine ?: MsgId(-1), - buffer = 5, - onLoadAtStart = { messageStore?.loadBefore(buffer, limit) }, - onLoadAtEnd = { messageStore?.loadAfter(buffer, limit) } - ) } } diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/util/format/IrcFormatRenderer.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/util/format/IrcFormatRenderer.kt index 87233d91f400145aa69e462799f61b398217e7c1..71cba22162f97028c2b4b155b32729baf202a3f4 100644 --- a/app/src/main/kotlin/de/justjanne/quasseldroid/util/format/IrcFormatRenderer.kt +++ b/app/src/main/kotlin/de/justjanne/quasseldroid/util/format/IrcFormatRenderer.kt @@ -43,8 +43,8 @@ object IrcFormatRenderer { textColor: Color, backgroundColor: Color ): SpanStyle { - val foreground = toColor(style.foreground) - val background = toColor(style.background) + val foreground = toColor(style.foreground) ?: textColor + val background = toColor(style.background) ?: backgroundColor return SpanStyle( fontWeight = if (style.flags.contains(IrcFormat.Flag.BOLD)) FontWeight.Bold else FontWeight.Normal, @@ -56,10 +56,8 @@ object IrcFormatRenderer { ) ), fontFamily = if (style.flags.contains(IrcFormat.Flag.MONOSPACE)) FontFamily.Monospace else null, - color = if (style.flags.contains(IrcFormat.Flag.INVERSE)) background ?: backgroundColor - else foreground ?: Color.Unspecified, - background = if (style.flags.contains(IrcFormat.Flag.INVERSE)) foreground ?: textColor - else background ?: Color.Unspecified, + color = if (style.flags.contains(IrcFormat.Flag.INVERSE)) background else foreground, + background = if (style.flags.contains(IrcFormat.Flag.INVERSE)) foreground else background, ) } } diff --git a/gradle/convention/src/main/kotlin/justjanne.kotlin.android.gradle.kts b/gradle/convention/src/main/kotlin/justjanne.kotlin.android.gradle.kts index 0739cbd609fba21de68b33e47e5761f2f3fc7dd2..3f663fe8b52fe25cb0ba6591bcab531c15e81e6b 100644 --- a/gradle/convention/src/main/kotlin/justjanne.kotlin.android.gradle.kts +++ b/gradle/convention/src/main/kotlin/justjanne.kotlin.android.gradle.kts @@ -30,6 +30,7 @@ tasks.withType<KotlinCompile> { "-opt-in=kotlin.ExperimentalUnsignedTypes", "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", "-opt-in=androidx.compose.ui.ExperimentalComposeUiApi", + "-opt-in=androidx.paging.ExperimentalPagingApi", ) jvmTarget = "1.8" } diff --git a/gradle/convention/src/main/kotlin/justjanne.kotlin.gradle.kts b/gradle/convention/src/main/kotlin/justjanne.kotlin.gradle.kts index 888ec7207555d99f7b4dbfc5fcebcdf64c55d34e..2e1834c8f35efa685191b8b02968ff378ef5e3a8 100644 --- a/gradle/convention/src/main/kotlin/justjanne.kotlin.gradle.kts +++ b/gradle/convention/src/main/kotlin/justjanne.kotlin.gradle.kts @@ -22,6 +22,7 @@ tasks.withType<KotlinCompile> { "-opt-in=kotlin.ExperimentalUnsignedTypes", "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", "-opt-in=androidx.compose.ui.ExperimentalComposeUiApi", + "-opt-in=androidx.paging.ExperimentalPagingApi", ) jvmTarget = "1.8" } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ff603593b9daf5fe784b542cfeccc66fd880e344..aacaa480a30ce0b1f352075eb626a48ff7f69bc1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,9 +1,14 @@ [versions] libquassel = "0.10.1" +androidx-collection = "1.2.0" +androidx-core = "1.7.0" androidx-activity = "1.4.0" androidx-appcompat = "1.4.1" androidx-compose = "1.1.1" +androidx-material3 = "1.0.0-alpha05" androidx-navigation = "2.4.1" +androidx-paging = "3.1.0" +androidx-room = "2.5.0-alpha01" [libraries] libquassel-protocol = { module = "de.justjanne.libquassel:libquassel-protocol", version.ref = "libquassel" } @@ -19,7 +24,8 @@ androidx-appcompat-resources = { module = "androidx.appcompat:appcompat-resource androidx-compose-animation = { module = "androidx.compose.animation:animation", version.ref = "androidx-compose" } androidx-compose-compiler = { module = "androidx.compose.compiler:compiler", version.ref = "androidx-compose" } androidx-compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "androidx-compose" } -androidx-compose-material = { module = "androidx.compose.material:material", version.ref = "androidx-compose" } +#androidx-compose-material = { module = "androidx.compose.material:material", version.ref = "androidx-compose" } +androidx-compose-material = { module = "androidx.compose.material3:material3", version.ref = "androidx-material3" } androidx-compose-material-icons = { module = "androidx.compose.material:material-icons-extended", version.ref = "androidx-compose" } androidx-compose-runtime = { module = "androidx.compose.runtime:runtime", version.ref = "androidx-compose" } androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "androidx-compose" } @@ -27,6 +33,18 @@ androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", versi androidx-compose-ui-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "androidx-compose" } androidx-compose-ui-test = { module = "androidx.compose.ui:ui-test", version.ref = "androidx-compose" } +androidx-collection-ktx = { module = "androidx.collection:collection-ktx", version.ref = "androidx-collection" } +androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } + androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } +androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "androidx-paging" } +androidx-paging-test = { module = "androidx.paging:paging-common", version.ref = "androidx-paging" } +androidx-paging-compose = { module = "androidx.paging:paging-compose", version = "1.0.0-alpha14" } + +androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "androidx-room" } +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "androidx-room" } +androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "androidx-room" } +androidx-room-paging = { module = "androidx.room:room-paging", version.ref = "androidx-room" } + compose-htmltext = { module = "de.charlex.compose:html-text", version = "1.1.0" }