Merge branch 'master' into f/channel-comments

This commit is contained in:
spaced4ndy
2026-04-10 13:37:36 +04:00
12 changed files with 165 additions and 29 deletions
@@ -42,7 +42,7 @@ struct GroupProfileView: View {
Section {
HStack {
TextField("Group display name", text: $groupProfile.displayName)
TextField(groupInfo.useRelays ? "Channel display name" : "Group display name", text: $groupProfile.displayName)
.focused($focusDisplayName)
if !validNewProfileName {
Button {
@@ -54,7 +54,7 @@ struct GroupProfileView: View {
}
let fullName = groupInfo.groupProfile.fullName
if fullName != "" && fullName != groupProfile.displayName {
TextField("Group full name (optional)", text: $groupProfile.fullName)
TextField(groupInfo.useRelays ? "Channel full name (optional)" : "Group full name (optional)", text: $groupProfile.fullName)
}
HStack {
TextField("Short description", text: $shortDescr)
@@ -67,7 +67,7 @@ struct GroupProfileView: View {
}
}
} footer: {
Text("Group profile is stored on members' devices, not on the servers.")
Text(groupInfo.useRelays ? "Channel profile is stored on subscribers' devices and on the chat relays." : "Group profile is stored on members' devices, not on the servers.")
}
Section {
@@ -80,11 +80,11 @@ struct GroupProfileView: View {
currentProfileHash == groupProfile.hashValue &&
(groupInfo.groupProfile.shortDescr ?? "") == shortDescr.trimmingCharacters(in: .whitespaces)
)
Button("Save group profile", action: saveProfile)
Button(groupInfo.useRelays ? "Save channel profile" : "Save group profile", action: saveProfile)
.disabled(!canUpdateProfile)
}
}
.confirmationDialog("Group image", isPresented: $showChooseSource, titleVisibility: .visible) {
.confirmationDialog(groupInfo.useRelays ? "Channel image" : "Group image", isPresented: $showChooseSource, titleVisibility: .visible) {
Button("Take picture") {
showTakePhoto = true
}
@@ -130,9 +130,15 @@ struct GroupProfileView: View {
.onDisappear {
if canUpdateProfile {
showAlert(
title: NSLocalizedString("Save group profile?", comment: "alert title"),
message: NSLocalizedString("Group profile was changed. If you save it, the updated profile will be sent to group members.", comment: "alert message"),
buttonTitle: NSLocalizedString("Save (and notify members)", comment: "alert button"),
title: groupInfo.useRelays
? NSLocalizedString("Save channel profile?", comment: "alert title")
: NSLocalizedString("Save group profile?", comment: "alert title"),
message: groupInfo.useRelays
? NSLocalizedString("Channel profile was changed. If you save it, the updated profile will be sent to channel subscribers.", comment: "alert message")
: NSLocalizedString("Group profile was changed. If you save it, the updated profile will be sent to group members.", comment: "alert message"),
buttonTitle: groupInfo.useRelays
? NSLocalizedString("Save (and notify subscribers)", comment: "alert button")
: NSLocalizedString("Save (and notify members)", comment: "alert button"),
buttonAction: saveProfile,
cancelButton: true
)
@@ -142,14 +148,14 @@ struct GroupProfileView: View {
switch a {
case let .saveError(err):
return Alert(
title: Text("Error saving group profile"),
title: Text(groupInfo.useRelays ? "Error saving channel profile" : "Error saving group profile"),
message: Text(err)
)
case let .invalidName(name):
return createInvalidNameAlert(name, $groupProfile.displayName)
}
}
.navigationBarTitle("Group profile")
.navigationBarTitle(groupInfo.useRelays ? "Channel profile" : "Group profile")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(focusDisplayName ? .inline : .large)
}
@@ -2217,18 +2217,19 @@ object ChatController {
return emptyList()
}
suspend fun apiUpdateGroup(rh: Long?, groupId: Long, groupProfile: GroupProfile): GroupInfo? {
suspend fun apiUpdateGroup(rh: Long?, groupId: Long, groupProfile: GroupProfile, isChannel: Boolean): GroupInfo? {
val r = sendCmd(rh, CC.ApiUpdateGroupProfile(groupId, groupProfile))
val errorTitle = if (isChannel) MR.strings.error_saving_channel_profile else MR.strings.error_saving_group_profile
return when {
r is API.Result && r.res is CR.GroupUpdated -> r.res.toGroup
r is API.Error -> {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_saving_group_profile), "$r.err")
AlertManager.shared.showAlertMsg(generalGetString(errorTitle), "$r.err")
null
}
else -> {
Log.e(TAG, "apiUpdateGroup bad response: ${r.responseType} ${r.details}")
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.error_saving_group_profile),
generalGetString(errorTitle),
"${r.responseType}: ${r.details}"
)
null
@@ -43,7 +43,7 @@ fun GroupPreferencesView(m: ChatModel, rhId: Long?, chatId: String, close: () ->
fun savePrefs(afterSave: () -> Unit = {}) {
withBGApi {
val gp = gInfo.groupProfile.copy(groupPreferences = preferences.toGroupPreferences())
val g = m.controller.apiUpdateGroup(rhId, gInfo.groupId, gp)
val g = m.controller.apiUpdateGroup(rhId, gInfo.groupId, gp, gInfo.useRelays)
if (g != null) {
withContext(Dispatchers.Main) {
chatModel.chatsContext.updateGroup(rhId, g)
@@ -32,10 +32,11 @@ import java.net.URI
fun GroupProfileView(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, close: () -> Unit) {
GroupProfileLayout(
close = close,
groupInfo = groupInfo,
groupProfile = groupInfo.groupProfile,
saveProfile = { p ->
withBGApi {
val gInfo = chatModel.controller.apiUpdateGroup(rhId, groupInfo.groupId, p)
val gInfo = chatModel.controller.apiUpdateGroup(rhId, groupInfo.groupId, p, groupInfo.useRelays)
if (gInfo != null) {
withContext(Dispatchers.Main) {
chatModel.chatsContext.updateGroup(rhId, gInfo)
@@ -50,9 +51,11 @@ fun GroupProfileView(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, cl
@Composable
fun GroupProfileLayout(
close: () -> Unit,
groupInfo: GroupInfo,
groupProfile: GroupProfile,
saveProfile: (GroupProfile) -> Unit,
) {
val isChannel = groupInfo.useRelays
val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val displayName = rememberSaveable { mutableStateOf(groupProfile.displayName) }
val fullName = rememberSaveable { mutableStateOf(groupProfile.fullName) }
@@ -71,7 +74,7 @@ fun GroupProfileLayout(
if (dataUnchanged || !canUpdateProfile(displayName.value, shortDescr.value, groupProfile)) {
close()
} else {
showUnsavedChangesAlert({
showUnsavedChangesAlert(isChannel, {
saveProfile(
groupProfile.copy(
displayName = displayName.value.trim(),
@@ -103,7 +106,11 @@ fun GroupProfileLayout(
Modifier.fillMaxWidth()
.padding(horizontal = DEFAULT_PADDING)
) {
ReadableText(MR.strings.group_profile_is_stored_on_members_devices, TextAlign.Center)
ReadableText(
if (isChannel) MR.strings.channel_profile_is_stored_on_subscribers_devices
else MR.strings.group_profile_is_stored_on_members_devices,
TextAlign.Center
)
Box(
Modifier
.fillMaxWidth()
@@ -122,7 +129,7 @@ fun GroupProfileLayout(
}
Row(Modifier.padding(bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
stringResource(MR.strings.group_display_name_field),
stringResource(if (isChannel) MR.strings.channel_display_name_field else MR.strings.group_display_name_field),
fontSize = 16.sp
)
if (!isValidNewProfileName(displayName.value, groupProfile)) {
@@ -136,7 +143,7 @@ fun GroupProfileLayout(
if (groupProfile.fullName.trim().isNotEmpty() && groupProfile.fullName.trim() != groupProfile.displayName.trim()) {
Spacer(Modifier.height(DEFAULT_PADDING))
Text(
stringResource(MR.strings.group_full_name_field),
stringResource(if (isChannel) MR.strings.channel_full_name_field else MR.strings.group_full_name_field),
fontSize = 16.sp,
modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF)
)
@@ -164,9 +171,10 @@ fun GroupProfileLayout(
Spacer(Modifier.height(DEFAULT_PADDING))
val enabled = !dataUnchanged && canUpdateProfile(displayName.value, shortDescr.value, groupProfile)
val saveProfileLabel = if (isChannel) MR.strings.save_channel_profile else MR.strings.save_group_profile
if (enabled) {
Text(
stringResource(MR.strings.save_group_profile),
stringResource(saveProfileLabel),
modifier = Modifier.clickable {
saveProfile(
groupProfile.copy(
@@ -181,7 +189,7 @@ fun GroupProfileLayout(
)
} else {
Text(
stringResource(MR.strings.save_group_profile),
stringResource(saveProfileLabel),
color = MaterialTheme.colors.secondary
)
}
@@ -204,10 +212,10 @@ private fun canUpdateProfile(displayName: String, shortDescr: String, groupProfi
private fun isValidNewProfileName(displayName: String, groupProfile: GroupProfile): Boolean =
displayName == groupProfile.displayName || isValidDisplayName(displayName.trim())
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
private fun showUnsavedChangesAlert(isChannel: Boolean, save: () -> Unit, revert: () -> Unit) {
AlertManager.shared.showAlertDialogStacked(
title = generalGetString(MR.strings.save_preferences_question),
confirmText = generalGetString(MR.strings.save_and_notify_group_members),
confirmText = generalGetString(if (isChannel) MR.strings.save_and_notify_channel_subscribers else MR.strings.save_and_notify_group_members),
dismissText = generalGetString(MR.strings.exit_without_saving),
onConfirm = save,
onDismiss = revert,
@@ -224,6 +232,7 @@ fun PreviewGroupProfileLayout() {
SimpleXTheme {
GroupProfileLayout(
close = {},
groupInfo = GroupInfo.sampleData,
groupProfile = GroupProfile.sampleData,
saveProfile = { _ -> }
)
@@ -34,7 +34,7 @@ fun MemberAdmissionView(m: ChatModel, rhId: Long?, chatId: String, close: () ->
fun saveAdmission(afterSave: () -> Unit = {}) {
withBGApi {
val gp = gInfo.groupProfile.copy(memberAdmission = admission)
val g = m.controller.apiUpdateGroup(rhId, gInfo.groupId, gp)
val g = m.controller.apiUpdateGroup(rhId, gInfo.groupId, gp, gInfo.useRelays)
if (g != null) {
withContext(Dispatchers.Main) {
chatModel.chatsContext.updateGroup(rhId, g)
@@ -45,7 +45,7 @@ fun GroupWelcomeView(m: ChatModel, rhId: Long?, groupInfo: GroupInfo, close: ()
welcome = null
}
val groupProfileUpdated = gInfo.groupProfile.copy(description = welcome)
val res = m.controller.apiUpdateGroup(rhId, gInfo.groupId, groupProfileUpdated)
val res = m.controller.apiUpdateGroup(rhId, gInfo.groupId, groupProfileUpdated, gInfo.useRelays)
if (res != null) {
gInfo = res
withContext(Dispatchers.Main) {
@@ -1171,6 +1171,7 @@
<string name="save_and_notify_contact">Save and notify contact</string>
<string name="save_and_notify_contacts">Save and notify contacts</string>
<string name="save_and_notify_group_members">Save and notify group members</string>
<string name="save_and_notify_channel_subscribers">Save and notify channel subscribers</string>
<string name="exit_without_saving">Exit without saving</string>
<!-- HiddenProfileView.kt -->
@@ -1999,6 +2000,7 @@
<string name="group_is_decentralized">Fully decentralized visible only to members.</string>
<string name="group_display_name_field">Enter group name:</string>
<string name="group_full_name_field">Group full name:</string>
<string name="channel_full_name_field">Channel full name:</string>
<string name="group_short_descr_field">Short description:</string>
<string name="group_descr_too_large">Description too large</string>
<string name="group_main_profile_sent">Your chat profile will be sent to group members</string>
@@ -2007,8 +2009,11 @@
<!-- GroupProfileView.kt -->
<string name="group_profile_is_stored_on_members_devices">Group profile is stored on members\' devices, not on the servers.</string>
<string name="channel_profile_is_stored_on_subscribers_devices">Channel profile is stored on subscribers\' devices and on the chat relays.</string>
<string name="save_group_profile">Save group profile</string>
<string name="save_channel_profile">Save channel profile</string>
<string name="error_saving_group_profile">Error saving group profile</string>
<string name="error_saving_channel_profile">Error saving channel profile</string>
<!-- NetworkAndServers.kt -->
<string name="network_preset_servers_title">Preset servers</string>
@@ -1,6 +1,6 @@
{
"name": "@simplex-chat/types",
"version": "0.3.0",
"version": "0.4.0",
"description": "TypeScript types for SimpleX Chat bot libraries",
"main": "dist/index.js",
"types": "dist/index.d.ts",
+1 -1
View File
@@ -24,7 +24,7 @@
"docs": "typedoc"
},
"dependencies": {
"@simplex-chat/types": "^0.3.0",
"@simplex-chat/types": "^0.4.0",
"extract-zip": "^2.0.1",
"fast-deep-equal": "^3.1.3",
"node-addon-api": "^8.5.0"
+31 -1
View File
@@ -813,7 +813,7 @@ export class ChatApi {
* Network usage: no.
*/
async apiCreateActiveUser(profile?: T.Profile): Promise<T.User> {
const r = await this.sendChatCmd(CC.CreateActiveUser.cmdString({newUser: {profile, pastTimestamp: false}}))
const r = await this.sendChatCmd(CC.CreateActiveUser.cmdString({newUser: {profile, pastTimestamp: false, userChatRelay: false}}))
if (r.type === "activeUser") return r.user
throw new ChatCommandError("unexpected response", r)
}
@@ -872,4 +872,34 @@ export class ChatApi {
const r = await this.sendChatCmd(CC.APISetContactPrefs.cmdString({contactId, preferences}))
if (r.type !== "contactPrefsUpdated") throw new ChatCommandError("error setting contact prefs", r)
}
/**
* Create a direct message contact with a group member.
* Returns the created contact.
* Network usage: interactive.
*/
async apiCreateMemberContact(groupId: number, groupMemberId: number): Promise<T.Contact> {
const r: any = await this.sendChatCmd(`/_create member contact #${groupId} ${groupMemberId}`)
if (r.type === "newMemberContact") return r.contact
throw new ChatCommandError("error creating member contact", r)
}
/**
* Send a direct message invitation to a group member contact.
* The contact must have been created with {@link apiCreateMemberContact}.
* Network usage: interactive.
*/
async apiSendMemberContactInvitation(contactId: number, message?: T.MsgContent | string): Promise<T.Contact> {
let cmd = `/_invite member contact @${contactId}`
if (message !== undefined) {
if (typeof message === "string") {
cmd += ` text ${message}`
} else {
cmd += ` json ${JSON.stringify(message)}`
}
}
const r: any = await this.sendChatCmd(cmd)
if (r.type === "newMemberContactSentInv") return r.contact
throw new ChatCommandError("error sending member contact invitation", r)
}
}
@@ -64,4 +64,89 @@ describe("API tests (use preset servers)", () => {
expect(servers[0] !== servers[1]).toBe(true)
expect(eventCount > 0).toBe(true)
}, 30000)
it("should create member contact and send invitation", async () => {
// create 3 users and start chat controllers
const alice = await api.ChatApi.init(alicePath)
const bob = await api.ChatApi.init(bobPath)
const carolPath = path.join(tmpDir, "carol")
const carol = await api.ChatApi.init(carolPath)
const aliceUser = await alice.apiCreateActiveUser({displayName: "alice", fullName: ""})
await bob.apiCreateActiveUser({displayName: "bob", fullName: ""})
await carol.apiCreateActiveUser({displayName: "carol", fullName: ""})
await alice.startChat()
await bob.startChat()
await carol.startChat()
// connect alice <-> bob
const aliceLink1 = await alice.apiCreateLink(aliceUser.userId)
await expect(bob.apiConnectActiveUser(aliceLink1)).resolves.toBe(api.ConnReqType.Invitation)
const [bobContact] = await Promise.all([
(await alice.wait("contactConnected")).contact,
(await bob.wait("contactConnected")).contact
])
// connect alice <-> carol
const aliceLink2 = await alice.apiCreateLink(aliceUser.userId)
await expect(carol.apiConnectActiveUser(aliceLink2)).resolves.toBe(api.ConnReqType.Invitation)
const [carolContact] = await Promise.all([
(await alice.wait("contactConnected")).contact,
(await carol.wait("contactConnected")).contact
])
// create group with direct messages enabled
const group = await alice.apiNewGroup(aliceUser.userId, {
displayName: "test-group",
fullName: "",
groupPreferences: {
directMessages: {enable: T.GroupFeatureEnabled.On},
},
})
const groupId = group.groupId
// add bob to the group
const bobInvP = bob.wait("receivedGroupInvitation", 15000)
await alice.apiAddMember(groupId, bobContact.contactId, T.GroupMemberRole.Member)
const bobInvEvt = await bobInvP
expect(bobInvEvt).toBeDefined()
const aliceBobConnP = alice.wait("connectedToGroupMember", 15000)
const bobAliceConnP = bob.wait("connectedToGroupMember", 15000)
await bob.apiJoinGroup(bobInvEvt!.groupInfo.groupId)
await Promise.all([aliceBobConnP, bobAliceConnP])
// add carol to the group
const carolInvP = carol.wait("receivedGroupInvitation", 30000)
await alice.apiAddMember(groupId, carolContact.contactId, T.GroupMemberRole.Member)
const carolInvEvt = await carolInvP
expect(carolInvEvt).toBeDefined()
// wait for carol to connect to both alice and bob (and vice versa)
const bobCarolConnP = bob.wait("connectedToGroupMember",
(evt: CEvt.ConnectedToGroupMember) => evt.member.memberProfile.displayName === "carol", 30000)
const carolAliceConnP = carol.wait("connectedToGroupMember",
(evt: CEvt.ConnectedToGroupMember) => evt.member.memberProfile.displayName === "alice", 30000)
const carolBobConnP = carol.wait("connectedToGroupMember",
(evt: CEvt.ConnectedToGroupMember) => evt.member.memberProfile.displayName === "bob", 30000)
const aliceCarolConnP = alice.wait("connectedToGroupMember",
(evt: CEvt.ConnectedToGroupMember) => evt.member.memberProfile.displayName === "carol", 30000)
await carol.apiJoinGroup(carolInvEvt!.groupInfo.groupId)
await Promise.all([bobCarolConnP, carolAliceConnP, carolBobConnP, aliceCarolConnP])
// find carol's memberId from bob's perspective
const members = await bob.apiListMembers(groupId)
const carolMember = members.find(m => m.memberProfile.displayName === "carol")
expect(carolMember).toBeDefined()
// test apiCreateMemberContact
const dmContact = await bob.apiCreateMemberContact(groupId, carolMember!.groupMemberId)
expect(dmContact).toBeDefined()
expect(dmContact.contactId).toBeDefined()
// test apiSendMemberContactInvitation
const carolDmP = carol.wait("newMemberContactReceivedInv" as CEvt.Tag, 30000)
const invContact = await bob.apiSendMemberContactInvitation(dmContact.contactId, "hello from bob")
expect(invContact).toBeDefined()
// carol should receive the member contact invitation
const carolDmEvt = await carolDmP
expect(carolDmEvt).toBeDefined()
expect((carolDmEvt as any).contact).toBeDefined()
// cleanup
await alice.stopChat()
await bob.stopChat()
await carol.stopChat()
await alice.close()
await bob.close()
await carol.close()
}, 90000)
})
+1 -1
View File
@@ -5,7 +5,7 @@ cabal-version: 1.12
-- see: https://github.com/sol/hpack
name: simplex-chat
version: 6.5.0.12
version: 6.5.0.14
category: Web, System, Services, Cryptography
homepage: https://github.com/simplex-chat/simplex-chat#readme
author: simplex.chat