Merge branch 'vitorpamplona:main' into profiles-list-management

This commit is contained in:
KotlinGeekDev
2025-03-11 23:59:51 +00:00
committed by GitHub
17 changed files with 542 additions and 265 deletions
@@ -44,14 +44,14 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.components.Split
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.ChatFileUploadState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.ChatFileUploader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.references.references
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris
import com.vitorpamplona.quartz.nip10Notes.content.findNostrEventUris
import com.vitorpamplona.quartz.nip10Notes.content.findURLs
import com.vitorpamplona.quartz.nip14Subject.subject
import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent
@@ -301,6 +301,8 @@ open class ChatNewMessageViewModel : ViewModel() {
TextFieldValue(draftEvent.content)
}
iMetaAttachments.addAll(draftEvent.imetas())
requiresNIP17 = draftEvent is NIP17Group
nip17 = draftEvent is NIP17Group
@@ -348,9 +350,15 @@ open class ChatNewMessageViewModel : ViewModel() {
val uploadState = uploadState ?: return
if (nip17) {
ChatFileUploader(room, account).uploadNIP17(uploadState, viewModelScope, onError, context, onceUploaded)
ChatFileUploader(room, account).uploadNIP17(uploadState, viewModelScope, onError, context) {
saveDraft()
onceUploaded()
}
} else {
ChatFileUploader(room, account).uploadNIP04(uploadState, viewModelScope, onError, context, onceUploaded)
ChatFileUploader(room, account).uploadNIP04(uploadState, viewModelScope, onError, context) {
saveDraft()
onceUploaded()
}
}
}
@@ -372,7 +380,7 @@ open class ChatNewMessageViewModel : ViewModel() {
ChatMessageEvent.build(message, room.users.map { LocalCache.getOrCreateUser(it).toPTag() }) {
hashtags(findHashtags(message))
references(findURLs(message))
quotes(findNostrUris(message))
quotes(findNostrEventUris(message))
emojis(emojis)
imetas(usedAttachments)
@@ -381,7 +389,7 @@ open class ChatNewMessageViewModel : ViewModel() {
ChatMessageEvent.reply(message, replyHint) {
hashtags(findHashtags(message))
references(findURLs(message))
quotes(findNostrUris(message))
quotes(findNostrEventUris(message))
emojis(emojis)
imetas(usedAttachments)
@@ -433,6 +441,8 @@ open class ChatNewMessageViewModel : ViewModel() {
userSuggestions.reset()
userSuggestionsMainMessage = null
iMetaAttachments.reset()
if (emojiSearch.value.isNotEmpty()) {
emojiSearch.tryEmit("")
}
@@ -67,6 +67,12 @@ class IMetaAttachments {
}
}
fun addAll(imetas: List<IMetaTag>) {
imetas.forEach {
replace(it.url, it)
}
}
fun remove(url: String) {
iMetaAttachments = iMetaAttachments.filter { it.url != url }
}
@@ -100,5 +106,9 @@ class IMetaAttachments {
replace(iMeta.url, iMeta)
}
fun filterIsIn(urls: Set<String>) = iMetaAttachments.filter { it.url !in urls }
fun filterIsIn(urls: Set<String>) = iMetaAttachments.filter { it.url in urls }
fun reset() {
iMetaAttachments = emptyList()
}
}
@@ -40,14 +40,12 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.navigation.EmptyNav
@@ -58,7 +56,7 @@ import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialog
import com.vitorpamplona.amethyst.ui.note.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.ui.note.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.ChatFileUploadDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.RoomChatFileUploadDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
@@ -119,22 +117,10 @@ fun PrivateMessageEditFieldRow(
channelScreenModel.uploadState?.let { uploading ->
uploading.multiOrchestrator?.let { selectedFiles ->
val context = LocalContext.current
ChatFileUploadDialog(
room = channelScreenModel.room!!,
RoomChatFileUploadDialog(
channelScreenModel = channelScreenModel,
state = uploading,
upload = {
channelScreenModel.upload(
onError = accountViewModel::toast,
context = context,
onceUploaded = onSendNewMessage,
)
if (uploading.selectedServer.type != ServerType.NIP95) {
accountViewModel.account.settings.changeDefaultFileServer(uploading.selectedServer)
}
},
onUpload = onSendNewMessage,
onCancel = uploading::reset,
accountViewModel = accountViewModel,
nav = nav,
@@ -20,264 +20,62 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.TitleIconModifier
import com.vitorpamplona.amethyst.ui.navigation.rememberHeightDecreaser
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SettingSwitchItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameOnlyDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.ChatNewMessageViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
import com.vitorpamplona.amethyst.ui.theme.Size34dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import kotlinx.collections.immutable.toImmutableList
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatFileUploadDialog(
room: ChatroomKey,
fun RoomChatFileUploadDialog(
channelScreenModel: ChatNewMessageViewModel,
state: ChatFileUploadState,
upload: () -> Unit,
onUpload: () -> Unit,
onCancel: () -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
) {
val scrollState = rememberScrollState()
val room = channelScreenModel.room ?: return
val context = LocalContext.current
Dialog(
onDismissRequest = { onCancel() },
properties =
DialogProperties(
usePlatformDefaultWidth = false,
dismissOnClickOutside = false,
decorFitsSystemWindows = false,
),
) {
SetDialogToEdgeToEdge()
Scaffold(
topBar = {
TopAppBar(
scrollBehavior = rememberHeightDecreaser(),
modifier = Modifier,
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
NonClickableUserPictures(
room = room,
accountViewModel = accountViewModel,
size = Size34dp,
)
RoomNameOnlyDisplay(room, Modifier.padding(start = 10.dp), FontWeight.Normal, accountViewModel)
}
},
navigationIcon = {
IconButton(
modifier = TitleIconModifier,
onClick = {
state.reset()
onCancel()
},
) {
ArrowBackIcon()
}
},
actions = {
SendButton(
modifier = Modifier.padding(end = 5.dp),
onPost = upload,
isActive = state.canPost(),
)
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
ChatFileUploadDialog(
state,
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
NonClickableUserPictures(
room = room,
accountViewModel = accountViewModel,
size = Size34dp,
)
},
) { pad ->
Surface(
modifier =
Modifier
.padding(pad)
.consumeWindowInsets(pad)
.imePadding(),
) {
Column(Modifier.fillMaxSize().padding(start = 10.dp, end = 10.dp, bottom = 10.dp)) {
Column(Modifier.fillMaxWidth().verticalScroll(scrollState)) {
ImageVideoPostChat(state, accountViewModel)
}
}
RoomNameOnlyDisplay(room, Modifier.padding(start = 10.dp), FontWeight.Normal, accountViewModel)
}
}
}
}
@Composable
private fun ImageVideoPostChat(
fileUploadState: ChatFileUploadState,
accountViewModel: AccountViewModel,
) {
val fileServers by accountViewModel.account.liveServerList.collectAsState()
val fileServerOptions =
remember(fileServers) {
fileServers
.mapNotNull {
if (it.type != ServerType.NIP95) {
TitleExplainer(it.name, it.baseUrl)
} else {
null
}
}.toImmutableList()
}
fileUploadState.multiOrchestrator?.let {
ShowImageUploadGallery(
it,
fileUploadState::deleteMediaToUpload,
accountViewModel,
)
}
OutlinedTextField(
label = { Text(text = stringRes(R.string.content_description)) },
modifier = Modifier.fillMaxWidth().padding(top = 3.dp).height(150.dp),
maxLines = 10,
value = fileUploadState.caption,
onValueChange = { fileUploadState.caption = it },
placeholder = {
Text(
text = stringRes(R.string.content_description_example),
color = MaterialTheme.colorScheme.placeholderText,
)
},
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
)
SettingSwitchItem(
title = R.string.add_sensitive_content_label,
description = R.string.add_sensitive_content_description,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
checked = fileUploadState.contentWarning,
onCheckedChange = fileUploadState::updateContentWarning,
)
SettingsRow(R.string.file_server, R.string.file_server_description) {
TextSpinner(
label = "",
placeholder =
fileServers
.firstOrNull { it == accountViewModel.account.settings.defaultFileServer }
?.name
?: fileServers[0].name,
options = fileServerOptions,
onSelect = { fileUploadState.selectedServer = fileServers[it] },
)
}
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(Size5dp),
) {
Text(
text = stringRes(R.string.media_compression_quality_label),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = stringRes(R.string.media_compression_quality_explainer),
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
maxLines = 5,
overflow = TextOverflow.Ellipsis,
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(modifier = Modifier.fillMaxWidth()) {
Text(
text =
when (fileUploadState.mediaQualitySlider) {
0 -> stringRes(R.string.media_compression_quality_low)
1 -> stringRes(R.string.media_compression_quality_medium)
2 -> stringRes(R.string.media_compression_quality_high)
3 -> stringRes(R.string.media_compression_quality_uncompressed)
else -> stringRes(R.string.media_compression_quality_medium)
},
modifier = Modifier.align(Alignment.Center),
upload = {
channelScreenModel.upload(
onError = accountViewModel::toast,
context = context,
onceUploaded = onUpload,
)
}
Slider(
value = fileUploadState.mediaQualitySlider.toFloat(),
onValueChange = { fileUploadState.mediaQualitySlider = it.toInt() },
valueRange = 0f..3f,
steps = 2,
)
}
}
@Composable
fun SendButton(
onPost: () -> Unit = {},
isActive: Boolean,
modifier: Modifier = Modifier,
) {
Button(
modifier = modifier,
enabled = isActive,
onClick = onPost,
) {
Text(text = stringRes(R.string.accessibility_send))
}
if (state.selectedServer.type != ServerType.NIP95) {
accountViewModel.account.settings.changeDefaultFileServer(state.selectedServer)
}
},
onCancel,
accountViewModel,
nav,
)
}
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
@@ -0,0 +1,142 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier
import com.vitorpamplona.amethyst.ui.theme.Size34dp
@Composable
fun ChannelFileUploadDialog(
channelScreenModel: ChannelNewMessageViewModel,
state: ChatFileUploadState,
onUpload: () -> Unit,
onCancel: () -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
) {
val channel = channelScreenModel.channel ?: return
val context = LocalContext.current
ChatFileUploadDialog(
state,
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
when (channel) {
is PublicChatChannel -> {
channel.profilePicture()?.let {
RobohashFallbackAsyncImage(
robot = channel.idHex,
model = it,
contentDescription = stringRes(R.string.profile_image),
contentScale = ContentScale.Crop,
modifier = HeaderPictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
)
}
Column(
Modifier
.padding(start = 10.dp)
.height(35.dp)
.weight(1f),
verticalArrangement = Arrangement.Center,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = channel.toBestDisplayName(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
is LiveActivitiesChannel -> {
channel.creator?.let {
UserPicture(
user = it,
size = Size34dp,
accountViewModel = accountViewModel,
nav = nav,
)
}
Column(
modifier =
Modifier
.padding(start = 10.dp)
.height(35.dp)
.weight(1f),
verticalArrangement = Arrangement.Center,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = channel.toBestDisplayName(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
},
upload = {
channelScreenModel.upload(
onError = accountViewModel::toast,
context = context,
onceUploaded = onUpload,
)
if (state.selectedServer.type != ServerType.NIP95) {
accountViewModel.account.settings.changeDefaultFileServer(state.selectedServer)
}
},
onCancel,
accountViewModel,
nav,
)
}
@@ -54,7 +54,7 @@ import com.vitorpamplona.amethyst.ui.components.Split
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.UserSuggestions
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.ChatFileUploadState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
@@ -262,6 +262,8 @@ open class ChannelNewMessageViewModel : ViewModel() {
message = TextFieldValue(draftEvent.content)
iMetaAttachments.addAll(draftEvent.imetas())
urlPreview = findUrlInMessage()
}
@@ -307,6 +309,7 @@ open class ChannelNewMessageViewModel : ViewModel() {
fun upload(
onError: (title: String, message: String) -> Unit,
context: Context,
onceUploaded: () -> Unit,
) {
viewModelScope.launch(Dispatchers.Default) {
val myAccount = account ?: return@launch
@@ -349,6 +352,8 @@ open class ChannelNewMessageViewModel : ViewModel() {
}
uploadState.reset()
onceUploaded()
saveDraft()
} else {
val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct()
@@ -494,6 +499,8 @@ open class ChannelNewMessageViewModel : ViewModel() {
userSuggestions.reset()
userSuggestionsMainMessage = null
iMetaAttachments.reset()
if (emojiSearch.value.isNotEmpty()) {
emojiSearch.tryEmit("")
}
@@ -81,6 +81,19 @@ fun EditFieldRow(
}
}
channelScreenModel.uploadState?.let { uploading ->
uploading.multiOrchestrator?.let { selectedFiles ->
ChannelFileUploadDialog(
channelScreenModel = channelScreenModel,
state = uploading,
onUpload = onSendNewMessage,
onCancel = uploading::reset,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
Column(
modifier = EditFieldModifier,
) {
@@ -0,0 +1,267 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.TitleIconModifier
import com.vitorpamplona.amethyst.ui.navigation.rememberHeightDecreaser
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SettingSwitchItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.collections.immutable.toImmutableList
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatFileUploadDialog(
state: ChatFileUploadState,
title: @Composable () -> Unit,
upload: () -> Unit,
onCancel: () -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
) {
val scrollState = rememberScrollState()
Dialog(
onDismissRequest = { onCancel() },
properties =
DialogProperties(
usePlatformDefaultWidth = false,
dismissOnClickOutside = false,
decorFitsSystemWindows = false,
),
) {
SetDialogToEdgeToEdge()
Scaffold(
topBar = {
TopAppBar(
scrollBehavior = rememberHeightDecreaser(),
modifier = Modifier,
title = title,
navigationIcon = {
IconButton(
modifier = TitleIconModifier,
onClick = {
state.reset()
onCancel()
},
) {
ArrowBackIcon()
}
},
actions = {
SendButton(
modifier = Modifier.padding(end = 5.dp),
onPost = upload,
isActive = state.canPost(),
)
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { pad ->
Surface(
modifier =
Modifier
.padding(pad)
.consumeWindowInsets(pad)
.imePadding(),
) {
Column(Modifier.fillMaxSize().padding(start = 10.dp, end = 10.dp, bottom = 10.dp)) {
Column(Modifier.fillMaxWidth().verticalScroll(scrollState)) {
ImageVideoPostChat(state, accountViewModel)
}
}
}
}
}
}
@Composable
private fun ImageVideoPostChat(
fileUploadState: ChatFileUploadState,
accountViewModel: AccountViewModel,
) {
val fileServers by accountViewModel.account.liveServerList.collectAsState()
val fileServerOptions =
remember(fileServers) {
fileServers
.mapNotNull {
if (it.type != ServerType.NIP95) {
TitleExplainer(it.name, it.baseUrl)
} else {
null
}
}.toImmutableList()
}
fileUploadState.multiOrchestrator?.let {
ShowImageUploadGallery(
it,
fileUploadState::deleteMediaToUpload,
accountViewModel,
)
}
OutlinedTextField(
label = { Text(text = stringRes(R.string.content_description)) },
modifier = Modifier.fillMaxWidth().padding(top = 3.dp).height(150.dp),
maxLines = 10,
value = fileUploadState.caption,
onValueChange = { fileUploadState.caption = it },
placeholder = {
Text(
text = stringRes(R.string.content_description_example),
color = MaterialTheme.colorScheme.placeholderText,
)
},
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
)
SettingSwitchItem(
title = R.string.add_sensitive_content_label,
description = R.string.add_sensitive_content_description,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
checked = fileUploadState.contentWarning,
onCheckedChange = fileUploadState::updateContentWarning,
)
SettingsRow(R.string.file_server, R.string.file_server_description) {
TextSpinner(
label = "",
placeholder =
fileServers
.firstOrNull { it == accountViewModel.account.settings.defaultFileServer }
?.name
?: fileServers[0].name,
options = fileServerOptions,
onSelect = { fileUploadState.selectedServer = fileServers[it] },
)
}
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(Size5dp),
) {
Text(
text = stringRes(R.string.media_compression_quality_label),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = stringRes(R.string.media_compression_quality_explainer),
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
maxLines = 5,
overflow = TextOverflow.Ellipsis,
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(modifier = Modifier.fillMaxWidth()) {
Text(
text =
when (fileUploadState.mediaQualitySlider) {
0 -> stringRes(R.string.media_compression_quality_low)
1 -> stringRes(R.string.media_compression_quality_medium)
2 -> stringRes(R.string.media_compression_quality_high)
3 -> stringRes(R.string.media_compression_quality_uncompressed)
else -> stringRes(R.string.media_compression_quality_medium)
},
modifier = Modifier.align(Alignment.Center),
)
}
Slider(
value = fileUploadState.mediaQualitySlider.toFloat(),
onValueChange = { fileUploadState.mediaQualitySlider = it.toInt() },
valueRange = 0f..3f,
steps = 2,
)
}
}
@Composable
fun SendButton(
onPost: () -> Unit = {},
isActive: Boolean,
modifier: Modifier = Modifier,
) {
Button(
modifier = modifier,
enabled = isActive,
onClick = onPost,
) {
Text(text = stringRes(R.string.accessibility_send))
}
}
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
@@ -81,6 +81,13 @@
<string name="thank_you_so_much">Děkuji moc!</string>
<string name="amount_in_sats">Částka v sats</string>
<string name="send_sats">Poslat sats</string>
<string name="secret_emoji_maker">Tajný tvůrce emoji</string>
<string name="secret_emoji_maker_explainer">Přidejte emoji se skrytou zprávou</string>
<string name="secret_note_to_receiver">Tajná poznámka pro příjemce</string>
<string name="secret_note_to_receiver_placeholder">Moje skrytá zpráva</string>
<string name="secret_visible_text">Viditelná předpona</string>
<string name="secret_visible_text_placeholder">😎</string>
<string name="secret_add_to_text">Přidat k příspěvku</string>
<string name="error_parsing_preview_for">"Chyba při zpracování náhledu pro %1$s : %2$s"</string>
<string name="preview_card_image_for">"Náhled obrázku karty pro %1$s"</string>
<string name="new_channel">Nový kanál</string>
@@ -81,6 +81,13 @@
<string name="thank_you_so_much">Vielen Dank!</string>
<string name="amount_in_sats">Betrag in Sats</string>
<string name="send_sats">Sats senden</string>
<string name="secret_emoji_maker">Geheimer Emoji-Maker</string>
<string name="secret_emoji_maker_explainer">Füge einen Emoji mit versteckter Nachricht hinzu</string>
<string name="secret_note_to_receiver">Geheime Notiz an Empfänger</string>
<string name="secret_note_to_receiver_placeholder">Meine versteckte Nachricht</string>
<string name="secret_visible_text">Sichtbares Präfix</string>
<string name="secret_visible_text_placeholder">😎</string>
<string name="secret_add_to_text">Zum Beitrag hinzufügen</string>
<string name="error_parsing_preview_for">"Fehler beim Parsen der Vorschau für %1$s: %2$s"</string>
<string name="preview_card_image_for">"Vorschaukartenbild für %1$s"</string>
<string name="new_channel">Neuer Kanal</string>
@@ -81,6 +81,13 @@
<string name="thank_you_so_much">Obrigado!</string>
<string name="amount_in_sats">Quantidade em Sats</string>
<string name="send_sats">Enviar Sats</string>
<string name="secret_emoji_maker">Criador de Emoji Secreto</string>
<string name="secret_emoji_maker_explainer">Adicionar um emoji com mensagem oculta na postagem</string>
<string name="secret_note_to_receiver">Nota Secreta para o Receptor</string>
<string name="secret_note_to_receiver_placeholder">Minha mensagem oculta</string>
<string name="secret_visible_text">Prefixo Visível</string>
<string name="secret_visible_text_placeholder">😎</string>
<string name="secret_add_to_text">Adicionar à postagem</string>
<string name="error_parsing_preview_for">"Erro ao analisar a visualização para %1$s: %2$s"</string>
<string name="preview_card_image_for">"Visualize a imagem do cartão para %1$s"</string>
<string name="new_channel">Novo Canal</string>
@@ -81,6 +81,13 @@
<string name="thank_you_so_much">Tack så mycket!</string>
<string name="amount_in_sats">Summa i Sats</string>
<string name="send_sats">Skicka Sats</string>
<string name="secret_emoji_maker">Hemlig emoji-skapare</string>
<string name="secret_emoji_maker_explainer">Lägg till en emoji med dolt meddelande</string>
<string name="secret_note_to_receiver">Hemlig anteckning till mottagaren</string>
<string name="secret_note_to_receiver_placeholder">Mitt dolda inlägg</string>
<string name="secret_visible_text">Synligt Prefix</string>
<string name="secret_visible_text_placeholder">😎</string>
<string name="secret_add_to_text">Lägg till inlägg</string>
<string name="error_parsing_preview_for">"Fel vid analys av förhandsvisning för %1$s : %2$s"</string>
<string name="preview_card_image_for">"Förhandsgranska kortbild för %1$s"</string>
<string name="new_channel">Ny kanal</string>
@@ -23,3 +23,5 @@ package com.vitorpamplona.quartz.nip10Notes.content
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
fun findNostrUris(content: String) = Nip19Parser.parseAll(content)
fun findNostrEventUris(content: String) = Nip19Parser.parseAllEvents(content)
@@ -53,6 +53,12 @@ object Nip19Parser {
Pattern.CASE_INSENSITIVE,
)
val nip19regexEvents: Pattern =
Pattern.compile(
"(nostr:)?@?(nevent1|naddr1|note1|nrelay1|nembed1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)([\\S]*)",
Pattern.CASE_INSENSITIVE,
)
@Immutable
data class ParseReturn(
val entity: Entity,
@@ -130,8 +136,11 @@ object Nip19Parser {
null
}
fun parseAll(content: String): List<Entity> {
val matcher2 = nip19regex.matcher(content)
fun parseAll(
content: String,
regex: Pattern,
): List<Entity> {
val matcher2 = regex.matcher(content)
val returningList = mutableListOf<Entity>()
while (matcher2.find()) {
val type = matcher2.group(2) // npub1
@@ -139,7 +148,7 @@ object Nip19Parser {
val additionalChars = matcher2.group(4) // additional chars
if (type != null) {
val parsed = Nip19Parser.parseComponents(type, key, additionalChars)?.entity
val parsed = parseComponents(type, key, additionalChars)?.entity
if (parsed != null) {
returningList.add(parsed)
@@ -148,6 +157,10 @@ object Nip19Parser {
}
return returningList
}
fun parseAll(content: String): List<Entity> = parseAll(content, nip19regex)
fun parseAllEvents(content: String): List<Entity> = parseAll(content, nip19regexEvents)
}
fun decodePublicKey(key: String): ByteArray =
@@ -81,7 +81,7 @@ class BloomFilterMurMur3Test {
failureCounter++
}
}
assertEquals(0, failureCounter)
assertTrue(failureCounter <= 2)
}
@Test