mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-23 08:33:09 +00:00
Merge branch 'stable' into stable-android
This commit is contained in:
122 files changed
+4046
-1564
No files matched your search
+2
-1
@@ -95,8 +95,9 @@ fun UserPickerUserBox(
|
||||
}
|
||||
}
|
||||
val user = userInfo.user
|
||||
Text(
|
||||
NameWithBadge(
|
||||
user.displayName,
|
||||
user.profile.localBadge,
|
||||
fontWeight = if (user.activeUser) FontWeight.Bold else FontWeight.Normal,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
|
||||
+91
-5
@@ -1430,6 +1430,17 @@ data class Chat(
|
||||
@Serializable
|
||||
sealed class ChatInfo: SomeChat, NamedChat {
|
||||
|
||||
// the badge shown for a chat's name: an active contact's or a contact request's (groups have none).
|
||||
// a badge that expired over a month ago (ExpiredOld) is not shown at all.
|
||||
val nameBadge: LocalBadge? get() {
|
||||
val badge = when {
|
||||
this is Direct && contact.active -> contact.profile.localBadge
|
||||
this is ContactRequest -> contactRequest.profile.localBadge
|
||||
else -> null
|
||||
}
|
||||
return if (badge?.status == BadgeStatus.ExpiredOld) null else badge
|
||||
}
|
||||
|
||||
@Serializable @SerialName("direct")
|
||||
data class Direct(val contact: Contact): ChatInfo() {
|
||||
override val chatType get() = ChatType.Direct
|
||||
@@ -1994,7 +2005,10 @@ data class Profile(
|
||||
override val localAlias : String = "",
|
||||
val contactLink: String? = null,
|
||||
val preferences: ChatPreferences? = null,
|
||||
val peerType: ChatPeerType? = null
|
||||
val peerType: ChatPeerType? = null,
|
||||
// the badge proof from the wire profile: not interpreted by the UI (display uses crypto-free LocalBadge),
|
||||
// but preserved so passing a link profile back to the core (apiPrepareContact) keeps the proof
|
||||
val badge: BadgeProof? = null
|
||||
): NamedChat {
|
||||
val profileViewName: String
|
||||
get() {
|
||||
@@ -2022,7 +2036,8 @@ data class LocalProfile(
|
||||
override val localAlias: String,
|
||||
val contactLink: String? = null,
|
||||
val preferences: ChatPreferences? = null,
|
||||
val peerType: ChatPeerType? = null
|
||||
val peerType: ChatPeerType? = null,
|
||||
val localBadge: LocalBadge? = null
|
||||
): NamedChat {
|
||||
val profileViewName: String = localAlias.ifEmpty { if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)" }
|
||||
|
||||
@@ -2046,6 +2061,70 @@ enum class ChatPeerType {
|
||||
@SerialName("bot") Bot
|
||||
}
|
||||
|
||||
// Supporter badge. The credential/proof bytes stay core-side; the UI only sees the disclosed type + status.
|
||||
// Unknown types keep their string so a verified badge's real name can be shown, while the icon falls back to supporter.
|
||||
@Serializable(with = BadgeTypeSerializer::class)
|
||||
sealed class BadgeType {
|
||||
@Serializable @SerialName("supporter") object Supporter: BadgeType()
|
||||
@Serializable @SerialName("legend") object Legend: BadgeType()
|
||||
@Serializable @SerialName("investor") object Investor: BadgeType()
|
||||
@Serializable @SerialName("unknown") data class Unknown(val type: String): BadgeType()
|
||||
|
||||
// the disclosed (signed) type name, shown to the user for verified badges
|
||||
val text: String
|
||||
get() = when (this) {
|
||||
is Supporter -> "supporter"
|
||||
is Legend -> "legend"
|
||||
is Investor -> "investor"
|
||||
is Unknown -> type
|
||||
}
|
||||
}
|
||||
|
||||
object BadgeTypeSerializer : KSerializer<BadgeType> {
|
||||
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("BadgeType", PrimitiveKind.STRING)
|
||||
override fun deserialize(decoder: Decoder): BadgeType =
|
||||
when (val v = decoder.decodeString()) {
|
||||
"supporter" -> BadgeType.Supporter
|
||||
"legend" -> BadgeType.Legend
|
||||
"investor" -> BadgeType.Investor
|
||||
else -> BadgeType.Unknown(v)
|
||||
}
|
||||
override fun serialize(encoder: Encoder, value: BadgeType) = encoder.encodeString(value.text)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class BadgeStatus {
|
||||
@SerialName("active") Active,
|
||||
@SerialName("expired") Expired,
|
||||
// expired over a month ago - the badge is not shown at all
|
||||
@SerialName("expiredOld") ExpiredOld,
|
||||
@SerialName("failed") Failed,
|
||||
// signed with a key index this app version does not know - shown as a warning
|
||||
@SerialName("unknownKey") UnknownKey
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class BadgeInfo(
|
||||
val badgeType: BadgeType,
|
||||
val badgeExpiry: Instant? = null,
|
||||
val badgeExtra: String = ""
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LocalBadge(
|
||||
val badge: BadgeInfo,
|
||||
val status: BadgeStatus
|
||||
)
|
||||
|
||||
// the wire proof carried on a profile - opaque to the UI, only round-tripped back to the core (apiPrepareContact)
|
||||
@Serializable
|
||||
data class BadgeProof(
|
||||
val badgeKeyIdx: Int,
|
||||
val presHeader: String,
|
||||
val proof: String,
|
||||
val badgeInfo: BadgeInfo
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UserProfileUpdateSummary(
|
||||
val updateSuccesses: Int,
|
||||
@@ -2278,7 +2357,9 @@ enum class MemberCriteria {
|
||||
data class ContactShortLinkData (
|
||||
val profile: Profile,
|
||||
val message: MsgContent?,
|
||||
val business: Boolean
|
||||
val business: Boolean,
|
||||
// set by the core when building the connection plan: the link profile's badge, verified and crypto-free
|
||||
val localBadge: LocalBadge? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -2409,6 +2490,11 @@ data class GroupMember (
|
||||
override val image: String? get() = memberProfile.image
|
||||
val contactLink: String? = memberProfile.contactLink
|
||||
val verified get() = activeConn?.connectionCode != null
|
||||
// the badge shown for a member's name; a badge that expired over a month ago (ExpiredOld) is not shown
|
||||
val nameBadge: LocalBadge? get() {
|
||||
val badge = memberProfile.localBadge
|
||||
return if (badge?.status == BadgeStatus.ExpiredOld) null else badge
|
||||
}
|
||||
val blocked get() = blockedByAdmin || !memberSettings.showMessages
|
||||
|
||||
override val localAlias: String = memberProfile.localAlias
|
||||
@@ -2727,7 +2813,7 @@ class UserContactRequest (
|
||||
val contactRequestId: Long,
|
||||
val cReqChatVRange: VersionRange,
|
||||
override val localDisplayName: String,
|
||||
val profile: Profile,
|
||||
val profile: LocalProfile,
|
||||
override val createdAt: Instant,
|
||||
override val updatedAt: Instant
|
||||
): SomeChat, NamedChat {
|
||||
@@ -2753,7 +2839,7 @@ class UserContactRequest (
|
||||
contactRequestId = 1,
|
||||
cReqChatVRange = VersionRange(1, 1),
|
||||
localDisplayName = "alice",
|
||||
profile = Profile.sampleData,
|
||||
profile = LocalProfile.sampleData,
|
||||
createdAt = Clock.System.now(),
|
||||
updatedAt = Clock.System.now()
|
||||
)
|
||||
|
||||
+20
-7
@@ -710,19 +710,32 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) {
|
||||
) {
|
||||
ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
|
||||
val displayName = contact.profile.displayName.trim()
|
||||
val badge = cInfo.nameBadge
|
||||
val text = buildAnnotatedString {
|
||||
if (contact.verified) {
|
||||
appendInlineContent(id = "shieldIcon")
|
||||
}
|
||||
append(displayName)
|
||||
}
|
||||
val inlineContent: Map<String, InlineTextContent> = mapOf(
|
||||
"shieldIcon" to InlineTextContent(
|
||||
Placeholder(24.sp, 24.sp, PlaceholderVerticalAlign.TextCenter)
|
||||
) {
|
||||
Icon(painterResource(MR.images.ic_verified_user), null, tint = MaterialTheme.colors.secondary)
|
||||
if (badge != null) {
|
||||
append(" ")
|
||||
appendInlineContent(id = "nameBadge")
|
||||
}
|
||||
)
|
||||
}
|
||||
val nameFontSize = MaterialTheme.typography.h1.fontSize
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val inlineContent: Map<String, InlineTextContent> = buildMap {
|
||||
put(
|
||||
"shieldIcon",
|
||||
InlineTextContent(
|
||||
Placeholder(24.sp, 24.sp, PlaceholderVerticalAlign.TextCenter)
|
||||
) {
|
||||
Icon(painterResource(MR.images.ic_verified_user), null, tint = MaterialTheme.colors.secondary)
|
||||
}
|
||||
)
|
||||
if (badge != null) {
|
||||
put("nameBadge", nameBadgeInline(badge, nameFontSize) { showBadgeInfoAlert(displayName, badge, uriHandler) })
|
||||
}
|
||||
}
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val copyNameToClipboard = fun (name: String) {
|
||||
clipboard.setText(AnnotatedString(name))
|
||||
|
||||
+10
-8
@@ -170,9 +170,10 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
|
||||
@Composable
|
||||
fun ForwardedFromSender(forwardedFromItem: AChatItem) {
|
||||
@Composable
|
||||
fun ItemText(text: String, fontStyle: FontStyle = FontStyle.Normal, color: Color = MaterialTheme.colors.onBackground) {
|
||||
Text(
|
||||
fun ItemText(text: String, fontStyle: FontStyle = FontStyle.Normal, color: Color = MaterialTheme.colors.onBackground, badge: LocalBadge? = null) {
|
||||
NameWithBadge(
|
||||
text,
|
||||
badge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.body1,
|
||||
@@ -191,13 +192,13 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
|
||||
if (forwardedFromItem.chatItem.chatDir.sent) {
|
||||
ItemText(text = stringResource(MR.strings.sender_you_pronoun), fontStyle = FontStyle.Italic)
|
||||
Spacer(Modifier.height(7.dp))
|
||||
ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.secondary)
|
||||
ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.secondary, badge = forwardedFromItem.chatInfo.nameBadge)
|
||||
} else if (forwardedFromItem.chatItem.chatDir is CIDirection.GroupRcv) {
|
||||
ItemText(text = forwardedFromItem.chatItem.chatDir.groupMember.chatViewName)
|
||||
ItemText(text = forwardedFromItem.chatItem.chatDir.groupMember.chatViewName, badge = forwardedFromItem.chatItem.chatDir.groupMember.nameBadge)
|
||||
Spacer(Modifier.height(7.dp))
|
||||
ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.secondary)
|
||||
ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.secondary, badge = forwardedFromItem.chatInfo.nameBadge)
|
||||
} else {
|
||||
ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.onBackground)
|
||||
ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.onBackground, badge = forwardedFromItem.chatInfo.nameBadge)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -344,9 +345,10 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
|
||||
) {
|
||||
MemberProfileImage(size = 36.dp, member)
|
||||
Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON))
|
||||
Text(
|
||||
NameWithBadge(
|
||||
member.chatViewName,
|
||||
modifier = Modifier.weight(10f, fill = true),
|
||||
member.nameBadge,
|
||||
Modifier.weight(10f, fill = true),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
+17
-4
@@ -21,6 +21,7 @@ import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.*
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -1564,8 +1565,8 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo
|
||||
if ((cInfo as? ChatInfo.Direct)?.contact?.verified == true) {
|
||||
ContactVerifiedShield()
|
||||
}
|
||||
Text(
|
||||
cInfo.displayName, fontWeight = FontWeight.SemiBold,
|
||||
NameWithBadge(
|
||||
cInfo.displayName, cInfo.nameBadge, fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
@@ -2016,8 +2017,10 @@ fun BoxScope.ChatItemsList(
|
||||
} else {
|
||||
null to 1
|
||||
}
|
||||
Text(
|
||||
// the name and the badge are one element, so SpaceBetween separates them from the role, not from each other
|
||||
NameWithBadge(
|
||||
memberNames(member, prevMember, memCount),
|
||||
if (prevMember == null && memCount == 1) member.nameBadge else null,
|
||||
Modifier
|
||||
.padding(start = (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + DEFAULT_PADDING_HALF)
|
||||
.weight(1f, false),
|
||||
@@ -2284,8 +2287,18 @@ fun BoxScope.ChatItemsList(
|
||||
.background(MaterialTheme.appColors.receivedMessage)
|
||||
) {
|
||||
ChatInfoImage(chatInfo, size = alertProfileImageSize, iconColor = MaterialTheme.colors.secondaryVariant.mixWith(MaterialTheme.colors.onBackground, 0.97f))
|
||||
val bannerBadge = chatInfo.nameBadge
|
||||
val uriHandler = LocalUriHandler.current
|
||||
Text(
|
||||
chatInfo.displayName,
|
||||
buildAnnotatedString {
|
||||
append(chatInfo.displayName)
|
||||
if (bannerBadge != null) {
|
||||
append(" ")
|
||||
appendInlineContent(id = "nameBadge")
|
||||
}
|
||||
},
|
||||
inlineContent =
|
||||
if (bannerBadge != null) mapOf("nameBadge" to nameBadgeInline(bannerBadge, MaterialTheme.typography.h3.fontSize) { showBadgeInfoAlert(chatInfo.displayName, bannerBadge, uriHandler) }) else emptyMap(),
|
||||
style = MaterialTheme.typography.h3,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
textAlign = TextAlign.Center,
|
||||
|
||||
+12
-5
@@ -113,7 +113,9 @@ data class ComposeState(
|
||||
val inProgress: Boolean = false,
|
||||
val progressByTimeout: Boolean = false,
|
||||
val useLinkPreviews: Boolean,
|
||||
val mentions: MentionedMembers = emptyMap()
|
||||
val mentions: MentionedMembers = emptyMap(),
|
||||
// the max file size the user may attach, raised by their active badge unless the chat is incognito; kept in sync on chat switch
|
||||
val maxFileSize: Long = getMaxFileSize(FileProtocol.XFTP)
|
||||
) {
|
||||
constructor(editingItem: ChatItem, liveMessage: LiveMessage? = null, useLinkPreviews: Boolean): this(
|
||||
ComposeMessage(
|
||||
@@ -251,8 +253,6 @@ data class ComposeState(
|
||||
}
|
||||
}
|
||||
|
||||
private val maxFileSize = getMaxFileSize(FileProtocol.XFTP)
|
||||
|
||||
sealed class RecordingState {
|
||||
object NotStarted: RecordingState()
|
||||
class Started(val filePath: String, val progressMs: Int = 0): RecordingState()
|
||||
@@ -300,6 +300,7 @@ fun MutableState<ComposeState>.onFilesAttached(uris: List<URI>) {
|
||||
|
||||
fun MutableState<ComposeState>.processPickedFile(uri: URI?, text: String?) {
|
||||
if (uri != null) {
|
||||
val maxFileSize = value.maxFileSize
|
||||
val fileSize = getFileSize(uri)
|
||||
if (fileSize != null && fileSize <= maxFileSize) {
|
||||
val fileName = getFileName(uri)
|
||||
@@ -318,6 +319,7 @@ fun MutableState<ComposeState>.processPickedFile(uri: URI?, text: String?) {
|
||||
}
|
||||
|
||||
suspend fun MutableState<ComposeState>.processPickedMedia(uris: List<URI>, text: String?) {
|
||||
val maxFileSize = value.maxFileSize
|
||||
val content = ArrayList<UploadContent>()
|
||||
val imagesPreview = ArrayList<String>()
|
||||
uris.forEach { uri ->
|
||||
@@ -487,7 +489,7 @@ fun ComposeView(
|
||||
if (live) {
|
||||
composeState.value = composeState.value.copy(inProgress = false, progressByTimeout = false)
|
||||
} else {
|
||||
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
|
||||
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews, maxFileSize = composeState.value.maxFileSize)
|
||||
resetLinkPreview()
|
||||
}
|
||||
recState.value = RecordingState.NotStarted
|
||||
@@ -1094,7 +1096,7 @@ fun ComposeView(
|
||||
if (composeState.value.contextItem != ComposeContextItem.NoContextItem || composeState.value.preview != ComposePreview.NoPreview) return
|
||||
val lastEditable = chatsCtx.chatItems.value.findLast { it.meta.editable }
|
||||
if (lastEditable != null) {
|
||||
composeState.value = ComposeState(editingItem = lastEditable, useLinkPreviews = useLinkPreviews)
|
||||
composeState.value = ComposeState(editingItem = lastEditable, useLinkPreviews = useLinkPreviews).copy(maxFileSize = composeState.value.maxFileSize)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1322,6 +1324,11 @@ fun ComposeView(
|
||||
chatModel.removeLiveDummy()
|
||||
CIFile.cachedRemoteFileRequests.clear()
|
||||
}
|
||||
// keep the attach size limit in sync with the chat: the user's active badge raises it, but not in incognito chats where no badge is presented
|
||||
LaunchedEffect(chat.chatInfo) {
|
||||
val incognito = if (chat.chatInfo.profileChangeProhibited) chat.chatInfo.incognito else chatModel.controller.appPrefs.incognito.get()
|
||||
composeState.value = composeState.value.copy(maxFileSize = getMaxFileSize(FileProtocol.XFTP, if (incognito) null else chatModel.currentUser.value?.profile))
|
||||
}
|
||||
if (appPlatform.isDesktop) {
|
||||
// Don't enable this on Android, it breaks it, This method only works on desktop. For Android there is a `KeyChangeEffect(chatModel.chatId.value)`
|
||||
DisposableEffect(Unit) {
|
||||
|
||||
+3
-2
@@ -354,9 +354,10 @@ fun ContactCheckRow(
|
||||
) {
|
||||
ProfileImage(size = 36.dp, contact.image)
|
||||
Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON))
|
||||
Text(
|
||||
NameWithBadge(
|
||||
contact.chatViewName,
|
||||
modifier = Modifier.weight(10f, fill = true),
|
||||
if (contact.active) contact.profile.localBadge else null,
|
||||
Modifier.weight(10f, fill = true),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = if (prohibitedToInviteIncognito) MaterialTheme.colors.secondary else Color.Unspecified
|
||||
|
||||
+2
-1
@@ -94,8 +94,9 @@ private fun ChannelMemberRow(member: GroupMember, user: Boolean, showRole: Boole
|
||||
if (member.verified) {
|
||||
MemberVerifiedShield()
|
||||
}
|
||||
Text(
|
||||
NameWithBadge(
|
||||
member.chatViewName,
|
||||
member.nameBadge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = if (member.memberIncognito) Indigo else Color.Unspecified
|
||||
|
||||
+4
-2
@@ -1078,8 +1078,10 @@ fun MemberRow(member: GroupMember, user: Boolean = false, infoPage: Boolean = tr
|
||||
if (member.verified) {
|
||||
MemberVerifiedShield()
|
||||
}
|
||||
Text(
|
||||
if (showlocalAliasAndFullName) member.localAliasAndFullName else member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
NameWithBadge(
|
||||
if (showlocalAliasAndFullName) member.localAliasAndFullName else member.chatViewName,
|
||||
member.nameBadge,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = if (member.memberIncognito) Indigo else Color.Unspecified
|
||||
)
|
||||
}
|
||||
|
||||
+21
-7
@@ -18,6 +18,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.*
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
@@ -735,19 +736,32 @@ fun GroupMemberInfoHeader(member: GroupMember) {
|
||||
) {
|
||||
MemberProfileImage(size = 192.dp, member, color = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
|
||||
val displayName = member.displayName.trim() // alias if set
|
||||
val badge = member.nameBadge
|
||||
val text = buildAnnotatedString {
|
||||
if (member.verified) {
|
||||
appendInlineContent(id = "shieldIcon")
|
||||
}
|
||||
append(displayName)
|
||||
}
|
||||
val inlineContent: Map<String, InlineTextContent> = mapOf(
|
||||
"shieldIcon" to InlineTextContent(
|
||||
Placeholder(24.sp, 24.sp, PlaceholderVerticalAlign.TextCenter)
|
||||
) {
|
||||
Icon(painterResource(MR.images.ic_verified_user), null, tint = MaterialTheme.colors.secondary)
|
||||
if (badge != null) {
|
||||
append(" ")
|
||||
appendInlineContent(id = "nameBadge")
|
||||
}
|
||||
)
|
||||
}
|
||||
val nameFontSize = MaterialTheme.typography.h1.fontSize
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val inlineContent: Map<String, InlineTextContent> = buildMap {
|
||||
put(
|
||||
"shieldIcon",
|
||||
InlineTextContent(
|
||||
Placeholder(24.sp, 24.sp, PlaceholderVerticalAlign.TextCenter)
|
||||
) {
|
||||
Icon(painterResource(MR.images.ic_verified_user), null, tint = MaterialTheme.colors.secondary)
|
||||
}
|
||||
)
|
||||
if (badge != null) {
|
||||
put("nameBadge", nameBadgeInline(badge, nameFontSize) { showBadgeInfoAlert(displayName, badge, uriHandler) })
|
||||
}
|
||||
}
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val copyNameToClipboard = fun(name: String) {
|
||||
clipboard.setText(AnnotatedString(name))
|
||||
|
||||
+2
-2
@@ -137,8 +137,8 @@ fun MemberSupportChatToolbarTitle(member: GroupMember, imageSize: Dp = 40.dp, ic
|
||||
if (member.verified) {
|
||||
MemberVerifiedShield()
|
||||
}
|
||||
Text(
|
||||
member.displayName, fontWeight = FontWeight.SemiBold,
|
||||
NameWithBadge(
|
||||
member.displayName, member.nameBadge, fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
+2
-2
@@ -234,8 +234,8 @@ fun SupportChatRow(member: GroupMember) {
|
||||
if (member.verified) {
|
||||
MemberVerifiedShield()
|
||||
}
|
||||
Text(
|
||||
member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
NameWithBadge(
|
||||
member.chatViewName, member.nameBadge, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = if (member.memberIncognito) Indigo else Color.Unspecified
|
||||
)
|
||||
}
|
||||
|
||||
+7
-4
@@ -33,6 +33,7 @@ fun CIFileView(
|
||||
edited: Boolean,
|
||||
showMenu: MutableState<Boolean>,
|
||||
smallView: Boolean = false,
|
||||
senderProfile: LocalProfile?,
|
||||
receiveFile: (Long) -> Unit
|
||||
) {
|
||||
val saveFileLauncher = rememberSaveFileLauncher(ciFile = file)
|
||||
@@ -71,12 +72,12 @@ fun CIFileView(
|
||||
if (file != null) {
|
||||
when {
|
||||
file.fileStatus is CIFileStatus.RcvInvitation || file.fileStatus is CIFileStatus.RcvAborted -> {
|
||||
if (fileSizeValid(file)) {
|
||||
if (fileSizeValid(file, senderProfile)) {
|
||||
receiveFile(file.fileId)
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.large_file),
|
||||
String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol)))
|
||||
String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol, senderProfile)))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -151,7 +152,7 @@ fun CIFileView(
|
||||
is CIFileStatus.SndError -> fileIcon(innerIcon = painterResource(MR.images.ic_close))
|
||||
is CIFileStatus.SndWarning -> fileIcon(innerIcon = painterResource(MR.images.ic_warning_filled))
|
||||
is CIFileStatus.RcvInvitation ->
|
||||
if (fileSizeValid(file))
|
||||
if (fileSizeValid(file, senderProfile))
|
||||
fileIcon(innerIcon = painterResource(MR.images.ic_arrow_downward), color = MaterialTheme.colors.primary, topPadding = 10.sp.toDp())
|
||||
else
|
||||
fileIcon(innerIcon = painterResource(MR.images.ic_priority_high), color = WarningOrange)
|
||||
@@ -225,7 +226,9 @@ fun CIFileView(
|
||||
}
|
||||
}
|
||||
|
||||
fun fileSizeValid(file: CIFile): Boolean = file.fileSize <= getMaxFileSize(file.fileProtocol)
|
||||
// whether a received file is within the size we accept from its sender
|
||||
fun fileSizeValid(file: CIFile, senderProfile: LocalProfile?): Boolean =
|
||||
file.fileSize <= getMaxFileSize(file.fileProtocol, senderProfile)
|
||||
|
||||
fun showFileErrorAlert(err: FileError, temporary: Boolean = false) {
|
||||
val title: String = generalGetString(if (temporary) MR.strings.temporary_file_error else MR.strings.file_error)
|
||||
|
||||
+3
-9
@@ -35,6 +35,7 @@ fun CIImageView(
|
||||
imageProvider: () -> ImageGalleryProvider,
|
||||
showMenu: MutableState<Boolean>,
|
||||
smallView: Boolean,
|
||||
senderProfile: LocalProfile?,
|
||||
receiveFile: (Long) -> Unit
|
||||
) {
|
||||
val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) }
|
||||
@@ -160,13 +161,6 @@ fun CIImageView(
|
||||
}
|
||||
}
|
||||
|
||||
fun fileSizeValid(): Boolean {
|
||||
if (file != null) {
|
||||
return file.fileSize <= getMaxFileSize(file.fileProtocol)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
suspend fun imageAndFilePath(file: CIFile?): Triple<ImageBitmap, ByteArray, String>? {
|
||||
val res = getLoadedImage(file)
|
||||
if (res != null) {
|
||||
@@ -213,12 +207,12 @@ fun CIImageView(
|
||||
if (file != null) {
|
||||
when {
|
||||
file.fileStatus is CIFileStatus.RcvInvitation || file.fileStatus is CIFileStatus.RcvAborted ->
|
||||
if (fileSizeValid()) {
|
||||
if (fileSizeValid(file, senderProfile)) {
|
||||
receiveFile(file.fileId)
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.large_file),
|
||||
String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol)))
|
||||
String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol, senderProfile)))
|
||||
)
|
||||
}
|
||||
file.fileStatus is CIFileStatus.RcvAccepted ->
|
||||
|
||||
+6
-12
@@ -34,6 +34,7 @@ fun CIVideoView(
|
||||
imageProvider: () -> ImageGalleryProvider,
|
||||
showMenu: MutableState<Boolean>,
|
||||
smallView: Boolean = false,
|
||||
senderProfile: LocalProfile?,
|
||||
receiveFile: (Long) -> Unit
|
||||
) {
|
||||
val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) }
|
||||
@@ -84,7 +85,7 @@ fun CIVideoView(
|
||||
if (file != null) {
|
||||
when (file.fileStatus) {
|
||||
CIFileStatus.RcvInvitation, CIFileStatus.RcvAborted ->
|
||||
receiveFileIfValidSize(file, receiveFile)
|
||||
receiveFileIfValidSize(file, senderProfile, receiveFile)
|
||||
CIFileStatus.RcvAccepted ->
|
||||
when (file.fileProtocol) {
|
||||
FileProtocol.XFTP ->
|
||||
@@ -114,7 +115,7 @@ fun CIVideoView(
|
||||
DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(duration * 1000L) }, remember { mutableStateOf(0L) }/*, soundEnabled*/)
|
||||
}
|
||||
if (showDownloadButton(file?.fileStatus) && !blurred.value && file != null) {
|
||||
PlayButton(error = false, sizeMultiplier, { showMenu.value = true }) { receiveFileIfValidSize(file, receiveFile) }
|
||||
PlayButton(error = false, sizeMultiplier, { showMenu.value = true }) { receiveFileIfValidSize(file, senderProfile, receiveFile) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -546,20 +547,13 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean) {
|
||||
private fun showDownloadButton(status: CIFileStatus?): Boolean =
|
||||
status is CIFileStatus.RcvInvitation || status is CIFileStatus.RcvAborted
|
||||
|
||||
private fun fileSizeValid(file: CIFile?): Boolean {
|
||||
if (file != null) {
|
||||
return file.fileSize <= getMaxFileSize(file.fileProtocol)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun receiveFileIfValidSize(file: CIFile, receiveFile: (Long) -> Unit) {
|
||||
if (fileSizeValid(file)) {
|
||||
private fun receiveFileIfValidSize(file: CIFile, senderProfile: LocalProfile?, receiveFile: (Long) -> Unit) {
|
||||
if (fileSizeValid(file, senderProfile)) {
|
||||
receiveFile(file.fileId)
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.large_file),
|
||||
String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol)))
|
||||
String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol, senderProfile)))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -447,7 +447,7 @@ fun ChatItemView(
|
||||
}
|
||||
if (cItem.file != null && (getLoadedFilePath(cItem.file) != null || (chatModel.connectedToRemote() && cachedRemoteReqs[cItem.file.fileSource] != false && cItem.file.loaded))) {
|
||||
SaveContentItemAction(cItem, saveFileLauncher, showMenu)
|
||||
} else if (cItem.file != null && cItem.file.fileStatus is CIFileStatus.RcvInvitation && fileSizeValid(cItem.file)) {
|
||||
} else if (cItem.file != null && cItem.file.fileStatus is CIFileStatus.RcvInvitation && fileSizeValid(cItem.file, ciSenderProfile(cItem, chat.chatInfo))) {
|
||||
ItemAction(stringResource(MR.strings.download_file), painterResource(MR.images.ic_arrow_downward), onClick = {
|
||||
withBGApi {
|
||||
Log.d(TAG, "ChatItemView downloadFileAction")
|
||||
|
||||
+3
-3
@@ -201,7 +201,7 @@ fun FramedItemView(
|
||||
|
||||
@Composable
|
||||
fun ciFileView(ci: ChatItem, text: String) {
|
||||
CIFileView(ci.file, ci.meta.itemEdited, showMenu, false, receiveFile)
|
||||
CIFileView(ci.file, ci.meta.itemEdited, showMenu, false, ciSenderProfile(ci, chatInfo), receiveFile)
|
||||
if (text != "" || ci.meta.isLive) {
|
||||
CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode = linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
|
||||
}
|
||||
@@ -312,7 +312,7 @@ fun FramedItemView(
|
||||
} else {
|
||||
when (val mc = ci.content.msgContent) {
|
||||
is MsgContent.MCImage -> {
|
||||
CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, false, receiveFile)
|
||||
CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, false, ciSenderProfile(ci, chatInfo), receiveFile)
|
||||
if (mc.text == "" && !ci.meta.isLive) {
|
||||
metaColor = Color.White
|
||||
} else {
|
||||
@@ -320,7 +320,7 @@ fun FramedItemView(
|
||||
}
|
||||
}
|
||||
is MsgContent.MCVideo -> {
|
||||
CIVideoView(image = mc.image, mc.duration, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, smallView = false, receiveFile = receiveFile)
|
||||
CIVideoView(image = mc.image, mc.duration, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, smallView = false, senderProfile = ciSenderProfile(ci, chatInfo), receiveFile = receiveFile)
|
||||
if (mc.text == "" && !ci.meta.isLive) {
|
||||
metaColor = Color.White
|
||||
} else {
|
||||
|
||||
+12
-4
@@ -152,7 +152,15 @@ fun ChatPreviewView(
|
||||
} else {
|
||||
Color.Unspecified
|
||||
}
|
||||
chatPreviewTitleText(color = color)
|
||||
NameWithBadge(
|
||||
cInfo.chatViewName,
|
||||
cInfo.nameBadge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.h3,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = color
|
||||
)
|
||||
}
|
||||
}
|
||||
is ChatInfo.Group -> {
|
||||
@@ -316,13 +324,13 @@ fun ChatPreviewView(
|
||||
}
|
||||
}
|
||||
is MsgContent.MCImage -> SmallContentPreview {
|
||||
CIImageView(image = mc.image, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true) {
|
||||
CIImageView(image = mc.image, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) {
|
||||
val user = chatModel.currentUser.value ?: return@CIImageView
|
||||
withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) }
|
||||
}
|
||||
}
|
||||
is MsgContent.MCVideo -> SmallContentPreview {
|
||||
CIVideoView(image = mc.image, mc.duration, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true) {
|
||||
CIVideoView(image = mc.image, mc.duration, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) {
|
||||
val user = chatModel.currentUser.value ?: return@CIVideoView
|
||||
withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) }
|
||||
}
|
||||
@@ -334,7 +342,7 @@ fun ChatPreviewView(
|
||||
}
|
||||
}
|
||||
is MsgContent.MCFile -> SmallContentPreviewFile {
|
||||
CIFileView(ci.file, false, remember { mutableStateOf(false) }, smallView = true) {
|
||||
CIFileView(ci.file, false, remember { mutableStateOf(false) }, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) {
|
||||
val user = chatModel.currentUser.value ?: return@CIFileView
|
||||
withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) }
|
||||
}
|
||||
|
||||
+2
-1
@@ -27,8 +27,9 @@ fun ContactRequestView(contactRequest: ChatInfo.ContactRequest) {
|
||||
.padding(start = 8.dp, end = 8.sp.toDp())
|
||||
.weight(1F)
|
||||
) {
|
||||
Text(
|
||||
NameWithBadge(
|
||||
contactRequest.chatViewName,
|
||||
contactRequest.nameBadge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.h3,
|
||||
|
||||
+2
-2
@@ -104,8 +104,8 @@ private fun SharePreviewView(chat: Chat, disabled: Boolean) {
|
||||
} else {
|
||||
ProfileImage(size = 42.dp, chat.chatInfo.image)
|
||||
}
|
||||
Text(
|
||||
chat.chatInfo.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
NameWithBadge(
|
||||
chat.chatInfo.chatViewName, chat.chatInfo.nameBadge, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = if (disabled) MaterialTheme.colors.secondary else if (chat.chatInfo.incognito) Indigo else Color.Unspecified
|
||||
)
|
||||
}
|
||||
|
||||
+5
-4
@@ -210,7 +210,7 @@ fun UserPicker(
|
||||
}
|
||||
} else if (currentUser != null) {
|
||||
SectionItemView({ onUserClicked(currentUser) }, 80.dp, padding = PaddingValues(start = 16.dp, end = DEFAULT_PADDING), disabled = stopped) {
|
||||
ProfilePreview(currentUser.profile, iconColor = iconColor, stopped = stopped)
|
||||
ProfilePreview(currentUser.profile, iconColor = iconColor, stopped = stopped, badge = currentUser.profile.localBadge)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -468,10 +468,11 @@ fun UserProfileRow(u: User, enabled: Boolean = remember { chatModel.chatRunning
|
||||
image = u.image,
|
||||
size = 54.dp * fontSizeSqrtMultiplier
|
||||
)
|
||||
Text(
|
||||
// the end padding is on the row, not the name, so the badge stays right after the name
|
||||
NameWithBadge(
|
||||
u.displayName,
|
||||
modifier = Modifier
|
||||
.padding(start = 10.dp, end = 8.dp),
|
||||
u.profile.localBadge,
|
||||
Modifier.padding(start = 10.dp, end = 8.dp),
|
||||
color = if (enabled) MenuTextColor else MaterialTheme.colors.secondary,
|
||||
fontWeight = if (u.activeUser) FontWeight.Medium else FontWeight.Normal
|
||||
)
|
||||
|
||||
+4
-2
@@ -54,8 +54,9 @@ fun ContactPreviewView(
|
||||
if (cInfo.contact.verified) {
|
||||
VerifiedIcon()
|
||||
}
|
||||
Text(
|
||||
NameWithBadge(
|
||||
cInfo.chatViewName,
|
||||
cInfo.nameBadge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = textColor
|
||||
@@ -63,8 +64,9 @@ fun ContactPreviewView(
|
||||
}
|
||||
is ChatInfo.ContactRequest ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
NameWithBadge(
|
||||
cInfo.chatViewName,
|
||||
cInfo.nameBadge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = textColor
|
||||
|
||||
+14
-1
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CornerSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.*
|
||||
@@ -15,10 +16,12 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.model.ChatModel
|
||||
import chat.simplex.common.model.LocalBadge
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.res.MR
|
||||
@@ -272,6 +275,7 @@ class AlertManager {
|
||||
profileName: String,
|
||||
profileFullName: String,
|
||||
profileImage: @Composable () -> Unit,
|
||||
profileBadge: LocalBadge? = null,
|
||||
subtitle: String? = null,
|
||||
information: String? = null,
|
||||
confirmText: String? = generalGetString(MR.strings.connect_plan_open_chat),
|
||||
@@ -299,8 +303,17 @@ class AlertManager {
|
||||
) {
|
||||
profileImage()
|
||||
Spacer(Modifier.height(DEFAULT_PADDING_HALF))
|
||||
val nameFontSize = MaterialTheme.typography.h4.fontSize
|
||||
Text(
|
||||
profileName,
|
||||
buildAnnotatedString {
|
||||
append(profileName)
|
||||
if (profileBadge != null) {
|
||||
append(" ")
|
||||
appendInlineContent(id = "nameBadge")
|
||||
}
|
||||
},
|
||||
inlineContent =
|
||||
if (profileBadge != null) mapOf("nameBadge" to nameBadgeInline(profileBadge, nameFontSize)) else emptyMap(),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.h4,
|
||||
lineHeight = 20.sp,
|
||||
|
||||
+148
@@ -3,10 +3,14 @@ package chat.simplex.common.views.helpers
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.shape.*
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -14,15 +18,28 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.*
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.UriHandler
|
||||
import androidx.compose.ui.text.Placeholder
|
||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.*
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import chat.simplex.common.model.BadgeStatus
|
||||
import chat.simplex.common.model.BadgeType
|
||||
import chat.simplex.common.model.ChatInfo
|
||||
import chat.simplex.common.model.LocalBadge
|
||||
import chat.simplex.common.model.localDate
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.ImageResource
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun ChatInfoImage(chatInfo: ChatInfo, size: Dp, iconColor: Color = MaterialTheme.colors.secondaryVariant, shadow: Boolean = false) {
|
||||
@@ -103,6 +120,137 @@ fun ProfileImage(
|
||||
}
|
||||
}
|
||||
|
||||
// badge height in em: calibrated visually so the badge top matches capital letters and digits
|
||||
// (Inter's declared cap height is 2048/2816 = 0.727em, but the rendered text is taller than the metrics predict)
|
||||
private const val fontCapHeightRatio = 0.95f
|
||||
|
||||
// fraction of the badge height pushed below the text baseline (like the undershoot of round letters)
|
||||
private const val badgeBaselineOffsetRatio = 0.05f
|
||||
|
||||
// the badge glyph's width / height (the SVGs are cropped to the glyph: 300 x 399)
|
||||
private const val badgeAspectRatio = 300f / 399f
|
||||
|
||||
// A contact/member name with the badge right after it: the badge is baseline-aligned with the name
|
||||
// and sized to its font (fontSize if given, otherwise style.fontSize), and a truncated name keeps it visible.
|
||||
@Composable
|
||||
fun NameWithBadge(
|
||||
name: String,
|
||||
badge: LocalBadge?,
|
||||
modifier: Modifier = Modifier,
|
||||
color: Color = Color.Unspecified,
|
||||
fontSize: TextUnit = TextUnit.Unspecified,
|
||||
fontStyle: FontStyle? = null,
|
||||
fontWeight: FontWeight? = null,
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
style: TextStyle = LocalTextStyle.current
|
||||
) {
|
||||
Row(modifier) {
|
||||
Text(
|
||||
name,
|
||||
Modifier.alignByBaseline().weight(1f, fill = false),
|
||||
color = color,
|
||||
fontSize = fontSize,
|
||||
fontStyle = fontStyle,
|
||||
fontWeight = fontWeight,
|
||||
overflow = overflow,
|
||||
maxLines = maxLines,
|
||||
style = style
|
||||
)
|
||||
NameBadge(badge, if (fontSize.isSpecified) fontSize else style.fontSize)
|
||||
}
|
||||
}
|
||||
|
||||
// Badge next to the contact name in a Row: top aligned with capital letters, bottom just below the text baseline.
|
||||
// Use NameWithBadge unless the row needs special arrangement; then the name Text must use Modifier.alignByBaseline().
|
||||
@Composable
|
||||
fun RowScope.NameBadge(badge: LocalBadge?, fontSize: TextUnit = LocalTextStyle.current.fontSize) {
|
||||
// a badge that expired over a month ago (ExpiredOld) is not shown
|
||||
if (badge == null || badge.status == BadgeStatus.ExpiredOld) return
|
||||
val height = with(LocalDensity.current) { (if (fontSize.isSpecified) fontSize else 14.sp).toDp() } * fontCapHeightRatio
|
||||
BadgeGlyph(
|
||||
badge,
|
||||
// the alignment line sits badgeBaselineOffsetRatio above the badge's bottom edge,
|
||||
// so the Row places the badge that much below the text baseline;
|
||||
// 6.dp matches the visible gap between the name and the verification shield:
|
||||
// the shield has 3.dp end padding plus ~17% internal glyph margin, the badge artwork has none
|
||||
Modifier.alignBy { (it.measuredHeight * (1 - badgeBaselineOffsetRatio)).roundToInt() }.padding(start = 6.dp).height(height).aspectRatio(badgeAspectRatio)
|
||||
)
|
||||
}
|
||||
|
||||
// badge inside a Text via appendInlineContent(id): bottom on the baseline, cap-height tall.
|
||||
// precede with append(" ") for the space between the name and the badge.
|
||||
fun nameBadgeInline(badge: LocalBadge, fontSize: TextUnit, onBadgeClick: (() -> Unit)? = null): InlineTextContent {
|
||||
val height = fontSize * fontCapHeightRatio
|
||||
return InlineTextContent(
|
||||
Placeholder(height * badgeAspectRatio, height, PlaceholderVerticalAlign.AboveBaseline)
|
||||
) {
|
||||
// the placeholder bottom sits on the baseline and can't extend below it,
|
||||
// so the badge is drawn shifted down by badgeBaselineOffsetRatio instead
|
||||
BadgeGlyph(badge, Modifier.fillMaxSize().graphicsLayer { translationY = size.height * badgeBaselineOffsetRatio }, onBadgeClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BadgeGlyph(badge: LocalBadge, modifier: Modifier, onBadgeClick: (() -> Unit)? = null) {
|
||||
val mod = modifier.let { if (onBadgeClick != null) it.clickable(onClick = onBadgeClick) else it }
|
||||
if (badge.status == BadgeStatus.Failed || badge.status == BadgeStatus.UnknownKey) {
|
||||
Icon(painterResource(MR.images.ic_warning_filled), contentDescription = null, tint = WarningOrange, modifier = mod)
|
||||
} else {
|
||||
Image(
|
||||
painterResource(badgeImage(badge.badge.badgeType)),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
alpha = if (badge.status == BadgeStatus.Expired) 0.4f else 1f,
|
||||
modifier = mod
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun showBadgeInfoAlert(name: String, badge: LocalBadge, uriHandler: UriHandler) {
|
||||
// a verified badge's type is signed and can't be faked, so the real (possibly unknown) type name is the title
|
||||
val title = badge.badge.badgeType.text.replaceFirstChar { it.uppercase() }
|
||||
when {
|
||||
badge.status == BadgeStatus.Failed ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.badge_unverified_title),
|
||||
text = generalGetString(MR.strings.badge_unverified_desc)
|
||||
)
|
||||
badge.status == BadgeStatus.UnknownKey ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.badge_unknown_key_title),
|
||||
text = generalGetString(MR.strings.badge_unknown_key_desc)
|
||||
)
|
||||
badge.badge.badgeType is BadgeType.Investor ->
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = title,
|
||||
text = String.format(generalGetString(MR.strings.badge_invested), name),
|
||||
confirmText = generalGetString(MR.strings.ok),
|
||||
dismissText = generalGetString(MR.strings.learn_more),
|
||||
onDismiss = { uriHandler.openUriCatching("https://simplex.chat/crowdfunding") }
|
||||
)
|
||||
else -> {
|
||||
// Supporter, Legend and unknown types use the supporter wording
|
||||
val expiry = badge.badge.badgeExpiry
|
||||
val supports =
|
||||
if (badge.status == BadgeStatus.Expired && expiry != null)
|
||||
String.format(generalGetString(MR.strings.badge_supported_simplex), name, localDate(expiry))
|
||||
else
|
||||
String.format(generalGetString(MR.strings.badge_supports_simplex), name)
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = title,
|
||||
text = supports + "\n\n" + generalGetString(MR.strings.badge_support_from_v7)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun badgeImage(t: BadgeType): ImageResource = when (t) {
|
||||
is BadgeType.Legend -> MR.images.badge_legend
|
||||
is BadgeType.Investor -> MR.images.badge_investor
|
||||
else -> MR.images.badge_supporter // Supporter + Unknown
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileImage(size: Dp, image: ImageResource) {
|
||||
Image(
|
||||
|
||||
+21
-6
@@ -126,6 +126,10 @@ const val MAX_FILE_SIZE_SMP: Long = 8000000
|
||||
|
||||
const val MAX_FILE_SIZE_XFTP: Long = 1_073_741_824 // 1GB
|
||||
|
||||
// raised XFTP receive limits for files from a sender with a supporter badge (also investor) or a legend badge
|
||||
const val MAX_FILE_SIZE_XFTP_SUPPORTER: Long = 2_147_483_648 // 2GB
|
||||
const val MAX_FILE_SIZE_XFTP_LEGEND: Long = 5_368_709_120 // 5GB
|
||||
|
||||
const val MAX_FILE_SIZE_LOCAL: Long = Long.MAX_VALUE
|
||||
|
||||
expect fun getAppFileUri(fileName: String): URI
|
||||
@@ -383,7 +387,7 @@ fun uniqueCombine(fileName: String, dir: File): String {
|
||||
val ext = orig.extension
|
||||
fun tryCombine(n: Int): String {
|
||||
val suffix = if (n == 0) "" else "_$n"
|
||||
val f = "$name$suffix.$ext"
|
||||
val f = if (ext.isEmpty()) "$name$suffix" else "$name$suffix.$ext"
|
||||
return if (File(dir, f).exists()) tryCombine(n + 1) else f
|
||||
}
|
||||
return tryCombine(0)
|
||||
@@ -442,14 +446,25 @@ fun directoryFileCountAndSize(dir: String): Pair<Int, Long> { // count, size in
|
||||
return fileCount to bytes
|
||||
}
|
||||
|
||||
fun getMaxFileSize(fileProtocol: FileProtocol): Long {
|
||||
return when (fileProtocol) {
|
||||
FileProtocol.XFTP -> MAX_FILE_SIZE_XFTP
|
||||
FileProtocol.SMP -> MAX_FILE_SIZE_SMP
|
||||
FileProtocol.LOCAL -> MAX_FILE_SIZE_LOCAL
|
||||
fun getMaxFileSize(fileProtocol: FileProtocol, senderProfile: LocalProfile? = null): Long = when (fileProtocol) {
|
||||
FileProtocol.SMP -> MAX_FILE_SIZE_SMP
|
||||
FileProtocol.LOCAL -> MAX_FILE_SIZE_LOCAL
|
||||
// a sender's active badge raises the XFTP limit: legend to 5GB, any other (supporter/investor) to 2GB
|
||||
FileProtocol.XFTP -> {
|
||||
val badge = senderProfile?.localBadge
|
||||
if (badge == null || badge.status != BadgeStatus.Active) MAX_FILE_SIZE_XFTP
|
||||
else if (badge.badge.badgeType == BadgeType.Legend) MAX_FILE_SIZE_XFTP_LEGEND
|
||||
else MAX_FILE_SIZE_XFTP_SUPPORTER
|
||||
}
|
||||
}
|
||||
|
||||
// the profile of whoever sent a received chat item - the group member, or the direct chat's contact
|
||||
fun ciSenderProfile(ci: ChatItem, chatInfo: ChatInfo): LocalProfile? = when (val dir = ci.chatDir) {
|
||||
is CIDirection.GroupRcv -> dir.groupMember.memberProfile
|
||||
is CIDirection.DirectRcv -> (chatInfo as? ChatInfo.Direct)?.contact?.profile
|
||||
else -> null
|
||||
}
|
||||
|
||||
expect suspend fun getBitmapFromVideo(uri: URI, timestamp: Long? = null, random: Boolean = true, withAlertOnException: Boolean = true): VideoPlayerInterface.PreviewAndDuration
|
||||
|
||||
fun showWrongUriAlert() {
|
||||
|
||||
+3
@@ -474,6 +474,8 @@ private fun showOpenKnownContactAlert(chatModel: ChatModel, rhId: Long?, close:
|
||||
icon = contact.chatIconName
|
||||
)
|
||||
},
|
||||
// the alert shows the badge inline, so it skips the long-expired (ExpiredOld) badge here too
|
||||
profileBadge = if (contact.active && contact.profile.localBadge?.status != BadgeStatus.ExpiredOld) contact.profile.localBadge else null,
|
||||
confirmText = generalGetString(if (contact.nextConnectPrepared) MR.strings.connect_plan_open_new_chat else MR.strings.connect_plan_open_chat),
|
||||
onConfirm = {
|
||||
openKnownContact(chatModel, rhId, close, contact)
|
||||
@@ -633,6 +635,7 @@ fun showPrepareContactAlert(
|
||||
else MR.images.ic_account_circle_filled
|
||||
)
|
||||
},
|
||||
profileBadge = if (contactShortLinkData.localBadge?.status == BadgeStatus.ExpiredOld) null else contactShortLinkData.localBadge,
|
||||
information = ownerVerificationMessage(ownerVerification),
|
||||
confirmText = generalGetString(MR.strings.connect_plan_open_new_chat),
|
||||
onConfirm = {
|
||||
|
||||
+5
-3
@@ -230,7 +230,8 @@ private fun ProfilePickerOption(
|
||||
disabled: Boolean,
|
||||
onSelected: () -> Unit,
|
||||
image: @Composable () -> Unit,
|
||||
onInfo: (() -> Unit)? = null
|
||||
onInfo: (() -> Unit)? = null,
|
||||
badge: LocalBadge? = null
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
@@ -243,7 +244,7 @@ private fun ProfilePickerOption(
|
||||
) {
|
||||
image()
|
||||
TextIconSpaced(false)
|
||||
Text(title, modifier = Modifier.align(Alignment.CenterVertically))
|
||||
NameWithBadge(title, badge, Modifier.align(Alignment.CenterVertically))
|
||||
if (onInfo != null) {
|
||||
Spacer(Modifier.padding(6.dp))
|
||||
Column(Modifier
|
||||
@@ -365,7 +366,8 @@ fun ActiveProfilePicker(
|
||||
}
|
||||
}
|
||||
},
|
||||
image = { ProfileImage(size = 42.dp, image = user.image) }
|
||||
image = { ProfileImage(size = 42.dp, image = user.image) },
|
||||
badge = user.profile.localBadge
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -309,12 +309,13 @@ fun AppVersionItem(showVersion: () -> Unit) {
|
||||
Text(appVersionInfo.first + (if (appVersionInfo.second != null) " (" + appVersionInfo.second + ")" else ""))
|
||||
}
|
||||
|
||||
@Composable fun ProfilePreview(profileOf: NamedChat, size: Dp = 60.dp, iconColor: Color = MaterialTheme.colors.secondaryVariant, textColor: Color = MaterialTheme.colors.onBackground, stopped: Boolean = false) {
|
||||
@Composable fun ProfilePreview(profileOf: NamedChat, size: Dp = 60.dp, iconColor: Color = MaterialTheme.colors.secondaryVariant, textColor: Color = MaterialTheme.colors.onBackground, stopped: Boolean = false, badge: LocalBadge? = null) {
|
||||
ProfileImage(size = size, image = profileOf.image, color = iconColor)
|
||||
Spacer(Modifier.padding(horizontal = 8.dp))
|
||||
Column(Modifier.height(size), verticalArrangement = Arrangement.Center) {
|
||||
Text(
|
||||
NameWithBadge(
|
||||
profileOf.displayName,
|
||||
badge,
|
||||
style = MaterialTheme.typography.caption,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (stopped) MaterialTheme.colors.secondary else textColor,
|
||||
|
||||
@@ -3105,4 +3105,12 @@
|
||||
<string name="tray_tooltip_unread">SimpleX — %d unread</string>
|
||||
<string name="appearance_minimize_to_tray">Minimize to tray when closing window</string>
|
||||
<string name="appearance_minimize_to_tray_desc">Keep SimpleX running in the background to receive messages.</string>
|
||||
<string name="badge_supports_simplex">%s supports SimpleX Chat.</string>
|
||||
<string name="badge_supported_simplex">%1$s supported SimpleX Chat. The badge expired on %2$s.</string>
|
||||
<string name="badge_support_from_v7">You can support SimpleX starting from v7 of the app.</string>
|
||||
<string name="badge_invested">%s invested in SimpleX Chat crowdfunding.</string>
|
||||
<string name="badge_unverified_title">Unverified badge</string>
|
||||
<string name="badge_unverified_desc">This badge could not be verified and may not be genuine.</string>
|
||||
<string name="badge_unknown_key_title">Badge cannot be verified</string>
|
||||
<string name="badge_unknown_key_desc">The badge is signed with a key that this version of the app does not recognize. Update the app to verify this badge.</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="300" height="399" viewBox="8.25 8.25 300 399">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#ffee9b"/>
|
||||
<stop offset="5%" stop-color="#ffee9b"/>
|
||||
<stop offset="50%" stop-color="#ffc233"/>
|
||||
<stop offset="100%" stop-color="#e67700"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path fill="url(#g)" d="M98.25,8.25h120a90,90,0,0,1,90,90v219a90,90,0,0,1-90,90h-120a90,90,0,0,1-90-90v-219a90,90,0,0,1,90-90Z"/>
|
||||
<path fill="#ffffff" fill-rule="nonzero" d="M107.25,214.5h-8.62A40.51,40.51,0,0,1,58.12,174V117.37a40.51,40.51,0,0,1,40.51-40.5h8.62a40.5,40.5,0,0,1,40.5,40.5V174a40.5,40.5,0,0,1-40.5,40.5Zm18-93.38a21.75,21.75,0,0,0-21.75-21.75h-1.12a21.75,21.75,0,0,0-21.76,21.75v49.13A21.75,21.75,0,0,0,102.38,192h1.12a21.75,21.75,0,0,0,21.75-21.75V121.12Z M218.25,214.5h-8.63a40.5,40.5,0,0,1-40.5-40.5V117.37a40.51,40.51,0,0,1,40.5-40.5h8.63a40.5,40.5,0,0,1,40.5,40.5V174a40.5,40.5,0,0,1-40.5,40.5Zm18-93.38a21.75,21.75,0,0,0-21.75-21.75h-1.13a21.75,21.75,0,0,0-21.75,21.75v49.13A21.75,21.75,0,0,0,213.37,192h1.13a21.75,21.75,0,0,0,21.75-21.75V121.12Z M114.37,133.88h88.88a13.13,13.13,0,1,1,0,26.25H114.37a13.13,13.13,0,0,1,0-26.25Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="300" height="399" viewBox="8.25 8.25 300 399">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#29f5ff"/>
|
||||
<stop offset="5%" stop-color="#26dee8"/>
|
||||
<stop offset="50%" stop-color="#3064ea"/>
|
||||
<stop offset="100%" stop-color="#001064"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path fill="url(#g)" d="M98.25,8.25h120a90,90,0,0,1,90,90v219a90,90,0,0,1-90,90h-120a90,90,0,0,1-90-90v-219a90,90,0,0,1,90-90Z"/>
|
||||
<path fill="#ffffff" fill-rule="nonzero" d="M107.25,214.5h-8.62A40.51,40.51,0,0,1,58.12,174V117.37a40.51,40.51,0,0,1,40.51-40.5h8.62a40.5,40.5,0,0,1,40.5,40.5V174a40.5,40.5,0,0,1-40.5,40.5Zm18-93.38a21.75,21.75,0,0,0-21.75-21.75h-1.12a21.75,21.75,0,0,0-21.76,21.75v49.13A21.75,21.75,0,0,0,102.38,192h1.12a21.75,21.75,0,0,0,21.75-21.75V121.12Z M218.25,214.5h-8.63a40.5,40.5,0,0,1-40.5-40.5V117.37a40.51,40.51,0,0,1,40.5-40.5h8.63a40.5,40.5,0,0,1,40.5,40.5V174a40.5,40.5,0,0,1-40.5,40.5Zm18-93.38a21.75,21.75,0,0,0-21.75-21.75h-1.13a21.75,21.75,0,0,0-21.75,21.75v49.13A21.75,21.75,0,0,0,213.37,192h1.13a21.75,21.75,0,0,0,21.75-21.75V121.12Z M114.37,133.88h88.88a13.13,13.13,0,1,1,0,26.25H114.37a13.13,13.13,0,0,1,0-26.25Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="300" height="399" viewBox="8.25 8.25 300 399">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#29f5ff"/>
|
||||
<stop offset="5%" stop-color="#29f5ff"/>
|
||||
<stop offset="95%" stop-color="#527eed"/>
|
||||
<stop offset="100%" stop-color="#3669e9"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path fill="url(#g)" d="M98.25,8.25h120a90,90,0,0,1,90,90v219a90,90,0,0,1-90,90h-120a90,90,0,0,1-90-90v-219a90,90,0,0,1,90-90Z"/>
|
||||
<path fill="#ffffff" fill-rule="nonzero" d="M107.25,214.5h-8.62A40.51,40.51,0,0,1,58.12,174V117.37a40.51,40.51,0,0,1,40.51-40.5h8.62a40.5,40.5,0,0,1,40.5,40.5V174a40.5,40.5,0,0,1-40.5,40.5Zm18-93.38a21.75,21.75,0,0,0-21.75-21.75h-1.12a21.75,21.75,0,0,0-21.76,21.75v49.13A21.75,21.75,0,0,0,102.38,192h1.12a21.75,21.75,0,0,0,21.75-21.75V121.12Z M218.25,214.5h-8.63a40.5,40.5,0,0,1-40.5-40.5V117.37a40.51,40.51,0,0,1,40.5-40.5h8.63a40.5,40.5,0,0,1,40.5,40.5V174a40.5,40.5,0,0,1-40.5,40.5Zm18-93.38a21.75,21.75,0,0,0-21.75-21.75h-1.13a21.75,21.75,0,0,0-21.75,21.75v49.13A21.75,21.75,0,0,0,213.37,192h1.13a21.75,21.75,0,0,0,21.75-21.75V121.12Z M114.37,133.88h88.88a13.13,13.13,0,1,1,0,26.25H114.37a13.13,13.13,0,0,1,0-26.25Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
+11
-10
@@ -12,7 +12,6 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -67,15 +66,17 @@ actual fun UserPickerUsersSection(
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
user.displayName,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = if (user.activeUser) FontWeight.Bold else FontWeight.Normal,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.width(65.dp),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Row(Modifier.width(65.dp), horizontalArrangement = Arrangement.Center) {
|
||||
Text(
|
||||
user.displayName,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = if (user.activeUser) FontWeight.Bold else FontWeight.Normal,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.alignByBaseline().weight(1f, fill = false)
|
||||
)
|
||||
NameBadge(user.profile.localBadge, 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user