Merge branch 'master' into master-android
@@ -56,6 +56,7 @@ website/src/images/
|
||||
website/src/js/lottie.min.js
|
||||
website/src/js/ethers*
|
||||
website/src/file-assets/
|
||||
website/src/link-images/
|
||||
website/src/privacy.md
|
||||
# Generated files
|
||||
website/package/generated*
|
||||
|
||||
@@ -2122,6 +2122,7 @@ struct AppSettings: Codable, Equatable {
|
||||
var privacyAskToApproveRelays: Bool? = nil
|
||||
var privacyAcceptImages: Bool? = nil
|
||||
var privacyLinkPreviews: Bool? = nil
|
||||
var privacySanitizeLinks: Bool? = nil
|
||||
var privacyShowChatPreviews: Bool? = nil
|
||||
var privacySaveLastDraft: Bool? = nil
|
||||
var privacyProtectScreen: Bool? = nil
|
||||
@@ -2157,6 +2158,7 @@ struct AppSettings: Codable, Equatable {
|
||||
if privacyAskToApproveRelays != def.privacyAskToApproveRelays { empty.privacyAskToApproveRelays = privacyAskToApproveRelays }
|
||||
if privacyAcceptImages != def.privacyAcceptImages { empty.privacyAcceptImages = privacyAcceptImages }
|
||||
if privacyLinkPreviews != def.privacyLinkPreviews { empty.privacyLinkPreviews = privacyLinkPreviews }
|
||||
if privacySanitizeLinks != def.privacySanitizeLinks { empty.privacySanitizeLinks = privacySanitizeLinks }
|
||||
if privacyShowChatPreviews != def.privacyShowChatPreviews { empty.privacyShowChatPreviews = privacyShowChatPreviews }
|
||||
if privacySaveLastDraft != def.privacySaveLastDraft { empty.privacySaveLastDraft = privacySaveLastDraft }
|
||||
if privacyProtectScreen != def.privacyProtectScreen { empty.privacyProtectScreen = privacyProtectScreen }
|
||||
@@ -2193,6 +2195,7 @@ struct AppSettings: Codable, Equatable {
|
||||
privacyAskToApproveRelays: true,
|
||||
privacyAcceptImages: true,
|
||||
privacyLinkPreviews: true,
|
||||
privacySanitizeLinks: false,
|
||||
privacyShowChatPreviews: true,
|
||||
privacySaveLastDraft: true,
|
||||
privacyProtectScreen: false,
|
||||
|
||||
@@ -742,7 +742,7 @@ struct ComposeView: View {
|
||||
(relay, chatModel.groupMembers.first(where: { $0.wrapped.groupMemberId == relay.groupMemberId })?.wrapped)
|
||||
}
|
||||
let removedCount = relayMembers.filter { (_, m) in relayMemberRemoved(m?.memberStatus) }.count
|
||||
let activeCount = relayMembers.filter { (relay, m) in !relayMemberRemoved(m?.memberStatus) && relay.relayStatus == .rsActive && m?.activeConn?.connFailedErr == nil }.count
|
||||
let activeCount = relayMembers.filter { (relay, m) in !relayMemberRemoved(m?.memberStatus) && relay.relayStatus == .active && m?.activeConn?.connFailedErr == nil }.count
|
||||
let failedCount = relayMembers.filter { (_, m) in !relayMemberRemoved(m?.memberStatus) && m?.activeConn?.connFailedErr != nil }.count
|
||||
let noActiveRelays = activeCount == 0 && (failedCount + removedCount) == relays.count
|
||||
return (relays, activeCount, failedCount, removedCount, noActiveRelays)
|
||||
|
||||
@@ -37,7 +37,9 @@ struct ChannelRelaysView: View {
|
||||
}
|
||||
// TODO [relays] re-enable when relay management ships
|
||||
// .sheet(isPresented: $showAddRelay) {
|
||||
// let existingRelayIds = Set(groupRelays.filter { $0.relayStatus != .rsInactive }.compactMap { $0.userChatRelay.chatRelayId })
|
||||
// // Backend gate (APIAddGroupRelays) rejects any chatRelayId already in group_relays
|
||||
// // regardless of relayStatus, so all current rows must be excluded from the add list.
|
||||
// let existingRelayIds = Set(groupRelays.compactMap { $0.userChatRelay.chatRelayId })
|
||||
// AddGroupRelayView(groupInfo: groupInfo, existingRelayIds: existingRelayIds) {
|
||||
// Task { await chatModel.loadGroupMembers(groupInfo) }
|
||||
// }
|
||||
@@ -112,7 +114,10 @@ struct ChannelRelaysView: View {
|
||||
}
|
||||
|
||||
private func ownerRelayStatusText(_ member: GroupMember) -> LocalizedStringKey {
|
||||
if [.memLeft, .memRemoved, .memGroupDeleted].contains(member.memberStatus) {
|
||||
let relayStatus = groupRelays.first(where: { $0.groupMemberId == member.groupMemberId })?.relayStatus
|
||||
return if relayStatus == .rejected {
|
||||
"rejected"
|
||||
} else if [.memLeft, .memRemoved, .memGroupDeleted].contains(member.memberStatus) {
|
||||
relayConnStatus(member).text
|
||||
} else if case .failed = member.activeConn?.connStatus {
|
||||
"failed"
|
||||
@@ -121,8 +126,7 @@ struct ChannelRelaysView: View {
|
||||
} else if member.activeConn?.connInactive ?? false {
|
||||
"inactive"
|
||||
} else {
|
||||
groupRelays.first(where: { $0.groupMemberId == member.groupMemberId })?.relayStatus.text
|
||||
?? relayConnStatus(member).text
|
||||
relayStatus?.text ?? relayConnStatus(member).text
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -199,6 +199,9 @@ struct GroupMemberInfoView: View {
|
||||
Label("Share relay address", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
if groupRelay?.relayStatus == .rejected {
|
||||
infoRow("Status", "rejected by relay operator")
|
||||
}
|
||||
} header: {
|
||||
Text(channelMemberSectionHeader).foregroundColor(theme.colors.secondary)
|
||||
} footer: {
|
||||
|
||||
@@ -281,7 +281,7 @@ struct AddChannelView: View {
|
||||
|
||||
private func progressStepView(_ gInfo: GroupInfo) -> some View {
|
||||
let failedCount = groupRelays.filter { relayMemberConnFailed($0) != nil }.count
|
||||
let activeCount = groupRelays.filter { $0.relayStatus == .rsActive && relayMemberConnFailed($0) == nil }.count
|
||||
let activeCount = groupRelays.filter { $0.relayStatus == .active && relayMemberConnFailed($0) == nil }.count
|
||||
let total = groupRelays.count
|
||||
return List {
|
||||
Group {
|
||||
@@ -376,7 +376,7 @@ struct AddChannelView: View {
|
||||
.onChange(of: channelRelaysModel.groupRelays) { relays in
|
||||
guard channelRelaysModel.groupId == gInfo.groupId else { return }
|
||||
groupRelays = relays.sorted { relayDisplayName($0) < relayDisplayName($1) }
|
||||
if relays.allSatisfy({ $0.relayStatus == .rsActive && relayMemberConnFailed($0) == nil }) {
|
||||
if relays.allSatisfy({ $0.relayStatus == .active && relayMemberConnFailed($0) == nil }) {
|
||||
showLinkStep = true
|
||||
channelRelaysModel.reset()
|
||||
}
|
||||
@@ -433,7 +433,7 @@ struct AddChannelView: View {
|
||||
}
|
||||
|
||||
private func showCancelChannelAlert(_ gInfo: GroupInfo) {
|
||||
let activeCount = groupRelays.filter { $0.relayStatus == .rsActive && relayMemberConnFailed($0) == nil }.count
|
||||
let activeCount = groupRelays.filter { $0.relayStatus == .active && relayMemberConnFailed($0) == nil }.count
|
||||
let total = groupRelays.count
|
||||
showAlert(
|
||||
NSLocalizedString("Cancel creating channel?", comment: "alert title"),
|
||||
@@ -486,8 +486,14 @@ func chatRelayDisplayName(_ relay: UserChatRelay) -> String {
|
||||
|
||||
func relayStatusIndicator(_ status: RelayStatus, connFailed: Bool = false, memberStatus: GroupMemberStatus? = nil) -> some View {
|
||||
let removed = memberStatus.map { [.memLeft, .memRemoved, .memGroupDeleted].contains($0) } ?? false
|
||||
let color: Color = connFailed || removed ? .red : (status == .rsActive ? .green : .yellow)
|
||||
let text: LocalizedStringKey = connFailed ? "failed" : memberStatus == .memLeft ? "removed by operator" : removed ? "removed" : status.text
|
||||
let isRejected = status == .rejected
|
||||
let color: Color = connFailed || removed || isRejected ? .red : (status == .active ? .green : .yellow)
|
||||
let text: LocalizedStringKey =
|
||||
connFailed ? "failed"
|
||||
: isRejected ? "rejected"
|
||||
: memberStatus == .memLeft ? "removed by operator"
|
||||
: removed ? "removed"
|
||||
: status.text
|
||||
return HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
|
||||
@@ -38,6 +38,7 @@ extension AppSettings {
|
||||
privacyLinkPreviewsGroupDefault.set(val)
|
||||
def.setValue(val, forKey: DEFAULT_PRIVACY_LINK_PREVIEWS)
|
||||
}
|
||||
if let val = privacySanitizeLinks { privacySanitizeLinksGroupDefault.set(val) }
|
||||
if let val = privacyShowChatPreviews { def.setValue(val, forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) }
|
||||
if let val = privacySaveLastDraft { def.setValue(val, forKey: DEFAULT_PRIVACY_SAVE_LAST_DRAFT) }
|
||||
if let val = privacyProtectScreen { def.setValue(val, forKey: DEFAULT_PRIVACY_PROTECT_SCREEN) }
|
||||
@@ -77,6 +78,7 @@ extension AppSettings {
|
||||
c.privacyAskToApproveRelays = privacyAskToApproveRelaysGroupDefault.get()
|
||||
c.privacyAcceptImages = privacyAcceptImagesGroupDefault.get()
|
||||
c.privacyLinkPreviews = privacyLinkPreviewsGroupDefault.get()
|
||||
c.privacySanitizeLinks = privacySanitizeLinksGroupDefault.get()
|
||||
c.privacyShowChatPreviews = def.bool(forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS)
|
||||
c.privacySaveLastDraft = def.bool(forKey: DEFAULT_PRIVACY_SAVE_LAST_DRAFT)
|
||||
c.privacyProtectScreen = def.bool(forKey: DEFAULT_PRIVACY_PROTECT_SCREEN)
|
||||
|
||||
@@ -237,6 +237,8 @@ public let privacyEncryptLocalFilesGroupDefault = BoolDefault(defaults: groupDef
|
||||
|
||||
public let privacyAskToApproveRelaysGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_ASK_TO_APPROVE_RELAYS)
|
||||
|
||||
public let privacySanitizeLinksGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS)
|
||||
|
||||
public let profileImageCornerRadiusGroupDefault = Default<Double>(defaults: groupDefaults, forKey: GROUP_DEFAULT_PROFILE_IMAGE_CORNER_RADIUS)
|
||||
|
||||
public let ntfBadgeCountGroupDefault = IntDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_NTF_BADGE_COUNT)
|
||||
|
||||
@@ -2635,11 +2635,12 @@ public struct GroupShortLinkData: Codable, Hashable {
|
||||
}
|
||||
|
||||
public enum RelayStatus: String, Decodable, Equatable, Hashable {
|
||||
case rsNew = "new"
|
||||
case rsInvited = "invited"
|
||||
case rsAccepted = "accepted"
|
||||
case rsActive = "active"
|
||||
case rsInactive = "inactive"
|
||||
case new
|
||||
case invited
|
||||
case accepted
|
||||
case active
|
||||
case inactive
|
||||
case rejected
|
||||
}
|
||||
|
||||
public struct RelayProfile: Codable, Equatable, Hashable {
|
||||
@@ -2708,11 +2709,12 @@ public struct GroupRelay: Identifiable, Decodable, Equatable, Hashable {
|
||||
extension RelayStatus {
|
||||
public var text: LocalizedStringKey {
|
||||
switch self {
|
||||
case .rsNew: "new"
|
||||
case .rsInvited: "invited"
|
||||
case .rsAccepted: "accepted"
|
||||
case .rsActive: "active"
|
||||
case .rsInactive: "inactive"
|
||||
case .new: "new"
|
||||
case .invited: "invited"
|
||||
case .accepted: "accepted"
|
||||
case .active: "active"
|
||||
case .inactive: "inactive"
|
||||
case .rejected: "rejected"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ This document provides a structured mapping between product-level concepts, thei
|
||||
| 28 | Chat Tags | [views/chat-list.md](views/chat-list.md) | [spec/state.md](../spec/state.md) | `ChatList/TagListView.swift`, `ChatListView.swift` | `Types.hs` (`ChatTag`), `Controller.hs` |
|
||||
| 29 | User Address | [views/settings.md](views/settings.md) | [spec/api.md](../spec/api.md) | `UserSettings/UserAddressView.swift`, `Onboarding/AddressCreationCard.swift` | `Controller.hs` (`APICreateMyAddress`) |
|
||||
| 30 | Member Support Chat | [views/group-info.md](views/group-info.md) | [spec/api.md](../spec/api.md) | `Group/MemberSupportView.swift`, `MemberAdmissionView.swift` | `Messages.hs` (`GroupChatScope`), `Controller.hs` |
|
||||
| 31 | Channels (Relays) | [glossary.md](glossary.md), [views/chat.md](views/chat.md) | [spec/api.md](../spec/api.md), [spec/state.md](../spec/state.md), [spec/client/chat-view.md](../spec/client/chat-view.md), [spec/client/compose.md](../spec/client/compose.md) | `SimpleXChat/ChatTypes.swift` (`RelayStatus`, `RelayStatus.text`, `GroupRelay`, `GroupMemberRole.relay`, `CIDirection.channelRcv`, `GroupInfo.chatIconName`, `userCantSendReason`), `Shared/Views/Chat/ChatView.swift` (channel message rendering), `Shared/Views/Chat/ComposeMessage/ComposeView.swift` (`sendAsGroup`, Broadcast placeholder), `Shared/Views/Chat/Group/GroupChatInfoView.swift` (channel info adaptations), `Shared/Views/Chat/Group/ChannelMembersView.swift`, `Shared/Views/Chat/Group/ChannelRelaysView.swift`, `Shared/Model/AppAPITypes.swift` (`GroupShortLinkInfo`, `UserChatRelay`), `Shared/Model/SimpleXAPI.swift` (`apiNewPublicGroup`), `SimpleX SE/ShareAPI.swift` (channel `sendAsGroup`) | `Controller.hs` (`APINewPublicGroup`) |
|
||||
| 31 | Channels (Relays) | [glossary.md](glossary.md), [views/chat.md](views/chat.md), [views/group-info.md](views/group-info.md) | [spec/api.md](../spec/api.md), [spec/state.md](../spec/state.md), [spec/client/chat-view.md](../spec/client/chat-view.md), [spec/client/compose.md](../spec/client/compose.md) | `SimpleXChat/ChatTypes.swift` (`RelayStatus` incl. `.rsRejected`, `RelayStatus.text`, `GroupRelay`, `GroupMemberRole.relay`, `GroupMemberStatus.memRejected`, `CIDirection.channelRcv`, `GroupInfo.chatIconName`, `userCantSendReason`), `Shared/Views/Chat/ChatView.swift` (channel message rendering), `Shared/Views/Chat/ComposeMessage/ComposeView.swift` (`sendAsGroup`, Broadcast placeholder), `Shared/Views/Chat/Group/GroupChatInfoView.swift` (channel info adaptations), `Shared/Views/Chat/Group/GroupMemberInfoView.swift` (rejected-status row), `Shared/Views/Chat/Group/ChannelMembersView.swift`, `Shared/Views/Chat/Group/ChannelRelaysView.swift`, `Shared/Views/NewChat/AddChannelView.swift` (`relayStatusIndicator` rejected branch), `Shared/Model/AppAPITypes.swift` (`GroupShortLinkInfo`, `UserChatRelay`), `Shared/Model/SimpleXAPI.swift` (`apiNewPublicGroup`), `SimpleX SE/ShareAPI.swift` (channel `sendAsGroup`) | `Controller.hs` (`APINewPublicGroup`, `APIAllowRelayGroup`, `XGrpRelayReject` CONF handler) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -188,6 +188,7 @@ New view accessible from channel info, showing relay members (role == `.relay`):
|
||||
| Relay list | Filtered from `chatModel.groupMembers` by `.relay` role |
|
||||
| Relay row | Profile image, relay display name, status text (`RelayStatus` or connection status) |
|
||||
| Relay tap | NavigationLink to `GroupMemberInfoView` with `groupRelay:` parameter |
|
||||
| Add relay sheet | Owner-only "Add relay" button opens `AddGroupRelayView`; the available-to-add list excludes any `chatRelayId` already present in `groupRelays` (regardless of `relayStatus`), so inactive or rejected relays cannot be re-added without first removing them via the row's swipe action |
|
||||
| Empty state | "No chat relays" |
|
||||
| Footer | "Chat relays forward messages to channel subscribers." |
|
||||
|
||||
@@ -221,6 +222,7 @@ Owner sees relay status from `apiGetGroupRelays`; non-owner sees connection stat
|
||||
| "Unblock for all?" alert | "Unblock subscriber for all?" |
|
||||
| Relay link info row | Shown when `member.relayLink` exists, displays `hostFromRelayLink(link)` |
|
||||
| Relay address info row | Shown when `groupRelay?.userChatRelay.address` exists, with "Share relay address" button |
|
||||
| Status row (rejected) | Shown when `groupRelay?.relayStatus == .rsRejected`: "Status: rejected by relay operator". The relay rejected the invitation to rejoin this channel after a prior `/leave`; the owner-side `GroupMember.memberStatus` is also set to `.memLeft` so the relay renders identically to one that explicitly left. Clearable only by the relay operator running `/group allow #<channel>`. |
|
||||
| Relay footer | Owner: "Subscribers use relay link to connect to the channel. Relay address was used to set up this relay for the channel." Non-owner: "You connected to the channel via this relay link." |
|
||||
|
||||
## Related Specs
|
||||
|
||||
@@ -415,6 +415,7 @@ Event processing entry point: [`processReceivedMsg`](../Shared/Model/SimpleXAPI.
|
||||
| `groupUpdated` | `user, toGroup: GroupInfo` | Group profile changed | [L1106](../Shared/Model/AppAPITypes.swift#L1106) |
|
||||
| `groupLinkRelaysUpdated` | `user, groupInfo, groupLink, groupRelays: [GroupRelay]` | Channel relay configuration changed | [L1107](../Shared/Model/AppAPITypes.swift#L1107) |
|
||||
| `groupMemberUpdated` | `user, groupInfo, fromMember, toMember` | Member info updated | [L1081](../Shared/Model/AppAPITypes.swift#L1081) |
|
||||
| `groupRelayUpdated` | `user, groupInfo, member, groupRelay` | Owner-side: a relay's `relayStatus` and/or the member's status changed. Fires on `XGrpRelayReject` with `relayStatus = .rsRejected` and `member.memberStatus = .memRejected` — final until cleared by the relay operator's `/group allow <groupId>` (no event emitted to the owner for that clear). | Controller.hs (`CEvtGroupRelayUpdated`) |
|
||||
|
||||
### File Transfer Events
|
||||
|
||||
|
||||
@@ -350,8 +350,13 @@ Groups use separate [`groupLinkButton()`](../../Shared/Views/Chat/Group/GroupCha
|
||||
### [`channelRelaysButton()`](../../Shared/Views/Chat/Group/GroupChatInfoView.swift#L639) → [`ChannelRelaysView`](../../Shared/Views/Chat/Group/ChannelRelaysView.swift)
|
||||
|
||||
Navigates to relay list view with role-based branches:
|
||||
- **Owner**: loads `[GroupRelay]` via [`apiGetGroupRelays`](../../Shared/Model/SimpleXAPI.swift#L1839) (owner-only API, guarded by `assertUserGroupRole GROwner` on backend). Joins with `chatModel.groupMembers` by `groupMemberId` for display names. Shows status indicators (colored circle + `RelayStatus.text`).
|
||||
- **Owner**: loads `[GroupRelay]` via [`apiGetGroupRelays`](../../Shared/Model/SimpleXAPI.swift#L1839) (owner-only API, guarded by `assertUserGroupRole GROwner` on backend). Joins with `chatModel.groupMembers` by `groupMemberId` for display names. Shows status indicators (colored circle + `RelayStatus.text`). When `relayStatus == .rsRejected` the indicator dot is red and the text reads "rejected", matching the `connFailed`/`removed` rendering.
|
||||
- **Member**: filters `chatModel.groupMembers` by `.memberRole == .relay`. Shows relay member display names only (no status data).
|
||||
- **Add relay sheet**: `existingRelayIds` excludes every `chatRelayId` present in `groupRelays` regardless of `relayStatus`, so an already-listed relay (including `.rsInactive` and `.rsRejected`) cannot be re-added from the sheet. This mirrors the backend gate at `APIAddGroupRelays` (`existingRelayIds`), which rejects duplicate `chatRelayId`s; operator must remove the relay first via the swipe action.
|
||||
|
||||
### Relay Rejection Surface
|
||||
|
||||
When a relay operator runs `/leave #channel`, the relay sends `x.grp.relay.reject` over the owner-relay direct contact channel. Owner-side handling: the corresponding `GroupRelay.relayStatus` transitions `RSInvited → RSRejected`; the relay's `GroupMember.memberStatus` is set to `.memLeft` so the owner UI renders the rejected relay identically to one that explicitly ran `/leave` (`.memRejected` is reserved for the knocking-admission flow). The transition surfaces through `CEvtGroupRelayUpdated`. In `GroupMemberInfoView`, an additional `Status: rejected by relay operator` info row appears when `groupRelay?.relayStatus == .rsRejected`. The status is final on the owner side — clearable only by the relay operator running `/group allow #<channel>`, which has no owner-facing event.
|
||||
|
||||
### Leave Button Logic
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
| Shared/Views/Chat/Group/ChannelRelaysView.swift | PC31 | Medium | Channel relay status list |
|
||||
| Shared/Views/Chat/Group/AddGroupMembersView.swift | PC14, PC16 | Medium | Member invitation flow |
|
||||
| Shared/Views/Chat/Group/GroupLinkView.swift | PC15 | Low | Group link creation/sharing |
|
||||
| Shared/Views/Chat/Group/GroupMemberInfoView.swift | PC3, PC14, PC16, PC30 | Medium | Member details and role management |
|
||||
| Shared/Views/Chat/Group/GroupMemberInfoView.swift | PC3, PC14, PC16, PC30, PC31 | Medium | Member details and role management; rejected-by-operator status row for relay members |
|
||||
| Shared/Views/NewChat/NewChatView.swift | PC12, PC31 | High | New connection creation — onramp for all contacts and channels |
|
||||
| Shared/Views/NewChat/QRCode.swift | PC12 | Low | QR code display/scanning utility |
|
||||
| Shared/Views/Call/ActiveCallView.swift | PC17 | Medium | Call UI rendering |
|
||||
|
||||
@@ -390,8 +390,8 @@ A **channel** is a group with `groupInfo.useRelays == true`. These types support
|
||||
|
||||
| Type | Kind | Description | Line |
|
||||
|------|------|-------------|------|
|
||||
| `RelayStatus` | `enum` | Relay lifecycle: `.rsNew`, `.rsInvited`, `.rsAccepted`, `.rsActive` | [L2506](../SimpleXChat/ChatTypes.swift#L2506) |
|
||||
| `RelayStatus.text` | `extension` | Localized display text: New/Invited/Accepted/Active | [L2565](../SimpleXChat/ChatTypes.swift#L2565) |
|
||||
| `RelayStatus` | `enum` | Relay lifecycle: `.rsNew`, `.rsInvited`, `.rsAccepted`, `.rsActive`, `.rsInactive`, `.rsRejected` | [L2506](../SimpleXChat/ChatTypes.swift#L2506) |
|
||||
| `RelayStatus.text` | `extension` | Localized display text: New/Invited/Accepted/Active/Inactive/Rejected | [L2565](../SimpleXChat/ChatTypes.swift#L2565) |
|
||||
| `GroupRelay` | `struct` | Relay instance for a group (ID, member ID, relay status). Fetched at runtime via `apiGetGroupRelays` (owner only) | [L2555](../SimpleXChat/ChatTypes.swift#L2555) |
|
||||
| `UserChatRelay` | `struct` | User's chat relay configuration (ID, SMP address, name, domains, preset/tested/enabled/deleted flags) | [L2513](../SimpleXChat/ChatTypes.swift#L2513) |
|
||||
|
||||
|
||||
@@ -2286,18 +2286,20 @@ data class GroupShortLinkData (
|
||||
|
||||
@Serializable
|
||||
enum class RelayStatus {
|
||||
@SerialName("new") RsNew,
|
||||
@SerialName("invited") RsInvited,
|
||||
@SerialName("accepted") RsAccepted,
|
||||
@SerialName("active") RsActive,
|
||||
@SerialName("inactive") RsInactive;
|
||||
@SerialName("new") New,
|
||||
@SerialName("invited") Invited,
|
||||
@SerialName("accepted") Accepted,
|
||||
@SerialName("active") Active,
|
||||
@SerialName("inactive") Inactive,
|
||||
@SerialName("rejected") Rejected;
|
||||
|
||||
val text: String get() = when (this) {
|
||||
RsNew -> generalGetString(MR.strings.relay_status_new)
|
||||
RsInvited -> generalGetString(MR.strings.relay_status_invited)
|
||||
RsAccepted -> generalGetString(MR.strings.relay_status_accepted)
|
||||
RsActive -> generalGetString(MR.strings.relay_status_active)
|
||||
RsInactive -> generalGetString(MR.strings.relay_status_inactive)
|
||||
New -> generalGetString(MR.strings.relay_status_new)
|
||||
Invited -> generalGetString(MR.strings.relay_status_invited)
|
||||
Accepted -> generalGetString(MR.strings.relay_status_accepted)
|
||||
Active -> generalGetString(MR.strings.relay_status_active)
|
||||
Inactive -> generalGetString(MR.strings.relay_status_inactive)
|
||||
Rejected -> generalGetString(MR.strings.relay_status_rejected)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8042,6 +8042,7 @@ data class AppSettings(
|
||||
var privacyAskToApproveRelays: Boolean? = null,
|
||||
var privacyAcceptImages: Boolean? = null,
|
||||
var privacyLinkPreviews: Boolean? = null,
|
||||
var privacySanitizeLinks: Boolean? = null,
|
||||
var privacyChatListOpenLinks: PrivacyChatListOpenLinksMode? = null,
|
||||
var privacyShowChatPreviews: Boolean? = null,
|
||||
var privacySaveLastDraft: Boolean? = null,
|
||||
@@ -8078,6 +8079,7 @@ data class AppSettings(
|
||||
if (privacyAskToApproveRelays != def.privacyAskToApproveRelays) { empty.privacyAskToApproveRelays = privacyAskToApproveRelays }
|
||||
if (privacyAcceptImages != def.privacyAcceptImages) { empty.privacyAcceptImages = privacyAcceptImages }
|
||||
if (privacyLinkPreviews != def.privacyLinkPreviews) { empty.privacyLinkPreviews = privacyLinkPreviews }
|
||||
if (privacySanitizeLinks != def.privacySanitizeLinks) { empty.privacySanitizeLinks = privacySanitizeLinks }
|
||||
if (privacyChatListOpenLinks != def.privacyChatListOpenLinks) { empty.privacyChatListOpenLinks = privacyChatListOpenLinks }
|
||||
if (privacyShowChatPreviews != def.privacyShowChatPreviews) { empty.privacyShowChatPreviews = privacyShowChatPreviews }
|
||||
if (privacySaveLastDraft != def.privacySaveLastDraft) { empty.privacySaveLastDraft = privacySaveLastDraft }
|
||||
@@ -8125,6 +8127,7 @@ data class AppSettings(
|
||||
privacyAskToApproveRelays?.let { def.privacyAskToApproveRelays.set(it) }
|
||||
privacyAcceptImages?.let { def.privacyAcceptImages.set(it) }
|
||||
privacyLinkPreviews?.let { def.privacyLinkPreviews.set(it) }
|
||||
privacySanitizeLinks?.let { def.privacySanitizeLinks.set(it) }
|
||||
privacyChatListOpenLinks?.let { def.privacyChatListOpenLinks.set(it) }
|
||||
privacyShowChatPreviews?.let { def.privacyShowChatPreviews.set(it) }
|
||||
privacySaveLastDraft?.let { def.privacySaveLastDraft.set(it) }
|
||||
@@ -8162,6 +8165,7 @@ data class AppSettings(
|
||||
privacyAskToApproveRelays = true,
|
||||
privacyAcceptImages = true,
|
||||
privacyLinkPreviews = true,
|
||||
privacySanitizeLinks = false,
|
||||
privacyChatListOpenLinks = PrivacyChatListOpenLinksMode.ASK,
|
||||
privacyShowChatPreviews = true,
|
||||
privacySaveLastDraft = true,
|
||||
@@ -8200,6 +8204,7 @@ data class AppSettings(
|
||||
privacyAskToApproveRelays = def.privacyAskToApproveRelays.get(),
|
||||
privacyAcceptImages = def.privacyAcceptImages.get(),
|
||||
privacyLinkPreviews = def.privacyLinkPreviews.get(),
|
||||
privacySanitizeLinks = def.privacySanitizeLinks.get(),
|
||||
privacyChatListOpenLinks = def.privacyChatListOpenLinks.get(),
|
||||
privacyShowChatPreviews = def.privacyShowChatPreviews.get(),
|
||||
privacySaveLastDraft = def.privacySaveLastDraft.get(),
|
||||
|
||||
@@ -2011,7 +2011,7 @@ private fun ownerRelayState(chat: Chat, chatModel: ChatModel): OwnerRelayState?
|
||||
relay to chatModel.groupMembers.value.firstOrNull { it.groupMemberId == relay.groupMemberId }
|
||||
}
|
||||
val removedCount = relayMembers.count { (_, m) -> relayMemberRemoved(m?.memberStatus) }
|
||||
val activeCount = relayMembers.count { (relay, m) -> !relayMemberRemoved(m?.memberStatus) && relay.relayStatus == RelayStatus.RsActive && m?.activeConn?.connFailedErr == null }
|
||||
val activeCount = relayMembers.count { (relay, m) -> !relayMemberRemoved(m?.memberStatus) && relay.relayStatus == RelayStatus.Active && m?.activeConn?.connFailedErr == null }
|
||||
val failedCount = relayMembers.count { (_, m) -> !relayMemberRemoved(m?.memberStatus) && m?.activeConn?.connFailedErr != null }
|
||||
val noActiveRelays = activeCount == 0 && (failedCount + removedCount) == relays.size
|
||||
return OwnerRelayState(relays, activeCount, failedCount, removedCount, noActiveRelays)
|
||||
|
||||
@@ -114,7 +114,9 @@ private fun ChannelRelaysLayout(
|
||||
if (groupInfo.isOwner) {
|
||||
SectionView {
|
||||
SectionItemView(click = {
|
||||
val existingRelayIds = groupRelays.filter { it.relayStatus != RelayStatus.RsInactive }.mapNotNull { it.userChatRelay.chatRelayId }.toSet()
|
||||
// Backend gate (APIAddGroupRelays) rejects any chatRelayId already in group_relays
|
||||
// regardless of relayStatus, so all current rows must be excluded from the add list.
|
||||
val existingRelayIds = groupRelays.mapNotNull { it.userChatRelay.chatRelayId }.toSet()
|
||||
ModalManager.end.showModalCloseable(true) { close ->
|
||||
AddGroupRelayView(
|
||||
groupInfo = groupInfo,
|
||||
@@ -179,7 +181,10 @@ private fun subscriberRelayStatusText(member: GroupMember): String {
|
||||
}
|
||||
|
||||
private fun ownerRelayStatusText(member: GroupMember, groupRelays: List<GroupRelay>): String {
|
||||
return if (member.memberStatus in listOf(GroupMemberStatus.MemLeft, GroupMemberStatus.MemRemoved, GroupMemberStatus.MemGroupDeleted)) {
|
||||
val relayStatus = groupRelays.firstOrNull { it.groupMemberId == member.groupMemberId }?.relayStatus
|
||||
return if (relayStatus == RelayStatus.Rejected) {
|
||||
generalGetString(MR.strings.relay_status_rejected)
|
||||
} else if (member.memberStatus in listOf(GroupMemberStatus.MemLeft, GroupMemberStatus.MemRemoved, GroupMemberStatus.MemGroupDeleted)) {
|
||||
relayConnStatus(member).first
|
||||
} else if (member.activeConn?.connStatus is ConnStatus.Failed) {
|
||||
generalGetString(MR.strings.relay_conn_status_failed)
|
||||
@@ -188,8 +193,7 @@ private fun ownerRelayStatusText(member: GroupMember, groupRelays: List<GroupRel
|
||||
} else if (member.activeConn?.connInactive == true) {
|
||||
generalGetString(MR.strings.member_info_member_inactive)
|
||||
} else {
|
||||
groupRelays.firstOrNull { it.groupMemberId == member.groupMemberId }?.relayStatus?.text
|
||||
?: relayConnStatus(member).first
|
||||
relayStatus?.text ?: relayConnStatus(member).first
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -616,6 +616,9 @@ fun GroupMemberInfoLayout(
|
||||
val clipboard = LocalClipboardManager.current
|
||||
ShareRelayAddressButton { clipboard.shareText(simplexChatLink(relayAddress)) }
|
||||
}
|
||||
if (groupRelay?.relayStatus == RelayStatus.Rejected) {
|
||||
InfoRow(stringResource(MR.strings.member_info_status), stringResource(MR.strings.member_info_relay_status_rejected_by_operator))
|
||||
}
|
||||
}
|
||||
if (groupInfo.useRelays && member.memberRole == GroupMemberRole.Relay) {
|
||||
SectionTextFooter(
|
||||
|
||||
@@ -361,11 +361,11 @@ private fun ProgressStepView(
|
||||
cancelChannelCreation: () -> Unit
|
||||
) {
|
||||
val failedCount = groupRelays.value.count { relayMemberConnFailed(chatModel, it) != null }
|
||||
val activeCount = groupRelays.value.count { it.relayStatus == RelayStatus.RsActive && relayMemberConnFailed(chatModel, it) == null }
|
||||
val activeCount = groupRelays.value.count { it.relayStatus == RelayStatus.Active && relayMemberConnFailed(chatModel, it) == null }
|
||||
val total = groupRelays.value.size
|
||||
|
||||
fun showCancelAlert() {
|
||||
val active = groupRelays.value.count { it.relayStatus == RelayStatus.RsActive && relayMemberConnFailed(chatModel, it) == null }
|
||||
val active = groupRelays.value.count { it.relayStatus == RelayStatus.Active && relayMemberConnFailed(chatModel, it) == null }
|
||||
val tot = groupRelays.value.size
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.cancel_creating_channel_question),
|
||||
@@ -394,7 +394,7 @@ private fun ProgressStepView(
|
||||
.collect { relays ->
|
||||
if (ChannelRelaysModel.groupId.value != gInfo.groupId) return@collect
|
||||
groupRelays.value = relays.sortedBy { relayDisplayName(it) }
|
||||
if (relays.all { it.relayStatus == RelayStatus.RsActive && relayMemberConnFailed(chatModel, it) == null }) {
|
||||
if (relays.all { it.relayStatus == RelayStatus.Active && relayMemberConnFailed(chatModel, it) == null }) {
|
||||
onLinkReady()
|
||||
ChannelRelaysModel.reset()
|
||||
}
|
||||
@@ -596,8 +596,14 @@ fun chatRelayDisplayName(relay: UserChatRelay): String {
|
||||
@Composable
|
||||
fun RelayStatusIndicator(status: RelayStatus, connFailed: Boolean = false, memberStatus: GroupMemberStatus? = null) {
|
||||
val removed = memberStatus in listOf(GroupMemberStatus.MemLeft, GroupMemberStatus.MemRemoved, GroupMemberStatus.MemGroupDeleted)
|
||||
val color = if (connFailed || removed) Color.Red else if (status == RelayStatus.RsActive) Color.Green else WarningYellow
|
||||
val text = if (connFailed) generalGetString(MR.strings.relay_status_failed) else if (memberStatus == GroupMemberStatus.MemLeft) generalGetString(MR.strings.relay_conn_status_removed_by_operator) else if (removed) generalGetString(MR.strings.relay_conn_status_removed) else status.text
|
||||
val isRejected = status == RelayStatus.Rejected
|
||||
val color = if (connFailed || removed || isRejected) Color.Red else if (status == RelayStatus.Active) Color.Green else WarningYellow
|
||||
val text =
|
||||
if (connFailed) generalGetString(MR.strings.relay_status_failed)
|
||||
else if (isRejected) generalGetString(MR.strings.relay_status_rejected)
|
||||
else if (memberStatus == GroupMemberStatus.MemLeft) generalGetString(MR.strings.relay_conn_status_removed_by_operator)
|
||||
else if (removed) generalGetString(MR.strings.relay_conn_status_removed)
|
||||
else status.text
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
<string name="non_content_uri_alert_title">Invalid file path</string>
|
||||
<string name="non_content_uri_alert_text">You shared an invalid file path. Report the issue to the app developers.</string>
|
||||
<string name="app_was_crashed">View crashed</string>
|
||||
<string name="another_instance_title">App is already running</string>
|
||||
<string name="another_instance_not_responding">Another app instance may be running or did not exit properly. Start anyway?</string>
|
||||
|
||||
<!-- Server info - ChatModel.kt -->
|
||||
<string name="server_connected">connected</string>
|
||||
@@ -2994,6 +2996,9 @@
|
||||
<string name="relay_status_accepted">accepted</string>
|
||||
<string name="relay_status_active">active</string>
|
||||
<string name="relay_status_inactive">inactive</string>
|
||||
<string name="relay_status_rejected">rejected</string>
|
||||
<string name="member_info_status">Status</string>
|
||||
<string name="member_info_relay_status_rejected_by_operator">rejected by relay operator</string>
|
||||
|
||||
<!-- ComposeView.kt channel relay bars -->
|
||||
<string name="relay_bar_all_relays_removed">All relays removed</string>
|
||||
|
||||
@@ -23,6 +23,7 @@ import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.*
|
||||
import java.awt.Frame
|
||||
import java.awt.event.WindowEvent
|
||||
import java.awt.event.WindowFocusListener
|
||||
import java.io.File
|
||||
@@ -241,10 +242,10 @@ private fun ApplicationScope.handleCloseRequest(closedByError: MutableState<Bool
|
||||
val pref = ChatController.appPrefs.closeBehavior
|
||||
when (pref.get()) {
|
||||
CloseBehavior.Quit -> exitApplication()
|
||||
CloseBehavior.MinimizeToTray -> if (trayIsAvailable) {
|
||||
CloseBehavior.MinimizeToTray -> if (trayIsAvailable && singleInstanceLock) {
|
||||
simplexWindowState.windowVisible.value = false
|
||||
} else exitApplication()
|
||||
CloseBehavior.Ask -> if (trayIsAvailable) {
|
||||
CloseBehavior.Ask -> if (trayIsAvailable && singleInstanceLock) {
|
||||
requestCloseBehavior()
|
||||
} else {
|
||||
// Tray unavailable — Minimize is not a real option; remember Quit and exit.
|
||||
@@ -254,6 +255,17 @@ private fun ApplicationScope.handleCloseRequest(closedByError: MutableState<Bool
|
||||
}
|
||||
}
|
||||
|
||||
fun showWindow() {
|
||||
simplexWindowState.windowVisible.value = true
|
||||
simplexWindowState.window?.apply {
|
||||
// Clear ICONIFIED so a minimized window un-minimizes; preserves MAXIMIZED_BOTH
|
||||
// when set. toFront() alone does not un-minimize on any AWT platform.
|
||||
extendedState = extendedState and Frame.ICONIFIED.inv()
|
||||
toFront()
|
||||
requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
class SimplexWindowState {
|
||||
lateinit var windowState: WindowState
|
||||
val backstack = mutableStateListOf<() -> Unit>()
|
||||
|
||||
@@ -47,12 +47,6 @@ val trayIsAvailable: Boolean by lazy {
|
||||
}
|
||||
}
|
||||
|
||||
fun showWindow() {
|
||||
simplexWindowState.windowVisible.value = true
|
||||
simplexWindowState.window?.toFront()
|
||||
simplexWindowState.window?.requestFocus()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ApplicationScope.SimplexTray() {
|
||||
if (!trayIsAvailable) return
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package chat.simplex.common
|
||||
|
||||
import chat.simplex.common.platform.Log
|
||||
import chat.simplex.common.platform.TAG
|
||||
import chat.simplex.common.platform.dataDir
|
||||
import java.io.IOException
|
||||
import java.nio.channels.FileChannel
|
||||
import java.nio.channels.FileLock
|
||||
import java.nio.channels.OverlappingFileLockException
|
||||
import java.nio.file.*
|
||||
import java.nio.file.StandardOpenOption.CREATE
|
||||
import java.nio.file.StandardOpenOption.READ
|
||||
import java.nio.file.StandardOpenOption.WRITE
|
||||
import javax.swing.SwingUtilities
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
private var lockHandle: FileLock? = null
|
||||
private var watcher: WatchService? = null
|
||||
|
||||
private val lockPath get() = dataDir.resolve("simplex.started").toPath()
|
||||
private val showPath get() = dataDir.resolve("simplex.show").toPath()
|
||||
|
||||
var singleInstanceLock = false
|
||||
private set
|
||||
|
||||
private sealed interface LockResult {
|
||||
class Acquired(val lock: FileLock) : LockResult
|
||||
object Taken : LockResult
|
||||
object Failed : LockResult
|
||||
}
|
||||
|
||||
fun acquireSingleInstance(): Boolean {
|
||||
dataDir.mkdirs()
|
||||
when (val result = tryAcquireLock()) {
|
||||
is LockResult.Acquired -> {
|
||||
lockHandle = result.lock
|
||||
singleInstanceLock = true
|
||||
deleteShowFile()
|
||||
startShowFileWatcher()
|
||||
return true
|
||||
}
|
||||
LockResult.Failed -> {
|
||||
return true
|
||||
}
|
||||
LockResult.Taken -> {
|
||||
// Ensure the signal file exists (createShowFile is a no-op if it does)
|
||||
// and wait up to 1s for the primary's watcher to consume it. If still
|
||||
// there after the wait, the primary is hung — let the user decide.
|
||||
createShowFile()
|
||||
val deadline = System.currentTimeMillis() + 1000
|
||||
while (Files.exists(showPath) && System.currentTimeMillis() < deadline) {
|
||||
try { Thread.sleep(50) } catch (_: InterruptedException) { break }
|
||||
}
|
||||
if (!Files.exists(showPath)) return false
|
||||
val start = showSingleInstanceAlert()
|
||||
if (start) deleteShowFile()
|
||||
return start
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun tryAcquireLock(): LockResult {
|
||||
val channel = try {
|
||||
FileChannel.open(lockPath, READ, WRITE, CREATE)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "single-instance: cannot open lock file: ${e.message}")
|
||||
return LockResult.Failed
|
||||
}
|
||||
return try {
|
||||
val lock = channel.tryLock(0L, 1L, false)
|
||||
if (lock != null) {
|
||||
LockResult.Acquired(lock)
|
||||
} else {
|
||||
channel.close()
|
||||
LockResult.Taken
|
||||
}
|
||||
} catch (_: OverlappingFileLockException) {
|
||||
Log.w(TAG, "single-instance: overlapping lock in same JVM")
|
||||
LockResult.Failed
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "single-instance: tryLock failed: ${e.message}")
|
||||
channel.close(); LockResult.Failed
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteShowFile() {
|
||||
try { Files.deleteIfExists(showPath) } catch (e: IOException) {
|
||||
Log.w(TAG, "single-instance: cannot delete show file: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun createShowFile() {
|
||||
try { Files.createFile(showPath) } catch (_: FileAlreadyExistsException) {
|
||||
// Another duplicate already signalled; primary will pick it up.
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "single-instance: cannot create show file: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun showSingleInstanceAlert(): Boolean {
|
||||
val title = chat.simplex.common.views.helpers.generalGetString(chat.simplex.res.MR.strings.another_instance_title)
|
||||
val message = chat.simplex.common.views.helpers.generalGetString(chat.simplex.res.MR.strings.another_instance_not_responding)
|
||||
val result = javax.swing.JOptionPane.showConfirmDialog(
|
||||
null, message, title,
|
||||
javax.swing.JOptionPane.YES_NO_OPTION,
|
||||
javax.swing.JOptionPane.WARNING_MESSAGE
|
||||
)
|
||||
return result == javax.swing.JOptionPane.YES_OPTION
|
||||
}
|
||||
|
||||
private fun startShowFileWatcher() {
|
||||
if (watcher != null) return
|
||||
val ws = try {
|
||||
dataDir.toPath().fileSystem.newWatchService()
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "single-instance: WatchService failed: ${e.message}")
|
||||
return
|
||||
}
|
||||
dataDir.toPath().register(ws, StandardWatchEventKinds.ENTRY_CREATE)
|
||||
watcher = ws
|
||||
thread(name = "simplex-single-instance", isDaemon = true) {
|
||||
while (true) {
|
||||
val key = try { ws.take() } catch (_: ClosedWatchServiceException) { return@thread } catch (_: InterruptedException) { return@thread }
|
||||
for (event in key.pollEvents()) {
|
||||
if ((event.context() as? Path)?.fileName?.toString() == "simplex.show") {
|
||||
deleteShowFile()
|
||||
SwingUtilities.invokeLater { showWindow() }
|
||||
}
|
||||
}
|
||||
if (!key.reset()) return@thread
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package chat.simplex.app
|
||||
|
||||
import java.nio.channels.FileChannel
|
||||
import java.nio.channels.OverlappingFileLockException
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardOpenOption.CREATE
|
||||
import java.nio.file.StandardOpenOption.READ
|
||||
import java.nio.file.StandardOpenOption.WRITE
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertNotNull
|
||||
|
||||
class SingleInstanceTest {
|
||||
@Test
|
||||
fun overlappingLockOnSameRegionThrowsWithinOneJvm() = withTempDir { dir ->
|
||||
val lockPath = dir.resolve("simplex.started")
|
||||
val first = FileChannel.open(lockPath, READ, WRITE, CREATE)
|
||||
val firstLock = first.tryLock(0L, 1L, false)
|
||||
assertNotNull(firstLock, "first acquirer must get the lock")
|
||||
|
||||
val second = FileChannel.open(lockPath, READ, WRITE, CREATE)
|
||||
assertFailsWith<OverlappingFileLockException> {
|
||||
second.tryLock(0L, 1L, false)
|
||||
}
|
||||
second.close()
|
||||
firstLock.release()
|
||||
first.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun releasedLockCanBeReacquired() = withTempDir { dir ->
|
||||
val lockPath = dir.resolve("simplex.started")
|
||||
val first = FileChannel.open(lockPath, READ, WRITE, CREATE)
|
||||
val firstLock = first.tryLock(0L, 1L, false)
|
||||
assertNotNull(firstLock)
|
||||
firstLock.release()
|
||||
first.close()
|
||||
|
||||
val second = FileChannel.open(lockPath, READ, WRITE, CREATE)
|
||||
val secondLock = second.tryLock(0L, 1L, false)
|
||||
assertNotNull(secondLock, "after release, a fresh acquirer must succeed")
|
||||
secondLock.release()
|
||||
second.close()
|
||||
}
|
||||
|
||||
private fun withTempDir(block: (java.nio.file.Path) -> Unit) {
|
||||
val tmp = Files.createTempDirectory("simplex-singleinstance-test")
|
||||
try {
|
||||
block(tmp)
|
||||
} finally {
|
||||
Files.walk(tmp).sorted(Comparator.reverseOrder()).forEach {
|
||||
try { Files.delete(it) } catch (_: java.io.IOException) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.*
|
||||
import chat.simplex.common.acquireSingleInstance
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.model.size
|
||||
import chat.simplex.common.platform.*
|
||||
@@ -19,6 +20,7 @@ import kotlinx.coroutines.*
|
||||
import java.io.File
|
||||
|
||||
fun main() {
|
||||
if (!acquireSingleInstance()) return
|
||||
// Disable hardware acceleration
|
||||
//System.setProperty("skiko.renderApi", "SOFTWARE")
|
||||
initHaskell()
|
||||
|
||||
@@ -49,6 +49,7 @@ This document provides a structured mapping between product-level concepts, thei
|
||||
| PC28 | Chat Tags | [README.md](README.md) (Navigation Map) | [spec/state.md](../spec/state.md) | `common/.../views/chatlist/TagListView.kt`, `ChatListView.kt` | `Types.hs` (`ChatTag`), `Controller.hs` |
|
||||
| PC29 | User Address | [README.md](README.md) (Contacts, User Management) | [spec/api.md](../spec/api.md) | `common/.../views/usersettings/UserAddressView.kt`, `UserAddressLearnMore.kt` | `Controller.hs` (`APICreateMyAddress`) |
|
||||
| PC30 | Member Support Chat | [README.md](README.md) (Groups) | [spec/api.md](../spec/api.md) | `common/.../views/chat/group/MemberSupportView.kt`, `MemberSupportChatView.kt`, `MemberAdmission.kt` | `Messages.hs` (`GroupChatScope`), `Controller.hs` |
|
||||
| PC31 | Channels (Relays) | [views/group-info.md](views/group-info.md) | [spec/client/chat-view.md](../spec/client/chat-view.md), [spec/state.md](../spec/state.md) | `common/.../model/ChatModel.kt` (`RelayStatus` incl. `RsRejected`, `GroupRelay`, `GroupMemberRole.Relay`, `GroupMemberStatus.MemRejected`), `common/.../views/chat/group/ChannelRelaysView.kt`, `GroupMemberInfoView.kt` (rejected-status row), `common/.../views/newchat/AddChannelView.kt` (`RelayStatusIndicator` rejected branch), `common/.../views/chat/group/AddGroupRelayView.kt` | `Controller.hs` (`APIAddGroupRelays`, `APIAllowRelayGroup`, `XGrpRelayReject` CONF handler) |
|
||||
|
||||
**Legend for abbreviated paths:**
|
||||
- `common/.../` expands to `common/src/commonMain/kotlin/chat/simplex/common/`
|
||||
|
||||
@@ -130,6 +130,30 @@ Shown when `developerTools` preference is enabled:
|
||||
|
||||
Business chats use alternative labels: "Delete chat" instead of "Delete group".
|
||||
|
||||
### Channel Relays View (`ChannelRelaysView`)
|
||||
|
||||
Accessible from channel info; shows relay members (role == `Relay`):
|
||||
|
||||
| Element | Description |
|
||||
|---|---|
|
||||
| Relay list | Filtered from `chatModel.groupMembers` by `Relay` role; excludes `MemRemoved` and `MemGroupDeleted` |
|
||||
| Relay row | Profile image, relay display name, status text (`RelayStatus.text` or connection status via `relayConnStatus`) |
|
||||
| Relay tap | Navigates to `GroupMemberInfoView` with `groupRelay:` parameter |
|
||||
| Add relay entry | Owner-only "Add relay" action opens `AddGroupRelayView`; the available-to-add list excludes any `chatRelayId` already present in `groupRelays` (regardless of `relayStatus`), so inactive or rejected relays cannot be re-added without first removing them via the row's long-press menu |
|
||||
| Long-press menu | Owner-only "Remove relay" action for relays that can be removed |
|
||||
| Empty state | "No chat relays" |
|
||||
| Footer | "Chat relays forward messages to channel subscribers." |
|
||||
|
||||
Owner sees relay status from `apiGetGroupRelays`; non-owner sees connection status only.
|
||||
|
||||
#### Channel Member Info — relay surface (in `GroupMemberInfoView`)
|
||||
|
||||
| Element | Description |
|
||||
|---|---|
|
||||
| Relay link info row | Shown when `member.relayLink` exists, displays `hostFromRelayLink(link)` |
|
||||
| Relay address info row | Shown when `groupRelay?.userChatRelay.address` exists, with "Share relay address" button |
|
||||
| Status row (rejected) | Shown when `groupRelay?.relayStatus == RelayStatus.RsRejected`: "Status: rejected by relay operator". The relay rejected the invitation to rejoin this channel after a prior `/leave`; the owner-side `GroupMember.memberStatus` is also set to `MemLeft` so the relay renders identically to one that explicitly left. Clearable only by the relay operator running `/group allow #<channel>`. |
|
||||
|
||||
## Source Files
|
||||
|
||||
| File | Path |
|
||||
@@ -143,3 +167,6 @@ Business chats use alternative labels: "Delete chat" instead of "Delete group".
|
||||
| `WelcomeMessageView.kt` | `views/chat/group/WelcomeMessageView.kt` |
|
||||
| `MemberAdmission.kt` | `views/chat/group/MemberAdmission.kt` |
|
||||
| `MemberSupportView.kt` | `views/chat/group/MemberSupportView.kt` |
|
||||
| `ChannelRelaysView.kt` | `views/chat/group/ChannelRelaysView.kt` |
|
||||
| `AddGroupRelayView.kt` | `views/chat/group/AddGroupRelayView.kt` |
|
||||
| `AddChannelView.kt` (`RelayStatusIndicator`) | `views/newchat/AddChannelView.kt` |
|
||||
|
||||
@@ -352,6 +352,7 @@ Events handled in `processReceivedMsg` include:
|
||||
| `DeletedMember` / `DeletedMemberUser` | A member was removed |
|
||||
| `LeftMember` | A member left voluntarily |
|
||||
| `GroupUpdated` | Group profile changed |
|
||||
| `GroupRelayUpdated` | Owner-side: a relay's `relayStatus` and/or the member's status changed. Fires on `XGrpRelayReject` with `relayStatus = RsRejected` and `GroupMember.memberStatus = MemLeft` — final on owner side until cleared by the relay operator's `/group allow #<channel>` (no event emitted to the owner for that clear). |
|
||||
| `MemberRole` | A member's role changed |
|
||||
| `MemberBlockedForAll` | A member was blocked for all |
|
||||
| `RcvFileStart` / `RcvFileComplete` / `RcvFileError` | File receive progress |
|
||||
|
||||
@@ -322,3 +322,11 @@ Key sections: group profile, group link, member list with roles, group preferenc
|
||||
| `MemberSupportChatView.kt` | Member support chat (scoped context) |
|
||||
| `MemberSupportView.kt` | Support chat list for moderators |
|
||||
| `WelcomeMessageView.kt` | Group welcome message editor |
|
||||
| `ChannelRelaysView.kt` | Channel relay list. Owner-only Add relay entry opens `AddGroupRelayView` with `existingRelayIds = groupRelays.mapNotNull { it.userChatRelay.chatRelayId }.toSet()` — every relay currently in `groupRelays` is excluded regardless of `relayStatus`, mirroring the backend `APIAddGroupRelays` gate. Long-press menu offers Remove relay for relays that can be removed. |
|
||||
| `AddGroupRelayView.kt` | Sheet to pick relays to add to a channel |
|
||||
|
||||
### Relay Rejection Surface
|
||||
|
||||
When a relay operator runs `/leave #channel`, the relay sends `x.grp.relay.reject` over the owner-relay direct contact channel. Owner-side handling: the corresponding `GroupRelay.relayStatus` transitions `RSInvited → RSRejected`; the relay's `GroupMember.memberStatus` is set to `MemLeft` so the owner UI renders the rejected relay identically to one that explicitly ran `/leave` (`MemRejected` is reserved for the knocking-admission flow). In `GroupMemberInfoView`, an additional "Status: rejected by relay operator" `InfoRow` appears when `groupRelay?.relayStatus == RelayStatus.RsRejected`. The status is final on the owner side — clearable only by the relay operator running `/group allow #<channel>`, which has no owner-facing event.
|
||||
|
||||
The `RelayStatusIndicator` composable in `AddChannelView.kt` renders `RsRejected` with a red dot and "rejected" text, matching the `connFailed`/`removed` rendering.
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
| PC28 | Chat Tags |
|
||||
| PC29 | User Address |
|
||||
| PC30 | Member Support Chat |
|
||||
| PC31 | Channels (Relays) |
|
||||
|
||||
---
|
||||
|
||||
@@ -51,13 +52,13 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/`
|
||||
|
||||
| Source File | Product Concepts Affected | Risk Level | Notes |
|
||||
|-------------|--------------------------|------------|-------|
|
||||
| `App.kt` | PC1 through PC30 | High | Root composable — navigation scaffold for all features |
|
||||
| `App.kt` | PC1 through PC31 | High | Root composable — navigation scaffold for all features |
|
||||
| `AppLock.kt` | PC22 | Medium | App lock state and authorization lifecycle |
|
||||
| `model/ChatModel.kt` | PC1 through PC30 | High | Central state object — every feature reads or writes here |
|
||||
| `model/SimpleXAPI.kt` | PC1 through PC30 | High | FFI bridge to Haskell core — all commands and responses |
|
||||
| `model/ChatModel.kt` | PC1 through PC31 | High | Central state object — every feature reads or writes here |
|
||||
| `model/SimpleXAPI.kt` | PC1 through PC31 | High | FFI bridge to Haskell core — all commands and responses |
|
||||
| `model/CryptoFile.kt` | PC10, PC23 | Medium | Encrypted file read/write helpers |
|
||||
| `platform/Core.kt` | PC1 through PC30 | High | Native FFI declarations (`chatMigrateInit`, `chatSendCmd`, etc.) — all API traffic |
|
||||
| `platform/AppCommon.kt` | PC1 through PC30 | Medium | Shared app initialization logic |
|
||||
| `platform/Core.kt` | PC1 through PC31 | High | Native FFI declarations (`chatMigrateInit`, `chatSendCmd`, etc.) — all API traffic |
|
||||
| `platform/AppCommon.kt` | PC1 through PC31 | Medium | Shared app initialization logic |
|
||||
| `platform/Files.kt` | PC10, PC23, PC26 | Medium | File path resolution, temp dirs, encryption utilities |
|
||||
| `platform/NtfManager.kt` | PC18 | High | Notification manager expect declarations |
|
||||
| `platform/Notifications.kt` | PC18 | Medium | Notification channel and permission abstractions |
|
||||
@@ -67,7 +68,7 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/`
|
||||
| `platform/Cryptor.kt` | PC23 | Medium | Keystore encryption expect declarations |
|
||||
| `platform/Share.kt` | PC10, PC12 | Low | Share sheet abstractions |
|
||||
| `platform/Images.kt` | PC10, PC19 | Low | Image processing utilities |
|
||||
| `platform/Platform.kt` | PC1 through PC30 | Low | Platform detection and capability flags |
|
||||
| `platform/Platform.kt` | PC1 through PC31 | Low | Platform detection and capability flags |
|
||||
| `platform/PlatformTextField.kt` | PC4 | Low | Native text input expect declarations |
|
||||
| `platform/Back.kt` | PC1 | Low | Back navigation handling |
|
||||
| `platform/UI.kt` | PC24 | Low | UI density and locale helpers |
|
||||
@@ -160,7 +161,9 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/`
|
||||
|-------------|--------------------------|------------|-------|
|
||||
| `views/chat/group/GroupChatInfoView.kt` | PC3, PC14, PC15, PC16, PC30 | High | Group management hub |
|
||||
| `views/chat/group/AddGroupMembersView.kt` | PC14, PC16 | Medium | Member invitation flow |
|
||||
| `views/chat/group/GroupMemberInfoView.kt` | PC3, PC14, PC16, PC30 | Medium | Member details and role management |
|
||||
| `views/chat/group/GroupMemberInfoView.kt` | PC3, PC14, PC16, PC30, PC31 | Medium | Member details and role management; relay-address + rejected-status info rows |
|
||||
| `views/chat/group/ChannelRelaysView.kt` | PC31 | Medium | Channel relay list, add/remove entries |
|
||||
| `views/chat/group/AddGroupRelayView.kt` | PC31 | Low | Add relay sheet |
|
||||
| `views/chat/group/GroupProfileView.kt` | PC3, PC14 | Medium | Group profile editing |
|
||||
| `views/chat/group/GroupLinkView.kt` | PC15 | Low | Group link creation and sharing |
|
||||
| `views/chat/group/GroupPreferences.kt` | PC3, PC8, PC14 | Medium | Group feature toggles |
|
||||
@@ -189,6 +192,7 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/`
|
||||
| `views/newchat/NewChatSheet.kt` | PC12 | Medium | Bottom sheet with connection options |
|
||||
| `views/newchat/ConnectPlan.kt` | PC12, PC15 | Medium | Link parsing and connection plan resolution |
|
||||
| `views/newchat/AddGroupView.kt` | PC3, PC14 | Medium | New group creation flow |
|
||||
| `views/newchat/AddChannelView.kt` | PC31 | Medium | Public channel creation, channel link card, `RelayStatusIndicator` |
|
||||
| `views/newchat/ContactConnectionInfoView.kt` | PC12 | Low | Pending connection details |
|
||||
| `views/newchat/AddContactLearnMore.kt` | PC12 | Low | Educational content |
|
||||
| `views/newchat/QRCode.kt` | PC12 | Low | QR code display |
|
||||
@@ -264,9 +268,9 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/`
|
||||
|
||||
| Source File | Product Concepts Affected | Risk Level | Notes |
|
||||
|-------------|--------------------------|------------|-------|
|
||||
| `views/helpers/AlertManager.kt` | PC1 through PC30 | Medium | Modal alert system used across all features |
|
||||
| `views/helpers/ModalView.kt` | PC1 through PC30 | Medium | Modal navigation stack |
|
||||
| `views/helpers/Utils.kt` | PC1 through PC30 | Low | Shared formatting, clipboard, and utility functions |
|
||||
| `views/helpers/AlertManager.kt` | PC1 through PC31 | Medium | Modal alert system used across all features |
|
||||
| `views/helpers/ModalView.kt` | PC1 through PC31 | Medium | Modal navigation stack |
|
||||
| `views/helpers/Utils.kt` | PC1 through PC31 | Low | Shared formatting, clipboard, and utility functions |
|
||||
| `views/helpers/DatabaseUtils.kt` | PC23 | Medium | Keystore passphrase and database helpers |
|
||||
| `views/helpers/LinkPreviews.kt` | PC11 | Medium | Link preview fetching and rendering |
|
||||
| `views/helpers/LocalAuthentication.kt` | PC22 | Medium | Biometric/passcode authentication expect |
|
||||
@@ -319,8 +323,8 @@ Path prefix: `android/src/main/java/chat/simplex/app/`
|
||||
|
||||
| Source File | Product Concepts Affected | Risk Level | Notes |
|
||||
|-------------|--------------------------|------------|-------|
|
||||
| `SimplexApp.kt` | PC1 through PC30 | High | Application class — initializes core, preferences, and notification channels |
|
||||
| `MainActivity.kt` | PC1 through PC30 | High | Single-activity host — intent handling, lifecycle, deep links |
|
||||
| `SimplexApp.kt` | PC1 through PC31 | High | Application class — initializes core, preferences, and notification channels |
|
||||
| `MainActivity.kt` | PC1 through PC31 | High | Single-activity host — intent handling, lifecycle, deep links |
|
||||
| `SimplexService.kt` | PC18 | High | Foreground service — keeps message receiver alive |
|
||||
| `CallService.kt` | PC17 | Medium | Foreground service for active calls |
|
||||
| `MessagesFetcherWorker.kt` | PC18 | Medium | WorkManager periodic message fetch |
|
||||
@@ -334,7 +338,7 @@ Path prefix: `common/src/androidMain/kotlin/chat/simplex/common/`
|
||||
|
||||
| Source File | Product Concepts Affected | Risk Level | Notes |
|
||||
|-------------|--------------------------|------------|-------|
|
||||
| `platform/AppCommon.android.kt` | PC1 through PC30 | Medium | Android app initialization actual declarations |
|
||||
| `platform/AppCommon.android.kt` | PC1 through PC31 | Medium | Android app initialization actual declarations |
|
||||
| `platform/SimplexService.android.kt` | PC18 | Medium | Android foreground service actual implementation |
|
||||
| `platform/Files.android.kt` | PC10, PC23, PC26 | Medium | Android file paths and content-URI resolution |
|
||||
| `platform/Cryptor.android.kt` | PC23 | Medium | Android Keystore encryption actual implementation |
|
||||
@@ -400,7 +404,7 @@ Path prefix: `desktop/src/jvmMain/kotlin/chat/simplex/desktop/`
|
||||
|
||||
| Source File | Product Concepts Affected | Risk Level | Notes |
|
||||
|-------------|--------------------------|------------|-------|
|
||||
| `Main.kt` | PC1 through PC30 | High | JVM entry point — Haskell init, migrations, app launch |
|
||||
| `Main.kt` | PC1 through PC31 | High | JVM entry point — Haskell init, migrations, app launch |
|
||||
|
||||
### 3.2 Desktop Platform Implementations (desktopMain)
|
||||
|
||||
@@ -411,7 +415,7 @@ Path prefix: `common/src/desktopMain/kotlin/chat/simplex/common/`
|
||||
| `DesktopApp.kt` | PC1, PC2, PC3 | High | Desktop Compose window — window lifecycle, crash recovery |
|
||||
| `StoreWindowState.kt` | — | Low | Window position/size persistence |
|
||||
| `model/NtfManager.desktop.kt` | PC18 | Medium | Desktop system tray notification display |
|
||||
| `platform/AppCommon.desktop.kt` | PC1 through PC30 | Medium | Desktop app initialization actual declarations |
|
||||
| `platform/AppCommon.desktop.kt` | PC1 through PC31 | Medium | Desktop app initialization actual declarations |
|
||||
| `platform/SimplexService.desktop.kt` | PC18 | Low | Desktop background receiver (no foreground service) |
|
||||
| `platform/Files.desktop.kt` | PC10, PC23, PC26 | Medium | Desktop file path resolution |
|
||||
| `platform/Cryptor.desktop.kt` | PC23 | Medium | Desktop keystore encryption actual implementation |
|
||||
@@ -473,13 +477,13 @@ The Haskell core is compiled as a shared native library (`libsimplex.so` / `libs
|
||||
|
||||
| Source File | Product Concepts Affected | Risk Level | Notes |
|
||||
|-------------|--------------------------|------------|-------|
|
||||
| `src/Simplex/Chat.hs` | PC1 through PC30 | High | Main chat module — top-level orchestration |
|
||||
| `src/Simplex/Chat/Controller.hs` | PC1 through PC30 | High | Command processor — all API commands dispatched here |
|
||||
| `src/Simplex/Chat/Types.hs` | PC1 through PC30 | High | Core data types shared across all features |
|
||||
| `src/Simplex/Chat/Core.hs` | PC1 through PC30 | High | Chat engine lifecycle (start, stop, subscribe) |
|
||||
| `src/Simplex/Chat/Library/Commands.hs` | PC1 through PC30 | High | API command handler implementations |
|
||||
| `src/Simplex/Chat/Library/Internal.hs` | PC1 through PC30 | High | Internal helpers for command processing |
|
||||
| `src/Simplex/Chat/Library/Subscriber.hs` | PC1 through PC30 | High | Event subscriber — incoming message routing |
|
||||
| `src/Simplex/Chat.hs` | PC1 through PC31 | High | Main chat module — top-level orchestration |
|
||||
| `src/Simplex/Chat/Controller.hs` | PC1 through PC31 | High | Command processor — all API commands dispatched here |
|
||||
| `src/Simplex/Chat/Types.hs` | PC1 through PC31 | High | Core data types shared across all features |
|
||||
| `src/Simplex/Chat/Core.hs` | PC1 through PC31 | High | Chat engine lifecycle (start, stop, subscribe) |
|
||||
| `src/Simplex/Chat/Library/Commands.hs` | PC1 through PC31 | High | API command handler implementations |
|
||||
| `src/Simplex/Chat/Library/Internal.hs` | PC1 through PC31 | High | Internal helpers for command processing |
|
||||
| `src/Simplex/Chat/Library/Subscriber.hs` | PC1 through PC31 | High | Event subscriber — incoming message routing |
|
||||
| `src/Simplex/Chat/Protocol.hs` | PC2, PC3, PC4, PC5, PC6, PC7 | High | Chat-level message protocol (x-events) |
|
||||
| `src/Simplex/Chat/Messages.hs` | PC2, PC3, PC4, PC5, PC6, PC7, PC8, PC9 | High | Message types and content |
|
||||
| `src/Simplex/Chat/Messages/CIContent.hs` | PC4, PC5, PC6, PC7, PC8, PC9, PC11 | Medium | Chat item content variants |
|
||||
@@ -489,8 +493,8 @@ The Haskell core is compiled as a shared native library (`libsimplex.so` / `libs
|
||||
| `src/Simplex/Chat/Files.hs` | PC10 | Medium | File transfer orchestration |
|
||||
| `src/Simplex/Chat/Delivery.hs` | PC2, PC3 | Medium | Message delivery engine |
|
||||
| `src/Simplex/Chat/Markdown.hs` | PC4 | Low | Markdown parsing for message formatting |
|
||||
| `src/Simplex/Chat/Store.hs` | PC1 through PC30 | High | Database store interface |
|
||||
| `src/Simplex/Chat/Store/Shared.hs` | PC1 through PC30 | Medium | Shared store utilities |
|
||||
| `src/Simplex/Chat/Store.hs` | PC1 through PC31 | High | Database store interface |
|
||||
| `src/Simplex/Chat/Store/Shared.hs` | PC1 through PC31 | Medium | Shared store utilities |
|
||||
| `src/Simplex/Chat/Store/Messages.hs` | PC4, PC5, PC6, PC7, PC8 | High | Message persistence |
|
||||
| `src/Simplex/Chat/Store/Groups.hs` | PC3, PC14, PC15, PC16, PC30 | High | Group persistence |
|
||||
| `src/Simplex/Chat/Store/Direct.hs` | PC2, PC12, PC13 | High | Contact persistence |
|
||||
@@ -519,11 +523,11 @@ The Haskell core is compiled as a shared native library (`libsimplex.so` / `libs
|
||||
| `src/Simplex/Chat/Operators/Presets.hs` | PC25 | Low | Preset server operators |
|
||||
| `src/Simplex/Chat/Operators/Conditions.hs` | PC25 | Low | Operator usage conditions |
|
||||
| `src/Simplex/Chat/AppSettings.hs` | PC25 | Low | App settings sync types |
|
||||
| `src/Simplex/Chat/Mobile.hs` | PC1 through PC30 | High | C FFI exports — JNI bridge target |
|
||||
| `src/Simplex/Chat/Mobile.hs` | PC1 through PC31 | High | C FFI exports — JNI bridge target |
|
||||
| `src/Simplex/Chat/Mobile/File.hs` | PC10 | Medium | Mobile file read/write FFI |
|
||||
| `src/Simplex/Chat/Mobile/Shared.hs` | PC1 through PC30 | Medium | Shared FFI helpers |
|
||||
| `src/Simplex/Chat/Mobile/Shared.hs` | PC1 through PC31 | Medium | Shared FFI helpers |
|
||||
| `src/Simplex/Chat/Mobile/WebRTC.hs` | PC17 | Low | WebRTC FFI helpers |
|
||||
| `src/Simplex/Chat/View.hs` | PC1 through PC30 | Low | Terminal view rendering (not used by mobile/desktop UI) |
|
||||
| `src/Simplex/Chat/View.hs` | PC1 through PC31 | Low | Terminal view rendering (not used by mobile/desktop UI) |
|
||||
| `src/Simplex/Chat/Stats.hs` | PC25 | Low | Server statistics tracking |
|
||||
| `src/Simplex/Chat/Util.hs` | — | Low | General Haskell utilities |
|
||||
| `src/Simplex/Chat/Styled.hs` | — | Low | Terminal styled text (not used by mobile/desktop UI) |
|
||||
|
||||
@@ -300,6 +300,21 @@ data class ChatStats(
|
||||
| `ChatInfo.ContactConnection` | `"contactConnection"` | `contactConnection: PendingContactConnection` |
|
||||
| `ChatInfo.InvalidJSON` | `"invalidJSON"` | `json: String` |
|
||||
|
||||
### RelayStatus (Channels)
|
||||
|
||||
`RelayStatus` is an `enum class` at [`ChatModel.kt line 2288`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L2288) modelling a relay's lifecycle for a channel on the owner's side. Serialized as a lowercase string via `@SerialName`.
|
||||
|
||||
| Case | SerialName | Meaning |
|
||||
|---|---|---|
|
||||
| `RsNew` | `"new"` | Allocated locally; not yet sent |
|
||||
| `RsInvited` | `"invited"` | `XGrpRelayInv` sent, awaiting `XGrpRelayAcpt` |
|
||||
| `RsAccepted` | `"accepted"` | Accepted, link-data update pending |
|
||||
| `RsActive` | `"active"` | Listed in channel link data; forwarding |
|
||||
| `RsInactive` | `"inactive"` | No longer in link data or backend reports it removed |
|
||||
| `RsRejected` | `"rejected"` | Relay sent `XGrpRelayReject` for the channel link; final on the owner side. Clearable only by the relay operator running `/group allow #<channel>`. The owner-side `GroupMember.memberStatus` is also set to `MemLeft` so the relay renders identically to one that explicitly left (`MemRejected` is reserved for the knocking-admission flow). |
|
||||
|
||||
The `text` extension on the enum returns the localized status string (resource key `relay_status_*`, with `relay_status_rejected` = "rejected").
|
||||
|
||||
---
|
||||
|
||||
<a id="AppPreferences"></a>
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@simplex-chat/types": "^0.6.0",
|
||||
"@simplex-chat/types": "^0.7.0",
|
||||
"async-mutex": "^0.5.0",
|
||||
"commander": "^14.0.3",
|
||||
"simplex-chat": "^6.5.1",
|
||||
"simplex-chat": "^6.5.2",
|
||||
"yaml": "^2.8.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -33,6 +33,7 @@ This file is generated automatically.
|
||||
- [APINewPublicGroup](#apinewpublicgroup)
|
||||
- [APIGetGroupRelays](#apigetgrouprelays)
|
||||
- [APIAddGroupRelays](#apiaddgrouprelays)
|
||||
- [APIAllowRelayGroup](#apiallowrelaygroup)
|
||||
- [APIUpdateGroupProfile](#apiupdategroupprofile)
|
||||
|
||||
[Group link commands](#group-link-commands)
|
||||
@@ -1080,6 +1081,43 @@ ChatCmdError: Command error (only used in WebSockets API).
|
||||
---
|
||||
|
||||
|
||||
### APIAllowRelayGroup
|
||||
|
||||
Clear relay rejection for a channel (relay operator).
|
||||
|
||||
*Network usage*: background.
|
||||
|
||||
**Parameters**:
|
||||
- groupId: int64
|
||||
|
||||
**Syntax**:
|
||||
|
||||
```
|
||||
/_relay allow #<groupId>
|
||||
```
|
||||
|
||||
```javascript
|
||||
'/_relay allow #' + groupId // JavaScript
|
||||
```
|
||||
|
||||
```python
|
||||
'/_relay allow #' + str(groupId) # Python
|
||||
```
|
||||
|
||||
**Responses**:
|
||||
|
||||
RelayGroupAllowed: Relay rejection cleared for a channel.
|
||||
- type: "relayGroupAllowed"
|
||||
- user: [User](./TYPES.md#user)
|
||||
- groupInfo: [GroupInfo](./TYPES.md#groupinfo)
|
||||
|
||||
ChatCmdError: Command error (only used in WebSockets API).
|
||||
- type: "chatCmdError"
|
||||
- chatError: [ChatError](./TYPES.md#chaterror)
|
||||
|
||||
---
|
||||
|
||||
|
||||
### APIUpdateGroupProfile
|
||||
|
||||
Update group profile.
|
||||
|
||||
@@ -3350,6 +3350,7 @@ ParseError:
|
||||
- "accepted"
|
||||
- "active"
|
||||
- "inactive"
|
||||
- "rejected"
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -120,6 +120,7 @@ chatCommandsDocsData =
|
||||
("APINewPublicGroup", [], "Create public group.", ["CRPublicGroupCreated", "CRPublicGroupCreationFailed", "CRChatCmdError"], [], Just UNInteractive, "/_public group " <> Param "userId" <> OnOffParam "incognito" "incognito" (Just False) <> " " <> Join ',' "relayIds" <> " " <> Json "groupProfile"),
|
||||
("APIGetGroupRelays", [], "Get group relays.", ["CRGroupRelays", "CRChatCmdError"], [], Nothing, "/_get relays #" <> Param "groupId"),
|
||||
("APIAddGroupRelays", [], "Add relays to group.", ["CRGroupRelaysAdded", "CRGroupRelaysAddFailed", "CRChatCmdError"], [], Just UNInteractive, "/_add relays #" <> Param "groupId" <> " " <> Join ',' "relayIds"),
|
||||
("APIAllowRelayGroup", [], "Clear relay rejection for a channel (relay operator).", ["CRRelayGroupAllowed", "CRChatCmdError"], [], Just UNBackground, "/_relay allow #" <> Param "groupId"),
|
||||
("APIUpdateGroupProfile", [], "Update group profile.", ["CRGroupUpdated", "CRChatCmdError"], [], Just UNBackground, "/_group_profile #" <> Param "groupId" <> " " <> Json "groupProfile")
|
||||
]
|
||||
),
|
||||
@@ -203,6 +204,7 @@ cliCommands =
|
||||
"AcceptMember",
|
||||
"AddContact",
|
||||
"AddMember",
|
||||
"AllowRelayGroup",
|
||||
"BlockForAll",
|
||||
"ChatHelp",
|
||||
"ClearContact",
|
||||
|
||||
@@ -73,6 +73,7 @@ chatResponsesDocsData =
|
||||
("CRGroupRelays", ""),
|
||||
("CRGroupRelaysAdded", ""),
|
||||
("CRGroupRelaysAddFailed", ""),
|
||||
("CRRelayGroupAllowed", "Relay rejection cleared for a channel"),
|
||||
("CRGroupMembers", ""),
|
||||
("CRGroupUpdated", ""),
|
||||
("CRGroupsList", "Groups"),
|
||||
|
||||
@@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd
|
||||
source-repository-package
|
||||
type: git
|
||||
location: https://github.com/simplex-chat/simplexmq.git
|
||||
tag: 1f173abf6d6fccb617be1e7994629c405983c431
|
||||
tag: f03cec7a58ed13a39a52886888c74bcefdb64479
|
||||
|
||||
source-repository-package
|
||||
type: git
|
||||
|
||||
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 767 B |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 3.5 MiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 649 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 285 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 402 KiB |
|
After Width: | Height: | Size: 242 KiB |
|
After Width: | Height: | Size: 482 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 440 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 692 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 906 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 33 KiB |