Skip to content

Commit

Permalink
Implements sending direct message
Browse files Browse the repository at this point in the history
  • Loading branch information
AleksandarIlic committed Oct 27, 2023
1 parent dbc6713 commit 34cd9bd
Show file tree
Hide file tree
Showing 9 changed files with 168 additions and 29 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import net.primal.android.core.compose.icons.PrimalIcons
import net.primal.android.core.compose.icons.primaliconpack.Settings
import net.primal.android.theme.AppTheme
import net.primal.android.theme.PrimalTheme

@Composable
Expand All @@ -30,12 +31,15 @@ fun AppBarIcon(
modifier: Modifier = Modifier,
enabled: Boolean = true,
tint: Color = LocalContentColor.current,
backgroundColor: Color = Color.Unspecified,
enabledBackgroundColor: Color = Color.Unspecified,
disabledBackgroundColor: Color = AppTheme.colorScheme.outline,
) {
IconButton(
modifier = modifier
.clip(CircleShape)
.background(color = backgroundColor),
.background(
color = if (enabled) enabledBackgroundColor else disabledBackgroundColor
),
enabled = enabled,
onClick = onClick,
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import net.primal.android.nostr.model.primal.PrimalEvent
import net.primal.android.nostr.model.primal.content.ContentPrimalPaging

data class MessagesResponse(
val paging: ContentPrimalPaging?,
val messages: List<NostrEvent>,
val profileMetadata: List<NostrEvent>,
val mediaResources: List<PrimalEvent>,
val paging: ContentPrimalPaging? = null,
val profileMetadata: List<NostrEvent> = emptyList(),
val mediaResources: List<PrimalEvent> = emptyList(),
)
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,21 @@ interface ChatContract {
data class UiState(
val participantId: String,
val messages: Flow<PagingData<ChatMessageUi>>,
val newMessageText: String = "",
val sending: Boolean = false,
val error: ChatError? = null,
val participantProfile: ProfileDetailsUi? = null,
val participantMediaResources: List<MediaResourceUi> = emptyList(),
)
) {
sealed class ChatError {
data class PublishError(val cause: Throwable?) : ChatError()
data class MissingRelaysConfiguration(val cause: Throwable) : ChatError()
}
}

sealed class UiEvent {
data object MessagesSeen : UiEvent()
data class MessageSend(val text: String) : UiEvent()
data object SendMessage : UiEvent()
data class UpdateNewMessage(val text: String) : UiEvent()
}
}
75 changes: 58 additions & 17 deletions app/src/main/kotlin/net/primal/android/messages/chat/ChatScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,19 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
Expand Down Expand Up @@ -102,11 +103,17 @@ fun ChatScreen(
) {
val messagesPagingItems = state.messages.collectAsLazyPagingItems()
val listState = messagesPagingItems.rememberLazyListStatePagingWorkaround()
val snackbarHostState = remember { SnackbarHostState() }

LaunchedEffect(messagesPagingItems.itemCount) {
eventPublisher(ChatContract.UiEvent.MessagesSeen)
}

ChatErrorHandler(
error = state.error,
snackbarHostState = snackbarHostState,
)

Scaffold(
modifier = Modifier.navigationBarsPadding(),
topBar = {
Expand Down Expand Up @@ -149,22 +156,27 @@ fun ChatScreen(
Column(
modifier = Modifier.background(color = AppTheme.colorScheme.surface),
) {
var newMessageText by rememberSaveable { mutableStateOf("") }

PrimalDivider()
MessageOutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
.padding(vertical = 8.dp)
.imePadding(),
value = newMessageText,
value = state.newMessageText,
enabled = state.newMessageText.isNotBlank() && !state.sending,
onSend = {
eventPublisher(ChatContract.UiEvent.SendMessage)
},
onValueChange = {
newMessageText = it
eventPublisher(ChatContract.UiEvent.UpdateNewMessage(text = it))
},
)
}
},
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
}
)
}

