feat: profile settings (set pfp and background)

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2023-10-31 20:07:12 +01:00
parent fe024004cd
commit 771fc74cc1
12 changed files with 607 additions and 16 deletions

View File

@ -191,7 +191,6 @@ dependencies {
// Accompanist - Jetpack Compose Extensions
implementation "com.google.accompanist:accompanist-systemuicontroller:$accompanist_version"
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
implementation "com.google.accompanist:accompanist-flowlayout:$accompanist_version"
// KTOR - HTTP+WebSocket Library
implementation "io.ktor:ktor-client-core:$ktor_version"

View File

@ -43,6 +43,7 @@ import chat.revolt.screens.settings.AppearanceSettingsScreen
import chat.revolt.screens.settings.ChangelogsSettingsScreen
import chat.revolt.screens.settings.ClosedBetaUpdaterScreen
import chat.revolt.screens.settings.DebugSettingsScreen
import chat.revolt.screens.settings.ProfileSettingsScreen
import chat.revolt.screens.settings.SessionSettingsScreen
import chat.revolt.screens.settings.SettingsScreen
import chat.revolt.ui.theme.RevoltTheme
@ -143,6 +144,7 @@ fun AppEntrypoint(windowSizeClass: WindowSizeClass) {
composable("chat") { ChatRouterScreen(navController, windowSizeClass) }
composable("settings") { SettingsScreen(navController) }
composable("settings/profile") { ProfileSettingsScreen(navController) }
composable("settings/sessions") { SessionSettingsScreen(navController) }
composable("settings/appearance") { AppearanceSettingsScreen(navController) }
composable("settings/debug") { DebugSettingsScreen(navController) }

View File

@ -259,6 +259,13 @@ object RealtimeSocket {
val existing = RevoltAPI.userCache[userUpdateFrame.id]
?: return // if we don't have the user no point in updating it
if (userUpdateFrame.clear != null) {
if (userUpdateFrame.clear.contains("Avatar")) {
RevoltAPI.userCache[userUpdateFrame.id] =
existing.copy(avatar = null)
}
}
RevoltAPI.userCache[userUpdateFrame.id] =
existing.mergeWithPartial(userUpdateFrame.data)
}

View File

@ -62,6 +62,9 @@ suspend fun uploadToAutumn(
val error = RevoltJson.decodeFromString(AutumnError.serializer(), response.bodyAsText())
throw Exception(error.type)
} catch (e: Exception) {
if (response.status.value == 429) {
throw Exception("Rate limited")
}
throw Exception("Unknown error")
}
}

View File

@ -14,6 +14,7 @@ import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.contentType
import kotlinx.serialization.SerializationException
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.JsonElement
@ -41,12 +42,47 @@ suspend fun fetchSelf(): User {
return user
}
suspend fun patchSelf(status: Status? = null, pure: Boolean = false) {
suspend fun patchSelf(
status: Status? = null,
avatar: String? = null,
background: String? = null,
bio: String? = null,
remove: List<String>? = null,
pure: Boolean = false
) {
val body = mutableMapOf<String, JsonElement>()
if (status != null) {
body["status"] = RevoltJson.encodeToJsonElement(Status.serializer(), status)
}
if (avatar != null) {
body["avatar"] = RevoltJson.encodeToJsonElement(String.serializer(), avatar)
}
if (background != null || bio != null) {
val profileMap = mutableMapOf<String, String>()
if (background != null) {
profileMap["background"] = background
}
if (bio != null) {
profileMap["bio"] = bio
}
body["profile"] = RevoltJson.encodeToJsonElement(
MapSerializer(
String.serializer(),
String.serializer()
),
profileMap
)
}
if (remove != null) {
body["remove"] = RevoltJson.encodeToJsonElement(ListSerializer(String.serializer()), remove)
}
val response = RevoltHttp.patch("/users/@me") {
contentType(ContentType.Application.Json)
setBody(
@ -122,4 +158,4 @@ suspend fun fetchUserProfile(id: String): Profile {
}
return RevoltJson.decodeFromString(Profile.serializer(), response)
}
}

View File

@ -0,0 +1,179 @@
package chat.revolt.components.generic
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
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.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import chat.revolt.R
import com.bumptech.glide.integration.compose.CrossFade
import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi
import com.bumptech.glide.integration.compose.GlideImage
@Composable
fun InlineMediaPicker(
currentModel: Any?,
mimeType: String = "image/*",
circular: Boolean = false,
onPick: (Uri) -> Unit,
canRemove: Boolean = true,
onRemove: () -> Unit = {}
) {
if (circular) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
InlineMediaPickerMediaPicker(
currentModel = currentModel,
mimeType = mimeType,
circular = true,
onPick = onPick
)
if (canRemove) {
Spacer(modifier = Modifier.width(8.dp))
IconButton(
onClick = {
onRemove()
},
enabled = currentModel != null
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.inline_media_picker_remove)
)
}
}
}
} else {
Column {
InlineMediaPickerMediaPicker(
currentModel = currentModel,
mimeType = mimeType,
circular = false,
onPick = onPick
)
if (canRemove) {
Spacer(modifier = Modifier.height(8.dp))
TextButton(
onClick = {
onRemove()
},
enabled = currentModel != null,
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.inline_media_picker_remove)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.inline_media_picker_remove),
style = MaterialTheme.typography.bodySmall
)
}
}
}
}
}
@OptIn(ExperimentalGlideComposeApi::class)
@Composable
fun InlineMediaPickerMediaPicker(
currentModel: Any?,
mimeType: String = "image/*",
circular: Boolean = false,
onPick: (Uri) -> Unit
) {
val documentsUiLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri ->
if (uri != null) {
onPick(uri)
}
}
if (currentModel != null) {
GlideImage(
model = currentModel,
contentDescription = stringResource(R.string.inline_media_picker_current_description),
contentScale = if (circular) ContentScale.Crop else ContentScale.FillWidth,
modifier = if (circular) {
Modifier
.clip(CircleShape)
.width(82.dp)
.height(82.dp)
} else {
Modifier
.clip(MaterialTheme.shapes.large)
.width(480.dp)
.height(140.dp)
}.clickable {
documentsUiLauncher.launch(mimeType)
},
transition = CrossFade,
)
} else {
Box(
modifier = if (circular) {
Modifier
.clip(CircleShape)
.width(82.dp)
.height(82.dp)
} else {
Modifier
.clip(MaterialTheme.shapes.large)
.width(480.dp)
.height(140.dp)
}
.clickable {
documentsUiLauncher.launch(mimeType)
}
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.4f)),
contentAlignment = Alignment.Center
) {
if (circular) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = stringResource(R.string.inline_media_picker_no_media_placeholder)
)
} else {
Text(
text = stringResource(R.string.inline_media_picker_no_media_placeholder),
style = MaterialTheme.typography.bodySmall.copy(
textAlign = TextAlign.Center
)
)
}
}
}
}

