From d8953a4eb67ad26e4f62df04502101737b5b375a Mon Sep 17 00:00:00 2001 From: Infi Date: Mon, 1 Jan 2024 19:57:23 +0100 Subject: [PATCH] feat: nsfw age gate Signed-off-by: Infi --- .../revolt/components/generic/DobPicker.kt | 177 ++++++++++ .../chat/views/channel/ChannelScreen.kt | 306 ++++++++++-------- .../views/channel/ChannelScreenAgeGate.kt | 188 +++++++++++ .../views/channel/ChannelScreenViewModel.kt | 14 +- .../res/drawable/ic_comment_alert_24dp.xml | 9 + app/src/main/res/values/strings.xml | 13 + 6 files changed, 564 insertions(+), 143 deletions(-) create mode 100644 app/src/main/java/chat/revolt/components/generic/DobPicker.kt create mode 100644 app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreenAgeGate.kt create mode 100644 app/src/main/res/drawable/ic_comment_alert_24dp.xml diff --git a/app/src/main/java/chat/revolt/components/generic/DobPicker.kt b/app/src/main/java/chat/revolt/components/generic/DobPicker.kt new file mode 100644 index 00000000..c1669700 --- /dev/null +++ b/app/src/main/java/chat/revolt/components/generic/DobPicker.kt @@ -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 = 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), + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt index 349109f2..c8d365a6 100644 --- a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt @@ -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, diff --git a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreenAgeGate.kt b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreenAgeGate.kt new file mode 100644 index 00000000..906dc1cf --- /dev/null +++ b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreenAgeGate.kt @@ -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*/ }) +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreenViewModel.kt b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreenViewModel.kt index 327e6308..b755e959 100644 --- a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreenViewModel.kt +++ b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreenViewModel.kt @@ -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 diff --git a/app/src/main/res/drawable/ic_comment_alert_24dp.xml b/app/src/main/res/drawable/ic_comment_alert_24dp.xml new file mode 100644 index 00000000..91ff8abf --- /dev/null +++ b/app/src/main/res/drawable/ic_comment_alert_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 74693d27..7088bfab 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -138,6 +138,19 @@ You\'re not in a channel right now. Select a server from the left to get started. If you\'re feeling adventurous, you can create a new server. + This channel is marked as NSFW + You must be 18 or older to view this channel. + Date of birth + Day + Month + Year + Proceed + Cancel + Enter your date of birth + You must be 18 or older to view this channel. + You can\'t be born in the future. + You can\'t be born before %1$d. + %1$s\'s avatar Direct Messages