feat: nsfw age gate

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2024-01-01 19:57:23 +01:00
parent 171586c312
commit d8953a4eb6
6 changed files with 564 additions and 143 deletions

View File

@ -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),
)
}
}

View File

@ -305,165 +305,187 @@ fun ChannelScreen(
}
LaunchedEffect(viewModel.activeChannel, RevoltAPI.channelCache, RevoltAPI.serverCache) {
viewModel.checkShouldDenyMessageField()
viewModel.doInitialChecks()
}
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.BottomEnd
) {
LazyColumn(state = lazyListState, reverseLayout = true) {
item {
Spacer(modifier = Modifier.height(25.dp))
}
Crossfade(targetState = viewModel.showAgeGate, label = "Age gate shown/hidden") {
if (it) {
ChannelScreenAgeGate(
onAccept = {
viewModel.showAgeGate = false
},
onDeny = {
onToggleDrawer()
}
)
} else {
LazyColumn(state = lazyListState, reverseLayout = true) {
item {
Spacer(modifier = Modifier.height(25.dp))
}
items(
items = viewModel.renderableMessages,
key = { it.id!! }
) { message ->
when {
message.system != null -> SystemMessage(message)
else -> Message(
message,
parse = {
val parser = MarkdownParser()
.addRules(
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
items(
items = viewModel.renderableMessages,
key = { it.id!! }
) { message ->
when {
message.system != null -> SystemMessage(message)
else -> Message(
message,
parse = {
val parser = MarkdownParser()
.addRules(
SimpleMarkdownRules.createEscapeRule()
)
} ?: 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
.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(),
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 = {
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
onClick = {
coroutineScope.launch {
lazyListState.animateScrollToItem(0)
}
},
contentColor = MaterialTheme.colorScheme.onPrimary,
containerColor = MaterialTheme.colorScheme.primary
)
}
}
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)
)
}
}
TypingIndicator(
users = viewModel.typingUsers
)
}
}
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(
@ -550,7 +572,7 @@ fun ChannelScreen(
R.string.unknown
),
forceSendButton = viewModel.pendingAttachments.isNotEmpty(),
disabled = viewModel.pendingAttachments.isNotEmpty() && viewModel.isSendingMessage,
disabled = (viewModel.pendingAttachments.isNotEmpty() && viewModel.isSendingMessage) || viewModel.showAgeGate,
channelId = channelId,
serverId = channel?.server,
editMode = viewModel.editingMessage != null,

View File

@ -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*/ })
}

View File

@ -86,6 +86,8 @@ class ChannelScreenViewModel : ViewModel() {
var denyMessageField by mutableStateOf(false)
var denyMessageFieldReasonResource by mutableIntStateOf(R.string.message_field_denied_generic)
var showAgeGate by mutableStateOf(false)
private fun popAttachmentBatch() {
pendingAttachments =
pendingAttachments.drop(MAX_ATTACHMENTS_PER_MESSAGE).toMutableStateList()
@ -518,7 +520,17 @@ class ChannelScreenViewModel : ViewModel() {
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
val selfUser = RevoltAPI.userCache[RevoltAPI.selfId] ?: return

View File

@ -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>

View File

@ -138,6 +138,19 @@
<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="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="direct_messages">Direct Messages</string>