View File

@ -37,6 +37,7 @@ import chat.revolt.api.internals.SpecialUsers
import chat.revolt.api.internals.ULID
import chat.revolt.api.internals.solidColor
import chat.revolt.api.routes.user.fetchUserProfile
import chat.revolt.api.schemas.AutumnResource
import chat.revolt.api.schemas.Profile
import chat.revolt.api.schemas.User
import chat.revolt.components.generic.RemoteImage
@ -70,7 +71,12 @@ fun UserOverview(user: User) {
}
@Composable
fun RawUserOverview(user: User, profile: Profile? = null) {
fun RawUserOverview(
user: User,
profile: Profile? = null,
pfpUrl: String? = null,
backgroundUrl: String? = null
) {
val context = LocalContext.current
var teamMemberFlair by remember { mutableStateOf<Brush?>(null) }
@ -104,9 +110,10 @@ fun RawUserOverview(user: User, profile: Profile? = null) {
}
)
) {
profile?.background?.let { background ->
(backgroundUrl ?: profile?.background)?.let { background ->
RemoteImage(
url = "$REVOLT_FILES/backgrounds/${background.id}",
url = backgroundUrl
?: "$REVOLT_FILES/backgrounds/${if (background is AutumnResource) background.id else null}",
description = null,
modifier = Modifier
.height(128.dp)
@ -137,6 +144,7 @@ fun RawUserOverview(user: User, profile: Profile? = null) {
) {
UserAvatar(
username = user.displayName ?: stringResource(id = R.string.unknown),
rawUrl = pfpUrl,
userId = user.id ?: ULID.makeSpecial(0),
avatar = user.avatar,
size = 48.dp,

View File

@ -2,7 +2,10 @@ package chat.revolt.screens.settings
import android.widget.Toast
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
@ -29,7 +32,6 @@ import chat.revolt.components.generic.PageHeader
import chat.revolt.components.screens.settings.appearance.ThemeChip
import chat.revolt.ui.theme.Theme
import chat.revolt.ui.theme.systemSupportsDynamicColors
import com.google.accompanist.flowlayout.FlowRow
import kotlinx.coroutines.launch
class AppearanceSettingsScreenViewModel : ViewModel() {
@ -42,6 +44,7 @@ class AppearanceSettingsScreenViewModel : ViewModel() {
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun AppearanceSettingsScreen(
navController: NavController,
@ -80,14 +83,16 @@ fun AppearanceSettingsScreen(
)
FlowRow(
mainAxisSpacing = 10.dp,
crossAxisSpacing = 10.dp
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
ThemeChip(
color = Color(0xff1c243c),
text = stringResource(id = R.string.settings_appearance_theme_revolt),
selected = GlobalState.theme == Theme.Revolt,
modifier = Modifier.weight(1f).testTag("set_theme_revolt")
modifier = Modifier
.weight(1f)
.testTag("set_theme_revolt")
) {
setNewTheme(Theme.Revolt)
}
@ -96,7 +101,9 @@ fun AppearanceSettingsScreen(
color = Color(0xfff7f7f7),
text = stringResource(id = R.string.settings_appearance_theme_light),
selected = GlobalState.theme == Theme.Light,
modifier = Modifier.weight(1f).testTag("set_theme_light")
modifier = Modifier
.weight(1f)
.testTag("set_theme_light")
) {
setNewTheme(Theme.Light)
}
@ -105,7 +112,9 @@ fun AppearanceSettingsScreen(
color = Color(0xff000000),
text = stringResource(id = R.string.settings_appearance_theme_amoled),
selected = GlobalState.theme == Theme.Amoled,
modifier = Modifier.weight(1f).testTag("set_theme_amoled")
modifier = Modifier
.weight(1f)
.testTag("set_theme_amoled")
) {
setNewTheme(Theme.Amoled)
}
@ -114,7 +123,9 @@ fun AppearanceSettingsScreen(
color = if (isSystemInDarkTheme()) Color(0xff1c243c) else Color(0xfff7f7f7),
text = stringResource(id = R.string.settings_appearance_theme_none),
selected = GlobalState.theme == Theme.None,
modifier = Modifier.weight(1f).testTag("set_theme_none")
modifier = Modifier
.weight(1f)
.testTag("set_theme_none")
) {
setNewTheme(Theme.None)
}
@ -124,7 +135,9 @@ fun AppearanceSettingsScreen(
color = dynamicDarkColorScheme(LocalContext.current).primary,
text = stringResource(id = R.string.settings_appearance_theme_m3dynamic),
selected = GlobalState.theme == Theme.M3Dynamic,
modifier = Modifier.weight(1f).testTag("set_theme_m3dynamic")
modifier = Modifier
.weight(1f)
.testTag("set_theme_m3dynamic")
) {
setNewTheme(Theme.M3Dynamic)
}
@ -135,7 +148,9 @@ fun AppearanceSettingsScreen(
id = R.string.settings_appearance_theme_m3dynamic_unsupported
),
selected = false,
modifier = Modifier.weight(1f).testTag("set_theme_m3dynamic_unsupported")
modifier = Modifier
.weight(1f)
.testTag("set_theme_m3dynamic_unsupported")
) {
Toast.makeText(
context,

View File

@ -0,0 +1,306 @@
package chat.revolt.screens.settings
import android.content.Context
import android.net.Uri
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavController
import chat.revolt.R
import chat.revolt.api.RevoltAPI
import chat.revolt.api.routes.microservices.autumn.uploadToAutumn
import chat.revolt.api.routes.user.fetchUserProfile
import chat.revolt.api.routes.user.patchSelf
import chat.revolt.api.schemas.Profile
import chat.revolt.components.generic.InlineMediaPicker
import chat.revolt.components.generic.PageHeader
import chat.revolt.components.screens.settings.RawUserOverview
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import io.ktor.http.ContentType
import kotlinx.coroutines.launch
import java.io.File
import javax.inject.Inject
@HiltViewModel
@Suppress("StaticFieldLeak")
class ProfileSettingsScreenViewModel @Inject constructor(@ApplicationContext val context: Context) :
ViewModel() {
var pfpModel by mutableStateOf<Any?>(null)
var currentProfile by mutableStateOf<Profile?>(null)
var pendingProfile by mutableStateOf<Profile?>(null)
var backgroundModel by mutableStateOf<Any?>(null)
var uploadProgress by mutableFloatStateOf(0f)
var uploadError by mutableStateOf<String?>(null)
init {
RevoltAPI.selfId?.let { self ->
RevoltAPI.userCache[self]?.avatar?.id?.let {
pfpModel = "https://autumn.revolt.chat/avatars/${it}"
}
viewModelScope.launch {
currentProfile = fetchUserProfile(self)
currentProfile!!.background?.id?.let {
backgroundModel = "https://autumn.revolt.chat/backgrounds/${it}"
}
pendingProfile = currentProfile!!.copy()
}
}
}
fun saveNewPfp() {
uploadError = null
val uri = when (pfpModel) {
is Uri -> pfpModel as Uri
is String -> Uri.parse(pfpModel as String)
else -> return
}
val mFile = File(context.cacheDir, uri.lastPathSegment ?: "avatar")
mFile.outputStream().use { output ->
context.contentResolver.openInputStream(uri)?.use { input ->
input.copyTo(output)
}
}
val mime = context.contentResolver.getType(uri)
if (mime?.endsWith("webp") == true) {
uploadError = "WebP is not supported"
return
}
viewModelScope.launch {
try {
val id = uploadToAutumn(
mFile,
uri.lastPathSegment ?: "avatar",
"avatars",
ContentType.parse(mime ?: "image/*"),
onProgress = { soFar, outOf ->
uploadProgress = soFar.toFloat() / outOf.toFloat()
}
)
patchSelf(avatar = id)
} catch (e: Exception) {
uploadError = e.message
uploadProgress = 0f
return@launch
}
pfpModel = RevoltAPI.userCache[RevoltAPI.selfId]?.avatar?.id?.let {
"https://autumn.revolt.chat/avatars/${it}"
}
uploadProgress = 0f
}
}
fun saveNewBackground() {
uploadError = null
val uri = when (backgroundModel) {
is Uri -> backgroundModel as Uri
is String -> Uri.parse(backgroundModel as String)
else -> return
}
val mFile = File(context.cacheDir, uri.lastPathSegment ?: "background")
mFile.outputStream().use { output ->
context.contentResolver.openInputStream(uri)?.use { input ->
input.copyTo(output)
}
}
val mime = context.contentResolver.getType(uri)
if (mime?.endsWith("webp") == true) {
uploadError = "WebP is not supported"
return
}
viewModelScope.launch {
try {
val id = uploadToAutumn(
mFile,
uri.lastPathSegment ?: "background",
"backgrounds",
ContentType.parse(mime ?: "image/*"),
onProgress = { soFar, outOf ->
uploadProgress = soFar.toFloat() / outOf.toFloat()
}
)
patchSelf(background = id)
} catch (e: Exception) {
uploadError = e.message
uploadProgress = 0f
return@launch
}
backgroundModel = RevoltAPI.selfId?.let {
val profile = fetchUserProfile(it)
currentProfile = profile
pendingProfile = profile
profile.background?.id?.let {
"https://autumn.revolt.chat/backgrounds/${it}"
}
}
uploadProgress = 0f
}
}
fun removePfp() {
viewModelScope.launch {
patchSelf(remove = listOf("Avatar"))
pfpModel = null
}
}
fun removeBackground() {
viewModelScope.launch {
patchSelf(remove = listOf("ProfileBackground"))
pfpModel = null
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ProfileSettingsScreen(
navController: NavController,
viewModel: ProfileSettingsScreenViewModel = hiltViewModel()
) {
Column(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
) {
PageHeader(
text = stringResource(id = R.string.settings_profile),
showBackButton = true,
onBackButtonClicked = {
navController.popBackStack()
}
)
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
RevoltAPI.userCache[RevoltAPI.selfId]?.let {
RawUserOverview(
it,
viewModel.pendingProfile,
viewModel.pfpModel?.toString(),
viewModel.backgroundModel?.toString()
)
}
AnimatedVisibility(visible = viewModel.uploadProgress > 0f) {
LinearProgressIndicator(
progress = viewModel.uploadProgress,
modifier = Modifier
.fillMaxSize()
.padding(start = 20.dp, end = 20.dp, top = 20.dp, bottom = 0.dp)
)
}
AnimatedVisibility(visible = viewModel.uploadError != null) {
Text(
text = viewModel.uploadError ?: "",
style = MaterialTheme.typography.labelLarge.copy(
color = MaterialTheme.colorScheme.error
),
modifier = Modifier
.padding(start = 20.dp, end = 20.dp, top = 20.dp, bottom = 0.dp)
)
}
FlowRow(
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Column(
modifier = Modifier
.padding(start = 20.dp, end = 20.dp, top = 20.dp, bottom = 0.dp)
) {
Text(
text = stringResource(id = R.string.settings_profile_profile_picture),
style = MaterialTheme.typography.labelLarge
)
Spacer(Modifier.height(10.dp))
InlineMediaPicker(
currentModel = viewModel.pfpModel,
circular = true,
onPick = {
viewModel.pfpModel = it.toString()
viewModel.saveNewPfp()
},
canRemove = true,
onRemove = {
viewModel.removePfp()
}
)
}
Column(
modifier = Modifier
.padding(20.dp)
) {
Text(
text = stringResource(id = R.string.settings_profile_custom_background),
style = MaterialTheme.typography.labelLarge,
)
Spacer(Modifier.height(10.dp))
InlineMediaPicker(
currentModel = viewModel.backgroundModel,
onPick = {
viewModel.backgroundModel = it.toString()
viewModel.saveNewBackground()
},
canRemove = true,
onRemove = {
viewModel.removeBackground()
}
)
}
}
}
}
}

View File

@ -33,8 +33,8 @@ import chat.revolt.components.generic.SheetClickable
import chat.revolt.components.screens.settings.SelfUserOverview
import chat.revolt.persistence.KVStorage
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.runBlocking
import javax.inject.Inject
@HiltViewModel
class SettingsScreenViewModel @Inject constructor(
@ -85,6 +85,25 @@ fun SettingsScreen(
modifier = Modifier.padding(bottom = 10.dp, start = 10.dp, top = 20.dp)
)
SheetClickable(
icon = { modifier ->
Icon(
painter = painterResource(R.drawable.ic_card_account_details_24dp),
contentDescription = stringResource(id = R.string.settings_profile),
modifier = modifier
)
},
label = { textStyle ->
Text(
text = stringResource(id = R.string.settings_profile),
style = textStyle
)
},
modifier = Modifier.testTag("settings_view_profile")
) {
navController.navigate("settings/profile")
}
SheetClickable(
icon = { modifier ->
Icon(

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="M2,3H22C23.05,3 24,3.95 24,5V19C24,20.05 23.05,21 22,21H2C0.95,21 0,20.05 0,19V5C0,3.95 0.95,3 2,3M14,6V7H22V6H14M14,8V9H21.5L22,9V8H14M14,10V11H21V10H14M8,13.91C6,13.91 2,15 2,17V18H14V17C14,15 10,13.91 8,13.91M8,6A3,3 0 0,0 5,9A3,3 0 0,0 8,12A3,3 0 0,0 11,9A3,3 0 0,0 8,6Z" />
</vector>

View File

@ -367,6 +367,10 @@
<string name="file_picker_chip_documents">Attach a file</string>
<string name="file_picker_chip_camera">Take a photo</string>
<string name="inline_media_picker_current_description">Currently selected media</string>
<string name="inline_media_picker_no_media_placeholder">Pick media…</string>
<string name="inline_media_picker_remove">Remove</string>
<string name="emoji_picker_close_skin_tone_menu">Close skin tone menu</string>
<string name="emoji_picker_skin_tone_none">No skin tone</string>
<string name="emoji_picker_skin_tone_fitzpatrick_1_2">Light skin tone</string>
@ -391,6 +395,10 @@
<string name="settings_category_miscellaneous">Miscellaneous</string>
<string name="settings_category_last" translatable="false">Revolt v%1$s</string>
<string name="settings_profile">Profile</string>
<string name="settings_profile_profile_picture">Profile picture</string>
<string name="settings_profile_custom_background">Custom background</string>
<string name="settings_sessions">Sessions</string>
<string name="settings_sessions_this_device">This Device</string>
<string name="settings_sessions_this_device_unavailable">The session of this device is unavailable. Please **log in again** to view your current session.</string>