profile images (restore #423) (#466)

* core: configurable smp servers (#366)

* core: update simplexmq hash

* core: update simplexmq hash (fix SMPServer json encoding)

* core: fix crashing on supplying duplicate SMP servers

* core: update simplexmq hash (remove SMPServer FromJSON)

* core: update simplexmq hash (merged master)

* core: profile images (#384)

* adding initial RFC

* adding migration SQL

* update RFC

* linting

* Apply suggestions from code review

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

* refine RFC

* add avatars db migration to Store.hs

* initial chages to have images in users/groups

* fix protocol tests

* update SQL & MobileTests

* minor bug fixes

* add missing comma

* fix query error

* refactor and update  functions

* bug fixes + testing

* update to parse base64 web format images

* fix parsing and use valid padded base64 encoded image

* fix typos

* respose to and suggestions from review

* fix: typo

* refactor: avatars -> profile_images

* fix: typo

* swap updateProfile parameters

* remove TODO

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

* initial changes to show profile images

* simple set up complete

* add initial shape of image getting (needs work)

* redesign

* ios, android: configurable smp servers (only model and api for android) (#392)

* example image picker placed in edit profile screen

* tidy up and allow encoding

* more tidying

* update bottom modal bar

* v0.1 UI for upload ready

* add api calls

* refactor edit profile screen

* complete the refactor with connection back to api

* linting

* update encoding for hs compat

* no line wrapping and resize image

* refactor and tidy up for cleanest compatability with haskell

* ios: UI for editing images

* crop image to square

* update profile edit layout

* fixing image preview orientation etc

* allow expandable image in profile view

* handle case where user exits camera rather than take image

* housekeeping on when to call apiUpdateProfileImage

* improve scaling of large image

* linting

* spacing

* fix padding

* revert whitespace change

* tidy up, one remaining issue

* refactor to get parsing working

* add missed change

* use custom modal in user profile

* fix image size after scaling

* scale image iteratively

* add filter

* update profile editing view

* ios: edit profile image (TODO aspect ratio)

* ios: UI to manage profile images

* ios: use new profile api

* android: use new api to update profile

* android: scroll profile view up when editing

* revert change

* reduce profile image resolution to 104px to fit in 12.5kb

Co-authored-by: IanRDavies <ian_davies_@hotmail.co.uk>
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
This commit is contained in:
JRoberts
2022-03-25 22:13:01 +04:00
committed by GitHub
parent ff32a44345
commit 26558dfaca
21 changed files with 767 additions and 198 deletions
+11 -1
View File
@@ -4,6 +4,7 @@
<uses-feature android:name="android.hardware.camera" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
@@ -33,6 +34,15 @@
<data android:scheme="simplex" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="chat.simplex.app.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"/>
</provider>
</application>
</manifest>
</manifest>
@@ -191,6 +191,7 @@ data class User(
): NamedChat {
override val displayName: String get() = profile.displayName
override val fullName: String get() = profile.fullName
override val image: String? get() = profile.image
companion object {
val sampleData = User(
@@ -208,6 +209,7 @@ typealias ChatId = String
interface NamedChat {
val displayName: String
val fullName: String
val image: String?
val chatViewName: String
get() = displayName + (if (fullName == "" || fullName == displayName) "" else " / $fullName")
}
@@ -272,6 +274,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val createdAt get() = contact.createdAt
override val displayName get() = contact.displayName
override val fullName get() = contact.fullName
override val image get() = contact.image
companion object {
val sampleData = Direct(Contact.sampleData)
@@ -288,6 +291,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val createdAt get() = groupInfo.createdAt
override val displayName get() = groupInfo.displayName
override val fullName get() = groupInfo.fullName
override val image get() = groupInfo.image
companion object {
val sampleData = Group(GroupInfo.sampleData)
@@ -304,6 +308,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val createdAt get() = contactRequest.createdAt
override val displayName get() = contactRequest.displayName
override val fullName get() = contactRequest.fullName
override val image get() = contactRequest.image
companion object {
val sampleData = ContactRequest(UserContactRequest.sampleData)
@@ -326,6 +331,7 @@ class Contact(
override val ready get() = activeConn.connStatus == "ready" || activeConn.connStatus == "snd-ready"
override val displayName get() = profile.displayName
override val fullName get() = profile.fullName
override val image get() = profile.image
companion object {
val sampleData = Contact(
@@ -354,7 +360,8 @@ class Connection(val connStatus: String) {
@Serializable
class Profile(
val displayName: String,
val fullName: String
val fullName: String,
val image: String? = null
) {
companion object {
val sampleData = Profile(
@@ -377,6 +384,7 @@ class GroupInfo (
override val ready get() = true
override val displayName get() = groupProfile.displayName
override val fullName get() = groupProfile.fullName
override val image get() = groupProfile.image
companion object {
val sampleData = GroupInfo(
@@ -391,7 +399,8 @@ class GroupInfo (
@Serializable
class GroupProfile (
override val displayName: String,
override val fullName: String
override val fullName: String,
override val image: String? = null
): NamedChat {
companion object {
val sampleData = GroupProfile(
@@ -444,6 +453,7 @@ class UserContactRequest (
override val ready get() = true
override val displayName get() = profile.displayName
override val fullName get() = profile.fullName
override val image get() = profile.image
companion object {
val sampleData = UserContactRequest(
@@ -641,6 +651,7 @@ sealed class MsgContent {
}
object MsgContentSerializer : KSerializer<MsgContent> {
@OptIn(InternalSerializationApi::class)
override val descriptor: SerialDescriptor = buildSerialDescriptor("MsgContent", PolymorphicKind.SEALED) {
element("MCText", buildClassSerialDescriptor("MCText") {
element<String>("text")
@@ -203,7 +203,7 @@ open class ChatController(val ctrl: ChatCtrl, val ntfManager: NtfManager, val ap
}
suspend fun apiUpdateProfile(profile: Profile): Profile? {
val r = sendCmd(CC.UpdateProfile(profile))
val r = sendCmd(CC.ApiUpdateProfile(profile))
if (r is CR.UserProfileNoChange) return profile
if (r is CR.UserProfileUpdated) return r.toProfile
Log.e(TAG, "apiUpdateProfile bad response: ${r.responseType} ${r.details}")
@@ -351,7 +351,7 @@ sealed class CC {
class AddContact: CC()
class Connect(val connReq: String): CC()
class ApiDeleteChat(val type: ChatType, val id: Long): CC()
class UpdateProfile(val profile: Profile): CC()
class ApiUpdateProfile(val profile: Profile): CC()
class CreateMyAddress: CC()
class DeleteMyAddress: CC()
class ShowMyAddress: CC()
@@ -373,7 +373,7 @@ sealed class CC {
is AddContact -> "/connect"
is Connect -> "/connect $connReq"
is ApiDeleteChat -> "/_delete ${chatRef(type, id)}"
is UpdateProfile -> "/profile ${profile.displayName} ${profile.fullName}"
is ApiUpdateProfile -> "/_profile ${json.encodeToString(profile)}"
is CreateMyAddress -> "/address"
is DeleteMyAddress -> "/delete_address"
is ShowMyAddress -> "/show_address"
@@ -396,7 +396,7 @@ sealed class CC {
is AddContact -> "addContact"
is Connect -> "connect"
is ApiDeleteChat -> "apiDeleteChat"
is UpdateProfile -> "updateProfile"
is ApiUpdateProfile -> "updateProfile"
is CreateMyAddress -> "createMyAddress"
is DeleteMyAddress -> "deleteMyAddress"
is ShowMyAddress -> "showMyAddress"
@@ -148,7 +148,7 @@ fun CreateProfilePanel(chatModel: ChatModel) {
Button(onClick = {
withApi {
val user = chatModel.controller.apiCreateActiveUser(
Profile(displayName, fullName)
Profile(displayName, fullName, null)
)
chatModel.controller.startChat(user)
}
@@ -3,18 +3,18 @@ package chat.simplex.app.views.chat
import android.content.res.Configuration
import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.mapSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
@@ -23,7 +23,6 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.TAG
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.item.ChatItemView
import chat.simplex.app.views.helpers.*
@@ -1,6 +1,8 @@
package chat.simplex.app.views.helpers
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons
@@ -8,6 +10,10 @@ import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.SupervisedUserCircle
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@@ -20,12 +26,32 @@ fun ChatInfoImage(chat: Chat, size: Dp) {
val icon =
if (chat.chatInfo is ChatInfo.Group) Icons.Filled.SupervisedUserCircle
else Icons.Filled.AccountCircle
ProfileImage(size, chat.chatInfo.image, icon)
}
@Composable
fun ProfileImage(
size: Dp,
image: String? = null,
icon: ImageVector = Icons.Filled.AccountCircle
) {
Box(Modifier.size(size)) {
Icon(icon,
contentDescription = "Avatar Placeholder",
tint = MaterialTheme.colors.secondary,
modifier = Modifier.fillMaxSize()
)
if (image == null) {
Icon(
icon,
contentDescription = "profile image placeholder",
tint = MaterialTheme.colors.secondary,
modifier = Modifier.fillMaxSize()
)
} else {
val imageBitmap = base64ToBitmap(image).asImageBitmap()
Image(
imageBitmap,
"profile image",
contentScale = ContentScale.Crop,
modifier = Modifier.size(size).padding(size / 12).clip(CircleShape)
)
}
}
}
@@ -0,0 +1,187 @@
package chat.simplex.app.views.helpers
import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.*
import android.net.Uri
import android.provider.MediaStore
import android.util.Base64
import android.util.Log
import android.widget.Toast
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.CallSuper
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Collections
import androidx.compose.material.icons.outlined.PhotoCamera
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import chat.simplex.app.BuildConfig
import chat.simplex.app.TAG
import chat.simplex.app.views.newchat.ActionButton
import java.io.ByteArrayOutputStream
import java.io.File
// Inspired by https://github.com/MakeItEasyDev/Jetpack-Compose-Capture-Image-Or-Choose-from-Gallery
fun bitmapToBase64(bitmap: Bitmap, squareCrop: Boolean = true): String {
val size = 104
var height = size
var width = size
var xOffset = 0
var yOffset = 0
if (bitmap.height < bitmap.width) {
width = height * bitmap.width / bitmap.height
xOffset = (width - height) / 2
} else {
height = width * bitmap.height / bitmap.width
yOffset = (height - width) / 2
}
var image = bitmap
while (image.width / 2 > width) {
image = Bitmap.createScaledBitmap(image, image.width / 2, image.height / 2, true)
}
image = Bitmap.createScaledBitmap(image, width, height, true)
if (squareCrop) {
image = Bitmap.createBitmap(image, xOffset, yOffset, size, size)
}
val stream = ByteArrayOutputStream()
image.compress(Bitmap.CompressFormat.JPEG, 85, stream)
return "data:image/jpg;base64," + Base64.encodeToString(stream.toByteArray(), Base64.NO_WRAP)
}
fun base64ToBitmap(base64ImageString: String) : Bitmap {
val imageString = base64ImageString
.removePrefix("data:image/png;base64,")
.removePrefix("data:image/jpg;base64,")
val imageBytes = Base64.decode(imageString, Base64.NO_WRAP)
return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
}
class CustomTakePicturePreview : ActivityResultContract<Void?, Bitmap?>() {
private var uri: Uri? = null
private var tmpFile: File? = null
lateinit var externalContext: Context
@CallSuper
override fun createIntent(context: Context, input: Void?): Intent {
externalContext = context
tmpFile = File.createTempFile("image", ".bmp", context.filesDir)
uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", tmpFile!!)
return Intent(MediaStore.ACTION_IMAGE_CAPTURE)
.putExtra(MediaStore.EXTRA_OUTPUT, uri)
}
override fun getSynchronousResult(
context: Context,
input: Void?
): SynchronousResult<Bitmap?>? = null
override fun parseResult(resultCode: Int, intent: Intent?): Bitmap? {
return if (resultCode == Activity.RESULT_OK && uri != null) {
val source = ImageDecoder.createSource(externalContext.contentResolver, uri!!)
val bitmap = ImageDecoder.decodeBitmap(source)
tmpFile?.delete()
bitmap
} else {
Log.e( TAG, "Getting image from camera cancelled or failed.")
tmpFile?.delete()
null
}
}
}
@Composable
fun rememberGalleryLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher<String, Uri?> =
rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent(), cb)
@Composable
fun rememberCameraLauncher(cb: (Bitmap?) -> Unit): ManagedActivityResultLauncher<Void?, Bitmap?> =
rememberLauncherForActivityResult(contract = CustomTakePicturePreview(), cb)
@Composable
fun rememberPermissionLauncher(cb: (Boolean) -> Unit): ManagedActivityResultLauncher<String, Boolean> =
rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission(), cb)
@Composable
fun GetImageBottomSheet(
profileImageStr: MutableState<String?>,
hideBottomSheet: () -> Unit
) {
val context = LocalContext.current
val isCameraSelected = remember { mutableStateOf (false) }
val galleryLauncher = rememberGalleryLauncher { uri: Uri? ->
if (uri != null) {
val source = ImageDecoder.createSource(context.contentResolver, uri)
val bitmap = ImageDecoder.decodeBitmap(source)
profileImageStr.value = bitmapToBase64(bitmap)
}
}
val cameraLauncher = rememberCameraLauncher { bitmap: Bitmap? ->
if (bitmap != null) profileImageStr.value = bitmapToBase64(bitmap)
}
val permissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
if (isGranted) {
if (isCameraSelected.value) cameraLauncher.launch(null)
else galleryLauncher.launch("image/*")
hideBottomSheet()
} else {
Toast.makeText(context, "Permission Denied!", Toast.LENGTH_SHORT).show()
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.onFocusChanged { focusState ->
if (!focusState.hasFocus) hideBottomSheet()
}
) {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 30.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
ActionButton(null, "Use Camera", icon = Icons.Outlined.PhotoCamera) {
when (PackageManager.PERMISSION_GRANTED) {
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
cameraLauncher.launch(null)
hideBottomSheet()
}
else -> {
isCameraSelected.value = true
permissionLauncher.launch(Manifest.permission.CAMERA)
}
}
}
ActionButton(null, "From Gallery", icon = Icons.Outlined.Collections) {
when (PackageManager.PERMISSION_GRANTED) {
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) -> {
galleryLauncher.launch("image/*")
hideBottomSheet()
}
else -> {
isCameraSelected.value = false
permissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
}
}
}
}
}
}
@@ -83,7 +83,7 @@ fun NewChatSheetLayout(addContact: () -> Unit, scanCode: () -> Unit) {
}
@Composable
fun ActionButton(text: String, comment: String, icon: ImageVector, disabled: Boolean = false,
fun ActionButton(text: String?, comment: String?, icon: ImageVector, disabled: Boolean = false,
click: () -> Unit = {}) {
Column(
Modifier
@@ -97,16 +97,22 @@ fun ActionButton(text: String, comment: String, icon: ImageVector, disabled: Boo
modifier = Modifier
.size(40.dp)
.padding(bottom = 8.dp))
Text(text,
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
color = tint,
modifier = Modifier.padding(bottom = 4.dp)
)
Text(comment,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.body2
)
if (text != null) {
Text(
text,
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
color = tint,
modifier = Modifier.padding(bottom = 4.dp)
)
}
if (comment != null) {
Text(
comment,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.body2
)
}
}
}
@@ -16,7 +16,7 @@ import chat.simplex.app.ui.theme.SimpleXTheme
@Composable
fun MarkdownHelpView() {
Column(Modifier.padding(horizontal = 16.dp)) {
Column {
Text(
"How to use markdown",
style = MaterialTheme.typography.h1,
@@ -23,6 +23,7 @@ import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.Profile
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.TerminalView
import chat.simplex.app.views.helpers.ProfileImage
import chat.simplex.app.views.newchat.ModalManager
@Composable
@@ -32,6 +33,7 @@ fun SettingsView(chatModel: ChatModel) {
SettingsLayout(
profile = user.profile,
showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } },
showCustomModal = { modalView -> { ModalManager.shared.showCustomModal { close -> modalView(chatModel, close) } } },
showTerminal = { ModalManager.shared.showCustomModal { close -> TerminalView(chatModel, close) } }
)
}
@@ -44,6 +46,7 @@ val simplexTeamUri =
fun SettingsLayout(
profile: Profile,
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
showTerminal: () -> Unit
) {
val uriHandler = LocalUriHandler.current
@@ -66,11 +69,8 @@ fun SettingsLayout(
)
Spacer(Modifier.height(30.dp))
SettingsSectionView(showModal { UserProfileView(it) }, 60.dp) {
Icon(
Icons.Outlined.AccountCircle,
contentDescription = "Avatar Placeholder",
)
SettingsSectionView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp) {
ProfileImage(size = 60.dp, profile.image)
Spacer(Modifier.padding(horizontal = 4.dp))
Column {
Text(
@@ -186,7 +186,7 @@ fun SettingsSectionView(click: () -> Unit, height: Dp = 48.dp, content: (@Compos
.height(height),
verticalAlignment = Alignment.CenterVertically
) {
content.invoke()
content()
}
}
@@ -202,6 +202,7 @@ fun PreviewSettingsLayout() {
SettingsLayout(
profile = Profile.sampleData,
showModal = {{}},
showCustomModal = {{}},
showTerminal = {}
)
}
@@ -1,15 +1,24 @@
package chat.simplex.app.views.usersettings
import android.content.res.Configuration
import androidx.compose.foundation.clickable
import android.widget.ScrollView
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.PhotoCamera
import androidx.compose.runtime.*
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.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.Preview
@@ -17,29 +26,32 @@ import androidx.compose.ui.unit.dp
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.Profile
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.withApi
import chat.simplex.app.views.chat.CIListState
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.ModalView
import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.insets.navigationBarsWithImePadding
import kotlinx.coroutines.launch
@Composable
fun UserProfileView(chatModel: ChatModel) {
fun UserProfileView(chatModel: ChatModel, close: () -> Unit) {
val user = chatModel.currentUser.value
if (user != null) {
var editProfile by remember { mutableStateOf(false) }
var editProfile = remember { mutableStateOf(false) }
var profile by remember { mutableStateOf(user.profile) }
UserProfileLayout(
close = close,
editProfile = editProfile,
profile = profile,
editProfileOff = { editProfile = false },
editProfileOn = { editProfile = true },
saveProfile = { displayName: String, fullName: String ->
saveProfile = { displayName, fullName, image ->
withApi {
val newProfile = chatModel.controller.apiUpdateProfile(
profile = Profile(displayName, fullName)
)
val p = Profile(displayName, fullName, image)
val newProfile = chatModel.controller.apiUpdateProfile(p)
if (newProfile != null) {
chatModel.updateUserProfile(newProfile)
profile = newProfile
}
editProfile = false
editProfile.value = false
}
}
)
@@ -48,119 +60,192 @@ fun UserProfileView(chatModel: ChatModel) {
@Composable
fun UserProfileLayout(
editProfile: Boolean,
close: () -> Unit,
editProfile: MutableState<Boolean>,
profile: Profile,
editProfileOff: () -> Unit,
editProfileOn: () -> Unit,
saveProfile: (String, String) -> Unit,
saveProfile: (String, String, String?) -> Unit,
) {
Column(horizontalAlignment = Alignment.Start) {
Text(
"Your chat profile",
Modifier.padding(bottom = 24.dp),
style = MaterialTheme.typography.h1,
color = MaterialTheme.colors.onBackground
)
Text(
"Your profile is stored on your device and shared only with your contacts.\n" +
"SimpleX servers cannot see your profile.",
Modifier.padding(bottom = 24.dp),
color = MaterialTheme.colors.onBackground
)
if (editProfile) {
var displayName by remember { mutableStateOf(profile.displayName) }
var fullName by remember { mutableStateOf(profile.fullName) }
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start
) {
// TODO hints
BasicTextField(
value = displayName,
onValueChange = { displayName = it },
modifier = Modifier
.padding(bottom = 24.dp)
.fillMaxWidth(),
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false
),
singleLine = true
)
BasicTextField(
value = fullName,
onValueChange = { fullName = it },
modifier = Modifier
.padding(bottom = 24.dp)
.fillMaxWidth(),
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false
),
singleLine = true
)
Row {
Text(
"Cancel",
color = MaterialTheme.colors.primary,
modifier = Modifier
.clickable(onClick = editProfileOff),
)
Spacer(Modifier.padding(horizontal = 8.dp))
Text(
"Save (and notify contacts)",
color = MaterialTheme.colors.primary,
modifier = Modifier
.clickable(onClick = { saveProfile(displayName, fullName) })
)
}
}
} else {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start
) {
Row(
Modifier.padding(bottom = 24.dp)
val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val displayName = remember { mutableStateOf(profile.displayName) }
val fullName = remember { mutableStateOf(profile.fullName) }
val profileImage = remember { mutableStateOf(profile.image) }
val scope = rememberCoroutineScope()
val scrollState = rememberScrollState()
val keyboardState by getKeyboardState()
var savedKeyboardState by remember { mutableStateOf(keyboardState) }
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
ModalBottomSheetLayout(
scrimColor = Color.Black.copy(alpha = 0.12F),
modifier = Modifier.navigationBarsWithImePadding(),
sheetContent = {
GetImageBottomSheet(profileImage, hideBottomSheet = {
scope.launch { bottomSheetModalState.hide() }
})
},
sheetState = bottomSheetModalState,
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
) {
ModalView(close = close) {
Column(
Modifier
.verticalScroll(scrollState)
.padding(bottom = 16.dp),
horizontalAlignment = Alignment.Start
) {
Text(
"Display name:",
"Your chat profile",
Modifier.padding(bottom = 24.dp),
style = MaterialTheme.typography.h1,
color = MaterialTheme.colors.onBackground
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
profile.displayName,
fontWeight = FontWeight.Bold,
"Your profile is stored on your device and shared only with your contacts.\n\n" +
"SimpleX servers cannot see your profile.",
Modifier.padding(bottom = 24.dp),
color = MaterialTheme.colors.onBackground
)
if (editProfile.value) {
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start
) {
Box(
Modifier
.fillMaxWidth()
.padding(bottom = 24.dp),
contentAlignment = Alignment.Center
) {
Box(contentAlignment = Alignment.TopEnd) {
Box(contentAlignment = Alignment.Center) {
ProfileImage(192.dp, profileImage.value)
EditImageButton { scope.launch { bottomSheetModalState.show() } }
}
if (profileImage.value != null) {
DeleteImageButton { profileImage.value = null }
}
}
}
ProfileNameTextField(displayName)
ProfileNameTextField(fullName)
Row {
TextButton("Cancel") {
displayName.value = profile.displayName
fullName.value = profile.fullName
profileImage.value = profile.image
editProfile.value = false
}
Spacer(Modifier.padding(horizontal = 8.dp))
TextButton("Save (and notify contacts)") {
saveProfile(displayName.value, fullName.value, profileImage.value)
}
}
}
} else {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start
) {
Box(
Modifier
.fillMaxWidth()
.padding(bottom = 24.dp), contentAlignment = Alignment.Center
) {
ProfileImage(192.dp, profile.image)
if (profile.image == null) {
EditImageButton {
editProfile.value = true
scope.launch { bottomSheetModalState.show() }
}
}
}
ProfileNameRow("Display name:", profile.displayName)
ProfileNameRow("Full name:", profile.fullName)
TextButton("Edit") { editProfile.value = true }
}
}
if (savedKeyboardState != keyboardState) {
LaunchedEffect(keyboardState) {
scope.launch {
savedKeyboardState = keyboardState
scrollState.animateScrollTo(scrollState.maxValue)
}
}
}
}
Row(
Modifier.padding(bottom = 24.dp)
) {
Text(
"Full name:",
color = MaterialTheme.colors.onBackground
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
profile.fullName,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onBackground
)
}
Text(
"Edit",
color = MaterialTheme.colors.primary,
modifier = Modifier
.clickable(onClick = editProfileOn)
)
}
}
}
}
@Composable
private fun ProfileNameTextField(name: MutableState<String>) {
BasicTextField(
value = name.value,
onValueChange = { name.value = it },
modifier = Modifier
.padding(bottom = 24.dp)
.fillMaxWidth(),
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false
),
singleLine = true
)
}
@Composable
private fun ProfileNameRow(label: String, text: String) {
Row(Modifier.padding(bottom = 24.dp)) {
Text(
label,
color = MaterialTheme.colors.onBackground
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
text,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onBackground
)
}
}
@Composable
private fun TextButton(text: String, click: () -> Unit) {
Text(
text,
color = MaterialTheme.colors.primary,
modifier = Modifier.clickable(onClick = click),
)
}
@Composable
fun EditImageButton(click: () -> Unit) {
IconButton(
onClick = click,
modifier = Modifier.background(Color(1f, 1f, 1f, 0.2f), shape = CircleShape)
) {
Icon(
Icons.Outlined.PhotoCamera,
contentDescription = "Edit image",
tint = MaterialTheme.colors.primary,
modifier = Modifier.size(36.dp)
)
}
}
@Composable
fun DeleteImageButton(click: () -> Unit) {
IconButton(onClick = click) {
Icon(
Icons.Outlined.Close,
contentDescription = "Delete image",
tint = MaterialTheme.colors.primary,
)
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
@@ -171,11 +256,10 @@ fun UserProfileLayout(
fun PreviewUserProfileLayoutEditOff() {
SimpleXTheme {
UserProfileLayout(
close = {},
profile = Profile.sampleData,
editProfile = false,
editProfileOff = {},
editProfileOn = {},
saveProfile = { _, _ -> }
editProfile = remember { mutableStateOf(false) },
saveProfile = { _, _, _ -> }
)
}
}
@@ -190,11 +274,10 @@ fun PreviewUserProfileLayoutEditOff() {
fun PreviewUserProfileLayoutEditOn() {
SimpleXTheme {
UserProfileLayout(
close = {},
profile = Profile.sampleData,
editProfile = true,
editProfileOff = {},
editProfileOn = {},
saveProfile = { _, _ -> }
editProfile = remember { mutableStateOf(true) },
saveProfile = {_, _, _ ->}
)
}
}
@@ -0,0 +1,3 @@
<paths>
<files-path name="my_files" path="/"/>
</paths>
+17 -1
View File
@@ -190,8 +190,8 @@ struct User: Decodable, NamedChat {
var activeUser: Bool
var displayName: String { get { profile.displayName } }
var fullName: String { get { profile.fullName } }
var image: String? { get { profile.image } }
static let sampleData = User(
userId: 1,
@@ -209,6 +209,7 @@ typealias GroupName = String
struct Profile: Codable, NamedChat {
var displayName: String
var fullName: String
var image: String?
static let sampleData = Profile(
displayName: "alice",
@@ -225,6 +226,7 @@ enum ChatType: String {
protocol NamedChat {
var displayName: String { get }
var fullName: String { get }
var image: String? { get }
}
extension NamedChat {
@@ -270,6 +272,16 @@ enum ChatInfo: Identifiable, Decodable, NamedChat {
}
}
var image: String? {
get {
switch self {
case let .direct(contact): return contact.image
case let .group(groupInfo): return groupInfo.image
case let .contactRequest(contactRequest): return contactRequest.image
}
}
}
var id: ChatId {
get {
switch self {
@@ -420,6 +432,7 @@ struct Contact: Identifiable, Decodable, NamedChat {
var ready: Bool { get { activeConn.connStatus == "ready" || activeConn.connStatus == "snd-ready" } }
var displayName: String { get { profile.displayName } }
var fullName: String { get { profile.fullName } }
var image: String? { get { profile.image } }
static let sampleData = Contact(
contactId: 1,
@@ -452,6 +465,7 @@ struct UserContactRequest: Decodable, NamedChat {
var ready: Bool { get { true } }
var displayName: String { get { profile.displayName } }
var fullName: String { get { profile.fullName } }
var image: String? { get { profile.image } }
static let sampleData = UserContactRequest(
contactRequestId: 1,
@@ -472,6 +486,7 @@ struct GroupInfo: Identifiable, Decodable, NamedChat {
var ready: Bool { get { true } }
var displayName: String { get { groupProfile.displayName } }
var fullName: String { get { groupProfile.fullName } }
var image: String? { get { groupProfile.image } }
static let sampleData = GroupInfo(
groupId: 1,
@@ -484,6 +499,7 @@ struct GroupInfo: Identifiable, Decodable, NamedChat {
struct GroupProfile: Codable, NamedChat {
var displayName: String
var fullName: String
var image: String?
static let sampleData = GroupProfile(
displayName: "team",
+11 -8
View File
@@ -28,7 +28,7 @@ enum ChatCommand {
case addContact
case connect(connReq: String)
case apiDeleteChat(type: ChatType, id: Int64)
case updateProfile(profile: Profile)
case apiUpdateProfile(profile: Profile)
case createMyAddress
case deleteMyAddress
case showMyAddress
@@ -52,7 +52,7 @@ enum ChatCommand {
case .addContact: return "/connect"
case let .connect(connReq): return "/connect \(connReq)"
case let .apiDeleteChat(type, id): return "/_delete \(ref(type, id))"
case let .updateProfile(profile): return "/profile \(profile.displayName) \(profile.fullName)"
case let .apiUpdateProfile(profile): return "/_profile \(encodeJSON(profile))"
case .createMyAddress: return "/address"
case .deleteMyAddress: return "/delete_address"
case .showMyAddress: return "/show_address"
@@ -79,7 +79,7 @@ enum ChatCommand {
case .addContact: return "addContact"
case .connect: return "connect"
case .apiDeleteChat: return "apiDeleteChat"
case .updateProfile: return "updateProfile"
case .apiUpdateProfile: return "apiUpdateProfile"
case .createMyAddress: return "createMyAddress"
case .deleteMyAddress: return "deleteMyAddress"
case .showMyAddress: return "showMyAddress"
@@ -155,7 +155,7 @@ enum ChatResponse: Decodable, Error {
case .sentInvitation: return "sentInvitation"
case .contactDeleted: return "contactDeleted"
case .userProfileNoChange: return "userProfileNoChange"
case .userProfileUpdated: return "userProfileNoChange"
case .userProfileUpdated: return "userProfileUpdated"
case .userContactLink: return "userContactLink"
case .userContactLinkCreated: return "userContactLinkCreated"
case .userContactLinkDeleted: return "userContactLinkDeleted"
@@ -427,7 +427,7 @@ func apiDeleteChat(type: ChatType, id: Int64) async throws {
}
func apiUpdateProfile(profile: Profile) async throws -> Profile? {
let r = await chatSendCmd(.updateProfile(profile: profile))
let r = await chatSendCmd(.apiUpdateProfile(profile: profile))
switch r {
case .userProfileNoChange: return nil
case let .userProfileUpdated(_, toProfile): return toProfile
@@ -703,10 +703,13 @@ private func getJSONObject(_ cjson: UnsafePointer<CChar>) -> NSDictionary? {
return try? JSONSerialization.jsonObject(with: d) as? NSDictionary
}
private func encodeCJSON<T: Encodable>(_ value: T) -> [CChar] {
private func encodeJSON<T: Encodable>(_ value: T) -> String {
let data = try! jsonEncoder.encode(value)
let str = String(decoding: data, as: UTF8.self)
return str.cString(using: .utf8)!
return String(decoding: data, as: UTF8.self)
}
private func encodeCJSON<T: Encodable>(_ value: T) -> [CChar] {
encodeJSON(value).cString(using: .utf8)!
}
enum ChatError: Decodable {
@@ -130,7 +130,9 @@ struct ChatView: View {
}
func sendMessage(_ msg: String) {
logger.debug("ChatView sendMessage")
Task {
logger.debug("ChatView sendMessage: in Task")
do {
let chatItem = try await apiSendMessage(
type: chat.chatInfo.chatType,
@@ -19,10 +19,11 @@ struct ChatInfoImage: View {
case .group: iconName = "person.2.circle.fill"
default: iconName = "circle.fill"
}
return Image(systemName: iconName)
.resizable()
.foregroundColor(color)
return ProfileImage(
imageStr: chat.chatInfo.image,
iconName: iconName,
color: color
)
}
}
@@ -0,0 +1,48 @@
//
// ImagePicker.swift
// SimpleX
//
// Created by Evgeny on 23/03/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct ImagePicker: UIViewControllerRepresentable {
@Environment(\.presentationMode) var presentationMode
var source: UIImagePickerController.SourceType
@Binding var image: UIImage?
class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
let parent: ImagePicker
init(_ parent: ImagePicker) {
self.parent = parent
}
func imagePickerController(_ picker: UIImagePickerController,
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
if let uiImage = info[.originalImage] as? UIImage {
parent.image = uiImage
}
parent.presentationMode.wrappedValue.dismiss()
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIViewController(context: UIViewControllerRepresentableContext<ImagePicker>) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.sourceType = source
picker.allowsEditing = false
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: UIViewControllerRepresentableContext<ImagePicker>) {
}
}
@@ -0,0 +1,45 @@
//
// ProfileImage.swift
// SimpleX
//
// Created by Evgeny on 23/03/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct ProfileImage: View {
var imageStr: String? = nil
var iconName: String = "person.crop.circle.fill"
var color = Color(uiColor: .tertiarySystemGroupedBackground)
var body: some View {
if let image = imageStr,
let data = Data(base64Encoded: dropImagePrefix(image)),
let uiImage = UIImage(data: data) {
Image(uiImage: uiImage)
.resizable()
.clipShape(Circle())
} else {
Image(systemName: iconName)
.resizable()
.foregroundColor(color)
}
}
func dropPrefix(_ s: String, _ prefix: String) -> String {
s.hasPrefix(prefix) ? String(s.dropFirst(prefix.count)) : s
}
func dropImagePrefix(_ s: String) -> String {
dropPrefix(dropPrefix(s, "data:image/png;base64,"), "data:image/jpg;base64,")
}
}
struct ProfileImage_Previews: PreviewProvider {
static var previews: some View {
ProfileImage(imageStr: "data:image/png;base64,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")
.previewLayout(.fixed(width: 63, height: 63))
.background(.black)
}
}
@@ -30,15 +30,18 @@ struct SettingsView: View {
.navigationTitle("Your chat profile")
} label: {
HStack {
Image(systemName: "person.crop.circle")
.padding(.trailing, 8)
ProfileImage(imageStr: user.image)
.frame(width: 44, height: 44)
.padding(.trailing, 6)
.padding(.vertical, 6)
VStack(alignment: .leading) {
Text(user.profile.displayName)
Text(user.displayName)
.fontWeight(.bold)
.font(.title2)
Text(user.profile.fullName)
Text(user.fullName)
}
}
.padding(.leading, -8)
}
NavigationLink {
UserAddress()
File diff suppressed because one or more lines are too long
@@ -43,6 +43,10 @@
5C5346A927B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; };
5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */; };
5C577F7E27C83AA10006112D /* MarkdownHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */; };
5C5F2B6D27EBC3FE006A9D5F /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */; };
5C5F2B6E27EBC3FE006A9D5F /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */; };
5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */; };
5C5F2B7127EBC704006A9D5F /* ProfileImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */; };
5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AD81227A834E300348BD7 /* NewChatButton.swift */; };
5C6AD81427A834E300348BD7 /* NewChatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AD81227A834E300348BD7 /* NewChatButton.swift */; };
5C7505A227B65FDB00BE3227 /* CIMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */; };
@@ -151,6 +155,8 @@
5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = "<group>"; };
5C5346A727B59A6A004DF848 /* ChatHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHelp.swift; sourceTree = "<group>"; };
5C577F7C27C83AA10006112D /* MarkdownHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownHelp.swift; sourceTree = "<group>"; };
5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePicker.swift; sourceTree = "<group>"; };
5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileImage.swift; sourceTree = "<group>"; };
5C6AD81227A834E300348BD7 /* NewChatButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewChatButton.swift; sourceTree = "<group>"; };
5C7505A127B65FDB00BE3227 /* CIMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMetaView.swift; sourceTree = "<group>"; };
5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavLinkPlain.swift; sourceTree = "<group>"; };
@@ -310,6 +316,8 @@
5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */,
5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */,
5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */,
5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */,
5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */,
);
path = Helpers;
sourceTree = "<group>";
@@ -634,6 +642,7 @@
5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */,
5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */,
5C971E2127AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */,
5C5F2B6D27EBC3FE006A9D5F /* ImagePicker.swift in Sources */,
5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */,
5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */,
5CCD403727A5F9A200368C90 /* ConnectContactView.swift in Sources */,
@@ -644,6 +653,7 @@
5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */,
5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */,
5CB924D427A853F100ACCCDD /* SettingsButton.swift in Sources */,
5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */,
5CE4407227ADB1D0007B033A /* Emoji.swift in Sources */,
5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */,
);
@@ -683,6 +693,7 @@
5C2E261027A30FDC00F70299 /* ChatView.swift in Sources */,
5C2E260C27A30CFA00F70299 /* ChatListView.swift in Sources */,
5C971E2227AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */,
5C5F2B6E27EBC3FE006A9D5F /* ImagePicker.swift in Sources */,
5C577F7E27C83AA10006112D /* MarkdownHelp.swift in Sources */,
5CA059EC279559F40002BEB4 /* SimpleXApp.swift in Sources */,
5CCD403827A5F9A200368C90 /* ConnectContactView.swift in Sources */,
@@ -693,6 +704,7 @@
5CC1C99627A6CF7F000D9FF6 /* ShareSheet.swift in Sources */,
5C2E260827A2941F00F70299 /* SimpleXAPI.swift in Sources */,
5CB924D527A853F100ACCCDD /* SettingsButton.swift in Sources */,
5C5F2B7127EBC704006A9D5F /* ProfileImage.swift in Sources */,
5CE4407327ADB1D0007B033A /* Emoji.swift in Sources */,
5C1A4C1F27A715B700EAD5AD /* ChatItemView.swift in Sources */,
);