diff --git a/app/src/main/java/chat/revolt/api/routes/channel/Channel.kt b/app/src/main/java/chat/revolt/api/routes/channel/Channel.kt index 1a131442..898456d5 100644 --- a/app/src/main/java/chat/revolt/api/routes/channel/Channel.kt +++ b/app/src/main/java/chat/revolt/api/routes/channel/Channel.kt @@ -17,6 +17,7 @@ import io.ktor.client.request.setBody import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType import io.ktor.http.contentType +import kotlinx.serialization.SerialName import kotlinx.serialization.SerializationException import kotlinx.serialization.builtins.ListSerializer @@ -78,7 +79,19 @@ data class EditMessageBody( val content: String? ) -suspend fun sendMessage( +@kotlinx.serialization.Serializable +data class CreateInviteResponse( + val type: String, + @SerialName("_id") + val id: String, + val server: String, + val creator: String, + val channel: String, +) + +suspend + +fun sendMessage( channelId: String, content: String, nonce: String? = ULID.makeNext(), @@ -143,3 +156,13 @@ suspend fun fetchGroupParticipants(channelId: String): List { response ) } + +suspend fun createInvite(channelId: String): CreateInviteResponse { + val response = RevoltHttp.post("/channels/$channelId/invites") + .bodyAsText() + + val error = RevoltJson.decodeFromString(RevoltError.serializer(), response) + if (error.type != "Server") throw Error(error.type) + + return RevoltJson.decodeFromString(CreateInviteResponse.serializer(), response) +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/screens/chat/dialogs/InviteDialog.kt b/app/src/main/java/chat/revolt/screens/chat/dialogs/InviteDialog.kt new file mode 100644 index 00000000..afb924a4 --- /dev/null +++ b/app/src/main/java/chat/revolt/screens/chat/dialogs/InviteDialog.kt @@ -0,0 +1,195 @@ +package chat.revolt.screens.chat.dialogs + +import android.net.Uri +import android.widget.Toast +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +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.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import chat.revolt.R +import chat.revolt.api.REVOLT_INVITES +import chat.revolt.api.RevoltAPI +import chat.revolt.api.routes.channel.createInvite +import chat.revolt.internals.Platform + +private val inviteChars = ('a'..'z') + ('A'..'Z') + ('0'..'9') + +private fun placeholderInviteCode(): String { + return (1..8) + .map { inviteChars.random() } + .joinToString("") +} + +data class InviteCodeChar( + val char: Char, + val isActual: Boolean +) + +operator fun InviteCodeChar.compareTo(other: InviteCodeChar): Int { + return char.compareTo(other.char) +} + +@Composable +fun InviteDialog(channelId: String, onDismissRequest: () -> Unit) { + val channel = RevoltAPI.channelCache[channelId] + + if (channel == null) { + onDismissRequest() + return + } + + var isActual by remember { mutableStateOf(false) } + var inviteCode by remember { mutableStateOf(placeholderInviteCode()) } + + val invitePrefixOpacity by animateFloatAsState( + targetValue = if (isActual) 1f else 0f, + label = "Invite prefix opacity" + ) + + val clipboardManager = LocalClipboardManager.current + val context = LocalContext.current + + LaunchedEffect(Unit) { + try { + val invite = createInvite(channelId) + isActual = true + inviteCode = invite.id + } catch (e: Error) { + isActual = true + inviteCode = "error" + } + } + + BoxWithConstraints { + Column( + modifier = Modifier + .clip(MaterialTheme.shapes.large) + .background(MaterialTheme.colorScheme.surface) + .padding(24.dp) + .width(maxWidth * 0.85f) + .heightIn(max = maxHeight * 0.85f) + ) { + Text( + stringResource( + R.string.invite_dialog_header, + channel.name ?: stringResource(R.string.unknown) + ), + style = MaterialTheme.typography.headlineMedium + ) + + Spacer(Modifier.height(16.dp)) + + Text( + (Uri.parse(REVOLT_INVITES).host ?: "rvlt.gg") + "/", + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .alpha(invitePrefixOpacity) + ) + + Row( + modifier = Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + inviteCode + .map { InviteCodeChar(it, isActual) } + .forEach { + AnimatedContent( + targetState = it, + transitionSpec = { + if (targetState > initialState) { + slideInVertically { -it } togetherWith slideOutVertically { it } + } else { + slideInVertically { it } togetherWith slideOutVertically { -it } + } + }, + label = "Invite code char" + ) { state -> + Text( + state.char.toString(), + style = MaterialTheme.typography.displayLarge, + fontFamily = FontFamily(Font(R.font.jetbrainsmono_regular)), + modifier = Modifier + .alpha(if (state.isActual) 1f else 0f) + ) + } + } + } + + Spacer(Modifier.height(16.dp)) + + Text( + stringResource(R.string.invite_dialog_description), + style = MaterialTheme.typography.bodyMedium + ) + + Spacer(Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = { + onDismissRequest() + }) { + Text(stringResource(R.string.invite_dialog_close)) + } + Spacer(Modifier.width(8.dp)) + Button(onClick = { + clipboardManager.setText(AnnotatedString.Builder().apply { + append(REVOLT_INVITES) + append("/") + append(inviteCode) + }.toAnnotatedString()) + + if (Platform.needsShowClipboardNotification()) { + Toast.makeText( + context, + context.getString(R.string.copied), + Toast.LENGTH_SHORT + ).show() + } + }) { + Text(stringResource(R.string.invite_dialog_copy)) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/sheets/ChannelInfoSheet.kt b/app/src/main/java/chat/revolt/sheets/ChannelInfoSheet.kt index c0036714..a0c4c361 100644 --- a/app/src/main/java/chat/revolt/sheets/ChannelInfoSheet.kt +++ b/app/src/main/java/chat/revolt/sheets/ChannelInfoSheet.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog import chat.revolt.R import chat.revolt.api.RevoltAPI import chat.revolt.api.internals.ChannelUtils @@ -37,12 +38,14 @@ import chat.revolt.api.internals.has import chat.revolt.api.schemas.ChannelType import chat.revolt.components.generic.SheetClickable import chat.revolt.components.screens.chat.ChannelSheetHeader +import chat.revolt.screens.chat.dialogs.InviteDialog @OptIn(ExperimentalMaterial3Api::class) @Composable fun ChannelInfoSheet(channelId: String) { val channel = RevoltAPI.channelCache[channelId] var memberListSheetShown by remember { mutableStateOf(false) } + var inviteDialogShown by remember { mutableStateOf(false) } if (memberListSheetShown) { val memberListSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -60,6 +63,21 @@ fun ChannelInfoSheet(channelId: String) { } } + if (inviteDialogShown) { + Dialog( + onDismissRequest = { + inviteDialogShown = false + } + ) { + InviteDialog( + channelId = channelId, + onDismissRequest = { + inviteDialogShown = false + } + ) + } + } + if (channel == null) { Box( modifier = Modifier @@ -167,6 +185,7 @@ fun ChannelInfoSheet(channelId: String) { ) } ) { + inviteDialogShown = true } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7de97d98..b310b9bf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -239,6 +239,11 @@ Add members Manage notifications + Invite to #%1$s + Send this link to invite people to this channel. + Copy + Close + Copy Message is empty, nothing to copy Reply