feat(regression/cs2): age gate
Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
parent
8049c29e38
commit
5e4f542894
|
|
@ -13,6 +13,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.Crossfade
|
||||||
import androidx.compose.animation.ExperimentalAnimationApi
|
import androidx.compose.animation.ExperimentalAnimationApi
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.animation.core.animateIntAsState
|
import androidx.compose.animation.core.animateIntAsState
|
||||||
|
|
@ -469,479 +470,505 @@ fun ChannelScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
) { pv ->
|
) { pv ->
|
||||||
Column(
|
Crossfade(
|
||||||
modifier = Modifier
|
targetState = viewModel.ageGateUnlocked,
|
||||||
.padding(pv)
|
label = "ageGateUnlocked"
|
||||||
) {
|
) { ageGateUnlocked ->
|
||||||
Box(
|
if (!ageGateUnlocked) {
|
||||||
modifier = Modifier.weight(1f),
|
ChannelScreenAgeGate(
|
||||||
contentAlignment = Alignment.BottomCenter
|
onAccept = {
|
||||||
) {
|
viewModel.ageGateUnlocked = true
|
||||||
LazyColumn(
|
},
|
||||||
state = lazyListState,
|
onDeny = {
|
||||||
reverseLayout = true,
|
onToggleDrawer()
|
||||||
contentPadding = PaddingValues(top = 16.dp, bottom = 32.dp)
|
|
||||||
) {
|
|
||||||
|
|
||||||
// If we don't have a guaranteed first item, the message list will not scroll
|
|
||||||
// to the bottom when new messages are added. Evil hack to make our other evil
|
|
||||||
// hack (clear/addAll) work. Too bad!
|
|
||||||
item(key = "guaranteed_first") {
|
|
||||||
Box {}
|
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(pv)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
contentAlignment = Alignment.BottomCenter
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
state = lazyListState,
|
||||||
|
reverseLayout = true,
|
||||||
|
contentPadding = PaddingValues(top = 16.dp, bottom = 32.dp)
|
||||||
|
) {
|
||||||
|
|
||||||
items(
|
// If we don't have a guaranteed first item, the message list will not scroll
|
||||||
viewModel.items.size,
|
// to the bottom when new messages are added. Evil hack to make our other evil
|
||||||
key = { index ->
|
// hack (clear/addAll) work. Too bad!
|
||||||
when (val item = viewModel.items[index]) {
|
item(key = "guaranteed_first") {
|
||||||
is ChannelScreenItem.RegularMessage -> item.message.id!!
|
Box {}
|
||||||
is ChannelScreenItem.ProspectiveMessage -> item.message.id!!
|
|
||||||
is ChannelScreenItem.FailedMessage -> item.message.id!!
|
|
||||||
is ChannelScreenItem.SystemMessage -> item.message.id!!
|
|
||||||
is ChannelScreenItem.DateDivider -> item.instant.toEpochMilliseconds()
|
|
||||||
is ChannelScreenItem.LoadTrigger -> index
|
|
||||||
is ChannelScreenItem.Loading -> index
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
contentType = { index ->
|
items(
|
||||||
when (viewModel.items.getOrNull(index)) {
|
viewModel.items.size,
|
||||||
null -> null
|
key = { index ->
|
||||||
is ChannelScreenItem.RegularMessage -> "RegularMessage"
|
when (val item = viewModel.items[index]) {
|
||||||
is ChannelScreenItem.ProspectiveMessage -> "ProspectiveMessage"
|
is ChannelScreenItem.RegularMessage -> item.message.id!!
|
||||||
is ChannelScreenItem.FailedMessage -> "FailedMessage"
|
is ChannelScreenItem.ProspectiveMessage -> item.message.id!!
|
||||||
is ChannelScreenItem.SystemMessage -> "SystemMessage"
|
is ChannelScreenItem.FailedMessage -> item.message.id!!
|
||||||
is ChannelScreenItem.DateDivider -> "DateDivider"
|
is ChannelScreenItem.SystemMessage -> item.message.id!!
|
||||||
is ChannelScreenItem.LoadTrigger -> "LoadTrigger"
|
is ChannelScreenItem.DateDivider -> item.instant.toEpochMilliseconds()
|
||||||
is ChannelScreenItem.Loading -> "Loading"
|
is ChannelScreenItem.LoadTrigger -> index
|
||||||
}
|
is ChannelScreenItem.Loading -> index
|
||||||
}
|
}
|
||||||
) { index ->
|
},
|
||||||
when (val item = viewModel.items[index]) {
|
contentType = { index ->
|
||||||
is ChannelScreenItem.RegularMessage -> {
|
when (viewModel.items.getOrNull(index)) {
|
||||||
Message(
|
null -> null
|
||||||
message = item.message,
|
is ChannelScreenItem.RegularMessage -> "RegularMessage"
|
||||||
onMessageContextMenu = {
|
is ChannelScreenItem.ProspectiveMessage -> "ProspectiveMessage"
|
||||||
item.message.id?.let { messageId ->
|
is ChannelScreenItem.FailedMessage -> "FailedMessage"
|
||||||
messageContextSheetTarget = messageId
|
is ChannelScreenItem.SystemMessage -> "SystemMessage"
|
||||||
messageContextSheetShown = true
|
is ChannelScreenItem.DateDivider -> "DateDivider"
|
||||||
}
|
is ChannelScreenItem.LoadTrigger -> "LoadTrigger"
|
||||||
},
|
is ChannelScreenItem.Loading -> "Loading"
|
||||||
onAvatarClick = {
|
|
||||||
item.message.author?.let { author ->
|
|
||||||
scope.launch {
|
|
||||||
ActionChannel.send(
|
|
||||||
Action.OpenUserSheet(
|
|
||||||
author,
|
|
||||||
viewModel.channel?.server
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onNameClick = {
|
|
||||||
val author =
|
|
||||||
item.message.author?.let { RevoltAPI.userCache[it] }
|
|
||||||
?: return@Message
|
|
||||||
viewModel.putAtCursorPosition("@${author.username}#${author.discriminator}")
|
|
||||||
},
|
|
||||||
canReply = true,
|
|
||||||
onReply = {
|
|
||||||
item.message.id?.let { messageId ->
|
|
||||||
scope.launch { viewModel.addReplyTo(messageId) }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onAddReaction = {
|
|
||||||
item.message.id?.let { messageId ->
|
|
||||||
reactSheetTarget = messageId
|
|
||||||
reactSheetShown = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is ChannelScreenItem.ProspectiveMessage -> {
|
|
||||||
Box(Modifier.alpha(0.5f)) {
|
|
||||||
Message(
|
|
||||||
message = item.message,
|
|
||||||
onMessageContextMenu = {
|
|
||||||
// TODO Context menu that allows you to cancel send
|
|
||||||
},
|
|
||||||
onAvatarClick = {},
|
|
||||||
onNameClick = {},
|
|
||||||
canReply = false,
|
|
||||||
onReply = {},
|
|
||||||
onAddReaction = {}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
) { index ->
|
||||||
|
when (val item = viewModel.items[index]) {
|
||||||
is ChannelScreenItem.FailedMessage -> {
|
is ChannelScreenItem.RegularMessage -> {
|
||||||
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.error) {
|
|
||||||
Column {
|
|
||||||
Message(
|
Message(
|
||||||
message = item.message,
|
message = item.message,
|
||||||
onMessageContextMenu = {},
|
onMessageContextMenu = {
|
||||||
onAvatarClick = {},
|
item.message.id?.let { messageId ->
|
||||||
onNameClick = {},
|
messageContextSheetTarget = messageId
|
||||||
canReply = false,
|
messageContextSheetShown = true
|
||||||
onReply = {},
|
|
||||||
onAddReaction = {}
|
|
||||||
)
|
|
||||||
Row {
|
|
||||||
UserAvatarWidthPlaceholder()
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.message_failed_to_send),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.error.copy(alpha = 0.8f),
|
|
||||||
modifier = Modifier.padding(
|
|
||||||
top = 4.dp,
|
|
||||||
bottom = 4.dp,
|
|
||||||
start = 20.dp
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is ChannelScreenItem.SystemMessage -> {
|
|
||||||
SystemMessage(message = item.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
is ChannelScreenItem.DateDivider -> {
|
|
||||||
DateDivider(instant = item.instant)
|
|
||||||
}
|
|
||||||
|
|
||||||
is ChannelScreenItem.LoadTrigger -> {
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
Log.d(
|
|
||||||
"ChannelScreen",
|
|
||||||
"LoadTrigger: After ${item.after} Before ${item.before}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is ChannelScreenItem.Loading -> {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(16.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TypingIndicator(
|
|
||||||
users = viewModel.typingUsers,
|
|
||||||
serverId = viewModel.channel?.server
|
|
||||||
)
|
|
||||||
|
|
||||||
androidx.compose.animation.AnimatedVisibility(
|
|
||||||
!isScrolledToBottom.value,
|
|
||||||
enter = slideInVertically(
|
|
||||||
animationSpec = RevoltTweenInt,
|
|
||||||
initialOffsetY = { it }
|
|
||||||
) + fadeIn(animationSpec = RevoltTweenFloat),
|
|
||||||
exit = slideOutVertically(
|
|
||||||
animationSpec = RevoltTweenInt,
|
|
||||||
targetOffsetY = { it }
|
|
||||||
) + fadeOut(animationSpec = RevoltTweenFloat)
|
|
||||||
) {
|
|
||||||
SmallFloatingActionButton(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(bottom = scrollDownFABPadding)
|
|
||||||
.align(Alignment.BottomCenter)
|
|
||||||
.padding(16.dp),
|
|
||||||
onClick = {
|
|
||||||
scope.launch {
|
|
||||||
lazyListState.animateScrollToItem(0)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(R.drawable.ic_arrow_down_24dp),
|
|
||||||
contentDescription = stringResource(R.string.scroll_to_bottom)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp))
|
|
||||||
.fillMaxWidth(),
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
AnimatedContent(
|
|
||||||
targetState = viewModel.denyMessageField,
|
|
||||||
label = "denyMessageField"
|
|
||||||
) { deny ->
|
|
||||||
if (!deny) {
|
|
||||||
Column {
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = viewModel.draftReplyTo.isNotEmpty() && !viewModel.denyMessageField
|
|
||||||
) {
|
|
||||||
ReplyManager(
|
|
||||||
replies = viewModel.draftReplyTo,
|
|
||||||
onToggleMention = {
|
|
||||||
scope.launch { viewModel.toggleMentionOnReply(it.id) }
|
|
||||||
},
|
|
||||||
onRemove = {
|
|
||||||
viewModel.draftReplyTo.remove(it)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = viewModel.draftAttachments.isNotEmpty() && !viewModel.denyMessageField
|
|
||||||
) {
|
|
||||||
AttachmentManager(
|
|
||||||
attachments = viewModel.draftAttachments,
|
|
||||||
uploading = viewModel.attachmentUploadProgress > 0,
|
|
||||||
uploadProgress = viewModel.attachmentUploadProgress,
|
|
||||||
canRemove = true,
|
|
||||||
canPreview = true,
|
|
||||||
onRemove = {
|
|
||||||
viewModel.draftAttachments.remove(it)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
AnimatedVisibility(visible = viewModel.editingMessage != null) {
|
|
||||||
Row(Modifier.padding(start = 24.dp, top = 8.dp)) {
|
|
||||||
AssistChip(
|
|
||||||
onClick = {
|
|
||||||
viewModel.editingMessage = null
|
|
||||||
viewModel.putDraftContent("")
|
|
||||||
},
|
|
||||||
label = {
|
|
||||||
Text(stringResource(R.string.message_field_editing_message))
|
|
||||||
},
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Edit,
|
|
||||||
contentDescription = null
|
|
||||||
)
|
|
||||||
},
|
|
||||||
trailingIcon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Close,
|
|
||||||
contentDescription = stringResource(R.string.message_field_editing_message_cancel_alt),
|
|
||||||
tint = MaterialTheme.colorScheme.onSurface,
|
|
||||||
modifier = Modifier.alpha(0.8f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
NativeMessageField(
|
|
||||||
value = viewModel.draftContent,
|
|
||||||
onValueChange = viewModel::putDraftContent,
|
|
||||||
onAddAttachment = {
|
|
||||||
if (viewModel.activePane == ChannelScreenActivePane.AttachmentPicker) {
|
|
||||||
viewModel.activePane = ChannelScreenActivePane.None
|
|
||||||
} else {
|
|
||||||
viewModel.activePane =
|
|
||||||
ChannelScreenActivePane.AttachmentPicker
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onCommitAttachment = {
|
|
||||||
processFileUri(it, null)
|
|
||||||
},
|
|
||||||
onPickEmoji = {
|
|
||||||
if (viewModel.activePane == ChannelScreenActivePane.EmojiPicker) {
|
|
||||||
viewModel.activePane = ChannelScreenActivePane.None
|
|
||||||
} else {
|
|
||||||
viewModel.activePane = ChannelScreenActivePane.EmojiPicker
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSendMessage = viewModel::sendPendingMessage,
|
|
||||||
channelType = viewModel.channel?.channelType
|
|
||||||
?: ChannelType.TextChannel,
|
|
||||||
channelName = viewModel.channel?.name
|
|
||||||
?: stringResource(R.string.unknown),
|
|
||||||
onFocusChange = { isFocused ->
|
|
||||||
if (isFocused && viewModel.activePane != ChannelScreenActivePane.None) {
|
|
||||||
viewModel.activePane = ChannelScreenActivePane.None
|
|
||||||
imeInTransition = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
forceSendButton = viewModel.draftAttachments.isNotEmpty(),
|
|
||||||
canAttach = (channelPermissions has PermissionBit.UploadFiles) && viewModel.editingMessage == null,
|
|
||||||
serverId = viewModel.channel?.server,
|
|
||||||
channelId = channelId,
|
|
||||||
failedValidation = viewModel.draftContent.length > 2000,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 32.dp, vertical = 16.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
stringResource(viewModel.denyMessageFieldReasonResource),
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (viewModel.activePane == ChannelScreenActivePane.None && !imeInTransition) {
|
|
||||||
Spacer(
|
|
||||||
Modifier
|
|
||||||
.imePadding()
|
|
||||||
.navigationBarsPadding()
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp))
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.heightIn(min = pxAsDp(fallbackKeyboardHeight))
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
Modifier.then(
|
|
||||||
if (emojiSearchFocused) {
|
|
||||||
Modifier.requiredHeight(
|
|
||||||
pxAsDp(
|
|
||||||
max(
|
|
||||||
imeCurrentInset * 2,
|
|
||||||
fallbackKeyboardHeight
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Modifier.requiredHeight(pxAsDp(fallbackKeyboardHeight))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
when (viewModel.activePane) {
|
|
||||||
ChannelScreenActivePane.EmojiPicker -> {
|
|
||||||
BackHandler(enabled = viewModel.activePane == ChannelScreenActivePane.EmojiPicker) {
|
|
||||||
viewModel.activePane = ChannelScreenActivePane.None
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
MaterialTheme.colorScheme.surfaceColorAtElevation(
|
|
||||||
1.dp
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.padding(4.dp)
|
|
||||||
.navigationBarsPadding()
|
|
||||||
) {
|
|
||||||
EmojiPicker(
|
|
||||||
onEmojiSelected = viewModel::putAtCursorPosition,
|
|
||||||
bottomInset = pxAsDp(
|
|
||||||
max(
|
|
||||||
imeCurrentInset - navigationBarsInset,
|
|
||||||
0
|
|
||||||
)
|
|
||||||
),
|
|
||||||
onSearchFocus = {
|
|
||||||
emojiSearchFocused = it
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ChannelScreenActivePane.AttachmentPicker -> {
|
|
||||||
BackHandler(enabled = viewModel.activePane == ChannelScreenActivePane.AttachmentPicker) {
|
|
||||||
viewModel.activePane = ChannelScreenActivePane.None
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
InbuiltMediaPicker(
|
|
||||||
onOpenDocumentsUi = {
|
|
||||||
pickFileLauncher.launch(arrayOf("*/*"))
|
|
||||||
viewModel.activePane = ChannelScreenActivePane.None
|
|
||||||
},
|
|
||||||
onOpenCamera = {
|
|
||||||
// Create a new content URI to store the captured image.
|
|
||||||
val contentResolver = context.contentResolver
|
|
||||||
val contentValues = ContentValues().apply {
|
|
||||||
put(
|
|
||||||
MediaStore.MediaColumns.DISPLAY_NAME,
|
|
||||||
"RVL_${System.currentTimeMillis()}.jpg"
|
|
||||||
)
|
|
||||||
put(
|
|
||||||
MediaStore.MediaColumns.MIME_TYPE,
|
|
||||||
"image/jpeg"
|
|
||||||
)
|
|
||||||
put(
|
|
||||||
MediaStore.MediaColumns.RELATIVE_PATH,
|
|
||||||
Environment.DIRECTORY_PICTURES
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
capturedPhotoUri.value = contentResolver.insert(
|
|
||||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
|
|
||||||
contentValues
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
|
||||||
pickCameraLauncher.launch(capturedPhotoUri.value)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
context.getString(
|
|
||||||
R.string.file_picker_chip_camera_none_installed
|
|
||||||
),
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModel.activePane = ChannelScreenActivePane.None
|
|
||||||
},
|
},
|
||||||
onClose = {
|
onAvatarClick = {
|
||||||
viewModel.activePane = ChannelScreenActivePane.None
|
item.message.author?.let { author ->
|
||||||
},
|
scope.launch {
|
||||||
onMediaSelected = { media ->
|
ActionChannel.send(
|
||||||
try {
|
Action.OpenUserSheet(
|
||||||
processFileUri(
|
author,
|
||||||
media.uri,
|
viewModel.channel?.server
|
||||||
media.uri.lastPathSegment
|
)
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
|
||||||
if (e is FileNotFoundException) {
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
context.getString(
|
|
||||||
R.string.file_picker_cannot_attach_file_invalid
|
|
||||||
),
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
pendingMedia = viewModel.draftAttachments
|
onNameClick = {
|
||||||
.filterNot { it.pickerIdentifier == null }
|
val author =
|
||||||
.map { it.pickerIdentifier!! },
|
item.message.author?.let { RevoltAPI.userCache[it] }
|
||||||
modifier = Modifier
|
?: return@Message
|
||||||
.imePadding()
|
viewModel.putAtCursorPosition("@${author.username}#${author.discriminator}")
|
||||||
.navigationBarsPadding()
|
},
|
||||||
|
canReply = true,
|
||||||
|
onReply = {
|
||||||
|
item.message.id?.let { messageId ->
|
||||||
|
scope.launch { viewModel.addReplyTo(messageId) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onAddReaction = {
|
||||||
|
item.message.id?.let { messageId ->
|
||||||
|
reactSheetTarget = messageId
|
||||||
|
reactSheetShown = true
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
is ChannelScreenItem.ProspectiveMessage -> {
|
||||||
// Do nothing
|
Box(Modifier.alpha(0.5f)) {
|
||||||
|
Message(
|
||||||
|
message = item.message,
|
||||||
|
onMessageContextMenu = {
|
||||||
|
// TODO Context menu that allows you to cancel send
|
||||||
|
},
|
||||||
|
onAvatarClick = {},
|
||||||
|
onNameClick = {},
|
||||||
|
canReply = false,
|
||||||
|
onReply = {},
|
||||||
|
onAddReaction = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is ChannelScreenItem.FailedMessage -> {
|
||||||
|
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.error) {
|
||||||
|
Column {
|
||||||
|
Message(
|
||||||
|
message = item.message,
|
||||||
|
onMessageContextMenu = {},
|
||||||
|
onAvatarClick = {},
|
||||||
|
onNameClick = {},
|
||||||
|
canReply = false,
|
||||||
|
onReply = {},
|
||||||
|
onAddReaction = {}
|
||||||
|
)
|
||||||
|
Row {
|
||||||
|
UserAvatarWidthPlaceholder()
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.message_failed_to_send),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.error.copy(
|
||||||
|
alpha = 0.8f
|
||||||
|
),
|
||||||
|
modifier = Modifier.padding(
|
||||||
|
top = 4.dp,
|
||||||
|
bottom = 4.dp,
|
||||||
|
start = 20.dp
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is ChannelScreenItem.SystemMessage -> {
|
||||||
|
SystemMessage(message = item.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
is ChannelScreenItem.DateDivider -> {
|
||||||
|
DateDivider(instant = item.instant)
|
||||||
|
}
|
||||||
|
|
||||||
|
is ChannelScreenItem.LoadTrigger -> {
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
Log.d(
|
||||||
|
"ChannelScreen",
|
||||||
|
"LoadTrigger: After ${item.after} Before ${item.before}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is ChannelScreenItem.Loading -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Box(Modifier.imePadding())
|
|
||||||
|
TypingIndicator(
|
||||||
|
users = viewModel.typingUsers,
|
||||||
|
serverId = viewModel.channel?.server
|
||||||
|
)
|
||||||
|
|
||||||
|
androidx.compose.animation.AnimatedVisibility(
|
||||||
|
!isScrolledToBottom.value,
|
||||||
|
enter = slideInVertically(
|
||||||
|
animationSpec = RevoltTweenInt,
|
||||||
|
initialOffsetY = { it }
|
||||||
|
) + fadeIn(animationSpec = RevoltTweenFloat),
|
||||||
|
exit = slideOutVertically(
|
||||||
|
animationSpec = RevoltTweenInt,
|
||||||
|
targetOffsetY = { it }
|
||||||
|
) + fadeOut(animationSpec = RevoltTweenFloat)
|
||||||
|
) {
|
||||||
|
SmallFloatingActionButton(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(bottom = scrollDownFABPadding)
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(16.dp),
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
lazyListState.animateScrollToItem(0)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_arrow_down_24dp),
|
||||||
|
contentDescription = stringResource(R.string.scroll_to_bottom)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp))
|
||||||
|
.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = viewModel.denyMessageField,
|
||||||
|
label = "denyMessageField"
|
||||||
|
) { deny ->
|
||||||
|
if (!deny) {
|
||||||
|
Column {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = viewModel.draftReplyTo.isNotEmpty() && !viewModel.denyMessageField
|
||||||
|
) {
|
||||||
|
ReplyManager(
|
||||||
|
replies = viewModel.draftReplyTo,
|
||||||
|
onToggleMention = {
|
||||||
|
scope.launch { viewModel.toggleMentionOnReply(it.id) }
|
||||||
|
},
|
||||||
|
onRemove = {
|
||||||
|
viewModel.draftReplyTo.remove(it)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = viewModel.draftAttachments.isNotEmpty() && !viewModel.denyMessageField
|
||||||
|
) {
|
||||||
|
AttachmentManager(
|
||||||
|
attachments = viewModel.draftAttachments,
|
||||||
|
uploading = viewModel.attachmentUploadProgress > 0,
|
||||||
|
uploadProgress = viewModel.attachmentUploadProgress,
|
||||||
|
canRemove = true,
|
||||||
|
canPreview = true,
|
||||||
|
onRemove = {
|
||||||
|
viewModel.draftAttachments.remove(it)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(visible = viewModel.editingMessage != null) {
|
||||||
|
Row(Modifier.padding(start = 24.dp, top = 8.dp)) {
|
||||||
|
AssistChip(
|
||||||
|
onClick = {
|
||||||
|
viewModel.editingMessage = null
|
||||||
|
viewModel.putDraftContent("")
|
||||||
|
},
|
||||||
|
label = {
|
||||||
|
Text(stringResource(R.string.message_field_editing_message))
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Edit,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(R.string.message_field_editing_message_cancel_alt),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.alpha(0.8f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NativeMessageField(
|
||||||
|
value = viewModel.draftContent,
|
||||||
|
onValueChange = viewModel::putDraftContent,
|
||||||
|
onAddAttachment = {
|
||||||
|
if (viewModel.activePane == ChannelScreenActivePane.AttachmentPicker) {
|
||||||
|
viewModel.activePane = ChannelScreenActivePane.None
|
||||||
|
} else {
|
||||||
|
viewModel.activePane =
|
||||||
|
ChannelScreenActivePane.AttachmentPicker
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCommitAttachment = {
|
||||||
|
processFileUri(it, null)
|
||||||
|
},
|
||||||
|
onPickEmoji = {
|
||||||
|
if (viewModel.activePane == ChannelScreenActivePane.EmojiPicker) {
|
||||||
|
viewModel.activePane = ChannelScreenActivePane.None
|
||||||
|
} else {
|
||||||
|
viewModel.activePane =
|
||||||
|
ChannelScreenActivePane.EmojiPicker
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSendMessage = viewModel::sendPendingMessage,
|
||||||
|
channelType = viewModel.channel?.channelType
|
||||||
|
?: ChannelType.TextChannel,
|
||||||
|
channelName = viewModel.channel?.name
|
||||||
|
?: stringResource(R.string.unknown),
|
||||||
|
onFocusChange = { isFocused ->
|
||||||
|
if (isFocused && viewModel.activePane != ChannelScreenActivePane.None) {
|
||||||
|
viewModel.activePane = ChannelScreenActivePane.None
|
||||||
|
imeInTransition = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
forceSendButton = viewModel.draftAttachments.isNotEmpty(),
|
||||||
|
canAttach = (channelPermissions has PermissionBit.UploadFiles) && viewModel.editingMessage == null,
|
||||||
|
serverId = viewModel.channel?.server,
|
||||||
|
channelId = channelId,
|
||||||
|
failedValidation = viewModel.draftContent.length > 2000,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 32.dp, vertical = 16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(viewModel.denyMessageFieldReasonResource),
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewModel.activePane == ChannelScreenActivePane.None && !imeInTransition) {
|
||||||
|
Spacer(
|
||||||
|
Modifier
|
||||||
|
.imePadding()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp))
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.heightIn(min = pxAsDp(fallbackKeyboardHeight))
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier.then(
|
||||||
|
if (emojiSearchFocused) {
|
||||||
|
Modifier.requiredHeight(
|
||||||
|
pxAsDp(
|
||||||
|
max(
|
||||||
|
imeCurrentInset * 2,
|
||||||
|
fallbackKeyboardHeight
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Modifier.requiredHeight(pxAsDp(fallbackKeyboardHeight))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
when (viewModel.activePane) {
|
||||||
|
ChannelScreenActivePane.EmojiPicker -> {
|
||||||
|
BackHandler(enabled = viewModel.activePane == ChannelScreenActivePane.EmojiPicker) {
|
||||||
|
viewModel.activePane = ChannelScreenActivePane.None
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(
|
||||||
|
MaterialTheme.colorScheme.surfaceColorAtElevation(
|
||||||
|
1.dp
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.padding(4.dp)
|
||||||
|
.navigationBarsPadding()
|
||||||
|
) {
|
||||||
|
EmojiPicker(
|
||||||
|
onEmojiSelected = viewModel::putAtCursorPosition,
|
||||||
|
bottomInset = pxAsDp(
|
||||||
|
max(
|
||||||
|
imeCurrentInset - navigationBarsInset,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
),
|
||||||
|
onSearchFocus = {
|
||||||
|
emojiSearchFocused = it
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ChannelScreenActivePane.AttachmentPicker -> {
|
||||||
|
BackHandler(enabled = viewModel.activePane == ChannelScreenActivePane.AttachmentPicker) {
|
||||||
|
viewModel.activePane = ChannelScreenActivePane.None
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
InbuiltMediaPicker(
|
||||||
|
onOpenDocumentsUi = {
|
||||||
|
pickFileLauncher.launch(arrayOf("*/*"))
|
||||||
|
viewModel.activePane =
|
||||||
|
ChannelScreenActivePane.None
|
||||||
|
},
|
||||||
|
onOpenCamera = {
|
||||||
|
// Create a new content URI to store the captured image.
|
||||||
|
val contentResolver =
|
||||||
|
context.contentResolver
|
||||||
|
val contentValues = ContentValues().apply {
|
||||||
|
put(
|
||||||
|
MediaStore.MediaColumns.DISPLAY_NAME,
|
||||||
|
"RVL_${System.currentTimeMillis()}.jpg"
|
||||||
|
)
|
||||||
|
put(
|
||||||
|
MediaStore.MediaColumns.MIME_TYPE,
|
||||||
|
"image/jpeg"
|
||||||
|
)
|
||||||
|
put(
|
||||||
|
MediaStore.MediaColumns.RELATIVE_PATH,
|
||||||
|
Environment.DIRECTORY_PICTURES
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
capturedPhotoUri.value =
|
||||||
|
contentResolver.insert(
|
||||||
|
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
|
||||||
|
contentValues
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
pickCameraLauncher.launch(
|
||||||
|
capturedPhotoUri.value
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
context.getString(
|
||||||
|
R.string.file_picker_chip_camera_none_installed
|
||||||
|
),
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.activePane =
|
||||||
|
ChannelScreenActivePane.None
|
||||||
|
},
|
||||||
|
onClose = {
|
||||||
|
viewModel.activePane =
|
||||||
|
ChannelScreenActivePane.None
|
||||||
|
},
|
||||||
|
onMediaSelected = { media ->
|
||||||
|
try {
|
||||||
|
processFileUri(
|
||||||
|
media.uri,
|
||||||
|
media.uri.lastPathSegment
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is FileNotFoundException) {
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
context.getString(
|
||||||
|
R.string.file_picker_cannot_attach_file_invalid
|
||||||
|
),
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pendingMedia = viewModel.draftAttachments
|
||||||
|
.filterNot { it.pickerIdentifier == null }
|
||||||
|
.map { it.pickerIdentifier!! },
|
||||||
|
modifier = Modifier
|
||||||
|
.imePadding()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
// Do nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(Modifier.imePadding())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
|
@ -116,7 +117,7 @@ fun ChannelScreenAgeGate(
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize().imePadding(),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically)
|
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically)
|
||||||
) {
|
) {
|
||||||
|
|
@ -181,7 +182,7 @@ fun ChannelScreenAgeGate(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgeGateScreenPreview() {
|
private fun AgeGateScreenPreview() {
|
||||||
ChannelScreenAgeGate(onAccept = { /*TODO*/ }, onDeny = { /*TODO*/ })
|
ChannelScreenAgeGate(onAccept = { /*TODO*/ }, onDeny = { /*TODO*/ })
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,8 @@ class ChannelScreenViewModel @Inject constructor(
|
||||||
|
|
||||||
var editingMessage by mutableStateOf<String?>(null)
|
var editingMessage by mutableStateOf<String?>(null)
|
||||||
|
|
||||||
|
var ageGateUnlocked by mutableStateOf(false)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
keyboardHeight = kvStorage.getInt("keyboardHeight") ?: 900 // reasonable default for now
|
keyboardHeight = kvStorage.getInt("keyboardHeight") ?: 900 // reasonable default for now
|
||||||
|
|
@ -110,6 +112,7 @@ class ChannelScreenViewModel @Inject constructor(
|
||||||
this.denyMessageField = false
|
this.denyMessageField = false
|
||||||
this.denyMessageFieldReasonResource = R.string.typing_blank
|
this.denyMessageFieldReasonResource = R.string.typing_blank
|
||||||
this.editingMessage = null
|
this.editingMessage = null
|
||||||
|
this.ageGateUnlocked = channel?.nsfw != true
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
draftContent = kvStorage.get("draftContent/$id") ?: ""
|
draftContent = kvStorage.get("draftContent/$id") ?: ""
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue