From df5ea3d4603f8bb6d6825fd9823ce21f45be5280 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Fri, 22 May 2026 12:45:02 +0100 Subject: [PATCH 001/200] android, desktop: new settings section design (#6777) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * android, desktop: new settings section design * Section facelift: LIGHT canvas swap, equal padding, 2dp item dividers - LIGHT canvas (themedBackground) now paints the off-white formula (bg.mixWith(onBackground, 0.95f)) so white cards read as raised. DARK/BLACK keep palette bg (cards already raised via founder's formula in Section.kt). SIMPLEX keeps its gradient. - Section cards in LIGHT switch from formula to pure white via Color.White. DARK/BLACK keep the formula, unchanged. - Section card horizontal padding equalized to 16dp on outer + inner for clean canvas-edge alignment. extraPadding (icon-indented rows) keeps DEFAULT_PADDING * 1.7f. - 2dp dividers between rows inside section cards, color matches the per-theme canvas (SIMPLEX uses gradient bottom stop). Implemented via Modifier.drawBehind on each SectionItemView, gated by a private LocalInSectionCard CompositionLocal set true only by SectionView's inner Column — standalone SectionItemView usage (alerts, pickers) stays unaffected. Single canvas helper canvasColorForCurrentTheme() in Theme.kt is the source of truth for both canvas paint and divider color. Co-Authored-By: Claude Opus 4.7 (1M context) * Section: paint item divider on top of clickable's hover indication Previously sectionItemDivider() was inside the modifier val before clickable, so the hover background drew over it inconsistently — on hover the row's content area got a tinted overlay while the 2dp divider area stayed at canvas color, creating visible contrast that read as a "dark line below hovered row". Moving the modifier to the end of the chain (after clickable+padding) makes drawBehind paint after the hover indication, so the divider color is consistently #F2F2F2-ish regardless of hover state. Co-Authored-By: Claude Opus 4.7 (1M context) * Section: trim section item horizontal padding to 15dp CARD_PADDING (16dp) still drives outer card margin from screen edge. Item content inside the card now uses CARD_ITEM_PADDING = CARD_PADDING - 1.dp, giving the row text a slightly tighter horizontal inset that reads better at the current card width. Co-Authored-By: Claude Opus 4.7 (1M context) * Appearance: drop redundant 10dp spacer between Apply-to row and wallpaper preview Before section facelift the spacer separated the Apply-to row from the wallpaper preview block visually. With the new 2dp item divider drawing under the Apply-to row that separation is already provided, and the spacer leaves an awkward white gap between the divider and the preview. Co-Authored-By: Claude Opus 4.7 (1M context) * Section: repurpose SectionDivider() as explicit 2dp canvas-color line; use in Appearance themes card SectionDivider() composable had 0 callsites and used Material Divider with horizontal inset (unused legacy). Repurposed to draw a 2dp canvas-color Box matching the auto-divider style used by SectionItemView, gated by LocalInSectionCard so it no-ops outside a section card. Use it in Appearance themes card between WallpaperPresetSelector (custom composable, not a SectionItemView, so no auto-divider) and the following content (Remove image / Color mode / Dark mode), providing the visual separator the user expects between the theme grid and the rows below it. Co-Authored-By: Claude Opus 4.7 (1M context) * Appearance: symmetric vertical padding around profile avatar row ProfileImageSection's Row had Modifier.padding(top = 10.dp), giving 10dp above the avatar and 0dp below — visibly asymmetric inside the card. Changed to vertical = 10.dp so top and bottom padding match. Co-Authored-By: Claude Opus 4.7 (1M context) * NetworkAndServers: move messages card footer/spacer out of SectionView Before PR #6777 SectionView had no card chrome, so SectionTextFooter and SectionDividerSpaced placed inside its content lambda rendered as plain inline content. After the card chrome was added, the same code rendered the footer caption and the spacer INSIDE the white card area, producing an unwanted gap (and visible auto-divider tail) under Advanced network settings. Move both out of the SectionView lambda so the footer reads as a caption below the card (iOS-style) and the spacer separates this card from the next one. Co-Authored-By: Claude Opus 4.7 (1M context) * DeveloperView: move card footer/bottom-spacer out of SectionView lambdas Same pre-card-chrome pattern as NetworkAndServers: SectionTextFooter ("Show: Database IDs and Transport isolation...") and SectionBottomSpacer were inside SectionView lambdas, so after PR #6777 added card chrome they rendered inside the white card area — the footer caption sat inside the first card and the 48dp bottom spacer appeared as an empty row at the end of the deprecated-options card (after SimpleX links). Move both out of the SectionView lambda so the footer reads as a caption below the first card and the bottom spacer adds safe-area room after the deprecated-options card (not inside it). Co-Authored-By: Claude Opus 4.7 (1M context) * ChatInfoView: move chat-ttl footer caption out of SectionView lambda Same pre-card-chrome pattern: SectionTextFooter("Delete chat messages from your device.") was inside the ChatTTLOption SectionView lambda, so after PR #6777 added card chrome it rendered inside the card. Move it out so the caption sits below the card iOS-style. Co-Authored-By: Claude Opus 4.7 (1M context) * ChatWallpaperEditor: wrap loose params in SectionView cards In ChatInfo > Chat theme screen the wallpaper preset selector, the wallpaper setup controls, the reset/set-default buttons and the "Apply to" mode dropdown were rendered as loose composables on a gray canvas — no card chrome, inconsistent with the rest of Appearance. Wrap them in SectionView so they read as raised iOS-style cards: - wallpaper preset selector + setup view → one card - reset-to-global + set-default buttons → one card - (advanced mode) Apply-to dropdown → one card - (collapsed mode) Advanced-settings button → one card CustomizeThemeColorsSection and ImportExportThemeSection were already SectionView-wrapped and remain unchanged. UserWallpaperEditor (sister function with similar layout, lines 28-220) is intentionally left alone — user reported only the chat-theme entry point. Co-Authored-By: Claude Opus 4.7 (1M context) * GroupChatInfoView: render group members inside the same SectionView card as owner Previously the members card showed only the current user (owner) and the add-members button — the actual group members were rendered as separate LazyColumn items() OUTSIDE the SectionView, so they sat on the gray canvas without card chrome. Visually inconsistent: owner in a card, everyone else floating. Move filteredMembers.value.forEach { ... } INSIDE the SectionView lambda so every member row is part of the same card as the owner. Drop the explicit Divider() call (auto-divider handles it now). Move remember key to member.groupMemberId so per-member state survives reorders. Trade-off: lazy rendering of member rows is replaced with eager composition inside a Column. For typical groups (<100 members) this is imperceptible; very large groups may compose slower on open. Watching for reports. Co-Authored-By: Claude Opus 4.7 (1M context) * ChatInfoView: move E2E encryption card spacer out of SectionView lambda Same pre-card-chrome pattern: SectionDividerSpaced was inside the single-row SectionView around the InfoRow, so after PR #6777 added card chrome it rendered as a white gap inside the card (under the auto-divider on the InfoRow), producing the "extra divider + gap" the user reported. Co-Authored-By: Claude Opus 4.7 (1M context) * ServersSummaryView: wrap Message reception sections in SectionView card SubscriptionsSectionView and SMPSubscriptionsSection both rendered their InfoRows + control item in a plain Column without card chrome — so on the Servers info screen the "Message reception" section title sat above loose rows on the gray canvas (no card), inconsistent with the rest of the screen. Wrap the inner Column in SectionView so the rows get the raised iOS-style card look. The custom header Row (title + subscription status indicator) stays outside the card so the icon stays inline with the title text. Co-Authored-By: Claude Opus 4.7 (1M context) * Appearance: add missing SectionDivider import f922d8fc introduced SectionDivider() call in the themes card but forgot to add the per-symbol import. SectionView/SectionDividerSpaced etc. in this codebase are imported individually (Section.kt declares them at top level, not inside a package), so SectionDivider needs its own import line. Co-Authored-By: Claude Opus 4.7 (1M context) * Section: fix item divider position — paint via drawWithContent at full row bottom 8050676b moved sectionItemDivider() AFTER clickable.padding() in the modifier chain to make the line paint on top of clickable's hover indication. Side effect: drawBehind then saw the size of the padding-reduced content area, not the full row, so dividers rendered 15dp ABOVE the actual row bottom (in the middle of the row's bottom padding zone) instead of at the row edge between adjacent items. Fix: keep sectionItemDivider() in the modifier val BEFORE clickable/ padding (so size = full row outer bounds) AND switch from drawBehind to drawWithContent { drawContent(); drawLine(...) } so the line is painted AFTER the chain's content + hover indication draw. Both goals satisfied: divider sits at the true row bottom AND paints on top of hover overlay. Co-Authored-By: Claude Opus 4.7 (1M context) * Section: bump hover ripple alpha to 0.08 on LIGHT theme for visibility against canvas Default Compose Material 1 RippleTheme uses hoveredAlpha=0.04 for LIGHT, producing a Black·0.04 overlay (~#F5F5F5) on white cards — visually 3 units per channel away from the off-white canvas (~#F2F2F2), so the hover state blends into the canvas and the row looks unfocused. Add a section-local SectionRippleTheme that mirrors Material's defaults for everything except hoveredAlpha on LIGHT (raised to 0.08 → ~#EBEBEB overlay, ~7 units delta from canvas — visibly distinct). Dark themes keep Material defaults since their hover contrast is already adequate. Provided via CompositionLocalProvider in all three SectionView variants alongside LocalInSectionCard, so it scopes only to section card items. Co-Authored-By: Claude Opus 4.7 (1M context) * ProtocolServerView: pad CustomServer address field inside its card The TextEditor (144dp tall input) sat flush against the top and bottom edges of its containing SectionView card on the New server screen. Pass padding = PaddingValues(vertical = DEFAULT_PADDING_HALF) to the SectionView so the field gets 10dp of breathing room top and bottom inside the card. Co-Authored-By: Claude Opus 4.7 (1M context) * NetworkAndServers: move ConditionsButton into operators card; wrap Save servers in its own card - "Review conditions" (ConditionsButton — a bare SectionItemView) was rendered between the operators card and the messages card on the canvas without card chrome. Move it inside the operators SectionView lambda after the operator rows, so it shares the card and gets a 2dp auto-divider above it separating it from the operator list. - "Save servers" (a bare SectionItemView further down) is now wrapped in its own SectionView so it reads as a single-item card matching the iOS-style facelift of the rest of the screen. Co-Authored-By: Claude Opus 4.7 (1M context) * Appearance: fix Transparency truncation and SettingsActionItem horizontal padding Two layout regressions from earlier facelift commits where slider/item math assumed DEFAULT_PADDING (20dp) for inner card padding, but the facelift uses CARD_PADDING (16dp outer) + CARD_ITEM_PADDING (15dp inner) = 31dp per side instead of 20dp. - Slider widthIn calc in AppToolbarsSection and MessageShapeSection used (maxWidth - DEFAULT_PADDING * 2) so the slider was ~22dp wider than it should be, shrinking the label Box (weight 1f) and clipping "Transparency" to "Transparenc". Switched to (CARD_PADDING + CARD_ITEM_PADDING) * 2. - SettingsActionItemWithContent explicitly passed PaddingValues(horizontal = DEFAULT_PADDING) to its SectionItemView, overriding the new CARD_ITEM_PADDING default. That made any row using SettingsPreferenceItem/SettingsActionItem sit 5dp further inset than rows using plain SectionItemViewWithoutMinPadding — visible as a left indent on "Tail" relative to "Corner". Replaced with CARD_ITEM_PADDING so it matches. Removed `private` from CARD_PADDING and CARD_ITEM_PADDING in Section.kt to allow imports from other files (used the same way as SectionView etc. are imported individually). Co-Authored-By: Claude Opus 4.7 (1M context) * Appearance + Customize theme: bring loose items into cards, drop spurious spacer Four small fixes on the same theme-related screens: - Move "Customize theme" SectionItemView INSIDE the THEMES SectionView in AppearanceView so it sits in the same card as Color mode / Dark mode colors with an auto-divider above it. - Wrap WallpaperPresetSelector (theme slots + chat preview) and the conditional Remove-image button in CustomizeThemeView with a SectionView so they read as a card, matching the Appearance themes card pattern. Add a SectionDividerSpaced after. - Drop SectionSpacer() that sat between the Wallpaper tint row and the Sent message row inside WallpaperSetupView — auto-divider on the Wallpaper tint SectionItemView already provides separation; the 30dp spacer rendered as extra empty padding inside the card. - Wrap the Reset colors action in a single-item SectionView so it reads as its own card, matching the export/import card below. Co-Authored-By: Claude Opus 4.7 (1M context) * ServersSummaryView: wrap "Showing info for" dropdown in SectionView card The user-selection ExposedDropDownSettingRow at the top of the servers info screen was rendered loose on the canvas with no card chrome. Wrap in SectionView so it reads as a card matching the rest of the screen. Co-Authored-By: Claude Opus 4.7 (1M context) * GroupChatInfoView: move chat-ttl footer caption out of SectionView lambda Same pre-card-chrome pattern as ChatInfoView (fixed in b1a1dad8): SectionTextFooter("Delete chat messages from your device.") sat inside the SectionView around ChatTTLOption, so it rendered inside the white card after PR #6777 added card chrome. Move it out so the caption sits below the card iOS-style. Co-Authored-By: Claude Opus 4.7 (1M context) * NewChatSheet: render filtered contact list inside SectionView card The "Contacts" header was a SectionView with empty content lambda, and the actual contact rows were rendered as separate LazyColumn items OUTSIDE the SectionView — so they sat on canvas without card chrome. Move filteredContactChats.forEachIndexed { ContactListNavLinkView } INSIDE the SectionView lambda in both OneHandLazyColumn and NonOneHandLazyColumn so the contacts read as a single card matching the iOS-style facelift. Same trade-off as GroupChatInfoView members fix (fa29bb7a): lazy rendering of contact rows replaced with eager composition inside a Column. For typical contact lists (<100) imperceptible; very long lists may compose slower on open. Co-Authored-By: Claude Opus 4.7 (1M context) * AddGroupView/AddChannelView: wrap action buttons + toggles in SectionView card In Create group and Create public channel screens the action buttons (Create / Configure relays) and incognito toggle were rendered as loose SectionItemViews on the gray canvas with no card chrome. Wrap them in SectionView so they read as a single card matching the iOS-style facelift. The display-name input above and the descriptive footer below stay outside the card (text input keeps its own padding, footer reads as caption). Added missing `import SectionView` in AddGroupView. Co-Authored-By: Claude Opus 4.7 (1M context) * TagListView: wrap Add/Save list button in SectionView card The "Add to list" / "Save list" action button in TagListEditor (opened from chatlist "+" Add list) was a loose SectionItemView on the canvas with no card chrome. Wrap in SectionView so it reads as a single-item card. ChatTagInput stays as a form field above. Added missing `import SectionView`. Co-Authored-By: Claude Opus 4.7 (1M context) * UserAddressView: move "contacts remain connected" footer out; pad welcome message field; wrap Save in card Three SimpleX address fixes: - "Your contacts will remain connected" SectionTextFooter moved out of the DeleteAddressButton SectionView (was rendering inside the card). - Address settings > welcome message field gets 10dp vertical contentPadding on its SectionView so the TextEditor doesn't sit flush against the card top/bottom. - Address settings > Save action wrapped in its own SectionView so it reads as a single-item card. Co-Authored-By: Claude Opus 4.7 (1M context) * WelcomeView: wrap Create profile action button in SectionView card The Create profile action SettingsActionItem at the bottom of the Create profile screen was loose on canvas. Wrap in SectionView so it reads as a single-item card matching the iOS-style facelift. The two SectionTextFooter captions below stay outside the card. Added missing `import SectionView`. Co-Authored-By: Claude Opus 4.7 (1M context) * ConnectMobileView: move footer + spacer out of "this device name" SectionView lambda Same pre-card-chrome pattern: SectionTextFooter and SectionDividerSpaced were inside the SectionView around DeviceNameField + multicast toggle, so they rendered inside the white card after PR #6777 — visible as an extra empty padding below the "Discoverable via local network" toggle (the SectionDividerSpaced 10dp Spacer inside the card). Move both outside the SectionView so the footer reads as caption below the card and the spacer separates this card from the next. Co-Authored-By: Claude Opus 4.7 (1M context) * Section: suppress RippleTheme deprecation warnings (build was treating warnings as errors) `androidx.compose.material.ripple.RippleTheme` and `LocalRippleTheme` were deprecated in newer Compose Material in favor of the modern Indication APIs. Our SectionRippleTheme override (a5b199660) hit those deprecations and the project's Kotlin compiler flags treat warnings as errors, breaking the build. Add `@file:Suppress("DEPRECATION")` to Section.kt — narrow file-level scope. Modern Indication-based ripple migration is a separate, larger concern; suppress for now so the section hover-alpha override keeps working. Co-Authored-By: Claude Opus 4.7 (1M context) * Section: also suppress DEPRECATION_ERROR — RippleTheme is @Deprecated(level=ERROR) Previous attempt (4bf981a6b) suppressed DEPRECATION but the Compose library deprecated RippleTheme with level=DeprecationLevel.ERROR, which requires the DEPRECATION_ERROR suppression key instead. Add both so either severity is covered. Co-Authored-By: Claude Opus 4.7 (1M context) * Section: replace deprecated RippleTheme override with modern Modifier.hoverable + background Drop SectionRippleTheme/RippleAlpha/LocalRippleTheme machinery (deprecated in newer Compose Material, would not compile with the project's warnings- as-errors policy without DEPRECATION_ERROR suppress, which is a code smell). Replace with a Modifier.hoverable + Modifier.background pattern — the modern Compose-native way to apply a hover overlay: - New private @Composable Modifier.sectionItemHover() that: - returns Modifier as-is outside SectionView card (LocalInSectionCard = false) - inside a card, attaches its own MutableInteractionSource via .hoverable() and paints a transparent or onBackground@0.08-alpha background based on collectIsHoveredAsState - Applied alongside .sectionItemDivider() in each SectionItemView modifier chain. Click ripple keeps coming from Modifier.clickable's own indication (default ripple, no changes there). - Drop @file:Suppress deprecation lines; drop SectionRippleTheme object; drop ripple imports; drop LocalRippleTheme from CompositionLocalProvider calls in three SectionView variants. Visual result identical to the previous attempt (hovered row gets a visible gray overlay on LIGHT canvas), no deprecated APIs, no warnings-as-errors fight. Click ripple unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) * Section: suppress sectionItemHover on disabled SectionItemView sectionItemHover was applied unconditionally inside section cards, so a disabled row would still show the hover overlay on mouseover — misleading: the visible interactive feedback contradicts the disabled state (no click reaction). Add `enabled: Boolean = true` parameter; the helper now returns `this` unchanged when `enabled = false`. The 3 SectionItemView family functions that own a modifier chain pass `enabled = !disabled`. SectionItemViewWithoutMinPadding inherits through SectionItemView delegation. Non-clickable info rows (click == null but disabled = false) still get the hover overlay — that's intentional cursor feedback matching iOS Settings behavior. Co-Authored-By: Claude Opus 4.7 (1M context) * Section: lighten section item hover overlay from 0.08 to 0.05 alpha 0.08 read as too dark on white cards. Original Compose default 0.04 blended with the off-white canvas (#F2F2F2 vs ~#F5F5F5). 0.05 is the midpoint — still visibly distinct from canvas (~#F2F2F2 canvas vs ~#F3F3F3 hover on white card) but no longer reads as a heavy box. Co-Authored-By: Claude Opus 4.7 (1M context) * RTCServers: wrap Configure ICE servers toggle in SectionView card Your ICE servers screen had its Configure-ICE toggle and the description text / editor / read-only display all directly in a raw Column with no card chrome. Wrap the toggle row in SectionView so it reads as a card matching the iOS-style facelift. The description text and the TextEditor / read-only Surface stay in the same loose Column below (they're a form/display block, not a settings row). Removed the explicit `padding = PaddingValues()` on the SectionItemViewSpaceBetween — inside SectionView it inherits CARD_ITEM_PADDING by default which is what we want now. Co-Authored-By: Claude Opus 4.7 (1M context) * NetworkAndServers: rewrite UseSocksProxySwitch via SettingsActionItemWithContent UseSocksProxySwitch was a custom Row with hard-coded horizontal padding of DEFAULT_PADDING (20dp) — but its neighbours on the messages card are SettingsActionItem rows that go through SectionItemView with the new CARD_ITEM_PADDING (15dp). 5dp icon misalignment between the SOCKS toggle row and the rest, plus no auto-divider underneath since it wasn't a SectionItemView. Replace the custom Row with SettingsActionItemWithContent — same icon + label + DefaultSwitch shape, now wrapped in SectionItemView so it shares padding and auto-divider with siblings. Co-Authored-By: Claude Opus 4.7 (1M context) * SocksProxySettings: move section text footers out of SectionView lambdas Same pre-card-chrome pattern as elsewhere: two SectionTextFooter calls ("Disable onion hosts when not supported" and the proxy-auth footer) were inside their SectionView lambdas in SocksProxySettings, so after the card chrome was added they rendered inside the white cards as inline content. Move both out so they read as captions below the corresponding cards. Co-Authored-By: Claude Opus 4.7 (1M context) * SocksProxySettings: split UseOnionHosts so the dynamic description footer renders outside the card UseOnionHosts wrapped its ExposedDropDownSettingRow and a dynamic SectionTextFooter ("Onion hosts will be used when available." / similar) in a Column, so when UseOnionHosts was called inside a SectionView lambda the footer rendered inside the white card. Split into two composables: - UseOnionHosts — only the dropdown row (no longer wraps in Column) - UseOnionHostsDescription — only the dynamic SectionTextFooter, called separately by the caller Shared `onionHostsValues` is now a private @Composable val accessible to both. In SocksProxySettings, UseOnionHostsDescription is now placed AFTER the SectionView block (alongside the existing "Disable onion hosts when not supported" caption) so the dynamic description reads as a caption below the card. Co-Authored-By: Claude Opus 4.7 (1M context) * Section: suppress hover on rows whose action is an inline control (switch/dropdown) sectionItemHover used to show on every row inside a section card. But rows where the action is an inline control (switch via PreferenceToggle, dropdown via ExposedDropDownSettingRow) are not "interactive as a row" — the user has to hit the actual control, not the whole row. Showing hover on the whole row was misleading. Add `clickable: Boolean = true` param to sectionItemHover; suppress when false. SectionItemView and SectionItemViewSpaceBetween pass `clickable = click != null`. SectionItemViewLongClickable keeps the default (its click is non-nullable, always interactive). Co-Authored-By: Claude Opus 4.7 (1M context) * Section + Theme: lighten LIGHT canvas to 0.97; revert custom hover overlay User feedback: the off-white canvas at 0.95 (#F2F2F2) read as too dark. Two coordinated changes: - canvasColorForCurrentTheme LIGHT branch: 0.95f → 0.97f. Canvas now #F7F7F7 (3% darker than white, was 5%). Still distinct from pure white card but lighter. - Drop the custom sectionItemHover Modifier helper (and its hoverable + InteractionSource + background machinery). The reason for the custom hover was that the default Material 0.04-alpha ripple hover (#F5F5F5 on white card) blended with the old #F2F2F2 canvas. With the lighter canvas at #F7F7F7 the default hover #F5F5F5 is now visibly darker than canvas (2 units delta) — visible enough at Material default without our custom override. Removed unused MutableInteractionSource and collectIsHoveredAsState imports. Co-Authored-By: Claude Opus 4.7 (1M context) * Section: bump section item minHeight by 6dp (50 → 56) User asked for taller settings rows. Bump the default minHeight in all four SectionItemView family functions from DEFAULT_MIN_SECTION_ITEM_HEIGHT (50dp) to DEFAULT_MIN_SECTION_ITEM_HEIGHT + 6.dp (56dp). Scoped to SectionItemView callers only — does not touch the global DEFAULT_MIN_SECTION_ITEM_HEIGHT constant, so non-section callers (ChatItemInfoView, ComposeContextProfilePicker, TagListView, UserPicker) keep the 50dp baseline. Callers that pass explicit minHeight (e.g. 54dp in GroupChatInfoView members) are unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) * Move SectionTextFooter / Spacer out of cards in 5 screens Fixes from the user's verified list of misplaced footers/spacers: - ChatInfoView: SimpleX address footer ("You can share this address with your contacts to let them connect with you.") moved out of the address SectionView lambda. - GroupMemberInfoView: same string for member address. - Appearance: SectionSpacer in the Image-wallpaper branch (after "Remove image" button) removed — it created 30dp empty padding inside the THEMES card only when a custom image was selected. - NotificationsSettingsView: Xiaomi battery-optimization footer ("Xiaomi devices: please enable Autostart...") moved out of the notifications SectionView lambda (visible only on Xiaomi devices in Periodic/Service notification mode). - ConnectMobileView: dropped the 20dp Spacer that sat inside the QR SectionView after the developer-tools "Share link" row — visible as extra padding below Share link inside the card. Same pre-card-chrome pattern as other moves: helpers placed inside SectionView lambdas before PR #6777 rendered fine when SectionView was a plain Column; after card chrome they render inside the white card. Moved them outside so footers read as captions and spacers actually separate cards. Co-Authored-By: Claude Opus 4.7 (1M context) * Migrate views: move all SectionTextFooter / SectionSpacer out of SectionView lambdas Same pre-card-chrome pattern as elsewhere. MigrateToDevice (4 footers) and MigrateFromDevice (9 footers + 1 SectionSpacer in error view) historically wrote captions and inter-card spacers inside their SectionView content lambdas. After PR #6777 added card chrome these rendered inside the white cards. MigrateToDevice fixes (4 footers, one per sub-view): - Confirm network settings footer - Database init failed retry footer - Archive import failed retry footer - Passphrase entering dynamic footer MigrateFromDevice fixes (9 footers + 1 SectionSpacer): - ChatStopFailed view footer - Passphrase confirmation footer - Upload confirmation footer - Upload failed retry footer - Link shown view: archive-will-be-deleted + choose-migrate footers - Finished view: 2 warning footers (must-not-use-two-devices, using-on-two-devices-breaks-encryption) - Implicit SectionSpacer at ChatStopFailed view also moved out Co-Authored-By: Claude Opus 4.7 (1M context) * Section + ChatListNavLink.android: align in-card chat row divider with desktop; canvas to 0.94 Two changes: 1) Theme.kt LIGHT canvas: 0.97f → 0.94f (#F0F0F0). User wants more contrast against cards. With Material's default 0.04-alpha hover (#F5F5F5) this puts hover LIGHTER than canvas by 5 units — unusual direction but it's the user's call; they'll evaluate visually. 2) ChatListNavLinkView.android: when rendered inside a SectionView card (e.g. contact list inside NewChatSheet after the forEach-into-card refactor), use SectionDivider() — same 2dp full-width canvas-color divider as desktop. Outside a card (main chat list), fall back to the original Material `Divider(Modifier.padding(horizontal = 8.dp))` so unchanged for that context. 3) LocalInSectionCard made `internal` so the android-specific file can read it. Same pattern as LocalAppColors etc. Co-Authored-By: Claude Opus 4.7 (1M context) * Section: bump section item minHeight by 2dp more (56 → 58) * GroupWelcomeView: wrap message editor/preview and buttons in SectionView cards * GroupLinkView: wrap action items below QR in SectionView card * Section: align InfoRow / IndentedInfoRow horizontal padding with CARD_ITEM_PADDING InfoRow defaulted to DEFAULT_PADDING (20dp), but card chrome adopted CARD_ITEM_PADDING (15dp) for SectionItemView and InfoRowTwoValues. Inside a card, rows of different kinds visibly jumped left/right. Bring InfoRow and its IndentedInfoRow variant onto the same baseline. * ServersSummaryView: align Message reception header indent with other section headers * ServersSummaryView: split SMP/XFTP server summary into separate top-level cards The summary layouts wrapped Stats / Subscriptions / Sessions inside the outer Server address SectionView, which produced nested cards and pushed the Statistics 'Starting from...' footer inside a card. Unnest them so each section is its own card with proper spacing and the footer renders outside. * CreateProfile: add vertical gap between profile fields and Create profile action card * UserPicker: wrap menu options in SectionView cards (SecondSection + GlobalSettingsSection) * UserPicker: gate SectionView card wrap on Android only Desktop UserPicker doesn't have a canvas background, so white cards on the white surface were invisible and the existing desktop divider above inactive users looked stray next to the SectionItemView mini-dividers. * Revert "UserPicker: gate SectionView card wrap on Android only" This reverts commit be365e55aea25489d8812c8fb5f650db0b340ce6. * UserPicker: use canvas color on desktop and split SecondSection around inactive-users grid Desktop background was MaterialTheme.colors.surface (white) so the SectionView cards introduced earlier were invisible. Switch to canvasColorForCurrentTheme() to match Android. Drop the explicit Divider above the inactive-users grid: split SecondSection into two SectionView cards with the avatar grid between them so the section dividers come from the cards themselves. * ChatListNavLinkView.android: fix LocalInSectionCard import path LocalInSectionCard is declared in Section.kt which has no package (root package), so it must be imported as 'import LocalInSectionCard', not as 'chat.simplex.common.views.helpers.LocalInSectionCard'. * UserPicker: merge SecondSection + GlobalSettings into one card on portrait Both Android and desktop portrait now show address, preferences, (desktop: inactive-users grid), profiles, link mobile / use from desktop, and settings inside a single SectionView card. Desktop landscape keeps the side-by-side two-card layout. * ChatInfoImage: default placeholder icon color to secondary secondaryVariant is near-white (#F1F2F6) and disappears against the gray canvas. Use the visible secondary tone instead so default avatars without a photo are legible on both card and canvas backgrounds. * UserPicker: align item padding with Settings, add divider after inactive-users grid UserPickerOptionRow no longer applies extraPadding on desktop, and the Settings row uses default SectionItemView padding instead of its own. Both now match the CARD_ITEM_PADDING used in Settings screens. After the inactive-users avatar grid in the unified card, paint a SectionDivider so it visually separates from Your chat profiles. * ChatInfoImage: place default avatar one canvas-shade darker than the canvas secondary (#8B8786) was too dark. Use background mixed with onBackground at 0.88 — same darkening recipe as canvasColorForCurrentTheme uses with 0.94, applied a step further. On LIGHT this lands near #E1E1E1: 15 units darker than the canvas, matching how the canvas sits 15 darker than white. * Appearance: symmetric vertical padding around Font size and Zoom preview tiles Both rows used Modifier.padding(top = 10.dp) so the tile hugged the bottom of the SectionView card. Switch to padding(vertical = 10.dp) to match the symmetric padding used by ProfileImageSection. * ChatInfoImage: dark themes use secondaryVariant for default avatar to match UserPicker The mixWith canvas-darkening formula only lands well in LIGHT. For DARK / BLACK / SIMPLEX, fall back to secondaryVariant, which UserPicker already uses for the active profile avatar — keeps placeholder avatars consistent across the app on dark themes. * Section: tighten icon-to-text spacing in TextIconSpaced by 2dp * ChatInfoImage: lighten LIGHT default avatar to halfway between white and canvas * ChatInfoView: hide Servers section header when there is no server content When both chatSubStatus and cStats are null, the SectionView body rendered as empty (zero-height card) but the SERVERS title still appeared, leaving an orphan header between E2E encryption and Clear chat. Gate the whole section on having at least one of the two. * GroupLink / WelcomeMessage: use SectionDividerSpaced between adjacent cards Three places had adjacent SectionView cards with no spacer (GroupLinkView QR + actions, WelcomeMessageView non-owner preview + copy), or used a one-off Spacer(8.dp) instead of the conventional helper (owner mode-button card). Replace with SectionDividerSpaced() so all between-card gaps live behind one helper. * Chat info / Group info: use full spacing between two title-less cards ChatInfoView (Contact prefs/Send receipts/Chat theme → Delete messages) and GroupChatInfoView (Member reports → Edit group profile) both used SectionDividerSpaced(maxBottomPadding = false) = 10dp between two cards that have neither header nor footer touching the gap, so the tight variant wasn't justified. Switch to the default 20dp. * remove diff noise * Sections: drop .uppercase() from all section / header titles Source string resources are already in sentence case (e.g. "Profile images", "Message reception"). The .uppercase() calls forced them to ALL CAPS, which is the Android settings convention but conflicts with the iOS-style facelift. Remove the call everywhere so headers render as in the source. * Section: shrink icon-to-text spacing to 5dp (-3dp from previous 8dp) * Appearance: shrink ColorModeSwitcher tap target to keep UserPicker Settings row at standard 58dp height * UserPicker.android: align profile boxes with menu card left edge (CARD_PADDING) * ChatListNavLinkView.desktop: restore chat list dividers outside SectionView Commit 633e0f414 made SectionDivider() a no-op outside SectionView card, which removed the desktop chat list dividers (the list is not wrapped in a SectionView). Mirror the Android conditional: SectionDivider in-card, padded Divider otherwise. * UserPicker: restore SectionView wrap around desktop active-profile row Commit 3a7118235 extracted the profile out of its original SectionView when wrapping the menu in its own card. Without the card chrome the profile shifted to the screen edge instead of sitting at CARD_PADDING like the menu below. Wrap it back in SectionView and add SectionDividerSpaced before the menu card. * Strings: convert section-title resources from ALL CAPS to sentence case 26 section header strings used as SectionView titles (SETTINGS, CHAT DATABASE, HELP, SERVERS, etc.) were stored ALL CAPS in source. The .uppercase() removal commit did nothing for them. Convert the source values to sentence case with proper-noun preservation (SimpleX, SOCKS). LIVE and OK stay all-caps (status badge and button). * UserPicker: add top spacer above active profile card and tighten its left padding Card was flush against the sheet's top edge; add DEFAULT_PADDING spacer above. Left padding inside the card was 16dp while the avatar (60dp) sat in an 80dp minHeight row so visual top/bottom were ~10dp — bring start down to 10dp so the photo sits equidistant from card top, bottom and left. * Section: bump card-title font size from 12sp to 14sp across all 3 SectionView variants * ServersSummaryView: bump Message reception custom header to 14sp to match other card titles * Sections: add SemiBold weight to card titles across all 4 places * Sections: drop card-title weight from SemiBold to Medium (W500) * ServersSummaryView: Message reception header bottom padding 5dp -> 8dp to match SectionView default * Strings: convert group_info_section_title_num_members to sentence case * Strings: convert settings_section_title_interface to sentence case (Interface) * Revert UserPicker to pre-card-wraps state per founder's request Restore UserPicker.kt and UserPicker.android.kt to their state at 43855ae07 (before commit 3a7118235 introduced SectionView wraps). The founder asked in chat to keep UserPicker out of the cards facelift — undo all of our changes to it, including the followup tweaks (avatar padding, divider above grid, active-profile wrap, etc.) and the founder's own followup cleanup 23b0e41d8 which only existed to refactor our wraps. * Section / Theme: extract sectionCardColor() helper Three SectionView overloads were each computing the same cardColor inline: if (CurrentColors.value.base == DefaultTheme.LIGHT) Color.White else MaterialTheme.colors.background.mixWith(...). DRY violation paired with the canvasColorForCurrentTheme() helper that already covers the canvas side of the same theme split. Add a sectionCardColor() function in Theme.kt and collapse the 3 inline formulas to one call. * Theme: document why canvasColorForCurrentTheme reads CurrentColors.value directly Reviewer asked why this helper uses CurrentColors.value.base instead of the Compose MaterialTheme/CompositionLocal route. Reason is that the helper is intentionally callable from both @Composable bodies and DrawScope (inside sectionItemDivider's drawWithContent), and DrawScope can't invoke @Composable getters. Add a paragraph to the doc-comment so future readers don't try to 'fix' it back to MaterialTheme.colors and break the divider draw path. * ConnectMobileView: collapse double blank line left over from move-footer edit * Sections: normalize redundant SectionDividerSpaced flag combinations After founder simplified SectionDividerSpaced to one Spacer height (any flag true -> DEFAULT_PADDING; both false -> DEFAULT_PADDING_HALF), many call sites still pass combinations like (maxTopPadding = true) or (maxTopPadding = true, maxBottomPadding = false) that all produce the same 20dp gap as the default. The flag names no longer match what they do — reviewer flagged this as misleading. Collapse all call sites to two canonical forms: SectionDividerSpaced() for the 20dp gap, SectionDividerSpaced(maxBottomPadding = false) for the 10dp tight gap. Behavior identical. Function signature kept (founder's API). * NewChatSheet: render filtered contacts in search mode (regression fix) Commit 3a9ece8d1 moved contacts forEach inside the if-branch and made the else-branch fall back to NoFilteredContactsItem. That broke search: when the user typed text and the filter returned non-empty results, the if-condition (filtered.isNotEmpty() && searchText.isEmpty()) was false, the else ran NoFilteredContactsItem, NoFilteredContactsItem's internal guard saw a non-empty filter and rendered nothing — search results disappeared. Restore three-way branching with when{}: header + contacts in card when no search; contacts in plain card when search has matches; NoFilteredContactsItem when filter is empty. Applied at both OneHandLazyColumn and the regular layout. * GroupChatInfoView: keep Invite + owner in card, render members as lazy items fa29bb7a7 put filteredMembers.value.forEach inside the same SectionView as the Invite button and the owner row to get a unified card visual. That sacrificed lazy rendering — all members composed at once, hurting big-group scroll perf. Founder asked to bring lazy back. Compromise: keep Invite + (search row) + owner row inside the SectionView card (the 'hero' rows). Move the rest of the members out to a sibling items(filteredMembers.value, key = { it.groupMemberId }) call in the parent LazyColumn — bare SectionItemViewLongClickable rows below the card, lazy-composed by LazyColumn. * ChatInfoImage: LIGHT default avatar at midpoint of white card and gray canvas Was at 0.91 mix (~#E8) — designed to sit 'below' the white card, but on the ~#F0 canvas it nearly blended (delta ~8). Switch to 0.97 mix (~#F7), which is the geometric midpoint between #FF (white card) and ~#F0 (canvas) and so sits at equal absolute contrast against either background. * MemberProfileImage: use defaultProfileIconColor instead of secondaryVariant MemberProfileImage hard-coded color = MaterialTheme.colors.secondaryVariant as default, which is LightGray (#F1F2F6) on LIGHT — slightly bluish and nearly invisible against the ~#F0F0F0 canvas. Reuse the defaultProfileIconColor() helper so the LIGHT default matches the rest of the app (midpoint between canvas and white card), and DARK themes keep their palette secondaryVariant. defaultProfileIconColor() in ChatInfoImage.kt promoted from private to file- level visibility so it can be referenced from GroupMemberInfoView. * ProfileImage colors: split into card vs canvas variants Revert defaultProfileIconColor back to 0.91 mix (~#E8) — that's the right amount of contrast against a white SectionView card. Add a sibling helper defaultProfileIconColorOnCanvas() at 0.85 mix (~#D9), which sits 23 units below the ~#F0 canvas — same absolute contrast as the card variant achieves on white. Switch MemberProfileImage default from defaultProfileIconColor to the canvas variant. Member avatars almost always render on canvas (chat list rows, chat-bubble author avatar, group members list outside the card, channel members, channel relays). Callers that need the card variant pass an explicit color. * GroupChatInfoView: move owner row out of the members card into the lazy list Per review #1: card holds only Invite + (optional) search; the user-as-owner row joins the same lazy column as the rest of the members, picking up the canvas-variant avatar color through MemberProfileImage's updated default. * Revert "ProfileImage colors: split into card vs canvas variants" This reverts commit 379f84a4aeb86ace5b1cb9fcac084915d8868146. * Revert "MemberProfileImage: use defaultProfileIconColor instead of secondaryVariant" This reverts commit bea3f246644b410c939aee00a0978a004b1d2bed. * Revert "ChatInfoImage: LIGHT default avatar at midpoint of white card and gray canvas" This reverts commit 05fbd6e0b1bfc959f8e883c3b04065af40d763dc. * Theme: darken LightColorPalette.secondaryVariant from #F1F2F6 (LightGray) to #E0E0E0 Old value (LightGray = #F1F2F6) was nearly invisible against the ~#F0F0F0 canvas — slightly bluish hue, ~1-2 units of contrast. The new #E0E0E0 sits ~16 units below canvas and ~31 below white card, visible on both. Affects all LIGHT-theme avatar placeholders, UserPicker icons, DevicePill borders and a handful of subtle UI surfaces using secondaryVariant. * Card-less screens: paint background with Material surface Form-only and link/QR screens have no card sections — the off-white canvas under them just adds an extra visual layer with nothing to lift. Switch their background to MaterialTheme.colors.surface (white on LIGHT, palette surface on DARK/BLACK/SIMPLEX) so the screen reads as a single sheet. Two patterns by container: - 11 ModalView callsites get background = MaterialTheme.colors.surface. - 4 screens rendered inside someone else's ModalView (GroupLinkView, HiddenProfileView, TagListView, UserProfilesView) wrap their root ColumnWithScrollBar in Box(Modifier.fillMaxSize().background(...)) so they own their background regardless of caller. - 1 BottomSheet root (CreateProfile in WelcomeView) gets background on the fillMaxSize Box. Touched screens: Create profile, Create first profile (mobile/desktop), Create group, Create channel (3 wizard steps), Edit group profile, Group link, Add welcome message / Welcome message, Edit own profile, Hide profile, Tag list editor, Your chat profiles, Add server, Add chat relay (new variant only — Edit relay stays settings-style). * NewServerView: add missing MaterialTheme import after previous commit * Revert "GroupLink / WelcomeMessage: use SectionDividerSpaced between adjacent cards" This reverts commit 29be15404f8965c98f46a441534c9c45c44caef8. * Revert "CreateProfile: add vertical gap between profile fields and Create profile action card" This reverts commit 43855ae07d7a6c492e2ae5d5477d07de6e997a80. * Revert "WelcomeView: wrap Create profile action button in SectionView card" This reverts commit c61ea0109250e1a75d9c3efd6f8b72d1a67db752. * Revert "AddGroupView/AddChannelView: wrap action buttons + toggles in SectionView card" This reverts commit 4d9319d12af6eebb2b344b1d8d627b4510c644ab. * Revert "GroupLinkView: wrap action items below QR in SectionView card" This reverts commit f2ef38092a5375dd19971dfe399eb1a768856ca0. * Revert "GroupWelcomeView: wrap message editor/preview and buttons in SectionView cards" This reverts commit edb3495a8f04f249145c6a0ca8a6d15aaf6aabda. * Revert "TagListView: wrap Add/Save list button in SectionView card" This reverts commit c25f36a90045c9a5da77f3fccd73e113779d9d9b. * UserProfilesView: drop SectionView wraps to remove card chrome The Your-chat-profiles screen is now on white surface bg; the SectionView cards (founder's original from PR #6777) painted white-on-white and only contributed padding. Unwrap the two SectionViews (hidden-profile reveal button + main profiles list) so the rows render directly inside the ColumnWithScrollBar without card chrome. * NewChatSheet: use standard 20dp gap between cards instead of tight 10dp * WelcomeMessageView/GroupChatInfoView: unify owner button row, restore member dividers WelcomeMessageView: drop SectionView wrap on SaveButton so all three owner-mode action rows (Edit/Preview, Copy, Save) render uniformly as loose rows on canvas, matching the post-revert direction of the card-chrome cleanup. GroupChatInfoView: restore per-item Divider() in the members lazy list (lost during card-chrome experimentation). Owner row stays attached to the "N members" card by design; divider appears between owner and first lazy member, and between each subsequent member. Co-Authored-By: Claude Opus 4.7 (1M context) * Strings: sentence case for 29 section-title keys across all locales Base file was converted in 8292a815f / de36f1f40 / 314384b69 but other locales still rendered titles like SETTINGS, НАСТРОЙКИ, EINSTELLUNGEN, PARAMÈTRES, USTAWIENIA, ÎMPOSTAZIONI in ALL CAPS. Bring every locale to sentence case with a single sweep. Implementation: Python script (/tmp/fix_uppercase_locales.py) walks every non-base locale dir, finds the 29 key strings, and rewrites them when the value is entirely uppercase (no lowercase letter). Placeholders like %1$s are preserved as-is; SimpleX and SOCKS are kept as proper nouns after the lowercase pass. Values already in sentence case, empty, or in scripts with no case distinction are left alone. 540 string changes across 33 locales (ar, bg, ca, cs, da, de, el, es, fa, fi, fr, hr, hu, in, it, iw, ja, ko, ku, lt, nb-rNO, nl, pl, pt, pt-rBR, ro, ru, th, tr, uk, vi, zh-rCN, zh-rTW). Locales bn, hi, ml, sv, lv had nothing to change. * UserProfilesView: use Divider() between rows (SectionDivider no-op outside SectionView) * ShareListView: use MaterialTheme.colors.surface background (Forward picker) * ChatItemInfoView: white surface background + drop SectionView card wraps Message info screen (right-click → Info on desktop) had off-white themedBackground canvas with white SectionView cards inside. Switch to MaterialTheme.colors.surface background and replace 7 SectionView wraps with plain Column (preserving the contentPadding the SectionViews had) — content reads as a single sheet, no ghost card edges on white-on-white. * Card-less screens batch 2: surface bg for conditions + how-to-use + about + version Six more screens get white surface background to match the form-screen visual: - UsageConditionsView (Network & servers → Review conditions): root ColumnWithScrollBar gets .background(surface). - SingleOperatorUsageConditionsView (operator-conditions modal opened from enabling an operator): same. - HowItWorks (Settings → How to use it): root Column gets .background(surface). - WhatsNewView (Settings → What's new): ModalView gets background = surface. - SimpleXInfoLayout (Settings → About SimpleX Chat): conditional on onboardingStage == null so the onboarding entry keeps its themedBackground while the settings entry switches to surface. - VersionInfoView (Settings → App version): root ColumnWithScrollBar gets .background(surface). * fix language strings * fix contact list to be lazy * more language fixes * fix greek * fix indentation * refactor and simplify * remove dividers * background for settings pages with cards * fix section titles * remove footers outside of section cards * move footers out of cards * fix appearance etc * fix members lists, add background * appearance * reduce paddings inside cards * paddings * more paddings * card item paddings * fix paddings * toolbar color * more toolbar color * fix toolbar * add padding * refactor modals hierarchy * more cards * more cards * fix theme * split walpaper settings to two sections * better grid * grid --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> Co-authored-by: another-simple-pixel Co-authored-by: Claude Opus 4.7 (1M context) --- .../views/usersettings/Appearance.android.kt | 7 +- .../chat/simplex/common/ui/theme/Theme.kt | 32 +- .../simplex/common/views/chat/ChatInfoView.kt | 25 +- .../simplex/common/views/chat/ChatView.kt | 10 +- .../common/views/chat/ContactPreferences.kt | 18 +- .../views/chat/group/AddGroupMembersView.kt | 7 +- .../views/chat/group/AddGroupRelayView.kt | 5 +- .../views/chat/group/ChannelMembersView.kt | 2 +- .../views/chat/group/ChannelRelaysView.kt | 2 +- .../views/chat/group/GroupChatInfoView.kt | 92 +++--- .../common/views/chat/group/GroupLinkView.kt | 110 ++++--- .../views/chat/group/GroupMemberInfoView.kt | 16 +- .../views/chat/group/GroupPreferences.kt | 37 ++- .../views/chat/group/MemberAdmission.kt | 6 +- .../views/chat/group/MemberSupportChatView.kt | 2 +- .../views/chatlist/ChatListNavLinkView.kt | 2 +- .../common/views/chatlist/ChatListView.kt | 7 +- .../views/chatlist/ServersSummaryView.kt | 59 ++-- .../common/views/chatlist/TagListView.kt | 3 +- .../common/views/chatlist/UserPicker.kt | 2 +- .../views/database/DatabaseEncryptionView.kt | 2 +- .../common/views/database/DatabaseView.kt | 11 +- .../simplex/common/views/helpers/ModalView.kt | 15 +- .../simplex/common/views/helpers/Section.kt | 135 ++++++-- .../common/views/helpers/ThemeModeEditor.kt | 270 ++++++++------- .../views/migration/MigrateFromDevice.kt | 46 +-- .../common/views/migration/MigrateToDevice.kt | 33 +- .../common/views/newchat/AddChannelView.kt | 4 +- .../common/views/newchat/AddGroupView.kt | 4 +- .../newchat/ContactConnectionInfoView.kt | 5 +- .../common/views/newchat/NewChatSheet.kt | 4 +- .../common/views/newchat/NewChatView.kt | 8 +- .../views/onboarding/ChooseServerOperators.kt | 8 +- .../views/onboarding/LinkAMobileView.kt | 2 +- .../common/views/remote/ConnectDesktopView.kt | 55 ++-- .../common/views/remote/ConnectMobileView.kt | 8 +- .../common/views/usersettings/Appearance.kt | 307 +++++++++--------- .../views/usersettings/DeveloperView.kt | 21 +- .../views/usersettings/HiddenProfileView.kt | 2 +- .../usersettings/NotificationsSettingsView.kt | 9 +- .../common/views/usersettings/Preferences.kt | 15 +- .../views/usersettings/PrivacySettings.kt | 9 +- .../common/views/usersettings/SettingsView.kt | 10 +- .../views/usersettings/UserAddressView.kt | 34 +- .../views/usersettings/UserProfilesView.kt | 3 +- .../AdvancedNetworkSettings.kt | 14 +- .../networkAndServers/ChatRelayView.kt | 11 +- .../networkAndServers/NetworkAndServers.kt | 25 +- .../networkAndServers/OperatorView.kt | 21 +- .../networkAndServers/ProtocolServerView.kt | 14 +- .../networkAndServers/ProtocolServersView.kt | 12 +- .../commonMain/resources/MR/ar/strings.xml | 2 +- .../commonMain/resources/MR/base/strings.xml | 64 ++-- .../commonMain/resources/MR/bg/strings.xml | 48 +-- .../commonMain/resources/MR/ca/strings.xml | 52 +-- .../commonMain/resources/MR/cs/strings.xml | 48 +-- .../commonMain/resources/MR/da/strings.xml | 2 +- .../commonMain/resources/MR/de/strings.xml | 58 ++-- .../commonMain/resources/MR/el/strings.xml | 46 +-- .../commonMain/resources/MR/es/strings.xml | 56 ++-- .../commonMain/resources/MR/fa/strings.xml | 2 +- .../commonMain/resources/MR/fi/strings.xml | 40 +-- .../commonMain/resources/MR/fr/strings.xml | 50 +-- .../commonMain/resources/MR/hr/strings.xml | 36 +- .../commonMain/resources/MR/hu/strings.xml | 56 ++-- .../commonMain/resources/MR/in/strings.xml | 48 +-- .../commonMain/resources/MR/it/strings.xml | 56 ++-- .../commonMain/resources/MR/iw/strings.xml | 2 +- .../commonMain/resources/MR/ja/strings.xml | 2 +- .../commonMain/resources/MR/ko/strings.xml | 2 +- .../commonMain/resources/MR/ku/strings.xml | 24 +- .../commonMain/resources/MR/lt/strings.xml | 40 +-- .../resources/MR/nb-rNO/strings.xml | 2 +- .../commonMain/resources/MR/nl/strings.xml | 46 +-- .../commonMain/resources/MR/pl/strings.xml | 48 +-- .../resources/MR/pt-rBR/strings.xml | 46 +-- .../commonMain/resources/MR/pt/strings.xml | 28 +- .../commonMain/resources/MR/ro/strings.xml | 48 +-- .../commonMain/resources/MR/ru/strings.xml | 56 ++-- .../commonMain/resources/MR/th/strings.xml | 2 +- .../commonMain/resources/MR/tr/strings.xml | 46 +-- .../commonMain/resources/MR/uk/strings.xml | 48 +-- .../commonMain/resources/MR/vi/strings.xml | 46 +-- .../resources/MR/zh-rCN/strings.xml | 2 +- .../resources/MR/zh-rTW/strings.xml | 2 +- .../chatlist/ChatListNavLinkView.desktop.kt | 3 +- .../views/usersettings/Appearance.desktop.kt | 13 +- 87 files changed, 1463 insertions(+), 1268 deletions(-) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/Appearance.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/Appearance.android.kt index 47506d9532..c16d1ea90d 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/Appearance.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/Appearance.android.kt @@ -2,7 +2,6 @@ package chat.simplex.common.views.usersettings import SectionBottomSpacer import SectionDividerSpaced -import SectionSpacer import SectionView import android.app.Activity import android.content.ComponentName @@ -126,9 +125,9 @@ fun AppearanceScope.AppearanceLayout( SectionDividerSpaced() ProfileImageSection() - SectionDividerSpaced(maxTopPadding = true) + SectionDividerSpaced() - SectionView(stringResource(MR.strings.settings_section_title_icon), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING_HALF)) { + SectionView(stringResource(MR.strings.settings_section_title_icon), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING_HALF, vertical = DEFAULT_PADDING_HALF)) { LazyRow { items(AppIcon.values().size, { index -> AppIcon.values()[index] }) { index -> val item = AppIcon.values()[index] @@ -152,7 +151,7 @@ fun AppearanceScope.AppearanceLayout( } } - SectionDividerSpaced(maxTopPadding = true) + SectionDividerSpaced() FontScaleSection() SectionBottomSpacer() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt index 1de47df7ce..b8dc9ff6d8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt @@ -596,10 +596,38 @@ data class ThemeModeOverride ( } } -fun Modifier.themedBackground(baseTheme: DefaultTheme = CurrentColors.value.base, bgLayerSize: MutableState?, bgLayer: GraphicsLayer?/*, shape: Shape = RectangleShape*/): Modifier { +// Canvas color for settings/info screens (drawn behind cards by themedBackground) +// and for the 2dp item divider inside section cards (matches canvas so dividers +// read as gaps showing the screen behind). +// LIGHT: formula derives off-white from palette bg + onBackground — lifts white +// cards above. DARK/BLACK: palette bg (cards already raised via founder's +// formula in Section.kt). SIMPLEX: gradient bottom stop (darker), since the +// canvas itself is a gradient drawn by themedBackgroundBrush. +fun canvasColorForCurrentTheme(): Color { + val theme = CurrentColors.value + val c = theme.colors + return when (theme.base) { + DefaultTheme.LIGHT -> c.background.mixWith(c.onBackground, 0.94f) + DefaultTheme.SIMPLEX -> c.background.darker(0.4f) + else -> c.background + } +} + +// Card background color for SectionView. LIGHT: pure white (raised above the +// off-white canvas). DARK/BLACK/SIMPLEX: founder's mixWith formula (lifts cards +// above palette bg using onBackground tint). +fun sectionCardColor(): Color { + val theme = CurrentColors.value + return if (theme.base == DefaultTheme.LIGHT) Color.White + else theme.colors.background.mixWith(theme.colors.onBackground, 0.95f) +} + +fun Modifier.themedBackground(baseTheme: DefaultTheme = CurrentColors.value.base, bgLayerSize: MutableState?, bgLayer: GraphicsLayer?, overrideColor: Color? = null): Modifier { return drawBehind { copyBackgroundToAppBar(bgLayerSize, bgLayer) { - if (baseTheme == DefaultTheme.SIMPLEX) { + if (overrideColor != null) { + drawRect(overrideColor) + } else if (baseTheme == DefaultTheme.SIMPLEX) { drawRect(brush = themedBackgroundBrush()) } else { drawRect(CurrentColors.value.colors.background) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index 061ea71016..a063477f84 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -3,10 +3,9 @@ package chat.simplex.common.views.chat import InfoRow import InfoRowEllipsis import SectionBottomSpacer -import SectionDividerSpaced import SectionItemView import SectionItemViewSpaceBetween -import SectionSpacer +import SectionDividerSpaced import SectionTextFooter import SectionView import androidx.compose.desktop.ui.tooling.preview.Preview @@ -553,7 +552,7 @@ fun ChatInfoLayout( LocalAliasEditor(chat.id, localAlias, updateValue = onLocalAliasChanged) - SectionSpacer() + SectionDividerSpaced() Box( Modifier.fillMaxWidth(), @@ -573,10 +572,10 @@ fun ChatInfoLayout( } } - SectionSpacer() + SectionDividerSpaced() if (customUserProfile != null) { - SectionView(generalGetString(MR.strings.incognito).uppercase()) { + SectionView(generalGetString(MR.strings.incognito)) { SectionItemViewSpaceBetween { Text(generalGetString(MR.strings.incognito_random_profile)) Text(customUserProfile.chatViewName, color = Indigo) @@ -601,7 +600,7 @@ fun ChatInfoLayout( } WallpaperButton { - ModalManager.end.showModal { + ModalManager.end.showModal(cardScreen = true) { val chat = remember { derivedStateOf { chatModel.chats.value.firstOrNull { it.id == chat.id } } } val c = chat.value if (c != null) { @@ -610,13 +609,13 @@ fun ChatInfoLayout( } } } - SectionDividerSpaced(maxBottomPadding = false) + SectionDividerSpaced() SectionView { ChatTTLOption(chatItemTTL, setChatItemTTL, deletingItems) - SectionTextFooter(stringResource(MR.strings.chat_ttl_options_footer)) } - SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + SectionTextFooter(stringResource(MR.strings.chat_ttl_options_footer)) + SectionDividerSpaced() val conn = contact.activeConn if (conn != null) { @@ -627,13 +626,13 @@ fun ChatInfoLayout( } if (contact.contactLink != null) { - SectionView(stringResource(MR.strings.address_section_title).uppercase()) { + SectionView(stringResource(MR.strings.address_section_title)) { SimpleXLinkQRCode(contact.contactLink) val clipboard = LocalClipboardManager.current ShareAddressButton { clipboard.shareText(simplexChatLink(contact.contactLink)) } - SectionTextFooter(stringResource(MR.strings.you_can_share_this_address_with_your_contacts).format(contact.displayName)) } - SectionDividerSpaced(maxTopPadding = true) + SectionTextFooter(stringResource(MR.strings.you_can_share_this_address_with_your_contacts).format(contact.displayName)) + SectionDividerSpaced() } if (contact.ready && contact.active) { @@ -670,7 +669,7 @@ fun ChatInfoLayout( } } } - SectionDividerSpaced(maxBottomPadding = false) + SectionDividerSpaced() } SectionView { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index f42969a73f..5299a5e686 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -406,7 +406,7 @@ fun ChatView( val selectedItems: MutableState?> = mutableStateOf(null) ModalManager.end.showCustomModal { close -> val appBar = remember { mutableStateOf(null as @Composable (BoxScope.() -> Unit)?) } - ModalView(close, appBar = appBar.value) { + ModalView(close, cardScreen = true, appBar = appBar.value) { val chatInfo = remember { activeChat }.value?.chatInfo if (chatInfo is ChatInfo.Direct) { var contactInfo: Pair? by remember { mutableStateOf(preloadedContactInfo) } @@ -509,7 +509,7 @@ fun ChatView( if (chatsCtx.secondaryContextFilter == null) { ModalManager.end.closeModals() } - ModalManager.end.showModalCloseable(true) { close -> + ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { close -> remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> GroupMemberInfoView(chatRh, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = false, close = close, closeAll = close) } @@ -801,7 +801,7 @@ fun ChatView( } is ChatInfo.ContactConnection -> { val close = { chatModel.chatId.value = null } - ModalView(close, showClose = appPlatform.isAndroid, content = { + ModalView(close, showClose = appPlatform.isAndroid, cardScreen = true, content = { ContactConnectionInfoView(chatModel, chatRh, chatInfo.contactConnection.connLinkInv, chatInfo.contactConnection, false, close) }) LaunchedEffect(chatInfo.id) { @@ -3193,7 +3193,7 @@ fun addGroupMembers(groupInfo: GroupInfo, rhId: Long?, view: Any? = null, close: withBGApi { setGroupMembers(rhId, groupInfo, chatModel) close?.invoke() - ModalManager.end.showModalCloseable(true) { close -> + ModalManager.end.showModalCloseable(showClose = true) { close -> AddGroupMembersView(rhId, groupInfo, false, chatModel, close) } } @@ -3204,7 +3204,7 @@ fun openGroupLink(groupInfo: GroupInfo, rhId: Long?, view: Any? = null, close: ( withBGApi { val link = chatModel.controller.apiGetGroupLink(rhId, groupInfo.groupId) close?.invoke() - ModalManager.end.showModalCloseable(true) { + ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { GroupLinkView(chatModel, rhId, groupInfo, link, onGroupLinkUpdated = null, isChannel = groupInfo.useRelays, shareGroupInfo = groupInfo) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt index 7c04c30f67..0276727ccc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt @@ -2,12 +2,13 @@ package chat.simplex.common.views.chat import InfoRow import SectionBottomSpacer -import SectionDividerSpaced import SectionItemView +import SectionDividerSpaced import SectionTextFooter import SectionView import androidx.compose.foundation.* import androidx.compose.foundation.layout.* +import androidx.compose.ui.Modifier import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.* @@ -54,6 +55,7 @@ fun ContactPreferencesView( if (featuresAllowed == currentFeaturesAllowed) close() else showUnsavedChangesAlert({ savePrefs(close) }, close) }, + cardScreen = true, ) { ContactPreferencesLayout( featuresAllowed, @@ -90,27 +92,27 @@ private fun ContactPreferencesLayout( TimedMessagesFeatureSection(featuresAllowed, contact.mergedPreferences.timedMessages, timedMessages, onTTLUpdated) { allowed, ttl -> applyPrefs(featuresAllowed.copy(timedMessagesAllowed = allowed, timedMessagesTTL = ttl ?: currentFeaturesAllowed.timedMessagesTTL)) } - SectionDividerSpaced(true) + SectionDividerSpaced() val allowFullDeletion: MutableState = remember(featuresAllowed) { mutableStateOf(featuresAllowed.fullDelete) } FeatureSection(ChatFeature.FullDelete, user.fullPreferences.fullDelete.allow, contact.mergedPreferences.fullDelete, allowFullDeletion) { applyPrefs(featuresAllowed.copy(fullDelete = it)) } - SectionDividerSpaced(true) + SectionDividerSpaced() val allowReactions: MutableState = remember(featuresAllowed) { mutableStateOf(featuresAllowed.reactions) } FeatureSection(ChatFeature.Reactions, user.fullPreferences.reactions.allow, contact.mergedPreferences.reactions, allowReactions) { applyPrefs(featuresAllowed.copy(reactions = it)) } - SectionDividerSpaced(true) + SectionDividerSpaced() val allowVoice: MutableState = remember(featuresAllowed) { mutableStateOf(featuresAllowed.voice) } FeatureSection(ChatFeature.Voice, user.fullPreferences.voice.allow, contact.mergedPreferences.voice, allowVoice) { applyPrefs(featuresAllowed.copy(voice = it)) } - SectionDividerSpaced(true) + SectionDividerSpaced() val allowCalls: MutableState = remember(featuresAllowed) { mutableStateOf(featuresAllowed.calls) } FeatureSection(ChatFeature.Calls, user.fullPreferences.calls.allow, contact.mergedPreferences.calls, allowCalls) { applyPrefs(featuresAllowed.copy(calls = it)) } - SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + SectionDividerSpaced() ResetSaveButtons( reset = reset, save = savePrefs, @@ -135,7 +137,7 @@ private fun FeatureSection( ) SectionView( - feature.text.uppercase(), + feature.text, icon = feature.iconFilled(), iconTint = if (enabled.forUser) SimplexGreen else if (enabled.forContact) WarningYellow else Color.Red, leadingIcon = true, @@ -170,7 +172,7 @@ private fun TimedMessagesFeatureSection( ) SectionView( - ChatFeature.TimedMessages.text.uppercase(), + ChatFeature.TimedMessages.text, icon = ChatFeature.TimedMessages.iconFilled(), iconTint = if (enabled.forUser) SimplexGreen else if (enabled.forContact) WarningYellow else Color.Red, leadingIcon = true, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt index 9298b600e9..0749df7775 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt @@ -5,7 +5,6 @@ import SectionCustomFooter import SectionDividerSpaced import SectionItemView import SectionItemViewWithoutMinPadding -import SectionSpacer import SectionView import androidx.compose.foundation.* import androidx.compose.foundation.layout.* @@ -161,7 +160,7 @@ fun AddGroupMembersLayout( iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight ) } - SectionSpacer() + SectionDividerSpaced() if (contactsToAdd.isEmpty() && searchText.value.text.isEmpty()) { Row( @@ -195,8 +194,8 @@ fun AddGroupMembersLayout( SectionCustomFooter { InviteSectionFooter(selectedContactsCount = selectedContacts.size, allowModifyMembers, clearSelection) } - SectionDividerSpaced(maxTopPadding = true) - SectionView(stringResource(MR.strings.select_contacts).uppercase()) { + SectionDividerSpaced() + SectionView(stringResource(MR.strings.select_contacts)) { SectionItemView(padding = PaddingValues(start = DEFAULT_PADDING, end = DEFAULT_PADDING_HALF)) { SearchRowView(searchText) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupRelayView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupRelayView.kt index d0c2486069..1ed75bd2a2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupRelayView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupRelayView.kt @@ -5,6 +5,7 @@ import SectionCustomFooter import SectionDividerSpaced import SectionItemView import SectionView +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* @@ -131,8 +132,8 @@ private fun AddGroupRelayLayout( fontSize = 14.sp ) } - SectionDividerSpaced(maxTopPadding = true) - SectionView(generalGetString(MR.strings.select_relays).uppercase()) { + SectionDividerSpaced() + SectionView(generalGetString(MR.strings.select_relays)) { availableRelays.forEach { item -> val selected = item.relayId in selectedRelayIds SectionItemView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt index 0cf3a3c96f..bcf8048971 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt @@ -44,7 +44,7 @@ fun ChannelMembersView( if (groupInfo.isOwner) { val subscriberCount = groupInfo.groupSummary.publicMemberCount ?: (members.size + 1).toLong() - SectionView(title = subscriberCountStr(subscriberCount).uppercase()) { + SectionView(title = subscriberCountStr(subscriberCount)) { SectionItemView(minHeight = 54.dp, padding = PaddingValues(horizontal = DEFAULT_PADDING)) { ChannelMemberRow(groupInfo.membership, user = true, showRole = true) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt index cfe9f0472d..d99e16d15f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt @@ -117,7 +117,7 @@ private fun ChannelRelaysLayout( // 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 -> + ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { close -> AddGroupRelayView( groupInfo = groupInfo, existingRelayIds = existingRelayIds, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index 0f64479359..10eec7b62f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -1,20 +1,24 @@ package chat.simplex.common.views.chat.group +import CARD_PADDING import InfoRow import SectionBottomSpacer -import SectionDividerSpaced import SectionItemView import SectionItemViewLongClickable import SectionItemViewSpaceBetween -import SectionSpacer +import SectionDividerSpaced import SectionTextFooter import SectionView import androidx.compose.animation.* import androidx.compose.animation.core.animateDpAsState import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.RectangleShape import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable @@ -111,7 +115,7 @@ fun ModalData.GroupChatInfoView( setGroupMembers(rhId, groupInfo, chatModel) if (!isActive) return@launch - ModalManager.end.showModalCloseable(true) { close -> + ModalManager.end.showModalCloseable(showClose = true) { close -> AddGroupMembersView(rhId, groupInfo, false, chatModel, close) } } @@ -126,7 +130,7 @@ fun ModalData.GroupChatInfoView( } else { member to null } - ModalManager.end.showModalCloseable(true) { closeCurrent -> + ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { closeCurrent -> remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> GroupMemberInfoView(rhId, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = false, groupRelay = groupRelay, close = closeCurrent) { closeCurrent() @@ -167,7 +171,7 @@ fun ModalData.GroupChatInfoView( clearChat = { clearChatDialog(chat, close) }, leaveGroup = { leaveGroupDialog(rhId, groupInfo, chatModel, close) }, manageGroupLink = { - ModalManager.end.showModal { GroupLinkView(chatModel, rhId, groupInfo, groupLink, onGroupLinkUpdated, isChannel = groupInfo.useRelays, shareGroupInfo = groupInfo) } + ModalManager.end.showModal(cardScreen = true) { GroupLinkView(chatModel, rhId, groupInfo, groupLink, onGroupLinkUpdated, isChannel = groupInfo.useRelays, shareGroupInfo = groupInfo) } }, onSearchClicked = onSearchClicked, deletingItems = deletingItems @@ -552,7 +556,7 @@ fun ModalData.GroupChatInfoLayout( LocalAliasEditor(chat.id, groupInfo.localAlias, isContact = false, updateValue = onLocalAliasChanged) - SectionSpacer() + SectionDividerSpaced() Box( Modifier.fillMaxWidth(), @@ -581,10 +585,10 @@ fun ModalData.GroupChatInfoLayout( } } - SectionSpacer() + SectionDividerSpaced() if (groupInfo.useRelays && groupInfo.membership.memberIncognito) { - SectionView(generalGetString(MR.strings.incognito).uppercase()) { + SectionView(generalGetString(MR.strings.incognito)) { SectionItemViewSpaceBetween { Text(generalGetString(MR.strings.incognito_random_profile)) Text(groupInfo.membership.chatViewName, color = Indigo) @@ -658,7 +662,7 @@ fun ModalData.GroupChatInfoLayout( } } if (anyTopSectionRowShow) { - SectionDividerSpaced(maxBottomPadding = false) + SectionDividerSpaced() } SectionView { if (groupInfo.isOwner && groupInfo.businessChat?.chatType == null) { @@ -677,7 +681,7 @@ fun ModalData.GroupChatInfoLayout( else if (groupInfo.businessChat == null) MR.strings.only_group_owners_can_change_prefs else MR.strings.only_chat_owners_can_change_prefs SectionTextFooter(stringResource(footerId)) - SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + SectionDividerSpaced() SectionView { if (!groupInfo.useRelays) { @@ -688,7 +692,7 @@ fun ModalData.GroupChatInfoLayout( } } WallpaperButton { - ModalManager.end.showModal { + ModalManager.end.showModal(cardScreen = true) { val chat = remember { derivedStateOf { chatModel.chats.value.firstOrNull { it.id == chat.id } } } val c = chat.value if (c != null) { @@ -697,12 +701,12 @@ fun ModalData.GroupChatInfoLayout( } } ChatTTLOption(chatItemTTL, setChatItemTTL, deletingItems) - SectionTextFooter(stringResource(MR.strings.chat_ttl_options_footer)) } - SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = true) + SectionTextFooter(stringResource(MR.strings.chat_ttl_options_footer)) + SectionDividerSpaced() if (!groupInfo.nextConnectPrepared && !groupInfo.useRelays) { - SectionView(title = String.format(generalGetString(MR.strings.group_info_section_title_num_members), activeSortedMembers.count() + 1)) { + SectionView(title = String.format(generalGetString(MR.strings.group_info_section_title_num_members), activeSortedMembers.count() + 1), cardShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)) { if (groupInfo.canAddMembers) { val onAddMembersClick = if (chat.chatInfo.incognito) ::cantInviteIncognitoAlert else addMembers val tint = if (chat.chatInfo.incognito) MaterialTheme.colors.secondary else MaterialTheme.colors.primary @@ -725,32 +729,36 @@ fun ModalData.GroupChatInfoLayout( } } if (!groupInfo.nextConnectPrepared && !groupInfo.useRelays) { - items(filteredMembers.value, key = { it.groupMemberId }) { member -> - Divider() - val showMenu = remember { mutableStateOf(false) } - val canBeSelected = groupInfo.membership.memberRole >= member.memberRole && member.memberRole < GroupMemberRole.Moderator - SectionItemViewLongClickable( - click = { - if (selectedItems.value != null) { - if (canBeSelected) { - toggleItemSelection(member.groupMemberId, selectedItems) + itemsIndexed(filteredMembers.value, key = { _, m -> m.groupMemberId }) { index, member -> + val isLast = index == filteredMembers.value.lastIndex + val shape = if (isLast) RoundedCornerShape(bottomStart = 16.dp, bottomEnd = 16.dp) else RectangleShape + Column(Modifier.padding(horizontal = CARD_PADDING).fillMaxWidth().clip(shape).background(sectionCardColor())) { + Divider() + val showMenu = remember { mutableStateOf(false) } + val canBeSelected = groupInfo.membership.memberRole >= member.memberRole && member.memberRole < GroupMemberRole.Moderator + SectionItemViewLongClickable( + click = { + if (selectedItems.value != null) { + if (canBeSelected) { + toggleItemSelection(member.groupMemberId, selectedItems) + } + } else { + showMemberInfo(member, null) + } + }, + longClick = { showMenu.value = true }, + minHeight = 54.dp, + padding = PaddingValues(horizontal = DEFAULT_PADDING) + ) { + Box(contentAlignment = Alignment.CenterStart) { + androidx.compose.animation.AnimatedVisibility(selectedItems.value != null, enter = fadeIn(), exit = fadeOut()) { + SelectedListItem(Modifier.alpha(if (canBeSelected) 1f else 0f).padding(start = 2.dp), member.groupMemberId, selectedItems) + } + val selectionOffset by animateDpAsState(if (selectedItems.value != null) 20.dp + 22.dp * fontSizeMultiplier else 0.dp) + DropDownMenuForMember(chat.remoteHostId, member, groupInfo, selectedItems, showMenu) + Box(Modifier.padding(start = selectionOffset)) { + MemberRow(member) } - } else { - showMemberInfo(member, null) - } - }, - longClick = { showMenu.value = true }, - minHeight = 54.dp, - padding = PaddingValues(horizontal = DEFAULT_PADDING) - ) { - Box(contentAlignment = Alignment.CenterStart) { - androidx.compose.animation.AnimatedVisibility(selectedItems.value != null, enter = fadeIn(), exit = fadeOut()) { - SelectedListItem(Modifier.alpha(if (canBeSelected) 1f else 0f).padding(start = 2.dp), member.groupMemberId, selectedItems) - } - val selectionOffset by animateDpAsState(if (selectedItems.value != null) 20.dp + 22.dp * fontSizeMultiplier else 0.dp) - DropDownMenuForMember(chat.remoteHostId, member, groupInfo, selectedItems, showMenu) - Box(Modifier.padding(start = selectionOffset)) { - MemberRow(member) } } } @@ -758,7 +766,7 @@ fun ModalData.GroupChatInfoLayout( } item { if (!groupInfo.nextConnectPrepared && !groupInfo.useRelays) { - SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + SectionDividerSpaced() } SectionView { if (groupInfo.useRelays && (groupInfo.isOwner || activeSortedMembers.any { it.memberRole == GroupMemberRole.Relay })) { @@ -1186,7 +1194,9 @@ private fun ChannelLinkButton(onClick: () -> Unit) { @Composable private fun ChannelLinkQRCodeSection(groupLink: String) { val clipboard = LocalClipboardManager.current - SimpleXLinkQRCode(connReq = groupLink) + Box(Modifier.padding(vertical = DEFAULT_PADDING_HALF)) { + SimpleXLinkQRCode(connReq = groupLink) + } SectionItemView({ clipboard.shareText(simplexChatLink(groupLink)) }) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt index 673f72bb4e..b68f0efaf5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt @@ -1,7 +1,9 @@ package chat.simplex.common.views.chat.group import SectionBottomSpacer +import SectionDividerSpaced import SectionItemView +import SectionView import SectionViewWithButton import androidx.compose.foundation.layout.* import androidx.compose.material.* @@ -215,7 +217,10 @@ fun GroupLinkLayout( } } else { if (!isChannel) { - RoleSelectionRow(groupInfo, groupLinkMemberRole) + SectionView { + RoleSelectionRow(groupInfo, groupLinkMemberRole) + } + SectionDividerSpaced() } var initialLaunch by remember { mutableStateOf(true) } LaunchedEffect(groupLinkMemberRole.value) { @@ -225,69 +230,70 @@ fun GroupLinkLayout( initialLaunch = false } val showShortLink = remember { mutableStateOf(true) } - Spacer(Modifier.height(DEFAULT_PADDING_HALF)) SectionViewWithButton( titleButton = if (!isChannel && groupLink.connLinkContact.connShortLink != null) { { ToggleShortLinkButton(showShortLink) } } else null) { - SimpleXCreatedLinkQRCode(groupLink.connLinkContact, short = showShortLink.value) - } - if (!isChannel && groupLink.shouldBeUpgraded) { + Box(Modifier.padding(vertical = DEFAULT_PADDING_HALF)) { + SimpleXCreatedLinkQRCode(groupLink.connLinkContact, short = showShortLink.value) + } + if (!isChannel && groupLink.shouldBeUpgraded) { + SettingsActionItem( + painterResource(MR.images.ic_add), + stringResource(MR.strings.upgrade_group_link), + click = { showAddShortLinkAlert(null) }, + iconColor = MaterialTheme.colors.primary, + textColor = MaterialTheme.colors.primary, + ) + } + val clipboard = LocalClipboardManager.current SettingsActionItem( - painterResource(MR.images.ic_add), - stringResource(MR.strings.upgrade_group_link), - click = { showAddShortLinkAlert(null) }, - iconColor = MaterialTheme.colors.primary, - textColor = MaterialTheme.colors.primary, - ) - } - val clipboard = LocalClipboardManager.current - SettingsActionItem( - painterResource(MR.images.ic_share), - stringResource(MR.strings.share_link), - click = { - if (!isChannel && groupLink.shouldBeUpgraded) { - showAddShortLinkAlert { + painterResource(MR.images.ic_share), + stringResource(MR.strings.share_link), + click = { + if (!isChannel && groupLink.shouldBeUpgraded) { + showAddShortLinkAlert { + clipboard.shareText(groupLink.connLinkContact.simplexChatUri(short = showShortLink.value)) + } + } else { clipboard.shareText(groupLink.connLinkContact.simplexChatUri(short = showShortLink.value)) } - } else { - clipboard.shareText(groupLink.connLinkContact.simplexChatUri(short = showShortLink.value)) - } - }, - iconColor = MaterialTheme.colors.primary, - textColor = MaterialTheme.colors.primary, - ) - if (shareGroupInfo != null && isChannel) { - SettingsActionItem( - painterResource(MR.images.ic_forward), - stringResource(MR.strings.share_via_chat), - click = { - chatModel.sharedContent.value = SharedContent.ChatLink(shareGroupInfo) - chatModel.chatId.value = null - ModalManager.closeAllModalsEverywhere() }, iconColor = MaterialTheme.colors.primary, textColor = MaterialTheme.colors.primary, ) - } - if (!creatingGroup && !isChannel) { - SettingsActionItem( - painterResource(MR.images.ic_delete), - stringResource(MR.strings.delete_link), - click = deleteLink, - iconColor = Color.Red, - textColor = Color.Red, - ) - } - if (creatingGroup && close != null) { - SettingsActionItem( - painterResource(MR.images.ic_check), - stringResource(MR.strings.continue_to_next_step), - click = close, - iconColor = MaterialTheme.colors.primary, - textColor = MaterialTheme.colors.primary, - ) + if (shareGroupInfo != null && isChannel) { + SettingsActionItem( + painterResource(MR.images.ic_forward), + stringResource(MR.strings.share_via_chat), + click = { + chatModel.sharedContent.value = SharedContent.ChatLink(shareGroupInfo) + chatModel.chatId.value = null + ModalManager.closeAllModalsEverywhere() + }, + iconColor = MaterialTheme.colors.primary, + textColor = MaterialTheme.colors.primary, + ) + } + if (!creatingGroup && !isChannel) { + SettingsActionItem( + painterResource(MR.images.ic_delete), + stringResource(MR.strings.delete_link), + click = deleteLink, + iconColor = Color.Red, + textColor = Color.Red, + ) + } + if (creatingGroup && close != null) { + SettingsActionItem( + painterResource(MR.images.ic_check), + stringResource(MR.strings.continue_to_next_step), + click = close, + iconColor = MaterialTheme.colors.primary, + textColor = MaterialTheme.colors.primary, + ) + } } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index 8677609863..8117f674f6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -2,12 +2,12 @@ package chat.simplex.common.views.chat.group import InfoRow import SectionBottomSpacer -import SectionDividerSpaced import SectionItemView -import SectionSpacer +import SectionDividerSpaced import SectionTextFooter import SectionView import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.InlineTextContent @@ -423,7 +423,7 @@ fun GroupMemberInfoLayout( // TODO [relays] re-enable when relay management ships val canRemove = member.canBeRemoved(groupInfo) && member.memberRole != GroupMemberRole.Relay if (canBlockForAll || canRemove) { - SectionDividerSpaced(maxBottomPadding = false) + SectionDividerSpaced() SectionView { if (canBlockForAll) { if (member.blockedByAdmin) { @@ -445,7 +445,7 @@ fun GroupMemberInfoLayout( @Composable fun NonAdminBlockSection() { - SectionDividerSpaced(maxBottomPadding = false) + SectionDividerSpaced() SectionView { if (member.blockedByAdmin) { SettingsActionItem( @@ -469,7 +469,7 @@ fun GroupMemberInfoLayout( ) { GroupMemberInfoHeader(member) } - SectionSpacer() + SectionDividerSpaced() val contactId = member.memberContactId @@ -533,7 +533,7 @@ fun GroupMemberInfoLayout( } } - SectionSpacer() + SectionDividerSpaced() } val showMemberSupportChat = !openedFromSupportChat && @@ -566,7 +566,7 @@ fun GroupMemberInfoLayout( } if (member.contactLink != null) { - SectionView(stringResource(MR.strings.address_section_title).uppercase()) { + SectionView(stringResource(MR.strings.address_section_title)) { SimpleXLinkQRCode(member.contactLink) val clipboard = LocalClipboardManager.current ShareAddressButton { clipboard.shareText(simplexChatLink(member.contactLink)) } @@ -577,8 +577,8 @@ fun GroupMemberInfoLayout( } else { ConnectViaAddressButton(onClick = { connectViaAddress(member.contactLink) }) } - SectionTextFooter(stringResource(MR.strings.you_can_share_this_address_with_your_contacts).format(member.displayName)) } + SectionTextFooter(stringResource(MR.strings.you_can_share_this_address_with_your_contacts).format(member.displayName)) SectionDividerSpaced() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt index 740349eaea..d8be4998be 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt @@ -6,9 +6,11 @@ import SectionDividerSpaced import SectionItemView import SectionTextFooter import SectionView +import androidx.compose.foundation.background import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.* +import androidx.compose.ui.Modifier import androidx.compose.runtime.saveable.rememberSaveable import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.compose.stringResource @@ -64,6 +66,7 @@ fun GroupPreferencesView(m: ChatModel, rhId: Long?, chatId: String, close: () -> if (preferences == currentPreferences) close() else showUnsavedChangesAlert({ savePrefs(close) }, close, saveTextId) }, + cardScreen = true, ) { GroupPreferencesLayout( preferences, @@ -182,37 +185,39 @@ private fun GroupPreferencesLayout( AppBarTitle(stringResource(titleId)) if (!groupInfo.useRelays) { if (groupInfo.businessChat == null) { - MemberAdmissionButton(openMemberAdmission) - SectionDividerSpaced(maxBottomPadding = false) + SectionView { + MemberAdmissionButton(openMemberAdmission) + } + SectionDividerSpaced() } TimedMessagesPreference() - SectionDividerSpaced(true, maxBottomPadding = false) + SectionDividerSpaced() DirectMessagesPreference() - SectionDividerSpaced(true, maxBottomPadding = false) + SectionDividerSpaced() FullDeletePreference() - SectionDividerSpaced(true, maxBottomPadding = false) + SectionDividerSpaced() ReactionsPreference() - SectionDividerSpaced(true, maxBottomPadding = false) + SectionDividerSpaced() VoicePreference() - SectionDividerSpaced(true, maxBottomPadding = false) + SectionDividerSpaced() FilesPreference() - SectionDividerSpaced(true, maxBottomPadding = false) + SectionDividerSpaced() SimplexLinksPreference() - SectionDividerSpaced(true, maxBottomPadding = false) + SectionDividerSpaced() ReportsPreference() - SectionDividerSpaced(true, maxBottomPadding = false) + SectionDividerSpaced() HistoryPreference() - SectionDividerSpaced(true, maxBottomPadding = false) + SectionDividerSpaced() SupportPreference(disabled = true) } else { TimedMessagesPreference() - SectionDividerSpaced(true, maxBottomPadding = false) + SectionDividerSpaced() FullDeletePreference() - SectionDividerSpaced(true, maxBottomPadding = false) + SectionDividerSpaced() ReactionsPreference() - SectionDividerSpaced(true, maxBottomPadding = false) + SectionDividerSpaced() HistoryPreference() - SectionDividerSpaced(true, maxBottomPadding = false) + SectionDividerSpaced() SupportPreference(notice = generalGetString(MR.strings.chat_with_admins_relay_note), onEnable = { revert -> AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.enable_chats_with_admins_question), @@ -225,7 +230,7 @@ private fun GroupPreferencesLayout( }) } if (groupInfo.isOwner) { - SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + SectionDividerSpaced() val saveTextId = if (groupInfo.useRelays) MR.strings.save_and_notify_channel_subscribers else MR.strings.save_and_notify_group_members ResetSaveButtons( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt index 7c9db58316..544af8ed7e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt @@ -6,7 +6,10 @@ import SectionDividerSpaced import SectionItemView import SectionTextFooter import SectionView +import androidx.compose.foundation.background import androidx.compose.material.MaterialTheme +import androidx.compose.ui.Modifier +import chat.simplex.common.ui.theme.* import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable @@ -49,6 +52,7 @@ fun MemberAdmissionView(m: ChatModel, rhId: Long?, chatId: String, close: () -> if (admission == currentAdmission) close() else showUnsavedChangesAlert({ saveAdmission(close) }, close) }, + cardScreen = true, ) { MemberAdmissionLayout( admission, @@ -85,7 +89,7 @@ private fun MemberAdmissionLayout( } } if (groupInfo.isOwner) { - SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + SectionDividerSpaced() ResetSaveButtons( reset = reset, save = saveAdmission, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt index 6680ef99bc..180c9f9d23 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt @@ -85,7 +85,7 @@ fun MemberSupportChatAppBar( } else { null } - ModalManager.end.showModalCloseable(true) { closeCurrent -> + ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { closeCurrent -> remember { derivedStateOf { chatModel.getGroupMember(scopeMember_.groupMemberId) } }.value?.let { mem -> GroupMemberInfoView(rhId, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = true, close = closeCurrent) { closeCurrent() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 0cec9ab773..d3533bbd02 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -583,7 +583,7 @@ fun ContactConnectionMenuItems(rhId: Long?, chatInfo: ChatInfo.ContactConnection onClick = { ModalManager.center.closeModals() ModalManager.end.closeModals() - ModalManager.center.showModalCloseable(true, showClose = appPlatform.isAndroid) { close -> + ModalManager.center.showModalCloseable(settings = true, showClose = appPlatform.isAndroid, cardScreen = true) { close -> ContactConnectionInfoView(chatModel, rhId, chatInfo.contactConnection.connLinkInv, chatInfo.contactConnection, true, close) } showMenu.value = false diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 01dcd021f7..94b13a8270 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -1,5 +1,6 @@ package chat.simplex.common.views.chatlist +import LocalCardScreen import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState @@ -572,7 +573,7 @@ private fun ChatListToolbar(userPickerState: MutableStateFlow navigationButton = { if (chatModel.users.isEmpty() && !chatModel.desktopNoUserNoRemote) { NavigationButtonMenu { - ModalManager.start.showModalCloseable { close -> + ModalManager.start.showModalCloseable(cardScreen = true) { close -> SettingsView(chatModel, setPerformLA, close) } } @@ -854,8 +855,8 @@ enum class ScrollDirection { @Composable fun BoxScope.StatusBarBackground() { if (appPlatform.isAndroid) { - val finalColor = MaterialTheme.colors.background.copy(0.88f) - Box(Modifier.fillMaxWidth().windowInsetsTopHeight(WindowInsets.statusBars).background(finalColor)) + val bg = if (LocalCardScreen.current) canvasColorForCurrentTheme() else MaterialTheme.colors.background + Box(Modifier.fillMaxWidth().windowInsetsTopHeight(WindowInsets.statusBars).background(bg.copy(0.88f))) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt index ed1c7116e6..5d94b9c2d6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt @@ -10,6 +10,7 @@ import SectionView import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.background import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -151,7 +152,7 @@ enum class PresentedServerType { @Composable private fun ServerSessionsView(sess: ServerSessions) { - SectionView(generalGetString(MR.strings.servers_info_transport_sessions_section_header).uppercase()) { + SectionView(generalGetString(MR.strings.servers_info_transport_sessions_section_header)) { InfoRow( generalGetString(MR.strings.servers_info_sessions_connected), numOrDash(sess.ssConnected) @@ -293,7 +294,7 @@ private fun XFTPServersListView(servers: List, statsStartedAt @Composable private fun SMPStatsView(stats: AgentSMPServerStatsData, statsStartedAt: Instant, remoteHostInfo: RemoteHostInfo?) { - SectionView(generalGetString(MR.strings.servers_info_statistics_section_header).uppercase()) { + SectionView(generalGetString(MR.strings.servers_info_statistics_section_header)) { InfoRow( generalGetString(MR.strings.servers_info_messages_sent), numOrDash(stats._sentDirect + stats._sentViaProxy) @@ -329,7 +330,7 @@ private fun SMPSubscriptionsSection(totals: SMPTotals) { horizontalArrangement = Arrangement.spacedBy(DEFAULT_SPACE_AFTER_ICON * 2) ) { Text( - generalGetString(MR.strings.servers_info_subscriptions_section_header).uppercase(), + generalGetString(MR.strings.servers_info_subscriptions_section_header), color = MaterialTheme.colors.secondary, style = MaterialTheme.typography.body2, fontSize = 12.sp @@ -359,7 +360,7 @@ private fun SMPSubscriptionsSection(subs: SMPServerSubs, summary: SMPServerSumma horizontalArrangement = Arrangement.spacedBy(DEFAULT_SPACE_AFTER_ICON * 2) ) { Text( - generalGetString(MR.strings.servers_info_subscriptions_section_header).uppercase(), + generalGetString(MR.strings.servers_info_subscriptions_section_header), color = MaterialTheme.colors.secondary, style = MaterialTheme.typography.body2, fontSize = 12.sp @@ -415,7 +416,7 @@ private fun reconnectServerAlert(rh: RemoteHostInfo?, server: String) { @Composable fun XFTPStatsView(stats: AgentXFTPServerStatsData, statsStartedAt: Instant, rh: RemoteHostInfo?) { - SectionView(generalGetString(MR.strings.servers_info_statistics_section_header).uppercase()) { + SectionView(generalGetString(MR.strings.servers_info_statistics_section_header)) { InfoRow( generalGetString(MR.strings.servers_info_uploaded), prettySize(stats._uploadsSize) @@ -449,7 +450,7 @@ private fun IndentedInfoRow(title: String, desc: String) { @Composable fun DetailedSMPStatsLayout(stats: AgentSMPServerStatsData, statsStartedAt: Instant) { - SectionView(generalGetString(MR.strings.servers_info_detailed_statistics_sent_messages_header).uppercase()) { + SectionView(generalGetString(MR.strings.servers_info_detailed_statistics_sent_messages_header)) { InfoRow(generalGetString(MR.strings.servers_info_detailed_statistics_sent_messages_total), numOrDash(stats._sentDirect + stats._sentViaProxy)) InfoRowTwoValues(generalGetString(MR.strings.sent_directly), generalGetString(MR.strings.attempts_label), stats._sentDirect, stats._sentDirectAttempts) InfoRowTwoValues(generalGetString(MR.strings.sent_via_proxy), generalGetString(MR.strings.attempts_label), stats._sentViaProxy, stats._sentViaProxyAttempts) @@ -465,7 +466,7 @@ fun DetailedSMPStatsLayout(stats: AgentSMPServerStatsData, statsStartedAt: Insta SectionDividerSpaced() - SectionView(generalGetString(MR.strings.servers_info_detailed_statistics_received_messages_header).uppercase()) { + SectionView(generalGetString(MR.strings.servers_info_detailed_statistics_received_messages_header)) { InfoRow(generalGetString(MR.strings.servers_info_detailed_statistics_received_total), numOrDash(stats._recvMsgs)) SectionItemView { Text(generalGetString(MR.strings.servers_info_detailed_statistics_receive_errors), color = MaterialTheme.colors.onBackground) @@ -483,7 +484,7 @@ fun DetailedSMPStatsLayout(stats: AgentSMPServerStatsData, statsStartedAt: Insta SectionDividerSpaced() - SectionView(generalGetString(MR.strings.connections).uppercase()) { + SectionView(generalGetString(MR.strings.connections)) { InfoRow(generalGetString(MR.strings.created), numOrDash(stats._connCreated)) InfoRow(generalGetString(MR.strings.secured), numOrDash(stats._connSecured)) InfoRow(generalGetString(MR.strings.completed), numOrDash(stats._connCompleted)) @@ -502,7 +503,7 @@ fun DetailedSMPStatsLayout(stats: AgentSMPServerStatsData, statsStartedAt: Insta @Composable fun DetailedXFTPStatsLayout(stats: AgentXFTPServerStatsData, statsStartedAt: Instant) { - SectionView(generalGetString(MR.strings.uploaded_files).uppercase()) { + SectionView(generalGetString(MR.strings.uploaded_files)) { InfoRow(generalGetString(MR.strings.size), prettySize(stats._uploadsSize)) InfoRowTwoValues(generalGetString(MR.strings.chunks_uploaded), generalGetString(MR.strings.attempts_label), stats._uploads, stats._uploadAttempts) InfoRow(generalGetString(MR.strings.upload_errors), numOrDash(stats._uploadErrs)) @@ -510,7 +511,7 @@ fun DetailedXFTPStatsLayout(stats: AgentXFTPServerStatsData, statsStartedAt: Ins InfoRow(generalGetString(MR.strings.deletion_errors), numOrDash(stats._deleteErrs)) } SectionDividerSpaced() - SectionView(generalGetString(MR.strings.downloaded_files).uppercase()) { + SectionView(generalGetString(MR.strings.downloaded_files)) { InfoRow(generalGetString(MR.strings.size), prettySize(stats._downloadsSize)) InfoRowTwoValues(generalGetString(MR.strings.chunks_downloaded), generalGetString(MR.strings.attempts_label), stats._downloads, stats._downloadAttempts) SectionItemView { @@ -528,7 +529,7 @@ fun DetailedXFTPStatsLayout(stats: AgentXFTPServerStatsData, statsStartedAt: Ins @Composable fun XFTPServerSummaryLayout(summary: XFTPServerSummary, statsStartedAt: Instant, rh: RemoteHostInfo?) { - SectionView(generalGetString(MR.strings.server_address).uppercase()) { + SectionView(generalGetString(MR.strings.server_address)) { SelectionContainer { Text( summary.xftpServer, @@ -546,7 +547,7 @@ fun XFTPServerSummaryLayout(summary: XFTPServerSummary, statsStartedAt: Instant, if (summary.stats != null) { XFTPStatsView(stats = summary.stats, rh = rh, statsStartedAt = statsStartedAt) if (summary.sessions != null) { - SectionDividerSpaced(maxTopPadding = true) + SectionDividerSpaced() } } @@ -560,7 +561,7 @@ fun XFTPServerSummaryLayout(summary: XFTPServerSummary, statsStartedAt: Instant, @Composable fun SMPServerSummaryLayout(summary: SMPServerSummary, statsStartedAt: Instant, rh: RemoteHostInfo?) { - SectionView(generalGetString(MR.strings.server_address).uppercase()) { + SectionView(generalGetString(MR.strings.server_address)) { SelectionContainer { Text( summary.smpServer, @@ -578,7 +579,7 @@ fun SMPServerSummaryLayout(summary: SMPServerSummary, statsStartedAt: Instant, r if (summary.stats != null) { SMPStatsView(stats = summary.stats, remoteHostInfo = rh, statsStartedAt = statsStartedAt) if (summary.subs != null || summary.sessions != null) { - SectionDividerSpaced(maxTopPadding = true) + SectionDividerSpaced() } } @@ -605,7 +606,8 @@ fun ModalData.SMPServerSummaryView( statsStartedAt: Instant ) { ModalView( - close = close + close = close, + cardScreen = true, ) { ColumnWithScrollBar { val bottomPadding = DEFAULT_PADDING @@ -628,7 +630,8 @@ fun ModalData.DetailedXFTPStatsView( statsStartedAt: Instant ) { ModalView( - close = close + close = close, + cardScreen = true, ) { ColumnWithScrollBar { Box(contentAlignment = Alignment.Center) { @@ -652,7 +655,8 @@ fun ModalData.DetailedSMPStatsView( statsStartedAt: Instant ) { ModalView( - close = close + close = close, + cardScreen = true, ) { ColumnWithScrollBar { Box(contentAlignment = Alignment.Center) { @@ -676,7 +680,8 @@ fun ModalData.XFTPServerSummaryView( statsStartedAt: Instant ) { ModalView( - close = close + close = close, + cardScreen = true, ) { ColumnWithScrollBar { Box(contentAlignment = Alignment.Center) { @@ -839,7 +844,7 @@ fun ModalData.ServersSummaryView(rh: RemoteHostInfo?, serversSummary: MutableSta val statsStartedAt = it.statsStartedAt SMPStatsView(totals.stats, statsStartedAt, rh) - SectionDividerSpaced(maxTopPadding = true) + SectionDividerSpaced() SMPSubscriptionsSection(totals) SectionDividerSpaced() @@ -847,7 +852,7 @@ fun ModalData.ServersSummaryView(rh: RemoteHostInfo?, serversSummary: MutableSta SMPServersListView( servers = currentlyUsedSMPServers, statsStartedAt = statsStartedAt, - header = generalGetString(MR.strings.servers_info_connected_servers_section_header).uppercase(), + header = generalGetString(MR.strings.servers_info_connected_servers_section_header), rh = rh ) SectionDividerSpaced() @@ -857,7 +862,7 @@ fun ModalData.ServersSummaryView(rh: RemoteHostInfo?, serversSummary: MutableSta SMPServersListView( servers = previouslyUsedSMPServers, statsStartedAt = statsStartedAt, - header = generalGetString(MR.strings.servers_info_previously_connected_servers_section_header).uppercase(), + header = generalGetString(MR.strings.servers_info_previously_connected_servers_section_header), rh = rh ) SectionDividerSpaced() @@ -867,11 +872,11 @@ fun ModalData.ServersSummaryView(rh: RemoteHostInfo?, serversSummary: MutableSta SMPServersListView( servers = proxySMPServers, statsStartedAt = statsStartedAt, - header = generalGetString(MR.strings.servers_info_proxied_servers_section_header).uppercase(), + header = generalGetString(MR.strings.servers_info_proxied_servers_section_header), footer = generalGetString(MR.strings.servers_info_proxied_servers_section_footer), rh = rh ) - SectionDividerSpaced(maxTopPadding = true) + SectionDividerSpaced() } ServerSessionsView(totals.sessions) @@ -888,13 +893,13 @@ fun ModalData.ServersSummaryView(rh: RemoteHostInfo?, serversSummary: MutableSta val previouslyUsedXFTPServers = xftpSummary.previouslyUsedXFTPServers XFTPStatsView(totals.stats, statsStartedAt, rh) - SectionDividerSpaced(maxTopPadding = true) + SectionDividerSpaced() if (currentlyUsedXFTPServers.isNotEmpty()) { XFTPServersListView( currentlyUsedXFTPServers, statsStartedAt, - generalGetString(MR.strings.servers_info_connected_servers_section_header).uppercase(), + generalGetString(MR.strings.servers_info_connected_servers_section_header), rh ) SectionDividerSpaced() @@ -904,7 +909,7 @@ fun ModalData.ServersSummaryView(rh: RemoteHostInfo?, serversSummary: MutableSta XFTPServersListView( previouslyUsedXFTPServers, statsStartedAt, - generalGetString(MR.strings.servers_info_previously_connected_servers_section_header).uppercase(), + generalGetString(MR.strings.servers_info_previously_connected_servers_section_header), rh ) SectionDividerSpaced() @@ -915,7 +920,7 @@ fun ModalData.ServersSummaryView(rh: RemoteHostInfo?, serversSummary: MutableSta } } - SectionDividerSpaced(maxBottomPadding = false) + SectionDividerSpaced() SectionView { ReconnectAllServersButton(rh) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt index c6cc887655..fe61859937 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt @@ -1,7 +1,6 @@ package chat.simplex.common.views.chatlist import SectionCustomFooter -import SectionDivider import SectionItemView import TextIconSpaced import androidx.compose.animation.core.animateDpAsState @@ -157,7 +156,7 @@ fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, reorderMode: Icon(painterResource(MR.images.ic_drag_handle), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary) } } - SectionDivider() + Divider(Modifier.padding(horizontal = 8.dp)) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt index a02e0dc768..5cf7d9325f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt @@ -380,7 +380,7 @@ private fun GlobalSettingsSection( SectionItemView( click = { - ModalManager.start.showModalCloseable { close -> + ModalManager.start.showModalCloseable(cardScreen = true) { close -> SettingsView(chatModel, setPerformLA, close) } }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt index 1c1c37b7ac..b656b8b8da 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt @@ -119,7 +119,7 @@ fun DatabaseEncryptionLayout( ChatStoppedView() SectionSpacer() } - SectionView(if (migration) generalGetString(MR.strings.database_passphrase).uppercase() else null) { + SectionView(if (migration) generalGetString(MR.strings.database_passphrase) else null) { SavePassphraseSetting( useKeychain.value, initialRandomDBPassphrase.value, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt index d55d89f26b..648a0eb8e7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment +import androidx.compose.foundation.background import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter @@ -170,7 +171,7 @@ fun DatabaseLayout( AppBarTitle(stringResource(MR.strings.your_chat_database)) if (!chatModel.desktopNoUserNoRemote) { - SectionView(stringResource(MR.strings.messages_section_title).uppercase()) { + SectionView(stringResource(MR.strings.messages_section_title)) { TtlOptions(chatItemTTL, enabled = rememberUpdatedState(!stopped && !progressIndicator), onChatItemTTLSelected) } SectionTextFooter( @@ -184,7 +185,7 @@ fun DatabaseLayout( } } ) - SectionDividerSpaced(maxTopPadding = true) + SectionDividerSpaced() } val toggleEnabled = remember { chatModel.remoteHosts }.none { it.sessionState is RemoteHostSessionState.Connected } if (chatModel.localUserCreated.value == true) { @@ -200,7 +201,7 @@ fun DatabaseLayout( RunChatSetting(stopped, toggleEnabled && !progressIndicator, startChat, stopChatAlert) } if (stopped) SectionTextFooter(stringResource(MR.strings.you_must_use_the_most_recent_version_of_database)) - SectionDividerSpaced(maxTopPadding = true) + SectionDividerSpaced() } SectionView(stringResource(MR.strings.chat_database_section)) { @@ -214,7 +215,7 @@ fun DatabaseLayout( if (unencrypted) painterResource(MR.images.ic_lock_open_right) else if (useKeyChain) painterResource(MR.images.ic_vpn_key_filled) else painterResource(MR.images.ic_lock), stringResource(MR.strings.database_passphrase), - click = { ModalManager.start.showModal { DatabaseEncryptionView(chatModel, false) } }, + click = { ModalManager.start.showModal(cardScreen = true) { DatabaseEncryptionView(chatModel, false) } }, iconColor = if (unencrypted || (appPlatform.isDesktop && passphraseSaved)) WarningOrange else MaterialTheme.colors.secondary, disabled = operationsDisabled ) @@ -262,7 +263,7 @@ fun DatabaseLayout( } SectionDividerSpaced() - SectionView(stringResource(MR.strings.files_and_media_section).uppercase()) { + SectionView(stringResource(MR.strings.files_and_media_section)) { val deleteFilesDisabled = operationsDisabled || appFilesCountAndSize.value.first == 0 SectionItemView( deleteAppFilesAndMedia, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt index 28c81fbf56..02c0b45de4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.graphics.Color import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel import chat.simplex.common.platform.* +import LocalCardScreen import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chatlist.StatusBarBackground import chat.simplex.common.views.onboarding.OnboardingStage @@ -27,6 +28,7 @@ fun ModalView( showAppBar: Boolean = true, enableClose: Boolean = true, background: Color = Color.Unspecified, + cardScreen: Boolean = false, modifier: Modifier = Modifier, showSearch: Boolean = false, searchAlwaysVisible: Boolean = false, @@ -40,7 +42,9 @@ fun ModalView( } val oneHandUI = remember { derivedStateOf { if (appPrefs.onboardingStage.state.value == OnboardingStage.OnboardingComplete) appPrefs.oneHandUI.state.value else false } } Surface(Modifier.fillMaxSize(), contentColor = LocalContentColor.current) { - Box(if (background != Color.Unspecified) Modifier.background(background) else Modifier.themedBackground(bgLayerSize = LocalAppBarHandler.current?.backgroundGraphicsLayerSize, bgLayer = LocalAppBarHandler.current?.backgroundGraphicsLayer)) { + val bgOverride = if (cardScreen) canvasColorForCurrentTheme() else if (background != Color.Unspecified) background else null + CompositionLocalProvider(LocalCardScreen provides cardScreen) { + Box(Modifier.themedBackground(bgLayerSize = LocalAppBarHandler.current?.backgroundGraphicsLayerSize, bgLayer = LocalAppBarHandler.current?.backgroundGraphicsLayer, overrideColor = bgOverride)) { Box(modifier = modifier) { content() } @@ -66,6 +70,7 @@ fun ModalView( } } } + } } } @@ -111,15 +116,15 @@ class ModalManager(private val placement: ModalPlacement? = null) { fun isLastModalOpen(id: ModalViewId): Boolean = modalViews.lastOrNull()?.id == id - fun showModal(settings: Boolean = false, showClose: Boolean = true, id: ModalViewId? = null, forceAnimated: Boolean = false, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) { + fun showModal(settings: Boolean = false, showClose: Boolean = true, id: ModalViewId? = null, forceAnimated: Boolean = false, cardScreen: Boolean = false, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) { showCustomModal(id = id, forceAnimated = forceAnimated) { close -> - ModalView(close, showClose = showClose, endButtons = endButtons, content = { content() }) + ModalView(close, showClose = showClose, cardScreen = cardScreen, endButtons = endButtons, content = { content() }) } } - fun showModalCloseable(settings: Boolean = false, showClose: Boolean = true, id: ModalViewId? = null, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.(close: () -> Unit) -> Unit) { + fun showModalCloseable(settings: Boolean = false, showClose: Boolean = true, id: ModalViewId? = null, cardScreen: Boolean = false, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.(close: () -> Unit) -> Unit) { showCustomModal(id = id) { close -> - ModalView(close, showClose = showClose, endButtons = endButtons, content = { content(close) }) + ModalView(close, showClose = showClose, cardScreen = cardScreen, endButtons = endButtons, content = { content(close) }) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt index 7ee52af784..9afcdd0b94 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt @@ -1,9 +1,15 @@ import androidx.compose.foundation.* import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.layout.Layout import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalDensity @@ -12,6 +18,7 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* +import androidx.compose.ui.text.font.FontWeight import chat.simplex.common.platform.onRightClick import chat.simplex.common.platform.windowWidth import chat.simplex.common.ui.theme.* @@ -20,16 +27,82 @@ import chat.simplex.common.views.onboarding.SelectableCard import chat.simplex.common.views.usersettings.SettingsActionItemWithContent import chat.simplex.res.MR +private val SectionCardShape = RoundedCornerShape(16.dp) +val CARD_PADDING = 18.dp +val ICON_TEXT_SPACING = 8.dp + +val LocalCardScreen = staticCompositionLocalOf { false } + +val itemHPadding: Dp + @Composable get() = if (LocalCardScreen.current) CARD_PADDING else DEFAULT_PADDING + @Composable -fun SectionView(title: String? = null, contentPadding: PaddingValues = PaddingValues(), headerBottomPadding: Dp = DEFAULT_PADDING, content: (@Composable ColumnScope.() -> Unit)) { +private fun CardColumnLayout( + contentPadding: PaddingValues = PaddingValues(), + cardShape: Shape = SectionCardShape, + content: @Composable () -> Unit +) { + val dividerColor = canvasColorForCurrentTheme() + val dividerPx = with(LocalDensity.current) { 2.dp.toPx() } + val childBottoms = remember { mutableListOf() } + Layout( + content = content, + modifier = Modifier + .padding(horizontal = CARD_PADDING) + .fillMaxWidth() + .clip(cardShape) + .background(sectionCardColor()) + .padding(contentPadding) + .drawBehind { + for (i in 0 until childBottoms.size - 1) { + val y = childBottoms[i] + drawLine(dividerColor, Offset(0f, y), Offset(size.width, y), strokeWidth = dividerPx) + } + } + ) { measurables, constraints -> + val placeables = measurables.map { it.measure(constraints) } + childBottoms.clear() + var y = 0f + placeables.forEach { p -> + y += p.height + childBottoms.add(y) + } + layout(constraints.maxWidth, y.toInt()) { + var yPos = 0 + placeables.forEach { p -> + p.placeRelative(0, yPos) + yPos += p.height + } + } + } +} + +@Composable +private fun CardColumn( + contentPadding: PaddingValues = PaddingValues(), + cardShape: Shape = SectionCardShape, + content: @Composable () -> Unit +) { + if (LocalCardScreen.current) { + CardColumnLayout(contentPadding, cardShape, content) + } else { + Column(Modifier.padding(contentPadding).fillMaxWidth()) { content() } + } +} + +@Composable +fun SectionView(title: String? = null, contentPadding: PaddingValues = PaddingValues(), headerBottomPadding: Dp = DEFAULT_PADDING, cardShape: Shape = SectionCardShape, content: (@Composable ColumnScope.() -> Unit)) { + val card = LocalCardScreen.current Column { if (title != null) { Text( title, color = MaterialTheme.colors.secondary, style = MaterialTheme.typography.body2, - modifier = Modifier.padding(start = DEFAULT_PADDING, bottom = headerBottomPadding), fontSize = 12.sp + modifier = Modifier.padding(start = if (card) DEFAULT_PADDING + DEFAULT_PADDING_HALF else DEFAULT_PADDING, bottom = if (card) 8.dp else headerBottomPadding), + fontSize = if (card) 14.sp else 12.sp, + fontWeight = if (card) FontWeight.Medium else FontWeight.Normal ) } - Column(Modifier.padding(contentPadding).fillMaxWidth()) { content() } + CardColumn(contentPadding, cardShape) { content() } } } @@ -42,24 +115,27 @@ fun SectionView( padding: PaddingValues = PaddingValues(), content: (@Composable ColumnScope.() -> Unit) ) { + val card = LocalCardScreen.current Column { val iconSize = with(LocalDensity.current) { 21.sp.toDp() } - Row(Modifier.padding(start = DEFAULT_PADDING, bottom = 5.dp), verticalAlignment = Alignment.CenterVertically) { + Row(Modifier.padding(start = if (card) DEFAULT_PADDING + DEFAULT_PADDING_HALF else DEFAULT_PADDING, bottom = 5.dp), verticalAlignment = Alignment.CenterVertically) { if (leadingIcon) Icon(icon, null, Modifier.padding(end = DEFAULT_PADDING_HALF).size(iconSize), tint = iconTint) - Text(title, color = MaterialTheme.colors.secondary, style = MaterialTheme.typography.body2, fontSize = 12.sp) + Text(title, color = MaterialTheme.colors.secondary, style = MaterialTheme.typography.body2, fontSize = if (card) 14.sp else 12.sp, fontWeight = if (card) FontWeight.Medium else FontWeight.Normal) if (!leadingIcon) Icon(icon, null, Modifier.padding(start = DEFAULT_PADDING_HALF).size(iconSize), tint = iconTint) } - Column(Modifier.padding(padding).fillMaxWidth()) { content() } + CardColumn(padding) { content() } } } @Composable fun SectionViewWithButton(title: String? = null, titleButton: (@Composable () -> Unit)?, contentPadding: PaddingValues = PaddingValues(), headerBottomPadding: Dp = DEFAULT_PADDING, content: (@Composable ColumnScope.() -> Unit)) { + val card = LocalCardScreen.current Column { if (title != null || titleButton != null) { - Row(modifier = Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = headerBottomPadding).fillMaxWidth()) { + val hPadding = if (card) DEFAULT_PADDING + DEFAULT_PADDING_HALF else DEFAULT_PADDING + Row(modifier = Modifier.padding(start = hPadding, end = hPadding, bottom = if (card) 8.dp else headerBottomPadding).fillMaxWidth()) { if (title != null) { - Text(title, color = MaterialTheme.colors.secondary, style = MaterialTheme.typography.body2, fontSize = 12.sp) + Text(title, color = MaterialTheme.colors.secondary, style = MaterialTheme.typography.body2, fontSize = if (card) 14.sp else 12.sp, fontWeight = if (card) FontWeight.Medium else FontWeight.Normal) } if (titleButton != null) { Spacer(modifier = Modifier.weight(1f)) @@ -67,7 +143,7 @@ fun SectionViewWithButton(title: String? = null, titleButton: (@Composable () -> } } } - Column(Modifier.padding(contentPadding).fillMaxWidth()) { content() } + CardColumn(contentPadding) { content() } } } @@ -121,9 +197,9 @@ fun SectionItemView( disabled: Boolean = false, extraPadding: Boolean = false, padding: PaddingValues = if (extraPadding) - PaddingValues(start = DEFAULT_PADDING * 1.7f, end = DEFAULT_PADDING, top = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL, bottom = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL) + PaddingValues(start = DEFAULT_PADDING * 1.7f, end = itemHPadding, top = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL, bottom = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL) else - PaddingValues(horizontal = DEFAULT_PADDING, vertical = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL), + PaddingValues(horizontal = itemHPadding, vertical = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL), content: (@Composable RowScope.() -> Unit) ) { val modifier = Modifier @@ -144,9 +220,9 @@ fun SectionItemViewWithoutMinPadding( disabled: Boolean = false, extraPadding: Boolean = false, padding: PaddingValues = if (extraPadding) - PaddingValues(start = DEFAULT_PADDING * 1.7f, end = DEFAULT_PADDING) + PaddingValues(start = DEFAULT_PADDING * 1.7f, end = itemHPadding) else - PaddingValues(horizontal = DEFAULT_PADDING), + PaddingValues(horizontal = itemHPadding), content: (@Composable RowScope.() -> Unit) ) { SectionItemView(click, minHeight, disabled, extraPadding, padding, content) @@ -160,9 +236,9 @@ fun SectionItemViewLongClickable( disabled: Boolean = false, extraPadding: Boolean = false, padding: PaddingValues = if (extraPadding) - PaddingValues(start = DEFAULT_PADDING * 1.7f, end = DEFAULT_PADDING, top = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL, bottom = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL) + PaddingValues(start = DEFAULT_PADDING * 1.7f, end = itemHPadding, top = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL, bottom = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL) else - PaddingValues(horizontal = DEFAULT_PADDING, vertical = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL), + PaddingValues(horizontal = itemHPadding, vertical = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL), content: (@Composable RowScope.() -> Unit) ) { val modifier = Modifier @@ -185,7 +261,7 @@ fun SectionItemViewSpaceBetween( click: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null, minHeight: Dp = DEFAULT_MIN_SECTION_ITEM_HEIGHT, - padding: PaddingValues = PaddingValues(horizontal = DEFAULT_PADDING), + padding: PaddingValues = PaddingValues(horizontal = itemHPadding), disabled: Boolean = false, content: (@Composable RowScope.() -> Unit) ) { @@ -256,20 +332,19 @@ fun SectionCustomFooter(padding: PaddingValues = PaddingValues(start = DEFAULT_P } } -@Composable -fun SectionDivider() { - Divider(Modifier.padding(horizontal = 8.dp)) -} - @Composable fun SectionDividerSpaced(maxTopPadding: Boolean = false, maxBottomPadding: Boolean = true) { - Divider( - Modifier.padding( - start = DEFAULT_PADDING_HALF, - top = if (maxTopPadding) DEFAULT_PADDING + 18.dp else DEFAULT_PADDING + 2.dp, - end = DEFAULT_PADDING_HALF, - bottom = if (maxBottomPadding) DEFAULT_PADDING + 18.dp else DEFAULT_PADDING + 2.dp) - ) + if (LocalCardScreen.current) { + Spacer(Modifier.height(30.dp)) + } else { + Divider( + Modifier.padding( + start = DEFAULT_PADDING_HALF, + top = if (maxTopPadding) DEFAULT_PADDING + 18.dp else DEFAULT_PADDING + 2.dp, + end = DEFAULT_PADDING_HALF, + bottom = if (maxBottomPadding) DEFAULT_PADDING + 18.dp else DEFAULT_PADDING + 2.dp) + ) + } } @Composable @@ -284,11 +359,11 @@ fun SectionBottomSpacer() { @Composable fun TextIconSpaced(extraPadding: Boolean = false) { - Spacer(Modifier.padding(horizontal = if (extraPadding) 17.dp else DEFAULT_PADDING_HALF)) + Spacer(Modifier.padding(horizontal = if (extraPadding) 17.dp else if (LocalCardScreen.current) ICON_TEXT_SPACING else DEFAULT_PADDING_HALF)) } @Composable -fun InfoRow(title: String, value: String, icon: Painter? = null, iconTint: Color? = null, textColor: Color = MaterialTheme.colors.onBackground, padding: PaddingValues = PaddingValues(horizontal = DEFAULT_PADDING)) { +fun InfoRow(title: String, value: String, icon: Painter? = null, iconTint: Color? = null, textColor: Color = MaterialTheme.colors.onBackground, padding: PaddingValues = PaddingValues(horizontal = itemHPadding)) { SectionItemViewSpaceBetween(padding = padding) { Row { val iconSize = with(LocalDensity.current) { 21.sp.toDp() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ThemeModeEditor.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ThemeModeEditor.kt index d7cdf0e2e3..c8c24d918a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ThemeModeEditor.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ThemeModeEditor.kt @@ -3,8 +3,8 @@ package chat.simplex.common.views.helpers import SectionBottomSpacer import SectionDividerSpaced import SectionItemView -import SectionSpacer import SectionView +import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme.colors @@ -108,18 +108,22 @@ fun ModalData.UserWallpaperEditor( ) } - WallpaperPresetSelector( - selectedWallpaper = wallpaperType, - baseTheme = currentTheme.base, - currentColors = { type -> - // If applying for : - // - all themes: no overrides needed - // - specific user: only user overrides for currently selected theme are needed, because they will NOT be copied when other wallpaper is selected - val perUserOverride = if (wallpaperType.sameType(type)) chatModel.currentUser.value?.uiThemes else null - ThemeManager.currentColors(type, null, perUserOverride, appPrefs.themeOverrides.get()) - }, - onChooseType = onChooseType - ) + SectionView { + WallpaperPresetSelector( + selectedWallpaper = wallpaperType, + baseTheme = currentTheme.base, + currentColors = { type -> + // If applying for : + // - all themes: no overrides needed + // - specific user: only user overrides for currently selected theme are needed, because they will NOT be copied when other wallpaper is selected + val perUserOverride = if (wallpaperType.sameType(type)) chatModel.currentUser.value?.uiThemes else null + ThemeManager.currentColors(type, null, perUserOverride, appPrefs.themeOverrides.get()) + }, + onChooseType = onChooseType + ) + } + + SectionDividerSpaced() WallpaperSetupView( themeModeOverride.value.type, @@ -133,29 +137,30 @@ fun ModalData.UserWallpaperEditor( onTypeChange = onTypeChange, ) - SectionSpacer() + SectionDividerSpaced() - if (!globalThemeUsed.value) { - ResetToGlobalThemeButton(true) { - themeModeOverride.value = ThemeManager.defaultActiveTheme(chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get()) - globalThemeUsed.value = true - withBGApi { save(applyToMode.value, null) } - } - } - - SetDefaultThemeButton { - globalThemeUsed.value = false - val lightBase = DefaultTheme.LIGHT - val darkBase = if (CurrentColors.value.base != DefaultTheme.LIGHT) CurrentColors.value.base else if (appPrefs.systemDarkTheme.get() == DefaultTheme.DARK.themeName) DefaultTheme.DARK else if (appPrefs.systemDarkTheme.get() == DefaultTheme.BLACK.themeName) DefaultTheme.BLACK else DefaultTheme.SIMPLEX - val mode = themeModeOverride.value.mode - withBGApi { - // Saving for both modes in one place by changing mode once per save - if (applyToMode.value == null) { - val oppositeMode = if (mode == DefaultThemeMode.LIGHT) DefaultThemeMode.DARK else DefaultThemeMode.LIGHT - save(oppositeMode, ThemeModeOverride.withFilledAppDefaults(oppositeMode, if (oppositeMode == DefaultThemeMode.LIGHT) lightBase else darkBase)) + SectionView { + if (!globalThemeUsed.value) { + ResetToGlobalThemeButton(true) { + themeModeOverride.value = ThemeManager.defaultActiveTheme(chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get()) + globalThemeUsed.value = true + withBGApi { save(applyToMode.value, null) } + } + } + SetDefaultThemeButton { + globalThemeUsed.value = false + val lightBase = DefaultTheme.LIGHT + val darkBase = if (CurrentColors.value.base != DefaultTheme.LIGHT) CurrentColors.value.base else if (appPrefs.systemDarkTheme.get() == DefaultTheme.DARK.themeName) DefaultTheme.DARK else if (appPrefs.systemDarkTheme.get() == DefaultTheme.BLACK.themeName) DefaultTheme.BLACK else DefaultTheme.SIMPLEX + val mode = themeModeOverride.value.mode + withBGApi { + // Saving for both modes in one place by changing mode once per save + if (applyToMode.value == null) { + val oppositeMode = if (mode == DefaultThemeMode.LIGHT) DefaultThemeMode.DARK else DefaultThemeMode.LIGHT + save(oppositeMode, ThemeModeOverride.withFilledAppDefaults(oppositeMode, if (oppositeMode == DefaultThemeMode.LIGHT) lightBase else darkBase)) + } + themeModeOverride.value = ThemeModeOverride.withFilledAppDefaults(mode, if (mode == DefaultThemeMode.LIGHT) lightBase else darkBase) + save(themeModeOverride.value.mode, themeModeOverride.value) } - themeModeOverride.value = ThemeModeOverride.withFilledAppDefaults(mode, if (mode == DefaultThemeMode.LIGHT) lightBase else darkBase) - save(themeModeOverride.value.mode, themeModeOverride.value) } } @@ -174,38 +179,40 @@ fun ModalData.UserWallpaperEditor( } } - SectionSpacer() + SectionDividerSpaced() if (showMore) { - val values by remember { mutableStateOf( - listOf( - null to generalGetString(MR.strings.chat_theme_apply_to_all_modes), - DefaultThemeMode.LIGHT to generalGetString(MR.strings.chat_theme_apply_to_light_mode), - DefaultThemeMode.DARK to generalGetString(MR.strings.chat_theme_apply_to_dark_mode), + SectionView { + val values by remember { mutableStateOf( + listOf( + null to generalGetString(MR.strings.chat_theme_apply_to_all_modes), + DefaultThemeMode.LIGHT to generalGetString(MR.strings.chat_theme_apply_to_light_mode), + DefaultThemeMode.DARK to generalGetString(MR.strings.chat_theme_apply_to_dark_mode), + ) ) - ) - } - ExposedDropDownSettingRow( - generalGetString(MR.strings.chat_theme_apply_to_mode), - values, - applyToMode, - icon = null, - enabled = remember { mutableStateOf(true) }, - onSelected = { - applyToMode.value = it - if (it != null && it != CurrentColors.value.base.mode) { - val lightBase = DefaultTheme.LIGHT - val darkBase = if (CurrentColors.value.base != DefaultTheme.LIGHT) CurrentColors.value.base else if (appPrefs.systemDarkTheme.get() == DefaultTheme.DARK.themeName) DefaultTheme.DARK else if (appPrefs.systemDarkTheme.get() == DefaultTheme.BLACK.themeName) DefaultTheme.BLACK else DefaultTheme.SIMPLEX - ThemeManager.applyTheme(if (it == DefaultThemeMode.LIGHT) lightBase.themeName else darkBase.themeName) - } } - ) + ExposedDropDownSettingRow( + generalGetString(MR.strings.chat_theme_apply_to_mode), + values, + applyToMode, + icon = null, + enabled = remember { mutableStateOf(true) }, + onSelected = { + applyToMode.value = it + if (it != null && it != CurrentColors.value.base.mode) { + val lightBase = DefaultTheme.LIGHT + val darkBase = if (CurrentColors.value.base != DefaultTheme.LIGHT) CurrentColors.value.base else if (appPrefs.systemDarkTheme.get() == DefaultTheme.DARK.themeName) DefaultTheme.DARK else if (appPrefs.systemDarkTheme.get() == DefaultTheme.BLACK.themeName) DefaultTheme.BLACK else DefaultTheme.SIMPLEX + ThemeManager.applyTheme(if (it == DefaultThemeMode.LIGHT) lightBase.themeName else darkBase.themeName) + } + } + ) + } SectionDividerSpaced() AppearanceScope.CustomizeThemeColorsSection(currentTheme, editColor = editColor) - SectionDividerSpaced(maxBottomPadding = false) + SectionDividerSpaced() ImportExportThemeSection(null, remember { chatModel.currentUser }.value?.uiThemes) { withBGApi { @@ -214,7 +221,9 @@ fun ModalData.UserWallpaperEditor( } } } else { - AdvancedSettingsButton { showMore = true } + SectionView { + AdvancedSettingsButton { showMore = true } + } } SectionBottomSpacer() @@ -329,32 +338,36 @@ fun ModalData.ChatWallpaperEditor( ThemeManager.currentColors(type, if (type?.sameType(themeModeOverride.value.type) == true) themeModeOverride.value else null, chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get()) } - WallpaperPresetSelector( - selectedWallpaper = currentTheme.wallpaper.type, - activeBackgroundColor = currentTheme.wallpaper.background, - activeTintColor = currentTheme.wallpaper.tint, - baseTheme = CurrentColors.collectAsState().value.base, - currentColors = { type -> currentColors(type) }, - onChooseType = { type -> - when { - type is WallpaperType.Image && chatModel.remoteHostId() != null -> { /* do nothing */ } - type is WallpaperType.Image && ((themeModeOverride.value.type is WallpaperType.Image && !globalThemeUsed.value) || currentColors(type).wallpaper.type.image == null) -> { - withLongRunningApi { importWallpaperLauncher.launch("image/*") } - } - type is WallpaperType.Image -> { - if (!onTypeCopyFromSameTheme(currentColors(type).wallpaper.type)) { + SectionView { + WallpaperPresetSelector( + selectedWallpaper = currentTheme.wallpaper.type, + activeBackgroundColor = currentTheme.wallpaper.background, + activeTintColor = currentTheme.wallpaper.tint, + baseTheme = CurrentColors.collectAsState().value.base, + currentColors = { type -> currentColors(type) }, + onChooseType = { type -> + when { + type is WallpaperType.Image && chatModel.remoteHostId() != null -> { /* do nothing */ } + type is WallpaperType.Image && ((themeModeOverride.value.type is WallpaperType.Image && !globalThemeUsed.value) || currentColors(type).wallpaper.type.image == null) -> { withLongRunningApi { importWallpaperLauncher.launch("image/*") } } + type is WallpaperType.Image -> { + if (!onTypeCopyFromSameTheme(currentColors(type).wallpaper.type)) { + withLongRunningApi { importWallpaperLauncher.launch("image/*") } + } + } + globalThemeUsed.value || themeModeOverride.value.type != type -> { + onTypeCopyFromSameTheme(type) + } + else -> { + onTypeChange(type) + } } - globalThemeUsed.value || themeModeOverride.value.type != type -> { - onTypeCopyFromSameTheme(type) - } - else -> { - onTypeChange(type) - } - } - }, - ) + }, + ) + } + + SectionDividerSpaced() WallpaperSetupView( themeModeOverride.value.type, @@ -368,29 +381,30 @@ fun ModalData.ChatWallpaperEditor( onTypeChange = onTypeChange, ) - SectionSpacer() + SectionDividerSpaced() - if (!globalThemeUsed.value) { - ResetToGlobalThemeButton(remember { chatModel.currentUser }.value?.uiThemes?.preferredMode(isInDarkTheme()) == null) { - themeModeOverride.value = ThemeManager.defaultActiveTheme(chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get()) - globalThemeUsed.value = true - withBGApi { save(applyToMode.value, null) } - } - } - - SetDefaultThemeButton { - globalThemeUsed.value = false - val lightBase = DefaultTheme.LIGHT - val darkBase = if (CurrentColors.value.base != DefaultTheme.LIGHT) CurrentColors.value.base else if (appPrefs.systemDarkTheme.get() == DefaultTheme.DARK.themeName) DefaultTheme.DARK else if (appPrefs.systemDarkTheme.get() == DefaultTheme.BLACK.themeName) DefaultTheme.BLACK else DefaultTheme.SIMPLEX - val mode = themeModeOverride.value.mode - withBGApi { - // Saving for both modes in one place by changing mode once per save - if (applyToMode.value == null) { - val oppositeMode = if (mode == DefaultThemeMode.LIGHT) DefaultThemeMode.DARK else DefaultThemeMode.LIGHT - save(oppositeMode, ThemeModeOverride.withFilledAppDefaults(oppositeMode, if (oppositeMode == DefaultThemeMode.LIGHT) lightBase else darkBase)) + SectionView { + if (!globalThemeUsed.value) { + ResetToGlobalThemeButton(remember { chatModel.currentUser }.value?.uiThemes?.preferredMode(isInDarkTheme()) == null) { + themeModeOverride.value = ThemeManager.defaultActiveTheme(chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get()) + globalThemeUsed.value = true + withBGApi { save(applyToMode.value, null) } + } + } + SetDefaultThemeButton { + globalThemeUsed.value = false + val lightBase = DefaultTheme.LIGHT + val darkBase = if (CurrentColors.value.base != DefaultTheme.LIGHT) CurrentColors.value.base else if (appPrefs.systemDarkTheme.get() == DefaultTheme.DARK.themeName) DefaultTheme.DARK else if (appPrefs.systemDarkTheme.get() == DefaultTheme.BLACK.themeName) DefaultTheme.BLACK else DefaultTheme.SIMPLEX + val mode = themeModeOverride.value.mode + withBGApi { + // Saving for both modes in one place by changing mode once per save + if (applyToMode.value == null) { + val oppositeMode = if (mode == DefaultThemeMode.LIGHT) DefaultThemeMode.DARK else DefaultThemeMode.LIGHT + save(oppositeMode, ThemeModeOverride.withFilledAppDefaults(oppositeMode, if (oppositeMode == DefaultThemeMode.LIGHT) lightBase else darkBase)) + } + themeModeOverride.value = ThemeModeOverride.withFilledAppDefaults(mode, if (mode == DefaultThemeMode.LIGHT) lightBase else darkBase) + save(themeModeOverride.value.mode, themeModeOverride.value) } - themeModeOverride.value = ThemeModeOverride.withFilledAppDefaults(mode, if (mode == DefaultThemeMode.LIGHT) lightBase else darkBase) - save(themeModeOverride.value.mode, themeModeOverride.value) } } @@ -409,38 +423,40 @@ fun ModalData.ChatWallpaperEditor( } } - SectionSpacer() + SectionDividerSpaced() if (showMore) { - val values by remember { mutableStateOf( - listOf( - null to generalGetString(MR.strings.chat_theme_apply_to_all_modes), - DefaultThemeMode.LIGHT to generalGetString(MR.strings.chat_theme_apply_to_light_mode), - DefaultThemeMode.DARK to generalGetString(MR.strings.chat_theme_apply_to_dark_mode), + SectionView { + val values by remember { mutableStateOf( + listOf( + null to generalGetString(MR.strings.chat_theme_apply_to_all_modes), + DefaultThemeMode.LIGHT to generalGetString(MR.strings.chat_theme_apply_to_light_mode), + DefaultThemeMode.DARK to generalGetString(MR.strings.chat_theme_apply_to_dark_mode), + ) ) - ) - } - ExposedDropDownSettingRow( - generalGetString(MR.strings.chat_theme_apply_to_mode), - values, - applyToMode, - icon = null, - enabled = remember { mutableStateOf(true) }, - onSelected = { - applyToMode.value = it - if (it != null && it != CurrentColors.value.base.mode) { - val lightBase = DefaultTheme.LIGHT - val darkBase = if (CurrentColors.value.base != DefaultTheme.LIGHT) CurrentColors.value.base else if (appPrefs.systemDarkTheme.get() == DefaultTheme.DARK.themeName) DefaultTheme.DARK else if (appPrefs.systemDarkTheme.get() == DefaultTheme.BLACK.themeName) DefaultTheme.BLACK else DefaultTheme.SIMPLEX - ThemeManager.applyTheme(if (it == DefaultThemeMode.LIGHT) lightBase.themeName else darkBase.themeName) - } } - ) + ExposedDropDownSettingRow( + generalGetString(MR.strings.chat_theme_apply_to_mode), + values, + applyToMode, + icon = null, + enabled = remember { mutableStateOf(true) }, + onSelected = { + applyToMode.value = it + if (it != null && it != CurrentColors.value.base.mode) { + val lightBase = DefaultTheme.LIGHT + val darkBase = if (CurrentColors.value.base != DefaultTheme.LIGHT) CurrentColors.value.base else if (appPrefs.systemDarkTheme.get() == DefaultTheme.DARK.themeName) DefaultTheme.DARK else if (appPrefs.systemDarkTheme.get() == DefaultTheme.BLACK.themeName) DefaultTheme.BLACK else DefaultTheme.SIMPLEX + ThemeManager.applyTheme(if (it == DefaultThemeMode.LIGHT) lightBase.themeName else darkBase.themeName) + } + } + ) + } SectionDividerSpaced() AppearanceScope.CustomizeThemeColorsSection(currentTheme, editColor = editColor) - SectionDividerSpaced(maxBottomPadding = false) + SectionDividerSpaced() ImportExportThemeSection(themeModeOverride.value, remember { chatModel.currentUser }.value?.uiThemes) { withBGApi { themeModeOverride.value = it @@ -448,7 +464,9 @@ fun ModalData.ChatWallpaperEditor( } } } else { - AdvancedSettingsButton { showMore = true } + SectionView { + AdvancedSettingsButton { showMore = true } + } } SectionBottomSpacer() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt index 03542ca8af..39c4cb0b7f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt @@ -1,7 +1,7 @@ package chat.simplex.common.views.migration import SectionBottomSpacer -import SectionSpacer +import SectionDividerSpaced import SectionTextFooter import SectionView import androidx.compose.foundation.layout.* @@ -11,6 +11,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate +import androidx.compose.foundation.background import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -134,6 +135,7 @@ fun MigrateFromDeviceView(close: () -> Unit) { } close() }, + cardScreen = true, ) { MigrateFromDeviceLayout( migrationState = migrationState, @@ -182,7 +184,7 @@ private fun SectionByState( @Composable private fun MutableState.ChatStopInProgressView() { Box { - SectionView(stringResource(MR.strings.migrate_from_device_stopping_chat).uppercase()) {} + SectionView(stringResource(MR.strings.migrate_from_device_stopping_chat)) {} ProgressView() } LaunchedEffect(Unit) { @@ -192,9 +194,9 @@ private fun MutableState.ChatStopInProgressView() { @Composable private fun MutableState.ChatStopFailedView(reason: String) { - SectionView(stringResource(MR.strings.error_stopping_chat).uppercase()) { + SectionView(stringResource(MR.strings.error_stopping_chat)) { Text(reason) - SectionSpacer() + SectionDividerSpaced() SettingsActionItemWithContent( icon = painterResource(MR.images.ic_report_filled), text = stringResource(MR.strings.auth_stop_chat), @@ -224,9 +226,9 @@ private fun MutableState.PassphraseConfirmationView() { val view = LocalMultiplatformView() Column { ChatStoppedView() - SectionSpacer() + SectionDividerSpaced() - SectionView(stringResource(MR.strings.migrate_from_device_verify_database_passphrase).uppercase()) { + SectionView(stringResource(MR.strings.migrate_from_device_verify_database_passphrase)) { PassphraseField(currentKey, placeholder = stringResource(MR.strings.current_passphrase), Modifier.padding(horizontal = DEFAULT_PADDING), isValid = ::validKey, requestFocus = true) SettingsActionItemWithContent( @@ -243,8 +245,8 @@ private fun MutableState.PassphraseConfirmationView() { } } ) {} - SectionTextFooter(stringResource(MR.strings.migrate_from_device_confirm_you_remember_passphrase)) } + SectionTextFooter(stringResource(MR.strings.migrate_from_device_confirm_you_remember_passphrase)) } if (verifyingPassphrase.value) { ProgressView() @@ -254,7 +256,7 @@ private fun MutableState.PassphraseConfirmationView() { @Composable private fun MutableState.UploadConfirmationView() { - SectionView(stringResource(MR.strings.migrate_from_device_confirm_upload).uppercase()) { + SectionView(stringResource(MR.strings.migrate_from_device_confirm_upload)) { SettingsActionItemWithContent( icon = painterResource(MR.images.ic_ios_share), text = stringResource(MR.strings.migrate_from_device_archive_and_upload), @@ -268,7 +270,7 @@ private fun MutableState.UploadConfirmationView() { @Composable private fun MutableState.ArchivingView() { Box { - SectionView(stringResource(MR.strings.migrate_from_device_archiving_database).uppercase()) {} + SectionView(stringResource(MR.strings.migrate_from_device_archiving_database)) {} ProgressView() } LaunchedEffect(Unit) { @@ -279,7 +281,7 @@ private fun MutableState.ArchivingView() { @Composable private fun MutableState.DatabaseInitView(tempDatabaseFile: File, totalBytes: Long, archivePath: String) { Box { - SectionView(stringResource(MR.strings.migrate_from_device_database_init).uppercase()) {} + SectionView(stringResource(MR.strings.migrate_from_device_database_init)) {} ProgressView() } LaunchedEffect(Unit) { @@ -298,7 +300,7 @@ private fun MutableState.UploadProgressView( archivePath: String, ) { Box { - SectionView(stringResource(MR.strings.migrate_from_device_uploading_archive).uppercase()) { + SectionView(stringResource(MR.strings.migrate_from_device_uploading_archive)) { val ratio = uploadedBytes.toFloat() / max(totalBytes, 1) LargeProgressView(ratio, "${(ratio * 100).toInt()}%", stringResource(MR.strings.migrate_from_device_bytes_uploaded).format(formatBytes(uploadedBytes))) } @@ -310,7 +312,7 @@ private fun MutableState.UploadProgressView( @Composable private fun MutableState.UploadFailedView(totalBytes: Long, archivePath: String, chatReceiver: MigrationFromChatReceiver?) { - SectionView(stringResource(MR.strings.migrate_from_device_upload_failed).uppercase()) { + SectionView(stringResource(MR.strings.migrate_from_device_upload_failed)) { SettingsActionItemWithContent( icon = painterResource(MR.images.ic_ios_share), text = stringResource(MR.strings.migrate_from_device_repeat_upload), @@ -329,7 +331,7 @@ private fun MutableState.UploadFailedView(totalBytes: Long, @Composable private fun LinkCreationView() { Box { - SectionView(stringResource(MR.strings.migrate_from_device_creating_archive_link).uppercase()) {} + SectionView(stringResource(MR.strings.migrate_from_device_creating_archive_link)) {} ProgressView() } } @@ -361,15 +363,15 @@ private fun MutableState.LinkShownView(fileId: Long, link: S ) } ) {} - SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_archive_will_be_deleted)) - SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_choose_migrate_from_another_device)) } - SectionSpacer() - SectionView(stringResource(MR.strings.show_QR_code).uppercase()) { + SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_archive_will_be_deleted)) + SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_choose_migrate_from_another_device)) + SectionDividerSpaced() + SectionView(stringResource(MR.strings.show_QR_code)) { SimpleXLinkQRCode(link, onShare = {}) } - SectionSpacer() - SectionView(stringResource(MR.strings.migrate_from_device_or_share_this_file_link).uppercase()) { + SectionDividerSpaced() + SectionView(stringResource(MR.strings.migrate_from_device_or_share_this_file_link)) { LinkTextView(link, true) } } @@ -377,7 +379,7 @@ private fun MutableState.LinkShownView(fileId: Long, link: S @Composable private fun MutableState.FinishedView(chatDeletion: Boolean) { Box { - SectionView(stringResource(MR.strings.migrate_from_device_migration_complete).uppercase()) { + SectionView(stringResource(MR.strings.migrate_from_device_migration_complete)) { SettingsActionItemWithContent( icon = painterResource(MR.images.ic_play_arrow_filled), text = stringResource(MR.strings.migrate_from_device_start_chat), @@ -410,9 +412,9 @@ private fun MutableState.FinishedView(chatDeletion: Boolean) ) } ) {} - SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_you_must_not_start_database_on_two_device)) - SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_using_on_two_device_breaks_encryption)) } + SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_you_must_not_start_database_on_two_device)) + SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_using_on_two_device_breaks_encryption)) if (chatDeletion) { ProgressView() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt index cabfbf031e..f92a5e0ce4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt @@ -2,6 +2,7 @@ package chat.simplex.common.views.migration import SectionBottomSpacer import SectionItemView +import SectionDividerSpaced import SectionSpacer import SectionTextFooter import SectionView @@ -9,6 +10,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.foundation.background import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboardManager import chat.simplex.common.model.* @@ -148,6 +150,7 @@ fun ModalData.MigrateToDeviceView(close: () -> Unit) { close() } }, + cardScreen = true, ) { MigrateToDeviceLayout( migrationState = migrationState, @@ -201,7 +204,7 @@ private fun MutableState.PasteOrScanLinkView(close: () -> Uni val progressIndicator = remember { mutableStateOf(false) } Column { if (appPlatform.isAndroid) { - SectionView(stringResource(MR.strings.scan_QR_code).replace('\n', ' ').uppercase()) { + SectionView(stringResource(MR.strings.scan_QR_code).replace('\n', ' ')) { QRCodeScanner(showQRCodeScanner = remember { mutableStateOf(true) }) { text -> checkUserLink(text) } @@ -209,12 +212,12 @@ private fun MutableState.PasteOrScanLinkView(close: () -> Uni SectionSpacer() } - SectionView(stringResource(if (appPlatform.isAndroid) MR.strings.or_paste_archive_link else MR.strings.paste_archive_link).uppercase()) { + SectionView(stringResource(if (appPlatform.isAndroid) MR.strings.or_paste_archive_link else MR.strings.paste_archive_link)) { PasteLinkView() } SectionSpacer() - SectionView(stringResource(MR.strings.chat_archive).uppercase()) { + SectionView(stringResource(MR.strings.chat_archive)) { ArchiveImportView(progressIndicator, close) } } @@ -280,7 +283,7 @@ private fun ModalData.OnionView(link: String, legacyLinkSocksProxy: String?, lin mutableStateOf(getNetCfg().withOnionHosts(onionHosts.value).copy(socksProxy = linkNetworkProxy?.toProxyString() ?: legacyLinkSocksProxy, sessionMode = sessionMode.value)) } - SectionView(stringResource(MR.strings.migrate_to_device_confirm_network_settings).uppercase()) { + SectionView(stringResource(MR.strings.migrate_to_device_confirm_network_settings)) { SettingsActionItemWithContent( icon = painterResource(MR.images.ic_check), text = stringResource(MR.strings.migrate_to_device_apply_onion), @@ -305,7 +308,7 @@ private fun ModalData.OnionView(link: String, legacyLinkSocksProxy: String?, lin val networkProxyPref = SharedPreference(get = { networkProxy.value }, set = { networkProxy.value = it }) - SectionView(stringResource(MR.strings.network_settings_title).uppercase()) { + SectionView(stringResource(MR.strings.network_settings_title)) { OnionRelatedLayout( appPreferences.developerTools.get(), networkUseSocksProxy, @@ -325,7 +328,7 @@ private fun ModalData.OnionView(link: String, legacyLinkSocksProxy: String?, lin @Composable private fun MutableState.DatabaseInitView(link: String, tempDatabaseFile: File, netCfg: NetCfg, networkProxy: NetworkProxy?) { Box { - SectionView(stringResource(MR.strings.migrate_to_device_database_init).uppercase()) {} + SectionView(stringResource(MR.strings.migrate_to_device_database_init)) {} ProgressView() } LaunchedEffect(Unit) { @@ -345,7 +348,7 @@ private fun MutableState.LinkDownloadingView( networkProxy: NetworkProxy? ) { Box { - SectionView(stringResource(MR.strings.migrate_to_device_downloading_details).uppercase()) {} + SectionView(stringResource(MR.strings.migrate_to_device_downloading_details)) {} ProgressView() } LaunchedEffect(Unit) { @@ -356,7 +359,7 @@ private fun MutableState.LinkDownloadingView( @Composable private fun DownloadProgressView(downloadedBytes: Long, totalBytes: Long) { Box { - SectionView(stringResource(MR.strings.migrate_to_device_downloading_archive).uppercase()) { + SectionView(stringResource(MR.strings.migrate_to_device_downloading_archive)) { val ratio = downloadedBytes.toFloat() / max(totalBytes, 1) LargeProgressView(ratio, "${(ratio * 100).toInt()}%", stringResource(MR.strings.migrate_to_device_bytes_downloaded).format(formatBytes(downloadedBytes))) } @@ -365,7 +368,7 @@ private fun DownloadProgressView(downloadedBytes: Long, totalBytes: Long) { @Composable private fun MutableState.DownloadFailedView(link: String, chatReceiver: MigrationToChatReceiver?, archivePath: String, netCfg: NetCfg, networkProxy: NetworkProxy?) { - SectionView(stringResource(MR.strings.migrate_to_device_download_failed).uppercase()) { + SectionView(stringResource(MR.strings.migrate_to_device_download_failed)) { SettingsActionItemWithContent( icon = painterResource(MR.images.ic_download), text = stringResource(MR.strings.migrate_to_device_repeat_download), @@ -386,7 +389,7 @@ private fun MutableState.DownloadFailedView(link: String, cha @Composable private fun MutableState.ArchiveImportView(archivePath: String, netCfg: NetCfg, networkProxy: NetworkProxy?) { Box { - SectionView(stringResource(MR.strings.migrate_to_device_importing_archive).uppercase()) {} + SectionView(stringResource(MR.strings.migrate_to_device_importing_archive)) {} ProgressView() } LaunchedEffect(Unit) { @@ -396,7 +399,7 @@ private fun MutableState.ArchiveImportView(archivePath: Strin @Composable private fun MutableState.ArchiveImportFailedView(archivePath: String, netCfg: NetCfg, networkProxy: NetworkProxy?) { - SectionView(stringResource(MR.strings.migrate_to_device_import_failed).uppercase()) { + SectionView(stringResource(MR.strings.migrate_to_device_import_failed)) { SettingsActionItemWithContent( icon = painterResource(MR.images.ic_download), text = stringResource(MR.strings.migrate_to_device_repeat_import), @@ -417,7 +420,7 @@ private fun MutableState.PassphraseEnteringView(currentKey: S Box { val view = LocalMultiplatformView() - SectionView(stringResource(MR.strings.migrate_to_device_enter_passphrase).uppercase()) { + SectionView(stringResource(MR.strings.migrate_to_device_enter_passphrase)) { SavePassphraseSetting( useKeychain.value, false, @@ -489,7 +492,7 @@ private fun MutableState.MigrationConfirmationView(status: DB } else -> Tuple4(generalGetString(MR.strings.error), null, generalGetString(MR.strings.unknown_error), null) } - SectionView(header.uppercase()) { + SectionView(header) { if (button != null && confirmation != null) { SettingsActionItemWithContent( icon = painterResource(MR.images.ic_download), @@ -500,14 +503,14 @@ private fun MutableState.MigrationConfirmationView(status: DB } ) {} } - SectionTextFooter(footer) } + SectionTextFooter(footer) } @Composable private fun MigrationView(passphrase: String, confirmation: MigrationConfirmation, useKeychain: Boolean, netCfg: NetCfg, networkProxy: NetworkProxy?, close: () -> Unit) { Box { - SectionView(stringResource(MR.strings.migrate_to_device_migrating).uppercase()) {} + SectionView(stringResource(MR.strings.migrate_to_device_migrating)) {} ProgressView() } LaunchedEffect(Unit) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt index 93bb4f49db..b5188178fa 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt @@ -66,7 +66,7 @@ fun AddChannelView(chatModel: ChatModel, close: () -> Unit, closeAll: () -> Unit closeAll() withBGApi { openGroupChat(null, gInfo.groupId) - ModalManager.end.showModalCloseable(true) { close -> + ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { close -> GroupLinkView(chatModel, rhId = null, groupInfo = gInfo, groupLink = groupLink.value, onGroupLinkUpdated = null, creatingGroup = true, isChannel = true, shareGroupInfo = gInfo, close = close) } } @@ -567,7 +567,7 @@ private fun LinkStepView( } } } - ModalView(close = close, showClose = false) { + ModalView(close = close, showClose = false, cardScreen = true) { GroupLinkView( chatModel = chatModel, rhId = null, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt index a54d2e42e7..1d8da1690c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt @@ -53,11 +53,11 @@ fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit, c closeAll.invoke() if (!groupInfo.incognito) { - ModalManager.end.showModalCloseable(true) { close -> + ModalManager.end.showModalCloseable(showClose = true) { close -> AddGroupMembersView(rhId, groupInfo, creatingGroup = true, chatModel, close) } } else { - ModalManager.end.showModalCloseable(true) { close -> + ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { close -> GroupLinkView(chatModel, rhId, groupInfo, groupLink = null, onGroupLinkUpdated = null, creatingGroup = true, close = close) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt index 0f299b5187..f1bd732d87 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt @@ -7,6 +7,7 @@ import SectionView import SectionViewWithButton import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.layout.* +import androidx.compose.foundation.background import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -130,7 +131,7 @@ private fun ContactConnectionInfoLayout( if (connLink != null && connLink.connFullLink.isNotEmpty() && contactConnection.initiated) { Spacer(Modifier.height(DEFAULT_PADDING)) SectionViewWithButton( - stringResource(MR.strings.one_time_link).uppercase(), + stringResource(MR.strings.one_time_link), titleButton = if (connLink.connShortLink == null) null else {{ ToggleShortLinkButton(showShortLink) }} ) { SimpleXCreatedLinkQRCode(connLink, short = showShortLink.value) @@ -146,7 +147,7 @@ private fun ContactConnectionInfoLayout( } SectionTextFooter(sharedProfileInfo(chatModel, contactConnection.incognito)) - SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + SectionDividerSpaced() DeleteButton(deleteConnection) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt index 1eceaf4158..993e1fca01 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt @@ -325,7 +325,7 @@ private fun ModalData.NewChatSheetLayout( item { if (filteredContactChats.isNotEmpty() && searchText.value.text.isEmpty()) { SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false) - SectionView(stringResource(MR.strings.contact_list_header_title).uppercase(), headerBottomPadding = DEFAULT_PADDING_HALF) {} + SectionView(stringResource(MR.strings.contact_list_header_title), headerBottomPadding = DEFAULT_PADDING_HALF) {} Spacer(Modifier.height(DEFAULT_PADDING_HALF)) } } @@ -410,7 +410,7 @@ private fun ModalData.NewChatSheetLayout( item { if (filteredContactChats.isNotEmpty() && searchText.value.text.isEmpty()) { SectionDividerSpaced() - SectionView(stringResource(MR.strings.contact_list_header_title).uppercase(), headerBottomPadding = DEFAULT_PADDING_HALF) {} + SectionView(stringResource(MR.strings.contact_list_header_title), headerBottomPadding = DEFAULT_PADDING_HALF) {} } } item { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt index 72311cd7fe..5b3fd34c22 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt @@ -495,7 +495,7 @@ private fun InviteView(rhId: Long?, connLinkInvitation: CreatedConnLink, contact ) SimpleXCreatedLinkQRCode(connLinkInvitation, short = showShortLink.value, onShare = { chatModel.markShowingInvitationUsed() }) } else { - SectionView(stringResource(MR.strings.share_this_1_time_link).uppercase(), headerBottomPadding = 5.dp) { + SectionView(stringResource(MR.strings.share_this_1_time_link), headerBottomPadding = 5.dp) { LinkTextView(connLinkInvitation.simplexChatUri(short = showShortLink.value), true) } @@ -519,7 +519,7 @@ private fun InviteView(rhId: Long?, connLinkInvitation: CreatedConnLink, contact val currentUser = remember { chatModel.currentUser }.value if (currentUser != null) { - SectionView(stringResource(MR.strings.new_chat_share_profile).uppercase(), headerBottomPadding = 5.dp) { + SectionView(stringResource(MR.strings.new_chat_share_profile), headerBottomPadding = 5.dp) { SectionItemView( padding = PaddingValues( top = 0.dp, @@ -643,14 +643,14 @@ private fun ConnectView(rhId: Long?, showQRCodeScanner: MutableState, p ) } - SectionView(stringResource(MR.strings.paste_the_link_you_received).uppercase(), headerBottomPadding = 5.dp) { + SectionView(stringResource(MR.strings.paste_the_link_you_received), headerBottomPadding = 5.dp) { PasteLinkView(rhId, pastedLink, showQRCodeScanner, close) } if (appPlatform.isAndroid) { Spacer(Modifier.height(10.dp)) - SectionView(stringResource(MR.strings.or_scan_qr_code).uppercase(), headerBottomPadding = 5.dp) { + SectionView(stringResource(MR.strings.or_scan_qr_code), headerBottomPadding = 5.dp) { QRCodeScanner(showQRCodeScanner) { text -> val linkVerified = verifyOnly(text) if (!linkVerified) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt index 2fd77b46a1..d11e396388 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt @@ -55,7 +55,7 @@ fun OnboardingConditionsView(chatModel: ChatModel) { OnboardingConditionsDesktop(selectedOperatorIds) } else { CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) { - ModalView({}, showClose = false, showAppBar = false) { + ModalView({}, showClose = false, showAppBar = false, cardScreen = true) { OnboardingShrinkingLayout( modifier = Modifier.fillMaxSize().themedBackground(bgLayerSize = LocalAppBarHandler.current?.backgroundGraphicsLayerSize, bgLayer = LocalAppBarHandler.current?.backgroundGraphicsLayer) .systemBarsPadding() @@ -133,7 +133,7 @@ fun OnboardingConditionsView(chatModel: ChatModel) { @Composable private fun OnboardingConditionsDesktop(selectedOperatorIds: MutableState>) { CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) { - ModalView({}, showClose = false) { + ModalView({}, showClose = false, cardScreen = true) { ColumnWithScrollBar(horizontalAlignment = Alignment.CenterHorizontally) { Column(Modifier.widthIn(max = 600.dp).fillMaxHeight().padding(horizontal = DEFAULT_PADDING).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) { Box(Modifier.align(Alignment.CenterHorizontally)) { @@ -184,7 +184,7 @@ fun ModalData.ChooseServerOperators( prepareChatBeforeFinishingOnboarding() } CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) { - ModalView(close, enableClose = selectedOperatorIds.value.isNotEmpty()) { + ModalView(close, enableClose = selectedOperatorIds.value.isNotEmpty(), cardScreen = true) { ColumnWithScrollBar( Modifier .themedBackground(bgLayerSize = LocalAppBarHandler.current?.backgroundGraphicsLayerSize, bgLayer = LocalAppBarHandler.current?.backgroundGraphicsLayer), @@ -373,7 +373,7 @@ private fun ChooseServerOperatorsInfoView() { SectionDividerSpaced() - SectionView(title = stringResource(MR.strings.onboarding_network_about_operators).uppercase()) { + SectionView(title = stringResource(MR.strings.onboarding_network_about_operators)) { chatModel.conditions.value.serverOperators.forEach { op -> ServerOperatorRow(op) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/LinkAMobileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/LinkAMobileView.kt index e902b7947e..97dfcd34b9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/LinkAMobileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/LinkAMobileView.kt @@ -65,7 +65,7 @@ private fun LinkAMobileLayout( Modifier.weight(0.3f), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - SectionView(generalGetString(MR.strings.this_device_name).uppercase()) { + SectionView(generalGetString(MR.strings.this_device_name)) { DeviceNameField(deviceName.value ?: "") { updateDeviceName(it) } SectionTextFooter(generalGetString(MR.strings.this_device_name_shared_with_mobile)) PreferenceToggle(stringResource(MR.strings.multicast_discoverable_via_local_network), checked = remember { ChatModel.controller.appPrefs.offerRemoteMulticast.state }.value) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt index 8bb84060c2..81e1afd22c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt @@ -4,10 +4,10 @@ import SectionBottomSpacer import SectionDividerSpaced import SectionItemView import SectionItemViewLongClickable -import SectionSpacer import SectionView import TextIconSpaced import androidx.compose.foundation.layout.* +import androidx.compose.foundation.background import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.* import androidx.compose.runtime.* @@ -29,8 +29,7 @@ import chat.simplex.common.model.ChatController.switchToLocalSession import chat.simplex.common.model.ChatModel.connectedToRemote import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.platform.* -import chat.simplex.common.ui.theme.DEFAULT_PADDING -import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF +import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.QRCodeScanner @@ -53,7 +52,7 @@ fun ConnectDesktopView(close: () -> Unit) { showDisconnectDesktopAlert(close) } } - ModalView(close = closeWithAlert) { + ModalView(close = closeWithAlert, cardScreen = true) { ConnectDesktopLayout( deviceName = deviceName.value!!, close @@ -128,7 +127,7 @@ private fun ConnectDesktopLayout(deviceName: String, close: () -> Unit) { @Composable private fun ConnectDesktop(deviceName: String, remoteCtrls: SnapshotStateList, sessionAddress: MutableState) { AppBarTitle(stringResource(MR.strings.connect_to_desktop)) - SectionView(stringResource(MR.strings.this_device_name).uppercase()) { + SectionView(stringResource(MR.strings.this_device_name)) { DevicesView(deviceName, remoteCtrls) { if (it != "") { setDeviceName(it) @@ -139,7 +138,7 @@ private fun ConnectDesktop(deviceName: String, remoteCtrls: SnapshotStateList) { AppBarTitle(stringResource(MR.strings.connecting_to_desktop)) - SectionView(stringResource(MR.strings.this_device_name).uppercase()) { + SectionView(stringResource(MR.strings.this_device_name)) { DevicesView(deviceName, remoteCtrls) { if (it != "") { setDeviceName(it) @@ -197,10 +196,10 @@ private fun SearchingDesktop(deviceName: String, remoteCtrls: SnapshotStateList< } } SectionDividerSpaced() - SectionView(stringResource(MR.strings.found_desktop).uppercase(), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) { + SectionView(stringResource(MR.strings.found_desktop), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) { Text(stringResource(MR.strings.waiting_for_desktop), fontStyle = FontStyle.Italic) } - SectionSpacer() + SectionDividerSpaced() DisconnectButton(stringResource(MR.strings.scan_QR_code).replace('\n', ' '), MR.images.ic_qr_code, ::disconnectDesktop) } @@ -215,7 +214,7 @@ private fun FoundDesktop( sessionAddress: MutableState, ) { AppBarTitle(stringResource(MR.strings.found_desktop)) - SectionView(stringResource(MR.strings.this_device_name).uppercase()) { + SectionView(stringResource(MR.strings.this_device_name)) { DevicesView(deviceName, remoteCtrls) { if (it != "") { setDeviceName(it) @@ -224,7 +223,7 @@ private fun FoundDesktop( } } SectionDividerSpaced() - SectionView(stringResource(MR.strings.found_desktop).uppercase(), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) { + SectionView(stringResource(MR.strings.found_desktop), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) { CtrlDeviceNameText(session, rc) CtrlDeviceVersionText(session) if (!compatible) { @@ -232,7 +231,7 @@ private fun FoundDesktop( } } - SectionSpacer() + SectionDividerSpaced() if (compatible) { SectionItemView({ withBGApi { confirmKnownDesktop(sessionAddress, rc) } }) { @@ -256,19 +255,19 @@ private fun FoundDesktop( @Composable private fun VerifySession(session: RemoteCtrlSession, rc: RemoteCtrlInfo?, sessCode: String, remoteCtrls: SnapshotStateList) { AppBarTitle(stringResource(MR.strings.verify_connection)) - SectionView(stringResource(MR.strings.connected_to_desktop).uppercase(), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) { + SectionView(stringResource(MR.strings.connected_to_desktop), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) { CtrlDeviceNameText(session, rc) Spacer(Modifier.height(DEFAULT_PADDING_HALF)) CtrlDeviceVersionText(session) } - SectionSpacer() + SectionDividerSpaced() - SectionView(stringResource(MR.strings.verify_code_with_desktop).uppercase()) { + SectionView(stringResource(MR.strings.verify_code_with_desktop)) { SessionCodeText(sessCode) } - SectionSpacer() + SectionDividerSpaced() SectionItemView({ verifyDesktopSessionCode(remoteCtrls, sessCode) }) { Icon(painterResource(MR.images.ic_check), generalGetString(MR.strings.confirm_verb), tint = MaterialTheme.colors.secondary) @@ -311,20 +310,20 @@ private fun CtrlDeviceVersionText(session: RemoteCtrlSession) { @Composable private fun ActiveSession(session: RemoteCtrlSession, rc: RemoteCtrlInfo, close: () -> Unit) { AppBarTitle(stringResource(MR.strings.connected_to_desktop)) - SectionView(stringResource(MR.strings.connected_desktop).uppercase(), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) { + SectionView(stringResource(MR.strings.connected_desktop), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) { Text(rc.deviceViewName) Spacer(Modifier.height(DEFAULT_PADDING_HALF)) CtrlDeviceVersionText(session) } if (session.sessionCode != null) { - SectionSpacer() - SectionView(stringResource(MR.strings.session_code).uppercase()) { + SectionDividerSpaced() + SectionView(stringResource(MR.strings.session_code)) { SessionCodeText(session.sessionCode!!) } } - SectionSpacer() + SectionDividerSpaced() SectionView { DisconnectButton { disconnectDesktop(close) } @@ -355,7 +354,7 @@ private fun DevicesView(deviceName: String, remoteCtrls: SnapshotStateList) { - SectionView(stringResource(MR.strings.scan_qr_code_from_desktop).uppercase()) { + SectionView(stringResource(MR.strings.scan_qr_code_from_desktop)) { QRCodeScanner { text -> sessionAddress.value = text connectDesktopAddress(sessionAddress, text) @@ -366,7 +365,7 @@ private fun ScanDesktopAddressView(sessionAddress: MutableState) { @Composable private fun DesktopAddressView(sessionAddress: MutableState) { val clipboard = LocalClipboardManager.current - SectionView(stringResource(MR.strings.desktop_address).uppercase()) { + SectionView(stringResource(MR.strings.desktop_address)) { if (sessionAddress.value.isEmpty()) { SettingsActionItem( painterResource(MR.images.ic_content_paste), @@ -410,7 +409,7 @@ private fun DesktopAddressView(sessionAddress: MutableState) { private fun LinkedDesktopsView(remoteCtrls: SnapshotStateList) { ColumnWithScrollBar { AppBarTitle(stringResource(MR.strings.linked_desktops)) - SectionView(stringResource(MR.strings.desktop_devices).uppercase()) { + SectionView(stringResource(MR.strings.desktop_devices)) { remoteCtrls.forEach { rc -> val showMenu = rememberSaveable { mutableStateOf(false) } SectionItemViewLongClickable(click = {}, longClick = { showMenu.value = true }) { @@ -427,7 +426,7 @@ private fun LinkedDesktopsView(remoteCtrls: SnapshotStateList) { } SectionDividerSpaced() - SectionView(stringResource(MR.strings.linked_desktop_options).uppercase()) { + SectionView(stringResource(MR.strings.linked_desktop_options)) { PreferenceToggle(stringResource(MR.strings.verify_connections), checked = remember { controller.appPrefs.confirmRemoteSessions.state }.value) { controller.appPrefs.confirmRemoteSessions.set(it) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt index 1d01ab11ff..8caf038481 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt @@ -92,7 +92,7 @@ fun ConnectMobileLayout( ) { ColumnWithScrollBar { AppBarTitle(stringResource(if (remember { chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles)) - SectionView(generalGetString(MR.strings.this_device_name).uppercase()) { + SectionView(generalGetString(MR.strings.this_device_name)) { DeviceNameField(deviceName.value ?: "") { updateDeviceName(it) } SectionTextFooter(generalGetString(MR.strings.this_device_name_shared_with_mobile)) PreferenceToggle(stringResource(MR.strings.multicast_discoverable_via_local_network), checked = remember { controller.appPrefs.offerRemoteMulticast.state }.value) { @@ -100,7 +100,7 @@ fun ConnectMobileLayout( } SectionDividerSpaced() } - SectionView(stringResource(MR.strings.devices).uppercase()) { + SectionView(stringResource(MR.strings.devices)) { if (chatModel.localUserCreated.value == true) { SettingsActionItemWithContent(text = stringResource(MR.strings.this_device), icon = painterResource(MR.images.ic_desktop), click = connectDesktop) { if (connectedHost.value == null) { @@ -215,7 +215,7 @@ private fun ConnectMobileViewLayout( Spacer(Modifier.height(DEFAULT_PADDING)) } if (deviceName != null || sessionCode != null) { - SectionView(stringResource(MR.strings.connected_mobile).uppercase()) { + SectionView(stringResource(MR.strings.connected_mobile)) { SelectionContainer { Text( deviceName ?: stringResource(MR.strings.new_mobile_device), @@ -228,7 +228,7 @@ private fun ConnectMobileViewLayout( } if (sessionCode != null) { - SectionView(stringResource(MR.strings.verify_code_on_mobile).uppercase()) { + SectionView(stringResource(MR.strings.verify_code_on_mobile)) { SelectionContainer { Text( sessionCode.substring(0, 23), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt index e24c09afd0..1c9e602f1b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt @@ -1,11 +1,13 @@ package chat.simplex.common.views.usersettings +import CARD_PADDING +import LocalCardScreen import SectionBottomSpacer import SectionDividerSpaced import SectionItemView +import itemHPadding import SectionItemViewSpaceBetween import SectionItemViewWithoutMinPadding -import SectionSpacer import SectionView import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource @@ -58,9 +60,9 @@ expect fun AppearanceView(m: ChatModel) object AppearanceScope { @Composable fun ProfileImageSection() { - SectionView(stringResource(MR.strings.settings_section_title_profile_images).uppercase(), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) { + SectionView(stringResource(MR.strings.settings_section_title_profile_images), contentPadding = PaddingValues(horizontal = CARD_PADDING)) { val image = remember { chatModel.currentUser }.value?.image - Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { + Row(Modifier.padding(vertical = 10.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { val size = 60 Box(Modifier.offset(x = -(size / 12).dp)) { if (!image.isNullOrEmpty()) { @@ -91,9 +93,10 @@ object AppearanceScope { @Composable fun AppToolbarsSection() { BoxWithConstraints { - SectionView(stringResource(MR.strings.appearance_app_toolbars).uppercase()) { + SectionView(stringResource(MR.strings.appearance_app_toolbars)) { SectionItemViewWithoutMinPadding { Box(Modifier.weight(1f)) { + var fontScale by remember { mutableStateOf(1f) } Text( stringResource(MR.strings.appearance_in_app_bars_alpha), Modifier.clickable( @@ -102,7 +105,9 @@ object AppearanceScope { ) { appPrefs.inAppBarsAlpha.set(appPrefs.inAppBarsDefaultAlpha) }, - maxLines = 1 + maxLines = 1, + fontSize = MaterialTheme.typography.body1.fontSize * fontScale, + onTextLayout = { if (it.hasVisualOverflow && fontScale > 0.5f) fontScale -= 0.05f } ) } Spacer(Modifier.padding(end = 10.dp)) @@ -175,7 +180,7 @@ object AppearanceScope { @Composable fun MessageShapeSection() { BoxWithConstraints { - SectionView(stringResource(MR.strings.settings_section_title_message_shape).uppercase()) { + SectionView(stringResource(MR.strings.settings_section_title_message_shape)) { SectionItemViewWithoutMinPadding { Text(stringResource(MR.strings.settings_message_shape_corner), Modifier.weight(1f)) Spacer(Modifier.width(10.dp)) @@ -205,8 +210,8 @@ object AppearanceScope { @Composable fun FontScaleSection() { val localFontScale = remember { mutableStateOf(appPrefs.fontScale.get()) } - SectionView(stringResource(MR.strings.appearance_font_size).uppercase(), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) { - Row(Modifier.padding(top = 10.dp), verticalAlignment = Alignment.CenterVertically) { + SectionView(stringResource(MR.strings.appearance_font_size), contentPadding = PaddingValues(horizontal = CARD_PADDING)) { + Row(Modifier.padding(vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) { Box(Modifier.size(50.dp) .background(MaterialTheme.colors.surface, RoundedCornerShape(percent = 22)) .clip(RoundedCornerShape(percent = 22)) @@ -409,26 +414,29 @@ object AppearanceScope { } if (appPlatform.isDesktop) { - val itemWidth = (DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier - DEFAULT_PADDING * 2 - DEFAULT_PADDING_HALF * 3) / 4 - val itemHeight = (DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier - DEFAULT_PADDING * 2) / 4 + val gridPadding = 12.dp + val cardPadding = if (LocalCardScreen.current) CARD_PADDING * 2 else 0.dp + val itemSize = (DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier - cardPadding - gridPadding * 5) / 4 val rows = ceil((PresetWallpaper.entries.size + 2) / 4f).roundToInt() LazyVerticalGrid( columns = GridCells.Fixed(4), - Modifier.height(itemHeight * rows + DEFAULT_PADDING_HALF * (rows - 1) + DEFAULT_PADDING * 2), - contentPadding = PaddingValues(DEFAULT_PADDING), - verticalArrangement = Arrangement.spacedBy(DEFAULT_PADDING_HALF), - horizontalArrangement = Arrangement.spacedBy(DEFAULT_PADDING_HALF), + Modifier.height(itemSize * rows + gridPadding * (rows + 1)), + contentPadding = PaddingValues(gridPadding), + verticalArrangement = Arrangement.spacedBy(gridPadding), + horizontalArrangement = Arrangement.spacedBy(gridPadding), ) { - gridContent(itemWidth, itemHeight) + gridContent(itemSize, itemSize) } } else { - LazyHorizontalGrid( + val gridPadding = 14.dp + val itemSize = 81.dp + LazyHorizontalGrid( rows = GridCells.Fixed(1), - Modifier.height(80.dp + DEFAULT_PADDING * 2), - contentPadding = PaddingValues(DEFAULT_PADDING), - horizontalArrangement = Arrangement.spacedBy(DEFAULT_PADDING_HALF), + Modifier.height(itemSize + gridPadding * 2), + contentPadding = PaddingValues(gridPadding), + horizontalArrangement = Arrangement.spacedBy(gridPadding), ) { - gridContent(80.dp, 80.dp) + gridContent(itemSize, itemSize) } } } @@ -521,9 +529,7 @@ object AppearanceScope { } SectionView(stringResource(MR.strings.settings_section_title_themes)) { - Spacer(Modifier.height(DEFAULT_PADDING_HALF)) ThemeDestinationPicker(themeUserDestination) - Spacer(Modifier.height(DEFAULT_PADDING_HALF)) val importWallpaperLauncher = rememberFileChooserLauncher(true) { to: URI? -> if (to != null) onImport(to) @@ -555,7 +561,7 @@ object AppearanceScope { color = if (chatModel.remoteHostId != null && themeUserDestination.value != null) MaterialTheme.colors.secondary else MaterialTheme.colors.primary ) } - SectionSpacer() + SectionDividerSpaced() } val state: State = remember(appPrefs.currentTheme.get()) { @@ -584,23 +590,23 @@ object AppearanceScope { } saveThemeToDatabase(null) } - } - SectionItemView(click = { - val user = themeUserDestination.value - if (user == null) { - ModalManager.start.showModal { - val importWallpaperLauncher = rememberFileChooserLauncher(true) { to: URI? -> - if (to != null) onImport(to) + SectionItemView(click = { + val user = themeUserDestination.value + if (user == null) { + ModalManager.start.showModal(cardScreen = true) { + val importWallpaperLauncher = rememberFileChooserLauncher(true) { to: URI? -> + if (to != null) onImport(to) + } + CustomizeThemeView { onChooseType(it, importWallpaperLauncher) } + } + } else { + ModalManager.start.showModalCloseable(cardScreen = true) { close -> + UserWallpaperEditorModal(chatModel.remoteHostId(), user.first, close) } - CustomizeThemeView { onChooseType(it, importWallpaperLauncher) } - } - } else { - ModalManager.start.showModalCloseable { close -> - UserWallpaperEditorModal(chatModel.remoteHostId(), user.first, close) } + }) { + Text(stringResource(MR.strings.customize_theme_title)) } - }) { - Text(stringResource(MR.strings.customize_theme_title)) } } @@ -626,68 +632,70 @@ object AppearanceScope { ) } - WallpaperPresetSelector( - selectedWallpaper = wallpaperType, - baseTheme = currentTheme.base, - currentColors = { type -> - ThemeManager.currentColors(type, null, null, appPrefs.themeOverrides.get()) - }, - onChooseType = onChooseType - ) - - val type = MaterialTheme.wallpaper.type - if (type is WallpaperType.Image) { - SectionItemView(disabled = chatModel.remoteHostId != null, click = { - val defaultActiveTheme = ThemeManager.defaultActiveTheme(appPrefs.themeOverrides.get()) - ThemeManager.saveAndApplyWallpaper(baseTheme, null) - ThemeManager.removeTheme(defaultActiveTheme?.themeId) - removeWallpaperFile(type.filename) - saveThemeToDatabase(null) - }) { - Text( - stringResource(MR.strings.theme_remove_image), - color = if (chatModel.remoteHostId == null) MaterialTheme.colors.primary else MaterialTheme.colors.secondary - ) - } - SectionSpacer() - } - - SectionView(stringResource(MR.strings.settings_section_title_chat_colors).uppercase()) { - WallpaperSetupView( - wallpaperType, - baseTheme, - MaterialTheme.wallpaper, - MaterialTheme.appColors.sentMessage, - MaterialTheme.appColors.sentQuote, - MaterialTheme.appColors.receivedMessage, - MaterialTheme.appColors.receivedQuote, - editColor = { name -> - editColor(name) - }, - onTypeChange = { type -> - ThemeManager.saveAndApplyWallpaper(baseTheme, type) - saveThemeToDatabase(null) + SectionView { + WallpaperPresetSelector( + selectedWallpaper = wallpaperType, + baseTheme = currentTheme.base, + currentColors = { type -> + ThemeManager.currentColors(type, null, null, appPrefs.themeOverrides.get()) }, + onChooseType = onChooseType ) + val type = MaterialTheme.wallpaper.type + if (type is WallpaperType.Image) { + SectionItemView(disabled = chatModel.remoteHostId != null, click = { + val defaultActiveTheme = ThemeManager.defaultActiveTheme(appPrefs.themeOverrides.get()) + ThemeManager.saveAndApplyWallpaper(baseTheme, null) + ThemeManager.removeTheme(defaultActiveTheme?.themeId) + removeWallpaperFile(type.filename) + saveThemeToDatabase(null) + }) { + Text( + stringResource(MR.strings.theme_remove_image), + color = if (chatModel.remoteHostId == null) MaterialTheme.colors.primary else MaterialTheme.colors.secondary + ) + } + } } SectionDividerSpaced() + WallpaperSetupView( + wallpaperType, + baseTheme, + MaterialTheme.wallpaper, + MaterialTheme.appColors.sentMessage, + MaterialTheme.appColors.sentQuote, + MaterialTheme.appColors.receivedMessage, + MaterialTheme.appColors.receivedQuote, + editColor = { name -> + editColor(name) + }, + onTypeChange = { type -> + ThemeManager.saveAndApplyWallpaper(baseTheme, type) + saveThemeToDatabase(null) + }, + firstSectionTitle = stringResource(MR.strings.settings_section_title_chat_colors), + ) + SectionDividerSpaced() + CustomizeThemeColorsSection(currentTheme) { name -> editColor(name) } - SectionDividerSpaced(maxBottomPadding = false) + SectionDividerSpaced() val currentOverrides = remember(currentTheme) { ThemeManager.defaultActiveTheme(appPrefs.themeOverrides.get()) } val canResetColors = currentTheme.base.hasChangedAnyColor(currentOverrides) if (canResetColors) { - SectionItemView({ - ThemeManager.resetAllThemeColors() - saveThemeToDatabase(null) - }) { - Text(generalGetString(MR.strings.reset_color), color = colors.primary) + SectionView { + SectionItemView({ + ThemeManager.resetAllThemeColors() + saveThemeToDatabase(null) + }) { + Text(generalGetString(MR.strings.reset_color), color = colors.primary) + } } - SectionSpacer() + SectionDividerSpaced() } SectionView { @@ -1007,7 +1015,7 @@ object AppearanceScope { SimpleXThemeOverride(currentColors()) { ChatThemePreview(theme, wallpaperImage, wallpaperType, previewBackgroundColor, previewTintColor) } - SectionSpacer() + SectionDividerSpaced() } var currentColor by remember { mutableStateOf(initialColor) } @@ -1084,7 +1092,7 @@ object AppearanceScope { }) { Text(generalGetString(MR.strings.reset_single_color), color = colors.primary) } - SectionSpacer() + SectionDividerSpaced() } } @@ -1188,75 +1196,82 @@ fun WallpaperSetupView( initialReceivedQuoteColor: Color, editColor: (ThemeColor) -> Unit, onTypeChange: (WallpaperType?) -> Unit, + firstSectionTitle: String? = null, ) { - if (wallpaperType is WallpaperType.Image) { - val state = remember(wallpaperType.scaleType, initialWallpaper?.type) { mutableStateOf(wallpaperType.scaleType ?: (initialWallpaper?.type as? WallpaperType.Image)?.scaleType ?: WallpaperScaleType.FILL) } - val values = remember { - WallpaperScaleType.entries.map { it to generalGetString(it.text) } - } - ExposedDropDownSettingRow( - stringResource(MR.strings.wallpaper_scale), - values, - state, - onSelected = { scaleType -> - onTypeChange(wallpaperType.copy(scaleType = scaleType)) - } - ) - } + val hasWallpaperSettings = wallpaperType is WallpaperType.Preset || wallpaperType is WallpaperType.Image - if (wallpaperType is WallpaperType.Preset || (wallpaperType is WallpaperType.Image && wallpaperType.scaleType == WallpaperScaleType.REPEAT)) { - val state = remember(wallpaperType, initialWallpaper?.type?.scale) { mutableStateOf(wallpaperType.scale ?: initialWallpaper?.type?.scale ?: 1f) } - Row(Modifier.padding(horizontal = DEFAULT_PADDING), verticalAlignment = Alignment.CenterVertically) { - Text("${state.value}".substring(0, min("${state.value}".length, 4)), Modifier.width(50.dp)) - Slider( - state.value, - valueRange = 0.5f..2f, - onValueChange = { - if (wallpaperType is WallpaperType.Preset) { - onTypeChange(wallpaperType.copy(scale = it)) - } else if (wallpaperType is WallpaperType.Image) { - onTypeChange(wallpaperType.copy(scale = it)) - } + if (hasWallpaperSettings) { + SectionView(firstSectionTitle) { + if (wallpaperType is WallpaperType.Image) { + val state = remember(wallpaperType.scaleType, initialWallpaper?.type) { mutableStateOf(wallpaperType.scaleType ?: (initialWallpaper?.type as? WallpaperType.Image)?.scaleType ?: WallpaperScaleType.FILL) } + val values = remember { + WallpaperScaleType.entries.map { it to generalGetString(it.text) } } - ) + ExposedDropDownSettingRow( + stringResource(MR.strings.wallpaper_scale), + values, + state, + onSelected = { scaleType -> + onTypeChange(wallpaperType.copy(scaleType = scaleType)) + } + ) + } + + if (wallpaperType is WallpaperType.Preset || (wallpaperType is WallpaperType.Image && wallpaperType.scaleType == WallpaperScaleType.REPEAT)) { + val state = remember(wallpaperType, initialWallpaper?.type?.scale) { mutableStateOf(wallpaperType.scale ?: initialWallpaper?.type?.scale ?: 1f) } + Row(Modifier.padding(horizontal = DEFAULT_PADDING), verticalAlignment = Alignment.CenterVertically) { + Text("${state.value}".substring(0, min("${state.value}".length, 4)), Modifier.width(50.dp)) + Slider( + state.value, + valueRange = 0.5f..2f, + onValueChange = { + if (wallpaperType is WallpaperType.Preset) { + onTypeChange(wallpaperType.copy(scale = it)) + } else if (wallpaperType is WallpaperType.Image) { + onTypeChange(wallpaperType.copy(scale = it)) + } + } + ) + } + } + + val wallpaperBackgroundColor = initialWallpaper?.background ?: wallpaperType.defaultBackgroundColor(theme, MaterialTheme.colors.background) + SectionItemViewSpaceBetween({ editColor(ThemeColor.WALLPAPER_BACKGROUND) }) { + val title = generalGetString(MR.strings.color_wallpaper_background) + Text(title) + Icon(painterResource(MR.images.ic_circle_filled), title, tint = wallpaperBackgroundColor) + } + val wallpaperTintColor = initialWallpaper?.tint ?: wallpaperType.defaultTintColor(theme) + SectionItemViewSpaceBetween({ editColor(ThemeColor.WALLPAPER_TINT) }) { + val title = generalGetString(MR.strings.color_wallpaper_tint) + Text(title) + Icon(painterResource(MR.images.ic_circle_filled), title, tint = wallpaperTintColor) + } } + SectionDividerSpaced() } - if (wallpaperType is WallpaperType.Preset || wallpaperType is WallpaperType.Image) { - val wallpaperBackgroundColor = initialWallpaper?.background ?: wallpaperType.defaultBackgroundColor(theme, MaterialTheme.colors.background) - SectionItemViewSpaceBetween({ editColor(ThemeColor.WALLPAPER_BACKGROUND) }) { - val title = generalGetString(MR.strings.color_wallpaper_background) + SectionView(if (!hasWallpaperSettings) firstSectionTitle else null) { + SectionItemViewSpaceBetween({ editColor(ThemeColor.SENT_MESSAGE) }) { + val title = generalGetString(MR.strings.color_sent_message) Text(title) - Icon(painterResource(MR.images.ic_circle_filled), title, tint = wallpaperBackgroundColor) + Icon(painterResource(MR.images.ic_circle_filled), title, tint = initialSentColor) } - val wallpaperTintColor = initialWallpaper?.tint ?: wallpaperType.defaultTintColor(theme) - SectionItemViewSpaceBetween({ editColor(ThemeColor.WALLPAPER_TINT) }) { - val title = generalGetString(MR.strings.color_wallpaper_tint) + SectionItemViewSpaceBetween({ editColor(ThemeColor.SENT_QUOTE) }) { + val title = generalGetString(MR.strings.color_sent_quote) Text(title) - Icon(painterResource(MR.images.ic_circle_filled), title, tint = wallpaperTintColor) + Icon(painterResource(MR.images.ic_circle_filled), title, tint = initialSentQuoteColor) + } + SectionItemViewSpaceBetween({ editColor(ThemeColor.RECEIVED_MESSAGE) }) { + val title = generalGetString(MR.strings.color_received_message) + Text(title) + Icon(painterResource(MR.images.ic_circle_filled), title, tint = initialReceivedColor) + } + SectionItemViewSpaceBetween({ editColor(ThemeColor.RECEIVED_QUOTE) }) { + val title = generalGetString(MR.strings.color_received_quote) + Text(title) + Icon(painterResource(MR.images.ic_circle_filled), title, tint = initialReceivedQuoteColor) } - SectionSpacer() - } - - SectionItemViewSpaceBetween({ editColor(ThemeColor.SENT_MESSAGE) }) { - val title = generalGetString(MR.strings.color_sent_message) - Text(title) - Icon(painterResource(MR.images.ic_circle_filled), title, tint = initialSentColor) - } - SectionItemViewSpaceBetween({ editColor(ThemeColor.SENT_QUOTE) }) { - val title = generalGetString(MR.strings.color_sent_quote) - Text(title) - Icon(painterResource(MR.images.ic_circle_filled), title, tint = initialSentQuoteColor) - } - SectionItemViewSpaceBetween({ editColor(ThemeColor.RECEIVED_MESSAGE) }) { - val title = generalGetString(MR.strings.color_received_message) - Text(title) - Icon(painterResource(MR.images.ic_circle_filled), title, tint = initialReceivedColor) - } - SectionItemViewSpaceBetween({ editColor(ThemeColor.RECEIVED_QUOTE) }) { - val title = generalGetString(MR.strings.color_received_quote) - Text(title) - Icon(painterResource(MR.images.ic_circle_filled), title, tint = initialReceivedQuoteColor) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt index dcb71a552d..2c729149d0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt @@ -4,9 +4,12 @@ import SectionBottomSpacer import SectionDividerSpaced import SectionTextFooter import SectionView +import androidx.compose.foundation.background import androidx.compose.runtime.* +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.ui.theme.* import chat.simplex.common.platform.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -29,14 +32,14 @@ fun DeveloperView(withAuth: (title: String, desc: String, block: () -> Unit) -> ChatConsoleItem { withAuth(generalGetString(MR.strings.auth_open_chat_console), generalGetString(MR.strings.auth_log_in_using_credential)) { ModalManager.start.showModalCloseable { TerminalView(false) } } } ResetHintsItem(unchangedHints) SettingsPreferenceItem(painterResource(MR.images.ic_code), stringResource(MR.strings.show_developer_options), developerTools) - SectionTextFooter( - generalGetString(if (devTools.value) MR.strings.show_dev_options else MR.strings.hide_dev_options) + " " + - generalGetString(MR.strings.developer_options) - ) } + SectionTextFooter( + generalGetString(if (devTools.value) MR.strings.show_dev_options else MR.strings.hide_dev_options) + " " + + generalGetString(MR.strings.developer_options) + ) if (devTools.value) { - SectionDividerSpaced(maxTopPadding = true) - SectionView(stringResource(MR.strings.developer_options_section).uppercase()) { + SectionDividerSpaced() + SectionView(stringResource(MR.strings.developer_options_section)) { SettingsActionItemWithContent(painterResource(MR.images.ic_breaking_news), stringResource(MR.strings.debug_logs)) { DefaultSwitch( checked = remember { appPrefs.logLevel.state }.value <= LogLevel.DEBUG, @@ -59,15 +62,15 @@ fun DeveloperView(withAuth: (title: String, desc: String, block: () -> Unit) -> SettingsPreferenceItem(painterResource(MR.images.ic_avg_pace), stringResource(MR.strings.show_slow_api_calls), appPreferences.showSlowApiCalls) } } - SectionDividerSpaced(maxTopPadding = true) - SectionView(stringResource(MR.strings.deprecated_options_section).uppercase()) { + SectionDividerSpaced() + SectionView(stringResource(MR.strings.deprecated_options_section)) { val simplexLinkMode = chatModel.controller.appPrefs.simplexLinkMode SimpleXLinkOptions(chatModel.simplexLinkMode, onSelected = { simplexLinkMode.set(it) chatModel.simplexLinkMode.value = it }) - SectionBottomSpacer() } + SectionBottomSpacer() } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt index 55bd796a3b..4a3806ab89 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt @@ -68,7 +68,7 @@ private fun HiddenProfileLayout( val passwordValid by remember { derivedStateOf { hidePassword.value == hidePassword.value.trim() } } val confirmValid by remember { derivedStateOf { confirmHidePassword.value == "" || hidePassword.value == confirmHidePassword.value } } val saveDisabled by remember { derivedStateOf { hidePassword.value == "" || !passwordValid || confirmHidePassword.value == "" || !confirmValid } } - SectionView(stringResource(MR.strings.hidden_profile_password).uppercase()) { + SectionView(stringResource(MR.strings.hidden_profile_password)) { SectionItemViewWithoutMinPadding { PassphraseField(hidePassword, generalGetString(MR.strings.password_to_show), isValid = { passwordValid }, showStrength = true) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt index 2fc427cd2e..150b2a38e0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt @@ -4,8 +4,11 @@ import SectionBottomSpacer import SectionTextFooter import SectionView import SectionViewSelectable +import androidx.compose.foundation.background import androidx.compose.material.* import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import chat.simplex.common.ui.theme.* import androidx.compose.ui.text.AnnotatedString import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.capitalize @@ -74,9 +77,9 @@ fun NotificationsSettingsLayout( color = MaterialTheme.colors.secondary ) } - if (platform.androidIsXiaomiDevice() && (notificationsMode.value == NotificationsMode.PERIODIC || notificationsMode.value == NotificationsMode.SERVICE)) { - SectionTextFooter(annotatedStringResource(MR.strings.xiaomi_ignore_battery_optimization)) - } + } + if (platform.androidIsXiaomiDevice() && (notificationsMode.value == NotificationsMode.PERIODIC || notificationsMode.value == NotificationsMode.SERVICE)) { + SectionTextFooter(annotatedStringResource(MR.strings.xiaomi_ignore_battery_optimization)) } SectionBottomSpacer() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt index fe9137ee35..63f3491d80 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt @@ -1,13 +1,15 @@ package chat.simplex.common.views.usersettings import SectionBottomSpacer -import SectionDividerSpaced import SectionItemView +import SectionDividerSpaced import SectionTextFooter import SectionView import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.material.MaterialTheme +import androidx.compose.ui.Modifier +import chat.simplex.common.ui.theme.* import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable @@ -47,6 +49,7 @@ fun PreferencesView(m: ChatModel, user: User, close: () -> Unit,) { if (preferences == currentPreferences) close() else showUnsavedChangesAlert({ savePrefs(close) }, close) }, + cardScreen = true, ) { PreferencesLayout( preferences, @@ -81,27 +84,27 @@ private fun PreferencesLayout( onTTLUpdated = onTTLUpdated ) - SectionDividerSpaced(true, maxBottomPadding = false) + SectionDividerSpaced() val allowFullDeletion = remember(preferences) { mutableStateOf(preferences.fullDelete.allow) } FeatureSection(ChatFeature.FullDelete, allowFullDeletion) { applyPrefs(preferences.copy(fullDelete = SimpleChatPreference(allow = it))) } - SectionDividerSpaced(true, maxBottomPadding = false) + SectionDividerSpaced() val allowReactions = remember(preferences) { mutableStateOf(preferences.reactions.allow) } FeatureSection(ChatFeature.Reactions, allowReactions) { applyPrefs(preferences.copy(reactions = SimpleChatPreference(allow = it))) } - SectionDividerSpaced(true, maxBottomPadding = false) + SectionDividerSpaced() val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.allow) } FeatureSection(ChatFeature.Voice, allowVoice) { applyPrefs(preferences.copy(voice = SimpleChatPreference(allow = it))) } - SectionDividerSpaced(true, maxBottomPadding = false) + SectionDividerSpaced() val allowCalls = remember(preferences) { mutableStateOf(preferences.calls.allow) } FeatureSection(ChatFeature.Calls, allowCalls) { applyPrefs(preferences.copy(calls = SimpleChatPreference(allow = it))) } - SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + SectionDividerSpaced() ResetSaveButtons( reset = reset, save = savePrefs, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index 2771b5ac62..7316c9bd82 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -1,10 +1,11 @@ package chat.simplex.common.views.usersettings import SectionBottomSpacer -import SectionDividerSpaced import SectionItemView +import SectionDividerSpaced import SectionTextFooter import SectionView +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* @@ -171,7 +172,7 @@ fun PrivacySettingsView( } if (!chatModel.desktopNoUserNoRemote) { - SectionDividerSpaced(maxTopPadding = true) + SectionDividerSpaced() ContacRequestsFromGroupsSection( currentUser = currentUser, setAutoAcceptGrpDirectInvs = { enable -> @@ -179,7 +180,7 @@ fun PrivacySettingsView( } ) - SectionDividerSpaced(maxTopPadding = true) + SectionDividerSpaced() DeliveryReceiptsSection( currentUser = currentUser, setOrAskSendReceiptsContacts = { enable -> @@ -619,7 +620,7 @@ fun SimplexLockView( } if (performLA.value && laMode.value == LAMode.PASSCODE) { SectionDividerSpaced() - SectionView(stringResource(MR.strings.self_destruct_passcode).uppercase()) { + SectionView(stringResource(MR.strings.self_destruct_passcode)) { val openInfo = { ModalManager.start.showModal { SelfDestructInfoView() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index a02d67265d..f17d3a6e4b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -1,8 +1,9 @@ package chat.simplex.common.views.usersettings import SectionBottomSpacer -import SectionDividerSpaced +import itemHPadding import SectionItemView +import SectionDividerSpaced import SectionView import TextIconSpaced import androidx.compose.desktop.ui.tooling.preview.Preview @@ -46,12 +47,13 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, close: ( user?.displayName, setPerformLA = setPerformLA, showModal = { modalView -> { ModalManager.start.showModal { modalView(chatModel) } } }, - showSettingsModal = { modalView -> { ModalManager.start.showModal(true) { modalView(chatModel) } } }, + showSettingsModal = { modalView -> { ModalManager.start.showModal(settings = true, cardScreen = true) { modalView(chatModel) } } }, showSettingsModalWithSearch = { modalView -> ModalManager.start.showCustomModal { close -> val search = rememberSaveable { mutableStateOf("") } ModalView( { close() }, + cardScreen = true, showSearch = true, searchAlwaysVisible = true, onSearchValueChanged = { search.value = it }, @@ -348,9 +350,9 @@ fun SettingsActionItemWithContent(icon: Painter?, text: String? = null, click: ( click, extraPadding = extraPadding, padding = if (extraPadding && icon != null) - PaddingValues(start = DEFAULT_PADDING * 1.7f, end = DEFAULT_PADDING) + PaddingValues(start = DEFAULT_PADDING * 1.7f, end = itemHPadding) else - PaddingValues(horizontal = DEFAULT_PADDING), + PaddingValues(horizontal = itemHPadding), disabled = disabled ) { if (icon != null) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt index e5c731f3b2..c55eaf6c10 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt @@ -8,6 +8,7 @@ import SectionView import SectionViewWithButton import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.ui.layout.ContentScale import androidx.compose.foundation.shape.RoundedCornerShape @@ -171,7 +172,7 @@ fun UserAddressView( ) } - ModalView(close = close) { + ModalView(close = close, cardScreen = true) { showLayout() } @@ -301,16 +302,16 @@ private fun UserAddressLayout( ) { if (userAddress == null) { if (!onboarding) { - SectionView(generalGetString(MR.strings.for_social_media).uppercase()) { + SectionView(generalGetString(MR.strings.for_social_media)) { CreateAddressButton(createAddress) } SectionDividerSpaced() - SectionView(generalGetString(MR.strings.or_to_share_privately).uppercase()) { + SectionView(generalGetString(MR.strings.or_to_share_privately)) { CreateOneTimeLinkButton() } - SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + SectionDividerSpaced() SectionView { LearnMoreButton(learnMore) } @@ -336,7 +337,7 @@ private fun UserAddressLayout( val savedAddressSettingsState = remember { mutableStateOf(addressSettingsState.value) } SectionViewWithButton( - stringResource(MR.strings.for_social_media).uppercase(), + stringResource(MR.strings.for_social_media), titleButton = if (userAddress.connLinkContact.connShortLink != null) {{ ToggleShortLinkButton(showShortLink) }} else null ) { SimpleXCreatedLinkQRCode(userAddress.connLinkContact, short = showShortLink.value) @@ -353,26 +354,25 @@ private fun UserAddressLayout( // ShareViaEmailButton { sendEmail(userAddress) } BusinessAddressToggle(addressSettingsState) { saveAddressSettings(addressSettingsState.value, savedAddressSettingsState) } AddressSettingsButton(user, userAddress, shareViaProfile, setProfileAddress, saveAddressSettings) - - if (addressSettingsState.value.businessAddress) { - SectionTextFooter(stringResource(MR.strings.add_your_team_members_to_conversations)) - } + } + if (addressSettingsState.value.businessAddress) { + SectionTextFooter(stringResource(MR.strings.add_your_team_members_to_conversations)) } - SectionDividerSpaced(maxTopPadding = addressSettingsState.value.businessAddress) - SectionView(generalGetString(MR.strings.or_to_share_privately).uppercase()) { + SectionDividerSpaced() + SectionView(generalGetString(MR.strings.or_to_share_privately)) { CreateOneTimeLinkButton() } - SectionDividerSpaced(maxBottomPadding = false) + SectionDividerSpaced() SectionView { LearnMoreButton(learnMore) } - SectionDividerSpaced(maxBottomPadding = false) + SectionDividerSpaced() SectionView { DeleteAddressButton(deleteAddress) - SectionTextFooter(stringResource(MR.strings.your_contacts_will_remain_connected)) } + SectionTextFooter(stringResource(MR.strings.your_contacts_will_remain_connected)) } } } @@ -495,7 +495,7 @@ private fun ModalData.UserAddressSettings( } } - ModalView(close = { onClose(close) }) { + ModalView(close = { onClose(close) }, cardScreen = true) { ColumnWithScrollBar { AppBarTitle(stringResource(MR.strings.address_settings), hostDevice(user?.remoteHostId)) Column( @@ -512,10 +512,10 @@ private fun ModalData.UserAddressSettings( } SectionDividerSpaced() - SectionView(stringResource(MR.strings.address_welcome_message).uppercase()) { + SectionView(stringResource(MR.strings.address_welcome_message)) { AutoReplyEditor(addressSettingsState) } - SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + SectionDividerSpaced() saveAddressSettingsButton(addressSettingsState.value == savedAddressSettingsState.value) { saveAddressSettings(addressSettingsState.value, savedAddressSettingsState) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt index d7ddb6b950..ac21fb6b23 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt @@ -1,7 +1,6 @@ package chat.simplex.common.views.usersettings import SectionBottomSpacer -import SectionDivider import SectionItemView import SectionItemViewSpaceBetween import SectionItemViewWithoutMinPadding @@ -177,7 +176,7 @@ private fun UserProfilesLayout( SectionView { for (user in filteredUsers) { UserView(user, visibleUsersCount, activateUser, removeUser, unhideUser, muteUser, unmuteUser, showHiddenProfile) - SectionDivider() + Divider(Modifier.padding(horizontal = 8.dp)) } if (searchTextOrPassword.value.trim().isEmpty()) { SectionItemView(addUser, minHeight = 68.dp) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt index 8c38070c98..42746006a3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt @@ -8,6 +8,7 @@ import SectionTextFooter import SectionView import SectionViewSelectableCards import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* @@ -158,6 +159,7 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (@Composable ModalData.() - }, close) } }, + cardScreen = true, ) { AdvancedNetworkSettingsLayout( currentRemoteHost = currentRemoteHost, @@ -234,13 +236,13 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (@Composable ModalData.() - SettingsPreferenceItem(painterResource(MR.images.ic_arrow_forward), stringResource(MR.strings.private_routing_show_message_status), chatModel.controller.appPrefs.showSentViaProxy) } SectionTextFooter(stringResource(MR.strings.private_routing_explanation)) - SectionDividerSpaced(maxTopPadding = true) + SectionDividerSpaced() - SectionView(stringResource(MR.strings.network_session_mode_transport_isolation).uppercase()) { + SectionView(stringResource(MR.strings.network_session_mode_transport_isolation)) { SessionModePicker(sessionMode, showModal, updateSessionMode) } SectionDividerSpaced() - SectionView(stringResource(MR.strings.network_smp_web_port_section_title).uppercase()) { + SectionView(stringResource(MR.strings.network_smp_web_port_section_title)) { ExposedDropDownSettingRow( stringResource(MR.strings.network_smp_web_port_toggle), SMPWebPortServers.entries.map { it to stringResource(it.text) }, @@ -251,9 +253,9 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (@Composable ModalData.() - if (smpWebPortServers.value == SMPWebPortServers.Preset) stringResource(MR.strings.network_smp_web_port_preset_footer) else String.format(stringResource(MR.strings.network_smp_web_port_footer), if (smpWebPortServers.value == SMPWebPortServers.All) "443" else "5223") ) - SectionDividerSpaced(maxTopPadding = true) + SectionDividerSpaced() - SectionView(stringResource(MR.strings.network_option_tcp_connection).uppercase()) { + SectionView(stringResource(MR.strings.network_option_tcp_connection)) { SectionItemView { TimeoutSettingRow( stringResource(MR.strings.network_option_tcp_connection_timeout), networkTCPConnectTimeoutInteractive, @@ -330,7 +332,7 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (@Composable ModalData.() - } } - SectionDividerSpaced(maxBottomPadding = false) + SectionDividerSpaced() SectionView { SectionItemView(reset, disabled = resetDisabled) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ChatRelayView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ChatRelayView.kt index 1c68e780dc..ab63067226 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ChatRelayView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ChatRelayView.kt @@ -149,7 +149,8 @@ fun ChatRelayView( text = generalGetString(MR.strings.check_relay_address) ) } - } + }, + cardScreen = true, ) { ChatRelayLayout( relayToEdit, @@ -182,7 +183,7 @@ private fun ChatRelayLayout( @Composable private fun PresetRelay(relay: MutableState, testing: MutableState) { - SectionView(stringResource(MR.strings.preset_relay_address).uppercase()) { + SectionView(stringResource(MR.strings.preset_relay_address)) { SelectionContainer { Text( relay.value.address, @@ -192,7 +193,7 @@ private fun PresetRelay(relay: MutableState, testing: MutableStat } } SectionDividerSpaced() - SectionView(stringResource(MR.strings.preset_relay_name).uppercase()) { + SectionView(stringResource(MR.strings.preset_relay_name)) { SectionItemView { Text(relay.value.displayName) } @@ -291,7 +292,7 @@ private fun UseRelaySection( testing: MutableState ) { val scope = rememberCoroutineScope() - SectionView(stringResource(MR.strings.use_relay).uppercase()) { + SectionView(stringResource(MR.strings.use_relay)) { SectionItemViewSpaceBetween( click = { testing.value = true @@ -377,7 +378,7 @@ fun ModalData.NewChatRelayView( ModalView(close = { addChatRelay(relayToEdit.value, userServers, serverErrors, serverWarnings, rhId, close) - }) { + }, cardScreen = true) { NewChatRelayLayout(relayToEdit) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt index a62a58cb10..892a252a9d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt @@ -9,6 +9,7 @@ import SectionTextFooter import SectionView import SectionViewSelectable import TextIconSpaced +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.KeyboardActions import androidx.compose.material.* @@ -84,7 +85,7 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) { onClose(close = { ModalManager.start.closeModals() }) } } - ModalView(close = { onClose(closeNetworkAndServers) }) { + ModalView(close = { onClose(closeNetworkAndServers) }, cardScreen = true) { NetworkAndServersLayout( currentRemoteHost = currentRemoteHost, networkUseSocksProxy = networkUseSocksProxy, @@ -210,7 +211,7 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) { AppBarTitle(stringResource(MR.strings.network_and_servers)) // TODO: Review this and socks. if (!chatModel.desktopNoUserNoRemote) { - SectionView(generalGetString(MR.strings.network_preset_servers_title).uppercase()) { + SectionView(generalGetString(MR.strings.network_preset_servers_title)) { userServers.value.forEachIndexed { index, srv -> srv.operator?.let { ServerOperatorRow(index, it, currUserServers, userServers, serverErrors, serverWarnings, currentRemoteHost?.remoteHostId) } } @@ -262,14 +263,11 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) { UseSocksProxySwitch(networkUseSocksProxy, toggleSocksProxy) SettingsActionItem(painterResource(MR.images.ic_settings_ethernet), stringResource(MR.strings.network_socks_proxy_settings), { showCustomModal { SocksProxySettings(networkUseSocksProxy.value, appPrefs.networkProxy, onionHosts, sessionMode = appPrefs.networkSessionMode.get(), false, it) } }) SettingsActionItem(painterResource(MR.images.ic_cable), stringResource(MR.strings.network_settings), { ModalManager.start.showCustomModal { AdvancedNetworkSettingsView(showModal, it) } }) - if (networkUseSocksProxy.value) { - SectionTextFooter(annotatedStringResource(MR.strings.socks_proxy_setting_limitations)) - SectionDividerSpaced(maxTopPadding = true) - } else { - SectionDividerSpaced(maxBottomPadding = false) - } } } + if (currentRemoteHost == null && networkUseSocksProxy.value) { + SectionTextFooter(annotatedStringResource(MR.strings.socks_proxy_setting_limitations)) + } val saveDisabled = !serversCanBeSaved(currUserServers.value, userServers.value, serverErrors.value) SectionItemView( @@ -303,7 +301,7 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) { if (appPlatform.isAndroid) { SectionDividerSpaced() - SectionView(generalGetString(MR.strings.settings_section_title_network_connection).uppercase()) { + SectionView(generalGetString(MR.strings.settings_section_title_network_connection)) { val info = remember { chatModel.networkInfo }.value SettingsActionItemWithContent(icon = null, info.networkType.text) { Icon(painterResource(MR.images.ic_circle_filled), stringResource(MR.strings.icon_descr_server_status_connected), tint = if (info.online) Color.Green else MaterialTheme.colors.error) @@ -466,10 +464,11 @@ fun SocksProxySettings( ) } }, + cardScreen = true, ) { ColumnWithScrollBar { AppBarTitle(generalGetString(MR.strings.network_socks_proxy_settings)) - SectionView(stringResource(MR.strings.network_socks_proxy).uppercase()) { + SectionView(stringResource(MR.strings.network_socks_proxy)) { Column(Modifier.padding(horizontal = DEFAULT_PADDING)) { DefaultConfigurableTextField( hostUnsaved, @@ -495,9 +494,9 @@ fun SocksProxySettings( SectionTextFooter(annotatedStringResource(MR.strings.disable_onion_hosts_when_not_supported)) } - SectionDividerSpaced(maxTopPadding = true) + SectionDividerSpaced() - SectionView(stringResource(MR.strings.network_proxy_auth).uppercase()) { + SectionView(stringResource(MR.strings.network_proxy_auth)) { PreferenceToggle( stringResource(MR.strings.network_proxy_random_credentials), checked = proxyAuthRandomUnsaved.value, @@ -526,7 +525,7 @@ fun SocksProxySettings( SectionTextFooter(proxyAuthFooter(usernameUnsaved.value.text, passwordUnsaved.value.text, proxyAuthModeUnsaved.value, sessionMode)) } - SectionDividerSpaced(maxBottomPadding = false, maxTopPadding = true) + SectionDividerSpaced() SectionView { SectionItemView({ diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt index 9e11b9a932..f5bceabbf1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* +import androidx.compose.foundation.background import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -181,7 +182,7 @@ fun OperatorViewLayout( val duplicateHosts = findDuplicateHosts(serverErrors.value) Column { - SectionView(generalGetString(MR.strings.operator).uppercase()) { + SectionView(generalGetString(MR.strings.operator)) { SectionItemView({ ModalManager.start.showModalCloseable { _ -> OperatorInfoView(operator) } }) { Row( Modifier.fillMaxWidth(), @@ -238,7 +239,7 @@ fun OperatorViewLayout( if (userServers.value[operatorIndex].chatRelays.any { !it.deleted }) { val duplicateRelayAddresses = findDuplicateRelayAddresses(serverErrors.value) SectionDividerSpaced() - SectionView(generalGetString(MR.strings.chat_relays).uppercase()) { + SectionView(generalGetString(MR.strings.chat_relays)) { userServers.value[operatorIndex].chatRelays.forEachIndexed { index, relay -> if (!relay.deleted) { ChatRelayViewLink(relay, duplicateRelayAddresses) { @@ -252,7 +253,7 @@ fun OperatorViewLayout( if (userServers.value[operatorIndex].smpServers.any { !it.deleted }) { SectionDividerSpaced() - SectionView(generalGetString(MR.strings.operator_use_for_messages).uppercase()) { + SectionView(generalGetString(MR.strings.operator_use_for_messages)) { SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) { Text( stringResource(MR.strings.operator_use_for_messages_receiving), @@ -306,7 +307,7 @@ fun OperatorViewLayout( // Preset servers can't be deleted if (userServers.value[operatorIndex].smpServers.any { it.preset }) { SectionDividerSpaced() - SectionView(generalGetString(MR.strings.message_servers).uppercase()) { + SectionView(generalGetString(MR.strings.message_servers)) { userServers.value[operatorIndex].smpServers.forEachIndexed { i, server -> if (!server.preset) return@forEachIndexed SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.SMP) }) { @@ -340,7 +341,7 @@ fun OperatorViewLayout( if (userServers.value[operatorIndex].smpServers.any { !it.preset && !it.deleted }) { SectionDividerSpaced() - SectionView(generalGetString(MR.strings.operator_added_message_servers).uppercase()) { + SectionView(generalGetString(MR.strings.operator_added_message_servers)) { userServers.value[operatorIndex].smpServers.forEachIndexed { i, server -> if (server.deleted || server.preset) return@forEachIndexed SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.SMP) }) { @@ -356,7 +357,7 @@ fun OperatorViewLayout( if (userServers.value[operatorIndex].xftpServers.any { !it.deleted }) { SectionDividerSpaced() - SectionView(generalGetString(MR.strings.operator_use_for_files).uppercase()) { + SectionView(generalGetString(MR.strings.operator_use_for_files)) { SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) { Text( stringResource(MR.strings.operator_use_for_sending), @@ -389,7 +390,7 @@ fun OperatorViewLayout( // Preset servers can't be deleted if (userServers.value[operatorIndex].xftpServers.any { it.preset }) { SectionDividerSpaced() - SectionView(generalGetString(MR.strings.media_and_file_servers).uppercase()) { + SectionView(generalGetString(MR.strings.media_and_file_servers)) { userServers.value[operatorIndex].xftpServers.forEachIndexed { i, server -> if (!server.preset) return@forEachIndexed SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.XFTP) }) { @@ -423,7 +424,7 @@ fun OperatorViewLayout( if (userServers.value[operatorIndex].xftpServers.any { !it.preset && !it.deleted}) { SectionDividerSpaced() - SectionView(generalGetString(MR.strings.operator_added_xftp_servers).uppercase()) { + SectionView(generalGetString(MR.strings.operator_added_xftp_servers)) { userServers.value[operatorIndex].xftpServers.forEachIndexed { i, server -> if (server.deleted || server.preset) return@forEachIndexed SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.XFTP) }) { @@ -490,7 +491,7 @@ fun OperatorInfoView(serverOperator: ServerOperator) { } } - SectionDividerSpaced(maxBottomPadding = false) + SectionDividerSpaced() val uriHandler = LocalUriHandler.current SectionView { @@ -507,7 +508,7 @@ fun OperatorInfoView(serverOperator: ServerOperator) { val selfhost = serverOperator.info.selfhost if (selfhost != null) { - SectionDividerSpaced(maxBottomPadding = false) + SectionDividerSpaced() SectionView { SectionItemView { val (text, link) = selfhost diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt index 01630a2b52..b3326bd2e9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt @@ -5,6 +5,7 @@ import SectionDividerSpaced import SectionItemView import SectionItemViewSpaceBetween import SectionView +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.* @@ -80,7 +81,8 @@ fun ProtocolServerView( ) } } - } + }, + cardScreen = true, ) { Box { ProtocolServerLayout( @@ -140,7 +142,7 @@ private fun PresetServer( testing: Boolean, testServer: () -> Unit ) { - SectionView(stringResource(MR.strings.smp_servers_preset_address).uppercase()) { + SectionView(stringResource(MR.strings.smp_servers_preset_address)) { SelectionContainer { Text( server.value.server, @@ -172,7 +174,7 @@ fun CustomServer( } } SectionView( - stringResource(MR.strings.smp_servers_your_server_address).uppercase(), + stringResource(MR.strings.smp_servers_your_server_address), icon = painterResource(MR.images.ic_error), iconTint = if (!valid.value) MaterialTheme.colors.error else Color.Transparent, ) { @@ -190,13 +192,13 @@ fun CustomServer( } } } - SectionDividerSpaced(maxTopPadding = true) + SectionDividerSpaced() UseServerSection(server, valid.value, testing, testServer, onDelete) if (valid.value) { SectionDividerSpaced() - SectionView(stringResource(MR.strings.smp_servers_add_to_another_device).uppercase()) { + SectionView(stringResource(MR.strings.smp_servers_add_to_another_device)) { QRCode(serverAddress.value, small = true) } } @@ -210,7 +212,7 @@ private fun UseServerSection( testServer: () -> Unit, onDelete: (() -> Unit)? = null, ) { - SectionView(stringResource(MR.strings.smp_servers_use_server).uppercase()) { + SectionView(stringResource(MR.strings.smp_servers_use_server)) { SectionItemViewSpaceBetween(testServer, disabled = !valid || testing) { Text(stringResource(MR.strings.smp_servers_test_server), color = if (valid && !testing) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary) ShowTestStatus(server.value) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt index 3be2456b72..280cd7bedb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt @@ -7,9 +7,11 @@ import SectionItemView import SectionTextFooter import SectionView import androidx.compose.foundation.layout.* +import androidx.compose.foundation.background import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import chat.simplex.common.ui.theme.* import androidx.compose.ui.platform.LocalUriHandler import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -86,7 +88,7 @@ fun YourServersViewLayout( Column { if (userServers.value[operatorIndex].chatRelays.any { !it.deleted }) { val duplicateRelayAddresses = findDuplicateRelayAddresses(serverErrors.value) - SectionView(generalGetString(MR.strings.chat_relays).uppercase()) { + SectionView(generalGetString(MR.strings.chat_relays)) { userServers.value[operatorIndex].chatRelays.forEachIndexed { i, relay -> if (relay.deleted) return@forEachIndexed ChatRelayViewLink(relay, duplicateRelayAddresses) { @@ -99,7 +101,7 @@ fun YourServersViewLayout( if (userServers.value[operatorIndex].smpServers.any { !it.deleted }) { SectionDividerSpaced() - SectionView(generalGetString(MR.strings.message_servers).uppercase()) { + SectionView(generalGetString(MR.strings.message_servers)) { userServers.value[operatorIndex].smpServers.forEachIndexed { i, server -> if (server.deleted) return@forEachIndexed SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.SMP) }) { @@ -133,7 +135,7 @@ fun YourServersViewLayout( if (userServers.value[operatorIndex].xftpServers.any { !it.deleted }) { SectionDividerSpaced() - SectionView(generalGetString(MR.strings.media_and_file_servers).uppercase()) { + SectionView(generalGetString(MR.strings.media_and_file_servers)) { userServers.value[operatorIndex].xftpServers.forEachIndexed { i, server -> if (server.deleted) return@forEachIndexed SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.XFTP) }) { @@ -170,7 +172,7 @@ fun YourServersViewLayout( userServers.value[operatorIndex].xftpServers.any { !it.deleted } || userServers.value[operatorIndex].chatRelays.any { !it.deleted } ) { - SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false) + SectionDividerSpaced() } SectionView { @@ -195,7 +197,7 @@ fun YourServersViewLayout( ServersWarningFooter(serversWarn) } } - SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false) + SectionDividerSpaced() SectionView { TestServersButton( diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml index 95ec53287a..36744ad503 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml @@ -1091,7 +1091,7 @@ للاتصال، يمكن لجهة الاتصال مسح رمز QR أو استخدام الرابط في التطبيق. اختبر الخوادم لا معرّفات مُستخدم - دعم SIMPLEX CHAT + دعم SimpleX Chat بدِّل العنوان الرئيسي سيتم وضع علامة على الرسالة على أنها تحت الإشراف لجميع الأعضاء. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 375edecd44..e5d313e9ed 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1101,7 +1101,7 @@ Off Appearance Customize theme - INTERFACE COLORS + Interface colors App version App version: v%s App build: %s @@ -1537,26 +1537,26 @@ Open clean link - YOU - SETTINGS - CHAT DATABASE - HELP - SUPPORT SIMPLEX CHAT - APP - DEVICE - CHATS - FILES - SEND DELIVERY RECEIPTS TO - CONTACT REQUESTS FROM GROUPS + You + Settings + Chat database + Help + Support SimpleX Chat + App + Device + Chats + Files + Send delivery receipts to + Contact requests from groups Restart Shutdown Developer tools Experimental features - SOCKS PROXY - INTERFACE + SOCKS proxy + Interface LANGUAGE - APP ICON - THEMES + App icon + Themes Profile images Message shape Corner @@ -1564,21 +1564,21 @@ Chat theme Profile theme Chat colors - MESSAGES AND FILES - PRIVATE MESSAGE ROUTING - CALLS + Messages and files + Private message routing + Calls Network connection Incognito mode - EXPERIMENTAL + Experimental Use from desktop Your chat database - RUN CHAT + Run chat Remote mobiles Chat is running Chat is stopped - CHAT DATABASE + Chat database Database passphrase Export database Import database @@ -1887,7 +1887,7 @@ Invite members Add team members Add friends - %1$s MEMBERS + %1$s members you: %1$s Delete group Delete channel @@ -1940,7 +1940,7 @@ Chat relays - FOR CONSOLE + For console Local name Database ID Debug delivery @@ -2010,7 +2010,7 @@ disabled failed inactive - MEMBER + Member Role Change role Change @@ -2027,7 +2027,7 @@ Group Chat Connection - CONNECTION FAILED + Connection failed direct indirect (%1$s) Message queue info @@ -2056,7 +2056,7 @@ Message too large - SERVERS + Servers Receiving via Sending via Network status @@ -3020,9 +3020,9 @@ Waiting for channel owner to add relays. - RELAY - OWNER - SUBSCRIBER + Relay + Owner + Subscriber Channel Relay link Relay address @@ -3096,6 +3096,6 @@ Quit SimpleX SimpleX SimpleX — %d unread - Minimize to tray when closing window - Keep SimpleX running in the background to receive messages. + Close to tray + Runs in background to receive messages \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml index c691447b32..890720a727 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml @@ -83,7 +83,7 @@ Android Keystore се използва за сигурно съхраняване на паролата - тоа позволява на услугата за известия да работи. Създаен беше празен профил за чат с предоставеното име и приложението се отвари както обикновено. Приложението може да получава известия само когато работи, няма да се стартира услуга във фонов режим - ИКОНА НА ПРИЛОЖЕНИЕТО + Икона на приложението Идентифицирай Оптимизацията на батерията е активна, изключват се фоновата услуга и периодичните заявки за нови съобщения. Можете да ги активирате отново през настройките. за всеки чат профил, който имате в приложението.]]> @@ -169,13 +169,13 @@ чрез реле видео разговор Вашите обаждания - ПРИЛОЖЕНИЕ + Приложение Резервно копие на данните от приложението Кода за достъп до приложение се заменя с код за самоунищожение. Автоматично приемане на изображения Идентификацията е отменена Изпрати визуализация на линковете - ОБАЖДАНИЯ + Обаждания Android Keystore ще се използва за сигурно съхраняване на паролата, след като рестартирате приложението или промените паролата - това ще позволи получаването на известия. Промяна на паролата на базата данни\? променена ролята от %s на %s @@ -223,7 +223,7 @@ Базата данни е изтрита Чатът работи Чатът е спрян - БАЗА ДАННИ + База данни Базата данни е импортирана Потвърди новата парола… Потвърди актуализаациите на базата данни @@ -314,13 +314,13 @@ Промяна на режима на заключване Промени режима на самоунищожение Промени кода за достъп за самоунищожение - ЧАТОВЕ + Чатове промяна на адреса… В момента максималният поддържан размер на файла е %1$s. ID в базата данни ID в базата данни: %d Контакти - ТЕМИ + Теми Базата данни е криптирана с автоматично генерирана парола. Моля, променете я преди експортиране. Парола за базата данни Изтрий базата данни @@ -406,7 +406,7 @@ Идентификатори в базата данни и опция за изолация на транспорта. Изтрий адрес Изтрий адрес\? - ЦВЕТОВЕ НА ИНТЕРФЕЙСА + Цветове на интерфейса Създай Създай профил Изтрий изображение @@ -446,7 +446,7 @@ Активирай потвърждениeто\? Изпращането на потвърждениe за доставка е деактивирано за %d контакта Изпращането на потвърждениe е активирано за %d контакта - УСТРОЙСТВО + Устройство Деактивиране (запазване на промените) %d файл(а) с общ размер от %s Криптирай @@ -485,7 +485,7 @@ Те могат да бъдат променени в настройките за всеки контакт и група. Инструменти за разработчици Деактивиране за всички - ИЗПРАЩАЙТЕ ПОТВЪРЖДЕНИE ЗА ДОСТАВКА НА + Изпращайте потвърждениe за доставка на Изтрий съобщенията след %s секунда(и) Изтрий съобщенията @@ -622,7 +622,7 @@ Пълно име: Изход без запазване Парола за скрит профил - ЕКСПЕРИМЕНТАЛЕН + Експериментален Файл: %s Разшири избора на роля Поправи връзката\? @@ -632,7 +632,7 @@ Изпратените съобщения ще бъдат изтрити след зададеното време. Групов линк Файлове и медия - ЗА КОНЗОЛАТА + За конзолата Групови настройки Файл Файлът не е намерен @@ -643,7 +643,7 @@ Филтрирайте непрочетените и любимите чатове. Членовете могат да изпращат лични съобщения. помощ - ПОМОЩ + Помощ Здравей, \nСвържи се с мен през SimpleX Chat: %s Членовете могат да добавят реакции към съобщенията. @@ -805,7 +805,7 @@ Когато приложението работи Периодично Постави получения линк - СЪОБЩЕНИЯ И ФАЙЛОВЕ + Съобщения и файлове Няма получени или изпратени файлове Известията ще се доставят само докато приложението не е спряно! Премахване на парола от Keystore\? @@ -987,7 +987,7 @@ Режим на заключване Моля, докладвайте го на разработчиците. Защити екрана на приложението - ЧЛЕН + Член Премахване PING бройка Само вашият контакт може да добавя реакции на съобщенията. @@ -1042,8 +1042,8 @@ Сподели с контактите Спри споделянето Спри споделянето на адреса\? - НАСТРОЙКИ - СТАРТИРАНЕ НА ЧАТ + Настройки + Стартиране на чат Задай име на контакт… Няма информация за доставката Отзови файл\? @@ -1067,7 +1067,7 @@ Изпращането на потвърждениe за доставка е разрешено за %d групи Рестартирайте приложението, за да използвате импортирана база данни. Тази група има над %1$d членове, потвърждениeто за доставка няма да се изпраща. - СЪРВЪРИ + Сървъри %s: %s Доставка Активиране (запазване на груповите промени) @@ -1093,7 +1093,7 @@ Сподели медия… SimpleX адрес Сигурността на SimpleX Chat беше одитирана от Trail of Bits. - SOCKS ПРОКСИ + SOCKS прокси Рестартиране Изключване Рестартирайте приложението, за да създадете нов чат профил. @@ -1152,7 +1152,7 @@ Заглавие (за споделяне с вашия контакт) Тази група вече не съществува. - ПОДКРЕПЕТЕ SIMPLEX CHAT + Подкрепете SimpleX Chat Вашият контакт изпрати файл, който е по-голям от поддържания в момента максимален размер (%1$s). Тази функция все още не се поддържа. Опитайте следващата версия. Докосни за започване на нов чат @@ -1231,7 +1231,7 @@ адреса за получаване е променен Можете да споделите този адрес с вашите контакти, за да им позволите да се свържат с %s. Премахни от любимите - ВИЕ + Вие Вашата база данни Изчаква се получаването на изображението Изчаква се получаването на изображението @@ -1775,7 +1775,7 @@ За да защити вашия IP адрес, поверително рутиране използва вашите SMP сървъри за доставяне на съобщения. Препращане на съобщенията без файловете? Неизвестни сървъри - ФАЙЛОВЕ + Файлове Показване на списъка на чатовете в нов прозорец Системна Тъмна @@ -1800,7 +1800,7 @@ Препращащ сървър: %1$s\nГрешка: %2$s Версията на сървъра е несъвместима с мрежовите настройки. Защити IP адреса - ПОВЕРИТЕЛНО РУТИРАНЕ НА СЪОБЩЕНИЯ + Поверително рутиране на съобщения Приложението ще поиска потвърждение за изтегляния от неизвестни файлови сървъри (с изключение на .onion сървъри или когато SOCKS прокси е активирано). Грешка: %1$s Изтегляне @@ -2038,7 +2038,7 @@ Изтегли %s (%s) Пропусни тази версия Провери за актуализации - БАЗА ДАННИ + База данни Можете да изпращате съобщения до %1$s от архивираните контакти. Достъпен панел Изпращането на съобщения на груповия член не е налично @@ -2501,7 +2501,7 @@ Сподели стар линк Линкът ще бъде кратък и профилът на групата ще бъде споделен чрез него. Обнови групов линк - ЗАЯВКИ ЗА КОНТАКТ ОТ ГРУПИ + Заявки за контакт от групи Членът е изтрит - не може да се приеме заявката заявка за връзка от група %1$s Тази настройка е за текущия профил diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml index 7ab3f5a381..c53247853f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml @@ -126,10 +126,10 @@ S\'eliminaran totes les dades de l\'aplicació. Es crea un perfil de xat buit amb el nom proporcionat i l\'aplicació s\'obre com de costum. La contrasenya de l\'aplicació es substitueix per una contrasenya d\'autodestrucció. - APLICACIÓ - ICONA APLICACIÓ + Aplicació + Icona aplicació Desenfocar els mitjans - TRUCADES + Trucades Android Keystore s\'utilitza per emmagatzemar de manera segura la frase de contrasenya: permet que el servei de notificacions funcioni. Android Keystore s\'utilitzarà per emmagatzemar de manera segura la frase de contrasenya després de reiniciar l\'aplicació o canviar la frase de contrasenya; permetrà rebre notificacions. No es pot accedir a Keystore per desar la contrasenya de la base de dades @@ -383,7 +383,7 @@ Desactivar rebuts? Desactivar rebuts per a grups? Eines per a desenvolupadors - DISPOSITIU + Dispositiu La base de dades es xifra amb una contrasenya aleatòria. Si us plau, canvieu-la abans d\'exportar. Contrasenya de la base de dades Voleu suprimir el perfil? @@ -514,8 +514,8 @@ Canvia el mode l\'autodestrucció Canvia el codi d\'autodestrucció Confirmeu el codi d\'accés - BASE DE DADES DELS XATS - XATS + Base de dades dels xats + Xats Tema del xat Colors del xat Base de dades suprimida @@ -551,7 +551,7 @@ Obre a l\'aplicació mòbil.]]> Error en desar els servidors ICE Error en desar el servidor intermediari - BASE DE DADES DELS XATS + Base de dades dels xats El xat s\'està executant El xat està aturat Error: %s @@ -742,7 +742,7 @@ s\'està connectant… Les condicions s\'acceptaran per als operadors habilitats després de 30 dies. SimpleX no pot funcionar en segon pla. Només rebreu les notificacions quan obriu l\'aplicació. - Trucades de SimpleX chat + Trucades de SimpleX Chat Missatges de xat de SimpleX enviat per llegir @@ -781,7 +781,7 @@ Mostra: Amaga: SimpleX - La seguretat de SimpleX chat ha estat auditada per Trail of Bits. + La seguretat de SimpleX Chat ha estat auditada per Trail of Bits. Parlem a SimpleX Chat El nom no és vàlid! cursiva @@ -794,7 +794,7 @@ inactiu Mode clar Grups d\'incògnit - MEMBRE + Membre Voleu unir-vos al grup? Surt Voleu sortir del xat? @@ -966,8 +966,8 @@ Activar els rebuts? Activar autodestrucció Activar els rebuts per a grups? - FITXERS - EXPERIMENTAL + Fitxers + Experimental Exportar base de dades Xifrar Fitxer: %s @@ -981,7 +981,7 @@ Grup no trobat! es requereix renegociar el xifratge grup esborrat - PER A CONSOLA + Per a consola Arreglar connexió? Correcció no suportada per membre del grup Nom complet del grup: @@ -1055,7 +1055,7 @@ Donar permís(os) per fer trucades Auriculars Xifra fitxers locals - AJUT + Ajut Arxius i mitjans Xifrar base de dades? Base de dades xifrada @@ -1109,7 +1109,7 @@ Missatge El missatge és massa llarg! missatge - MISSATGES I FITXERS + Missatges i fitxers Missatges Estat del missatge Estat del missatge: %s @@ -1266,15 +1266,15 @@ L\'enviament de rebuts està desactivat per a %d contactes L\'enviament de rebuts està habilitat per a %d contactes L\'enviament de rebuts està habilitat per a %d grups - ENVIAR ELS REBUS DE LLIURAMENT A + Enviar els rebus de lliurament a L\'enviament de rebuts està desactivat per a %d grups Reiniciar - SERVIDOR INTERMEDIARI SOCKS + Servidor intermediari SOCKS Imatges de perfil - TEMES + Temes Cua Forma del missatge - EXECUTAR SIMPLEX + Executar SimpleX Usar des d\'ordinador Base de dades de xat Importar base de dades @@ -1899,7 +1899,7 @@ Utilitzar servidor intermediari SOCKS? Si disponibles Les vostres credencials es podrien enviar sense xifrar. - COLORS DE LA INTERFÍCIE + Colors de la interfície Alternativa d\'encaminament de missatges Mode d\'encaminament de missatges Obrir ubicació del fitxer @@ -1949,12 +1949,12 @@ Aquesta configuració és per al vostre perfil actual Es pot canviar a la configuració de contacte i grup. No - CONFIGURACIÓ + Configuració Tou Fort - SUPORT SIMPLEX XAT + Suport SimpleX Chat Connexió a la xarxa - ENCAMINAMENT DE MISSATGES PRIVAT + Encaminament de missatges privat mai No s\'han rebut ni enviats fitxers Reinicieu l\'aplicació per crear un perfil de xat nou. @@ -2024,7 +2024,7 @@ Vista prèvia Rebent via Desa i actualitza el perfil del grup - SERVIDORS + Servidors Missatge de benvinguda El missatge de benvinguda és massa llarg Els teus servidors @@ -2183,7 +2183,7 @@ Servidors SMP Servidors XFTP Altaveu - VÓS + Vós %s segon(s) "Heu estat convidat a un grup" %s connectat @@ -2474,7 +2474,7 @@ Compartir l\'enllaç antic L\'enllaç serà curt i el perfil del grup es compartirà a través d\'ell. Actualitzar l\'enllaç del grup - SOL·LICITUDS DE CONTACTE DE GRUPS + Sol·licituds de contacte de grups Membre eliminat(da); no es pot acceptar la sol·licitud. connexió sol·licitada del grup %1$s Aquesta configuració és per al perfil actual diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml index df4907885c..3a97c594b1 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml @@ -40,11 +40,11 @@ Vytvořit odkaz Smazat odkaz\? Odeslat přímou zprávu - ČLEN + Člen Změnit roli ve skupině\? Připoj nepřímé (%1$s) - SERVERY + Servery Příjímáno přes Vytvoření tajné skupiny Zadejte název skupiny: @@ -259,16 +259,16 @@ Reproduktor zapnut Probíhající hovor Automaticky přijímat obrázky - NASTAVENÍ - NÁPOVĚDA - ZAŘÍZENÍ - KONVERZACE + Nastavení + Nápověda + Zařízení + Konverzace Experimentální funkce - SOCKS PROXY - IKONA APLIKACE - TÉMATA - ZPRÁVY A SOUBORY - VOLÁNÍ + SOCKS proxy + Ikona aplikace + Témata + Zprávy a soubory + Volání Export databáze Import databáze Smazat databázi @@ -700,15 +700,15 @@ \n1. Zprávy vypršely v odesílajícím klientovi po 2 dnech nebo na serveru po 30 dnech. \n2. Dešifrování zprávy se nezdařilo, protože vy nebo váš kontakt jste použili starou zálohu databáze. \n3. Spojení je kompromitováno. - VY - PODPOŘIT SIMPLEX CHAT + Vy + Podpořit SimpleX Chat Nástroje pro vývojáře Inkognito mód Vaše chat databáze - SPUSTIT CHAT + Spustit chat Chat je spuštěn Chat je zastaven - DATABÁZE CHATU + Databáze chatu přístupová fráze k databázi Archiv nové databáze Archiv staré databáze @@ -836,7 +836,7 @@ Chyba při vytváření odkazu skupiny Chyba při odstraňování odkazu skupiny Předvolby skupiny mohou měnit pouze vlastníci skupiny. - PRO KONSOLE + Pro konsole Místní název ID databáze Odstranit člena @@ -991,7 +991,7 @@ Zvýšit a otevřít chat Skrýt: Zobrazit možnosti vývojáře - POKUSNÝ + Pokusný Obrázek bude přijat, až kontakt dokončí jeho nahrání. Zobrazit: ID databáze a možnost Izolace přenosu. @@ -1164,7 +1164,7 @@ Když někdo požádá o připojení, můžete žádost přijmout nebo odmítnout. Uživatelské příručce.]]> Adresa SimpleX - BARVY MOTIVU + Barvy motivu Přizpůsobit motiv Aktualizace profilu bude zaslána vašim kontaktům. Sdílet adresu s kontakty? @@ -1301,7 +1301,7 @@ V odpovědi na Žádná historie Časový limit protokolu na KB - ZASLAT POTVRZENÍ O DORUČENÍ NA + Zaslat potvrzení o doručení na Druhé zaškrtnutí jsme přehlédli! ✅ Přijímací adresa bude změněna na jiný server. Změna adresy bude dokončena po připojení odesílatele. Vybrat soubor @@ -1803,8 +1803,8 @@ \nProsím sdělte jakékoli další problémy vývojářům. Ne NEposílejte zprávy přímo, i když váš nebo cílový server nepodporuje soukromé směrování. - SOUBORY - SOUKROMÉ SMĚROVÁNÍ ZPRÁV + Soubory + Soukromé směrování zpráv Téma profilu Přijata odpověď Obnovit barvu @@ -1878,7 +1878,7 @@ Připomenout později Zkontrolovat aktualizace Vypnuto - CHAT DATABÁZE + Chat databáze vypnut info fronty serveru: %1$s\n\nposlední obdržená zpráva: %2$s Uložit a připojit znovu @@ -2362,7 +2362,7 @@ Členové budou odstraněny ze skupiny - toto nelze zvrátit! Odebrat členy? Členové budou odstraněny z chatu - toto nelze zvrátit! - Použitím SimpleX chatu souhlasíte že:\n- ve veřejných skupinách budete zasílat pouze legální obsah.\n- budete respektovat ostatní uživatele – žádný spam. + Použitím SimpleX Chatu souhlasíte že:\n- ve veřejných skupinách budete zasílat pouze legální obsah.\n- budete respektovat ostatní uživatele – žádný spam. Přijmout Zásady ochrany soukromí a podmínky používání. Soukromé konverzace, skupiny a kontakty nejsou přístupné provozovatelům serverů. @@ -2430,7 +2430,7 @@ Otevřít novou skupinu Připojit Připojte se rychleji! 🚀 - POŽADAVKY NA PŘIPOJENÍ ZE SKUPIN + Požadavky na připojení ze skupin kontakt by měl přijmout… Vytvořit vaši adresu Popis příliš dlouhý diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml index 38507cc228..9487b85cb3 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml @@ -157,7 +157,7 @@ En anden grund Svaropkald Alle kan være vært for servere. - APP + App App løber altid i baggrunden App Build: %s App kan kun modtage meddelelser, når den kører, ingen baggrundstjeneste startes diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml index 8700ade74e..ebe38b2dc7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -549,26 +549,26 @@ Linkvorschau senden App-Datensicherung - MEINE DATEN - EINSTELLUNGEN - HILFE - UNTERSTÜTZUNG VON SIMPLEX CHAT - GERÄT - CHATS + Meine Daten + Einstellungen + Hilfe + Unterstützung von SimpleX Chat + Gerät + Chats Entwicklertools Experimentelle Funktionen - SOCKS-PROXY - APP-ICON - DESIGN - NACHRICHTEN und DATEIEN - CALLS + SOCKS-Proxy + App-Icon + Design + Nachrichten und Dateien + Calls Inkognito-Modus Chat-Datenbank - CHAT STARTEN + Chat starten Der Chat läuft Der Chat ist beendet - CHAT-DATENBANK + Chat-Datenbank Datenbank-Passwort Datenbank exportieren Datenbank importieren @@ -747,7 +747,7 @@ Sie versuchen, einen Kontakt, mit dem Sie ein Inkognito-Profil geteilt haben, in die Gruppe einzuladen, in der Sie Ihr Hauptprofil verwenden. Mitglieder einladen - %1$s MITGLIEDER + %1$s Mitglieder Sie: %1$s Gruppe löschen Gruppe löschen? @@ -765,7 +765,7 @@ Fehler beim Löschen des Gruppen-Links Gruppen-Präferenzen können nur von Gruppen-Eigentümern geändert werden. - FÜR KONSOLE + Für Konsole Lokaler Name Datenbank-ID @@ -773,7 +773,7 @@ Direktnachricht senden Das Mitglied wird aus der Gruppe entfernt. Dies kann nicht rückgängig gemacht werden! Entfernen - MITGLIED + Mitglied Rolle Rolle ändern Ändern @@ -788,7 +788,7 @@ direkt indirekt (%1$s) - SERVER + Server Empfangen über Senden über Netzwerkstatus @@ -1065,7 +1065,7 @@ Datenbank-Aktualisierungen bestätigen Anzeigen: Entwickleroptionen anzeigen - EXPERIMENTELL + Experimentell Datenbank-Aktualisierung Unterschiedlicher Migrationsstand in der App/Datenbank: %s / %s Datenbank herabstufen und den Chat öffnen @@ -1189,7 +1189,7 @@ Wenn Personen eine Verbindung anfordern, können Sie diese annehmen oder ablehnen. Sie werden Ihre damit verbundenen Kontakte nicht verlieren, wenn Sie diese Adresse später löschen. Design anpassen - INTERFACE-FARBEN + Interface-Farben Fügen Sie die Adresse Ihrem Profil hinzu, damit Ihre SimpleX-Kontakte sie mit anderen Personen teilen können. Es wird eine Profilaktualisierung an Ihre SimpleX-Kontakte gesendet. Alle Ihre Kontakte bleiben verbunden. Es wird eine Profilaktualisierung an Ihre Kontakte gesendet. Erstellen Sie eine Adresse, damit sich Personen mit Ihnen verbinden können. @@ -1309,7 +1309,7 @@ Während des Imports sind nicht schwerwiegende Fehler aufgetreten: Herunterfahren\? Bis zum Neustart der App erhalten Sie keine Benachrichtigungen mehr - APP + App Neustart Herunterfahren Fehler beim Beenden des Adresswechsels @@ -1372,7 +1372,7 @@ Bestätigungen aktivieren\? Das Senden von Bestätigungen an %d Kontakte ist aktiviert Für alle aktivieren - EMPFANGSBESTÄTIGUNGEN SENDEN AN + Empfangsbestätigungen senden an Deaktivieren (vorgenommene Einstellungen bleiben erhalten) Bestätigungen senden Ihre Verbindungen beibehalten @@ -1851,13 +1851,13 @@ Herabstufung erlauben Sie nutzen immer privates Routing. Nachrichten werden nicht direkt versendet, selbst wenn Ihr oder der Ziel-Server kein privates Routing unterstützt. - PRIVATES NACHRICHTEN-ROUTING + Privates Nachrichten-Routing Nachrichten werden direkt versendet, wenn die IP-Adresse geschützt ist, und Ihr oder der Ziel-Server kein privates Routing unterstützt. Nachrichten werden direkt versendet, wenn Ihr oder der Ziel-Server kein privates Routing unterstützt. Zum Schutz Ihrer IP-Adresse, wird für die Nachrichten-Auslieferung privates Routing über Ihre konfigurierten SMP-Server genutzt. Sie nutzen privates Routing mit unbekannten Servern, wenn Ihre IP-Adresse nicht geschützt ist. IP-Adresse schützen - DATEIEN + Dateien Die App wird bei unbekannten Datei-Servern nach einer Download-Bestätigung fragen (außer bei .onion oder wenn ein SOCKS-Proxy aktiviert ist). Unbekannte Server! Ohne Tor- oder VPN-Nutzung wird Ihre IP-Adresse für Datei-Server sichtbar sein. @@ -2126,7 +2126,7 @@ Neue Nachricht Bitte überprüfen Sie, ob der SimpleX-Link korrekt ist. Ungültiger Link - CHAT-DATENBANK + Chat-Datenbank Fehler beim Wechseln des Profils Die Nachrichten werden gelöscht. Dies kann nicht rückgängig gemacht werden! Profil teilen @@ -2583,7 +2583,7 @@ Alten Link teilen Der Link wird gekürzt sein, und das Gruppen-Profil wird über den Link geteilt. Gruppen-Link aktualisieren - KONTAKTANFRAGEN VON GRUPPEN + Kontaktanfragen von Gruppen Mitglied ist gelöscht - Anfrage kann nicht angenommen werden Angefragte Verbindung von Gruppe %1$s Diese Einstellung gilt für Ihr aktuelles Profil @@ -2626,7 +2626,7 @@ Sprachnachrichten suchen Videos Sprachnachrichten - VERBINDUNG FEHLGESCHLAGEN + Verbindung fehlgeschlagen Fehlgeschlagen Kanäle, welche Sie erstellt haben oder denen Sie beigetreten sind, werden dauerhaft deaktiviert. %1$d/%2$d Relais aktiv @@ -2695,12 +2695,12 @@ Es sind nicht alle Relais verbunden Kanal öffnen Neuen Kanal öffnen - EIGENTÜMER + Eigentümer Eigentümer Voreingestellte Relais-Adresse Voreingestellter Relais-Name Relais - RELAIS + Relais Relais-Adresse Relais-Adresse Relais-Verbindung fehlgeschlagen @@ -2711,7 +2711,7 @@ Der Server erfordert eine Autorisierung, um eine Verbindung zum Relais herzustellen. Bitte Passwort überprüfen. Serverwarnung Relais-Adresse teilen - ABONNENT + Abonnent Abonnenten Abonnenten verbinden sich über den Relais‑Link mit dem Kanal.\nDie Relais-Adresse wurde zur Einrichtung dieses Relais für diesen Kanal verwendet. Abonnent wird aus dem Kanal entfernt. Dies kann nicht rückgängig gemacht werden! diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml index 47cfd90ad6..390913c5f7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml @@ -86,7 +86,7 @@ Ο ΙCE διακομιστής σου Κωδικός πρόσβασης εφαρμογής Αίτημα σύνδεσης θα σταλεί σε αυτό το μέλος της ομάδας. - ΟΙΚΟΝΑ ΕΦΑΡΜΟΓΗΣ + Εικόνα εφαρμογής Εφαρμογή Οι ρυθμίσεις σου Έκδοση εφαρμογής: v%s @@ -105,7 +105,7 @@ Άλλαξε \nΔιαθέσιμο στην έκδοση 5.1 Τέλος κλήσης - ΚΛΗΣΕΙΣ + Κλήσεις Αυτόματη αποδοχή %1$d αποτυχία κρυπτογράφησης μηνύματος αλλαγή διεύθυνσης για %s… @@ -252,7 +252,7 @@ Eνεργοποίηση ήχου Κακό μήνυμα hash Θάμπωση των μέσων - ΒΑΣΗ ΔΕΔΟΜΕΝΩΝ ΣΥΝΟΜΙΛΙΑΣ + Βάση δεδομένων συνομιλίας Το Android Keystore χρησιμοποιείται για την ασφαλή αποθήκευση της φράσης πρόσβασης - επιτρέπει την υπηρεσία ειδοποιήσεων να λειτουργεί. αποκλεισμένος Αποκλεισμένος από τον διαχειριστή @@ -288,7 +288,7 @@ Αρχειοθετημένες επαφές Ακύρωση μεταφοράς Χρώματα συνομιλίας - ΒΑΣΗ ΔΕΔΟΜΕΝΩΝ ΣΥΝΟΜΙΛΙΑΣ + Βάση δεδομένων συνομιλίας Η συνομιλία εκτελείται Παρακαλώ σημείωσε: ΔΕΝ θα μπορείς να ανακτήσεις ή να αλλάξεις τη φράση πρόσβασης εάν τη χάσεις.]]> Αποκλεισμός για όλους @@ -377,7 +377,7 @@ κακό αναγνωριστικό μηνύματος Απάντηση κλήσης Κακό αναγνωριστικό μηνύματος - ΣΥΝΟΜΙΛΙΕΣ + Συνομιλίες Η βάση δεδεδομένων της συνομιλίας εισάχθηκε συμφωνία κρυπτογράφησης για %s… Να επιτραπούν οι κλήσεις; @@ -600,7 +600,7 @@ Κρυμμένη επαφή: Η επαφή διαγράφηκε. η επαφή δεν είναι έτοιμη - ΑΙΤΗΣΕΙΣ ΕΠΑΦΩΝ ΑΠΟ ΟΜΑΔΕΣ + Αιτήσεις επαφών από ομάδες Επαφές η επαφή πρέπει να αποδεχτεί… Η επαφή θα διαγραφεί – αυτή η ενέργεια δεν μπορεί να αναιρεθεί! @@ -759,7 +759,7 @@ Λεπτομέρειες Επιλογές προγραμματιστή Εργαλεία προγραμματιστή - ΣΥΣΚΕΥΗ + Συσκευή Η επαλήθευση συσκευής είναι απενεργοποιημένη. Απενεργοποιείται το SimpleX Lock. Η επαλήθευση συσκευής δεν είναι ενεργοποιημένη. Μπορείς να ενεργοποιήσεις το SimpleX Lock από τις Ρυθμίσεις, αφού πρώτα ενεργοποιήσεις την επαλήθευση συσκευής. Συσκευές @@ -927,7 +927,7 @@ Για όλους τους διαχειριστές για καλύτερη ιδιωτικότητα μεταδεδομένων Για το προφίλ συνομιλίας %s: - ΓΙΑ ΚΟΝΣΟΛΑ + Για κονσόλα Για όλους Για παράδειγμα, αν η επαφή σου λαμβάνει μηνύματα μέσω κάποιου SimpleX Chat διακομιιστή, η εφαρμογή σου θα τα παραδίδει μέσω ενός Flux διακομιστή. Για μένα @@ -986,7 +986,7 @@ Τερματισμός κλήσης Ακουστικά βοήθεια - ΒΟΗΘΕΙΑ + Βοήθεια Βοήθησε τους διαχειριστές να διαχειρίζονται τις ομάδες τους. Γεια σου!\nΣυνδέσου μαζί μου μέσω SimpleX Chat: %s Κρυφό @@ -1069,7 +1069,7 @@ Άμεσες ειδοποιήσεις Άμεσες ειδοποιήσεις! Οι άμεσες ειδοποιήσεις είναι απενεργοποιημένες! - ΧΡΩΜΑΤΑ ΔΙΕΠΑΦΗΣ + Χρώματα διεπαφής Εσωτερικό σφάλμα μη έγκυρη συνομιλία Μη έγκυρος σύνδεσμος @@ -1175,7 +1175,7 @@ Διακομιστές πολυμέσων & αρχείων Μεσαίο μέλος - ΜΕΛΟΣ + Μέλος Μέλος %1$s το μέλος %1$s άλλαξε σε %2$s Εγγραφή μέλους @@ -1219,7 +1219,7 @@ Εναλλακτική δρομολόγηση μηνυμάτων Λειτουργία δρομολόγησης μηνυμάτων Μηνύματα - ΜΗΝΥΜΑΤΑ ΚΑΙ ΑΡΧΕΙΑ + Μηνύματα και αρχεία Διακομιστές μηνυμάτων Θα εμφανιστούν τα μηνύματα από το %s! Θα εμφανιστούν τα μηνύματα από αυτά τα μέλη! @@ -1332,7 +1332,7 @@ Η εικόνα δεν μπορεί να αποκωδικοποιηθεί. Δοκίμασε μια άλλη εικόνα ή επικοινώνησε με τους προγραμματιστές. Ο σύνδεσμος θα είναι σύντομος και το προφίλ της ομάδας θα κοινοποιηθεί μέσω αυτού. Θέμα - ΘΕΜΑΤΑ + Θέματα Τα μηνύματα θα διαγραφούν για όλα τα μέλη. Τα μηνύματα θα επισημαίνονται ως ελεγχόμενα για όλα τα μέλη. Το μήνυμα θα διαγραφεί για όλα τα μέλη. @@ -1582,7 +1582,7 @@ Έξοδος χωρίς αποθήκευση Επέκτεινε Επέκταση επιλογής ρόλου - ΠΕΙΡΑΜΑΤΙΚΟ + Πειραματικό Πειραματικά χαρακτηριστικά έληξε Εξαγωγή της βάσης δεδομένων @@ -1604,7 +1604,7 @@ Το αρχείο δεν βρέθηκε - πιθανότατα το αρχείο διαγράφηκε ή ακυρώθηκε. Αρχείο: %s Αρχεία - ΑΡΧΕΙΑ + Αρχεία Αρχεία και πολυμέσα Απαγορεύονται τα αρχεία και τα πολυμέσα. Τα αρχεία και τα πολυμέσα, απαγορεύονται σε αυτήν τη συνομιλία. @@ -1863,7 +1863,7 @@ Ιδιωτικά ονόματα αρχείων Ιδιωτικά ονόματα αρχείων πολυμέσων. Δρομολόγηση ιδιωτικών μηνυμάτων 🚀 - ΔΡΟΜΟΛΟΓΗΣΗ ΙΔΙΩΤΙΚΩΝ ΜΗΝΥΜΑΤΩΝ + Δρομολόγηση ιδιωτικών μηνυμάτων Ιδιωτικές σημειώσεις Ιδιωτικές σημειώσεις Ιδιωτικές ειδοποιήσεις @@ -2032,7 +2032,7 @@ Ανάκληση αρχείου Ανάκληση αρχείου; Ρόλος - ΕΚΚΙΝΗΣΗ ΣΥΝΟΜΙΛΙΑΣ + Εκκίνηση συνομιλίας Εκτελείται όταν η εφαρμογή είναι ανοιχτή Ασφαλής λήψη αρχείων Ασφαλέστερες ομάδες @@ -2098,7 +2098,7 @@ Απέστειλε Στείλε ένα ζωντανό μήνυμα - θα ενημερώνεται για τον παραλήπτη ή τους παραλήπτες καθώς το πληκτρολογείς. Αποστολή αιτήματος επαφής; - ΑΠΟΣΤΟΛΗ ΑΝΑΦΟΡΩΝ ΠΑΡΑΔΟΣΗΣ ΣΕ + Αποστολή αναφορών παράδοσης σε Αποστολή άμεσου μηνύματος Στείλε άμεσο μήνυμα για να συνδεθείς Αποστολή μηνύματος που εξαφανίζεται @@ -2153,7 +2153,7 @@ πληροφορίες ουράς διακομιστή: %1$s\n\nτελευταίο ληφθέν μήνυμα: %2$s Ο διακομιστής απαιτεί εξουσιοδότηση για τη δημιουργία ουρών, έλεγξε τον κωδικό. Ο διακομιστής απαιτεί εξουσιοδότηση για ανέβασμα αρχείων, έλεγξε τον κωδικό. - ΔΙΑΚΟΜΙΣΤΕΣ + Διακομιστές Πληροφορίες διακομιστών Θα γίνει επαναφορά στα στατιστικά στοιχεία των διακομιστών - αυτή η ενέργεια δεν μπορεί να αναιρεθεί! Η δοκιμή του διακομιστή απέτυχε! @@ -2179,7 +2179,7 @@ Όρισε το εμφανιζόμενο μήνυμα για τα νέα μέλη! Ρυθμίσεις Ρυθμίσεις - ΡΥΘΜΙΣΕΙΣ + Ρυθμίσεις Όρισε τη φράση πρόσβασης της βάσης δεδομένων Διαμόρφωση εικόνων προφίλ Διαμοίρασε @@ -2260,7 +2260,7 @@ Διακομιστής SMP Διακομιστές SMP Διακομιστής μεσολάβησης SOCKS - ΔΙΑΚΟΜΙΣΤΗΣ ΜΕΣΟΛΑΒΗΣΗΣ SOCKS + Διακομιστής μεσολάβησης SOCKS Ρυθμίσεις διακομιστή μεσολάβησης SOCKS Απαλό Κάποιο/α αρχείο/α δεν εξήχθησαν @@ -2309,7 +2309,7 @@ Η εγγραφή αγνοήθηκε %s ανεβασμένα Υποστήριξη bluetooth και άλλων βελτιώσεων. - ΥΠΟΣΤΗΡΙΞΗ SIMPLEX CHAT + Υποστήριξη SimpleX Chat Ενάλλαξε Εναλλαγή ήχου και βίντεο κατά τη διάρκεια της κλήσης. Αλλαγή προφίλ συνομιλίας για προσκλήσεις 1-χρήσης. @@ -2414,7 +2414,7 @@ Ναι Ναι εσύ - ΕΣΥ + Εσύ εσύ: %1$s Αποδέχθηκες τη σύνδεση αποδέχθηκες αυτό το μέλος diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml index 7088c54d9b..6b02074b7d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -20,7 +20,7 @@ Permites que tus contactos envien mensajes de voz. siempre La aplicación sólo puede recibir notificaciones cuando se está ejecutando. No se iniciará ningún servicio en segundo plano. - ICONO DE LA APLICACIÓN + Icono de la aplicación La optimización de la batería está activa, desactivando el servicio en segundo plano y las solicitudes periódicas de nuevos mensajes. Puedes volver a activarlos en Configuración. El servicio está siempre en funcionamiento en segundo plano. Las notificaciones se muestran en cuanto haya mensajes nuevos. Se puede desactivar en la configuración. En ese caso las notificaciones se seguirán mostrando mientras la aplicación esté en funcionamiento.]]> @@ -200,7 +200,7 @@ Eliminar servidor Introduce tu nombre: conectado - DISPOSITIVO + Dispositivo Contraseña base de datos Eliminar base de datos Eliminar todos los archivos @@ -243,7 +243,7 @@ Core versión: v%s Eliminar imagen Editar imagen - CHATS + Chats Cambiar Se realizan comprobaciones de mensajes nuevos periódicas de hasta un minuto de duración cada 10 minutos Limpiar @@ -274,7 +274,7 @@ Preferencias generales cancelado %s SimpleX está parado - LLAMADAS + Llamadas SimpleX está en ejecución está cambiando de servidor… habla con los desarrolladores @@ -295,7 +295,7 @@ Llamadas en la ventana de bloqueo ¡No se pueden invitar contactos! Consola de Chat - BASE DE DATOS DE SIMPLEX + Base de datos de SimpleX Base de datos eliminada Base de datos importada Comprueba la dirección del servidor e inténtalo de nuevo. @@ -393,15 +393,15 @@ Archivo no encontrado Guía de uso finalizado - AYUDA + Ayuda Exportar base de datos Error al exportar base de datos Error al iniciar Chat se ha unido mediante tu enlace de grupo Error al actualizar enlace de grupo - PARA CONSOLA + Para consola Error al cambiar rol - SERVIDORES + Servidores Nombre del grupo: Preferencias del grupo Los miembros pueden enviar mensajes directos. @@ -455,7 +455,7 @@ \n2. El descifrado ha fallado porque tu o tu contacto estáis usando una copia de seguridad antigua de la base de datos. \n3. La conexión ha sido comprometida. Contacto y texto - MIEMBRO + Miembro nunca No se usarán hosts .onion Vista previa de notificaciones @@ -524,7 +524,7 @@ videollamada (sin cifrar) sin cifrar Importar base de datos - MENSAJES Y ARCHIVOS + Mensajes y archivos ¿Importar base de datos\? Sin archivos recibidos o enviados Mensajes @@ -661,7 +661,7 @@ No se permiten mensajes temporales. Sólo tú puedes enviar mensajes de voz. Sólo tu contacto puede enviar mensajes de voz. - EJECUTAR SIMPLEX + Ejecutar SimpleX Reinicia la aplicación para poder usar la base de datos importada. Introduce la contraseña actual correcta. recepción no permitida @@ -703,8 +703,8 @@ Aislamiento de transporte tachado Abrir SimpleX - PROXY SOCKS - TEMAS + Proxy SOCKS + Temas Parar Esta acción es irreversible. Tu perfil, contactos, mensajes y archivos se perderán. Omitir invitación a miembros @@ -786,7 +786,7 @@ El perfil sólo se comparte con tus contactos. inicializando… Mensajes omitidos - CONFIGURACIÓN + Configuración ¿Parar SimpleX? %s segundo(s) Pulsa para unirte @@ -794,7 +794,7 @@ Timeout de la conexión TCP Tema Establece preferencias de grupo - SOPORTE SIMPLEX CHAT + Soporte SimpleX Chat Escribe la contraseña para exportar Actualizar Actualizar contraseña base de datos @@ -899,7 +899,7 @@ Llamadas Servidores ICE Privacidad - MIS DATOS + Mis datos Base de datos Puedes iniciar el chat en Configuración / Base de datos o reiniciando la aplicación. Has enviado una invitación de grupo @@ -990,7 +990,7 @@ Versión de base de datos incompatible Confirmar actualizaciones de la bases de datos la versión de la base de datos es más reciente que la aplicación, pero no hay migración hacia versión anterior para: %s - EXPERIMENTAL + Experimental IDs de la base de datos y opciones de aislamiento de transporte. El archivo se recibirá cuando el contacto termine de subirlo. La imagen se recibirá cuando el contacto termine de subirla. @@ -1142,7 +1142,7 @@ Mensaje enviado Dejar de compartir ¿Dejar de compartir la dirección\? - COLORES DE LA INTERFAZ + Colores de la interfaz Puedes crearla más tarde ¿Compartir la dirección con los contactos SimpleX? Compartir con contactos SimpleX @@ -1229,7 +1229,7 @@ sin texto Han ocurrido algunos errores no críticos durante la importación: ¿Salir de SimpleX? - APLICACIÓN + Aplicación Reiniciar Salir Las notificaciones dejarán de funcionar hasta que vuelvas a iniciar la aplicación @@ -1291,7 +1291,7 @@ Activar para todos Activar (conservar anulaciones) Desactivar para todos - ENVIAR CONFIRMACIONES DE ENTREGA A + Enviar confirmaciones de entrega a ¡Las confirmaciones de entrega están desactivadas! No activar ¡Error al activar confirmaciones de entrega! @@ -1776,7 +1776,7 @@ Usar siempre enrutamiento privado. Aviso de entrega de mensaje Nunca - ENRUTAMIENTO PRIVADO DE MENSAJES + Enrutamiento privado de mensajes La dirección del servidor es incompatible con la configuración de la red. Con IP desprotegida Clave incorrecta o conexión desconocida - probablemente esta conexión fue eliminada @@ -1787,7 +1787,7 @@ \n%1$s. Proteger dirección IP Sin Tor o VPN, tu dirección IP será visible para los servidores de archivos. - ARCHIVOS + Archivos La aplicación pedirá que confirmes las descargas desde servidores de archivos desconocidos (excepto si son .onion o cuando esté habilitado el proxy SOCKS). Colores del chat Tema del chat @@ -2053,7 +2053,7 @@ Por favor, comprueba que el enlace SimpleX es correcto. %1$d archivo(s) se está(n) descargando todavía. %1$d otro(s) error(es) de archivo. - BASE DE DATOS + Base de datos Error en reenvío de mensajes ¿Reenviar %1$s mensaje(s)? Reenviar mensajes… @@ -2508,7 +2508,7 @@ Compartir enlace antiguo El enlace será corto y el perfil del grupo se compartirá mediante el enlace. Actualizar enlace de grupo - SOLICITUDES DE CONTACTO EN GRUPOS + Solicitudes de contacto en grupos conexión solicitada desde el grupo %1$s Esta configuración se aplica al perfil actual Miembro eliminado, no puede aceptar solicitudes @@ -2597,7 +2597,7 @@ perfil del canal actualizado El canal será eliminado para todos los suscriptores. ¡No puede deshacerse! El canal será eliminado para tí. ¡No puede deshacerse! - CONEXIÓN FALLIDA + Conexión fallida Crear canal público Crear canal público Crear canal público (BETA) @@ -2631,12 +2631,12 @@ Hay servidores no conectados Abrir canal Abrir canal nuevo - PROPIETARIO + Propietario Propietarios Direcciones predefinidas Nombres predefinidos servidor - SERVIDOR + Servidor Dirección servidor Dirección del servidor Enlace servidor @@ -2647,7 +2647,7 @@ El servidor requiere autorización para conectar con el servidor, comprueba la contraseña. Alerta del servidor Compartir dirección del servidor - SUSCRIPTOR + Suscriptor Suscriptores Los suscriptores usan el enlace del servidor para conectarse a los canales.\nLa dirección del servidor se usó para establecer el servidor para el canal. El suscriptor será eliminado del canal. ¡No puede deshacerse! diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml index 3f7d4ff025..a3da005ac6 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml @@ -781,7 +781,7 @@ خاموش ارسال رسید برای %d گروه فعال است ارسال رسید برای %d گروه غیرفعال است - حمایت از SIMPLEX CHAT + حمایت از SimpleX Chat پروکسی SOCKS استفاده از کامپیوتر آرشیو پایگاه داده جدید diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml index 24634192ec..4753cdb9cf 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml @@ -39,9 +39,9 @@ Salli lähetettyjen viestien peruuttamaton poistaminen. Salli katoavien viestien lähettäminen. Kaikki tiedot poistetaan, kun se syötetään. - SOVELLUKSEN KUVAKE + Sovelluksen kuvake Sovelluksen tietojen varmuuskopiointi - PUHELUT + Puhelut Pyydettiin videon vastaanottamista Tunnistaudu Tunnistautuminen ei ole käytettävissä @@ -54,7 +54,7 @@ Tietokannan salauksen tunnuslause päivitetään ja tallennetaan Keystoreen. Poista keskusteluprofiili käyttäjälle poistettu - LAITE + Laite %dh Yhteysvirhe Tiedostoa ei voi vastaanottaa @@ -153,7 +153,7 @@ Vaihda itsetuhotilaa Vaihda itsetuhoutuva pääsykoodi Sovelluksen salasana korvataan itsetuhoutuvalla pääsykoodilla. - KESKUSTELUJEN TIETOKANTA + Keskustelujen tietokanta Kehittäjän työkalut Ei pääsyä Keystoreen tietokannan salasanan tallentamiseksi Tietokannan tunnus: %d @@ -308,7 +308,7 @@ Hyväksy kuvat automaattisesti Virheellinen viestin tunniste Vaihda lukitustilaa - KESKUSTELUT + Keskustelut Kaikki ryhmän jäsenet pysyvät yhteydessä. Kontaktia ei voi kutsua! valmis @@ -453,7 +453,7 @@ Virhe käynnistettäessä keskustelua Virhe keskustelun lopettamisessa Virhe asetuksen muuttamisessa - KOKEELLINEN + Kokeellinen Piilota: Kuinka se toimii e2e-salattu videopuhelu @@ -526,7 +526,7 @@ Kuva tallennettu galleriaan Kuva vastaanotetaan, kun kontaktisi on ladannut sen. Tiedosto - APUA + Apua Virhe tietokannan salauksessa Alenna ja avaa chat Ei-aktiivinen ryhmä @@ -576,7 +576,7 @@ Immuuni roskapostille ja väärinkäytöksille Virhe vietäessä keskustelujen tietokantaa Piilota - KONSOLIIN + Konsoliin poistettu ryhmä ryhmäprofiili päivitetty Vanhentunut kutsu! @@ -655,7 +655,7 @@ PING-väli Profiili- ja palvelinyhteydet Aseta ryhmän asetukset - PALVELIMET + Palvelimet Tallenna ja ilmoita kontaktille Tallenna ja ilmoita kontakteille Ohitetut viestit @@ -715,7 +715,7 @@ Itsetuho Itsetuhoutuva pääsykoodi vaihdettu! Itsetuhoutuva pääsykoodi käytössä! - SUKAT VÄLITYSPALVELIN + SOCKS välityspalvelin Uusi tietokanta-arkisto Ei vastaanotettuja tai lähetettyjä tiedostoja Poistetaanko tunnuslause Keystoresta\? @@ -752,7 +752,7 @@ Tallenna ja ilmoita ryhmän jäsenille Lopeta Pysäytä keskustelut viedäksesi, tuodaksesi tai poistaaksesi keskustelujen tietokannan. Et voi vastaanottaa ja lähettää viestejä, kun keskustelut on pysäytetty. - SUORITA CHAT + Suorita chat Aseta tunnuslause vientiä varten Anna oikea nykyinen tunnuslause. Palauta @@ -836,7 +836,7 @@ Avaa SimpleX Chat hyväksyäksesi puhelun ei e2e-salausta TUE SIMPLEX CHATia - VIESTIT JA TIEDOSTOT + Viestit ja tiedostot Jaa osoite Vain paikalliset profiilitiedot Vastaanotettu viesti @@ -867,7 +867,7 @@ OK ei tietoja Kertakutsulinkki - ASETUKSET + Asetukset Uusi tunnuslause… Palauta tietokannan varmuuskopio Tietokannan tunnuslauseen muuttamista ei suoritettu loppuun. @@ -967,7 +967,7 @@ Päivitetty: %s Lähetetty klo Lähetetty: %s - JÄSEN + Jäsen Moderoitu klo: %s %s (nykyinen) Vaihda @@ -1061,7 +1061,7 @@ Hallitset keskustelujasi! Nykyinen profiilisi Profiilisi tallennetaan laitteeseesi ja jaetaan vain kontaktiesi kanssa. SimpleX -palvelimet eivät näe profiiliasi. - TEEMAT + Teemat Tämä asetus koskee nykyisen keskusteluprofiilisi viestejä Tätä toimintoa ei voi kumota - kaikki vastaanotetut ja lähetetyt tiedostot ja media poistetaan. Matalan resoluution kuvat säilyvät. Tuntematon virhe @@ -1116,7 +1116,7 @@ SMP-palvelimesi XFTP-palvelimesi Käytä SimpleX Chat palvelimia\? - KÄYTTÖLIITTYMÄN VÄRIT + Käyttöliittymän värit Päivitä kuljetuksen eristystila\? Voit luoda sen myöhemmin Voit paljastaa piilotetun profiilisi kirjoittamalla koko salasanan Keskusteluprofiilit-sivun hakukenttään. @@ -1151,7 +1151,7 @@ Video lähetetty Odottaa videota Tämä merkkijono ei ole yhteyslinkki! - SINÄ + Sinä Päivitä ja avaa keskustelu Aikavyöhykkeen suojaamiseksi kuva-/äänitiedostot käyttävät UTC:tä. Videot ja tiedostot 1 Gt asti @@ -1228,7 +1228,7 @@ viikkoa Ilmoitukset lakkaavat toimimasta, kunnes käynnistät sovelluksen uudelleen Sulje - SOVELLUS + Sovellus Käynnistä uudelleen Sulje\? Pois @@ -1305,7 +1305,7 @@ Salli kuittaukset\? Kuittauksien lähettäminen on pois käytöstä %d kontakteilta Kuittauksien lähettäminen on käytössä %d kontakteille - LÄHETÄ TOIMITUSKUITTAUKSET VASTAANOTTAJALLE + Lähetä toimituskuittaukset vastaanottajalle turvakoodi on muuttunut hyväksyy salausta… salauksen uudelleenneuvottelu sallittu %s:lle @@ -1465,7 +1465,7 @@ Kamera Avaa asetukset Suojaa IP-osoite - TIEDOSTOT + Tiedostot Profiilikuvat tuntematon Poista jäsen diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml index d95f8ad500..571823140a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml @@ -453,7 +453,7 @@ Consomme davantage de batterie L\'app fonctionne toujours en arrière-plan - les notifications s\'affichent instantanément.]]> %1$d message(s) manqué(s) ID du message incorrect - PARAMÈTRES + Paramètres Cela peut arriver quand : \n1. Les messages ont expiré dans le client expéditeur après 2 jours ou sur le serveur après 30 jours. \n2. Le déchiffrement du message a échoué, car vous ou votre contact avez utilisé une ancienne sauvegarde de base de données. @@ -487,12 +487,12 @@ Appel en cours Appel terminé Votre vie privée - APPAREIL - DISCUSSIONS + Appareil + Discussions Outils du développeur - ICONE DE L\'APP + Icone de l\'app Votre base de données de chat - LANCER LE CHAT + Lancer le chat Arrêter le chat \? Redémarrez l\'application pour utiliser la base de données de chat importée. 1 jour @@ -522,10 +522,10 @@ Appels audio et vidéo chiffré de bout en bout Fonctionnalités expérimentales - SOCKS PROXY - THEMES - MESSAGES ET FICHIERS - APPELS + SOCKS proxy + Themes + Messages et fichiers + Appels Importer la base de données Nouvelle archive de base de données Archives de l\'ancienne base de données @@ -601,13 +601,13 @@ Protéger l\'écran de l\'app Acceptation automatique des images Sauvegarde des données de l\'app - VOUS - AIDE - SOUTENEZ SIMPLEX CHAT + Vous + Aide + Soutenez SimpleX Chat Mode Incognito Le chat est en cours d\'exécution Le chat est arrêté - BASE DE DONNÉES DU CHAT + Base de données du chat Phrase secrète de la base de données Exporter la base de données Arrêter @@ -694,7 +694,7 @@ Créer un lien Modifier le profil du groupe Supprimer - MEMBRE + Membre Message dynamique ! Envoyer un message dynamique Envoyez un message dynamique - il sera mis à jour pour le⸱s destinataire⸱s au fur et à mesure que vous le tapez @@ -708,7 +708,7 @@ Erreur lors de la suppression du lien du groupe Erreur lors de la création du lien du groupe Seuls les propriétaires du groupe peuvent modifier les préférences du groupe. - POUR TERMINAL + Pour terminal Changer le rôle du groupe \? Son rôle est désormais %s. Tous les membres du groupe en seront informés. Contact vérifié⸱e @@ -747,7 +747,7 @@ Messages directs Supprimer pour tous Vous êtes le seul à pouvoir supprimer des messages de manière irréversible (votre contact peut les marquer comme supprimé). (24 heures) - SERVEURS + Serveurs Réception via Système Autoriser l\'envoi de messages directs aux membres. @@ -996,7 +996,7 @@ Afficher les options pour les développeurs Le fichier sera reçu lorsque votre contact aura terminé de le mettre en ligne. IDs de base de données et option d\'isolement du transport. - EXPÉRIMENTALE + Expérimentale Cacher : Dévoiler le profil de chat Dévoiler le profil @@ -1102,7 +1102,7 @@ Vous ne perdrez pas vos contacts si vous supprimez votre adresse ultérieurement. Adresse SimpleX Vous pouvez accepter ou refuser les demandes de contacts. - COULEURS DE L\'INTERFACE + Couleurs de l\'interface Vos contacts resteront connectés. Partager l\'adresse avec vos contacts ? Partager avec vos contacts @@ -1232,7 +1232,7 @@ Arrêt \? Mise à l\'arrêt Redémarrer - APP + App Abandonner Erreur lors de l\'annulation du changement d\'adresse Abandonner le changement d\'adresse \? @@ -1251,7 +1251,7 @@ Les membres peuvent envoyer des fichiers et des médias. Les fichiers et les médias sont interdits. Correction non prise en charge par un membre du groupe - ENVOYER DES ACCUSÉS DE RÉCEPTION AUX + Envoyer des accusés de réception aux Le chiffrement fonctionne et le nouvel accord de chiffrement n\'est pas nécessaire. Cela peut provoquer des erreurs de connexion ! Encore quelques points Justificatifs de réception! @@ -1776,8 +1776,8 @@ Rabattement du routage des messages Afficher le statut du message Protection de l\'adresse IP - FICHIERS - ROUTAGE PRIVÉ DES MESSAGES + Fichiers + Routage privé des messages Erreur au niveau du serveur de destination : %1$s Erreur : %1$s Capacité dépassée - le destinataire n\'a pas pu recevoir les messages envoyés précédemment. @@ -2091,7 +2091,7 @@ Utiliser des identifiants aléatoires Nom d\'utilisateur Les messages seront supprimés - il n\'est pas possible de revenir en arrière ! - BASE DE DONNÉES DU CHAT + Base de données du chat Mode système Serveur De nouveaux identifiants SOCKS seront utilisées pour chaque serveur. @@ -2374,7 +2374,7 @@ Se connecter Se connecter connecté - CONNEXION ÉCHOUÉE + Connexion échouée contact supprimé contact désactivé le contact devrait accepter… @@ -2410,7 +2410,7 @@ rejeté Rejeter le membre? relais - RELAIS + Relais Adresse de relais Adresse de relais Échec de la connexion au relais diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml index 84e806dda0..55d3e14907 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml @@ -29,7 +29,7 @@ prihvati poziv Dodeliti dozvolu Slušalice - POMOĆ + Pomoć Grupa će biti obrisana za Vas – ovo ne može da se poništi! Akcenat Grupni linkovi @@ -120,15 +120,15 @@ Greška Napravi jednokratnu poveznicu Nalepiti - PODEŠAVANJE + Podešavanje Profilne slike Razumeo Odstranjeno odstranjeno Napraviti - PORUKE I DATOTEKE + Poruke i datoteke Poruka - SERVERI + Serveri Odstraniti profil razgovora administratori Nasumično @@ -240,7 +240,7 @@ Preuzimanje Napredna podešavanja Poziv u toku - POZIVI + Pozivi Blokiraj članove grupe Nepoznati serveri! Datoteka @@ -253,7 +253,7 @@ %d poruka blokirano povezivanje Povezan telefon - VI + Vi Zamućeno za bolju privatnost. %d meseca(i) Poziv završen @@ -295,7 +295,7 @@ %d minut(a) Proveri ažuriranje Stabilno - DATOTEKE + Datoteke %s otpremljeno Onemogućiti obavještenja %s nije verifikovan @@ -312,7 +312,7 @@ Skenirati QR kod Server Onemogućiti - BAZA PODATAKA CHATA + Baza podataka chata onemogućeno Greška pri uvoženju teme Datoteke i medijski sadržaji su zabranjeni. @@ -328,7 +328,7 @@ Ili skenirati QR kod Onemogućeno Aplikacija - RAZGOVORI + Razgovori Datoteke i medijski sadržaji su zabranjeni! Poruke koje nestaju su zabranjene u ovom razgovoru. Chat je zaustavljen @@ -370,7 +370,7 @@ QR kod Chat je pokrenut Uvesti bazu podataka - BAZA PODATAKA CHATA + Baza podataka chata Chat je zaustavljen %s, %s i %d ostali članovi povezani Uvoz neuspešan @@ -428,7 +428,7 @@ SimpleX adresa SimpleX Logo Prikazati: - UREĐAJ + Uređaj Nova poruka Sekundarni Kontakti @@ -474,7 +474,7 @@ Omiljen Nikada Veza - TEME + Teme Audio/video pozivi ne šifrovanje ok @@ -491,7 +491,7 @@ SimpleX Adresa Sačuvati simplexmq: v%s (%2s) - EKSPERIMENTALNO + Eksperimentalno nikada Očistiti Zahvaljujući korisnicima – doprinesi pomoću Weblate! @@ -694,7 +694,7 @@ Izabrati profil razgovora Skenirati QR kod servera Napredna mrežna podešavanja - SOCKS PROXY + SOCKS proxy Anonimni režim broj PING Obnoviti statistiku? @@ -725,7 +725,7 @@ Arhiviraj bazu podataka Periodično Ukloniti - ČLAN + Član Pristupanje grupi SMP server Pozvati u razgovor @@ -986,7 +986,7 @@ Novi server Prikazati procente Napustiti bez čuvanja - POKRENUTI RAZGOVOR + Pokrenuti razgovor Odblokirati člana za sve? Priprema za preuzimanje Proxied(posredovan) @@ -1260,9 +1260,9 @@ Pin kod postavljen! Svi podaci u aplikaciji su odstranjeni. Pin kod aplikacije je zamenjen pin kodom za samouništenje. - POTPORI SIMPLEX CHAT + Potpori SimpleX Chat Oblik poruke - IKONA APLIKACIJE + Ikona aplikacije Pristupna fraza baze podataka Odrediti pristupnu frazu Baza podataka će biti šifrovana. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml index 2df64ae590..e1730a54aa 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml @@ -71,7 +71,7 @@ Továbbfejlesztett csoportok Az összes üzenet törölve lesz – ez a művelet nem vonható vissza! Az üzenetek CSAK az Ön számára törlődnek. A hívás véget ért - HÍVÁSOK + Hívások és további %d esemény Cím A csatlakozás folyamatban van a csoporthoz! @@ -149,13 +149,13 @@ hívás folyamatban Képek automatikus elfogadása A hívások kezdeményezése engedélyezve van a partnerei számára. - ALKALMAZÁSIKON + Alkalmazásikon Kiszolgáló hozzáadása QR-kód beolvasásával. Az eltűnő üzenetek küldése engedélyezve van. Az eltűnő üzenetek küldése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. Hang kikapcsolva A közvetlen üzenetek küldése a tagok között engedélyezve van. - ALKALMAZÁS + Alkalmazás Hívás folyamatban Mindkét fél hozzáadhat az üzenetekhez reakciókat. Mindkét fél tud hívásokat kezdeményezni. @@ -300,7 +300,7 @@ Hívás kapcsolása Törli a fájlokat és a médiatartalmakat? kész - CSEVEGÉSI ADATBÁZIS + Csevegési adatbázis Önmegsemmisítő jelkód módosítása Várólista létrehozása színezett @@ -313,7 +313,7 @@ kapcsolódás Egyéni időköz Kapcsolódás inkognitóban - CSEVEGÉSEK + Csevegések Új profil létrehozása a számítógépes alkalmazásban. 💻 kapcsolódás (bejelentve) kapcsolódás… @@ -393,7 +393,7 @@ Ne jelenjen meg újra SimpleX-zár kikapcsolása végpontok között titkosított - ESZKÖZ + Eszköz végpontok között titkosított videóhívás közvetlen Számítógép @@ -522,7 +522,7 @@ Akkor is, ha le van tiltva a beszélgetésben. Gyorsabb csatlakozás és megbízhatóbb üzenetkézbesítés. Zárolás engedélyezése - SÚGÓ + Súgó Teljesen decentralizált – csak a tagok számára látható. Fájl: %s Hívás befejezése @@ -530,7 +530,7 @@ Fájl mentve Kapcsolat javítása? Fájlok és médiatartalmak - KONZOLHOZ + Konzolhoz Nem sikerült a titkosítást újraegyeztetni. Hiba történt a felhasználói profil törlésekor Csoporttag általi javítás nem támogatott @@ -579,7 +579,7 @@ A csoport teljes neve: súgó Önmegsemmisítő jelkód engedélyezése - KÍSÉRLETI + Kísérleti Hiba történt a cím módosításának megszakításakor Hiba történt a fájl fogadásakor titkosítása rendben van @@ -722,7 +722,7 @@ A reakciók hozzáadása az üzenetekhez le van tiltva. Nem nincs szöveg - TAG + Tag Hogyan befolyásolja az akkumulátort Új tag szerepköre Kikapcsolva @@ -842,7 +842,7 @@ Név és üzenet Az értesítések csak az alkalmazás bezárásáig érkeznek! Információ - ÜZENETEK ÉS FÁJLOK + Üzenetek és fájlok tag Privát kapcsolat létrehozása %s moderálta ezt az üzenetet @@ -918,7 +918,7 @@ Üdvözlőüzenet %s, %s és további %d tag kapcsolódott Csak a partnere kezdeményezhet hívásokat. - TÉMÁK + Témák Túl sok videó! Üdvözöljük! Önmegsemmisítő jelkód @@ -963,7 +963,7 @@ Ön elfogadta a kapcsolatot Elutasítás Partner nevének és az üzenet tartalmának megjelenítése - BEÁLLÍTÁSOK + Beállítások Profiljelszó mentése Megállítja a fájlküldést? Leválasztja a számítógépet? @@ -1007,7 +1007,7 @@ QR-kód beolvasása Kiszolgáló tesztelése Küldjön nekünk e-mailt - KISZOLGÁLÓK + Kiszolgálók Kiszolgálók tesztelése Jelkód bevitele Rendszer @@ -1018,12 +1018,12 @@ A reakciók hozzáadása az üzenethez le van tiltva. Véletlenszerű jelmondat használata egyenrangú - CSEVEGÉSI SZOLGÁLTATÁS INDÍTÁSA + Csevegési szolgáltatás indítása Kapott hivatkozás beillesztése Menti a kiszolgálókat? A SimpleX Chat biztonsága a Trail of Bits által lett auditálva. frissítette a csoportprofilt - SIMPLEX CHAT TÁMOGATÁSA + SimpleX Chat támogatása SimpleX Chat szolgáltatás Ön megfigyelő %s ellenőrizve @@ -1072,10 +1072,10 @@ Egyszer használható SimpleX meghívó Hívások nem sikerült elküldeni - KEZELŐFELÜLET SZÍNEI + Kezelőfelület színei Adja meg a korábbi jelszót az adatbázis biztonsági mentésének visszaállítása után. Ez a művelet nem vonható vissza. Másodlagos szín - SOCKS PROXY + SOCKS proxy Mentés Újraindítás SMP-kiszolgálók @@ -1106,7 +1106,7 @@ igen Hangüzenet Társítás számítógéppel - PROFIL + Profil %d-s port Kapcsolódás egy hivatkozáson keresztül Cím megosztása @@ -1457,7 +1457,7 @@ Kis csoportok (legfeljebb 20 tag) Az Ön által elfogadott kapcsolat vissza lesz vonva! Élő üzenet küldése – az üzenet a címzett(ek) számára valós időben frissül, ahogy Ön beírja az üzenetet - A KÉZBESÍTÉSI JELENTÉSEKET A KÖVETKEZŐ CÍMRE KELL KÜLDENI + A kézbesítési jelentéseket a következő címre kell küldeni A következő üzenet azonosítója érvénytelen (kisebb vagy egyenlő az előzővel).\nEz valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. Az eszköz neve meg lesz osztva a társított hordozható eszközön használt alkalmazással. A címzettek a beírás közben látják a szövegváltozásokat. @@ -1747,11 +1747,11 @@ Közvetlen üzenetküldés, ha a saját kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. Az IP-cím védelmének érdekében a privát útválasztás az SMP-kiszolgálókat használja az üzenetek kézbesítéséhez. Üzenet-útválasztási tartalék - PRIVÁT ÜZENET-ÚTVÁLASZTÁS + Privát üzenet-útválasztás Privát útválasztás használata az ismeretlen kiszolgálókkal, ha az IP-cím nem védett. NE küldjön üzeneteket közvetlenül, még akkor sem, ha a saját kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. Tor vagy VPN nélkül az IP-címe láthatóvá válik a fájlkiszolgálók számára. - FÁJLOK + Fájlok IP-cím védelme Az alkalmazás kérni fogja az ismeretlen fájlkiszolgálókról történő letöltések megerősítését (kivéve, ha az .onion vagy a SOCKS proxy engedélyezve van). Ismeretlen kiszolgálók! @@ -2019,7 +2019,7 @@ Az üzenetek törölve lesznek – ez a művelet nem vonható vissza! Eltávolítja az archívumot? A feltöltött adatbázis-archívum véglegesen el lesz távolítva a kiszolgálókról. - CSEVEGÉSI ADATBÁZIS + Csevegési adatbázis Profil megosztása Rendszerbeállítások használata Csevegési profil kiválasztása @@ -2476,7 +2476,7 @@ A hivatkozás rövid lesz és a csoportprofil meg lesz osztva a hivatkozáson keresztül. Régi cím megosztása Régi (hosszú) hivatkozás megosztása - PARTNERI KAPCSOLATKÉRÉSEK A CSOPORTOKBÓL + Partneri kapcsolatkérések a csoportokból A tag törölve lett – nem lehet elfogadni a kérést a(z) %1$s nevű csoportból partneri kapcsolatot kért Ez a beállítás a jelenlegi profiljára vonatkozik @@ -2519,7 +2519,7 @@ Hangüzenetek keresése Videók Hangüzenetek - NEM SIKERÜLT LÉTREHOZNI A KAPCSOLATOT + Nem sikerült létrehozni a kapcsolatot sikertelen Ha csatornákat hozott létre vagy csak csatlakozott hozzájuk, akkor azok véglegesen le fognak állni. aktív @@ -2545,7 +2545,7 @@ meghíva Csatorna megnyitása Új csatorna megnyitása - TULAJDONOS + Tulajdonos Tulajdonosok Csatorna elhagyása Elhagyja a csatornát? @@ -2555,7 +2555,7 @@ Ön Saját csatorna Saját csatorna - FELIRATKOZÓ + Feliratkozó Feliratkozók %1$d feliratkozó %1$d feliratkozó @@ -2607,7 +2607,7 @@ %1$d/%2$d átjátszó aktív %1$d/%2$d átjátszó kapcsolódva, %3$d hiba %1$d/%2$d átjátszó kapcsolódva - ÁTJÁTSZÓ + Átjátszó Átjátszóhivatkozás Átjátszó címe a következőn keresztül: %1$s diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml index 60ed7db384..70c31f5399 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml @@ -44,7 +44,7 @@ Tambahkan ke perangkat lain Boleh Selalu - APLIKASI + Aplikasi Tampilan Tentang SimpleX Chat Terima @@ -293,7 +293,7 @@ Terlalu banyak gambar! cari panggilan - PENGATURAN + Pengaturan Untuk semua orang Hentikan berkas Cabut berkas @@ -643,9 +643,9 @@ Kode sandi hapus otomatis Aktifkan hapus otomatis Pasang kode sandi - BANTUAN - DUKUNG SIMPLEX CHAT - PANGGILAN + Bantuan + Dukung SimpleX Chat + Panggilan Mulai ulang aplikasi untuk buat profil obrolan baru. Hapus pesan keluar @@ -729,9 +729,9 @@ Sedang Buram media Kuat - ANDA + Anda Lunak - BASIS DATA OBROLAN + Basis data obrolan Setel frasa sandi untuk diekspor Buka folder basis data menghapus anda @@ -968,7 +968,7 @@ Build aplikasi: %s Versi inti: v%s Ketika IP disembunyikan - WARNA ANTARMUKA + Warna antarmuka Fallback perutean pesan Mode routing pesan Routing pribadi @@ -1003,7 +1003,7 @@ Server ICE Anda Server ICE WebRTC Jika Anda memasukkan kode sandi hapus otomatis saat membuka aplikasi: - IKON APLIKASI + Ikon aplikasi Aplikasi akan meminta untuk mengonfirmasi unduhan dari server berkas yang tidak dikenal (kecuali .onion atau saat proxy SOCKS diaktifkan). Reaksi pesan dilarang dalam obrolan ini. Pindah ke perangkat lain @@ -1020,8 +1020,8 @@ Panggilan tak terjawab Panggilan ditolak ID pesan berikutnya salah (kurang atau sama dengan yang sebelumnya).\nHal ini dapat terjadi karena beberapa bug atau ketika koneksi terganggu. - TEMA - KIRIM TANDA TERIMA KIRIMAN KE + Tema + Kirim tanda terima kiriman ke Hal ini dapat terjadi ketika Anda atau koneksi Anda menggunakan cadangan basis data lama. Android Keystore digunakan untuk menyimpan frasa sandi dengan aman - memungkinkan layanan notifikasi berfungsi. Hapus @@ -1134,9 +1134,9 @@ Dikenal Menunggu gambar Menunggu video - PERANGKAT - OBROLAN - BERKAS + Perangkat + Obrolan + Berkas Reset semua petunjuk Gagal menambah anggota Gagal gabung ke grup @@ -1281,7 +1281,7 @@ Buka blokir anggota untuk semua? Buka untuk semua Diblokir oleh admin - ANGGOTA + Anggota Hapus anggota Status pesan: %s Status berkas: %s @@ -1326,7 +1326,7 @@ Perbaikan tidak didukung oleh kontak Obrolan Terima kondisi - SERVER + Server Buat grup Nama lengkap grup: Simpan profil grup @@ -1466,7 +1466,7 @@ Terbaik untuk baterai. Anda akan menerima notifikasi saat aplikasi sedang berjalan (TANPA layanan latar belakang).]]> Baik untuk baterai. Aplikasi memeriksa pesan setiap 10 menit. Anda mungkin melewatkan panggilan atau pesan penting.]]> Tema obrolan - BASIS DATA OBROLAN + Basis data obrolan Basis data dienkripsi menggunakan frasa sandi acak. Harap ubah frasa sandi sebelum mengekspor. Basis data obrolan diekspor Frasa sandi saat ini… @@ -1685,7 +1685,7 @@ Berkas dan media Enkripsi basis data? Versi basis data tidak kompatibel - UNTUK KONSOL + Untuk konsol Grup sudah ada! Masukkan frasa sandi Aktifkan hapus pesan otomatis? @@ -1703,7 +1703,7 @@ Gagal verifikasi frasa sandi: Gagal hubungkan ulang server Gagal hubungkan ulang server - EKSPERIMENTAL + Eksperimental Ekspor basis data Impor basis data Gagal hentikan obrolan @@ -1830,7 +1830,7 @@ %s diunduh Pesan diterima Catatan diperbarui pada - PESAN DAN BERKAS + Pesan dan berkas Tema profil Gambar profil Harap masukkan frasa sandi saat ini yang benar. @@ -1908,7 +1908,7 @@ Simpan Jadikan profil pribadi! Ponsel jarak jauh - JALANKAN OBROLAN + Jalankan obrolan Harap simpan frasa sandi dengan aman, Anda TIDAK akan dapat mengakses obrolan jika hilang. dihapus %1$s Dikirim pada: %s @@ -1979,7 +1979,7 @@ Server baru Info antrian pesan Bentuk pesan - ROUTING PESAN PRIBADI + Routing pesan pribadi info antrean server: %1$s\n\npesan terakhir diterima: %2$s Hanya data profil lokal Buka perubahan @@ -2024,7 +2024,7 @@ Profil, kontak, dan pesan terkirim Anda disimpan di perangkat Anda. Platform perpesanan dan aplikasi yang melindungi privasi dan keamanan Anda. Untuk melindungi privasi Anda, SimpleX gunakan ID terpisah untuk setiap kontak. - PROXY SOCKS + Proxy SOCKS Tingkatkan dan buka obrolan Ketuk untuk gabung ke samaran Anda memblokir %s @@ -2423,7 +2423,7 @@ Chat dengan anggota sebelum mereka bergabung. Hubungkan Terhubung lebih cepat! 🚀 - PERMINTAAN KONTAK DARI GRUP + Permintaan kontak dari grup kontak harus menerima… Buat alamat Anda Opsi tidak berlaku diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml index adce58e804..9db5b3a58f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -269,7 +269,7 @@ L\'archivio chiavi di Android è usato per memorizzare in modo sicuro la password; permette il funzionamento del servizio di notifica. Permetti ai tuoi contatti di inviare messaggi vocali. Database della chat eliminato - ICONA APP + Icona app Ideale per la batteria. Riceverai notifiche solo quando l\'app è in esecuzione (NESSUN servizio in secondo piano).]]> Consuma più batteria! L\'app funziona sempre in secondo piano: le notifiche vengono mostrate istantaneamente.]]> chiamata… @@ -378,24 +378,24 @@ Attiva le chiamate dalla schermata di blocco tramite le impostazioni. Fotocamera frontale/posteriore Riaggancia - CHIAMATE - DATABASE DELLA CHAT + Chiamate + Database della chat Database della chat importato Chat in esecuzione - CHAT + Chat Il database è crittografato con una password casuale. Cambiala prima di esportare. Password del database Eliminare il profilo di chat\? Elimina database Strumenti di sviluppo - DISPOSITIVO + Dispositivo Errore nell\'eliminazione del database della chat Errore nell\'esportazione del database della chat Errore nell\'avvio della chat Errore nell\'interruzione della chat Funzionalità sperimentali Esporta database - AIUTO + Aiuto Chat fermata Errore del database La password del database è diversa da quella salvata nell\'archivio chiavi. @@ -437,7 +437,7 @@ Errore nella creazione del link del gruppo Errore nell\'eliminazione del link del gruppo Espandi la selezione dei ruoli - PER CONSOLE + Per console Link del gruppo Il gruppo verrà eliminato per tutti i membri. Non è reversibile! Il gruppo verrà eliminato per te. Non è reversibile! @@ -697,23 +697,23 @@ Importare il database della chat\? Importa database Modalità incognito - MESSAGGI E FILE + Messaggi e file Nuovo archivio database Vecchio archivio del database Riavvia l\'app per creare un profilo di chat nuovo. Riavvia l\'app per usare il database della chat importato. - AVVIA CHAT + Avvia chat Invia le anteprime dei link Imposta la password per esportare - IMPOSTAZIONI - PROXY SOCKS + Impostazioni + Proxy SOCKS Ferma Fermare la chat\? Ferma la chat per esportare, importare o eliminare il database della chat. Non potrai ricevere e inviare messaggi mentre la chat è ferma. - SUPPORTA SIMPLEX CHAT - TEMI + Supporta SimpleX Chat + Temi Questa azione non può essere annullata: il tuo profilo, i contatti, i messaggi e i file andranno persi in modo irreversibile. - TU + Tu Il tuo database della chat Il tuo attuale database di chat verrà ELIMINATO e SOSTITUITO con quello importato. \nQuesta azione non può essere annullata: il tuo profilo, i contatti, i messaggi e i file andranno persi in modo irreversibile. @@ -774,7 +774,7 @@ Invita al gruppo Esci dal gruppo Nome locale - MEMBRO + Membro Il membro verrà rimosso dal gruppo, non è reversibile! Nuovo ruolo del membro Nessun contatto selezionato @@ -806,7 +806,7 @@ Salva il profilo del gruppo sec Invio tramite - SERVER + Server Cambia indirizzo di ricezione Sistema Scadenza connessione TCP @@ -994,7 +994,7 @@ Conferma aggiornamenti database migrazione diversa nell\'app/nel database: %s / %s Conferma di migrazione non valida - SPERIMENTALE + Sperimentale L\'immagine verrà ricevuta quando il tuo contatto completerà l\'invio. la versione del database è più recente di quella dell\'app, ma nessuna migrazione downgrade per: %s Il file verrà ricevuto quando il tuo contatto completerà l\'invio. @@ -1102,7 +1102,7 @@ Per connettervi, il tuo contatto può scansionare il codice QR o usare il link nell\'app. Quando le persone chiedono di connettersi, puoi accettare o rifiutare. Indirizzo SimpleX - COLORI DELL\'INTERFACCIA + Colori dell\'interfaccia I tuoi contatti resteranno connessi. Aggiungi l\'indirizzo al tuo profilo, in modo che i tuoi contatti di SimpleX possano condividerlo con altre persone. L\'aggiornamento del profilo verrà inviato ai tuoi contatti di SimpleX. Crea un indirizzo per consentire alle persone di connettersi con te. @@ -1230,7 +1230,7 @@ nessun testo Si sono verificati alcuni errori non fatali durante l\'importazione: Riavvia - APP + App Le notifiche smetteranno di funzionare fino a quando non riavvierai l\'app Spegni Spegnere\? @@ -1261,7 +1261,7 @@ L\'invio delle ricevute di consegna sarà attivo per tutti i contatti. Errore nell\'attivazione delle ricevute di consegna! Puoi attivarle più tardi nelle impostazioni - INVIA RICEVUTE DI CONSEGNA A + Invia ricevute di consegna a concordando la crittografia per %s… Ricevute di consegna! Contatti @@ -1779,7 +1779,7 @@ NON inviare messaggi direttamente, anche se il tuo server o quello di destinazione non supporta l\'instradamento privato. NON usare l\'instradamento privato. No - INSTRADAMENTO PRIVATO DEI MESSAGGI + Instradamento privato dei messaggi Invia messaggi direttamente quando l\'indirizzo IP è protetto e il tuo server o quello di destinazione non supporta l\'instradamento privato. Per proteggere il tuo indirizzo IP, l\'instradamento privato usa i tuoi server SMP per consegnare i messaggi. Non protetto @@ -1787,7 +1787,7 @@ Proteggi l\'indirizzo IP L\'app chiederà di confermare i download da server di file sconosciuti (eccetto .onion o quando il proxy SOCKS è attivo). Senza Tor o VPN, il tuo indirizzo IP sarà visibile ai server di file. - FILE + File Senza Tor o VPN, il tuo indirizzo IP sarà visibile a questi relay XFTP: \n%1$s. Tema della chat @@ -2056,7 +2056,7 @@ Errore nel cambio di profilo Seleziona il profilo di chat Condividi il profilo - DATABASE DELLA CHAT + Database della chat Modalità di sistema Rimuovere l\'archivio? I messaggi verranno eliminati. Non è reversibile! @@ -2512,7 +2512,7 @@ Condividi il link vecchio Il link sarà breve e il profilo del gruppo verrà condiviso attraverso il link. Aggiorna il link del gruppo - RICHIESTE DI CONTATTO DAI GRUPPI + Richieste di contatto dai gruppi Il membro è eliminato - impossibile accettare la richiesta connessione richiesta dal gruppo %1$s Questa impostazione è per il tuo profilo attuale @@ -2555,7 +2555,7 @@ Video Messaggi vocali Filtro - CONNESSIONE FALLITA + Connessione fallita fallito Se sei dentro canali o ne hai creati, essi smetteranno di funzionare definitivamente. %1$d/%2$d relay attivo/i @@ -2620,12 +2620,12 @@ Non tutti i relay sono connessi Apri canale Apri un canale nuovo - PROPRIETARIO + Proprietario Proprietari Indirizzo relay preimpostato Nome relay preimpostato relay - RELAY + Relay Indirizzo del relay Indirizzo del relay Connessione del relay fallita @@ -2636,7 +2636,7 @@ Il server richiede l\'autorizzazione per connettersi al relay, controlla la password. Avviso del server Condividi l\'indirizzo del relay - ISCRITTO + Iscritto Iscritti Gli iscritti usano il link del relay per connettersi al canale.\nL\'indirizzo del relay è stato usato per impostare questo relay per il canale. L\'iscritto verrà rimosso dal canale, non è reversibile! diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml index faf69dfd03..61613e63b0 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml @@ -973,7 +973,7 @@ רמקול כבוי רמקול פעיל הגדרות - תמיכה ב־SIMPLEX CHAT + תמיכה ב־SimpleX Chat לעצור צ׳אט\? עיצרו את הצ׳אט כדי לייצא, לייבא או למחוק את מסד הנתונים. לא תוכלו לקבל ולשלוח הודעות בזמן שהצ׳אט מופסק. עצור diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml index 5c17946c24..9784befef6 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml @@ -880,7 +880,7 @@ メールを送る メディア共有… SimpleXリンク - SIMPLEX CHATを支援 + SimpleX Chatを支援 テストサーバ 受信アドレスは別のサーバーに変更されます。アドレス変更は送信者がオンラインになった後に完了します。 あなたのプライバシーを守るために、他のアプリと違って、ユーザーIDの変わりに SimpleX メッセージ束毎にIDを配布し、各連絡先が別々と扱います。 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml index 83f937db32..a60dea56b7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml @@ -822,7 +822,7 @@ 설정 링크 미리보기 보내기 SOCKS 프록시 - SIMPLEX CHAT 도와주기 + SimpleX Chat 도와주기 실험적 기능 표시 : diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ku/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ku/strings.xml index 92985b15be..09c428e48b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ku/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ku/strings.xml @@ -282,13 +282,13 @@ Adresa serverê Adres Adresa serverê li eyarên torê nayê. - SERVER + Server Melûmata serveran Ceribandina serverê bi ser neket! Versiyona serverê li eyarên torê nayê. 1 roj deyne Tercihên komê diyar bike - EYAR + Eyar Parve bike Lînka 1-carê parve bike Adresê parve bike @@ -337,7 +337,7 @@ xet/xêz/xîşk Biqewet Abonekirî - PIŞT BIDE SIMPLEX CHATÊ + Pişt bide SimpleX Chatê Biguhere Sîstem Sîstem @@ -513,7 +513,7 @@ Kompîter ne aktîv e Girêdana bi kompîterê re qut bû Detay - CIHAZ + Cihaz %d dosya bi mezibnbûniya timam ya %s %d hewadîsên komê %d seet @@ -588,7 +588,7 @@ Tu dihêlî te ev endam qebûl kir tu: %1$s - TU + Tu tu Erê erê @@ -633,11 +633,11 @@ Lînkê veke Lînka timam veke Lînka paqij veke - ARÎKARÎ - APLÎKASYON - DOSYA + Arîkarî + Aplîkasyon + Dosya Ji nû ve veke - PROKSIYA SOCKSÊ + Proksiya SOCKSê Sûretên profîlan Girêdana torê Ji kompîterê bişuxulîne @@ -687,7 +687,7 @@ Ji admîn blokkirî blokkirî ne aktîv - ENDAM + Endam Rol Kom Te standin bi riya @@ -785,10 +785,10 @@ Profîla siḧbetê Tu siḧbeta xwe qontrol dikî! Siḧbetê bişuxulîne - SIḦBET + Siḧbet Rengên siḧbetê Siḧbet sekinandî ye - DATABASA SIḦBETÊ + Databasa siḧbetê Ber siḧbet were sekinandin? Xeletî di sekinandina siḧbetê de Ber profîla siḧbetê were jêbirin? diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml index bccd49eed9..950569c85b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml @@ -17,10 +17,10 @@ Skambutis jau baigtas! Atsiliepti Skambutis baigtas - SKAMBUČIAI + Skambučiai Leisti jūsų kontaktams negrįžtamai ištrinti išsiųstas žinutes. (24 valandas) Atgal - PROGRAMĖLĖS PIKTOGRAMA + Programėlės piktograma visada Leisti jūsų kontaktams siųsti balso žinutes. Leisti negrįžtamą žinučių ištrynimą tik tuo atveju, jei jūsų kontaktas jums tai leidžia. (24 valandas) @@ -78,8 +78,8 @@ Apversti kamerą Atmestas skambutis Privatumas ir saugumas - ĮRENGINYS - PAGALBA + Įrenginys + Pagalba Šifruoti Šalinti Ištrinti grupę @@ -241,7 +241,7 @@ Išjungti garsiakalbį Įjungti garsiakalbį Praleistos žinutės - NUSTATYMAI + Nustatymai Sistemos nežinomas žinutės formatas SimpleX kontakto adresas @@ -292,7 +292,7 @@ Išjungti vaizdą Įjungti vaizdą Jūsų privatumas - JŪS + Jūs Neteisinga slaptafrazė! SimpleX jūs @@ -348,10 +348,10 @@ Įrašyti ir pranešti grupės nariams gautas patvirtinimas… Praleistas skambutis - POKALBIAI - APIPAVIDALINIMAI + Pokalbiai + Apipavidalinimai Inkognito veiksena - ŽINUTĖS IR FAILAI + Žinutės ir failai Norėdami naudoti importuotą pokalbio duomenų bazę, paleiskite programėlę iš naujo. Pakviesti narius Išnykstančios žinutės šiame pokalbyje yra uždraustos. @@ -420,7 +420,7 @@ Pokalbio profilis Profilis yra bendrinamas tik su jūsų kontaktais. „GitHub“ saugykloje.]]> - SOCKS ĮGALIOTASIS SERVERIS + SOCKS įgaliotasis serveris Įrašyti slaptafrazę ir atverti pokalbį Atkurti atsarginę duomenų bazės kopiją Atkurti atsarginę duomenų bazės kopiją\? @@ -459,7 +459,7 @@ Kontakto nuostatos Prisijungti Keisti - SERVERIAI + Serveriai Išvalyti Nebeslėpti profilio Per daug vaizdo įrašų! @@ -545,7 +545,7 @@ Išjungti garsą Visi programėlės duomenys bus ištrinti. Sukuriamas tuščias pokalbių profilis nurodytu pavadinimu ir programėlė atveriama kaip įprasta. - PROGRAMĖLĖ + Programėlė Saugiam slaptafrazės saugojimui yra naudojama „Android Keystore“ – tai įgalina pranešimų tarnybą veikti. Papildoma antrinė spalva Papildomas akcentavimas @@ -636,7 +636,7 @@ Sukurti grupę: sukurti naują grupę.]]> Pridėti kontaktą Tinkinti apipavidalinimą - POKALBIO DUOMENŲ BAZĖ + Pokalbio duomenų bazė Naudotojo sąsaja kinų ir ispanų kalbomis Pranešimai apie pristatymą! Išjungti SimpleX užraktą @@ -855,7 +855,7 @@ Prisijungti inkognito režimu Reikalinga slaptafrazė Uždrausti siųsti balso žinutes. - EKSPERIMENTINIS + Eksperimentinis Greitai ir nelaukiant kol siuntėjas prisijungs! Failai ir medija Failai ir medija yra draudžiami šioje grupėje. @@ -1000,7 +1000,7 @@ ištrintas kontaktas pakviestas Ištrinta - KONSOLEI + Konsolei Blokuoti Protokolui skirtas laikas numatyta (%s) @@ -1106,7 +1106,7 @@ Prisijungimas, kurį priėmėte, bus atšauktas! Bakstelėkite, kad įklijuoti nuorodą Testuoti serverį - TEMOS SPALVOS + Temos spalvos Rodyti lėtus API iškvietimus Nustoti bendrinti adresą? Žinučių siuntimo ir programų platforma, apsauganti jūsų privatumą ir saugumą. @@ -1217,7 +1217,7 @@ Nustatyti duomenų slaptafrazę Nustatyti slaptafrazę Rodyti paskutines žinutes - PALAIKYKITE SIMPLEX CHAT + Palaikykite SimpleX Chat Jų galima nepaisyti kontaktų ir grupių nustatymuose. Šis veiksmas negali būti atšauktas - žinutės išsiųstos ir gautos anksčiau nei pasirinkta bus ištrintos. Tai gali užtrukti kelias minutes. %s, %s ir %d kiti nariai prisijungė @@ -1516,7 +1516,7 @@ paslaptis Pranešimai nustos veikti iki tol kol paleisite programėlę iš naujo Galite naudoti markdown, kad formatuoti žinutes: - PALEISTI POKALBIUS + Paleisti pokalbius Naudoti iš darbastalio Sveikinimo žinutė yra per ilga Žinučių reakcijos @@ -1618,7 +1618,7 @@ Galite paleisti pokalbius per programėlės nustatymus/ duomenų bazę arba paleisdami programėlę iš naujo. pašalino jus Moderuota - NARYS + Narys %s %s nėra teksto Meniu ir įspėjimai @@ -1651,7 +1651,7 @@ Naudoti atsiktinę slaptafrazę lygiaverčiai mazgai Pašalinti slaptafrazę iš nustatymų? - SIŲSTI PRISTATYMO KVITUS PAS + Siųsti pristatymo kvitus pas Pristatymo kvitai yra išjungti %d grupėms Turite naudoti pačią naujausią pokalbių duomenų bazės versiją TIK viename įrenginyje, kitaip galite nebegauti žinučių iš kai kurių kontaktų. Nauja slaptafrazė… diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nb-rNO/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nb-rNO/strings.xml index a6385a5ce0..1275c31573 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nb-rNO/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nb-rNO/strings.xml @@ -124,7 +124,7 @@ En annen grunn Svar anrop Hvem som helst kan være vert for servere. - APP + App Appen kjører alltid i bakgrunnen App build: %s Appen kan bare motta varsler når den er åpen, ingen bakgrunnstjeneste vil bli startet. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml index cc81e5365b..53ff325f43 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -5,7 +5,7 @@ Oproepen op vergrendelscherm: oproep bezig Gesprek bezig - OPROEPEN + Oproepen Annuleren Bestandsvoorbeeld annuleren Annuleer afbeeldingsvoorbeeld @@ -23,7 +23,7 @@ Sta toe om spraak berichten te verzenden. Chat is actief Wissen - CHAT DATABASE + Chat database Chat console Chat database geïmporteerd Chat database verwijderd @@ -85,7 +85,7 @@ App build: %s App kan alleen meldingen ontvangen wanneer deze actief is, er wordt geen achtergrondservice gestart Uiterlijk - APP ICON + App icon App versie App versie: v%s voor elk chatprofiel dat je in de app hebt .]]> @@ -119,7 +119,7 @@ Chat is gestopt Chat voorkeuren Chatprofiel - CHATS + Chats Praat met de ontwikkelaars Controleer het server adres en probeer het opnieuw. Bestand @@ -231,7 +231,7 @@ Verwijderen voor iedereen Link verwijderen direct - APPARAAT + Apparaat Verwijder alle bestanden Berichten verwijderen na Directe berichten @@ -309,7 +309,7 @@ e2e versleuteld video gesprek Schakel oproepen vanaf het vergrendelscherm in via Instellingen. Ophangen - HELP + Help Experimentele functies Fout bij het starten van de chat Database exporteren @@ -358,7 +358,7 @@ Fout bij het accepteren van een contactverzoek Groep uitnodiging verlopen Bestand - VOOR CONSOLE + Voor console Groep profiel wordt opgeslagen op de apparaten van de leden, niet op de servers. Verborgen De groep wordt voor u verwijderd, dit kan niet ongedaan worden gemaakt! @@ -473,8 +473,8 @@ Verlaten Lid link voorbeeld afbeelding - LID - BERICHTEN EN BESTANDEN + Lid + Berichten en bestanden Openen in mobiele app en tik vervolgens op Verbinden in de app.]]> Lid wordt uit de groep verwijderd, dit kan niet ongedaan worden gemaakt! Fout bij bezorging van bericht @@ -730,10 +730,10 @@ App scherm verbergen Uw privacy Link voorbeelden verzenden - INSTELLINGEN - ONDERSTEUNING SIMPLEX CHAT - JIJ - CHAT UITVOEREN + Instellingen + Ondersteuning SimpleX Chat + Jij + Chat uitvoeren Uw chat database Wachtwoord instellen om te exporteren Start de app opnieuw om een nieuw chatprofiel aan te maken. @@ -790,7 +790,7 @@ Direct bericht sturen De rol wordt gewijzigd in "%s". De gebruiker ontvangt een nieuwe uitnodiging. Verzenden via - SERVERS + Servers Resetten naar standaardwaarden Ontvangst adres wijzigen Protocol timeout @@ -874,11 +874,11 @@ Bericht delen… SimpleX Vergrendelen Sla het wachtwoord op in Keychain - SOCKS PROXY + SOCKS proxy Dank aan de gebruikers – draag bij via Weblate! De app haalt regelmatig nieuwe berichten op - het gebruikt een paar procent van de batterij per dag. De app maakt geen gebruik van push meldingen, gegevens van uw apparaat worden niet naar de servers verzonden. De afbeelding kan niet worden gedecodeerd. Probeer een andere afbeelding of neem contact op met de ontwikkelaars. - THEMA\'S + Thema\'s Scan server QR-code Deze string is geen verbinding link! Deze actie kan niet ongedaan worden gemaakt, de berichten die eerder zijn verzonden en ontvangen dan geselecteerd, worden verwijderd. Het kan enkele minuten duren. @@ -994,7 +994,7 @@ Database-ID\'s en Transport isolatie optie. Verbergen: Ontwikkelaars opties tonen - EXPERIMENTEEL + Experimenteel Verwijder profiel Profiel wachtwoord Chatprofiel zichtbaar maken @@ -1146,7 +1146,7 @@ Zorg ervoor dat het bestand de juiste YAML-syntaxis heeft. Exporteer het thema om een voorbeeld te hebben van de themabestandsstructuur. Database openen… Gebruikershandleiding.]]> - INTERFACE KLEUREN + Interface kleuren U kunt uw adres delen als een link of QR-code - iedereen kan verbinding met u maken. Alle app-gegevens worden verwijderd. Er wordt een leeg chatprofiel met de opgegeven naam gemaakt en de app wordt zoals gewoonlijk geopend. @@ -1226,7 +1226,7 @@ geen tekst Er zijn enkele niet-fatale fouten opgetreden tijdens het importeren: Afsluiten\? - APP + App Herstarten Afsluiten Meldingen werken niet meer totdat u de app opnieuw start @@ -1285,7 +1285,7 @@ Inschakelen (overschrijvingen behouden) Het verzenden van ontvangst bevestiging is uitgeschakeld voor %d-contactpersonen Uitschakelen voor iedereen - STUUR ONTVANGST BEVESTIGING NAAR + Stuur ontvangst bevestiging naar Ontvangst bevestiging verzenden De tweede vink die we gemist hebben! ✅ Filter ongelezen en favoriete chats. @@ -1779,13 +1779,13 @@ Om uw IP-adres te beschermen, gebruikt privéroutering uw SMP-servers om berichten te bezorgen. Stuur GEEN berichten rechtstreeks, zelfs als uw of de bestemmingsserver geen privéroutering ondersteunt. Terugval op berichtroutering - PRIVÉBERICHT ROUTING + Privébericht routing Stuur berichten rechtstreeks als het IP-adres beschermd is en uw of bestemmingsserver geen privéroutering ondersteunt. Onbekende servers! Zonder Tor of VPN is uw IP-adres zichtbaar voor deze XFTP-relays: \n%1$s. Zonder Tor of VPN is uw IP-adres zichtbaar voor bestandsservers. - BESTANDEN + Bestanden Bescherm het IP-adres De app vraagt om downloads van onbekende bestandsservers te bevestigen (behalve .onion of wanneer SOCKS-proxy is ingeschakeld). Fout bij het initialiseren van WebView. Update uw systeem naar de nieuwe versie. Neem contact op met ontwikkelaars. @@ -2055,7 +2055,7 @@ Selecteer chatprofiel Profiel delen Uw verbinding is verplaatst naar %s, maar er is een onverwachte fout opgetreden tijdens het omleiden naar het profiel. - CHAT DATABASE + Chat database Systeemmodus Archief verwijderen? Berichten worden verwijderd. Dit kan niet ongedaan worden gemaakt! diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml index 9cc43851d6..51bfacb404 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml @@ -495,36 +495,36 @@ Pominięte wiadomości Twoja prywatność Kopia zapasowa danych aplikacji - IKONA APLIKACJI + Ikona aplikacji Automatyczne akceptowanie obrazów - POŁĄCZENIA - BAZA DANYCH CZATU + Połączenia + Baza danych czatu Czat jest uruchomiony Czat jest zatrzymany - CZATY + Czaty Hasło do bazy danych Usuń bazę danych Narzędzia deweloperskie - URZĄDZENIE + Urządzenie Błąd uruchamiania czatu - EKSPERYMENTALNE + Eksperymentalne Funkcje eksperymentalne Eksportuj bazę danych - POMOC + Pomoc Importuj bazę danych Tryb incognito - WIADOMOŚCI I PLIKI + Wiadomości i pliki Nowe archiwum bazy danych Stare archiwum bazy danych Chroń ekran aplikacji - URUCHOM CZAT + Uruchom czat Wyślij podgląd linku - USTAWIENIA - PROXY SOCKS + Ustawienia + Proxy SOCKS Zatrzymać czat\? - WSPIERAJ SIMPLEX CHAT - MOTYWY - TY + Wspieraj SimpleX Chat + Motywy + Ty Twoja baza danych czatu Ustaw hasło do eksportu Zatrzymaj @@ -707,12 +707,12 @@ Błąd tworzenia linku grupy Błąd usuwania linku grupy Błąd usuwania członka - DLA KONSOLI + Dla konsoli Grupa Wprowadź nazwę grupy: Pełna nazwa grupy: Nazwa lokalna - CZŁONEK + Członek Członek zostanie usunięty z grupy - nie można tego cofnąć! Status sieci Tylko właściciele grup mogą zmieniać preferencje grupy. @@ -724,7 +724,7 @@ Zapisać wiadomość powitalną\? Wyślij wiadomość bezpośrednią Wysyłanie przez - SERWERY + Serwery Przełącz Zmień adres odbioru W pełni zdecentralizowana – widoczna tylko dla członków. @@ -1136,7 +1136,7 @@ Kiedy ludzie proszą o połączenie, możesz je zaakceptować lub odrzucić. Nie stracisz kontaktów, jeśli później usuniesz swój adres. Dostosuj motyw - KOLORY INTERFEJSU + Kolory interfejsu Twoje kontakty pozostaną połączone. Dodaj adres do swojego profilu, aby Twoje kontakty mogły go udostępnić innym osobom. Aktualizacja profilu zostanie wysłana do Twoich kontaktów. Utwórz adres, aby ludzie mogli się z Tobą połączyć. @@ -1229,7 +1229,7 @@ brak tekstu Podczas importu wystąpiły niekrytyczne błędy: Restart - APLIKACJA + Aplikacja Powiadomienia przestaną działać do momentu ponownego uruchomienia aplikacji. Wyłączenie Wyłączyć\? @@ -1262,7 +1262,7 @@ Spraw, aby jedna wiadomość zniknęła Renegocjuj szyfrowanie kod bezpieczeństwa zmieniony - WYŚLIJ POTWIERDZENIA DOSTAWY DO + Wyślij potwierdzenia dostawy do Wysyłanie potwierdzeń dostawy zostanie włączone dla wszystkich kontaktów we wszystkich widocznych profilach czatu. Kontakty Włączyć potwierdzenia\? @@ -1772,7 +1772,7 @@ Nie Gdy IP ukryty Pokaż status wiadomości - TRASOWANIE PRYWATNYCH WIADOMOŚCI + Trasowanie prywatnych wiadomości NIE wysyłaj wiadomości bezpośrednio, nawet jeśli serwer docelowy nie obsługuje prywatnego trasowania. Aby chronić Twój adres IP, prywatne trasowanie używa Twoich serwerów SMP, aby dostarczyć wiadomości. Nieznane serwery @@ -1788,7 +1788,7 @@ Chroń adres IP Aplikacja będzie prosić o potwierdzenie pobierań z nieznanych serwerów plików (z wyjątkiem .onion lub gdy proxy SOCKS jest włączone). Bez Tor lub VPN, Twój adres IP będzie widoczny do serwerów plików. - PLIKI + Pliki Motyw profilu Pokaż listę czatów w nowym oknie Kolory ciemnego trybu @@ -2065,7 +2065,7 @@ %1$d plik(ów/i) dalej są pobierane. %1$d plik(ów/i) nie udało się pobrać. Błąd zmiany profilu - BAZA CZATU + Baza czatu %1$d błędów plików:\n%2$s %1$d innych błędów plików. Wiadomości zostały usunięte po wybraniu ich. @@ -2233,7 +2233,7 @@ kontakt usunięty kontakt wyłączony kontakt nie gotowy - PROŚBY O KONTAKT OD GRUP + Prośby o kontakt od grup kontakt powinien zaakceptować… Stwórz swój adres %d czat(y) diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml index c129d68521..2d33731ce1 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml @@ -74,16 +74,16 @@ para cada perfil de bate-papo que você tiver no aplicativo.]]> Melhor para bateria. Você receberá notificações apenas quando o aplicativo estiver em execução (SEM o serviço em segundo plano).]]> Consome mais bateria! O aplicativo em segundo plano está sempre em execução - as notificações são exibidas instantaneamente.]]> - BATE-PAPOS - ÍCONE DO APLICATIVO - BANCO DE DADOS DE BATE-PAPO + Bate-papos + Ícone do aplicativo + Banco de dados de bate-papo O bate-papo está em execução O bate-papo está parado Alterar senha do banco de dados\? endereço alterado para você Você e seu contato podem enviar mensagens temporárias. Backup de dados do aplicativo - CHAMADAS + Chamadas Aceitar solicitações de contato automaticamente Aparência O serviço em segundo plano está sempre em execução - as notificações serão exibidas assim que as mensagens estiverem disponíveis. @@ -247,7 +247,7 @@ Tempo de conexão esgotado Excluir mensagem do membro\? Excluir fila - DISPOSITIVO + Dispositivo Ferramentas de desenvolvedor conectando (introduzido) Tonalidade @@ -378,7 +378,7 @@ Arquivo salvo Os membros podem enviar mensagens de voz. O grupo será excluído para todos os membros - isso não pode ser desfeito! - AJUDA + Ajuda Ocultar contato e mensagem Como usar Como usar markdown @@ -420,7 +420,7 @@ Servidores ICE (um por linha) Ignorar A imagem será recebida quando seu contato estiver online, aguarde ou verifique mais tarde! - SERVIDORES + Servidores Recebendo via Status da conexão seg @@ -495,8 +495,8 @@ Usar proxy SOCKS\? Chamada rejeitada Restaurar o backup do banco de dados - PARA CONSOLE - EXECUTAR BATE-PAPO + Para console + Executar bate-papo Parar Definir senha para exportar Reinicie o aplicativo para usar o banco de dados do chat importado. @@ -571,7 +571,7 @@ você mudou o cargo de %s para %s Novo cargo de membro Remover - MEMBRO + Membro O membro será removido do grupo - isso não pode ser desfeito! Cargo Enviando via @@ -663,8 +663,8 @@ Interface chinesa e espanhola Maior redução no uso da bateria Mais melhorias chegarão em breve! - VOCÊ - MENSAGENS E ARQUIVOS + Você + Mensagens e arquivos Seu banco de dados de bate-papo Você removeu %1$s removido @@ -800,7 +800,7 @@ Ocultar perfil confirmação recebida… O servidor de relay protege seu endereço IP, mas pode observar a duração da chamada. - EXPERIMENTAL + Experimental você alterou o endereço Atualização do banco de dados O cargo será alterado para "%s". O membro receberá um novo convite. @@ -819,7 +819,7 @@ Somente o proprietários de grupo podem ativar mensagens de voz você compartilhou um link de uso único Você será conectado quando sua solicitação de conexão for aceita, aguarde ou verifique mais tarde! - CONFIGURAÇÕES + Configurações Defina a mensagem mostrada aos novos membros! Configurações Alternar endereço de recebimento @@ -848,7 +848,7 @@ Ligar Bem-vindo(a)! O futuro da transmissão de mensagens - PROXY SOCKS + Proxy SOCKS A tentativa de alterar a senha do banco de dados não foi concluída. Pare o bate-papo para exportar, importar ou excluir o banco de dados do chat. Você não poderá receber e enviar mensagens enquanto o chat estiver interrompido. %s segundo(s) @@ -887,7 +887,7 @@ chamada de vídeo Mostrar Servidores ICE WebRTC - TEMAS + Temas Atualizar O app busca novas mensagens periodicamente – ele usa alguns por cento da bateria por dia. O aplicativo não usa notificações por push – os dados do seu dispositivo não são enviados para os servidores. Para receber notificações, por favor, digite a senha do banco de dados @@ -1001,7 +1001,7 @@ desativado Desatualizar e abrir o bate-papo desativado - APOIE SIMPLEX CHAT + Apoie SimpleX Chat Esta ação não pode ser desfeita - as mensagens enviadas e recebidas antes do selecionado serão excluídas. Pode levar vários minutos. Confirme as atualizações do banco de dados Somente o cliente dos dispositivos armazenam perfis de usuários, contatos, grupos e mensagens. @@ -1127,7 +1127,7 @@ Você não perderá seus contatos se, posteriormente, excluir seu endereço. Endereço SimpleX Quando as pessoas solicitam uma conexão, você pode aceitá-la ou rejeitá-la. - CORES DA INTERFACE + Cores da interface compartilhar com os contatos A atualização do perfil será enviada aos seus contatos. Salvar configurações\? @@ -1253,7 +1253,7 @@ Correção não suportada pelo membro do grupo concordando com criptografia… Permitir o envio de arquivos e mídia. - APP + App criptografia OK renegociação de criptografia necessária criptografia concordada para %s @@ -1284,7 +1284,7 @@ Ativar recibos? Encontrar conversas mais rápido Contatos - ENVIAR RECIBOS DE ENTREGA PARA + Enviar recibos de entrega para Enviar confirmações está desativado para %d contatos. Enviar confirmações está ativado para %d contatos. Enviar confirmações @@ -1862,9 +1862,9 @@ Alto falante Headphones Sem Tor ou VPN, seu endereço de IP ficará visível para servidores de arquivo. - ARQUIVOS + Arquivos Fotos de perfil - ROTEAMENTO DE MENSAGEM PRIVADA + Roteamento de mensagem privada criptografia padrão ponta a ponta proprietários Migrar para outro dispositivo @@ -2055,7 +2055,7 @@ Barras de ferramentas de aplicativos acessível Falha no baixar de %1$d arquivo(s). %1$s mensagens não encaminhadas. - DADOS DO BATE-PAPO + Dados do bate-papo Utilize credenciais aleatórias O arquivo de banco de dados enviado será removido permanentemente dos servidores. Use credenciais diferentes de proxy para cada conexão. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml index 5f12e762aa..af292da504 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml @@ -28,9 +28,9 @@ Backup de dados da aplicação Aceitar imagens automaticamente Código de acesso definido! - VOCÊ - MENSAGENS E FICHEIROS - ÍCONE DA APLICAÇÃO + Você + Mensagens e ficheiros + Ícone da aplicação 1 mês Mensagens Adicionar mensagem de boas-vindas @@ -137,7 +137,7 @@ Eliminar Eliminar todos os ficheiros Eliminar base de dados - BASE DE DADOS DE CONVERSA + Base de dados de conversa Base de dados de conversa eliminada Nome para Exibição Mostrar: @@ -184,7 +184,7 @@ Colar ligação recebida Senha não encontrada na Keystore, por favor insira-a manualmente. Isto pode ter acontecido se você restaurou os dados da aplicação usando uma ferramenta de backup. Se não for o caso, entre em contato com os desenvolvedores. O servidor requer autorização para criar filas, verifique a senha - SERVIDORES + Servidores O servidor requer autorização para fazer upload, verifique a senha Usar hosts .onion como Não se o proxy SOCKS não o suportar.]]> Usar hosts .onion @@ -226,7 +226,7 @@ Chamada já finalizada! Chamada em curso Chamada finalizada - CHAMADAS + Chamadas Por perfil de conversa (padrão) ou por ligação (BETA). Não é possível aceder à Keystore para salvar a senha da base de dados Não é possível convidar o contato! @@ -251,7 +251,7 @@ Erro ao eliminar ligação de grupo Perfil de conversa Alterar o modo de bloqueio - CONVERSAS + Conversas Conversa em execução Erro ao alterar configuração Alterar a senha da base de dados\? @@ -471,7 +471,7 @@ ID da base de dados Eliminar ficheiro Eliminar contacto? - DISPOSITIVO + Dispositivo Mensagens diretas Descentralizado mensagem duplicada @@ -540,7 +540,7 @@ %1$d mensagens ignoradas. Importar base de dados As suas definições - DEFINIÇÕES + Definições Partilhar Partilhar endereço Definições @@ -554,12 +554,12 @@ \nEsta ação é irreversível - o seu perfil, contactos, mensagens e ficheiros serão irreversivelmente perdidos. Marcar como não lido membro - MEMBRO + Membro Máximo de 40 segundos, recebido instantaneamente. Mais Rede e servidores Configurações avançadas - EXPERIMENTAL + Experimental Você pode iniciar a conversa através das Definições da aplicação / Base de Dados ou reiniciando a aplicação. Atualizar A atualização das definições reconectará o cliente a todos os servidores. @@ -572,10 +572,10 @@ Muito provavelmente este contato eliminou a conexão consigo. Este texto está disponível nas definições Pode ser alterado mais tarde através das definições. - AJUDA - SUPORTE SIMPLEX CHAT + Ajuda + Suporte SimpleX Chat Funcionalidades experimentais - TEMAS + Temas Escuro Tema escuro nunca diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml index 81cf8ed452..b8f8f3f111 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml @@ -98,7 +98,7 @@ și %d alte evenimente Răspunde la apel Android Keystore va fi folosit pentru a stoca în siguranță parola după ce repornești aplicația sau schimbi parola — acest lucru va permite primirea de notificări. - APLICAȚIE + Aplicație Creează grup Apeluri audio și video Arhivează și încarcă @@ -109,7 +109,7 @@ Apel audio apel audio Audio oprit - PICTOGRAMĂ APLICAȚIE + Pictogramă aplicație Cod de acces aplicație Creează grup secret Creează coadă @@ -292,11 +292,11 @@ Afișează: Afișează erorile interne secret - SETĂRI + Setări %s conectat setați o nouă poză de profil Trimis la: %s - SERVERE + Servere Trimite mesaj în direct %s descărcat Partajați adresa cu contactele? @@ -376,7 +376,7 @@ Hash mesaj incorect Schimbă adresa de primire Conversația este oprită. Dacă ai folosit deja această bază de date pe alt dispozitiv, ar trebui să o transferi înapoi înainte de a porni conversația. - APELURI + Apeluri v-ați schimbat rolul în %s Capacitate depășită - destinatarul nu a primit mesajele trimise anterior. Schimbă codul de acces autodistructibil @@ -431,8 +431,8 @@ contactul are criptare e2e contactul nu are criptare e2e Contacte - CONVERSAȚII - BAZĂ DE DATE CONVERSAȚIE + Conversații + Bază de date conversație Baza de date a conversației a fost ștearsă Conversația rulează Baza de date a conversațiilor tale @@ -684,7 +684,7 @@ Verifică pentru actualizări Creează Estompează media - BAZĂ DE DATE CONVERSAȚIE + Bază de date conversație Conectează-te cu prietenii mai ușor. încercări Finalizat @@ -719,11 +719,11 @@ Setări apel audio criptat e2e apel video criptat e2e - DISPOZITIV - EXPERIMENTAL + Dispozitiv + Experimental Criptează erori de decriptare - TU + Tu nicio criptare e2e criptat e2e Apel video primit @@ -825,7 +825,7 @@ Notificări și baterie Deschide Activați confirmarea de primire pentru grupuri? - PENTRU CONSOLĂ + Pentru consolă Moderat la Remediați conexiunea Profiluri de conversație multiple @@ -915,7 +915,7 @@ Nicio conversație în lista %s. Nimic selectat deschis - AJUTOR + Ajutor Doar contactul tău poate trimite mesaje care dispar. Se importă arhiva Migrare dispozitiv @@ -1010,7 +1010,7 @@ Luminos Nu Deschizi linkul web? - MESAJE ȘI FIȘIERE + Mesaje și fișiere moderator Rol inițial Doar proprietarii grupului pot modifica preferințele grupului. @@ -1043,7 +1043,7 @@ Cum afectează bateria Activare (păstrați suprascrierile) Activează codul de autodistrugere - MEMBRU + Membru Operator de rețea Desktop-uri conectate Eroare la acceptarea membrului @@ -1148,7 +1148,7 @@ Activați confirmarea de primire? Nume nou afișat: Instrumente pentru dezvoltatori - FIȘIERE + Fișiere Deschide linkurile din lista de conversații Forma mesajului Importați baza de date @@ -1393,7 +1393,7 @@ Șterge Instalați SimpleX Chat pentru terminal Eroare la salvarea serverelor ICE - CULORILE INTERFEȚEI + Culorile interfeței %d fișier(e) cu dimensiunea totală de %s Fișiere și media invitat %1$s @@ -1636,7 +1636,7 @@ Conexiuni de profil și server răspuns primit… Politica de confidențialitate și condițiile de utilizare. - RUTAREA MESAJELOR PRIVATE + Rutarea mesajelor private Te rugăm să stochezi parola în siguranță, altfel NU o vei putea schimba dacă o pierzi. Parola nu a fost găsită în Keystore. Te rugăm să o introduci manual. Acest lucru s-ar putea întâmpla dacă ai restaurat datele aplicației folosind un instrument de backup. Dacă nu este cazul, te rugăm să contactezi dezvoltatorii. Parola stocată în Keystore nu poate fi citită. Acest lucru se poate întâmpla după o actualizare a sistemului incompatibilă cu aplicația. Dacă nu este cazul, te rugăm să contactezi dezvoltatorii. @@ -1767,7 +1767,7 @@ revizuit de administratori Operatori de server Serverul de retransmisie protejează adresa IP, dar poate observa durata apelului. - PORNIȚI CHATUL + Porniți chatul te-a eliminat %s la %s Protocolul serverului a fost modificat. @@ -1800,7 +1800,7 @@ Selectează profilul de conversație Salvează setările adresei SimpleX Salvează lista - TRIMITE CONFIRMĂRI DE LIVRARE LA + Trimite confirmări de livrare la Parola de autodistrugere a fost schimbată! Înregistrare actualizată la Selectează operatorii de rețea de utilizat. @@ -1973,9 +1973,9 @@ Aplicația va cere să confirmați descărcările de pe servere de fișiere necunoscute (cu excepția celor .onion sau când proxy-ul SOCKS este activat). Sistem Acestea pot fi ignorate în setările de contact și de grup. - SUPORT SIMPLEX CHAT - PROXY SOCKS - TEME + Suport SimpleX Chat + Proxy SOCKS + Teme În timpul importului au apărut câteva erori non-fatale: Atingeți pentru a vă alătura Atenție: este posibil să pierdeți unele date! @@ -2430,7 +2430,7 @@ Trimis contactului tău după conectare. Actualizezi la o adresă permanentă? Mesaj de bun venit - SOLICITĂRI DE CONTACT DE LA GRUPURI + Solicitări de contact de la grupuri Această setare este pentru profilul tău actual Partajează adresa veche Partajează linkul vechi diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml index b5bbf47973..18f9118d3d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml @@ -548,26 +548,26 @@ Отправлять картинки ссылок Резервная копия данных - ВЫ - НАСТРОЙКИ - ПОМОЩЬ - ПОДДЕРЖАТЬ SIMPLEX CHAT - УСТРОЙСТВО - ЧАТЫ + Вы + Настройки + Помощь + Поддержать SimpleX Chat + Устройство + Чаты Инструменты разработчика Экспериментальные функции - SOCKS-ПРОКСИ - ЗНАЧОК - ТЕМЫ - СООБЩЕНИЯ И ФАЙЛЫ - ЗВОНКИ + SOCKS-прокси + Значок + Темы + Сообщения и файлы + Звонки Режим Инкогнито База данных - ЗАПУСТИТЬ ЧАТ + Запустить чат Чат запущен Чат остановлен - БАЗА ДАННЫХ + База данных Пароль базы данных Экспорт архива чата Импорт архива чата @@ -767,7 +767,7 @@ Ошибка при удалении ссылки группы Только владельцы группы могут изменять предпочтения группы. - ДЛЯ КОНСОЛИ + Для консоли Локальное имя ID базы данных @@ -775,7 +775,7 @@ Отправить сообщение Член группы будет удалён - это действие нельзя отменить! Удалить - ЧЛЕН ГРУППЫ + Член группы Роль Поменять роль Поменять @@ -790,7 +790,7 @@ прямое непрямое (%1$s) - СЕРВЕРЫ + Серверы Получение через Отправка через Состояние сети @@ -1084,7 +1084,7 @@ Ожидание видео Видео будет получено когда Ваш контакт загрузит его. Скрыть: - ЭКСПЕРИМЕНТАЛЬНЫЕ + Экспериментальные Только 10 видео могут быть отправлены одновременно Раскрыть профиль Видео будет получено, когда Ваш контакт будет онлайн, пожалуйста, подождите или проверьте позже! @@ -1226,7 +1226,7 @@ Запретить реакции на сообщения. Запретить реакции на сообщения. секунд - ЦВЕТА ИНТЕРФЕЙСА + Цвета интерфейса Поделиться адресом с контактами SimpleX? Обновление профиля будет отправлено Вашим SimpleX контактам. Об адресе SimpleX @@ -1347,15 +1347,15 @@ Только владельцы группы могут разрешить файлы и медиа. Файлы и медиа Выключить\? - ПРИЛОЖЕНИЕ + Приложение Перезапустить Выключить Выключить для всех Включить для всех Включить (кроме исключений) Выключить отчёты о доставке\? - ОТПРАВКА ОТЧЁТОВ О ДОСТАВКЕ - ЗАПРОСЫ НА СОЕДИНЕНИЕ ИЗ ГРУПП + Отправка отчётов о доставке + Запросы на соединение из групп шифрование согласовано шифрование согласовано для %s шифрование работает @@ -1829,7 +1829,7 @@ Форма картинок профилей Квадрат, круг и все, что между ними. Будет включено в прямых разговорах! - ФАЙЛЫ + Файлы Новые темы чатов нет Светлая @@ -1898,7 +1898,7 @@ Показать статус сообщения Прямая доставка сообщений Режим доставки сообщений - КОНФИДЕНЦИАЛЬНАЯ ДОСТАВКА СООБЩЕНИЙ + Конфиденциальная доставка сообщений Цвета чата Тема чата Тема профиля @@ -2151,7 +2151,7 @@ Переслать сообщения… Проверьте правильность ссылки SimpleX. Ошибка ссылки - БАЗА ДАННЫХ + База данных Ошибка инициализации WebView. Убедитесь, что у вас установлен WebView и его поддерживаемая архитектура - arm64.\nОшибка: %s Звук отключен Сообщения будут удалены - это нельзя отменить! @@ -2719,9 +2719,9 @@ Открыть канал Открыть новый канал Владельцы - ВЛАДЕЛЕЦ + Владелец релей - РЕЛЕЙ + Релей Адрес релея Адрес релея Ошибка подключения релея @@ -2740,7 +2740,7 @@ Поделиться адресом релея Поделиться в чате ⚠️ Ошибка проверки подписи: %s. - ПОДПИСЧИК + Подписчик Подписчики Подписчик будет удалён из канала - это нельзя отменить! Начните разговор @@ -2801,7 +2801,7 @@ Вы перестанете получать сообщения из этого канала. История чата сохранится. обновлён профиль канала ошибка - ОШИБКА СОЕДИНЕНИЯ + Ошибка соединения Чат с админами Разрешить членам группы общаться с админами. Запретить чаты с админами. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml index c355d8d9fb..df879fe7ff 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml @@ -996,7 +996,7 @@ ข้อความที่ข้ามไป ส่ง ระบบ - สนับสนุน SIMPLEX แชท + สนับสนุน SimpleX Chat พร็อกซี SOCKS หยุด หยุดแชท\? diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml index 0e9c54fb87..d56d308654 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml @@ -50,8 +50,8 @@ Aramayı cevapla Uygulama veri yedekleme Tüm uygulama verileri silinir. - UYGULAMA - UYGULAMA SİMGESİ + Uygulama + Uygulama simgesi 1 hafta şifreleme kabul ediliyor… yönetici @@ -86,7 +86,7 @@ Konuştuğunuz kişinin uygulamasından güvenlik kodunu okut. WebRTC ICE sunucu adreslerinin doğru formatta olduğundan emin olun: Satırlara ayrılmış ve yinelenmemiş şekilde. Kaydet - ARAYÜZ RENKLERİ + Arayüz renkleri SimpleX adres ayarlarını kaydet Ayarlar kaydedilsin mi? Kaydet ve konuştuğun kişilere bildir @@ -97,7 +97,7 @@ Ses kapalı Doğrulama iptal edildi Yeniden başlat - TEMALAR + Temalar İçe aktarılan konuşma veri tabanını kullanmak için uygulamayı yeniden başlat. Yeni bir konuşma profili oluşturmak için uygulamayı yeniden başlatın. Geri Yükle @@ -184,7 +184,7 @@ silindi dosya alma henüz desteklenmiyor sen - geçersi̇z sohbet + geçersiz sohbet bağlantı %1$d Tarayıcı ile %1$s tarafından @@ -287,10 +287,10 @@ Ek ikincil renk Üyeyi çıkar Kaldır - ARAMALAR - SOHBETLER - SEN - SOHBET VERİTABANI + Aramalar + Sohbetler + Sen + Sohbet veritabanı Kaldır Yanlış parola! Veritabanı yükseltmelerini onayla @@ -300,7 +300,7 @@ bağlanılıyor (duyuruldu) sen: %1$s kaldırıldı - ÜYE + Üye Üyeler kendiliğinden yok olan mesajlar gönderebilir. Kendiliğinden yok olan mesaj gönderimini engelle. Yalnızca kişiniz sesli mesaj göndermeye izin veriyorsa sen de ver. @@ -422,7 +422,7 @@ Eğer uygulamayı açarken tüm verileri yok eden erişim kodunu girersen: Eğer uygulamayı açarken bu erişim kodunu kullanırsan uygulama içi tüm veriler kalıcı olarak silinecektir! Erişim kodu belirle - AYGIT + Aygit Veri tabanı parolası Veri tabanı, rastgele bir parola ile şifrelendi. Dışa aktarmadan önce lütfen değiştir. Dosyaları ve medyayı sil\? @@ -600,7 +600,7 @@ ICE sonucuları kaydedilirken hata oluştu Kullanıcı gizliliği güncellenirken hata oluştu Gözde - DENEYSEL + Deneysel Dosya, sunuculardan silinecektir. Yedekleri geri yükledikten sonra şifrelemeyi onar. Fransız arayüzü @@ -622,7 +622,7 @@ Konuşma başlatılırken hata oluştu Deneysel özellikler Veri tabanını dışa aktar - YARDIM + Yardim Veri tabanını içe aktar Konuşma durdulurken hata oluştu İçe aktar @@ -634,7 +634,7 @@ grup profili güncellendi grup silindi Toplu konuşma bağlantısı güncellenirken hata oluştu - UÇBİRİM İÇİN + Uçbirim için Grup bağlantısı Grup dolaylı (%1$s) @@ -801,7 +801,7 @@ arama sona erdi %1$s Kilit ekranında aramalar: Kötü mesaj kimliği - MESAJLAR VE DOSYALAR + Mesajlar ve dosyalar Veri tabanı parolasını değiştir\? Parola Keystore\'da bulunamadı, lütfen manuel olarak girin. Bu, uygulamanın verilerini bir yedekleme aracı kullanarak geri yüklediyseniz olabilir. Eğer durum böyle değilse, lütfen geliştiricilerle iletişime geçin. Ayrıl @@ -1037,7 +1037,7 @@ Sohbeti durdur Mevcut profili kullan Sistem - SIMPLEX CHAT\'İ DESTEKLE + SimpleX Chat\'i destekle Sohbet veri tabanını dışa aktarmak, içe aktarmak veya silmek için sohbeti durdur. Sohbet durdurulduğunda mesaj alamaz ve gönderemezsiniz. Masaüstü Bağlanmak için dokun @@ -1134,7 +1134,7 @@ Bağlantı paylaş SimpleX Ekibi %s, %s ve %s bağlandı - SOCKS VEKİLİ + SOCKS vekili Masaüstür cihazlar SMP sunucuları Uyumlu değil! @@ -1217,7 +1217,7 @@ güvenlik kodu değiştirildi Bluetooth desteği ve diğer iyileştirmeler. Ayarlar - AYARLAR + Ayarlar Bağlanmak için doğrudan mesaj gönderin Güvenlik kodu Daha hızlı gruplara katılma ve daha güvenilir mesajlar. @@ -1352,7 +1352,7 @@ Yönlendirici sunucusu sadece lazım ise kullanılacak. Diğer taraf IP adresini görebilir. %s ın bağlantısı kesildi]]> Sunucuyu test et - SUNUCULAR + Sunucular Sunucuları test et Mesaj taslağı Bir mesajı yok edin @@ -1361,7 +1361,7 @@ Kişi doğrulandı Rasgele parola kullan Sistem yetkilendirilmesi yerine ayarla. - SOHBETİ ÇALIŞTIR + Sohbeti çalıştır Direkt internet bağlantısı kullan? grup profili güncellendi Onion ana bilgisayarları bağlantı için gerekli olacaktır. @@ -1446,7 +1446,7 @@ Yeni sohbet Bir canlı mesaj gönder - bu yazdıklarını anlık olarak alıcıya(lara) güncelleyen bir mesajdır Şifre Yöneticisindeki parola silinsin mi? - LERE GÖNDER + Lere gönder Takma adla bağlan Her zaman yönlendirici kullan. Kilidini aç @@ -1778,7 +1778,7 @@ Sizin veya hedef sunucunun özel yönlendirmeyi desteklememesi durumunda bile mesajları doğrudan GÖNDERMEYİN. IP adresi korumalı olduğunda ve sizin veya hedef sunucunun özel yönlendirmeyi desteklemediği durumlarda mesajları doğrudan gönderin. Sizin veya hedef sunucunun özel yönlendirmeyi desteklemediği durumlarda mesajları doğrudan gönderin. - GİZLİ MESAJ YÖNLENDİRME + Gizli mesaj yönlendirme Mesaj durumunu göster IP adresinizi korumak için,gizli yönlendirme mesajları iletmek için SMP sunucularınızı kullanır. Korumasız @@ -1805,7 +1805,7 @@ Karanlık Aydınlık mod IP adresini koru - DOSYALAR + Dosyalar Sohbet renkleri Sığdır Alınan cevap diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml index 4e62631dbb..b61b77ec9d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml @@ -33,14 +33,14 @@ Дозволити надсилати зникаючі повідомлення. прийнятий виклик Завжди використовувати реле - ДОДАТОК + Додаток Дозволити надсилання приватних повідомлень учасникам. Дозволити безповоротно видаляти надіслані повідомлення. (24 години) Дозволяйте надсилати голосові повідомлення. Дозволити реакції на повідомлення. Вся інформація стирається при його введенні. Пароль для додатка - ІКОНКА ДОДАТКУ + Іконка додатку Дозволити зникаючі повідомлення тільки за умови, що ваш контакт дозволяє їх. Дозвольте вашим контактам додавати реакції на повідомлення. Дозволити реакції на повідомлення тільки за умови, що ваш контакт дозволяє їх. @@ -278,8 +278,8 @@ Повернути камеру Відхилений виклик %1$d пропущено повідомлень - ЧАТИ - SOCKS-ПРОКСІ + Чати + SOCKS-проксі Помилка при запуску чату Зупинити Імпортувати @@ -416,9 +416,9 @@ Підключення виклику Конфіденційність і безпека Конфіденційність - НАЛАШТУВАННЯ - ДОПОМОГА - ПІДТРИМАЙТЕ SIMPLEX CHAT + Налаштування + Допомога + Підтримайте SimpleX Chat Зупиніть чат, щоб експортувати, імпортувати або видалити базу даних чату. Ви не зможете отримувати та надсилати повідомлення, поки чат зупинено. Помилка видалення бази даних чату Сповіщення будуть доставлятися лише до зупинки додатка! @@ -464,7 +464,7 @@ Перезапустити База даних чату Чат зупинено - БАЗА ДАНИХ ЧАТУ + База даних чату Новий архів бази даних Зупинити чат\? Ваша поточна база даних чату буде ВИДАЛЕНА та ЗАМІНЕНА імпортованою. @@ -658,11 +658,11 @@ Пароль самознищення увімкнено! Пароль самознищення змінено! Ваш профіль, контакти та доставлені повідомлення зберігаються на вашому пристрої. - ВИ - ПРИСТРІЙ + Ви + Пристрій Вимкнути - ТЕМИ - ПОВІДОМЛЕННЯ ТА ФАЙЛИ + Теми + Повідомлення та файли Чат працює Імпортувати базу даних Старий архів бази даних @@ -782,7 +782,7 @@ місяці Ви вже підключені до %1$s через це посилання. Режим інкогніто - СЕРВЕРИ + Сервери Зберегти вітальне повідомлення? Отримання через Приглушено, коли неактивно! @@ -879,7 +879,7 @@ %d день %d днів скасовано %s - ЗАПУСК ЧАТУ + Запуск чату Пароль бази даних Експортувати базу даних Видалити всі файли @@ -930,7 +930,7 @@ змінює адресу… Залишити спостерігач - УЧАСНИК + Учасник Режим інкогніто захищає вашу конфіденційність, використовуючи новий випадковий профіль для кожного контакту. Більше поліпшень незабаром! Тільки власники груп можуть увімкнути голосові повідомлення. @@ -979,7 +979,7 @@ Контакт відмічено Ви намагаєтеся запросити контакт, з яким ви поділилися інкогніто-профілем, до групи, в якій ви використовуєте основний профіль Помилка при створенні посилання на групу - ДЛЯ КОНСОЛІ + Для консолі Учасника буде вилучено з групи - цю дію неможливо скасувати! Змінити роль Ви все ще отримуватимете дзвінки та сповіщення від приглушених профілів, коли вони активні. @@ -1021,7 +1021,7 @@ Хост Порт Обов\'язково - КОЛЬОРИ ІНТЕРФЕЙСУ + Кольори інтерфейсу Створіть адресу, щоб дозволити людям підключатися до вас. Контакти залишатимуться підключеними. Створити SimpleX-адресу @@ -1110,7 +1110,7 @@ Галерея Команда SimpleX хоче підключитися до вас! - ЕКСПЕРИМЕНТАЛЬНІ ФУНКЦІЇ + Експериментальні функції Ви повинні використовувати найновішу версію бази даних чату лише на одному пристрої, інакше ви можете припинити отримання повідомлень від деяких контактів. Цей параметр застосовується до повідомлень у вашому поточному профілі чату Зашифрована база даних @@ -1163,7 +1163,7 @@ Кожен може хостити сервери. Інструменти розробника Експериментальні функції - ДЗВІНКИ + Дзвінки Зберегти ключову фразу в сховищі ключів Помилка шифрування бази даних Вилучити ключову фразу із сховища ключів? @@ -1259,7 +1259,7 @@ Заборонено файли та медіа! Буде відправлено ваш профіль %1$s. Вимкнути для всіх груп - НАДСИЛАТИ ПОВІДОМЛЕННЯ ПРО ДОСТАВКУ + Надсилати повідомлення про доставку Підключитися безпосередньо? %s: %s Запит на підключення буде відправлено учаснику групи. @@ -1734,7 +1734,7 @@ Звуки вхідного дзвінка Світлий режим Запасний варіант маршрутизації повідомлень - МАРШРУТИЗАЦІЯ ПРИВАТНИХ ПОВІДОМЛЕНЬ + Маршрутизація приватних повідомлень переслано Інше Дозволити надсилати посилання SimpleX. @@ -1746,7 +1746,7 @@ Камера та мікрофон Надайте дозвіл(и) на здійснення дзвінків Відкрити налаштування - ФАЙЛИ + Файли Зображення профілів Підключення до мережі адміністратори @@ -2049,7 +2049,7 @@ Скинути всі підказки Доступно оновлення: %s Завантаження оновлення скасовано - БАЗА ДАНИХ ЧАТУ + База даних чату Вибрати профіль чату Помилка при зміні профілю Повідомлення будуть видалені — це не можна скасувати! @@ -2513,7 +2513,7 @@ Дозвольте своїм контактам надсилати файли та медіа. Бот Ви, і ваш контакт можете надсилати файли та медіа. - ЗАПИТИ НА ЗВ’ЯЗОК ВІД ГРУП + Запити на зв’язок від груп Застарілі опції Помилка при відмітці як прочитане Файли та медіа заборонені у цьому чаті. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml index 235158585d..93347fa228 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml @@ -93,14 +93,14 @@ Ứng dụng chỉ có thể nhận thông báo khi nó đang chạy, không có dịch vụ nền nào được khởi động Bản dựng ứng dụng: %s Giao diện - ỨNG DỤNG + Ứng dụng Di chuyển dữ liệu ứng dụng Sao lưu dữ liệu ứng dụng Mã truy cập ứng dụng đã được thay thế bằng mã tự hủy. Ứng dụng mã hóa các tệp cục bộ mới (trừ video). Áp dụng Mã truy cập ứng dụng - BIỂU TƯỢNG ỨNG DỤNG + Biểu tượng ứng dụng Mã truy cập Phiên bản ứng dụng: v%s Phiên bản ứng dụng @@ -184,7 +184,7 @@ Cuộc gọi kết thúc cuộc gọi kết thúc %1$s lỗi cuộc gọi - CUỘC GỌI + Cuộc gọi Hủy xem trước ảnh Hủy xem trước tệp Hủy @@ -234,11 +234,11 @@ đang thay đổi địa chỉ cho %s… Tùy chọn trò chuyện Màu trò chuyện - CƠ SỞ DỮ LIỆU TRÒ CHUYỆN + Cơ sở dữ liệu trò chuyện Kết nối trò chuyện đã được dừng lại Cơ sở dữ liệu đã được di chuyển! Các cuộc trò chuyện - CÁC CUỘC TRÒ CHUYỆN + Các cuộc trò chuyện Kiểm tra tin nhắn mới mỗi 10 phút trong tối đa 1 phút Giao diện Trung Quốc và Tây Ban Nha Trò chuyện với nhà phát triển @@ -463,7 +463,7 @@ Xóa máy chủ Xóa hàng đợi Công cụ nhà phát triển - THIẾT BỊ + Thiết bị Tùy chọn cho nhà phát triển Xác thực thiết bị đã bị vô hiệu hóa. Tắt Khóa SimpleX. Lỗi máy chủ đích: %1$s @@ -738,7 +738,7 @@ Lỗi cập nhật cấu hình mạng Lỗi cập nhật quyền riêng tư người dùng Mở rộng chọn chức vụ - THỬ NGHIỆM + Thử nghiệm Mở rộng Thoát mà không lưu đã hết hạn @@ -748,7 +748,7 @@ Xuất cơ sở dữ liệu Lỗi tải lên kho lưu trữ Tập tin đã xuất không tồn tại - TẬP TIN + Tập tin Không thể tải các cuộc trò chuyện Không tìm thấy tệp - có thể tập tin đã bị xóa và hủy bỏ. Lỗi tệp @@ -776,7 +776,7 @@ Tệp sẽ được nhận khi liên hệ của bạn hoạt động, vui lòng chờ hoặc kiểm tra lại sau! Trạng thái tệp: %s Lấp đầy - CƠ SỞ DỮ LIỆU TRÒ CHUYỆN + Cơ sở dữ liệu trò chuyện Lỗi chuyển đổi hồ sơ Lọc các cuộc hội thoại chưa đọc và các cuộc hội thoại yêu thích. Cuối cùng, chúng ta đã có chúng! 🚀 @@ -821,7 +821,7 @@ Máy chủ chuyển tiếp %1$s không thể kết nối tới máy chủ đích %2$s. Vui lòng thử lại sau. Địa chỉ máy chủ chuyển tiếp không tương thích với cài đặt mạng: %1$s. Phiên bản máy chủ chuyển tiếp không tương thích với cài đặt mạng: %1$s. - CHO CONSOLE + Cho console Chuyển tiếp tin nhắn… Giảm thiểu sử dụng pin hơn nữa Chuyển tiếp tin nhắn mà không có tệp? @@ -863,7 +863,7 @@ Xin chào! \nKết nối với tôi qua SimpleX Chat: %s Ẩn hồ sơ - TRỢ GIÚP + Trợ giúp Nhóm sẽ bị xóa cho tất cả các thành viên - điều này không thể hoàn tác! Nhóm sẽ bị xóa cho bạn - điều này không thể hoàn tác! Tùy chọn nhóm @@ -952,7 +952,7 @@ Cài đặt cập nhật Cuộc gọi video đến Phiên bản không tương thích - MÀU SẮC GIAO DIỆN + Màu sắc giao diện đã được mời Đường dẫn không hợp lệ cuộc trò chuyện không hợp lệ @@ -1001,7 +1001,7 @@ Mời thành viên Mời vào nhóm Rời nhóm - THÀNH VIÊN + Thành viên Thông tin hàng đợi tin nhắn Chỉ dữ liệu hồ sơ cục bộ Giữ lại các kết nối của bạn @@ -1050,7 +1050,7 @@ Thành viên sẽ bị xóa khỏi nhóm - việc này không thể được hoàn tác! Chế độ sáng UI tiếng Litva - TIN NHẮN VÀ TỆP + Tin nhắn và tệp Việc xóa tin nhắn mà không thể phục hồi là bị cấm. Tham gia vào các cuộc trò chuyện nhóm Chế độ định tuyến tin nhắn @@ -1323,7 +1323,7 @@ Mật khẩu hồ sơ Ghi chú riêng tư Cấm xóa tin nhắn mà không thể phục hồi. - ĐỊNH TUYẾN TIN NHẮN RIÊNG TƯ + Định tuyến tin nhắn riêng tư Tên hồ sơ: ảnh đại diện Hồ sơ và các kết nối máy chủ @@ -1581,7 +1581,7 @@ Đặt lại tất cả số liệu thống kê? Lưu mật khẩu và mở kết nối trò chuyện Gửi - KHỞI CHẠY KẾT NỐI TRÒ CHUYỆN + Khởi chạy kết nối trò chuyện Lưu Quét / Dán đường dẫn Quét mã QR máy chủ @@ -1623,7 +1623,7 @@ Lưu lời chào? gửi thất bại Quét mã bảo mật từ ứng dụng của liên hệ bạn. - GỬI CHỈ BÁO ĐÃ NHẬN TỚI + Gửi chỉ báo đã nhận tới Tìm kiếm Tìm kiếm hoặc dán đường dẫn SimpleX Lưu danh sách @@ -1685,7 +1685,7 @@ đặt ảnh đại diện mới Các tin nhắn đã gửi sẽ bị xóa sau thời gian đã cài. thông tin hàng đợi máy chủ: %1$s\n\ntin nhắn được nhận cuối cùng: %2$s - CÀI ĐẶT + Cài đặt Đã gửi vào Địa chỉ máy chủ Mã phiên @@ -1716,7 +1716,7 @@ Đặt mật khẩu Cài đặt Địa chỉ máy chủ không tương thích với cài đặt mạng: %1$s. - CÁC MÁY CHỦ + Các máy chủ Máy chủ yêu cầu xác thực để tải lên, kiểm tra mật khẩu Máy chủ Đặt mã truy cập @@ -1828,7 +1828,7 @@ Loa ngoài bật Âm thanh đã bị tắt Ổn định - PROXY SOCKS + Proxy SOCKS Các nhóm nhỏ (tối đa 20 thành viên) Một vài lỗi không nghiêm trọng đã xảy ra trong lúc nhập: Loa ngoài tắt @@ -1896,7 +1896,7 @@ Xin gửi lời cảm ơn tới các người dùng đã góp công qua Weblate! Xin gửi lời cảm ơn tới các người dùng đã góp công qua Weblate! Chế độ hệ thống - HỖ TRỢ SIMPLEX CHAT + Hỗ trợ SimpleX Chat Lỗi tệp tạm thời Nhấn nút Kết nối TCP @@ -1947,7 +1947,7 @@ ID của tin nhắn tiếp theo là không chính xác (nhỏ hơn hoặc bằng với cái trước).\nViệc này có thể xảy ra do một vài lỗi hoặc khi kết nối bị xâm phạm. Nỗ lực đổi mật khẩu cơ sở dữ liệu đã không được hoàn thành. Tên thiết bị sẽ được chia sẻ với thiết bị di động đã được kết nối. - CÁC CHỦ ĐỀ + Các chủ đề Tên hiển thị này không hợp lệ. Xin vui lòng chọn một cái tên khác. Hồ sơ chỉ được chia sẻ với các liên hệ của bạn. Cuộc trò chuyện này được bảo vệ bằng mã hóa đầu cuối có kháng lượng tử. @@ -2196,7 +2196,7 @@ Bạn có thể thay đổi nói trong cài đặt Giao diện. Bạn đang tham gia nhóm thông qua đường dẫn này. Bạn có thể bật vào lúc sau thông qua Cài đặt - BẠN + Bạn Bạn có thể chia sẻ một đường dẫn hoặc mã QR - bất kỳ ai cũng sẽ có thể tham gia nhóm. Bạn sẽ không mất các thành viên của nhóm nếu sau này bạn xóa nó đi. Bạn có thể thử một lần nữa. Bạn đã kết nối tới máy chủ dùng để nhận tin nhắn từ liên hệ này. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml index 1392d7b42b..ff69251bc6 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml @@ -754,7 +754,7 @@ 下一代私密通讯软件 粘贴你收到的链接 已跳过消息 - 支持 SIMPLEX CHAT + 支持 SimpleX Chat 发送链接预览 SOCKS 代理 停止聊天程序? diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml index 9ec116058a..9e51b9ba9d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml @@ -668,7 +668,7 @@ 裝置 幫助 設定 - 幫助 SIMPLEX CHAT + 幫助 SimpleX Chat 聊天 開發者工具 SOCKS 代理伺服器 diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt index a1f70213d0..c875c0c9da 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt @@ -1,6 +1,5 @@ package chat.simplex.common.views.chatlist -import SectionDivider import androidx.compose.foundation.* import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.foundation.layout.* @@ -62,6 +61,6 @@ actual fun ChatListNavLinkLayout( if (selectedChat.value || nextChatSelected.value) { Divider() } else { - SectionDivider() + Divider(Modifier.padding(horizontal = 8.dp)) } } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt index 66be736fca..6b36a3b1b2 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt @@ -1,10 +1,11 @@ package chat.simplex.common.views.usersettings +import CARD_PADDING import SectionBottomSpacer import SectionDividerSpaced -import SectionSpacer import SectionTextFooter import SectionView +import itemHPadding import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape @@ -23,7 +24,7 @@ import chat.simplex.common.model.CloseBehavior import chat.simplex.common.model.SharedPreference import chat.simplex.common.trayIsAvailable import chat.simplex.common.platform.* -import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import dev.icerock.moko.resources.compose.stringResource @@ -82,10 +83,10 @@ fun AppearanceScope.AppearanceLayout( SectionDividerSpaced() ProfileImageSection() - SectionDividerSpaced(maxTopPadding = true) + SectionDividerSpaced() FontScaleSection() - SectionDividerSpaced(maxTopPadding = true) + SectionDividerSpaced() DensityScaleSection() SectionBottomSpacer() @@ -110,8 +111,8 @@ private fun MinimizeToTraySection() { @Composable fun DensityScaleSection() { val localDensityScale = remember { mutableStateOf(appPrefs.densityScale.get()) } - SectionView(stringResource(MR.strings.appearance_zoom).uppercase(), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) { - Row(Modifier.padding(top = 10.dp), verticalAlignment = Alignment.CenterVertically) { + SectionView(stringResource(MR.strings.appearance_zoom), contentPadding = PaddingValues(horizontal = CARD_PADDING)) { + Row(Modifier.padding(vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) { Box(Modifier.size(50.dp) .background(MaterialTheme.colors.surface, RoundedCornerShape(percent = 22)) .clip(RoundedCornerShape(percent = 22)) From 2b48b551907f4ed27910777cf95916dfa382a259 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 22 May 2026 20:52:01 +0000 Subject: [PATCH 002/200] core: deliver member profiles via relay (#6953) --- ...6-04-29-member-profile-sending-channels.md | 11 + ...05-08-public-groups-via-relays-overview.md | 45 +++ plans/2026-05-08-public-groups-via-relays.md | 378 ++++++++++++++++++ simplex-chat.cabal | 2 + src/Simplex/Chat/Delivery.hs | 2 +- src/Simplex/Chat/Library/Commands.hs | 2 +- src/Simplex/Chat/Library/Internal.hs | 6 +- src/Simplex/Chat/Library/Subscriber.hs | 138 ++++++- src/Simplex/Chat/Messages/Batch.hs | 119 +++++- src/Simplex/Chat/Protocol.hs | 6 +- src/Simplex/Chat/Store/Delivery.hs | 36 +- src/Simplex/Chat/Store/Groups.hs | 22 +- src/Simplex/Chat/Store/Postgres/Migrations.hs | 4 +- .../M20260515_delivery_job_senders.hs | 56 +++ .../Store/Postgres/Migrations/chat_schema.sql | 13 +- src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../M20260515_delivery_job_senders.hs | 55 +++ .../SQLite/Migrations/chat_query_plans.txt | 121 +++--- .../Store/SQLite/Migrations/chat_schema.sql | 6 +- src/Simplex/Chat/View.hs | 2 +- tests/Bots/DirectoryTests.hs | 8 +- tests/ChatTests/Groups.hs | 330 +++++++++++++-- tests/PostgresSchemaDump.hs | 4 +- tests/SchemaDump.hs | 6 +- 24 files changed, 1196 insertions(+), 180 deletions(-) create mode 100644 plans/2026-05-08-public-groups-via-relays-overview.md create mode 100644 plans/2026-05-08-public-groups-via-relays.md create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations/M20260515_delivery_job_senders.hs create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20260515_delivery_job_senders.hs diff --git a/plans/2026-04-29-member-profile-sending-channels.md b/plans/2026-04-29-member-profile-sending-channels.md index 2ee36b676e..be7d26ecab 100644 --- a/plans/2026-04-29-member-profile-sending-channels.md +++ b/plans/2026-04-29-member-profile-sending-channels.md @@ -1,5 +1,16 @@ # Plan: Member Profile Sending in Channels +## Implementation note (2026-05-18) + +The shipped implementation is **monotonic and reuses `member_relations_vector`**, not a new `sent_profile_vector` column: + +- The introduction bit lives in `group_members.member_relations_vector` with status `MRIntroduced`. The M20251117 backfill already populates this column for channel rows (relay role is not admin/owner), and `createNewGroupMember` writes `Binary B.empty` for new members. +- Bits flip 0 → 1 when the relay first announces the member to a recipient via prepended `XGrpMemNew` (or via `XGrpMemIntro` in `introduceInChannel`'s join-time direct path). They are **never cleared**. +- Profile updates propagate via the sender's own signed `XInfo`, forwarded unchanged by the relay. The relay updates its DB on receipt; subscribers verify with the key obtained from the earlier `XGrpMemNew`. Section 5 below ("Clear vector on profile update") is superseded by this — no clearing happens. +- The mutually-exclusive two-column delivery-jobs storage (`single_sender_group_member_id` + `sender_group_member_ids`) collapses into a single nullable `sender_group_member_ids BYTEA` column: `[s]` for single-sender jobs, `[s1, s2, ...]` for multi-sender batches, NULL for sender-less jobs (`DJRelayRemoved`). + +The plan body below is preserved for historical context. + ## Context In channels (relayed groups), subscribers don't know profiles of other subscribers. When subscriber A sends a reaction/message that gets forwarded to subscriber B, B creates an "unknown member" record with a synthesized name. This degrades UX — subscribers see "unknown member" instead of real profiles. diff --git a/plans/2026-05-08-public-groups-via-relays-overview.md b/plans/2026-05-08-public-groups-via-relays-overview.md new file mode 100644 index 0000000000..c0114e237d --- /dev/null +++ b/plans/2026-05-08-public-groups-via-relays-overview.md @@ -0,0 +1,45 @@ +# Public groups via relays — plan summary + +A third kind of group: relay-mediated like channels, but every member can post like a +secret group. Resolves the scale ceiling of full-mesh groups without the broadcast-only +governance of channels. Two orthogonal axes, already in the model: + +| `useRelays` | `groupType` | Name | +|-------------|--------------|--------------| +| false | (none) | Secret group | +| true | `GTChannel` | Channel | +| true | `GTGroup` | Public group | ← new +| true | `GTUnknown` | refuse | ← older client sees this for `"group"` + +`useRelays` is transport; `groupType` is the governance model (broadcast vs +participatory). The joiner role is a per-group value the owner sets at creation, +carried on the (owner-signed) channel profile, so every relay derives the same role for +the same group — not from a relay-side global config and not from `groupType`. The +blocker is narrow: no path produces `GTGroup` today, and the channel profile carries +no joiner-role field yet. + +## Shape of the work + +Backend: wire/version bump, type helpers, create command, owner-configured joiner-role +field on the channel profile, relay role derivation from that field. Clients (iOS + +Kotlin mirror): model, audit splitting transport-vs-governance call sites, unified +create flow with a Channel/Public-group toggle that picks the joiner-role default, +views, connect-plan messaging. + +## Threat model deltas vs. channels + +**Relay can fabricate content as any member** (broader than channels, where it could +only forge as owners). Same deniability property as channels by design; future fix is +opt-in content signing. + +Everything else in the channel threat model carries over unchanged. Out of scope for +now: member-to-member DMs in relay-mediated groups — deferred, not killed. + +## Sequencing & boundary + +Hard prerequisite: the member-profile dissemination plan +(`2026-04-29-member-profile-sending-channels.md`) lands first. Then backend → iOS → +Kotlin; platforms ship independently; older clients refuse to join. Owner→relay +role/rejection-rule communication and owner-signature verification on the channel +profile by relays are not planned here — both apply to channels equally; neither blocks +Public groups. diff --git a/plans/2026-05-08-public-groups-via-relays.md b/plans/2026-05-08-public-groups-via-relays.md new file mode 100644 index 0000000000..702f06fe7f --- /dev/null +++ b/plans/2026-05-08-public-groups-via-relays.md @@ -0,0 +1,378 @@ +# Plan: Public groups via relays + +Date: 2026-05-08 + +## 1. Overview + +Channels (shipped) are relay-mediated groups in which the relay forwards +content from any sender, but subscribers are pinned to `GRObserver` and +cannot post. Public groups are the second value of the same two-axis design: +same wire, same transport, members can post. The blocker is narrow — no +path produces `groupType = GTGroup`, and the relay's joiner-role default +comes from a global config instead of the owner-signed channel profile. +Add a `memberRole` field to the profile, plumb it (with `groupType`) +through the create command, derive the relay's joiner role from it, audit +clients for sites that conflate transport with governance, ride on the +approved member-profile dissemination plan. Member-to-member DMs in +relay-mediated groups are deferred (§10). + +## 2. Concept summary: the `useRelays × groupType` matrix + +| `useRelays` | `groupType` | Name | Wire shape | UX | +|-------------|-------------------------|-------------------|-------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------| +| `false` | (no `publicGroup`) | **Secret group** | P2P `x.grp.inv` invitations; full mesh between members; JSON array batch. | Today's group: all members can post; profiles known eagerly; admins moderate. | +| `true` | `GTChannel` | **Channel** | Relay-mediated; subscribers join via channel link; binary signed-batch format; profile carries `memberRole` (default `GRObserver` at creation). | Today's channel: only owners post; subscribers anonymous to each other. | +| `true` | `GTGroup` | **Public group** | Same wire as channel; profile carries `memberRole` (default `GRMember` at creation); profile dissemination on demand. | New: every member can post; member-to-member DMs prohibited (deferred); member roster grown lazily via on-demand profile send. | +| `true` | `GTUnknown _` (decode) | (refuse to join) | Channel link from a newer client; older client sees unknown discriminator. | New clients reject with a clear "needs newer version" message; pre-existing channels unaffected. | + +Three axes: **transport** = `useRelays` (topology, batch, signatures, +delivery); **governance model** = `groupType` (profile dissemination, +member affordances; member DMs prohibited in any relay-mediated group); +**joiner role** = `memberRole` on the owner-signed profile, set at +creation, type-keyed default. Today's client sites branch on `useRelays` +as a proxy for `isChannel` — that's the audit work (§4.2, §5.2). + +## 3. Backend changes + +### 3.1 Wire format / protocol + +New optional `memberRole :: Maybe GroupMemberRole` on `PublicGroupProfile` +(owner-signed; relays read from cache). New chat-protocol version +`publicGroupsVersion` signals understanding of `groupType = "group"` and +`memberRole`. Older peers decode unknown `groupType` as `GTUnknown` +(lossless tag preservation already exists) and ignore unknown JSON fields +— §7 covers behavior. Channel-protocol docs gain a paragraph naming +`groupType` the discriminator and `memberRole` the owner-set joiner role. + +### 3.2 Type changes + +`PublicGroupProfile` gains `memberRole :: Maybe GroupMemberRole`. New +single-line helpers in the same module as `useRelays'`: `groupType'` / +`memberRole'` (accessors); `isPublicGroup'` (`useRelays' && groupType' +== Just GTGroup`); `defaultMemberRoleFor` (`GTChannel → GRObserver`, +`GTGroup → GRMember`, `GTUnknown _ → GRObserver` defensive); +`joinerRoleFor` (canonical resolver — `memberRole'` if present, else +`defaultMemberRoleFor groupType'`). `requiresSignature` unchanged for +MVP; opt-in content signing is future-work mitigation per §6. + +### 3.3 API / command changes + +`APINewPublicGroup` / `/public group` gain `groupType` (default channel) +and optional `memberRole` (default `defaultMemberRoleFor groupType`); both +are written onto the constructed profile. The subscriber-side prepare-group +flow reads `memberRole` from the resolved link with the same fallback. The +`channelSubscriberRole` config is removed (no callers after §3.4); tests +that flipped it migrate to Public groups or explicit `memberRole`. + +#### 3.3.1 Default group preferences + +Public-group defaults equal secret-group defaults — the channel override +(`support = OFF`) does not apply, since member-to-moderator escalation is +expected. Parameterize the existing channel-prefs parser by `GroupType` +(Channel keeps its path; Public group and `GTUnknown` use secret-group). +`directMessages` stays ON by inheritance but is **dormant** in any relay- +mediated group (relay doesn't forward `XGrpDirectInv`; clients hide the +toggle); keeping the wire ON lets a future plan re-enable DMs without a +profile-shape change. + +### 3.4 Message processing + +- **Relay joiner-role derivation** (today reads `channelSubscriberRole`): + switch to `joinerRoleFor gInfo`. Eliminates cross-relay disparity. +- **Member-DM defensive refusal** (`xGrpDirectInv`): when `useRelays'`, + emit `messageError` and create no contact. Belt-and-suspenders with the + §4/§5 client suppression; unreachable today (no forwarding, no P2P). +- **Legacy `x.grp.inv`**: existing channel rejection covers Public groups. +- **`unverifiedAllowed`**: unchanged. Tightening becomes possible once + the dissemination plan distributes member keys; existing TODO is + updated to name that precondition. +- **Inherited unchanged** (add a test each): `checkSendAsGroup` + (role-based), receipts cutoff (count-based), introduce-in-channel + + history (`useRelays`-keyed). +- **`memberAdmission` on relay-mediated join**: hardcoded `GAAccepted` + bypasses review/captcha. Generic relay-mediated-groups gap; §8. + +### 3.5 Database migrations + +No schema migration: `groupType` and `memberRole` ride the existing +JSON-serialized profile; absent fields resolve via `defaultMemberRoleFor`. +The dissemination plan's `sent_profile_vector BLOB` migration is a hard +prerequisite owned by that plan. + +### 3.6 Test scenarios + +Add Public-group helpers paralleling the channel helpers, plus: + +1. Member sends content; all members receive it (no "unknown member" lines). +2. Multi-author session: no "unknown member" lines anywhere. +3. Member edit / delete / react forwarded by relay to all members. +4. Member-DM refused on receive: inject `XGrpDirectInv`, expect `messageError`, no contact created; repeat for Channel. +5. Role changes propagate through signed forwarding. +6. Blocked member's subsequent messages not forwarded. +7. Multi-relay delivery with cross-relay deduplication. +8. History on join. +9. `asGroup=true` from a non-owner member rejected with existing error. +10. Receipts disabled above the 20-member limit. +11. Older-client refusal on `groupType = "group"` shows needs-newer-version. +12. Incognito member posting attributes the incognito profile to others. +13. `memberRole` propagates: explicit `GRAuthor` at creation → joiners get `GRAuthor`; resolved link data carries the value. +14. `memberRole` defaults: Channel → `GRObserver`; Public group → `GRMember`. +15. Old-profile fallback: `memberRole = Nothing` → `defaultMemberRoleFor groupType` (`GRObserver` for Channel). + +## 4. iOS changes + +### 4.1 Model + +Add `case group` to `GroupType` (with serializer arms); +`memberRole: GroupMemberRole?` on `PublicGroupProfile`; `isPublicGroup`, +`groupType`, `memberRole` accessors on `GroupProfile`/`GroupInfo`. Client +uses `memberRole` for display only; authoritative resolution stays on +Haskell. + +### 4.2 Audit `useRelays` vs `isChannel` (≈73 sites) + +Per-site rule: **transport** (link/relay management, owner-can't-leave-own- +relay-group, relay-status indicator, incognito flag display, typing-state +gating, member-DM-affordance suppression) → keep `useRelays`. **Governance** +(titles, "subscribers" vs "members" framing, "Channel preferences" labels, +channel-style vs group-style member display) → switch to `isChannel`. +Roughly 70% flip to `isChannel`. Visually compare Public / Channel / Secret +after. + +### 4.3 Create flow + +Unified view with a "Channel / Public group" segmented control above the +display-name field, defaulting to Channel. The toggle drives the screen +title, link-step label, success screen, and two API parameters: `groupType` +and `memberRole` (`.observer` for Channel, `.member` for Public group — +no separate role picker in MVP). Default `groupPreferences` builder is +`groupType`-keyed per §3.3.1. The `directMessages` toggle is hidden in +the create-flow prefs section when `useRelays`. When `groupType = .group`, +render below the title: + +> "In a Public group, every member can post. Messages are delivered through +> relays you choose, which means a malicious relay could change or +> fabricate messages from any member. Pick relays you trust." + +### 4.4 Strings, views, icons, connect-plan + +- **Strings:** ~5–10 keys mirroring channel forms with `_public_group` + suffixes (create/add/leave/delete/link/temporarily-unavailable/no- + relays), plus `create_public_group_threat_model_note`. Reuse + `group_members_*` for "members" framing; channels keep `_subscriber*`. +- **Compose:** existing role-based gates allow members to post; **suppress + the member-tap "send direct message" affordance in any relay-mediated + group** (client side of the DM prohibition; receive gate at §3.4). +- **Views:** `GroupChatInfoView` and the link view branch three ways at + §4.2 sites; the link view takes `groupInfo` and derives variant inside. + `GroupPreferencesView` hides `directMessages` when `useRelays`. +- **Icon:** `chatIconName` gains a Public-group arm with a distinct icon + (different from channel-antenna and secret-group-people — §8). +- **Members view:** show the relay-known roster; header "subscribers" for + channels, "members" for Public groups. No filtered view in MVP. +- **Connect-plan:** wording keyed on resolved `groupType` — "ok to + subscribe via relays" (channel) vs "ok to join via relays" (Public + group). CLI string changes alongside; tests follow. + +## 5. Kotlin changes + +Mirror of §4 across the Compose surface. Subsections parallel §4 and +note divergences only. + +### 5.1 Model + +`GroupType` gains `Group`; `memberRole` / `isPublicGroup` accessors on +`GroupInfo` / `GroupProfile`. + +### 5.2 Audit `useRelays` vs `isChannel` (≈74 sites) + +Same transport-vs-governance rule as §4.2; ~70% flip to `isChannel`. + +### 5.3 Create flow + +Single-view create with Channel / Public-group toggle driving +`groupType`+`memberRole`; threat-model note below the title; +`directMessages` toggle hidden under `useRelays`. + +### 5.4 Strings, views, icons, ConnectPlan + +Strings, views, icons, and ConnectPlan mirror §4.4. **Kotlin-only:** +chat-list filter chips place Public groups in the "groups" bucket +(mental model: "things I can post in"), not "channels". + +## 6. Threat model: changes from channels + +This section assumes the channel threat model +(`docs/protocol/channels-overview.md` §"Threat model"). Public groups +inherit every property listed there. One threat is *broader* (channels +have a narrower form of the same threat). The relay's "can / cannot" +framing matches the existing doc style; the items below are written so +they can be folded directly into a future revision of +`channels-overview.md` once Public groups ship. + +### 6.A.1 A relay can fabricate content as any member + +Content messages (`XMsgNew`, `XMsgUpdate`, `XMsgDel`, `XMsgReact`, +`XFileCancel`) are unsigned in both channels and Public groups +(`Protocol.hs:1221`, `requiresSignature` lists only roster / +administrative events). In channels this gives a compromised relay +the ability to fabricate content attributed to owners — already +documented in `channels-overview.md` §"Threat model" ("Substitute +unsigned content or selectively drop messages for its subscribers"). +In Public groups, the same property has a **broader blast radius**: +the relay can fabricate content attributed to *any* member, not just +to owners. + +This matches the channel deniability property by design (see +`channels-overview.md` §"Signing scope: roster only, content +optional"): unsigned content is precisely what enables cryptographic +deniability — no third party can prove a member authored anything. +The trade-off is that the operator on the delivery path cannot be +prevented from forging in the same channel. + +**A single compromised relay** + +*can:* + +- Fabricate content messages attributed to any member, not just to + owners. Detectable by other members through cross-relay + consistency (same TODO as the channel case: difference detection + not yet implemented). +- Modify the text or content of messages in transit and re-attribute + the modified message to its original author. +- Drop content messages selectively — same property as channels. + +*cannot:* + +- Forge signed administrative events: `XGrpInfo`, `XGrpPrefs`, + `XGrpMemRole`, `XGrpMemRestrict`, `XGrpMemDel`, `XGrpDel`, + `XGrpLeave`, `XInfo` (`Protocol.hs:1221`). Roster manipulation, + profile changes, and member-attributed leave / profile-update + events all require valid signatures. +- Substitute the channel profile or impersonate an owner — the + channel's entity ID and owner authorization chain are validated + by every recipient against the channel link. The new `memberRole` + field is part of the (owner-signed) channel profile, so a + compromised relay also cannot fabricate a different joiner role + than the owner configured. +- Alter authoritative state on owner devices. + +**Mitigation.** No code change for the MVP. The future-work fix is +opt-in content signing per the channel roadmap +(`channels-overview.md` §"Future work" / "Transcript integrity" / +"Opt-in content signing"). When that ships, owners of Public groups +will be able to require all content (member or owner) to carry a +signature; member keys are already disseminated to other members +via the prior plan (`2026-04-29-member-profile-sending-channels.md`), +so verification on the recipient side is not a separate effort. + +In the meantime, the create-flow help text for "Public group" on +both platforms (§4.3, §5.3) carries this trade-off framing: "In a +Public group, the relay forwards messages on behalf of every member. +A compromised relay could change message text or attribute fabricated +messages to any member. Use a secret group if you need non- +repudiable peer-to-peer messaging." This is the same trade-off +channels make for owner posts; making it explicit at create time +lets users choose Public-group-via-relay vs secret-group based on +whether they value scale or content integrity. + +### 6.A.2 What is unchanged from channels + +Every other property of the channel threat model carries over +without change. In particular: + +- A relay cannot impersonate an owner or substitute the channel + profile (signed events, validated entity ID). The configured + `memberRole` is part of the signed profile, so the relay cannot + unilaterally elevate or demote joiners relative to what the owner + specified. +- A relay cannot determine subscriber / member real identity or + network address (inherited from SMP transport). +- All-relays-compromised-and-colluding cannot forge signed events + or alter owner-authoritative state. +- A passive network observer cannot determine which Public group a + member is in, or correlate Public-group activity with other + SimpleX activity. + +Public-group members get the same participant-privacy guarantees as +channel subscribers, and Public-group owners get the same key-loss +risk profile as channel owners (see `channels-overview.md` +§"Compromise of owner keys" and §"Loss of all owner devices"). + +**Out of scope for now: member-to-member DMs in relay-mediated +groups.** In channels, members do not DM each other today. In Public +groups, this plan prohibits the affordance (client-side and +defensively on the receive path) and the relay does not forward +`XGrpDirectInv`. The relay therefore does not see a "member DM +graph" — that threat (which a forwarded-DM design would have +introduced) does not exist under this plan. A future plan can +re-introduce member-to-member DMs and revisit the metadata trade-off +explicitly; the design space is sketched in §10. + +### 6.A.3 Release-notes line + +For the Public-groups release notes, include a one-line summary of +the new property: + +> "In a Public group, the relay you choose could in principle alter +> or fabricate group messages attributed to any member. Pick relays +> you trust, or use a secret group if you need peer-to-peer message +> integrity." + +## 7. Migration / compatibility + +- **Existing channels unaffected.** Pre-upgrade profiles have no + `memberRole`; readers fall back to `defaultMemberRoleFor GTChannel = + GRObserver`. No data migration. +- **Older clients** decode `groupType = "group"` as `GTUnknown` and must + refuse to join with a "needs newer version" alert; they ignore unknown + fields on channel profiles otherwise. +- **Older relays** forward Public-group traffic but resolve joiner role + from their global config — joiners via un-upgraded relays get the legacy + role and cannot post. Mitigation: warn the owner at create time if any + selected relay's chat version is below `publicGroupsVersion`. Soft + warning, not a hard block. + +## 8. Open questions + +1. **Future member-DM design.** (i) Relay-forwarded `XGrpDirectInv` + (simple; leaks DM-graph metadata); (ii) relay-blind rendezvous via + per-member queues on the profile (privacy-preserving; new protocol). + Either re-derives §6. +2. **`memberAdmission` on relay-mediated join.** Hardcoded `GAAccepted` + bypasses review/captcha; generic relay-mediated-groups gap; defer. +3. **Distinct icon for Public groups.** Visually different from channel- + antenna and secret-group-people metaphors. Pending design review. +4. **`channelSubscriberRole` removal.** Verify no out-of-tree consumer + reads it before deleting. +5. **`memberRole` on profile edit.** MVP exposes no UI; Haskell accepts + the edit but role-rebase of existing members is undefined. Deferred. +6. **Roster filter in members view.** Paginate/filter for 100K+ members? + Generic relay-roster question; defer. +7. **Connect-plan wording.** "subscribe / join / connect via relays" — + pick per `groupType`; update tests when CLI string changes. + +## 9. Sequencing + +1. **Prerequisite:** member-profile dissemination plan lands first. +2. **Backend:** types, `memberRole` field, command parameters, profile- + based role derivation, removal of `channelSubscriberRole`, defensive + `XGrpDirectInv` refusal, tests 1–15. +3. **iOS** then **Kotlin** (independent of each other; API defaults are + backward compatible): model, audit, create flow, strings; then views, + icons, ConnectPlan, DM-affordance suppression. +4. **Older-client refusal, version-bump release notes, channel-docs + updates** ship with the backend release. + +## 10. Adjacent work (not planned here) + +- **Owner→relay communication of rejection rules.** Joiner-role side is + fixed here (travels on the signed profile); rejection-rule side + (admission/captcha) is still relay-side config. Future plan: carry it + on the profile too. +- **Owner-signature verification on the channel profile by relays.** + Affects channels equally; does not gate this plan. +- **Member-to-member DMs in relay-mediated groups.** Deferred (§1, §8 Q1). + A future plan must re-derive §6 — relay-forwarded DMs would re-introduce + (sender, target, time) metadata exposure this plan + avoids. diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 6e459d6484..bdd7fef0b2 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -133,6 +133,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260429_relay_request_retries Simplex.Chat.Store.Postgres.Migrations.M20260507_relay_inactive_at Simplex.Chat.Store.Postgres.Migrations.M20260514_relay_request_group_link_index + Simplex.Chat.Store.Postgres.Migrations.M20260515_delivery_job_senders else exposed-modules: Simplex.Chat.Archive @@ -288,6 +289,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260429_relay_request_retries Simplex.Chat.Store.SQLite.Migrations.M20260507_relay_inactive_at Simplex.Chat.Store.SQLite.Migrations.M20260514_relay_request_group_link_index + Simplex.Chat.Store.SQLite.Migrations.M20260515_delivery_job_senders other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Delivery.hs b/src/Simplex/Chat/Delivery.hs index 822ee5efb9..a6ccc74247 100644 --- a/src/Simplex/Chat/Delivery.hs +++ b/src/Simplex/Chat/Delivery.hs @@ -161,7 +161,7 @@ instance TextEncoding DeliveryTaskStatus where data MessageDeliveryJob = MessageDeliveryJob { jobId :: Int64, jobScope :: DeliveryJobScope, - singleSenderGMId_ :: Maybe GroupMemberId, -- Just for single-sender deliveries, Nothing for multi-sender deliveries + senderGMIds :: [GroupMemberId], body :: ByteString, cursorGMId_ :: Maybe GroupMemberId } diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index bb31ee26a5..84eece9915 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -2958,7 +2958,7 @@ processChatCommand vr nm = \case withFastStore' $ \db -> do deleteGroupDeliveryTasks db gInfo deleteGroupDeliveryJobs db gInfo - createMsgDeliveryJob db gInfo (DJSGroup {jobSpec = DJRelayRemoved}) Nothing body + createMsgDeliveryJob db gInfo (DJSGroup {jobSpec = DJRelayRemoved}) [] body lift . void $ getDeliveryJobWorker True (groupId, DWSGroup) pure msg leaveGroupSendMsg user gInfo = do diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index c6c3f92752..8c6d2a20cd 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1167,12 +1167,16 @@ memberIntroEvt gInfo reMember = -- This doesn't create introduction records in db, compared to above methods. introduceInChannel :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM () introduceInChannel _ _ _ GroupMember {activeConn = Nothing} = throwChatError $ CEInternalError "member connection not active" -introduceInChannel vr user gInfo subscriber@GroupMember {activeConn = Just conn} = do +introduceInChannel vr user gInfo subscriber@GroupMember {activeConn = Just conn, indexInGroup = subscriberIdx} = do modMs <- withStore' $ \db -> getGroupModerators db vr user gInfo void $ sendGroupMessage' user gInfo modMs $ XGrpMemNew (memberInfo gInfo subscriber) Nothing + withStore' $ \db -> + setMemberVectorNewRelations db subscriber [(indexInGroup m, (IDSubjectIntroduced, MRIntroduced)) | m <- modMs] let introEvts = map (memberIntroEvt gInfo) modMs forM_ (L.nonEmpty introEvts) $ \introEvts' -> sendGroupMemberMessages user gInfo conn introEvts' + withStore' $ \db -> + setMembersVectorsNewRelation db modMs subscriberIdx IDSubjectIntroduced MRIntroduced userProfileInGroup :: User -> GroupInfo -> Maybe Profile -> Profile userProfileInGroup user = userProfileInGroup' user . groupFeatureUserAllowed SGFSimplexLinks diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 08ca90f2a6..f90630bc77 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -28,11 +28,13 @@ import Data.Either (lefts, partitionEithers, rights) import Data.Foldable (foldr', foldrM) import Data.Functor (($>)) import Data.Int (Int64) -import Data.List (find) +import Data.List (find, foldl') import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L +import qualified Data.IntSet as IS import Data.Map.Strict (Map) import qualified Data.Map.Strict as M +import qualified Data.Set as S import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, mapMaybe) import Data.Text (Text) import qualified Data.Text as T @@ -46,7 +48,7 @@ import Simplex.Chat.Controller import Simplex.Chat.Delivery import Simplex.Chat.Library.Internal import Simplex.Chat.Messages -import Simplex.Chat.Messages.Batch (batchDeliveryTasks1, encodeBinaryBatch, encodeFwdElement) +import Simplex.Chat.Messages.Batch (batchDeliveryTasks1, batchProfiles, batchProfilesWithBody, encodeBinaryBatch, encodeFwdElement, maxBatchElementSize) import Simplex.Chat.Messages.CIContent import Simplex.Chat.Messages.CIContent.Events import Simplex.Chat.ProfileGenerator (generateRandomProfile) @@ -2946,7 +2948,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xGrpMemNew :: GroupInfo -> GroupMember -> MemberInfo -> Maybe MsgScope -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole _ _ _) msgScope_ msg brokerTs = do - unless (useRelays' gInfo && isRelay m) $ checkHostRole m memRole + if useRelays' gInfo && isRelay m + then when (memRole > GRMember) $ throwChatError $ CEException "x.grp.mem.new: relay cannot introduce role above member in channel" + else checkHostRole m memRole if sameMemberId memId (membership gInfo) then pure Nothing else do @@ -3608,17 +3612,13 @@ runDeliveryTaskWorker a deliveryKey Worker {doWork} = do withStore' $ \db -> setDeliveryTaskErrStatus db (deliveryTaskId task) "relay inactive" | otherwise -> withWorkItems a doWork (withStore' $ \db -> getNextDeliveryTasks db gInfo task) $ \nextTasks -> do - let (body, taskIds, largeTaskIds) = batchDeliveryTasks1 vr maxEncodedMsgLength nextTasks + let (body, acceptedTasks, largeTasks) = batchDeliveryTasks1 vr maxEncodedMsgLength nextTasks + senderGMIds = S.toList . S.fromList $ map (\MessageDeliveryTask {senderGMId} -> senderGMId) acceptedTasks withStore' $ \db -> do - createMsgDeliveryJob db gInfo jobScope (singleSenderGMId_ nextTasks) body - forM_ taskIds $ \taskId -> updateDeliveryTaskStatus db taskId DTSProcessed - forM_ largeTaskIds $ \taskId -> setDeliveryTaskErrStatus db taskId "large" + createMsgDeliveryJob db gInfo jobScope senderGMIds body + forM_ acceptedTasks $ \t -> updateDeliveryTaskStatus db (deliveryTaskId t) DTSProcessed + forM_ largeTasks $ \t -> setDeliveryTaskErrStatus db (deliveryTaskId t) "large" lift . void $ getDeliveryJobWorker True deliveryKey - where - singleSenderGMId_ :: NonEmpty MessageDeliveryTask -> Maybe GroupMemberId - singleSenderGMId_ (MessageDeliveryTask {senderGMId = senderGMId'} :| ts) - | all (\MessageDeliveryTask {senderGMId} -> senderGMId == senderGMId') ts = Just senderGMId' - | otherwise = Nothing -- DJRelayRemoved is allowed when RSInactive - it forwards XGrpMemDel about relay's own deletion DJRelayRemoved | workerScope /= DWSGroup -> @@ -3628,7 +3628,7 @@ runDeliveryTaskWorker a deliveryKey Worker {doWork} = do fwd = GrpMsgForward {fwdSender, fwdBrokerTs} body = encodeBinaryBatch [encodeFwdElement fwd verifiedMsg] withStore' $ \db -> do - createMsgDeliveryJob db gInfo jobScope (Just senderGMId) body + createMsgDeliveryJob db gInfo jobScope [senderGMId] body updateDeliveryTaskStatus db (deliveryTaskId task) DTSProcessed lift . void $ getDeliveryJobWorker True deliveryKey @@ -3647,6 +3647,27 @@ getDeliveryJobWorker hasWork deliveryKey = do getAgentWorker "delivery_job" hasWork a deliveryKey ws $ runDeliveryJobWorker a deliveryKey +-- TODO [relays] dissemination here is unsigned (relay-asserted profile). +-- Future: members sign an XMember on channel join, relay stores it per +-- member and forwards the signed XMember via this sidecar — enables +-- subscribers to verify member profiles out-of-band without trusting the relay. + +-- | Encode an XGrpMemNew for first-introduction dissemination as a direct +-- (non-forwarded) batch element. 'Left' when the encoded element wouldn't +-- fit a singleton batch (see 'maxBatchElementSize'). +encodeMemberNew :: VersionRangeChat -> GroupInfo -> GroupMember -> Either ChatError ByteString +encodeMemberNew vr gInfo member = case encodeChatMessage maxBatchElementSize chatMsg of + ECMEncoded bs -> Right bs + ECMLarge -> Left $ ChatError $ CEException $ "large profile element for member " <> show (groupMemberId' member) + where + chatMsg :: ChatMessage 'Json + chatMsg = + ChatMessage + { chatVRange = vr, + msgId = Nothing, + chatMsgEvent = XGrpMemNew (memberInfo gInfo member) Nothing + } + runDeliveryJobWorker :: AgentClient -> DeliveryWorkerKey -> Worker -> CM () runDeliveryJobWorker a deliveryKey Worker {doWork} = do delay <- asks $ deliveryWorkerDelay . config @@ -3688,7 +3709,10 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do deleteGroupConnections user gInfo True withStore' $ \db -> updateDeliveryJobStatus db jobId DJSComplete where - MessageDeliveryJob {jobId, jobScope, singleSenderGMId_, body, cursorGMId_ = startingCursor} = job + MessageDeliveryJob {jobId, jobScope, senderGMIds, body, cursorGMId_ = startingCursor} = job + singleSenderGMId_ = case senderGMIds of + [s] -> Just s + _ -> Nothing sendBodyToMembers :: CM () sendBodyToMembers -- channel @@ -3696,16 +3720,88 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do -- there's no member review in channels, so job spec includePending is ignored DJSGroup {} -> do bucketSize <- asks $ deliveryBucketSize . config - sendLoop bucketSize startingCursor + senders <- withStore' $ \db -> + fmap catMaybes . forM senderGMIds $ \sId -> + fmap eitherToMaybe . runExceptT $ do + sender <- getGroupMemberById db vr user sId + vec <- getMemberRelationsVector db sender + pure (sender, vec) + let missingSenders = length senderGMIds - length senders + when (missingSenders > 0) $ + logInfo $ "delivery job " <> tshow jobId <> ": " <> tshow missingSenders <> " senders missing; skipping their profile prepend" + -- Small profiles ride inline (extBody); the rest spill + -- into standalone batches that ship before the body. + (extBody, inBodySenders, overflowBatches, activeSenders) <- + if null senders + then pure (body, [], [], []) + else do + -- Skip role > GRMember (mirrors xGrpMemNew gate). + -- TODO [relays] public groups: revisit if mods/admins are introduced via this sidecar. + let (encoderErrs, validLabeled) = + partitionEithers + [ (\bs -> (s, bs)) <$> encodeMemberNew vr gInfo s + | (s, _) <- senders, memberRole' s <= GRMember + ] + (extBody', inBody, overflowLabeled, large1) = batchProfilesWithBody maxEncodedMsgLength body validLabeled + (overflowBatches', large2) = batchProfiles maxEncodedMsgLength overflowLabeled + packerErrs = [ChatError (CEInternalError $ "oversized profile element for member " <> show (groupMemberId' s)) | s <- large1 <> large2] + allErrs = encoderErrs <> packerErrs + unless (null allErrs) $ do + logInfo $ "delivery job " <> tshow jobId <> ": dropping " <> tshow (length allErrs) <> " oversized profile element(s)" + toView $ CEvtChatErrors allErrs + let active = inBody <> concatMap snd overflowBatches' + pure (extBody', inBody, overflowBatches', active) + -- Per-job constants — independent of the cursor page in sendLoop. + let senderVec = M.fromList [(groupMemberId' s, v) | (s, v) <- senders] + -- Body IDs: 0 = plain body, 1 = extBody, 2.. = overflow batches in order. + overflowWithIds = zip [2 :: Int ..] overflowBatches + sendLoop bucketSize startingCursor senderVec overflowWithIds inBodySenders extBody activeSenders where - sendLoop :: Int -> Maybe GroupMemberId -> CM () - sendLoop bucketSize cursorGMId_ = do + sendLoop :: Int -> Maybe GroupMemberId -> Map GroupMemberId ByteString -> [(Int, (ByteString, [GroupMember]))] -> [GroupMember] -> ByteString -> [GroupMember] -> CM () + sendLoop bucketSize cursorGMId_ senderVec overflowWithIds inBodySenders extBody activeSenders = do mems <- withStore' $ \db -> getGroupMembersByCursor db vr user gInfo cursorGMId_ singleSenderGMId_ bucketSize unless (null mems) $ do - deliver body mems + let msgReqs = buildMsgReqs mems + unless (null msgReqs) $ void $ withAgent (`sendMessages` msgReqs) + -- Mark only (sender, recipient) pairs where the bit was MRNew — + -- skip recipients already MRIntroduced (steady-case savings). + let readyMems = [m | m <- mems, isJust (readyMemberConn m)] + markFor sender = do + vec <- M.lookup (groupMemberId' sender) senderVec + let ms = [(indexInGroup r, (IDSubjectIntroduced, MRIntroduced)) | r <- readyMems, getRelation (indexInGroup r) vec == MRNew] + if null ms then Nothing else Just (sender, ms) + senderMarks = mapMaybe markFor activeSenders + unless (null senderMarks) $ + withStore' $ \db -> + forM_ senderMarks $ \(sender, ms) -> + setMemberVectorNewRelations db sender ms let cursorGMId' = groupMemberId' $ last mems withStore' $ \db -> updateDeliveryJobCursor db jobId cursorGMId' - unless (length mems < bucketSize) $ sendLoop bucketSize (Just cursorGMId') + unless (length mems < bucketSize) $ + sendLoop bucketSize (Just cursorGMId') senderVec overflowWithIds inBodySenders extBody activeSenders + where + -- First recipient needing body i carries VRValue (Just i); rest use VRRef i. + -- First piece per connection: aConnId; rest: empty (agent convention). + buildMsgReqs :: [GroupMember] -> [MsgReq] + buildMsgReqs mems = reverse . snd $ foldl' addRecipient (IS.empty, []) mems + where + addRecipient acc r = case readyMemberConn r of + Just (_, conn) -> snd $ foldl' (addPiece conn) (0 :: Int, acc) (recipientBodyPieces r) + Nothing -> acc + addPiece conn (k, (issued, reqs)) (bid, msgBody) = + let vor + | IS.member bid issued = VRRef bid + | otherwise = VRValue (Just bid) msgBody + issued' = IS.insert bid issued + connId = if k == 0 then aConnId conn else B.empty + in (k + 1, (issued', (connId, PQEncOff, MsgFlags False, vor) : reqs)) + recipientBodyPieces r = + [(i, b) | (i, (b, ss)) <- overflowWithIds, any missing ss] + <> [if any missing inBodySenders then (1, extBody) else (0, body)] + where + missing s = case M.lookup (groupMemberId' s) senderVec of + Just vec -> getRelation (indexInGroup r) vec == MRNew + Nothing -> True DJSMemberSupport scopeGMId -> do -- for member support scope we just load all recipients in one go, without cursor modMs <- withStore' $ \db -> getGroupModerators db vr user gInfo @@ -3724,8 +3820,8 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do -- fully connected group | otherwise = case singleSenderGMId_ of Nothing -> throwChatError $ CEInternalError "delivery job worker: singleSenderGMId is required when not using relays" - Just singleSenderGMId -> do - sender <- withStore $ \db -> getGroupMemberById db vr user singleSenderGMId + Just sId -> do + sender <- withStore $ \db -> getGroupMemberById db vr user sId ms <- buildMemberList sender unless (null ms) $ deliver body ms where diff --git a/src/Simplex/Chat/Messages/Batch.hs b/src/Simplex/Chat/Messages/Batch.hs index a9e835a83e..ed65bd4af7 100644 --- a/src/Simplex/Chat/Messages/Batch.hs +++ b/src/Simplex/Chat/Messages/Batch.hs @@ -13,20 +13,29 @@ module Simplex.Chat.Messages.Batch encodeBinaryBatch, batchMessages, batchDeliveryTasks1, + batchProfilesWithBody, + batchProfiles, + maxBatchElementSize, ) where import Data.ByteString.Char8 (ByteString) +import qualified Data.ByteString as BS import qualified Data.ByteString.Char8 as B -import Data.Int (Int64) -import Data.List (foldl') +import Data.Char (ord) +import Data.Function (on) +import Data.Foldable (foldr') +import Data.List (foldl', sortBy) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L +import Data.Ord (Down (..)) +import Data.Word (Word8) import Simplex.Chat.Controller (ChatError (..), ChatErrorType (..)) import Simplex.Chat.Delivery import Simplex.Chat.Messages import Simplex.Chat.Protocol -import Simplex.Chat.Types (VersionRangeChat) +import Data.Maybe (isJust) +import Simplex.Chat.Types (GroupMember (..), LocalProfile (..), VersionRangeChat) import Simplex.Messaging.Encoding (Large (..), smpEncode, smpEncodeList) data BatchMode = BMJson | BMBinary @@ -70,29 +79,29 @@ batchMessages mode maxLen = addBatch . foldr addToBatch ([], [], [], 0, 0) let encoded = encodeBatch mode bodies in Right (MsgBatch encoded msgs) : batches --- | Batches delivery tasks into (batch, [taskIds], [largeTaskIds]). +-- | Batches delivery tasks into (batch, accepted, large). -- Always uses binary batch format for relay groups. -batchDeliveryTasks1 :: VersionRangeChat -> Int -> NonEmpty MessageDeliveryTask -> (ByteString, [Int64], [Int64]) +batchDeliveryTasks1 :: VersionRangeChat -> Int -> NonEmpty MessageDeliveryTask -> (ByteString, [MessageDeliveryTask], [MessageDeliveryTask]) batchDeliveryTasks1 _vr maxLen = toResult . foldl' addToBatch ([], [], [], 0, 0) . L.toList where - addToBatch :: ([ByteString], [Int64], [Int64], Int, Int) -> MessageDeliveryTask -> ([ByteString], [Int64], [Int64], Int, Int) - addToBatch (msgBodies, taskIds, largeTaskIds, len, n) task - -- too large: skip, record taskId in largeTaskIds - | msgLen > maxLen = (msgBodies, taskIds, taskId : largeTaskIds, len, n) + addToBatch :: ([ByteString], [MessageDeliveryTask], [MessageDeliveryTask], Int, Int) -> MessageDeliveryTask -> ([ByteString], [MessageDeliveryTask], [MessageDeliveryTask], Int, Int) + addToBatch (msgBodies, accepted, large, len, n) task + -- too large: skip, record in large + | msgLen > maxLen = (msgBodies, accepted, task : large, len, n) -- fits: include in batch -- batch overhead: '=' + count (2) + 2-byte length prefix per element - | len' + (n + 1) * 2 + 2 <= maxLen = (msgBody : msgBodies, taskId : taskIds, largeTaskIds, len', n + 1) + | len' + (n + 1) * 2 + 2 <= maxLen = (msgBody : msgBodies, task : accepted, large, len', n + 1) -- doesn't fit: stop adding further messages - | otherwise = (msgBodies, taskIds, largeTaskIds, len, n) + | otherwise = (msgBodies, accepted, large, len, n) where - MessageDeliveryTask {taskId, fwdSender, brokerTs = fwdBrokerTs, verifiedMsg} = task + MessageDeliveryTask {fwdSender, brokerTs = fwdBrokerTs, verifiedMsg} = task msgBody = encodeFwdElement GrpMsgForward {fwdSender, fwdBrokerTs} verifiedMsg msgLen = B.length msgBody len' = len + msgLen - toResult :: ([ByteString], [Int64], [Int64], Int, Int) -> (ByteString, [Int64], [Int64]) - toResult (msgBodies, taskIds, largeTaskIds, _, _) = + toResult :: ([ByteString], [MessageDeliveryTask], [MessageDeliveryTask], Int, Int) -> (ByteString, [MessageDeliveryTask], [MessageDeliveryTask]) + toResult (msgBodies, accepted, large, _, _) = let encoded = encodeBinaryBatch (reverse msgBodies) - in (encoded, reverse taskIds, reverse largeTaskIds) + in (encoded, reverse accepted, reverse large) -- | Encode a batch element for relay groups: >[/]. encodeFwdElement :: GrpMsgForward -> VerifiedMsg 'Json -> ByteString @@ -118,3 +127,83 @@ batchLen _ _ 0 = 0 batchLen _ len 1 = len batchLen BMJson len n = len + n + 1 -- (n - 1) commas + 2 brackets batchLen BMBinary len n = len + n * 2 + 2 -- 2-byte length prefix per element + '=' + count + +-- | Largest element that fits a singleton 'encodeBinaryBatch' inside an +-- agent SMP message: '=' + count(1) + Word16 length prefix(2) = 4 bytes +-- of framing on top of the element. +maxBatchElementSize :: Int +maxBatchElementSize = maxEncodedMsgLength - 4 + +-- | Sort key for the profile packers. No-image profiles are processed +-- first so they pack densely; image-bearing profiles take any remaining +-- space or spill to overflow. +hasImage :: GroupMember -> Bool +hasImage GroupMember {memberProfile = LocalProfile {image}} = isJust image + +-- | Greedy-pack profile elements with 'body' (no-image members first) +-- while the result fits 'maxLen'. Returns (extBody, accepted, overflow, +-- large): the senders whose profile is now inline, the labeled elements +-- that did not fit, and the senders whose element doesn't fit even a +-- singleton batch (must be dropped — equivalent to 'batchMessages' +-- 'errLarge'). +-- +-- Precondition on 'body': must be either 'B.empty' or output of +-- 'encodeBinaryBatch' — the function reads byte 1 as the existing +-- element count and drops bytes 0-1 before reassembly. Passing +-- arbitrary bytes produces malformed output. +batchProfilesWithBody :: Int -> ByteString -> [(GroupMember, ByteString)] -> (ByteString, [GroupMember], [(GroupMember, ByteString)], [GroupMember]) +batchProfilesWithBody maxLen body labeled = + let (_, _, acceptedPairs, overflow, large) = + foldl' step initState (sortBy (compare `on` (hasImage . fst)) labeled) + in (buildBody acceptedPairs, map fst acceptedPairs, overflow, large) + where + initEmpty = B.null body + initLen = B.length body + initCount = if initEmpty then 0 else ord (B.index body 1) + -- (predicted total bytes, predicted count, accepted pairs, overflow, large) + initState = (initLen, initCount, [], [], []) + step (totalLen, count, acceptedPairs, overflow, large) (s, e) + | B.length e + 4 > maxLen = (totalLen, count, acceptedPairs, overflow, s : large) + | count >= 255 = full + | candidateLen <= maxLen = (candidateLen, count + 1, (s, e) : acceptedPairs, overflow, large) + | otherwise = full + where + full = (totalLen, count, acceptedPairs, (s, e) : overflow, large) + -- First element on an empty body costs '=' + count(1) + Word16(2) + element; + -- every subsequent element costs just Word16(2) + element. + candidateLen + | initEmpty && null acceptedPairs = 4 + B.length e + | otherwise = totalLen + 2 + B.length e + -- Assemble the final body once: existing tail (sans '=' + count) with + -- the accepted elements (each length-prefixed) inserted in front, and + -- a refreshed count byte. + buildBody [] = body + buildBody acceptedPairs = + let prefixedNew = B.concat [smpEncode (Large e) | (_, e) <- acceptedPairs] + newCount = initCount + length acceptedPairs + tail_ = if initEmpty then B.empty else B.drop 2 body + in B.concat [B.singleton '=', BS.singleton (fromIntegral newCount :: Word8), prefixedNew, tail_] + +-- | Pack labeled profile elements into one or more (batch, senders) +-- pairs, each bounded by 'maxLen', plus a list of senders whose element +-- doesn't fit even a singleton batch (must be dropped — equivalent to +-- 'batchMessages' 'errLarge'). No-image members first (matches +-- 'batchProfilesWithBody'). +batchProfiles :: Int -> [(GroupMember, ByteString)] -> ([(ByteString, [GroupMember])], [GroupMember]) +batchProfiles maxLen = + finish . foldr addToBatch ([], [], [], 0, 0, []) . sortBy (compare `on` (Down . hasImage . fst)) + where + addToBatch :: (GroupMember, ByteString) -> ([(ByteString, [GroupMember])], [ByteString], [GroupMember], Int, Int, [GroupMember]) -> ([(ByteString, [GroupMember])], [ByteString], [GroupMember], Int, Int, [GroupMember]) + addToBatch (s, e) acc@(batches, elems, members, len, n, large) + | B.length e + 4 > maxLen = (batches, elems, members, len, n, s : large) + -- batch overhead: '=' + count (2) + 2-byte length prefix per element + | n + 1 <= 255 && len + B.length e + (n + 1) * 2 + 2 <= maxLen = + (batches, e : elems, s : members, len + B.length e, n + 1, large) + -- doesn't fit current — flush and start new with this element alone + | otherwise = + (flush acc, [e], [s], B.length e, 1, large) + flush :: ([(ByteString, [GroupMember])], [ByteString], [GroupMember], Int, Int, [GroupMember]) -> [(ByteString, [GroupMember])] + flush (batches, _, _, _, 0, _) = batches + flush (batches, elems, members, _, _, _) = + (encodeBinaryBatch elems, members) : batches + finish acc@(_, _, _, _, _, large) = (flush acc, large) diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index f9c29e3552..902e919a7f 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -1242,10 +1242,8 @@ requiresSignature = \case XInfo_ -> True _ -> False --- TODO [relays] relay: vectors tracking which members received which other member profiles/keys. --- TODO - don't forward XGrpLeave/XInfo to members who haven't seen sender's profile/key. --- TODO - unverifiedAllowed is a temporary workaround postponing targeted event forwarding. - +-- TODO [relays] can be tightened — sender keys are now disseminated via +-- TODO prepended XGrpMemNew before forwarded XInfo/XGrpLeave reach the recipient. -- Allow signed but unverified XGrpLeave/XInfo between subscribers when sender's key is unknown. -- Owner keys are always known, so subscribers are required to verify from owners. -- Likewise, subscriber keys are always known to owners, so owners are required to verify from subscribers. diff --git a/src/Simplex/Chat/Store/Delivery.hs b/src/Simplex/Chat/Store/Delivery.hs index 393e008835..75345e5e86 100644 --- a/src/Simplex/Chat/Store/Delivery.hs +++ b/src/Simplex/Chat/Store/Delivery.hs @@ -34,6 +34,7 @@ import Data.ByteString.Char8 (ByteString) import Data.Int (Int64) import qualified Data.List.NonEmpty as L import Data.Text (Text) +import qualified Data.Text as T import Data.Time.Clock (UTCTime, getCurrentTime) import Simplex.Chat.Delivery import Simplex.Chat.Protocol hiding (Binary) @@ -45,6 +46,7 @@ import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Encoding (smpDecode) import Simplex.Messaging.Util (eitherToMaybe, firstRow') +import Text.Read (readMaybe) #if defined(dbPostgres) import Database.PostgreSQL.Simple (In (..), Only (..), (:.) (..)) import Database.PostgreSQL.Simple.SqlQQ (sql) @@ -245,8 +247,8 @@ deleteDoneDeliveryTasks db createdAtCutoff = do |] (createdAtCutoff, DTSProcessed, DTSError) -createMsgDeliveryJob :: DB.Connection -> GroupInfo -> DeliveryJobScope -> Maybe GroupMemberId -> ByteString -> IO () -createMsgDeliveryJob db gInfo jobScope singleSenderGMId_ body = do +createMsgDeliveryJob :: DB.Connection -> GroupInfo -> DeliveryJobScope -> [GroupMemberId] -> ByteString -> IO () +createMsgDeliveryJob db gInfo jobScope senderGMIds body = do currentTs <- getCurrentTime DB.execute db @@ -254,12 +256,17 @@ createMsgDeliveryJob db gInfo jobScope singleSenderGMId_ body = do INSERT INTO delivery_jobs ( group_id, worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id, - single_sender_group_member_id, body, job_status, created_at, updated_at + sender_group_member_ids, body, job_status, created_at, updated_at ) VALUES (?,?,?,?,?,?,?,?,?,?) |] - ((Only groupId) :. jobScopeRow_ jobScope :. (singleSenderGMId_, Binary body, DJSPending, currentTs, currentTs)) + ((Only groupId) :. jobScopeRow_ jobScope :. (senderColumn, Binary body, DJSPending, currentTs, currentTs)) where GroupInfo {groupId} = gInfo + -- NULL ↔ []; non-empty list ↔ comma-separated decimal Int64s. + senderColumn :: Maybe Text + senderColumn + | null senderGMIds = Nothing + | otherwise = Just $ T.intercalate "," $ map (T.pack . show) senderGMIds getPendingDeliveryJobScopes :: DB.Connection -> IO [DeliveryWorkerKey] getPendingDeliveryJobScopes db = @@ -272,7 +279,7 @@ getPendingDeliveryJobScopes db = |] (Only DJSPending) -type MessageDeliveryJobRow = (Only Int64) :. DeliveryJobScopeRow :. (Maybe GroupMemberId, Binary ByteString, Maybe GroupMemberId) +type MessageDeliveryJobRow = (Only Int64) :. DeliveryJobScopeRow :. (Maybe Text, Binary ByteString, Maybe GroupMemberId) getNextDeliveryJob :: DB.Connection -> DeliveryWorkerKey -> IO (Either StoreError (Maybe MessageDeliveryJob)) getNextDeliveryJob db deliveryKey = do @@ -302,17 +309,26 @@ getNextDeliveryJob db deliveryKey = do SELECT delivery_job_id, worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id, - single_sender_group_member_id, body, cursor_group_member_id + sender_group_member_ids, body, cursor_group_member_id FROM delivery_jobs WHERE delivery_job_id = ? |] (Only jobId) where toDeliveryJob :: MessageDeliveryJobRow -> Either StoreError MessageDeliveryJob - toDeliveryJob ((Only jobId') :. jobScopeRow :. (singleSenderGMId_, Binary body, cursorGMId_)) = - case toJobScope_ jobScopeRow of - Just jobScope -> Right $ MessageDeliveryJob {jobId = jobId', jobScope, singleSenderGMId_, body, cursorGMId_} - Nothing -> Left $ SEInvalidDeliveryJob jobId' + toDeliveryJob ((Only jobId') :. jobScopeRow :. (senderGMIdsText_, Binary body, cursorGMId_)) = do + jobScope <- maybe (Left $ SEInvalidDeliveryJob jobId') Right $ toJobScope_ jobScopeRow + -- NULL or empty string means []; otherwise the value must parse + -- as a comma-separated decimal Int64 list. An unparseable + -- segment surfaces as job error rather than silent degradation. + senderGMIds <- case senderGMIdsText_ of + Nothing -> Right [] + Just t -> maybe (Left $ SEInvalidDeliveryJob jobId') Right $ parseSenderGMIds t + Right $ MessageDeliveryJob {jobId = jobId', jobScope, senderGMIds, body, cursorGMId_} + parseSenderGMIds :: Text -> Maybe [GroupMemberId] + parseSenderGMIds t + | T.null t = Just [] + | otherwise = traverse (readMaybe . T.unpack) (T.splitOn "," t) markJobFailed :: Int64 -> IO () markJobFailed jobId = DB.execute db "UPDATE delivery_jobs SET failed = 1 where delivery_job_id = ?" (Only jobId) diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 9b21f0697b..22e9c89b79 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -1365,11 +1365,11 @@ createRelayForOwner db vr gVar user@User {userId, userContactId} GroupInfo {grou db [sql| INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, user_id, local_display_name, contact_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, indexInGroup, MemberId memId, GRRelay, GCInviteeMember, GSMemInvited, fromInvitedBy userContactId IBUser, groupMemberId' membership) + ( (groupId, indexInGroup, MemberId memId, GRRelay, GCInviteeMember, GSMemInvited, Binary B.empty, fromInvitedBy userContactId IBUser, groupMemberId' membership) :. (userId, localDisplayName, memProfileId, currentTs, currentTs) ) liftIO $ insertedRowId db @@ -1402,12 +1402,12 @@ getCreateRelayForMember db vr gVar user@User {userId, userContactId} GroupInfo { db [sql| INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, user_id, local_display_name, contact_profile_id, created_at, updated_at, relay_link ) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, indexInGroup, memberId, GRRelay, GCHostMember, GSMemAccepted, fromInvitedBy userContactId IBUnknown) + ( (groupId, indexInGroup, memberId, GRRelay, GCHostMember, GSMemAccepted, Binary B.empty, fromInvitedBy userContactId IBUnknown) :. (userId, localDisplayName, profileId, currentTs, currentTs, relayLink) ) insertedRowId db @@ -1575,12 +1575,12 @@ createRelayRequestGroup db vr user@User {userId} GroupRelayInvitation {fromMembe db [sql| INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, indexInGroup, memberId, memberRole, GCHostMember, memberStatus) + ( (groupId, indexInGroup, memberId, memberRole, GCHostMember, memberStatus, Binary B.empty) :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) :. (minV, maxV) ) @@ -2162,7 +2162,7 @@ updateGroupMemberRole db User {userId} GroupMember {groupMemberId} memRole = setMemberVectorNewRelations :: DB.Connection -> GroupMember -> [(Int64, (IntroductionDirection, MemberRelation))] -> IO () setMemberVectorNewRelations db GroupMember {groupMemberId} relations = do - v_ <- maybeFirstRow fromOnly $ + v_ <- fmap join . maybeFirstRow fromOnly $ DB.query db ( "SELECT member_relations_vector FROM group_members WHERE group_member_id = ?" @@ -2226,7 +2226,7 @@ setMemberVectorRelationConnected db GroupMember {groupMemberId} GroupMember {ind getMemberRelationsVector :: DB.Connection -> GroupMember -> ExceptT StoreError IO ByteString getMemberRelationsVector db GroupMember {groupMemberId} = - ExceptT . firstRow fromOnly (SEGroupMemberNotFound groupMemberId) $ + ExceptT . firstRow (fromMaybe B.empty . fromOnly) (SEGroupMemberNotFound groupMemberId) $ DB.query db "SELECT member_relations_vector FROM group_members WHERE group_member_id = ?" diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 437f16a43c..ed0b3c9312 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -31,6 +31,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260403_item_viewed import Simplex.Chat.Store.Postgres.Migrations.M20260429_relay_request_retries import Simplex.Chat.Store.Postgres.Migrations.M20260507_relay_inactive_at import Simplex.Chat.Store.Postgres.Migrations.M20260514_relay_request_group_link_index +import Simplex.Chat.Store.Postgres.Migrations.M20260515_delivery_job_senders import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -61,7 +62,8 @@ schemaMigrations = ("20260403_item_viewed", m20260403_item_viewed, Just down_m20260403_item_viewed), ("20260429_relay_request_retries", m20260429_relay_request_retries, Just down_m20260429_relay_request_retries), ("20260507_relay_inactive_at", m20260507_relay_inactive_at, Just down_m20260507_relay_inactive_at), - ("20260514_relay_request_group_link_index", m20260514_relay_request_group_link_index, Just down_m20260514_relay_request_group_link_index) + ("20260514_relay_request_group_link_index", m20260514_relay_request_group_link_index, Just down_m20260514_relay_request_group_link_index), + ("20260515_delivery_job_senders", m20260515_delivery_job_senders, Just down_m20260515_delivery_job_senders) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260515_delivery_job_senders.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260515_delivery_job_senders.hs new file mode 100644 index 0000000000..d082587391 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260515_delivery_job_senders.hs @@ -0,0 +1,56 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +-- delivery_jobs.sender_group_member_ids: comma-separated decimal GroupMemberIds. +-- NULL means [] (sender-less jobs, e.g. DJRelayRemoved). One column carries +-- single- and multi-sender jobs uniformly; the per-job introduction bits live +-- in group_members.member_relations_vector (MRIntroduced). +module Simplex.Chat.Store.Postgres.Migrations.M20260515_delivery_job_senders where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260515_delivery_job_senders :: Text +m20260515_delivery_job_senders = + [r| +DROP INDEX idx_delivery_jobs_single_sender_group_member_id; + +ALTER TABLE delivery_jobs ADD COLUMN sender_group_member_ids TEXT; + +UPDATE delivery_jobs +SET sender_group_member_ids = single_sender_group_member_id::text +WHERE single_sender_group_member_id IS NOT NULL; + +ALTER TABLE delivery_jobs DROP COLUMN single_sender_group_member_id; +|] + +down_m20260515_delivery_job_senders :: Text +down_m20260515_delivery_job_senders = + [r| +-- Pre-up the FK was ON DELETE CASCADE, so orphan delivery_jobs cannot +-- exist. After up the FK was dropped and orphans may accumulate. Drop +-- them here, matching pre-up semantics, before re-adding the FK column. +DELETE FROM delivery_jobs +WHERE sender_group_member_ids IS NOT NULL + AND length(sender_group_member_ids) > 0 + AND position(',' in sender_group_member_ids) = 0 + AND NOT EXISTS ( + SELECT 1 FROM group_members + WHERE group_member_id = sender_group_member_ids::bigint + ); + +ALTER TABLE delivery_jobs ADD COLUMN single_sender_group_member_id BIGINT REFERENCES group_members(group_member_id) ON DELETE CASCADE; + +UPDATE delivery_jobs +SET single_sender_group_member_id = + CASE + WHEN sender_group_member_ids IS NULL THEN NULL + WHEN position(',' in sender_group_member_ids) > 0 THEN NULL + WHEN length(sender_group_member_ids) = 0 THEN NULL + ELSE sender_group_member_ids::bigint + END; + +ALTER TABLE delivery_jobs DROP COLUMN sender_group_member_ids; + +CREATE INDEX idx_delivery_jobs_single_sender_group_member_id ON delivery_jobs(single_sender_group_member_id); +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index 6026049313..86cce86d9e 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -635,14 +635,14 @@ CREATE TABLE test_chat_schema.delivery_jobs ( job_scope_spec_tag text, job_scope_include_pending smallint, job_scope_support_gm_id bigint, - single_sender_group_member_id bigint, body bytea, cursor_group_member_id bigint, job_status text NOT NULL, job_err_reason text, failed smallint DEFAULT 0, created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL + updated_at timestamp with time zone DEFAULT now() NOT NULL, + sender_group_member_ids text ); @@ -2203,10 +2203,6 @@ CREATE INDEX idx_delivery_jobs_next ON test_chat_schema.delivery_jobs USING btre -CREATE INDEX idx_delivery_jobs_single_sender_group_member_id ON test_chat_schema.delivery_jobs USING btree (single_sender_group_member_id); - - - CREATE INDEX idx_delivery_tasks_created_at ON test_chat_schema.delivery_tasks USING btree (created_at); @@ -2835,11 +2831,6 @@ ALTER TABLE ONLY test_chat_schema.delivery_jobs -ALTER TABLE ONLY test_chat_schema.delivery_jobs - ADD CONSTRAINT delivery_jobs_single_sender_group_member_id_fkey FOREIGN KEY (single_sender_group_member_id) REFERENCES test_chat_schema.group_members(group_member_id) ON DELETE CASCADE; - - - ALTER TABLE ONLY test_chat_schema.delivery_tasks ADD CONSTRAINT delivery_tasks_group_id_fkey FOREIGN KEY (group_id) REFERENCES test_chat_schema.groups(group_id) ON DELETE CASCADE; diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 9990ed74fd..65ccb8e58f 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -154,6 +154,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260403_item_viewed import Simplex.Chat.Store.SQLite.Migrations.M20260429_relay_request_retries import Simplex.Chat.Store.SQLite.Migrations.M20260507_relay_inactive_at import Simplex.Chat.Store.SQLite.Migrations.M20260514_relay_request_group_link_index +import Simplex.Chat.Store.SQLite.Migrations.M20260515_delivery_job_senders import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -307,7 +308,8 @@ schemaMigrations = ("20260403_item_viewed", m20260403_item_viewed, Just down_m20260403_item_viewed), ("20260429_relay_request_retries", m20260429_relay_request_retries, Just down_m20260429_relay_request_retries), ("20260507_relay_inactive_at", m20260507_relay_inactive_at, Just down_m20260507_relay_inactive_at), - ("20260514_relay_request_group_link_index", m20260514_relay_request_group_link_index, Just down_m20260514_relay_request_group_link_index) + ("20260514_relay_request_group_link_index", m20260514_relay_request_group_link_index, Just down_m20260514_relay_request_group_link_index), + ("20260515_delivery_job_senders", m20260515_delivery_job_senders, Just down_m20260515_delivery_job_senders) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260515_delivery_job_senders.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260515_delivery_job_senders.hs new file mode 100644 index 0000000000..67a9ae31e8 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260515_delivery_job_senders.hs @@ -0,0 +1,55 @@ +{-# LANGUAGE QuasiQuotes #-} + +-- delivery_jobs.sender_group_member_ids: comma-separated decimal GroupMemberIds. +-- NULL means [] (sender-less jobs, e.g. DJRelayRemoved). One column carries +-- single- and multi-sender jobs uniformly; the per-job introduction bits live +-- in group_members.member_relations_vector (MRIntroduced). +module Simplex.Chat.Store.SQLite.Migrations.M20260515_delivery_job_senders where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20260515_delivery_job_senders :: Query +m20260515_delivery_job_senders = + [sql| +DROP INDEX idx_delivery_jobs_single_sender_group_member_id; + +ALTER TABLE delivery_jobs ADD COLUMN sender_group_member_ids TEXT; + +UPDATE delivery_jobs +SET sender_group_member_ids = CAST(single_sender_group_member_id AS TEXT) +WHERE single_sender_group_member_id IS NOT NULL; + +ALTER TABLE delivery_jobs DROP COLUMN single_sender_group_member_id; +|] + +down_m20260515_delivery_job_senders :: Query +down_m20260515_delivery_job_senders = + [sql| +-- Pre-up the FK was ON DELETE CASCADE, so orphan delivery_jobs cannot +-- exist. After up the FK was dropped and orphans may accumulate. Drop +-- them here, matching pre-up semantics, before re-adding the FK column. +DELETE FROM delivery_jobs +WHERE sender_group_member_ids IS NOT NULL + AND length(sender_group_member_ids) > 0 + AND instr(sender_group_member_ids, ',') = 0 + AND NOT EXISTS ( + SELECT 1 FROM group_members + WHERE group_member_id = CAST(sender_group_member_ids AS INTEGER) + ); + +ALTER TABLE delivery_jobs ADD COLUMN single_sender_group_member_id INTEGER REFERENCES group_members(group_member_id) ON DELETE CASCADE; + +UPDATE delivery_jobs +SET single_sender_group_member_id = + CASE + WHEN sender_group_member_ids IS NULL THEN NULL + WHEN instr(sender_group_member_ids, ',') > 0 THEN NULL + WHEN length(sender_group_member_ids) = 0 THEN NULL + ELSE CAST(sender_group_member_ids AS INTEGER) + END; + +ALTER TABLE delivery_jobs DROP COLUMN sender_group_member_ids; + +CREATE INDEX idx_delivery_jobs_single_sender_group_member_id ON delivery_jobs(single_sender_group_member_id); +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 127fce8e45..dbbe2f8a0a 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -31,7 +31,6 @@ Query: Plan: SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -84,7 +83,6 @@ Query: Plan: SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -285,7 +283,6 @@ Query: Plan: SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -320,7 +317,6 @@ Query: Plan: SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -355,7 +351,6 @@ Query: Plan: SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -522,49 +517,13 @@ SEARCH users USING COVERING INDEX sqlite_autoindex_users_1 (contact_id=?) Query: INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) -SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) -SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) -SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) -SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) -SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) -SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) -SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) -SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) -SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) -SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) -SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) -SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) -SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) -SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) -SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) -SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) -SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) -SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) -SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) - -Query: - INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, - user_id, local_display_name, contact_profile_id, created_at, updated_at, relay_link - ) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) - -Plan: -SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -598,7 +557,40 @@ Query: Plan: SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) +SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) +SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) +SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) +SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) + +Query: + INSERT INTO group_members + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, + user_id, local_display_name, contact_profile_id, created_at, updated_at, relay_link + ) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: +SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -633,7 +625,6 @@ Query: Plan: SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -685,7 +676,7 @@ Query: SELECT delivery_job_id, worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id, - single_sender_group_member_id, body, cursor_group_member_id + sender_group_member_ids, body, cursor_group_member_id FROM delivery_jobs WHERE delivery_job_id = ? @@ -1156,13 +1147,12 @@ SEARCH group_relays USING COVERING INDEX idx_group_relays_chat_relay_id (chat_re Query: INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, user_id, local_display_name, contact_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -1198,7 +1188,6 @@ Query: Plan: SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -1829,7 +1818,6 @@ Query: Plan: SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -1864,7 +1852,6 @@ Query: Plan: SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -3889,6 +3876,15 @@ SEARCH c USING INTEGER PRIMARY KEY (rowid=?) SEARCH cs USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +Query: + SELECT s.member_relations_vector, r.index_in_group + FROM group_members s, group_members r + WHERE s.local_display_name = ? AND r.local_display_name = ? + +Plan: +SCAN s +SEARCH r USING AUTOMATIC PARTIAL COVERING INDEX (local_display_name=?) + Query: SELECT smp_server_id, host, port, key_hash, basic_auth, preset, tested, enabled FROM protocol_servers @@ -3969,6 +3965,15 @@ Query: Plan: SEARCH chat_items USING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=?) +Query: + UPDATE chat_items + SET item_deleted = ?, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 + RETURNING chat_item_id + +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id=? AND msg_content_tag=? AND item_deleted=? AND item_sent=?) + Query: UPDATE chat_items SET item_deleted = ?, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, updated_at = ? @@ -4681,7 +4686,7 @@ Query: INSERT INTO delivery_jobs ( group_id, worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id, - single_sender_group_member_id, body, job_status, created_at, updated_at + sender_group_member_ids, body, job_status, created_at, updated_at ) VALUES (?,?,?,?,?,?,?,?,?,?) Plan: @@ -6294,7 +6299,6 @@ Query: DELETE FROM group_members WHERE user_id = ? AND group_id = ? Plan: SEARCH group_members USING COVERING INDEX idx_group_members_group_id (user_id=? AND group_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -6324,7 +6328,6 @@ Query: DELETE FROM group_members WHERE user_id = ? AND group_member_id = ? Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -6870,6 +6873,10 @@ Query: SELECT member_status FROM group_members WHERE local_display_name = ? Plan: SCAN group_members +Query: SELECT member_status FROM group_members WHERE member_role = 'relay' +Plan: +SCAN group_members + Query: SELECT member_xcontact_id, member_welcome_shared_msg_id FROM group_members WHERE user_id = ? AND group_id = ? AND group_member_id = ? Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) @@ -6990,6 +6997,10 @@ Query: UPDATE connections_sync SET should_sync = 1 WHERE connections_sync_id = 1 Plan: SEARCH connections_sync USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE contact_profiles SET image = ? WHERE display_name = ? +Plan: +SEARCH contact_profiles USING INDEX contact_profiles_index (display_name=?) + Query: UPDATE contact_requests SET business_group_id = ? WHERE contact_request_id = ? Plan: SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 86c198670c..8fca9f2e84 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -734,7 +734,6 @@ CREATE TABLE delivery_jobs( job_scope_spec_tag TEXT, job_scope_include_pending INTEGER, job_scope_support_gm_id INTEGER REFERENCES group_members(group_member_id) ON DELETE CASCADE, - single_sender_group_member_id INTEGER REFERENCES group_members(group_member_id) ON DELETE CASCADE, body BLOB, cursor_group_member_id INTEGER, job_status TEXT NOT NULL, @@ -742,6 +741,8 @@ CREATE TABLE delivery_jobs( failed INTEGER DEFAULT 0, created_at TEXT NOT NULL DEFAULT(datetime('now')), updated_at TEXT NOT NULL DEFAULT(datetime('now')) + , + sender_group_member_ids TEXT ) STRICT; CREATE TABLE group_member_status_predicates( member_status TEXT NOT NULL PRIMARY KEY, @@ -1218,9 +1219,6 @@ CREATE INDEX idx_delivery_jobs_group_id ON delivery_jobs(group_id); CREATE INDEX idx_delivery_jobs_job_scope_support_gm_id ON delivery_jobs( job_scope_support_gm_id ); -CREATE INDEX idx_delivery_jobs_single_sender_group_member_id ON delivery_jobs( - single_sender_group_member_id -); CREATE INDEX idx_delivery_jobs_next ON delivery_jobs( group_id, worker_scope, diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 477850d4b0..7785d06d44 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1326,7 +1326,7 @@ viewJoinedGroupMemberConnecting g@GroupInfo {groupId} host m@GroupMember {groupM [ (ttyGroup' g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group (connecting and pending review...), ") <> ("use " <> highlight ("/_accept member #" <> show groupId <> " " <> show groupMemberId <> " ") <> " to accept member") ] - _ | useRelays' g -> [ttyGroup' g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group"] + _ | useRelays' g -> [ttyGroup' g <> ": " <> ttyMember host <> " introduced " <> ttyFullMember m <> " in the channel"] | otherwise -> [ttyGroup' g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group (connecting...)"] viewConnectedToGroupMember :: GroupInfo -> GroupMember -> [StyledString] diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 7fdd34061f..0abbe5bd65 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -1996,7 +1996,7 @@ testRegisterChannelViaCard ps = [ do relay <## "'SimpleX Directory': accepting request to join group #news..." relay <## "#news: 'SimpleX Directory' joined the group", - bob <## "#news: relay added 'SimpleX Directory_1' to the group" + bob <## "#news: relay introduced 'SimpleX Directory_1' in the channel" ] -- owner sends a message to trigger member introduction bob <# "'SimpleX Directory'> Joined the channel news. Registration is pending approval — it may take up to 48 hours." @@ -2095,7 +2095,7 @@ testDeleteChannelRegistration ps = [ do relay <## "'SimpleX Directory': accepting request to join group #news..." relay <## "#news: 'SimpleX Directory' joined the group", - bob <## "#news: relay added 'SimpleX Directory_1' to the group" + bob <## "#news: relay introduced 'SimpleX Directory_1' in the channel" ] bob <# "'SimpleX Directory'> Joined the channel news. Registration is pending approval — it may take up to 48 hours." superUser <# "'SimpleX Directory'> bob submitted the channel ID 1:" @@ -2139,7 +2139,7 @@ testReregistrationAlreadyListed ps = [ do relay <## "'SimpleX Directory': accepting request to join group #news..." relay <## "#news: 'SimpleX Directory' joined the group", - bob <## "#news: relay added 'SimpleX Directory_1' to the group" + bob <## "#news: relay introduced 'SimpleX Directory_1' in the channel" ] bob <# "'SimpleX Directory'> Joined the channel news. Registration is pending approval — it may take up to 48 hours." superUser <# "'SimpleX Directory'> bob submitted the channel ID 1:" @@ -2198,7 +2198,7 @@ testLinkCheckUpdatesCount ps = do [ do relay <## "'SimpleX Directory': accepting request to join group #news..." relay <## "#news: 'SimpleX Directory' joined the group", - bob <## "#news: relay added 'SimpleX Directory_1' to the group" + bob <## "#news: relay introduced 'SimpleX Directory_1' in the channel" ] bob <# "'SimpleX Directory'> Joined the channel news. Registration is pending approval — it may take up to 48 hours." superUser <# "'SimpleX Directory'> bob submitted the channel ID 1:" diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index e0ff178db4..82bf20e6cf 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -18,8 +18,9 @@ import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) import Control.Monad (forM_, void, when) import Data.Bifunctor (second) -import Data.Maybe (fromMaybe, maybeToList) +import Data.ByteString (ByteString) import qualified Data.ByteString.Char8 as B +import Data.Maybe (fromMaybe, listToMaybe, maybeToList) import Data.Int (Int64) import Data.List (intercalate, isInfixOf) import qualified Data.Map.Strict as M @@ -32,7 +33,7 @@ import Simplex.Chat.Messages.CIContent (publicGroupNoE2EText) import Simplex.Chat.Options import Simplex.Chat.Protocol (MsgMention (..), MsgContent (..), msgContentText) import Simplex.Chat.Types -import Simplex.Chat.Types.MemberRelations (MemberRelation (..), setRelation) +import Simplex.Chat.Types.MemberRelations (MemberRelation (..), getRelation, setRelation) import Simplex.Chat.Types.Shared (GroupMemberRole (..), GroupAcceptance (..)) import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.RetryInterval @@ -251,6 +252,13 @@ chatGroupTests = do describe "multiple relays" $ do it "2 relays: should deliver messages to members" testChannels2RelaysDeliver it "should share same incognito profile with all relays" testChannels2RelaysIncognito + describe "deliver member profiles via relay" $ do + it "late joiner (no prior history) learns sender on first forward" testChannelLateJoinerReceivesProfile + it "2 relays: deduplicate member announcement" testChannel2RelaysDeduplicateProfile + it "multi senders disseminate independently" testChannelMultiSendersIndependent + it "large profile fits in body" testChannelLargeProfileFits + it "multiple large profiles pack across batches in one multi-sender job" testChannelMultipleLargeProfiles + it "profile update reuses existing announcement (no re-prepend)" testChannelProfileUpdateNoRePrepend describe "channel operations" $ do it "should update channel profile (signed)" testChannelUpdateProfileSigned it "should preserve working link after profile update" testChannelLinkAfterProfileUpdate @@ -8546,10 +8554,11 @@ testChannels1RelayDeliver ps = -- alice knows cath via XGrpMemNew announcement from relay alice <# "#team cath> > hi" alice <## " + 👍" - dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + -- dan/eve learn cath via prepended XGrpMemNew before the forwarded reaction + dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> > hi" dan <## " + 👍" - eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> > hi" eve <## " + 👍" @@ -8689,7 +8698,7 @@ memberJoinChannel' gName gId relaySfx ownerSfx memberRelaySfx relays owners shor relay <## ("#" <> gName <> ": " <> sfxMName relaySfx <> " joined the group") | relay <- relays ] - <> [ owner <### [EndsWith ("added " <> sfxName ownerSfx <> " to the group")] + <> [ owner <### [EndsWith ("introduced " <> sfxName ownerSfx <> " in the channel")] | owner <- owners ] @@ -8721,11 +8730,29 @@ memberJoinChannelIncognito gName relays owners shortLink fullLink member = do relay <## ("#" <> gName <> ": " <> memIncognito <> " joined the group") | relay <- relays ] - <> [ owner <### [EndsWith ("added " <> memIncognito <> " to the group")] + <> [ owner <### [EndsWith ("introduced " <> memIncognito <> " in the channel")] | owner <- owners ] pure memIncognito +-- | Assert that sender's member_relations_vector has 'MRIntroduced' at +-- the recipient's index, looked up by display name on the same DB. +memberIntroducedTo :: HasCallStack => TestCC -> T.Text -> T.Text -> IO () +memberIntroducedTo cc senderName recipientName = do + rows <- withCCTransaction cc $ \db -> + DB.query + db + [sql| + SELECT s.member_relations_vector, r.index_in_group + FROM group_members s, group_members r + WHERE s.local_display_name = ? AND r.local_display_name = ? + |] + (senderName, recipientName) :: + IO [(Maybe ByteString, Int64)] + case rows of + [(mv, idx)] -> getRelation idx (fromMaybe B.empty mv) `shouldBe` MRIntroduced + _ -> expectationFailure $ "memberIntroducedTo: expected exactly one row for " <> show (senderName, recipientName) <> ", got " <> show (length rows) + testChannels1RelayDeliverLoop :: HasCallStack => Int -> TestParams -> IO () testChannels1RelayDeliverLoop deliveryBucketSize ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do @@ -8745,10 +8772,10 @@ testChannels1RelayDeliverLoop deliveryBucketSize ps = bob <## " + 👍" alice <# "#team cath> > hi" alice <## " + 👍" - dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> > hi" dan <## " + 👍" - eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> > hi" eve <## " + 👍" where @@ -8787,14 +8814,14 @@ testChannelsSenderDeduplicateOwn ps = do WithTime "#team dan> 6 [>>]" ] cath - <### [ "#team: bob forwarded a message from an unknown member, creating unknown member record dan", + <### [ "#team: bob introduced dan (Daniel) in the channel", WithTime "#team> 1 [>>]", WithTime "#team> 2 [>>]", WithTime "#team> 3 [>>]", WithTime "#team dan> 6 [>>]" ] dan - <### [ "#team: bob forwarded a message from an unknown member, creating unknown member record cath", + <### [ "#team: bob introduced cath (Catherine) in the channel", WithTime "#team> 1 [>>]", WithTime "#team> 2 [>>]", WithTime "#team> 3 [>>]", @@ -8802,8 +8829,8 @@ testChannelsSenderDeduplicateOwn ps = do WithTime "#team cath> 5 [>>]" ] eve - <### [ "#team: bob forwarded a message from an unknown member, creating unknown member record cath", - "#team: bob forwarded a message from an unknown member, creating unknown member record dan", + <### [ "#team: bob introduced cath (Catherine) in the channel", + "#team: bob introduced dan (Daniel) in the channel", WithTime "#team> 1 [>>]", WithTime "#team> 2 [>>]", WithTime "#team> 3 [>>]", @@ -8814,6 +8841,231 @@ testChannelsSenderDeduplicateOwn ps = do where cfg = testCfg {deliveryWorkerDelay = 250000} +testChannelLateJoinerReceivesProfile :: HasCallStack => TestParams -> IO () +testChannelLateJoinerReceivesProfile ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> do + withNewTestChat ps "cath" cathProfile $ \cath -> do + withNewTestChat ps "dan" danProfile $ \dan -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + memberJoinChannel "team" [bob] [alice] shortLink fullLink dan + + -- first forward: dan learns cath via prepended XGrpMemNew. + cath #> "#team hi" + bob <# "#team cath> hi" + alice <# "#team cath> hi [>>]" + dan <## "#team: bob introduced cath (Catherine) in the channel" + dan <# "#team cath> hi [>>]" + + -- second forward: dan's bit is set, no prepend, no view event. + cath #> "#team hi again" + bob <# "#team cath> hi again" + alice <# "#team cath> hi again [>>]" + dan <# "#team cath> hi again [>>]" + + memberIntroducedTo bob "cath" "alice" + memberIntroducedTo bob "cath" "dan" + + -- profile update: rename piggybacks on next send; no re-prepend, bits stay set. + cath ##> "/p kate Kate" + cath <## "user profile is changed to kate (Kate) (your 0 contacts are notified)" + + cath #> "#team renamed" + bob <# "#team kate> renamed" + alice <# "#team kate> renamed [>>]" + dan <# "#team kate> renamed [>>]" + threadDelay 500000 + memberIntroducedTo bob "kate" "alice" + memberIntroducedTo bob "kate" "dan" + +testChannel2RelaysDeduplicateProfile :: HasCallStack => TestParams -> IO () +testChannel2RelaysDeduplicateProfile ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + (shortLink, fullLink) <- prepareChannel2Relays "team" alice bob cath + memberJoinChannel "team" [bob, cath] [alice] shortLink fullLink dan + memberJoinChannel "team" [bob, cath] [alice] shortLink fullLink eve + + -- first forward: both relays prepend XGrpMemNew(dan) for eve; + -- second hits xGrpMemNew's "already created via another relay" branch. + dan #> "#team hi" + bob <# "#team dan> hi" + cath <# "#team dan> hi" + alice <# "#team dan> hi [>>]" + eve .<## " introduced dan (Daniel) in the channel" + eve <# "#team dan> hi [>>]" + + -- second forward: eve's bit is set on both relays, no prepend. + dan #> "#team hi again" + bob <# "#team dan> hi again" + cath <# "#team dan> hi again" + alice <# "#team dan> hi again [>>]" + eve <# "#team dan> hi again [>>]" + + -- both relays independently mark eve in dan's vector; + -- alice's bit was set at join via introduceInChannel and stays set. + memberIntroducedTo bob "dan" "alice" + memberIntroducedTo bob "dan" "eve" + memberIntroducedTo cath "dan" "alice" + memberIntroducedTo cath "dan" "eve" + + -- profile update: rename piggybacks on next send; no re-prepend, bits stay set. + dan ##> "/p dean Dean" + dan <## "user profile is changed to dean (Dean) (your 0 contacts are notified)" + + dan #> "#team renamed" + bob <# "#team dean> renamed" + cath <# "#team dean> renamed" + alice <# "#team dean> renamed [>>]" + eve <# "#team dean> renamed [>>]" + threadDelay 500000 + memberIntroducedTo bob "dean" "alice" + memberIntroducedTo bob "dean" "eve" + memberIntroducedTo cath "dean" "alice" + memberIntroducedTo cath "dean" "eve" + +testChannelLargeProfileFits :: HasCallStack => TestParams -> IO () +testChannelLargeProfileFits ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> do + withNewTestChat ps "cath" cathProfile $ \cath -> do + withNewTestChat ps "dan" danProfile $ \dan -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + memberJoinChannel "team" [bob] [alice] shortLink fullLink dan + + -- ~14000 chars: profile fits in a singleton batch AND packs + -- inline with the forwarded body (exercises the in-body path). + let bigImage = T.pack ("data:image/png;base64," <> replicate 14000 'A') + withCCTransaction bob $ \db -> + DB.execute db "UPDATE contact_profiles SET image = ? WHERE display_name = ?" (bigImage, "cath" :: T.Text) + + cath #> "#team hi" + bob <# "#team cath> hi" + alice <# "#team cath> hi [>>]" + dan <## "#team: bob introduced cath (Catherine) in the channel" + dan <# "#team cath> hi [>>]" + + memberIntroducedTo bob "cath" "dan" + +testChannelMultipleLargeProfiles :: HasCallStack => TestParams -> IO () +testChannelMultipleLargeProfiles ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChatCfgOpts ps cfg relayTestOpts "bob" bobProfile $ \bob -> do + withNewTestChat ps "cath" cathProfile $ \cath -> do + withNewTestChat ps "dan" danProfile $ \dan -> do + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + -- ~14500 chars each: one rides inline with the body, + -- the other spills into a standalone overflow batch. + let cathImage = T.pack ("data:image/png;base64," <> replicate 14500 'A') + danImage = T.pack ("data:image/png;base64," <> replicate 14500 'B') + withCCTransaction bob $ \db -> do + DB.execute db "UPDATE contact_profiles SET image = ? WHERE display_name = ?" (cathImage, "cath" :: T.Text) + DB.execute db "UPDATE contact_profiles SET image = ? WHERE display_name = ?" (danImage, "dan" :: T.Text) + + -- deliveryWorkerDelay=250ms lets the relay coalesce cath's and + -- dan's sends into one multi-sender job. + cath #> "#team from cath" + bob <# "#team cath> from cath" + dan #> "#team from dan" + bob <# "#team dan> from dan" + + alice + <### [ WithTime "#team cath> from cath [>>]", + WithTime "#team dan> from dan [>>]" + ] + cath + <### [ "#team: bob introduced dan (Daniel) in the channel", + WithTime "#team dan> from dan [>>]" + ] + dan + <### [ "#team: bob introduced cath (Catherine) in the channel", + WithTime "#team cath> from cath [>>]" + ] + eve + <### [ "#team: bob introduced dan (Daniel) in the channel", + "#team: bob introduced cath (Catherine) in the channel", + WithTime "#team cath> from cath [>>]", + WithTime "#team dan> from dan [>>]" + ] + + memberIntroducedTo bob "cath" "eve" + memberIntroducedTo bob "dan" "eve" + where + cfg = testCfg {deliveryWorkerDelay = 250000} + +-- Asserted via SQL on the relay's DB rather than terminal output: the +-- "updated profile" chat item rendering on relays/owners is order-sensitive. +testChannelProfileUpdateNoRePrepend :: HasCallStack => TestParams -> IO () +testChannelProfileUpdateNoRePrepend ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + memberJoinChannel "team" [bob] [alice] shortLink fullLink dan + + cath #> "#team hi" + bob <# "#team cath> hi" + alice <# "#team cath> hi [>>]" + dan <## "#team: bob introduced cath (Catherine) in the channel" + dan <# "#team cath> hi [>>]" + + memberIntroducedTo bob "cath" "dan" + + -- /p only delivers XInfo to direct contacts; for group members it + -- piggybacks on the next group send via shouldSendProfileUpdate. + cath ##> "/p kate Kate" + cath <## "user profile is changed to kate (Kate) (your 0 contacts are notified)" + + cath #> "#team hi again" + bob <# "#team kate> hi again" + alice <# "#team kate> hi again [>>]" + dan <# "#team kate> hi again [>>]" + threadDelay 500000 + memberIntroducedTo bob "kate" "dan" + +testChannelMultiSendersIndependent :: HasCallStack => TestParams -> IO () +testChannelMultiSendersIndependent ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> do + withNewTestChat ps "cath" cathProfile $ \cath -> do + withNewTestChat ps "dan" danProfile $ \dan -> do + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + -- cath posts: dan and eve learn cath via prepended XGrpMemNew + cath #> "#team from cath" + bob <# "#team cath> from cath" + alice <# "#team cath> from cath [>>]" + dan <## "#team: bob introduced cath (Catherine) in the channel" + dan <# "#team cath> from cath [>>]" + eve <## "#team: bob introduced cath (Catherine) in the channel" + eve <# "#team cath> from cath [>>]" + + -- dan posts: cath and eve learn dan independently of cath's vector + dan #> "#team from dan" + bob <# "#team dan> from dan" + alice <# "#team dan> from dan [>>]" + cath <## "#team: bob introduced dan (Daniel) in the channel" + cath <# "#team dan> from dan [>>]" + eve <## "#team: bob introduced dan (Daniel) in the channel" + eve <# "#team dan> from dan [>>]" + + -- second post from cath: all recipients have cath marked, no prepend + cath #> "#team again from cath" + bob <# "#team cath> again from cath" + alice <# "#team cath> again from cath [>>]" + dan <# "#team cath> again from cath [>>]" + eve <# "#team cath> again from cath [>>]" + testChannels2RelaysDeliver :: HasCallStack => TestParams -> IO () testChannels2RelaysDeliver ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do @@ -8836,10 +9088,10 @@ testChannels2RelaysDeliver ps = cath <## " + 👍" alice <# "#team dan> > hi" alice <## " + 👍" - eve .<## " forwarded a message from an unknown member, creating unknown member record dan" + eve .<## " introduced dan (Daniel) in the channel" eve <# "#team dan> > hi" eve <## " + 👍" - frank .<## " forwarded a message from an unknown member, creating unknown member record dan" + frank .<## " introduced dan (Daniel) in the channel" frank <# "#team dan> > hi" frank <## " + 👍" @@ -8874,10 +9126,10 @@ testChannels2RelaysIncognito ps = cath <## " + 👍" alice <# ("#team " <> danIncognito <> "> > hi") alice <## " + 👍" - eve .<## (" forwarded a message from an unknown member, creating unknown member record " <> danIncognito) + eve .<## (" introduced " <> danIncognito <> " in the channel") eve <# ("#team " <> danIncognito <> "> > hi") eve <## " + 👍" - frank .<## (" forwarded a message from an unknown member, creating unknown member record " <> danIncognito) + frank .<## (" introduced " <> danIncognito <> " in the channel") frank <# ("#team " <> danIncognito <> "> > hi") frank <## " + 👍" @@ -9090,10 +9342,10 @@ testChannelChangeRoleSigned ps = concurrentlyN_ [ alice <# "#team cath> hello from cath [>>]", do - dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hello from cath [>>]", do - eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> hello from cath [>>]" ] @@ -9145,10 +9397,10 @@ testChannelBlockMemberSigned ps = concurrentlyN_ [ alice <# "#team cath> hello from cath [>>]", do - dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hello from cath [>>]", do - eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> hello from cath [>>]" ] @@ -9208,10 +9460,10 @@ testChannelRemoveMemberSigned ps = concurrentlyN_ [ alice <# "#team eve> hello from eve [>>]", do - dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record eve" + dan <## "#team: bob introduced eve (Eve) in the channel" dan <# "#team eve> hello from eve [>>]", do - cath <## "#team: bob forwarded a message from an unknown member, creating unknown member record eve" + cath <## "#team: bob introduced eve (Eve) in the channel" cath <# "#team eve> hello from eve [>>]" ] @@ -9376,10 +9628,10 @@ testChannelSubscriberLeave ps = concurrentlyN_ [ alice <# "#team cath> hello from cath [>>]", do - dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hello from cath [>>]", do - eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> hello from cath [>>]" ] @@ -9430,7 +9682,9 @@ testChannelSubscriberLeave ps = dan <## "use /d #team to delete the group" bob <## "#team: dan left the group (signed)" alice <## "#team: dan left the group (signed)" - -- eve doesn't know dan - no unknown member record created (skipped for XGrpLeave) + -- dan never sent before leaving, so dan's profile is disseminated to eve + -- via prepended XGrpMemNew before the forwarded XGrpLeave + eve <## "#team: bob introduced dan (Daniel) in the channel" alice #$> ("/_get chat #1 count=1", chat, [(0, "left (signed)")]) bob #$> ("/_get chat #1 count=1", chat, [(0, "left (signed)")]) dan #$> ("/_get chat #1 count=1", chat, [(1, "left (signed)")]) @@ -9449,8 +9703,10 @@ testChannelSubscriberLeave ps = checkMemberStatus alice "dan" (Just "left") checkMemberStatus bob "dan" (Just "left") checkMemberStatus dan "dan" (Just "left") - -- eve doesn't know dan - no member record (XGrpLeave skips unknown member creation) - checkMemberStatus eve "dan" Nothing + -- eve learned dan via prepended XGrpMemNew before the forwarded XGrpLeave, + -- so eve now has a record for dan with status "left" + checkMemberStatus eve "dan" (Just "left") + -- cath left earlier and was excluded from the forward; no record on cath checkMemberStatus cath "dan" Nothing where checkMemberStatus :: HasCallStack => TestCC -> T.Text -> Maybe T.Text -> IO () @@ -9627,10 +9883,10 @@ testChannelSubscriberProfileUpdate ps = concurrentlyN_ [ alice <# "#team cath> hello from cath [>>]", do - dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hello from cath [>>]", do - eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> hello from cath [>>]" ] @@ -9673,10 +9929,10 @@ testChannelSubscriberProfileUpdate ps = concurrentlyN_ [ alice <# "#team dave> hello from dave [>>]", do - eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record dave" + eve <## "#team: bob introduced dave in the channel" eve <# "#team dave> hello from dave [>>]", do - cath <## "#team: bob forwarded a message from an unknown member, creating unknown member record dave" + cath <## "#team: bob introduced dave in the channel" cath <# "#team dave> hello from dave [>>]" ] -- no profile update items in main scope (dan has no support chat, item not created) @@ -10441,11 +10697,11 @@ testChannelMessageQuote ps = alice <# "#team cath> > hello from channel [>>]" alice <## " replying to channel [>>]", do - dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> > hello from channel [>>]" dan <## " replying to channel [>>]", do - eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> > hello from channel [>>]" eve <## " replying to channel [>>]" ] @@ -10801,9 +11057,9 @@ testChannelMemberMessageUpdate ps = bob <# "#team cath> hello" concurrentlyN_ [ alice <# "#team cath> hello [>>]", - do dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + do dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hello [>>]", - do eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + do eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> hello [>>]" ] @@ -10832,9 +11088,9 @@ testChannelMemberMessageDelete ps = bob <# "#team cath> hello" concurrentlyN_ [ alice <# "#team cath> hello [>>]", - do dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + do dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hello [>>]", - do eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + do eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> hello [>>]" ] diff --git a/tests/PostgresSchemaDump.hs b/tests/PostgresSchemaDump.hs index 7df0beb2fa..197e9a9b89 100644 --- a/tests/PostgresSchemaDump.hs +++ b/tests/PostgresSchemaDump.hs @@ -78,5 +78,7 @@ skipComparisonForDownMigrations = [ -- via_group field moves "20250922_remove_unused_connections", -- group_member_intro_id field moves - "20251128_migrate_member_relations" + "20251128_migrate_member_relations", + -- on down migration single_sender_group_member_id column is re-added at the end of the table + "20260515_delivery_job_senders" ] diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index 2c4ba05ce6..bc74f3ec33 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -141,7 +141,11 @@ skipComparisonForDownMigrations = -- indexes move down to the end of the file "20250922_remove_unused_connections", -- group_member_intros table moves down to the end of the file - "20251128_migrate_member_relations" + "20251128_migrate_member_relations", + -- on down migration single_sender_group_member_id column and its index + -- are re-added at the end of the table / file (ALTER TABLE ADD COLUMN + -- appends; CREATE INDEX appends). + "20260515_delivery_job_senders" ] getSchema :: FilePath -> FilePath -> IO String From 0bef18138bfa5cbfc82e3abd9052a11bcb3d29e6 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Sat, 23 May 2026 07:57:44 +0000 Subject: [PATCH 003/200] ios: add "update" scripts (#7004) --- scripts/ios/update-pbxproj.sh | 88 +++++++++++++++++++++++++++++++++++ scripts/ios/update-version.sh | 84 +++++++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100755 scripts/ios/update-pbxproj.sh create mode 100755 scripts/ios/update-version.sh diff --git a/scripts/ios/update-pbxproj.sh b/scripts/ios/update-pbxproj.sh new file mode 100755 index 0000000000..fd48cdfa60 --- /dev/null +++ b/scripts/ios/update-pbxproj.sh @@ -0,0 +1,88 @@ +#!/bin/sh + +# Updates libHSsimplex-chat-*.a references in project.pbxproj to match the +# libraries currently in apps/ios/Libraries/ios (populated by prepare.sh). +# Handles both the plain .a and the -ghc*.a variant. + +set -e + +PBXPROJ=./apps/ios/SimpleX.xcodeproj/project.pbxproj +LIB_DIR=./apps/ios/Libraries/ios + +if [ ! -f "$PBXPROJ" ]; then + echo "Error: $PBXPROJ not found. Run from repo root." >&2 + exit 1 +fi +if [ ! -d "$LIB_DIR" ]; then + echo "Error: $LIB_DIR not found. Run prepare.sh first." >&2 + exit 1 +fi + +# New filenames from the prepared Libraries directory. +NEW_PLAIN= +NEW_GHC= +for f in "$LIB_DIR"/libHSsimplex-chat-*.a; do + [ -f "$f" ] || continue + base=$(basename "$f") + case "$base" in + *-ghc*) NEW_GHC=$base ;; + *) NEW_PLAIN=$base ;; + esac +done +if [ -z "$NEW_PLAIN" ] || [ -z "$NEW_GHC" ]; then + echo "Error: expected libHSsimplex-chat-*.a and -ghc*.a in $LIB_DIR." >&2 + echo "Run prepare.sh first." >&2 + exit 1 +fi + +# Current filenames referenced in project.pbxproj. +OLD_PLAIN= +OLD_GHC= +for ref in $(grep -hoE 'libHSsimplex-chat-[^ "/]+\.a' "$PBXPROJ" | sort -u); do + case "$ref" in + *-ghc*) OLD_GHC=$ref ;; + *) OLD_PLAIN=$ref ;; + esac +done +if [ -z "$OLD_PLAIN" ] || [ -z "$OLD_GHC" ]; then + echo "Error: no libHSsimplex-chat references found in $PBXPROJ." >&2 + exit 1 +fi + +if [ "$OLD_PLAIN" = "$NEW_PLAIN" ] && [ "$OLD_GHC" = "$NEW_GHC" ]; then + echo "Already up to date: $NEW_PLAIN" + exit 0 +fi + +# Sanity check before mutating: pbxproj must have exactly 4 lines per variant. +OLD_PLAIN_LINES=$(grep -cF "$OLD_PLAIN" "$PBXPROJ" || true) +OLD_GHC_LINES=$(grep -cF "$OLD_GHC" "$PBXPROJ" || true) +if [ "$OLD_PLAIN_LINES" -ne 4 ] || [ "$OLD_GHC_LINES" -ne 4 ]; then + echo "Error: expected 4 + 4 lines, found $OLD_PLAIN_LINES plain and $OLD_GHC_LINES ghc." >&2 + exit 1 +fi + +echo "Replacing in $PBXPROJ:" +echo " $OLD_PLAIN -> $NEW_PLAIN" +echo " $OLD_GHC -> $NEW_GHC" + +# Escape regex metachar '.' so versions match literally (no other metachars present). +escape_dots() { printf '%s' "$1" | sed 's/\./\\./g'; } +OLD_PLAIN_RE=$(escape_dots "$OLD_PLAIN") +OLD_GHC_RE=$(escape_dots "$OLD_GHC") + +# Put TMP next to PBXPROJ so the final mv is an atomic rename (same filesystem). +TMP=$(mktemp "$PBXPROJ.XXXXXX") +trap 'rm -f "$TMP"' EXIT +# Replace ghc variant first (longer, more specific), then plain. +sed -e "s|$OLD_GHC_RE|$NEW_GHC|g" -e "s|$OLD_PLAIN_RE|$NEW_PLAIN|g" "$PBXPROJ" > "$TMP" +mv "$TMP" "$PBXPROJ" + +# Verify result: exactly 4 plain lines and 4 ghc lines. +NEW_PLAIN_LINES=$(grep -cF "$NEW_PLAIN" "$PBXPROJ" || true) +NEW_GHC_LINES=$(grep -cF "$NEW_GHC" "$PBXPROJ" || true) +if [ "$NEW_PLAIN_LINES" -ne 4 ] || [ "$NEW_GHC_LINES" -ne 4 ]; then + echo "Error: post-replacement: $NEW_PLAIN_LINES plain + $NEW_GHC_LINES ghc (expected 4+4)." >&2 + exit 1 +fi +echo "Updated 8 lines (4 plain + 4 ghc)." diff --git a/scripts/ios/update-version.sh b/scripts/ios/update-version.sh new file mode 100755 index 0000000000..87097f9d27 --- /dev/null +++ b/scripts/ios/update-version.sh @@ -0,0 +1,84 @@ +#!/bin/sh + +# Bumps CURRENT_PROJECT_VERSION (build number) and MARKETING_VERSION in +# apps/ios/SimpleX.xcodeproj/project.pbxproj. Each appears in 10 places. +# +# Usage: ./scripts/ios/update-version.sh +# Example: ./scripts/ios/update-version.sh 333 6.5.3 + +set -e + +if [ $# -ne 2 ]; then + echo "Usage: $0 " >&2 + echo "Example: $0 333 6.5.3" >&2 + exit 1 +fi + +NEW_BUILD=$1 +NEW_MARKETING=$2 + +if ! echo "$NEW_BUILD" | grep -qE '^[0-9]+$'; then + echo "Error: build_number must be a positive integer (got: $NEW_BUILD)." >&2 + exit 1 +fi +if ! echo "$NEW_MARKETING" | grep -qE '^[0-9]+(\.[0-9]+)+$'; then + echo "Error: marketing_version must be like 6.5.3 (got: $NEW_MARKETING)." >&2 + exit 1 +fi + +PBXPROJ=./apps/ios/SimpleX.xcodeproj/project.pbxproj +if [ ! -f "$PBXPROJ" ]; then + echo "Error: $PBXPROJ not found. Run from repo root." >&2 + exit 1 +fi + +# Detect current values; head -1 covers the (unexpected) mixed-values case, +# which the 10-line sanity check below will reject. +OLD_BUILD=$(grep -hoE 'CURRENT_PROJECT_VERSION = [^;]+;' "$PBXPROJ" \ + | sed -E 's/^.*= ([^;]+);$/\1/' | sort -u | head -1) +OLD_MARKETING=$(grep -hoE 'MARKETING_VERSION = [^;]+;' "$PBXPROJ" \ + | sed -E 's/^.*= ([^;]+);$/\1/' | sort -u | head -1) +if [ -z "$OLD_BUILD" ] || [ -z "$OLD_MARKETING" ]; then + echo "Error: CURRENT_PROJECT_VERSION or MARKETING_VERSION not found in $PBXPROJ." >&2 + exit 1 +fi + +if [ "$OLD_BUILD" = "$NEW_BUILD" ] && [ "$OLD_MARKETING" = "$NEW_MARKETING" ]; then + echo "Already up to date: build $NEW_BUILD, version $NEW_MARKETING" + exit 0 +fi + +# Each field must appear in exactly 10 lines with a single uniform value. +OLD_BUILD_LINES=$(grep -cF "CURRENT_PROJECT_VERSION = $OLD_BUILD;" "$PBXPROJ" || true) +OLD_MARKETING_LINES=$(grep -cF "MARKETING_VERSION = $OLD_MARKETING;" "$PBXPROJ" || true) +if [ "$OLD_BUILD_LINES" -ne 10 ] || [ "$OLD_MARKETING_LINES" -ne 10 ]; then + echo "Error: expected 10 + 10 lines, found $OLD_BUILD_LINES CURRENT_PROJECT_VERSION and $OLD_MARKETING_LINES MARKETING_VERSION (mixed values?)." >&2 + exit 1 +fi + +echo "Bumping in $PBXPROJ:" +if [ "$OLD_BUILD" != "$NEW_BUILD" ]; then + echo " CURRENT_PROJECT_VERSION: $OLD_BUILD -> $NEW_BUILD" +fi +if [ "$OLD_MARKETING" != "$NEW_MARKETING" ]; then + echo " MARKETING_VERSION: $OLD_MARKETING -> $NEW_MARKETING" +fi + +# Escape '.' in OLD_MARKETING so version dots match literally. +OLD_MARKETING_RE=$(printf '%s' "$OLD_MARKETING" | sed 's/\./\\./g') + +TMP=$(mktemp "$PBXPROJ.XXXXXX") +trap 'rm -f "$TMP"' EXIT +sed \ + -e "s|CURRENT_PROJECT_VERSION = $OLD_BUILD;|CURRENT_PROJECT_VERSION = $NEW_BUILD;|g" \ + -e "s|MARKETING_VERSION = $OLD_MARKETING_RE;|MARKETING_VERSION = $NEW_MARKETING;|g" \ + "$PBXPROJ" > "$TMP" +mv "$TMP" "$PBXPROJ" + +NEW_BUILD_LINES=$(grep -cF "CURRENT_PROJECT_VERSION = $NEW_BUILD;" "$PBXPROJ" || true) +NEW_MARKETING_LINES=$(grep -cF "MARKETING_VERSION = $NEW_MARKETING;" "$PBXPROJ" || true) +if [ "$NEW_BUILD_LINES" -ne 10 ] || [ "$NEW_MARKETING_LINES" -ne 10 ]; then + echo "Error: post-replacement: $NEW_BUILD_LINES CURRENT_PROJECT_VERSION + $NEW_MARKETING_LINES MARKETING_VERSION (expected 10+10)." >&2 + exit 1 +fi +echo "Updated 20 lines (10 + 10)." From fe6b5186e181d68c4b48aaef06471663023d0abe Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 25 May 2026 10:37:13 +0100 Subject: [PATCH 004/200] core: update simplexmq (receiving services) (#6212) * core: update simplexmq * update agent api * update simplexmq * core: add flag to User to use client services * update simplexmq * cli command to toggle service for a user * test, fix * query plans, core/bot api types * remove local package reference * increase server queue size in tests * show client service status in users list * update query plans * cli: fix redraw slowness (#6735) * cli: add pland to fix redraw slowness * updtae doc * cli: decouple key reading from processing via TQueue * schema and bot types --------- Co-authored-by: sh <37271604+shumvgolove@users.noreply.github.com> --- .../src/Broadcast/Options.hs | 2 +- .../src/Directory/Options.hs | 11 +- bots/api/TYPES.md | 7 +- bots/src/API/Docs/Commands.hs | 1 + bots/src/API/Docs/Events.hs | 1 + cabal.project | 2 +- .../types/typescript/src/types.ts | 10 +- .../src/simplex_chat/types/_types.py | 10 +- plans/cli-paste-slowness.md | 111 +++++++++++ scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 2 + src/Simplex/Chat.hs | 174 +++++++++--------- src/Simplex/Chat/Controller.hs | 19 +- src/Simplex/Chat/Core.hs | 52 +++--- src/Simplex/Chat/Library/Commands.hs | 63 ++++--- src/Simplex/Chat/Library/Internal.hs | 5 +- src/Simplex/Chat/Library/Subscriber.hs | 40 ++-- src/Simplex/Chat/Mobile.hs | 8 +- src/Simplex/Chat/Options.hs | 11 +- src/Simplex/Chat/Remote.hs | 2 +- src/Simplex/Chat/Store/Postgres/Migrations.hs | 4 +- .../Migrations/M20260520_client_services.hs | 19 ++ .../Store/Postgres/Migrations/chat_schema.sql | 3 +- src/Simplex/Chat/Store/Profiles.hs | 26 ++- src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../Migrations/M20260520_client_services.hs | 18 ++ .../SQLite/Migrations/agent_query_plans.txt | 101 +++++++++- .../SQLite/Migrations/chat_query_plans.txt | 30 +-- .../Store/SQLite/Migrations/chat_schema.sql | 3 +- src/Simplex/Chat/Store/Shared.hs | 8 +- src/Simplex/Chat/Terminal.hs | 10 +- src/Simplex/Chat/Terminal/Input.hs | 14 +- src/Simplex/Chat/Types.hs | 10 +- src/Simplex/Chat/View.hs | 31 +++- tests/Bots/DirectoryTests.hs | 1 + tests/ChatClient.hs | 26 ++- tests/ChatTests/Profiles.hs | 83 +++++++++ tests/ChatTests/Utils.hs | 7 +- tests/JSONFixtures.hs | 4 +- tests/MobileTests.hs | 4 +- 40 files changed, 681 insertions(+), 258 deletions(-) create mode 100644 plans/cli-paste-slowness.md create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations/M20260520_client_services.hs create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20260520_client_services.hs diff --git a/apps/simplex-broadcast-bot/src/Broadcast/Options.hs b/apps/simplex-broadcast-bot/src/Broadcast/Options.hs index ff853f403d..268e4329cc 100644 --- a/apps/simplex-broadcast-bot/src/Broadcast/Options.hs +++ b/apps/simplex-broadcast-bot/src/Broadcast/Options.hs @@ -94,5 +94,5 @@ mkChatOpts BroadcastBotOpts {coreOptions, botDisplayName} = autoAcceptFileSize = 0, muteNotifications = True, markRead = False, - createBot = Just CreateBotOpts {botDisplayName, allowFiles = False} + createBot = Just CreateBotOpts {botDisplayName, allowFiles = False, clientService = False} } diff --git a/apps/simplex-directory-service/src/Directory/Options.hs b/apps/simplex-directory-service/src/Directory/Options.hs index f566ed5ded..5d51023781 100644 --- a/apps/simplex-directory-service/src/Directory/Options.hs +++ b/apps/simplex-directory-service/src/Directory/Options.hs @@ -39,6 +39,7 @@ data DirectoryOpts = DirectoryOpts directoryLog :: Maybe FilePath, migrateDirectoryLog :: Maybe MigrateLog, serviceName :: T.Text, + clientService :: Bool, runCLI :: Bool, searchResults :: Int, webFolder :: Maybe FilePath, @@ -151,6 +152,11 @@ directoryOpts appDir defaultDbName = do <> help "The display name of the directory service bot, without *'s and spaces (SimpleX Directory)" <> value "SimpleX Directory" ) + clientService <- + switch + ( long "client-service" + <> help "Use client service certificate" + ) runCLI <- switch ( long "run-cli" @@ -188,6 +194,7 @@ directoryOpts appDir defaultDbName = do directoryLog, migrateDirectoryLog, serviceName = T.pack serviceName, + clientService, runCLI, searchResults = 10, webFolder, @@ -207,7 +214,7 @@ getDirectoryOpts appDir defaultDbName = versionAndUpdate = versionStr <> "\n" <> updateStr mkChatOpts :: DirectoryOpts -> ChatOpts -mkChatOpts DirectoryOpts {coreOptions, serviceName} = +mkChatOpts DirectoryOpts {coreOptions, serviceName, clientService} = ChatOpts { coreOptions, chatCmd = "", @@ -221,7 +228,7 @@ mkChatOpts DirectoryOpts {coreOptions, serviceName} = autoAcceptFileSize = 0, muteNotifications = True, markRead = False, - createBot = Just CreateBotOpts {botDisplayName = serviceName, allowFiles = False} + createBot = Just CreateBotOpts {botDisplayName = serviceName, allowFiles = False, clientService} } parseMigrateLog :: ReadM MigrateLog diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index b4edb9bd22..1b843bc6e4 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -992,9 +992,6 @@ NoRcvFileUser: UserUnknown: - type: "userUnknown" -ActiveUserExists: -- type: "activeUserExists" - UserExists: - type: "userExists" - contactName: string @@ -2882,6 +2879,7 @@ SubscribeError: - profile: [Profile](#profile)? - pastTimestamp: bool - userChatRelay: bool +- clientService: bool --- @@ -4086,8 +4084,9 @@ Handshake: - sendRcptsSmallGroups: bool - autoAcceptMemberContacts: bool - userMemberProfileUpdatedAt: UTCTime? -- uiThemes: [UIThemeEntityOverrides](#uithemeentityoverrides)? - userChatRelay: bool +- clientService: bool +- uiThemes: [UIThemeEntityOverrides](#uithemeentityoverrides)? --- diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 8894609758..756cf8c10e 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -271,6 +271,7 @@ cliCommands = "SetAddressSettings", "SetBotCommands", "SetChatTTL", + "SetClientService", "SetContactFeature", "SetContactTimedMessages", "SetGroupFeature", diff --git a/bots/src/API/Docs/Events.hs b/bots/src/API/Docs/Events.hs index c8446e9e67..f0c9352efd 100644 --- a/bots/src/API/Docs/Events.hs +++ b/bots/src/API/Docs/Events.hs @@ -188,6 +188,7 @@ undocumentedEvents = "CEvtCustomChatEvent", "CEvtGroupMemberRatchetSync", "CEvtGroupMemberSwitch", + "CEvtServiceSubStatus", "CEvtNewRemoteHost", "CEvtNoMemberContactCreating", "CEvtNtfMessage", diff --git a/cabal.project b/cabal.project index 7ee797e621..22eeebe714 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: f03cec7a58ed13a39a52886888c74bcefdb64479 + tag: f0b7a4be7325cb787297a881076299c5ffbe26e7 source-repository-package type: git diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 7e618e05c8..1b9e9f6f65 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -994,7 +994,6 @@ export type ChatErrorType = | ChatErrorType.NoSndFileUser | ChatErrorType.NoRcvFileUser | ChatErrorType.UserUnknown - | ChatErrorType.ActiveUserExists | ChatErrorType.UserExists | ChatErrorType.ChatRelayExists | ChatErrorType.DifferentActiveUser @@ -1072,7 +1071,6 @@ export namespace ChatErrorType { | "noSndFileUser" | "noRcvFileUser" | "userUnknown" - | "activeUserExists" | "userExists" | "chatRelayExists" | "differentActiveUser" @@ -1170,10 +1168,6 @@ export namespace ChatErrorType { type: "userUnknown" } - export interface ActiveUserExists extends Interface { - type: "activeUserExists" - } - export interface UserExists extends Interface { type: "userExists" contactName: string @@ -3181,6 +3175,7 @@ export interface NewUser { profile?: Profile pastTimestamp: boolean userChatRelay: boolean + clientService: boolean } export interface NoteFolder { @@ -4795,8 +4790,9 @@ export interface User { sendRcptsSmallGroups: boolean autoAcceptMemberContacts: boolean userMemberProfileUpdatedAt?: string // ISO-8601 timestamp - uiThemes?: UIThemeEntityOverrides userChatRelay: boolean + clientService: boolean + uiThemes?: UIThemeEntityOverrides } export interface UserChatRelay { diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_types.py b/packages/simplex-chat-python/src/simplex_chat/types/_types.py index b2fc00a44c..c378ad56fd 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -712,9 +712,6 @@ class ChatErrorType_noRcvFileUser(TypedDict): class ChatErrorType_userUnknown(TypedDict): type: Literal["userUnknown"] -class ChatErrorType_activeUserExists(TypedDict): - type: Literal["activeUserExists"] - class ChatErrorType_userExists(TypedDict): type: Literal["userExists"] contactName: str @@ -987,7 +984,6 @@ ChatErrorType = ( | ChatErrorType_noSndFileUser | ChatErrorType_noRcvFileUser | ChatErrorType_userUnknown - | ChatErrorType_activeUserExists | ChatErrorType_userExists | ChatErrorType_chatRelayExists | ChatErrorType_differentActiveUser @@ -1059,7 +1055,7 @@ ChatErrorType = ( | ChatErrorType_exception ) -ChatErrorType_Tag = Literal["noActiveUser", "noConnectionUser", "noSndFileUser", "noRcvFileUser", "userUnknown", "activeUserExists", "userExists", "chatRelayExists", "differentActiveUser", "cantDeleteActiveUser", "cantDeleteLastUser", "cantHideLastUser", "hiddenUserAlwaysMuted", "emptyUserPassword", "userAlreadyHidden", "userNotHidden", "invalidDisplayName", "chatNotStarted", "chatNotStopped", "chatStoreChanged", "invalidConnReq", "unsupportedConnReq", "connReqMessageProhibited", "contactNotReady", "contactNotActive", "contactDisabled", "connectionDisabled", "groupUserRole", "groupMemberInitialRole", "contactIncognitoCantInvite", "groupIncognitoCantInvite", "groupContactRole", "groupDuplicateMember", "groupDuplicateMemberId", "groupNotJoined", "groupMemberNotActive", "cantBlockMemberForSelf", "groupMemberUserRemoved", "groupMemberNotFound", "groupCantResendInvitation", "groupInternal", "fileNotFound", "fileSize", "fileAlreadyReceiving", "fileCancelled", "fileCancel", "fileAlreadyExists", "fileWrite", "fileSend", "fileRcvChunk", "fileInternal", "fileImageType", "fileImageSize", "fileNotReceived", "fileNotApproved", "fallbackToSMPProhibited", "inlineFileProhibited", "invalidForward", "invalidChatItemUpdate", "invalidChatItemDelete", "hasCurrentCall", "noCurrentCall", "callContact", "directMessagesProhibited", "agentVersion", "agentNoSubResult", "commandError", "agentCommandError", "invalidFileDescription", "connectionIncognitoChangeProhibited", "connectionUserChangeProhibited", "peerChatVRangeIncompatible", "relayTestError", "internalError", "exception"] +ChatErrorType_Tag = Literal["noActiveUser", "noConnectionUser", "noSndFileUser", "noRcvFileUser", "userUnknown", "userExists", "chatRelayExists", "differentActiveUser", "cantDeleteActiveUser", "cantDeleteLastUser", "cantHideLastUser", "hiddenUserAlwaysMuted", "emptyUserPassword", "userAlreadyHidden", "userNotHidden", "invalidDisplayName", "chatNotStarted", "chatNotStopped", "chatStoreChanged", "invalidConnReq", "unsupportedConnReq", "connReqMessageProhibited", "contactNotReady", "contactNotActive", "contactDisabled", "connectionDisabled", "groupUserRole", "groupMemberInitialRole", "contactIncognitoCantInvite", "groupIncognitoCantInvite", "groupContactRole", "groupDuplicateMember", "groupDuplicateMemberId", "groupNotJoined", "groupMemberNotActive", "cantBlockMemberForSelf", "groupMemberUserRemoved", "groupMemberNotFound", "groupCantResendInvitation", "groupInternal", "fileNotFound", "fileSize", "fileAlreadyReceiving", "fileCancelled", "fileCancel", "fileAlreadyExists", "fileWrite", "fileSend", "fileRcvChunk", "fileInternal", "fileImageType", "fileImageSize", "fileNotReceived", "fileNotApproved", "fallbackToSMPProhibited", "inlineFileProhibited", "invalidForward", "invalidChatItemUpdate", "invalidChatItemDelete", "hasCurrentCall", "noCurrentCall", "callContact", "directMessagesProhibited", "agentVersion", "agentNoSubResult", "commandError", "agentCommandError", "invalidFileDescription", "connectionIncognitoChangeProhibited", "connectionUserChangeProhibited", "peerChatVRangeIncompatible", "relayTestError", "internalError", "exception"] ChatFeature = Literal["timedMessages", "fullDelete", "reactions", "voice", "files", "calls", "sessions"] @@ -2226,6 +2222,7 @@ class NewUser(TypedDict): profile: NotRequired["Profile"] pastTimestamp: bool userChatRelay: bool + clientService: bool class NoteFolder(TypedDict): noteFolderId: int # int64 @@ -3363,8 +3360,9 @@ class User(TypedDict): sendRcptsSmallGroups: bool autoAcceptMemberContacts: bool userMemberProfileUpdatedAt: NotRequired[str] # ISO-8601 timestamp - uiThemes: NotRequired["UIThemeEntityOverrides"] userChatRelay: bool + clientService: bool + uiThemes: NotRequired["UIThemeEntityOverrides"] class UserChatRelay(TypedDict): chatRelayId: int # int64 diff --git a/plans/cli-paste-slowness.md b/plans/cli-paste-slowness.md new file mode 100644 index 0000000000..33255996be --- /dev/null +++ b/plans/cli-paste-slowness.md @@ -0,0 +1,111 @@ +# CLI terminal: event loss root cause analysis + +## Two distinct problems + +### Problem 1: Paste — TMVar capacity-1 bottleneck + +When copy-pasting text, the capacity-1 `TMVar` event channel between the keyboard input reader and the consumer loop throttles stdin reading to terminal redraw speed. + +**Root cause:** `events <- liftIO newEmptyTMVarIO` (`Platform.hsc:64`). Producer blocks on `putTMVar` after each event until consumer finishes redrawing. Consumer does a full terminal redraw per event (`Input.hs:161`). + +**Fix:** Replace `TMVar` with `TQueue` in `Platform.hsc` (6 line changes on POSIX, matching changes on Windows). Decouples producer from consumer — stdin is drained at full speed regardless of redraw speed. + +See previous analysis in git history for full details on this issue. + +--- + +### Problem 2: Heavy load — `outputQ` backpressure blocks `agentSubscriber` + +When the CLI is used as a heavy client (e.g., 1M connections), incoming chat events overwhelm the terminal display, causing cascading backpressure that blocks message acknowledgments and stalls the entire event processing pipeline. + +**This is the more severe problem.** It causes actual message loss at the protocol level, not just UI slowness. + +## Root cause: bounded `outputQ` + single-threaded `agentSubscriber` + +### The queue chain + +``` +Network (SMP/XFTP connections) + → agent internal queues + → subQ (TBQueue, capacity 1024) ← agent → chat boundary + → agentSubscriber (single-threaded) ← Commands.hs:4167 + → processAgentMessage ← Subscriber.hs:109 + → toView_ → writeTBQueue outputQ ← Controller.hs:1528, BLOCKS when full + → outputQ (TBQueue, capacity 1024) ← Chat.hs:152 + → runTerminalOutput ← Output.hs:146 + → printToTerminal (acquires termLock) ← Output.hs:298-303 + → terminal I/O (slow) +``` + +All queues are bounded `TBQueue` with default capacity 1024 (`Options.hs:226`). All writes use `writeTBQueue` which **blocks when full** — no events are dropped within the application, but backpressure cascades upstream. + +### The blocking chain under heavy load + +1. **Terminal I/O is the bottleneck.** `runTerminalOutput` (`Output.hs:146`) reads one event at a time from `outputQ`, acquires `termLock`, prints the message + redraws input, releases lock. Each iteration involves ANSI escape sequences, cursor manipulation, and `flush` syscalls. Throughput: ~hundreds of events/sec at best. + +2. **`outputQ` fills up.** With 1M connections generating events, the arrival rate far exceeds terminal display speed. The 1024-element TBQueue fills in seconds. + +3. **`toView_` blocks.** `Controller.hs:1528`: `writeTBQueue localQ (Nothing, event)` blocks when the queue is full. This call happens inside `processAgentMessage` → `processAgentMessageConn`, which runs within the `agentSubscriber` loop. + +4. **`agentSubscriber` blocks — head-of-line blocking.** `Commands.hs:4164-4167`: + ```haskell + agentSubscriber = do + q <- asks $ subQ . smpAgent + forever (atomically (readTBQueue q) >>= process) + ``` + Single-threaded. When `process` blocks on `toView_`, ALL events for ALL connections queue up behind it. Events for 1M other connections — including time-critical ACKs, keepalives, and handshakes — are stuck. + +5. **ACKs are never sent.** The message receive path (`Subscriber.hs:1537-1540`) calls `toView` BEFORE `ackMsg`: + ```haskell + -- Inside withAckMessage's action: + saveRcvChatItem' ... -- save to DB (succeeds) + toView $ CEvtNewChatItems ... -- BLOCKS here (outputQ full) + -- returns (withRcpt, shouldDelConns) + + -- After action returns (Subscriber.hs:1396-1397): + ackMsg msgMeta ... -- NEVER REACHED while toView blocks + ``` + The developers explicitly acknowledge this at `Subscriber.hs:122-123`: + > *without ACK the message delivery will be stuck* + +6. **`subQ` fills up.** The agent can't deliver events to `subQ` (also capacity 1024) because `agentSubscriber` isn't reading. Agent-level processing stalls. + +7. **Network-level failure.** Connections time out due to unprocessed keepalives and unacknowledged messages. Messages are lost at the protocol level. + +### `termLock` contention worsens the bottleneck + +`termLock` (`Output.hs:55`) is a `TMVar ()` mutex shared between: +- **Output thread** (`runTerminalOutput` → `printToTerminal`): acquires lock for each displayed message +- **Input thread** (`receiveFromTTY` → `updateInput`): acquires lock after each keystroke +- **Live prompt thread** (`blinkLivePrompt` → `updateInputView`): acquires lock every 1 second + +Under heavy load, the output thread dominates the lock (constant stream of messages). The input thread is starved — user keystrokes are delayed. This also slows the output thread itself (lock contention overhead). + +Note: `withTermLock` (`Output.hs:138-142`) is not exception-safe — no `bracket`/`finally`. If the action throws, the lock leaks and all threads deadlock. + +### Error reporting also blocks + +When `processAgentMessage` encounters an error, the error handler (`Commands.hs:4179`) calls `eToView'` → `toView_` → `writeTBQueue outputQ`. If `outputQ` is already full, even error reporting blocks. There is no escape path. + +## Impact summary + +| Load level | `outputQ` state | Effect | +|---|---|---| +| Light (few connections) | Nearly empty | No issues | +| Moderate (hundreds) | Partially filled | Occasional display lag | +| Heavy (thousands+) | Full (1024) | `toView_` blocks → `agentSubscriber` blocks → head-of-line blocking for ALL connections → ACKs delayed → message delivery stuck | +| Extreme (1M connections) | Permanently full | Cascading failure: all event processing stops, connections time out, messages lost at protocol level | + +## Fix + +The core fix: **`toView_` must never block the event processing pipeline on terminal display.** + +Options (in order of simplicity): + +1. **Make `outputQ` unbounded** — replace `TBQueue` with `TQueue` in `Chat.hs:152`. `writeTQueue` never blocks. Events accumulate in memory under heavy load but the event processing pipeline (including ACKs) is never stalled. Tradeoff: unbounded memory growth under sustained heavy load. + +2. **Non-blocking write with drop** — use `tryWriteTBQueue` in `toView_`. When `outputQ` is full, drop the display event (or a coalesced summary). ACKs and network processing proceed unblocked. Tradeoff: some events not displayed, but none lost at protocol level. + +3. **Separate ACK from display** — restructure `withAckMessage` to send ACK immediately after DB save, before `toView`. This decouples protocol correctness from display. `toView` can still block, but ACKs are always timely. Tradeoff: requires careful restructuring of the message processing path. + +4. **Increase queue capacity** — increase `tbqSize` from 1024 to a larger value. Delays the problem but doesn't fix it. Under sustained heavy load, any finite queue eventually fills. diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 8a91d35f05..e4532c49b5 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."f03cec7a58ed13a39a52886888c74bcefdb64479" = "0bkd8kqgmwgfh5rwnw7s4p6mx9kwigi4jq9ljlfvzj23pslk1aq7"; + "https://github.com/simplex-chat/simplexmq.git"."f0b7a4be7325cb787297a881076299c5ffbe26e7" = "0a8a9l31l4a9nilcqg8h60mrxpqxpzzqxi58i60nw8h4vxqqlzcz"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index bdd7fef0b2..e9a5660637 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -134,6 +134,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260507_relay_inactive_at Simplex.Chat.Store.Postgres.Migrations.M20260514_relay_request_group_link_index Simplex.Chat.Store.Postgres.Migrations.M20260515_delivery_job_senders + Simplex.Chat.Store.Postgres.Migrations.M20260520_client_services else exposed-modules: Simplex.Chat.Archive @@ -290,6 +291,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260507_relay_inactive_at Simplex.Chat.Store.SQLite.Migrations.M20260514_relay_request_group_link_index Simplex.Chat.Store.SQLite.Migrations.M20260515_delivery_job_senders + Simplex.Chat.Store.SQLite.Migrations.M20260520_client_services other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index c3658a1c94..ec17614db3 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -135,7 +135,7 @@ createChatDatabase chatDbOpts migrationConfig = runExceptT $ do agentStore <- ExceptT $ createAgentStore (toDBOpts chatDbOpts agentSuffix False []) migrationConfig pure ChatDatabase {chatStore, agentStore} -newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> Bool -> IO ChatController +newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> Bool -> IO (Either AgentErrorType ChatController) newChatController ChatDatabase {chatStore, agentStore} user @@ -145,8 +145,6 @@ newChatController let inlineFiles' = if allowInstantFiles || autoAcceptFileSize > 0 then inlineFiles else inlineFiles {sendChunks = 0, receiveInstant = False} confirmMigrations' = if confirmMigrations == MCConsole && yesToUpMigrations then MCYesUp else confirmMigrations config = cfg {logLevel, showReactions, tbqSize, subscriptionEvents = logConnections, hostEvents = logServerHosts, presetServers = presetServers', inlineFiles = inlineFiles', autoAcceptFileSize, highlyAvailable, confirmMigrations = confirmMigrations'} - firstTime = dbNew chatStore - currentUser <- newTVarIO user randomPresetServers <- chooseRandomServers presetServers' let rndSrvs = L.toList randomPresetServers operatorWithId (i, op) = (\o -> o {operatorId = DBEntityId i}) <$> pOperator op @@ -154,90 +152,93 @@ newChatController agentSMP <- randomServerCfgs "agent SMP servers" SPSMP opDomains rndSrvs agentXFTP <- randomServerCfgs "agent XFTP servers" SPXFTP opDomains rndSrvs let randomAgentServers = RandomAgentServers {smpServers = agentSMP, xftpServers = agentXFTP} - currentRemoteHost <- newTVarIO Nothing servers <- withTransaction chatStore $ \db -> agentServers db config randomPresetServers randomAgentServers - smpAgent <- getSMPAgentClient aCfg {tbqSize} servers agentStore backgroundMode - agentAsync <- newTVarIO Nothing - random <- liftIO C.newRandom - eventSeq <- newTVarIO 0 - inputQ <- newTBQueueIO tbqSize - outputQ <- newTBQueueIO tbqSize - subscriptionMode <- newTVarIO SMSubscribe - chatLock <- newEmptyTMVarIO - entityLocks <- TM.emptyIO - sndFiles <- newTVarIO M.empty - rcvFiles <- newTVarIO M.empty - currentCalls <- TM.emptyIO - localDeviceName <- newTVarIO $ fromMaybe deviceNameForRemote deviceName - multicastSubscribers <- newTMVarIO 0 - remoteSessionSeq <- newTVarIO 0 - remoteHostSessions <- TM.emptyIO - remoteHostsFolder <- newTVarIO Nothing - remoteCtrlSession <- newTVarIO Nothing - filesFolder <- newTVarIO optFilesFolder - chatStoreChanged <- newTVarIO False - deliveryTaskWorkers <- TM.emptyIO - deliveryJobWorkers <- TM.emptyIO - relayRequestWorkers <- TM.emptyIO - chatRelayTests <- TM.emptyIO - expireCIThreads <- TM.emptyIO - expireCIFlags <- TM.emptyIO - cleanupManagerAsync <- newTVarIO Nothing - relayGroupLinkChecksAsync <- newTVarIO Nothing - timedItemThreads <- TM.emptyIO - chatActivated <- newTVarIO True - showLiveItems <- newTVarIO False - encryptLocalFiles <- newTVarIO False - tempDirectory <- newTVarIO optTempDirectory - assetsDirectory <- newTVarIO Nothing - contactMergeEnabled <- newTVarIO True - pure - ChatController - { firstTime, - currentUser, - randomPresetServers, - randomAgentServers, - currentRemoteHost, - smpAgent, - agentAsync, - chatStore, - chatStoreChanged, - random, - eventSeq, - inputQ, - outputQ, - subscriptionMode, - chatLock, - entityLocks, - sndFiles, - rcvFiles, - currentCalls, - localDeviceName, - multicastSubscribers, - remoteSessionSeq, - remoteHostSessions, - remoteHostsFolder, - remoteCtrlSession, - config, - filesFolder, - deliveryTaskWorkers, - deliveryJobWorkers, - relayRequestWorkers, - chatRelayTests, - expireCIThreads, - expireCIFlags, - cleanupManagerAsync, - relayGroupLinkChecksAsync, - timedItemThreads, - chatActivated, - showLiveItems, - encryptLocalFiles, - tempDirectory, - assetsDirectory, - logFilePath = logFile, - contactMergeEnabled - } + runExceptT (getSMPAgentClient aCfg {tbqSize} servers agentStore backgroundMode) + >>= mapM (mkChatController config randomPresetServers randomAgentServers) where + mkChatController config randomPresetServers randomAgentServers smpAgent = do + currentUser <- newTVarIO user + currentRemoteHost <- newTVarIO Nothing + agentAsync <- newTVarIO Nothing + random <- liftIO C.newRandom + eventSeq <- newTVarIO 0 + inputQ <- newTBQueueIO tbqSize + outputQ <- newTBQueueIO tbqSize + subscriptionMode <- newTVarIO SMSubscribe + chatLock <- newEmptyTMVarIO + entityLocks <- TM.emptyIO + sndFiles <- newTVarIO M.empty + rcvFiles <- newTVarIO M.empty + currentCalls <- TM.emptyIO + localDeviceName <- newTVarIO $ fromMaybe deviceNameForRemote deviceName + multicastSubscribers <- newTMVarIO 0 + remoteSessionSeq <- newTVarIO 0 + remoteHostSessions <- TM.emptyIO + remoteHostsFolder <- newTVarIO Nothing + remoteCtrlSession <- newTVarIO Nothing + filesFolder <- newTVarIO optFilesFolder + chatStoreChanged <- newTVarIO False + deliveryTaskWorkers <- TM.emptyIO + deliveryJobWorkers <- TM.emptyIO + relayRequestWorkers <- TM.emptyIO + relayGroupLinkChecksAsync <- newTVarIO Nothing + chatRelayTests <- TM.emptyIO + expireCIThreads <- TM.emptyIO + expireCIFlags <- TM.emptyIO + cleanupManagerAsync <- newTVarIO Nothing + timedItemThreads <- TM.emptyIO + chatActivated <- newTVarIO True + showLiveItems <- newTVarIO False + encryptLocalFiles <- newTVarIO False + tempDirectory <- newTVarIO optTempDirectory + assetsDirectory <- newTVarIO Nothing + contactMergeEnabled <- newTVarIO True + pure + ChatController + { firstTime = dbNew chatStore, + currentUser, + randomPresetServers, + randomAgentServers, + currentRemoteHost, + smpAgent, + agentAsync, + chatStore, + chatStoreChanged, + random, + eventSeq, + inputQ, + outputQ, + subscriptionMode, + chatLock, + entityLocks, + sndFiles, + rcvFiles, + currentCalls, + localDeviceName, + multicastSubscribers, + remoteSessionSeq, + remoteHostSessions, + remoteHostsFolder, + remoteCtrlSession, + config, + filesFolder, + deliveryTaskWorkers, + deliveryJobWorkers, + relayRequestWorkers, + relayGroupLinkChecksAsync, + chatRelayTests, + expireCIThreads, + expireCIFlags, + cleanupManagerAsync, + timedItemThreads, + chatActivated, + showLiveItems, + encryptLocalFiles, + tempDirectory, + assetsDirectory, + logFilePath = logFile, + contactMergeEnabled + } presetServers' :: PresetServers presetServers' = presetServers {operators = operators', netCfg = netCfg'} where @@ -271,7 +272,8 @@ newChatController ops <- getUpdateServerOperators db presetOps (null users) let opDomains = operatorDomains $ mapMaybe snd ops (smp', xftp') <- unzip <$> mapM (getServers ops opDomains) users - pure InitialAgentServers {smp = M.fromList (optServers smp' smpServers), xftp = M.fromList (optServers xftp' xftpServers), ntf, netCfg, presetDomains, presetServers = L.toList allPresetServers} + let useServices = M.fromList $ map (\User {agentUserId = AgentUserId uId, clientService} -> (uId, isTrue clientService)) users + pure InitialAgentServers {smp = M.fromList (optServers smp' smpServers), xftp = M.fromList (optServers xftp' xftpServers), ntf, netCfg, useServices, presetDomains, presetServers = L.toList allPresetServers} where optServers :: [(UserId, NonEmpty (ServerCfg p))] -> [ProtoServerWithAuth p] -> [(UserId, NonEmpty (ServerCfg p))] optServers srvs overrides_ = case L.nonEmpty overrides_ of diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index fa2d0af009..402bfa6b10 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -255,11 +255,11 @@ data ChatController = ChatController deliveryTaskWorkers :: TMap DeliveryWorkerKey Worker, deliveryJobWorkers :: TMap DeliveryWorkerKey Worker, relayRequestWorkers :: TMap Int Worker, -- single global worker with key 1 is used to fit into existing worker management framework + relayGroupLinkChecksAsync :: TVar (Maybe (Async ())), chatRelayTests :: TMap ConnId RelayTest, expireCIThreads :: TMap UserId (Maybe (Async ())), expireCIFlags :: TMap UserId Bool, cleanupManagerAsync :: TVar (Maybe (Async ())), - relayGroupLinkChecksAsync :: TVar (Maybe (Async ())), chatActivated :: TVar Bool, timedItemThreads :: TMap (ChatRef, ChatItemId) (TVar (Maybe (Weak ThreadId))), showLiveItems :: TVar Bool, @@ -294,6 +294,7 @@ data ChatCommand | UnhideUser UserPwd | MuteUser | UnmuteUser + | SetClientService UserId ContactName Bool | APIDeleteUser {userId :: UserId, delSMPQueues :: Bool, viewPwd :: Maybe UserPwd} | DeleteUser UserName Bool (Maybe UserPwd) | StartChat {mainApp :: Bool, enableSndFiles :: Bool} -- enableSndFiles has no effect when mainApp is True @@ -895,6 +896,7 @@ data ChatEvent | CEvtConnectionsDiff {userIds :: DatabaseDiff AgentUserId, connIds :: DatabaseDiff AgentConnId} | CEvtSubscriptionEnd {user :: User, connectionEntity :: ConnectionEntity} | CEvtSubscriptionStatus {server :: SMPServer, subscriptionStatus :: SubscriptionStatus, connections :: [AgentConnId]} + | CEvtServiceSubStatus {server :: SMPServer, serviceSubEvent :: ServiceSubEvent} | CEvtHostConnected {protocol :: AProtocolType, transportHost :: TransportHost} | CEvtHostDisconnected {protocol :: AProtocolType, transportHost :: TransportHost} | CEvtReceivedGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, fromMemberRole :: GroupMemberRole, memberRole :: GroupMemberRole} @@ -1309,6 +1311,13 @@ data ChatItemDeletion = ChatItemDeletion } deriving (Show) +data ServiceSubEvent + = ServiceSubUp {serviceError :: Maybe Text, queueCount :: Int64} + | ServiceSubDown {queueCount :: Int64} + | ServiceSubAll + | ServiceSubEnd {queueCount :: Int64} + deriving (Show) + data ChatLogLevel = CLLDebug | CLLInfo | CLLWarning | CLLError | CLLImportant deriving (Eq, Ord, Show) @@ -1342,7 +1351,6 @@ data ChatErrorType | CENoSndFileUser {agentSndFileId :: AgentSndFileId} | CENoRcvFileUser {agentRcvFileId :: AgentRcvFileId} | CEUserUnknown - | CEActiveUserExists -- TODO delete | CEUserExists {contactName :: ContactName} | CEChatRelayExists | CEDifferentActiveUser {commandUserId :: UserId, activeUserId :: UserId} @@ -1432,6 +1440,9 @@ data SQLiteError = SQLiteErrorNotADatabase | SQLiteError {dbError :: String} throwDBError :: DatabaseError -> CM () throwDBError = throwError . ChatErrorDatabase +chatErrorAgent :: AgentErrorType -> ChatError +chatErrorAgent e = ChatErrorAgent e (AgentConnId B.empty) Nothing + -- TODO review errors, some of it can be covered by HTTP2 errors data RemoteHostError = RHEMissing -- No remote session matches this identifier @@ -1663,7 +1674,7 @@ withAgent :: (AgentClient -> ExceptT AgentErrorType IO a) -> CM a withAgent action = asks smpAgent >>= liftIO . runExceptT . action - >>= liftEither . first (\e -> ChatErrorAgent e (AgentConnId "") Nothing) + >>= liftEither . first chatErrorAgent withAgent' :: (AgentClient -> IO a) -> CM' a withAgent' action = asks smpAgent >>= liftIO . action @@ -1728,6 +1739,8 @@ $(JQ.deriveJSON defaultJSON ''ParsedServerAddress) $(JQ.deriveJSON defaultJSON ''ChatItemDeletion) +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "ServiceSub") ''ServiceSubEvent) + $(JQ.deriveJSON defaultJSON ''CoreVersionInfo) #if !defined(dbPostgres) diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index 3c1ce9bc26..bd6cac2110 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -59,11 +59,15 @@ simplexChatCore cfg@ChatConfig {confirmMigrations, testView, chatHooks} opts@Cha users <- withTransaction chatStore getUsers u_ <- selectActiveUser coreOptions chatStore users let backgroundMode = maintenance - cc <- newChatController db u_ cfg opts backgroundMode - forM_ (preStartHook chatHooks) ($ cc) - u <- maybe (noMaintenance >> createActiveUser cc coreOptions createBot) pure u_ - unless testView $ putStrLn $ "Current user: " <> userStr u - runSimplexChat cfg opts u cc chat + newChatController db u_ cfg opts backgroundMode >>= \case + Left e -> do + putStrLn $ "Error starting chat: " <> show e + exitFailure + Right cc -> do + forM_ (preStartHook chatHooks) ($ cc) + u <- maybe (noMaintenance >> createActiveUser cc coreOptions createBot) pure u_ + unless testView $ putStrLn $ "Current user: " <> userStr u + runSimplexChat cfg opts u cc chat noMaintenance = when maintenance $ do putStrLn "exiting: no active user in maintenance mode" exitFailure @@ -118,29 +122,27 @@ selectActiveUser CoreChatOpts {chatRelay} st users createActiveUser :: ChatController -> CoreChatOpts -> Maybe CreateBotOpts -> IO User createActiveUser cc CoreChatOpts {chatRelay} = \case - Just CreateBotOpts {botDisplayName, allowFiles} -> do + Just CreateBotOpts {botDisplayName, allowFiles, clientService} -> do let preferences = if allowFiles then Nothing else Just emptyChatPrefs {files = Just FilesPreference {allow = FANo}} - createUser exitFailure $ (mkProfile botDisplayName) {peerType = Just CPTBot, preferences} - Nothing - | chatRelay -> do - putStrLn - "No chat relay user profile found, it will be created now.\n\ - \Please choose chat relay display name." - loop - | otherwise -> do - putStrLn - "No user profiles found, it will be created now.\n\ - \Please choose your display name.\n\ - \It will be sent to your contacts when you connect.\n\ - \It is only stored on your device and you can change it later." - loop + createUser exitFailure clientService $ (mkProfile botDisplayName) {peerType = Just CPTBot, preferences} + Nothing -> putStrLn noProfile >> loop + where + noProfile + | chatRelay = + "No chat relay user profile found, it will be created now.\n\ + \Please choose chat relay display name." + | otherwise = + "No user profiles found, it will be created now.\n\ + \Please choose your display name.\n\ + \It will be sent to your contacts when you connect.\n\ + \It is only stored on your device and you can change it later." + loop = do + displayName <- T.pack <$> withPrompt "display name" getLine + createUser loop False $ mkProfile displayName where - loop = do - displayName <- T.pack <$> withPrompt "display name: " getLine - createUser loop $ mkProfile displayName mkProfile displayName = Profile {displayName, fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing} - createUser onError p = - execChatCommand' (CreateActiveUser NewUser {profile = Just p, pastTimestamp = False, userChatRelay = chatRelay}) 0 `runReaderT` cc >>= \case + createUser onError clientService p = + execChatCommand' (CreateActiveUser NewUser {profile = Just p, pastTimestamp = False, userChatRelay = BoolDef chatRelay, clientService = BoolDef clientService}) 0 `runReaderT` cc >>= \case Right (CRActiveUser user) -> pure user r -> printResponseEvent (Nothing, Nothing) (config cc) r >> onError diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 84eece9915..f835445f0d 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -348,7 +348,7 @@ parseChatCommand = A.parseOnly chatCommandP . B.dropWhileEnd isSpace processChatCommand :: VersionRangeChat -> NetworkRequestMode -> ChatCommand -> CM ChatResponse processChatCommand vr nm = \case ShowActiveUser -> withUser' $ pure . CRActiveUser - CreateActiveUser NewUser {profile, pastTimestamp, userChatRelay} -> do + CreateActiveUser NewUser {profile, pastTimestamp, userChatRelay, clientService} -> do forM_ profile $ \Profile {displayName} -> checkValidName displayName p@Profile {displayName} <- liftIO $ maybe generateRandomProfile pure profile u <- asks currentUser @@ -356,12 +356,13 @@ processChatCommand vr nm = \case forM_ users $ \User {localDisplayName = n, activeUser, viewPwdHash, userChatRelay = userChatRelay'} -> do when (n == displayName) . throwChatError $ if activeUser || isNothing viewPwdHash then CEUserExists displayName else CEInvalidDisplayName {displayName, validName = ""} - when (userChatRelay && isTrue userChatRelay') $ throwChatError CEChatRelayExists + when (isTrue userChatRelay && isTrue userChatRelay') $ throwChatError CEChatRelayExists (uss, (smp', xftp')) <- chooseServers =<< readTVarIO u - auId <- withAgent $ \a -> createUser a smp' xftp' + let service = isTrue clientService + auId <- withAgent $ \a -> createUser a service smp' xftp' ts <- liftIO $ getCurrentTime >>= if pastTimestamp then coupleDaysAgo else pure user <- withFastStore $ \db -> do - user <- createUserRecordAt db (AgentUserId auId) p userChatRelay True ts + user <- createUserRecordAt db (AgentUserId auId) (isTrue userChatRelay) service p True ts mapM_ (setUserServers db user ts) uss createPresetContactCards db user `catchAllErrors` \_ -> pure () createNoteFolder db user @@ -460,6 +461,19 @@ processChatCommand vr nm = \case UnhideUser viewPwd -> withUser $ \User {userId} -> processChatCommand vr nm $ APIUnhideUser userId viewPwd MuteUser -> withUser $ \User {userId} -> processChatCommand vr nm $ APIMuteUser userId UnmuteUser -> withUser $ \User {userId} -> processChatCommand vr nm $ APIUnmuteUser userId + SetClientService userId' name enable -> checkChatStopped $ withUser' $ \currUser@User {userId} -> do + user@User {agentUserId = AgentUserId auId, clientService, profile = LocalProfile {displayName}} <- + if userId == userId' then pure currUser else privateGetUser userId' + unless (name == displayName) $ throwChatError CEUserUnknown + if enable == isTrue clientService + then ok user + else do + withStore' $ \db -> updateClientService db userId' enable + withAgent $ \a -> setUserService a auId enable + let user' = user {clientService = BoolDef enable} :: User + when (userId == userId') $ chatWriteVar currentUser $ Just user' + setStoreChanged + ok user' APIDeleteUser userId' delSMPQueues viewPwd_ -> withUser $ \user -> do user' <- privateGetUser userId' validateUserPassword user user' viewPwd_ @@ -1728,7 +1742,7 @@ processChatCommand vr nm = \case pure $ CRChatItemTTL user (Just ttl) GetChatItemTTL -> withUser' $ \User {userId} -> do processChatCommand vr nm $ APIGetChatItemTTL userId - APISetNetworkConfig cfg -> withUser' $ \_ -> lift (withAgent' (`setNetworkConfig` cfg)) >> ok_ + APISetNetworkConfig cfg -> withUser' $ \_ -> withAgent (`setNetworkConfig` cfg) >> ok_ APIGetNetworkConfig -> withUser' $ \_ -> CRNetworkConfig <$> lift getNetworkConfig SetNetworkConfig simpleNetCfg -> do @@ -1943,8 +1957,7 @@ processChatCommand vr nm = \case subMode <- chatReadVar subscriptionMode let userData = contactShortLinkData (userProfileDirect user incognitoProfile Nothing True) Nothing userLinkData = UserInvLinkData userData - -- TODO [certs rcv] - (connId, (ccLink, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId user) True False SCMInvitation (Just userLinkData) Nothing IKPQOn subMode + (connId, ccLink) <- withAgent $ \a -> createConnection a nm (aUserId user) True False SCMInvitation (Just userLinkData) Nothing IKPQOn subMode ccLink' <- shortenCreatedLink ccLink -- TODO PQ pass minVersion from the current range conn <- withFastStore' $ \db -> createDirectConnection db user connId ccLink' Nothing ConnNew incognitoProfile subMode initialChatVersion PQSupportOn @@ -1985,8 +1998,7 @@ processChatCommand vr nm = \case userLinkData_ | short = Just $ UserInvLinkData $ contactShortLinkData (userProfileDirect newUser Nothing Nothing True) Nothing | otherwise = Nothing - -- TODO [certs rcv] - (agConnId, (ccLink, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId newUser) True False SCMInvitation userLinkData_ Nothing IKPQOn subMode + (agConnId, ccLink) <- withAgent $ \a -> createConnection a nm (aUserId newUser) True False SCMInvitation userLinkData_ Nothing IKPQOn subMode ccLink' <- shortenCreatedLink ccLink conn' <- withFastStore' $ \db -> do deleteConnectionRecord db user connId @@ -2263,8 +2275,7 @@ processChatCommand vr nm = \case | isTrue userChatRelay = relayShortLinkData (userProfileDirect user Nothing Nothing True) | otherwise = contactShortLinkData (userProfileDirect user Nothing Nothing True) Nothing userLinkData = UserContactLinkData UserContactData {direct = True, owners = [], relays = [], userData} - -- TODO [certs rcv] - (connId, (ccLink, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId user) True True SCMContact (Just userLinkData) Nothing IKPQOn subMode + (connId, ccLink) <- withAgent $ \a -> createConnection a nm (aUserId user) True True SCMContact (Just userLinkData) Nothing IKPQOn subMode ccLink' <- shortenCreatedLink ccLink let ccLink'' = if isTrue userChatRelay then setShortLinkType CCTRelay ccLink' else ccLink' withFastStore $ \db -> createUserContactLink db user connId ccLink'' subMode @@ -2594,8 +2605,7 @@ processChatCommand vr nm = \case Nothing -> do gVar <- asks random subMode <- chatReadVar subscriptionMode - -- TODO [certs rcv] - (agentConnId, (CCLink cReq _, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId user) True False SCMInvitation Nothing Nothing IKPQOff subMode + (agentConnId, CCLink cReq _) <- withAgent $ \a -> createConnection a nm (aUserId user) True False SCMInvitation Nothing Nothing IKPQOff subMode member <- withFastStore $ \db -> createNewContactMember db gVar user gInfo contact memRole agentConnId cReq subMode sendInvitation member cReq pure $ CRSentGroupInvitation user gInfo contact member @@ -3042,8 +3052,7 @@ processChatCommand vr nm = \case let userData = encodeShortLinkData $ GroupShortLinkData {groupProfile, publicGroupData = Nothing} userLinkData = UserContactLinkData UserContactData {direct = True, owners = [], relays = [], userData} crClientData = encodeJSON $ CRDataGroup groupLinkId - -- TODO [certs rcv] - (connId, (ccLink, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId user) True True SCMContact (Just userLinkData) (Just crClientData) IKPQOff subMode + (connId, ccLink) <- withAgent $ \a -> createConnection a nm (aUserId user) True True SCMContact (Just userLinkData) (Just crClientData) IKPQOff subMode ccLink' <- setShortLinkType CCTGroup <$> shortenCreatedLink ccLink gVar <- asks random gLink <- withFastStore $ \db -> createGroupLink db gVar user gInfo connId ccLink' groupLinkId mRole subMode @@ -3083,8 +3092,7 @@ processChatCommand vr nm = \case when (isJust $ memberContactId m) $ throwCmdError "member contact already exists" subMode <- chatReadVar subscriptionMode -- TODO PQ should negotitate contact connection with PQSupportOn? - -- TODO [certs rcv] - (connId, (CCLink cReq _, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId user) True False SCMInvitation Nothing Nothing IKPQOff subMode + (connId, CCLink cReq _) <- withAgent $ \a -> createConnection a nm (aUserId user) True False SCMInvitation Nothing Nothing IKPQOff subMode -- [incognito] reuse membership incognito profile ct <- withFastStore' $ \db -> createMemberContact db user connId cReq g m mConn subMode void $ createChatItem user (CDDirectSnd ct) False CIChatBanner Nothing (Just epochStart) @@ -3145,7 +3153,7 @@ processChatCommand vr nm = \case -- [incognito] send membership incognito profile let p = userProfileDirect user (fromLocalProfile <$> incognitoMembershipProfile gInfo) Nothing True dm <- encodeConnInfo $ XInfo p - (sqSecured, _serviceId) <- withAgent $ \a -> joinConnection a nm (aUserId user) (aConnId conn) True cReq dm PQSupportOff subMode + sqSecured <- withAgent $ \a -> joinConnection a nm (aUserId user) (aConnId conn) True cReq dm PQSupportOff subMode let newStatus = if sqSecured then ConnSndReady else ConnJoined void $ withFastStore' $ \db -> updateConnectionStatusFromTo db conn ConnPrepared newStatus CreateGroupLink gName mRole -> withUser $ \user -> do @@ -3452,11 +3460,11 @@ processChatCommand vr nm = \case (chatRef,) <$> case cType of CTGroup -> withFastStore' $ \db -> getMessageMentions db user chatId msg _ -> pure [] -#if !defined(dbPostgres) checkChatStopped :: CM ChatResponse -> CM ChatResponse checkChatStopped a = asks agentAsync >>= readTVarIO >>= maybe a (const $ throwChatError CEChatNotStopped) setStoreChanged :: CM () setStoreChanged = asks chatStoreChanged >>= atomically . (`writeTVar` True) +#if !defined(dbPostgres) withStoreChanged :: CM () -> CM ChatResponse withStoreChanged a = checkChatStopped $ a >> setStoreChanged >> ok_ #endif @@ -3522,7 +3530,7 @@ processChatCommand vr nm = \case joinPreparedConn conn incognitoProfile chatV = do let profileToSend = userProfileDirect user incognitoProfile Nothing True dm <- encodeConnInfoPQ pqSup' chatV $ XInfo profileToSend - (sqSecured, _serviceId) <- withAgent $ \a -> joinConnection a nm (aUserId user) (aConnId conn) True cReq dm pqSup' subMode + sqSecured <- withAgent $ \a -> joinConnection a nm (aUserId user) (aConnId conn) True cReq dm pqSup' subMode let newStatus = if sqSecured then ConnSndReady else ConnJoined conn' <- withFastStore' $ \db -> updateConnectionStatusFromTo db conn ConnPrepared newStatus pure (conn', incognitoProfile) @@ -3988,7 +3996,7 @@ processChatCommand vr nm = \case groupLink = groupSLink } dm <- encodeConnInfo $ XGrpRelayInv relayInv - (sqSecured, _serviceId) <- withAgent $ \a -> joinConnection a nm (aUserId user) (aConnId conn) True cReq dm PQSupportOff subMode + sqSecured <- withAgent $ \a -> joinConnection a nm (aUserId user) (aConnId conn) True cReq dm PQSupportOff subMode let newConnStatus = if sqSecured then ConnSndReady else ConnJoined withFastStore' $ \db -> do void $ updateConnectionStatusFromTo db conn ConnPrepared newConnStatus @@ -4698,7 +4706,7 @@ agentSubscriber = do q <- asks $ subQ . smpAgent forever (atomically (readTBQueue q) >>= process) `catchOwn` \e -> do - eToView' $ ChatErrorAgent (CRITICAL True $ "Message reception stopped: " <> show e) (AgentConnId "") Nothing + eToView' $ chatErrorAgent $ CRITICAL True $ "Message reception stopped: " <> show e E.throwIO e where process :: (ACorrId, AEntityId, AEvt) -> CM' () @@ -4710,7 +4718,7 @@ agentSubscriber = do where run action = action `catchAllOwnErrors'` eToView' -type AgentSubResult = Map ConnId (Either AgentErrorType (Maybe ClientServiceId)) +type AgentSubResult = Map ConnId (Either AgentErrorType ()) cleanupManager :: CM () cleanupManager = do @@ -4925,6 +4933,7 @@ chatCommandP = "/unhide user " *> (UnhideUser <$> pwdP), "/mute user" $> MuteUser, "/unmute user" $> UnmuteUser, + "/set client service " *> (SetClientService <$> A.decimal <* A.char ':' <*> displayNameP <* A.space <*> onOffP), "/_delete user " *> (APIDeleteUser <$> A.decimal <* " del_smp=" <*> onOffP <*> optional (A.space *> jsonP)), "/delete user " *> (DeleteUser <$> displayNameP <*> pure True <*> optional (A.space *> pwdP)), ("/user" <|> "/u") $> ShowActiveUser, @@ -5372,18 +5381,20 @@ chatCommandP = k : ws -> pure (k, if null ws then Nothing else Just $ T.unwords ws) pure CBCCommand {label, keyword, params} quoted = A.char '\'' *> A.takeTill (== '\'') <* A.char '\'' - newUserP userChatRelay = do + newUserP relay = do (cName, shortDescr) <- profileNameDescr + service <- (" service=" *> onOffP) <|> pure False let profile = Just Profile {displayName = cName, fullName = "", shortDescr, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing} - pure NewUser {profile, pastTimestamp = False, userChatRelay} + pure NewUser {profile, pastTimestamp = False, userChatRelay = BoolDef relay, clientService = BoolDef service} newBotUserP = do files_ <- optional $ "files=" *> onOffP <* A.space + service <- ("service=" *> onOffP <* A.space) <|> pure False (cName, shortDescr) <- profileNameDescr let preferences = case files_ of Just True -> Nothing _ -> Just (emptyChatPrefs :: Preferences) {files = Just FilesPreference {allow = FANo}} profile = Just Profile {displayName = cName, fullName = "", shortDescr, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences} - pure NewUser {profile, pastTimestamp = False, userChatRelay = False} + pure NewUser {profile, pastTimestamp = False, userChatRelay = BoolDef False, clientService = BoolDef service} jsonP :: J.FromJSON a => Parser a jsonP = J.eitherDecodeStrict' <$?> A.takeByteString groupProfile = do diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 8c6d2a20cd..576eb942a5 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -908,8 +908,7 @@ acceptContactRequest nm user@User {userId} UserContactRequest {agentInvitationId pure (ct, conn, ExistingIncognito <$> incognitoProfile) let profileToSend = userProfileDirect user (fromIncognitoProfile <$> incognitoProfile) (Just ct) True dm <- encodeConnInfoPQ pqSup' chatV $ XInfo profileToSend - -- TODO [certs rcv] - (ct,conn,) . fst <$> withAgent (\a -> acceptContact a nm (aUserId user) (aConnId conn) True invId dm pqSup' subMode) + (ct,conn,) <$> withAgent (\a -> acceptContact a nm (aUserId user) (aConnId conn) True invId dm pqSup' subMode) acceptContactRequestAsync :: User -> Int64 -> Contact -> UserContactRequest -> Maybe IncognitoProfile -> CM Contact acceptContactRequestAsync @@ -2059,7 +2058,7 @@ deliverMessagesB msgReqs = do Left _ce -> (prev, Left (AP.INTERNAL "ChatError, skip")) -- as long as it is Left, the agent batchers should just step over it prepareBatch (Right req) (Right ar) = Right (req, ar) prepareBatch (Left ce) _ = Left ce -- restore original ChatError - prepareBatch _ (Left ae) = Left $ ChatErrorAgent ae (AgentConnId "") Nothing + prepareBatch _ (Left ae) = Left $ chatErrorAgent ae createDelivery :: DB.Connection -> (ChatMsgReq, (AgentMsgId, PQEncryption)) -> IO (Either ChatError ([Int64], PQEncryption)) createDelivery db ((Connection {connId}, _, (_, msgIds)), (agentMsgId, pqEnc')) = do Right . (,pqEnc') <$> mapM (createSndMsgDelivery db (SndMsgDelivery {connId, agentMsgId})) msgIds diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index f90630bc77..a661e53752 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -88,7 +88,7 @@ import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..), patt import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Encoding (smpEncode) import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Protocol (ErrorType (..), MsgFlags (..)) +import Simplex.Messaging.Protocol (ErrorType (..), MsgFlags (..), ServiceSub (..), ServiceSubError (..), ServiceSubResult (..)) import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.ServiceScheme (ServiceScheme (..)) import qualified Simplex.Messaging.TMap as TM @@ -113,7 +113,7 @@ processAgentMessage _ _ (DEL_RCVQS delQs) = processAgentMessage _ _ (DEL_CONNS connIds) = toView $ CEvtAgentConnsDeleted $ L.map AgentConnId connIds processAgentMessage _ "" (ERR e) = - eToView $ ChatErrorAgent e (AgentConnId "") Nothing + eToView $ chatErrorAgent e processAgentMessage corrId connId msg = do lockEntity <- critical connId (withStore (`getChatLockEntity` AgentConnId connId)) withEntityLock "processAgentMessage" lockEntity $ do @@ -144,12 +144,23 @@ processAgentMessageNoConn = \case UP srv conns -> serverEvent srv SSActive conns SUSPENDED -> toView CEvtChatSuspended DEL_USER agentUserId -> toView $ CEvtAgentUserDeleted agentUserId + SERVICE_UP srv (ServiceSubResult e_ ss) -> serviceEvent srv $ ServiceSubUp (errText <$> e_) (smpQueueCount ss) + where + errText = \case + SSErrorServiceId {} -> "unexpected service ID" + SSErrorQueueCount {expectedQueueCount = n} -> "expected " <> tshow n <> " connections" + SSErrorQueueIdsHash {} -> "different IDs hash" + SERVICE_DOWN srv ss -> serviceEvent srv $ ServiceSubDown $ smpQueueCount ss + SERVICE_ALL srv -> serviceEvent srv ServiceSubAll + SERVICE_END srv ss -> serviceEvent srv $ ServiceSubEnd $ smpQueueCount ss ERRS cErrs -> errsEvent $ L.toList cErrs where hostEvent :: ChatEvent -> CM () hostEvent = whenM (asks $ hostEvents . config) . toView serverEvent :: SMPServer -> SubscriptionStatus -> [ConnId] -> CM () serverEvent srv nsStatus conns = toView $ CEvtSubscriptionStatus srv nsStatus $ map AgentConnId conns + serviceEvent :: SMPServer -> ServiceSubEvent -> CM () + serviceEvent srv = toView . CEvtServiceSubStatus srv errsEvent :: [(ConnId, AgentErrorType)] -> CM () errsEvent = toView . CEvtChatErrors . map (\(cId, e) -> ChatErrorAgent e (AgentConnId cId) Nothing) @@ -383,7 +394,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = agentMsgConnStatus :: Connection -> AEvent e -> Maybe ConnStatus agentMsgConnStatus Connection {connStatus = cs} = \case - JOINED True _ -> Just ConnSndReady + JOINED True -> Just ConnSndReady CONF {} -> Just ConnRequested INFO {} -> Just ConnSndReady CON _ -> Just ConnReady @@ -457,8 +468,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = OK -> -- [async agent commands] continuation on receiving OK when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () - -- TODO [certs rcv] - JOINED _ _serviceId -> + JOINED _ -> -- [async agent commands] continuation on receiving JOINED when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () QCONT -> @@ -477,8 +487,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- TODO add debugging output _ -> pure () Just ct@Contact {contactId} -> case agentMsg of - -- TODO [certs rcv] - INV (ACR _ cReq) _serviceId -> + INV (ACR _ cReq) -> -- [async agent commands] XGrpMemIntro continuation on receiving INV withCompletedCommand conn agentMsg $ \_ -> case cReq of @@ -667,8 +676,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = OK -> -- [async agent commands] continuation on receiving OK when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () - -- TODO [certs rcv] - JOINED sqSecured _serviceId -> + JOINED sqSecured -> -- [async agent commands] continuation on receiving JOINED when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> when (directOrUsed ct && sqSecured) $ do @@ -709,8 +717,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = processGroupMessage :: AEvent e -> ConnectionEntity -> Connection -> GroupInfo -> GroupMember -> CM () processGroupMessage agentMsg connEntity conn@Connection {connId, connChatVersion, customUserProfileId, connectionCode} gInfo@GroupInfo {groupId, groupProfile, membership, chatSettings} m = case agentMsg of - -- TODO [certs rcv] - INV (ACR _ cReq) _serviceId -> + INV (ACR _ cReq) -> withCompletedCommand conn agentMsg $ \CommandData {cmdFunction} -> case cReq of groupConnReq@(CRInvitationUri _ _) -> case cmdFunction of @@ -1149,8 +1156,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = OK -> -- [async agent commands] continuation on receiving OK when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () - -- TODO [certs rcv] - JOINED sqSecured _serviceId -> + JOINED sqSecured -> -- [async agent commands] continuation on receiving JOINED when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> when (sqSecured && connChatVersion >= batchSend2Version) $ do @@ -1680,7 +1686,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = unless shouldDelConns $ withLog (eInfo <> " ok") $ ackMsg msgMeta $ if withRcpt then Just "" else Nothing -- If showCritical is True, then these errors don't result in ACK and show user visible alert -- This prevents losing the message that failed to be processed. - Left (ChatErrorStore SEDBBusyError {message}) | showCritical -> throwError $ ChatErrorAgent (CRITICAL True message) (AgentConnId "") Nothing + Left (ChatErrorStore SEDBBusyError {message}) | showCritical -> throwError $ chatErrorAgent $ CRITICAL True message Left e -> do withLog (eInfo <> " error: " <> tshow e) $ ackMsg msgMeta Nothing throwError e @@ -3338,10 +3344,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = fromGroupId_ = Just groupId, fromGroupMemberId_ = Just (groupMemberId' m), fromGroupMemberConnId_ = Just mConnId, - groupDirectInvStartedConnection = isTrue $ autoAcceptMemberContacts user + groupDirectInvStartedConnection = autoAcceptMemberContacts user } joinExistingContact subMode mCt@Contact {contactId = mContactId} - | isTrue (autoAcceptMemberContacts user) = do + | autoAcceptMemberContacts user = do (cmdId, acId) <- joinConn subMode mCt' <- withStore $ \db -> do updateMemberContactInvited db user mCt groupDirectInv @@ -3359,7 +3365,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = createInternalChatItem user (CDDirectRcv mCt') (CIRcvDirectEvent $ RDEGroupInvLinkReceived gp) Nothing createItems mCt' m createNewContact subMode - | isTrue (autoAcceptMemberContacts user) = do + | autoAcceptMemberContacts user = do (cmdId, acId) <- joinConn subMode -- [incognito] reuse membership incognito profile (mCt, m') <- withStore $ \db -> do diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 281fc6b03b..018457c7e7 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -49,6 +49,7 @@ import Simplex.Chat.Store.Profiles import Simplex.Chat.Types import Simplex.Messaging.Agent.Client (agentClientStore) import Simplex.Messaging.Agent.Env.SQLite (createAgentStore) +import Simplex.Messaging.Agent.Protocol (AgentErrorType) import Simplex.Messaging.Agent.Store.Interface (closeDBStore, reopenDBStore) import Simplex.Messaging.Agent.Store.Shared (MigrationConfig (..), MigrationConfirmation (..), MigrationError) import qualified Simplex.Messaging.Crypto as C @@ -72,6 +73,7 @@ data DBMigrationResult | DBMErrorNotADatabase {dbFile :: String} | DBMErrorMigration {dbFile :: String, migrationError :: MigrationError} | DBMErrorSQL {dbFile :: String, migrationSQLError :: String} + | DBMAgentError {agentError :: AgentErrorType} deriving (Show) $(JQ.deriveToJSON (sumTypeJSON $ dropPrefix "DBM") ''DBMigrationResult) @@ -298,12 +300,12 @@ chatMigrateInitKey chatDbOpts keepKey confirm backgroundMode = runExceptT $ do let migrationConfig = MigrationConfig confirmMigrations (Just "") chatStore <- migrate createChatStore (toDBOpts chatDbOpts chatSuffix keepKey chatDBFunctions) migrationConfig agentStore <- migrate createAgentStore (toDBOpts chatDbOpts agentSuffix keepKey []) migrationConfig - liftIO $ initialize chatStore ChatDatabase {chatStore, agentStore} + ExceptT $ initialize chatStore ChatDatabase {chatStore, agentStore} where opts = mobileChatOpts $ removeDbKey chatDbOpts initialize st db = do - user_ <- getActiveUser_ st - newChatController db user_ defaultMobileConfig opts backgroundMode + user_ <- liftIO $ getActiveUser_ st + first DBMAgentError <$> newChatController db user_ defaultMobileConfig opts backgroundMode migrate createStore dbOpts confirmMigrations = ExceptT $ (first (DBMErrorMigration errDbStr) <$> createStore dbOpts confirmMigrations) diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index dde223f6b9..08a765077f 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -74,7 +74,8 @@ data CoreChatOpts = CoreChatOpts data CreateBotOpts = CreateBotOpts { botDisplayName :: Text, - allowFiles :: Bool + allowFiles :: Bool, + clientService :: Bool } data ChatCmdLog = CCLAll | CCLMessages | CCLNone @@ -390,6 +391,11 @@ chatOptsP appDir defaultDbName = do ( long "create-bot-allow-files" <> help "Flag for created bot to allow files (only allowed together with --create-bot option)" ) + createBotClientService <- + switch + ( long "create-bot-client-service" + <> help "Flag for created bot to use client service certificate" + ) pure ChatOpts { coreOptions, @@ -405,9 +411,10 @@ chatOptsP appDir defaultDbName = do muteNotifications, markRead, createBot = case createBotDisplayName of - Just botDisplayName -> Just CreateBotOpts {botDisplayName, allowFiles = createBotAllowFiles} + Just botDisplayName -> Just CreateBotOpts {botDisplayName, allowFiles = createBotAllowFiles, clientService = createBotClientService} Nothing | createBotAllowFiles -> error "--create-bot-allow-files option requires --create-bot-name option" + | createBotClientService -> error "--create-bot-client-service option requires --create-bot-name option" | otherwise -> Nothing } diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index cfe8e944a5..89100ff890 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -539,7 +539,7 @@ handleRemoteCommand execCC encryption remoteOutputQ HTTP2Request {request, reqBo Left e -> eToView' $ ChatErrorRemoteCtrl $ RCEProtocolError e takeRCStep :: RCStepTMVar a -> CM a -takeRCStep = liftError' (\e -> ChatErrorAgent {agentError = RCP e, agentConnId = AgentConnId "", connectionEntity_ = Nothing}) . atomically . takeTMVar +takeRCStep = liftError' (chatErrorAgent . RCP) . atomically . takeTMVar type GetChunk = Int -> IO ByteString diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index ed0b3c9312..10368e2e30 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -32,6 +32,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260429_relay_request_retries import Simplex.Chat.Store.Postgres.Migrations.M20260507_relay_inactive_at import Simplex.Chat.Store.Postgres.Migrations.M20260514_relay_request_group_link_index import Simplex.Chat.Store.Postgres.Migrations.M20260515_delivery_job_senders +import Simplex.Chat.Store.Postgres.Migrations.M20260520_client_services import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -63,7 +64,8 @@ schemaMigrations = ("20260429_relay_request_retries", m20260429_relay_request_retries, Just down_m20260429_relay_request_retries), ("20260507_relay_inactive_at", m20260507_relay_inactive_at, Just down_m20260507_relay_inactive_at), ("20260514_relay_request_group_link_index", m20260514_relay_request_group_link_index, Just down_m20260514_relay_request_group_link_index), - ("20260515_delivery_job_senders", m20260515_delivery_job_senders, Just down_m20260515_delivery_job_senders) + ("20260515_delivery_job_senders", m20260515_delivery_job_senders, Just down_m20260515_delivery_job_senders), + ("20260520_client_services", m20260520_client_services, Just down_m20260520_client_services) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260520_client_services.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260520_client_services.hs new file mode 100644 index 0000000000..af567130eb --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260520_client_services.hs @@ -0,0 +1,19 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260520_client_services where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260520_client_services :: Text +m20260520_client_services = + [r| +ALTER TABLE users ADD COLUMN client_service SMALLINT NOT NULL DEFAULT 0; +|] + +down_m20260520_client_services :: Text +down_m20260520_client_services = + [r| +ALTER TABLE users DROP COLUMN client_service; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index 86cce86d9e..35388141bc 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -1433,7 +1433,8 @@ CREATE TABLE test_chat_schema.users ( ui_themes text, active_order bigint DEFAULT 0 NOT NULL, auto_accept_member_contacts smallint DEFAULT 0 NOT NULL, - is_user_chat_relay smallint DEFAULT 0 NOT NULL + is_user_chat_relay smallint DEFAULT 0 NOT NULL, + client_service smallint DEFAULT 0 NOT NULL ); diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index cff3e68234..da45b43f8f 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -20,7 +20,6 @@ module Simplex.Chat.Store.Profiles UserMsgReceiptSettings (..), UserContactLink (..), GroupLinkInfo (..), - createUserRecord, createUserRecordAt, getUsersInfo, getUsers, @@ -38,6 +37,7 @@ module Simplex.Chat.Store.Profiles getUserFileInfo, deleteUserRecord, updateUserPrivacy, + updateClientService, updateAllContactReceipts, updateUserContactReceipts, updateUserGroupReceipts, @@ -128,11 +128,8 @@ import Database.SQLite.Simple (Only (..), Query, (:.) (..)) import Database.SQLite.Simple.QQ (sql) #endif -createUserRecord :: DB.Connection -> AgentUserId -> Profile -> Bool -> Bool -> ExceptT StoreError IO User -createUserRecord db auId p userChatRelay activeUser = createUserRecordAt db auId p userChatRelay activeUser =<< liftIO getCurrentTime - -createUserRecordAt :: DB.Connection -> AgentUserId -> Profile -> Bool -> Bool -> UTCTime -> ExceptT StoreError IO User -createUserRecordAt db (AgentUserId auId) Profile {displayName, fullName, shortDescr, image, peerType, preferences = userPreferences} userChatRelay activeUser currentTs = +createUserRecordAt :: DB.Connection -> AgentUserId -> Bool -> Bool -> Profile -> Bool -> UTCTime -> ExceptT StoreError IO User +createUserRecordAt db (AgentUserId auId) userChatRelay clientService Profile {displayName, fullName, shortDescr, image, peerType, preferences = userPreferences} activeUser currentTs = checkConstraint SEDuplicateName . liftIO $ do when activeUser $ DB.execute_ db "UPDATE users SET active_user = 0" let showNtfs = True @@ -142,9 +139,9 @@ createUserRecordAt db (AgentUserId auId) Profile {displayName, fullName, shortDe order <- getNextActiveOrder db DB.execute db - "INSERT INTO users (agent_user_id, local_display_name, active_user, is_user_chat_relay, active_order, contact_id, show_ntfs, send_rcpts_contacts, send_rcpts_small_groups, auto_accept_member_contacts, created_at, updated_at) VALUES (?,?,?,?,?,0,?,?,?,?,?,?)" + "INSERT INTO users (agent_user_id, local_display_name, active_user, is_user_chat_relay, active_order, contact_id, show_ntfs, send_rcpts_contacts, send_rcpts_small_groups, auto_accept_member_contacts, client_service, created_at, updated_at) VALUES (?,?,?,?,?,0,?,?,?,?,?,?,?)" ( (auId, displayName, BI activeUser, BI userChatRelay, order) - :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, currentTs, currentTs) + :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, BI clientService, currentTs, currentTs) ) userId <- insertedRowId db DB.execute @@ -162,7 +159,7 @@ createUserRecordAt db (AgentUserId auId) Profile {displayName, fullName, shortDe (profileId, displayName, userId, BI True, currentTs, currentTs, currentTs) contactId <- insertedRowId db DB.execute db "UPDATE users SET contact_id = ? WHERE user_id = ?" (contactId, userId) - pure $ toUser $ (userId, auId, contactId, profileId, BI activeUser, order) :. (displayName, fullName, shortDescr, image, Nothing, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, Nothing, Nothing, Nothing, Nothing, BI userChatRelay) + pure $ toUser $ (userId, auId, contactId, profileId, BI activeUser, order) :. (displayName, fullName, shortDescr, image, Nothing, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, Nothing, Nothing, Nothing, BI userChatRelay, BI clientService, Nothing) -- TODO [mentions] getUsersInfo :: DB.Connection -> IO [UserInfo] @@ -285,6 +282,17 @@ updateUserPrivacy db User {userId, showNtfs, viewPwdHash} = where hashSalt = L.unzip . fmap (\UserPwdHash {hash, salt} -> (hash, salt)) +updateClientService :: DB.Connection -> UserId -> Bool -> IO () +updateClientService db userId enable = + DB.execute + db + [sql| + UPDATE users + SET client_service = ? + WHERE user_id = ? + |] + (BI enable, userId) + updateAllContactReceipts :: DB.Connection -> Bool -> IO () updateAllContactReceipts db onOff = DB.execute diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 65ccb8e58f..2674705181 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -155,6 +155,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260429_relay_request_retries import Simplex.Chat.Store.SQLite.Migrations.M20260507_relay_inactive_at import Simplex.Chat.Store.SQLite.Migrations.M20260514_relay_request_group_link_index import Simplex.Chat.Store.SQLite.Migrations.M20260515_delivery_job_senders +import Simplex.Chat.Store.SQLite.Migrations.M20260520_client_services import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -309,7 +310,8 @@ schemaMigrations = ("20260429_relay_request_retries", m20260429_relay_request_retries, Just down_m20260429_relay_request_retries), ("20260507_relay_inactive_at", m20260507_relay_inactive_at, Just down_m20260507_relay_inactive_at), ("20260514_relay_request_group_link_index", m20260514_relay_request_group_link_index, Just down_m20260514_relay_request_group_link_index), - ("20260515_delivery_job_senders", m20260515_delivery_job_senders, Just down_m20260515_delivery_job_senders) + ("20260515_delivery_job_senders", m20260515_delivery_job_senders, Just down_m20260515_delivery_job_senders), + ("20260520_client_services", m20260520_client_services, Just down_m20260520_client_services) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260520_client_services.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260520_client_services.hs new file mode 100644 index 0000000000..db141d6c03 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260520_client_services.hs @@ -0,0 +1,18 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260520_client_services where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20260520_client_services :: Query +m20260520_client_services = + [sql| +ALTER TABLE users ADD COLUMN client_service INTEGER NOT NULL DEFAULT 0; +|] + +down_m20260520_client_services :: Query +down_m20260520_client_services = + [sql| +ALTER TABLE users DROP COLUMN client_service; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt index de1e0a093d..ee857211aa 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -293,6 +293,15 @@ Query: Plan: SEARCH connections USING PRIMARY KEY (conn_id=?) +Query: + INSERT INTO client_services + (user_id, host, port, server_key_hash, service_cert_hash, service_cert, service_priv_key) + VALUES (?,?,?,?,?,?,?) + ON CONFLICT (user_id, host, port, server_key_hash) DO NOTHING + RETURNING 1 + +Plan: + Query: INSERT INTO conn_confirmations (confirmation_id, conn_id, sender_key, e2e_snd_pub_key, ratchet_state, sender_conn_info, smp_reply_queues, smp_client_version, accepted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0); @@ -457,6 +466,27 @@ Plan: SCAN ntf_tokens_to_delete USE TEMP B-TREE FOR DISTINCT +Query: + SELECT c.service_cert_hash, c.service_cert, c.service_priv_key, c.service_id + FROM client_services c + JOIN servers s ON c.host = s.host AND c.port = s.port + WHERE c.user_id = ? AND c.host = ? AND c.port = ? + AND COALESCE(c.server_key_hash, s.key_hash) = ? + +Plan: +SEARCH s USING PRIMARY KEY (host=? AND port=?) +SEARCH c USING INDEX idx_server_certs_user_id_host_port (user_id=? AND host=? AND port=?) + +Query: + SELECT c.service_id, c.service_queue_count, c.service_queue_ids_hash + FROM client_services c + JOIN servers s ON s.host = c.host AND s.port = c.port + WHERE c.user_id = ? AND c.host = ? AND c.port = ? AND COALESCE(c.server_key_hash, s.key_hash) = ? AND service_id IS NOT NULL + +Plan: +SEARCH s USING PRIMARY KEY (host=? AND port=?) +SEARCH c USING INDEX idx_server_certs_user_id_host_port (user_id=? AND host=? AND port=?) + Query: SELECT confirmation_id, ratchet_state, own_conn_info, sender_key, e2e_snd_pub_key, sender_conn_info, smp_reply_queues, smp_client_version FROM conn_confirmations @@ -518,6 +548,15 @@ Plan: SEARCH s USING PRIMARY KEY (conn_id=? AND internal_snd_id=?) SEARCH m USING PRIMARY KEY (conn_id=? AND internal_id=?) +Query: + UPDATE rcv_messages + SET receive_attempts = receive_attempts + 1 + WHERE conn_id = ? AND internal_id = ? + RETURNING receive_attempts + +Plan: +SEARCH rcv_messages USING COVERING INDEX idx_rcv_messages_conn_id_internal_id (conn_id=? AND internal_id=?) + Query: DELETE FROM conn_confirmations WHERE conn_id = ? @@ -602,11 +641,11 @@ SEARCH messages USING COVERING INDEX idx_messages_conn_id_internal_rcv_id (conn_ Query: INSERT INTO rcv_queues - ( host, port, rcv_id, conn_id, rcv_private_key, rcv_dh_secret, e2e_priv_key, e2e_dh_secret, + ( host, port, rcv_id, rcv_service_assoc, conn_id, rcv_private_key, rcv_dh_secret, e2e_priv_key, e2e_dh_secret, snd_id, queue_mode, status, to_subscribe, rcv_queue_id, rcv_primary, replace_rcv_queue_id, smp_client_version, server_key_hash, link_id, link_key, link_priv_sig_key, link_enc_fixed_data, ntf_public_key, ntf_private_key, ntf_id, rcv_ntf_dh_secret - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?); + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?); Plan: @@ -657,6 +696,21 @@ Query: Plan: SEARCH snd_file_chunk_replica_recipients USING INDEX idx_snd_file_chunk_replica_recipients_snd_file_chunk_replica_id (snd_file_chunk_replica_id=?) +Query: + UPDATE client_services + SET service_id = ? + FROM servers s + WHERE client_services.user_id = ? + AND client_services.host = ? + AND client_services.port = ? + AND s.host = client_services.host + AND s.port = client_services.port + AND COALESCE(client_services.server_key_hash, s.key_hash) = ? + +Plan: +SEARCH s USING PRIMARY KEY (host=? AND port=?) +SEARCH client_services USING COVERING INDEX idx_server_certs_user_id_host_port (user_id=? AND host=? AND port=?) + Query: UPDATE conn_confirmations SET accepted = 1, @@ -746,6 +800,16 @@ Query: Plan: SEARCH rcv_queues USING PRIMARY KEY (host=? AND port=? AND rcv_id=?) +Query: + UPDATE rcv_queues + SET rcv_service_assoc = 0 + FROM connections c + WHERE c.conn_id = rcv_queues.conn_id AND c.user_id = ? + +Plan: +SEARCH c USING COVERING INDEX idx_connections_user (user_id=?) +SEARCH rcv_queues USING COVERING INDEX idx_rcv_queue_id (conn_id=?) + Query: UPDATE rcv_queues SET status = ? @@ -816,7 +880,7 @@ SEARCH s USING PRIMARY KEY (host=? AND port=?) Query: SELECT c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.rcv_id, q.rcv_private_key, q.rcv_dh_secret, q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.queue_mode, q.status, c.enable_ntfs, q.client_notice_id, - q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id, q.switch_status, q.smp_client_version, q.delete_errors, + q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id, q.switch_status, q.smp_client_version, q.delete_errors, q.rcv_service_assoc, q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret, q.link_id, q.link_key, q.link_priv_sig_key, q.link_enc_fixed_data FROM rcv_queues q @@ -831,7 +895,7 @@ SEARCH s USING PRIMARY KEY (host=? AND port=?) Query: SELECT c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.rcv_id, q.rcv_private_key, q.rcv_dh_secret, q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.queue_mode, q.status, c.enable_ntfs, q.client_notice_id, - q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id, q.switch_status, q.smp_client_version, q.delete_errors, + q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id, q.switch_status, q.smp_client_version, q.delete_errors, q.rcv_service_assoc, q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret, q.link_id, q.link_key, q.link_priv_sig_key, q.link_enc_fixed_data FROM rcv_queues q @@ -846,7 +910,7 @@ SEARCH c USING PRIMARY KEY (conn_id=?) Query: SELECT c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.rcv_id, q.rcv_private_key, q.rcv_dh_secret, q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.queue_mode, q.status, c.enable_ntfs, q.client_notice_id, - q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id, q.switch_status, q.smp_client_version, q.delete_errors, + q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id, q.switch_status, q.smp_client_version, q.delete_errors, q.rcv_service_assoc, q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret, q.link_id, q.link_key, q.link_priv_sig_key, q.link_enc_fixed_data FROM rcv_queues q @@ -861,7 +925,7 @@ SEARCH c USING PRIMARY KEY (conn_id=?) Query: SELECT c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.rcv_id, q.rcv_private_key, q.rcv_dh_secret, q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.queue_mode, q.status, c.enable_ntfs, q.client_notice_id, - q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id, q.switch_status, q.smp_client_version, q.delete_errors, + q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id, q.switch_status, q.smp_client_version, q.delete_errors, q.rcv_service_assoc, q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret, q.link_id, q.link_key, q.link_priv_sig_key, q.link_enc_fixed_data FROM rcv_queues q @@ -876,7 +940,7 @@ SEARCH s USING PRIMARY KEY (host=? AND port=?) Query: SELECT c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.rcv_id, q.rcv_private_key, q.rcv_dh_secret, q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.queue_mode, q.status, c.enable_ntfs, q.client_notice_id, - q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id, q.switch_status, q.smp_client_version, q.delete_errors, + q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id, q.switch_status, q.smp_client_version, q.delete_errors, q.rcv_service_assoc, q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret, q.link_id, q.link_key, q.link_priv_sig_key, q.link_enc_fixed_data FROM rcv_queues q @@ -888,6 +952,18 @@ SEARCH q USING PRIMARY KEY (host=? AND port=? AND rcv_id=?) SEARCH s USING PRIMARY KEY (host=? AND port=?) SEARCH c USING PRIMARY KEY (conn_id=?) +Query: + SELECT c.user_id, q.conn_id, q.host, q.port, COALESCE(q.server_key_hash, s.key_hash), q.rcv_id, q.rcv_private_key, q.status, c.enable_ntfs, q.client_notice_id, + q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id + FROM rcv_queues q + JOIN servers s ON q.host = s.host AND q.port = s.port + JOIN connections c ON q.conn_id = c.conn_id + WHERE c.deleted = 0 AND q.deleted = 0 AND c.user_id = ? AND q.host = ? AND q.port = ? AND COALESCE(q.server_key_hash, s.key_hash) = ? AND q.rcv_service_assoc = 0 ORDER BY q.rcv_id LIMIT ? +Plan: +SEARCH s USING PRIMARY KEY (host=? AND port=?) +SEARCH q USING PRIMARY KEY (host=? AND port=?) +SEARCH c USING PRIMARY KEY (conn_id=?) + Query: SELECT c.user_id, q.conn_id, q.host, q.port, COALESCE(q.server_key_hash, s.key_hash), q.rcv_id, q.rcv_private_key, q.status, c.enable_ntfs, q.client_notice_id, q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id @@ -912,6 +988,10 @@ SEARCH q USING INDEX idx_rcv_queues_to_subscribe (to_subscribe=? AND host=? AND SEARCH c USING PRIMARY KEY (conn_id=?) SEARCH s USING PRIMARY KEY (host=? AND port=?) +Query: DELETE FROM client_services WHERE user_id = ? +Plan: +SEARCH client_services USING COVERING INDEX idx_server_certs_user_id_host_port (user_id=?) + Query: DELETE FROM commands WHERE command_id = ? Plan: SEARCH commands USING INTEGER PRIMARY KEY (rowid=?) @@ -1002,6 +1082,7 @@ SEARCH snd_queues USING COVERING INDEX idx_snd_queue_id (conn_id=? AND snd_queue Query: DELETE FROM users WHERE user_id = 2 Plan: SEARCH users USING INTEGER PRIMARY KEY (rowid=?) +SEARCH client_services USING COVERING INDEX idx_server_certs_user_id_host_port (user_id=?) SEARCH deleted_snd_chunk_replicas USING COVERING INDEX idx_deleted_snd_chunk_replicas_user_id (user_id=?) SEARCH snd_files USING COVERING INDEX idx_snd_files_user_id (user_id=?) SEARCH rcv_files USING COVERING INDEX idx_rcv_files_user_id (user_id=?) @@ -1010,6 +1091,7 @@ SEARCH connections USING COVERING INDEX idx_connections_user (user_id=?) Query: DELETE FROM users WHERE user_id = ? Plan: SEARCH users USING INTEGER PRIMARY KEY (rowid=?) +SEARCH client_services USING COVERING INDEX idx_server_certs_user_id_host_port (user_id=?) SEARCH deleted_snd_chunk_replicas USING COVERING INDEX idx_deleted_snd_chunk_replicas_user_id (user_id=?) SEARCH snd_files USING COVERING INDEX idx_snd_files_user_id (user_id=?) SEARCH rcv_files USING COVERING INDEX idx_rcv_files_user_id (user_id=?) @@ -1041,6 +1123,7 @@ Plan: Query: INSERT INTO servers (host, port, key_hash) VALUES (?,?,?) ON CONFLICT (host, port) DO NOTHING RETURNING 1 Plan: +SEARCH client_services USING COVERING INDEX idx_server_certs_host_port (host=? AND port=?) SEARCH inv_short_links USING COVERING INDEX idx_inv_short_links_link_id (host=? AND port=?) SEARCH commands USING COVERING INDEX idx_commands_server_commands (host=? AND port=?) SEARCH ntf_subscriptions USING COVERING INDEX idx_ntf_subscriptions_smp_host_smp_port (smp_host=? AND smp_port=?) @@ -1257,6 +1340,10 @@ Query: UPDATE rcv_queues SET rcv_primary = ?, replace_rcv_queue_id = ? WHERE con Plan: SEARCH rcv_queues USING COVERING INDEX idx_rcv_queue_id (conn_id=? AND rcv_queue_id=?) +Query: UPDATE rcv_queues SET rcv_service_assoc = 1 WHERE host = ? AND port = ? AND rcv_id = ? +Plan: +SEARCH rcv_queues USING PRIMARY KEY (host=? AND port=? AND rcv_id=?) + Query: UPDATE rcv_queues SET to_subscribe = 0 WHERE to_subscribe = 1 Plan: SEARCH rcv_queues USING COVERING INDEX idx_rcv_queues_to_subscribe (to_subscribe=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index dbbe2f8a0a..598ee920dd 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -5251,6 +5251,14 @@ Query: Plan: SEARCH user_contact_links USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE users + SET client_service = ? + WHERE user_id = ? + +Plan: +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE users SET view_pwd_hash = ?, view_pwd_salt = ?, show_ntfs = ? @@ -5804,7 +5812,7 @@ SEARCH server_operators USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5816,7 +5824,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5829,7 +5837,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5842,7 +5850,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5856,7 +5864,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5869,7 +5877,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5882,7 +5890,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5895,7 +5903,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5908,7 +5916,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5920,7 +5928,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -6596,7 +6604,7 @@ Plan: Query: INSERT INTO user_contact_links (user_id, group_id, group_link_id, local_display_name, conn_req_contact, short_link_contact, short_link_data_set, short_link_large_data_set, group_link_member_role, auto_accept, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) Plan: -Query: INSERT INTO users (agent_user_id, local_display_name, active_user, is_user_chat_relay, active_order, contact_id, show_ntfs, send_rcpts_contacts, send_rcpts_small_groups, auto_accept_member_contacts, created_at, updated_at) VALUES (?,?,?,?,?,0,?,?,?,?,?,?) +Query: INSERT INTO users (agent_user_id, local_display_name, active_user, is_user_chat_relay, active_order, contact_id, show_ntfs, send_rcpts_contacts, send_rcpts_small_groups, auto_accept_member_contacts, client_service, created_at, updated_at) VALUES (?,?,?,?,?,0,?,?,?,?,?,?,?) Plan: Query: INSERT INTO xftp_file_descriptions (user_id, file_descr_text, file_descr_part_no, file_descr_complete, created_at, updated_at) VALUES (?,?,?,?,?,?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 8fca9f2e84..fb72eecfc0 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -39,7 +39,8 @@ CREATE TABLE users( ui_themes TEXT, active_order INTEGER NOT NULL DEFAULT 0, auto_accept_member_contacts INTEGER NOT NULL DEFAULT 0, - is_user_chat_relay INTEGER NOT NULL DEFAULT 0, -- 1 for active user + is_user_chat_relay INTEGER NOT NULL DEFAULT 0, + client_service INTEGER NOT NULL DEFAULT 0, -- 1 for active user FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE RESTRICT diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index af0958ed35..cf630eae02 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -539,15 +539,15 @@ userQuery :: Query userQuery = [sql| SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id |] -toUser :: (UserId, UserId, ContactId, ProfileId, BoolInt, Int64) :. (ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe Preferences) :. (BoolInt, BoolInt, BoolInt, BoolInt, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime, Maybe UIThemeEntityOverrides, BoolInt) -> User -toUser ((userId, auId, userContactId, profileId, BI activeUser, activeOrder) :. (displayName, fullName, shortDescr, image, contactLink, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, viewPwdHash_, viewPwdSalt_, userMemberProfileUpdatedAt, uiThemes, BI userChatRelay)) = - User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, activeOrder, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, autoAcceptMemberContacts = BoolDef autoAcceptMemberContacts, viewPwdHash, userMemberProfileUpdatedAt, uiThemes, userChatRelay = BoolDef userChatRelay} +toUser :: (UserId, UserId, ContactId, ProfileId, BoolInt, Int64) :. (ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe Preferences) :. (BoolInt, BoolInt, BoolInt, BoolInt, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime, BoolInt, BoolInt, Maybe UIThemeEntityOverrides) -> User +toUser ((userId, auId, userContactId, profileId, BI activeUser, activeOrder) :. (displayName, fullName, shortDescr, image, contactLink, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, viewPwdHash_, viewPwdSalt_, userMemberProfileUpdatedAt, BI userChatRelay, BI clientService, uiThemes)) = + User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, activeOrder, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, autoAcceptMemberContacts, viewPwdHash, userMemberProfileUpdatedAt, userChatRelay = BoolDef userChatRelay, clientService = BoolDef clientService, uiThemes} where profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, preferences = userPreferences, localAlias = ""} fullPreferences = fullPreferences' userPreferences diff --git a/src/Simplex/Chat/Terminal.hs b/src/Simplex/Chat/Terminal.hs index 21781229e4..29299cfeae 100644 --- a/src/Simplex/Chat/Terminal.hs +++ b/src/Simplex/Chat/Terminal.hs @@ -8,6 +8,7 @@ module Simplex.Chat.Terminal where import Control.Monad +import Control.Monad.IO.Class (liftIO) import qualified Data.List.NonEmpty as L import Simplex.Chat (defaultChatConfig) import Simplex.Chat.Controller @@ -22,6 +23,8 @@ import Simplex.Chat.Terminal.Output import Simplex.FileTransfer.Client.Presets (defaultXFTPServers) import Simplex.Messaging.Client (NetworkConfig (..), SMPProxyFallback (..), SMPProxyMode (..), defaultNetworkConfig) import Simplex.Messaging.Util (raceAny_) +import System.Terminal (Key, Modifiers) +import UnliftIO.STM #if !defined(dbPostgres) import Control.Exception (handle, throwIO) import qualified Data.ByteArray as BA @@ -99,4 +102,9 @@ simplexChatTerminal cfg options t = run options #endif runChatTerminal :: ChatTerminal -> ChatController -> ChatOpts -> IO () -runChatTerminal ct cc opts = raceAny_ [runTerminalInput ct cc, runTerminalOutput ct cc opts, runInputLoop ct cc] +runChatTerminal ct cc opts = do + keyQ <- newTQueueIO + raceAny_ [runKeyReader ct keyQ, runTerminalInput ct cc keyQ, runTerminalOutput ct cc opts, runInputLoop ct cc] + +runKeyReader :: ChatTerminal -> TQueue (Key, Modifiers) -> IO () +runKeyReader ct q = withChatTerm ct $ forever $ getKey >>= liftIO . atomically . writeTQueue q diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index e0ee10aff9..effcb7a71c 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -152,14 +152,14 @@ sendUpdatedLiveMessage cc sentMsg LiveMessage {chatName, chatItemId} live = do let cmd = UpdateLiveMessage chatName chatItemId live $ T.pack sentMsg execChatCommand' cmd 0 `runReaderT` cc -runTerminalInput :: ChatTerminal -> ChatController -> IO () -runTerminalInput ct cc = withChatTerm ct $ do - updateInput ct - receiveFromTTY cc ct +runTerminalInput :: ChatTerminal -> ChatController -> TQueue (Key, Modifiers) -> IO () +runTerminalInput ct cc keyQ = do + updateInputView ct + receiveFromTTY keyQ cc ct -receiveFromTTY :: forall m. MonadTerminal m => ChatController -> ChatTerminal -> m () -receiveFromTTY cc@ChatController {inputQ, currentUser, currentRemoteHost, chatStore} ct@ChatTerminal {termSize, termState, liveMessageState, activeTo} = - forever $ getKey >>= liftIO . processKey >> withTermLock ct (updateInput ct) +receiveFromTTY :: TQueue (Key, Modifiers) -> ChatController -> ChatTerminal -> IO () +receiveFromTTY keyQ cc@ChatController {inputQ, currentUser, currentRemoteHost, chatStore} ct@ChatTerminal {termSize, termState, liveMessageState, activeTo} = + forever $ atomically (readTQueue keyQ) >>= processKey >> updateInputView ct where processKey :: (Key, Modifiers) -> IO () processKey key = case key of diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index f2892898c4..145f3343f3 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -134,17 +134,19 @@ data User = User showNtfs :: Bool, sendRcptsContacts :: Bool, sendRcptsSmallGroups :: Bool, - autoAcceptMemberContacts :: BoolDef, + autoAcceptMemberContacts :: Bool, userMemberProfileUpdatedAt :: Maybe UTCTime, - uiThemes :: Maybe UIThemeEntityOverrides, - userChatRelay :: BoolDef + userChatRelay :: BoolDef, + clientService :: BoolDef, + uiThemes :: Maybe UIThemeEntityOverrides } deriving (Show) data NewUser = NewUser { profile :: Maybe Profile, pastTimestamp :: Bool, - userChatRelay :: Bool + userChatRelay :: BoolDef, + clientService :: BoolDef } deriving (Show) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 7785d06d44..725642b6e3 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -481,7 +481,8 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView} CEvtSubscriptionEnd u acEntity -> let Connection {connId} = entityConnection acEntity in ttyUser u [sShow connId <> ": END"] - CEvtSubscriptionStatus srv status conns -> [plain $ subStatusStr status <> " " <> show (length conns) <> " connections on server " <> showSMPServer srv] + CEvtSubscriptionStatus srv status conns -> [plain $ subStatusStr status <> " " <> tshow (length conns) <> " connections on server " <> showSMPServer srv] + CEvtServiceSubStatus srv event -> [plain $ serviceSubEventStr srv event] CEvtReceivedGroupInvitation {user = u, groupInfo = g, contact = c, memberRole = r} -> ttyUser u $ viewReceivedGroupInvitation g c r CEvtUserJoinedGroup u g m -> ttyUser u $ viewUserJoinedGroup g m CEvtGroupLinkDataUpdated u g groupLink relays relaysChanged @@ -618,13 +619,14 @@ viewUsersList us = in if null ss then ["no users"] else ss where ldn (UserInfo User {localDisplayName = n} _) = T.toLower n - userInfo (UserInfo User {localDisplayName = n, profile = LocalProfile {fullName, shortDescr, peerType}, activeUser, showNtfs, viewPwdHash} count) + userInfo (UserInfo User {localDisplayName = n, profile = LocalProfile {fullName, shortDescr, peerType}, activeUser, showNtfs, viewPwdHash, clientService} count) | activeUser || isNothing viewPwdHash = Just $ ttyFullName n fullName shortDescr <> infoStr <> bot | otherwise = Nothing where infoStr = if null info then "" else " (" <> mconcat (intersperse ", " info) <> ")" info = [highlight' "active" | activeUser] + <> [highlight' "service" | isTrue clientService] <> [highlight' "hidden" | isJust viewPwdHash] <> ["muted" | not showNtfs] <> [plain ("unread: " <> show count) | count /= 0] @@ -632,8 +634,8 @@ viewUsersList us = Just CPTBot -> " (bot)" _ -> "" -showSMPServer :: SMPServer -> String -showSMPServer ProtocolServer {host} = B.unpack $ strEncode host +showSMPServer :: SMPServer -> Text +showSMPServer ProtocolServer {host} = safeDecodeUtf8 $ strEncode host viewHostEvent :: AProtocolType -> TransportHost -> String viewHostEvent p h = map toUpper (B.unpack $ strEncode p) <> " host " <> B.unpack (strEncode h) @@ -1493,7 +1495,7 @@ groupInvitation' g@GroupInfo {localDisplayName = ldn, groupProfile = GroupProfil viewNewMemberContactReceivedInv :: User -> Contact -> GroupInfo -> GroupMember -> [StyledString] viewNewMemberContactReceivedInv user ct@Contact {localDisplayName = c} g m - | isTrue (autoAcceptMemberContacts user) = + | autoAcceptMemberContacts user = [ttyGroup' g <> " " <> ttyMember m <> " is creating direct contact " <> ttyContact' ct <> " with you"] | otherwise = [ ttyGroup' g <> " " <> ttyMember m <> " requests to create direct contact with you", @@ -1579,13 +1581,23 @@ viewConnDiffIds userDiff connDiff where showIds = plain . T.intercalate ", " . map (tshow . unwrapId) -subStatusStr :: SubscriptionStatus -> String +subStatusStr :: SubscriptionStatus -> Text subStatusStr = \case SSActive -> "subscribed" SSPending -> "disconnected" - SSRemoved e -> "removed: " <> e + SSRemoved e -> "removed: " <> T.pack e SSNoSub -> "no subscription" +serviceSubEventStr :: SMPServer -> ServiceSubEvent -> Text +serviceSubEventStr srv = \case + ServiceSubUp e_ n -> "subscribed service " <> conns n <> srvStr <> ": " <> fromMaybe "ok" e_ + ServiceSubDown n -> "disconnected service " <> conns n <> srvStr + ServiceSubAll -> "received messages from service" <> srvStr -- "(" <> n <> "connections)" + ServiceSubEnd n -> "service subscription ended " <> conns n <> srvStr + where + conns n = "(" <> tshow n <> " connections)" + srvStr = " on server " <> showSMPServer srv + viewUserServers :: UserOperatorServers -> [StyledString] viewUserServers (UserOperatorServers _ [] [] []) = [] viewUserServers UserOperatorServers {operator, smpServers, xftpServers, chatRelays} = @@ -1810,7 +1822,7 @@ viewConnectionStats ConnectionStats {rcvQueuesInfo, sndQueuesInfo} = <> ["sending messages via: " <> viewSndQueuesInfo sndQueuesInfo | not $ null sndQueuesInfo] viewRcvQueuesInfo :: [RcvQueueInfo] -> StyledString -viewRcvQueuesInfo = plain . intercalate ", " . map showQueueInfo +viewRcvQueuesInfo = plain . T.intercalate ", " . map showQueueInfo where showQueueInfo RcvQueueInfo {rcvServer, rcvSwitchStatus, canAbortSwitch} = let switchCanBeAborted = if canAbortSwitch then ", can be aborted" else "" @@ -1823,7 +1835,7 @@ viewRcvQueuesInfo = plain . intercalate ", " . map showQueueInfo RSReceivedMessage -> "switch secured" viewSndQueuesInfo :: [SndQueueInfo] -> StyledString -viewSndQueuesInfo = plain . intercalate ", " . map showQueueInfo +viewSndQueuesInfo = plain . T.intercalate ", " . map showQueueInfo where showQueueInfo SndQueueInfo {sndServer, sndSwitchStatus} = showSMPServer sndServer @@ -2584,7 +2596,6 @@ viewChatError isCmd logLevel testView = \case CENoConnectionUser agentConnId -> ["error: message user not found, conn id: " <> sShow agentConnId | logLevel <= CLLError] CENoSndFileUser aFileId -> ["error: snd file user not found, file id: " <> sShow aFileId | logLevel <= CLLError] CENoRcvFileUser aFileId -> ["error: rcv file user not found, file id: " <> sShow aFileId | logLevel <= CLLError] - CEActiveUserExists -> ["error: active user already exists"] CEUserExists name -> ["user with the name " <> ttyContact name <> " already exists"] CEChatRelayExists -> ["chat realy user already exists"] CEUserUnknown -> ["user does not exist or incorrect password"] diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 0abbe5bd65..140739b4f4 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -126,6 +126,7 @@ mkDirectoryOpts TestParams {tmpPath = ps} superUsers ownersGroup webFolder = directoryLog = Just $ ps "directory_service.log", migrateDirectoryLog = Nothing, serviceName = "SimpleX Directory", + clientService = True, runCLI = False, searchResults = 3, webFolder, diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 279a09e718..ede3c1f2a2 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -25,6 +25,7 @@ import Data.Functor (($>)) import Data.List (dropWhileEnd, find) import Data.Maybe (isNothing) import qualified Data.Text as T +import Data.Time.Clock (getCurrentTime) import Network.Socket import Simplex.Chat import Simplex.Chat.Controller (ChatCommand (..), ChatConfig (..), ChatController (..), ChatDatabase (..), ChatLogLevel (..), defaultSimpleNetCfg) @@ -281,11 +282,12 @@ prevVersion (Version v) = Version (v - 1) nextVersion :: Version v -> Version v nextVersion (Version v) = Version (v + 1) -createTestChat :: TestParams -> ChatConfig -> ChatOpts -> String -> Profile -> IO TestCC -createTestChat ps cfg opts@ChatOpts {coreOptions = coreOptions@CoreChatOpts {chatRelay}} dbPrefix profile = do +createTestChat :: TestParams -> ChatConfig -> ChatOpts -> String -> Bool -> Profile -> IO TestCC +createTestChat ps cfg opts@ChatOpts {coreOptions = coreOptions@CoreChatOpts {chatRelay}} dbPrefix clientService profile = do Right db@ChatDatabase {chatStore, agentStore} <- createDatabase ps coreOptions dbPrefix insertUser agentStore - Right user <- withTransaction chatStore $ \db' -> runExceptT $ createUserRecord db' (AgentUserId 1) profile chatRelay True + ts <- getCurrentTime + Right user <- withTransaction chatStore $ \db' -> runExceptT $ createUserRecordAt db' (AgentUserId 1) chatRelay clientService profile True ts startTestChat_ ps db cfg opts user startTestChat :: TestParams -> ChatConfig -> ChatOpts -> String -> IO TestCC @@ -313,7 +315,7 @@ startTestChat_ :: TestParams -> ChatDatabase -> ChatConfig -> ChatOpts -> User - startTestChat_ TestParams {printOutput} db cfg opts@ChatOpts {coreOptions = CoreChatOpts {maintenance}} user = do t <- withVirtualTerminal termSettings pure ct <- newChatTerminal t opts - cc <- newChatController db (Just user) cfg opts False + Right cc <- newChatController db (Just user) cfg opts False void $ execChatCommand' (SetTempFolder "tests/tmp/tmp") 0 `runReaderT` cc chatAsync <- async $ runSimplexChat cfg opts user cc $ \_u cc' -> runChatTerminal ct cc' opts unless maintenance $ atomically $ readTVar (agentAsync cc) >>= \a -> when (isNothing a) retry @@ -351,6 +353,9 @@ stopTestChat ps TestCC {chatController = cc@ChatController {smpAgent, chatStore} withNewTestChat :: HasCallStack => TestParams -> String -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a withNewTestChat ps = withNewTestChatCfgOpts ps testCfg testOpts +withNewTestChat_ :: HasCallStack => TestParams -> String -> Bool -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a +withNewTestChat_ ps = withNewTestChatCfgOpts_ ps testCfg testOpts + withNewTestChatV1 :: HasCallStack => TestParams -> String -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a withNewTestChatV1 ps = withNewTestChatCfg ps testCfgV1 @@ -361,9 +366,12 @@ withNewTestChatOpts :: HasCallStack => TestParams -> ChatOpts -> String -> Profi withNewTestChatOpts ps = withNewTestChatCfgOpts ps testCfg withNewTestChatCfgOpts :: HasCallStack => TestParams -> ChatConfig -> ChatOpts -> String -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a -withNewTestChatCfgOpts ps cfg opts dbPrefix profile runTest = +withNewTestChatCfgOpts ps cfg opts dbPrefix = withNewTestChatCfgOpts_ ps cfg opts dbPrefix False + +withNewTestChatCfgOpts_ :: HasCallStack => TestParams -> ChatConfig -> ChatOpts -> String -> Bool -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a +withNewTestChatCfgOpts_ ps cfg opts dbPrefix clientService profile runTest = bracket - (createTestChat ps cfg opts dbPrefix profile) + (createTestChat ps cfg opts dbPrefix clientService profile) (stopTestChat ps) (\cc -> runTest cc >>= ((cc )) @@ -420,9 +428,11 @@ testChatN :: HasCallStack => ChatConfig -> ChatOpts -> [Profile] -> (HasCallStac testChatN cfg opts ps test params = bracket (getTestCCs $ zip ps [1 ..]) endTests test where + useClientServices = False + -- useClientServices = True getTestCCs :: [(Profile, Int)] -> IO [TestCC] getTestCCs [] = pure [] - getTestCCs ((p, db) : envs') = (:) <$> createTestChat params cfg opts (show db) p <*> getTestCCs envs' + getTestCCs ((p, db) : envs') = (:) <$> createTestChat params cfg opts (show db) useClientServices p <*> getTestCCs envs' endTests tcs = do mapConcurrently_ ( 2" cath #> "#club 3" [alice, bob] *<# "#club cath> 3" + +testClientService :: HasCallStack => TestParams -> IO () +testClientService ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChat ps "bob" bobProfile $ \bob -> do + -- create user as service + withNewTestChat_ ps "service" True serviceProfile $ \service -> do + connectUsers alice service + alice <##> service + service ##> "/set client service 1:service_user off" + service <## "error: chat not stopped" + service ##> "/users" + service <## "service_user (Service user) (active, service)" + -- connect as service + withTestChat ps "service" $ \service -> do + subscribeClientService service 1 + alice <##> service + setClientService ps "off" + -- connect without service + withTestChat ps "service" $ \service -> do + service <## "subscribed 1 connections on server localhost" + alice <##> service + connectUsers bob service + bob <##> service + setClientService ps "on" + -- connect as service, queue associated + withTestChat ps "service" $ \service -> do + service <## "subscribed 2 connections on server localhost" + alice <##> service + bob <##> service + -- connect as service + withTestChat ps "service" $ \service -> do + subscribeClientService service 2 + alice <##> service + bob <##> service + +testSwitchClientService :: HasCallStack => TestParams -> IO () +testSwitchClientService ps = + withNewTestChat ps "user" aliceProfile $ \alice -> + withNewTestChat ps "bob" bobProfile $ \bob -> do + -- create user without service + withNewTestChat_ ps "service" False serviceProfile $ \service -> do + connectUsers alice service + alice <##> service + -- connect without service + withTestChat ps "service" $ \service -> do + service <## "subscribed 1 connections on server localhost" + alice <##> service + setClientService ps "on" + -- connect as service, queue associated + withTestChat ps "service" $ \service -> do + service <## "subscribed 1 connections on server localhost" + alice <##> service + connectUsers bob service + bob <##> service + -- connect as service + withTestChat ps "service" $ \service -> do + subscribeClientService service 2 + alice <##> service + bob <##> service + -- connect without service + setClientService ps "off" + withTestChat ps "service" $ \service -> do + service <## "subscribed 2 connections on server localhost" + alice <##> service + bob <##> service + +setClientService :: TestParams -> String -> IO () +setClientService ps onOff = + withTestChatCfgOpts ps testCfg testOpts {coreOptions = testCoreOpts {maintenance = True}} "service" $ \service -> do + service ##> ("/set client service 1:service_user " <> onOff) + service <## "ok" + +subscribeClientService :: TestCC -> Int -> IO () +subscribeClientService service n = + service + <### + [ ConsoleString $ "subscribed service (" <> show n <> " connections) on server localhost: ok", + "received messages from service on server localhost" + ] diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 4b28229348..4987319899 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -84,6 +84,9 @@ businessProfile = mkProfile "biz" "Biz Inc" Nothing chatRelayProfile :: Profile chatRelayProfile = mkProfile "relay" "Relay" Nothing +serviceProfile :: Profile +serviceProfile = mkProfile "service_user" "Service user" Nothing + mkProfile :: T.Text -> T.Text -> Maybe ImageData -> Profile mkProfile displayName descr image = Profile {displayName, fullName = "", shortDescr = Just descr, image, contactLink = Nothing, peerType = Nothing, preferences = defaultPrefs} @@ -120,7 +123,7 @@ skip = before_ . pendingWith versionTestMatrix2 :: (HasCallStack => Bool -> Bool -> TestCC -> TestCC -> IO ()) -> SpecWith TestParams versionTestMatrix2 runTest = do it "current" $ testChat2 aliceProfile bobProfile (runTest True True) - it "prev" $ testChatCfg2 testCfgVPrev aliceProfile bobProfile (runTest False True) + it "prev" $ runTestCfg2 testCfgVPrev testCfgVPrev (runTest False True) it "prev to curr" $ runTestCfg2 testCfg testCfgVPrev (runTest False True) it "curr to prev" $ runTestCfg2 testCfgVPrev testCfg (runTest False True) it "old (1st supported)" $ testChatCfg2 testCfgV1 aliceProfile bobProfile (runTest False False) @@ -130,7 +133,7 @@ versionTestMatrix2 runTest = do versionTestMatrix3 :: (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> SpecWith TestParams versionTestMatrix3 runTest = do it "current" $ testChat3 aliceProfile bobProfile cathProfile runTest - it "prev" $ testChatCfg3 testCfgVPrev aliceProfile bobProfile cathProfile runTest + it "prev" $ runTestCfg3 testCfgVPrev testCfgVPrev testCfgVPrev runTest it "prev to curr" $ runTestCfg3 testCfg testCfgVPrev testCfgVPrev runTest it "curr+prev to curr" $ runTestCfg3 testCfg testCfg testCfgVPrev runTest it "curr to prev" $ runTestCfg3 testCfgVPrev testCfg testCfg runTest diff --git a/tests/JSONFixtures.hs b/tests/JSONFixtures.hs index d611df8867..37fab0e4f0 100644 --- a/tests/JSONFixtures.hs +++ b/tests/JSONFixtures.hs @@ -17,10 +17,10 @@ activeUserExistsTagged :: LB.ByteString activeUserExistsTagged = "{\"error\":{\"type\":\"error\",\"errorType\":{\"type\":\"userExists\",\"contactName\":\"alice\"}}}" activeUserSwift :: LB.ByteString -activeUserSwift = "{\"result\":{\"_owsf\":true,\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"files\":{\"allow\":\"always\"},\"calls\":{\"allow\":\"yes\"},\"sessions\":{\"allow\":\"no\"},\"commands\":[]},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false,\"userChatRelay\":false}}}}" +activeUserSwift = "{\"result\":{\"_owsf\":true,\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"files\":{\"allow\":\"always\"},\"calls\":{\"allow\":\"yes\"},\"sessions\":{\"allow\":\"no\"},\"commands\":[]},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false,\"userChatRelay\":false,\"clientService\":false}}}}" activeUserTagged :: LB.ByteString -activeUserTagged = "{\"result\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"files\":{\"allow\":\"always\"},\"calls\":{\"allow\":\"yes\"},\"sessions\":{\"allow\":\"no\"},\"commands\":[]},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false,\"userChatRelay\":false}}}" +activeUserTagged = "{\"result\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"files\":{\"allow\":\"always\"},\"calls\":{\"allow\":\"yes\"},\"sessions\":{\"allow\":\"no\"},\"commands\":[]},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false,\"userChatRelay\":false,\"clientService\":false}}}" chatStartedSwift :: LB.ByteString chatStartedSwift = "{\"result\":{\"_owsf\":true,\"chatStarted\":{}}}" diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index d57411a598..c75bc37166 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -22,6 +22,7 @@ import qualified Data.ByteString as B import qualified Data.ByteString.Char8 as BS import Data.ByteString.Internal (create) import qualified Data.ByteString.Lazy.Char8 as LB +import Data.Time.Clock (getCurrentTime) import Data.Word (Word8, Word32) import Foreign.C import Foreign.Marshal.Alloc (mallocBytes) @@ -147,7 +148,8 @@ testChatApi ps = do dbPrefix = tmp "1" Right ChatDatabase {chatStore, agentStore} <- createChatDatabase (ChatDbOpts dbPrefix "myKey" DB.TQOff True) (MigrationConfig MCYesUp Nothing) insertUser agentStore - Right _ <- withTransaction chatStore $ \db -> runExceptT $ createUserRecord db (AgentUserId 1) aliceProfile {preferences = Nothing} False True + ts <- getCurrentTime + Right _ <- withTransaction chatStore $ \db -> runExceptT $ createUserRecordAt db (AgentUserId 1) False False aliceProfile {preferences = Nothing} True ts Right cc <- chatMigrateInit dbPrefix "myKey" "yesUp" Left (DBMErrorNotADatabase _) <- chatMigrateInit dbPrefix "" "yesUp" Left (DBMErrorNotADatabase _) <- chatMigrateInit dbPrefix "anotherKey" "yesUp" From c017c25d0f29075160229c56f208762ecfeaaded Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 25 May 2026 10:43:36 +0000 Subject: [PATCH 005/200] core, ui: member full delete with messages (#6994) --- apps/ios/Shared/Model/ChatModel.swift | 67 ++++++++++---- apps/ios/SimpleXChat/ChatTypes.swift | 4 +- .../chat/simplex/common/model/ChatModel.kt | 65 ++++++++++---- .../chat/simplex/common/model/SimpleXAPI.kt | 3 +- .../2026-05-20-member-deletion-fulldelete.md | 73 ++++++++++++++++ src/Simplex/Chat/Library/Commands.hs | 10 ++- src/Simplex/Chat/Library/Internal.hs | 31 ++++--- src/Simplex/Chat/Library/Subscriber.hs | 19 ++-- src/Simplex/Chat/Store/Messages.hs | 87 +++++++++++++------ .../SQLite/Migrations/chat_query_plans.txt | 45 ++++++---- tests/ChatTests/Groups.hs | 63 +++++++++++--- 11 files changed, 352 insertions(+), 115 deletions(-) create mode 100644 plans/2026-05-20-member-deletion-fulldelete.md diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index a1d28b8e22..111dff382a 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -812,32 +812,63 @@ final class ChatModel: ObservableObject { } func removeMemberItems(_ removedMember: GroupMember, byMember: GroupMember, _ groupInfo: GroupInfo) { + // Mirrors backend groupFeatureMemberAllowed: fullDelete may be role-gated in business groups. + let fullDeletePref = groupInfo.fullGroupPreferences.fullDelete + let fullDelete = fullDeletePref.on + && byMember.memberRole >= (fullDeletePref.role ?? .observer) if chatId == groupInfo.id { - for i in 0..= 0 { + let item = im.reversedChatItems[i] + if isRemovedMemberItem(item) { + if item.isRcvNew { + unreadCollector.changeUnreadCounter(groupInfo.id, by: -1, unreadMentions: item.meta.userMention ? -1 : 0) + } + if item.isActiveReport { + decreaseGroupReportsCounter(groupInfo.id) + } + VoiceItemState.stopVoiceInChatView(cInfo, item) + removed.append((item.id, i, item.isRcvNew)) + im.reversedChatItems.remove(at: i) + } + i -= 1 + } + if !removed.isEmpty { + im.chatState.itemsRemoved(removed.reversed(), im.reversedChatItems.reversed()) + } + } else { + for i in 0.. 0 { + let preview = chat.chatItems[0] + if isRemovedMemberItem(preview) { + if fullDelete { + chat.chatItems = [ChatItem.deletedItemDummy()] + } else if let updatedItem = markedUpdatedItem(preview) { + chat.chatItems = [updatedItem] } } - } else if let chat = getChat(groupInfo.id), - chat.chatItems.count > 0, - let updatedItem = removedUpdatedItem(chat.chatItems[0]) { - chat.chatItems = [updatedItem] } - func removedUpdatedItem(_ item: ChatItem) -> ChatItem? { - let newContent: CIContent - if case .groupSnd = item.chatDir, removedMember.groupMemberId == groupInfo.membership.groupMemberId { - newContent = .sndModerated - } else if case let .groupRcv(groupMember) = item.chatDir, groupMember.groupMemberId == removedMember.groupMemberId { - newContent = .rcvModerated - } else { - return nil + func isRemovedMemberItem(_ item: ChatItem) -> Bool { + switch item.chatDir { + case .groupSnd: return removedMember.groupMemberId == groupInfo.membership.groupMemberId + case let .groupRcv(groupMember): return groupMember.groupMemberId == removedMember.groupMemberId + default: return false } + } + + func markedUpdatedItem(_ item: ChatItem) -> ChatItem? { + guard isRemovedMemberItem(item) else { return nil } var updatedItem = item updatedItem.meta.itemDeleted = .moderated(deletedTs: Date.now, byGroupMember: byMember) - if groupInfo.fullGroupPreferences.fullDelete.on { - updatedItem.content = newContent - } if item.isActiveReport { decreaseGroupReportsCounter(groupInfo.id) } diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 594f90c4e4..b7372bf6b7 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1358,6 +1358,7 @@ public func toGroupPreferences(_ fullPreferences: FullGroupPreferences) -> Group public struct GroupPreference: Codable, Equatable, Hashable { public var enable: GroupFeatureEnabled + public var role: GroupMemberRole? public var on: Bool { enable == .on @@ -1375,8 +1376,9 @@ public struct GroupPreference: Codable, Equatable, Hashable { } } - public init(enable: GroupFeatureEnabled) { + public init(enable: GroupFeatureEnabled, role: GroupMemberRole? = nil) { self.enable = enable + self.role = role } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 3c9ece9dce..09142d2cc7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -697,15 +697,15 @@ object ChatModel { } suspend fun removeMemberItems(rhId: Long?, removedMember: GroupMember, byMember: GroupMember, groupInfo: GroupInfo) { - fun removedUpdatedItem(item: ChatItem): ChatItem? { - val newContent = when { - item.chatDir is CIDirection.GroupSnd && removedMember.groupMemberId == groupInfo.membership.groupMemberId -> CIContent.SndModerated - item.chatDir is CIDirection.GroupRcv && item.chatDir.groupMember.groupMemberId == removedMember.groupMemberId -> CIContent.RcvModerated - else -> return null - } + fun isRemovedMemberItem(item: ChatItem): Boolean = when { + item.chatDir is CIDirection.GroupSnd -> removedMember.groupMemberId == groupInfo.membership.groupMemberId + item.chatDir is CIDirection.GroupRcv -> item.chatDir.groupMember.groupMemberId == removedMember.groupMemberId + else -> false + } + fun markedUpdatedItem(item: ChatItem): ChatItem? { + if (!isRemovedMemberItem(item)) return null val updatedItem = item.copy( - meta = item.meta.copy(itemDeleted = CIDeleted.Moderated(Clock.System.now(), byGroupMember = byMember)), - content = if (groupInfo.fullGroupPreferences.fullDelete.on) newContent else item.content + meta = item.meta.copy(itemDeleted = CIDeleted.Moderated(Clock.System.now(), byGroupMember = byMember)) ) if (item.isActiveReport) { decreaseGroupReportsCounter(rhId, groupInfo.id) @@ -713,21 +713,52 @@ object ChatModel { return updatedItem } + // Mirrors backend groupFeatureMemberAllowed: fullDelete may be role-gated in business groups. + val fullDeletePref = groupInfo.fullGroupPreferences.fullDelete + val fullDelete = fullDeletePref.on && + byMember.memberRole >= (fullDeletePref.role ?: GroupMemberRole.Observer) val cInfo = ChatInfo.Group(groupInfo, groupChatScope = null) // TODO [knocking] review if (chatId.value == groupInfo.id) { - for (i in 0 until chatItems.value.size) { - val updatedItem = removedUpdatedItem(chatItems.value[i]) - if (updatedItem != null) { - updateChatItem(cInfo, updatedItem, atIndex = i) + if (fullDelete) { + for (item in chatItems.value) { + if (isRemovedMemberItem(item)) { + if (item.isRcvNew) { + decreaseCounterInPrimaryContext(rhId, groupInfo.id) + } + if (item.isActiveReport) { + decreaseGroupReportsCounter(rhId, groupInfo.id) + } + } + } + chatItems.removeAllAndNotify { item -> + val remove = isRemovedMemberItem(item) + if (remove) AudioPlayer.stop(item) + remove + } + } else { + for (i in 0 until chatItems.value.size) { + val updatedItem = markedUpdatedItem(chatItems.value[i]) + if (updatedItem != null) { + updateChatItem(cInfo, updatedItem, atIndex = i) + } } } } else { val i = getChatIndex(rhId, groupInfo.id) - val chat = chats[i] - if (chat.chatItems.isNotEmpty()) { - val updatedItem = removedUpdatedItem(chat.chatItems[0]) - if (updatedItem != null) { - chats.value[i] = chat.copy(chatItems = listOf(updatedItem)) + if (i >= 0) { + val chat = chats[i] + if (chat.chatItems.isNotEmpty()) { + val preview = chat.chatItems[0] + if (isRemovedMemberItem(preview)) { + if (fullDelete) { + chats.value[i] = chat.copy(chatItems = listOf(ChatItem.deletedItemDummy)) + } else { + val updatedItem = markedUpdatedItem(preview) + if (updatedItem != null) { + chats.value[i] = chat.copy(chatItems = listOf(updatedItem)) + } + } + } } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index a31dc145a3..ec75c1a359 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -6067,7 +6067,8 @@ data class GroupPreferences( @Serializable data class GroupPreference( - val enable: GroupFeatureEnabled + val enable: GroupFeatureEnabled, + val role: GroupMemberRole? = null, ) { val on: Boolean get() = enable == GroupFeatureEnabled.ON diff --git a/plans/2026-05-20-member-deletion-fulldelete.md b/plans/2026-05-20-member-deletion-fulldelete.md new file mode 100644 index 0000000000..9d6e516b00 --- /dev/null +++ b/plans/2026-05-20-member-deletion-fulldelete.md @@ -0,0 +1,73 @@ +# Full delete on member removal under fullDelete preference + +Plan for the next attempt at the change previously tried in PR #6831 (closed as too messy: the member row was deleted twice on one path, and the user's own membership row was deleted when the user was the one removed). The change is small: two SQL-function edits, one new chat-layer helper, an explicit fullDelete branch plus order swap in two backend handlers, and one in-memory removal branch in `removeMemberItems` on each UI platform. + +## Problem + +When a member is removed via `XGrpMemDel` with `withMessages = True` and the group's `fullDelete` preference is on for the deleter's role, the member's chat items are currently rewritten to `CIModerated` placeholders by `updateMemberCIsModerated`, and the member row is preserved by `deleteOrUpdateMemberRecord` when any item references it. The intent of `fullDelete` is physical deletion. The current behavior leaves placeholder rows and, because `deleteOrUpdateMemberRecord` runs before the items pass, the relay subpath of the latter deletes the member row first and the subsequent file collection returns nothing — files on disk leak. The same ordering bug exists on the moderator side (`APIRemoveMembers`). + +## What changes + +In `xGrpMemDel` (`src/Simplex/Chat/Library/Subscriber.hs:3157`), only on the branch where `withMessages = True` AND `groupFeatureMemberAllowed SGFFullDelete m gInfo`: + +**Case A — the deleted member is the user themselves (`memId == membership.memberId`).** The user's own sent items (those with `group_member_id IS NULL AND item_sent = 1`) and their files are physically deleted. The `membership` row stays with status `GSMemRemoved`, so the group can still be loaded in the chat list and opened. + +**Case B — the deleted member is somebody else.** The member's chat items and their files are physically deleted, then the `group_members` row is deleted. Historical system event items that referenced this member as `item_deleted_by_group_member_id` survive with NULL via the existing `ON DELETE SET NULL`. + +The non-fullDelete branch, the `withMessages = False` branch, and the entire message-moderation path (`XMsgDel`, `APIDeleteMemberChatItem`, `deleteGroupCIs`, `markGroupCIsDeleted`, `createCIModeration`, `chat_item_moderations`) are not changed. + +## Implementation + +The whole change is two SQL-function edits in `Store/Messages.hs`, a new member-record helper in `Library/Internal.hs`, and explicit branching plus an order swap in both `xGrpMemDel` (recipient side) and `APIRemoveMembers` (moderator side). + +**Edit 1 — rewrite `updateMemberCIsModerated` to physically delete.** Recommended rename: `deleteMemberCIs`. Keep the existing `memId == groupMemberId' membership` branch unchanged (the membership branch selects `WHERE group_member_id IS NULL AND item_sent = 1`; the other branch selects `WHERE group_member_id = ?`). Change the body from "UPDATE chat_items SET moderated content" to "physically delete chat_items + side-table rows analogous to `deleteGroupChatItem` in bulk": delete from `chat_item_messages`, `chat_item_versions`, `chat_item_reactions`, then `DELETE FROM chat_items`. The function loses the `byGroupMember`, `msgDir`, and `deletedTs` parameters since they were only used to construct the moderated content. The chat-layer wrappers `deleteGroupMemberCIs` and `deleteGroupMembersCIs` follow the same signature simplification. + +**Edit 2 — extend `getGroupMemberFileInfo` to handle the membership case.** Today it queries `WHERE group_member_id = ?` only, so for Case A it returns nothing and files for the user's own sent items leak (this is a pre-existing bug on both the off and on paths — `markGroupMemberCIsDeleted_` also relies on this function to cancel in-progress transfers). Add the same `memId == groupMemberId' membership` branch as in `deleteMemberCIs`: for the membership case, query `WHERE group_member_id IS NULL AND item_sent = 1`. The only two callers (`deleteGroupMemberCIs_` and `markGroupMemberCIsDeleted_`) both benefit from the fix. + +**Edit 3 — add `fullyDeleteMemberRecord` helper in `Library/Internal.hs` next to `deleteOrUpdateMemberRecord`.** Wraps `deleteSupportChatIfExists` + `deleteGroupMember`, returns updated `GroupInfo`. No `isRelay` branch and no `checkGroupMemberHasItems` query — the caller has already physically deleted the member's items, so the existence check would be a wasted query and the function communicates intent explicitly: unconditional row deletion. The `CM` wrapper plus an `IO` variant (`fullyDeleteMemberRecordIO`) mirror the shape of the existing `deleteOrUpdateMemberRecord` / `deleteOrUpdateMemberRecordIO`. + +**Edit 4 — swap order and add explicit branching in `xGrpMemDel` Case B (the `else` branch).** Move `when withMessages $ deleteMessages gInfo'' deletedMember' SMDRcv` to run *before* the member-record decision, on the same `gInfo` (the new `deleteMessages` reads only `groupId` and `membership` from the passed `gInfo`). Replace the current member-record dispatch with an explicit branch: + +``` +gInfo' <- case deliveryScope of + Just (DJSMemberSupport _) | shouldForward -> updateMemberRecordDeleted user gInfo deletedMember GSMemRemoved + _ -> if withMessages && groupFeatureMemberAllowed SGFFullDelete m gInfo + then fullyDeleteMemberRecord user gInfo deletedMember + else deleteOrUpdateMemberRecord user gInfo deletedMember +``` + +`deleteMemberItem` (the RGE event creation) keeps its current position after `updatePublicGroupData`. Case A (the `then` branch) needs no order change — the membership row is never deleted there, and `deleteMessages` already runs in the right relative position. + +The `DJSMemberSupport _ | shouldForward` subcase keeps its existing `updateMemberRecordDeleted` call regardless of fullDelete — the row is preserved for support-scope forwarding. Under fullDelete the items are still gone (the `deleteMessages` step ran first), the row stays. + +**Edit 5 — mirror the swap and explicit branching in `APIRemoveMembers` (`src/Simplex/Chat/Library/Commands.hs:2834`).** Inside `deleteMemsSend`, compute `fullDelete = withMessages && groupFeatureUserAllowed SGFFullDelete gInfo` once. Move the items pass to before `delMember`: run `deleteMessages user gInfo memsToDelete` inside `deleteMemsSend` before the `withStoreBatch'` that calls `delMember`. Change `delMember` to branch explicitly: + +``` +delMember db m = do + if fullDelete + then void $ fullyDeleteMemberRecordIO db user gInfo m + else void $ deleteOrUpdateMemberRecordIO db user gInfo m + pure m {memberStatus = GSMemRemoved} +``` + +`deletePendingMember` flows through `deleteMemsSend` and inherits the new behavior. The outer line 2864 call (`when withMessages $ deleteMessages user gInfo' deleted`) collapses — items are already handled inside `deleteMemsSend` for current and pending members, and invited members (handled by `deleteInvitedMems`) have no chat items. Remove it. + +**Edit 6 — extend `removeMemberItems` on both UI platforms to physically remove items from the in-memory list when `fullDelete.on`.** Today (iOS `apps/ios/Shared/Model/ChatModel.swift:814-846`, Kotlin `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt:699-734`) the function walks the in-memory items, identifies matches by direction and member id, and sets `itemDeleted = .moderated(...)`; under `fullDelete.on` it additionally rewrites content to `Snd/RcvModerated`. Items are never removed from `im.reversedChatItems` / `chatItems.value`. After the backend change, the chat_item rows are physically gone in DB while the UI keeps stale moderated placeholders until the next refetch — flicker. Extend the existing `fullDelete.on` branch so it also removes matching items from the in-memory list (iOS: drop them from `im.reversedChatItems`, decrement unread counters, stop voice playback on dropped items; Kotlin: `removeAllAndNotify { isMemberItem(it) }` equivalent, decrement counters, stop audio). The fullDelete-off branch is unchanged (still marks moderated in place). + +The three callers — iOS `removeMember` in `GroupChatInfoView.swift:977`, Kotlin `removeMembers` in `GroupChatInfoView.kt:1316`, Kotlin `removeMember` in `GroupMemberInfoView.kt:339`, and the event handlers for `.deletedMember`/`.deletedMemberUser` in `SimpleXAPI.swift:2578-2596` and `SimpleXAPI.kt:2945-2973` — all converge on the same `removeMemberItems` function on each platform and inherit the new behavior automatically. The chat-list preview path inside `removeMemberItems` (the `else` branch that updates `chat.chatItems[0]`) also needs to drop the preview item under fullDelete so the chat list doesn't show a stale moderated last-message. + +The `fullDelete.on` gate matches the backend's `groupFeatureMemberAllowed SGFFullDelete` / `groupFeatureUserAllowed SGFFullDelete` because FullDelete is a `GroupFeatureNoRoleI` feature — the role check collapses to the `.on` check. + +## Anti-patterns from PR #6831 to avoid + +No path may call `deleteGroupMember` twice. No path under Case A may delete the `membership` row — that row must survive. File info must be collected before any chat-item deletion, since `getGroupMemberFileInfo` reads `chat_items`. Do not rely on `ON DELETE SET NULL` to clean up the deleted member's authored items — they are deleted explicitly first. `fullyDeleteMemberRecord` is the only function that should call `deleteGroupMember` directly on the new path; do not duplicate that call in the handler. + +## Tests + +Add cases in `tests/ChatTests/Groups.hs` for: Case A (user removed by admin, fullDelete on — user's sent items and their files gone, `membership` row exists with `GSMemRemoved`, group still loadable); Case B (member removed by admin, fullDelete on — member's items and files gone, `group_members` row gone, system event items previously referencing the removed member now have NULL `item_deleted_by_group_member_id` and still display correctly); regression for fullDelete=off (items become `CIModerated` placeholders via `markMemberCIsDeleted`); regression for `withMessages = False` (items untouched, row handled by existing path); regression that message moderation under fullDelete=on still produces `CIModerated` placeholders, confirming the moderation path is unchanged. Verify the same Case A and Case B behaviors over both XGrpMemDel (recipient side, Subscriber.hs) and APIRemoveMembers (moderator side, Commands.hs). + +UI checks for the manual smoke test: in a group with fullDelete on, remove a member with messages — that member's bubbles disappear immediately from the open chat view on both moderator's and recipients' devices, the chat list preview updates to the previous non-deleted message, and the unread/report counters decrement; with fullDelete off, the same removal produces moderated placeholders as today. Verify on iOS, Android, and Desktop. + +## Open items for review + +Naming of the rewritten `updateMemberCIsModerated`: `deleteMemberCIs` is the natural rename (the function physically deletes chat items associated with a member, handling the membership case internally). Naming of the new chat-layer helper: `fullyDeleteMemberRecord` (parallels `deleteOrUpdateMemberRecord`). Confirm or amend before implementation. diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index f835445f0d..1bd49af52a 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -2871,7 +2871,6 @@ processChatCommand vr nm = \case let acis' = map (updateACIGroupInfo gInfo') acis unless (null acis') $ toView $ CEvtNewChatItems user acis' unless (null errs) $ toView $ CEvtChatErrors errs - when withMessages $ deleteMessages user gInfo' deleted pure $ CRUserDeletedMembers user gInfo' deleted withMessages msgSigned -- same order is not guaranteed where selectMembers :: S.Set GroupMemberId -> [GroupMember] -> (Int, [GroupMember], [GroupMember], [GroupMember], [GroupMember], GroupMemberRole, Bool) @@ -2916,11 +2915,14 @@ processChatCommand vr nm = \case Left e -> Just $ Left e itemsData = mapMaybe skipUnwantedItem itemsData_ cis_ <- saveSndChatItems user (CDGroupSnd gInfo chatScopeInfo) False itemsData Nothing False + -- MUST run before delMember so getGroupMemberFileInfo can still resolve file info under fullDelete. + when withMessages $ deleteMessages user gInfo memsToDelete deleteMembersConnections' user memsToDelete True (errs, deleted) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (delMember db) memsToDelete) let acis = map (AChatItem SCTGroup SMDSnd (GroupChat gInfo chatScopeInfo)) $ rights cis_ pure (errs, deleted, acis, signed) where + fullDelete = withMessages && groupFeatureUserAllowed SGFFullDelete gInfo sndItemData :: GroupMember -> SndMessage -> Maybe (NewSndChatItemData c) sndItemData GroupMember {groupMemberId, memberProfile, memberStatus} msg | memberStatus == GSMemRemoved || memberStatus == GSMemLeft = Nothing @@ -2933,10 +2935,12 @@ processChatCommand vr nm = \case -- voided result (updated group info) may have incorrect state of membersRequireAttention. -- To avoid complicating code by chaining group info updates, -- instead we re-read it once after deleting all members before response. - void $ deleteOrUpdateMemberRecordIO db user gInfo m + if fullDelete + then void $ fullyDeleteMemberRecordIO db user gInfo m + else void $ deleteOrUpdateMemberRecordIO db user gInfo m pure m {memberStatus = GSMemRemoved} deleteMessages user gInfo@GroupInfo {membership} ms - | groupFeatureUserAllowed SGFFullDelete gInfo = deleteGroupMembersCIs user gInfo ms membership + | groupFeatureUserAllowed SGFFullDelete gInfo = deleteGroupMembersCIs user gInfo ms | otherwise = markGroupMembersCIsDeleted user gInfo ms membership APILeaveGroup groupId -> withUser $ \user@User {userId} -> do gInfo@GroupInfo {membership} <- withFastStore $ \db -> getGroupInfo db vr user groupId diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 576eb942a5..f824777f28 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -515,22 +515,20 @@ updateACIGroupInfo gInfo' = \case AChatItem SCTGroup dir (GroupChat gInfo' chatScopeInfo) ci aci -> aci -deleteGroupMemberCIs :: MsgDirectionI d => User -> GroupInfo -> GroupMember -> GroupMember -> SMsgDirection d -> CM () -deleteGroupMemberCIs user gInfo member byGroupMember msgDir = do - deletedTs <- liftIO getCurrentTime - filesInfo <- withStore' $ \db -> deleteGroupMemberCIs_ db user gInfo member byGroupMember msgDir deletedTs +deleteGroupMemberCIs :: User -> GroupInfo -> GroupMember -> CM () +deleteGroupMemberCIs user gInfo member = do + filesInfo <- withStore' $ \db -> deleteGroupMemberCIs_ db user gInfo member deleteCIFiles user filesInfo -deleteGroupMembersCIs :: User -> GroupInfo -> [GroupMember] -> GroupMember -> CM () -deleteGroupMembersCIs user gInfo members byGroupMember = do - deletedTs <- liftIO getCurrentTime - filesInfo <- withStore' $ \db -> fmap concat $ forM members $ \m -> deleteGroupMemberCIs_ db user gInfo m byGroupMember SMDRcv deletedTs +deleteGroupMembersCIs :: User -> GroupInfo -> [GroupMember] -> CM () +deleteGroupMembersCIs user gInfo members = do + filesInfo <- withStore' $ \db -> fmap concat $ forM members $ deleteGroupMemberCIs_ db user gInfo deleteCIFiles user filesInfo -deleteGroupMemberCIs_ :: MsgDirectionI d => DB.Connection -> User -> GroupInfo -> GroupMember -> GroupMember -> SMsgDirection d -> UTCTime -> IO [CIFileInfo] -deleteGroupMemberCIs_ db user gInfo member byGroupMember msgDir deletedTs = do +deleteGroupMemberCIs_ :: DB.Connection -> User -> GroupInfo -> GroupMember -> IO [CIFileInfo] +deleteGroupMemberCIs_ db user gInfo member = do fs <- getGroupMemberFileInfo db user gInfo member - updateMemberCIsModerated db user gInfo member byGroupMember msgDir deletedTs + deleteMemberCIs db user gInfo member pure fs deleteLocalCIs :: User -> NoteFolder -> [CChatItem 'CTLocal] -> Bool -> Bool -> CM ChatResponse @@ -1853,6 +1851,17 @@ deleteOrUpdateMemberRecordIO db user@User {userId} gInfo m = do Nothing -> deleteGroupMember db user m' pure gInfo' +-- Unlike deleteOrUpdateMemberRecord, skips checkGroupMemberHasItems. +fullyDeleteMemberRecord :: User -> GroupInfo -> GroupMember -> CM GroupInfo +fullyDeleteMemberRecord user gInfo m = + withStore' $ \db -> fullyDeleteMemberRecordIO db user gInfo m + +fullyDeleteMemberRecordIO :: DB.Connection -> User -> GroupInfo -> GroupMember -> IO GroupInfo +fullyDeleteMemberRecordIO db user gInfo m = do + (gInfo', m') <- deleteSupportChatIfExists db user gInfo m + deleteGroupMember db user m' + pure gInfo' + updateMemberRecordDeleted :: User -> GroupInfo -> GroupMember -> GroupMemberStatus -> CM GroupInfo updateMemberRecordDeleted user@User {userId} gInfo m newStatus = withStore' $ \db -> do diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index a661e53752..f0fea2dbf1 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -3176,7 +3176,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = updateGroupMemberStatus db userId membership GSMemRemoved when (maybe False (/= RSRejected) (relayOwnStatus gInfo)) $ updateRelayOwnStatus_ db gInfo RSInactive let membership' = membership {memberStatus = GSMemRemoved} - when withMessages $ deleteMessages gInfo membership' SMDSnd + when withMessages $ deleteMessages gInfo membership' deleteMemberItem msg gInfo RGEUserDeleted toView $ CEvtDeletedMemberUser user gInfo {membership = membership'} m withMessages msgSigned pure $ Just DJSGroup {jobSpec = DJRelayRemoved} @@ -3196,15 +3196,18 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = deleteMemberConnection' deletedMember True else deleteMemberConnection deletedMember let deliveryScope = memberEventDeliveryScope deletedMember + deletedMember' = deletedMember {memberStatus = GSMemRemoved} + when withMessages $ deleteMessages gInfo deletedMember' gInfo' <- case deliveryScope of -- Keep member record if it's support scope - it will be required for forwarding inside that scope. Just (DJSMemberSupport _) | shouldForward -> updateMemberRecordDeleted user gInfo deletedMember GSMemRemoved - -- Undeleted "member connected" chat item will prevent deletion of member record. - _ -> deleteOrUpdateMemberRecord user gInfo deletedMember + _ + | withMessages && groupFeatureMemberAllowed SGFFullDelete m gInfo -> + fullyDeleteMemberRecord user gInfo deletedMember + -- Undeleted "member connected" chat item will prevent deletion of member record. + | otherwise -> deleteOrUpdateMemberRecord user gInfo deletedMember gInfo'' <- updatePublicGroupData user gInfo' let wasDeleted = memberStatus == GSMemRemoved || memberStatus == GSMemLeft - deletedMember' = deletedMember {memberStatus = GSMemRemoved} - when withMessages $ deleteMessages gInfo'' deletedMember' SMDRcv -- Clear forwardedByMember if it references the deleted member, -- as the member record was already deleted above. let RcvMessage {forwardedByMember = fwdBy} = msg @@ -3221,9 +3224,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = (gi', m', scopeInfo) <- mkGroupChatScope gi m (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gi' scopeInfo m') msg' brokerTs (CIRcvGroupEvent gEvent) groupMsgToView cInfo ci - deleteMessages :: MsgDirectionI d => GroupInfo -> GroupMember -> SMsgDirection d -> CM () - deleteMessages gInfo' delMem msgDir - | groupFeatureMemberAllowed SGFFullDelete m gInfo' = deleteGroupMemberCIs user gInfo' delMem m msgDir + deleteMessages :: GroupInfo -> GroupMember -> CM () + deleteMessages gInfo' delMem + | groupFeatureMemberAllowed SGFFullDelete m gInfo' = deleteGroupMemberCIs user gInfo' delMem | otherwise = markGroupMemberCIsDeleted user gInfo' delMem m forwardToMember :: GroupMember -> CM () forwardToMember member = diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 5d433088a4..cdd3185209 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -65,7 +65,7 @@ module Simplex.Chat.Store.Messages updateGroupCIMentions, deleteGroupChatItem, updateGroupChatItemModerated, - updateMemberCIsModerated, + deleteMemberCIs, updateGroupCIBlockedByAdmin, markGroupChatItemDeleted, markMemberCIsDeleted, @@ -211,9 +211,19 @@ getGroupFileInfo db User {userId} GroupInfo {groupId} = <$> DB.query db (fileInfoQuery <> " WHERE i.user_id = ? AND i.group_id = ?") (userId, groupId) getGroupMemberFileInfo :: DB.Connection -> User -> GroupInfo -> GroupMember -> IO [CIFileInfo] -getGroupMemberFileInfo db User {userId} GroupInfo {groupId} GroupMember {groupMemberId} = - map toFileInfo - <$> DB.query db (fileInfoQuery <> " WHERE i.user_id = ? AND i.group_id = ? AND i.group_member_id = ?") (userId, groupId, groupMemberId) +getGroupMemberFileInfo db User {userId} GroupInfo {groupId, membership} member + | groupMemberId' member == groupMemberId' membership = + map toFileInfo + <$> DB.query + db + (fileInfoQuery <> " WHERE i.user_id = ? AND i.group_id = ? AND i.group_member_id IS NULL AND i.item_sent = 1") + (userId, groupId) + | otherwise = + map toFileInfo + <$> DB.query + db + (fileInfoQuery <> " WHERE i.user_id = ? AND i.group_id = ? AND i.group_member_id = ?") + (userId, groupId, groupMemberId' member) deleteGroupChatItemsMessages :: DB.Connection -> User -> GroupInfo -> IO () deleteGroupChatItemsMessages db User {userId} GroupInfo {groupId} = do @@ -2814,39 +2824,60 @@ updateGroupChatItemModerated db User {userId} GroupInfo {groupId} ci m@GroupMemb (deletedTs, groupMemberId, toContent, toText, currentTs, userId, groupId, itemId) pure ci {content = toContent, meta = (meta ci) {itemText = toText, itemDeleted = Just (CIModerated (Just deletedTs) m), editable = False, deletable = False}, formattedText = Nothing} -updateMemberCIsModerated :: MsgDirectionI d => DB.Connection -> User -> GroupInfo -> GroupMember -> GroupMember -> SMsgDirection d -> UTCTime -> IO () -updateMemberCIsModerated db User {userId} GroupInfo {groupId, membership} member byGroupMember md deletedTs = do - itemIds <- updateCIs =<< getCurrentTime +deleteMemberCIs :: DB.Connection -> User -> GroupInfo -> GroupMember -> IO () +deleteMemberCIs db User {userId} GroupInfo {groupId, membership} member = do + items <- selectItems + let itemMemberId = memberId' member #if defined(dbPostgres) - let inItemIds = Only $ In (map fromOnly itemIds) - DB.execute db "DELETE FROM messages WHERE message_id IN (SELECT message_id FROM chat_item_messages WHERE chat_item_id IN ?)" inItemIds - DB.execute db "DELETE FROM chat_item_versions WHERE chat_item_id IN ?" inItemIds + let itemIds = map fst items + sharedMsgIds = mapMaybe snd items + unless (null itemIds) $ do + DB.execute + db + [sql| + DELETE FROM messages WHERE message_id IN ( + SELECT message_id FROM chat_item_messages WHERE chat_item_id IN ? + ) + |] + (Only (In itemIds)) + DB.execute db "DELETE FROM chat_item_versions WHERE chat_item_id IN ?" (Only (In itemIds)) + unless (null sharedMsgIds) $ + DB.execute + db + "DELETE FROM chat_item_reactions WHERE group_id = ? AND shared_msg_id IN ? AND item_member_id IS NOT DISTINCT FROM ?" + (groupId, In sharedMsgIds, itemMemberId) + unless (null itemIds) $ + DB.execute + db + "DELETE FROM chat_items WHERE user_id = ? AND group_id = ? AND chat_item_id IN ?" + (userId, groupId, In itemIds) #else - DB.executeMany db deleteChatItemMessagesQuery itemIds - DB.executeMany db "DELETE FROM chat_item_versions WHERE chat_item_id = ?" itemIds + forM_ items $ \(itemId, itemSharedMsgId_) -> do + deleteChatItemMessages_ db itemId + deleteChatItemVersions_ db itemId + forM_ itemSharedMsgId_ $ \sharedMsgId -> + DB.execute + db + "DELETE FROM chat_item_reactions WHERE group_id = ? AND shared_msg_id = ? AND item_member_id IS NOT DISTINCT FROM ?" + (groupId, sharedMsgId, itemMemberId) + DB.execute + db + "DELETE FROM chat_items WHERE user_id = ? AND group_id = ? AND chat_item_id = ?" + (userId, groupId, itemId) #endif where - memId = groupMemberId' member - updateQuery = - [sql| - UPDATE chat_items - SET item_deleted = 1, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, item_content = ?, item_text = ?, updated_at = ? - WHERE user_id = ? AND group_id = ? - |] - updateCIs :: UTCTime -> IO [Only Int64] - updateCIs currentTs - | memId == groupMemberId' membership = + selectItems :: IO [(ChatItemId, Maybe SharedMsgId)] + selectItems + | groupMemberId' member == groupMemberId' membership = DB.query db - (updateQuery <> " AND group_member_id IS NULL AND item_sent = 1 RETURNING chat_item_id") - (columns :. (userId, groupId)) + "SELECT chat_item_id, shared_msg_id FROM chat_items WHERE user_id = ? AND group_id = ? AND group_member_id IS NULL AND item_sent = 1" + (userId, groupId) | otherwise = DB.query db - (updateQuery <> " AND group_member_id = ? RETURNING chat_item_id") - (columns :. (userId, groupId, memId)) - where - columns = (deletedTs, groupMemberId' byGroupMember, msgDirToModeratedContent_ md, ciModeratedText, currentTs) + "SELECT chat_item_id, shared_msg_id FROM chat_items WHERE user_id = ? AND group_id = ? AND group_member_id = ?" + (userId, groupId, groupMemberId' member) updateGroupCIBlockedByAdmin :: DB.Connection -> User -> GroupInfo -> ChatItem 'CTGroup d -> UTCTime -> IO (ChatItem 'CTGroup d) updateGroupCIBlockedByAdmin db User {userId} GroupInfo {groupId} ci deletedTs = do diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 598ee920dd..16f14a0484 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -3925,22 +3925,6 @@ Query: Plan: SEARCH user_contact_links USING INTEGER PRIMARY KEY (rowid=?) -Query: - UPDATE chat_items - SET item_deleted = 1, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, item_content = ?, item_text = ?, updated_at = ? - WHERE user_id = ? AND group_id = ? - AND group_member_id = ? RETURNING chat_item_id -Plan: -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=?) - -Query: - UPDATE chat_items - SET item_deleted = 1, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, item_content = ?, item_text = ?, updated_at = ? - WHERE user_id = ? AND group_id = ? - AND group_member_id IS NULL AND item_sent = 1 RETURNING chat_item_id -Plan: -SEARCH chat_items USING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=?) - Query: UPDATE chat_items SET item_deleted = 1, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, item_content = ?, item_text = ?, updated_at = ? @@ -5682,6 +5666,15 @@ Plan: SEARCH i USING COVERING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=?) SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) +Query: + SELECT f.file_id, f.ci_file_status, f.file_path + FROM chat_items i + JOIN files f ON f.chat_item_id = i.chat_item_id + WHERE i.user_id = ? AND i.group_id = ? AND i.group_member_id IS NULL AND i.item_sent = 1 +Plan: +SEARCH i USING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=?) +SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) + Query: SELECT f.file_id, f.ci_file_status, f.file_path FROM chat_items i @@ -6171,6 +6164,18 @@ SEARCH chat_items USING COVERING INDEX idx_chat_items_fwd_from_chat_item_id (fwd SEARCH files USING COVERING INDEX idx_files_chat_item_id (chat_item_id=?) SEARCH groups USING COVERING INDEX idx_groups_chat_item_id (chat_item_id=?) +Query: DELETE FROM chat_items WHERE user_id = ? AND group_id = ? AND chat_item_id = ? +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) +SEARCH chat_item_mentions USING COVERING INDEX idx_chat_item_mentions_chat_item_id (chat_item_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_chat_item_id (chat_item_id=?) +SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?) +SEARCH calls USING COVERING INDEX idx_calls_chat_item_id (chat_item_id=?) +SEARCH chat_item_messages USING COVERING INDEX sqlite_autoindex_chat_item_messages_2 (chat_item_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_fwd_from_chat_item_id (fwd_from_chat_item_id=?) +SEARCH files USING COVERING INDEX idx_files_chat_item_id (chat_item_id=?) +SEARCH groups USING COVERING INDEX idx_groups_chat_item_id (chat_item_id=?) + Query: DELETE FROM chat_items WHERE user_id = ? AND group_id = ? AND group_member_id = ? Plan: SEARCH chat_items USING COVERING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=?) @@ -6717,6 +6722,14 @@ Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AN Plan: SEARCH chat_items USING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=? AND shared_msg_id=?) +Query: SELECT chat_item_id, shared_msg_id FROM chat_items WHERE user_id = ? AND group_id = ? AND group_member_id = ? +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=?) + +Query: SELECT chat_item_id, shared_msg_id FROM chat_items WHERE user_id = ? AND group_id = ? AND group_member_id IS NULL AND item_sent = 1 +Plan: +SEARCH chat_items USING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=?) + Query: SELECT chat_item_ttl FROM contacts WHERE contact_id = ? LIMIT 1 Plan: SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 82bf20e6cf..67c32fcca8 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -42,6 +42,7 @@ import Simplex.Messaging.Agent.Store.DB (Binary (..)) import Simplex.Messaging.Server.Env.STM hiding (subscriptions) import Simplex.Messaging.Transport import Simplex.Messaging.Version +import System.Directory (copyFile, doesFileExist) import Test.Hspec hiding (it) #if defined(dbPostgres) import Database.PostgreSQL.Simple (Only (..)) @@ -50,7 +51,6 @@ import Database.PostgreSQL.Simple.SqlQQ (sql) import Database.SQLite.Simple (Only (..)) import Database.SQLite.Simple.QQ (sql) import Simplex.Chat.Options.DB -import System.Directory (copyFile) import System.FilePath (()) #endif @@ -1918,7 +1918,7 @@ testGroupDelayedModerationFullDelete ps = do testDeleteMemberWithMessages :: HasCallStack => TestParams -> IO () testDeleteMemberWithMessages = testChat3 aliceProfile bobProfile cathProfile $ - \alice bob cath -> do + \alice bob cath -> withXFTPServer $ do createGroup3' "team" alice (bob, GRMember) (cath, GRMember) threadDelay 750000 alice ##> "/set delete #team on" @@ -1936,22 +1936,61 @@ testDeleteMemberWithMessages = cath <## "Full deletion: on" ] threadDelay 750000 - bob #> "#team hello" - concurrently_ - (alice <# "#team bob> hello") - (cath <# "#team bob> hello") - alice #$> ("/_get chat #1 count=1", chat, [(0, "hello")]) - bob #$> ("/_get chat #1 count=1", chat, [(1, "hello")]) - cath #$> ("/_get chat #1 count=1", chat, [(0, "hello")]) + + alice #$> ("/_files_folder ./tests/tmp/alice_app_files", id, "ok") + bob #$> ("/_files_folder ./tests/tmp/bob_app_files", id, "ok") + cath #$> ("/_files_folder ./tests/tmp/cath_app_files", id, "ok") + copyFile "./tests/fixtures/test.jpg" "./tests/tmp/bob_app_files/test.jpg" + + bob ##> "/_send #1 json [{\"filePath\": \"test.jpg\", \"msgContent\": {\"type\": \"text\", \"text\": \"file from bob\"}}]" + bob <# "#team file from bob" + bob <# "/f #team test.jpg" + bob <## "use /fc 1 to cancel sending" + + alice <# "#team bob> file from bob" + alice <# "#team bob> sends file test.jpg (136.5 KiB / 139737 bytes)" + alice <## "use /fr 1 [/ | ] to receive it" + + cath <# "#team bob> file from bob" + cath <# "#team bob> sends file test.jpg (136.5 KiB / 139737 bytes)" + cath <## "use /fr 1 [/ | ] to receive it" + + bob <## "completed uploading file 1 (test.jpg) for #team" + + alice ##> "/fr 1" + alice + <### [ "saving file 1 from bob to test.jpg", + "started receiving file 1 (test.jpg) from bob" + ] + alice <## "completed receiving file 1 (test.jpg) from bob" + + cath ##> "/fr 1" + cath + <### [ "saving file 1 from bob to test.jpg", + "started receiving file 1 (test.jpg) from bob" + ] + cath <## "completed receiving file 1 (test.jpg) from bob" + + src <- B.readFile "./tests/fixtures/test.jpg" + B.readFile "./tests/tmp/alice_app_files/test.jpg" `shouldReturn` src + B.readFile "./tests/tmp/bob_app_files/test.jpg" `shouldReturn` src + B.readFile "./tests/tmp/cath_app_files/test.jpg" `shouldReturn` src + threadDelay 1000000 alice ##> "/rm #team bob messages=on" alice <## "#team: you removed bob from the group with all messages" bob <## "#team: alice removed you from the group with all messages" bob <## "use /d #team to delete the group" cath <## "#team: alice removed bob from the group with all messages" - alice #$> ("/_get chat #1 count=2", chat, [(0, "moderated [deleted by you]"), (1, "removed bob")]) - bob #$> ("/_get chat #1 count=2", chat, [(1, "moderated [deleted by alice]"), (0, "removed you")]) - cath #$> ("/_get chat #1 count=2", chat, [(0, "moderated [deleted by alice]"), (0, "removed bob")]) + + doesFileExist "./tests/tmp/alice_app_files/test.jpg" `shouldReturn` False + doesFileExist "./tests/tmp/bob_app_files/test.jpg" `shouldReturn` False + doesFileExist "./tests/tmp/cath_app_files/test.jpg" `shouldReturn` False + + -- Under fullDelete, bob's items are physically deleted on all sides; only the system event remains. + alice #$> ("/_get chat #1 count=1", chat, [(1, "removed bob")]) + bob #$> ("/_get chat #1 count=1", chat, [(0, "removed you")]) + cath #$> ("/_get chat #1 count=1", chat, [(0, "removed bob")]) testDeleteMemberMarkMessagesDeleted :: HasCallStack => TestParams -> IO () testDeleteMemberMarkMessagesDeleted = From 9a812c8a8a28d426c127e6b7310971b8ea757478 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Mon, 25 May 2026 13:12:52 +0000 Subject: [PATCH 006/200] flatpak: update metainfo (#7008) --- .../flatpak/chat.simplex.simplex.metainfo.xml | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/scripts/flatpak/chat.simplex.simplex.metainfo.xml b/scripts/flatpak/chat.simplex.simplex.metainfo.xml index b55a08df26..1cb5174990 100644 --- a/scripts/flatpak/chat.simplex.simplex.metainfo.xml +++ b/scripts/flatpak/chat.simplex.simplex.metainfo.xml @@ -38,6 +38,32 @@ + + https://simplex.chat/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.html + +

New in v6.5.3:

+
    +
  • relays reject groups that were removed, can be manually re-allowed
  • +
+

New in v6.5:

+

Public channels - speak freely!

+
    +
  • Reliability: many relays per channel.
  • +
  • Ownership: you can run your own relays.
  • +
  • Security: owners hold channel keys.
  • +
  • Privacy: for owners and subscribers.
  • +
+

Easier to invite your friends: we made connecting simpler for new users.

+

Safe web links:

+
    +
  • opt-in to send link previews.
  • +
  • use SOCKS proxy for previews (if enabled).
  • +
  • prevent hyperlink phishing.
  • +
  • remove link tracking.
  • +
+

Non-profit governance: to make SimpleX Network last.

+
+
https://simplex.chat/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.html From ca4d78a5028bc383873b5a8c1710c6dbbe8361af Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Mon, 25 May 2026 13:20:17 +0000 Subject: [PATCH 007/200] docs: update security assessment schedule (#7007) --- docs/SECURITY.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/SECURITY.md b/docs/SECURITY.md index b9218fbebc..73c050c106 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -1,7 +1,7 @@ --- title: Security Policy permalink: /security/index.html -revision: 23.04.2024 +revision: 25.05.2026 --- # Security Policy @@ -12,7 +12,7 @@ The implementation security assessment of SimpleX cryptography and networking wa The cryptographic review of SimpleX protocols design was done by Trail of Bits in [July 2024](../blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md). -We are planning implementation security assessment in early 2025. +We have scheduled implementation security assessment for June 2026. ## Reporting security issues From f3abb7aa76a5de3a17d9d167850773f49c51469c Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Mon, 25 May 2026 18:34:32 +0000 Subject: [PATCH 008/200] android, desktop: fix E2E encryption section divider rendered inside card (#7012) Move SectionDividerSpaced() out of the SectionView { ... } block so it acts as inter-section spacing instead of being rendered as the section's last child. Matches the pattern used by every other section in ChatInfoLayout. Plan: plans/2026-05-25-fix-e2e-encryption-section-divider.md --- .../simplex/common/views/chat/ChatInfoView.kt | 2 +- ...5-25-fix-e2e-encryption-section-divider.md | 50 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 plans/2026-05-25-fix-e2e-encryption-section-divider.md diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index a063477f84..dce1b6ea33 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -621,8 +621,8 @@ fun ChatInfoLayout( if (conn != null) { SectionView { InfoRow("E2E encryption", if (conn.connPQEnabled) "Quantum resistant" else "Standard") - SectionDividerSpaced() } + SectionDividerSpaced() } if (contact.contactLink != null) { diff --git a/plans/2026-05-25-fix-e2e-encryption-section-divider.md b/plans/2026-05-25-fix-e2e-encryption-section-divider.md new file mode 100644 index 0000000000..69d4bd630a --- /dev/null +++ b/plans/2026-05-25-fix-e2e-encryption-section-divider.md @@ -0,0 +1,50 @@ +# Fix E2E encryption section divider rendered inside the section + +Branch: `nd/fix-e2e-encryption-section-divider` · base: `master`. + +## Problem + +On the contact info screen (Android and desktop, current `master`), the "E2E encryption — Quantum resistant / Standard" card has a horizontal divider line cutting across it under its single row, followed by extra padded space — visually reading as the card being sliced in two with a second, empty card underneath. Repros for any 1:1 contact with an active connection. Behaviour of the row itself is correct; bug is purely visual. + +## Fix + +One line in `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt` — move `SectionDividerSpaced()` out of the `SectionView { ... }` block: + +```diff + if (conn != null) { + SectionView { + InfoRow("E2E encryption", if (conn.connPQEnabled) "Quantum resistant" else "Standard") +- SectionDividerSpaced() + } ++ SectionDividerSpaced() + } +``` + +Total diff: 1 file, +1 / −1. + +## Cause + +Two unrelated changes combined to produce the visible bug: + +1. PR #4060 (`9e3f528d4`, "android: remove experimental PQ toggle") removed the conditional `AllowContactPQButton` / `SectionTextFooter` that used to sit between the `InfoRow` and the divider — but left `SectionDividerSpaced()` inside the `SectionView { ... }` block. At that point `SectionView` was a plain column, so the leftover divider only looked like extra inter-section spacing. + +2. PR #6777 (`df5ea3d46`, "android, desktop: new settings section design") wrapped `SectionView`'s content in `CardColumn` with `SectionCardShape`, giving each section a rounded card background. After this, *anything* drawn by the section content — including the leftover `Divider` — is drawn inside the card. + +Rendered structure on current master: + +``` +SectionView (card background, rounded shape) + └ CardColumn + ├ InfoRow("E2E encryption", ...) + ├ Divider ← line cutting across the card + └ 18 dp bottom padding ← reads as a second, empty card +``` + +After the fix the divider re-parents to the enclosing `ChatInfoLayout` column and sits between the E2E card and the next section's card, matching the pattern used by every other section on the screen. + +## Risk + +- One composable call site, structural move of a single node; no logic, state, or styling change. +- iOS is a separate codebase and is unaffected. +- Grep confirms no other `SectionDividerSpaced` call sits inside a `SectionView { ... }` in `apps/multiplatform`. +- Rollback: `git revert` the fix commit. From 037c05cd297eb7f2463a6325a7ba6674e2fb1e09 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 26 May 2026 09:07:26 +0000 Subject: [PATCH 009/200] core: fix races on member removal / delivery of their messages (#7010) --- simplex-chat.cabal | 2 + src/Simplex/Chat/Library/Commands.hs | 9 ++++ src/Simplex/Chat/Library/Internal.hs | 8 ++- src/Simplex/Chat/Library/Subscriber.hs | 13 +++-- src/Simplex/Chat/Store/Groups.hs | 31 ++++++++++++ src/Simplex/Chat/Store/Postgres/Migrations.hs | 4 +- .../Migrations/M20260525_member_removed_at.hs | 19 +++++++ .../Store/Postgres/Migrations/chat_schema.sql | 3 +- src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../Migrations/M20260525_member_removed_at.hs | 18 +++++++ .../SQLite/Migrations/chat_query_plans.txt | 50 +++++++++++++++++++ .../Store/SQLite/Migrations/chat_schema.sql | 1 + tests/ChatTests/Groups.hs | 28 ++++++++--- 13 files changed, 173 insertions(+), 17 deletions(-) create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations/M20260525_member_removed_at.hs create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20260525_member_removed_at.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index e9a5660637..29436e128e 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -135,6 +135,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260514_relay_request_group_link_index Simplex.Chat.Store.Postgres.Migrations.M20260515_delivery_job_senders Simplex.Chat.Store.Postgres.Migrations.M20260520_client_services + Simplex.Chat.Store.Postgres.Migrations.M20260525_member_removed_at else exposed-modules: Simplex.Chat.Archive @@ -292,6 +293,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260514_relay_request_group_link_index Simplex.Chat.Store.SQLite.Migrations.M20260515_delivery_job_senders Simplex.Chat.Store.SQLite.Migrations.M20260520_client_services + Simplex.Chat.Store.SQLite.Migrations.M20260525_member_removed_at other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 1bd49af52a..e27223094a 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -4761,6 +4761,8 @@ cleanupManager = do liftIO $ threadDelay' stepDelay cleanupStaleRelayTestConns user `catchAllErrors` eToView liftIO $ threadDelay' stepDelay + cleanupRemovedMembers user `catchAllErrors` eToView + liftIO $ threadDelay' stepDelay cleanupTimedItems cleanupInterval user = do ts <- liftIO getCurrentTime let startTimedThreadCutoff = addUTCTime cleanupInterval ts @@ -4787,6 +4789,13 @@ cleanupManager = do forM_ staleConns $ \acId -> do deleteAgentConnectionAsync acId withStore' $ \db -> deleteConnectionByAgentConnId db user acId + cleanupRemovedMembers user = do + vr <- chatVersionRange + ts <- liftIO getCurrentTime + let cutoffTs = addUTCTime (-nominalDay) ts + removedMembers <- withStore' $ \db -> getRemovedMembersToCleanup db vr user cutoffTs + forM_ removedMembers $ \m -> + withStore' (\db -> deleteGroupMember db user m) `catchAllErrors` eToView cleanupMessages = do ts <- liftIO getCurrentTime let cutoffTs = addUTCTime (-(30 * nominalDay)) ts diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index f824777f28..eb0fd564e3 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1848,7 +1848,9 @@ deleteOrUpdateMemberRecordIO db user@User {userId} gInfo m = do else checkGroupMemberHasItems db user m' >>= \case Just _ -> updateGroupMemberStatus db userId m' GSMemRemoved - Nothing -> deleteGroupMember db user m' + Nothing + | useRelays' gInfo -> updateGroupMemberRemovedAt db user m' + | otherwise -> deleteGroupMember db user m' pure gInfo' -- Unlike deleteOrUpdateMemberRecord, skips checkGroupMemberHasItems. @@ -1859,7 +1861,9 @@ fullyDeleteMemberRecord user gInfo m = fullyDeleteMemberRecordIO :: DB.Connection -> User -> GroupInfo -> GroupMember -> IO GroupInfo fullyDeleteMemberRecordIO db user gInfo m = do (gInfo', m') <- deleteSupportChatIfExists db user gInfo m - deleteGroupMember db user m' + if useRelays' gInfo && not (isRelay m') + then updateGroupMemberRemovedAt db user m' + else deleteGroupMember db user m' pure gInfo' updateMemberRecordDeleted :: User -> GroupInfo -> GroupMember -> GroupMemberStatus -> CM GroupInfo diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index f0fea2dbf1..ca903e309d 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -3415,10 +3415,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = unknownRole <- unknownMemberRole gInfo let allowCreate = toCMEventTag chatMsgEvent /= XGrpLeave_ withStore (\db -> getCreateUnknownGMByMemberId db vr user gInfo memberId memberName unknownRole allowCreate) >>= \case - Just (author, unknown) -> do - when unknown $ toView $ CEvtUnknownMemberCreated user gInfo m author - void $ withVerifiedMsg gInfo scopeInfo author parsedMsg msgTs $ - (`processForwardedMsg` Just author) + Just (author, unknown) + | memberRemoved author -> + logInfo $ "x.grp.msg.forward: ignoring content from removed member, group " <> tshow (groupId' gInfo) <> ", member " <> safeDecodeUtf8 (strEncode memberId) <> ", event " <> tshow (toCMEventTag chatMsgEvent) + | otherwise -> do + when unknown $ toView $ CEvtUnknownMemberCreated user gInfo m author + void $ withVerifiedMsg gInfo scopeInfo author parsedMsg msgTs $ + (`processForwardedMsg` Just author) Nothing -> pure () FwdChannel -> processForwardedMsg (VMUnsigned chatMsg) Nothing where @@ -3732,7 +3735,7 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do senders <- withStore' $ \db -> fmap catMaybes . forM senderGMIds $ \sId -> fmap eitherToMaybe . runExceptT $ do - sender <- getGroupMemberById db vr user sId + sender <- getNonRemovedMemberById db vr user sId vec <- getMemberRelationsVector db sender pure (sender, vec) let missingSenders = length senderGMIds - length senders diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 22e9c89b79..37909a67b2 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -57,6 +57,7 @@ module Simplex.Chat.Store.Groups getMentionedGroupMember, getMentionedMemberByMemberId, getGroupMemberById, + getNonRemovedMemberById, getGroupMemberByIndex, getGroupMemberByMemberId, getCreateUnknownGMByMemberId, @@ -68,6 +69,7 @@ module Simplex.Chat.Store.Groups getGroupModerators, getGroupRelayMembers, getGroupMembersForExpiration, + getRemovedMembersToCleanup, deleteGroupChatItems, deleteGroupMembers, cleanupHostGroupLinkConn, @@ -116,6 +118,7 @@ module Simplex.Chat.Store.Groups updateRelayGroupKeys, updateGroupMemberStatus, updateGroupMemberStatusById, + updateGroupMemberRemovedAt, updateGroupMemberAccepted, deleteGroupMemberSupportChat, updateGroupMembersRequireAttention, @@ -1092,6 +1095,14 @@ getGroupMemberById db vr user@User {userId} groupMemberId = (groupMemberQuery <> " WHERE m.group_member_id = ? AND m.user_id = ?") (groupMemberId, userId) +getNonRemovedMemberById :: DB.Connection -> VersionRangeChat -> User -> GroupMemberId -> ExceptT StoreError IO GroupMember +getNonRemovedMemberById db vr user@User {userId} groupMemberId = + ExceptT . firstRow (toContactMember vr user) (SEGroupMemberNotFound groupMemberId) $ + DB.query + db + (groupMemberQuery <> " WHERE m.group_member_id = ? AND m.user_id = ? AND m.member_status NOT IN (?,?,?,?)") + (groupMemberId, userId, GSMemRejected, GSMemRemoved, GSMemLeft, GSMemGroupDeleted) + getGroupMemberByIndex :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> Int64 -> ExceptT StoreError IO GroupMember getGroupMemberByIndex db vr user GroupInfo {groupId} indexInGroup = ExceptT . firstRow (toContactMember vr user) (SEGroupMemberNotFoundByIndex indexInGroup) $ @@ -1209,6 +1220,14 @@ getGroupMembersForExpiration db vr user@User {userId, userContactId} GroupInfo { ) (groupId, userId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted, GSMemUnknown) +getRemovedMembersToCleanup :: DB.Connection -> VersionRangeChat -> User -> UTCTime -> IO [GroupMember] +getRemovedMembersToCleanup db vr user@User {userId} cutoffTs = + map (toContactMember vr user) + <$> DB.query + db + (groupMemberQuery <> " WHERE m.user_id = ? AND m.removed_at < ?") + (userId, cutoffTs) + getGroupInvitation :: DB.Connection -> VersionRangeChat -> User -> GroupId -> ExceptT StoreError IO ReceivedGroupInvitation getGroupInvitation db vr user groupId = getConnRec_ user >>= \case @@ -1955,6 +1974,18 @@ updateGroupMemberStatusById db userId groupMemberId memStatus = do |] (memStatus, currentTs, userId, groupMemberId) +updateGroupMemberRemovedAt :: DB.Connection -> User -> GroupMember -> IO () +updateGroupMemberRemovedAt db User {userId} GroupMember {groupMemberId} = do + currentTs <- getCurrentTime + DB.execute + db + [sql| + UPDATE group_members + SET member_status = ?, removed_at = ?, updated_at = ? + WHERE user_id = ? AND group_member_id = ? + |] + (GSMemRemoved, currentTs, currentTs, userId, groupMemberId) + updateGroupMemberAccepted :: DB.Connection -> User -> GroupMember -> GroupMemberStatus -> GroupMemberRole -> IO GroupMember updateGroupMemberAccepted db User {userId} m@GroupMember {groupMemberId} status role = do currentTs <- getCurrentTime diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 10368e2e30..9e6376fa2b 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -33,6 +33,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260507_relay_inactive_at import Simplex.Chat.Store.Postgres.Migrations.M20260514_relay_request_group_link_index import Simplex.Chat.Store.Postgres.Migrations.M20260515_delivery_job_senders import Simplex.Chat.Store.Postgres.Migrations.M20260520_client_services +import Simplex.Chat.Store.Postgres.Migrations.M20260525_member_removed_at import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -65,7 +66,8 @@ schemaMigrations = ("20260507_relay_inactive_at", m20260507_relay_inactive_at, Just down_m20260507_relay_inactive_at), ("20260514_relay_request_group_link_index", m20260514_relay_request_group_link_index, Just down_m20260514_relay_request_group_link_index), ("20260515_delivery_job_senders", m20260515_delivery_job_senders, Just down_m20260515_delivery_job_senders), - ("20260520_client_services", m20260520_client_services, Just down_m20260520_client_services) + ("20260520_client_services", m20260520_client_services, Just down_m20260520_client_services), + ("20260525_member_removed_at", m20260525_member_removed_at, Just down_m20260525_member_removed_at) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260525_member_removed_at.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260525_member_removed_at.hs new file mode 100644 index 0000000000..6099751702 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260525_member_removed_at.hs @@ -0,0 +1,19 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260525_member_removed_at where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260525_member_removed_at :: Text +m20260525_member_removed_at = + [r| +ALTER TABLE group_members ADD COLUMN removed_at TIMESTAMPTZ; +|] + +down_m20260525_member_removed_at :: Text +down_m20260525_member_removed_at = + [r| +ALTER TABLE group_members DROP COLUMN removed_at; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index 35388141bc..cd38c3f8c2 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -818,7 +818,8 @@ CREATE TABLE test_chat_schema.group_members ( index_in_group bigint DEFAULT 0 NOT NULL, member_relations_vector bytea, relay_link bytea, - member_pub_key bytea + member_pub_key bytea, + removed_at timestamp with time zone ); diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 2674705181..3430409fb8 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -156,6 +156,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260507_relay_inactive_at import Simplex.Chat.Store.SQLite.Migrations.M20260514_relay_request_group_link_index import Simplex.Chat.Store.SQLite.Migrations.M20260515_delivery_job_senders import Simplex.Chat.Store.SQLite.Migrations.M20260520_client_services +import Simplex.Chat.Store.SQLite.Migrations.M20260525_member_removed_at import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -311,7 +312,8 @@ schemaMigrations = ("20260507_relay_inactive_at", m20260507_relay_inactive_at, Just down_m20260507_relay_inactive_at), ("20260514_relay_request_group_link_index", m20260514_relay_request_group_link_index, Just down_m20260514_relay_request_group_link_index), ("20260515_delivery_job_senders", m20260515_delivery_job_senders, Just down_m20260515_delivery_job_senders), - ("20260520_client_services", m20260520_client_services, Just down_m20260520_client_services) + ("20260520_client_services", m20260520_client_services, Just down_m20260520_client_services), + ("20260525_member_removed_at", m20260525_member_removed_at, Just down_m20260525_member_removed_at) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260525_member_removed_at.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260525_member_removed_at.hs new file mode 100644 index 0000000000..704950b3fb --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260525_member_removed_at.hs @@ -0,0 +1,18 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260525_member_removed_at where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20260525_member_removed_at :: Query +m20260525_member_removed_at = + [sql| +ALTER TABLE group_members ADD COLUMN removed_at TEXT; +|] + +down_m20260525_member_removed_at :: Query +down_m20260525_member_removed_at = + [sql| +ALTER TABLE group_members DROP COLUMN removed_at; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 16f14a0484..508d91dff5 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -5040,6 +5040,14 @@ Query: Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE group_members + SET member_status = ?, removed_at = ?, updated_at = ? + WHERE user_id = ? AND group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE group_members SET member_status = ?, updated_at = ? @@ -5564,6 +5572,25 @@ SEARCH m USING INTEGER PRIMARY KEY (rowid=?) SEARCH p USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN +Query: + SELECT + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN connections c ON c.group_member_id = m.group_member_id + WHERE m.group_member_id = ? AND m.user_id = ? AND m.member_status NOT IN (?,?,?,?) +Plan: +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN + Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, @@ -5621,6 +5648,25 @@ SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) SEARCH p USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN +Query: + SELECT + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN connections c ON c.group_member_id = m.group_member_id + WHERE m.user_id = ? AND m.removed_at < ? +Plan: +SEARCH m USING INDEX idx_group_members_user_id (user_id=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN + Query: SELECT f.file_id, f.ci_file_status, f.file_path FROM chat_items i @@ -6898,6 +6944,10 @@ Query: SELECT member_status FROM group_members WHERE member_role = 'relay' Plan: SCAN group_members +Query: SELECT member_status, removed_at FROM group_members WHERE local_display_name = ? +Plan: +SCAN group_members + Query: SELECT member_xcontact_id, member_welcome_shared_msg_id FROM group_members WHERE user_id = ? AND group_id = ? AND group_member_id = ? Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index fb72eecfc0..c710b2c5cb 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -222,6 +222,7 @@ CREATE TABLE group_members( member_relations_vector BLOB, relay_link BLOB, member_pub_key BLOB, + removed_at TEXT, FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 67c32fcca8..7ab4234b86 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -20,7 +20,8 @@ import Control.Monad (forM_, void, when) import Data.Bifunctor (second) import Data.ByteString (ByteString) import qualified Data.ByteString.Char8 as B -import Data.Maybe (fromMaybe, listToMaybe, maybeToList) +import Data.Maybe (fromMaybe, isJust, listToMaybe, maybeToList) +import Data.Time (UTCTime) import Data.Int (Int64) import Data.List (intercalate, isInfixOf) import qualified Data.Map.Strict as M @@ -9533,6 +9534,9 @@ testChannelRemoveMemberSigned ps = dan #$> ("/_get chat #1 count=1", chat, [(0, "removed eve (signed)")]) eve #$> ("/_get chat #1 count=1", chat, [(0, "removed you (signed)")]) + -- eve had items (posted "hello from eve") -> kept as permanent GSMemRemoved records, removed_at NULL + checkRemovedMember alice "eve" False + -- after first removal alice ##> "/_info #1" alice <## "group ID: 1" @@ -9559,6 +9563,9 @@ testChannelRemoveMemberSigned ps = dan #$> ("/_get chat #1 count=1", chat, [(0, "removed you (signed)")]) eve #$> ("/_get chat #1 count=1", chat, [(0, "removed you (signed)")]) -- no new chat item + -- dan had no items -> kept as GSMemRemoved record with removed_at set (TTL cleanup path) + checkRemovedMember alice "dan" True + -- after second removal alice ##> "/_info #1" alice <## "group ID: 1" @@ -9568,6 +9575,14 @@ testChannelRemoveMemberSigned ps = cath <## "group ID: 1" cath <## "subscribers: 2" +-- asserts the member row is GSMemRemoved, with removed_at set (TTL tombstone) or NULL (permanent) +checkRemovedMember :: HasCallStack => TestCC -> String -> Bool -> Expectation +checkRemovedMember cc name removedAtSet = do + rows <- + withCCTransaction cc $ \db -> + DB.query db "SELECT member_status, removed_at FROM group_members WHERE local_display_name = ?" (Only name) :: IO [(String, Maybe UTCTime)] + map (\(status, removedAt) -> (status, isJust removedAt)) rows `shouldBe` [("removed", removedAtSet)] + testChannelDeleteGroupSigned :: HasCallStack => TestParams -> IO () testChannelDeleteGroupSigned ps = withNewTestChat ps "alice" aliceProfile $ \alice -> @@ -9721,9 +9736,8 @@ testChannelSubscriberLeave ps = dan <## "use /d #team to delete the group" bob <## "#team: dan left the group (signed)" alice <## "#team: dan left the group (signed)" - -- dan never sent before leaving, so dan's profile is disseminated to eve - -- via prepended XGrpMemNew before the forwarded XGrpLeave - eve <## "#team: bob introduced dan (Daniel) in the channel" + -- dan never sent before leaving and is now left, so the relay does not prepend + -- his XGrpMemNew; eve receives the bare XGrpLeave and does not create a record (allowCreate=False) alice #$> ("/_get chat #1 count=1", chat, [(0, "left (signed)")]) bob #$> ("/_get chat #1 count=1", chat, [(0, "left (signed)")]) dan #$> ("/_get chat #1 count=1", chat, [(1, "left (signed)")]) @@ -9742,9 +9756,9 @@ testChannelSubscriberLeave ps = checkMemberStatus alice "dan" (Just "left") checkMemberStatus bob "dan" (Just "left") checkMemberStatus dan "dan" (Just "left") - -- eve learned dan via prepended XGrpMemNew before the forwarded XGrpLeave, - -- so eve now has a record for dan with status "left" - checkMemberStatus eve "dan" (Just "left") + -- the relay did not announce left dan, and the bare XGrpLeave does not create a + -- record (allowCreate=False), so eve never learned dan + checkMemberStatus eve "dan" Nothing -- cath left earlier and was excluded from the forward; no record on cath checkMemberStatus cath "dan" Nothing where From 39717d3327fd754590d70f004f01a7ac48868d5b Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Fri, 29 May 2026 08:38:14 +0000 Subject: [PATCH 010/200] directory: add rtsopts (#7006) --- simplex-chat.cabal | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 29436e128e..20cad947bb 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -500,7 +500,7 @@ executable simplex-directory-service Directory.Store.Migrate Directory.Util Paths_simplex_chat - ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -threaded + ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -threaded -rtsopts build-depends: aeson ==2.2.* , async ==2.2.* From 16982b61114fe57af3713b4006e32d339d454e35 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sat, 30 May 2026 17:16:56 +0100 Subject: [PATCH 011/200] core: rename migrations (#7028) --- simplex-chat.cabal | 12 ++++++------ src/Simplex/Chat/Store/Postgres/Migrations.hs | 12 ++++++------ ..._senders.hs => M20260529_delivery_job_senders.hs} | 10 +++++----- ...ient_services.hs => M20260530_client_services.hs} | 10 +++++----- ..._removed_at.hs => M20260531_member_removed_at.hs} | 10 +++++----- .../Chat/Store/Postgres/Migrations/chat_schema.sql | 2 +- src/Simplex/Chat/Store/SQLite/Migrations.hs | 12 ++++++------ ..._senders.hs => M20260529_delivery_job_senders.hs} | 10 +++++----- ...ient_services.hs => M20260530_client_services.hs} | 10 +++++----- ..._removed_at.hs => M20260531_member_removed_at.hs} | 10 +++++----- tests/PostgresSchemaDump.hs | 2 +- tests/SchemaDump.hs | 2 +- 12 files changed, 51 insertions(+), 51 deletions(-) rename src/Simplex/Chat/Store/Postgres/Migrations/{M20260515_delivery_job_senders.hs => M20260529_delivery_job_senders.hs} (89%) rename src/Simplex/Chat/Store/Postgres/Migrations/{M20260520_client_services.hs => M20260530_client_services.hs} (57%) rename src/Simplex/Chat/Store/Postgres/Migrations/{M20260525_member_removed_at.hs => M20260531_member_removed_at.hs} (56%) rename src/Simplex/Chat/Store/SQLite/Migrations/{M20260515_delivery_job_senders.hs => M20260529_delivery_job_senders.hs} (89%) rename src/Simplex/Chat/Store/SQLite/Migrations/{M20260520_client_services.hs => M20260530_client_services.hs} (56%) rename src/Simplex/Chat/Store/SQLite/Migrations/{M20260525_member_removed_at.hs => M20260531_member_removed_at.hs} (54%) diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 20cad947bb..0116ddbc56 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -133,9 +133,9 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260429_relay_request_retries Simplex.Chat.Store.Postgres.Migrations.M20260507_relay_inactive_at Simplex.Chat.Store.Postgres.Migrations.M20260514_relay_request_group_link_index - Simplex.Chat.Store.Postgres.Migrations.M20260515_delivery_job_senders - Simplex.Chat.Store.Postgres.Migrations.M20260520_client_services - Simplex.Chat.Store.Postgres.Migrations.M20260525_member_removed_at + Simplex.Chat.Store.Postgres.Migrations.M20260529_delivery_job_senders + Simplex.Chat.Store.Postgres.Migrations.M20260530_client_services + Simplex.Chat.Store.Postgres.Migrations.M20260531_member_removed_at else exposed-modules: Simplex.Chat.Archive @@ -291,9 +291,9 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260429_relay_request_retries Simplex.Chat.Store.SQLite.Migrations.M20260507_relay_inactive_at Simplex.Chat.Store.SQLite.Migrations.M20260514_relay_request_group_link_index - Simplex.Chat.Store.SQLite.Migrations.M20260515_delivery_job_senders - Simplex.Chat.Store.SQLite.Migrations.M20260520_client_services - Simplex.Chat.Store.SQLite.Migrations.M20260525_member_removed_at + Simplex.Chat.Store.SQLite.Migrations.M20260529_delivery_job_senders + Simplex.Chat.Store.SQLite.Migrations.M20260530_client_services + Simplex.Chat.Store.SQLite.Migrations.M20260531_member_removed_at other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 9e6376fa2b..792865a3d7 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -31,9 +31,9 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260403_item_viewed import Simplex.Chat.Store.Postgres.Migrations.M20260429_relay_request_retries import Simplex.Chat.Store.Postgres.Migrations.M20260507_relay_inactive_at import Simplex.Chat.Store.Postgres.Migrations.M20260514_relay_request_group_link_index -import Simplex.Chat.Store.Postgres.Migrations.M20260515_delivery_job_senders -import Simplex.Chat.Store.Postgres.Migrations.M20260520_client_services -import Simplex.Chat.Store.Postgres.Migrations.M20260525_member_removed_at +import Simplex.Chat.Store.Postgres.Migrations.M20260529_delivery_job_senders +import Simplex.Chat.Store.Postgres.Migrations.M20260530_client_services +import Simplex.Chat.Store.Postgres.Migrations.M20260531_member_removed_at import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -65,9 +65,9 @@ schemaMigrations = ("20260429_relay_request_retries", m20260429_relay_request_retries, Just down_m20260429_relay_request_retries), ("20260507_relay_inactive_at", m20260507_relay_inactive_at, Just down_m20260507_relay_inactive_at), ("20260514_relay_request_group_link_index", m20260514_relay_request_group_link_index, Just down_m20260514_relay_request_group_link_index), - ("20260515_delivery_job_senders", m20260515_delivery_job_senders, Just down_m20260515_delivery_job_senders), - ("20260520_client_services", m20260520_client_services, Just down_m20260520_client_services), - ("20260525_member_removed_at", m20260525_member_removed_at, Just down_m20260525_member_removed_at) + ("20260529_delivery_job_senders", m20260529_delivery_job_senders, Just down_m20260529_delivery_job_senders), + ("20260530_client_services", m20260530_client_services, Just down_m20260530_client_services), + ("20260531_member_removed_at", m20260531_member_removed_at, Just down_m20260531_member_removed_at) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260515_delivery_job_senders.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260529_delivery_job_senders.hs similarity index 89% rename from src/Simplex/Chat/Store/Postgres/Migrations/M20260515_delivery_job_senders.hs rename to src/Simplex/Chat/Store/Postgres/Migrations/M20260529_delivery_job_senders.hs index d082587391..660e33561f 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20260515_delivery_job_senders.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260529_delivery_job_senders.hs @@ -5,13 +5,13 @@ -- NULL means [] (sender-less jobs, e.g. DJRelayRemoved). One column carries -- single- and multi-sender jobs uniformly; the per-job introduction bits live -- in group_members.member_relations_vector (MRIntroduced). -module Simplex.Chat.Store.Postgres.Migrations.M20260515_delivery_job_senders where +module Simplex.Chat.Store.Postgres.Migrations.M20260529_delivery_job_senders where import Data.Text (Text) import Text.RawString.QQ (r) -m20260515_delivery_job_senders :: Text -m20260515_delivery_job_senders = +m20260529_delivery_job_senders :: Text +m20260529_delivery_job_senders = [r| DROP INDEX idx_delivery_jobs_single_sender_group_member_id; @@ -24,8 +24,8 @@ WHERE single_sender_group_member_id IS NOT NULL; ALTER TABLE delivery_jobs DROP COLUMN single_sender_group_member_id; |] -down_m20260515_delivery_job_senders :: Text -down_m20260515_delivery_job_senders = +down_m20260529_delivery_job_senders :: Text +down_m20260529_delivery_job_senders = [r| -- Pre-up the FK was ON DELETE CASCADE, so orphan delivery_jobs cannot -- exist. After up the FK was dropped and orphans may accumulate. Drop diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260520_client_services.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260530_client_services.hs similarity index 57% rename from src/Simplex/Chat/Store/Postgres/Migrations/M20260520_client_services.hs rename to src/Simplex/Chat/Store/Postgres/Migrations/M20260530_client_services.hs index af567130eb..2a37f8f4e3 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20260520_client_services.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260530_client_services.hs @@ -1,19 +1,19 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Store.Postgres.Migrations.M20260520_client_services where +module Simplex.Chat.Store.Postgres.Migrations.M20260530_client_services where import Data.Text (Text) import Text.RawString.QQ (r) -m20260520_client_services :: Text -m20260520_client_services = +m20260530_client_services :: Text +m20260530_client_services = [r| ALTER TABLE users ADD COLUMN client_service SMALLINT NOT NULL DEFAULT 0; |] -down_m20260520_client_services :: Text -down_m20260520_client_services = +down_m20260530_client_services :: Text +down_m20260530_client_services = [r| ALTER TABLE users DROP COLUMN client_service; |] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260525_member_removed_at.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260531_member_removed_at.hs similarity index 56% rename from src/Simplex/Chat/Store/Postgres/Migrations/M20260525_member_removed_at.hs rename to src/Simplex/Chat/Store/Postgres/Migrations/M20260531_member_removed_at.hs index 6099751702..9dde712f0b 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20260525_member_removed_at.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260531_member_removed_at.hs @@ -1,19 +1,19 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Store.Postgres.Migrations.M20260525_member_removed_at where +module Simplex.Chat.Store.Postgres.Migrations.M20260531_member_removed_at where import Data.Text (Text) import Text.RawString.QQ (r) -m20260525_member_removed_at :: Text -m20260525_member_removed_at = +m20260531_member_removed_at :: Text +m20260531_member_removed_at = [r| ALTER TABLE group_members ADD COLUMN removed_at TIMESTAMPTZ; |] -down_m20260525_member_removed_at :: Text -down_m20260525_member_removed_at = +down_m20260531_member_removed_at :: Text +down_m20260531_member_removed_at = [r| ALTER TABLE group_members DROP COLUMN removed_at; |] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index cd38c3f8c2..286fc63a4c 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -963,7 +963,7 @@ CREATE TABLE test_chat_schema.groups ( public_member_count bigint, relay_request_retries bigint DEFAULT 0 NOT NULL, relay_request_delay bigint DEFAULT 0 NOT NULL, - relay_request_execute_at timestamp with time zone DEFAULT '1970-01-01 04:00:00+04'::timestamp with time zone NOT NULL, + relay_request_execute_at timestamp with time zone DEFAULT '1970-01-01 01:00:00+01'::timestamp with time zone NOT NULL, relay_inactive_at timestamp with time zone ); diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 3430409fb8..a3c8e8eea7 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -154,9 +154,9 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260403_item_viewed import Simplex.Chat.Store.SQLite.Migrations.M20260429_relay_request_retries import Simplex.Chat.Store.SQLite.Migrations.M20260507_relay_inactive_at import Simplex.Chat.Store.SQLite.Migrations.M20260514_relay_request_group_link_index -import Simplex.Chat.Store.SQLite.Migrations.M20260515_delivery_job_senders -import Simplex.Chat.Store.SQLite.Migrations.M20260520_client_services -import Simplex.Chat.Store.SQLite.Migrations.M20260525_member_removed_at +import Simplex.Chat.Store.SQLite.Migrations.M20260529_delivery_job_senders +import Simplex.Chat.Store.SQLite.Migrations.M20260530_client_services +import Simplex.Chat.Store.SQLite.Migrations.M20260531_member_removed_at import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -311,9 +311,9 @@ schemaMigrations = ("20260429_relay_request_retries", m20260429_relay_request_retries, Just down_m20260429_relay_request_retries), ("20260507_relay_inactive_at", m20260507_relay_inactive_at, Just down_m20260507_relay_inactive_at), ("20260514_relay_request_group_link_index", m20260514_relay_request_group_link_index, Just down_m20260514_relay_request_group_link_index), - ("20260515_delivery_job_senders", m20260515_delivery_job_senders, Just down_m20260515_delivery_job_senders), - ("20260520_client_services", m20260520_client_services, Just down_m20260520_client_services), - ("20260525_member_removed_at", m20260525_member_removed_at, Just down_m20260525_member_removed_at) + ("20260529_delivery_job_senders", m20260529_delivery_job_senders, Just down_m20260529_delivery_job_senders), + ("20260530_client_services", m20260530_client_services, Just down_m20260530_client_services), + ("20260531_member_removed_at", m20260531_member_removed_at, Just down_m20260531_member_removed_at) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260515_delivery_job_senders.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260529_delivery_job_senders.hs similarity index 89% rename from src/Simplex/Chat/Store/SQLite/Migrations/M20260515_delivery_job_senders.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20260529_delivery_job_senders.hs index 67a9ae31e8..9346b16128 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20260515_delivery_job_senders.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260529_delivery_job_senders.hs @@ -4,13 +4,13 @@ -- NULL means [] (sender-less jobs, e.g. DJRelayRemoved). One column carries -- single- and multi-sender jobs uniformly; the per-job introduction bits live -- in group_members.member_relations_vector (MRIntroduced). -module Simplex.Chat.Store.SQLite.Migrations.M20260515_delivery_job_senders where +module Simplex.Chat.Store.SQLite.Migrations.M20260529_delivery_job_senders where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) -m20260515_delivery_job_senders :: Query -m20260515_delivery_job_senders = +m20260529_delivery_job_senders :: Query +m20260529_delivery_job_senders = [sql| DROP INDEX idx_delivery_jobs_single_sender_group_member_id; @@ -23,8 +23,8 @@ WHERE single_sender_group_member_id IS NOT NULL; ALTER TABLE delivery_jobs DROP COLUMN single_sender_group_member_id; |] -down_m20260515_delivery_job_senders :: Query -down_m20260515_delivery_job_senders = +down_m20260529_delivery_job_senders :: Query +down_m20260529_delivery_job_senders = [sql| -- Pre-up the FK was ON DELETE CASCADE, so orphan delivery_jobs cannot -- exist. After up the FK was dropped and orphans may accumulate. Drop diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260520_client_services.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260530_client_services.hs similarity index 56% rename from src/Simplex/Chat/Store/SQLite/Migrations/M20260520_client_services.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20260530_client_services.hs index db141d6c03..d65f8f1c67 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20260520_client_services.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260530_client_services.hs @@ -1,18 +1,18 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Store.SQLite.Migrations.M20260520_client_services where +module Simplex.Chat.Store.SQLite.Migrations.M20260530_client_services where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) -m20260520_client_services :: Query -m20260520_client_services = +m20260530_client_services :: Query +m20260530_client_services = [sql| ALTER TABLE users ADD COLUMN client_service INTEGER NOT NULL DEFAULT 0; |] -down_m20260520_client_services :: Query -down_m20260520_client_services = +down_m20260530_client_services :: Query +down_m20260530_client_services = [sql| ALTER TABLE users DROP COLUMN client_service; |] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260525_member_removed_at.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260531_member_removed_at.hs similarity index 54% rename from src/Simplex/Chat/Store/SQLite/Migrations/M20260525_member_removed_at.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20260531_member_removed_at.hs index 704950b3fb..c63e6a37f9 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20260525_member_removed_at.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260531_member_removed_at.hs @@ -1,18 +1,18 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Store.SQLite.Migrations.M20260525_member_removed_at where +module Simplex.Chat.Store.SQLite.Migrations.M20260531_member_removed_at where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) -m20260525_member_removed_at :: Query -m20260525_member_removed_at = +m20260531_member_removed_at :: Query +m20260531_member_removed_at = [sql| ALTER TABLE group_members ADD COLUMN removed_at TEXT; |] -down_m20260525_member_removed_at :: Query -down_m20260525_member_removed_at = +down_m20260531_member_removed_at :: Query +down_m20260531_member_removed_at = [sql| ALTER TABLE group_members DROP COLUMN removed_at; |] diff --git a/tests/PostgresSchemaDump.hs b/tests/PostgresSchemaDump.hs index 197e9a9b89..0cd79ac513 100644 --- a/tests/PostgresSchemaDump.hs +++ b/tests/PostgresSchemaDump.hs @@ -80,5 +80,5 @@ skipComparisonForDownMigrations = -- group_member_intro_id field moves "20251128_migrate_member_relations", -- on down migration single_sender_group_member_id column is re-added at the end of the table - "20260515_delivery_job_senders" + "20260529_delivery_job_senders" ] diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index bc74f3ec33..2336fd56dd 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -145,7 +145,7 @@ skipComparisonForDownMigrations = -- on down migration single_sender_group_member_id column and its index -- are re-added at the end of the table / file (ALTER TABLE ADD COLUMN -- appends; CREATE INDEX appends). - "20260515_delivery_job_senders" + "20260529_delivery_job_senders" ] getSchema :: FilePath -> FilePath -> IO String From 6538b1527045716194f28bc15eda826b34a5fd3b Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sun, 31 May 2026 10:38:12 +0100 Subject: [PATCH 012/200] Revert "cli: fix redraw slowness (#6735)" This reverts commit b801d77c74f0b688000e0bc2194e835bd1d1965e. --- plans/cli-paste-slowness.md | 111 ----------------------------- src/Simplex/Chat/Terminal.hs | 10 +-- src/Simplex/Chat/Terminal/Input.hs | 14 ++-- 3 files changed, 8 insertions(+), 127 deletions(-) delete mode 100644 plans/cli-paste-slowness.md diff --git a/plans/cli-paste-slowness.md b/plans/cli-paste-slowness.md deleted file mode 100644 index 33255996be..0000000000 --- a/plans/cli-paste-slowness.md +++ /dev/null @@ -1,111 +0,0 @@ -# CLI terminal: event loss root cause analysis - -## Two distinct problems - -### Problem 1: Paste — TMVar capacity-1 bottleneck - -When copy-pasting text, the capacity-1 `TMVar` event channel between the keyboard input reader and the consumer loop throttles stdin reading to terminal redraw speed. - -**Root cause:** `events <- liftIO newEmptyTMVarIO` (`Platform.hsc:64`). Producer blocks on `putTMVar` after each event until consumer finishes redrawing. Consumer does a full terminal redraw per event (`Input.hs:161`). - -**Fix:** Replace `TMVar` with `TQueue` in `Platform.hsc` (6 line changes on POSIX, matching changes on Windows). Decouples producer from consumer — stdin is drained at full speed regardless of redraw speed. - -See previous analysis in git history for full details on this issue. - ---- - -### Problem 2: Heavy load — `outputQ` backpressure blocks `agentSubscriber` - -When the CLI is used as a heavy client (e.g., 1M connections), incoming chat events overwhelm the terminal display, causing cascading backpressure that blocks message acknowledgments and stalls the entire event processing pipeline. - -**This is the more severe problem.** It causes actual message loss at the protocol level, not just UI slowness. - -## Root cause: bounded `outputQ` + single-threaded `agentSubscriber` - -### The queue chain - -``` -Network (SMP/XFTP connections) - → agent internal queues - → subQ (TBQueue, capacity 1024) ← agent → chat boundary - → agentSubscriber (single-threaded) ← Commands.hs:4167 - → processAgentMessage ← Subscriber.hs:109 - → toView_ → writeTBQueue outputQ ← Controller.hs:1528, BLOCKS when full - → outputQ (TBQueue, capacity 1024) ← Chat.hs:152 - → runTerminalOutput ← Output.hs:146 - → printToTerminal (acquires termLock) ← Output.hs:298-303 - → terminal I/O (slow) -``` - -All queues are bounded `TBQueue` with default capacity 1024 (`Options.hs:226`). All writes use `writeTBQueue` which **blocks when full** — no events are dropped within the application, but backpressure cascades upstream. - -### The blocking chain under heavy load - -1. **Terminal I/O is the bottleneck.** `runTerminalOutput` (`Output.hs:146`) reads one event at a time from `outputQ`, acquires `termLock`, prints the message + redraws input, releases lock. Each iteration involves ANSI escape sequences, cursor manipulation, and `flush` syscalls. Throughput: ~hundreds of events/sec at best. - -2. **`outputQ` fills up.** With 1M connections generating events, the arrival rate far exceeds terminal display speed. The 1024-element TBQueue fills in seconds. - -3. **`toView_` blocks.** `Controller.hs:1528`: `writeTBQueue localQ (Nothing, event)` blocks when the queue is full. This call happens inside `processAgentMessage` → `processAgentMessageConn`, which runs within the `agentSubscriber` loop. - -4. **`agentSubscriber` blocks — head-of-line blocking.** `Commands.hs:4164-4167`: - ```haskell - agentSubscriber = do - q <- asks $ subQ . smpAgent - forever (atomically (readTBQueue q) >>= process) - ``` - Single-threaded. When `process` blocks on `toView_`, ALL events for ALL connections queue up behind it. Events for 1M other connections — including time-critical ACKs, keepalives, and handshakes — are stuck. - -5. **ACKs are never sent.** The message receive path (`Subscriber.hs:1537-1540`) calls `toView` BEFORE `ackMsg`: - ```haskell - -- Inside withAckMessage's action: - saveRcvChatItem' ... -- save to DB (succeeds) - toView $ CEvtNewChatItems ... -- BLOCKS here (outputQ full) - -- returns (withRcpt, shouldDelConns) - - -- After action returns (Subscriber.hs:1396-1397): - ackMsg msgMeta ... -- NEVER REACHED while toView blocks - ``` - The developers explicitly acknowledge this at `Subscriber.hs:122-123`: - > *without ACK the message delivery will be stuck* - -6. **`subQ` fills up.** The agent can't deliver events to `subQ` (also capacity 1024) because `agentSubscriber` isn't reading. Agent-level processing stalls. - -7. **Network-level failure.** Connections time out due to unprocessed keepalives and unacknowledged messages. Messages are lost at the protocol level. - -### `termLock` contention worsens the bottleneck - -`termLock` (`Output.hs:55`) is a `TMVar ()` mutex shared between: -- **Output thread** (`runTerminalOutput` → `printToTerminal`): acquires lock for each displayed message -- **Input thread** (`receiveFromTTY` → `updateInput`): acquires lock after each keystroke -- **Live prompt thread** (`blinkLivePrompt` → `updateInputView`): acquires lock every 1 second - -Under heavy load, the output thread dominates the lock (constant stream of messages). The input thread is starved — user keystrokes are delayed. This also slows the output thread itself (lock contention overhead). - -Note: `withTermLock` (`Output.hs:138-142`) is not exception-safe — no `bracket`/`finally`. If the action throws, the lock leaks and all threads deadlock. - -### Error reporting also blocks - -When `processAgentMessage` encounters an error, the error handler (`Commands.hs:4179`) calls `eToView'` → `toView_` → `writeTBQueue outputQ`. If `outputQ` is already full, even error reporting blocks. There is no escape path. - -## Impact summary - -| Load level | `outputQ` state | Effect | -|---|---|---| -| Light (few connections) | Nearly empty | No issues | -| Moderate (hundreds) | Partially filled | Occasional display lag | -| Heavy (thousands+) | Full (1024) | `toView_` blocks → `agentSubscriber` blocks → head-of-line blocking for ALL connections → ACKs delayed → message delivery stuck | -| Extreme (1M connections) | Permanently full | Cascading failure: all event processing stops, connections time out, messages lost at protocol level | - -## Fix - -The core fix: **`toView_` must never block the event processing pipeline on terminal display.** - -Options (in order of simplicity): - -1. **Make `outputQ` unbounded** — replace `TBQueue` with `TQueue` in `Chat.hs:152`. `writeTQueue` never blocks. Events accumulate in memory under heavy load but the event processing pipeline (including ACKs) is never stalled. Tradeoff: unbounded memory growth under sustained heavy load. - -2. **Non-blocking write with drop** — use `tryWriteTBQueue` in `toView_`. When `outputQ` is full, drop the display event (or a coalesced summary). ACKs and network processing proceed unblocked. Tradeoff: some events not displayed, but none lost at protocol level. - -3. **Separate ACK from display** — restructure `withAckMessage` to send ACK immediately after DB save, before `toView`. This decouples protocol correctness from display. `toView` can still block, but ACKs are always timely. Tradeoff: requires careful restructuring of the message processing path. - -4. **Increase queue capacity** — increase `tbqSize` from 1024 to a larger value. Delays the problem but doesn't fix it. Under sustained heavy load, any finite queue eventually fills. diff --git a/src/Simplex/Chat/Terminal.hs b/src/Simplex/Chat/Terminal.hs index 29299cfeae..21781229e4 100644 --- a/src/Simplex/Chat/Terminal.hs +++ b/src/Simplex/Chat/Terminal.hs @@ -8,7 +8,6 @@ module Simplex.Chat.Terminal where import Control.Monad -import Control.Monad.IO.Class (liftIO) import qualified Data.List.NonEmpty as L import Simplex.Chat (defaultChatConfig) import Simplex.Chat.Controller @@ -23,8 +22,6 @@ import Simplex.Chat.Terminal.Output import Simplex.FileTransfer.Client.Presets (defaultXFTPServers) import Simplex.Messaging.Client (NetworkConfig (..), SMPProxyFallback (..), SMPProxyMode (..), defaultNetworkConfig) import Simplex.Messaging.Util (raceAny_) -import System.Terminal (Key, Modifiers) -import UnliftIO.STM #if !defined(dbPostgres) import Control.Exception (handle, throwIO) import qualified Data.ByteArray as BA @@ -102,9 +99,4 @@ simplexChatTerminal cfg options t = run options #endif runChatTerminal :: ChatTerminal -> ChatController -> ChatOpts -> IO () -runChatTerminal ct cc opts = do - keyQ <- newTQueueIO - raceAny_ [runKeyReader ct keyQ, runTerminalInput ct cc keyQ, runTerminalOutput ct cc opts, runInputLoop ct cc] - -runKeyReader :: ChatTerminal -> TQueue (Key, Modifiers) -> IO () -runKeyReader ct q = withChatTerm ct $ forever $ getKey >>= liftIO . atomically . writeTQueue q +runChatTerminal ct cc opts = raceAny_ [runTerminalInput ct cc, runTerminalOutput ct cc opts, runInputLoop ct cc] diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index effcb7a71c..e0ee10aff9 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -152,14 +152,14 @@ sendUpdatedLiveMessage cc sentMsg LiveMessage {chatName, chatItemId} live = do let cmd = UpdateLiveMessage chatName chatItemId live $ T.pack sentMsg execChatCommand' cmd 0 `runReaderT` cc -runTerminalInput :: ChatTerminal -> ChatController -> TQueue (Key, Modifiers) -> IO () -runTerminalInput ct cc keyQ = do - updateInputView ct - receiveFromTTY keyQ cc ct +runTerminalInput :: ChatTerminal -> ChatController -> IO () +runTerminalInput ct cc = withChatTerm ct $ do + updateInput ct + receiveFromTTY cc ct -receiveFromTTY :: TQueue (Key, Modifiers) -> ChatController -> ChatTerminal -> IO () -receiveFromTTY keyQ cc@ChatController {inputQ, currentUser, currentRemoteHost, chatStore} ct@ChatTerminal {termSize, termState, liveMessageState, activeTo} = - forever $ atomically (readTQueue keyQ) >>= processKey >> updateInputView ct +receiveFromTTY :: forall m. MonadTerminal m => ChatController -> ChatTerminal -> m () +receiveFromTTY cc@ChatController {inputQ, currentUser, currentRemoteHost, chatStore} ct@ChatTerminal {termSize, termState, liveMessageState, activeTo} = + forever $ getKey >>= liftIO . processKey >> withTermLock ct (updateInput ct) where processKey :: (Key, Modifiers) -> IO () processKey key = case key of From e4ef8ef101e5113a702db1fc3fdca267c5c39c9c Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:20:29 +0000 Subject: [PATCH 013/200] simplex-chat-python: add BotCommand.params field (#7034) --- .../examples/squaring_bot.py | 24 +++++++- .../src/simplex_chat/bot.py | 52 ++++++++++++++++-- .../tests/test_bot_registration.py | 55 +++++++++++++++++++ 3 files changed, 126 insertions(+), 5 deletions(-) diff --git a/packages/simplex-chat-python/examples/squaring_bot.py b/packages/simplex-chat-python/examples/squaring_bot.py index 296b51347e..4d062ad718 100644 --- a/packages/simplex-chat-python/examples/squaring_bot.py +++ b/packages/simplex-chat-python/examples/squaring_bot.py @@ -26,7 +26,15 @@ bot = Bot( profile=BotProfile(display_name="Squaring bot"), db=SqliteDb(file_prefix="./squaring_bot"), welcome="Send me a number, I'll square it.", - commands=[BotCommand(keyword="help", label="Show help")], + commands=[ + # `params=None` (default): the client SENDS `/help` immediately + # when the user taps it in the commands menu. + BotCommand(keyword="help", label="Show help"), + # `params=""`: the client PASTES `/square ` + # into the input box and positions the cursor at the end. The + # user replaces `` with the actual number and sends. + BotCommand(keyword="square", label="Square a number", params=""), + ], ) NUMBER_RE = re.compile(r"^-?\d+(\.\d+)?$") @@ -48,5 +56,19 @@ async def help_cmd(msg: Message, _cmd: ParsedCommand) -> None: await msg.reply("Send a number, I'll square it.") +@bot.on_command("square") +async def square_cmd(msg: Message, cmd: ParsedCommand) -> None: + """Demonstrates the `params` flow: `cmd.args` is the trimmed text + AFTER `/square`. When the user tapped the menu entry above, the + client pasted `/square ` and the user replaced + `` with the actual value before sending.""" + try: + n = float(cmd.args) + except ValueError: + await msg.reply(f"Usage: /square (got {cmd.args!r})") + return + await msg.reply(f"{n} * {n} = {n * n}") + + if __name__ == "__main__": bot.run() diff --git a/packages/simplex-chat-python/src/simplex_chat/bot.py b/packages/simplex-chat-python/src/simplex_chat/bot.py index fb511e2818..4e385493b2 100644 --- a/packages/simplex-chat-python/src/simplex_chat/bot.py +++ b/packages/simplex-chat-python/src/simplex_chat/bot.py @@ -33,8 +33,38 @@ from .types import T @dataclass(slots=True) class BotCommand: + """One entry in the bot's advertised slash-command list (wire-side + `groupPreferences.commands` or profile `preferences.commands`). + + `keyword` and `label` are required: `keyword` is what the user + types after `/`; `label` is the human-readable description shown + next to the keyword in the SimpleX client's commands menu. + + `params` is an optional placeholder string that controls how the + client behaves when the user taps the command in the menu: + + * `params=None` (default) — the client SENDS `/` + immediately on tap; no input-box detour. Use this for + zero-argument commands (`/help`, `/ping`) where the action is + unambiguous. + + * `params=""` — the client PASTES `/ ` + into the input box and positions the cursor at the end. The + user edits the placeholder and sends. Use this for commands + that take a required argument (`/review `, + `/order `) so the user sees the expected shape + without having to remember it. + + Mirrors `CBCCommand` in the Haskell core + (`Simplex.Chat.Types.Preferences`) and the wire TypedDict + `ChatBotCommand_command`. Both SimpleX clients (Android/Kotlin + and iOS/Swift) implement the paste-vs-send branch on the + `params` field; see `CommandsMenuView.{kt,swift}` for the + reference UI behaviour. + """ keyword: str label: str + params: str | None = None class Bot(Client): @@ -145,10 +175,24 @@ class Bot(Client): "files": {"allow": "yes" if self._allow_files else "no"}, } if self._commands: - prefs["commands"] = [ - {"type": "command", "keyword": c.keyword, "label": c.label} - for c in self._commands - ] + cmds: list[T.ChatBotCommand] = [] + for c in self._commands: + entry: T.ChatBotCommand_command = { + "type": "command", + "keyword": c.keyword, + "label": c.label, + } + # `params` is `NotRequired[str]` on the wire; omit the + # key entirely when None so the Haskell parser sees + # `Nothing` rather than `Just ""`. The two have + # different client semantics: `Nothing` (`params=None`) + # triggers an immediate send on tap; `Just ""` would + # paste `/ ` (with a trailing space) into the + # input box, which is rarely what the operator wants. + if c.params is not None: + entry["params"] = c.params + cmds.append(entry) + prefs["commands"] = cmds p["preferences"] = prefs p["peerType"] = "bot" return p diff --git a/packages/simplex-chat-python/tests/test_bot_registration.py b/packages/simplex-chat-python/tests/test_bot_registration.py index f6f245c344..06837bdc07 100644 --- a/packages/simplex-chat-python/tests/test_bot_registration.py +++ b/packages/simplex-chat-python/tests/test_bot_registration.py @@ -86,8 +86,63 @@ def test_bot_profile_to_wire_with_commands(): ) cmds = bot._profile_to_wire().get("preferences", {}).get("commands") or [] assert len(cmds) == 2 + # `params` defaults to None and must be ABSENT from the wire dict + # (not present as `null`/`""`) so the Haskell parser sees + # `Nothing` and the SimpleX client sends the bare `/keyword` on + # tap rather than pasting a trailing-space placeholder. assert cmds[0] == {"type": "command", "keyword": "ping", "label": "Ping bot"} assert cmds[1] == {"type": "command", "keyword": "help", "label": "Show help"} + assert "params" not in cmds[0] + assert "params" not in cmds[1] + + +def test_bot_command_params_emits_on_wire(): + """When `BotCommand.params` is set, the wire dict carries it as + `params: `. The SimpleX client (verified against + `CommandsMenuView.kt:153-161` and `CommandsMenuView.swift:117-128` + in simplex-chat 6.5) then pastes `/ ` into the + input box on tap, positions the cursor at the end, and lets the + user edit before sending. Use this for commands that take a + required argument (`/review `).""" + bot = Bot( + profile=BotProfile(display_name="x"), + db=SqliteDb(file_prefix="/tmp/test"), + commands=[ + BotCommand(keyword="review", label="Review PR", params=""), + BotCommand(keyword="order", label="Place order", params=""), + ], + ) + cmds = bot._profile_to_wire().get("preferences", {}).get("commands") or [] + assert cmds[0] == { + "type": "command", + "keyword": "review", + "label": "Review PR", + "params": "", + } + assert cmds[1] == { + "type": "command", + "keyword": "order", + "label": "Place order", + "params": "", + } + + +def test_bot_command_distinguishes_none_from_empty_params(): + """`params=None` (immediate send) and `params=""` (paste with + trailing space) are semantically different on the client side. + Verify the wire form preserves the distinction: None → key + absent; empty string → key present with empty value.""" + bot = Bot( + profile=BotProfile(display_name="x"), + db=SqliteDb(file_prefix="/tmp/test"), + commands=[ + BotCommand(keyword="send", label="Send", params=None), + BotCommand(keyword="paste", label="Paste", params=""), + ], + ) + cmds = bot._profile_to_wire().get("preferences", {}).get("commands") or [] + assert "params" not in cmds[0] + assert cmds[1].get("params") == "" def test_client_profile_to_wire_has_no_bot_extras(): From cfd25b8c73432c398f8176ea7747c7c6409a42ba Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 1 Jun 2026 22:54:38 +0100 Subject: [PATCH 014/200] ios: update library --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 3d274bcc85..5bc1de821c 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -183,8 +183,8 @@ 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; }; 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; }; 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; }; - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.3.0-GwIsQfwCbNXBEEtsO80Uxy-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.3.0-GwIsQfwCbNXBEEtsO80Uxy-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.3.0-GwIsQfwCbNXBEEtsO80Uxy.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.3.0-GwIsQfwCbNXBEEtsO80Uxy.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-4Ybrr1jdwOoLdJnIO0aOG7-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-4Ybrr1jdwOoLdJnIO0aOG7-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-4Ybrr1jdwOoLdJnIO0aOG7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-4Ybrr1jdwOoLdJnIO0aOG7.a */; }; 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; }; 64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; }; 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; }; @@ -561,8 +561,8 @@ 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.3.0-GwIsQfwCbNXBEEtsO80Uxy-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.3.0-GwIsQfwCbNXBEEtsO80Uxy-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.3.0-GwIsQfwCbNXBEEtsO80Uxy.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.3.0-GwIsQfwCbNXBEEtsO80Uxy.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-4Ybrr1jdwOoLdJnIO0aOG7-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.4.1-4Ybrr1jdwOoLdJnIO0aOG7-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-4Ybrr1jdwOoLdJnIO0aOG7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.4.1-4Ybrr1jdwOoLdJnIO0aOG7.a"; sourceTree = ""; }; 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; }; 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = ""; }; @@ -731,8 +731,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.3.0-GwIsQfwCbNXBEEtsO80Uxy-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.3.0-GwIsQfwCbNXBEEtsO80Uxy.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-4Ybrr1jdwOoLdJnIO0aOG7-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-4Ybrr1jdwOoLdJnIO0aOG7.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -818,8 +818,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.3.0-GwIsQfwCbNXBEEtsO80Uxy-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.3.0-GwIsQfwCbNXBEEtsO80Uxy.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-4Ybrr1jdwOoLdJnIO0aOG7-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-4Ybrr1jdwOoLdJnIO0aOG7.a */, ); path = Libraries; sourceTree = ""; From 79ac515f6ce3d4065ad467426cab746516c6ef40 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 3 Jun 2026 21:04:28 +0000 Subject: [PATCH 015/200] core: fix delivery cursor not advancing to maximum group member id for posgtgres (#7043) --- src/Simplex/Chat/Store/Delivery.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Simplex/Chat/Store/Delivery.hs b/src/Simplex/Chat/Store/Delivery.hs index 75345e5e86..eeef3e0181 100644 --- a/src/Simplex/Chat/Store/Delivery.hs +++ b/src/Simplex/Chat/Store/Delivery.hs @@ -370,7 +370,7 @@ getGroupMembersByCursor db vr user@User {userContactId} GroupInfo {groupId} curs map (toContactMember vr user) <$> DB.query db - (groupMemberQuery <> " WHERE m.group_member_id IN ?") + (groupMemberQuery <> " WHERE m.group_member_id IN ? ORDER BY m.group_member_id ASC") (Only (In gmIds)) #else rights <$> mapM (runExceptT . getGroupMemberById db vr user) gmIds From bf905eb545c23aa0deb22d6d6d3ea05fd1d7d53a Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Sun, 7 Jun 2026 22:38:05 +0000 Subject: [PATCH 016/200] ui: settings navigation reorganization (#7005) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * android, desktop: settings navigation reorganization Restructure the root Settings screen into two top-level sections and fold previously-scattered items into three new sub-screens. Root: - Appearance, Your privacy, Chat data - Help & support, Migrate to another device, Advanced settings Your privacy (renamed from Privacy & security): keeps Device, link previews / remove tracking, auto-accept images, blur media, contact requests from groups. Adds a "More privacy" sub-screen. More privacy (new): show last messages, message draft, encrypt local files, protect IP address (with original dynamic footer preserved), notification preview mode (moved from Notifications), and delivery receipts. Help & support (new): merges Help and Support SimpleX Chat sections into Help / About (with App version) / Contact / Support the project. Advanced settings (new): Network & servers, Notifications (Android), Audio & video calls; then Developer tools, Restart and Shutdown (Android), App update channel (desktop). Notifications is hidden on desktop because the screen is empty after Show preview moves to More privacy. Chat data is the new top-level menu label for the existing Database passphrase & export screen. * android, desktop: trim settings reorg diff - Remove 5 dead strings from base/strings.xml (privacy_and_security, database_passphrase_and_export, settings_section_title_chat_database, settings_section_title_support, settings_section_title_app) — no Kotlin references after the reorg. - Drop the now single-variant CurrentPage enum in NotificationsSettingsView; replace with a direct callback. - Read userDisplayName locally in HelpAndSupportView instead of threading it through SettingsLayout/SettingsView/Preview. * android, desktop: remove orphan locale strings Companion to 442a368c9 which removed 5 dead keys from base/strings.xml. The :common:adjustFormatting task enforces that every locale string has a corresponding base entry, so these orphans broke the build. Removed across 35 locale files (154 lines). Keys removed: - privacy_and_security - database_passphrase_and_export - settings_section_title_chat_database - settings_section_title_support - settings_section_title_app * Revert "android, desktop: remove orphan locale strings" This reverts commit 0ad5fc9308a3599d15bf50ca1980002ae0e467b2. * android, desktop: restore base strings for removed keys Counterpart to revert of 0ad5fc930: re-add the 5 base entries that 442a368c9 had deleted so the locale files (restored by the prior revert) are no longer orphaned. Translation keys must not be removed once introduced — the values can change but the keys stay. Keys restored to base with their master English values: - database_passphrase_and_export - privacy_and_security - settings_section_title_chat_database - settings_section_title_support - settings_section_title_app * android, desktop: keep share-button helpers in SettingsView ContributeItem, RateAppItem, StarOnGithubItem were moved from SettingsView.kt to HelpAndSupportView.kt as part of the reorg. Move them back: just drop the `private` modifier (one-word edit per function) so HelpAndSupportView can call them in place. Saves ~60 lines of diff churn vs the move + matches the file's existing pattern where helpers like AppVersionItem, ChatPreferencesItem, ChatLockItem, etc. are all public top-level @Composable. * android, desktop: inline HelpAndSupportView into SettingsView.kt HelpAndSupportView is the only call site of SettingsView.kt's share-button helpers; placing it as a top-level @Composable in SettingsView.kt keeps the help/about/contact/support flow next to the other settings entry points and removes the need for a new file. Three imports (BuildConfigCommon, SimpleXInfo, WhatsNewView) that the reorg was deleting from SettingsView.kt stay in place. Saves ~35 lines of diff and one new file. * android, desktop: inline AdvancedSettingsView into SettingsView.kt Same treatment as HelpAndSupportView in the previous commit: AdvancedSettingsView is only reached from SettingsLayout, so the function and its expect declaration live as top-level @Composable in SettingsView.kt instead of a new file. NetworkAndServersView import that the reorg was deleting from SettingsView.kt stays. The .android.kt / .desktop.kt actuals are unchanged and keep implementing the (now relocated) expect. Saves ~15 lines and a file. * ios: settings navigation reorganization Mirror the multiplatform reorg on iOS: Root SettingsView: collapse the 5 sections into 2 unlabeled groups — {Appearance, Your privacy, Chat data} and {Help & support, Migrate to another device, Advanced settings}. "Privacy & security" becomes "Your privacy"; the database row label becomes "Chat data". PrivacySettings: keeps Device, link previews / remove tracking, auto-accept images, blur media, contact requests from groups. Adds a "More privacy" link. MorePrivacy (new, inlined in PrivacySettings.swift): show last messages, message draft, encrypt local files, protect IP address (with original dynamic footer preserved), notification preview mode (moved from NotificationsView), delivery receipts. Own state and private helpers for the moved set* functions. HelpAndSupportView (new, inlined in SettingsView.swift): merges Help and Support sections into Help / About (with App version) / Contact / Support the project. AdvancedSettingsView (new, inlined in SettingsView.swift): Network & servers, Notifications, Audio & video calls, Developer tools. iOS has no Restart/Shutdown (Android-only) or App update channel (desktop). NotificationsView: "Show preview" navigation removed — it now lives in MorePrivacy. notificationsIcon() promoted to a free function so AdvancedSettingsView can render the notifications status badge. * android, desktop: keep platform file names as SettingsView.{android,desktop}.kt Revert the file renames from {Advanced}SettingsView.{android,desktop}.kt. Function rename SettingsSectionApp → AdvancedSettingsAppSection stays inside each file; only the file path returns to its original name. No behavior change; diff stat now shows two in-place modifications instead of renames. * ios: keep PrivacySettings/SettingsView state in place, use inline destinations Restructure the iOS reorg to avoid moving state, helpers, and the alert enum out of PrivacySettings — and to avoid moving notificationsIcon out of SettingsView. The Help & Support, Advanced Settings, and More Privacy "screens" become private computed properties on their parent struct, so all @AppStorage, @State, set* helpers, and the PrivacySettingsViewAlert enum stay UNCHANGED from master. NavigationLink destinations reference the computed properties directly. Net iOS diff vs master: 220+/154- (was 361+/259-) — saves ~245 lines. * simplex settings * android, desktop: mirror iOS settings reorganization - inline Advanced settings section into the main settings list (Network & servers, Notifications, Audio & video calls, App version); remove the separate Advanced settings page - reorder first section: Appearance, Your privacy, Help & support, Chat data, Migrate; merge About SimpleX Chat into the Help section - move the developer/maintenance section under App version (VersionInfoView); load core version inside the view so it always opens (Developer tools and Shutdown stay reachable even if the version request fails) - keep "Developer tools" label (not renamed to "Developer") - replace the Restart row with Cancel/Restart/Shutdown options in the Shutdown dialog - split DatabaseView: "Chat data" page (messages TTL, Database passphrase & export, Files & media) and a sub-page with passphrase/export/import/delete and the Run chat toggle; rename title to "Chat data" - align delivery receipts alert wording with the renamed "Your privacy" settings * android, desktop: simplify settings reorg internals - VersionInfoView: drop the section/card wrapping, keep the original plain version-text layout; load core version in-view so the screen always opens - DatabaseView: make the "Database passphrase & export" sub-page a self-contained DatabaseManagementView that owns its own state, mirroring the DatabaseView/DatabaseLayout pattern instead of threading params through a modal * android, desktop: show App version screen as a card screen VersionInfoView now hosts a settings section (Developer tools / updates), so open it with cardScreen = true like the other settings screens — otherwise the section renders without the card box around it. * android, desktop: show "Rate the app" only on mobile The action opens a Play Store link, which does nothing on desktop (the market:// scheme has no handler and the web fallback never fires). Gate it to Android, like the Contribute item. * android, desktop: move Shutdown to settings above app version Move the Shutdown action out of the app version screen into the main advanced settings section, just above the app version row. It stays Android only (desktop is closed via the window) through an AppShutdownItem expect/actual. * android, desktop: show app version info in its own card Wrap the version info block on the app version screen in a section card, matching iOS and the rest of the card-screen settings. * fix background --------- Co-authored-by: Evgeny Poberezkin --- .../Shared/Views/Database/DatabaseView.swift | 149 ++++----- .../NetworkAndServers/NetworkAndServers.swift | 10 - .../UserSettings/NotificationsView.swift | 30 -- .../Views/UserSettings/PrivacySettings.swift | 196 +++++++----- .../SetDeliveryReceiptsView.swift | 2 +- .../Views/UserSettings/SettingsView.swift | 147 ++++----- .../Views/UserSettings/VersionView.swift | 28 +- .../ar.xcloc/Localized Contents/ar.xliff | 4 +- .../bg.xcloc/Localized Contents/bg.xliff | 4 +- .../bn.xcloc/Localized Contents/bn.xliff | 4 +- .../cs.xcloc/Localized Contents/cs.xliff | 4 +- .../de.xcloc/Localized Contents/de.xliff | 4 +- .../el.xcloc/Localized Contents/el.xliff | 4 +- .../en.xcloc/Localized Contents/en.xliff | 6 +- .../es.xcloc/Localized Contents/es.xliff | 4 +- .../fi.xcloc/Localized Contents/fi.xliff | 4 +- .../fr.xcloc/Localized Contents/fr.xliff | 4 +- .../he.xcloc/Localized Contents/he.xliff | 4 +- .../hr.xcloc/Localized Contents/hr.xliff | 4 +- .../hu.xcloc/Localized Contents/hu.xliff | 4 +- .../it.xcloc/Localized Contents/it.xliff | 4 +- .../ja.xcloc/Localized Contents/ja.xliff | 4 +- .../ko.xcloc/Localized Contents/ko.xliff | 4 +- .../lt.xcloc/Localized Contents/lt.xliff | 4 +- .../nl.xcloc/Localized Contents/nl.xliff | 4 +- .../pl.xcloc/Localized Contents/pl.xliff | 4 +- .../Localized Contents/pt-BR.xliff | 4 +- .../pt.xcloc/Localized Contents/pt.xliff | 4 +- .../ru.xcloc/Localized Contents/ru.xliff | 4 +- .../th.xcloc/Localized Contents/th.xliff | 4 +- .../tr.xcloc/Localized Contents/tr.xliff | 4 +- .../uk.xcloc/Localized Contents/uk.xliff | 4 +- .../Localized Contents/zh-Hans.xliff | 4 +- .../Localized Contents/zh-Hant.xliff | 4 +- apps/ios/SimpleX SE/ShareModel.swift | 2 +- .../SimpleX SE/de.lproj/Localizable.strings | 2 +- .../SimpleX SE/es.lproj/Localizable.strings | 2 +- .../SimpleX SE/fr.lproj/Localizable.strings | 2 +- .../SimpleX SE/hu.lproj/Localizable.strings | 2 +- .../SimpleX SE/it.lproj/Localizable.strings | 2 +- .../SimpleX SE/nl.lproj/Localizable.strings | 2 +- .../SimpleX SE/pl.lproj/Localizable.strings | 2 +- .../SimpleX SE/ru.lproj/Localizable.strings | 2 +- .../SimpleX SE/tr.lproj/Localizable.strings | 2 +- .../SimpleX SE/uk.lproj/Localizable.strings | 2 +- .../zh-Hans.lproj/Localizable.strings | 2 +- apps/ios/bg.lproj/Localizable.strings | 6 +- apps/ios/cs.lproj/Localizable.strings | 6 +- apps/ios/de.lproj/Localizable.strings | 6 +- apps/ios/es.lproj/Localizable.strings | 6 +- apps/ios/fi.lproj/Localizable.strings | 6 +- apps/ios/fr.lproj/Localizable.strings | 6 +- apps/ios/hu.lproj/Localizable.strings | 6 +- apps/ios/it.lproj/Localizable.strings | 6 +- apps/ios/ja.lproj/Localizable.strings | 6 +- apps/ios/nl.lproj/Localizable.strings | 6 +- apps/ios/pl.lproj/Localizable.strings | 6 +- apps/ios/product/README.md | 2 +- apps/ios/product/views/settings.md | 8 +- apps/ios/ru.lproj/Localizable.strings | 6 +- apps/ios/th.lproj/Localizable.strings | 6 +- apps/ios/tr.lproj/Localizable.strings | 6 +- apps/ios/uk.lproj/Localizable.strings | 6 +- apps/ios/zh-Hans.lproj/Localizable.strings | 6 +- .../usersettings/SettingsView.android.kt | 47 ++- .../common/views/database/DatabaseView.kt | 282 ++++++++++-------- .../usersettings/NotificationsSettingsView.kt | 31 +- .../views/usersettings/PrivacySettings.kt | 157 ++++++---- .../common/views/usersettings/SettingsView.kt | 91 +++--- .../views/usersettings/VersionInfoView.kt | 54 +++- .../commonMain/resources/MR/base/strings.xml | 9 +- .../usersettings/SettingsView.desktop.kt | 20 +- 72 files changed, 835 insertions(+), 658 deletions(-) diff --git a/apps/ios/Shared/Views/Database/DatabaseView.swift b/apps/ios/Shared/Views/Database/DatabaseView.swift index d5d70abaea..278893a669 100644 --- a/apps/ios/Shared/Views/Database/DatabaseView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseView.swift @@ -110,33 +110,88 @@ struct DatabaseView: View { } Section { - settingsRow( - stopped ? "exclamationmark.octagon.fill" : "play.fill", - color: stopped ? .red : .green - ) { - Toggle( - stopped ? "Chat is stopped" : "Chat is running", - isOn: $runChat - ) - .onChange(of: runChat) { _ in - if runChat { - DatabaseView.startChat($runChat, $progressIndicator) - } else if !stoppingChat { - stoppingChat = false - alert = .stopChat - } - } - } - } header: { - Text("Run chat") - .foregroundColor(theme.colors.secondary) - } footer: { - if case .documents = dbContainer { - Text("Database will be migrated when the app restarts") - .foregroundColor(theme.colors.secondary) - } + NavigationLink("Database passphrase & export", destination: databaseManagementView) } + Section { + Button(m.users.count > 1 ? "Delete files for all chat profiles" : "Delete all files", role: .destructive) { + alert = .deleteFilesAndMedia + } + .disabled(progressIndicator || appFilesCountAndSize?.0 == 0) + } header: { + Text("Files & media") + .foregroundColor(theme.colors.secondary) + } footer: { + if let (fileCount, size) = appFilesCountAndSize { + if fileCount == 0 { + Text("No received or sent files") + .foregroundColor(theme.colors.secondary) + } else { + Text("\(fileCount) file(s) with total size of \(ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .binary))") + .foregroundColor(theme.colors.secondary) + } + } + } + } + .onAppear { + runChat = m.chatRunning ?? true + appFilesCountAndSize = directoryFileCountAndSize(getAppFilesDirectory()) + currentChatItemTTL = chatItemTTL + } + .onChange(of: chatItemTTL) { ttl in + if ttl < currentChatItemTTL { + alert = .setChatItemTTL(ttl: ttl) + } else if ttl != currentChatItemTTL { + setCiTTL(ttl) + } + } + .alert(item: $alert) { item in databaseAlert(item) } + .fileImporter( + isPresented: $showFileImporter, + allowedContentTypes: [.zip], + allowsMultipleSelection: false + ) { result in + if case let .success(files) = result, let fileURL = files.first { + importedArchivePath = fileURL + alert = .importArchive + } + } + } + + private func runChatToggleView() -> some View { + Section { + let stopped = m.chatRunning == false + settingsRow( + stopped ? "exclamationmark.octagon.fill" : "play.fill", + color: stopped ? .red : .green + ) { + Toggle( + stopped ? "Chat is stopped" : "Chat is running", + isOn: $runChat + ) + .onChange(of: runChat) { _ in + if runChat { + DatabaseView.startChat($runChat, $progressIndicator) + } else if !stoppingChat { + stoppingChat = false + alert = .stopChat + } + } + } + } header: { + Text("Run chat") + .foregroundColor(theme.colors.secondary) + } footer: { + if case .documents = dbContainer { + Text("Database will be migrated when the app restarts") + .foregroundColor(theme.colors.secondary) + } + } + } + + private func databaseManagementView() -> some View { + List { + let stopped = m.chatRunning == false Section { let unencrypted = m.chatDbEncrypted == false let color: Color = unencrypted ? .orange : theme.colors.secondary @@ -194,49 +249,9 @@ struct DatabaseView: View { } } - Section { - Button(m.users.count > 1 ? "Delete files for all chat profiles" : "Delete all files", role: .destructive) { - alert = .deleteFilesAndMedia - } - .disabled(progressIndicator || appFilesCountAndSize?.0 == 0) - } header: { - Text("Files & media") - .foregroundColor(theme.colors.secondary) - } footer: { - if let (fileCount, size) = appFilesCountAndSize { - if fileCount == 0 { - Text("No received or sent files") - .foregroundColor(theme.colors.secondary) - } else { - Text("\(fileCount) file(s) with total size of \(ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .binary))") - .foregroundColor(theme.colors.secondary) - } - } - } - } - .onAppear { - runChat = m.chatRunning ?? true - appFilesCountAndSize = directoryFileCountAndSize(getAppFilesDirectory()) - currentChatItemTTL = chatItemTTL - } - .onChange(of: chatItemTTL) { ttl in - if ttl < currentChatItemTTL { - alert = .setChatItemTTL(ttl: ttl) - } else if ttl != currentChatItemTTL { - setCiTTL(ttl) - } - } - .alert(item: $alert) { item in databaseAlert(item) } - .fileImporter( - isPresented: $showFileImporter, - allowedContentTypes: [.zip], - allowsMultipleSelection: false - ) { result in - if case let .success(files) = result, let fileURL = files.first { - importedArchivePath = fileURL - alert = .importArchive - } + runChatToggleView() } + .modifier(ThemedBackground(grouped: true)) } private func databaseAlert(_ alertItem: DatabaseAlert) -> Alert { diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift index f10b945dc0..24cf088918 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift @@ -121,16 +121,6 @@ struct NetworkAndServers: View { } } - Section(header: Text("Calls").foregroundColor(theme.colors.secondary)) { - NavigationLink { - RTCServers() - .navigationTitle("Your ICE servers") - .modifier(ThemedBackground(grouped: true)) - } label: { - Text("WebRTC ICE servers") - } - } - Section(header: Text("Network connection").foregroundColor(theme.colors.secondary)) { HStack { Text(m.networkInfo.networkType.text) diff --git a/apps/ios/Shared/Views/UserSettings/NotificationsView.swift b/apps/ios/Shared/Views/UserSettings/NotificationsView.swift index c4d0588987..131eeecef7 100644 --- a/apps/ios/Shared/Views/UserSettings/NotificationsView.swift +++ b/apps/ios/Shared/Views/UserSettings/NotificationsView.swift @@ -63,36 +63,6 @@ struct NotificationsView: View { } } - NavigationLink { - List { - Section { - SelectionListView(list: NotificationPreviewMode.values, selection: $m.notificationPreview) { previewMode in - ntfPreviewModeGroupDefault.set(previewMode) - m.notificationPreview = previewMode - } - } footer: { - VStack(alignment: .leading, spacing: 1) { - Text("You can set lock screen notification preview via settings.") - .foregroundColor(theme.colors.secondary) - Button("Open Settings") { - DispatchQueue.main.async { - UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil) - } - } - } - } - } - .navigationTitle("Show preview") - .modifier(ThemedBackground(grouped: true)) - .navigationBarTitleDisplayMode(.inline) - } label: { - HStack { - Text("Show preview") - Spacer() - Text(m.notificationPreview.label) - } - } - if let server = m.notificationServer { smpServers("Push server", [server], theme.colors.secondary) testTokenButton(server) diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift index 3ae9f0eacd..ad6b2d4454 100644 --- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift +++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift @@ -81,30 +81,12 @@ struct PrivacySettings: View { settingsRow("link", color: theme.colors.secondary) { Toggle("Remove link tracking", isOn: $privacySanitizeLinks) } - settingsRow("message", color: theme.colors.secondary) { - Toggle("Show last messages", isOn: $showChatPreviews) - } - settingsRow("rectangle.and.pencil.and.ellipsis", color: theme.colors.secondary) { - Toggle("Message draft", isOn: $saveLastDraft) - } - .onChange(of: saveLastDraft) { saveDraft in - if !saveDraft { - m.draft = nil - m.draftChatId = nil - } - } } header: { Text("Chats") .foregroundColor(theme.colors.secondary) } Section { - settingsRow("lock.doc", color: theme.colors.secondary) { - Toggle("Encrypt local files", isOn: $encryptLocalFiles) - .onChange(of: encryptLocalFiles) { - setEncryptLocalFiles($0) - } - } settingsRow("photo", color: theme.colors.secondary) { Toggle("Auto-accept images", isOn: $autoAcceptImages) .onChange(of: autoAcceptImages) { @@ -126,20 +108,9 @@ struct PrivacySettings: View { } } } - settingsRow("network.badge.shield.half.filled", color: theme.colors.secondary) { - Toggle("Protect IP address", isOn: $askToApproveRelays) - } } header: { Text("Files") .foregroundColor(theme.colors.secondary) - } footer: { - if askToApproveRelays { - Text("The app will ask to confirm downloads from unknown file servers (except .onion).") - .foregroundColor(theme.colors.secondary) - } else { - Text("Without Tor or VPN, your IP address will be visible to file servers.") - .foregroundColor(theme.colors.secondary) - } } Section { @@ -155,45 +126,8 @@ struct PrivacySettings: View { } Section { - settingsRow("person", color: theme.colors.secondary) { - Toggle("Contacts", isOn: $contactReceipts) - } - settingsRow("person.2", color: theme.colors.secondary) { - Toggle("Small groups (max 20)", isOn: $groupReceipts) - } - } header: { - Text("Send delivery receipts to") - .foregroundColor(theme.colors.secondary) - } footer: { - VStack(alignment: .leading) { - Text("These settings are for your current profile **\(m.currentUser?.displayName ?? "")**.") - Text("They can be overridden in contact and group settings.") - } - .foregroundColor(theme.colors.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - } - .confirmationDialog(contactReceiptsDialogTitle, isPresented: $contactReceiptsDialogue, titleVisibility: .visible) { - Button(contactReceipts ? "Enable (keep overrides)" : "Disable (keep overrides)") { - setSendReceiptsContacts(contactReceipts, clearOverrides: false) - } - Button(contactReceipts ? "Enable for all" : "Disable for all", role: .destructive) { - setSendReceiptsContacts(contactReceipts, clearOverrides: true) - } - Button("Cancel", role: .cancel) { - contactReceiptsReset = true - contactReceipts.toggle() - } - } - .confirmationDialog(groupReceiptsDialogTitle, isPresented: $groupReceiptsDialogue, titleVisibility: .visible) { - Button(groupReceipts ? "Enable (keep overrides)" : "Disable (keep overrides)") { - setSendReceiptsGroups(groupReceipts, clearOverrides: false) - } - Button(groupReceipts ? "Enable for all" : "Disable for all", role: .destructive) { - setSendReceiptsGroups(groupReceipts, clearOverrides: true) - } - Button("Cancel", role: .cancel) { - groupReceiptsReset = true - groupReceipts.toggle() + NavigationLink(destination: morePrivacyView) { + settingsRow("ellipsis", color: theme.colors.secondary) { Text("More privacy") } } } } @@ -243,6 +177,132 @@ struct PrivacySettings: View { } } + @ViewBuilder + private func morePrivacyView() -> some View { + List { + Section { + settingsRow("message", color: theme.colors.secondary) { + Toggle("Show last messages", isOn: $showChatPreviews) + } + settingsRow("rectangle.and.pencil.and.ellipsis", color: theme.colors.secondary) { + Toggle("Message draft", isOn: $saveLastDraft) + } + .onChange(of: saveLastDraft) { saveDraft in + if !saveDraft { + m.draft = nil + m.draftChatId = nil + } + } + } header: { + Text("Chats") + .foregroundColor(theme.colors.secondary) + } + + Section { + settingsRow("lock.doc", color: theme.colors.secondary) { + Toggle("Encrypt local files", isOn: $encryptLocalFiles) + .onChange(of: encryptLocalFiles) { + setEncryptLocalFiles($0) + } + } + settingsRow("network.badge.shield.half.filled", color: theme.colors.secondary) { + Toggle("Protect IP address", isOn: $askToApproveRelays) + } + } header: { + Text("Files") + .foregroundColor(theme.colors.secondary) + } footer: { + if askToApproveRelays { + Text("The app will ask to confirm downloads from unknown file servers (except .onion).") + .foregroundColor(theme.colors.secondary) + } else { + Text("Without Tor or VPN, your IP address will be visible to file servers.") + .foregroundColor(theme.colors.secondary) + } + } + + Section { + NavigationLink { + List { + Section { + SelectionListView(list: NotificationPreviewMode.values, selection: $m.notificationPreview) { previewMode in + ntfPreviewModeGroupDefault.set(previewMode) + m.notificationPreview = previewMode + } + } footer: { + VStack(alignment: .leading, spacing: 1) { + Text("You can set lock screen notification preview via settings.") + .foregroundColor(theme.colors.secondary) + Button("Open Settings") { + DispatchQueue.main.async { + UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil) + } + } + } + } + } + .navigationTitle("Show preview") + .modifier(ThemedBackground(grouped: true)) + .navigationBarTitleDisplayMode(.inline) + } label: { + HStack { + Text("Show preview") + Spacer() + Text(m.notificationPreview.label) + } + } + } header: { + Text("Notifications") + .foregroundColor(theme.colors.secondary) + } + + Section { + settingsRow("person", color: theme.colors.secondary) { + Toggle("Contacts", isOn: $contactReceipts) + } + settingsRow("person.2", color: theme.colors.secondary) { + Toggle("Small groups (max 20)", isOn: $groupReceipts) + } + } header: { + Text("Send delivery receipts to") + .foregroundColor(theme.colors.secondary) + } footer: { + VStack(alignment: .leading) { + Text("These settings are for your current profile **\(m.currentUser?.displayName ?? "")**.") + Text("They can be overridden in contact and group settings.") + } + .foregroundColor(theme.colors.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + .confirmationDialog(contactReceiptsDialogTitle, isPresented: $contactReceiptsDialogue, titleVisibility: .visible) { + Button(contactReceipts ? "Enable (keep overrides)" : "Disable (keep overrides)") { + setSendReceiptsContacts(contactReceipts, clearOverrides: false) + } + Button(contactReceipts ? "Enable for all" : "Disable for all", role: .destructive) { + setSendReceiptsContacts(contactReceipts, clearOverrides: true) + } + Button("Cancel", role: .cancel) { + contactReceiptsReset = true + contactReceipts.toggle() + } + } + .confirmationDialog(groupReceiptsDialogTitle, isPresented: $groupReceiptsDialogue, titleVisibility: .visible) { + Button(groupReceipts ? "Enable (keep overrides)" : "Disable (keep overrides)") { + setSendReceiptsGroups(groupReceipts, clearOverrides: false) + } + Button(groupReceipts ? "Enable for all" : "Disable for all", role: .destructive) { + setSendReceiptsGroups(groupReceipts, clearOverrides: true) + } + Button("Cancel", role: .cancel) { + groupReceiptsReset = true + groupReceipts.toggle() + } + } + } + .navigationTitle("More privacy") + .modifier(ThemedBackground(grouped: true)) + } + private func setEncryptLocalFiles(_ enable: Bool) { do { try apiSetEncryptLocalFiles(enable) diff --git a/apps/ios/Shared/Views/UserSettings/SetDeliveryReceiptsView.swift b/apps/ios/Shared/Views/UserSettings/SetDeliveryReceiptsView.swift index e03dace43d..e46edbc5af 100644 --- a/apps/ios/Shared/Views/UserSettings/SetDeliveryReceiptsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SetDeliveryReceiptsView.swift @@ -69,7 +69,7 @@ struct SetDeliveryReceiptsView: View { Button { AlertManager.shared.showAlert(Alert( title: Text("Delivery receipts are disabled!"), - message: Text("You can enable them later via app Privacy & Security settings."), + message: Text("You can enable them later via app Your privacy settings."), primaryButton: .default(Text("Don't show again")) { m.setDeliveryReceipts = false privacyDeliveryReceiptsSet.set(true) diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index a903329454..93f32a53a6 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -290,47 +290,7 @@ struct SettingsView: View { func settingsView() -> some View { List { - let user = chatModel.currentUser - Section(header: Text("Settings").foregroundColor(theme.colors.secondary)) { - NavigationLink { - NotificationsView() - .navigationTitle("Notifications") - .modifier(ThemedBackground(grouped: true)) - } label: { - HStack { - notificationsIcon() - Text("Notifications") - } - } - .disabled(chatModel.chatRunning != true) - - NavigationLink { - NetworkAndServers() - .navigationTitle("Network & servers") - .modifier(ThemedBackground(grouped: true)) - } label: { - settingsRow("externaldrive.connected.to.line.below", color: theme.colors.secondary) { Text("Network & servers") } - } - .disabled(chatModel.chatRunning != true) - - NavigationLink { - CallSettings() - .navigationTitle("Your calls") - .modifier(ThemedBackground(grouped: true)) - } label: { - settingsRow("video", color: theme.colors.secondary) { Text("Audio & video calls") } - } - .disabled(chatModel.chatRunning != true) - - NavigationLink { - PrivacySettings() - .navigationTitle("Your privacy") - .modifier(ThemedBackground(grouped: true)) - } label: { - settingsRow("lock", color: theme.colors.secondary) { Text("Privacy & security") } - } - .disabled(chatModel.chatRunning != true) - + Section(header: Text(verbatim: "").foregroundColor(theme.colors.secondary)) { if UIApplication.shared.supportsAlternateIcons { NavigationLink { AppearanceSettings() @@ -341,10 +301,24 @@ struct SettingsView: View { } .disabled(chatModel.chatRunning != true) } - } - Section(header: Text("Chat database").foregroundColor(theme.colors.secondary)) { + NavigationLink { + PrivacySettings() + .navigationTitle("Your privacy") + .modifier(ThemedBackground(grouped: true)) + } label: { + settingsRow("lock", color: theme.colors.secondary) { Text("Your privacy") } + } + .disabled(chatModel.chatRunning != true) + + NavigationLink { + helpAndSupportView + } label: { + settingsRow("questionmark", color: theme.colors.secondary) { Text("Help & support") } + } + chatDatabaseRow() + NavigationLink { MigrateFromDevice(showProgressOnSettings: $showProgress) .toolbar { @@ -360,6 +334,58 @@ struct SettingsView: View { } } + Section(header: Text("Advanced settings").foregroundColor(theme.colors.secondary)) { + NavigationLink { + NetworkAndServers() + .navigationTitle("Network & servers") + .modifier(ThemedBackground(grouped: true)) + } label: { + settingsRow("externaldrive.connected.to.line.below", color: theme.colors.secondary) { Text("Network & servers") } + } + .disabled(chatModel.chatRunning != true) + + NavigationLink { + NotificationsView() + .navigationTitle("Notifications") + .modifier(ThemedBackground(grouped: true)) + } label: { + HStack { + notificationsIcon() + Text("Notifications") + } + } + .disabled(chatModel.chatRunning != true) + + NavigationLink { + CallSettings() + .navigationTitle("Your calls") + .modifier(ThemedBackground(grouped: true)) + } label: { + settingsRow("video", color: theme.colors.secondary) { Text("Audio & video calls") } + } + .disabled(chatModel.chatRunning != true) + + NavigationLink { + VersionView() + .navigationBarTitle("App version") + .modifier(ThemedBackground()) + } label: { + Text(verbatim: "v\(appVersion ?? "?")") + } + } + } + .navigationTitle("Your settings") + .modifier(ThemedBackground(grouped: true)) + .onDisappear { + chatModel.showingTerminal = false + chatModel.terminalItems = [] + } + } + + @ViewBuilder + private var helpAndSupportView: some View { + List { + let user = chatModel.currentUser Section(header: Text("Help").foregroundColor(theme.colors.secondary)) { if let user = user { NavigationLink { @@ -378,6 +404,7 @@ struct SettingsView: View { } label: { settingsRow("plus", color: theme.colors.secondary) { Text("What's new") } } + NavigationLink { SimpleXInfo(onboarding: false) .navigationBarTitle("", displayMode: .inline) @@ -386,6 +413,9 @@ struct SettingsView: View { } label: { settingsRow("info", color: theme.colors.secondary) { Text("About SimpleX Chat") } } + } + + Section(header: Text("Contact").foregroundColor(theme.colors.secondary)) { settingsRow("number", color: theme.colors.secondary) { Button("Send questions and ideas") { dismiss() @@ -398,7 +428,7 @@ struct SettingsView: View { settingsRow("envelope", color: theme.colors.secondary) { Text("[Send us email](mailto:chat@simplex.chat)") } } - Section(header: Text("Support SimpleX Chat").foregroundColor(theme.colors.secondary)) { + Section(header: Text("Support the project").foregroundColor(theme.colors.secondary)) { settingsRow("keyboard", color: theme.colors.secondary) { ExternalLink("Contribute", destination: URL(string: "https://github.com/simplex-chat/simplex-chat#contribute")!) } @@ -421,42 +451,21 @@ struct SettingsView: View { } } } - - Section(header: Text("Develop").foregroundColor(theme.colors.secondary)) { - NavigationLink { - DeveloperView() - .navigationTitle("Developer tools") - .modifier(ThemedBackground(grouped: true)) - } label: { - settingsRow("chevron.left.forwardslash.chevron.right", color: theme.colors.secondary) { Text("Developer tools") } - } - NavigationLink { - VersionView() - .navigationBarTitle("App version") - .modifier(ThemedBackground()) - } label: { - Text("v\(appVersion ?? "?") (\(appBuild ?? "?"))") - } - } } - .navigationTitle("Your settings") + .navigationTitle("Help & support") .modifier(ThemedBackground(grouped: true)) - .onDisappear { - chatModel.showingTerminal = false - chatModel.terminalItems = [] - } } - + private func chatDatabaseRow() -> some View { NavigationLink { DatabaseView(dismissSettingsSheet: dismiss, chatItemTTL: chatModel.chatItemTTL) - .navigationTitle("Your chat database") + .navigationTitle("Chat data") .modifier(ThemedBackground(grouped: true)) } label: { let color: Color = chatModel.chatDbEncrypted == false ? .orange : theme.colors.secondary settingsRow("internaldrive", color: color) { HStack { - Text("Database passphrase & export") + Text("Chat data") Spacer() if chatModel.chatRunning == false { Image(systemName: "exclamationmark.octagon.fill").foregroundColor(.red) diff --git a/apps/ios/Shared/Views/UserSettings/VersionView.swift b/apps/ios/Shared/Views/UserSettings/VersionView.swift index 0fc2b4cb3e..e30c11699e 100644 --- a/apps/ios/Shared/Views/UserSettings/VersionView.swift +++ b/apps/ios/Shared/Views/UserSettings/VersionView.swift @@ -10,21 +10,33 @@ import SwiftUI import SimpleXChat struct VersionView: View { + @EnvironmentObject var theme: AppTheme @State var versionInfo: CoreVersionInfo? var body: some View { - VStack(alignment: .leading) { - Text("App version: v\(appVersion ?? "?")") - Text("App build: \(appBuild ?? "?")") - if let info = versionInfo { - Text("Core version: v\(info.version)") - if let v = try? AttributedString(markdown: "simplexmq: v\(info.simplexmqVersion) ([\(info.simplexmqCommit.prefix(7))](https://github.com/simplex-chat/simplexmq/commit/\(info.simplexmqCommit)))") { - Text(v) + List { + Section { + Text("App version: v\(appVersion ?? "?")") + Text("App build: \(appBuild ?? "?")") + if let info = versionInfo { + Text("Core version: v\(info.version)") + if let v = try? AttributedString(markdown: "simplexmq: v\(info.simplexmqVersion) ([\(info.simplexmqCommit.prefix(7))](https://github.com/simplex-chat/simplexmq/commit/\(info.simplexmqCommit)))") { + Text(v) + } + } + } + + Section { + NavigationLink { + DeveloperView() + .navigationTitle("Developer") + .modifier(ThemedBackground(grouped: true)) + } label: { + Text("Developer") } } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .padding() .onAppear { do { versionInfo = try apiGetVersion() diff --git a/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff b/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff index 427430b833..bd8d6a17ef 100644 --- a/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff +++ b/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff @@ -1157,8 +1157,8 @@ يطور No comment provided by engineer. - - Developer tools + + Developer أدوات المطور No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff index 364cee97e5..89d5014c95 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -3014,8 +3014,8 @@ alert button Developer options No comment provided by engineer. - - Developer tools + + Developer Инструменти за разработчици No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/bn.xcloc/Localized Contents/bn.xliff b/apps/ios/SimpleX Localizations/bn.xcloc/Localized Contents/bn.xliff index fbda1abd29..bdb0c0ce24 100644 --- a/apps/ios/SimpleX Localizations/bn.xcloc/Localized Contents/bn.xliff +++ b/apps/ios/SimpleX Localizations/bn.xcloc/Localized Contents/bn.xliff @@ -1223,8 +1223,8 @@ Develop No comment provided by engineer. - - Developer tools + + Developer No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff index 5ba29ec846..f7beecf4ba 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -2904,8 +2904,8 @@ alert button Developer options No comment provided by engineer. - - Developer tools + + Developer Nástroje pro vývojáře No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff index 797a489c92..822c13649e 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -3130,8 +3130,8 @@ alert button Optionen für Entwickler No comment provided by engineer. - - Developer tools + + Developer Entwicklertools No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/el.xcloc/Localized Contents/el.xliff b/apps/ios/SimpleX Localizations/el.xcloc/Localized Contents/el.xliff index 7a560bb41b..181f51eb99 100644 --- a/apps/ios/SimpleX Localizations/el.xcloc/Localized Contents/el.xliff +++ b/apps/ios/SimpleX Localizations/el.xcloc/Localized Contents/el.xliff @@ -1100,8 +1100,8 @@ Available in v5.1 Develop No comment provided by engineer. - - Developer tools + + Developer No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index c108dcc904..bd498d53e3 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -3140,9 +3140,9 @@ alert button Developer options No comment provided by engineer. - - Developer tools - Developer tools + + Developer + Developer No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff index d93e692a63..60de758c9c 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -3130,8 +3130,8 @@ alert button Opciones desarrollador No comment provided by engineer. - - Developer tools + + Developer Herramientas desarrollo No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff index 5656516b7d..c818fdc472 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -2791,8 +2791,8 @@ alert button Developer options No comment provided by engineer. - - Developer tools + + Developer Kehittäjätyökalut No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff index 3ea0859d76..4d1616df20 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -3039,8 +3039,8 @@ alert button Options pour les développeurs No comment provided by engineer. - - Developer tools + + Developer Outils du développeur No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/he.xcloc/Localized Contents/he.xliff b/apps/ios/SimpleX Localizations/he.xcloc/Localized Contents/he.xliff index f94d6cefd8..a22d30dd73 100644 --- a/apps/ios/SimpleX Localizations/he.xcloc/Localized Contents/he.xliff +++ b/apps/ios/SimpleX Localizations/he.xcloc/Localized Contents/he.xliff @@ -1356,8 +1356,8 @@ Available in v5.1 לְפַתֵחַ No comment provided by engineer. - - Developer tools + + Developer כלי מפתחים No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/hr.xcloc/Localized Contents/hr.xliff b/apps/ios/SimpleX Localizations/hr.xcloc/Localized Contents/hr.xliff index 2aa945f603..33fe9a0e9c 100644 --- a/apps/ios/SimpleX Localizations/hr.xcloc/Localized Contents/hr.xliff +++ b/apps/ios/SimpleX Localizations/hr.xcloc/Localized Contents/hr.xliff @@ -1012,8 +1012,8 @@ Develop No comment provided by engineer. - - Developer tools + + Developer No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff index 129436ecb0..11b447dc2b 100644 --- a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff +++ b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff @@ -3130,8 +3130,8 @@ alert button Fejlesztői beállítások No comment provided by engineer. - - Developer tools + + Developer Fejlesztői eszközök No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff index 469da88ce2..876cdb10bb 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -3130,8 +3130,8 @@ alert button Opzioni sviluppatore No comment provided by engineer. - - Developer tools + + Developer Strumenti di sviluppo No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff index 13396b13a4..ff0d085c08 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -2891,8 +2891,8 @@ alert button 開発者向けの設定 No comment provided by engineer. - - Developer tools + + Developer 開発ツール No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/ko.xcloc/Localized Contents/ko.xliff b/apps/ios/SimpleX Localizations/ko.xcloc/Localized Contents/ko.xliff index ca51a875c7..01b5e2b9a2 100644 --- a/apps/ios/SimpleX Localizations/ko.xcloc/Localized Contents/ko.xliff +++ b/apps/ios/SimpleX Localizations/ko.xcloc/Localized Contents/ko.xliff @@ -1141,8 +1141,8 @@ Develop No comment provided by engineer. - - Developer tools + + Developer No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/lt.xcloc/Localized Contents/lt.xliff b/apps/ios/SimpleX Localizations/lt.xcloc/Localized Contents/lt.xliff index 4b51d66a34..5aa2a98854 100644 --- a/apps/ios/SimpleX Localizations/lt.xcloc/Localized Contents/lt.xliff +++ b/apps/ios/SimpleX Localizations/lt.xcloc/Localized Contents/lt.xliff @@ -1005,8 +1005,8 @@ Develop No comment provided by engineer. - - Developer tools + + Developer No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff index 9f1818fba9..a7670a4475 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -3040,8 +3040,8 @@ alert button Ontwikkelaars opties No comment provided by engineer. - - Developer tools + + Developer Ontwikkel gereedschap No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff index 2644708927..a60b5891d4 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -3063,8 +3063,8 @@ alert button Opcje deweloperskie No comment provided by engineer. - - Developer tools + + Developer Narzędzia deweloperskie No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/pt-BR.xcloc/Localized Contents/pt-BR.xliff b/apps/ios/SimpleX Localizations/pt-BR.xcloc/Localized Contents/pt-BR.xliff index d9af0624bf..032a33ff62 100644 --- a/apps/ios/SimpleX Localizations/pt-BR.xcloc/Localized Contents/pt-BR.xliff +++ b/apps/ios/SimpleX Localizations/pt-BR.xcloc/Localized Contents/pt-BR.xliff @@ -1179,8 +1179,8 @@ Desenvolver No comment provided by engineer. - - Developer tools + + Developer Ferramentas de desenvolvimento No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/pt.xcloc/Localized Contents/pt.xliff b/apps/ios/SimpleX Localizations/pt.xcloc/Localized Contents/pt.xliff index e4fac55bcb..3905130e84 100644 --- a/apps/ios/SimpleX Localizations/pt.xcloc/Localized Contents/pt.xliff +++ b/apps/ios/SimpleX Localizations/pt.xcloc/Localized Contents/pt.xliff @@ -1203,8 +1203,8 @@ Available in v5.1 Develop No comment provided by engineer. - - Developer tools + + Developer No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index a3971c0325..22df6680f6 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -3130,8 +3130,8 @@ alert button Опции разработчика No comment provided by engineer. - - Developer tools + + Developer Инструменты разработчика No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff index cd2e30977d..11d6b28091 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -2779,8 +2779,8 @@ alert button Developer options No comment provided by engineer. - - Developer tools + + Developer เครื่องมือสำหรับนักพัฒนา No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff index 1189b53e3c..b673b609b9 100644 --- a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff +++ b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff @@ -3066,8 +3066,8 @@ alert button Geliştirici seçenekleri No comment provided by engineer. - - Developer tools + + Developer Geliştirici araçları No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff index 49f9e21eda..3c0c6ef8de 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -3050,8 +3050,8 @@ alert button Можливості для розробників No comment provided by engineer. - - Developer tools + + Developer Інструменти для розробників No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff index 8823f17204..9001375615 100644 --- a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff +++ b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff @@ -3060,8 +3060,8 @@ alert button 开发者选项 No comment provided by engineer. - - Developer tools + + Developer 开发者工具 No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/zh-Hant.xcloc/Localized Contents/zh-Hant.xliff b/apps/ios/SimpleX Localizations/zh-Hant.xcloc/Localized Contents/zh-Hant.xliff index 0e4e383b52..ebd6f4da71 100644 --- a/apps/ios/SimpleX Localizations/zh-Hant.xcloc/Localized Contents/zh-Hant.xliff +++ b/apps/ios/SimpleX Localizations/zh-Hant.xcloc/Localized Contents/zh-Hant.xliff @@ -1148,8 +1148,8 @@ 開發 No comment provided by engineer. - - Developer tools + + Developer 開發者工具 No comment provided by engineer. diff --git a/apps/ios/SimpleX SE/ShareModel.swift b/apps/ios/SimpleX SE/ShareModel.swift index 18f3e2c344..9790e5944f 100644 --- a/apps/ios/SimpleX SE/ShareModel.swift +++ b/apps/ios/SimpleX SE/ShareModel.swift @@ -75,7 +75,7 @@ class ShareModel: ObservableObject { func setup(context: NSExtensionContext) { if appLocalAuthEnabledGroupDefault.get() && !allowShareExtensionGroupDefault.get() { - errorAlert = ErrorAlert(title: "App is locked!", message: "You can allow sharing in Privacy & Security / SimpleX Lock settings.") + errorAlert = ErrorAlert(title: "App is locked!", message: "You can allow sharing in Your privacy / SimpleX Lock settings.") return } if let item = context.inputItems.first as? NSExtensionItem, diff --git a/apps/ios/SimpleX SE/de.lproj/Localizable.strings b/apps/ios/SimpleX SE/de.lproj/Localizable.strings index df368686e8..1d2e5a4b01 100644 --- a/apps/ios/SimpleX SE/de.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/de.lproj/Localizable.strings @@ -107,5 +107,5 @@ "Wrong database passphrase" = "Falsches Datenbank-Passwort"; /* No comment provided by engineer. */ -"You can allow sharing in Privacy & Security / SimpleX Lock settings." = "Sie können das Teilen in den Einstellungen zu Datenschutz & Sicherheit / SimpleX-Sperre erlauben."; +"You can allow sharing in Your privacy / SimpleX Lock settings." = "Sie können das Teilen in den Einstellungen zu Datenschutz & Sicherheit / SimpleX-Sperre erlauben."; diff --git a/apps/ios/SimpleX SE/es.lproj/Localizable.strings b/apps/ios/SimpleX SE/es.lproj/Localizable.strings index 4cc5029537..5bb42b4f0a 100644 --- a/apps/ios/SimpleX SE/es.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/es.lproj/Localizable.strings @@ -107,5 +107,5 @@ "Wrong database passphrase" = "Contraseña incorrecta de la base de datos"; /* No comment provided by engineer. */ -"You can allow sharing in Privacy & Security / SimpleX Lock settings." = "Puedes dar permiso para compartir en Privacidad y Seguridad / Bloque SimpleX."; +"You can allow sharing in Your privacy / SimpleX Lock settings." = "Puedes dar permiso para compartir en Privacidad y Seguridad / Bloque SimpleX."; diff --git a/apps/ios/SimpleX SE/fr.lproj/Localizable.strings b/apps/ios/SimpleX SE/fr.lproj/Localizable.strings index 46a458b471..6b492ee882 100644 --- a/apps/ios/SimpleX SE/fr.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/fr.lproj/Localizable.strings @@ -107,5 +107,5 @@ "Wrong database passphrase" = "Mauvaise phrase secrète pour la base de données"; /* No comment provided by engineer. */ -"You can allow sharing in Privacy & Security / SimpleX Lock settings." = "Vous pouvez autoriser le partage dans les paramètres Confidentialité et sécurité / SimpleX Lock."; +"You can allow sharing in Your privacy / SimpleX Lock settings." = "Vous pouvez autoriser le partage dans les paramètres Confidentialité et sécurité / SimpleX Lock."; diff --git a/apps/ios/SimpleX SE/hu.lproj/Localizable.strings b/apps/ios/SimpleX SE/hu.lproj/Localizable.strings index 3aad39c5d1..dae06b1d73 100644 --- a/apps/ios/SimpleX SE/hu.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/hu.lproj/Localizable.strings @@ -107,5 +107,5 @@ "Wrong database passphrase" = "Érvénytelen adatbázis-jelmondat"; /* No comment provided by engineer. */ -"You can allow sharing in Privacy & Security / SimpleX Lock settings." = "A megosztást az Adatvédelem és biztonság / SimpleX-zár menüben engedélyezheti."; +"You can allow sharing in Your privacy / SimpleX Lock settings." = "A megosztást az Adatvédelem és biztonság / SimpleX-zár menüben engedélyezheti."; diff --git a/apps/ios/SimpleX SE/it.lproj/Localizable.strings b/apps/ios/SimpleX SE/it.lproj/Localizable.strings index e3d34650a3..3b27092f24 100644 --- a/apps/ios/SimpleX SE/it.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/it.lproj/Localizable.strings @@ -107,5 +107,5 @@ "Wrong database passphrase" = "Password del database sbagliata"; /* No comment provided by engineer. */ -"You can allow sharing in Privacy & Security / SimpleX Lock settings." = "Puoi consentire la condivisione in Privacy e sicurezza / impostazioni di SimpleX Lock."; +"You can allow sharing in Your privacy / SimpleX Lock settings." = "Puoi consentire la condivisione in Privacy e sicurezza / impostazioni di SimpleX Lock."; diff --git a/apps/ios/SimpleX SE/nl.lproj/Localizable.strings b/apps/ios/SimpleX SE/nl.lproj/Localizable.strings index e5d2487b54..050cb1a735 100644 --- a/apps/ios/SimpleX SE/nl.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/nl.lproj/Localizable.strings @@ -107,5 +107,5 @@ "Wrong database passphrase" = "Verkeerde database wachtwoord"; /* No comment provided by engineer. */ -"You can allow sharing in Privacy & Security / SimpleX Lock settings." = "U kunt delen toestaan in de instellingen voor Privacy en beveiliging / SimpleX Lock."; +"You can allow sharing in Your privacy / SimpleX Lock settings." = "U kunt delen toestaan in de instellingen voor Privacy en beveiliging / SimpleX Lock."; diff --git a/apps/ios/SimpleX SE/pl.lproj/Localizable.strings b/apps/ios/SimpleX SE/pl.lproj/Localizable.strings index c563431c28..0755e1e374 100644 --- a/apps/ios/SimpleX SE/pl.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/pl.lproj/Localizable.strings @@ -107,5 +107,5 @@ "Wrong database passphrase" = "Nieprawidłowe hasło bazy danych"; /* No comment provided by engineer. */ -"You can allow sharing in Privacy & Security / SimpleX Lock settings." = "Możesz zezwolić na udostępnianie w ustawieniach Prywatność i bezpieczeństwo / Blokada SimpleX."; +"You can allow sharing in Your privacy / SimpleX Lock settings." = "Możesz zezwolić na udostępnianie w ustawieniach Prywatność i bezpieczeństwo / Blokada SimpleX."; diff --git a/apps/ios/SimpleX SE/ru.lproj/Localizable.strings b/apps/ios/SimpleX SE/ru.lproj/Localizable.strings index e4c8c000d4..90a5841738 100644 --- a/apps/ios/SimpleX SE/ru.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/ru.lproj/Localizable.strings @@ -107,5 +107,5 @@ "Wrong database passphrase" = "Неправильный пароль базы данных"; /* No comment provided by engineer. */ -"You can allow sharing in Privacy & Security / SimpleX Lock settings." = "Вы можете разрешить функцию Поделиться в настройках Конфиденциальности / Блокировка SimpleX."; +"You can allow sharing in Your privacy / SimpleX Lock settings." = "Вы можете разрешить функцию Поделиться в настройках Конфиденциальности / Блокировка SimpleX."; diff --git a/apps/ios/SimpleX SE/tr.lproj/Localizable.strings b/apps/ios/SimpleX SE/tr.lproj/Localizable.strings index baef71c127..69b122832d 100644 --- a/apps/ios/SimpleX SE/tr.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/tr.lproj/Localizable.strings @@ -107,5 +107,5 @@ "Wrong database passphrase" = "Yanlış veritabanı parolası"; /* No comment provided by engineer. */ -"You can allow sharing in Privacy & Security / SimpleX Lock settings." = "Gizlilik ve Güvenlik / SimpleX Lock ayarlarından paylaşıma izin verebilirsiniz."; +"You can allow sharing in Your privacy / SimpleX Lock settings." = "Gizlilik ve Güvenlik / SimpleX Lock ayarlarından paylaşıma izin verebilirsiniz."; diff --git a/apps/ios/SimpleX SE/uk.lproj/Localizable.strings b/apps/ios/SimpleX SE/uk.lproj/Localizable.strings index a6da81185e..7574470d01 100644 --- a/apps/ios/SimpleX SE/uk.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/uk.lproj/Localizable.strings @@ -107,5 +107,5 @@ "Wrong database passphrase" = "Неправильна ключова фраза до бази даних"; /* No comment provided by engineer. */ -"You can allow sharing in Privacy & Security / SimpleX Lock settings." = "Ви можете дозволити спільний доступ у налаштуваннях Конфіденційність і безпека / SimpleX Lock."; +"You can allow sharing in Your privacy / SimpleX Lock settings." = "Ви можете дозволити спільний доступ у налаштуваннях Конфіденційність і безпека / SimpleX Lock."; diff --git a/apps/ios/SimpleX SE/zh-Hans.lproj/Localizable.strings b/apps/ios/SimpleX SE/zh-Hans.lproj/Localizable.strings index 362e2edb74..fc046ab087 100644 --- a/apps/ios/SimpleX SE/zh-Hans.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/zh-Hans.lproj/Localizable.strings @@ -107,5 +107,5 @@ "Wrong database passphrase" = "数据库密码错误"; /* No comment provided by engineer. */ -"You can allow sharing in Privacy & Security / SimpleX Lock settings." = "您可以在 \"隐私与安全\"/\"SimpleX Lock \"设置中允许共享。"; +"You can allow sharing in Your privacy / SimpleX Lock settings." = "您可以在 \"隐私与安全\"/\"SimpleX Lock \"设置中允许共享。"; diff --git a/apps/ios/bg.lproj/Localizable.strings b/apps/ios/bg.lproj/Localizable.strings index ec869e05b4..68f2cfe502 100644 --- a/apps/ios/bg.lproj/Localizable.strings +++ b/apps/ios/bg.lproj/Localizable.strings @@ -1659,7 +1659,7 @@ alert button */ "Develop" = "Разработване"; /* No comment provided by engineer. */ -"Developer tools" = "Инструменти за разработчици"; +"Developer" = "Инструменти за разработчици"; /* No comment provided by engineer. */ "Device" = "Устройство"; @@ -3217,7 +3217,7 @@ alert button */ "Preview" = "Визуализация"; /* No comment provided by engineer. */ -"Privacy & security" = "Поверителност и сигурност"; +"Your privacy" = "Поверителност и сигурност"; /* No comment provided by engineer. */ "Private filenames" = "Поверителни имена на файлове"; @@ -4452,7 +4452,7 @@ server test failure */ "You can enable later via Settings" = "Можете да активирате по-късно през Настройки"; /* No comment provided by engineer. */ -"You can enable them later via app Privacy & Security settings." = "Можете да ги активирате по-късно през настройките за \"Поверителност и сигурност\" на приложението."; +"You can enable them later via app Your privacy settings." = "Можете да ги активирате по-късно през настройките за \"Поверителност и сигурност\" на приложението."; /* No comment provided by engineer. */ "You can give another try." = "Можете да опитате още веднъж."; diff --git a/apps/ios/cs.lproj/Localizable.strings b/apps/ios/cs.lproj/Localizable.strings index fc4b3f0fc6..0b74ef9504 100644 --- a/apps/ios/cs.lproj/Localizable.strings +++ b/apps/ios/cs.lproj/Localizable.strings @@ -1306,7 +1306,7 @@ alert button */ "Develop" = "Vyvinout"; /* No comment provided by engineer. */ -"Developer tools" = "Nástroje pro vývojáře"; +"Developer" = "Nástroje pro vývojáře"; /* No comment provided by engineer. */ "Device" = "Zařízení"; @@ -2578,7 +2578,7 @@ alert button */ "Preview" = "Náhled"; /* No comment provided by engineer. */ -"Privacy & security" = "Ochrana osobních údajů a zabezpečení"; +"Your privacy" = "Ochrana osobních údajů a zabezpečení"; /* No comment provided by engineer. */ "Private filenames" = "Soukromé názvy souborů"; @@ -3543,7 +3543,7 @@ server test failure */ "You can enable later via Settings" = "Můžete povolit později v Nastavení"; /* No comment provided by engineer. */ -"You can enable them later via app Privacy & Security settings." = "Můžete je povolit později v nastavení Soukromí & Bezpečnosti aplikace"; +"You can enable them later via app Your privacy settings." = "Můžete je povolit později v nastavení Soukromí & Bezpečnosti aplikace"; /* No comment provided by engineer. */ "You can hide or mute a user profile - swipe it to the right." = "Profil uživatele můžete skrýt nebo ztlumit - přejeďte prstem doprava."; diff --git a/apps/ios/de.lproj/Localizable.strings b/apps/ios/de.lproj/Localizable.strings index 2c4e37791b..80246b727b 100644 --- a/apps/ios/de.lproj/Localizable.strings +++ b/apps/ios/de.lproj/Localizable.strings @@ -2064,7 +2064,7 @@ alert button */ "Developer options" = "Optionen für Entwickler"; /* No comment provided by engineer. */ -"Developer tools" = "Entwicklertools"; +"Developer" = "Entwicklertools"; /* No comment provided by engineer. */ "Device" = "Gerät"; @@ -4533,7 +4533,7 @@ alert button */ "Previously connected servers" = "Bisher verbundene Server"; /* No comment provided by engineer. */ -"Privacy & security" = "Datenschutz & Sicherheit"; +"Your privacy" = "Datenschutz & Sicherheit"; /* No comment provided by engineer. */ "Privacy for your customers." = "Schutz der Privatsphäre Ihrer Kunden."; @@ -6759,7 +6759,7 @@ server test failure */ "You can enable later via Settings" = "Sie können diese später in den Einstellungen aktivieren"; /* No comment provided by engineer. */ -"You can enable them later via app Privacy & Security settings." = "Sie können diese später in den Datenschutz & Sicherheits-Einstellungen der App aktivieren."; +"You can enable them later via app Your privacy settings." = "Sie können diese später in den Datenschutz & Sicherheits-Einstellungen der App aktivieren."; /* No comment provided by engineer. */ "You can give another try." = "Sie können es nochmal probieren."; diff --git a/apps/ios/es.lproj/Localizable.strings b/apps/ios/es.lproj/Localizable.strings index cf03ae6dbf..e0611c0afc 100644 --- a/apps/ios/es.lproj/Localizable.strings +++ b/apps/ios/es.lproj/Localizable.strings @@ -2064,7 +2064,7 @@ alert button */ "Developer options" = "Opciones desarrollador"; /* No comment provided by engineer. */ -"Developer tools" = "Herramientas desarrollo"; +"Developer" = "Herramientas desarrollo"; /* No comment provided by engineer. */ "Device" = "Dispositivo"; @@ -4533,7 +4533,7 @@ alert button */ "Previously connected servers" = "Servidores conectados previamente"; /* No comment provided by engineer. */ -"Privacy & security" = "Seguridad y Privacidad"; +"Your privacy" = "Seguridad y Privacidad"; /* No comment provided by engineer. */ "Privacy for your customers." = "Privacidad para tus clientes."; @@ -6759,7 +6759,7 @@ server test failure */ "You can enable later via Settings" = "Puedes activar más tarde en Configuración"; /* No comment provided by engineer. */ -"You can enable them later via app Privacy & Security settings." = "Puedes activarlos más tarde en la configuración de Privacidad y Seguridad."; +"You can enable them later via app Your privacy settings." = "Puedes activarlos más tarde en la configuración de Privacidad y Seguridad."; /* No comment provided by engineer. */ "You can give another try." = "Puedes intentarlo de nuevo."; diff --git a/apps/ios/fi.lproj/Localizable.strings b/apps/ios/fi.lproj/Localizable.strings index 3b1bd6523c..36b516374e 100644 --- a/apps/ios/fi.lproj/Localizable.strings +++ b/apps/ios/fi.lproj/Localizable.strings @@ -982,7 +982,7 @@ alert button */ "Develop" = "Kehitä"; /* No comment provided by engineer. */ -"Developer tools" = "Kehittäjätyökalut"; +"Developer" = "Kehittäjätyökalut"; /* No comment provided by engineer. */ "Device" = "Laite"; @@ -2232,7 +2232,7 @@ new chat action */ "Preview" = "Esikatselu"; /* No comment provided by engineer. */ -"Privacy & security" = "Yksityisyys ja turvallisuus"; +"Your privacy" = "Yksityisyys ja turvallisuus"; /* No comment provided by engineer. */ "Private filenames" = "Yksityiset tiedostonimet"; @@ -3179,7 +3179,7 @@ server test failure */ "You can enable later via Settings" = "Voit ottaa käyttöön myöhemmin asetusten kautta"; /* No comment provided by engineer. */ -"You can enable them later via app Privacy & Security settings." = "Voit ottaa ne käyttöön myöhemmin sovelluksen Yksityisyys & Turvallisuus -asetuksista."; +"You can enable them later via app Your privacy settings." = "Voit ottaa ne käyttöön myöhemmin sovelluksen Yksityisyys & Turvallisuus -asetuksista."; /* No comment provided by engineer. */ "You can hide or mute a user profile - swipe it to the right." = "Voit piilottaa tai mykistää käyttäjäprofiilin pyyhkäisemällä sitä oikealle."; diff --git a/apps/ios/fr.lproj/Localizable.strings b/apps/ios/fr.lproj/Localizable.strings index 91cd6f3078..98cdb4aec8 100644 --- a/apps/ios/fr.lproj/Localizable.strings +++ b/apps/ios/fr.lproj/Localizable.strings @@ -1745,7 +1745,7 @@ alert button */ "Developer options" = "Options pour les développeurs"; /* No comment provided by engineer. */ -"Developer tools" = "Outils du développeur"; +"Developer" = "Outils du développeur"; /* No comment provided by engineer. */ "Device" = "Appareil"; @@ -3742,7 +3742,7 @@ alert button */ "Previously connected servers" = "Serveurs précédemment connectés"; /* No comment provided by engineer. */ -"Privacy & security" = "Vie privée et sécurité"; +"Your privacy" = "Vie privée et sécurité"; /* No comment provided by engineer. */ "Privacy for your customers." = "Respect de la vie privée de vos clients."; @@ -5448,7 +5448,7 @@ server test failure */ "You can enable later via Settings" = "Vous pouvez l'activer ultérieurement via Paramètres"; /* No comment provided by engineer. */ -"You can enable them later via app Privacy & Security settings." = "Vous pouvez les activer ultérieurement via les paramètres de Confidentialité et Sécurité de l'application."; +"You can enable them later via app Your privacy settings." = "Vous pouvez les activer ultérieurement via les paramètres de Confidentialité et Sécurité de l'application."; /* No comment provided by engineer. */ "You can give another try." = "Vous pouvez faire un nouvel essai."; diff --git a/apps/ios/hu.lproj/Localizable.strings b/apps/ios/hu.lproj/Localizable.strings index 029fb9edd5..362d9157fd 100644 --- a/apps/ios/hu.lproj/Localizable.strings +++ b/apps/ios/hu.lproj/Localizable.strings @@ -2064,7 +2064,7 @@ alert button */ "Developer options" = "Fejlesztői beállítások"; /* No comment provided by engineer. */ -"Developer tools" = "Fejlesztői eszközök"; +"Developer" = "Fejlesztői eszközök"; /* No comment provided by engineer. */ "Device" = "Eszköz"; @@ -4533,7 +4533,7 @@ alert button */ "Previously connected servers" = "Korábban kapcsolódott kiszolgálók"; /* No comment provided by engineer. */ -"Privacy & security" = "Adatvédelem és biztonság"; +"Your privacy" = "Adatvédelem és biztonság"; /* No comment provided by engineer. */ "Privacy for your customers." = "Saját ügyfeleinek adatvédelme."; @@ -6759,7 +6759,7 @@ server test failure */ "You can enable later via Settings" = "Később engedélyezheti a beállításokban"; /* No comment provided by engineer. */ -"You can enable them later via app Privacy & Security settings." = "Később engedélyezheti őket az „Adatvédelem és biztonság” menüben."; +"You can enable them later via app Your privacy settings." = "Később engedélyezheti őket az „Adatvédelem és biztonság” menüben."; /* No comment provided by engineer. */ "You can give another try." = "Megpróbálhatja még egyszer."; diff --git a/apps/ios/it.lproj/Localizable.strings b/apps/ios/it.lproj/Localizable.strings index c882eb662c..8d4fa3532b 100644 --- a/apps/ios/it.lproj/Localizable.strings +++ b/apps/ios/it.lproj/Localizable.strings @@ -2064,7 +2064,7 @@ alert button */ "Developer options" = "Opzioni sviluppatore"; /* No comment provided by engineer. */ -"Developer tools" = "Strumenti di sviluppo"; +"Developer" = "Strumenti di sviluppo"; /* No comment provided by engineer. */ "Device" = "Dispositivo"; @@ -4533,7 +4533,7 @@ alert button */ "Previously connected servers" = "Server precedentemente connessi"; /* No comment provided by engineer. */ -"Privacy & security" = "Privacy e sicurezza"; +"Your privacy" = "Privacy e sicurezza"; /* No comment provided by engineer. */ "Privacy for your customers." = "Privacy per i tuoi clienti."; @@ -6759,7 +6759,7 @@ server test failure */ "You can enable later via Settings" = "Puoi attivarle più tardi nelle impostazioni"; /* No comment provided by engineer. */ -"You can enable them later via app Privacy & Security settings." = "Puoi attivarle più tardi nelle impostazioni di privacy e sicurezza dell'app."; +"You can enable them later via app Your privacy settings." = "Puoi attivarle più tardi nelle impostazioni di privacy e sicurezza dell'app."; /* No comment provided by engineer. */ "You can give another try." = "Puoi fare un altro tentativo."; diff --git a/apps/ios/ja.lproj/Localizable.strings b/apps/ios/ja.lproj/Localizable.strings index 35d8732e3f..b88eb10414 100644 --- a/apps/ios/ja.lproj/Localizable.strings +++ b/apps/ios/ja.lproj/Localizable.strings @@ -1270,7 +1270,7 @@ alert button */ "Developer options" = "開発者向けの設定"; /* No comment provided by engineer. */ -"Developer tools" = "開発ツール"; +"Developer" = "開発ツール"; /* No comment provided by engineer. */ "Device" = "端末"; @@ -2533,7 +2533,7 @@ alert button */ "Preview" = "プレビュー"; /* No comment provided by engineer. */ -"Privacy & security" = "プライバシーとセキュリティ"; +"Your privacy" = "プライバシーとセキュリティ"; /* No comment provided by engineer. */ "Private filenames" = "プライベートなファイル名"; @@ -3459,7 +3459,7 @@ server test failure */ "You can enable later via Settings" = "あとで設定から有効にできます"; /* No comment provided by engineer. */ -"You can enable them later via app Privacy & Security settings." = "あとでアプリのプライバシーとセキュリティの設定から有効にすることができます。"; +"You can enable them later via app Your privacy settings." = "あとでアプリのプライバシーとセキュリティの設定から有効にすることができます。"; /* No comment provided by engineer. */ "You can hide or mute a user profile - swipe it to the right." = "ユーザープロファイルを右にスワイプすると、非表示またはミュートにすることができます。"; diff --git a/apps/ios/nl.lproj/Localizable.strings b/apps/ios/nl.lproj/Localizable.strings index 407665bbec..1c776a7bbe 100644 --- a/apps/ios/nl.lproj/Localizable.strings +++ b/apps/ios/nl.lproj/Localizable.strings @@ -1773,7 +1773,7 @@ alert button */ "Developer options" = "Ontwikkelaars opties"; /* No comment provided by engineer. */ -"Developer tools" = "Ontwikkel gereedschap"; +"Developer" = "Ontwikkel gereedschap"; /* No comment provided by engineer. */ "Device" = "Apparaat"; @@ -3929,7 +3929,7 @@ alert button */ "Previously connected servers" = "Eerder verbonden servers"; /* No comment provided by engineer. */ -"Privacy & security" = "Privacy en beveiliging"; +"Your privacy" = "Privacy en beveiliging"; /* No comment provided by engineer. */ "Privacy for your customers." = "Privacy voor uw klanten."; @@ -5777,7 +5777,7 @@ server test failure */ "You can enable later via Settings" = "U kunt later inschakelen via Instellingen"; /* No comment provided by engineer. */ -"You can enable them later via app Privacy & Security settings." = "U kunt ze later inschakelen via de privacy- en beveiligingsinstellingen van de app."; +"You can enable them later via app Your privacy settings." = "U kunt ze later inschakelen via de privacy- en beveiligingsinstellingen van de app."; /* No comment provided by engineer. */ "You can give another try." = "Je kunt het nog een keer proberen."; diff --git a/apps/ios/pl.lproj/Localizable.strings b/apps/ios/pl.lproj/Localizable.strings index 8905300160..9cc6eca252 100644 --- a/apps/ios/pl.lproj/Localizable.strings +++ b/apps/ios/pl.lproj/Localizable.strings @@ -1845,7 +1845,7 @@ alert button */ "Developer options" = "Opcje deweloperskie"; /* No comment provided by engineer. */ -"Developer tools" = "Narzędzia deweloperskie"; +"Developer" = "Narzędzia deweloperskie"; /* No comment provided by engineer. */ "Device" = "Urządzenie"; @@ -4131,7 +4131,7 @@ alert button */ "Previously connected servers" = "Wcześniej połączone serwery"; /* No comment provided by engineer. */ -"Privacy & security" = "Prywatność i bezpieczeństwo"; +"Your privacy" = "Prywatność i bezpieczeństwo"; /* No comment provided by engineer. */ "Privacy for your customers." = "Prywatność dla Twoich klientów."; @@ -6135,7 +6135,7 @@ server test failure */ "You can enable later via Settings" = "Możesz włączyć później w Ustawieniach"; /* No comment provided by engineer. */ -"You can enable them later via app Privacy & Security settings." = "Możesz je włączyć później w ustawieniach Prywatności i Bezpieczeństwa aplikacji."; +"You can enable them later via app Your privacy settings." = "Możesz je włączyć później w ustawieniach Prywatności i Bezpieczeństwa aplikacji."; /* No comment provided by engineer. */ "You can give another try." = "Możesz spróbować ponownie."; diff --git a/apps/ios/product/README.md b/apps/ios/product/README.md index 107c0e6569..fd25b09d01 100644 --- a/apps/ios/product/README.md +++ b/apps/ios/product/README.md @@ -101,7 +101,7 @@ End-to-end encrypted audio and video communication. | Call history | Call events displayed as chat items | `Shared/Views/Chat/ChatItem/CICallItemView.swift` | | Incoming call view | Dedicated UI for incoming call notifications | `Shared/Views/Call/IncomingCallView.swift` | -### 5. Privacy & Security +### 5. Your privacy Encryption, authentication, and privacy controls. diff --git a/apps/ios/product/views/settings.md b/apps/ios/product/views/settings.md index 3cc4da5d2b..7e7f653910 100644 --- a/apps/ios/product/views/settings.md +++ b/apps/ios/product/views/settings.md @@ -4,7 +4,7 @@ ## Purpose -Configure all aspects of app behavior including notifications, network/servers, privacy, appearance, database management, call settings, and developer tools. Accessed from the UserPicker sheet on the chat list. +Configure all aspects of app behavior including notifications, network/servers, privacy, appearance, database management, call settings, and Developer. Accessed from the UserPicker sheet on the chat list. ## Route / Navigation @@ -22,7 +22,7 @@ Configure all aspects of app behavior including notifications, network/servers, | Notifications | `bolt` (color varies by token status) | `NotificationsView` | Push notification mode and preview settings | | Network & servers | `externaldrive.connected.to.line.below` | `NetworkAndServers` | SMP/XFTP servers, proxy, .onion hosts, advanced network | | Audio & video calls | `video` | `CallSettings` | WebRTC relay policy, ICE servers, CallKit options | -| Privacy & security | `lock` | `PrivacySettings` | SimpleX Lock, screen protection, delivery receipts, auto-accept | +| Your privacy | `lock` | `PrivacySettings` | SimpleX Lock, screen protection, delivery receipts, auto-accept | | Appearance | `sun.max` | `AppearanceSettings` | Theme, language, wallpapers, chat bubbles, toolbar opacity | All rows disabled when `chatModel.chatRunning != true`. Appearance row only shown when `UIApplication.shared.supportsAlternateIcons`. @@ -77,7 +77,7 @@ Adding a relay: `NewChatRelayView` form with name, address, test, and enable tog Server validation (`validateServers_`) now returns both errors and warnings. -#### Privacy & Security (`PrivacySettings`) +#### Your privacy (`PrivacySettings`) | Setting | Description | |---|---| @@ -152,7 +152,7 @@ Database row shows exclamation octagon icon in red when `chatRunning == false`. | Row | Icon | Destination | Description | |---|---|---|---| -| Developer tools | `chevron.left.forwardslash.chevron.right` | `DeveloperView` | Chat console/terminal, log level, confirm DB upgrades | +| Developer | `chevron.left.forwardslash.chevron.right` | `DeveloperView` | Chat console/terminal, log level, confirm DB upgrades | | App version | (none) | `VersionView` | Shows "v{version} ({build})" | ## Loading / Error States diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index 9f79b5dea0..042359dd7e 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -2064,7 +2064,7 @@ alert button */ "Developer options" = "Опции разработчика"; /* No comment provided by engineer. */ -"Developer tools" = "Инструменты разработчика"; +"Developer" = "Разработчик"; /* No comment provided by engineer. */ "Device" = "Устройство"; @@ -4533,7 +4533,7 @@ alert button */ "Previously connected servers" = "Ранее подключенные серверы"; /* No comment provided by engineer. */ -"Privacy & security" = "Конфиденциальность"; +"Your privacy" = "Конфиденциальность"; /* No comment provided by engineer. */ "Privacy for your customers." = "Конфиденциальность для ваших покупателей."; @@ -6759,7 +6759,7 @@ server test failure */ "You can enable later via Settings" = "Вы можете включить их позже в Настройках"; /* No comment provided by engineer. */ -"You can enable them later via app Privacy & Security settings." = "Вы можете включить их позже в настройках Конфиденциальности."; +"You can enable them later via app Your privacy settings." = "Вы можете включить их позже в настройках Конфиденциальности."; /* No comment provided by engineer. */ "You can give another try." = "Вы можете попробовать ещё раз."; diff --git a/apps/ios/th.lproj/Localizable.strings b/apps/ios/th.lproj/Localizable.strings index cc3abea189..62bf4d3d59 100644 --- a/apps/ios/th.lproj/Localizable.strings +++ b/apps/ios/th.lproj/Localizable.strings @@ -946,7 +946,7 @@ alert button */ "Develop" = "พัฒนา"; /* No comment provided by engineer. */ -"Developer tools" = "เครื่องมือสำหรับนักพัฒนา"; +"Developer" = "เครื่องมือสำหรับนักพัฒนา"; /* No comment provided by engineer. */ "Device" = "อุปกรณ์"; @@ -2172,7 +2172,7 @@ new chat action */ "Preview" = "ดูตัวอย่าง"; /* No comment provided by engineer. */ -"Privacy & security" = "ความเป็นส่วนตัวและความปลอดภัย"; +"Your privacy" = "ความเป็นส่วนตัวและความปลอดภัย"; /* No comment provided by engineer. */ "Private filenames" = "ชื่อไฟล์ส่วนตัว"; @@ -3089,7 +3089,7 @@ server test failure */ "You can enable later via Settings" = "คุณสามารถเปิดใช้งานในภายหลังผ่านการตั้งค่า"; /* No comment provided by engineer. */ -"You can enable them later via app Privacy & Security settings." = "คุณสามารถเปิดใช้งานได้ในภายหลังผ่านการตั้งค่าความเป็นส่วนตัวและความปลอดภัยของแอป"; +"You can enable them later via app Your privacy settings." = "คุณสามารถเปิดใช้งานได้ในภายหลังผ่านการตั้งค่าความเป็นส่วนตัวและความปลอดภัยของแอป"; /* No comment provided by engineer. */ "You can hide or mute a user profile - swipe it to the right." = "คุณสามารถซ่อนหรือปิดเสียงโปรไฟล์ผู้ใช้ - ปัดไปทางขวา"; diff --git a/apps/ios/tr.lproj/Localizable.strings b/apps/ios/tr.lproj/Localizable.strings index e06989afee..8dfc23078c 100644 --- a/apps/ios/tr.lproj/Localizable.strings +++ b/apps/ios/tr.lproj/Localizable.strings @@ -1859,7 +1859,7 @@ alert button */ "Developer options" = "Geliştirici seçenekleri"; /* No comment provided by engineer. */ -"Developer tools" = "Geliştirici araçları"; +"Developer" = "Geliştirici araçları"; /* No comment provided by engineer. */ "Device" = "Cihaz"; @@ -4099,7 +4099,7 @@ alert button */ "Previously connected servers" = "Önceden bağlanılmış sunucular"; /* No comment provided by engineer. */ -"Privacy & security" = "Gizlilik & güvenlik"; +"Your privacy" = "Gizlilik & güvenlik"; /* No comment provided by engineer. */ "Privacy for your customers." = "Müşterileriniz için gizlilik."; @@ -6064,7 +6064,7 @@ server test failure */ "You can enable later via Settings" = "Daha sonra Ayarlardan etkinleştirebilirsin"; /* No comment provided by engineer. */ -"You can enable them later via app Privacy & Security settings." = "Daha sonra uygulamanın Gizlilik ve Güvenlik ayarlarından etkinleştirebilirsiniz."; +"You can enable them later via app Your privacy settings." = "Daha sonra uygulamanın Gizlilik ve Güvenlik ayarlarından etkinleştirebilirsiniz."; /* No comment provided by engineer. */ "You can give another try." = "Bir kez daha deneyebilirsiniz."; diff --git a/apps/ios/uk.lproj/Localizable.strings b/apps/ios/uk.lproj/Localizable.strings index 4a21eb4ae8..ff2f64e2ed 100644 --- a/apps/ios/uk.lproj/Localizable.strings +++ b/apps/ios/uk.lproj/Localizable.strings @@ -1806,7 +1806,7 @@ alert button */ "Developer options" = "Можливості для розробників"; /* No comment provided by engineer. */ -"Developer tools" = "Інструменти для розробників"; +"Developer" = "Інструменти для розробників"; /* No comment provided by engineer. */ "Device" = "Пристрій"; @@ -4019,7 +4019,7 @@ alert button */ "Previously connected servers" = "Раніше підключені сервери"; /* No comment provided by engineer. */ -"Privacy & security" = "Конфіденційність і безпека"; +"Your privacy" = "Конфіденційність і безпека"; /* No comment provided by engineer. */ "Privacy for your customers." = "Конфіденційність для ваших клієнтів."; @@ -5966,7 +5966,7 @@ server test failure */ "You can enable later via Settings" = "Ви можете увімкнути пізніше в Налаштуваннях"; /* No comment provided by engineer. */ -"You can enable them later via app Privacy & Security settings." = "Ви можете увімкнути їх пізніше в налаштуваннях конфіденційності та безпеки програми."; +"You can enable them later via app Your privacy settings." = "Ви можете увімкнути їх пізніше в налаштуваннях конфіденційності та безпеки програми."; /* No comment provided by engineer. */ "You can give another try." = "Ви можете спробувати ще раз."; diff --git a/apps/ios/zh-Hans.lproj/Localizable.strings b/apps/ios/zh-Hans.lproj/Localizable.strings index 13be5125ea..f3138230f9 100644 --- a/apps/ios/zh-Hans.lproj/Localizable.strings +++ b/apps/ios/zh-Hans.lproj/Localizable.strings @@ -1836,7 +1836,7 @@ alert button */ "Developer options" = "开发者选项"; /* No comment provided by engineer. */ -"Developer tools" = "开发者工具"; +"Developer" = "开发者工具"; /* No comment provided by engineer. */ "Device" = "设备"; @@ -4107,7 +4107,7 @@ alert button */ "Previously connected servers" = "以前连接的服务器"; /* No comment provided by engineer. */ -"Privacy & security" = "隐私和安全"; +"Your privacy" = "隐私和安全"; /* No comment provided by engineer. */ "Privacy for your customers." = "客户隐私。"; @@ -6093,7 +6093,7 @@ server test failure */ "You can enable later via Settings" = "您可以稍后在设置中启用它"; /* No comment provided by engineer. */ -"You can enable them later via app Privacy & Security settings." = "您可以稍后通过应用程序的 \"隐私与安全 \"设置启用它们。"; +"You can enable them later via app Your privacy settings." = "您可以稍后通过应用程序的 \"隐私与安全 \"设置启用它们。"; /* No comment provided by engineer. */ "You can give another try." = "你可以再试一次。"; diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.android.kt index 04b59732dd..5e9706d713 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.android.kt @@ -1,7 +1,15 @@ package chat.simplex.common.views.usersettings +import SectionItemView import SectionView +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign import chat.simplex.common.model.ChatModel import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.* @@ -11,19 +19,19 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @Composable -actual fun SettingsSectionApp( +actual fun AdvancedSettingsAppSection( showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), - showVersion: () -> Unit, - withAuth: (title: String, desc: String, block: () -> Unit) -> Unit + withAuth: (title: String, desc: String, block: () -> Unit) -> Unit, ) { - SectionView(stringResource(MR.strings.settings_section_title_app)) { - SettingsActionItem(painterResource(MR.images.ic_restart_alt), stringResource(MR.strings.settings_restart_app), ::restartApp) - SettingsActionItem(painterResource(MR.images.ic_power_settings_new), stringResource(MR.strings.settings_shutdown), { shutdownAppAlert(::shutdownApp) }) + SectionView { SettingsActionItem(painterResource(MR.images.ic_code), stringResource(MR.strings.settings_developer_tools), showSettingsModal { DeveloperView(withAuth) }) - AppVersionItem(showVersion) } } +@Composable +actual fun AppShutdownItem() { + SettingsActionItem(painterResource(MR.images.ic_power_settings_new), stringResource(MR.strings.settings_shutdown), ::shutdownAppAlert) +} fun restartApp() { ProcessPhoenix.triggerRebirth(androidAppContext) @@ -36,11 +44,28 @@ private fun shutdownApp() { Runtime.getRuntime().exit(0) } -private fun shutdownAppAlert(onConfirm: () -> Unit) { - AlertManager.shared.showAlertDialog( +private fun shutdownAppAlert() { + AlertManager.shared.showAlertDialogButtonsColumn( title = generalGetString(MR.strings.shutdown_alert_question), text = generalGetString(MR.strings.shutdown_alert_desc), - destructive = true, - onConfirm = onConfirm + buttons = { + Column { + SectionItemView({ AlertManager.shared.hideAlert() }) { + Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center) + } + SectionItemView({ + AlertManager.shared.hideAlert() + restartApp() + }) { + Text(stringResource(MR.strings.settings_restart_app), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + SectionItemView({ + AlertManager.shared.hideAlert() + shutdownApp() + }) { + Text(stringResource(MR.strings.settings_shutdown), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) + } + } + } ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt index 648a0eb8e7..80f97d1caf 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt @@ -44,29 +44,8 @@ fun DatabaseView() { val prefs = m.controller.appPrefs val useKeychain = remember { mutableStateOf(prefs.storeDBPassphrase.get()) } val chatLastStart = remember { mutableStateOf(prefs.chatLastStart.get()) } - val chatArchiveFile = remember { mutableStateOf(null) } val stopped = remember { m.chatRunning }.value == false - val saveArchiveLauncher = rememberFileChooserLauncher(false) { to: URI? -> - val archive = chatArchiveFile.value - if (archive != null && to != null) { - copyFileToFile(File(archive), to) {} - } - // delete no matter the database was exported or canceled the export process - if (archive != null) { - File(archive).delete() - chatArchiveFile.value = null - } - } val appFilesCountAndSize = remember { mutableStateOf(directoryFileCountAndSize(appFilesDir.absolutePath)) } - val importArchiveLauncher = rememberFileChooserLauncher(true) { to: URI? -> - if (to != null) { - importArchiveAlert { - stopChatRunBlockStartChat(stopped, chatLastStart, progressIndicator) { - importArchive(to, appFilesCountAndSize, progressIndicator, false) - } - } - } - } val chatItemTTL = remember { mutableStateOf(m.chatItemTTL.value) } Box( Modifier.fillMaxSize(), @@ -79,27 +58,10 @@ fun DatabaseView() { useKeychain.value, m.chatDbEncrypted.value, m.controller.appPrefs.storeDBPassphrase.state.value, - m.controller.appPrefs.initialRandomDBPassphrase, - importArchiveLauncher, appFilesCountAndSize, chatItemTTL, user, m.users, - startChat = { startChat(m, chatLastStart, m.chatDbChanged, progressIndicator) }, - stopChatAlert = { stopChatAlert(m, progressIndicator) }, - exportArchive = { - stopChatRunBlockStartChat(stopped, chatLastStart, progressIndicator) { - exportArchive(m, progressIndicator, chatArchiveFile, saveArchiveLauncher) - } - }, - deleteChatAlert = { - deleteChatAlert { - stopChatRunBlockStartChat(stopped, chatLastStart, progressIndicator) { - deleteChat(m, progressIndicator) - true - } - } - }, deleteAppFilesAndMedia = { deleteFilesAndMediaAlert { stopChatRunBlockStartChat(stopped, chatLastStart, progressIndicator) { @@ -120,12 +82,9 @@ fun DatabaseView() { setCiTTL(m, rhId, chatItemTTL, progressIndicator, appFilesCountAndSize) } }, - disconnectAllHosts = { - val connected = chatModel.remoteHosts.filter { it.sessionState is RemoteHostSessionState.Connected } - connected.forEachIndexed { index, h -> - controller.stopRemoteHostAndReloadHosts(h, index == connected.lastIndex && chatModel.connectedToRemote()) - } - } + showDatabaseManagement = { + ModalManager.start.showModal(cardScreen = true) { DatabaseManagementView() } + }, ) if (progressIndicator.value) { Box( @@ -151,24 +110,18 @@ fun DatabaseLayout( useKeyChain: Boolean, chatDbEncrypted: Boolean?, passphraseSaved: Boolean, - initialRandomDBPassphrase: SharedPreference, - importArchiveLauncher: FileChooserLauncher, appFilesCountAndSize: MutableState>, chatItemTTL: MutableState, currentUser: User?, users: List, - startChat: () -> Unit, - stopChatAlert: () -> Unit, - exportArchive: () -> Unit, - deleteChatAlert: () -> Unit, deleteAppFilesAndMedia: () -> Unit, onChatItemTTLSelected: (ChatItemTTL?) -> Unit, - disconnectAllHosts: () -> Unit, + showDatabaseManagement: () -> Unit, ) { val operationsDisabled = progressIndicator && !chatModel.desktopNoUserNoRemote ColumnWithScrollBar { - AppBarTitle(stringResource(MR.strings.your_chat_database)) + AppBarTitle(stringResource(MR.strings.chat_data)) if (!chatModel.desktopNoUserNoRemote) { SectionView(stringResource(MR.strings.messages_section_title)) { @@ -187,79 +140,17 @@ fun DatabaseLayout( ) SectionDividerSpaced() } - val toggleEnabled = remember { chatModel.remoteHosts }.none { it.sessionState is RemoteHostSessionState.Connected } - if (chatModel.localUserCreated.value == true) { - // still show the toggle in case database was stopped when the user opened this screen because it can be in the following situations: - // - database was stopped after migration and the app relaunched - // - something wrong happened with database operations and the database couldn't be launched when it should - SectionView(stringResource(MR.strings.run_chat_section)) { - if (!toggleEnabled) { - SectionItemView(disconnectAllHosts) { - Text(generalGetString(MR.strings.disconnect_remote_hosts), Modifier.fillMaxWidth(), color = WarningOrange) - } - } - RunChatSetting(stopped, toggleEnabled && !progressIndicator, startChat, stopChatAlert) - } - if (stopped) SectionTextFooter(stringResource(MR.strings.you_must_use_the_most_recent_version_of_database)) - SectionDividerSpaced() - } - SectionView(stringResource(MR.strings.chat_database_section)) { - if (chatModel.localUserCreated.value != true && !toggleEnabled) { - SectionItemView(disconnectAllHosts) { - Text(generalGetString(MR.strings.disconnect_remote_hosts), Modifier.fillMaxWidth(), color = WarningOrange) - } - } + SectionView { val unencrypted = chatDbEncrypted == false SettingsActionItem( if (unencrypted) painterResource(MR.images.ic_lock_open_right) else if (useKeyChain) painterResource(MR.images.ic_vpn_key_filled) else painterResource(MR.images.ic_lock), - stringResource(MR.strings.database_passphrase), - click = { ModalManager.start.showModal(cardScreen = true) { DatabaseEncryptionView(chatModel, false) } }, + stringResource(MR.strings.database_passphrase_and_export), + click = showDatabaseManagement, iconColor = if (unencrypted || (appPlatform.isDesktop && passphraseSaved)) WarningOrange else MaterialTheme.colors.secondary, disabled = operationsDisabled ) - if (appPlatform.isDesktop) { - SettingsActionItem( - painterResource(MR.images.ic_folder_open), - stringResource(MR.strings.open_database_folder), - ::desktopOpenDatabaseDir, - disabled = operationsDisabled - ) - } - SettingsActionItem( - painterResource(MR.images.ic_ios_share), - stringResource(MR.strings.export_database), - click = { - if (initialRandomDBPassphrase.get()) { - exportProhibitedAlert() - ModalManager.start.showModal { - DatabaseEncryptionView(chatModel, false) - } - } else { - exportArchive() - } - }, - textColor = MaterialTheme.colors.primary, - iconColor = MaterialTheme.colors.primary, - disabled = operationsDisabled - ) - SettingsActionItem( - painterResource(MR.images.ic_download), - stringResource(MR.strings.import_database), - { withLongRunningApi { importArchiveLauncher.launch("application/zip") } }, - textColor = Color.Red, - iconColor = Color.Red, - disabled = operationsDisabled - ) - SettingsActionItem( - painterResource(MR.images.ic_delete_forever), - stringResource(MR.strings.delete_database), - deleteChatAlert, - textColor = Color.Red, - iconColor = Color.Red, - disabled = operationsDisabled - ) } SectionDividerSpaced() @@ -287,6 +178,155 @@ fun DatabaseLayout( } } +@Composable +fun DatabaseManagementView() { + val m = chatModel + val progressIndicator = remember { mutableStateOf(false) } + val prefs = m.controller.appPrefs + val useKeychain = remember { mutableStateOf(prefs.storeDBPassphrase.get()) } + val chatLastStart = remember { mutableStateOf(prefs.chatLastStart.get()) } + val chatArchiveFile = remember { mutableStateOf(null) } + val stopped = remember { m.chatRunning }.value == false + val saveArchiveLauncher = rememberFileChooserLauncher(false) { to: URI? -> + val archive = chatArchiveFile.value + if (archive != null && to != null) { + copyFileToFile(File(archive), to) {} + } + // delete no matter the database was exported or canceled the export process + if (archive != null) { + File(archive).delete() + chatArchiveFile.value = null + } + } + val appFilesCountAndSize = remember { mutableStateOf(directoryFileCountAndSize(appFilesDir.absolutePath)) } + val importArchiveLauncher = rememberFileChooserLauncher(true) { to: URI? -> + if (to != null) { + importArchiveAlert { + stopChatRunBlockStartChat(stopped, chatLastStart, progressIndicator) { + importArchive(to, appFilesCountAndSize, progressIndicator, false) + } + } + } + } + val operationsDisabled = progressIndicator.value && !m.desktopNoUserNoRemote + + Box(Modifier.fillMaxSize()) { + ColumnWithScrollBar { + AppBarTitle(stringResource(MR.strings.database_passphrase_and_export)) + + val toggleEnabled = remember { chatModel.remoteHosts }.none { it.sessionState is RemoteHostSessionState.Connected } + val disconnectAllHosts = { + val connected = chatModel.remoteHosts.filter { it.sessionState is RemoteHostSessionState.Connected } + connected.forEachIndexed { index, h -> + controller.stopRemoteHostAndReloadHosts(h, index == connected.lastIndex && chatModel.connectedToRemote()) + } + } + SectionView(stringResource(MR.strings.chat_database_section)) { + if (chatModel.localUserCreated.value != true && !toggleEnabled) { + SectionItemView(disconnectAllHosts) { + Text(generalGetString(MR.strings.disconnect_remote_hosts), Modifier.fillMaxWidth(), color = WarningOrange) + } + } + val unencrypted = m.chatDbEncrypted.value == false + SettingsActionItem( + if (unencrypted) painterResource(MR.images.ic_lock_open_right) else if (useKeychain.value) painterResource(MR.images.ic_vpn_key_filled) + else painterResource(MR.images.ic_lock), + stringResource(MR.strings.database_passphrase), + click = { ModalManager.start.showModal(cardScreen = true) { DatabaseEncryptionView(chatModel, false) } }, + iconColor = if (unencrypted || (appPlatform.isDesktop && prefs.storeDBPassphrase.state.value)) WarningOrange else MaterialTheme.colors.secondary, + disabled = operationsDisabled + ) + if (appPlatform.isDesktop) { + SettingsActionItem( + painterResource(MR.images.ic_folder_open), + stringResource(MR.strings.open_database_folder), + ::desktopOpenDatabaseDir, + disabled = operationsDisabled + ) + } + SettingsActionItem( + painterResource(MR.images.ic_ios_share), + stringResource(MR.strings.export_database), + click = { + if (prefs.initialRandomDBPassphrase.get()) { + exportProhibitedAlert() + ModalManager.start.showModal { + DatabaseEncryptionView(chatModel, false) + } + } else { + stopChatRunBlockStartChat(stopped, chatLastStart, progressIndicator) { + exportArchive(m, progressIndicator, chatArchiveFile, saveArchiveLauncher) + } + } + }, + textColor = MaterialTheme.colors.primary, + iconColor = MaterialTheme.colors.primary, + disabled = operationsDisabled + ) + SettingsActionItem( + painterResource(MR.images.ic_download), + stringResource(MR.strings.import_database), + { withLongRunningApi { importArchiveLauncher.launch("application/zip") } }, + textColor = Color.Red, + iconColor = Color.Red, + disabled = operationsDisabled + ) + SettingsActionItem( + painterResource(MR.images.ic_delete_forever), + stringResource(MR.strings.delete_database), + { + deleteChatAlert { + stopChatRunBlockStartChat(stopped, chatLastStart, progressIndicator) { + deleteChat(m, progressIndicator) + true + } + } + }, + textColor = Color.Red, + iconColor = Color.Red, + disabled = operationsDisabled + ) + } + + if (chatModel.localUserCreated.value == true) { + SectionDividerSpaced() + // still show the toggle in case database was stopped when the user opened this screen because it can be in the following situations: + // - database was stopped after migration and the app relaunched + // - something wrong happened with database operations and the database couldn't be launched when it should + SectionView(stringResource(MR.strings.run_chat_section)) { + if (!toggleEnabled) { + SectionItemView(disconnectAllHosts) { + Text(generalGetString(MR.strings.disconnect_remote_hosts), Modifier.fillMaxWidth(), color = WarningOrange) + } + } + RunChatSetting( + stopped, + toggleEnabled && !progressIndicator.value, + startChat = { startChat(m, chatLastStart, m.chatDbChanged, progressIndicator) }, + stopChatAlert = { stopChatAlert(m, progressIndicator) } + ) + } + if (stopped) SectionTextFooter(stringResource(MR.strings.you_must_use_the_most_recent_version_of_database)) + } + SectionBottomSpacer() + } + if (progressIndicator.value) { + Box( + Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + Modifier + .padding(horizontal = 2.dp) + .size(30.dp), + color = MaterialTheme.colors.secondary, + strokeWidth = 2.5.dp + ) + } + } + } +} + private fun setChatItemTTLAlert( m: ChatModel, rhId: Long?, selectedChatItemTTL: MutableState, progressIndicator: MutableState, @@ -832,19 +872,13 @@ fun PreviewDatabaseLayout() { useKeyChain = false, chatDbEncrypted = false, passphraseSaved = false, - initialRandomDBPassphrase = SharedPreference({ true }, {}), - importArchiveLauncher = rememberFileChooserLauncher(true) {}, appFilesCountAndSize = remember { mutableStateOf(0 to 0L) }, chatItemTTL = remember { mutableStateOf(ChatItemTTL.None) }, currentUser = User.sampleData, users = listOf(UserInfo.sampleData), - startChat = {}, - stopChatAlert = {}, - exportArchive = {}, - deleteChatAlert = {}, deleteAppFilesAndMedia = {}, onChatItemTTLSelected = {}, - disconnectAllHosts = {}, + showDatabaseManagement = {}, ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt index 150b2a38e0..91324bb39a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt @@ -24,43 +24,28 @@ import kotlin.collections.ArrayList fun NotificationsSettingsView( chatModel: ChatModel, ) { - val onNotificationPreviewModeSelected = { mode: NotificationPreviewMode -> - chatModel.controller.appPrefs.notificationPreviewMode.set(mode.name) - chatModel.notificationPreviewMode.value = mode - } - NotificationsSettingsLayout( notificationsMode = remember { chatModel.controller.appPrefs.notificationsMode.state }, - notificationPreviewMode = chatModel.notificationPreviewMode, - showPage = { page -> + showNotificationsMode = { ModalManager.start.showModalCloseable(true) { - when (page) { - CurrentPage.NOTIFICATIONS_MODE -> NotificationsModeView(chatModel.controller.appPrefs.notificationsMode.state) { changeNotificationsMode(it, chatModel) } - CurrentPage.NOTIFICATION_PREVIEW_MODE -> NotificationPreviewView(chatModel.notificationPreviewMode, onNotificationPreviewModeSelected) - } + NotificationsModeView(chatModel.controller.appPrefs.notificationsMode.state) { changeNotificationsMode(it, chatModel) } } }, ) } -enum class CurrentPage { - NOTIFICATIONS_MODE, NOTIFICATION_PREVIEW_MODE -} - @Composable fun NotificationsSettingsLayout( notificationsMode: State, - notificationPreviewMode: State, - showPage: (CurrentPage) -> Unit, + showNotificationsMode: () -> Unit, ) { val modes = remember { notificationModes() } - val previewModes = remember { notificationPreviewModes() } ColumnWithScrollBar { AppBarTitle(stringResource(MR.strings.notifications)) SectionView(null) { if (appPlatform == AppPlatform.ANDROID) { - SettingsActionItemWithContent(null, stringResource(MR.strings.settings_notifications_mode_title), { showPage(CurrentPage.NOTIFICATIONS_MODE) }) { + SettingsActionItemWithContent(null, stringResource(MR.strings.settings_notifications_mode_title), showNotificationsMode) { Text( modes.firstOrNull { it.value == notificationsMode.value }?.title ?: "", maxLines = 1, @@ -69,14 +54,6 @@ fun NotificationsSettingsLayout( ) } } - SettingsActionItemWithContent(null, stringResource(MR.strings.settings_notification_preview_mode_title), { showPage(CurrentPage.NOTIFICATION_PREVIEW_MODE) }) { - Text( - previewModes.firstOrNull { it.value == notificationPreviewMode.value }?.title ?: "", - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colors.secondary - ) - } } if (platform.androidIsXiaomiDevice() && (notificationsMode.value == NotificationsMode.PERIODIC || notificationsMode.value == NotificationsMode.SERVICE)) { SectionTextFooter(annotatedStringResource(MR.strings.xiaomi_ignore_battery_optimization)) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index 7316c9bd82..cf34fd5a44 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp @@ -73,6 +74,48 @@ fun PrivacySettingsView( stringResource(MR.strings.sanitize_links_toggle), chatModel.controller.appPrefs.privacySanitizeLinks ) + } + SectionDividerSpaced() + + SectionView(stringResource(MR.strings.settings_section_title_files)) { + SettingsPreferenceItem(painterResource(MR.images.ic_image), stringResource(MR.strings.auto_accept_images), chatModel.controller.appPrefs.privacyAcceptImages) + BlurRadiusOptions(remember { appPrefs.privacyMediaBlurRadius.state }) { + appPrefs.privacyMediaBlurRadius.set(it) + } + } + + val currentUser = chatModel.currentUser.value + if (currentUser != null && !chatModel.desktopNoUserNoRemote) { + SectionDividerSpaced() + ContacRequestsFromGroupsSection( + currentUser = currentUser, + setAutoAcceptGrpDirectInvs = { enable -> + withApi { + chatModel.controller.apiSetUserAutoAcceptMemberContacts(currentUser, enable) + chatModel.currentUser.value = currentUser.copy(autoAcceptMemberContacts = enable) + } + } + ) + } + + SectionDividerSpaced() + SectionView { + SettingsActionItem( + painterResource(MR.images.ic_more_horiz), + stringResource(MR.strings.more_privacy), + showSettingsModal { MorePrivacyView(it) } + ) + } + SectionBottomSpacer() + } +} + +@Composable +fun MorePrivacyView(chatModel: ChatModel) { + ColumnWithScrollBar { + AppBarTitle(stringResource(MR.strings.more_privacy)) + + SectionView(stringResource(MR.strings.settings_section_title_chats)) { SettingsPreferenceItem( painterResource(MR.images.ic_chat_bubble), stringResource(MR.strings.privacy_show_last_messages), @@ -98,10 +141,6 @@ fun PrivacySettingsView( SettingsPreferenceItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.encrypt_local_files), chatModel.controller.appPrefs.privacyEncryptLocalFiles, onChange = { enable -> withBGApi { chatModel.controller.apiSetEncryptLocalFiles(enable) } }) - SettingsPreferenceItem(painterResource(MR.images.ic_image), stringResource(MR.strings.auto_accept_images), chatModel.controller.appPrefs.privacyAcceptImages) - BlurRadiusOptions(remember { appPrefs.privacyMediaBlurRadius.state }) { - appPrefs.privacyMediaBlurRadius.set(it) - } SettingsPreferenceItem(painterResource(MR.images.ic_security), stringResource(MR.strings.protect_ip_address), chatModel.controller.appPrefs.privacyAskToApproveRelays) } SectionTextFooter( @@ -111,9 +150,34 @@ fun PrivacySettingsView( stringResource(MR.strings.without_tor_or_vpn_ip_address_will_be_visible_to_file_servers) } ) + SectionDividerSpaced() + + SectionView(stringResource(MR.strings.notifications)) { + val previewModes = remember { notificationPreviewModes() } + val notificationPreviewMode = remember { chatModel.notificationPreviewMode } + SettingsActionItemWithContent( + painterResource(MR.images.ic_visibility_off), + stringResource(MR.strings.settings_notification_preview_mode_title), + click = { + ModalManager.start.showModalCloseable(true) { + NotificationPreviewView(notificationPreviewMode) { mode -> + chatModel.controller.appPrefs.notificationPreviewMode.set(mode.name) + chatModel.notificationPreviewMode.value = mode + } + } + } + ) { + Text( + previewModes.firstOrNull { it.value == notificationPreviewMode.value }?.title ?: "", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colors.secondary + ) + } + } val currentUser = chatModel.currentUser.value - if (currentUser != null) { + if (currentUser != null && !chatModel.desktopNoUserNoRemote) { fun setSendReceiptsContacts(enable: Boolean, clearOverrides: Boolean) { withLongRunningApi(slow = 60_000) { val mrs = UserMsgReceiptSettings(enable, clearOverrides) @@ -164,57 +228,40 @@ fun PrivacySettingsView( } } - fun setAutoAcceptGrpDirectInvs(enable: Boolean) { - withApi { - chatModel.controller.apiSetUserAutoAcceptMemberContacts(currentUser, enable) - chatModel.currentUser.value = currentUser.copy(autoAcceptMemberContacts = enable) + SectionDividerSpaced() + DeliveryReceiptsSection( + currentUser = currentUser, + setOrAskSendReceiptsContacts = { enable -> + val contactReceiptsOverrides = chatModel.chats.value.fold(0) { count, chat -> + if (chat.chatInfo is ChatInfo.Direct) { + val sendRcpts = chat.chatInfo.contact.chatSettings.sendRcpts + count + (if (sendRcpts == null || sendRcpts == enable) 0 else 1) + } else { + count + } + } + if (contactReceiptsOverrides == 0) { + setSendReceiptsContacts(enable, clearOverrides = false) + } else { + showUserContactsReceiptsAlert(enable, contactReceiptsOverrides, ::setSendReceiptsContacts) + } + }, + setOrAskSendReceiptsGroups = { enable -> + val groupReceiptsOverrides = chatModel.chats.value.fold(0) { count, chat -> + if (chat.chatInfo is ChatInfo.Group) { + val sendRcpts = chat.chatInfo.groupInfo.chatSettings.sendRcpts + count + (if (sendRcpts == null || sendRcpts == enable) 0 else 1) + } else { + count + } + } + if (groupReceiptsOverrides == 0) { + setSendReceiptsGroups(enable, clearOverrides = false) + } else { + showUserGroupsReceiptsAlert(enable, groupReceiptsOverrides, ::setSendReceiptsGroups) + } } - } - - if (!chatModel.desktopNoUserNoRemote) { - SectionDividerSpaced() - ContacRequestsFromGroupsSection( - currentUser = currentUser, - setAutoAcceptGrpDirectInvs = { enable -> - setAutoAcceptGrpDirectInvs(enable) - } - ) - - SectionDividerSpaced() - DeliveryReceiptsSection( - currentUser = currentUser, - setOrAskSendReceiptsContacts = { enable -> - val contactReceiptsOverrides = chatModel.chats.value.fold(0) { count, chat -> - if (chat.chatInfo is ChatInfo.Direct) { - val sendRcpts = chat.chatInfo.contact.chatSettings.sendRcpts - count + (if (sendRcpts == null || sendRcpts == enable) 0 else 1) - } else { - count - } - } - if (contactReceiptsOverrides == 0) { - setSendReceiptsContacts(enable, clearOverrides = false) - } else { - showUserContactsReceiptsAlert(enable, contactReceiptsOverrides, ::setSendReceiptsContacts) - } - }, - setOrAskSendReceiptsGroups = { enable -> - val groupReceiptsOverrides = chatModel.chats.value.fold(0) { count, chat -> - if (chat.chatInfo is ChatInfo.Group) { - val sendRcpts = chat.chatInfo.groupInfo.chatSettings.sendRcpts - count + (if (sendRcpts == null || sendRcpts == enable) 0 else 1) - } else { - count - } - } - if (groupReceiptsOverrides == 0) { - setSendReceiptsGroups(enable, clearOverrides = false) - } else { - showUserGroupsReceiptsAlert(enable, groupReceiptsOverrides, ::setSendReceiptsGroups) - } - } - ) - } + ) } SectionBottomSpacer() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index f17d3a6e4b..c0b822dbb4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -37,17 +37,15 @@ import chat.simplex.res.MR @Composable fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, close: () -> Unit) { - val user = chatModel.currentUser.value val stopped = chatModel.chatRunning.value == false + val showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit) = { modalView -> { ModalManager.start.showModal(settings = true, cardScreen = true) { modalView(chatModel) } } } SettingsLayout( stopped, chatModel.chatDbEncrypted.value == true, remember { chatModel.controller.appPrefs.storeDBPassphrase.state }.value, - remember { chatModel.controller.appPrefs.notificationsMode.state }, - user?.displayName, setPerformLA = setPerformLA, showModal = { modalView -> { ModalManager.start.showModal { modalView(chatModel) } } }, - showSettingsModal = { modalView -> { ModalManager.start.showModal(settings = true, cardScreen = true) { modalView(chatModel) } } }, + showSettingsModal = showSettingsModal, showSettingsModalWithSearch = { modalView -> ModalManager.start.showCustomModal { close -> val search = rememberSaveable { mutableStateOf("") } @@ -62,12 +60,7 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, close: ( }, showCustomModal = { modalView -> { ModalManager.start.showCustomModal { close -> modalView(chatModel, close) } } }, showVersion = { - withBGApi { - val info = chatModel.controller.apiGetVersion() - if (info != null) { - ModalManager.start.showModal { VersionInfoView(info) } - } - } + ModalManager.start.showModal(cardScreen = true) { VersionInfoView(showSettingsModal, ::doWithAuth) } }, withAuth = ::doWithAuth, ) @@ -84,8 +77,6 @@ fun SettingsLayout( stopped: Boolean, encrypted: Boolean, passphraseSaved: Boolean, - notificationsMode: State, - userDisplayName: String?, setPerformLA: (Boolean) -> Unit, showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), @@ -98,30 +89,52 @@ fun SettingsLayout( LaunchedEffect(Unit) { hideKeyboard(view) } - val uriHandler = LocalUriHandler.current + val notificationsMode = remember { chatModel.controller.appPrefs.notificationsMode.state } ColumnWithScrollBar { AppBarTitle(stringResource(MR.strings.your_settings)) - SectionView(stringResource(MR.strings.settings_section_title_settings)) { - SettingsActionItem(painterResource(if (notificationsMode.value == NotificationsMode.OFF) MR.images.ic_bolt_off else MR.images.ic_bolt), stringResource(MR.strings.notifications), showSettingsModal { NotificationsSettingsView(it) }, disabled = stopped) - SettingsActionItem(painterResource(MR.images.ic_wifi_tethering), stringResource(MR.strings.network_and_servers), showCustomModal { _, close -> NetworkAndServersView(close) }, disabled = stopped) - SettingsActionItem(painterResource(MR.images.ic_videocam), stringResource(MR.strings.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped) - SettingsActionItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.privacy_and_security), showSettingsModal { PrivacySettingsView(it, showSettingsModal, setPerformLA) }, disabled = stopped) + SectionView { SettingsActionItem(painterResource(MR.images.ic_light_mode), stringResource(MR.strings.appearance_settings), showSettingsModal { AppearanceView(it) }) - } - SectionDividerSpaced() - - SectionView(stringResource(MR.strings.settings_section_title_chat_database)) { + SettingsActionItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.your_privacy), showSettingsModal { PrivacySettingsView(it, showSettingsModal, setPerformLA) }, disabled = stopped) + SettingsActionItem(painterResource(MR.images.ic_help), stringResource(MR.strings.help_and_support), showSettingsModal { HelpAndSupportView(it, showModal, showCustomModal) }) DatabaseItem(encrypted, passphraseSaved, showSettingsModal { DatabaseView() }, stopped) SettingsActionItem(painterResource(MR.images.ic_ios_share), stringResource(MR.strings.migrate_from_device_to_another_device), { withAuth(generalGetString(MR.strings.auth_open_migration_to_another_device), generalGetString(MR.strings.auth_log_in_using_credential)) { ModalManager.fullscreen.showCustomModal { close -> MigrateFromDeviceView(close) } } }, disabled = stopped) } - SectionDividerSpaced() + SectionView(stringResource(MR.strings.advanced_settings)) { + SettingsActionItem(painterResource(MR.images.ic_wifi_tethering), stringResource(MR.strings.network_and_servers), showCustomModal { _, close -> NetworkAndServersView(close) }, disabled = stopped) + if (appPlatform == AppPlatform.ANDROID) { + SettingsActionItem(painterResource(if (notificationsMode.value == NotificationsMode.OFF) MR.images.ic_bolt_off else MR.images.ic_bolt), stringResource(MR.strings.notifications), showSettingsModal { NotificationsSettingsView(it) }, disabled = stopped) + } + SettingsActionItem(painterResource(MR.images.ic_videocam), stringResource(MR.strings.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped) + AppShutdownItem() + AppVersionItem(showVersion) + } + SectionBottomSpacer() + } +} + +@Composable +fun HelpAndSupportView( + chatModel: ChatModel, + showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), + showCustomModal: (@Composable ModalData.(ChatModel, () -> Unit) -> Unit) -> (() -> Unit), +) { + val uriHandler = LocalUriHandler.current + val stopped = chatModel.chatRunning.value == false + val userDisplayName = chatModel.currentUser.value?.displayName ?: "" + ColumnWithScrollBar { + AppBarTitle(stringResource(MR.strings.help_and_support)) + SectionView(stringResource(MR.strings.settings_section_title_help)) { - SettingsActionItem(painterResource(MR.images.ic_help), stringResource(MR.strings.how_to_use_simplex_chat), showModal { HelpView(userDisplayName ?: "") }, disabled = stopped) + SettingsActionItem(painterResource(MR.images.ic_help), stringResource(MR.strings.how_to_use_simplex_chat), showModal { HelpView(userDisplayName) }, disabled = stopped) SettingsActionItem(painterResource(MR.images.ic_add), stringResource(MR.strings.whats_new), showCustomModal { _, close -> WhatsNewView(viaSettings = true, close = close) }, disabled = stopped) SettingsActionItem(painterResource(MR.images.ic_info), stringResource(MR.strings.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) }) + } + SectionDividerSpaced() + + SectionView(stringResource(MR.strings.settings_section_title_contact)) { if (!chatModel.desktopNoUserNoRemote) { SettingsActionItem(painterResource(MR.images.ic_tag), stringResource(MR.strings.chat_with_the_founder), { uriHandler.openVerifiedSimplexUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped) } @@ -129,27 +142,29 @@ fun SettingsLayout( } SectionDividerSpaced() - SectionView(stringResource(MR.strings.settings_section_title_support)) { + SectionView(stringResource(MR.strings.settings_section_title_support_project)) { if (!BuildConfigCommon.ANDROID_BUNDLE) { ContributeItem(uriHandler) } - RateAppItem(uriHandler) + if (appPlatform.isAndroid) { + RateAppItem(uriHandler) + } StarOnGithubItem(uriHandler) } - SectionDividerSpaced() - - SettingsSectionApp(showSettingsModal, showVersion, withAuth) SectionBottomSpacer() } } @Composable -expect fun SettingsSectionApp( +expect fun AdvancedSettingsAppSection( showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), - showVersion: () -> Unit, - withAuth: (title: String, desc: String, block: () -> Unit) -> Unit + withAuth: (title: String, desc: String, block: () -> Unit) -> Unit, ) +// Shutdown is only available on Android; on desktop the app is closed via the window. +@Composable +expect fun AppShutdownItem() + @Composable private fun DatabaseItem(encrypted: Boolean, saved: Boolean, openDatabaseView: () -> Unit, stopped: Boolean) { SectionItemView(openDatabaseView) { Row( @@ -160,11 +175,11 @@ expect fun SettingsSectionApp( Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { Icon( painterResource(MR.images.ic_database), - contentDescription = stringResource(MR.strings.database_passphrase_and_export), + contentDescription = stringResource(MR.strings.chat_data), tint = if (encrypted && (appPlatform.isAndroid || !saved)) MaterialTheme.colors.secondary else WarningOrange, ) TextIconSpaced(false) - Text(stringResource(MR.strings.database_passphrase_and_export)) + Text(stringResource(MR.strings.chat_data)) } if (stopped) { Icon( @@ -208,7 +223,7 @@ fun ChatLockItem( } } -@Composable private fun ContributeItem(uriHandler: UriHandler) { +@Composable fun ContributeItem(uriHandler: UriHandler) { SectionItemView({ uriHandler.openExternalLink("https://github.com/simplex-chat/simplex-chat#contribute") }) { Icon( painterResource(MR.images.ic_keyboard), @@ -220,7 +235,7 @@ fun ChatLockItem( } } -@Composable private fun RateAppItem(uriHandler: UriHandler) { +@Composable fun RateAppItem(uriHandler: UriHandler) { SectionItemView({ runCatching { uriHandler.openUriCatching("market://details?id=chat.simplex.app") } .onFailure { uriHandler.openUriCatching("https://play.google.com/store/apps/details?id=chat.simplex.app") } @@ -236,7 +251,7 @@ fun ChatLockItem( } } -@Composable private fun StarOnGithubItem(uriHandler: UriHandler) { +@Composable fun StarOnGithubItem(uriHandler: UriHandler) { SectionItemView({ uriHandler.openExternalLink("https://github.com/simplex-chat/simplex-chat") }) { Icon( painter = painterResource(MR.images.ic_github), @@ -486,8 +501,6 @@ fun PreviewSettingsLayout() { stopped = false, encrypted = false, passphraseSaved = false, - notificationsMode = remember { mutableStateOf(NotificationsMode.OFF) }, - userDisplayName = "Alice", setPerformLA = { _ -> }, showModal = { {} }, showSettingsModal = { {} }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/VersionInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/VersionInfoView.kt index 52addd146b..5070c3c0aa 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/VersionInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/VersionInfoView.kt @@ -1,33 +1,55 @@ package chat.simplex.common.views.usersettings +import SectionBottomSpacer +import SectionDividerSpaced +import SectionView +import itemHPadding +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.material.Text -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Modifier import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.BuildConfigCommon +import chat.simplex.common.model.ChatModel import chat.simplex.common.model.CoreVersionInfo import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.platform.appPlatform -import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF import chat.simplex.common.views.helpers.AppBarTitle import chat.simplex.res.MR @Composable -fun VersionInfoView(info: CoreVersionInfo) { - ColumnWithScrollBar( - Modifier.padding(horizontal = DEFAULT_PADDING), - ) { - AppBarTitle(stringResource(MR.strings.app_version_title), withPadding = false) - if (appPlatform.isAndroid) { - Text(String.format(stringResource(MR.strings.app_version_name), BuildConfigCommon.ANDROID_VERSION_NAME)) - Text(String.format(stringResource(MR.strings.app_version_code), BuildConfigCommon.ANDROID_VERSION_CODE)) - } else { - Text(String.format(stringResource(MR.strings.app_version_name), BuildConfigCommon.DESKTOP_VERSION_NAME)) - Text(String.format(stringResource(MR.strings.app_version_code), BuildConfigCommon.DESKTOP_VERSION_CODE)) +fun VersionInfoView( + showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), + withAuth: (title: String, desc: String, block: () -> Unit) -> Unit, +) { + val versionInfo = remember { mutableStateOf(null) } + LaunchedEffect(Unit) { + versionInfo.value = chatModel.controller.apiGetVersion() + } + ColumnWithScrollBar { + AppBarTitle(stringResource(MR.strings.app_version_title)) + SectionView { + Column(Modifier.padding(horizontal = itemHPadding, vertical = DEFAULT_PADDING_HALF)) { + if (appPlatform.isAndroid) { + Text(String.format(stringResource(MR.strings.app_version_name), BuildConfigCommon.ANDROID_VERSION_NAME)) + Text(String.format(stringResource(MR.strings.app_version_code), BuildConfigCommon.ANDROID_VERSION_CODE)) + } else { + Text(String.format(stringResource(MR.strings.app_version_name), BuildConfigCommon.DESKTOP_VERSION_NAME)) + Text(String.format(stringResource(MR.strings.app_version_code), BuildConfigCommon.DESKTOP_VERSION_CODE)) + } + versionInfo.value?.let { info -> + Text(String.format(stringResource(MR.strings.core_version), info.version)) + val simplexmqCommit = if (info.simplexmqCommit.length >= 7) info.simplexmqCommit.substring(startIndex = 0, endIndex = 7) else info.simplexmqCommit + Text(String.format(stringResource(MR.strings.core_simplexmq_version), info.simplexmqVersion, simplexmqCommit)) + } + } } - Text(String.format(stringResource(MR.strings.core_version), info.version)) - val simplexmqCommit = if (info.simplexmqCommit.length >= 7) info.simplexmqCommit.substring(startIndex = 0, endIndex = 7) else info.simplexmqCommit - Text(String.format(stringResource(MR.strings.core_simplexmq_version), info.simplexmqVersion, simplexmqCommit)) + SectionDividerSpaced() + + AdvancedSettingsAppSection(showSettingsModal, withAuth) + SectionBottomSpacer() } } diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index ee79fc0af0..5bca9402a8 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1555,6 +1555,13 @@ Files Send delivery receipts to Contact requests from groups + About + Contact + Support the project + Chat data + Help & support + More privacy + Advanced settings Restart Shutdown Developer tools @@ -2681,7 +2688,7 @@ Don\'t enable You can enable later via Settings Delivery receipts are disabled! - You can enable them later via app Privacy & Security settings. + You can enable them later via app Your privacy settings. Error enabling delivery receipts! diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.desktop.kt index 5b4a044df3..174ad63c7a 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.desktop.kt @@ -1,27 +1,21 @@ package chat.simplex.common.views.usersettings import SectionView -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier +import androidx.compose.runtime.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel -import chat.simplex.common.platform.AppUpdatesChannel -import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF +import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @Composable -actual fun SettingsSectionApp( +actual fun AdvancedSettingsAppSection( showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), - showVersion: () -> Unit, - withAuth: (title: String, desc: String, block: () -> Unit) -> Unit + withAuth: (title: String, desc: String, block: () -> Unit) -> Unit, ) { - SectionView(stringResource(MR.strings.settings_section_title_app)) { + SectionView { SettingsActionItem(painterResource(MR.images.ic_code), stringResource(MR.strings.settings_developer_tools), showSettingsModal { DeveloperView(withAuth) }) val selectedChannel = remember { appPrefs.appUpdateChannel.state } val values = AppUpdatesChannel.entries.map { it to it.text } @@ -29,6 +23,8 @@ actual fun SettingsSectionApp( appPrefs.appUpdateChannel.set(it) setupUpdateChecker() } - AppVersionItem(showVersion) } } + +@Composable +actual fun AppShutdownItem() {} From 7548fdae3bfd36db2211da287e15d0f29a3fff83 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Sun, 7 Jun 2026 22:46:59 +0000 Subject: [PATCH 017/200] android, desktop: fix chat item long-press menu and ripple shape (#6997) * android, desktop: fix chat item long-press menu and ripple shape clipChatItem clipped the bubble shape with Modifier.clip. Modifier.clip of the bubble GenericShape mis-hit-tests its path on very tall items, so long-press on the lower part of a long message did not reach combinedClickable and the context menu did not open (#6991); on desktop the same clip also left the press ripple rendered as a rectangle. Clip the bubble GenericShape in the draw pass (drawWithCache + clipPath) instead: drawing is clipped identically, the press ripple included, with no effect on hit-test. The RoundRect shape (tail disabled) hit-tests correctly and keeps Modifier.clip. Fixes #6991 * plans: justify chat item long-press and ripple shape fix --------- Co-authored-by: Evgeny Poberezkin --- .../common/views/chat/item/ChatItemView.kt | 25 +++++-- ...2026-05-20-fix-hold-on-long-msg-android.md | 68 +++++++++++++++++++ 2 files changed, 88 insertions(+), 5 deletions(-) create mode 100644 plans/2026-05-20-fix-hold-on-long-msg-android.md diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index 64288d9055..b4abbe70a2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -12,8 +12,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.geometry.* import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.drawscope.clipPath import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.* @@ -1224,12 +1226,25 @@ fun Modifier.clipChatItem(chatItem: ChatItem? = null, tailVisible: Boolean = fal val style = shapeStyle(chatItem, chatItemTail.value, tailVisible, revealed) val cornerRoundness = chatItemRoundness.value.coerceIn(0f, 1f) - val shape = when (style) { - is ShapeStyle.Bubble -> chatItemShape(cornerRoundness, LocalDensity.current, style.tailVisible, chatItem?.chatDir?.sent == true) - is ShapeStyle.RoundRect -> RoundedCornerShape(style.radius * cornerRoundness) + return when (style) { + is ShapeStyle.Bubble -> { + // Modifier.clip of the bubble GenericShape mis-hit-tests its path on very tall + // items, dropping long-press on the lower part of the bubble (issue #6991). Clip + // in the draw pass instead — drawing is clipped identically (the press ripple + // included), with no effect on hit-test. + val shape = chatItemShape(cornerRoundness, LocalDensity.current, style.tailVisible, chatItem?.chatDir?.sent == true) + this.drawWithCache { + val path = Path().apply { + addOutline(shape.createOutline(size, layoutDirection, this@drawWithCache)) + } + onDrawWithContent { + clipPath(path) { this@onDrawWithContent.drawContent() } + } + } + } + // RoundRect hit-tests correctly — no bug here, keep the antialiased Modifier.clip. + is ShapeStyle.RoundRect -> this.clip(RoundedCornerShape(style.radius * cornerRoundness)) } - - return this.clip(shape) } private fun chatItemShape(roundness: Float, density: Density, tailVisible: Boolean, sent: Boolean = false): GenericShape = GenericShape { size, _ -> diff --git a/plans/2026-05-20-fix-hold-on-long-msg-android.md b/plans/2026-05-20-fix-hold-on-long-msg-android.md new file mode 100644 index 0000000000..f89aa26582 --- /dev/null +++ b/plans/2026-05-20-fix-hold-on-long-msg-android.md @@ -0,0 +1,68 @@ +# Fix chat item long-press menu and ripple shape + +Branch: `nd/fix-hold-on-long-msg-android` · PR [#6997](https://github.com/simplex-chat/simplex-chat/pull/6997) · issue [#6991](https://github.com/simplex-chat/simplex-chat/issues/6991). + +## 1. Problem statement + +Two issues with the chat-item bubble on the multiplatform UI: + +- **Android (#6991):** long-pressing the lower part of a very tall text message did not open the select/copy/reply context menu. Long-press on the top/middle worked. Reproduced with a long multi-line message (~150+ lines — e.g. 5000 random bytes as hex); never reproduced on short messages. Occurs **only with the message tail enabled** (bubble shape); with the tail preference disabled, messages use a plain rounded-rectangle shape and the bug does not reproduce. iOS unaffected. +- **Desktop:** the chat-item press ripple, in some cases, rendered as a rectangle instead of following the rounded bubble shape. + +## 2. Solution summary + +One function — `Modifier.clipChatItem` in `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt`. It clipped the chat item with `Modifier.clip(shape)` for every shape style. It now clips the **bubble** (`GenericShape`) in the draw pass with `drawWithCache` + `clipPath`, and keeps `Modifier.clip` for the **`RoundRect`** shape, which is unaffected by the bug (§3). + +```kotlin +return when (style) { + is ShapeStyle.Bubble -> { + val shape = chatItemShape(cornerRoundness, LocalDensity.current, style.tailVisible, chatItem?.chatDir?.sent == true) + this.drawWithCache { + val path = Path().apply { addOutline(shape.createOutline(size, layoutDirection, this@drawWithCache)) } + onDrawWithContent { clipPath(path) { this@onDrawWithContent.drawContent() } } + } + } + is ShapeStyle.RoundRect -> this.clip(RoundedCornerShape(style.radius * cornerRoundness)) +} +``` + +Net diff: 1 file (`ChatItemView.kt`), +20 / −5 — the `clipChatItem` function restructured plus two imports. + +## 3. Root cause + +`Modifier.clip(shape)` is defined in Compose as `graphicsLayer(shape = shape, clip = true)`. A clipping graphics layer restricts **both** drawing **and** pointer hit-test to the shape. + +`clipChatItem` is the first (outermost) modifier on the chat-bubble `Column`, and that same `Column` carries the `combinedClickable` long-press handler. So the layer's hit-test region gates every press on the bubble. + +- **Android:** for a very tall chat item the layer's hit-test region does not cover the bubble's lower portion — a press there is never delivered to `combinedClickable`, so the long-press menu does not open. This is specific to the bubble's `GenericShape` clip: with the tail disabled the item is clipped with a `RoundedCornerShape`, which hit-tests correctly. The exact reason the `GenericShape` clip's hit-test falls short on tall content was not isolated; the fix does not depend on it (see §4). +- **Desktop:** the layer's clip did not always extend to the `combinedClickable` press ripple, so the ripple drew to its own rectangular bounds instead of the bubble shape. + +## 4. The fix + +For the bubble shape, `clipChatItem` clips with a draw modifier instead of a graphics layer. `drawWithCache` builds the shape's `Path` once per size change; `onDrawWithContent { clipPath(path) { drawContent() } }` wraps the whole content draw — bubble background, text, and the press ripple — in a canvas clip. + +A draw modifier affects **only drawing**. It is not a layout or pointer-input node and has no effect on hit-test. Therefore: + +- the bubble and ripple are still clipped to the shape — visually identical to `Modifier.clip`; +- pointer hit-test is no longer clipped — `combinedClickable` receives presses anywhere in the `Column`'s bounds, fixing the Android long-press; +- the canvas `clipPath` clips the ripple reliably, fixing the rectangular desktop ripple. + +The `RoundRect` shape keeps `Modifier.clip`: it hit-tests correctly (no bug) and keeps its antialiased outline clip. Scoping by shape — rather than draw-clipping every shape — leaves every non-bubble chat item (service/event messages, tails-off messages, old Android) byte-for-byte unchanged. + +## 5. Alternatives rejected + +- **Remove `clipChatItem` from the bubble `Column`.** Fixes the Android long-press, but the press ripple loses its shape and renders as a rectangle. Intermediate state during development; replaced. +- **Draw-pass clip for every shape, unconditionally.** Also correct and a hair simpler (no `when`), but it needlessly moves the `RoundRect` shape off `Modifier.clip`'s antialiased outline clip onto a canvas `clipPath` — a behaviour change with no benefit, since `RoundRect` has no bug. Scoping to the bubble shape keeps `RoundRect` unchanged. +- **Keep `Modifier.clip`, move `combinedClickable` off the clipped `Column`.** A larger structural change to the chat-item layout tree; the draw-pass clip fixes both issues without moving anything. + +## 6. Verification + +- **Android** (debug APK): long-press on the lower half of a 150+-line message opens the context menu; top/middle still work; the tap ripple stays bubble-shaped; swipe-to-reply and link tap/long-press are unaffected. +- **Desktop** (Linux AppImage): the chat-item press ripple follows the bubble shape (rounded corners and tail), not a rectangle — confirmed against a build without the fix. +- The bubble draw-pass clip above was verified on those Android and desktop builds; this revision additionally keeps `Modifier.clip` for the `RoundRect` shape, which is the unchanged pre-fix behaviour. + +## 7. Risk and rollback + +- Blast radius: the `Bubble` branch of `clipChatItem`. The `RoundRect` branch is unchanged (`Modifier.clip` as before), so service/event items, tails-off messages and old-Android items are untouched. For the bubble, drawing is clipped identically; the single behavioural change is that pointer hit-test on the bubble is no longer shape-clipped — benign (bubble corners are transparent; a rectangular hit area is a marginally larger touch target). +- iOS is a separate codebase and is untouched. +- Rollback: revert the fix commit on the branch, or drop it before merge. From 7c5406a4e9b8762f9171924ecb77cb379aa93674 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Sun, 7 Jun 2026 23:02:54 +0000 Subject: [PATCH 018/200] desktop: fix drag-and-drop videos attached as files (#7015) onFilesAttached classified URIs by isImage only, so videos fell through to processPickedFile and were attached as generic files. processPickedMedia already handles video correctly; the classifier above it just never reached that branch. Recognise videos as media inline using getFileName. --- .../simplex/common/views/chat/ComposeView.kt | 14 ++++-- plans/2026-05-26-fix-video-drag-and-drop.md | 44 +++++++++++++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 plans/2026-05-26-fix-video-drag-and-drop.md diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index d874079238..26824cdd49 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -288,16 +288,22 @@ expect fun AttachmentSelection( ) fun MutableState.onFilesAttached(uris: List) { - val groups = uris.groupBy { isImage(it) } - val images = groups[true] ?: emptyList() + val groups = uris.groupBy { isImage(it) || isVideoUri(it) } + val media = groups[true] ?: emptyList() val files = groups[false] ?: emptyList() - if (images.isNotEmpty()) { - CoroutineScope(Dispatchers.IO).launch { processPickedMedia(images, null) } + if (media.isNotEmpty()) { + CoroutineScope(Dispatchers.IO).launch { processPickedMedia(media, null) } } else if (files.isNotEmpty()) { processPickedFile(uris.first(), null) } } +private fun isVideoUri(uri: URI): Boolean { + val name = getFileName(uri)?.lowercase() ?: return false + return name.endsWith(".mov") || name.endsWith(".avi") || name.endsWith(".mp4") || + name.endsWith(".mpg") || name.endsWith(".mpeg") || name.endsWith(".mkv") +} + fun MutableState.processPickedFile(uri: URI?, text: String?) { if (uri != null) { val fileSize = getFileSize(uri) diff --git a/plans/2026-05-26-fix-video-drag-and-drop.md b/plans/2026-05-26-fix-video-drag-and-drop.md new file mode 100644 index 0000000000..16258dea7f --- /dev/null +++ b/plans/2026-05-26-fix-video-drag-and-drop.md @@ -0,0 +1,44 @@ +# Fix desktop drag-and-drop of videos attached as files + +Branch: `nd/fix-video-drag-and-drop` · base: `master`. + +## Problem + +On desktop, dragging a video file into a chat attaches it as a generic file (paperclip + filename) instead of as a video (thumbnail + duration). Dragging an image works. Picking the same video via "Gallery → Video" attaches it correctly — so only the drag-and-drop routing is wrong. + +## Fix + +One file: `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt`. Recognise videos as media in `onFilesAttached`'s classifier. + +```diff + fun MutableState.onFilesAttached(uris: List) { +- val groups = uris.groupBy { isImage(it) } +- val images = groups[true] ?: emptyList() ++ val groups = uris.groupBy { isImage(it) || isVideoUri(it) } ++ val media = groups[true] ?: emptyList() + val files = groups[false] ?: emptyList() +- if (images.isNotEmpty()) { +- CoroutineScope(Dispatchers.IO).launch { processPickedMedia(images, null) } ++ if (media.isNotEmpty()) { ++ CoroutineScope(Dispatchers.IO).launch { processPickedMedia(media, null) } + } else if (files.isNotEmpty()) { + processPickedFile(uris.first(), null) + } + } ++ ++private fun isVideoUri(uri: URI): Boolean { ++ val name = getFileName(uri)?.lowercase() ?: return false ++ return name.endsWith(".mov") || name.endsWith(".avi") || name.endsWith(".mp4") || ++ name.endsWith(".mpg") || name.endsWith(".mpeg") || name.endsWith(".mkv") ++} +``` + +Total diff: 1 file, +11 / −5. + +## Cause + +`onFilesAttached` classified URIs by `isImage` only — non-images (including videos) fell through to `processPickedFile`, producing a `FilePreview`. The downstream `processPickedMedia` already handles video correctly (its `else` branch builds `UploadContent.Video`); the classifier above it just never reached that branch. The existing `isVideo` in `Videos.desktop.kt` is `desktopMain`-only and not visible from `ComposeView.kt` in `commonMain` — the structural gap that left the classifier video-blind. The inline `isVideoUri` uses the cross-platform `getFileName`, so the same fix also corrects the paste path (`onFilesPasted` at `ComposeView.kt:1378`). + +## Risk + +One file, no interface change. Image and non-media drops are bit-identical. Video extension list is now duplicated with `Videos.desktop.kt`; adding a new format means updating both — accepted as the cost of a single-file fix. iOS unaffected. Rollback: revert the commit. From 503d091ec4c46fecc43da087c76ed1376131c827 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Mon, 8 Jun 2026 11:13:06 +0000 Subject: [PATCH 019/200] android, desktop, ios: show chat name in delete / leave / clear confirmation dialogs (#7021) * android, desktop, ios: show chat name in delete / leave / clear confirmation dialogs The dialogs confirming delete contact, delete/leave group/channel and clear chat now show the chat's name on its own line above the existing warning, so the user can see which chat the destructive action will affect. Pure code change: no new translation strings, no signature changes, no new helpers. The name is read via existing displayName accessors on GroupInfo / Contact / ChatInfo. clearNoteFolderDialog is intentionally unchanged - the notes folder is a single-instance object and its existing warning already identifies it. * android, desktop, ios: also show chat name when deleting pending connection deleteContactConnectionAlert was missed in the original inventory. Same pattern as the other dispatchers - prepend the connection's displayName on its own line above the existing warning - so a user who set a custom name on a pending connection can see which one they are about to delete. * android: use
instead of \n for newline in delete confirmation dialog body On Android, the alert body goes through HtmlCompat.fromHtml which treats the input as HTML and collapses literal \n to a single space - so "Tech Talk\n\nGroup will be deleted..." rendered as "Tech Talk Group will be deleted...". Switch to

, which both HtmlCompat (Android) and the Desktop parser at Utils.desktop.kt:75 correctly render as a newline. * android, desktop: skip HTML parsing for delete confirmation dialog text Add parseHtml: Boolean = true parameter to showAlertDialog and showAlertDialogButtonsColumn; when false, the body text is wrapped as AnnotatedString and routed through the existing AnnotatedString AlertContent overload, bypassing escapedHtmlToAnnotatedString entirely. The 8 dispatchers that embed the user-controlled chat displayName now opt out (parseHtml = false). This means: - displayName is rendered as literal text - no HTML interpretation, so a contact whose alias is "X" or "
Kotlin-only workaround) - removes coupling to escapedHtmlToAnnotatedString in this path * android, desktop: shorten parseHtml comment in AlertManager * remove extra text --------- Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/Views/Chat/ChatInfoView.swift | 5 +- .../Views/Chat/Group/GroupChatInfoView.swift | 6 +- .../Views/ChatList/ChatListNavLink.swift | 14 +- .../simplex/common/views/chat/ChatInfoView.kt | 14 +- .../views/chat/group/GroupChatInfoView.kt | 6 +- .../views/chatlist/ChatListNavLinkView.kt | 3 +- .../common/views/helpers/AlertManager.kt | 21 +- .../delete-leave-dialog-with-profile-impl.md | 323 ++++++++++++++++++ plans/delete-leave-dialog-with-profile.md | 249 ++++++++++++++ 9 files changed, 620 insertions(+), 21 deletions(-) create mode 100644 plans/delete-leave-dialog-with-profile-impl.md create mode 100644 plans/delete-leave-dialog-with-profile.md diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index c17d8e23a8..29c87ff7e0 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -577,7 +577,7 @@ struct ChatInfoView: View { private func clearChatAlert() -> Alert { Alert( title: Text("Clear conversation?"), - message: Text("All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you."), + message: Text(chat.chatInfo.displayName + "\n\n") + Text("All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you."), primaryButton: .destructive(Text("Clear")) { Task { await clearChat(chat) @@ -1185,6 +1185,7 @@ private func deleteContactOrConversationDialog( showActionSheet(SomeActionSheet( actionSheet: ActionSheet( title: Text("Delete contact?"), + message: Text(contact.displayName), buttons: [ .destructive(Text("Only delete conversation")) { deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: .messages, dismissToChatList, showAlert) @@ -1331,6 +1332,7 @@ private func deleteContactWithoutConversation( showActionSheet(SomeActionSheet( actionSheet: ActionSheet( title: Text("Confirm contact deletion?"), + message: Text(contact.displayName), buttons: [ .destructive(Text("Delete and notify contact")) { deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: .full(notify: true), dismissToChatList, showAlert) @@ -1355,6 +1357,7 @@ private func deleteNotReadyContact( showActionSheet(SomeActionSheet( actionSheet: ActionSheet( title: Text("Confirm contact deletion?"), + message: Text(contact.displayName), buttons: [ .destructive(Text("Confirm")) { deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: .full(notify: false), dismissToChatList, showAlert) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 0a448a2772..1353f590fa 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -845,7 +845,7 @@ struct GroupChatInfoView: View { let label: LocalizedStringKey = groupInfo.useRelays ? "Delete channel?" : groupInfo.businessChat == nil ? "Delete group?" : "Delete chat?" return Alert( title: Text(label), - message: deleteGroupAlertMessage(groupInfo), + message: Text(chat.chatInfo.displayName + "\n\n") + deleteGroupAlertMessage(groupInfo), primaryButton: .destructive(Text("Delete")) { Task { do { @@ -867,7 +867,7 @@ struct GroupChatInfoView: View { private func clearChatAlert() -> Alert { Alert( title: Text("Clear conversation?"), - message: Text("All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you."), + message: Text(chat.chatInfo.displayName + "\n\n") + Text("All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you."), primaryButton: .destructive(Text("Clear")) { Task { await clearChat(chat) @@ -889,7 +889,7 @@ struct GroupChatInfoView: View { ) return Alert( title: Text(titleLabel), - message: Text(messageLabel), + message: Text(chat.chatInfo.displayName + "\n\n") + Text(messageLabel), primaryButton: .destructive(Text("Leave")) { Task { await leaveGroup(chat.chatInfo.apiId) diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index b4590fc124..76734dcb42 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -568,7 +568,7 @@ struct ChatListNavLink: View { let label: LocalizedStringKey = groupInfo.useRelays ? "Delete channel?" : groupInfo.businessChat == nil ? "Delete group?" : "Delete chat?" return Alert( title: Text(label), - message: deleteGroupAlertMessage(groupInfo), + message: Text(chat.chatInfo.displayName + "\n\n") + deleteGroupAlertMessage(groupInfo), primaryButton: .destructive(Text("Delete")) { Task { await deleteChat(chat) } }, @@ -600,7 +600,7 @@ struct ChatListNavLink: View { private func clearChatAlert() -> Alert { Alert( title: Text("Clear conversation?"), - message: Text("All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you."), + message: Text(chat.chatInfo.displayName + "\n\n") + Text("All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you."), primaryButton: .destructive(Text("Clear")) { Task { await clearChat(chat) } }, @@ -630,7 +630,7 @@ struct ChatListNavLink: View { ) return Alert( title: Text(titleLabel), - message: Text(messageLabel), + message: Text(chat.chatInfo.displayName + "\n\n") + Text(messageLabel), primaryButton: .destructive(Text("Leave")) { Task { await leaveGroup(groupInfo.groupId) } }, @@ -701,10 +701,10 @@ func rejectContactRequestAlert(_ contactRequestId: Int64) -> Alert { func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection, showError: @escaping (ErrorAlert) -> Void, success: @escaping () -> Void = {}) -> Alert { Alert( title: Text("Delete pending connection?"), - message: - contactConnection.initiated - ? Text("The contact you shared this link with will NOT be able to connect!") - : Text("The connection you accepted will be cancelled!"), + message: Text(contactConnection.displayName + "\n\n") + + (contactConnection.initiated + ? Text("The contact you shared this link with will NOT be able to connect!") + : Text("The connection you accepted will be cancelled!")), primaryButton: .destructive(Text("Delete")) { Task { do { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index dce1b6ea33..13be8b1d71 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -248,6 +248,8 @@ fun deleteContactDialog(chat: Chat, chatModel: ChatModel, close: (() -> Unit)? = private fun deleteContactOrConversationDialog(chat: Chat, contact: Contact, chatModel: ChatModel, close: (() -> Unit)?) { AlertManager.shared.showAlertDialogButtonsColumn( title = generalGetString(MR.strings.delete_contact_question), + text = contact.displayName, + parseHtml = false, buttons = { Column { // Only delete conversation @@ -306,7 +308,8 @@ private fun deleteActiveContactDialog(chat: Chat, contact: Contact, chatModel: C AlertManager.shared.showAlertDialogButtonsColumn( title = generalGetString(MR.strings.delete_contact_question), - text = generalGetString(MR.strings.delete_contact_cannot_undo_warning), + text = "${contact.displayName}\n\n${generalGetString(MR.strings.delete_contact_cannot_undo_warning)}", + parseHtml = false, buttons = { Column { // Keep conversation toggle @@ -361,7 +364,8 @@ private fun deleteActiveContactDialog(chat: Chat, contact: Contact, chatModel: C private fun deleteContactWithoutConversation(chat: Chat, chatModel: ChatModel, close: (() -> Unit)?) { AlertManager.shared.showAlertDialogButtonsColumn( title = generalGetString(MR.strings.confirm_delete_contact_question), - text = generalGetString(MR.strings.delete_contact_cannot_undo_warning), + text = "${chat.chatInfo.displayName}\n\n${generalGetString(MR.strings.delete_contact_cannot_undo_warning)}", + parseHtml = false, buttons = { Column { // Delete and notify contact @@ -417,7 +421,8 @@ private fun deleteContactWithoutConversation(chat: Chat, chatModel: ChatModel, c private fun deleteNotReadyContact(chat: Chat, chatModel: ChatModel, close: (() -> Unit)?) { AlertManager.shared.showAlertDialogButtonsColumn( title = generalGetString(MR.strings.confirm_delete_contact_question), - text = generalGetString(MR.strings.delete_contact_cannot_undo_warning), + text = "${chat.chatInfo.displayName}\n\n${generalGetString(MR.strings.delete_contact_cannot_undo_warning)}", + parseHtml = false, buttons = { // Confirm SectionItemView({ @@ -492,7 +497,8 @@ fun deleteContact(chat: Chat, chatModel: ChatModel, close: (() -> Unit)?, chatDe fun clearChatDialog(chat: Chat, close: (() -> Unit)? = null) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.clear_chat_question), - text = generalGetString(MR.strings.clear_chat_warning), + text = "${chat.chatInfo.displayName}\n\n${generalGetString(MR.strings.clear_chat_warning)}", + parseHtml = false, confirmText = generalGetString(MR.strings.clear_verb), onConfirm = { controller.clearChat(chat, close) }, destructive = true, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index 3e9b5f6f48..c88e3b2bff 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -199,7 +199,8 @@ fun deleteGroupDialog(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, cl } AlertManager.shared.showAlertDialog( title = generalGetString(titleId), - text = generalGetString(messageId), + text = "${groupInfo.displayName}\n\n${generalGetString(messageId)}", + parseHtml = false, confirmText = generalGetString(MR.strings.delete_verb), onConfirm = { withBGApi { @@ -233,7 +234,8 @@ fun leaveGroupDialog(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, cl MR.strings.you_will_stop_receiving_messages_from_this_chat_chat_history_will_be_preserved AlertManager.shared.showAlertDialog( title = generalGetString(titleId), - text = generalGetString(messageId), + text = "${groupInfo.displayName}\n\n${generalGetString(messageId)}", + parseHtml = false, confirmText = generalGetString(MR.strings.leave_group_button), onConfirm = { withLongRunningApi(60_000) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index d3533bbd02..7dfb52063e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -772,10 +772,11 @@ fun rejectContactRequest(rhId: Long?, contactRequestId: Long, chatModel: ChatMod fun deleteContactConnectionAlert(rhId: Long?, connection: PendingContactConnection, chatModel: ChatModel, onSuccess: () -> Unit) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.delete_pending_connection__question), - text = generalGetString( + text = "${connection.displayName}\n\n" + generalGetString( if (connection.initiated) MR.strings.contact_you_shared_link_with_wont_be_able_to_connect else MR.strings.connection_you_accepted_will_be_cancelled ), + parseHtml = false, confirmText = generalGetString(MR.strings.delete_verb), onConfirm = { withBGApi { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt index 3d670d1c43..1774cf4394 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt @@ -75,6 +75,8 @@ class AlertManager { onDismissRequest: (() -> Unit)? = null, hostDevice: Pair? = null, belowTextContent: @Composable (() -> Unit) = {}, + // When false, [text] is rendered as literal text — use for user-controlled content. + parseHtml: Boolean = true, buttons: @Composable () -> Unit, ) { showAlert { @@ -82,8 +84,14 @@ class AlertManager { onDismissRequest = { onDismissRequest?.invoke(); if (dismissible) hideAlert() }, title = alertTitle(title), buttons = { - AlertContent(text, hostDevice, extraPadding = true, textAlign = textAlign, belowTextContent = belowTextContent) { - buttons() + if (parseHtml) { + AlertContent(text, hostDevice, extraPadding = true, textAlign = textAlign, belowTextContent = belowTextContent) { + buttons() + } + } else { + AlertContent(text?.let { AnnotatedString(it) }, hostDevice, extraPadding = true) { + buttons() + } } }, shape = RoundedCornerShape(corner = CornerSize(25.dp)) @@ -122,13 +130,15 @@ class AlertManager { onDismissRequest: (() -> Unit)? = null, destructive: Boolean = false, hostDevice: Pair? = null, + // When false, [text] is rendered as literal text — use for user-controlled content. + parseHtml: Boolean = true, ) { showAlert { AlertDialog( onDismissRequest = { onDismissRequest?.invoke(); hideAlert() }, title = alertTitle(title), buttons = { - AlertContent(text, hostDevice, true) { + val buttonRow: @Composable () -> Unit = { Row( Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING), horizontalArrangement = Arrangement.SpaceBetween @@ -149,6 +159,11 @@ class AlertManager { }, Modifier.focusRequester(focusRequester)) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) } } } + if (parseHtml) { + AlertContent(text, hostDevice, true, content = buttonRow) + } else { + AlertContent(text?.let { AnnotatedString(it) }, hostDevice, true, content = buttonRow) + } }, shape = RoundedCornerShape(corner = CornerSize(25.dp)) ) diff --git a/plans/delete-leave-dialog-with-profile-impl.md b/plans/delete-leave-dialog-with-profile-impl.md new file mode 100644 index 0000000000..860d555d36 --- /dev/null +++ b/plans/delete-leave-dialog-with-profile-impl.md @@ -0,0 +1,323 @@ +# Implementation plan — chat name on its own line in delete/leave/clear dialogs + +Follows the product spec in +[`delete-leave-dialog-with-profile.md`](./delete-leave-dialog-with-profile.md). + +Pure code change — zero string additions, zero new helpers, zero +signature changes. Each call site edits one argument: the `text =` / +`message:` value gains `"${displayName}\n\n"` prepended to the +existing localized warning (or, where there is no current body, the +chat name becomes the new body). + +One commit per platform. + +## Commit 1 — Kotlin + +**Files touched:** +- `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt` + — adds `parseHtml: Boolean = true` to `showAlertDialog` and + `showAlertDialogButtonsColumn`. When `false`, the body text is wrapped + as `AnnotatedString` and routed through the existing AnnotatedString + `AlertContent` overload, which does NOT call + `escapedHtmlToAnnotatedString`. Default stays `true` so existing + callers are unaffected. +- `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt` +- `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt` +- `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt` + — adds the previously-missed `deleteContactConnectionAlert` + dispatcher to the coverage (pending contact connections). + +Every Kotlin call site that prepends the chat name sets +`parseHtml = false`, so `displayName` is never HTML-interpreted. + +### 1.1 — `deleteGroupDialog` (`GroupChatInfoView.kt:182`) + +```diff + fun deleteGroupDialog(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { + val chatInfo = chat.chatInfo + val titleId = /* unchanged */ + val messageId = /* unchanged */ + AlertManager.shared.showAlertDialog( + title = generalGetString(titleId), +- text = generalGetString(messageId), ++ text = "${groupInfo.displayName}\n\n${generalGetString(messageId)}", + confirmText = generalGetString(MR.strings.delete_verb), + onConfirm = { /* unchanged */ }, + destructive = true, + ) + } +``` + +### 1.2 — `leaveGroupDialog` (`GroupChatInfoView.kt:222`) + +```diff + fun leaveGroupDialog(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { + val titleId = /* unchanged */ + val messageId = /* unchanged */ + AlertManager.shared.showAlertDialog( + title = generalGetString(titleId), +- text = generalGetString(messageId), ++ text = "${groupInfo.displayName}\n\n${generalGetString(messageId)}", + confirmText = generalGetString(MR.strings.leave_group_button), + onConfirm = { /* unchanged */ }, + destructive = true, + ) + } +``` + +Signature unchanged. No caller updates. `groupInfo.displayName` is +already available on the existing parameter (`ChatModel.kt:2142`). + +### 1.3 — `clearChatDialog` (`ChatInfoView.kt:492`) + +```diff + fun clearChatDialog(chat: Chat, close: (() -> Unit)? = null) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.clear_chat_question), +- text = generalGetString(MR.strings.clear_chat_warning), ++ text = "${chat.chatInfo.displayName}\n\n${generalGetString(MR.strings.clear_chat_warning)}", + confirmText = generalGetString(MR.strings.clear_verb), + onConfirm = { controller.clearChat(chat, close) }, + destructive = true, + ) + } +``` + +### 1.4 — Contact-delete dispatchers (`ChatInfoView.kt`) + +Four functions. `deleteContactOrConversationDialog` (line 248) has +no existing `text =`, so the chat name becomes the new body. The +other three already have a `text =`, so the name is prepended. + +All four already have `contact: Contact` as a parameter, so +`contact.displayName` is used directly (same value as +`chat.chatInfo.displayName` for a direct chat, shorter expression). + +```diff + // deleteContactOrConversationDialog — line 248 + private fun deleteContactOrConversationDialog(chat: Chat, contact: Contact, chatModel: ChatModel, close: (() -> Unit)?) { + AlertManager.shared.showAlertDialogButtonsColumn( + title = generalGetString(MR.strings.delete_contact_question), ++ text = contact.displayName, + buttons = { /* unchanged */ } + ) + } +``` + +```diff + // deleteActiveContactDialog — line 304 + private fun deleteActiveContactDialog(chat: Chat, contact: Contact, chatModel: ChatModel, close: (() -> Unit)? = null) { + val contactDeleteMode = mutableStateOf(ContactDeleteMode.Full()) + AlertManager.shared.showAlertDialogButtonsColumn( + title = generalGetString(MR.strings.delete_contact_question), +- text = generalGetString(MR.strings.delete_contact_cannot_undo_warning), ++ text = "${contact.displayName}\n\n${generalGetString(MR.strings.delete_contact_cannot_undo_warning)}", + buttons = { /* unchanged */ } + ) + } +``` + +Same diff for `deleteContactWithoutConversation` (line 361) and +`deleteNotReadyContact` (line 417) — both use +`delete_contact_cannot_undo_warning`. Neither takes `contact` as +a parameter, so the name is read via `chat.chatInfo.displayName` +(which resolves to `contact.displayName` because these dispatchers +are only reached for `ChatInfo.Direct` chats). Their titles +(`confirm_delete_contact_question`) stay unchanged — the +not-ready / no-conversation paths keep their distinct title. + +## Commit 2 — iOS + +**Files touched:** +- `apps/ios/Shared/Views/ChatList/ChatListNavLink.swift` +- `apps/ios/Shared/Views/Chat/ChatInfoView.swift` +- `apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift` + +### 2.1 — `deleteGroupAlert` (two locations) + +`Views/Chat/Group/GroupChatInfoView.swift:835` and +`Views/ChatList/ChatListNavLink.swift:567` get the same diff. +`deleteGroupAlertMessage(_:)` already returns a `Text` containing +the localized warning — concatenate to it. + +```diff + private func deleteGroupAlert() -> Alert { + let label: LocalizedStringKey = /* unchanged */ + return Alert( + title: Text(label), +- message: deleteGroupAlertMessage(groupInfo), ++ message: Text(chat.chatInfo.displayName) + Text(verbatim: "\n\n") + deleteGroupAlertMessage(groupInfo), + primaryButton: .destructive(Text("Delete")) { /* unchanged */ }, + secondaryButton: .cancel() + ) + } +``` + +`Text(chat.chatInfo.displayName)` resolves to `Text(_ content: some StringProtocol)` +(the runtime-string overload — no localization lookup, matches +codebase convention: `ChatView.swift:984`, `ChatInfoToolbar.swift:49`, +`SettingsView.swift:540`). `Text(verbatim: "\n\n")` is the literal +separator, matching the codebase convention that reserves +`verbatim:` for fixed punctuation (`ContextItemView.swift:88` is +the textbook example: `Text(chatLink.displayName) + Text(verbatim: " - ")`). +The third term `Text(messageLabel)` keeps the existing +`LocalizedStringKey` lookup. + +### 2.2 — `leaveGroupAlert` (two locations) + +`Views/Chat/Group/GroupChatInfoView.swift:872` and +`Views/ChatList/ChatListNavLink.swift:622`: + +```diff + private func leaveGroupAlert() -> Alert { + let titleLabel: LocalizedStringKey = /* unchanged */ + let messageLabel: LocalizedStringKey = /* unchanged */ + return Alert( + title: Text(titleLabel), +- message: Text(messageLabel), ++ message: Text(chat.chatInfo.displayName) + Text(verbatim: "\n\n") + Text(messageLabel), + primaryButton: .destructive(Text("Leave")) { /* unchanged */ }, + secondaryButton: .cancel() + ) + } +``` + +### 2.3 — `clearChatAlert` (three locations) + +`Views/Chat/ChatInfoView.swift:577`, +`Views/Chat/Group/GroupChatInfoView.swift:858`, +`Views/ChatList/ChatListNavLink.swift:600`: + +```diff + private func clearChatAlert() -> Alert { + Alert( + title: Text("Clear conversation?"), +- message: Text("All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you."), ++ message: Text(chat.chatInfo.displayName) + Text(verbatim: "\n\n") + Text("All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you."), + primaryButton: .destructive(Text("Clear")) { /* unchanged */ }, + secondaryButton: .cancel() + ) + } +``` + +### 2.4 — Contact-delete action sheets + +Three functions in `Views/Chat/ChatInfoView.swift`. None currently +pass `message:` to `ActionSheet`; we add it. `ActionSheet`'s +`message:` is an optional second parameter that SwiftUI already +supports. + +All three functions have `contact: Contact` in scope. Use bare +`Text(contact.displayName)` (resolves to the `StringProtocol` +overload, no localization lookup, matches codebase convention). +Add only the name as `message:` — these ActionSheets had no +message before, so adding any additional warning would be new +behavior beyond the stated goal. + +**`deleteContactOrConversationDialog`** (line 1177): + +```diff + private func deleteContactOrConversationDialog( + _ chat: Chat, _ contact: Contact, _ dismissToChatList: Bool, + _ showAlert: @escaping (SomeAlert) -> Void, + _ showActionSheet: @escaping (SomeActionSheet) -> Void, + _ showSheetContent: @escaping (SomeSheet) -> Void + ) { + showActionSheet(SomeActionSheet( + actionSheet: ActionSheet( + title: Text("Delete contact?"), ++ message: Text(contact.displayName), + buttons: [ /* unchanged */ ] + ), + id: "deleteContactOrConversationDialog" + )) + } +``` + +**`deleteContactWithoutConversation`** (line 1324): + +```diff + showActionSheet(SomeActionSheet( + actionSheet: ActionSheet( + title: Text("Confirm contact deletion?"), ++ message: Text(contact.displayName), + buttons: [ /* unchanged */ ] + ), + id: "deleteContactWithoutConversation" + )) +``` + +**`deleteNotReadyContact`** (line 1348) — same: + +```diff + showActionSheet(SomeActionSheet( + actionSheet: ActionSheet( + title: Text("Confirm contact deletion?"), ++ message: Text(contact.displayName), + buttons: [ /* unchanged */ ] + ), + id: "deleteNotReadyContact" + )) +``` + +### 2.5 — `DeleteActiveContactDialog` sheet (line 1282) unchanged + +The secondary multi-option sheet is reached only after the user +confirms "Delete contact" in the previous action sheet — which now +shows the name. The sheet itself remains as-is. + +## Verification + +For each platform, exercise every entry point and confirm the +body reads `` on its own line followed by the existing +warning: + +- Android & Desktop: + - Chat list swipe — direct contact, group, channel, business chat + → delete / clear / leave. (Note folder's clear dialog is + intentionally unchanged — `clearNoteFolderDialog` excluded.) + - Chat info screens — "Delete contact" / "Delete group" / "Delete + channel" / "Clear conversation" / "Leave …" rows. + - Contact list (`ContactListNavView.kt:148`) — "Delete contact" + action shows the name in entry-point dialog and toggle dialog. + - Multi-option contact-delete path: entry dialog (now has a name + body where it had none) → toggle dialog (name above the + warning) → success. +- iOS: + - Same matrix from chat list swipe and chat info screens. + - Action-sheet contact-delete paths show the name as the + `message:` line on iPhone and iPad. + +Edge cases: + +- Long chat name — alert containers wrap automatically; the body + occupies 3+ lines. Confirm with a chat renamed to ~40 characters. +- Special characters (emoji, RTL, double quotes) — render literally + via string interpolation, no format-substitution involved. +- Empty `displayName` — does not occur in practice (`NamedChat` + enforces non-empty via `localAlias.ifEmpty { profile.displayName }`). + +Diff-level checks: + +- `git diff '*strings.xml' '*Localizable.strings'` returns zero + hunks. Pure code change. +- `git diff --stat` shows ~5 files total: two Kotlin dispatcher + files, three iOS view files. +- Cancel/confirm flows behave exactly as before — same API calls, + same model updates, same navigation. + +## Out of scope + +- Profile picture / avatar in dialogs — excluded by product decision. +- Refactoring the iOS duplication between `ChatListNavLink` and + `GroupChatInfoView` / `ChatInfoView` (pre-existing `// TODO` at + `GroupChatInfoView.swift:834`). +- Pre-existing wording divergence between Kotlin's "Clear chat?" + and iOS's "Clear conversation?". Both platforms keep their + titles. +- "Delete invitation" at `ChatListNavLink.swift:236` — has no + confirmation dialog (direct call to `deleteChat(chat)`); nothing + to modify. +- Bolding the chat name. SwiftUI `Text + Text` supports `.bold()` + on the first term, but Jetpack Compose `AlertDialog` text is a + single unstyled string — keeping both unstyled preserves parity. diff --git a/plans/delete-leave-dialog-with-profile.md b/plans/delete-leave-dialog-with-profile.md new file mode 100644 index 0000000000..a05fb66532 --- /dev/null +++ b/plans/delete-leave-dialog-with-profile.md @@ -0,0 +1,249 @@ +# Show chat name in delete / leave / clear confirmation dialogs + +## Goal + +The current delete-contact, delete-group, delete-channel, leave-group, +leave-channel and clear-chat confirmations are generic. From a long +chat list, swiping on a row and triggering one of these actions opens +a dialog whose title is "Delete group?", "Leave channel?", "Clear +conversation?" — with no indication of *which* chat is the target. A +user can easily act on the wrong chat. + +The fix: include the chat's display name in the dialog body, on a line +of its own above the existing warning text. Nothing else changes — +same title, same warning text, same buttons, same colors, same dialog +shape. No profile picture, no layout changes, no new helpers, no new +translation strings. + +We deliberately do NOT reuse the open-chat-link alert layout (centered +profile image + name + open-chat button). That layout is the *invite* +flow's identity; repurposing it for destructive confirmations would +confuse the two flows visually. The minimum change that solves the +"which chat?" problem is putting the name in the body text. + +## Why body, not title; why no new strings + +The title carries the action ("Delete group?", "Leave channel?"). The +body carries the consequences ("Group will be deleted for all +members…"). The chat name belongs with the body — it is the subject +of the consequence, not part of the question. + +Adding the name to the title would require new format-string variants +(`delete_group_named_question` etc.) and per-locale re-translation. +Putting the name on its own line in the body is a pure code change — +the existing translated warnings are concatenated with the chat name +in code: + +``` +Tech Talk + +Group will be deleted for all members - this cannot be undone! +``` + +The display name appears first because the user wants to confirm +*which* chat before reading *what* will happen. The blank line between +the name and the warning makes the name visually distinct. + +## Current state + +### Multiplatform (Kotlin / Android / Desktop) + +All eight dialogs go through `AlertManager.shared.showAlertDialog` or +`showAlertDialogButtonsColumn`: + +- `deleteGroupDialog` — `views/chat/group/GroupChatInfoView.kt:182` +- `leaveGroupDialog` — `views/chat/group/GroupChatInfoView.kt:222` +- `clearChatDialog` — `views/chat/ChatInfoView.kt:492` +- `deleteContactOrConversationDialog` — `views/chat/ChatInfoView.kt:248` +- `deleteActiveContactDialog` — `views/chat/ChatInfoView.kt:304` +- `deleteContactWithoutConversation` — `views/chat/ChatInfoView.kt:361` +- `deleteNotReadyContact` — `views/chat/ChatInfoView.kt:417` +- `deleteContactConnectionAlert` — `views/chatlist/ChatListNavLinkView.kt:772` + (deletes a pending contact connection; takes a `PendingContactConnection` + whose `displayName` reflects any custom name the user set) + +Call sites (chat-info screens, chat-list swipe / overflow, contact +list) funnel through these dispatcher functions. + +### iOS (Swift) + +Two SwiftUI patterns are used: + +- SwiftUI `Alert` with `primaryButton: .destructive` / `.cancel()`: + - `deleteGroupAlert` — `Views/ChatList/ChatListNavLink.swift:567`, + `Views/Chat/Group/GroupChatInfoView.swift:835` + - `leaveGroupAlert` — `Views/ChatList/ChatListNavLink.swift:622`, + `Views/Chat/Group/GroupChatInfoView.swift:872` + - `clearChatAlert` — `Views/ChatList/ChatListNavLink.swift:600`, + `Views/Chat/ChatInfoView.swift:577`, + `Views/Chat/Group/GroupChatInfoView.swift:858` +- SwiftUI `ActionSheet`: + - `deleteContactOrConversationDialog` — + `Views/Chat/ChatInfoView.swift:1177` + - `deleteContactWithoutConversation` — + `Views/Chat/ChatInfoView.swift:1324` + - `deleteNotReadyContact` — `Views/Chat/ChatInfoView.swift:1348` + +`Alert(message:)` accepts `Text`, and `ActionSheet(message:)` (an +existing optional parameter not used today) accepts `Text` too — so +the name can be added by composing the existing message string with +`"\n\n"` and the chat name. No widget changes. + +## Design + +| Dialog | Body today | Body after | +|---|---|---| +| Delete group | `Group will be deleted for all members – this cannot be undone!` | `Tech Talk` + blank line + existing text | +| Delete channel | `Channel will be deleted for all subscribers – this cannot be undone!` | `SimpleX news` + blank line + existing text | +| Leave group | `You will stop receiving messages from this group. …` | `Tech Talk` + blank line + existing text | +| Clear chat | `All messages will be deleted – this cannot be undone! …` | `Alice` + blank line + existing text | +| Delete contact (entry sheet) | *(no body today — title only + buttons)* | `Alice` (becomes the body) | +| Delete contact (active variant) | `Contact will be deleted – this cannot be undone!` | `Alice` + blank line + existing text | +| Confirm contact deletion (not-ready / no-conversation) | `Contact will be deleted – this cannot be undone!` | `Alice` + blank line + existing text | + +Title text is unchanged in every case. Existing titles +(`delete_contact_question`, `confirm_delete_contact_question`, etc.) +keep their semantic distinction — the "Confirm contact deletion?" +title still appears for the not-ready / no-conversation paths. + +### Which name to use: `displayName`, not `chatViewName` + +The chat list row labels chats with `cInfo.chatViewName` +(`ChatPreviewView.kt:87`), defined as: + +```kotlin +val chatViewName: String + get() = localAlias.ifEmpty { displayName + (if (fullName == "" || fullName == displayName) "" else " / $fullName") } +``` + +The dialog uses `chatInfo.displayName` (and `groupInfo.displayName` +for the leave dialog). For most chats these are identical: + +- If `localAlias` is set, both resolve to the alias. +- If `displayName == fullName` (or `fullName` is empty), both resolve + to `displayName`. + +For a contact with distinct display name and full name (no alias), +the row would show `alice / Alice Smith` while the dialog shows +`alice`. Acceptable: `displayName` is the recognizable identifier, +shorter, and the dialog format (single line above the warning) +benefits from concision. Two-part identifiers in the dialog would +crowd the layout. + +### `clearNoteFolderDialog` is excluded + +The local notes folder is a single-instance object — there is only +one per user — and its existing warning text already names it +unambiguously. Adding the display name on its own line would be +pure redundancy. Skipped. + +## Changes + +### Multiplatform (Kotlin) + +Each dispatcher function changes one argument: the `text =` parameter +passed to `AlertManager.shared.showAlertDialog` / +`showAlertDialogButtonsColumn`. The new value is the chat name + two +newlines + the existing message text: + +```kotlin +text = "${chatInfo.displayName}\n\n${generalGetString(messageId)}", +parseHtml = false, +``` + +`parseHtml = false` is a new boolean parameter added to both alert +helpers. It bypasses `escapedHtmlToAnnotatedString` so the +user-controlled `displayName` is rendered as literal text, never +interpreted as HTML markup (``, ``, `&`, etc.). The default +remains `true`; only our delete-confirmation dispatchers opt out. + +For `leaveGroupDialog` the source is `groupInfo.displayName` (the +function already takes `groupInfo` — no signature change needed, +no caller updates needed). + +For `deleteGroupDialog`, also `groupInfo.displayName`, for consistency +with `leaveGroupDialog` (both have `groupInfo` already in scope). + +For `deleteContactOrConversationDialog`, which has no `text =` +parameter today, add `text = chatInfo.displayName` (no concatenation +needed — the dialog had no body text before). + +### iOS + +Each of the eight call sites changes one argument: the `message:` +parameter passed to `Alert(…)` or `ActionSheet(…)`. The new value +composes the chat name with the existing localized message string: + +```swift +message: Text("\(chat.chatInfo.displayName)\n\n\(existingMessage)"), +``` + +For the three `ActionSheet` sites that have no `message:` today, add +`message: Text(chat.chatInfo.displayName)`. + +## Out of scope + +- Profile picture / avatar in any of these dialogs — excluded by + decision: the open-chat-link alert owns that layout, and reusing + it for destructive confirmations conflates two semantically + different flows. +- The pre-existing wording divergence between Kotlin's + `clear_chat_question` ("Clear chat?") and iOS's "Clear + conversation?". Both platforms keep their existing titles. +- Refactoring the iOS duplication between `ChatListNavLink` and + `GroupChatInfoView` / `ChatInfoView` (pre-existing `// TODO reuse + this and clearChatAlert with ChatInfoView` at + `GroupChatInfoView.swift:834`). +- "Delete invitation" at `ChatListNavLink.swift:236` — goes through + `deleteChat(chat)` directly with no confirmation dialog. No dialog + to modify. +- Bolding the chat name on its own line. SwiftUI `Text` concatenation + supports `.bold()`; Jetpack Compose `AlertDialog` text is a single + string. Keep both platforms unstyled for parity. + +## Verification + +Per platform, exercise every entry point and confirm the dialog body +reads `` on its own line followed by a blank line followed +by the existing warning: + +- Android & Desktop: + - Chat list swipe — direct contact, group, channel, business chat + → delete / clear / leave actions. (Note folder's clear dialog + is intentionally unchanged.) + - Chat info screens — "Delete contact" / "Delete group" / "Delete + channel" / "Clear conversation" / "Leave …" rows. + - Contact list (`ContactListNavView.kt:148`) — "Delete contact" + action. + - The multi-option contact-delete path: entry dialog (now has a + name body where it had none) → toggle dialog (name above the + warning) → success. +- iOS: + - Same matrix from chat list swipe and chat info screens. + - Action-sheet contact-delete paths show the name as the + `message:` line. + +Edge cases: + +- Long chat name (40+ chars) — alert containers wrap automatically; + body now occupies 3+ lines (name on 2, blank line, warning on 1+). + Confirm via a chat renamed to a long string. +- Special characters in name (emoji, RTL text, double quotes) — + render literally because the substitution is string concatenation, + not format expansion. A contact named `Bob "the builder"` displays + as `Bob "the builder"` on its own line. No quoting/escaping issue. +- Empty `displayName` would render an empty first line above the + warning. In practice `displayName` is non-empty (the `NamedChat` + interface enforces it via `localAlias.ifEmpty { profile.displayName }`); + no defensive trimming added. + +Diff-level checks: + +- `git diff strings.xml` and `git diff '*Localizable.strings'` show + zero hunks. The change is pure code. +- `git diff --stat` shows each platform touched in 2–4 files: + the dispatcher file(s) on Kotlin (`ChatInfoView.kt`, + `GroupChatInfoView.kt`), and the SwiftUI views holding the + alert/sheet builders on iOS. +- Behavior is unchanged. Cancel returns to the prior screen; + confirm performs the same destructive API call as before. From dd150e2d703e30e52496891ca6715712ecd01db8 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 9 Jun 2026 06:47:39 +0000 Subject: [PATCH 020/200] core: limit xftp file description (#7060) --- src/Simplex/Chat/Store/Files.hs | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Chat/Store/Files.hs b/src/Simplex/Chat/Store/Files.hs index 5289a3b304..6cb8e39bbc 100644 --- a/src/Simplex/Chat/Store/Files.hs +++ b/src/Simplex/Chat/Store/Files.hs @@ -79,6 +79,7 @@ import Data.Functor ((<&>)) import Data.Int (Int64) import Data.Maybe (fromMaybe, isJust, listToMaybe) import Data.Text (Text) +import qualified Data.Text as T import Data.Time (addUTCTime) import Data.Time.Clock (UTCTime (..), getCurrentTime, nominalDay) import Data.Type.Equality @@ -422,7 +423,7 @@ createRcvStandaloneFileTransfer db userId (CryptoFile filePath cfArgs_) fileSize createRcvFD_ :: DB.Connection -> UserId -> UTCTime -> FileDescr -> ExceptT StoreError IO RcvFileDescr createRcvFD_ db userId currentTs FileDescr {fileDescrText, fileDescrPartNo, fileDescrComplete} = do - when (fileDescrPartNo /= 0) $ throwError SERcvFileInvalidDescrPart + when (fileDescrPartNo /= 0 || not (rcvFileDescrWithinLimits fileDescrPartNo fileDescrText)) $ throwError SERcvFileInvalidDescrPart fileDescrId <- liftIO $ do DB.execute db @@ -450,8 +451,8 @@ appendRcvFD db userId fileId fd@FileDescr {fileDescrText, fileDescrPartNo, fileD fileDescrPartNo = rfdPNo, fileDescrComplete = rfdComplete } -> do - when (fileDescrPartNo /= rfdPNo + 1 || rfdComplete) $ throwError SERcvFileInvalidDescrPart let fileDescrText' = rfdText <> fileDescrText + when (fileDescrPartNo /= rfdPNo + 1 || rfdComplete || not (rcvFileDescrWithinLimits fileDescrPartNo fileDescrText')) $ throwError SERcvFileInvalidDescrPart liftIO $ DB.execute db @@ -463,6 +464,23 @@ appendRcvFD db userId fileId fd@FileDescr {fileDescrText, fileDescrPartNo, fileD (fileDescrText', fileDescrPartNo, BI fileDescrComplete, fileDescrId) pure RcvFileDescr {fileDescrId, fileDescrText = fileDescrText', fileDescrPartNo, fileDescrComplete} +-- Upper bounds sized above the largest legitimate received description; derived from simplexmq's +-- chunk tiers and redundancy, so a change there must revisit them. +-- ~1280 chunks max = maxFileSizeHard (5gb) / largest chunk tier (4mb). +-- ~150 chars per chunk in the description YAML = replicaId 24 + Ed25519 key 64 + SHA-256 digest 44 + chunkNo/colons. +-- Total ~0.18 MB at 1 replica/chunk (~0.42 MB at 3x), under the 1mb text and 1024 part caps. +maxRcvFileDescrParts :: Int +maxRcvFileDescrParts = 1024 + +maxRcvFileDescrTextLength :: Int +maxRcvFileDescrTextLength = 1024 * 1024 + +rcvFileDescrWithinLimits :: Int -> Text -> Bool +rcvFileDescrWithinLimits partNo descrText = + partNo >= 0 + && partNo <= maxRcvFileDescrParts + && T.length descrText <= maxRcvFileDescrTextLength + getRcvFileDescrByRcvFileId :: DB.Connection -> FileTransferId -> ExceptT StoreError IO RcvFileDescr getRcvFileDescrByRcvFileId db fileId = do liftIO (getRcvFileDescrByRcvFileId_ db fileId) >>= \case From 931881c86039764a12425ee7b5602c68527bb2a7 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:03:51 +0000 Subject: [PATCH 021/200] core: validate user chat ownership for chat tag and TTL APIs (#7063) --- src/Simplex/Chat/Library/Commands.hs | 31 ++++++++++++++++++---------- tests/ChatTests/Direct.hs | 20 ++++++++++++++++++ 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 0671b2e091..b72eae7c72 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -652,12 +652,14 @@ processChatCommand cxt nm = \case _ <- createChatTag db user emoji text CRChatTags user <$> getUserChatTags db user APISetChatTags (ChatRef cType chatId scope) tagIds -> withUser $ \user -> case cType of - CTDirect -> withFastStore' $ \db -> do - updateDirectChatTags db chatId (maybe [] L.toList tagIds) - CRTagsUpdated user <$> getUserChatTags db user <*> getDirectChatTags db chatId - CTGroup | isNothing scope -> withFastStore' $ \db -> do - updateGroupChatTags db chatId (maybe [] L.toList tagIds) - CRTagsUpdated user <$> getUserChatTags db user <*> getGroupChatTags db chatId + CTDirect -> withFastStore $ \db -> do + Contact {contactId} <- getContact db cxt user chatId + liftIO $ updateDirectChatTags db contactId (maybe [] L.toList tagIds) + CRTagsUpdated user <$> liftIO (getUserChatTags db user) <*> liftIO (getDirectChatTags db contactId) + CTGroup | isNothing scope -> withFastStore $ \db -> do + GroupInfo {groupId} <- getGroupInfo db cxt user chatId + liftIO $ updateGroupChatTags db groupId (maybe [] L.toList tagIds) + CRTagsUpdated user <$> liftIO (getUserChatTags db user) <*> liftIO (getGroupChatTags db groupId) _ -> throwCmdError "not supported" APIDeleteChatTag tagId -> withUser $ \user -> do withFastStore' $ \db -> deleteChatTag db user tagId @@ -1692,8 +1694,11 @@ processChatCommand cxt nm = \case CRServerOperatorConditions <$> getServerOperators db APISetChatTTL userId (ChatRef cType chatId scope) newTTL_ -> withUserId userId $ \user -> checkStoreNotChanged $ withChatLock "setChatTTL" $ do - (oldTTL_, globalTTL, ttlCount) <- withStore' $ \db -> - (,,) <$> getSetChatTTL db <*> getChatItemTTL db user <*> getChatTTLCount db user + (oldTTL_, globalTTL, ttlCount) <- withStore $ \db -> do + oldTTL <- getSetChatTTL db user + globalTTL <- liftIO $ getChatItemTTL db user + ttlCount <- liftIO $ getChatTTLCount db user + pure (oldTTL, globalTTL, ttlCount) let newTTL = fromMaybe globalTTL newTTL_ oldTTL = fromMaybe globalTTL oldTTL_ when (newTTL > 0 && (newTTL < oldTTL || oldTTL == 0)) $ do @@ -1702,9 +1707,13 @@ processChatCommand cxt nm = \case lift $ setChatItemsExpiration user globalTTL ttlCount ok user where - getSetChatTTL db = case cType of - CTDirect -> getDirectChatTTL db chatId <* setDirectChatTTL db chatId newTTL_ - CTGroup | isNothing scope -> getGroupChatTTL db chatId <* setGroupChatTTL db chatId newTTL_ + getSetChatTTL db currentUser = case cType of + CTDirect -> do + Contact {contactId} <- getContact db cxt currentUser chatId + liftIO $ getDirectChatTTL db contactId <* setDirectChatTTL db contactId newTTL_ + CTGroup | isNothing scope -> do + GroupInfo {groupId} <- getGroupInfo db cxt currentUser chatId + liftIO $ getGroupChatTTL db groupId <* setGroupChatTTL db groupId newTTL_ _ -> pure Nothing expireChat user globalTTL = do currentTs <- liftIO getCurrentTime diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 740e757ed8..7acfae1a95 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -122,6 +122,7 @@ chatDirectTests = do it "create user with same servers" testCreateUserSameServers it "delete user" testDeleteUser it "delete user with chat tags" testDeleteUserChatTags + it "rejects raw chat TTL updates for another user's chat" testRejectCrossUserChatTTL it "users have different chat item TTL configuration, chat items expire" testUsersDifferentCIExpirationTTL it "chat items expire after restart for all users according to per user configuration" testUsersRestartCIExpiration it "chat items only expire for users who configured expiration" testEnableCIExpirationOnlyForOneUser @@ -2096,6 +2097,25 @@ testDeleteUserChatTags = alice ##> "/users" alice <## "alisa (active)" +testRejectCrossUserChatTTL :: HasCallStack => TestParams -> IO () +testRejectCrossUserChatTTL = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + + alice #$> ("/_ttl 1 @2 2", id, "ok") + alice #$> ("/ttl @bob", id, "old messages are set to be deleted after: 2 second(s)") + + alice ##> "/create user alisa" + showActiveUser alice "alisa" + + alice ##> "/_ttl 2 @2 9" + alice <##. "chat db error:" + + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + alice #$> ("/ttl @bob", id, "old messages are set to be deleted after: 2 second(s)") + testUsersDifferentCIExpirationTTL :: HasCallStack => TestParams -> IO () testUsersDifferentCIExpirationTTL ps = do withNewTestChat ps "bob" bobProfile $ \bob -> do From b9d1f0c0a3c37fa17894d7dcd2e7c48a9ffd8ff3 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 9 Jun 2026 16:58:17 +0000 Subject: [PATCH 022/200] core: fix delivery batching (#7065) --- src/Simplex/Chat/Library/Subscriber.hs | 6 +-- src/Simplex/Chat/Messages/Batch.hs | 14 +++---- tests/MessageBatching.hs | 54 +++++++++++++++++++++++++- 3 files changed, 62 insertions(+), 12 deletions(-) diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 2e07282036..ba1f7f7d1f 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -3635,13 +3635,13 @@ runDeliveryTaskWorker a deliveryKey Worker {doWork} = do withStore' $ \db -> setDeliveryTaskErrStatus db (deliveryTaskId task) "relay inactive" | otherwise -> withWorkItems a doWork (withStore' $ \db -> getNextDeliveryTasks db gInfo task) $ \nextTasks -> do - let (body, acceptedTasks, largeTasks) = batchDeliveryTasks1 (vr cxt) maxEncodedMsgLength nextTasks + let (body_, acceptedTasks, largeTasks) = batchDeliveryTasks1 (vr cxt) maxEncodedMsgLength nextTasks senderGMIds = S.toList . S.fromList $ map (\MessageDeliveryTask {senderGMId} -> senderGMId) acceptedTasks withStore' $ \db -> do - createMsgDeliveryJob db gInfo jobScope senderGMIds body + forM_ body_ $ \body -> createMsgDeliveryJob db gInfo jobScope senderGMIds body forM_ acceptedTasks $ \t -> updateDeliveryTaskStatus db (deliveryTaskId t) DTSProcessed forM_ largeTasks $ \t -> setDeliveryTaskErrStatus db (deliveryTaskId t) "large" - lift . void $ getDeliveryJobWorker True deliveryKey + when (isJust body_) . lift . void $ getDeliveryJobWorker True deliveryKey -- DJRelayRemoved is allowed when RSInactive - it forwards XGrpMemDel about relay's own deletion DJRelayRemoved | workerScope /= DWSGroup -> diff --git a/src/Simplex/Chat/Messages/Batch.hs b/src/Simplex/Chat/Messages/Batch.hs index ed65bd4af7..81861aad74 100644 --- a/src/Simplex/Chat/Messages/Batch.hs +++ b/src/Simplex/Chat/Messages/Batch.hs @@ -24,7 +24,6 @@ import qualified Data.ByteString as BS import qualified Data.ByteString.Char8 as B import Data.Char (ord) import Data.Function (on) -import Data.Foldable (foldr') import Data.List (foldl', sortBy) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L @@ -79,15 +78,15 @@ batchMessages mode maxLen = addBatch . foldr addToBatch ([], [], [], 0, 0) let encoded = encodeBatch mode bodies in Right (MsgBatch encoded msgs) : batches --- | Batches delivery tasks into (batch, accepted, large). +-- | Batches delivery tasks into (batch if any task was accepted, accepted, large). -- Always uses binary batch format for relay groups. -batchDeliveryTasks1 :: VersionRangeChat -> Int -> NonEmpty MessageDeliveryTask -> (ByteString, [MessageDeliveryTask], [MessageDeliveryTask]) +batchDeliveryTasks1 :: VersionRangeChat -> Int -> NonEmpty MessageDeliveryTask -> (Maybe ByteString, [MessageDeliveryTask], [MessageDeliveryTask]) batchDeliveryTasks1 _vr maxLen = toResult . foldl' addToBatch ([], [], [], 0, 0) . L.toList where addToBatch :: ([ByteString], [MessageDeliveryTask], [MessageDeliveryTask], Int, Int) -> MessageDeliveryTask -> ([ByteString], [MessageDeliveryTask], [MessageDeliveryTask], Int, Int) addToBatch (msgBodies, accepted, large, len, n) task - -- too large: skip, record in large - | msgLen > maxLen = (msgBodies, accepted, task : large, len, n) + -- element can't fit even a singleton batch (4-byte binary-batch framing) + | msgLen + 4 > maxLen = (msgBodies, accepted, task : large, len, n) -- fits: include in batch -- batch overhead: '=' + count (2) + 2-byte length prefix per element | len' + (n + 1) * 2 + 2 <= maxLen = (msgBody : msgBodies, task : accepted, large, len', n + 1) @@ -98,10 +97,11 @@ batchDeliveryTasks1 _vr maxLen = toResult . foldl' addToBatch ([], [], [], 0, 0) msgBody = encodeFwdElement GrpMsgForward {fwdSender, fwdBrokerTs} verifiedMsg msgLen = B.length msgBody len' = len + msgLen - toResult :: ([ByteString], [MessageDeliveryTask], [MessageDeliveryTask], Int, Int) -> (ByteString, [MessageDeliveryTask], [MessageDeliveryTask]) + toResult :: ([ByteString], [MessageDeliveryTask], [MessageDeliveryTask], Int, Int) -> (Maybe ByteString, [MessageDeliveryTask], [MessageDeliveryTask]) toResult (msgBodies, accepted, large, _, _) = let encoded = encodeBinaryBatch (reverse msgBodies) - in (encoded, reverse accepted, reverse large) + body = if null accepted then Nothing else Just encoded + in (body, reverse accepted, reverse large) -- | Encode a batch element for relay groups: >[/]. encodeFwdElement :: GrpMsgForward -> VerifiedMsg 'Json -> ByteString diff --git a/tests/MessageBatching.hs b/tests/MessageBatching.hs index 05322a0834..00cbbd757b 100644 --- a/tests/MessageBatching.hs +++ b/tests/MessageBatching.hs @@ -12,14 +12,31 @@ import qualified Data.ByteString as B import Data.ByteString.Internal (c2w) import Data.Either (partitionEithers) import Data.Int (Int64) +import Data.List.NonEmpty (NonEmpty (..)) import Data.String (IsString (..)) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) +import Data.Time.Clock.System (SystemTime (..), systemToUTCTime) +import Simplex.Chat.Delivery + ( DeliveryJobScope (DJSGroup, jobSpec), + DeliveryJobSpec (DJDeliveryJob, includePending), + MessageDeliveryTask (MessageDeliveryTask, brokerTs, fwdSender, jobScope, senderGMId, taskId, verifiedMsg), + deliveryTaskId, + ) import Simplex.Chat.Messages.Batch import Simplex.Chat.Controller (ChatError (..), ChatErrorType (..)) import Simplex.Chat.Messages (SndMessage (..)) -import Simplex.Chat.Protocol (maxEncodedMsgLength) -import Simplex.Chat.Types (SharedMsgId (..)) +import Simplex.Chat.Protocol + ( ChatMessage (ChatMessage), + ChatMsgEvent (XMsgNew), + FwdSender (FwdChannel), + GrpMsgForward (GrpMsgForward), + MsgContent (MCText), + VerifiedMsg (VMUnsigned), + maxEncodedMsgLength, + mcSimple, + ) +import Simplex.Chat.Types (SharedMsgId (..), chatInitialVRange) import Simplex.Messaging.Encoding (Large (..), smpEncodeList) import Test.Hspec @@ -28,6 +45,8 @@ batchingTests = describe "message batching tests" $ do testBatchingCorrectness testBinaryBatchingCorrectness it "image x.msg.new and x.msg.file.descr should fit into single batch" testImageFitsSingleBatch + it "does not create a relay delivery body when every task is oversized" testRelayBatchAllLarge + it "classifies a task that fits raw but not as a framed singleton as large" testRelayBatchSingletonOverflow instance IsString SndMessage where fromString s = SndMessage {msgId, sharedMsgId = SharedMsgId "", msgBody = s', signedMsg_ = Nothing} @@ -131,6 +150,37 @@ testImageFitsSingleBatch = do runBatcherTest' BMJson maxEncodedMsgLength [msg xMsgNewStr, msg descrStr] [] [batched] +testRelayBatchAllLarge :: IO () +testRelayBatchAllLarge = do + let task1 = deliveryTask 1 "one" + task2 = deliveryTask 2 "two" + (body_, accepted, large) = batchDeliveryTasks1 chatInitialVRange 1 (task1 :| [task2]) + body_ `shouldBe` Nothing + map deliveryTaskId accepted `shouldBe` [] + map deliveryTaskId large `shouldBe` [1, 2] + +deliveryTask :: Int64 -> T.Text -> MessageDeliveryTask +deliveryTask taskId text = + MessageDeliveryTask + { taskId, + jobScope = DJSGroup {jobSpec = DJDeliveryJob {includePending = False}}, + senderGMId = 1, + fwdSender = FwdChannel, + brokerTs = systemToUTCTime $ MkSystemTime 0 0, + verifiedMsg = + VMUnsigned + (ChatMessage chatInitialVRange Nothing $ XMsgNew $ mcSimple $ MCText text) + } + +testRelayBatchSingletonOverflow :: IO () +testRelayBatchSingletonOverflow = do + let task = deliveryTask 1 "overflow" + elemLen = B.length $ encodeFwdElement (GrpMsgForward (fwdSender task) (brokerTs task)) (verifiedMsg task) + (body_, accepted, large) = batchDeliveryTasks1 chatInitialVRange (elemLen + 2) (task :| []) + body_ `shouldBe` Nothing + map deliveryTaskId accepted `shouldBe` [] + map deliveryTaskId large `shouldBe` [1] + runBatcherTest :: BatchMode -> Int -> [SndMessage] -> [ChatError] -> [ByteString] -> Spec runBatcherTest mode maxLen msgs expectedErrors expectedBatches = it From 43904dd0dccc46ade1e7413aa10e5ebf2eac1b3b Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:23:27 +0400 Subject: [PATCH 023/200] desktop: authenticate call server websocket (#7076) * Authenticate desktop call websocket * call: patch ui.ts source for ws token and add server auth integration test --------- Co-authored-by: Paul Bottinelli --- .../resources/assets/www/desktop/ui.js | 4 +- .../common/views/call/CallView.desktop.kt | 43 ++++++++++-- .../chat/simplex/app/CallServerAuthTest.kt | 70 +++++++++++++++++++ .../chat/simplex/app/CallServerTokenTest.kt | 29 ++++++++ .../simplex-chat-webrtc/src/desktop/ui.ts | 4 +- 5 files changed, 139 insertions(+), 11 deletions(-) create mode 100644 apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/CallServerAuthTest.kt create mode 100644 apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/CallServerTokenTest.kt diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js index 7c0836960c..e6828817ee 100644 --- a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js @@ -3,7 +3,7 @@ useWorker = typeof window.Worker !== "undefined"; isDesktop = true; // Create WebSocket connection. -const socket = new WebSocket(`ws://${location.host}`); +const socket = new WebSocket(`ws://${location.host}${location.search}`); socket.addEventListener("open", (_event) => { console.log("Opened socket"); sendMessageToNative = (msg) => { @@ -192,4 +192,4 @@ function updateCallInfoView(state, description) { document.getElementById("state").innerText = state; document.getElementById("description").innerText = description; } -//# sourceMappingURL=ui.js.map \ No newline at end of file +//# sourceMappingURL=ui.js.map diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt index 20fe6a48a3..75782d75d7 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt @@ -18,10 +18,12 @@ import org.nanohttpd.protocols.http.response.Status import org.nanohttpd.protocols.websockets.* import java.io.IOException import java.net.BindException -import java.net.URI +import java.security.SecureRandom +import java.util.Base64 private const val SERVER_HOST = "localhost" private const val SERVER_PORT = 50395 +private const val CALL_SERVER_TOKEN_BYTES = 32 val connections = ArrayList() // Spec: spec/services/calls.md#ActiveCallView @@ -153,14 +155,15 @@ private fun SendStateUpdates() { @Composable fun WebRTCController(callCommand: SnapshotStateList, onResponse: (WVAPIMessage) -> Unit) { val uriHandler = LocalUriHandler.current + val token = remember { newCallServerToken() } val endCall = { val call = chatModel.activeCall.value if (call != null) withBGApi { chatModel.callManager.endCall(call) } } val server = remember { - startServer(onResponse).apply { + startServer(onResponse, token = token).apply { try { - uriHandler.openUri("http://${SERVER_HOST}:${listeningPort}/simplex/call/") + uriHandler.openUri("http://${SERVER_HOST}:${listeningPort}/simplex/call/?token=$token") } catch (e: Exception) { Log.e(TAG, "Unable to open browser: ${e.stackTraceToString()}") AlertManager.shared.showAlertMsg( @@ -208,7 +211,11 @@ fun WebRTCController(callCommand: SnapshotStateList, onResponse: ( } } -fun startServer(onResponse: (WVAPIMessage) -> Unit, port: Int = SERVER_PORT): NanoWSD { +fun startServer( + onResponse: (WVAPIMessage) -> Unit, + port: Int = SERVER_PORT, + token: String = newCallServerToken(), +): NanoWSD { val server = object: NanoWSD(SERVER_HOST, port) { override fun openWebSocket(session: IHTTPSession): WebSocket = MyWebSocket(onResponse, session) @@ -227,8 +234,18 @@ fun startServer(onResponse: (WVAPIMessage) -> Unit, port: Int = SERVER_PORT): Na override fun handle(session: IHTTPSession): Response { return when { - session.headers["upgrade"] == "websocket" -> super.handle(session) - session.uri.contains("/simplex/call/") -> resourcesToResponse("/desktop/call.html") + session.headers["upgrade"] == "websocket" -> + if (hasValidCallServerToken(session.parameters, token)) { + super.handle(session) + } else { + unauthorizedResponse() + } + session.uri.contains("/simplex/call/") -> + if (hasValidCallServerToken(session.parameters, token)) { + resourcesToResponse("/desktop/call.html") + } else { + unauthorizedResponse() + } else -> resourcesToResponse(uriCreateOrNull(session.uri)?.path ?: return newFixedLengthResponse("Error parsing URL")) } } @@ -239,11 +256,23 @@ fun startServer(onResponse: (WVAPIMessage) -> Unit, port: Int = SERVER_PORT): Na if (port == 0) throw e Log.w(TAG, "Call server port $port is busy, using a random port: ${e.message}") server.stop() - return startServer(onResponse, port = 0) + return startServer(onResponse, port = 0, token = token) } return server } +internal fun newCallServerToken(): String { + val bytes = ByteArray(CALL_SERVER_TOKEN_BYTES) + SecureRandom().nextBytes(bytes) + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) +} + +internal fun hasValidCallServerToken(parameters: Map>, token: String): Boolean = + token.isNotEmpty() && parameters["token"]?.any { it == token } == true + +private fun unauthorizedResponse(): Response = + newFixedLengthResponse(Status.UNAUTHORIZED, "text/plain", "Unauthorized") + class MyWebSocket(val onResponse: (WVAPIMessage) -> Unit, handshakeRequest: IHTTPSession) : WebSocket(handshakeRequest) { override fun onOpen() { connections.add(this) diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/CallServerAuthTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/CallServerAuthTest.kt new file mode 100644 index 0000000000..800c69f617 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/CallServerAuthTest.kt @@ -0,0 +1,70 @@ +package chat.simplex.app + +import chat.simplex.common.views.call.startServer +import java.net.Socket +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +// Integration test for the desktop call server's token gate (the handle() enforcement), +// which the unit-level CallServerTokenTest does not exercise. +class CallServerAuthTest { + private val token = "integration-test-token" + // port = 0 binds a random free port, avoiding a clash with a real call server on SERVER_PORT + private val server = startServer(onResponse = {}, port = 0, token = token) + private val port get() = server.listeningPort + + @AfterTest + fun tearDown() = server.stop() + + @Test + fun testWebSocketUpgradeRejectedWithoutToken() { + assertEquals(401, requestStatus(webSocketUpgrade(path = "/"))) + } + + @Test + fun testWebSocketUpgradeRejectedWithWrongToken() { + assertEquals(401, requestStatus(webSocketUpgrade(path = "/?token=wrong"))) + } + + @Test + fun testWebSocketUpgradeAcceptedWithToken() { + assertEquals(101, requestStatus(webSocketUpgrade(path = "/?token=$token"))) + } + + @Test + fun testCallPageRejectedWithoutToken() { + assertEquals(401, requestStatus(get(path = "/simplex/call/"))) + } + + @Test + fun testCallPagePassesAuthGateWithToken() { + // Resource serving may differ in the test classpath, so assert only that the auth gate was passed (not 401) + assertNotEquals(401, requestStatus(get(path = "/simplex/call/?token=$token"))) + } + + private fun get(path: String): List = listOf("GET $path HTTP/1.1", "Host: localhost:$port") + + private fun webSocketUpgrade(path: String): List = + listOf( + "GET $path HTTP/1.1", + "Host: localhost:$port", + "Upgrade: websocket", + "Connection: Upgrade", + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==", + "Sec-WebSocket-Version: 13", + ) + + // Sends a raw HTTP request and returns the response status code from the status line. + private fun requestStatus(requestLines: List): Int = + Socket("localhost", port).use { socket -> + socket.soTimeout = 5000 + socket.getOutputStream().apply { + write((requestLines.joinToString("\r\n") + "\r\n\r\n").toByteArray()) + flush() + } + val statusLine = socket.getInputStream().bufferedReader().readLine() ?: error("no response from call server") + statusLine.split(" ")[1].toInt() + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/CallServerTokenTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/CallServerTokenTest.kt new file mode 100644 index 0000000000..dc729b1ec2 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/CallServerTokenTest.kt @@ -0,0 +1,29 @@ +package chat.simplex.app + +import chat.simplex.common.views.call.hasValidCallServerToken +import chat.simplex.common.views.call.newCallServerToken +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CallServerTokenTest { + @Test + fun testCallServerTokenRequiresExactTokenParameter() { + val token = "secret" + + assertTrue(hasValidCallServerToken(mapOf("token" to listOf(token)), token)) + assertFalse(hasValidCallServerToken(mapOf("token" to listOf("wrong")), token)) + assertFalse(hasValidCallServerToken(mapOf("x-token" to listOf(token)), token)) + assertFalse(hasValidCallServerToken(mapOf("token" to listOf(token)), "")) + } + + @Test + fun testCallServerTokenIsUrlSafe() { + val token = newCallServerToken() + + assertTrue(token.length >= 40) + assertFalse(token.contains("+")) + assertFalse(token.contains("/")) + assertFalse(token.contains("=")) + } +} diff --git a/packages/simplex-chat-webrtc/src/desktop/ui.ts b/packages/simplex-chat-webrtc/src/desktop/ui.ts index eac659a17a..862c727bd5 100644 --- a/packages/simplex-chat-webrtc/src/desktop/ui.ts +++ b/packages/simplex-chat-webrtc/src/desktop/ui.ts @@ -2,8 +2,8 @@ useWorker = typeof window.Worker !== "undefined" isDesktop = true -// Create WebSocket connection. -const socket = new WebSocket(`ws://${location.host}`) +// Create WebSocket connection. location.search carries the per-call ?token=... capability required by the server. +const socket = new WebSocket(`ws://${location.host}${location.search}`) socket.addEventListener("open", (_event) => { console.log("Opened socket") From adb3fb8cb2c0ffbfe9f04772964ab77f553fceb0 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Tue, 16 Jun 2026 14:36:55 +0100 Subject: [PATCH 024/200] core: render web previews for channels (#7029) * plan: web previews for channels * types for recipient side to support channel web previews and domain names * fix * migrations * update schema and api types * update schema * rename migrations * core: render channel preview data * core: render channel preview data in relays * website: use cpp to inject JS functions * JSC files * remove directory.js * channel preview renderer * Revert "cli: fix redraw slowness (#6735)" This reverts commit b801d77c74f0b688000e0bc2194e835bd1d1965e. * sample channel page * default avatar * rename options * better layout * layout * images * some fixes * tails * markdown colors * image sizes * reactions * fix reactions * fewer avatars * forward icon * command to change group access parameters * view public group access changes in CLI * media metadata color * ios: group web access ui * update ui * add init * kotlin, labels * update page * update relay base URL * fix * ios update channel web page info * update kotlin layout * use cards * update layout * use domains for relay data, path is fixed * update embed code * fix bots api * include only history items and senders * update preview JS/HTML * show different error if link is different * remove stale json files * better layout * layout fixes * improve layout * improve layout * update embed code * web cta * better layout * buttons * layout * paddings * desktop cta * desktop cta * cta layout * fonts * paddings * paddings * more paddings * copy link * read more * hide avatar and placeholder when all messages are from channel * color scheme * fix color * improve * layout * welcome message * dark mode colors * padding * font size * overscroll * font * logo on button * better join * buttons * refactor * another logo * text * desktop button * button text * center * fix svg * padding * smaller gap * render channel on any message changes etc * fixes * atomic file updates, escape attributes * fix tests * more tests * more efficient rendering * improve security * sanitize links, include mentioned members * schema * fixes * improve rendering * fix showing correct subscribers count * fix member names --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- .gitignore | 5 +- .../Chat/Group/ChannelWebAccessView.swift | 169 ++ .../Views/Chat/Group/GroupChatInfoView.swift | 17 + apps/ios/SimpleX.xcodeproj/project.pbxproj | 4 + apps/ios/SimpleXChat/ChatTypes.swift | 7 + .../views/chat/group/ChannelWebPageView.kt | 186 ++ .../views/chat/group/GroupChatInfoView.kt | 22 + .../common/views/helpers/TextEditor.kt | 22 + .../commonMain/resources/MR/base/strings.xml | 14 + bots/src/API/Docs/Commands.hs | 1 + simplex-chat.cabal | 3 + src/Simplex/Chat.hs | 7 +- src/Simplex/Chat/Controller.hs | 41 + src/Simplex/Chat/Library/Commands.hs | 40 + src/Simplex/Chat/Library/Internal.hs | 5 +- src/Simplex/Chat/Library/Subscriber.hs | 17 +- src/Simplex/Chat/Mobile.hs | 1 + src/Simplex/Chat/Options.hs | 44 +- src/Simplex/Chat/Protocol.hs | 7 +- src/Simplex/Chat/Store/Groups.hs | 39 + src/Simplex/Chat/Store/Messages.hs | 19 + src/Simplex/Chat/Store/Postgres/Migrations.hs | 4 +- .../M20260601_relay_sent_web_domain.hs | 19 + .../Store/Postgres/Migrations/chat_schema.sql | 3 +- src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../M20260601_relay_sent_web_domain.hs | 18 + .../SQLite/Migrations/agent_query_plans.txt | 9 - .../SQLite/Migrations/chat_query_plans.txt | 61 + .../Store/SQLite/Migrations/chat_schema.sql | 3 +- src/Simplex/Chat/Types.hs | 9 +- src/Simplex/Chat/View.hs | 22 +- src/Simplex/Chat/Web.hs | 430 +++++ tests/Bots/DirectoryTests.hs | 4 +- tests/ChatClient.hs | 7 +- tests/ChatTests/ChatRelays.hs | 252 +++ tests/ProtocolTests.hs | 8 +- website/.eleventy.js | 2 +- website/channel_sample.html | 28 + website/src/js/channel-preview.jsc | 1548 +++++++++++++++++ .../src/js/{directory.js => directory.jsc} | 148 +- website/src/js/simplex-lib.jsc | 156 ++ website/web.sh | 4 + 42 files changed, 3234 insertions(+), 175 deletions(-) create mode 100644 apps/ios/Shared/Views/Chat/Group/ChannelWebAccessView.swift create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelWebPageView.kt create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations/M20260601_relay_sent_web_domain.hs create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20260601_relay_sent_web_domain.hs create mode 100644 src/Simplex/Chat/Web.hs create mode 100644 website/channel_sample.html create mode 100644 website/src/js/channel-preview.jsc rename website/src/js/{directory.js => directory.jsc} (77%) create mode 100644 website/src/js/simplex-lib.jsc diff --git a/.gitignore b/.gitignore index 7bd3d04e59..035d24c6cd 100644 --- a/.gitignore +++ b/.gitignore @@ -54,7 +54,10 @@ website/translations.json website/src/img/images/ website/src/images/ website/src/js/lottie.min.js -website/src/js/ethers* +website/src/js/ethers.* +website/src/js/directory.js +website/src/js/channel-preview.js +website/src/js/simplex-lib.js website/src/file-assets/ website/src/link-images/ website/src/privacy.md diff --git a/apps/ios/Shared/Views/Chat/Group/ChannelWebAccessView.swift b/apps/ios/Shared/Views/Chat/Group/ChannelWebAccessView.swift new file mode 100644 index 0000000000..dd46b7a117 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/ChannelWebAccessView.swift @@ -0,0 +1,169 @@ +// +// ChannelWebAccessView.swift +// SimpleX (iOS) +// +// Created by simplex.chat on 31/05/2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct ChannelWebAccessView: View { + @EnvironmentObject var theme: AppTheme + @Environment(\.dismiss) var dismiss: DismissAction + @Binding var groupInfo: GroupInfo + @State private var webPage: String + @State private var allowEmbedding: Bool + @State private var saving = false + @State private var groupRelays: [GroupRelay] = [] + + init(groupInfo: Binding) { + _groupInfo = groupInfo + let access = groupInfo.wrappedValue.groupProfile.publicGroup?.publicGroupAccess + _webPage = State(initialValue: access?.groupWebPage ?? "") + _allowEmbedding = State(initialValue: access?.allowEmbedding ?? false) + } + + var body: some View { + List { + if let code = embedCode { + webpageInfo("Create a webpage to show your channel preview to visitors before they subscribe. Host it yourself or use any static hosting.") + + Section { + ScrollView { + Text(code) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } + .frame(maxHeight: 88) + Button { + UIPasteboard.general.string = code + } label: { + Label("Copy code", systemImage: "doc.on.doc") + } + } header: { + Text("Webpage code") + } footer: { + Text("Add this code to your webpage. It will display the preview of your channel / group.") + } + } else { + webpageInfo("Used chat relays do not support webpages.") + } + + Section { + TextField("https://", text: $webPage) + .keyboardType(.URL) + .autocapitalization(.none) + .disableAutocorrection(true) + } header: { + Text("Enter webpage URL") + } footer: { + Text("It will be shown to subscribers and used to allow loading the preview.") + } + + Section { + Toggle("Allow anyone to embed", isOn: $allowEmbedding) + } footer: { + Text(allowEmbedding ? "Any webpage can show the preview." : "Only your page above can show the preview.") + } + + Section { + Button { + saveAccess() + } label: { + HStack { + Text(groupInfo.isChannel ? "Save and notify subscribers" : "Save and notify members") + if saving { Spacer(); ProgressView() } + } + } + .disabled(!hasChanges || saving) + } + } + .modifier(ThemedBackground(grouped: true)) + .onAppear { + Task { + let relays = await apiGetGroupRelays(groupInfo.groupId) + await MainActor.run { groupRelays = relays } + } + } + .onDisappear { + if hasChanges { + showAlert( + title: NSLocalizedString("Save webpage settings?", comment: "alert title"), + message: NSLocalizedString("Webpage settings were changed. If you save, the updated settings will be sent to subscribers.", comment: "alert message"), + buttonTitle: NSLocalizedString("Save", comment: "alert button"), + buttonAction: saveAccess, + cancelButton: true + ) + } + } + } + + private func webpageInfo(_ text: LocalizedStringKey) -> some View { + Section { + Text(text).foregroundColor(theme.colors.secondary) + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 0, trailing: 16)) + } + + private var hasChanges: Bool { + let access = groupInfo.groupProfile.publicGroup?.publicGroupAccess + let currentWebPage = access?.groupWebPage ?? "" + let currentEmbedding = access?.allowEmbedding ?? false + return webPage != currentWebPage || allowEmbedding != currentEmbedding + } + + private var relayDomains: [String] { + groupRelays.compactMap { $0.relayCap.webDomain } + } + + private var embedCode: String? { + if let pg = groupInfo.groupProfile.publicGroup, + !relayDomains.isEmpty { + """ +
+ + """ + } else { + nil + } + } + + private func saveAccess() { + saving = true + Task { + do { + var gp = groupInfo.groupProfile + if var pg = gp.publicGroup { + let trimmedPage = webPage.trimmingCharacters(in: .whitespacesAndNewlines) + let existingAccess = pg.publicGroupAccess + pg.publicGroupAccess = PublicGroupAccess( + groupWebPage: trimmedPage.isEmpty ? nil : trimmedPage, + groupDomain: existingAccess?.groupDomain, + domainWebPage: existingAccess?.domainWebPage ?? false, + allowEmbedding: allowEmbedding + ) + gp.publicGroup = pg + } + let gInfo = try await apiUpdateGroup(groupInfo.groupId, gp) + await MainActor.run { + groupInfo = gInfo + ChatModel.shared.updateGroup(gInfo) + saving = false + } + } catch { + logger.error("ChannelWebAccessView apiUpdateGroup error: \(responseError(error))") + await MainActor.run { saving = false } + } + } + } +} diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 26aedb2541..b62939fd36 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -244,6 +244,12 @@ struct GroupChatInfoView: View { } } + if groupInfo.useRelays && groupInfo.isOwner { + Section(header: Text("Advanced options").foregroundColor(theme.colors.secondary)) { + channelWebAccessButton() + } + } + if developerTools { Section(header: Text("For console").foregroundColor(theme.colors.secondary)) { infoRow("Local name", chat.chatInfo.localDisplayName) @@ -657,6 +663,17 @@ struct GroupChatInfoView: View { } } + private func channelWebAccessButton() -> some View { + let title: LocalizedStringKey = groupInfo.isChannel ? "Channel webpage" : "Group webpage" + return NavigationLink { + ChannelWebAccessView(groupInfo: $groupInfo) + .navigationBarTitle(title) + .navigationBarTitleDisplayMode(.large) + } label: { + Label(title, systemImage: "globe") + } + } + private func groupLinkDestinationView() -> some View { GroupLinkView( groupId: groupInfo.groupId, diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 20dfbba6ee..c5dc039d8d 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -170,6 +170,7 @@ 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; 6495D7042F48CFC50060512B /* ChannelMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7032F48CFC50060512B /* ChannelMembersView.swift */; }; 6495D7062F48CFFD0060512B /* ChannelRelaysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */; }; + E5E418022F83D2CA00252B9E /* ChannelWebAccessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5E418032F83D2CA00252B9E /* ChannelWebAccessView.swift */; }; 6495D7082F48D0000060512B /* AddGroupRelayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7072F48D0000060512B /* AddGroupRelayView.swift */; }; 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; }; 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; @@ -549,6 +550,7 @@ 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 6495D7032F48CFC50060512B /* ChannelMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelMembersView.swift; sourceTree = ""; }; 6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelRelaysView.swift; sourceTree = ""; }; + E5E418032F83D2CA00252B9E /* ChannelWebAccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelWebAccessView.swift; sourceTree = ""; }; 6495D7072F48D0000060512B /* AddGroupRelayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupRelayView.swift; sourceTree = ""; }; 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = ""; }; 649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = ""; }; @@ -1178,6 +1180,7 @@ 64A779FD2DC3AFF200FDEF2F /* MemberSupportChatToolbar.swift */, 6495D7032F48CFC50060512B /* ChannelMembersView.swift */, 6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */, + E5E418032F83D2CA00252B9E /* ChannelWebAccessView.swift */, 6495D7072F48D0000060512B /* AddGroupRelayView.swift */, ); path = Group; @@ -1640,6 +1643,7 @@ 8C9BC2652C240D5200875A27 /* ThemeModeEditor.swift in Sources */, 647B15E82F4C8D2500EB431E /* AddChannelView.swift in Sources */, 6495D7062F48CFFD0060512B /* ChannelRelaysView.swift in Sources */, + E5E418022F83D2CA00252B9E /* ChannelWebAccessView.swift in Sources */, 6495D7082F48D0000060512B /* AddGroupRelayView.swift in Sources */, 5CB346E92869E8BA001FD2EF /* PushEnvironment.swift in Sources */, 5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */, diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 6b757e795f..56f92cfc0b 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2614,6 +2614,13 @@ public enum GroupType: Codable, Hashable { } public struct PublicGroupAccess: Codable, Hashable { + public init(groupWebPage: String? = nil, groupDomain: String? = nil, domainWebPage: Bool = false, allowEmbedding: Bool = false) { + self.groupWebPage = groupWebPage + self.groupDomain = groupDomain + self.domainWebPage = domainWebPage + self.allowEmbedding = allowEmbedding + } + public var groupWebPage: String? public var groupDomain: String? public var domainWebPage: Bool = false diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelWebPageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelWebPageView.kt new file mode 100644 index 0000000000..98067e49dd --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelWebPageView.kt @@ -0,0 +1,186 @@ +package chat.simplex.common.views.chat.group + +import SectionBottomSpacer +import SectionDividerSpaced +import SectionItemView +import SectionTextFooter +import SectionView +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.usersettings.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.* + +@Composable +fun ChannelWebPageView( + rhId: Long?, + groupInfo: GroupInfo, + chatModel: ChatModel, + close: () -> Unit +) { + val isChannel = groupInfo.isChannel + val access = groupInfo.groupProfile.publicGroup?.publicGroupAccess + val webPage = rememberSaveable { mutableStateOf(access?.groupWebPage ?: "") } + val allowEmbedding = rememberSaveable { mutableStateOf(access?.allowEmbedding ?: false) } + val groupRelays = remember { mutableStateListOf() } + + val dataUnchanged = webPage.value.trim() == (access?.groupWebPage ?: "") && + allowEmbedding.value == (access?.allowEmbedding ?: false) + + val save: () -> Unit = { + withBGApi { + val trimmedPage = webPage.value.trim() + val newAccess = PublicGroupAccess( + groupWebPage = trimmedPage.ifEmpty { null }, + groupDomain = access?.groupDomain, + domainWebPage = access?.domainWebPage ?: false, + allowEmbedding = allowEmbedding.value + ) + val gp = groupInfo.groupProfile.copy( + publicGroup = groupInfo.groupProfile.publicGroup?.copy(publicGroupAccess = newAccess) + ) + val gInfo = chatModel.controller.apiUpdateGroup(rhId, groupInfo.groupId, gp, isChannel) + if (gInfo != null) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, gInfo) + } + close() + } + } + } + + val closeWithAlert = { + if (dataUnchanged) { + close() + } else { + AlertManager.shared.showAlertDialogStacked( + title = generalGetString(MR.strings.save_preferences_question), + 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 = close, + ) + } + } + + LaunchedEffect(Unit) { + val relays = chatModel.controller.apiGetGroupRelays(groupInfo.groupId) + groupRelays.clear() + groupRelays.addAll(relays) + } + + BackHandler(onBack = closeWithAlert) + ModalView(close = closeWithAlert, cardScreen = true) { + ChannelWebPageLayout( + isChannel = isChannel, + webPage = webPage, + allowEmbedding = allowEmbedding, + groupRelays = groupRelays, + groupInfo = groupInfo, + dataUnchanged = dataUnchanged, + save = save + ) + } +} + +@Composable +private fun ChannelWebPageLayout( + isChannel: Boolean, + webPage: MutableState, + allowEmbedding: MutableState, + groupRelays: List, + groupInfo: GroupInfo, + dataUnchanged: Boolean, + save: () -> Unit +) { + val clipboard = LocalClipboardManager.current + ColumnWithScrollBar { + AppBarTitle(stringResource(if (isChannel) MR.strings.channel_webpage else MR.strings.group_webpage)) + + val embedCode = embedCode(groupRelays, groupInfo) + if (embedCode != null) { + SectionTextFooter(stringResource(MR.strings.webpage_info)) + SectionDividerSpaced() + + SectionView(stringResource(MR.strings.webpage_code)) { + SectionItemView { + Text( + embedCode, + style = MaterialTheme.typography.body2.copy(fontFamily = FontFamily.Monospace, fontSize = 12.sp), + maxLines = 6, + overflow = TextOverflow.Ellipsis + ) + } + SectionItemView({ + clipboard.setText(AnnotatedString(embedCode)) + showToast(generalGetString(MR.strings.copied)) + }) { + Icon(painterResource(MR.images.ic_content_copy), null, tint = MaterialTheme.colors.primary) + Spacer(Modifier.width(8.dp)) + Text(stringResource(MR.strings.copy_code), color = MaterialTheme.colors.primary) + } + } + SectionTextFooter(stringResource(MR.strings.webpage_code_footer)) + } else { + SectionTextFooter(stringResource(MR.strings.relays_no_web_support)) + } + SectionDividerSpaced() + + SectionView(stringResource(MR.strings.enter_webpage_url)) { + PlainTextEditor(webPage, placeholder = stringResource(MR.strings.web_page_url_placeholder)) + } + SectionTextFooter(stringResource(MR.strings.webpage_url_footer)) + SectionDividerSpaced() + + SectionView { + PreferenceToggle(stringResource(MR.strings.allow_anyone_to_embed), checked = allowEmbedding.value) { + allowEmbedding.value = it + } + } + SectionTextFooter(stringResource(if (allowEmbedding.value) MR.strings.embed_any_webpage_can_show else MR.strings.embed_only_your_page)) + SectionDividerSpaced() + + SectionView { + SectionItemView(save, disabled = dataUnchanged) { + Text( + stringResource(MR.strings.save_verb), + color = if (dataUnchanged) MaterialTheme.colors.secondary else MaterialTheme.colors.primary + ) + } + } + + SectionBottomSpacer() + } +} + +private fun embedCode(groupRelays: List, groupInfo: GroupInfo): String? { + val pg = groupInfo.groupProfile.publicGroup ?: return null + val relayDomains = groupRelays.mapNotNull { it.relayCap.webDomain } + if (relayDomains.isEmpty()) return null + val domains = relayDomains.joinToString(",") + return """
+""" +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index 93c318cab5..b2c25bf06c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -175,6 +175,9 @@ fun ModalData.GroupChatInfoView( manageGroupLink = { ModalManager.end.showModal(cardScreen = true) { GroupLinkView(chatModel, rhId, groupInfo, groupLink, onGroupLinkUpdated, isChannel = groupInfo.useRelays, shareGroupInfo = groupInfo) } }, + manageWebPage = { + ModalManager.end.showCustomModal { close -> ChannelWebPageView(rhId, groupInfo, chatModel, close) } + }, onSearchClicked = onSearchClicked, deletingItems = deletingItems ) @@ -506,6 +509,7 @@ fun ModalData.GroupChatInfoLayout( clearChat: () -> Unit, leaveGroup: () -> Unit, manageGroupLink: () -> Unit, + manageWebPage: () -> Unit, close: () -> Unit = { ModalManager.closeAllModalsEverywhere()}, onSearchClicked: () -> Unit, deletingItems: State @@ -796,6 +800,13 @@ fun ModalData.GroupChatInfoLayout( } } + if (groupInfo.useRelays && groupInfo.isOwner) { + SectionDividerSpaced() + SectionView(title = stringResource(MR.strings.advanced_options)) { + ChannelWebPageButton(groupInfo, manageWebPage) + } + } + if (developerTools) { SectionDividerSpaced() SectionView(title = stringResource(MR.strings.section_title_for_console)) { @@ -1209,6 +1220,16 @@ private fun ChannelLinkButton(onClick: () -> Unit) { ) } +@Composable +private fun ChannelWebPageButton(groupInfo: GroupInfo, onClick: () -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_travel_explore), + stringResource(if (groupInfo.isChannel) MR.strings.channel_webpage else MR.strings.group_webpage), + onClick, + iconColor = MaterialTheme.colors.secondary + ) +} + @Composable private fun ChannelLinkQRCodeSection(groupLink: String) { val clipboard = LocalClipboardManager.current @@ -1413,6 +1434,7 @@ fun PreviewGroupChatInfoLayout() { clearChat = {}, leaveGroup = {}, manageGroupLink = {}, + manageWebPage = {}, onSearchClicked = {}, deletingItems = remember { mutableStateOf(true) } ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt index e8070b5c76..cd40585cad 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt @@ -102,6 +102,28 @@ fun TextEditor( } } +@Composable +fun PlainTextEditor( + value: MutableState, + placeholder: String? = null, + singleLine: Boolean = true +) { + BasicTextField( + value = value.value, + onValueChange = { value.value = it }, + modifier = Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING, vertical = 12.dp), + textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground), + singleLine = singleLine, + cursorBrush = SolidColor(MaterialTheme.colors.secondary), + decorationBox = { innerTextField -> + if (value.value.isEmpty() && placeholder != null) { + Text(placeholder, style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.secondary)) + } + innerTextField() + } + ) +} + @Serializable data class ParsedFormattedText( val formattedText: List? = null diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index e943e0080a..9630c61004 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1925,6 +1925,20 @@ Welcome message Group link Channel link + Channel webpage + Group webpage + Advanced options + https:// + Allow anyone to embed + Enter webpage URL + It will be shown to subscribers and used to allow loading the preview. + Webpage code + Add this code to your webpage. It will display the preview of your channel / group. + Copy code + Create a webpage to show your channel preview to visitors before they subscribe. Host it yourself or use any static hosting. + Used chat relays do not support webpages. + Any webpage can show the preview. + Only your page above can show the preview. Create group link Create link Delete link? diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 003969660b..8ebd510a55 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -281,6 +281,7 @@ cliCommands = "SetGroupTimedMessages", "SetLocalDeviceName", "SetProfileAddress", + "SetPublicGroupAccess", "SetSendReceipts", "SetShowMemberMessages", "SetShowMessages", diff --git a/simplex-chat.cabal b/simplex-chat.cabal index ae573936e7..86350acdd4 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -93,6 +93,7 @@ library Simplex.Chat.Types.Shared Simplex.Chat.Types.UITheme Simplex.Chat.Util + Simplex.Chat.Web if !flag(client_library) exposed-modules: Simplex.Chat.Bot @@ -141,6 +142,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260529_delivery_job_senders Simplex.Chat.Store.Postgres.Migrations.M20260530_client_services Simplex.Chat.Store.Postgres.Migrations.M20260531_member_removed_at + Simplex.Chat.Store.Postgres.Migrations.M20260601_relay_sent_web_domain else exposed-modules: Simplex.Chat.Archive @@ -301,6 +303,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260529_delivery_job_senders Simplex.Chat.Store.SQLite.Migrations.M20260530_client_services Simplex.Chat.Store.SQLite.Migrations.M20260531_member_removed_at + Simplex.Chat.Store.SQLite.Migrations.M20260601_relay_sent_web_domain other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index af3f98d6a6..b795ba9b9c 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -128,6 +128,7 @@ defaultChatConfig = highlyAvailable = False, deliveryWorkerDelay = 0, deliveryBucketSize = 10000, + webPreviewConfig = Nothing, channelSubscriberRole = GRObserver, relayChecksInterval = 15 * 60, -- 15 minutes relayInactiveTTL = nominalDay, @@ -152,11 +153,11 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agentConfig = aCfg, presetServers, inlineFiles, deviceNameForRemote, confirmMigrations} - ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, simpleNetCfg, logLevel, logConnections, logServerHosts, logFile, tbqSize, deviceName, highlyAvailable, yesToUpMigrations}, optFilesFolder, optTempDirectory, showReactions, allowInstantFiles, autoAcceptFileSize} + ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, simpleNetCfg, logLevel, logConnections, logServerHosts, logFile, tbqSize, deviceName, webPreviewConfig, highlyAvailable, yesToUpMigrations}, optFilesFolder, optTempDirectory, showReactions, allowInstantFiles, autoAcceptFileSize} backgroundMode = do let inlineFiles' = if allowInstantFiles || autoAcceptFileSize > 0 then inlineFiles else inlineFiles {sendChunks = 0, receiveInstant = False} confirmMigrations' = if confirmMigrations == MCConsole && yesToUpMigrations then MCYesUp else confirmMigrations - config = cfg {logLevel, showReactions, tbqSize, subscriptionEvents = logConnections, hostEvents = logServerHosts, presetServers = presetServers', inlineFiles = inlineFiles', autoAcceptFileSize, highlyAvailable, confirmMigrations = confirmMigrations'} + config = cfg {logLevel, showReactions, tbqSize, subscriptionEvents = logConnections, hostEvents = logServerHosts, presetServers = presetServers', inlineFiles = inlineFiles', autoAcceptFileSize, webPreviewConfig, highlyAvailable, confirmMigrations = confirmMigrations'} randomPresetServers <- chooseRandomServers presetServers' let rndSrvs = L.toList randomPresetServers operatorWithId (i, op) = (\o -> o {operatorId = DBEntityId i}) <$> pOperator op @@ -194,6 +195,7 @@ newChatController deliveryJobWorkers <- TM.emptyIO relayRequestWorkers <- TM.emptyIO relayGroupLinkChecksAsync <- newTVarIO Nothing + webPreviewState <- forM webPreviewConfig $ \_ -> newWebPreviewState chatRelayTests <- TM.emptyIO expireCIThreads <- TM.emptyIO expireCIFlags <- TM.emptyIO @@ -238,6 +240,7 @@ newChatController deliveryJobWorkers, relayRequestWorkers, relayGroupLinkChecksAsync, + webPreviewState, chatRelayTests, expireCIThreads, expireCIFlags, diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 6a4545a380..48913af9a5 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -39,6 +39,7 @@ import Data.Char (ord) import Data.Int (Int64) import Data.List.NonEmpty (NonEmpty) import Data.Map.Strict (Map) +import Data.Set (Set) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe) import Data.String @@ -162,6 +163,7 @@ data ChatConfig = ChatConfig ciExpirationInterval :: Int64, -- microseconds deliveryWorkerDelay :: Int64, -- microseconds deliveryBucketSize :: Int, + webPreviewConfig :: Maybe WebPreviewConfig, channelSubscriberRole :: GroupMemberRole, -- TODO [relays] starting role should be communicated in protocol from owner to relays relayChecksInterval :: NominalDiffTime, relayInactiveTTL :: NominalDiffTime, @@ -173,6 +175,43 @@ data ChatConfig = ChatConfig chatHooks :: ChatHooks } +data WebPreviewConfig = WebPreviewConfig + { webDomain :: Text, + webJsonDir :: FilePath, + webCorsFile :: Maybe FilePath, + webUpdateInterval :: Int, -- seconds + webPreviewItemCount :: Int + } + +data PublishableGroup = PublishableGroup + { pgFileName :: FilePath, + pgCorsEntry :: Maybe (Text, CorsOrigin) + } + +data CorsOrigin = CorsAny | CorsOrigins [Text] + deriving (Show) + +data WebPreviewState = WebPreviewState + { publishableGroupIds :: TVar (Map Int64 PublishableGroup), + priorityRender :: TQueue Int64, + filesToRemove :: TQueue FilePath, + corsNeeded :: TVar Bool, + routinePending :: TVar (Set Int64), + wakeSignal :: TMVar (), + webPreviewWorkerAsync :: TVar (Maybe (Async ())) + } + +newWebPreviewState :: IO WebPreviewState +newWebPreviewState = do + publishableGroupIds <- newTVarIO mempty + priorityRender <- newTQueueIO + filesToRemove <- newTQueueIO + corsNeeded <- newTVarIO False + routinePending <- newTVarIO mempty + wakeSignal <- newEmptyTMVarIO + webPreviewWorkerAsync <- newTVarIO Nothing + pure WebPreviewState {publishableGroupIds, priorityRender, filesToRemove, corsNeeded, routinePending, wakeSignal, webPreviewWorkerAsync} + -- | Builds the read-only context threaded through store functions from chat config. -- The single construction point, so new store-wide config (e.g. server keys) is added in one place. mkStoreCxt :: ChatConfig -> StoreCxt @@ -266,6 +305,7 @@ data ChatController = ChatController deliveryJobWorkers :: TMap DeliveryWorkerKey Worker, relayRequestWorkers :: TMap Int Worker, -- single global worker with key 1 is used to fit into existing worker management framework relayGroupLinkChecksAsync :: TVar (Maybe (Async ())), + webPreviewState :: Maybe WebPreviewState, chatRelayTests :: TMap ConnId RelayTest, expireCIThreads :: TMap UserId (Maybe (Async ())), expireCIFlags :: TMap UserId Bool, @@ -555,6 +595,7 @@ data ChatCommand | ShowGroupProfile GroupName | UpdateGroupDescription GroupName (Maybe Text) | ShowGroupDescription GroupName + | SetPublicGroupAccess GroupName PublicGroupAccess | CreateGroupLink GroupName GroupMemberRole | GroupLinkMemberRole GroupName GroupMemberRole | DeleteGroupLink GroupName diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 72cf6a412c..646377ac9c 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -90,6 +90,7 @@ import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Util (liftIOEither, zipWith3') import qualified Simplex.Chat.Util as U +import Simplex.Chat.Web (webPreviewWorker) import Simplex.FileTransfer.Description (FileDescriptionURI (..), maxFileSize, maxFileSizeHard) import Simplex.Messaging.Agent import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles) @@ -201,6 +202,7 @@ startChatController mainApp enableSndFiles = do startCleanupManager void $ forkIO $ mapM_ startExpireCIs users startRelayChecks users + startWebPreview users else when enableSndFiles $ startXFTP xftpStartSndWorkers pure a1 startXFTP startWorkers = do @@ -232,6 +234,20 @@ startChatController mainApp enableSndFiles = do a <- Just <$> async (void $ runExceptT $ runRelayGroupLinkChecks relayUser) atomically $ writeTVar relayAsync a _ -> pure () + startWebPreview users = do + let relayUsers = filter (\User {userChatRelay} -> isTrue userChatRelay) users + ChatConfig {webPreviewConfig = cfg_} <- asks config + case (relayUsers, cfg_) of + (_ : _, Just cfg) -> do + wps_ <- asks webPreviewState + forM_ wps_ $ \WebPreviewState {webPreviewWorkerAsync} -> + readTVarIO webPreviewWorkerAsync >>= \case + Nothing -> do + cc <- ask + a <- Just <$> async (liftIO $ webPreviewWorker cfg cc relayUsers) + atomically $ writeTVar webPreviewWorkerAsync a + _ -> pure () + _ -> pure () startExpireCIs user = whenM shouldExpireChats $ do startExpireCIThread user setExpireCIFlag user True @@ -3060,6 +3076,12 @@ processChatCommand cxt nm = \case updateGroupProfileByName gName $ \p -> p {description} ShowGroupDescription gName -> withUser $ \user -> CRGroupDescription user <$> withFastStore (\db -> getGroupInfoByName db cxt user gName) + SetPublicGroupAccess gName access -> withUser $ \user -> do + gInfo@GroupInfo {groupProfile = p@GroupProfile {publicGroup}} <- withStore $ \db -> + getGroupIdByName db user gName >>= getGroupInfo db cxt user + case publicGroup of + Just pg -> runUpdateGroupProfile user gInfo p {publicGroup = Just pg {publicGroupAccess = Just access}} + Nothing -> throwChatError $ CECommandError "not a public group" APICreateGroupLink groupId mRole -> withUser $ \user -> withGroupLock "createGroupLink" groupId $ do gInfo@GroupInfo {groupProfile} <- withFastStore $ \db -> getGroupInfo db cxt user groupId assertUserGroupRole gInfo GRAdmin @@ -4896,6 +4918,17 @@ runRelayGroupLinkChecks user = do else void $ withStore' $ \db -> updateRelayOwnStatusFromTo db gInfo RSActive RSInactive _ -> pure () _ -> pure () + sendRelayCapIfNeeded cxt gInfo + sendRelayCapIfNeeded cxt gInfo = do + ChatConfig {webPreviewConfig} <- asks config + let currentWebDomain = (\WebPreviewConfig {webDomain} -> webDomain) <$> webPreviewConfig + sentWebDomain <- withStore' (`getRelaySentWebDomain` gInfo) + when (currentWebDomain /= sentWebDomain) $ do + owners <- withStore' $ \db -> getGroupOwners db cxt user gInfo + let capableOwners = filter (\m -> memberCurrent m && m `supportsVersion` relayWebCapVersion) owners + unless (null capableOwners) $ do + void $ sendGroupMessage' user gInfo capableOwners (XGrpRelayCap RelayCapabilities {webDomain = currentWebDomain}) + withStore' $ \db -> updateRelaySentWebDomain db gInfo currentWebDomain checkRelayInactiveGroups = do cxt <- chatStoreCxt ttl <- asks (relayInactiveTTL . config) @@ -5219,6 +5252,7 @@ chatCommandP = "/_group_profile #" *> (APIUpdateGroupProfile <$> A.decimal <* A.space <*> jsonP), ("/group_profile " <|> "/gp ") *> char_ '#' *> (UpdateGroupNames <$> displayNameP <* A.space <*> groupProfile), ("/group_profile " <|> "/gp ") *> char_ '#' *> (ShowGroupProfile <$> displayNameP), + "/public group access " *> char_ '#' *> (SetPublicGroupAccess <$> displayNameP <*> publicGroupAccessP), "/group_descr " *> char_ '#' *> (UpdateGroupDescription <$> displayNameP <*> optional (A.space *> msgTextP)), "/set welcome " *> char_ '#' *> (UpdateGroupDescription <$> displayNameP <* A.space <*> (Just <$> msgTextP)), "/delete welcome " *> char_ '#' *> (UpdateGroupDescription <$> displayNameP <*> pure Nothing), @@ -5425,6 +5459,12 @@ chatCommandP = clearOverrides <- (" clear_overrides=" *> onOffP) <|> pure False pure UserMsgReceiptSettings {enable, clearOverrides} onOffP = ("on" $> True) <|> ("off" $> False) + publicGroupAccessP = do + groupWebPage <- optional (" web=" *> (safeDecodeUtf8 <$> A.takeTill A.isSpace)) + groupDomain <- optional (" domain=" *> (safeDecodeUtf8 <$> A.takeTill A.isSpace)) + domainWebPage <- (" domain_page=" *> onOffP) <|> pure False + allowEmbedding <- (" embed=" *> onOffP) <|> pure False + pure PublicGroupAccess {groupWebPage, groupDomain, domainWebPage, allowEmbedding} profileNameDescr = (,) <$> displayNameP <*> shortDescrP -- 'Help with bot':'link ','Menu of commands':[...] botCommandsP :: Parser [ChatBotCommand] diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 0595cf7c4b..325e552d44 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1046,8 +1046,9 @@ acceptRelayJoinRequestAsync cReqInvId cReqChatVRange relayLink = do - -- TODO [channel web] derive RelayCapabilities from relay config (RelayWebOptions) - let msg = XGrpRelayAcpt relayLink defaultRelayCapabilities + ChatConfig {webPreviewConfig} <- asks config + let webDomain_ = (\WebPreviewConfig {webDomain} -> webDomain) <$> webPreviewConfig + msg = XGrpRelayAcpt relayLink RelayCapabilities {webDomain = webDomain_} subMode <- chatReadVar subscriptionMode cxt <- chatStoreCxt let chatV = vr cxt `peerConnChatVersion` cReqChatVRange diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 9bb3d5d2eb..b948d7727b 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -47,6 +47,7 @@ import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Delivery import Simplex.Chat.Library.Internal +import Simplex.Chat.Web (channelContentChanged, channelProfileUpdated, channelRemoved) import Simplex.Chat.Messages import Simplex.Chat.Messages.Batch (batchDeliveryTasks1, batchProfiles, batchProfilesWithBody, encodeBinaryBatch, encodeFwdElement, maxBatchElementSize) import Simplex.Chat.Messages.CIContent @@ -870,6 +871,9 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = else pure gInfo pure (m {memberStatus = GSMemConnected}, gInfo') toView $ CEvtUserJoinedGroup user gInfo' m' + when (isRelay membership) $ do + cc <- ask + atomically $ channelProfileUpdated cc groupId groupProfile (gInfo'', m'', scopeInfo) <- mkGroupChatScope gInfo' m' -- Create e2ee, feature and group description chat items only on first connected relay ifM @@ -1018,6 +1022,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = processEvent :: forall e. MsgEncodingI e => GroupInfo -> GroupMember -> VerifiedMsg e -> CM (Maybe NewMessageDeliveryTask) processEvent gInfo' m' verifiedMsg = do (m'', conn', msg@RcvMessage {msgId, chatMsgEvent = ACME _ event}) <- saveGroupRcvMsg user groupId m' conn msgMeta verifiedMsg + cc <- ask let ctx js = DeliveryTaskContext js False checkSendAsGroup :: Maybe Bool -> CM (Maybe DeliveryTaskContext) -> CM (Maybe DeliveryTaskContext) checkSendAsGroup asGroup_ a @@ -1074,7 +1079,17 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = XInfoProbeOk probe -> Nothing <$ xInfoProbeOk (COMGroupMember m'') probe BFileChunk sharedMsgId chunk -> Nothing <$ bFileChunkGroup gInfo' sharedMsgId chunk msgMeta _ -> Nothing <$ messageError ("unsupported message: " <> tshow event) - forM deliveryTaskContext_ $ \taskContext -> + forM deliveryTaskContext_ $ \taskContext -> do + let contentChanged :: CM () + contentChanged = atomically $ channelContentChanged cc groupId + case event of + XMsgNew {} -> contentChanged + XMsgUpdate {} -> contentChanged + XMsgDel {} -> contentChanged + XMsgReact {} -> contentChanged + XGrpInfo p' -> atomically $ channelProfileUpdated cc groupId p' + XGrpDel {} -> atomically $ channelRemoved cc groupId + _ -> pure () pure $ NewMessageDeliveryTask {messageId = msgId, taskContext} checkSendRcpt :: [AParsedMsg] -> CM Bool checkSendRcpt aMsgs = do diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 6cf30edbd5..85074e93f4 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -261,6 +261,7 @@ mobileChatOpts dbOptions = tbqSize = 4096, deviceName = Nothing, chatRelay = False, + webPreviewConfig = Nothing, highlyAvailable = False, yesToUpMigrations = False, migrationBackupPath = Just "", diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index 08a765077f..a936f58848 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -28,7 +28,7 @@ import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Numeric.Natural (Natural) import Options.Applicative -import Simplex.Chat.Controller (ChatLogLevel (..), SimpleNetCfg (..), updateStr, versionNumber, versionString) +import Simplex.Chat.Controller (ChatLogLevel (..), SimpleNetCfg (..), WebPreviewConfig (..), updateStr, versionNumber, versionString) import Simplex.FileTransfer.Description (mb) import Simplex.Messaging.Client (HostMode (..), SMPWebPortServers (..), SocksMode (..), textToHostMode) import Simplex.Messaging.Encoding.String @@ -66,6 +66,7 @@ data CoreChatOpts = CoreChatOpts tbqSize :: Natural, deviceName :: Maybe Text, chatRelay :: Bool, + webPreviewConfig :: Maybe WebPreviewConfig, highlyAvailable :: Bool, yesToUpMigrations :: Bool, migrationBackupPath :: Maybe FilePath, @@ -240,6 +241,46 @@ coreChatOptsP appDir defaultDbName = do ( long "relay" <> help "Run as a chat relay client" ) + webPreviewConfig <- do + webDomain_ <- + optional $ + strOption + ( long "relay-web-domain" + <> metavar "DOMAIN" + <> help "Domain for channel web previews (relay only)" + ) + webJsonDir_ <- + optional $ + strOption + ( long "relay-web-dir" + <> metavar "DIR" + <> help "Directory for channel web preview JSON files (relay only)" + ) + webCorsFile <- + optional $ + strOption + ( long "relay-web-cors-file" + <> metavar "FILE" + <> help "Path to generated Caddy CORS config file (relay only)" + ) + webUpdateInterval <- + option auto + ( long "relay-web-interval" + <> metavar "SECONDS" + <> help "Interval between web preview regeneration in seconds (relay only)" + <> value 300 + ) + webPreviewItemCount <- + option auto + ( long "relay-web-item-count" + <> metavar "COUNT" + <> help "Number of recent messages in channel web preview (relay only)" + <> value 50 + ) + pure $ case (webDomain_, webJsonDir_) of + (Just webDomain, Just webJsonDir) -> Just WebPreviewConfig {webDomain, webJsonDir, webCorsFile, webUpdateInterval, webPreviewItemCount} + (Nothing, Nothing) -> Nothing + _ -> errorWithoutStackTrace "--relay-web-domain and --relay-web-dir must both be provided" highlyAvailable <- switch ( long "ha" @@ -283,6 +324,7 @@ coreChatOptsP appDir defaultDbName = do tbqSize, deviceName, chatRelay, + webPreviewConfig, highlyAvailable, yesToUpMigrations, migrationBackupPath, diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 94951d1110..4546985e52 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -83,12 +83,13 @@ import Simplex.Messaging.Version hiding (version) -- 15 - support specifying message scopes for group messages (2025-03-12) -- 16 - support short link data (2025-06-10) -- 17 - allow host voice messages during member approval regardless of group voice setting (2026-02-10) +-- 18 - relay web capabilities (2026-05-31) -- This should not be used directly in code, instead use `maxVersion chatVRange` from ChatConfig. -- This indirection is needed for backward/forward compatibility testing. -- Testing with real app versions is still needed, as tests use the current code with different version ranges, not the old code. currentChatVersion :: VersionChat -currentChatVersion = VersionChat 17 +currentChatVersion = VersionChat 18 -- This should not be used directly in code, instead use `chatVRange` from ChatConfig (see comment above) supportedChatVRange :: VersionRangeChat @@ -155,6 +156,10 @@ shortLinkDataVersion = VersionChat 16 memberSupportVoiceVersion :: VersionChat memberSupportVoiceVersion = VersionChat 17 +-- relay sends web preview capabilities to owner +relayWebCapVersion :: VersionChat +relayWebCapVersion = VersionChat 18 + agentToChatVersion :: VersionSMPA -> VersionChat agentToChatVersion v | v < pqdrSMPAgentVersion = initialChatVersion diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index a0c9dd9ed0..2ea3fa9b84 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -67,6 +67,7 @@ module Simplex.Chat.Store.Groups getGroupMembersByIndexes, getSupportScopeMembersByIndexes, getGroupModerators, + getGroupOwners, getGroupRelayMembers, getGroupMembersForExpiration, getRemovedMembersToCleanup, @@ -98,9 +99,12 @@ module Simplex.Chat.Store.Groups createRelayRequestGroup, updateRelayOwnStatusFromTo, updateRelayOwnStatus_, + getRelaySentWebDomain, + updateRelaySentWebDomain, isRelayGroupRejected, allowRelayGroup, getRelayServedGroups, + getRelayPublishableGroups, getRelayInactiveGroups, createNewContactMemberAsync, createJoiningMember, @@ -1211,6 +1215,15 @@ getGroupModerators db cxt user@User {userId, userContactId} GroupInfo {groupId} (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?)") (userId, groupId, userContactId, GRModerator, GRAdmin, GROwner) +getGroupOwners :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] +getGroupOwners db cxt user@User {userId, userContactId} GroupInfo {groupId} = do + ts <- getCurrentTime + map (toContactMember ts cxt user) + <$> DB.query + db + (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role = ?") + (userId, groupId, userContactId, GROwner) + getGroupRelayMembers :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] getGroupRelayMembers db cxt user@User {userId, userContactId} GroupInfo {groupId} = do currentTs <- getCurrentTime @@ -1650,6 +1663,14 @@ updateRelayOwnStatus_ db GroupInfo {groupId} relayStatus = do let inactiveAt_ = if relayStatus == RSInactive then Just currentTs else Nothing DB.execute db "UPDATE groups SET relay_own_status = ?, relay_inactive_at = ?, updated_at = ? WHERE group_id = ?" (relayStatus, inactiveAt_, currentTs, groupId) +getRelaySentWebDomain :: DB.Connection -> GroupInfo -> IO (Maybe Text) +getRelaySentWebDomain db GroupInfo {groupId} = + join <$> maybeFirstRow fromOnly (DB.query db "SELECT relay_sent_web_domain FROM groups WHERE group_id = ?" (Only groupId)) + +updateRelaySentWebDomain :: DB.Connection -> GroupInfo -> Maybe Text -> IO () +updateRelaySentWebDomain db GroupInfo {groupId} webDomain_ = + DB.execute db "UPDATE groups SET relay_sent_web_domain = ? WHERE group_id = ?" (webDomain_, groupId) + -- Flip every RSRejected row sharing the targeted group's relay_request_group_link -- to RSInactive in one statement; returns the refreshed GroupInfo for the targeted groupId. allowRelayGroup :: DB.Connection -> StoreCxt -> User -> GroupId -> ExceptT StoreError IO GroupInfo @@ -1696,6 +1717,24 @@ getRelayServedGroups db cxt User {userId, userContactId} = do ) (userId, userContactId, RSAccepted, RSActive) +getRelayPublishableGroups :: DB.Connection -> User -> IO [(Int64, B64UrlByteString, Maybe PublicGroupAccess)] +getRelayPublishableGroups db User {userId, userContactId} = + map toRow <$> + DB.query + db + [sql| + SELECT g.group_id, gp.public_group_id, + gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding + FROM groups g + JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id + JOIN group_members mu ON mu.group_id = g.group_id AND mu.contact_id = ? + WHERE g.user_id = ? AND g.relay_own_status IN (?, ?) + AND gp.public_group_id IS NOT NULL + |] + (userContactId, userId, RSAccepted, RSActive) + where + toRow ((gId, pgId) :. accessRow) = (gId, pgId, toPublicGroupAccess accessRow) + getRelayInactiveGroups :: DB.Connection -> StoreCxt -> User -> NominalDiffTime -> IO [GroupInfo] getRelayInactiveGroups db cxt User {userId, userContactId} ttl = do currentTs <- getCurrentTime diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 3a96756712..cf12db7ec1 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -137,6 +137,7 @@ module Simplex.Chat.Store.Messages getGroupSndStatuses, getGroupSndStatusCounts, getGroupHistoryItems, + getGroupWebPreviewItems, ) where @@ -3716,3 +3717,21 @@ getGroupHistoryItems db user@User {userId} g@GroupInfo {groupId} m count = do LIMIT ? |] (groupMemberId' m, userId, groupId, count) + +getGroupWebPreviewItems :: DB.Connection -> User -> GroupInfo -> Int -> IO [Either StoreError (CChatItem 'CTGroup)] +getGroupWebPreviewItems db user@User {userId} g@GroupInfo {groupId} count = do + ciIds <- + map fromOnly + <$> DB.query + db + [sql| + SELECT i.chat_item_id + FROM chat_items i + WHERE i.user_id = ? AND i.group_id = ? + AND i.include_in_history = 1 + AND i.item_deleted = 0 + ORDER BY i.item_ts DESC, i.chat_item_id DESC + LIMIT ? + |] + (userId, groupId, count) + reverse <$> mapM (runExceptT . getGroupCIWithReactions db user g) ciIds diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 862a93f00d..20acf0b602 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -36,6 +36,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260516_supporter_badges import Simplex.Chat.Store.Postgres.Migrations.M20260529_delivery_job_senders import Simplex.Chat.Store.Postgres.Migrations.M20260530_client_services import Simplex.Chat.Store.Postgres.Migrations.M20260531_member_removed_at +import Simplex.Chat.Store.Postgres.Migrations.M20260601_relay_sent_web_domain import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -71,7 +72,8 @@ schemaMigrations = ("20260516_supporter_badges", m20260516_supporter_badges, Just down_m20260516_supporter_badges), ("20260529_delivery_job_senders", m20260529_delivery_job_senders, Just down_m20260529_delivery_job_senders), ("20260530_client_services", m20260530_client_services, Just down_m20260530_client_services), - ("20260531_member_removed_at", m20260531_member_removed_at, Just down_m20260531_member_removed_at) + ("20260531_member_removed_at", m20260531_member_removed_at, Just down_m20260531_member_removed_at), + ("20260601_relay_sent_web_domain", m20260601_relay_sent_web_domain, Just down_m20260601_relay_sent_web_domain) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260601_relay_sent_web_domain.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260601_relay_sent_web_domain.hs new file mode 100644 index 0000000000..1b8efbcead --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260601_relay_sent_web_domain.hs @@ -0,0 +1,19 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260601_relay_sent_web_domain where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260601_relay_sent_web_domain :: Text +m20260601_relay_sent_web_domain = + [r| +ALTER TABLE groups ADD COLUMN relay_sent_web_domain TEXT; +|] + +down_m20260601_relay_sent_web_domain :: Text +down_m20260601_relay_sent_web_domain = + [r| +ALTER TABLE groups DROP COLUMN relay_sent_web_domain; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index f015999274..89cddd48e5 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -978,7 +978,8 @@ CREATE TABLE test_chat_schema.groups ( relay_request_retries bigint DEFAULT 0 NOT NULL, relay_request_delay bigint DEFAULT 0 NOT NULL, relay_request_execute_at timestamp with time zone DEFAULT '1970-01-01 01:00:00+01'::timestamp with time zone NOT NULL, - relay_inactive_at timestamp with time zone + relay_inactive_at timestamp with time zone, + relay_sent_web_domain text ); diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 84860d35fe..78838c507f 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -159,6 +159,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260516_supporter_badges import Simplex.Chat.Store.SQLite.Migrations.M20260529_delivery_job_senders import Simplex.Chat.Store.SQLite.Migrations.M20260530_client_services import Simplex.Chat.Store.SQLite.Migrations.M20260531_member_removed_at +import Simplex.Chat.Store.SQLite.Migrations.M20260601_relay_sent_web_domain import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -317,7 +318,8 @@ schemaMigrations = ("20260516_supporter_badges", m20260516_supporter_badges, Just down_m20260516_supporter_badges), ("20260529_delivery_job_senders", m20260529_delivery_job_senders, Just down_m20260529_delivery_job_senders), ("20260530_client_services", m20260530_client_services, Just down_m20260530_client_services), - ("20260531_member_removed_at", m20260531_member_removed_at, Just down_m20260531_member_removed_at) + ("20260531_member_removed_at", m20260531_member_removed_at, Just down_m20260531_member_removed_at), + ("20260601_relay_sent_web_domain", m20260601_relay_sent_web_domain, Just down_m20260601_relay_sent_web_domain) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260601_relay_sent_web_domain.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260601_relay_sent_web_domain.hs new file mode 100644 index 0000000000..922a563356 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260601_relay_sent_web_domain.hs @@ -0,0 +1,18 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260601_relay_sent_web_domain where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20260601_relay_sent_web_domain :: Query +m20260601_relay_sent_web_domain = + [sql| +ALTER TABLE groups ADD COLUMN relay_sent_web_domain TEXT; +|] + +down_m20260601_relay_sent_web_domain :: Query +down_m20260601_relay_sent_web_domain = + [sql| +ALTER TABLE groups DROP COLUMN relay_sent_web_domain; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt index ee857211aa..a986773cb2 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -548,15 +548,6 @@ Plan: SEARCH s USING PRIMARY KEY (conn_id=? AND internal_snd_id=?) SEARCH m USING PRIMARY KEY (conn_id=? AND internal_id=?) -Query: - UPDATE rcv_messages - SET receive_attempts = receive_attempts + 1 - WHERE conn_id = ? AND internal_id = ? - RETURNING receive_attempts - -Plan: -SEARCH rcv_messages USING COVERING INDEX idx_rcv_messages_conn_id_internal_id (conn_id=? AND internal_id=?) - Query: DELETE FROM conn_confirmations WHERE conn_id = ? diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index e31194d151..d750be3275 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -1618,6 +1618,18 @@ Plan: SEARCH i USING INDEX idx_chat_items_group_id (group_id=?) SEARCH m USING INTEGER PRIMARY KEY (rowid=?) +Query: + SELECT i.chat_item_id + FROM chat_items i + WHERE i.user_id = ? AND i.group_id = ? + AND i.include_in_history = 1 + AND i.item_deleted = 0 + ORDER BY i.item_ts DESC, i.chat_item_id DESC + LIMIT ? + +Plan: +SEARCH i USING COVERING INDEX idx_chat_items_groups_history (user_id=? AND group_id=? AND include_in_history=? AND item_deleted=?) + Query: SELECT i.chat_item_id, i.contact_id, i.group_id, i.group_scope_tag, i.group_scope_group_member_id, i.note_folder_id FROM chat_items i @@ -5442,6 +5454,36 @@ SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) SEARCH pu USING INTEGER PRIMARY KEY (rowid=?) +Query: + SELECT + -- GroupInfo + g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, gp.group_type, gp.group_link, gp.public_group_id, + gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, + g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, + g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, + g.business_chat, g.business_member_id, g.customer_member_id, + g.use_relays, g.relay_own_status, + g.ui_themes, g.summary_current_members_count, g.public_member_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.root_priv_key, g.root_pub_key, g.member_priv_key, + -- GroupMember - membership + mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, + mu.created_at, mu.updated_at, + mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link + + FROM groups g + JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id + JOIN group_members mu ON mu.group_id = g.group_id + JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) + WHERE g.user_id = ? AND mu.contact_id = ? AND g.relay_own_status IN (?, ?) +Plan: +SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) +SEARCH pu USING INTEGER PRIMARY KEY (rowid=?) + Query: SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, @@ -5696,6 +5738,25 @@ Query: FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN connections c ON c.group_member_id = m.group_member_id + WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role = ? +Plan: +SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN + +Query: + SELECT + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN connections c ON c.group_member_id = m.group_member_id WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?) Plan: SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index f7bdcc1eb8..06810d6aab 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -191,7 +191,8 @@ CREATE TABLE groups( relay_request_retries INTEGER NOT NULL DEFAULT 0, relay_request_delay INTEGER NOT NULL DEFAULT 0, relay_request_execute_at TEXT NOT NULL DEFAULT '1970-01-01 00:00:00', - relay_inactive_at TEXT, -- received + relay_inactive_at TEXT, + relay_sent_web_domain TEXT, -- received FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 4f40e3a566..f9e36a86ad 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -881,8 +881,13 @@ instance FromJSON ImageData where parseJSON = fmap ImageData . J.parseJSON instance ToJSON ImageData where - toJSON (ImageData t) = J.toJSON t - toEncoding (ImageData t) = J.toEncoding t + toJSON (ImageData t) = J.toJSON $ safeImageData t + toEncoding (ImageData t) = J.toEncoding $ safeImageData t + +safeImageData :: Text -> Text +safeImageData t + | "data:" `T.isPrefixOf` t = t + | otherwise = "" instance ToField ImageData where toField (ImageData t) = toField t diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index b98d965254..cd7a5daea9 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1207,8 +1207,8 @@ viewReceivedContactRequest c Profile {fullName, shortDescr} = ] showRelay :: GroupRelay -> StyledString -showRelay GroupRelay {groupRelayId, relayStatus} = - " - relay id " <> sShow groupRelayId <> ": " <> plain (relayStatusText relayStatus) +showRelay GroupRelay {groupRelayId, relayStatus, relayCap = RelayCapabilities {webDomain}} = + " - relay id " <> sShow groupRelayId <> ": " <> plain (relayStatusText relayStatus) <> maybe "" (\d -> ", web: " <> plain d) webDomain viewGroupRelays :: GroupInfo -> [GroupRelay] -> [StyledString] viewGroupRelays g relays = @@ -1982,10 +1982,10 @@ countactUserPrefText cup = case cup of viewGroupUpdated :: GroupInfo -> GroupInfo -> Maybe GroupMember -> Maybe MsgSigStatus -> [StyledString] viewGroupUpdated - GroupInfo {localDisplayName = n, groupProfile = GroupProfile {fullName, shortDescr, description, image, groupPreferences = gps, memberAdmission = ma}} - g'@GroupInfo {localDisplayName = n', groupProfile = GroupProfile {fullName = fullName', shortDescr = shortDescr', description = description', image = image', groupPreferences = gps', memberAdmission = ma'}} + GroupInfo {localDisplayName = n, groupProfile = GroupProfile {fullName, shortDescr, description, image, groupPreferences = gps, memberAdmission = ma, publicGroup = pg}} + g'@GroupInfo {localDisplayName = n', groupProfile = GroupProfile {fullName = fullName', shortDescr = shortDescr', description = description', image = image', groupPreferences = gps', memberAdmission = ma', publicGroup = pg'}} m signed = do - let update = groupProfileUpdated <> groupPrefsUpdated <> memberAdmissionUpdated + let update = groupProfileUpdated <> groupPrefsUpdated <> memberAdmissionUpdated <> publicGroupAccessUpdated if null update then [] else memberUpdated <> update @@ -2010,6 +2010,18 @@ viewGroupUpdated memberAdmissionUpdated | ma == ma' = [] | otherwise = ["changed member admission rules"] + publicGroupAccessUpdated + | access == access' = [] + | otherwise = ["updated public group access:" <> viewAccess access'] + where + access = pg >>= publicGroupAccess + access' = pg' >>= publicGroupAccess + viewAccess Nothing = " removed" + viewAccess (Just PublicGroupAccess {groupWebPage, groupDomain, domainWebPage, allowEmbedding}) = + maybe "" (\u -> " web=" <> plain u) groupWebPage + <> maybe "" (\d -> " domain=" <> plain d) groupDomain + <> (if domainWebPage then " domain_page=on" else "") + <> (if allowEmbedding then " embed=on" else "") viewGroupProfile :: GroupInfo -> [StyledString] viewGroupProfile g@GroupInfo {groupProfile = GroupProfile {shortDescr, description, image, groupPreferences = gps}} = diff --git a/src/Simplex/Chat/Web.hs b/src/Simplex/Chat/Web.hs new file mode 100644 index 0000000000..2b4fb89137 --- /dev/null +++ b/src/Simplex/Chat/Web.hs @@ -0,0 +1,430 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TemplateHaskell #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} + +module Simplex.Chat.Web + ( WebChannelPreview (..), + WebMessage (..), + WebMemberProfile (..), + WebFileInfo (..), + webPreviewWorker, + writeCorsConfig, + removeStaleFiles, + channelContentChanged, + channelProfileUpdated, + channelRemoved, + extractOrigin, + ) +where + +import Control.Concurrent.STM (check, flushTQueue) +import Control.Exception (SomeException, catch) +import Control.Logger.Simple +import Control.Monad (forM_, void, when) +import Control.Monad.Except (runExceptT) +import Data.Either (rights) +import Data.Int (Int64) +import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ +import qualified Data.ByteString.Char8 as B +import qualified Data.ByteString.Lazy as LB +import Data.Text.Encoding (encodeUtf8) +import qualified Data.Map.Strict as M +import qualified Data.Set as S +import Data.Maybe (isJust, mapMaybe, maybeToList) +import Data.Text (Text) +import qualified Data.Text as T +import qualified Data.Text.IO as TIO +import Data.Time.Clock (UTCTime, getCurrentTime) +import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), CorsOrigin (..), PublishableGroup (..), WebPreviewConfig (..), WebPreviewState (..), mkStoreCxt) +import Simplex.Chat.Markdown (FormattedText (..), MarkdownList, parseMaybeMarkdownList) +import Simplex.Chat.Messages + ( CChatItem (..), + CIDirection (..), + CIFile (..), + CIMeta (..), + CIQDirection (..), + CIQuote (..), + CIReactionCount, + ChatItem (..), + ChatType (..), + ) +import Simplex.Chat.Messages.CIContent (ciMsgContent) +import Simplex.Chat.Protocol (MsgContent, MsgRef (..), QuotedMsg (..), isReport) +import Simplex.Chat.Store.Groups (getGroupOwners, getRelayPublishableGroups) +import Simplex.Chat.Store.Messages (getGroupWebPreviewItems) +import Simplex.Chat.Store.Shared (getGroupInfo) +import Simplex.Chat.Types + ( B64UrlByteString, + GroupInfo (..), + GroupMember (..), + GroupProfile (..), + GroupSummary (..), + ImageData, + LocalProfile (..), + MemberId, + PublicGroupAccess (..), + PublicGroupProfile (..), + User (..), + ) +import Simplex.Messaging.Agent.Store.Common (withTransaction) +import Simplex.Messaging.Encoding.String (strEncode) +import Simplex.Messaging.Util (safeDecodeUtf8) +import qualified URI.ByteString as U +import Simplex.Messaging.Parsers (defaultJSON) +import System.Directory (createDirectoryIfMissing, listDirectory, removeFile, renameFile) +import System.FilePath (dropExtension, takeExtension, ()) +import UnliftIO.STM + +data WebFileInfo = WebFileInfo + { fileName :: String, + fileSize :: Integer + } + deriving (Show) + +data WebMemberProfile = WebMemberProfile + { memberId :: MemberId, + displayName :: Text, + image :: Maybe ImageData + } + deriving (Show) + +data WebMessage = WebMessage + { sender :: Maybe MemberId, + ts :: UTCTime, + content :: MsgContent, + formattedText :: Maybe MarkdownList, + file :: Maybe WebFileInfo, + quote :: Maybe QuotedMsg, + reactions :: [CIReactionCount], + forward :: Maybe Bool, + edited :: Bool + } + deriving (Show) + +data WebChannelPreview = WebChannelPreview + { channel :: GroupProfile, + shortDescription :: Maybe MarkdownList, + welcomeMessage :: Maybe MarkdownList, + members :: [WebMemberProfile], + subscribers :: Maybe Int64, + messages :: [WebMessage], + updatedAt :: UTCTime + } + deriving (Show) + +$(JQ.deriveJSON defaultJSON ''WebFileInfo) + +$(JQ.deriveJSON defaultJSON ''WebMemberProfile) + +$(JQ.deriveJSON defaultJSON ''WebMessage) + +$(JQ.deriveJSON defaultJSON ''WebChannelPreview) + +webPreviewWorker :: WebPreviewConfig -> ChatController -> [User] -> IO () +webPreviewWorker cfg@WebPreviewConfig {webJsonDir, webCorsFile, webUpdateInterval} cc users = + forM_ (webPreviewState cc) $ \wps -> do + createDirectoryIfMissing True webJsonDir + initPublishableGroups wps + cleanStaleFiles wps + regenerateCors wps + seedRoutinePending wps + workerLoop wps + where + cxt = mkStoreCxt (config cc) + + workerLoop wps@WebPreviewState {priorityRender, filesToRemove, corsNeeded, routinePending, wakeSignal} = do + drainRemovals + drainPriority + handleCors + renderRoutine + noRoutine <- atomically $ S.null <$> readTVar routinePending + when noRoutine waitRefresh + workerLoop wps + where + drainRemovals = atomically (tryReadTQueue filesToRemove) >>= \case + Nothing -> pure () + Just f -> do + removeFile (webJsonDir f) `catch` \(_ :: SomeException) -> pure () + drainRemovals + + -- flush the whole queue and render each group once: a burst of changes in one + -- channel enqueues its id many times, but only needs a single render + drainPriority = do + gIds <- atomically $ flushTQueue priorityRender + forM_ (S.fromList gIds) $ renderOneGroup wps + + handleCors = do + needed <- atomically $ swapTVar corsNeeded False + when needed $ regenerateCors wps + + -- render a single routine item; the main loop calls this once per iteration + renderRoutine = do + mGId <- atomically $ do + pending <- readTVar routinePending + case S.minView pending of + Nothing -> pure Nothing + Just (gId, rest) -> writeTVar routinePending rest >> pure (Just gId) + forM_ mGId $ renderOneGroup wps + + -- routine list drained: wait for the refresh timer or a change signal; only the timer + -- seeds the next full sweep, a change just returns to let the main loop service it + waitRefresh = do + delay <- registerDelay (webUpdateInterval * 1000000) + timerFired <- atomically $ + (True <$ (readTVar delay >>= check)) `orElse` (False <$ takeTMVar wakeSignal) + when timerFired $ seedRoutinePending wps + + initPublishableGroups WebPreviewState {publishableGroupIds} = do + rows <- withTransaction (chatStore cc) $ \db -> + concat <$> mapM (getRelayPublishableGroups db) users + let gIds = M.fromList [(gId, toPublishableGroup pgId access) | (gId, pgId, access) <- rows] + atomically $ writeTVar publishableGroupIds gIds + + cleanStaleFiles WebPreviewState {publishableGroupIds} = do + ids <- readTVarIO publishableGroupIds + let activeFiles = S.fromList $ map pgFileName $ M.elems ids + removeStaleFiles webJsonDir activeFiles + + regenerateCors WebPreviewState {publishableGroupIds} = do + ids <- readTVarIO publishableGroupIds + let entries = mapMaybe pgCorsEntry $ M.elems ids + forM_ webCorsFile $ writeCorsConfig entries + + seedRoutinePending WebPreviewState {publishableGroupIds, routinePending} = + atomically $ M.keysSet <$> readTVar publishableGroupIds >>= writeTVar routinePending + + renderOneGroup WebPreviewState {publishableGroupIds} gId = do + publishable <- atomically $ M.member gId <$> readTVar publishableGroupIds + when publishable $ + renderOrRemoveStale `catch` \(e :: SomeException) -> + logError $ "web preview: error rendering group " <> T.pack (show gId) <> ": " <> T.pack (show e) + where + renderOrRemoveStale = do + r <- withTransaction (chatStore cc) $ \db -> + findUser $ \u -> fmap (\g -> (u, g)) <$> runExceptT (getGroupInfo db cxt u gId) + case r of + Just (u, gInfo) | hasPublicGroup gInfo -> + void $ renderGroupPreview cfg cc u gInfo + _ -> do + fName <- atomically $ do + pg <- M.lookup gId <$> readTVar publishableGroupIds + modifyTVar' publishableGroupIds (M.delete gId) + pure $ pgFileName <$> pg + forM_ fName $ \f -> + removeFile (webJsonDir f) `catch` \(_ :: SomeException) -> pure () + logInfo $ "web preview: group " <> T.pack (show gId) <> " no longer publishable" + + findUser f = go users + where + go [] = pure Nothing + go (u : us) = f u >>= \case + Right a -> pure (Just a) + Left _ -> go us + +renderGroupPreview :: WebPreviewConfig -> ChatController -> User -> GroupInfo -> IO (Maybe (Text, CorsOrigin)) +renderGroupPreview WebPreviewConfig {webJsonDir, webPreviewItemCount} cc user gInfo@GroupInfo {groupProfile = gp@GroupProfile {shortDescr = sd, description = wd, publicGroup}, groupSummary = GroupSummary {publicMemberCount}} = + case publicGroup of + Just PublicGroupProfile {publicGroupId, publicGroupAccess} -> do + let fName = publicGroupIdFileName publicGroupId <> ".json" + (items, owners) <- withTransaction (chatStore cc) $ \db -> do + is <- getGroupWebPreviewItems db user gInfo webPreviewItemCount + os <- getGroupOwners db cxt user gInfo + pure (is, os) + ts <- getCurrentTime + let rendered = mapMaybe toRenderedItem $ rights items + msgs = map fst rendered + senders = collectSenders $ map memberToProfile owners <> concatMap snd rendered + preview = WebChannelPreview + { channel = gp, + shortDescription = toFormattedText =<< sd, + welcomeMessage = toFormattedText =<< wd, + members = senders, + subscribers = publicMemberCount, + messages = msgs, + updatedAt = ts + } + let destPath = webJsonDir fName + tmpPath = destPath <> ".tmp" + LB.writeFile tmpPath (J.encode preview) + renameFile tmpPath destPath + pure $ corsEntry publicGroupId <$> publicGroupAccess + Nothing -> pure Nothing + where + cxt = mkStoreCxt (config cc) + +channelContentChanged :: ChatController -> Int64 -> STM () +channelContentChanged cc gId = + forM_ (webPreviewState cc) $ \WebPreviewState {publishableGroupIds, priorityRender, routinePending, wakeSignal} -> do + ids <- readTVar publishableGroupIds + when (M.member gId ids) $ do + writeTQueue priorityRender gId + modifyTVar' routinePending (S.delete gId) + void $ tryPutTMVar wakeSignal () + +channelProfileUpdated :: ChatController -> Int64 -> GroupProfile -> STM () +channelProfileUpdated cc gId GroupProfile {publicGroup} = + forM_ (webPreviewState cc) $ \WebPreviewState {publishableGroupIds, priorityRender, filesToRemove, corsNeeded, routinePending, wakeSignal} -> + case publicGroup of + Just PublicGroupProfile {publicGroupId, publicGroupAccess} -> do + let pg = PublishableGroup + { pgFileName = publicGroupIdFileName publicGroupId <> ".json", + pgCorsEntry = corsEntry publicGroupId <$> publicGroupAccess + } + modifyTVar' publishableGroupIds (M.insert gId pg) + writeTQueue priorityRender gId + modifyTVar' routinePending (S.delete gId) + writeTVar corsNeeded True + void $ tryPutTMVar wakeSignal () + Nothing -> do + ids <- readTVar publishableGroupIds + forM_ (pgFileName <$> M.lookup gId ids) $ writeTQueue filesToRemove + modifyTVar' publishableGroupIds (M.delete gId) + modifyTVar' routinePending (S.delete gId) + writeTVar corsNeeded True + void $ tryPutTMVar wakeSignal () + +channelRemoved :: ChatController -> Int64 -> STM () +channelRemoved cc gId = + forM_ (webPreviewState cc) $ \WebPreviewState {publishableGroupIds, filesToRemove, corsNeeded, routinePending, wakeSignal} -> do + ids <- readTVar publishableGroupIds + forM_ (pgFileName <$> M.lookup gId ids) $ writeTQueue filesToRemove + modifyTVar' publishableGroupIds (M.delete gId) + modifyTVar' routinePending (S.delete gId) + writeTVar corsNeeded True + void $ tryPutTMVar wakeSignal () + +toRenderedItem :: CChatItem 'CTGroup -> Maybe (WebMessage, [WebMemberProfile]) +toRenderedItem (CChatItem _ ChatItem {chatDir, meta = CIMeta {itemTs, itemTimed, itemForwarded, itemEdited}, content, formattedText, quotedItem, reactions, file}) + | isJust itemTimed = Nothing + | otherwise = case ciMsgContent content of + Just mc | not (isReport mc) -> + let (sender, senderProfile) = case chatDir of + CIGroupRcv m@GroupMember {memberId} -> (Just memberId, [memberToProfile m]) + _ -> (Nothing, []) + quotedProfile = case quotedItem of + Just CIQuote {chatDir = CIQGroupRcv (Just m)} -> [memberToProfile m] + _ -> [] + in Just + ( WebMessage + { sender, + ts = itemTs, + content = mc, + formattedText, + file = webFileInfo <$> file, + quote = quotedItem >>= ciQuoteToQuotedMsg, + reactions, + forward = if isJust itemForwarded then Just True else Nothing, + edited = itemEdited + }, + senderProfile <> quotedProfile + ) + _ -> Nothing + +ciQuoteToQuotedMsg :: CIQuote c -> Maybe QuotedMsg +ciQuoteToQuotedMsg CIQuote {chatDir = qDir, sharedMsgId, sentAt, content = qContent} = + Just QuotedMsg + { msgRef = MsgRef + { msgId = sharedMsgId, + sentAt, + sent = case qDir of + CIQDirectSnd -> True + CIQGroupSnd -> True + _ -> False, + memberId = case qDir of + CIQGroupRcv (Just GroupMember {memberId}) -> Just memberId + _ -> Nothing + }, + content = qContent + } + +webFileInfo :: CIFile d -> WebFileInfo +webFileInfo CIFile {fileName, fileSize} = WebFileInfo {fileName, fileSize} + +collectSenders :: [WebMemberProfile] -> [WebMemberProfile] +collectSenders = M.elems . M.fromList . map (\p@WebMemberProfile {memberId} -> (memberId, p)) + +memberToProfile :: GroupMember -> WebMemberProfile +memberToProfile GroupMember {memberId, memberProfile = LocalProfile {displayName, image}} = + WebMemberProfile {memberId, displayName, image} + +toPublishableGroup :: B64UrlByteString -> Maybe PublicGroupAccess -> PublishableGroup +toPublishableGroup pgId access = + PublishableGroup + { pgFileName = publicGroupIdFileName pgId <> ".json", + pgCorsEntry = corsEntry pgId <$> access + } + +corsEntry :: B64UrlByteString -> PublicGroupAccess -> (Text, CorsOrigin) +corsEntry publicGroupId PublicGroupAccess {groupWebPage, allowEmbedding} = + let fName = T.pack $ publicGroupIdFileName publicGroupId <> ".json" + origin + | allowEmbedding = CorsAny + | otherwise = CorsOrigins $ mapMaybe extractOrigin $ maybeToList groupWebPage + in (fName, origin) + +extractOrigin :: Text -> Maybe Text +extractOrigin url = + case U.parseURI U.laxURIParserOptions (encodeUtf8 url) of + Right uri@U.URI {uriScheme = U.Scheme sch, uriAuthority = Just _} + | sch == "https" || sch == "http" -> + let originUri = uri {U.uriPath = "", U.uriQuery = U.Query [], U.uriFragment = Nothing} + origin = safeDecodeUtf8 $ U.serializeURIRef' originUri + in if T.all safeOriginChar origin then Just origin else Nothing + _ -> Nothing + where + -- percent-encoded bytes in the host (e.g. %22, %0a) are decoded by serializeURIRef', + -- so reject any origin with characters that could break out of the Caddy CORS config or header + safeOriginChar c = + (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c `elem` (".-:/[]" :: [Char]) + +channelPath :: Text +channelPath = "/channel/" + +writeCorsConfig :: [(Text, CorsOrigin)] -> FilePath -> IO () +writeCorsConfig entries path = + TIO.writeFile path $ T.unlines $ + ["map {path} {cors_origin} {"] + <> map corsLine entries + <> [ " default \"\"", + "}", + "header " <> channelPath <> "*.json Access-Control-Allow-Origin {cors_origin}", + "header " <> channelPath <> "*.json Access-Control-Allow-Methods \"GET, OPTIONS\"" + ] + where + corsLine (fName, origin) = case origin of + CorsAny -> " " <> channelPath <> fName <> " \"*\"" + CorsOrigins origins -> case origins of + [] -> " # " <> fName <> " (no origin configured)" + (o : _) -> " " <> channelPath <> fName <> " \"" <> o <> "\"" + +removeStaleFiles :: FilePath -> S.Set FilePath -> IO () +removeStaleFiles dir activeFiles = do + let -- matches ".json" and leftover ".json.tmp" from an interrupted write + isPreviewFile f = + let f' = if takeExtension f == ".tmp" then dropExtension f else f + base = dropExtension f' + in takeExtension f' == ".json" && not (null base) && all isBase64Url base + isBase64Url c = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_' + allFiles <- S.filter isPreviewFile . S.fromList <$> listDirectory dir + mapM_ (\f -> removeFile (dir f)) $ S.difference allFiles activeFiles + +toFormattedText :: Text -> Maybe MarkdownList +toFormattedText t = case parseMaybeMarkdownList t of + Just fts | any hasFormat fts -> Just fts + _ -> Nothing + where + hasFormat (FormattedText fmt _) = isJust fmt + +publicGroupIdFileName :: B64UrlByteString -> String +publicGroupIdFileName = B.unpack . strEncode + +hasPublicGroup :: GroupInfo -> Bool +hasPublicGroup GroupInfo {groupProfile = GroupProfile {publicGroup}} = isJust publicGroup + diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index f92411839f..cd6d549581 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -27,8 +27,10 @@ import Simplex.Chat.Controller (ChatConfig (..)) import qualified Simplex.Chat.Markdown as MD import Simplex.Chat.Options (CoreChatOpts (..)) import Simplex.Chat.Options.DB +import Simplex.Chat.Protocol (memberSupportVoiceVersion) import Simplex.Chat.Types (ChatPeerType (..), Profile (..)) import Simplex.Chat.Types.Shared (GroupMemberRole (..)) +import Simplex.Messaging.Version import System.FilePath (()) import Test.Hspec hiding (it) @@ -1492,7 +1494,7 @@ testVoiceCaptchaOldClient ps@TestParams {tmpPath} = do setPermissions mockScript $ setOwnerExecutable True $ setOwnerReadable True $ setOwnerWritable True emptyPermissions withDirectoryServiceVoiceCaptcha ps mockScript $ \superUser dsLink -> withNewTestChat ps "bob" bobProfile $ \bob -> - withNewTestChatCfg ps testCfgVPrev "cath" cathProfile $ \cath -> do + withNewTestChatCfg ps testCfg {chatVRange = (chatVRange testCfg) {maxVersion = prevVersion memberSupportVoiceVersion}} "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" bob #> "@'SimpleX Directory' /role 1" diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index ede3c1f2a2..442834b244 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -24,11 +24,12 @@ import Control.Monad.Reader import Data.Functor (($>)) import Data.List (dropWhileEnd, find) import Data.Maybe (isNothing) +import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (getCurrentTime) import Network.Socket import Simplex.Chat -import Simplex.Chat.Controller (ChatCommand (..), ChatConfig (..), ChatController (..), ChatDatabase (..), ChatLogLevel (..), defaultSimpleNetCfg) +import Simplex.Chat.Controller (ChatCommand (..), ChatConfig (..), ChatController (..), ChatDatabase (..), ChatLogLevel (..), WebPreviewConfig (..), defaultSimpleNetCfg) import Simplex.Chat.Core import Simplex.Chat.Library.Commands import Simplex.Chat.Options @@ -153,6 +154,7 @@ testCoreOpts = tbqSize = 16, deviceName = Nothing, chatRelay = False, + webPreviewConfig = Nothing, highlyAvailable = False, yesToUpMigrations = False, migrationBackupPath = Nothing, @@ -162,6 +164,9 @@ testCoreOpts = relayTestOpts :: ChatOpts relayTestOpts = testOpts {coreOptions = testCoreOpts {chatRelay = True}} +relayWebTestOpts :: Text -> FilePath -> Maybe FilePath -> ChatOpts +relayWebTestOpts webDomain webDir webCorsFile = testOpts {coreOptions = testCoreOpts {chatRelay = True, webPreviewConfig = Just WebPreviewConfig {webDomain, webJsonDir = webDir, webCorsFile, webUpdateInterval = 300, webPreviewItemCount = 50}}} + #if !defined(dbPostgres) getTestOpts :: Bool -> ScrubbedBytes -> ChatOpts getTestOpts maintenance dbKey = testOpts {coreOptions = testCoreOpts {maintenance, dbOptions = (dbOptions testCoreOpts) {dbKey}}} diff --git a/tests/ChatTests/ChatRelays.hs b/tests/ChatTests/ChatRelays.hs index 4b09347dcf..57095fb28f 100644 --- a/tests/ChatTests/ChatRelays.hs +++ b/tests/ChatTests/ChatRelays.hs @@ -1,5 +1,7 @@ {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} module ChatTests.ChatRelays where @@ -16,8 +18,13 @@ import qualified Data.Text as T import ProtocolTests (testGroupProfile) import Simplex.Chat.Protocol (LinkOwnerSig, MsgChatLink (..), MsgContent (..)) import Simplex.Chat.Types (GroupProfile (..)) +import Simplex.Chat.Controller (CorsOrigin (..)) +import Simplex.Chat.Web (WebChannelPreview (..), WebMessage (..), extractOrigin, removeStaleFiles, writeCorsConfig) import Simplex.Messaging.Encoding.String (StrEncoding (..)) import Simplex.Messaging.Util (decodeJSON) +import qualified Data.Set as S +import System.Directory (createDirectoryIfMissing, doesFileExist, listDirectory) +import System.FilePath (takeExtension, ()) import Test.Hspec hiding (it) chatRelayTests :: SpecWith TestParams @@ -28,6 +35,19 @@ chatRelayTests = do it "re-add soft-deleted relay by same name" testReAddRelaySameName it "test chat relay" testChatRelayTest it "relay profile updated in address" testRelayProfileUpdateInAddress + describe "relay capabilities" $ do + it "relay sends webDomain in capabilities" testRelayWebCapabilities + describe "web preview" $ do + it "render messages and members" testWebPreviewRender + it "incremental render adds new messages" testWebPreviewIncremental + it "edited and deleted messages" testWebPreviewEditedDeleted + it "reactions in rendered messages" testWebPreviewReactions + it "non-public group produces no file" testWebPreviewNonPublic + it "multiple channels produce multiple files" testWebPreviewMultipleChannels + it "channel deletion removes preview file" testWebPreviewChannelDeleted + it "removeStaleFiles preserves non-base64url files" testWebPreviewStaleCleanup + it "generate CORS config" testWebPreviewCors + it "extractOrigin strips path from URL" testExtractOrigin describe "share channel card" $ do it "share channel card in direct chat" testShareChannelDirect it "share channel card in group" testShareChannelGroup @@ -325,6 +345,238 @@ testShareChannelChannel ps = getTermLine2 :: TestCC -> IO (String, String) getTermLine2 c = (,) <$> getTermLine c <*> getTermLine c +testRelayWebCapabilities :: HasCallStack => TestParams -> IO () +testRelayWebCapabilities ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps (relayWebTestOpts "relay.example.com" (tmpPath ps "web_cap") Nothing) "bob" bobProfile $ \relay -> do + rName <- userName relay + relay ##> "/ad" + (relaySLink, _cLink) <- getContactLinks relay True + alice ##> ("/relays name=" <> rName <> " " <> relaySLink) + alice <## "ok" + alice ##> "/public group relays=1 #news" + alice <## "group #news is created" + alice <## "wait for selected relay(s) to join, then you can invite members via group link" + concurrentlyN_ + [ do + alice <## "#news: group link relays updated, current relays:" + alice <### [EndsWith ": active, web: relay.example.com"] + alice <## "group link:" + _ <- getTermLine alice + pure (), + relay <## "#news: you joined the group as relay" + ] + +-- Helper: set up relay with web config + channel +withWebChannel :: TestParams -> String -> (TestCC -> TestCC -> FilePath -> IO ()) -> IO () +withWebChannel ps gName test = do + let webDir = tmpPath ps "web_" <> gName + corsFile = tmpPath ps "cors_" <> gName <> ".conf" + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps (relayWebTestOpts "relay.example.com" webDir (Just corsFile)) "bob" bobProfile $ \relay -> do + _ <- setupRelay alice relay + createChannelWithRelayWeb gName alice relay + test alice relay webDir + +createChannelWithRelayWeb :: HasCallStack => String -> TestCC -> TestCC -> IO () +createChannelWithRelayWeb gName owner relay = do + owner ##> ("/public group relays=1 #" <> gName) + owner <## ("group #" <> gName <> " is created") + owner <## "wait for selected relay(s) to join, then you can invite members via group link" + concurrentlyN_ + [ do + owner <## ("#" <> gName <> ": group link relays updated, current relays:") + owner <### [EndsWith ": active, web: relay.example.com"] + owner <## "group link:" + _ <- getTermLine owner + pure (), + relay <## ("#" <> gName <> ": you joined the group as relay") + ] + +-- Poll for a JSON preview file written by the worker that satisfies predicate, with timeout +waitPreviewWith :: HasCallStack => FilePath -> (WebChannelPreview -> Bool) -> IO WebChannelPreview +waitPreviewWith webDir check = go 50 + where + go :: Int -> IO WebChannelPreview + go 0 = error "waitPreview: timed out waiting for matching JSON file" + go n = do + files <- filter (\f -> takeExtension f == ".json") <$> listDirectory webDir + case files of + [f] -> do + jsonBytes <- LB.readFile (webDir f) + case J.eitherDecode jsonBytes of + Right p | check p -> pure p + _ -> threadDelay 100000 >> go (n - 1) + _ -> threadDelay 100000 >> go (n - 1) + +waitPreview :: HasCallStack => FilePath -> IO WebChannelPreview +waitPreview webDir = waitPreviewWith webDir (const True) + +testWebPreviewRender :: HasCallStack => TestParams -> IO () +testWebPreviewRender ps = + withWebChannel ps "news" $ \alice relay webDir -> do + alice #> "#news hello from the channel" + relay <# "#news> hello from the channel" + alice #> "#news second message" + relay <# "#news> second message" + wPreview <- waitPreviewWith webDir (\p -> length (messages p) >= 2) + let GroupProfile {displayName = chName} = channel wPreview + chName `shouldBe` "news" + length (messages wPreview) `shouldBe` 2 + content (messages wPreview !! 0) `shouldBe` MCText "hello from the channel" + content (messages wPreview !! 1) `shouldBe` MCText "second message" + length (members wPreview) `shouldSatisfy` (>= 1) + all (\m -> ts m > read "2020-01-01 00:00:00 UTC") (messages wPreview) `shouldBe` True + jsonFiles <- filter (\f -> takeExtension f == ".json") <$> listDirectory webDir + length jsonFiles `shouldBe` 1 + +testWebPreviewIncremental :: HasCallStack => TestParams -> IO () +testWebPreviewIncremental ps = + withWebChannel ps "inc" $ \alice relay webDir -> do + alice #> "#inc first" + relay <# "#inc> first" + p1 <- waitPreviewWith webDir (\p -> length (messages p) >= 1) + length (messages p1) `shouldBe` 1 + content (messages p1 !! 0) `shouldBe` MCText "first" + alice #> "#inc second" + relay <# "#inc> second" + alice #> "#inc third" + relay <# "#inc> third" + p2 <- waitPreviewWith webDir (\p -> length (messages p) >= 3) + length (messages p2) `shouldBe` 3 + content (messages p2 !! 0) `shouldBe` MCText "first" + content (messages p2 !! 1) `shouldBe` MCText "second" + content (messages p2 !! 2) `shouldBe` MCText "third" + +testWebPreviewEditedDeleted :: HasCallStack => TestParams -> IO () +testWebPreviewEditedDeleted ps = + withWebChannel ps "ed" $ \alice relay webDir -> do + alice #> "#ed msg one" + relay <# "#ed> msg one" + alice #> "#ed msg two" + relay <# "#ed> msg two" + msgId2 <- lastItemId alice + alice #> "#ed msg three" + relay <# "#ed> msg three" + msgId3 <- lastItemId alice + alice ##> ("/_update item #1 " <> msgId2 <> " text msg two edited") + alice <# "#ed [edited] msg two edited" + relay <# "#ed> [edited] msg two edited" + alice #$> ("/_delete item #1 " <> msgId3 <> " broadcast", id, "message marked deleted") + relay <# "#ed> [marked deleted] msg three" + p <- waitPreviewWith webDir (\p -> length (messages p) == 2 && any edited (messages p)) + length (messages p) `shouldBe` 2 + content (messages p !! 0) `shouldBe` MCText "msg one" + content (messages p !! 1) `shouldBe` MCText "msg two edited" + edited (messages p !! 0) `shouldBe` False + edited (messages p !! 1) `shouldBe` True + +testWebPreviewReactions :: HasCallStack => TestParams -> IO () +testWebPreviewReactions ps = + withWebChannel ps "react" $ \alice relay webDir -> do + alice #> "#react hello" + relay <# "#react> hello" + alice ##> "+1 #react hello" + alice <## "added 👍" + relay <# "#react alice> > hello" + relay <## " + 👍" + p <- waitPreviewWith webDir (\p -> not (null (messages p)) && not (null (reactions (head (messages p))))) + length (messages p) `shouldBe` 1 + length (reactions (messages p !! 0)) `shouldSatisfy` (>= 1) + +testWebPreviewNonPublic :: HasCallStack => TestParams -> IO () +testWebPreviewNonPublic ps = do + let webDir = tmpPath ps "web_nonpub" + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps (relayWebTestOpts "relay.example.com" webDir Nothing) "bob" bobProfile $ \relay -> do + _ <- setupRelay alice relay + alice ##> "/g private" + alice <## "group #private is created" + alice <## "to add members use /a private or /create link #private" + alice #> "#private hello" + threadDelay 2000000 + files <- filter (\f -> takeExtension f == ".json") <$> listDirectory webDir + length files `shouldBe` 0 + +testWebPreviewMultipleChannels :: HasCallStack => TestParams -> IO () +testWebPreviewMultipleChannels ps = do + let webDir = tmpPath ps "web_multi" + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps (relayWebTestOpts "relay.example.com" webDir Nothing) "bob" bobProfile $ \relay -> do + _ <- setupRelay alice relay + createChannelWithRelayWeb "ch1" alice relay + createChannelWithRelayWeb "ch2" alice relay + alice #> "#ch1 msg in ch1" + relay <# "#ch1> msg in ch1" + alice #> "#ch2 msg in ch2" + relay <# "#ch2> msg in ch2" + threadDelay 2000000 + files <- filter (\f -> takeExtension f == ".json") <$> listDirectory webDir + length files `shouldBe` 2 + +testWebPreviewChannelDeleted :: HasCallStack => TestParams -> IO () +testWebPreviewChannelDeleted ps = + withWebChannel ps "del" $ \alice relay webDir -> do + alice #> "#del hello" + relay <# "#del> hello" + _ <- waitPreviewWith webDir (\p -> not (null (messages p))) + jsonFiles <- filter (\f -> takeExtension f == ".json") <$> listDirectory webDir + length jsonFiles `shouldBe` 1 + let previewFile = webDir head jsonFiles + alice ##> "/d #del" + alice <## "#del: you deleted the group (signed)" + relay <## "#del: alice deleted the group (signed)" + relay <## "use /d #del to delete the local copy of the group" + waitFileDeleted previewFile 50 + +testWebPreviewStaleCleanup :: HasCallStack => TestParams -> IO () +testWebPreviewStaleCleanup ps = do + let webDir = tmpPath ps "web_stale_unit" + activeFile = "abc123.json" + staleFile = "AAAA_stale.json" + safeFile = "my.config.json" + createDirectoryIfMissing True webDir + writeFile (webDir activeFile) "{}" + writeFile (webDir staleFile) "{}" + writeFile (webDir safeFile) "{}" + removeStaleFiles webDir (S.singleton activeFile) + doesFileExist (webDir staleFile) `shouldReturn` False + doesFileExist (webDir safeFile) `shouldReturn` True + doesFileExist (webDir activeFile) `shouldReturn` True + +waitFileDeleted :: HasCallStack => FilePath -> Int -> IO () +waitFileDeleted _ 0 = error "waitFileDeleted: timed out" +waitFileDeleted path n = + doesFileExist path >>= \case + False -> pure () + True -> threadDelay 100000 >> waitFileDeleted path (n - 1) + +testWebPreviewCors :: HasCallStack => TestParams -> IO () +testWebPreviewCors ps = do + let corsFile = tmpPath ps "simplex-cors.conf" + entries = + [ ("abc123.json", CorsAny), + ("def456.json", CorsOrigins ["https://owner-site.com"]), + ("ghi789.json", CorsOrigins []) + ] + writeCorsConfig entries corsFile + corsContent <- readFile corsFile + corsContent `shouldContain` "/channel/abc123.json \"*\"" + corsContent `shouldContain` "/channel/def456.json \"https://owner-site.com\"" + corsContent `shouldContain` "# ghi789.json (no origin configured)" + corsContent `shouldContain` "Access-Control-Allow-Origin" + corsContent `shouldContain` "Access-Control-Allow-Methods" + +testExtractOrigin :: HasCallStack => TestParams -> IO () +testExtractOrigin _ps = do + extractOrigin "https://owner.example.com/channel.html" `shouldBe` Just "https://owner.example.com" + extractOrigin "https://owner.example.com/path/to/page?q=1#frag" `shouldBe` Just "https://owner.example.com" + extractOrigin "https://owner.example.com:8443/page" `shouldBe` Just "https://owner.example.com:8443" + extractOrigin "https://owner.example.com" `shouldBe` Just "https://owner.example.com" + extractOrigin "http://localhost:3000/preview" `shouldBe` Just "http://localhost:3000" + extractOrigin "ftp://example.com/file" `shouldBe` Nothing + extractOrigin "not-a-url" `shouldBe` Nothing + -- Create a public group with relay=1, wait for relay to join createChannelWithRelay :: HasCallStack => String -> TestCC -> TestCC -> IO () createChannelWithRelay gName owner relay = do diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 10f8808015..1482a9de10 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -133,7 +133,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (mcSimple (MCText "hello"))) it "x.msg.new chat message with chat version range" $ - "{\"v\":\"1-17\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"1-18\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage supportedChatVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (mcSimple (MCText "hello"))) it "x.msg.new quote" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" @@ -244,13 +244,13 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} Nothing it "x.grp.mem.new with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-17\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-18\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} Nothing it "x.grp.mem.intro" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} Nothing it "x.grp.mem.intro with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-17\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-18\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} Nothing it "x.grp.mem.intro with member restrictions" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberRestrictions\":{\"restriction\":\"blocked\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" @@ -265,7 +265,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-17\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-18\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.info" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" diff --git a/website/.eleventy.js b/website/.eleventy.js index f0310c5665..0567dd45d8 100644 --- a/website/.eleventy.js +++ b/website/.eleventy.js @@ -310,7 +310,7 @@ module.exports = function (ty) { ty.addPassthroughCopy("src/img") ty.addPassthroughCopy("src/video") ty.addPassthroughCopy("src/css") - ty.addPassthroughCopy("src/js") + ty.addPassthroughCopy("src/js/**/*.js") ty.addPassthroughCopy("src/lottie_file") ty.addPassthroughCopy("src/contact/*.js") ty.addPassthroughCopy("src/call") diff --git a/website/channel_sample.html b/website/channel_sample.html new file mode 100644 index 0000000000..169db55599 --- /dev/null +++ b/website/channel_sample.html @@ -0,0 +1,28 @@ + + + + + + SimpleX Channel Preview + + + + +
+ + + diff --git a/website/src/js/channel-preview.jsc b/website/src/js/channel-preview.jsc new file mode 100644 index 0000000000..be572fd8de --- /dev/null +++ b/website/src/js/channel-preview.jsc @@ -0,0 +1,1548 @@ +#include "simplex-lib.jsc" + +(function() { + +#include "qrcode.js" + +const STYLE = ` +.simplex-preview-container { + --sp-bg: var(--sp-light-bg, #fff); + --sp-text: #000; + --sp-text-secondary: #8b8786; + --sp-text-muted: #333; + --sp-text-small: #888; + --sp-bubble: #f5f5f6; + --sp-quote: #ececee; + --sp-border: #e5e5e5; + --sp-link: #0088ff; + --sp-link-hover: #0077e0; + --sp-btn: #007AE5; + --sp-btn-hover: #006BC9; + --sp-color-blue: #0053d0; + --sp-color-black: #000; + --sp-color-white: #000; + --sp-qr-fg: #062D56; + --sp-qr-bg: #ffffff; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-size: 15px; + line-height: 1.4; + color: var(--sp-text); + background: var(--sp-bg); + width: 100%; + height: 100%; + padding: 0; + -webkit-font-smoothing: antialiased; + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; + display: flex; + justify-content: center; +} + +.simplex-preview-container.simplex-scheme-dark, +.dark .simplex-preview-container.simplex-scheme-site { + --sp-bg: var(--sp-dark-bg, #000832); + --sp-text: #FFFBFA; + --sp-text-secondary: #B3AFAE; + --sp-text-muted: #B3AFAE; + --sp-text-small: #aaa; + --sp-bubble: #071C46; + --sp-quote: #1B325C; + --sp-border: #3A3A3C; + --sp-link: #70F0F9; + --sp-link-hover: #66D9E2; + --sp-btn: #7EF1F9; + --sp-btn-hover: #75DCE4; + --sp-btn-text: #000; + --sp-color-blue: #70F0F9; + --sp-color-black: #fff; + --sp-color-white: #fff; + --sp-qr-fg: #FFFBFA; + --sp-qr-bg: transparent; +} + +.simplex-preview-header { + position: sticky; + top: 0; + z-index: 10; + background: var(--sp-bg); + border-bottom: 1px solid var(--sp-border); + padding: 8px 16px; + display: flex; + align-items: center; + gap: 12px; +} + +.simplex-preview-header-avatar { + width: 36px; + height: 36px; + border-radius: 8px; + object-fit: cover; + flex-shrink: 0; +} + +.simplex-preview-header-info { + flex: 1; + min-width: 0; +} + +.simplex-preview-header-name { + font-size: 17px; + font-weight: 600; + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.simplex-preview-header-description { + font-size: 13px; + color: var(--sp-text-secondary); + margin: 2px 0 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.simplex-preview-join-btn { + flex-shrink: 0; + background: var(--sp-btn); + color: var(--sp-btn-text, #fff); + border: none; + border-radius: 34px; + padding: 6px 10px 6px 10px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 5px; + font-family: inherit; +} + +.simplex-preview-join-btn svg { + width: 15.4px; + height: 15.4px; + flex-shrink: 0; + margin-left: 2px; +} + +.simplex-preview-container .simplex-logo-light-bg { + display: none; +} + +.simplex-preview-container.simplex-scheme-dark .simplex-logo-dark-bg, +.dark .simplex-preview-container.simplex-scheme-site .simplex-logo-dark-bg { + display: none; +} + +.simplex-preview-container.simplex-scheme-dark .simplex-logo-light-bg, +.dark .simplex-preview-container.simplex-scheme-site .simplex-logo-light-bg { + display: inline; +} + +.simplex-preview-join-btn:hover { + background: var(--sp-btn-hover); +} + +.simplex-preview-messages { + padding: 8px 16px 32px; +} + +.simplex-preview-date-separator { + text-align: center; + padding: 8px 0; + font-size: 12px; + color: var(--sp-text-secondary); + font-weight: 500; +} + +.simplex-preview-msg-group { + padding: 0 8px; +} + +.simplex-preview-msg-name { + font-size: 13.5px; + color: var(--sp-text-secondary); + padding: 0 0 2px 0; + margin-left: 39px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.simplex-preview-msg-name-role { + font-weight: 500; + margin-left: 8px; +} + +.simplex-preview-msg-row { + display: flex; + align-items: flex-start; + margin-bottom: 2px; +} + +.simplex-preview-msg-row.has-gap { + margin-bottom: 6px; +} + +.simplex-preview-msg-avatar { + width: 30px; + height: 30px; + border-radius: 7px; + object-fit: cover; + flex-shrink: 0; + margin-right: 9px; +} + +.simplex-preview-msg-avatar-placeholder { + width: 30px; + flex-shrink: 0; + margin-right: 9px; +} + +.simplex-preview-bubble { + position: relative; + background: var(--sp-bubble); + border-radius: 18px; + min-width: 80px; + overflow: visible; +} + +.simplex-preview-bubble-inner { + border-radius: 18px; + overflow: hidden; +} + +.simplex-preview-bubble.has-tail { + border-bottom-left-radius: 0; +} + +.simplex-preview-bubble.has-tail .simplex-preview-bubble-inner { + border-bottom-left-radius: 0; +} + +.simplex-preview-bubble-tail { + position: absolute; + bottom: 0; + left: -9px; + width: 9px; + height: 16px; + color: var(--sp-bubble); +} + +.simplex-preview-bubble.media-only { + background: transparent; +} + +.simplex-preview-meta-overlay { + position: absolute; + bottom: 6px; + right: 12px; + font-size: 12px; + color: #fff; + text-shadow: 0 0 4px rgba(0,0,0,0.7), 0 0 2px rgba(0,0,0,0.9); + white-space: nowrap; +} + +.simplex-preview-meta-overlay .simplex-preview-meta-edited { + font-style: italic; +} + +.simplex-preview-forwarded-header { + background: var(--sp-quote); + padding: 6px 12px 6px 8px; + font-size: 12px; + font-style: italic; + color: var(--sp-text-secondary); + display: flex; + align-items: center; + gap: 4px; +} + +.simplex-preview-quote { + background: var(--sp-quote); + display: flex; + width: 100%; +} + +.simplex-preview-quote-content { + flex: 1; + padding: 6px 12px; + min-width: 0; +} + +.simplex-preview-quote-sender { + font-size: 13.5px; + color: var(--sp-text-secondary); + margin-bottom: 2px; +} + +.simplex-preview-quote-text { + font-size: 15px; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.simplex-preview-quote-thumb { + width: 68px; + height: 68px; + object-fit: cover; + flex-shrink: 0; +} + +.simplex-preview-quote-file-icon { + padding: 6px 4px 0 0; + flex-shrink: 0; + color: var(--sp-text-secondary); +} + +.simplex-preview-text { + padding: 7px 12px; + word-wrap: break-word; + overflow-wrap: break-word; +} + +.simplex-preview-text a { + color: var(--sp-link); + text-decoration: none; +} + +.simplex-preview-text a:hover { + text-decoration: underline; +} + +.simplex-preview-image { + display: block; + max-width: 100%; +} + +.simplex-preview-image.landscape { + width: 400px; +} + +.simplex-preview-image.portrait { + width: 300px; +} + +.simplex-preview-image-placeholder { + display: flex; + align-items: center; + justify-content: center; + width: 120px; + height: 80px; + background: var(--sp-quote); + border-radius: 12px; + color: var(--sp-text-secondary); +} + +.simplex-preview-image-placeholder svg { + width: 32px; + height: 32px; +} + +.simplex-preview-link-card { + display: block; + max-width: 400px; +} + +.simplex-preview-link-card-image { + display: block; + width: 100%; +} + +.simplex-preview-link-card-body { + padding: 6px 12px; +} + +.simplex-preview-link-card-title { + font-size: 15px; + line-height: 22px; + margin-bottom: 4px; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; +} + +.simplex-preview-link-card-description { + font-size: 14px; + line-height: 20px; + color: var(--sp-text-muted); + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 12; + -webkit-box-orient: vertical; +} + +.simplex-preview-link-card-uri { + font-size: 12px; + color: var(--sp-text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.simplex-preview-file-indicator { + padding: 7px 12px; + display: flex; + align-items: center; + gap: 8px; + color: var(--sp-text-secondary); +} + +.simplex-preview-file-icon { + width: 22px; + height: 22px; + flex-shrink: 0; +} + +.simplex-preview-file-name { + font-size: 14px; + color: var(--sp-text); +} + +.simplex-preview-file-size { + font-size: 12px; + color: var(--sp-text-secondary); +} + +.simplex-preview-voice { + padding: 7px 12px; + display: flex; + align-items: center; + gap: 8px; + color: var(--sp-text-secondary); + font-size: 14px; +} + +.simplex-preview-meta { + float: right; + font-size: 12px; + color: var(--sp-text-secondary); + padding: 0 2px 0 12px; + margin-top: 4px; + white-space: nowrap; +} + +.simplex-preview-meta-edited { + font-style: italic; +} + +.simplex-preview-reactions { + display: flex; + flex-wrap: wrap; + padding: 2px 5px 2px; +} + +.simplex-preview-reaction { + font-size: 12px; + font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif; + border-radius: 8px; + padding: 2px 5px; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.simplex-preview-reaction-count { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + color: var(--sp-text-secondary); + font-size: 11.5px; +} + +.simplex-preview-empty { + text-align: center; + padding: 48px 16px; + color: var(--sp-text-secondary); +} + +.simplex-preview-text .secret { + background: var(--sp-text-secondary); + color: transparent; + border-radius: 4px; + cursor: pointer; + user-select: none; + transition: all 0.2s; +} + +.simplex-preview-text .secret.visible { + background: transparent; + color: inherit; +} + +.simplex-preview-text .small-text { + font-size: 13px; + color: var(--sp-text-small); +} + +.simplex-preview-text .red { color: #DD0000; } +.simplex-preview-text .green { color: #20BD3D; } +.simplex-preview-text .blue { color: var(--sp-color-blue); } +.simplex-preview-text .yellow { color: #DEBD00; } +.simplex-preview-text .cyan { color: #0AC4D1; } +.simplex-preview-text .magenta { color: magenta; } +.simplex-preview-text .black { color: var(--sp-color-black); } +.simplex-preview-text .white { color: var(--sp-color-white); } + +.simplex-preview-main { + flex: 1; + min-width: 0; + max-width: 640px; + overflow-y: auto; + overscroll-behavior: contain; + position: relative; +} + +.simplex-preview-info { + overflow-y: auto; + overscroll-behavior: contain; + background: var(--sp-bg); +} + +.simplex-preview-info-close { + display: none; +} + +.simplex-preview-info-avatar { + width: 192px; + height: 192px; + border-radius: 42px; + object-fit: cover; + display: block; + margin: 12px auto; +} + +.simplex-preview-info-name { + font-size: 34px; + font-weight: 700; + text-align: center; + margin: 0; +} + +.simplex-preview-info-descr { + font-size: 14px; + color: var(--sp-text-secondary); + text-align: center; + margin: 8px 0; + word-wrap: break-word; + overflow-wrap: break-word; +} + +.simplex-preview-info-descr a { + color: var(--sp-link); + text-decoration: none; +} + +.simplex-preview-info-descr a:hover { + text-decoration: underline; +} + +.simplex-preview-info-subscribers { + font-size: 14px; + color: var(--sp-text-secondary); + text-align: center; + margin: 0 0 16px; +} + +.simplex-preview-info .simplex-preview-join-btn { + display: block; + text-align: center; + margin-top: 20px; + width: 100%; +} + +.simplex-preview-conversion { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; +} + +.simplex-preview-divider { + width: 100%; + height: 1px; + background: var(--sp-border); + margin: 40px 0; +} + +.simplex-preview-conversion-title { + font-size: 18px; + font-weight: 600; + text-align: center; + margin: 0 0 16px; +} + +.simplex-preview-qr-toggle { + font-size: 14px; + color: var(--sp-link); + cursor: pointer; + text-decoration: none; +} + +.simplex-preview-qr-toggle:hover { + text-decoration: underline; +} + +.simplex-preview-qr-popup { + flex-direction: column; + align-items: center; + gap: 8px; +} + +.simplex-preview-qr-popup canvas { + border-radius: 8px; +} + +.simplex-preview-qr-caption { + font-size: 14px; + text-align: center; + margin: 0; +} + +.simplex-preview-badges { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + flex-wrap: wrap; + margin: 0 0 6px; +} + +.simplex-preview-badges a { + display: block; +} + +.simplex-preview-badges a img { + height: 40px; + width: auto; + display: block; +} + +.simplex-preview-copy-action { + font-size: 14px; + margin: 0; +} + +.simplex-preview-copy-action a { + color: var(--sp-link); + text-decoration: none; + cursor: pointer; +} + +.simplex-preview-copy-action a:hover { + text-decoration: underline; +} + +.simplex-preview-step-title { + font-size: 14px; + text-align: center; + margin: 0 0 -8px; +} + +.simplex-preview-open-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + background: var(--sp-btn); + color: var(--sp-btn-text, #fff); + border: none; + border-radius: 34px; + padding: 16px 12px 16px 18px; + height: 44px; + font-size: 16px; + line-height: 19px; + letter-spacing: 0.02em; + cursor: pointer; + text-decoration: none; + font-family: inherit; + margin-top: 3px; +} + +.simplex-preview-open-btn svg { + width: 22px; + height: 22px; + flex-shrink: 0; + margin-left: 6px; +} + + +.simplex-preview-open-btn:hover { + background: var(--sp-btn-hover); +} + +@media (min-width: 1000px) { + .simplex-preview-info { + width: 320px; + flex-shrink: 0; + border-left: 1px solid var(--sp-border); + padding: 24px; + } + .simplex-preview-header .simplex-preview-join-btn { + display: none; + } +} + +@media (max-width: 999px) { + .simplex-preview-container { + font-size: 17px; + } + .simplex-preview-main { + max-width: none; + } + .simplex-preview-info { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 100; + padding: 16px; + } + .simplex-preview-info.open { + display: block; + } + .simplex-preview-info-close { + display: block; + position: absolute; + top: 12px; + right: 12px; + background: none; + border: none; + font-size: 24px; + color: var(--sp-text-secondary); + cursor: pointer; + padding: 4px 8px; + line-height: 1; + } + .simplex-preview-info-content { + padding-top: 32px; + } + .simplex-preview-header { + cursor: pointer; + } +} +`; + +const DEFAULT_AVATAR = 'data:image/svg+xml,' + encodeURIComponent(''); + +const IMAGE_PLACEHOLDER_SVG = ``; + +function isDataImage(src) { + return typeof src === 'string' && src.startsWith('data:image/'); +} + +function tailSvg() { + return ''; +} + +var _logoId = 0; +var _svgParser = new DOMParser(); + +function appendSimplexLogo(el) { + var n = _logoId++; + var darkSvg = '' + + '' + + '' + + ''; + var lightSvg = '' + + '' + + '' + + ''; + el.appendChild(document.importNode(_svgParser.parseFromString(darkSvg, 'image/svg+xml').documentElement, true)); + el.appendChild(document.importNode(_svgParser.parseFromString(lightSvg, 'image/svg+xml').documentElement, true)); +} + +const FILE_ICON_SVG = ``; + +const VOICE_ICON_SVG = ``; + +const FORWARD_ICON_SVG = ``; + +const COPY_ICON_SVG = ``; + +function initChannelPreview(container) { + const relayDomains = (container.dataset.relayDomains || '').split(',').map(u => u.trim()).filter(Boolean); + const relayScheme = container.dataset.relayScheme || 'https'; + const channelId = container.dataset.channelId || ''; + const channelLink = container.dataset.channelLink || ''; + const showAppBadges = container.dataset.appDownloadButtons !== 'off'; + const colorScheme = container.dataset.colorScheme || 'light'; + + if (!relayDomains.length || !channelId) { + container.innerHTML = '

Missing configuration: data-relay-domains and data-channel-id required.

'; + return; + } + + injectStyles(); + container.classList.add('simplex-preview-container', 'simplex-scheme-' + colorScheme); + if (container.dataset.lightBackground) { + container.style.setProperty('--sp-light-bg', container.dataset.lightBackground); + } + if (container.dataset.darkBackground) { + container.style.setProperty('--sp-dark-bg', container.dataset.darkBackground); + } + container.innerHTML = '

Loading channel...

'; + + fetchPreview(relayScheme, relayDomains, channelLink, channelId).then(data => { + if (data === 'link_mismatch') { + container.innerHTML = '

All relays returned a different channel link from specified in the page.

'; + return; + } + if (!data) { + container.innerHTML = '

Failed to load channel preview.

'; + return; + } + render(container, data, channelLink, showAppBadges); + }); +} + +let stylesInjected = false; +function injectStyles() { + if (stylesInjected) return; + stylesInjected = true; + const style = document.createElement('style'); + style.textContent = STYLE; + document.head.appendChild(style); +} + +async function fetchPreview(relayScheme, relayDomains, channelLink, channelId) { + let linkMismatch = false; + for (const domain of relayDomains) { + try { + const url = `${relayScheme}://${domain}/channel/${channelId}.json`; + const resp = await fetch(url); + if (!resp.ok) continue; + const data = await resp.json(); + const relayLink = data.channel?.publicGroup?.groupLink; + if (channelLink && relayLink && channelLink !== relayLink) { + linkMismatch = true; + continue; + } + return data; + } catch(e) { + continue; + } + } + return linkMismatch ? 'link_mismatch' : null; +} + +function render(container, data, channelLink, showAppBadges) { + const { channel, members, messages } = data; + const membersMap = {}; + for (const m of members) { + membersMap[m.memberId] = m; + } + + container.innerHTML = ''; + + const main = document.createElement('div'); + main.className = 'simplex-preview-main'; + + const header = renderHeader(channel, channelLink, data.subscribers); + main.appendChild(header); + + const messagesDiv = document.createElement('div'); + messagesDiv.className = 'simplex-preview-messages'; + const welcome = data.welcomeMessage || channel.description; + var allMessages = messages; + if (welcome) { + var welcomeMsg = { + sender: null, + ts: messages.length > 0 ? messages[0].ts : new Date().toISOString(), + content: { type: 'text', text: typeof welcome === 'string' ? welcome : '' }, + formattedText: Array.isArray(welcome) ? welcome : null, + reactions: [] + }; + allMessages = [welcomeMsg].concat(messages); + } + renderMessages(messagesDiv, allMessages, membersMap, channel); + main.appendChild(messagesDiv); + + container.appendChild(main); + + const info = document.createElement('div'); + info.className = 'simplex-preview-info'; + + const closeBtn = document.createElement('button'); + closeBtn.className = 'simplex-preview-info-close'; + closeBtn.innerHTML = '✕'; + info.appendChild(closeBtn); + + const infoContent = document.createElement('div'); + infoContent.className = 'simplex-preview-info-content'; + renderInfoContent(infoContent, data, channelLink, data.subscribers, showAppBadges); + info.appendChild(infoContent); + + container.appendChild(info); + + header.addEventListener('click', (e) => { + if (e.target.closest('.simplex-preview-join-btn')) return; + if (window.innerWidth < 1000) { + info.classList.add('open'); + main.style.overflow = 'hidden'; + } + }); + + closeBtn.addEventListener('click', () => { + info.classList.remove('open'); + main.style.overflow = ''; + }); + + setupSecretToggles(container); + setTimeout(() => { main.scrollTop = main.scrollHeight; }, 0); +} + +function renderHeader(channel, channelLink, subscriberCount) { + const header = document.createElement('div'); + header.className = 'simplex-preview-header'; + + const avatar = document.createElement('img'); + avatar.className = 'simplex-preview-header-avatar'; + avatar.src = isDataImage(channel.image) ? channel.image : DEFAULT_AVATAR; + avatar.alt = channel.displayName; + header.appendChild(avatar); + + const info = document.createElement('div'); + info.className = 'simplex-preview-header-info'; + + const name = document.createElement('h1'); + name.className = 'simplex-preview-header-name'; + name.textContent = channel.displayName; + info.appendChild(name); + + if (subscriberCount > 0) { + const desc = document.createElement('p'); + desc.className = 'simplex-preview-header-description'; + desc.textContent = subscriberCount + ' subscribers'; + info.appendChild(desc); + } + + header.appendChild(info); + + if (channelLink) { + const btn = document.createElement('a'); + btn.className = 'simplex-preview-join-btn'; + btn.textContent = 'Join'; + appendSimplexLogo(btn); + btn.href = channelLink; + header.appendChild(btn); + } + + return header; +} + +function renderInfoContent(container, data, channelLink, subscriberCount, showAppBadges) { + const { channel } = data; + + const avatar = document.createElement('img'); + avatar.className = 'simplex-preview-info-avatar'; + avatar.src = isDataImage(channel.image) ? channel.image : DEFAULT_AVATAR; + avatar.alt = channel.displayName; + container.appendChild(avatar); + + const name = document.createElement('h2'); + name.className = 'simplex-preview-info-name'; + name.textContent = channel.displayName; + container.appendChild(name); + + const shortDescr = data.shortDescription || channel.shortDescr; + if (shortDescr) { + const descrDiv = document.createElement('div'); + descrDiv.className = 'simplex-preview-info-descr'; + descrDiv.innerHTML = Array.isArray(shortDescr) ? renderMarkdown(shortDescr) : escapeHtml(shortDescr); + container.appendChild(descrDiv); + } + + if (subscriberCount > 0) { + const subs = document.createElement('p'); + subs.className = 'simplex-preview-info-subscribers'; + subs.textContent = subscriberCount + ' subscribers'; + container.appendChild(subs); + } + + if (channelLink) { + if (!isMobile.any()) { + const openBtn = document.createElement('a'); + openBtn.className = 'simplex-preview-open-btn'; + openBtn.style.display = 'flex'; + openBtn.style.width = 'fit-content'; + openBtn.style.margin = '32px auto 0'; + openBtn.textContent = 'Join in SimpleX Chat'; + appendSimplexLogo(openBtn); + openBtn.href = channelLink; + container.appendChild(openBtn); + } + + const showJoinSection = !isMobile.any() || showAppBadges; + if (showJoinSection) { + const divider = document.createElement('div'); + divider.className = 'simplex-preview-divider'; + container.appendChild(divider); + + const joinTitle = document.createElement('p'); + joinTitle.className = 'simplex-preview-conversion-title'; + joinTitle.textContent = 'To join this channel'; + container.appendChild(joinTitle); + } + + const conversion = document.createElement('div'); + conversion.className = 'simplex-preview-conversion'; + if (!showJoinSection) { + conversion.style.marginTop = '28px'; + } + if (isMobile.any()) { + renderMobileConversion(conversion, channelLink, showAppBadges); + } else { + renderDesktopConversion(conversion, channelLink, showAppBadges); + } + container.appendChild(conversion); + } +} + +var BADGE_APPLE = 'App Store'; +var BADGE_GOOGLE = 'Google Play'; +var BADGE_FDROID = 'F-Droid'; +var BADGE_APK = 'APK Download'; +var BADGE_TESTFLIGHT = 'TestFlight'; + +function renderAppBadges(container) { + const title = document.createElement('p'); + title.className = 'simplex-preview-step-title'; + title.textContent = 'Install SimpleX Chat app'; + container.appendChild(title); + + const badges = document.createElement('div'); + badges.className = 'simplex-preview-badges'; + if (isMobile.Android()) { + badges.innerHTML = BADGE_GOOGLE + BADGE_FDROID + BADGE_APK; + } else if (isMobile.iOS()) { + badges.innerHTML = BADGE_APPLE + BADGE_TESTFLIGHT; + } else { + badges.innerHTML = BADGE_APPLE + BADGE_GOOGLE; + } + container.appendChild(badges); +} + +function renderDesktopConversion(container, channelLink, showAppBadges) { + if (showAppBadges) { + renderAppBadges(container); + } + + const qrToggle = document.createElement('a'); + qrToggle.className = 'simplex-preview-qr-toggle'; + qrToggle.textContent = 'Show QR code for mobile app'; + qrToggle.href = '#'; + container.appendChild(qrToggle); + + const qrPopup = document.createElement('div'); + qrPopup.className = 'simplex-preview-qr-popup'; + qrPopup.style.display = 'none'; + + const caption = document.createElement('p'); + caption.className = 'simplex-preview-qr-caption'; + caption.textContent = 'Scan from SimpleX Chat app'; + qrPopup.appendChild(caption); + + const canvas = document.createElement('canvas'); + qrPopup.appendChild(canvas); + + const qrHide = document.createElement('a'); + qrHide.className = 'simplex-preview-qr-toggle'; + qrHide.textContent = 'Hide QR code'; + qrHide.href = '#'; + qrPopup.appendChild(qrHide); + container.appendChild(qrPopup); + + function toggleQr(e) { + e.preventDefault(); + if (qrPopup.style.display === 'none') { + qrPopup.style.display = 'flex'; + qrToggle.style.display = 'none'; + if (!canvas._rendered) { + canvas._rendered = true; + try { + var cs = getComputedStyle(container); + QRCode.toCanvas(canvas, channelLink, { + errorCorrectionLevel: 'M', + color: { + dark: cs.getPropertyValue('--sp-qr-fg').trim() || '#062D56', + light: cs.getPropertyValue('--sp-qr-bg').trim() || '#ffffff' + }, + width: 400, + margin: 1 + }).then(function() { + canvas.style.width = '200px'; + canvas.style.height = '200px'; + }).catch(function() { + qrPopup.style.display = 'none'; + qrToggle.style.display = 'none'; + }); + } catch(err) { + qrPopup.style.display = 'none'; + qrToggle.style.display = 'none'; + } + } + } else { + qrPopup.style.display = 'none'; + qrToggle.style.display = ''; + } + } + qrToggle.addEventListener('click', toggleQr); + qrHide.addEventListener('click', toggleQr); + + const copyAction = document.createElement('p'); + copyAction.className = 'simplex-preview-copy-action'; + const copyLink = document.createElement('a'); + copyLink.textContent = 'copy link'; + copyLink.addEventListener('click', function() { + navigator.clipboard.writeText(channelLink).then(function() { + copyLink.textContent = 'Copied!'; + setTimeout(function() { copyLink.textContent = 'copy link'; }, 2000); + }); + }); + copyAction.appendChild(document.createTextNode('Or ')); + copyAction.appendChild(copyLink); + copyAction.appendChild(document.createTextNode(' for desktop app')); + container.appendChild(copyAction); +} + +function renderMobileConversion(container, channelLink, showAppBadges) { + if (showAppBadges) { + renderAppBadges(container); + } + + const openBtn = document.createElement('a'); + openBtn.className = 'simplex-preview-open-btn'; + openBtn.textContent = 'Join in SimpleX Chat'; + appendSimplexLogo(openBtn); + openBtn.href = channelLink; + container.appendChild(openBtn); +} + + +function setupSecretToggles(container) { + container.addEventListener('click', (e) => { + const secret = e.target.closest('.secret'); + if (secret) secret.classList.toggle('visible'); + }); +} + +function renderMessages(container, messages, membersMap, channel) { + const hasAnySender = messages.some(function(m) { return m.sender; }); + let prevMsg = null; + let prevDate = null; + + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; + const nextMsg = i < messages.length - 1 ? messages[i + 1] : null; + + const msgDate = formatDateLabel(msg.ts); + if (msgDate !== prevDate) { + const dateSep = document.createElement('div'); + dateSep.className = 'simplex-preview-date-separator'; + dateSep.textContent = msgDate; + container.appendChild(dateSep); + prevDate = msgDate; + } + + const separation = getItemSeparation(msg, prevMsg); + const nextSeparation = getItemSeparation(nextMsg, msg); + const showAvatar = hasAnySender && (!prevMsg || msg.sender !== prevMsg.sender); + const showName = showAvatar; + const showTail = nextSeparation.largeGap; + + const member = msg.sender ? membersMap[msg.sender] : null; + const senderName = member ? member.displayName : channel.displayName; + const senderImage = member ? member.image : channel.image; + + const row = document.createElement('div'); + row.className = 'simplex-preview-msg-row' + (nextSeparation.largeGap ? ' has-gap' : ''); + + if (hasAnySender) { + if (showName) { + const nameDiv = document.createElement('div'); + nameDiv.className = 'simplex-preview-msg-name'; + nameDiv.textContent = senderName; + container.appendChild(nameDiv); + } + + if (showAvatar) { + const avatarImg = document.createElement('img'); + avatarImg.className = 'simplex-preview-msg-avatar'; + avatarImg.src = isDataImage(senderImage) ? senderImage : DEFAULT_AVATAR; + avatarImg.alt = senderName; + row.appendChild(avatarImg); + } else { + const spacer = document.createElement('div'); + spacer.className = 'simplex-preview-msg-avatar-placeholder'; + row.appendChild(spacer); + } + } + + const col = document.createElement('div'); + const bubble = renderBubble(msg, member, showTail, membersMap, channel); + col.appendChild(bubble); + + if (msg.reactions && msg.reactions.length > 0) { + col.appendChild(renderReactions(msg.reactions)); + } + + row.appendChild(col); + container.appendChild(row); + prevMsg = msg; + } +} + +function renderBubble(msg, member, showTail, membersMap, channel) { + const mc = msg.content; + const mediaOnly = (mc.type === 'image' || mc.type === 'video') && !mc.text && !msg.quote && !msg.forward; + const noTailContent = (mc.type === 'image' || mc.type === 'video' || mc.type === 'voice') && !mc.text; + const hasTail = showTail && !noTailContent; + + const bubble = document.createElement('div'); + bubble.className = 'simplex-preview-bubble' + (hasTail ? ' has-tail' : '') + (mediaOnly ? ' media-only' : ''); + + if (hasTail) { + const tail = document.createElement('div'); + tail.className = 'simplex-preview-bubble-tail'; + tail.innerHTML = tailSvg(); + bubble.appendChild(tail); + } + + const inner = document.createElement('div'); + inner.className = 'simplex-preview-bubble-inner'; + + if (msg.forward) { + const fwd = document.createElement('div'); + fwd.className = 'simplex-preview-forwarded-header'; + fwd.innerHTML = FORWARD_ICON_SVG + ' Forwarded'; + inner.appendChild(fwd); + } + + if (msg.quote) { + inner.appendChild(renderQuote(msg.quote, membersMap, channel)); + } + + switch (mc.type) { + case 'image': + renderImageContent(inner, mc, msg, mediaOnly); + break; + case 'video': + renderVideoContent(inner, mc, msg, mediaOnly); + break; + case 'link': + renderLinkContent(inner, mc, msg); + break; + case 'voice': + renderVoiceContent(inner, mc, msg); + break; + case 'file': + renderFileContent(inner, mc, msg); + break; + default: + renderTextContent(inner, msg); + break; + } + + bubble.appendChild(inner); + + if (mediaOnly) { + const overlay = document.createElement('div'); + overlay.className = 'simplex-preview-meta-overlay'; + if (msg.edited) overlay.innerHTML = 'edited '; + overlay.innerHTML += formatTime(msg.ts); + bubble.appendChild(overlay); + } + + return bubble; +} + +function renderQuote(quote, membersMap, channel) { + const quoteDiv = document.createElement('div'); + quoteDiv.className = 'simplex-preview-quote'; + + const contentDiv = document.createElement('div'); + contentDiv.className = 'simplex-preview-quote-content'; + + const ref = quote.msgRef; + let senderName = ''; + if (ref) { + if (ref.memberId) { + const quotedMember = membersMap[ref.memberId]; + senderName = quotedMember ? quotedMember.displayName : ''; + } else if (ref.sent) { + senderName = channel.displayName; + } + } + if (senderName) { + const sender = document.createElement('div'); + sender.className = 'simplex-preview-quote-sender'; + sender.textContent = senderName; + contentDiv.appendChild(sender); + } + + const textDiv = document.createElement('div'); + textDiv.className = 'simplex-preview-quote-text'; + textDiv.textContent = quote.content ? (quote.content.text || '') : ''; + contentDiv.appendChild(textDiv); + + quoteDiv.appendChild(contentDiv); + + if (quote.content) { + if ((quote.content.type === 'image' || quote.content.type === 'video') && isDataImage(quote.content.image)) { + const thumb = document.createElement('img'); + thumb.className = 'simplex-preview-quote-thumb'; + thumb.src = quote.content.image; + quoteDiv.appendChild(thumb); + } else if (quote.content.type === 'file') { + const icon = document.createElement('div'); + icon.className = 'simplex-preview-quote-file-icon'; + icon.innerHTML = FILE_ICON_SVG; + quoteDiv.appendChild(icon); + } else if (quote.content.type === 'voice') { + const icon = document.createElement('div'); + icon.className = 'simplex-preview-quote-file-icon'; + icon.innerHTML = VOICE_ICON_SVG; + quoteDiv.appendChild(icon); + } + } + + return quoteDiv; +} + +function classifyImage(img) { + const w = img.naturalWidth; + const h = img.naturalHeight; + img.classList.add(w * 0.97 <= h ? 'portrait' : 'landscape'); +} + +function renderImageContent(inner, mc, msg, mediaOnly) { + if (isDataImage(mc.image)) { + const img = document.createElement('img'); + img.className = 'simplex-preview-image'; + img.src = mc.image; + img.alt = 'Image'; + img.addEventListener('load', () => classifyImage(img)); + inner.appendChild(img); + } else { + const ph = document.createElement('div'); + ph.className = 'simplex-preview-image-placeholder'; + ph.innerHTML = IMAGE_PLACEHOLDER_SVG; + inner.appendChild(ph); + } + if (mc.text) { + appendTextBlock(inner, msg); + } else if (!mediaOnly) { + appendMetaOnly(inner, msg); + } +} + +function renderVideoContent(inner, mc, msg, mediaOnly) { + if (isDataImage(mc.image)) { + const wrapper = document.createElement('div'); + wrapper.style.position = 'relative'; + const img = document.createElement('img'); + img.className = 'simplex-preview-image'; + img.src = mc.image; + img.alt = 'Video'; + img.addEventListener('load', () => classifyImage(img)); + wrapper.appendChild(img); + const dur = document.createElement('span'); + dur.style.cssText = 'position:absolute;bottom:6px;left:12px;color:#fff;font-size:12px;text-shadow:0 0 4px rgba(0,0,0,0.7),0 0 2px rgba(0,0,0,0.9);'; + dur.textContent = formatDuration(mc.duration || 0); + wrapper.appendChild(dur); + inner.appendChild(wrapper); + } else { + const ph = document.createElement('div'); + ph.className = 'simplex-preview-image-placeholder'; + ph.innerHTML = IMAGE_PLACEHOLDER_SVG; + inner.appendChild(ph); + } + if (mc.text) { + appendTextBlock(inner, msg); + } else if (!mediaOnly) { + appendMetaOnly(inner, msg); + } +} + +function renderLinkContent(bubble, mc, msg) { + if (mc.preview) { + const card = document.createElement('div'); + card.className = 'simplex-preview-link-card'; + if (isDataImage(mc.preview.image)) { + const img = document.createElement('img'); + img.className = 'simplex-preview-link-card-image'; + img.src = mc.preview.image; + img.alt = mc.preview.title || ''; + card.appendChild(img); + } + const body = document.createElement('div'); + body.className = 'simplex-preview-link-card-body'; + if (mc.preview.title) { + const title = document.createElement('div'); + title.className = 'simplex-preview-link-card-title'; + title.textContent = mc.preview.title; + body.appendChild(title); + } + if (mc.preview.description) { + const desc = document.createElement('div'); + desc.className = 'simplex-preview-link-card-description'; + desc.textContent = mc.preview.description; + body.appendChild(desc); + } + if (mc.preview.uri) { + const uri = document.createElement('div'); + uri.className = 'simplex-preview-link-card-uri'; + uri.textContent = mc.preview.uri; + body.appendChild(uri); + } + card.appendChild(body); + bubble.appendChild(card); + } + appendTextBlock(bubble, msg); +} + +function renderVoiceContent(bubble, mc, msg) { + const voiceDiv = document.createElement('div'); + voiceDiv.className = 'simplex-preview-voice'; + voiceDiv.innerHTML = VOICE_ICON_SVG + ' ' + formatDuration(mc.duration || 0) + ''; + bubble.appendChild(voiceDiv); + if (mc.text) { + appendTextBlock(bubble, msg); + } else { + appendMetaOnly(bubble, msg); + } +} + +function renderFileContent(bubble, mc, msg) { + const fileDiv = document.createElement('div'); + fileDiv.className = 'simplex-preview-file-indicator'; + fileDiv.innerHTML = FILE_ICON_SVG; + const info = document.createElement('div'); + if (msg.file) { + const nameSpan = document.createElement('div'); + nameSpan.className = 'simplex-preview-file-name'; + nameSpan.textContent = msg.file.fileName; + info.appendChild(nameSpan); + const sizeSpan = document.createElement('div'); + sizeSpan.className = 'simplex-preview-file-size'; + sizeSpan.textContent = formatFileSize(msg.file.fileSize); + info.appendChild(sizeSpan); + } + fileDiv.appendChild(info); + bubble.appendChild(fileDiv); + if (mc.text) { + appendTextBlock(bubble, msg); + } else { + appendMetaOnly(bubble, msg); + } +} + +function renderTextContent(bubble, msg) { + appendTextBlock(bubble, msg); +} + +function appendTextBlock(bubble, msg) { + const textDiv = document.createElement('div'); + textDiv.className = 'simplex-preview-text'; + const meta = renderMetaHTML(msg); + if (msg.formattedText && msg.formattedText.length > 0) { + textDiv.innerHTML = renderMarkdown(msg.formattedText) + meta; + } else { + textDiv.innerHTML = escapeHtml(msg.content.text || '') + meta; + } + bubble.appendChild(textDiv); +} + +function appendMetaOnly(bubble, msg) { + const metaDiv = document.createElement('div'); + metaDiv.style.cssText = 'padding: 0 8px 4px; text-align: right;'; + metaDiv.innerHTML = renderMetaHTML(msg); + bubble.appendChild(metaDiv); +} + +function renderMetaHTML(msg) { + let html = ''; + if (msg.edited) html += 'edited '; + html += formatTime(msg.ts); + html += ''; + return html; +} + +function renderReactions(reactions) { + const div = document.createElement('div'); + div.className = 'simplex-preview-reactions'; + for (const r of reactions) { + if (r.totalReacted < 1) continue; + const badge = document.createElement('span'); + badge.className = 'simplex-preview-reaction'; + const emoji = r.reaction && r.reaction.emoji ? r.reaction.emoji : '?'; + badge.appendChild(document.createTextNode(emoji)); + if (r.totalReacted > 1) { + const count = document.createElement('span'); + count.className = 'simplex-preview-reaction-count'; + count.textContent = r.totalReacted; + badge.appendChild(count); + } + div.appendChild(badge); + } + return div; +} + +function getItemSeparation(msg, prevMsg) { + if (!prevMsg || !msg) return { largeGap: true }; + const sameSender = msg.sender === prevMsg.sender; + if (!sameSender) return { largeGap: true }; + const t1 = new Date(prevMsg.ts).valueOf(); + const t2 = new Date(msg.ts).valueOf(); + if (Math.abs(t2 - t1) >= 60000) return { largeGap: true }; + return { largeGap: false }; +} + +function formatTime(ts) { + try { + const d = new Date(ts); + const h = d.getHours().toString().padStart(2, '0'); + const m = d.getMinutes().toString().padStart(2, '0'); + return h + ':' + m; + } catch(e) { + return ''; + } +} + +function formatDateLabel(ts) { + try { + const d = new Date(ts); + const now = new Date(); + const weekday = d.toLocaleDateString(undefined, { weekday: 'short' }); + const dayMonth = d.toLocaleDateString(undefined, { + day: 'numeric', + month: 'short', + year: d.getFullYear() !== now.getFullYear() ? 'numeric' : undefined + }); + return weekday + ', ' + dayMonth; + } catch(e) { + return ''; + } +} + +function formatDuration(secs) { + const m = Math.floor(secs / 60); + const s = secs % 60; + return m.toString().padStart(2, '0') + ':' + s.toString().padStart(2, '0'); +} + +function formatFileSize(bytes) { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; +} + +document.querySelectorAll('[data-simplex-channel-preview]').forEach(initChannelPreview); + +})(); diff --git a/website/src/js/directory.js b/website/src/js/directory.jsc similarity index 77% rename from website/src/js/directory.js rename to website/src/js/directory.jsc index afaac1053f..6959cd41a5 100644 --- a/website/src/js/directory.js +++ b/website/src/js/directory.jsc @@ -1,3 +1,11 @@ +#include "simplex-lib.jsc" + +const simplexDirectoryDataURL = 'https://directory.simplex.chat/data/'; + +// const simplexDirectoryDataURL = 'http://localhost:8080/directory-data/'; + +const simplexUsersGroup = 'SimpleX users group'; + (function() { if (!document.location.pathname.startsWith('/directory')) return; @@ -428,144 +436,4 @@ if (document.readyState === 'loading') { } else { initDirectory(); } - -function escapeHtml(text) { - return text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'") - .replace(/\n/g, "
"); -} - -function getSimplexLinkDescr(linkType) { - switch (linkType) { - case 'contact': return 'SimpleX contact address'; - case 'invitation': return 'SimpleX one-time invitation'; - case 'group': return 'SimpleX group link'; - case 'channel': return 'SimpleX channel link'; - case 'relay': return 'SimpleX relay link'; - default: return 'SimpleX link'; - } -} - -function viaHost(smpHosts) { - const first = smpHosts[0] ?? '?'; - return `via ${first}`; -} - -function isCurrentSite(uri) { - return uri.startsWith("https://simplex.chat") || uri.startsWith("https://www.simplex.chat") -} - -function targetBlank(uri) { - return isCurrentSite(uri) ? '' : ' target="_blank"' -} - -function renderMarkdown(fts) { - let html = ''; - for (const ft of fts) { - const { format, text } = ft; - if (!format) { - html += escapeHtml(text); - continue; - } - try { - switch (format.type) { - case 'bold': - html += `${escapeHtml(text)}`; - break; - case 'italic': - html += `${escapeHtml(text)}`; - break; - case 'strikeThrough': - html += `${escapeHtml(text)}`; - break; - case 'snippet': - html += `${escapeHtml(text)}`; - break; - case 'secret': - html += `${escapeHtml(text)}`; - break; - case 'small': - html += `${escapeHtml(text)}`; - break; - case 'colored': - html += `${escapeHtml(text)}`; - break; - case 'uri': - let href = text.startsWith('http://') || text.startsWith('https://') || text.startsWith('simplex:/') ? text : 'https://' + text; - html += `${escapeHtml(text)}`; - break; - case 'hyperLink': { - const { showText, linkUri } = format; - html += `${escapeHtml(showText ?? linkUri)}`; - break; - } - case 'simplexLink': { - const { showText, linkType, simplexUri, smpHosts } = format; - const linkText = showText ? escapeHtml(showText) : getSimplexLinkDescr(linkType); - html += `${linkText} (${viaHost(smpHosts)})`; - break; - } - case 'command': - html += `${escapeHtml(text)}`; - break; - case 'mention': - html += `${escapeHtml(text)}`; - break; - case 'email': - html += `${escapeHtml(text)}`; - break; - case 'phone': - html += `${escapeHtml(text)}`; - break; - case 'unknown': - html += escapeHtml(text); - break; - default: - html += escapeHtml(text); - } - } catch(e) { - console.log(e); - html += escapeHtml(text); - } - } - return html; -} })(); - -const simplexDirectoryDataURL = 'https://directory.simplex.chat/data/'; - -// const simplexDirectoryDataURL = 'http://localhost:8080/directory-data/'; - -const simplexUsersGroup = 'SimpleX users group'; - -const simplexAddressRegexp = /^simplex:\/([a-z]+)#(.+)/i; - -const simplexShortLinkTypes = ["a", "c", "g", "i", "r"]; - -function platformSimplexUri(uri) { - if (isMobile.any()) return uri; - const res = uri.match(simplexAddressRegexp); - if (!res || !Array.isArray(res) || res.length < 3) return uri; - const linkType = res[1]; - const fragment = res[2]; - if (simplexShortLinkTypes.includes(linkType)) { - const queryIndex = fragment.indexOf('?'); - if (queryIndex === -1) return uri; - const hashPart = fragment.substring(0, queryIndex); - const queryStr = fragment.substring(queryIndex + 1); - const params = new URLSearchParams(queryStr); - const host = params.get('h'); - if (!host) return uri; - params.delete('h'); - let newFragment = hashPart; - const remainingParams = params.toString(); - if (remainingParams) newFragment += '?' + remainingParams; - return `https://${host}:/${linkType}#${newFragment}`; - } else { - return `https://simplex.chat/${linkType}#${fragment}`; - } -} diff --git a/website/src/js/simplex-lib.jsc b/website/src/js/simplex-lib.jsc new file mode 100644 index 0000000000..ffec278ca2 --- /dev/null +++ b/website/src/js/simplex-lib.jsc @@ -0,0 +1,156 @@ +const isMobile = { + Android: () => navigator.userAgent.match(/Android/i), + iOS: () => navigator.userAgent.match(/iPhone|iPad|iPod/i), + any: () => navigator.userAgent.match(/Android|iPhone|iPad|iPod/i) +}; + +function escapeHtml(text) { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(/\n/g, "
"); +} + +function escapeAttr(text) { + return text + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(//g, ">"); +} + +const SAFE_URI_SCHEME = /^(https?:|simplex:|mailto:|tel:)/i; + +function safeHref(uri) { + if (SAFE_URI_SCHEME.test(uri)) return escapeAttr(uri); + return escapeAttr(`javascript:void(alert('Potentially malicious link blocked:\\n'+${JSON.stringify(uri)}))`); +} + +function getSimplexLinkDescr(linkType) { + switch (linkType) { + case 'contact': return 'SimpleX contact address'; + case 'invitation': return 'SimpleX one-time invitation'; + case 'group': return 'SimpleX group link'; + case 'channel': return 'SimpleX channel link'; + case 'relay': return 'SimpleX relay link'; + default: return 'SimpleX link'; + } +} + +function viaHost(smpHosts) { + const first = smpHosts[0] ?? '?'; + return `via ${first}`; +} + +function isCurrentSite(uri) { + return uri.startsWith("https://simplex.chat") || uri.startsWith("https://www.simplex.chat") +} + +function targetBlank(uri) { + return isCurrentSite(uri) ? '' : ' target="_blank"' +} + +function renderMarkdown(fts) { + let html = ''; + for (const ft of fts) { + const { format, text } = ft; + if (!format) { + html += escapeHtml(text); + continue; + } + try { + switch (format.type) { + case 'bold': + html += `${escapeHtml(text)}`; + break; + case 'italic': + html += `${escapeHtml(text)}`; + break; + case 'strikeThrough': + html += `${escapeHtml(text)}`; + break; + case 'snippet': + html += `${escapeHtml(text)}`; + break; + case 'secret': + html += `${escapeHtml(text)}`; + break; + case 'small': + html += `${escapeHtml(text)}`; + break; + case 'colored': + html += `${escapeHtml(text)}`; + break; + case 'uri': { + let href = text.startsWith('http://') || text.startsWith('https://') || text.startsWith('simplex:/') ? text : 'https://' + text; + html += `${escapeHtml(text)}`; + break; + } + case 'hyperLink': { + const { showText, linkUri } = format; + html += `${escapeHtml(showText ?? linkUri)}`; + break; + } + case 'simplexLink': { + const { showText, linkType, simplexUri, smpHosts } = format; + const linkText = showText ? escapeHtml(showText) : getSimplexLinkDescr(linkType); + html += `${linkText} (${escapeHtml(viaHost(smpHosts))})`; + break; + } + case 'command': + html += `${escapeHtml(text)}`; + break; + case 'mention': + html += `${escapeHtml(text)}`; + break; + case 'email': + html += `${escapeHtml(text)}`; + break; + case 'phone': + html += `${escapeHtml(text)}`; + break; + case 'unknown': + html += escapeHtml(text); + break; + default: + html += escapeHtml(text); + } + } catch(e) { + console.log(e); + html += escapeHtml(text); + } + } + return html; +} + +const simplexAddressRegexp = /^simplex:\/([a-z]+)#(.+)/i; + +const simplexShortLinkTypes = ["a", "c", "g", "i", "r"]; + +function platformSimplexUri(uri) { + if (isMobile.any()) return uri; + const res = uri.match(simplexAddressRegexp); + if (!res || !Array.isArray(res) || res.length < 3) return uri; + const linkType = res[1]; + const fragment = res[2]; + if (simplexShortLinkTypes.includes(linkType)) { + const queryIndex = fragment.indexOf('?'); + if (queryIndex === -1) return uri; + const hashPart = fragment.substring(0, queryIndex); + const queryStr = fragment.substring(queryIndex + 1); + const params = new URLSearchParams(queryStr); + const host = params.get('h'); + if (!host) return uri; + params.delete('h'); + let newFragment = hashPart; + const remainingParams = params.toString(); + if (remainingParams) newFragment += '?' + remainingParams; + return `https://${host}:/${linkType}#${newFragment}`; + } else { + return `https://simplex.chat/${linkType}#${fragment}`; + } +} diff --git a/website/web.sh b/website/web.sh index 9464982a45..49888f78aa 100755 --- a/website/web.sh +++ b/website/web.sh @@ -55,6 +55,10 @@ for lang in "${langs[@]}"; do echo "done $lang copying" done +for f in src/js/*.jsc; do + [ -f "$f" ] && cpp -P -traditional-cpp "$f" "${f%.jsc}.js" +done + npm run build for lang in "${langs[@]}"; do From 2d80b2e463fb4e999550574bbeb0a6b566a2b205 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:28:49 +0400 Subject: [PATCH 025/200] fix(webrtc): stop preview tracks when abandoning pre-connect call (#7074) --- packages/simplex-chat-webrtc/src/call.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/simplex-chat-webrtc/src/call.ts b/packages/simplex-chat-webrtc/src/call.ts index 5f3d2bf332..8441560013 100644 --- a/packages/simplex-chat-webrtc/src/call.ts +++ b/packages/simplex-chat-webrtc/src/call.ts @@ -583,6 +583,8 @@ const processCommand = (function () { case "capabilities": console.log("starting outgoing call - capabilities") if (activeCall) endCall() + // Stop a preview stream from an earlier pre-connect outgoing call being replaced (activeCall may be null here) + stopNotConnectedCall() let localStream: MediaStream | null = null try { @@ -623,7 +625,8 @@ const processCommand = (function () { if (activeCall) endCall() // It can be already defined on Android when switching calls (if the previous call was outgoing) - notConnectedCall = undefined + // Stop its preview tracks before clearing, otherwise camera/mic stay live + stopNotConnectedCall() inactiveCallMediaSources.mic = true inactiveCallMediaSources.camera = command.media == CallMediaType.Video inactiveCallMediaSourcesChanged(inactiveCallMediaSources) @@ -1444,6 +1447,14 @@ const processCommand = (function () { } } + // Call on any path that abandons notConnectedCall, otherwise its preview camera/mic tracks stay live. + function stopNotConnectedCall() { + if (notConnectedCall) { + notConnectedCall.localStream.getTracks().forEach((track) => track.stop()) + notConnectedCall = undefined + } + } + function resetVideoElements() { const videos = getVideoElements() if (!videos) return From bcd980127d73f45ad87cd9745d14d6ef7468858f Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:11:04 +0000 Subject: [PATCH 026/200] docs: allow sign content messages in channels plan (#7049) --- plans/2026-06-04-channel-message-signing.md | 233 ++++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 plans/2026-06-04-channel-message-signing.md diff --git a/plans/2026-06-04-channel-message-signing.md b/plans/2026-06-04-channel-message-signing.md new file mode 100644 index 0000000000..f3828018b9 --- /dev/null +++ b/plans/2026-06-04-channel-message-signing.md @@ -0,0 +1,233 @@ +# Plan: optional signing of channel content messages (`XMsgNew` / `XMsgUpdate`) + +## Goal / user problem + +In relay-based channels, content (`XMsgNew`) is forwarded by relays and is **not** signed today (only group-state events are — `requiresSignature`, `Protocol.hs:1251`), so a relay can forge or alter content attributed to a member. This feature lets a member *optionally* attach their member signature, so recipients holding the (signed) roster can verify authorship + integrity. + +Decisions: +- **UI: both** — device-stored default ("sign my channel messages", off) + per-send long-press override (mirrors custom disappearing-message TTL). +- **Default: off**, with an in-UI tradeoff explanation (signing = non-repudiable, transferable proof of authorship). +- **Recipient indicator: in scope** (iOS + Kotlin) — signing is useless if invisible to readers. +- **Event scope: `XMsgNew` + `XMsgUpdate` only**; edits reuse the original's setting. `XMsgReact`/`XMsgDel` stay unsigned in v1. + +## Prerequisites / sequencing + +Lands after #7017 (signed roster) and #7048 (roster over inline files; `GRMember` role). Neither merged yet (branch `f/allow-sign-new-msg`; `git log` tops at #7043). Dependency is specific: *verification* needs the sender's member public key, distributed via the roster; without it a signed message degrades to `MSSSignedNoKey` rather than `MSSVerified`. Integration tests must use the roster/channel setup from those PRs. + +**Line numbers are pre-rebase** (grounded against #7043); #7017/#7048 shift every anchor, so **re-locate by symbol**. The dependency PRs add no 6th `updateGroupChatItem` caller, but other branches are queued (`f/channel-comments`, `f/public-groups-members-in-roster`) — hence the caller re-check gate below. + +## What already exists (so the change stays small) + +Wire format, signing, verification, DB persistence, and CLI display are present and reused unchanged: +- **Send signing:** `groupMsgSigning` (`Internal.hs:1963`) → `createSndMessages` threads `Maybe MsgSigning` (`:1950`) → `createNewSndMessage` Ed25519-signs `encodeChatBinding CBGroup (publicGroupId, memberId) <> msgBody`, storing `SignedMsg` in `SndMessage.signedMsg_` (`Store/Messages.hs:234`; `Messages.hs:1156`). +- **Wire:** `batchMessages` prepends the signature via `encodeBatchElement` (`Batch.hs:46,65`); relay groups always batch (`memberSendAction` → only `MSASendBatched` under `useRelays'`, `Internal.hs:2222,2228`). +- **Receive verify:** `withVerifiedMsg` (`Subscriber.hs:3469`) runs for all group messages (`:1004`, forwarded `:3431`); `XMsgNew_`/`XMsgUpdate_` ∉ `requiresSignature` ⇒ `signatureOptional` (`:3491`), so signed → `MSSVerified`/`MSSSignedNoKey`, unsigned → accepted. **No protocol-version bump.** +- **Sent-item persistence:** `createNewSndChatItem` sets `msgSigned = MSSVerified <$ signedMsg_` (`Store/Messages.hs:550`) — own item auto-marked, readable by the edit path. +- **Received-item persistence:** `createNewRcvChatItem` records `RcvMessage.msgSigned` (`Store/Messages.hs:565,567`); `CIMeta.msgSigned :: Maybe MsgSigStatus` (`Messages.hs:520`). +- **CLI:** `sigStatusStr` (`View.hs:388`) appends `" (signed)"` / `" (signed, no key to verify)"`. + +Missing: (1) the *decision* to sign content (`groupMsgSigning` returns `Nothing` for content today); (2) per-send plumbing from the API; (3) reuse on edit; (4) the §7 stale-badge fix; (5) the §5 anonymity gate (HIGH); (6) the apps. + +## Threat model + +Actors: member (sender), recipients, and **chat relays** that forward content + roster. Relays are untrusted for content authenticity. + +- **Forgery of member content.** Signing closes it for signed messages: relay lacks the Ed25519 key; signature binds `(publicGroupId, memberId, body)` — no forgery, cross-bind, or alteration. +- **Downgrade / stripping (residual, by design).** Optional signing lets a relay strip a signature and deliver unsigned. Absence of a badge is **not** proof of forgery — only *presence* of a verified badge is a guarantee. A future "required signing" group setting would close it; out of scope. +- **Stale-badge spoof on edits (fixed — §7).** An in-place edit must not keep a `verified` badge over content from an unsigned, relay-forged `XMsgUpdate`. +- **Publish-as-channel de-anonymization (structurally prevented — §5).** Channels allow "publish as the channel" (`showGroupAsSender`/`asGroup`): subscribers see a post as *from the channel*, not the specific owner (Design Objective 6, `docs/protocol/channels-overview.md:214`); today a relay revealing the owner is only a *deniable* leak (`channels-overview.md:~237`). `groupMsgSigning` (`Internal.hs:1963-1967`) is blind to `showGroupAsSender`, so it would sign with binding `(publicGroupId, ownerMemberId)`, broadcast on the wire even for `FwdChannel` (`encodeFwdElement` → `encodeBatchElement signedMsg_`, `Batch.hs:108`). A malicious relay sets the live-forward `fwdSender` freely (it is derived from stored `sentAsGroup`, `Store/Delivery.hs:158`), so every subscriber verifies it as `MSSVerified` — turning the deniable leak into **non-repudiable proof** of which owner authored an intentionally anonymous post; the device-default toggle would trigger this silently. For an anonymity property this must be structurally impossible: signing is never applied to as-channel content (§5), the app option is hidden for as-channel sends (§C), and a defense-in-depth guard keeps `encodeFwdElement` signature-free for `FwdChannel` (Edge cases). (`processContentItem:1302` is the *history* path and rebuilds content unsigned — not the vector.) +- **Non-repudiation (tradeoff, by design).** A verified signature is transferable proof of authorship — a deniability loss; hence opt-in/off-by-default with UI explanation. For *as-channel* posts the loss is unacceptable, not a tradeoff — hence the §5 exclusion. +- **What "verified" means.** Signed input is `encodeChatBinding CBGroup (publicGroupId, memberId) <> msgBody`, with `msgBody` embedding `sharedMsgId`, `MsgScope`, content (`Store/Messages.hs:242`). It proves **authorship + integrity + group/member/scope/message binding** — and nothing else: not `fwdBrokerTs` (relay-controlled, `Protocol.hs:382-387`), ordering, or completeness. Surface this in UI/help. +- **Signed content is still relay-suppressible.** `XMsgDel_` ∉ `requiresSignature` (`Protocol.hs:1252-1262`), so an unsigned relay-forged owner-attributed delete is accepted (role-based check vs. the relay-chosen author, `Subscriber.hs:~2269`). Pre-existing, within the relay's drop power; bounds signing's value (proves *what was said*, not that all is delivered). +- **Replay.** Binding covers `sharedMsgId` + `MsgScope`; cross-scope/group replay is blocked, same-message replay is a dedup duplicate. +- **Bad-signature spam (fail-closed, pre-existing).** Failed verification drops content with an `RGEMsgBadSignature` item per occurrence (`Subscriber.hs:3473-3475,3483`); a tampering relay can spam these. Inherited from state-event behavior. + +## Core changes (Haskell) + +### 1. Signable-content predicate + +`Protocol.hs`, next to `requiresSignature` (`:1251`): +```haskell +-- | Content events whose authorship a member may optionally prove by signing. +signableContent :: CMEventTag e -> Bool +signableContent = \case + XMsgNew_ -> True + XMsgUpdate_ -> True + _ -> False +``` + +### 2. Signing decision carries the opt-in + +Named type near `MsgSigning` (`Protocol.hs:426`) — not a bare `Bool`: +```haskell +-- | Whether opt-in content signing applies to this group send. +-- Independent of mandatory state-event signing (requiresSignature), +-- which always applies in relay groups regardless of this value. +data ContentSig = SignContent | DontSignContent + deriving (Eq, Show) +``` +Extend `groupMsgSigning` (`Internal.hs:1963`): +```haskell +groupMsgSigning :: ContentSig -> GroupInfo -> ChatMsgEvent e -> Maybe MsgSigning +groupMsgSigning csig gInfo@GroupInfo {membership = GroupMember {memberId}, groupKeys = Just GroupKeys {publicGroupId, memberPrivKey}} evt + | useRelays' gInfo && shouldSign = + Just $ MsgSigning CBGroup (smpEncode (publicGroupId, memberId)) KRMember memberPrivKey + where + tag = toCMEventTag evt + shouldSign = requiresSignature tag || (csig == SignContent && signableContent tag) +groupMsgSigning _ _ _ = Nothing +``` +- `useRelays'`/`groupKeys = Just` guards unchanged: in non-relay groups or keyless members, `SignContent` is a no-op (`Nothing`). +- Mandatory state-event signing unaffected (`requiresSignature` branch preserved). + +### 3. Thread `ContentSig` through the send functions + +`groupMsgSigning` is called only in `sendGroupMessages_` (`Internal.hs:2134`) and `sendGroupMemberMessages` (`:1972`). Add a `ContentSig` param to `sendGroupMessages_` (`:2132`, used in `idsEvts`), `sendGroupMessages` (`:2100`, pass-through), `sendGroupMessage` (`:2088`, pass-through). Keep `sendGroupMessage'` (`:2094`) and `sendGroupMemberMessages` (`:1969`) unchanged by hardcoding `DontSignContent` internally. + +Behavior-preserving (all existing callers pass `DontSignContent`) ⇒ its own commit. Call sites to pass `DontSignContent` (grep-verified): +- `sendGroupMessages`: `Subscriber.hs:1370`; `Commands.hs:793,800,2778,2909`. +- `sendGroupMessage`: `Commands.hs:889,2690,3272,3812,3815,3819`. +- `sendGroupMessages_` direct: `Commands.hs:2826,3849`. + +The two variable-`ContentSig` sites are the feature (next commit): content send (`Commands.hs:4405`) and group edit (`Commands.hs:732`). + +### 4. API: per-send `sign` flag + +Add a field to `APISendMessages` (`Controller.hs:332`): +```haskell +| APISendMessages {sendRef :: SendRef, liveMessage :: Bool, ttl :: Maybe Int, signMessages :: Bool, composedMessages :: NonEmpty ComposedMessage} +``` +Parser (`Commands.hs:5006`), mirroring `liveMessageP`/`sendMessageTTLP`, defaulting off so old command strings still parse: +```haskell +"/_send " *> (APISendMessages <$> sendRefP <*> liveMessageP <*> sendMessageTTLP <*> signMessagesP <*> (" json " *> jsonP <|> " text " *> composedMessagesTextP)) +-- with: signMessagesP = " sign=" *> onOffP <|> pure False (place after sendMessageTTLP, before " json ") +``` +Wire: `/_send live=.. ttl=.. sign=on|off json ...`. Per-send granularity (like `ttl`), not per-`ComposedMessage`. API boundary (app↔core, same bundle) ⇒ not a protocol-compat concern. + +### 5. Content send path + +`sendGroupContentMessages` (`Commands.hs:4366`) and `sendGroupContentMessages_` (`:4375`) gain a `ContentSig` param. `showGroupAsSender` is in scope at the send site (`:4405`); **as-channel posts are never signed** (anonymity gate — see threat model): +```haskell +let csig' = if showGroupAsSender then DontSignContent else csig +(msgs_, gsr) <- sendGroupMessages user gInfo Nothing showGroupAsSender recipients csig' chatMsgEvents +``` +This gate is structural (must live here, not only in UI); it also keeps the sender's own as-channel item unsigned and keeps §6 edit-reuse consistent. + +- `APISendMessages` handler (`:637-650`): `signMessages` → `SignContent`/`DontSignContent`, passed down (both `SRGroup` and `SRDirect`; direct ignores it — `sendContactContentMessages` doesn't sign). The `:4405` gate then forces `DontSignContent` for as-channel sends regardless of the flag. +- `APIReportMessage` (`:679`): `DontSignContent` (reports unsigned in v1). + +### 6. Edit / restore reuse (the `XMsgUpdate` requirement) + +Group edit, `Commands.hs:710-742`. Own sent item loaded with `CIMeta` at `:720`; add `msgSigned` to the pattern and reuse it: +```haskell +... meta = CIMeta {itemSharedMsgId, itemTimed, itemLive, editable, showGroupAsSender, msgSigned} +... +let reuseSig = if isJust msgSigned then SignContent else DontSignContent +SndMessage {msgId} <- sendGroupMessage user gInfo scope recipients reuseSig event +``` +`msgSigned` is loaded via `mkCIMeta`/`toGroupChatItem` (`Store/Messages.hs:2412`); for own items it is `Just MSSVerified` iff signed (`createNewSndChatItem` stores only `MSSVerified <$ signedMsg_`, `:550`), so `isJust` is the right test. This makes an edit (including the recipient-deleted-restore case) signed exactly when the original was; it is automatically consistent with §5 (as-channel originals are never signed ⇒ edits stay unsigned). + +Direct edit (`:697-704`) and local edit (`:745`) need no change (never signed). + +### 7. Security fix: refresh `msg_signed` on in-place content update + +**Finding:** `updateGroupChatItem_` (`Store/Messages.hs:2755`) updates content/status/timed fields but **not `msg_signed`** (`UPDATE` at `:2760-2767`); `updatedChatItem` (`:2749`) carries the original `meta.msgSigned`. Today invisible (content never signed); once content is signed and badged, an in-place edit from an **unsigned, relay-forged `XMsgUpdate`** would keep a stale `MSSVerified` badge over attacker content. + +**Why pass it in:** the `MSSVerified` vs `MSSSignedNoKey` outcome is computed at receive by `withVerifiedMsg` and lives only on the chat item; the stored `messages` row holds signature bytes but not the verification *outcome*. So the status must come from receive-time `RcvMessage.msgSigned`, not be re-derived. + +**Fix (contained to the group helper):** add a `Maybe MsgSigStatus` param to `updateGroupChatItem` (`:2746`); after `let ci' = updatedChatItem …` (`:2749`) override `ci'`'s `meta.msgSigned`, and add `msg_signed = ?` to `updateGroupChatItem_`'s `UPDATE` (`:2755`/`:2760-2767`). `updateGroupChatItem_` is called *only* from `updateGroupChatItem` (grep), so this is self-contained. **Leave `updatedChatItem` (`:2544`) unchanged** — it serves the unsigned direct/local paths (`:2540`, `:3210`). + +All **five** callers pass an explicit value (no implicit "preserve"): +- `Commands.hs:738` (sender edit): `MSSVerified <$ signedMsg_` from the returned `SndMessage` (mirrors `:550`; equals the reused setting). +- `Subscriber.hs:2212` (recipient in-place edit — *the spoof path*): `msgSigned` from the handler's `RcvMessage msg`. Unsigned forged edit ⇒ `Nothing` ⇒ badge removed; verified ⇒ kept. +- `Subscriber.hs:2172` (recipient restore in-place, after `saveRcvChatItem'`): same `msgSigned` from `msg`. +- `Subscriber.hs:1152` (`mdeUpdatedCI` decryption-error marker): `Nothing` — local marker, badge correctly cleared. +- `Subscriber.hs:1509` (`upsertBusinessRequestItem` business-chat welcome): `Nothing` — never a relay channel, safely preserves `Nothing`. (Sibling direct path `:1480` uses `updateDirectChatItem'`, unaffected.) + +Net: signed status is set explicitly from the source of current content in every group create/update path, so a stale badge cannot exist. + +### 8. Paths deliberately left unsigned + +- Auto-reply welcome content (`Subscriber.hs:1267` `XMsgUpdate`, `:1269` `XMsgNew`) via `sendGroupMessage'` ⇒ `DontSignContent`. +- `XMsgReact` (`Commands.hs:889`), `XMsgDel` (`Commands.hs:792-799`): unsigned in v1. Asymmetry: a post is verifiable, its reactions/deletes are not — and a signed post is still relay-suppressible (threat model). Later, extending `signableContent` could let recipients reject unsigned deletes of signed posts. + +## App changes (iOS + Kotlin) + +### A. Decode the signature status +- **JSON tags:** core uses `enumJSON (dropPrefix "MSS")` ⇒ `MSSVerified → "verified"`, `MSSSignedNoKey → "signedNoKey"` (lower-cases first letter). **Not** the DB/text strings (`"verified"`/`"no_key"`). +- iOS: `enum MsgSigStatus: String, Decodable { case verified, signedNoKey }`; add `public var msgSigned: MsgSigStatus?` to `CIMeta` (`apps/ios/SimpleXChat/ChatTypes.swift:3721-3737`). +- Kotlin: `@Serializable enum class MsgSigStatus { @SerialName("verified") Verified, @SerialName("signedNoKey") SignedNoKey }`; add `val msgSigned: MsgSigStatus? = null` to `CIMeta` (`apps/multiplatform/.../model/ChatModel.kt:3434-3450`). +- Optional field ⇒ backward-safe decode of old core JSON. + +### B. Device preference (default off) +- iOS: `@AppStorage(DEFAULT_PRIVACY_SIGN_CHANNEL_MESSAGES) private var signChannelMessages = false` + toggle in `PrivacySettings.swift` (pattern: `protectScreen`, `:68-70`) with a non-repudiation footer. +- Kotlin: `val privacySignChannelMessages = mkBoolPreference(SHARED_PREFS_PRIVACY_SIGN_CHANNEL_MESSAGES, false)` (`SimpleXAPI.kt:314`; declarations near `:122-125`) + `SettingsPreferenceItem` in `PrivacySettings.kt` with explanation. +- App-side only (like `customDisappearingMessageTime`), not core `AppSettings`. + +### C. Composer option (per-send override) + thread `sign` to the API +- Change the send closure to `(_ ttl: Int?, _ sign: Bool?)` (iOS `SendMessageView.swift:21`; Kotlin `SendMsgView.kt:54`), `sign == nil` ⇒ use device default; composer passes effective `sign = override ?? default`. +- Long-press item next to "Disappearing message" (iOS `SendMessageView.swift:224-247`; Kotlin `SendMsgView.kt:198-209`): "Sign message" (default off) / "Send without signing" (default on). +- **Gate visibility** on relay channel + membership has a signing key + **not as-channel** (the UI half of §5 — never offer it for as-channel publication). If app `GroupInfo` lacks relay/key state, add a derived `memberSigningAvailable` boolean to its JSON; AND it with the composer's as-channel state. Mirror `timedMessageAllowed`. +- `apiSendMessages`: add `sign: Bool`, append `sign=on|off` — iOS `ChatCommand.apiSendMessages` (`AppAPITypes.swift:48`, encode `:239`) + `SimpleXAPI.swift:545`; Kotlin `CC.ApiSendMessages` (`SimpleXAPI.kt:3676`, encode `:3867`) + `SimpleXAPI.kt:1097`. + +### D. Recipient indicator +- Show a "signed by author" indicator when `meta.msgSigned == .verified` in the meta row: iOS `CIMetaView.swift` `ciMetaText` (`:93-160`); Kotlin `CIMetaView.kt` `CIMetaText` (`:67-115`) + update `reserveSpaceForMeta` (`:118-175`) for icon width. +- `signedNoKey`: show muted or nothing so it isn't read as `verified` (design). Surface the "verified ≠ timestamp/ordering/completeness" caveat (threat model) in help. +- Own signed items use the same indicator (core sets `MSSVerified` on signed sends). + +## Compatibility analysis +- **Protocol wire format:** unchanged; existing batch-element signature prefix. No `chatVRange` bump; pre-feature relay-capable peers verify/accept correctly. +- **API command:** `sign=` additive with default; app+core ship together. +- **DB:** no migration. `chat_items.msg_signed` exists (added `M20260222_chat_relays`; in both schema files; written by `createNewChatItem_:603`). +- **App JSON:** new optional `msgSigned` decodes as absent on older cores. + +## Edge cases, races, correctness +- **Member without keys** (`groupKeys = Nothing`): `groupMsgSigning` returns `Nothing` even with `SignContent` ⇒ silent unsigned send. UI gate should prevent offering it; document the silent degrade. +- **Non-relay groups:** `useRelays'` guard ⇒ never signed; UI must not offer it. +- **Live messages:** initial `XMsgNew` then repeated `XMsgUpdate`, each reusing the item's `msgSigned` ⇒ every increment signed. Extra cost per keystroke-batch; acceptable. +- **Separate (non-batched) path drops signatures** (`sndMessageMBR` uses raw `msgBody`, `Internal.hs:2199`, vs the batched path's `encodeBatchElement`). Never reached in relay groups (`memberSendAction` → `MSASendBatched`). Add a test-asserted invariant; optionally make `sndMessageMBR` use `encodeBatchElement signedMsg_` too, so routing changes can't silently drop channel signatures. +- **Defense-in-depth: no signature on `FwdChannel`.** `encodeFwdElement` (`Batch.hs:108`) includes `signedMsg_` unconditionally; §5 makes it `Nothing` for `FwdChannel` in normal flow. Add a guard/assertion that `encodeFwdElement` carries no signature when `fwdSender = FwdChannel`, so no future upstream path can reintroduce the de-anonymization. +- **History re-send strips signatures (badge non-determinism, by design).** Relay history catch-up rebuilds content via `prepareGroupMsg` into plain `XGrpMsgForward` events (`processContentItem`, `Internal.hs:1279-1305`) and lacks the private key ⇒ unsigned. So for the same message, a live-forward recipient sees a badge while a history-catch-up recipient does not. Graceful (absence ≠ forgery); document in UI/help and test. +- **Concurrency:** signing/verification are pure given keys; no new shared state. Send holds `withGroupLock`; receive update runs under existing receive-loop serialization. No new races. + +## Tests + +Protocol (`tests/ProtocolTests.hs`, extending `:112-312`): +- Round-trip signed `XMsgNew`/`XMsgUpdate` through `SignedMsg`; assert binding `CBGroup <> (publicGroupId, memberId)`; `verify` accepts the right key, rejects wrong key / altered body / altered binding. + +Integration (`tests/ChatTests/`, using `setupRelay`/`prepareChannel1Relay`/`createChannel1Relay`/`memberJoinChannel`, `Groups.hs:8621-8750`): +- **Sign + verify:** `sign=on` ⇒ recipient and sender items are `(signed)` (`sigStatusStr`). +- **Off / opt-out:** `sign=off`/default ⇒ no `(signed)`. +- **No key:** missing roster key ⇒ `(signed, no key to verify)` (`MSSSignedNoKey`). +- **Edit reuse:** signed message edit stays `(signed)`; unsigned stays unsigned. +- **Edit downgrade (security):** unsigned `XMsgUpdate` for a previously-signed item (forging-relay, cf. `ChatRelays.hs:220-230`) ⇒ badge **removed** (§7). +- **As-channel never signed (anonymity):** owner posts `as_group=on sign=on` ⇒ no item is `(signed)` and no signature on the wire/stored message (guards §5). +- **History downgrade:** live-forward recipient sees `(signed)`; later history-catch-up recipient sees the same message without it (Edge cases). +- **Forgery rejection:** mismatched-binding replay/fabrication ⇒ signature stripped / `RGEMsgBadSignature`. + +App: minimal decode test that `"verified"`/`"signedNoKey"` parse to the right enum on both platforms (guards the §A tag mismatch). + +## Commit / diff plan + +1. **Structural (behavior-preserving):** add `ContentSig`, `signableContent`, parameterize `groupMsgSigning` + the three send functions, update all callers with `DontSignContent`. Reviewable as "no behavior change". +2. **Security fix (independent, behavioral no-op today):** add `Maybe MsgSigStatus` to `updateGroupChatItem`, override `meta.msgSigned` after `updatedChatItem`, add `msg_signed` to `updateGroupChatItem_`'s `UPDATE`, update all five callers (§7). Until commit 3 every call passes `Nothing`/unchanged, so no observable change yet — but correct on its own, with a regression test that bites once signing exists. +3. **Feature behavior (core):** `APISendMessages` field + parser; content send and edit pass the real `ContentSig` (with the §5 as-channel gate); report path `DontSignContent`. +4. **App — decode + recipient indicator.** +5. **App — device preference + composer option + `apiSendMessages` wiring.** +6. **Tests** (protocol + integration) — may accompany commits 2/3. + +Each commit builds and passes tests independently (bisect/rollback). + +### Pre-implementation gates (after rebasing onto #7017 + #7048) +- **MUST:** the as-channel gate (`showGroupAsSender ⇒ DontSignContent`, §5) lives in the *core* send path, and the app option is hidden for as-channel sends (§C) — not UI-only. +- **MUST:** re-run `grep -rn 'updateGroupChatItem\b'` and confirm **every** caller passes an explicit `Maybe MsgSigStatus` — a missed caller silently re-introduces the §7 spoof. (Pre-rebase set: `Commands.hs:738`; `Subscriber.hs:1152,1509,2172,2212`.) +- **SHOULD:** re-run the `sendGroupMessages`/`sendGroupMessage`/`sendGroupMessages_` caller greps; only content-send and edit pass a variable `ContentSig`, all others `DontSignContent`. +- **SHOULD:** the three "verified"-meaning caveats (no timestamp/ordering; history downgrade; relay-suppressible) are surfaced in UI/help, and the history-downgrade test exists. + +## Out of scope / future +- Group-level "expected/required signing" owner setting (closes the optional-downgrade gap). +- Signing reactions/deletes; signing auto-reply content; verifiable reports (signed `MCReport`). + +## Open assumptions to confirm during implementation +- App `GroupInfo` exposes relay+key state for the UI gate, or a derived boolean is added to its JSON. +- Visual treatment of `signedNoKey` vs `verified`, and how to surface the "verified ≠ timestamp/ordering/completeness" caveat (threat model) in help. From 8dd888295dcd28acf75ac274962285ccbac2a994 Mon Sep 17 00:00:00 2001 From: "Evgeny @ SimpleX Chat" <259188159+evgeny-simplex@users.noreply.github.com> Date: Wed, 17 Jun 2026 19:06:24 +0100 Subject: [PATCH 027/200] website: add SimpleX Network News channel preview (#7087) * website: add SimpleX Network News channel preview * send relay domain/capability to channel owner on profile update * add channel ID * add top offset parameter * allow overscroll * better scroll * improve * fix promoted communities * use path for channel renderer without host * fix --------- Co-authored-by: Evgeny Poberezkin --- src/Simplex/Chat/Library/Commands.hs | 12 +---------- src/Simplex/Chat/Library/Internal.hs | 16 ++++++++++++++ src/Simplex/Chat/Library/Subscriber.hs | 2 ++ website/src/_includes/navbar.html | 2 +- website/src/blog.html | 3 ++- website/src/js/channel-preview.jsc | 29 +++++++++++++++++++++++--- website/src/js/directory.jsc | 3 +-- website/src/news.html | 16 ++++++++++++++ 8 files changed, 65 insertions(+), 18 deletions(-) create mode 100644 website/src/news.html diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 646377ac9c..df03a776fe 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -4918,17 +4918,7 @@ runRelayGroupLinkChecks user = do else void $ withStore' $ \db -> updateRelayOwnStatusFromTo db gInfo RSActive RSInactive _ -> pure () _ -> pure () - sendRelayCapIfNeeded cxt gInfo - sendRelayCapIfNeeded cxt gInfo = do - ChatConfig {webPreviewConfig} <- asks config - let currentWebDomain = (\WebPreviewConfig {webDomain} -> webDomain) <$> webPreviewConfig - sentWebDomain <- withStore' (`getRelaySentWebDomain` gInfo) - when (currentWebDomain /= sentWebDomain) $ do - owners <- withStore' $ \db -> getGroupOwners db cxt user gInfo - let capableOwners = filter (\m -> memberCurrent m && m `supportsVersion` relayWebCapVersion) owners - unless (null capableOwners) $ do - void $ sendGroupMessage' user gInfo capableOwners (XGrpRelayCap RelayCapabilities {webDomain = currentWebDomain}) - withStore' $ \db -> updateRelaySentWebDomain db gInfo currentWebDomain + sendRelayCapIfNeeded user gInfo checkRelayInactiveGroups = do cxt <- chatStoreCxt ttl <- asks (relayInactiveTTL . config) diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 325e552d44..0b19c5a261 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -2126,6 +2126,22 @@ sendGroupMessage' user gInfo members chatMsgEvent = ((Right msg) :| [], _) -> pure msg _ -> throwChatError $ CEInternalError "sendGroupMessage': expected 1 message" +-- Relay advertises its current web preview capability to channel owners. +-- Idempotent: sends only when the configured web domain differs from what was last sent, and only to +-- owners whose recorded chat version supports relayWebCapVersion (older apps can't parse XGrpRelayCap). +sendRelayCapIfNeeded :: User -> GroupInfo -> CM () +sendRelayCapIfNeeded user gInfo = do + ChatConfig {webPreviewConfig} <- asks config + let currentWebDomain = (\WebPreviewConfig {webDomain} -> webDomain) <$> webPreviewConfig + sentWebDomain <- withStore' (`getRelaySentWebDomain` gInfo) + when (currentWebDomain /= sentWebDomain) $ do + cxt <- chatStoreCxt + owners <- withStore' $ \db -> getGroupOwners db cxt user gInfo + let capableOwners = filter (\m -> memberCurrent m && m `supportsVersion` relayWebCapVersion) owners + unless (null capableOwners) $ do + void $ sendGroupMessage' user gInfo capableOwners (XGrpRelayCap RelayCapabilities {webDomain = currentWebDomain}) + withStore' $ \db -> updateRelaySentWebDomain db gInfo currentWebDomain + sendGroupMessages :: MsgEncodingI e => User -> GroupInfo -> Maybe GroupChatScope -> ShowGroupAsSender -> [GroupMember] -> NonEmpty (ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult) sendGroupMessages user gInfo scope asGroup members events = do -- TODO [knocking] send current profile to pending member after approval? diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index b948d7727b..671b7a53df 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -3323,6 +3323,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = unless (useRelays' g'') $ void $ forkIO $ void $ setGroupLinkData' NRMBackground user g'' Just _ -> updateGroupPrefs_ msgSigned g m $ fromMaybe defaultBusinessGroupPrefs $ groupPreferences p' + -- relay advertises its web capability now that the owner's version is known (bumped by saveGroupRcvMsg) + when (isRelay (membership g)) $ sendRelayCapIfNeeded user g pure $ Just DJSGroup {jobSpec = DJDeliveryJob {includePending = True}} xGrpPrefs :: GroupInfo -> GroupMember -> GroupPreferences -> RcvMessage -> CM (Maybe DeliveryJobScope) diff --git a/website/src/_includes/navbar.html b/website/src/_includes/navbar.html index 34ee893dd3..cec2aa0a01 100644 --- a/website/src/_includes/navbar.html +++ b/website/src/_includes/navbar.html @@ -148,7 +148,7 @@ - {% if ('blog' not in page.url) and ('about' not in page.url) and ('donate' not in page.url) and ('privacy' not in page.url) and ('directory' not in page.url) and ('credits' not in page.url) and ('file' not in page.url) and ('links' not in page.url) %} + {% if ('blog' not in page.url) and ('about' not in page.url) and ('donate' not in page.url) and ('privacy' not in page.url) and ('directory' not in page.url) and ('credits' not in page.url) and ('file' not in page.url) and ('links' not in page.url) and ('news' not in page.url) %}