Expand All @@ -182,9 +194,11 @@ private fun ChatList(
reverseLayout = true,
) {
item {
Spacer(modifier = Modifier
.height(8.dp)
.fillMaxWidth())
Spacer(
modifier = Modifier
.height(8.dp)
.fillMaxWidth()
)
}

val messagesCount = messages.itemCount
Expand Down Expand Up @@ -249,9 +263,11 @@ private fun ChatList(
}

item {
Spacer(modifier = Modifier
.height(8.dp)
.fillMaxWidth())
Spacer(
modifier = Modifier
.height(8.dp)
.fillMaxWidth()
)
}
}
}
Expand Down Expand Up @@ -371,12 +387,14 @@ private fun ChatMessageListItem(
private fun MessageOutlinedTextField(
value: String,
onValueChange: (String) -> Unit,
onSend: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.Bottom,
){
) {
OutlinedTextField(
modifier = Modifier.weight(1.0f),
value = value,
Expand Down Expand Up @@ -409,9 +427,32 @@ private fun MessageOutlinedTextField(
AppBarIcon(
modifier = Modifier.padding(bottom = 4.dp, start = 8.dp),
icon = Icons.Outlined.ArrowUpward,
backgroundColor = AppTheme.colorScheme.primary,
enabledBackgroundColor = AppTheme.colorScheme.primary,
tint = Color.White,
onClick = {},
enabled = enabled,
onClick = onSend,
)
}
}

@Composable
private fun ChatErrorHandler(
error: ChatContract.UiState.ChatError?,
snackbarHostState: SnackbarHostState,
) {
val context = LocalContext.current
LaunchedEffect(error ?: true) {
val errorMessage = when (error) {
is ChatContract.UiState.ChatError.MissingRelaysConfiguration -> context.getString(R.string.app_missing_relays_config)
is ChatContract.UiState.ChatError.PublishError -> context.getString(R.string.chat_nostr_publish_error)
else -> null
}

if (errorMessage != null) {
snackbarHostState.showSnackbar(
message = errorMessage,
duration = SnackbarDuration.Short,
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import androidx.paging.PagingData
import androidx.paging.map
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
Expand All @@ -26,6 +27,8 @@ import net.primal.android.messages.chat.model.ChatMessageUi
import net.primal.android.messages.db.DirectMessage
import net.primal.android.messages.repository.MessageRepository
import net.primal.android.navigation.profileIdOrThrow
import net.primal.android.networking.relays.errors.MissingRelaysException
import net.primal.android.networking.relays.errors.NostrPublishException
import net.primal.android.networking.sockets.errors.WssException
import net.primal.android.profile.repository.ProfileRepository
import net.primal.android.user.accounts.active.ActiveAccountStore
Expand Down Expand Up @@ -71,9 +74,11 @@ class ChatViewModel @Inject constructor(
private fun observeEvents() = viewModelScope.launch {
_event.collect {
when (it) {
is UiEvent.MessageSend -> Unit
UiEvent.MessagesSeen -> Unit

UiEvent.MessagesSeen -> {}
UiEvent.SendMessage -> sendMessage()
is UiEvent.UpdateNewMessage -> {
setState { copy(newMessageText = it.text) }
}
}
}
}
Expand All @@ -92,7 +97,8 @@ class ChatViewModel @Inject constructor(
profileRepository.observeProfile(profileId = participantId).collect {
setState {
copy(
participantProfile = it.metadata?.asProfileDetailsUi() ?: this.participantProfile,
participantProfile = it.metadata?.asProfileDetailsUi()
?: this.participantProfile,
participantMediaResources = it.resources.map { it.asMediaResourceUi() },
)
}
Expand Down Expand Up @@ -120,6 +126,26 @@ class ChatViewModel @Inject constructor(
}
}

private fun sendMessage() = viewModelScope.launch {
setState { copy(sending = true) }
try {
messageRepository.sendMessage(
userId = userId,
receiverId = participantId,
text = state.value.newMessageText,
)
setState { copy(newMessageText = "") }
} catch (error: NostrPublishException) {
Timber.w(error)
setErrorState(error = UiState.ChatError.PublishError(error))
} catch (error: MissingRelaysException) {
Timber.w(error)
setErrorState(error = UiState.ChatError.MissingRelaysConfiguration(error))
} finally {
setState { copy(sending = false) }
}
}

private fun Flow<PagingData<DirectMessage>>.mapAsPagingDataOfChatMessageUi() =
map { pagingData -> pagingData.map { it.mapAsChatMessageUi() } }

Expand All @@ -134,4 +160,14 @@ class ChatViewModel @Inject constructor(
nostrResources = this.nostrUris.map { it.asNostrResourceUi() },
hashtags = this.data.hashtags,
)

private fun setErrorState(error: UiState.ChatError) {
setState { copy(error = error) }
viewModelScope.launch {
delay(2.seconds)
if (state.value.error == error) {
setState { copy(error = null) }
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,24 @@ import androidx.paging.PagingSource
import androidx.room.withTransaction
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.primal.android.crypto.CryptoUtils
import net.primal.android.crypto.bechToBytes
import net.primal.android.crypto.hexToNpubHrp
import net.primal.android.db.PrimalDatabase
import net.primal.android.messages.api.MessagesApi
import net.primal.android.messages.api.mediator.MessagesRemoteMediator
import net.primal.android.messages.api.mediator.processAndSave
import net.primal.android.messages.api.model.MessagesRequestBody
import net.primal.android.messages.api.model.MessagesResponse
import net.primal.android.messages.db.DirectMessage
import net.primal.android.messages.db.MessageConversation
import net.primal.android.messages.db.MessageConversationData
import net.primal.android.messages.domain.ConversationRelation
import net.primal.android.networking.relays.RelaysManager
import net.primal.android.nostr.ext.flatMapNotNullAsMediaResourcePO
import net.primal.android.nostr.ext.mapAsMessageDataPO
import net.primal.android.nostr.ext.mapAsProfileDataPO
import net.primal.android.nostr.notary.NostrNotary
import net.primal.android.user.accounts.active.ActiveAccountStore
import net.primal.android.user.credentials.CredentialsStore
import javax.inject.Inject
Expand All @@ -30,6 +35,8 @@ class MessageRepository @Inject constructor(
private val database: PrimalDatabase,
private val activeAccountStore: ActiveAccountStore,
private val credentialsStore: CredentialsStore,
private val relaysManager: RelaysManager,
private val nostrNotary: NostrNotary,
) {

fun newestConversations(relation: ConversationRelation) = createConversationsPager {
Expand Down Expand Up @@ -131,6 +138,34 @@ class MessageRepository @Inject constructor(
}
}

suspend fun sendMessage(
userId: String,
receiverId: String,
text: String,
) {
val encryptedContent = CryptoUtils.encrypt(
msg = text,
privateKey = credentialsStore
.findOrThrow(npub = userId.hexToNpubHrp())
.nsec.bechToBytes(hrp = "nsec"),
pubKey = receiverId.hexToNpubHrp().bechToBytes(hrp = "npub"),
)

val nostrEvent = nostrNotary.signEncryptedDirectMessage(
userId = userId,
receiverId = receiverId,
encryptedContent = encryptedContent,
)
relaysManager.publishEvent(nostrEvent)

MessagesResponse(messages = listOf(nostrEvent))
.processAndSave(
userId = userId,
credentialsStore = credentialsStore,
database = database,
)
}

private fun createConversationsPager(
pagingSourceFactory: () -> PagingSource<Int, MessageConversation>
) = Pager(
Expand Down
13 changes: 13 additions & 0 deletions app/src/main/kotlin/net/primal/android/nostr/notary/NostrNotary.kt
Original file line number Diff line number Diff line change
Expand Up @@ -196,4 +196,17 @@ class NostrNotary @Inject constructor(
tags = tags
).signOrThrow(nsec = findNsecOrThrow(pubkey = userId))
}

fun signEncryptedDirectMessage(
userId: String,
receiverId: String,
encryptedContent: String,
): NostrEvent {
return NostrUnsignedEvent(
pubKey = userId,
content = encryptedContent,
kind = NostrEventKind.EncryptedDirectMessages.value,
tags = listOf(receiverId.asPubkeyTag()),
).signOrThrow(nsec = findNsecOrThrow(pubkey = userId))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@ fun ProfileScreen(
val clickDebounce by remember { mutableStateOf(ClickDebounce()) }
AppBarIcon(
icon = PrimalIcons.ArrowBack,
backgroundColor = Color.Black.copy(alpha = 0.5f),
enabledBackgroundColor = Color.Black.copy(alpha = 0.5f),
tint = Color.White,
onClick = { clickDebounce.processEvent(onClose) },
)
Expand Down
Loading

0 comments on commit 34cd9bd

Please sign in to comment.