parent
171586c312
commit
d8953a4eb6
|
|
@ -0,0 +1,177 @@
|
||||||
|
package chat.revolt.components.generic
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusDirection
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import chat.revolt.R
|
||||||
|
|
||||||
|
sealed class DobElement {
|
||||||
|
data object Day : DobElement()
|
||||||
|
data object Month : DobElement()
|
||||||
|
data object Year : DobElement()
|
||||||
|
}
|
||||||
|
|
||||||
|
object DobRegion {
|
||||||
|
val American = listOf(DobElement.Month, DobElement.Day, DobElement.Year) to "/"
|
||||||
|
val British = listOf(DobElement.Day, DobElement.Month, DobElement.Year) to "/"
|
||||||
|
val European = listOf(DobElement.Day, DobElement.Month, DobElement.Year) to "."
|
||||||
|
val ISO2601 = listOf(DobElement.Year, DobElement.Month, DobElement.Day) to "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DobPicker(
|
||||||
|
dayValue: String,
|
||||||
|
monthValue: String,
|
||||||
|
yearValue: String,
|
||||||
|
onDayChange: (String) -> Unit,
|
||||||
|
onMonthChange: (String) -> Unit,
|
||||||
|
onYearChange: (String) -> Unit,
|
||||||
|
order: List<DobElement> = DobRegion.European.first,
|
||||||
|
separator: String = DobRegion.European.second,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
order.forEach { element ->
|
||||||
|
when (element) {
|
||||||
|
DobElement.Day -> DobPickerDay(dayValue, onDayChange)
|
||||||
|
DobElement.Month -> DobPickerMonth(monthValue, onMonthChange)
|
||||||
|
DobElement.Year -> DobPickerYear(yearValue, onYearChange)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element != order.last()) {
|
||||||
|
Text(
|
||||||
|
text = separator,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.offset(y = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DobPickerDay(
|
||||||
|
dayValue: String,
|
||||||
|
onDayChange: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.channel_age_gate_dob_day),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = dayValue,
|
||||||
|
onValueChange = {
|
||||||
|
if (it.length <= 2) {
|
||||||
|
onDayChange(it)
|
||||||
|
} else {
|
||||||
|
focusManager.moveFocus(FocusDirection.Next)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (it.length == 2) {
|
||||||
|
focusManager.moveFocus(FocusDirection.Next)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
fontFeatureSettings = "tnum",
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
),
|
||||||
|
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number),
|
||||||
|
modifier = Modifier.width(56.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DobPickerMonth(
|
||||||
|
monthValue: String,
|
||||||
|
onMonthChange: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
|
||||||
|
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.channel_age_gate_dob_month),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = monthValue,
|
||||||
|
onValueChange = {
|
||||||
|
if (it.length <= 2) {
|
||||||
|
onMonthChange(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (it.length == 2) {
|
||||||
|
focusManager.moveFocus(FocusDirection.Next)
|
||||||
|
} else if (it.isEmpty()) {
|
||||||
|
focusManager.moveFocus(FocusDirection.Previous)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
fontFeatureSettings = "tnum",
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
),
|
||||||
|
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number),
|
||||||
|
modifier = Modifier.width(56.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DobPickerYear(
|
||||||
|
yearValue: String,
|
||||||
|
onYearChange: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.channel_age_gate_dob_year),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = yearValue,
|
||||||
|
onValueChange = {
|
||||||
|
onYearChange(it)
|
||||||
|
if (it.isEmpty()) {
|
||||||
|
focusManager.moveFocus(FocusDirection.Previous)
|
||||||
|
} else if (it.length == 4) {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
fontFeatureSettings = "tnum",
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
),
|
||||||
|
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number),
|
||||||
|
modifier = Modifier.width(78.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -305,165 +305,187 @@ fun ChannelScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(viewModel.activeChannel, RevoltAPI.channelCache, RevoltAPI.serverCache) {
|
LaunchedEffect(viewModel.activeChannel, RevoltAPI.channelCache, RevoltAPI.serverCache) {
|
||||||
viewModel.checkShouldDenyMessageField()
|
viewModel.doInitialChecks()
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
contentAlignment = Alignment.BottomEnd
|
contentAlignment = Alignment.BottomEnd
|
||||||
) {
|
) {
|
||||||
LazyColumn(state = lazyListState, reverseLayout = true) {
|
Crossfade(targetState = viewModel.showAgeGate, label = "Age gate shown/hidden") {
|
||||||
item {
|
if (it) {
|
||||||
Spacer(modifier = Modifier.height(25.dp))
|
ChannelScreenAgeGate(
|
||||||
}
|
onAccept = {
|
||||||
|
viewModel.showAgeGate = false
|
||||||
|
},
|
||||||
|
onDeny = {
|
||||||
|
onToggleDrawer()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LazyColumn(state = lazyListState, reverseLayout = true) {
|
||||||
|
item {
|
||||||
|
Spacer(modifier = Modifier.height(25.dp))
|
||||||
|
}
|
||||||
|
|
||||||
items(
|
items(
|
||||||
items = viewModel.renderableMessages,
|
items = viewModel.renderableMessages,
|
||||||
key = { it.id!! }
|
key = { it.id!! }
|
||||||
) { message ->
|
) { message ->
|
||||||
when {
|
when {
|
||||||
message.system != null -> SystemMessage(message)
|
message.system != null -> SystemMessage(message)
|
||||||
else -> Message(
|
else -> Message(
|
||||||
message,
|
message,
|
||||||
parse = {
|
parse = {
|
||||||
val parser = MarkdownParser()
|
val parser = MarkdownParser()
|
||||||
.addRules(
|
.addRules(
|
||||||
SimpleMarkdownRules.createEscapeRule()
|
SimpleMarkdownRules.createEscapeRule()
|
||||||
)
|
|
||||||
.addRevoltRules(context)
|
|
||||||
.addRules(
|
|
||||||
createCodeRule(context, codeBlockColor.toArgb()),
|
|
||||||
createInlineCodeRule(context, codeBlockColor.toArgb())
|
|
||||||
)
|
|
||||||
.addRules(
|
|
||||||
SimpleMarkdownRules.createSimpleMarkdownRules(
|
|
||||||
includeEscapeRule = false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
SimpleRenderer.render(
|
|
||||||
source = it.content ?: "",
|
|
||||||
parser = parser,
|
|
||||||
initialState = MarkdownState(0),
|
|
||||||
renderContext = MarkdownContext(
|
|
||||||
memberMap = viewModel.activeChannel?.server?.let { serverId ->
|
|
||||||
RevoltAPI.members.markdownMemberMapFor(
|
|
||||||
serverId
|
|
||||||
)
|
)
|
||||||
} ?: mapOf(),
|
.addRevoltRules(context)
|
||||||
userMap = RevoltAPI.userCache.toMap(),
|
.addRules(
|
||||||
channelMap = RevoltAPI.channelCache.mapValues { ch ->
|
createCodeRule(context, codeBlockColor.toArgb()),
|
||||||
ch.value.name ?: ch.value.id ?: "#DeletedChannel"
|
createInlineCodeRule(
|
||||||
},
|
context,
|
||||||
emojiMap = RevoltAPI.emojiCache,
|
codeBlockColor.toArgb()
|
||||||
serverId = channel?.server ?: "",
|
)
|
||||||
// check if message consists solely of one *or more* custom emotes
|
)
|
||||||
useLargeEmojis = it.content?.matches(
|
.addRules(
|
||||||
Regex("(:([0-9A-Z]{26}):)+")
|
SimpleMarkdownRules.createSimpleMarkdownRules(
|
||||||
) == true
|
includeEscapeRule = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
SimpleRenderer.render(
|
||||||
|
source = it.content ?: "",
|
||||||
|
parser = parser,
|
||||||
|
initialState = MarkdownState(0),
|
||||||
|
renderContext = MarkdownContext(
|
||||||
|
memberMap = viewModel.activeChannel?.server?.let { serverId ->
|
||||||
|
RevoltAPI.members.markdownMemberMapFor(
|
||||||
|
serverId
|
||||||
|
)
|
||||||
|
} ?: mapOf(),
|
||||||
|
userMap = RevoltAPI.userCache.toMap(),
|
||||||
|
channelMap = RevoltAPI.channelCache.mapValues { ch ->
|
||||||
|
ch.value.name ?: ch.value.id
|
||||||
|
?: "#DeletedChannel"
|
||||||
|
},
|
||||||
|
emojiMap = RevoltAPI.emojiCache,
|
||||||
|
serverId = channel?.server ?: "",
|
||||||
|
// check if message consists solely of one *or more* custom emotes
|
||||||
|
useLargeEmojis = it.content?.matches(
|
||||||
|
Regex("(:([0-9A-Z]{26}):)+")
|
||||||
|
) == true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onMessageContextMenu = {
|
||||||
|
messageContextSheetShown = true
|
||||||
|
messageContextSheetTarget = message.id ?: ""
|
||||||
|
},
|
||||||
|
onAvatarClick = {
|
||||||
|
message.author?.let { author ->
|
||||||
|
onUserSheetOpenFor(author, channel?.server)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onNameClick = {
|
||||||
|
val author = message.author?.let { RevoltAPI.userCache[it] }
|
||||||
|
?: return@Message
|
||||||
|
viewModel.putAtCursorPosition("@${author.username}#${author.discriminator}")
|
||||||
|
},
|
||||||
|
canReply = true,
|
||||||
|
onReply = {
|
||||||
|
if (viewModel.pendingReplies.size >= 5) {
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
context.getString(R.string.too_many_replies, 5),
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
return@Message
|
||||||
|
}
|
||||||
|
viewModel.replyToMessage(message)
|
||||||
|
},
|
||||||
|
onAddReaction = {
|
||||||
|
message.id?.let {
|
||||||
|
reactSheetShown = true
|
||||||
|
reactSheetTarget = it
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
if (viewModel.hasNoMoreMessages) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.start_of_conversation),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(
|
||||||
|
start = 8.dp,
|
||||||
|
end = 8.dp,
|
||||||
|
top = 64.dp,
|
||||||
|
bottom = 32.dp
|
||||||
|
)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(16.dp)
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
androidx.compose.animation.AnimatedVisibility(
|
||||||
|
!isScrolledToBottom.value,
|
||||||
|
enter = slideInHorizontally(
|
||||||
|
animationSpec = RevoltTweenInt,
|
||||||
|
initialOffsetX = { it }
|
||||||
|
) + fadeIn(animationSpec = RevoltTweenFloat),
|
||||||
|
exit = slideOutHorizontally(
|
||||||
|
animationSpec = RevoltTweenInt,
|
||||||
|
targetOffsetX = { it }
|
||||||
|
) + fadeOut(animationSpec = RevoltTweenFloat),
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
) {
|
||||||
|
ExtendedFloatingActionButton(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(bottom = scrollDownFABPadding)
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.padding(16.dp),
|
||||||
|
text = {
|
||||||
|
Text(stringResource(R.string.scroll_to_bottom))
|
||||||
|
},
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.KeyboardArrowDown,
|
||||||
|
contentDescription = stringResource(R.string.scroll_to_bottom)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onMessageContextMenu = {
|
onClick = {
|
||||||
messageContextSheetShown = true
|
coroutineScope.launch {
|
||||||
messageContextSheetTarget = message.id ?: ""
|
lazyListState.animateScrollToItem(0)
|
||||||
},
|
|
||||||
onAvatarClick = {
|
|
||||||
message.author?.let { author ->
|
|
||||||
onUserSheetOpenFor(author, channel?.server)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onNameClick = {
|
|
||||||
val author = message.author?.let { RevoltAPI.userCache[it] }
|
|
||||||
?: return@Message
|
|
||||||
viewModel.putAtCursorPosition("@${author.username}#${author.discriminator}")
|
|
||||||
},
|
|
||||||
canReply = true,
|
|
||||||
onReply = {
|
|
||||||
if (viewModel.pendingReplies.size >= 5) {
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
context.getString(R.string.too_many_replies, 5),
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
return@Message
|
|
||||||
}
|
|
||||||
viewModel.replyToMessage(message)
|
|
||||||
},
|
|
||||||
onAddReaction = {
|
|
||||||
message.id?.let {
|
|
||||||
reactSheetShown = true
|
|
||||||
reactSheetTarget = it
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
item {
|
TypingIndicator(
|
||||||
if (viewModel.hasNoMoreMessages) {
|
users = viewModel.typingUsers
|
||||||
Text(
|
)
|
||||||
text = stringResource(R.string.start_of_conversation),
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(start = 8.dp, end = 8.dp, top = 64.dp, bottom = 32.dp)
|
|
||||||
.fillMaxWidth(),
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(16.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
androidx.compose.animation.AnimatedVisibility(
|
|
||||||
!isScrolledToBottom.value,
|
|
||||||
enter = slideInHorizontally(
|
|
||||||
animationSpec = RevoltTweenInt,
|
|
||||||
initialOffsetX = { it }
|
|
||||||
) + fadeIn(animationSpec = RevoltTweenFloat),
|
|
||||||
exit = slideOutHorizontally(
|
|
||||||
animationSpec = RevoltTweenInt,
|
|
||||||
targetOffsetX = { it }
|
|
||||||
) + fadeOut(animationSpec = RevoltTweenFloat),
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomEnd)
|
|
||||||
) {
|
|
||||||
ExtendedFloatingActionButton(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(bottom = scrollDownFABPadding)
|
|
||||||
.align(Alignment.BottomEnd)
|
|
||||||
.padding(16.dp),
|
|
||||||
text = {
|
|
||||||
Text(stringResource(R.string.scroll_to_bottom))
|
|
||||||
},
|
|
||||||
icon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.KeyboardArrowDown,
|
|
||||||
contentDescription = stringResource(R.string.scroll_to_bottom)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
coroutineScope.launch {
|
|
||||||
lazyListState.animateScrollToItem(0)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
containerColor = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
TypingIndicator(
|
|
||||||
users = viewModel.typingUsers
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
|
|
@ -550,7 +572,7 @@ fun ChannelScreen(
|
||||||
R.string.unknown
|
R.string.unknown
|
||||||
),
|
),
|
||||||
forceSendButton = viewModel.pendingAttachments.isNotEmpty(),
|
forceSendButton = viewModel.pendingAttachments.isNotEmpty(),
|
||||||
disabled = viewModel.pendingAttachments.isNotEmpty() && viewModel.isSendingMessage,
|
disabled = (viewModel.pendingAttachments.isNotEmpty() && viewModel.isSendingMessage) || viewModel.showAgeGate,
|
||||||
channelId = channelId,
|
channelId = channelId,
|
||||||
serverId = channel?.server,
|
serverId = channel?.server,
|
||||||
editMode = viewModel.editingMessage != null,
|
editMode = viewModel.editingMessage != null,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,188 @@
|
||||||
|
package chat.revolt.screens.chat.views.channel
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
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.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.intl.Locale
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import chat.revolt.R
|
||||||
|
import chat.revolt.components.generic.DobPicker
|
||||||
|
import chat.revolt.components.generic.DobRegion
|
||||||
|
import kotlinx.datetime.Clock
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.UtcOffset
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChannelScreenAgeGate(
|
||||||
|
onAccept: () -> Unit,
|
||||||
|
onDeny: () -> Unit
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
var dayValue by remember { mutableStateOf("") }
|
||||||
|
var monthValue by remember { mutableStateOf("") }
|
||||||
|
var yearValue by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
var dobPickerError by remember { mutableStateOf("") }
|
||||||
|
var dobValid by remember { mutableStateOf(false) }
|
||||||
|
var dobPickerRegion by remember { mutableStateOf(DobRegion.European) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
dobPickerRegion = when (Locale.current.region) {
|
||||||
|
"US" -> DobRegion.American
|
||||||
|
"GB" -> DobRegion.British
|
||||||
|
"JP", "KR", "CN" -> DobRegion.ISO2601
|
||||||
|
else -> DobRegion.European
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(dayValue, monthValue, yearValue) {
|
||||||
|
dobValid = false
|
||||||
|
if (dayValue.isNotBlank() && monthValue.isNotBlank() && yearValue.isNotBlank()) {
|
||||||
|
val day = dayValue.toIntOrNull()
|
||||||
|
val month = monthValue.toIntOrNull()
|
||||||
|
val year = yearValue.toIntOrNull()
|
||||||
|
|
||||||
|
// Invalid condition
|
||||||
|
if (day == null || month == null || year == null) {
|
||||||
|
dobPickerError = context.getString(R.string.channel_age_gate_dob_invalid)
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
|
||||||
|
val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
|
||||||
|
val then: LocalDateTime
|
||||||
|
|
||||||
|
// Invalid date condition
|
||||||
|
try {
|
||||||
|
then = LocalDateTime(year, month, day, 0, 0, 0)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
dobPickerError = context.getString(R.string.channel_age_gate_dob_invalid)
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
|
||||||
|
// Too young condition (< 18)
|
||||||
|
val diff =
|
||||||
|
now.toInstant(UtcOffset.ZERO).epochSeconds - then.toInstant(UtcOffset.ZERO).epochSeconds
|
||||||
|
if (diff < 568025136) {
|
||||||
|
dobPickerError = context.getString(R.string.channel_age_gate_dob_too_young)
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
|
||||||
|
// Born in future condition
|
||||||
|
if (then.toInstant(UtcOffset.ZERO).epochSeconds > now.toInstant(UtcOffset.ZERO).epochSeconds) {
|
||||||
|
dobPickerError = context.getString(R.string.channel_age_gate_dob_invalid_future)
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
|
||||||
|
// Born before oldest person condition
|
||||||
|
// Update from https://en.wikipedia.org/wiki/List_of_oldest_living_people
|
||||||
|
val minYob = 1907
|
||||||
|
if (year < minYob) {
|
||||||
|
dobPickerError =
|
||||||
|
context.getString(R.string.channel_age_gate_dob_invalid_unlikely, minYob)
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success condition
|
||||||
|
dobPickerError = ""
|
||||||
|
dobValid = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_comment_alert_24dp),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.channel_age_gate_title),
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.channel_age_gate_description),
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = dobPickerError,
|
||||||
|
style = MaterialTheme.typography.labelMedium.copy(
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.channel_age_gate_dob_section),
|
||||||
|
style = MaterialTheme.typography.labelLarge
|
||||||
|
)
|
||||||
|
|
||||||
|
DobPicker(
|
||||||
|
dayValue = dayValue,
|
||||||
|
monthValue = monthValue,
|
||||||
|
yearValue = yearValue,
|
||||||
|
onDayChange = { dayValue = it },
|
||||||
|
onMonthChange = { monthValue = it },
|
||||||
|
onYearChange = { yearValue = it },
|
||||||
|
order = dobPickerRegion.first,
|
||||||
|
separator = dobPickerRegion.second,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(0.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||||
|
) {
|
||||||
|
Button(onClick = { onDeny() }) {
|
||||||
|
Text(stringResource(R.string.channel_age_gate_dob_cancel))
|
||||||
|
}
|
||||||
|
|
||||||
|
TextButton(onClick = { onAccept() }, enabled = dobValid) {
|
||||||
|
Text(stringResource(R.string.channel_age_gate_dob_proceed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun AgeGateScreenPreview() {
|
||||||
|
ChannelScreenAgeGate(onAccept = { /*TODO*/ }, onDeny = { /*TODO*/ })
|
||||||
|
}
|
||||||
|
|
@ -86,6 +86,8 @@ class ChannelScreenViewModel : ViewModel() {
|
||||||
var denyMessageField by mutableStateOf(false)
|
var denyMessageField by mutableStateOf(false)
|
||||||
var denyMessageFieldReasonResource by mutableIntStateOf(R.string.message_field_denied_generic)
|
var denyMessageFieldReasonResource by mutableIntStateOf(R.string.message_field_denied_generic)
|
||||||
|
|
||||||
|
var showAgeGate by mutableStateOf(false)
|
||||||
|
|
||||||
private fun popAttachmentBatch() {
|
private fun popAttachmentBatch() {
|
||||||
pendingAttachments =
|
pendingAttachments =
|
||||||
pendingAttachments.drop(MAX_ATTACHMENTS_PER_MESSAGE).toMutableStateList()
|
pendingAttachments.drop(MAX_ATTACHMENTS_PER_MESSAGE).toMutableStateList()
|
||||||
|
|
@ -518,7 +520,17 @@ class ChannelScreenViewModel : ViewModel() {
|
||||||
currentSelection.first + content.length to currentSelection.first + content.length
|
currentSelection.first + content.length to currentSelection.first + content.length
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun checkShouldDenyMessageField() {
|
suspend fun doInitialChecks() {
|
||||||
|
checkShouldShowAgeGate()
|
||||||
|
checkShouldDenyMessageField()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkShouldShowAgeGate() {
|
||||||
|
if (activeChannel == null) return
|
||||||
|
showAgeGate = activeChannel!!.nsfw == true
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun checkShouldDenyMessageField() {
|
||||||
if (activeChannel == null) return
|
if (activeChannel == null) return
|
||||||
|
|
||||||
val selfUser = RevoltAPI.userCache[RevoltAPI.selfId] ?: return
|
val selfUser = RevoltAPI.userCache[RevoltAPI.selfId] ?: return
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:height="24dp"
|
||||||
|
android:width="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#ffffff"
|
||||||
|
android:pathData="M9 22C8.4 22 8 21.6 8 21V18H4C2.9 18 2 17.1 2 16V4C2 2.9 2.9 2 4 2H20C21.1 2 22 2.9 22 4V16C22 17.1 21.1 18 20 18H13.9L10.2 21.7C10 21.9 9.8 22 9.5 22H9M13 11V5H11V11M13 15V13H11V15H13Z" />
|
||||||
|
</vector>
|
||||||
|
|
@ -138,6 +138,19 @@
|
||||||
<string name="no_active_channel">You\'re not in a channel right now.</string>
|
<string name="no_active_channel">You\'re not in a channel right now.</string>
|
||||||
<string name="no_active_channel_body">Select a server from the left to get started. If you\'re feeling adventurous, you can create a new server.</string>
|
<string name="no_active_channel_body">Select a server from the left to get started. If you\'re feeling adventurous, you can create a new server.</string>
|
||||||
|
|
||||||
|
<string name="channel_age_gate_title">This channel is marked as NSFW</string>
|
||||||
|
<string name="channel_age_gate_description">You must be 18 or older to view this channel.</string>
|
||||||
|
<string name="channel_age_gate_dob_section">Date of birth</string>
|
||||||
|
<string name="channel_age_gate_dob_day">Day</string>
|
||||||
|
<string name="channel_age_gate_dob_month">Month</string>
|
||||||
|
<string name="channel_age_gate_dob_year">Year</string>
|
||||||
|
<string name="channel_age_gate_dob_proceed">Proceed</string>
|
||||||
|
<string name="channel_age_gate_dob_cancel">Cancel</string>
|
||||||
|
<string name="channel_age_gate_dob_invalid">Enter your date of birth</string>
|
||||||
|
<string name="channel_age_gate_dob_too_young">You must be 18 or older to view this channel.</string>
|
||||||
|
<string name="channel_age_gate_dob_invalid_future">You can\'t be born in the future.</string>
|
||||||
|
<string name="channel_age_gate_dob_invalid_unlikely">You can\'t be born before %1$d.</string>
|
||||||
|
|
||||||
<string name="avatar_alt">%1$s\'s avatar</string>
|
<string name="avatar_alt">%1$s\'s avatar</string>
|
||||||
|
|
||||||
<string name="direct_messages">Direct Messages</string>
|
<string name="direct_messages">Direct Messages</string>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue