mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: add emoji reactions to message bubbles (#1421)
* Add tapback emojis to message bubbles Added TapBackEmojiItem composable to display tapback emojis. Included it in MessageItem composable for incoming messages. Added a FlowRow to show tapback emojis below the message bubble. * feat: Add EmojiPicker View * feat: show emojis for local messages * feat: Add emoji tapbacks to messages This commit introduces the ability to send and receive emoji tapbacks for messages. - Adds emoji and replyId fields to DataPacket. - Adds emoji tapback support to the MeshService - Modifies UIState to handle emojis in message lists. * feat: store tapbacks in database Store tapbacks in the database and display them in the message list. - Add a new table to the database to store tapbacks. - Add a new DAO method to insert and retrieve tapbacks. - Update the message list UI to display tapbacks. * refactor: relation db and other changes --------- Co-authored-by: Andre K <andrekir@pm.me>
This commit is contained in:
parent
b3f4929cf4
commit
2234f5a713
15 changed files with 1049 additions and 187 deletions
|
|
@ -1,88 +1,91 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.database
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.AutoMigration
|
||||
import androidx.room.Database
|
||||
import androidx.room.DeleteTable
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import androidx.room.migration.AutoMigrationSpec
|
||||
import com.geeksville.mesh.database.dao.PacketDao
|
||||
import com.geeksville.mesh.database.dao.MeshLogDao
|
||||
import com.geeksville.mesh.database.dao.NodeInfoDao
|
||||
import com.geeksville.mesh.database.dao.QuickChatActionDao
|
||||
import com.geeksville.mesh.database.entity.ContactSettings
|
||||
import com.geeksville.mesh.database.entity.MeshLog
|
||||
import com.geeksville.mesh.database.entity.MyNodeEntity
|
||||
import com.geeksville.mesh.database.entity.NodeEntity
|
||||
import com.geeksville.mesh.database.entity.Packet
|
||||
import com.geeksville.mesh.database.entity.QuickChatAction
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
MyNodeEntity::class,
|
||||
NodeEntity::class,
|
||||
Packet::class,
|
||||
ContactSettings::class,
|
||||
MeshLog::class,
|
||||
QuickChatAction::class
|
||||
],
|
||||
autoMigrations = [
|
||||
AutoMigration(from = 3, to = 4),
|
||||
AutoMigration(from = 4, to = 5),
|
||||
AutoMigration(from = 5, to = 6),
|
||||
AutoMigration(from = 6, to = 7),
|
||||
AutoMigration(from = 7, to = 8),
|
||||
AutoMigration(from = 8, to = 9),
|
||||
AutoMigration(from = 9, to = 10),
|
||||
AutoMigration(from = 10, to = 11),
|
||||
AutoMigration(from = 11, to = 12),
|
||||
AutoMigration(from = 12, to = 13, spec = AutoMigration12to13::class),
|
||||
],
|
||||
version = 13,
|
||||
exportSchema = true,
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
abstract class MeshtasticDatabase : RoomDatabase() {
|
||||
abstract fun nodeInfoDao(): NodeInfoDao
|
||||
abstract fun packetDao(): PacketDao
|
||||
abstract fun meshLogDao(): MeshLogDao
|
||||
abstract fun quickChatActionDao(): QuickChatActionDao
|
||||
|
||||
companion object {
|
||||
fun getDatabase(context: Context): MeshtasticDatabase {
|
||||
|
||||
return Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
MeshtasticDatabase::class.java,
|
||||
"meshtastic_database"
|
||||
)
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteTable.Entries(
|
||||
DeleteTable(tableName = "NodeInfo"),
|
||||
DeleteTable(tableName = "MyNodeInfo")
|
||||
)
|
||||
class AutoMigration12to13 : AutoMigrationSpec
|
||||
/*
|
||||
* Copyright (c) 2024 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.database
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.AutoMigration
|
||||
import androidx.room.Database
|
||||
import androidx.room.DeleteTable
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import androidx.room.migration.AutoMigrationSpec
|
||||
import com.geeksville.mesh.database.dao.PacketDao
|
||||
import com.geeksville.mesh.database.dao.MeshLogDao
|
||||
import com.geeksville.mesh.database.dao.NodeInfoDao
|
||||
import com.geeksville.mesh.database.dao.QuickChatActionDao
|
||||
import com.geeksville.mesh.database.entity.ContactSettings
|
||||
import com.geeksville.mesh.database.entity.MeshLog
|
||||
import com.geeksville.mesh.database.entity.MyNodeEntity
|
||||
import com.geeksville.mesh.database.entity.NodeEntity
|
||||
import com.geeksville.mesh.database.entity.Packet
|
||||
import com.geeksville.mesh.database.entity.QuickChatAction
|
||||
import com.geeksville.mesh.database.entity.ReactionEntity
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
MyNodeEntity::class,
|
||||
NodeEntity::class,
|
||||
Packet::class,
|
||||
ContactSettings::class,
|
||||
MeshLog::class,
|
||||
QuickChatAction::class,
|
||||
ReactionEntity::class,
|
||||
],
|
||||
autoMigrations = [
|
||||
AutoMigration(from = 3, to = 4),
|
||||
AutoMigration(from = 4, to = 5),
|
||||
AutoMigration(from = 5, to = 6),
|
||||
AutoMigration(from = 6, to = 7),
|
||||
AutoMigration(from = 7, to = 8),
|
||||
AutoMigration(from = 8, to = 9),
|
||||
AutoMigration(from = 9, to = 10),
|
||||
AutoMigration(from = 10, to = 11),
|
||||
AutoMigration(from = 11, to = 12),
|
||||
AutoMigration(from = 12, to = 13, spec = AutoMigration12to13::class),
|
||||
AutoMigration(from = 13, to = 14),
|
||||
],
|
||||
version = 14,
|
||||
exportSchema = true,
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
abstract class MeshtasticDatabase : RoomDatabase() {
|
||||
abstract fun nodeInfoDao(): NodeInfoDao
|
||||
abstract fun packetDao(): PacketDao
|
||||
abstract fun meshLogDao(): MeshLogDao
|
||||
abstract fun quickChatActionDao(): QuickChatActionDao
|
||||
|
||||
companion object {
|
||||
fun getDatabase(context: Context): MeshtasticDatabase {
|
||||
|
||||
return Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
MeshtasticDatabase::class.java,
|
||||
"meshtastic_database"
|
||||
)
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteTable.Entries(
|
||||
DeleteTable(tableName = "NodeInfo"),
|
||||
DeleteTable(tableName = "MyNodeInfo")
|
||||
)
|
||||
class AutoMigration12to13 : AutoMigrationSpec
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import com.geeksville.mesh.Portnums.PortNum
|
|||
import com.geeksville.mesh.database.dao.PacketDao
|
||||
import com.geeksville.mesh.database.entity.ContactSettings
|
||||
import com.geeksville.mesh.database.entity.Packet
|
||||
import com.geeksville.mesh.database.entity.ReactionEntity
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.withContext
|
||||
|
|
@ -102,4 +103,8 @@ class PacketRepository @Inject constructor(private val packetDaoLazy: dagger.Laz
|
|||
suspend fun setMuteUntil(contacts: List<String>, until: Long) = withContext(Dispatchers.IO) {
|
||||
packetDao.setMuteUntil(contacts, until)
|
||||
}
|
||||
|
||||
suspend fun insertReaction(reaction: ReactionEntity) = withContext(Dispatchers.IO) {
|
||||
packetDao.insert(reaction)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@
|
|||
package com.geeksville.mesh.database.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.MapColumn
|
||||
import androidx.room.Update
|
||||
import androidx.room.Query
|
||||
|
|
@ -27,7 +26,9 @@ import androidx.room.Upsert
|
|||
import com.geeksville.mesh.DataPacket
|
||||
import com.geeksville.mesh.MessageStatus
|
||||
import com.geeksville.mesh.database.entity.ContactSettings
|
||||
import com.geeksville.mesh.database.entity.PacketEntity
|
||||
import com.geeksville.mesh.database.entity.Packet
|
||||
import com.geeksville.mesh.database.entity.ReactionEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
|
|
@ -81,8 +82,8 @@ interface PacketDao {
|
|||
)
|
||||
suspend fun clearUnreadCount(contact: String, timestamp: Long)
|
||||
|
||||
@Insert
|
||||
fun insert(packet: Packet)
|
||||
@Upsert
|
||||
suspend fun insert(packet: Packet)
|
||||
|
||||
@Query(
|
||||
"""
|
||||
|
|
@ -92,7 +93,8 @@ interface PacketDao {
|
|||
ORDER BY received_time DESC
|
||||
"""
|
||||
)
|
||||
fun getMessagesFrom(contact: String): Flow<List<Packet>>
|
||||
@Transaction
|
||||
fun getMessagesFrom(contact: String): Flow<List<PacketEntity>>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
|
|
@ -101,10 +103,10 @@ interface PacketDao {
|
|||
AND data = :data
|
||||
"""
|
||||
)
|
||||
fun findDataPacket(data: DataPacket): Packet?
|
||||
suspend fun findDataPacket(data: DataPacket): Packet?
|
||||
|
||||
@Query("DELETE FROM packet WHERE uuid in (:uuidList)")
|
||||
fun deleteMessages(uuidList: List<Long>)
|
||||
suspend fun deletePackets(uuidList: List<Long>)
|
||||
|
||||
@Query(
|
||||
"""
|
||||
|
|
@ -113,27 +115,42 @@ interface PacketDao {
|
|||
AND contact_key IN (:contactList)
|
||||
"""
|
||||
)
|
||||
fun deleteContacts(contactList: List<String>)
|
||||
suspend fun deleteContacts(contactList: List<String>)
|
||||
|
||||
@Query("DELETE FROM packet WHERE uuid=:uuid")
|
||||
fun _delete(uuid: Long)
|
||||
suspend fun _delete(uuid: Long)
|
||||
|
||||
@Transaction
|
||||
fun delete(packet: Packet) {
|
||||
suspend fun delete(packet: Packet) {
|
||||
_delete(packet.uuid)
|
||||
}
|
||||
|
||||
@Update
|
||||
fun update(packet: Packet)
|
||||
@Query("SELECT packet_id FROM packet WHERE uuid IN (:uuidList)")
|
||||
suspend fun getPacketIdsFrom(uuidList: List<Long>): List<Int>
|
||||
|
||||
@Query("DELETE FROM reactions WHERE reply_id IN (:packetIds)")
|
||||
suspend fun deleteReactions(packetIds: List<Int>)
|
||||
|
||||
@Transaction
|
||||
fun updateMessageStatus(data: DataPacket, m: MessageStatus) {
|
||||
suspend fun deleteMessages(uuidList: List<Long>) {
|
||||
val packetIds = getPacketIdsFrom(uuidList)
|
||||
if (packetIds.isNotEmpty()) {
|
||||
deleteReactions(packetIds)
|
||||
}
|
||||
deletePackets(uuidList)
|
||||
}
|
||||
|
||||
@Update
|
||||
suspend fun update(packet: Packet)
|
||||
|
||||
@Transaction
|
||||
suspend fun updateMessageStatus(data: DataPacket, m: MessageStatus) {
|
||||
val new = data.copy(status = m)
|
||||
findDataPacket(data)?.let { update(it.copy(data = new)) }
|
||||
}
|
||||
|
||||
@Transaction
|
||||
fun updateMessageId(data: DataPacket, id: Int) {
|
||||
suspend fun updateMessageId(data: DataPacket, id: Int) {
|
||||
val new = data.copy(id = id)
|
||||
findDataPacket(data)?.let { update(it.copy(data = new)) }
|
||||
}
|
||||
|
|
@ -145,7 +162,7 @@ interface PacketDao {
|
|||
ORDER BY received_time ASC
|
||||
"""
|
||||
)
|
||||
fun getDataPackets(): List<DataPacket>
|
||||
suspend fun getDataPackets(): List<DataPacket>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
|
|
@ -155,10 +172,10 @@ interface PacketDao {
|
|||
ORDER BY received_time DESC
|
||||
"""
|
||||
)
|
||||
fun getPacketById(requestId: Int): Packet?
|
||||
suspend fun getPacketById(requestId: Int): Packet?
|
||||
|
||||
@Transaction
|
||||
fun getQueuedPackets(): List<DataPacket>? =
|
||||
suspend fun getQueuedPackets(): List<DataPacket>? =
|
||||
getDataPackets().filter { it.status == MessageStatus.QUEUED }
|
||||
|
||||
@Query(
|
||||
|
|
@ -169,10 +186,10 @@ interface PacketDao {
|
|||
ORDER BY received_time ASC
|
||||
"""
|
||||
)
|
||||
fun getAllWaypoints(): List<Packet>
|
||||
suspend fun getAllWaypoints(): List<Packet>
|
||||
|
||||
@Transaction
|
||||
fun deleteWaypoint(id: Int) {
|
||||
suspend fun deleteWaypoint(id: Int) {
|
||||
val uuidList = getAllWaypoints().filter { it.data.waypoint?.id == id }.map { it.uuid }
|
||||
deleteMessages(uuidList)
|
||||
}
|
||||
|
|
@ -184,7 +201,7 @@ interface PacketDao {
|
|||
suspend fun getContactSettings(contact: String): ContactSettings?
|
||||
|
||||
@Upsert
|
||||
fun upsertContactSettings(contacts: List<ContactSettings>)
|
||||
suspend fun upsertContactSettings(contacts: List<ContactSettings>)
|
||||
|
||||
@Transaction
|
||||
suspend fun setMuteUntil(contacts: List<String>, until: Long) {
|
||||
|
|
@ -194,4 +211,7 @@ interface PacketDao {
|
|||
}
|
||||
upsertContactSettings(contactList)
|
||||
}
|
||||
|
||||
@Upsert
|
||||
suspend fun insert(reaction: ReactionEntity)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,10 +18,36 @@
|
|||
package com.geeksville.mesh.database.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.Relation
|
||||
import com.geeksville.mesh.DataPacket
|
||||
import com.geeksville.mesh.MeshProtos.User
|
||||
import com.geeksville.mesh.model.Message
|
||||
import com.geeksville.mesh.util.getShortDateTime
|
||||
|
||||
data class PacketEntity(
|
||||
@Embedded val packet: Packet,
|
||||
@Relation(entity = ReactionEntity::class, parentColumn = "packet_id", entityColumn = "reply_id")
|
||||
val reactions: List<ReactionEntity> = emptyList(),
|
||||
) {
|
||||
suspend fun toMessage(getUser: suspend (userId: String?) -> User) = with(packet) {
|
||||
Message(
|
||||
uuid = uuid,
|
||||
receivedTime = received_time,
|
||||
user = getUser(data.from),
|
||||
text = data.text.orEmpty(),
|
||||
time = getShortDateTime(data.time),
|
||||
read = read,
|
||||
status = data.status,
|
||||
routingError = routingError,
|
||||
packetId = packetId,
|
||||
emojis = reactions.toReaction(getUser),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Entity(
|
||||
tableName = "packet",
|
||||
|
|
@ -42,6 +68,7 @@ data class Packet(
|
|||
@ColumnInfo(name = "data") val data: DataPacket,
|
||||
@ColumnInfo(name = "packet_id", defaultValue = "0") val packetId: Int = 0,
|
||||
@ColumnInfo(name = "routing_error", defaultValue = "-1") var routingError: Int = -1,
|
||||
@ColumnInfo(name = "reply_id", defaultValue = "0") val replyId: Int = 0,
|
||||
)
|
||||
|
||||
@Entity(tableName = "contact_settings")
|
||||
|
|
@ -51,3 +78,37 @@ data class ContactSettings(
|
|||
) {
|
||||
val isMuted get() = System.currentTimeMillis() <= muteUntil
|
||||
}
|
||||
|
||||
data class Reaction(
|
||||
val replyId: Int,
|
||||
val user: User,
|
||||
val emoji: String,
|
||||
val timestamp: Long,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "reactions",
|
||||
primaryKeys = ["reply_id", "user_id", "emoji"],
|
||||
indices = [
|
||||
Index(value = ["reply_id"]),
|
||||
],
|
||||
)
|
||||
data class ReactionEntity(
|
||||
@ColumnInfo(name = "reply_id") val replyId: Int,
|
||||
@ColumnInfo(name = "user_id") val userId: String,
|
||||
val emoji: String,
|
||||
val timestamp: Long,
|
||||
)
|
||||
|
||||
private suspend fun ReactionEntity.toReaction(
|
||||
getUser: suspend (userId: String?) -> User
|
||||
) = Reaction(
|
||||
replyId = replyId,
|
||||
user = getUser(userId),
|
||||
emoji = emoji,
|
||||
timestamp = timestamp,
|
||||
)
|
||||
|
||||
private suspend fun List<ReactionEntity>.toReaction(
|
||||
getUser: suspend (userId: String?) -> User
|
||||
) = this.map { it.toReaction(getUser) }
|
||||
|
|
|
|||
|
|
@ -17,10 +17,11 @@
|
|||
|
||||
package com.geeksville.mesh.model
|
||||
|
||||
import com.geeksville.mesh.MeshProtos
|
||||
import com.geeksville.mesh.MeshProtos.Routing
|
||||
import com.geeksville.mesh.MeshProtos.User
|
||||
import com.geeksville.mesh.MessageStatus
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.database.entity.Reaction
|
||||
|
||||
val Routing.Error.stringRes: Int
|
||||
get() = when (this) {
|
||||
|
|
@ -46,12 +47,14 @@ val Routing.Error.stringRes: Int
|
|||
data class Message(
|
||||
val uuid: Long,
|
||||
val receivedTime: Long,
|
||||
val user: MeshProtos.User,
|
||||
val user: User,
|
||||
val text: String,
|
||||
val time: String,
|
||||
val read: Boolean,
|
||||
val status: MessageStatus?,
|
||||
val routingError: Int,
|
||||
val packetId: Int,
|
||||
val emojis: List<Reaction>,
|
||||
) {
|
||||
private fun getStatusStringRes(value: Int): Int {
|
||||
val error = Routing.Error.forNumber(value) ?: Routing.Error.UNRECOGNIZED
|
||||
|
|
|
|||
|
|
@ -61,7 +61,6 @@ import com.geeksville.mesh.service.MeshService
|
|||
import com.geeksville.mesh.service.ServiceAction
|
||||
import com.geeksville.mesh.ui.map.MAP_STYLE_ID
|
||||
import com.geeksville.mesh.util.getShortDate
|
||||
import com.geeksville.mesh.util.getShortDateTime
|
||||
import com.geeksville.mesh.util.positionToMeter
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -330,20 +329,8 @@ class UIViewModel @Inject constructor(
|
|||
)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun getMessagesFrom(contactKey: String) = packetRepository.getMessagesFrom(contactKey).mapLatest { list ->
|
||||
list.map {
|
||||
Message(
|
||||
uuid = it.uuid,
|
||||
receivedTime = it.received_time,
|
||||
user = getUser(it.data.from),
|
||||
text = it.data.text.orEmpty(),
|
||||
time = getShortDateTime(it.data.time),
|
||||
read = it.read,
|
||||
status = it.data.status,
|
||||
routingError = it.routingError,
|
||||
)
|
||||
}
|
||||
}
|
||||
fun getMessagesFrom(contactKey: String) = packetRepository.getMessagesFrom(contactKey)
|
||||
.mapLatest { list -> list.map { it.toMessage(::getUser) } }
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val waypoints = packetRepository.getWaypoints().mapLatest { list ->
|
||||
|
|
@ -386,10 +373,8 @@ class UIViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun sendTapback(emoji: String, replyId: Int, contactKey: String) {
|
||||
viewModelScope.launch {
|
||||
radioConfigRepository.onServiceAction(ServiceAction.Tapback(emoji, replyId, contactKey))
|
||||
}
|
||||
fun sendReaction(emoji: String, replyId: Int, contactKey: String) = viewModelScope.launch {
|
||||
radioConfigRepository.onServiceAction(ServiceAction.Reaction(emoji, replyId, contactKey))
|
||||
}
|
||||
|
||||
fun requestTraceroute(destNum: Int) {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import com.geeksville.mesh.database.entity.MeshLog
|
|||
import com.geeksville.mesh.database.entity.MyNodeEntity
|
||||
import com.geeksville.mesh.database.entity.NodeEntity
|
||||
import com.geeksville.mesh.database.entity.Packet
|
||||
import com.geeksville.mesh.database.entity.ReactionEntity
|
||||
import com.geeksville.mesh.database.entity.toNodeInfo
|
||||
import com.geeksville.mesh.model.DeviceVersion
|
||||
import com.geeksville.mesh.model.getTracerouteResponse
|
||||
|
|
@ -76,7 +77,7 @@ import javax.inject.Inject
|
|||
import kotlin.math.absoluteValue
|
||||
|
||||
sealed class ServiceAction {
|
||||
data class Tapback(val emoji: String, val replyId: Int, val contactKey: String) : ServiceAction()
|
||||
data class Reaction(val emoji: String, val replyId: Int, val contactKey: String) : ServiceAction()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -302,7 +303,7 @@ class MeshService : Service(), Logging {
|
|||
.launchIn(serviceScope)
|
||||
radioConfigRepository.serviceAction.onEach { action ->
|
||||
when (action) {
|
||||
is ServiceAction.Tapback -> sendTapback(action)
|
||||
is ServiceAction.Reaction -> sendReaction(action)
|
||||
}
|
||||
}.launchIn(serviceScope)
|
||||
|
||||
|
|
@ -630,6 +631,16 @@ class MeshService : Service(), Logging {
|
|||
Portnums.PortNum.WAYPOINT_APP_VALUE,
|
||||
)
|
||||
|
||||
private fun rememberReaction(packet: MeshPacket) = serviceScope.handledLaunch {
|
||||
val reaction = ReactionEntity(
|
||||
replyId = packet.decoded.replyId,
|
||||
userId = toNodeID(packet.from),
|
||||
emoji = packet.decoded.payload.toByteArray().decodeToString(),
|
||||
timestamp = System.currentTimeMillis(),
|
||||
)
|
||||
packetRepository.get().insertReaction(reaction)
|
||||
}
|
||||
|
||||
private fun rememberDataPacket(dataPacket: DataPacket, updateNotification: Boolean = true) {
|
||||
if (dataPacket.dataType !in rememberDataType) return
|
||||
val fromLocal = dataPacket.from == DataPacket.ID_LOCAL
|
||||
|
|
@ -682,8 +693,13 @@ class MeshService : Service(), Logging {
|
|||
|
||||
when (data.portnumValue) {
|
||||
Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> {
|
||||
debug("Received CLEAR_TEXT from $fromId")
|
||||
rememberDataPacket(dataPacket)
|
||||
if (data.emoji != 0) {
|
||||
debug("Received EMOJI from $fromId")
|
||||
rememberReaction(packet)
|
||||
} else {
|
||||
debug("Received CLEAR_TEXT from $fromId")
|
||||
rememberDataPacket(dataPacket)
|
||||
}
|
||||
}
|
||||
|
||||
Portnums.PortNum.WAYPOINT_APP_VALUE -> {
|
||||
|
|
@ -1741,19 +1757,22 @@ class MeshService : Service(), Logging {
|
|||
}
|
||||
}
|
||||
|
||||
private fun sendTapback(tapback: ServiceAction.Tapback) = toRemoteExceptions {
|
||||
private fun sendReaction(reaction: ServiceAction.Reaction) = toRemoteExceptions {
|
||||
// contactKey: unique contact key filter (channel)+(nodeId)
|
||||
val channel = tapback.contactKey[0].digitToInt()
|
||||
val destNum = tapback.contactKey.substring(1)
|
||||
val channel = reaction.contactKey[0].digitToInt()
|
||||
val destNum = reaction.contactKey.substring(1)
|
||||
|
||||
sendToRadio(newMeshPacketTo(destNum).buildMeshPacket(
|
||||
val packet = newMeshPacketTo(destNum).buildMeshPacket(
|
||||
channel = channel,
|
||||
priority = MeshPacket.Priority.BACKGROUND,
|
||||
) {
|
||||
replyId = tapback.replyId
|
||||
emoji = 1
|
||||
replyId = reaction.replyId
|
||||
portnumValue = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE
|
||||
payload = ByteString.copyFrom(tapback.emoji.encodeToByteArray())
|
||||
})
|
||||
payload = ByteString.copyFrom(reaction.emoji.encodeToByteArray())
|
||||
}
|
||||
sendToRadio(packet)
|
||||
rememberReaction(packet.copy { from = myNodeNum })
|
||||
}
|
||||
|
||||
private val binder = object : IMeshService.Stub() {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import androidx.compose.runtime.snapshotFlow
|
|||
import androidx.compose.ui.Modifier
|
||||
import com.geeksville.mesh.DataPacket
|
||||
import com.geeksville.mesh.model.Message
|
||||
import com.geeksville.mesh.ui.components.ReactionRow
|
||||
import com.geeksville.mesh.ui.components.SimpleAlertDialog
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
|
|
@ -46,6 +47,7 @@ internal fun MessageListView(
|
|||
selectedIds: MutableState<Set<Long>>,
|
||||
onUnreadChanged: (Long) -> Unit,
|
||||
contentPadding: PaddingValues,
|
||||
onSendReaction: (String, Int) -> Unit,
|
||||
onClick: (Message) -> Unit = {}
|
||||
) {
|
||||
val inSelectionMode by remember { derivedStateOf { selectedIds.value.isNotEmpty() } }
|
||||
|
|
@ -75,10 +77,12 @@ internal fun MessageListView(
|
|||
contentPadding = contentPadding
|
||||
) {
|
||||
items(messages, key = { it.uuid }) { msg ->
|
||||
val fromLocal = msg.user.id == DataPacket.ID_LOCAL
|
||||
val selected by remember { derivedStateOf { selectedIds.value.contains(msg.uuid) } }
|
||||
|
||||
ReactionRow(fromLocal, msg.emojis) { onSendReaction(it, msg.packetId) }
|
||||
MessageItem(
|
||||
shortName = msg.user.shortName.takeIf { msg.user.id != DataPacket.ID_LOCAL },
|
||||
shortName = msg.user.shortName.takeIf { !fromLocal },
|
||||
messageText = msg.text,
|
||||
messageTime = msg.time,
|
||||
messageStatus = msg.status,
|
||||
|
|
|
|||
|
|
@ -251,7 +251,8 @@ internal fun MessageScreen(
|
|||
messages = messages,
|
||||
selectedIds = selectedIds,
|
||||
onUnreadChanged = { viewModel.clearUnreadCount(contactKey, it) },
|
||||
contentPadding = innerPadding
|
||||
contentPadding = innerPadding,
|
||||
onSendReaction = { emoji, id -> viewModel.sendReaction(emoji, id, contactKey) },
|
||||
) {
|
||||
// TODO onCLick()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.components
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.emoji2.emojipicker.RecentEmojiProviderAdapter
|
||||
import com.geeksville.mesh.util.CustomRecentEmojiProvider
|
||||
|
||||
@Composable
|
||||
fun EmojiPicker(
|
||||
onDismiss: () -> Unit = {},
|
||||
onConfirm: (String) -> Unit
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Bottom
|
||||
) {
|
||||
BackHandler {
|
||||
onDismiss()
|
||||
}
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
androidx.emoji2.emojipicker.EmojiPickerView(context).apply {
|
||||
clipToOutline = true
|
||||
setRecentEmojiProvider(
|
||||
RecentEmojiProviderAdapter(CustomRecentEmojiProvider(context))
|
||||
)
|
||||
setOnEmojiPickedListener { emoji ->
|
||||
onDismiss()
|
||||
onConfirm(emoji.emoji)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(fraction = 0.4f)
|
||||
.background(MaterialTheme.colors.background)
|
||||
)
|
||||
}
|
||||
}
|
||||
220
app/src/main/java/com/geeksville/mesh/ui/components/Reaction.kt
Normal file
220
app/src/main/java/com/geeksville/mesh/ui/components/Reaction.kt
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.Badge
|
||||
import androidx.compose.material.BadgedBox
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.Add
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.geeksville.mesh.MeshProtos
|
||||
import com.geeksville.mesh.database.entity.Reaction
|
||||
import com.geeksville.mesh.ui.theme.AppTheme
|
||||
|
||||
@Composable
|
||||
private fun ReactionItem(
|
||||
emoji: String,
|
||||
isAddEmojiItem: Boolean = false,
|
||||
emojiCount: Int = 1,
|
||||
onClick: () -> Unit = {},
|
||||
) {
|
||||
BadgedBox(
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp),
|
||||
badge = {
|
||||
if (emojiCount > 1) {
|
||||
Badge(
|
||||
backgroundColor = MaterialTheme.colors.onBackground,
|
||||
contentColor = MaterialTheme.colors.background,
|
||||
) {
|
||||
Text(
|
||||
fontWeight = FontWeight.Bold,
|
||||
text = emojiCount.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.clickable { onClick() },
|
||||
color = MaterialTheme.colors.surface,
|
||||
shape = RoundedCornerShape(32.dp),
|
||||
elevation = 4.dp,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (isAddEmojiItem) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = emoji,
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.clip(CircleShape),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun ReactionRow(
|
||||
fromLocal: Boolean,
|
||||
reactions: List<Reaction> = emptyList(),
|
||||
onSendReaction: (String) -> Unit = {}
|
||||
) {
|
||||
val emojiList by remember(reactions) {
|
||||
mutableStateOf(
|
||||
reduceEmojis(
|
||||
if (fromLocal) {
|
||||
reactions.map { it.emoji }
|
||||
} else {
|
||||
reactions.map { it.emoji }.reversed()
|
||||
}
|
||||
).entries
|
||||
)
|
||||
}
|
||||
var showEmojiPickerDialog by remember { mutableStateOf(false) }
|
||||
if (showEmojiPickerDialog) {
|
||||
EmojiPickerDialog(
|
||||
onConfirm = {
|
||||
showEmojiPickerDialog = false
|
||||
onSendReaction(it)
|
||||
},
|
||||
onDismiss = { showEmojiPickerDialog = false }
|
||||
)
|
||||
}
|
||||
@Composable
|
||||
fun AddEmojiItem() {
|
||||
ReactionItem(
|
||||
emoji = "\uD83D\uDE42",
|
||||
isAddEmojiItem = true,
|
||||
onClick = {
|
||||
showEmojiPickerDialog = true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EmojiList() {
|
||||
emojiList.forEach { entry ->
|
||||
ReactionItem(
|
||||
emoji = entry.key,
|
||||
emojiCount = entry.value,
|
||||
onClick = {
|
||||
onSendReaction(entry.key)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = if (fromLocal) Arrangement.End else Arrangement.Start
|
||||
) {
|
||||
EmojiList()
|
||||
AddEmojiItem()
|
||||
}
|
||||
}
|
||||
|
||||
fun reduceEmojis(emojis: List<String>): Map<String, Int> = emojis.groupingBy { it }.eachCount()
|
||||
|
||||
@Composable
|
||||
fun EmojiPickerDialog(
|
||||
onConfirm: (String) -> Unit,
|
||||
onDismiss: () -> Unit = {},
|
||||
) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
) {
|
||||
EmojiPicker(
|
||||
onConfirm = onConfirm,
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
fun ReactionItemPreview() {
|
||||
AppTheme {
|
||||
Column(
|
||||
modifier = Modifier.background(MaterialTheme.colors.background)
|
||||
) {
|
||||
ReactionItem(emoji = "\uD83D\uDE42")
|
||||
ReactionItem(emoji = "\uD83D\uDE42", emojiCount = 2)
|
||||
ReactionItem(emoji = "\uD83D\uDE42", isAddEmojiItem = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ReactionRowPreview() {
|
||||
AppTheme {
|
||||
ReactionRow(
|
||||
fromLocal = true, reactions = listOf(
|
||||
Reaction(
|
||||
replyId = 1,
|
||||
user = MeshProtos.User.getDefaultInstance(),
|
||||
emoji = "\uD83D\uDE42",
|
||||
timestamp = 1L
|
||||
),
|
||||
Reaction(
|
||||
replyId = 1,
|
||||
user = MeshProtos.User.getDefaultInstance(),
|
||||
emoji = "\uD83D\uDE42",
|
||||
timestamp = 1L
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -17,7 +17,6 @@
|
|||
|
||||
package com.geeksville.mesh.ui.map
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
|
|
@ -25,7 +24,6 @@ import androidx.compose.foundation.layout.Column
|
|||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
|
|
@ -58,15 +56,12 @@ import androidx.compose.ui.text.style.TextAlign
|
|||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.emoji2.emojipicker.EmojiPickerView
|
||||
import androidx.emoji2.emojipicker.RecentEmojiProviderAdapter
|
||||
import com.geeksville.mesh.MeshProtos.Waypoint
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.copy
|
||||
import com.geeksville.mesh.ui.components.EditTextPreference
|
||||
import com.geeksville.mesh.ui.components.EmojiPicker
|
||||
import com.geeksville.mesh.ui.theme.AppTheme
|
||||
import com.geeksville.mesh.util.CustomRecentEmojiProvider
|
||||
import com.geeksville.mesh.waypoint
|
||||
|
||||
@Suppress("LongMethod")
|
||||
|
|
@ -184,31 +179,9 @@ internal fun EditWaypointDialog(
|
|||
}
|
||||
},
|
||||
) else {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Bottom
|
||||
) {
|
||||
BackHandler {
|
||||
showEmojiPickerView = false
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
EmojiPickerView(context).apply {
|
||||
clipToOutline = true
|
||||
setRecentEmojiProvider(
|
||||
RecentEmojiProviderAdapter(CustomRecentEmojiProvider(context))
|
||||
)
|
||||
setOnEmojiPickedListener { emoji ->
|
||||
showEmojiPickerView = false
|
||||
waypointInput = waypointInput.copy { icon = emoji.emoji.codePointAt(0) }
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(0.4f)
|
||||
.background(MaterialTheme.colors.background)
|
||||
)
|
||||
EmojiPicker(onDismiss = { showEmojiPickerView = false }) {
|
||||
showEmojiPickerView = false
|
||||
waypointInput = waypointInput.copy { icon = it.codePointAt(0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue