feat: prototype for user cards
Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
parent
d573988cce
commit
2d76949644
|
|
@ -251,9 +251,10 @@ dependencies {
|
|||
implementation("androidx.browser:browser:1.8.0")
|
||||
implementation("androidx.webkit:webkit:1.12.1")
|
||||
implementation("androidx.core:core-splashscreen:1.2.0-alpha02")
|
||||
implementation("androidx.palette:palette-ktx:1.0.0")
|
||||
|
||||
// Libraries used for legacy View-based UI
|
||||
implementation("androidx.constraintlayout:constraintlayout:2.2.0-rc01")
|
||||
implementation("androidx.constraintlayout:constraintlayout:2.2.0")
|
||||
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||
implementation("com.google.android.material:material:1.12.0")
|
||||
|
||||
|
|
@ -272,6 +273,10 @@ dependencies {
|
|||
// Compose libraries
|
||||
implementation("me.saket.telephoto:zoomable-image:1.0.0-alpha02")
|
||||
implementation("me.saket.telephoto:zoomable-image-glide:1.0.0-alpha02")
|
||||
implementation("androidx.constraintlayout:constraintlayout-compose:1.1.0")
|
||||
|
||||
// ZXing - QR Code generation
|
||||
implementation("com.google.zxing:core:3.5.3")
|
||||
|
||||
// Persistence
|
||||
implementation("app.cash.sqldelight:android-driver:2.0.1")
|
||||
|
|
|
|||
|
|
@ -202,8 +202,6 @@ class MainActivityViewModel @Inject constructor(
|
|||
return@launch startWithoutDestination()
|
||||
} catch (e: Exception) {
|
||||
Log.e("MainActivity", "Failed to check onboarding state, clearing session", e)
|
||||
kvStorage.remove("sessionToken")
|
||||
kvStorage.remove("sessionId")
|
||||
startWithDestination("login/greeting")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
package chat.revolt.api.internals
|
||||
|
||||
import chat.revolt.api.REVOLT_FILES
|
||||
import chat.revolt.api.api
|
||||
import chat.revolt.api.schemas.User
|
||||
|
||||
object ResourceLocations {
|
||||
fun userAvatarUrl(user: User?): String {
|
||||
if (user?.avatar != null) {
|
||||
return "$REVOLT_FILES/avatars/${user.avatar.id}/user.png?max_side=256"
|
||||
}
|
||||
return "/users/${(user?.id ?: "").ifBlank { "0".repeat(26) }}/default_avatar".api()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
package chat.revolt.api.internals
|
||||
|
||||
import chat.revolt.api.RevoltCbor
|
||||
import chat.revolt.api.schemas.User
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Serializable
|
||||
data class UserQRContents(
|
||||
val format: String = "rqr\$user\$0",
|
||||
val avatar: String,
|
||||
val displayName: String,
|
||||
val username: String,
|
||||
val discriminator: String,
|
||||
val id: String,
|
||||
)
|
||||
|
||||
object UserQR {
|
||||
@OptIn(ExperimentalSerializationApi::class, ExperimentalEncodingApi::class)
|
||||
fun contents(user: User): String {
|
||||
return "https://revolt.chat/qr?" + Base64.encode(
|
||||
RevoltCbor.encodeToByteArray(
|
||||
UserQRContents.serializer(),
|
||||
UserQRContents(
|
||||
avatar = user.avatar?.id
|
||||
?: "01JDZRBY95P8AY4CFVX16FFVWS", // Sentinel value for missing avatar
|
||||
displayName = user.displayName
|
||||
?: "01JDZRDD1YZ84HA8EST2E5GVXT", // Sentinel value for missing display name
|
||||
username = user.username
|
||||
?: "01JDZRBAG7AN9PGKKC5GSR7Z72", // Sentinel value for missing username
|
||||
discriminator = user.discriminator ?: "0000",
|
||||
id = user.id ?: "01JDZRDSJXH77K36XAFG1GX9JY" // Sentinel value for missing id
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
|
@ -89,6 +90,7 @@ fun UserAvatar(
|
|||
rawUrl: String? = null,
|
||||
size: Dp = 40.dp,
|
||||
presenceSize: Dp = 16.dp,
|
||||
shape: Shape = RoundedCornerShape(LoadedSettings.avatarRadius),
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
onClick: (() -> Unit)? = null
|
||||
) {
|
||||
|
|
@ -103,7 +105,7 @@ fun UserAvatar(
|
|||
contentScale = ContentScale.Crop,
|
||||
description = stringResource(id = R.string.avatar_alt, username),
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(LoadedSettings.avatarRadius))
|
||||
.clip(shape)
|
||||
.size(size)
|
||||
.then(
|
||||
if (onLongClick != null || onClick != null) {
|
||||
|
|
@ -122,7 +124,7 @@ fun UserAvatar(
|
|||
url = "$REVOLT_BASE/users/${userId.ifBlank { "0".repeat(26) }}/default_avatar",
|
||||
description = stringResource(id = R.string.avatar_alt, username),
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(LoadedSettings.avatarRadius))
|
||||
.clip(shape)
|
||||
.size(size)
|
||||
.then(
|
||||
if (onLongClick != null || onClick != null) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,360 @@
|
|||
package chat.revolt.components.profile
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.icu.text.DateFormat
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
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.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.layer.GraphicsLayer
|
||||
import androidx.compose.ui.graphics.layer.drawLayer
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.graphics.rememberGraphicsLayer
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.ExperimentalTextApi
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontVariation
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.constraintlayout.compose.ConstraintLayout
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.palette.graphics.Palette
|
||||
import chat.revolt.R
|
||||
import chat.revolt.api.internals.ResourceLocations
|
||||
import chat.revolt.api.internals.ULID
|
||||
import chat.revolt.api.internals.UserQR
|
||||
import chat.revolt.api.schemas.User
|
||||
import chat.revolt.components.generic.UserAvatar
|
||||
import chat.revolt.ui.theme.FragmentMono
|
||||
import com.bumptech.glide.Glide
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.EncodeHintType
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.Instant
|
||||
import java.util.Date
|
||||
|
||||
@OptIn(ExperimentalTextApi::class)
|
||||
@Composable
|
||||
fun UserCard(
|
||||
user: User? = null,
|
||||
graphicsLayer: GraphicsLayer = rememberGraphicsLayer(),
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
|
||||
var palette by remember { mutableStateOf<Palette?>(null) }
|
||||
LaunchedEffect(user) {
|
||||
val avatarUrl = ResourceLocations.userAvatarUrl(user)
|
||||
val bitmap = withContext(Dispatchers.IO) {
|
||||
Glide.with(context).load(avatarUrl).submit().get().toBitmap()
|
||||
}
|
||||
palette = Palette.from(bitmap).generate()
|
||||
}
|
||||
|
||||
var qrCode by remember(user) { mutableStateOf<Bitmap?>(null) }
|
||||
LaunchedEffect(user) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
if (user == null) throw NullPointerException("User is null")
|
||||
|
||||
val matrix = QRCodeWriter().encode(
|
||||
UserQR.contents(user),
|
||||
BarcodeFormat.QR_CODE,
|
||||
512,
|
||||
512,
|
||||
mapOf(EncodeHintType.MARGIN to "1")
|
||||
)
|
||||
|
||||
val bitmap =
|
||||
Bitmap.createBitmap(matrix.width, matrix.height, Bitmap.Config.ARGB_8888)
|
||||
|
||||
for (x in 0 until matrix.width) {
|
||||
for (y in 0 until matrix.height) {
|
||||
bitmap.setPixel(
|
||||
x,
|
||||
y,
|
||||
if (matrix.get(
|
||||
x,
|
||||
y
|
||||
)
|
||||
) Color.White.toArgb() else Color.Transparent.toArgb()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Log.d("UserCard", "Generated QR code for user ${user.id}")
|
||||
|
||||
qrCode = bitmap
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.aspectRatio(9 / 12f)
|
||||
.drawWithContent {
|
||||
graphicsLayer.record {
|
||||
this@drawWithContent.drawContent()
|
||||
}
|
||||
drawLayer(graphicsLayer)
|
||||
}
|
||||
.then(modifier)
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalContentColor provides Color.White,
|
||||
LocalTextStyle provides LocalTextStyle.current.copy(
|
||||
fontFamily = FontFamily(
|
||||
Font(
|
||||
R.font.revolt_usercard,
|
||||
variationSettings = FontVariation.Settings(FontVariation.weight(200)),
|
||||
weight = FontWeight.ExtraLight
|
||||
),
|
||||
Font(
|
||||
R.font.revolt_usercard,
|
||||
variationSettings = FontVariation.Settings(FontVariation.weight(300)),
|
||||
weight = FontWeight.Light
|
||||
),
|
||||
Font(
|
||||
R.font.revolt_usercard,
|
||||
variationSettings = FontVariation.Settings(FontVariation.weight(400)),
|
||||
weight = FontWeight.Normal
|
||||
),
|
||||
Font(
|
||||
R.font.revolt_usercard,
|
||||
variationSettings = FontVariation.Settings(FontVariation.weight(500)),
|
||||
weight = FontWeight.Medium
|
||||
),
|
||||
Font(
|
||||
R.font.revolt_usercard,
|
||||
variationSettings = FontVariation.Settings(FontVariation.weight(700)),
|
||||
weight = FontWeight.Bold
|
||||
),
|
||||
Font(
|
||||
R.font.revolt_usercard,
|
||||
variationSettings = FontVariation.Settings(FontVariation.weight(900)),
|
||||
weight = FontWeight.Black
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
ConstraintLayout(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
Color(0xFF150E2A),
|
||||
Color(0xFF332354)
|
||||
),
|
||||
start = Offset.Zero,
|
||||
end = Offset.Infinite
|
||||
)
|
||||
)
|
||||
.padding(9.dp)
|
||||
.border(3.dp, palette?.mutedSwatch?.rgb?.let { Color(it) }
|
||||
?: Color(0xFFFF005C), shape = MaterialTheme.shapes.medium)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
val (heading, nameLabel, name, usernameLabel, username, tagLabel, tag, joinDateLabel, joinDate, qrLabel, qr, photoLabel, photo, url) = createRefs()
|
||||
|
||||
Image(
|
||||
painter = painterResource(R.drawable.usercard_heading),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.5f)
|
||||
.constrainAs(heading) {
|
||||
top.linkTo(parent.top)
|
||||
start.linkTo(parent.start)
|
||||
}
|
||||
)
|
||||
|
||||
Text(
|
||||
stringResource(R.string.user_card_data_point_display_name, 1).uppercase(),
|
||||
fontFamily = FragmentMono,
|
||||
fontSize = 12.sp,
|
||||
letterSpacing = 1.sp,
|
||||
modifier = Modifier
|
||||
.constrainAs(nameLabel) {
|
||||
bottom.linkTo(name.top)
|
||||
start.linkTo(name.start)
|
||||
}
|
||||
)
|
||||
|
||||
Text(
|
||||
user?.displayName ?: user?.username ?: stringResource(R.string.unknown),
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.66f)
|
||||
.constrainAs(name) {
|
||||
bottom.linkTo(usernameLabel.top, margin = 8.dp)
|
||||
start.linkTo(usernameLabel.start)
|
||||
}
|
||||
)
|
||||
|
||||
Text(
|
||||
stringResource(R.string.user_card_data_point_username, 2).uppercase(),
|
||||
fontFamily = FragmentMono,
|
||||
fontSize = 12.sp,
|
||||
letterSpacing = 1.sp,
|
||||
modifier = Modifier
|
||||
.constrainAs(usernameLabel) {
|
||||
bottom.linkTo(username.top)
|
||||
start.linkTo(username.start)
|
||||
}
|
||||
)
|
||||
|
||||
Text(
|
||||
text = buildAnnotatedString {
|
||||
pushStyle(SpanStyle(fontWeight = FontWeight.Medium))
|
||||
append(user?.username ?: stringResource(R.string.unknown))
|
||||
pop()
|
||||
pushStyle(SpanStyle(fontWeight = FontWeight.ExtraLight))
|
||||
append("#${user?.discriminator ?: "0000"}")
|
||||
pop()
|
||||
},
|
||||
fontSize = 24.sp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.66f)
|
||||
.constrainAs(username) {
|
||||
bottom.linkTo(joinDateLabel.top, margin = 8.dp)
|
||||
start.linkTo(joinDateLabel.start)
|
||||
}
|
||||
)
|
||||
|
||||
Text(
|
||||
stringResource(R.string.user_card_data_point_join_date, 3).uppercase(),
|
||||
fontFamily = FragmentMono,
|
||||
fontSize = 12.sp,
|
||||
letterSpacing = 1.sp,
|
||||
modifier = Modifier
|
||||
.constrainAs(joinDateLabel) {
|
||||
bottom.linkTo(joinDate.top)
|
||||
start.linkTo(joinDate.start)
|
||||
}
|
||||
)
|
||||
|
||||
Text(
|
||||
DateFormat.getDateInstance().format(
|
||||
Date.from(
|
||||
Instant.ofEpochMilli(
|
||||
ULID.asTimestamp(
|
||||
user?.id ?: "00000000000000000000000000"
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier
|
||||
.constrainAs(joinDate) {
|
||||
bottom.linkTo(qrLabel.top, margin = 8.dp)
|
||||
start.linkTo(qrLabel.start)
|
||||
}
|
||||
)
|
||||
|
||||
Text(
|
||||
stringResource(R.string.user_card_data_point_qrcode, 4).uppercase(),
|
||||
fontFamily = FragmentMono,
|
||||
fontSize = 12.sp,
|
||||
letterSpacing = 1.sp,
|
||||
modifier = Modifier
|
||||
.constrainAs(qrLabel) {
|
||||
bottom.linkTo(qr.top)
|
||||
start.linkTo(qr.start)
|
||||
}
|
||||
)
|
||||
|
||||
Image(
|
||||
painter = qrCode?.let { BitmapPainter(it.asImageBitmap()) }
|
||||
?: painterResource(R.drawable.usercard_qr_placeholder),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.5f)
|
||||
.aspectRatio(1f)
|
||||
.constrainAs(qr) {
|
||||
bottom.linkTo(parent.bottom)
|
||||
start.linkTo(parent.start)
|
||||
}
|
||||
)
|
||||
|
||||
Text(
|
||||
stringResource(R.string.user_card_data_point_photo, 5).uppercase(),
|
||||
fontFamily = FragmentMono,
|
||||
fontSize = 12.sp,
|
||||
letterSpacing = 1.sp,
|
||||
modifier = Modifier
|
||||
.constrainAs(photoLabel) {
|
||||
top.linkTo(nameLabel.top)
|
||||
start.linkTo(photo.start)
|
||||
}
|
||||
)
|
||||
|
||||
BoxWithConstraints(Modifier.constrainAs(photo) {
|
||||
top.linkTo(photoLabel.bottom, margin = 8.dp)
|
||||
end.linkTo(parent.end)
|
||||
}) {
|
||||
UserAvatar(
|
||||
username = user?.username ?: stringResource(R.string.unknown),
|
||||
userId = user?.id ?: "00000000000000000000000000",
|
||||
avatar = user?.avatar,
|
||||
shape = CircleShape,
|
||||
size = maxWidth / 3
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
"revolt.chat",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier
|
||||
.constrainAs(url) {
|
||||
bottom.linkTo(parent.bottom)
|
||||
end.linkTo(parent.end)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -30,9 +30,11 @@ import androidx.compose.material3.Icon
|
|||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.ProvideTextStyle
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
|
|
@ -57,6 +59,7 @@ import chat.revolt.components.generic.NonIdealState
|
|||
import chat.revolt.components.screens.settings.UserOverview
|
||||
import chat.revolt.components.skeletons.UserOverviewSkeleton
|
||||
import chat.revolt.internals.extensions.zero
|
||||
import chat.revolt.sheets.UserCardSheet
|
||||
import io.sentry.Sentry
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
|
|
@ -85,6 +88,18 @@ fun OverviewScreen(navController: NavController, useDrawer: Boolean, onDrawerCli
|
|||
}
|
||||
}
|
||||
|
||||
var showUserCardSheet by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
if (showUserCardSheet) {
|
||||
val state = rememberModalBottomSheetState()
|
||||
ModalBottomSheet(
|
||||
sheetState = state,
|
||||
onDismissRequest = { showUserCardSheet = false },
|
||||
) {
|
||||
UserCardSheet(user = user)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
|
|
@ -189,12 +204,7 @@ fun OverviewScreen(navController: NavController, useDrawer: Boolean, onDrawerCli
|
|||
item(key = "shareProfile") {
|
||||
OverviewScreenLink(
|
||||
onClick = {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.comingsoon_toast),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
// navController.navigate("userCard")
|
||||
showUserCardSheet = true
|
||||
},
|
||||
backgroundColour = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
foregroundColour = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,238 @@
|
|||
package chat.revolt.sheets
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.clickable
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asAndroidBitmap
|
||||
import androidx.compose.ui.graphics.rememberGraphicsLayer
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.FileProvider
|
||||
import chat.revolt.BuildConfig
|
||||
import chat.revolt.R
|
||||
import chat.revolt.api.RevoltAPI
|
||||
import chat.revolt.api.schemas.User
|
||||
import chat.revolt.components.profile.UserCard
|
||||
import chat.revolt.internals.Platform
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
@Composable
|
||||
fun UserCardSheet(user: User?) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val cardGraphics = rememberGraphicsLayer()
|
||||
|
||||
val context = LocalContext.current
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
var hasError by remember { mutableStateOf(false) }
|
||||
|
||||
suspend fun shareCard() {
|
||||
val folder = File(
|
||||
context.cacheDir,
|
||||
"usercards"
|
||||
).let { File(it, RevoltAPI.selfId.toString()) }
|
||||
|
||||
try {
|
||||
folder.mkdirs()
|
||||
val bitmap = cardGraphics.toImageBitmap()
|
||||
val file = File(folder, "usercard.png")
|
||||
val outputStream = withContext(Dispatchers.IO) {
|
||||
FileOutputStream(file)
|
||||
}
|
||||
|
||||
bitmap
|
||||
.asAndroidBitmap()
|
||||
.compress(
|
||||
Bitmap.CompressFormat.PNG,
|
||||
90,
|
||||
outputStream
|
||||
)
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
outputStream.flush()
|
||||
outputStream.close()
|
||||
}
|
||||
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${BuildConfig.APPLICATION_ID}.fileprovider",
|
||||
file
|
||||
)
|
||||
|
||||
val intent = Intent(Intent.ACTION_SEND)
|
||||
intent.type = "image/png"
|
||||
intent.putExtra(
|
||||
Intent.EXTRA_TITLE,
|
||||
"User Card"
|
||||
)
|
||||
intent.putExtra(
|
||||
Intent.EXTRA_SUBJECT,
|
||||
"User Card"
|
||||
)
|
||||
intent.putExtra(Intent.EXTRA_STREAM, uri)
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
context.startActivity(Intent.createChooser(intent, null))
|
||||
} catch (e: Exception) {
|
||||
hasError = true
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun copyCard() {
|
||||
val folder = File(
|
||||
context.cacheDir,
|
||||
"usercards"
|
||||
).let { File(it, RevoltAPI.selfId.toString()) }
|
||||
|
||||
folder.mkdirs()
|
||||
val bitmap = cardGraphics.toImageBitmap()
|
||||
val file = File(folder, "usercard.png")
|
||||
val outputStream = withContext(Dispatchers.IO) {
|
||||
FileOutputStream(file)
|
||||
}
|
||||
|
||||
bitmap
|
||||
.asAndroidBitmap()
|
||||
.compress(
|
||||
Bitmap.CompressFormat.PNG,
|
||||
90,
|
||||
outputStream
|
||||
)
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
outputStream.flush()
|
||||
outputStream.close()
|
||||
}
|
||||
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${BuildConfig.APPLICATION_ID}.fileprovider",
|
||||
file
|
||||
)
|
||||
|
||||
clipboardManager.nativeClipboard.setPrimaryClip(
|
||||
ClipData.newUri(
|
||||
context.contentResolver,
|
||||
"User Card",
|
||||
uri
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.user_card_tap_to_copy),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 24.dp)
|
||||
)
|
||||
|
||||
UserCard(
|
||||
user = user,
|
||||
graphicsLayer = cardGraphics,
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
scope.launch {
|
||||
copyCard()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
AnimatedVisibility(visible = hasError) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.user_card_error_sharing),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
|
||||
if (Platform.needsShowClipboardNotification()) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
shareCard()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_share_24dp),
|
||||
contentDescription = null
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
stringResource(R.string.share),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
copyCard()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_content_copy_24dp),
|
||||
contentDescription = null
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
stringResource(R.string.copy),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package chat.revolt.sheets
|
|||
|
||||
import android.text.format.DateUtils
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
|
|
@ -15,10 +16,14 @@ import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan
|
|||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.SmallFloatingActionButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -51,6 +56,7 @@ import chat.revolt.components.screens.settings.UserButtons
|
|||
import chat.revolt.components.sheets.SheetTile
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun UserInfoSheet(
|
||||
userId: String,
|
||||
|
|
@ -102,6 +108,17 @@ fun UserInfoSheet(
|
|||
return
|
||||
}
|
||||
|
||||
var showUserCard by remember { mutableStateOf(false) }
|
||||
if (showUserCard) {
|
||||
val sheetState = rememberModalBottomSheetState(true)
|
||||
ModalBottomSheet(
|
||||
sheetState = sheetState,
|
||||
onDismissRequest = { showUserCard = false }
|
||||
) {
|
||||
UserCardSheet(user)
|
||||
}
|
||||
}
|
||||
|
||||
LazyVerticalStaggeredGrid(
|
||||
columns = StaggeredGridCells.Fixed(2),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
|
|
@ -109,7 +126,20 @@ fun UserInfoSheet(
|
|||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
item(key = "overview", span = StaggeredGridItemSpan.FullLine) {
|
||||
RawUserOverview(user, profile, internalPadding = false)
|
||||
Box {
|
||||
RawUserOverview(user, profile, internalPadding = false)
|
||||
SmallFloatingActionButton(
|
||||
onClick = { showUserCard = true },
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(top = 8.dp, end = 8.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_badge_account_horizontal_24dp),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
member?.roles?.let {
|
||||
|
|
|
|||
|
|
@ -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="M22,4H14V7H10V4H2A2,2 0 0,0 0,6V20A2,2 0 0,0 2,22H22A2,2 0 0,0 24,20V6A2,2 0 0,0 22,4M8,9A2,2 0 0,1 10,11A2,2 0 0,1 8,13A2,2 0 0,1 6,11A2,2 0 0,1 8,9M12,17H4V16C4,14.67 6.67,14 8,14C9.33,14 12,14.67 12,16V17M20,18H14V16H20V18M20,14H14V12H20V14M20,10H14V8H20V10M13,6H11V2H13V6Z" />
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:width="200dp"
|
||||
android:height="25dp"
|
||||
android:viewportWidth="200"
|
||||
android:viewportHeight="25">
|
||||
<path
|
||||
android:pathData="M3.35,19.77C3.35,15.27 3.35,12.31 3.35,8.48L0.49,5.06H9.76C10.86,5.06 11.82,5.25 12.65,5.65C13.48,6.05 14.12,6.62 14.58,7.37C15.04,8.12 15.27,9.01 15.27,10.06C15.27,11.11 15.03,12 14.56,12.72C14.09,13.45 13.43,13.99 12.58,14.36C11.73,14.73 10.75,14.91 9.61,14.91H5.79V11.81H8.8C9.28,11.81 9.68,11.75 10.01,11.64C10.35,11.52 10.61,11.33 10.79,11.07C10.97,10.81 11.06,10.47 11.06,10.06C11.06,9.64 10.97,9.29 10.79,9.03C10.61,8.76 10.35,8.56 10.01,8.44C9.68,8.31 9.28,8.25 8.8,8.25H7.38V19.77H3.35ZM12.05,13.02L15.76,19.77H11.38L7.76,13.02H12.05Z"
|
||||
android:fillColor="#FF005C" />
|
||||
<path
|
||||
android:pathData="M17.36,19.77V5.04H21.29V19.77H17.36ZM20.12,19.77L20.12,16.61H26.17L26.17,19.77H20.12ZM20.12,14.14L20.12,11.07H26.17L26.17,14.14H20.12ZM20.12,8.44L20.12,5.04H26.17V8.44H20.12Z"
|
||||
android:fillColor="#FF005C" />
|
||||
<path
|
||||
android:pathData="M34.04,19.77L39.26,5.04H43.5L37.84,19.77H34.04ZM33.6,19.77L27.89,5.04H32.13L37.39,19.77H33.6Z"
|
||||
android:fillColor="#FF005C" />
|
||||
<path
|
||||
android:pathData="M51.73,20.02C50.17,20.02 48.79,19.7 47.59,19.06C46.4,18.4 45.47,17.5 44.79,16.36C44.11,15.21 43.77,13.9 43.77,12.43C43.77,10.94 44.11,9.63 44.79,8.49C45.47,7.34 46.4,6.44 47.59,5.8C48.78,5.14 50.14,4.81 51.68,4.81C53.24,4.81 54.61,5.14 55.8,5.8C56.99,6.44 57.92,7.34 58.6,8.49C59.28,9.63 59.62,10.94 59.62,12.43C59.62,13.9 59.28,15.21 58.6,16.36C57.94,17.5 57.01,18.4 55.82,19.06C54.63,19.7 53.27,20.02 51.73,20.02ZM51.73,16.45C52.49,16.45 53.16,16.28 53.74,15.94C54.32,15.59 54.77,15.11 55.1,14.51C55.42,13.91 55.59,13.21 55.59,12.43C55.59,11.64 55.42,10.95 55.08,10.34C54.75,9.72 54.29,9.25 53.7,8.91C53.12,8.56 52.45,8.38 51.68,8.38C50.93,8.38 50.26,8.56 49.67,8.91C49.09,9.25 48.63,9.72 48.29,10.34C47.96,10.95 47.8,11.64 47.8,12.43C47.8,13.21 47.97,13.91 48.31,14.51C48.65,15.11 49.11,15.59 49.69,15.94C50.28,16.28 50.96,16.45 51.73,16.45Z"
|
||||
android:fillColor="#FF005C"
|
||||
tools:ignore="VectorPath" />
|
||||
<path
|
||||
android:pathData="M61.42,19.77V5.04H65.34V19.77H61.42ZM63.63,19.77L63.63,16.45L70.12,16.45L70.12,19.77L63.63,19.77Z"
|
||||
android:fillColor="#FF005C" />
|
||||
<path
|
||||
android:pathData="M73.16,19.77V6.26H77.08V19.77H73.16ZM68.96,8.38L68.96,5.04H81.26L81.26,8.38H68.96Z"
|
||||
android:fillColor="#FF005C" />
|
||||
<path
|
||||
android:pathData="M93.67,20.24C92.44,20.24 91.37,20.01 90.45,19.56C89.54,19.11 88.83,18.48 88.33,17.67C87.83,16.85 87.59,15.92 87.59,14.85V5.22H90.57V14.6C90.57,15.47 90.85,16.19 91.42,16.76C91.98,17.32 92.73,17.6 93.67,17.6C94.61,17.6 95.37,17.32 95.93,16.76C96.49,16.19 96.77,15.48 96.77,14.6V5.22H99.76V14.85C99.76,15.92 99.51,16.85 99.01,17.67C98.5,18.48 97.8,19.11 96.88,19.56C95.97,20.01 94.9,20.24 93.67,20.24ZM107.52,20.24C105.63,20.24 104.15,19.79 103.09,18.9C102.04,18.01 101.52,16.76 101.52,15.15H104.46C104.48,15.96 104.76,16.6 105.31,17.05C105.85,17.5 106.59,17.73 107.54,17.73C108.39,17.73 109.07,17.56 109.58,17.21C110.1,16.87 110.36,16.41 110.36,15.83C110.36,15.36 110.15,14.97 109.73,14.67C109.32,14.37 108.65,14.12 107.71,13.92L106.15,13.58C103.25,12.95 101.8,11.58 101.8,9.47C101.8,8.57 102.04,7.79 102.51,7.12C102.98,6.45 103.63,5.92 104.47,5.54C105.31,5.17 106.3,4.98 107.42,4.98C109.12,4.98 110.47,5.4 111.46,6.25C112.46,7.09 112.98,8.26 113.03,9.75H110.17C110.12,9.05 109.85,8.5 109.36,8.09C108.87,7.69 108.22,7.49 107.43,7.49C106.68,7.49 106.06,7.66 105.57,8C105.1,8.35 104.86,8.79 104.86,9.32C104.86,9.78 105.06,10.15 105.46,10.42C105.85,10.7 106.5,10.94 107.41,11.14L108.83,11.45C110.4,11.78 111.56,12.29 112.28,12.97C113.02,13.63 113.38,14.52 113.38,15.63C113.38,17.06 112.86,18.18 111.82,19.01C110.77,19.83 109.34,20.24 107.52,20.24ZM115.18,20V5.22H125.79V7.84H118.24V11.26H125.22V13.81H118.24V17.38H125.79V20H115.18ZM127.54,20V5.22H133.46C134.57,5.22 135.53,5.41 136.33,5.81C137.15,6.2 137.77,6.76 138.21,7.49C138.65,8.22 138.88,9.08 138.88,10.07C138.88,11.05 138.64,11.89 138.17,12.61C137.71,13.32 137.06,13.87 136.24,14.25L139.2,20H135.93L133.31,14.83C133.3,14.83 133.29,14.83 133.28,14.83H130.6V20H127.54ZM130.6,12.38H133.28C134.09,12.38 134.72,12.17 135.18,11.75C135.65,11.33 135.89,10.77 135.89,10.07C135.89,9.36 135.65,8.8 135.18,8.38C134.71,7.96 134.07,7.76 133.27,7.76H130.6V12.38ZM151.68,20.24C150.32,20.24 149.11,19.93 148.05,19.32C146.99,18.69 146.16,17.81 145.55,16.68C144.95,15.53 144.65,14.18 144.65,12.62C144.65,11.04 144.95,9.69 145.55,8.55C146.16,7.41 146.99,6.53 148.05,5.91C149.11,5.29 150.32,4.98 151.68,4.98C152.83,4.98 153.87,5.2 154.79,5.65C155.72,6.1 156.47,6.74 157.06,7.56C157.65,8.37 158.02,9.33 158.18,10.42H155.1C154.92,9.55 154.53,8.88 153.93,8.4C153.33,7.92 152.59,7.68 151.71,7.68C150.46,7.68 149.48,8.12 148.78,9.02C148.08,9.9 147.73,11.1 147.73,12.62C147.73,14.12 148.08,15.32 148.78,16.21C149.48,17.1 150.46,17.54 151.71,17.54C152.59,17.54 153.32,17.3 153.92,16.82C154.52,16.35 154.92,15.68 155.11,14.81H158.18C158.02,15.91 157.65,16.86 157.06,17.68C156.47,18.49 155.72,19.12 154.79,19.57C153.87,20.02 152.83,20.24 151.68,20.24ZM158.36,20L163.55,5.22H167.48L172.67,20H169.29L168.26,16.78H162.73L161.66,20H158.36ZM163.55,14.33H167.46L166.96,12.81C166.73,12.05 166.5,11.28 166.27,10.5C166.04,9.72 165.79,8.87 165.53,7.95C165.26,8.87 165.01,9.72 164.77,10.5C164.53,11.28 164.3,12.05 164.06,12.81L163.55,14.33ZM173.88,20V5.22H179.79C180.91,5.22 181.87,5.41 182.67,5.81C183.49,6.2 184.11,6.76 184.55,7.49C184.99,8.22 185.21,9.08 185.21,10.07C185.21,11.05 184.98,11.89 184.51,12.61C184.04,13.32 183.4,13.87 182.57,14.25L185.54,20H182.27L179.65,14.83C179.64,14.83 179.63,14.83 179.62,14.83H176.94V20H173.88ZM176.94,12.38H179.62C180.42,12.38 181.06,12.17 181.52,11.75C181.99,11.33 182.23,10.77 182.23,10.07C182.23,9.36 181.99,8.8 181.52,8.38C181.05,7.96 180.41,7.76 179.61,7.76H176.94V12.38ZM192.32,20H187.1V5.22H192.38C193.86,5.22 195.14,5.52 196.23,6.12C197.31,6.72 198.15,7.57 198.74,8.68C199.33,9.78 199.62,11.09 199.62,12.6C199.62,14.11 199.33,15.43 198.74,16.54C198.15,17.64 197.31,18.5 196.22,19.1C195.13,19.7 193.83,20 192.32,20ZM190.15,17.43H192.16C193.65,17.43 194.76,17.01 195.47,16.18C196.19,15.35 196.54,14.15 196.54,12.6C196.54,11.04 196.18,9.85 195.46,9.03C194.75,8.2 193.66,7.79 192.19,7.79H190.15V17.43Z"
|
||||
android:fillColor="#FFFFFF"
|
||||
tools:ignore="VectorPath" />
|
||||
</vector>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
Binary file not shown.
|
|
@ -147,7 +147,14 @@
|
|||
<string name="overview_screen_changelog_description">Revolt never sleeps, take a look to see what\'s been in the works</string>
|
||||
<string name="overview_screen_feedback">Provide Feedback</string>
|
||||
<string name="overview_screen_feedback_description">Got something to say? We\'re all ears</string>
|
||||
<!--Help shape the future of Revolt for Android by providing feedback-->
|
||||
|
||||
<string name="user_card_tap_to_copy">Tap to copy</string>
|
||||
<string name="user_card_error_sharing">An error occurred while sharing your user card.</string>
|
||||
<string name="user_card_data_point_display_name">%1$d.Name</string>
|
||||
<string name="user_card_data_point_username">%1$d.Username</string>
|
||||
<string name="user_card_data_point_join_date">%1$d.On Revolt Since</string>
|
||||
<string name="user_card_data_point_qrcode">%1$d.QR Code</string>
|
||||
<string name="user_card_data_point_photo">%1$d.Photo</string>
|
||||
|
||||
<string name="friends">Friends</string>
|
||||
<string name="friends_incoming_requests">Incoming Requests</string>
|
||||
|
|
|
|||
|
|
@ -2,4 +2,7 @@
|
|||
<cache-path
|
||||
path="attachments/"
|
||||
name="attachments" />
|
||||
<cache-path
|
||||
path="usercards/"
|
||||
name="usercards" />
|
||||
</paths>
|
||||
Loading…
Reference in New Issue