From df5ea3d4603f8bb6d6825fd9823ce21f45be5280 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Fri, 22 May 2026 12:45:02 +0100 Subject: [PATCH 01/66] 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 02/66] 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 03/66] 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 e9871b0383bccb315caa95cd88e18bf55ff3ddc3 Mon Sep 17 00:00:00 2001 From: SimpleX Chat Date: Sat, 23 May 2026 12:25:45 +0530 Subject: [PATCH 04/66] ios: update core 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 f0bd6d9118..f19445700b 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.2.0-8CNxlktzYRPIWqOtKFvRIa-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.2.0-8CNxlktzYRPIWqOtKFvRIa-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.2.0-8CNxlktzYRPIWqOtKFvRIa.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.2.0-8CNxlktzYRPIWqOtKFvRIa.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 */; }; 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.2.0-8CNxlktzYRPIWqOtKFvRIa-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.2.0-8CNxlktzYRPIWqOtKFvRIa-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.2.0-8CNxlktzYRPIWqOtKFvRIa.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.2.0-8CNxlktzYRPIWqOtKFvRIa.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 = ""; }; 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.2.0-8CNxlktzYRPIWqOtKFvRIa-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.2.0-8CNxlktzYRPIWqOtKFvRIa.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 */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -818,8 +818,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.2.0-8CNxlktzYRPIWqOtKFvRIa-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.2.0-8CNxlktzYRPIWqOtKFvRIa.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.3.0-GwIsQfwCbNXBEEtsO80Uxy-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.3.0-GwIsQfwCbNXBEEtsO80Uxy.a */, ); path = Libraries; sourceTree = ""; From 25ab10ffa3a2d3689a7446134323798085e62d97 Mon Sep 17 00:00:00 2001 From: SimpleX Chat Date: Sat, 23 May 2026 12:26:59 +0530 Subject: [PATCH 05/66] 6.5.3: ios 333 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 40 +++++++++++----------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index f19445700b..3d274bcc85 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -2073,7 +2073,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 332; + CURRENT_PROJECT_VERSION = 333; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2098,7 +2098,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES_THIN; - MARKETING_VERSION = 6.5.2; + MARKETING_VERSION = 6.5.3; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2123,7 +2123,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 332; + CURRENT_PROJECT_VERSION = 333; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2148,7 +2148,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.5.2; + MARKETING_VERSION = 6.5.3; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2165,11 +2165,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 332; + CURRENT_PROJECT_VERSION = 333; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.5.2; + MARKETING_VERSION = 6.5.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2185,11 +2185,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 332; + CURRENT_PROJECT_VERSION = 333; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.5.2; + MARKETING_VERSION = 6.5.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2210,7 +2210,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 332; + CURRENT_PROJECT_VERSION = 333; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2225,7 +2225,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.5.2; + MARKETING_VERSION = 6.5.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2247,7 +2247,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 332; + CURRENT_PROJECT_VERSION = 333; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2262,7 +2262,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.5.2; + MARKETING_VERSION = 6.5.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2284,7 +2284,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 332; + CURRENT_PROJECT_VERSION = 333; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2310,7 +2310,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.5.2; + MARKETING_VERSION = 6.5.3; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2335,7 +2335,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 332; + CURRENT_PROJECT_VERSION = 333; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2362,7 +2362,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.5.2; + MARKETING_VERSION = 6.5.3; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2389,7 +2389,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 332; + CURRENT_PROJECT_VERSION = 333; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2404,7 +2404,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.5.2; + MARKETING_VERSION = 6.5.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2423,7 +2423,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 332; + CURRENT_PROJECT_VERSION = 333; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2438,7 +2438,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.5.2; + MARKETING_VERSION = 6.5.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; From 1a82732f88af659bb197f73613b87763daeabd22 Mon Sep 17 00:00:00 2001 From: SimpleX Chat Date: Fri, 22 May 2026 11:50:50 +0000 Subject: [PATCH 06/66] 6.5.3: android 351, desktop 144 --- apps/multiplatform/gradle.properties | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 4d504e069e..3d4bf66913 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,13 +24,13 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=6.5.2 -android.version_code=349 +android.version_name=6.5.3 +android.version_code=351 android.bundle=false -desktop.version_name=6.5.2 -desktop.version_code=143 +desktop.version_name=6.5.3 +desktop.version_code=144 kotlin.version=2.1.20 gradle.plugin.version=8.7.0 From fe6b5186e181d68c4b48aaef06471663023d0abe Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 25 May 2026 10:37:13 +0100 Subject: [PATCH 07/66] 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 08/66] 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 09/66] 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 10/66] 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 9bd9e6a16c4840484c2d0891713aea1121208cf4 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Mon, 25 May 2026 15:08:48 +0000 Subject: [PATCH 11/66] desktop: fix in-app updater on Windows, AppImage, and aarch64 (#6985) * desktop: fix in-app updater silently failing on Windows chooseGitHubReleaseAssets ran `which dpkg` unconditionally to probe for Debian-derivative systems. On Windows there is no which.exe, so Runtime.exec threw IOException, which the outer catch in checkForUpdate logged and swallowed -- the update dialog never appeared. Gate the probe on desktopPlatform.isLinux(). * desktop: fix in-app updater install step on AppImage xdg-open on the downloaded .AppImage opened it in whatever the desktop environment's default handler for the AppImage MIME type is -- usually an archive viewer, which reports 'Archive format not recognized'. The running AppImage was never replaced. Detect $APPIMAGE (set by the AppImage runtime to the path of the running .AppImage file). Copy the downloaded file to a staging file in the target's own directory, mark it executable, then atomic-move it onto $APPIMAGE. Staging in the target directory keeps the final move a same-filesystem rename(2), so an interrupted copy never leaves the running AppImage partially overwritten. On failure (permission denied, target read-only, etc.) fall back to opening the parent directory so the user can install manually -- the same fallback the existing xdg-open path already used. * desktop: fix in-app updater silently failing on aarch64 AppImage The LINUX_AARCH64 githubAssetName had a literal leading space (" simplex-desktop-aarch64.AppImage"), so the exact-name filter in chooseGitHubReleaseAssets never matched the real release asset name "simplex-desktop-aarch64.AppImage". The asset list came back empty and checkForUpdate's early-return at "No assets to download for current system" suppressed the dialog. Same silent-failure pattern as the Windows bug. * plans: justify desktop in-app updater fixes --- .../common/platform/Platform.desktop.kt | 2 +- .../common/views/helpers/AppUpdater.kt | 50 ++++++--- plans/2026-05-16-desktop-updater-fixes.md | 100 ++++++++++++++++++ 3 files changed, 139 insertions(+), 13 deletions(-) create mode 100644 plans/2026-05-16-desktop-updater-fixes.md diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt index 97de08b07e..7ea41d3593 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt @@ -10,7 +10,7 @@ val desktopPlatform = detectDesktopPlatform() enum class DesktopPlatform(val libExtension: String, val configPath: String, val dataPath: String, val githubAssetName: String) { LINUX_X86_64("so", unixConfigPath, unixDataPath, "simplex-desktop-x86_64.AppImage"), - LINUX_AARCH64("so", unixConfigPath, unixDataPath, " simplex-desktop-aarch64.AppImage"), + LINUX_AARCH64("so", unixConfigPath, unixDataPath, "simplex-desktop-aarch64.AppImage"), WINDOWS_X86_64("dll", System.getenv("AppData") + File.separator + "SimpleX", System.getenv("AppData") + File.separator + "SimpleX", "simplex-desktop-windows-x86_64.msi"), MAC_X86_64("dylib", unixConfigPath, unixDataPath, "simplex-desktop-macos-x86_64.dmg"), MAC_AARCH64("dylib", unixConfigPath, unixDataPath, "simplex-desktop-macos-aarch64.dmg"); diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt index 974578882d..f6a6023d47 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt @@ -26,6 +26,8 @@ import java.io.Closeable import java.io.File import java.net.InetSocketAddress import java.net.Proxy +import java.nio.file.Files +import java.nio.file.StandardCopyOption import kotlin.math.min data class SemVer( @@ -376,7 +378,7 @@ private fun chooseGitHubReleaseAssets(release: GitHubRelease): List val res = if (isRunningFromFlatpak()) { // No need to show download options for Flatpak users emptyList() - } else if (!isRunningFromAppImage() && Runtime.getRuntime().exec("which dpkg").onExit().join().exitValue() == 0) { + } else if (desktopPlatform.isLinux() && !isRunningFromAppImage() && Runtime.getRuntime().exec("which dpkg").onExit().join().exitValue() == 0) { // Show all available .deb packages and user will choose the one that works on his system (for Debian derivatives) release.assets.filter { it.name.lowercase().endsWith(".deb") } } else { @@ -388,18 +390,42 @@ private fun chooseGitHubReleaseAssets(release: GitHubRelease): List private suspend fun installAppUpdate(file: File) = withContext(Dispatchers.IO) { when { desktopPlatform.isLinux() -> { - val process = Runtime.getRuntime().exec("xdg-open ${file.absolutePath}").onExit().join() - val startedInstallation = process.exitValue() == 0 && process.children().count() > 0 - if (!startedInstallation) { - Log.e(TAG, "Error starting installation: ${process.inputReader().use { it.readLines().joinToString("\n") }}${process.errorStream.use { String(it.readAllBytes()) }}") - // Failed to start installation. show directory with the file for manual installation - desktopOpenDir(file.parentFile) + val appImagePath = System.getenv("APPIMAGE") + if (appImagePath != null) { + // Replace the running AppImage crash-safely: copy onto the target's own + // filesystem first (an atomic rename only works within one filesystem, and + // the download lives in the temp dir which is usually a different one), + // then atomically move the staged file onto $APPIMAGE. + val target = File(appImagePath) + val staging = File(target.parentFile, ".${target.name}.update") + try { + Files.copy(file.toPath(), staging.toPath(), StandardCopyOption.REPLACE_EXISTING) + staging.setExecutable(true, false) + Files.move(staging.toPath(), target.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) + file.delete() + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.app_check_for_updates_installed_successfully_title), + text = generalGetString(MR.strings.app_check_for_updates_installed_successfully_desc) + ) + } catch (e: Exception) { + Log.e(TAG, "Failed to replace AppImage: ${e.stackTraceToString()}") + staging.delete() + desktopOpenDir(file.parentFile) + } } else { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.app_check_for_updates_installed_successfully_title), - text = generalGetString(MR.strings.app_check_for_updates_installed_successfully_desc) - ) - file.delete() + val process = Runtime.getRuntime().exec("xdg-open ${file.absolutePath}").onExit().join() + val startedInstallation = process.exitValue() == 0 && process.children().count() > 0 + if (!startedInstallation) { + Log.e(TAG, "Error starting installation: ${process.inputReader().use { it.readLines().joinToString("\n") }}${process.errorStream.use { String(it.readAllBytes()) }}") + // Failed to start installation. show directory with the file for manual installation + desktopOpenDir(file.parentFile) + } else { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.app_check_for_updates_installed_successfully_title), + text = generalGetString(MR.strings.app_check_for_updates_installed_successfully_desc) + ) + file.delete() + } } } desktopPlatform.isWindows() -> { diff --git a/plans/2026-05-16-desktop-updater-fixes.md b/plans/2026-05-16-desktop-updater-fixes.md new file mode 100644 index 0000000000..40dbefd11d --- /dev/null +++ b/plans/2026-05-16-desktop-updater-fixes.md @@ -0,0 +1,100 @@ +# Desktop In-App Updater Fixes + +## Problem Statement + +The desktop in-app updater (`apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt`) silently or visibly fails on three of the four supported desktop platforms: + +1. **Windows**: no update dialog ever appears for any Windows user, regardless of how out-of-date the running version is. +2. **AppImage (x86_64)**: the update dialog appears and the download succeeds, but clicking "Install update" opens the new AppImage in an archive viewer ("Archive format not recognized") instead of installing it. The running AppImage is never replaced. +3. **AppImage (aarch64)**: no update dialog ever appears for any aarch64 AppImage user. + +The desktop installer flow on macOS and the `.deb` flow on Debian-derivative Linux are not affected and remain unchanged. + +## Root Causes + +### 1. Windows — `which dpkg` IOException swallowed + +`chooseGitHubReleaseAssets` (AppUpdater.kt) invokes `Runtime.getRuntime().exec("which dpkg")` unconditionally to detect Debian-derivative systems. On Windows there is no `which.exe`; `CreateProcess` returns error 2 and the JVM throws `IOException: Cannot run program "which"` synchronously from `Runtime.exec`. The exception propagates up through `chooseGitHubReleaseAssets` into `checkForUpdate`'s outer `try { ... } catch (e: Exception) { Log.e(...) }`, which logs to stderr and returns. The user-facing alert is never built. + +The `.deb` probe was correct in intent but executed too eagerly: it has no business running on a non-Linux platform. + +### 2. AppImage — `xdg-open` is the wrong operation + +The Linux branch of `installAppUpdate` calls `xdg-open `. An AppImage is not "installable" in the package-manager sense — it is a self-contained executable that lives at the path stored in the `$APPIMAGE` environment variable (set by the AppImage runtime). On most desktop environments, `xdg-open` resolves the `.AppImage` MIME type to an archive handler (file-roller, ark, engrampa). The handler attempts to read the AppImage as a `.iso`/squashfs archive and fails with "Archive format not recognized". Even when it succeeds, it does not replace the running AppImage — the next launch still runs the old binary. + +The existing code had no awareness of `$APPIMAGE` at install time. The `GitHubAsset.isAppImage` field hints at an earlier abandoned attempt at AppImage-specific handling. + +### 3. aarch64 AppImage — leading space in asset name + +`Platform.desktop.kt` declares: + +```kotlin +LINUX_AARCH64("so", unixConfigPath, unixDataPath, " simplex-desktop-aarch64.AppImage"), +``` + +The `githubAssetName` literal has a leading space character. The actual release asset published by `.github/workflows/build.yml` is `simplex-desktop-aarch64.AppImage` (no space — verified against the live GitHub releases API). The exact-name filter in `chooseGitHubReleaseAssets` (`release.assets.filter { it.name == desktopPlatform.githubAssetName }`) never matches, the asset list is empty, and `checkForUpdate` returns at the "No assets to download for current system" branch without ever showing a dialog. Same silent-failure pattern as the Windows bug, single arch in blast radius. + +## Solution Summary + +Three small, independent commits — one per root cause. None of them changes shared logic; each touches one line (Windows, aarch64) or one branch of the install dispatch (AppImage). + +### Fix 1 — Gate the `dpkg` probe on Linux + +```kotlin +// AppUpdater.kt: chooseGitHubReleaseAssets +} else if (desktopPlatform.isLinux() && !isRunningFromAppImage() + && Runtime.getRuntime().exec("which dpkg").onExit().join().exitValue() == 0) { +``` + +Single conjunct (`desktopPlatform.isLinux() &&`) added at the start of the `else if`. Boolean short-circuit ensures `Runtime.exec` is never reached on non-Linux. The added gate matches the actual semantic intent: `.deb` is a Linux-only package format. Both the Windows IOException and the (theoretical) macOS misbehavior of the `which` probe are eliminated. + +### Fix 2 — AppImage-aware install path + +In `installAppUpdate`'s Linux branch, read `System.getenv("APPIMAGE")`: + +- If non-null, the running app is an AppImage at that path. Replacing it crash-safely takes two steps, because an atomic file replacement is only possible *within a single filesystem* (POSIX `rename(2)`), and the download lives in the temp dir — usually a different filesystem (tmpfs) from where `$APPIMAGE` lives: + 1. `Files.copy` the downloaded file to a staging file (`..update`) in the target's *own* directory. This is the unavoidable cross-filesystem transfer; it is not atomic, but it writes only a sidecar, never the live `$APPIMAGE`. + 2. Mark the staging file executable, then `Files.move` it onto `$APPIMAGE` with `ATOMIC_MOVE`. Because staging now shares the target's filesystem, this is a real atomic `rename(2)`: the live file flips from old to new in one indivisible step, never partially written. + + A direct `Files.move(downloaded, target, REPLACE_EXISTING)` is **not** sufficient — across filesystems it copies bytes straight onto the live `$APPIMAGE`, which is neither atomic nor crash-safe (an interrupted copy destroys the user's installed app). `ATOMIC_MOVE` on a cross-filesystem move throws `AtomicMoveNotSupportedException`. Staging on the target's filesystem first is what makes the atomic move possible. On Linux the kernel keeps the running process's open file descriptors valid across the rename: the running app continues to function until the user restarts, at which point the new binary is used. +- If null, fall back to the existing `xdg-open` path (used for `.deb` install on Debian, which is the only remaining caller of this path after Fix 2). + +On any exception (permission denied if the AppImage lives in `/opt/`, target read-only, etc.) the catch deletes the staging file and falls back to `desktopOpenDir(file.parentFile)` — the same fallback the original `xdg-open` path used. + +### Fix 3 — Remove leading space from `LINUX_AARCH64` asset name + +```kotlin +LINUX_AARCH64("so", unixConfigPath, unixDataPath, "simplex-desktop-aarch64.AppImage"), +``` + +Single character removed. The asset name now matches what `make-appimage-linux.sh` produces and what GitHub releases publish. + +## Why three commits, not one + +Each fix has a different blast radius, a different fix size, and (potentially) a different review path. Three focused commits let a reviewer judge each one in isolation: + +- Windows fix: 1 line, gates a side-effecting `Runtime.exec` on a platform check that the surrounding code already establishes. +- AppImage install: ~35 lines, introduces new file-system operations (`Files.copy` to a staging file, then `Files.move` with `ATOMIC_MOVE`). +- aarch64 fix: 1 character, fixes a typo in a string literal. + +Bundling them as a single commit would force a reviewer to verify all three at once and would obscure `git blame` on the AppImage install logic, which is the only one of the three that introduces meaningful new behavior. + +## Out of scope + +The following were identified during the audit (`apps/multiplatform/app-updater-audit.md`) but deliberately deferred to keep this PR focused: + +- `msiexec /i ${file.absolutePath}` uses the single-string `Runtime.exec` overload that tokenizes on whitespace; paths containing spaces (uncommon on Windows but possible) break the install. +- Download failures (network, TLS, disk-full, GitHub error) are caught but only logged; the user sees nothing. +- `process.children().count() > 0` in the Linux `xdg-open` path is racy and arguably wrong. +- No SHA256 / signature verification on the downloaded artifact — the updater installs whatever GitHub serves. +- 24h delay with no retry / backoff on transient network errors. +- macOS install hardcodes `/Applications/SimpleX.app`. + +Each is documented with `file:line` references in the audit; none affects the three platforms this PR fixes. + +## Test plan + +- **Windows**: built x86_64 MSI via the fork CI workflow [`build-windows-msi.yml`](https://github.com/Narasimha-sc/simplex-chat/actions/runs/25958413517), installed in a Windows VM as version 6.5.1 (intentionally lowered to trigger the check against current stable 6.5.2). Settings → Check for updates → Stable: dialog appeared as expected. +- **AppImage x86_64**: built locally (host build, GHC 9.6.3, gradle createDistributable, appimagetool), installed and ran on Linux. Settings → Check for updates → Stable: dialog appeared, Download landed file at `/tmp/simplex/simplex-desktop-x86_64.AppImage`, Install replaced `$APPIMAGE` in place. Verified by hashing `$APPIMAGE` before and after. +- **aarch64 AppImage**: not separately tested. Fix is a 1-character literal change verified against the live GitHub releases API (`simplex-desktop-aarch64.AppImage`, no leading space). +- **macOS**: no changes to the macOS install branch. From ff36d401ce40ec9a0d5f9073a589e656a1a57e00 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Mon, 25 May 2026 15:10:55 +0000 Subject: [PATCH 12/66] desktop: fix video playback hang caused by stuck preview snapshot (#6983) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * desktop: fix video playback hang caused by stuck preview snapshot Problem: clicking play on a video did nothing when an earlier video's preview generation was stuck — every subsequent VideoPlayer.play() was queued behind it on the shared playerThread. Cause: helper player reuse across previews exhausted the libavcodec h264 frame-buffer pool with --avcodec-hw=none (PR #6924), and the synchronous libvlc snapshots().get() call then hung waiting for a frame that was never decoded. Fix: drop the helper-player pool (release each helper after use) and run preview generation on a dedicated previewThread so a stuck preview can no longer block playback. * plans: add 2026-05-15-fix-video-preview-snapshot-hang.md * desktop: capture preview via callback surface, keep helper pool Follows up on the previous commit (4a964c66). The actual hang was in libvlc's synchronous snapshots().get() on a reused helper, not in the pooling itself. Replace the polling loop with a CallbackVideoSurface (the existing SkiaBitmapVideoSurface) wrapped in withTimeoutOrNull — the wait is bounded, so a non-decoding helper can't block previewThread. Restore the helper-player pool that the previous commit dropped. * plans: update 2026-05-15-fix-video-preview-snapshot-hang.md for final fix --- .../common/platform/VideoPlayer.desktop.kt | 14 +++-- ...6-05-15-fix-video-preview-snapshot-hang.md | 57 +++++++++++++++++++ 2 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 plans/2026-05-15-fix-video-preview-snapshot-hang.md diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt index 90c80d3b2a..c3b6dc3a4c 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt @@ -6,6 +6,7 @@ import androidx.compose.ui.graphics.* import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.coroutines.* +import org.jetbrains.compose.videoplayer.SkiaBitmapVideoSurface import uk.co.caprica.vlcj.media.VideoOrientation import uk.co.caprica.vlcj.player.base.* import uk.co.caprica.vlcj.player.component.CallbackMediaPlayerComponent @@ -214,7 +215,7 @@ actual class VideoPlayer actual constructor( } } - suspend fun getBitmapFromVideo(defaultPreview: ImageBitmap?, uri: URI?, withAlertOnException: Boolean = true): VideoPlayerInterface.PreviewAndDuration = withContext(playerThread.asCoroutineDispatcher()) { + suspend fun getBitmapFromVideo(defaultPreview: ImageBitmap?, uri: URI?, withAlertOnException: Boolean = true): VideoPlayerInterface.PreviewAndDuration = withContext(previewThread.asCoroutineDispatcher()) { val mediaComponent = getOrCreateHelperPlayer() val player = mediaComponent.mediaPlayer() if (uri == null || !uri.toFile().exists()) { @@ -222,12 +223,12 @@ actual class VideoPlayer actual constructor( return@withContext VideoPlayerInterface.PreviewAndDuration(preview = defaultPreview, timestamp = 0L, duration = 0L) } + val surface = SkiaBitmapVideoSurface() + player.videoSurface().set(surface) player.media().startPaused(uri.toFile().absolutePath) - val start = System.currentTimeMillis() - var snap: BufferedImage? = null - while (snap == null && start + 1500 > System.currentTimeMillis()) { - snap = player.snapshots()?.get() - delay(50) + val snap = withTimeoutOrNull(1500L) { + while (surface.bitmap.value == null) delay(50) + surface.bitmap.value!!.toAwtImage() } val orientation = player.media().info().videoTracks().firstOrNull()?.orientation() if (orientation == null) { @@ -255,6 +256,7 @@ actual class VideoPlayer actual constructor( } val playerThread = Executors.newSingleThreadExecutor() + private val previewThread = Executors.newSingleThreadExecutor() private val playersPool: ArrayList = ArrayList() private val helperPlayersPool: ArrayList = ArrayList() diff --git a/plans/2026-05-15-fix-video-preview-snapshot-hang.md b/plans/2026-05-15-fix-video-preview-snapshot-hang.md new file mode 100644 index 0000000000..4a64d0ca43 --- /dev/null +++ b/plans/2026-05-15-fix-video-preview-snapshot-hang.md @@ -0,0 +1,57 @@ +# Desktop: video playback hangs after a preview snapshot stalls + +Branch: `nd/fix-video` · final code commit `4c7073bdc` · PR [#6983](https://github.com/simplex-chat/simplex-chat/pull/6983). + +## 1. Problem statement + +On Desktop with several videos in a chat, clicking the play button on the second (or any subsequent) video does nothing. The first video plays normally; later ones present a play button that responds to the click but never starts playback. No error dialog appears in the UI. `stderr` shows libvlc and libavcodec noise: + +``` +[h264 @ 0x...] get_buffer() failed +[h264 @ 0x...] thread_get_buffer() failed +[h264 @ 0x...] decode_slice_header error +[h264 @ 0x...] no frame! +... main video output error: Failed to grab a snapshot +``` + +The bug appeared after PR [#6924](https://github.com/simplex-chat/simplex-chat/pull/6924) (`ab2d03630`), which switched the preview helper player from the shared `vlcFactory` to a dedicated `vlcPreviewFactory` with `--avcodec-hw=none`. Hardware-accelerated decoding had previously masked the underlying fragility. Scope: Desktop only. + +## 2. Root cause + +Two compounding defects in `VideoPlayer.desktop.kt`, surfaced by `#6924`: + +### 2a. Synchronous `snapshots().get()` blocks the shared `playerThread` indefinitely + +`getBitmapFromVideo` ran inside `withContext(playerThread.asCoroutineDispatcher())` — the same single-thread executor used by `play()`/`stop()` for playback. Its loop polls vlcj's snapshot API: + +```kotlin +while (snap == null && start + 1500 > System.currentTimeMillis()) { + snap = player.snapshots()?.get() + delay(50) +} +``` + +The 1500 ms wall-clock guard only fires *between* calls. `player.snapshots()?.get()` is a synchronous JNI call that, when libvlc cannot produce a frame, waits indefinitely. While it blocks, `playerThread` is held: every queued `playerThread.execute { videoPlaying.value = start(...) }` from a subsequent `play()` click sits in the queue and never runs. + +This was confirmed by instrumented printlns: after the first video's preview entered the snapshot loop, the second video's `play()` body executed (UI thread println fires), but its lambda submitted to `playerThread.execute` produced no `lambda started` print — because `playerThread` was stuck inside the JNI call. + +### 2b. Helper-player pool reuse exhausts the software h264 buffer pool + +`getOrCreateHelperPlayer()` returns a `CallbackMediaPlayerComponent` from `helperPlayersPool`, recycling it across preview generations. With `vlcFactory` (hardware-accelerated by default), this was harmless — the GPU buffer pool was large with different lifecycle semantics. After `#6924` switched the helper to `vlcPreviewFactory` (`--avcodec-hw=none`), libavcodec frames from the previous run were not released cleanly across `stop` + `startPaused`, and the second decoder ran out of buffers (`get_buffer() failed`). The vout never produced a frame, which is the trigger for the hang in 2a. + +## 3. Solution summary + +`apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt` — single file, +8 / −6 lines. Helper-player pool is preserved as-is. + +1. **Replace the polling `snapshots().get()` loop with a `CallbackVideoSurface` capture wrapped in `withTimeoutOrNull`.** The existing `SkiaBitmapVideoSurface` (already used for full-screen playback rendering) is attached to the helper player before `media().startPaused(...)`. Its `RenderCallback.display()` runs as soon as libvlc decodes the first frame, populating `surface.bitmap`. `getBitmapFromVideo` polls `surface.bitmap.value` from inside `withTimeoutOrNull(1500L) { ... }`; the wait is now structurally bounded — the synchronous JNI call is gone. Frame is converted to `BufferedImage` via `ImageBitmap.toAwtImage()` for the existing orientation-correction code path. This addresses 2a directly: a helper that fails to decode (2b) no longer holds the dispatcher. + +2. **Move preview generation to a dedicated executor.** A new `previewThread = Executors.newSingleThreadExecutor()` runs `getBitmapFromVideo`. Defense in depth: even if 1500 ms of preview work overlaps with a play click, playback's `playerThread` is free to service it. + +The pool is intentionally not touched. Removing it loses the factory-warmup amortization across distinct video URIs without addressing the actual hang (which is in the synchronous snapshot API, not in player reuse). + +## 4. Alternatives considered (and rejected) + +- **Drop the helper-player pool (initial attempt, commit `4a964c661`).** Replaces every preview's helper with a fresh `CallbackMediaPlayerComponent`. Fixes the symptom by sidestepping pool reuse, but costs the factory-warmup benefit and does not address the underlying blocking JNI call — a single corrupt video could still hang preview generation indefinitely (just on a fresh helper). Superseded by the surface-capture approach. +- **Keep the pool, reset the helper between uses.** vlcj has no clean reset API; would require `media().release()` + manual re-attach. More code, fragile, doesn't address 2a. +- **Wrap `snapshots().get()` in a coroutine timeout on a separate IO thread.** `withTimeoutOrNull` cannot cancel a blocked JNI call; the IO thread leaks until libvlc returns (which may be never). +- **Revert PR #6924.** Restores the masking effect of hardware-accelerated decoding but reintroduces whatever the PR was guarding against, and leaves both 2a and 2b in place. 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 13/66] 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 12fbf61f326e15ee643c72b3202bbcd6a758a07f Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 26 May 2026 09:03:41 +0000 Subject: [PATCH 14/66] core, ui: require update for public groups (#7009) --- apps/ios/Shared/Model/AppAPITypes.swift | 1 + .../Shared/Views/NewChat/NewChatView.swift | 27 ++++++++++++++++ .../chat/simplex/common/model/SimpleXAPI.kt | 1 + .../common/views/newchat/ConnectPlan.kt | 27 ++++++++++++++++ .../commonMain/resources/MR/base/strings.xml | 2 ++ .../src/Directory/Service.hs | 1 + bots/api/TYPES.md | 4 +++ .../types/typescript/src/types.ts | 7 ++++ .../src/simplex_chat/types/_types.py | 7 +++- src/Simplex/Chat/Controller.hs | 2 ++ src/Simplex/Chat/Library/Commands.hs | 32 +++++++++++-------- src/Simplex/Chat/View.hs | 1 + 12 files changed, 97 insertions(+), 15 deletions(-) diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index b459f36c9d..a5a56174b1 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -1404,6 +1404,7 @@ enum GroupLinkPlan: Decodable, Hashable { case connectingProhibit(groupInfo_: GroupInfo?) case known(groupInfo: GroupInfo) case noRelays(groupSLinkData_: GroupShortLinkData?) + case updateRequired(groupSLinkData_: GroupShortLinkData?) } struct ChatTagData: Encodable { diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index 9bcc326a66..f73a2f1503 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -1559,6 +1559,33 @@ func planAndConnect( cleanup?() } } + case let .updateRequired(groupSLinkData_): + logger.debug("planAndConnect, .groupLink, .updateRequired") + await MainActor.run { + if let groupSLinkData = groupSLinkData_ { + showOpenChatAlert( + profileName: groupSLinkData.groupProfile.displayName, + profileFullName: groupSLinkData.groupProfile.fullName, + profileImage: + ProfileImage( + imageStr: groupSLinkData.groupProfile.image, + iconName: "person.2.circle.fill", + size: alertProfileImageSize + ), + theme: theme, + subtitle: NSLocalizedString("This group requires a newer version of the app. Please update the app to join.", comment: "alert subtitle"), + cancelTitle: NSLocalizedString("OK", comment: "alert button"), + confirmTitle: nil, + onCancel: { cleanup?() } + ) + } else { + showAlert( + NSLocalizedString("App update required", comment: "alert title"), + message: NSLocalizedString("This group requires a newer version of the app. Please update the app to join.", comment: "alert message") + ) + cleanup?() + } + } } case let .error(chatError): logger.debug("planAndConnect, .error \(chatErrorString(chatError))") 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..8f7cce21c4 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 @@ -6993,6 +6993,7 @@ sealed class GroupLinkPlan { @Serializable @SerialName("connectingProhibit") class ConnectingProhibit(val groupInfo_: GroupInfo? = null): GroupLinkPlan() @Serializable @SerialName("known") class Known(val groupInfo: GroupInfo): GroupLinkPlan() @Serializable @SerialName("noRelays") class NoRelays(val groupSLinkData_: GroupShortLinkData? = null): GroupLinkPlan() + @Serializable @SerialName("updateRequired") class UpdateRequired(val groupSLinkData_: GroupShortLinkData? = null): GroupLinkPlan() } abstract class TerminalItem { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt index cafad97574..87cf01403c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt @@ -316,6 +316,33 @@ private suspend fun planAndConnectTask( cleanup() } } + is GroupLinkPlan.UpdateRequired -> { + Log.d(TAG, "planAndConnect, .GroupLink, .UpdateRequired") + val groupSLinkData = connectionPlan.groupLinkPlan.groupSLinkData_ + if (groupSLinkData != null) { + AlertManager.privacySensitive.showOpenChatAlert( + profileName = groupSLinkData.groupProfile.displayName, + profileFullName = groupSLinkData.groupProfile.fullName, + profileImage = { + ProfileImage( + size = alertProfileImageSize, + image = groupSLinkData.groupProfile.image, + icon = MR.images.ic_supervised_user_circle_filled + ) + }, + subtitle = generalGetString(MR.strings.group_link_requires_newer_version), + confirmText = null, + dismissText = generalGetString(MR.strings.ok), + onDismiss = { cleanup() } + ) + } else { + AlertManager.privacySensitive.showAlertMsg( + generalGetString(MR.strings.app_update_required), + generalGetString(MR.strings.group_link_requires_newer_version) + ) + cleanup() + } + } } is ConnectionPlan.Error -> { Log.d(TAG, "planAndConnect, error ${connectionPlan.chatError}") 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..5a0bc77ccf 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -196,6 +196,8 @@ This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. Channel temporarily unavailable Channel has no active relays. Please try to join later. + App update required + This group requires a newer version of the app. Please update the app to join. Connection error (AUTH) Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection. Connection blocked diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 6e414ef011..577cc99752 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -970,6 +970,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName GLPConnectingProhibit _ -> sendMessage cc ct $ "Already connecting to this " <> gt <> "." GLPConnectingConfirmReconnect -> sendMessage cc ct $ "Already connecting to this " <> gt <> "." GLPNoRelays _ -> sendMessage cc ct $ T.toTitle gt <> " has no active relays. Please try again later." + GLPUpdateRequired _ -> sendMessage cc ct $ T.toTitle gt <> " requires a newer version." GLPOwnLink _ -> sendMessage cc ct "Unexpected error. Please report it to directory admins." _ -> sendMessage cc ct "Unexpected error. Please report it to directory admins." diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index b4edb9bd22..3db6dcbcfc 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -2331,6 +2331,10 @@ NoRelays: - type: "noRelays" - groupSLinkData_: [GroupShortLinkData](#groupshortlinkdata)? +UpdateRequired: +- type: "updateRequired" +- groupSLinkData_: [GroupShortLinkData](#groupshortlinkdata)? + --- diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 7e618e05c8..44949611b2 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -2602,6 +2602,7 @@ export type GroupLinkPlan = | GroupLinkPlan.ConnectingProhibit | GroupLinkPlan.Known | GroupLinkPlan.NoRelays + | GroupLinkPlan.UpdateRequired export namespace GroupLinkPlan { export type Tag = @@ -2611,6 +2612,7 @@ export namespace GroupLinkPlan { | "connectingProhibit" | "known" | "noRelays" + | "updateRequired" interface Interface { type: Tag @@ -2649,6 +2651,11 @@ export namespace GroupLinkPlan { type: "noRelays" groupSLinkData_?: GroupShortLinkData } + + export interface UpdateRequired extends Interface { + type: "updateRequired" + groupSLinkData_?: GroupShortLinkData + } } export interface GroupMember { 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..409a187245 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -1854,6 +1854,10 @@ class GroupLinkPlan_noRelays(TypedDict): type: Literal["noRelays"] groupSLinkData_: NotRequired["GroupShortLinkData"] +class GroupLinkPlan_updateRequired(TypedDict): + type: Literal["updateRequired"] + groupSLinkData_: NotRequired["GroupShortLinkData"] + GroupLinkPlan = ( GroupLinkPlan_ok | GroupLinkPlan_ownLink @@ -1861,9 +1865,10 @@ GroupLinkPlan = ( | GroupLinkPlan_connectingProhibit | GroupLinkPlan_known | GroupLinkPlan_noRelays + | GroupLinkPlan_updateRequired ) -GroupLinkPlan_Tag = Literal["ok", "ownLink", "connectingConfirmReconnect", "connectingProhibit", "known", "noRelays"] +GroupLinkPlan_Tag = Literal["ok", "ownLink", "connectingConfirmReconnect", "connectingProhibit", "known", "noRelays", "updateRequired"] class GroupMember(TypedDict): groupMemberId: int # int64 diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index fa2d0af009..fe5b67f041 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -1051,6 +1051,7 @@ data GroupLinkPlan | GLPConnectingProhibit {groupInfo_ :: Maybe GroupInfo} | GLPKnown {groupInfo :: GroupInfo, groupUpdated :: BoolDef, ownerVerification :: Maybe OwnerVerification, linkOwners :: ListDef GroupLinkOwner} | GLPNoRelays {groupSLinkData_ :: Maybe GroupShortLinkData} + | GLPUpdateRequired {groupSLinkData_ :: Maybe GroupShortLinkData} deriving (Show) data GroupLinkOwner = GroupLinkOwner @@ -1096,6 +1097,7 @@ connectionPlanProceed = \case GLPOwnLink _ -> True GLPConnectingConfirmReconnect -> True GLPNoRelays _ -> False + GLPUpdateRequired _ -> False _ -> False CPError _ -> True diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index bb31ee26a5..8d9d882366 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -4120,21 +4120,25 @@ processChatCommand vr nm = \case Nothing -> do (fd, cData@(ContactLinkData _ UserContactData {direct, owners, relays})) <- getShortLinkConnReq' nm user l' groupSLinkData_ <- liftIO $ decodeLinkUserData cData - if not direct && null relays - then pure (con (linkConnReq fd), CPGroupLink (GLPNoRelays groupSLinkData_)) - else do - let FixedLinkData {linkConnReq = cReq, linkEntityId, rootKey} = fd - linkInfo = GroupShortLinkInfo {direct, groupRelays = relays, publicGroupId = B64UrlByteString <$> linkEntityId} - let profilePGId = groupSLinkData_ >>= \GroupShortLinkData {groupProfile = GroupProfile {publicGroup}} -> - fmap (\PublicGroupProfile {publicGroupId} -> publicGroupId) publicGroup - case (B64UrlByteString <$> linkEntityId, profilePGId) of - (Just entityId, Just publicGroupId) | entityId == publicGroupId -> pure () - (Nothing, Nothing) -> pure () - _ -> throwChatError CEInvalidConnReq - let ov = verifyLinkOwner rootKey owners l' sig_ - plan <- groupJoinRequestPlan user cReq (Just linkInfo) groupSLinkData_ ov - pure (con cReq, plan) + if + | not direct && unsupportedGroupType groupSLinkData_ -> pure (con (linkConnReq fd), CPGroupLink (GLPUpdateRequired groupSLinkData_)) + | not direct && null relays -> pure (con (linkConnReq fd), CPGroupLink (GLPNoRelays groupSLinkData_)) + | otherwise -> do + let FixedLinkData {linkConnReq = cReq, linkEntityId, rootKey} = fd + linkInfo = GroupShortLinkInfo {direct, groupRelays = relays, publicGroupId = B64UrlByteString <$> linkEntityId} + let profilePGId = groupSLinkData_ >>= \GroupShortLinkData {groupProfile = GroupProfile {publicGroup}} -> + fmap (\PublicGroupProfile {publicGroupId} -> publicGroupId) publicGroup + case (B64UrlByteString <$> linkEntityId, profilePGId) of + (Just entityId, Just publicGroupId) | entityId == publicGroupId -> pure () + (Nothing, Nothing) -> pure () + _ -> throwChatError CEInvalidConnReq + let ov = verifyLinkOwner rootKey owners l' sig_ + plan <- groupJoinRequestPlan user cReq (Just linkInfo) groupSLinkData_ ov + pure (con cReq, plan) where + unsupportedGroupType = \case + Just GroupShortLinkData {groupProfile = GroupProfile {publicGroup = Just PublicGroupProfile {groupType}}} -> groupType /= GTChannel + _ -> False knownLinkPlans = withFastStore $ \db -> liftIO (getGroupInfoViaUserShortLink db vr user l') >>= \case Just (cReq, g) -> pure $ Just (con cReq, CPGroupLink (GLPOwnLink g)) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 477850d4b0..838d15245a 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -2138,6 +2138,7 @@ viewConnectionPlan ChatConfig {logLevel, testView} _connLink = \case ] knownGroup prepared = grpOrBizLink g <> ": known " <> prepared <> grpOrBiz g <> " " <> ttyGroup' g GLPNoRelays _ -> [grpLink "channel has no active relays, please try to join later"] + GLPUpdateRequired _ -> [grpLink "this group requires a newer version of the app, please upgrade"] where connecting g = [grpOrBizLink g <> ": connecting to " <> grpOrBiz g <> " " <> ttyGroup' g] grpLink = ("group link: " <>) 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 15/66] 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 68abd805d44f9b940af94a97bdcec840c77aa0e8 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Thu, 28 May 2026 08:44:43 +0100 Subject: [PATCH 16/66] rfc: namespace (#7001) * rfc: namespace * update rfc * markdown for names * record type, app "upgrade" alerts * update api types * rfc: change namespace syntax - now it is the usual namespace * update bot types * move types to simplexmq * core: refactore markdown * update simplexmq * better names * new names * update nix content hashes * fix * change valid name function * update simplexq, update valid name conditions * fixes Co-authored-by: simplex-chat-agent[bot] <287173099+simplex-chat-agent[bot]@users.noreply.github.com> * update simplexmq * fix localization * simpler * refactor * refactor * fix --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> Co-authored-by: simplex-chat-agent[bot] <287173099+simplex-chat-agent[bot]@users.noreply.github.com> --- .../Views/Chat/ChatItem/MsgContentView.swift | 11 +- .../Shared/Views/ChatList/ChatListView.swift | 17 +- .../Views/NewChat/NewChatMenuButton.swift | 17 +- .../Shared/Views/NewChat/NewChatView.swift | 69 +++-- apps/ios/SimpleXChat/ChatTypes.swift | 19 ++ .../chat/simplex/common/model/ChatModel.kt | 23 ++ .../common/views/chat/item/TextItemView.kt | 19 +- .../common/views/chatlist/ChatListView.kt | 42 ++- .../common/views/newchat/ConnectPlan.kt | 32 ++- .../common/views/newchat/NewChatSheet.kt | 48 ++-- .../common/views/newchat/NewChatView.kt | 45 +++- .../commonMain/resources/MR/base/strings.xml | 5 + bots/api/TYPES.md | 37 +++ bots/src/API/Docs/Types.hs | 6 + cabal.project | 2 +- docs/rfcs/2026-05-21-public-namespaces.md | 246 ++++++++++++++++++ .../types/typescript/src/types.ts | 25 ++ .../src/simplex_chat/types/_types.py | 17 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Library/Commands.hs | 28 +- src/Simplex/Chat/Markdown.hs | 42 ++- .../SQLite/Migrations/chat_query_plans.txt | 13 + tests/MarkdownTests.hs | 45 +++- tests/ValidNames.hs | 49 ++-- 24 files changed, 703 insertions(+), 156 deletions(-) create mode 100644 docs/rfcs/2026-05-21-public-namespaces.md diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index 2f4338c0af..9aaff57cc5 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -208,7 +208,9 @@ private func handleTextTaps( var browser: Bool = false s.enumerateAttributes(in: NSRange(location: 0, length: s.length)) { attrs, range, stop in if index >= range.location && index < range.location + range.length { - if let url = attrs[linkAttrKey] as? String { + if let nameInfo = attrs[nameAttrKey] as? SimplexNameInfo { + showUnsupportedNameAlert(nameInfo) + } else if let url = attrs[linkAttrKey] as? String { linkURL = url browser = attrs[webLinkAttrKey] != nil } else if let showSecrets, let i = attrs[secretAttrKey] as? Int { @@ -251,6 +253,7 @@ private let webLinkAttrKey = NSAttributedString.Key("chat.simplex.app.webLink") private let secretAttrKey = NSAttributedString.Key("chat.simplex.app.secret") private let commandAttrKey = NSAttributedString.Key("chat.simplex.app.command") +private let nameAttrKey = NSAttributedString.Key("chat.simplex.app.name") typealias MsgTextResult = (string: NSMutableAttributedString, hasSecrets: Bool, handleTaps: Bool) @@ -424,6 +427,12 @@ func messageText( t = mentionText(memberName) } } + case let .simplexName(nameInfo): + attrs = linkAttrs() + if !preview { + attrs[nameAttrKey] = nameInfo + handleTaps = true + } case .email: attrs = linkAttrs() if !preview { diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index dc4971aafa..d90149c7dd 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -675,17 +675,18 @@ struct ChatListSearchBar: View { if ignoreSearchTextChange { ignoreSearchTextChange = false } else { - if let link = strHasSingleSimplexLink(t.trimmingCharacters(in: .whitespaces)) { // if SimpleX link is pasted, show connection dialogue + switch strConnectTarget(t.trimmingCharacters(in: .whitespaces)) { + case let .link(text, _, linkText): searchFocussed = false - if case let .simplexLink(_, linkType, _, smpHosts) = link.format { - ignoreSearchTextChange = true - searchText = simplexLinkText(linkType, smpHosts) - } + ignoreSearchTextChange = true + searchText = linkText searchShowingSimplexLink = true searchChatFilteredBySimplexLink = nil - connect(link.text) - } else { - if t != "" { // if some other text is pasted, enter search mode + connect(text) + case let .name(nameInfo): + showUnsupportedNameAlert(nameInfo) + case .none: + if t != "" { searchFocussed = true } else { ConnectProgressManager.shared.cancelConnectProgress() diff --git a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift index 177f8761f4..f99b03086e 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift @@ -381,17 +381,18 @@ struct ContactsListSearchBar: View { if ignoreSearchTextChange { ignoreSearchTextChange = false } else { - if let link = strHasSingleSimplexLink(t.trimmingCharacters(in: .whitespaces)) { // if SimpleX link is pasted, show connection dialogue + switch strConnectTarget(t.trimmingCharacters(in: .whitespaces)) { + case let .link(text, _, linkText): searchFocussed = false - if case let .simplexLink(_, linkType, _, smpHosts) = link.format { - ignoreSearchTextChange = true - searchText = simplexLinkText(linkType, smpHosts) - } + ignoreSearchTextChange = true + searchText = linkText searchShowingSimplexLink = true searchChatFilteredBySimplexLink = nil - connect(link.text) - } else { - if t != "" { // if some other text is pasted, enter search mode + connect(text) + case let .name(nameInfo): + showUnsupportedNameAlert(nameInfo) + case .none: + if t != "" { searchFocussed = true } else { connectProgressManager.cancelConnectProgress() diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index f73a2f1503..4a7e50d7d2 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -663,14 +663,13 @@ private struct ConnectView: View { ZStack(alignment: .trailing) { Button { if let str = UIPasteboard.general.string { - if let link = strHasSingleSimplexLink(str.trimmingCharacters(in: .whitespaces)) { - pastedLink = link.text - // It would be good to hide it, but right now it is not clear how to release camera in CodeScanner - // https://github.com/twostraws/CodeScanner/issues/121 - // No known tricks worked (changing view ID, wrapping it in another view, etc.) - // showQRCodeScanner = false + switch strConnectTarget(str.trimmingCharacters(in: .whitespaces)) { + case let .link(text, _, _): + pastedLink = text connect(pastedLink) - } else { + case let .name(nameInfo): + showUnsupportedNameAlert(nameInfo) + case .none: alert = .newChatSomeAlert(alert: SomeAlert( alert: mkAlert(title: "Invalid link", message: "The text you pasted is not a SimpleX link."), id: "pasteLinkView: code is not a SimpleX link" @@ -866,16 +865,36 @@ func strIsSimplexLink(_ str: String) -> Bool { } } -func strHasSingleSimplexLink(_ str: String) -> FormattedText? { - if let parsedMd = parseSimpleXMarkdown(str) { - let parsedLinks = parsedMd.filter({ $0.format?.isSimplexLink ?? false }) - if parsedLinks.count == 1 { - return parsedLinks[0] - } else { - return nil - } +enum ConnectTarget { + case link(text: String, linkType: SimplexLinkType, linkText: String) + case name(SimplexNameInfo) +} + +func strConnectTarget(_ str: String) -> ConnectTarget? { + let parsedMd = parseSimpleXMarkdown(str) + let links = parsedMd?.filter { $0.format?.isSimplexLink ?? false } ?? [] + return if links.count == 1, case let .simplexLink(_, linkType, _, smpHosts) = links[0].format { + .link(text: links[0].text, linkType: linkType, linkText: simplexLinkText(linkType, smpHosts)) + } else if links.isEmpty, + case let .simplexName(nameInfo) = parsedMd?.first(where: { if case .simplexName = $0.format { true } else { false } })?.format { + .name(nameInfo) } else { - return nil + nil + } +} + +func showUnsupportedNameAlert(_ nameInfo: SimplexNameInfo) { + let upgrade = " " + NSLocalizedString("Please upgrade the app.", comment: "alert message") + if nameInfo.nameType == .contact { + showAlert( + NSLocalizedString("Unsupported contact name", comment: "alert title"), + message: NSLocalizedString("Connecting via contact name requires a newer app version.", comment: "alert message") + upgrade + ) + } else { + showAlert( + NSLocalizedString("Unsupported channel name", comment: "alert title"), + message: NSLocalizedString("Connecting via channel name requires a newer app version.", comment: "alert message") + upgrade + ) } } @@ -1295,13 +1314,21 @@ func planAndConnect( filterKnownContact: ((Contact) -> Void)? = nil, filterKnownGroup: ((GroupInfo) -> Void)? = nil ) { - if case .simplexLink(_, .relay, _, _) = strHasSingleSimplexLink(shortOrFullLink)?.format { - showAlert( - NSLocalizedString("Relay address", comment: "alert title"), - message: NSLocalizedString("This is a chat relay address, it cannot be used to connect.", comment: "alert message") - ) + switch strConnectTarget(shortOrFullLink) { + case let .name(nameInfo): + showUnsupportedNameAlert(nameInfo) cleanup?() return + case let .link(_, linkType, _): + if linkType == .relay { + showAlert( + NSLocalizedString("Relay address", comment: "alert title"), + message: NSLocalizedString("This is a chat relay address, it cannot be used to connect.", comment: "alert message") + ) + cleanup?() + return + } + case .none: break } ConnectProgressManager.shared.cancelConnectProgress() let inProgress = BoxedValue(true) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 594f90c4e4..7265038f38 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -5104,6 +5104,7 @@ public enum Format: Decodable, Equatable, Hashable { case uri case hyperLink(showText: String?, linkUri: String) case simplexLink(showText: String?, linkType: SimplexLinkType, simplexUri: String, smpHosts: [String]) + case simplexName(nameInfo: SimplexNameInfo) case command(commandStr: String) case mention(memberName: String) case email @@ -5138,6 +5139,24 @@ public enum SimplexLinkType: String, Decodable, Hashable { } } +public struct SimplexNameInfo: Decodable, Equatable, Hashable { + public var nameType: SimplexNameType + public var nameTLD: SimplexTLD + public var domain: String + public var subDomain: [String] +} + +public enum SimplexTLD: String, Decodable, Hashable { + case simplex + case testing + case web +} + +public enum SimplexNameType: String, Decodable, Hashable { + case publicGroup + case contact +} + public enum FormatColor: String, Decodable, Hashable { case red = "red" case green = "green" 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..aa4b677b8a 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 @@ -4680,6 +4680,7 @@ sealed class Format { val viaHosts: String get() = "(${String.format(generalGetString(MR.strings.simplex_link_connection), smpHosts.firstOrNull() ?: "?")})" } + @Serializable @SerialName("simplexName") class SimplexName(val nameInfo: SimplexNameInfo): Format() @Serializable @SerialName("command") class Command(val commandStr: String): Format() @Serializable @SerialName("mention") class Mention(val memberName: String): Format() @Serializable @SerialName("email") class Email: Format() @@ -4697,6 +4698,7 @@ sealed class Format { is Uri -> linkStyle is HyperLink -> linkStyle is SimplexLink -> linkStyle + is SimplexName -> linkStyle is Command -> SpanStyle(color = MaterialTheme.colors.primary, fontFamily = FontFamily.Monospace) is Mention -> SpanStyle(fontWeight = FontWeight.Medium) is Email -> linkStyle @@ -4728,6 +4730,27 @@ enum class SimplexLinkType(val linkType: String) { }) } +@Serializable +data class SimplexNameInfo( + val nameType: SimplexNameType, + val nameTLD: SimplexTLD, + val domain: String, + val subDomain: List +) + +@Serializable +enum class SimplexTLD { + @SerialName("simplex") simplex, + @SerialName("testing") testing, + @SerialName("web") web +} + +@Serializable +enum class SimplexNameType { + @SerialName("publicGroup") publicGroup, + @SerialName("contact") contact +} + @Serializable enum class FormatColor(val color: String) { red("red"), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index 3358a23e1e..c9f7d96f39 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -281,6 +281,13 @@ fun MarkdownText ( } } } + is Format.SimplexName -> { + hasLinks = true + val ftStyle = Format.linkStyle + withAnnotation(tag = "SIMPLEX_NAME", annotation = i.toString()) { + withStyle(ftStyle) { append(ft.text) } + } + } is Format.Email -> { hasLinks = true val ftStyle = Format.linkStyle @@ -329,6 +336,16 @@ fun MarkdownText ( withAnnotation("WEB_URL") { a -> openBrowserAlert(a.item, uriHandler) } withAnnotation("OTHER_URL") { a -> safeOpenUri(a.item, uriHandler) } withAnnotation("SIMPLEX_URL") { a -> uriHandler.openVerifiedSimplexUri(a.item) } + withAnnotation("SIMPLEX_NAME") { a -> + val idx = a.item.toIntOrNull() + val nameInfo = (idx?.let { formattedText.getOrNull(it) }?.format as? Format.SimplexName)?.nameInfo + val (title, msg) = if (nameInfo?.nameType == SimplexNameType.contact) { + generalGetString(MR.strings.unsupported_contact_name) to generalGetString(MR.strings.contact_name_requires_newer_app_version) + } else { + generalGetString(MR.strings.unsupported_channel_name) to generalGetString(MR.strings.channel_name_requires_newer_app_version) + } + AlertManager.shared.showAlertMsg(title, "$msg ${generalGetString(MR.strings.please_upgrade_the_app)}") + } } if (hasSecrets) { withAnnotation("SECRET") { a -> @@ -343,7 +360,7 @@ fun MarkdownText ( onHover = { offset -> val hasAnnotation: (String) -> Boolean = { tag -> annotatedText.hasStringAnnotations(tag, start = offset, end = offset) } icon.value = - if (hasAnnotation("WEB_URL") || hasAnnotation("SIMPLEX_URL") || hasAnnotation("OTHER_URL") || hasAnnotation("SECRET") || hasAnnotation("COMMAND")) { + if (hasAnnotation("WEB_URL") || hasAnnotation("SIMPLEX_URL") || hasAnnotation("OTHER_URL") || hasAnnotation("SIMPLEX_NAME") || hasAnnotation("SECRET") || hasAnnotation("COMMAND")) { PointerIcon.Hand } else { PointerIcon.Text 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..e9dec64634 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 @@ -791,31 +791,29 @@ private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState snapshotFlow { searchText.value.text } .distinctUntilChanged() .collect { - val link = strHasSingleSimplexLink(it.trim()) - if (link != null) { - // if SimpleX link is pasted, show connection dialogue - hideKeyboard(view) - if (link.format is Format.SimplexLink) { - val linkText = link.format.simplexLinkText - searchText.value = searchText.value.copy(linkText, selection = TextRange.Zero) + when (val target = strConnectTarget(it.trim())) { + is ConnectTarget.Link -> { + hideKeyboard(view) + searchText.value = searchText.value.copy(target.linkText, selection = TextRange.Zero) + searchShowingSimplexLink.value = true + searchChatFilteredBySimplexLink.value = null + connect(target.text, searchChatFilteredBySimplexLink) { searchText.value = TextFieldValue() } } - searchShowingSimplexLink.value = true - searchChatFilteredBySimplexLink.value = null - connect(link.text, searchChatFilteredBySimplexLink) { searchText.value = TextFieldValue() } - } else if (!searchShowingSimplexLink.value || it.isEmpty()) { - if (it.isNotEmpty()) { - // if some other text is pasted, enter search mode - focusRequester.requestFocus() - } else { - if (!chatModel.appOpenUrlConnecting.value) { - connectProgressManager.cancelConnectProgress() - } - if (listState.layoutInfo.totalItemsCount > 0) { - listState.scrollToItem(0) + is ConnectTarget.Name -> showUnsupportedNameAlert(target.nameInfo) + null -> if (!searchShowingSimplexLink.value || it.isEmpty()) { + if (it.isNotEmpty()) { + focusRequester.requestFocus() + } else { + if (!chatModel.appOpenUrlConnecting.value) { + connectProgressManager.cancelConnectProgress() + } + if (listState.layoutInfo.totalItemsCount > 0) { + listState.scrollToItem(0) + } } + searchShowingSimplexLink.value = false + searchChatFilteredBySimplexLink.value = null } - searchShowingSimplexLink.value = false - searchChatFilteredBySimplexLink.value = null } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt index 87cf01403c..9fd5dd5b4a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt @@ -30,14 +30,23 @@ suspend fun planAndConnect( filterKnownContact: ((Contact) -> Unit)? = null, filterKnownGroup: ((GroupInfo) -> Unit)? = null, ): CompletableDeferred { - val link = strHasSingleSimplexLink(shortOrFullLink.trim()) - if (link?.format is Format.SimplexLink && (link.format as Format.SimplexLink).linkType == SimplexLinkType.relay) { - AlertManager.privacySensitive.showAlertMsg( - generalGetString(MR.strings.relay_address_alert_title), - generalGetString(MR.strings.relay_address_alert_message), - ) - cleanup?.invoke() - return CompletableDeferred(false) + when (val target = strConnectTarget(shortOrFullLink.trim())) { + is ConnectTarget.Name -> { + showUnsupportedNameAlert(target.nameInfo) + cleanup?.invoke() + return CompletableDeferred(false) + } + is ConnectTarget.Link -> { + if (target.linkType == SimplexLinkType.relay) { + AlertManager.privacySensitive.showAlertMsg( + generalGetString(MR.strings.relay_address_alert_title), + generalGetString(MR.strings.relay_address_alert_message), + ) + cleanup?.invoke() + return CompletableDeferred(false) + } + } + null -> {} } connectProgressManager.cancelConnectProgress() val inProgress = mutableStateOf(true) @@ -73,11 +82,8 @@ private suspend fun planAndConnectTask( if (!inProgress.value) { return completable } if (result != null) { val (connectionLink, connectionPlan) = result - val link = strHasSingleSimplexLink(shortOrFullLink.trim()) - val linkText = if (link?.format is Format.SimplexLink) - "

${link.format.simplexLinkText}" - else - "" + val target = strConnectTarget(shortOrFullLink.trim()) + val linkText = if (target is ConnectTarget.Link) "

${target.linkText}" else "" when (connectionPlan) { is ConnectionPlan.InvitationLink -> when (connectionPlan.invitationLinkPlan) { is InvitationLinkPlan.Ok -> 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..6f64fe5221 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 @@ -523,34 +523,32 @@ private fun ContactsSearchBar( snapshotFlow { searchText.value.text } .distinctUntilChanged() .collect { - val link = strHasSingleSimplexLink(it.trim()) - if (link != null) { - // if SimpleX link is pasted, show connection dialogue - hideKeyboard(view) - if (link.format is Format.SimplexLink) { - val linkText = link.format.simplexLinkText - searchText.value = searchText.value.copy(linkText, selection = TextRange.Zero) + when (val target = strConnectTarget(it.trim())) { + is ConnectTarget.Link -> { + hideKeyboard(view) + searchText.value = searchText.value.copy(target.linkText, selection = TextRange.Zero) + searchShowingSimplexLink.value = true + searchChatFilteredBySimplexLink.value = null + connect( + link = target.text, + searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink, + close = close, + cleanup = { searchText.value = TextFieldValue() } + ) } - searchShowingSimplexLink.value = true - searchChatFilteredBySimplexLink.value = null - connect( - link = link.text, - searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink, - close = close, - cleanup = { searchText.value = TextFieldValue() } - ) - } else if (!searchShowingSimplexLink.value || it.isEmpty()) { - if (it.isNotEmpty()) { - // if some other text is pasted, enter search mode - focusRequester.requestFocus() - } else { - connectProgressManager.cancelConnectProgress() - if (listState.layoutInfo.totalItemsCount > 0) { - listState.scrollToItem(0) + is ConnectTarget.Name -> showUnsupportedNameAlert(target.nameInfo) + null -> if (!searchShowingSimplexLink.value || it.isEmpty()) { + if (it.isNotEmpty()) { + focusRequester.requestFocus() + } else { + connectProgressManager.cancelConnectProgress() + if (listState.layoutInfo.totalItemsCount > 0) { + listState.scrollToItem(0) + } } + searchShowingSimplexLink.value = false + searchChatFilteredBySimplexLink.value = null } - searchShowingSimplexLink.value = false - searchChatFilteredBySimplexLink.value = null } } } 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..b1ab8eb24e 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 @@ -671,13 +671,14 @@ private fun PasteLinkView(rhId: Long?, pastedLink: MutableState, showQRC val clipboard = LocalClipboardManager.current SectionItemView({ val str = clipboard.getText()?.text ?: return@SectionItemView - val link = strHasSingleSimplexLink(str.trim()) - if (link != null) { - pastedLink.value = link.text - showQRCodeScanner.value = false - withBGApi { connect(rhId, link.text, close) { pastedLink.value = "" } } - } else { - AlertManager.shared.showAlertMsg( + when (val target = strConnectTarget(str.trim())) { + is ConnectTarget.Link -> { + pastedLink.value = target.text + showQRCodeScanner.value = false + withBGApi { connect(rhId, target.text, close) { pastedLink.value = "" } } + } + is ConnectTarget.Name -> showUnsupportedNameAlert(target.nameInfo) + null -> AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.invalid_contact_link), text = generalGetString(MR.strings.the_text_you_pasted_is_not_a_link) ) @@ -819,12 +820,32 @@ fun strIsSimplexLink(str: String): Boolean { return parsedMd != null && parsedMd.size == 1 && parsedMd[0].format is Format.SimplexLink } -fun strHasSingleSimplexLink(str: String): FormattedText? { - val parsedMd = parseToMarkdown(str) ?: return null - val parsedLinks = parsedMd.filter { it.format?.isSimplexLink ?: false } - if (parsedLinks.size != 1) return null +sealed class ConnectTarget { + class Link(val text: String, val linkType: SimplexLinkType, val linkText: String) : ConnectTarget() + class Name(val nameInfo: SimplexNameInfo) : ConnectTarget() +} - return parsedLinks[0] +fun strConnectTarget(str: String): ConnectTarget? { + val parsedMd = parseToMarkdown(str) ?: return null + val links = parsedMd.filter { it.format?.isSimplexLink ?: false } + if (links.size == 1) { + val fmt = links[0].format as Format.SimplexLink + return ConnectTarget.Link(links[0].text, fmt.linkType, fmt.simplexLinkText) + } + if (links.isEmpty()) { + val nameInfo = parsedMd.firstNotNullOfOrNull { (it.format as? Format.SimplexName)?.nameInfo } + if (nameInfo != null) return ConnectTarget.Name(nameInfo) + } + return null +} + +fun showUnsupportedNameAlert(nameInfo: SimplexNameInfo) { + val (title, msg) = if (nameInfo.nameType == SimplexNameType.contact) { + generalGetString(MR.strings.unsupported_contact_name) to generalGetString(MR.strings.contact_name_requires_newer_app_version) + } else { + generalGetString(MR.strings.unsupported_channel_name) to generalGetString(MR.strings.channel_name_requires_newer_app_version) + } + AlertManager.shared.showAlertMsg(title, "$msg ${generalGetString(MR.strings.please_upgrade_the_app)}") } @Composable 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 5a0bc77ccf..cd0508f95a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -194,6 +194,11 @@ Please check that you used the correct link or ask your contact to send you another one. Unsupported connection link This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. + Unsupported channel name + Unsupported contact name + Connecting via channel name requires a newer app version. + Connecting via contact name requires a newer app version. + Please upgrade the app. Channel temporarily unavailable Channel has no active relays. Please try to join later. App update required diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 3db6dcbcfc..af89c86411 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -165,6 +165,9 @@ This file is generated automatically. - [SecurityCode](#securitycode) - [SimplePreference](#simplepreference) - [SimplexLinkType](#simplexlinktype) +- [SimplexNameInfo](#simplexnameinfo) +- [SimplexNameType](#simplexnametype) +- [SimplexTLD](#simplextld) - [SndCIStatusProgress](#sndcistatusprogress) - [SndConnEvent](#sndconnevent) - [SndError](#snderror) @@ -2091,6 +2094,10 @@ SimplexLink: - simplexUri: string - smpHosts: [string] +SimplexName: +- type: "simplexName" +- nameInfo: [SimplexNameInfo](#simplexnameinfo) + Command: - type: "command" - commandStr: string @@ -3440,6 +3447,36 @@ A_QUEUE: - "relay" +--- + +## SimplexNameInfo + +**Record type**: +- nameType: [SimplexNameType](#simplexnametype) +- nameTLD: [SimplexTLD](#simplextld) +- domain: string +- subDomain: [string] + + +--- + +## SimplexNameType + +**Enum type**: +- "publicGroup" +- "contact" + + +--- + +## SimplexTLD + +**Enum type**: +- "simplex" +- "testing" +- "web" + + --- ## SndCIStatusProgress diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index be4a55835a..0f9e198cc1 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -345,6 +345,9 @@ chatTypesDocsData = (sti @SecurityCode, STRecord, "", [], "", ""), (sti @SimplePreference, STRecord, "", [], "", ""), (sti @SimplexLinkType, STEnum, "XL", [], "", ""), + (sti @SimplexNameInfo, STRecord, "", [], "", ""), + (sti @SimplexNameType, STEnum, "NT", [], "", ""), + (sti @SimplexTLD, STEnum, "TLD", [], "", ""), (sti @SMPAgentError, STUnion, "", [], "", ""), (sti @SndCIStatusProgress, STEnum, "SSP", [], "", ""), (sti @SndConnEvent, STUnion, "SCE", [], "", ""), @@ -558,6 +561,9 @@ deriving instance Generic RelayStatus deriving instance Generic ReportReason deriving instance Generic SecurityCode deriving instance Generic SimplexLinkType +deriving instance Generic SimplexNameInfo +deriving instance Generic SimplexNameType +deriving instance Generic SimplexTLD deriving instance Generic SMPAgentError deriving instance Generic SndCIStatusProgress deriving instance Generic SndConnEvent diff --git a/cabal.project b/cabal.project index 7ee797e621..728ab790c7 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: e9265a7f7cb723d70b03e1b67af01f2666872a44 source-repository-package type: git diff --git a/docs/rfcs/2026-05-21-public-namespaces.md b/docs/rfcs/2026-05-21-public-namespaces.md new file mode 100644 index 0000000000..9f968945f3 --- /dev/null +++ b/docs/rfcs/2026-05-21-public-namespaces.md @@ -0,0 +1,246 @@ +# Public Namespaces for SimpleX Network + +## Motivation + +SimpleX has no user identifiers - users exchange invitation links out-of-band to connect. Short links help but are unmemorable. Public namespaces map human-readable names to SimpleX addresses. + +Names also solve censorship at two levels. A short link is controlled by one SMP router - that router can delete it. An on-chain name can't be deleted by any router. If the link is removed, the owner points the name to a new link on a different router. At the network level, links can be URL-filtered, but names resolve through SMP proxy chains - censoring a name requires controlling all resolvers the user can reach. + +DNS-based naming is vulnerable to domain seizure and requires WHOIS entries. Blockchains provide censorship-resistant globally unique names. + +## Product requirements + +### MVP + +- **Names**: TLD `.simplex` (e.g., `privacy.simplex`, `my-channel.simplex`). Subdomains: `support.acme.simplex`. In markdown, `.simplex` can be omitted: `#privacy` = `privacy.simplex`. +- **Name rules**: see [Name rules](#name-rules). +- **Two address types**: each name stores channel links (set) and contact links (set). Client uses the first; set provides forward-compatible redundancy. Either can be empty. +- **Optional metadata**: admin SimpleX address, admin email. +- **Registration**: commit-reveal to prevent frontrunning. Length-based ETH pricing. Annual renewal. Dutch auction on expiry. +- **Launch gating**: requires SimpleX test NFT. Up to 5 paid + 5 test names per holder. Test names free, auto-removed after 3 months, use `testing` namespace. +- **Reserved names**: common verticals (books, games, music, movies, news, etc.) reserved for community-operated channels managed by SimpleX Network Consortium. +- Only 7+ character names can be registered during "launch phase". +- **Resolution**: client queries two independent name servers (Ethereum light clients) via two SMP proxies. Agreement = trusted. Disagreement = warning. +- **Double resolution**: name -> short link (on-chain), short link -> connection data (existing protocol). +- **Verification**: if on-chain link matches profile address, name is verified. Manual "verify" button + optional auto-verify on profile open. +- **Markdown**: `#name` (`.simplex` implied), `#name.simplex` (explicit), `#name.testing` for test namespace. In CLI, `#` is local in group commands, global in `/c` and message bodies. +- **Search**: `#name.simplex` auto-resolves. Disable in "More privacy" settings. +- **Router role**: `names` added to `ServerRoles`. Not all routers support it. +- **Contract**: ENS fork on Ethereum mainnet. ETH payment. Upgradeable. + +### Post-MVP + +- **Multiple links**: redundant entries per name. Forward-compatible schema in MVP where practical. +- **Contact syntax**: `:name.simplex`, `:my-name.simplex`. Same namespace, different link type. MVP parser supports this syntax; resolution works; UI support is post-MVP. +- **Community Credits**: replace ETH for private registration. +- **Unicode expansion**: add scripts as user base grows. + +## Part 1: Blockchain contract + +### Overview + +ENS fork on Ethereum mainnet. Retains commit-reveal, pricing, expiry, Dutch auction. Compatible with ENS dApp. Upgradeable. + +ENS source: +- Contracts: https://github.com/ensdomains/ens-contracts +- dApp: https://github.com/ensdomains/ens-app-v3 +- JS library: https://github.com/ensdomains/ensjs + +### Contract state + +``` +Name record (ENS structure + SimpleX resolver fields): + owner : address + channelLinks : string[] + contactLinks : string[] + adminAddress : string -- optional + adminEmail : string -- optional + expiry : uint256 + isTest : bool + +Global state: + reservedNames : mapping(string => bool) + testNFT : address + registrationLimit : uint8 -- 5 + testLimit : uint8 -- 5 +``` + +There must be maps to track names by owner, but specific contract design should be based on ENS. + +### Name rules + +ENS normalization (ENSIP-15) with additional restrictions enforced in dApp (registration) and resolvers (resolution). Contract follows ENS as-is. + +Additional restrictions beyond ENSIP-15: +- No consecutive hyphens. +- No accented characters. Latin is `a-z` only (same as DNS LDH rule). +- Allowed scripts: Latin, Cyrillic, Arabic, Hebrew, Devanagari, Bengali, Thai, Greek, CJK, Hangul, Kana. Expandable as user base grows. + +### Registration flow + +1. NFT check +2. Limit check (5 paid / 5 test) +3. `commit(hash(name, owner, secret))` +4. Wait (min 1 minute) +5. `reveal(name, owner, secret)` + ETH (zero for test) +6. Validate: well-formed, not taken, not reserved, fee covered +7. Store record + +### Pricing + +Annual fees by name length: + +| Length | Fee | +|---|---| +| 7+ | base | +| 6 | 4x | +| 5 | 16x | +| 4 | 64x | +| 3 | 256x | + +Test names: free, expire after 3 months. + +### Renewal and expiry + +Annual renewal. Grace period, then Dutch auction decaying to base price. + +### Updates + +Owner can update links, admin address, admin email. Transfer follows ENS mechanics. + +### Reserved names + +List for community channels (e.g., `books`, `games`, `music`, `news`): +- Not registrable by users +- Revenue shared with network + +### Retained ENS features + +- **Resolver pattern**: registry maps name -> (owner, resolver). A SimpleX Resolver contract stores channel links, contact links, admin fields. Allows future extensibility without registry changes. +- **Multicoin address records**: BTC/ETH/XMR donation addresses per name. Subscribers see donation options from name resolution. +- **Text records**: generic key-value store for future metadata without contract upgrades. +- **Reverse resolution**: name lookup by address. Enables verification and discovery. +- **Subdomain registrar**: owner of `acme.simplex` can create `support.acme.simplex`, `sales.acme.simplex` without additional on-chain registration. + +### Removed ENS features + +- Avatar/image records. +- `.eth` TLD and ENS name imports. +- DNS name registration (DNSSEC imports). + +### Governance + +SimpleX Chat during testing and launch phases, migration to SimpleX Network Consortium. + +## Part 2: SMP protocol extension + +### New router role + +```haskell +data ServerRoles = ServerRoles + { storage :: Bool, + proxy :: Bool, + names :: Bool + } +``` + +Name-capable routers run an Ethereum light client. + +### Resolution protocol + +Uses existing SMP proxy infrastructure. Client sends queries through a proxy, not directly to name servers. + +#### Commands + +``` +Client -> Proxy -> Name Server: + RSLV + +Name Server -> Proxy -> Client: + NAME + ERR AUTH +``` + +Forwarded via `PRXY`/`PFWD`/`RRES` mechanism. + +#### Two-operator resolution + +``` +Client -> Proxy A (Op 1) -> Name Server X (Op 1) +Client -> Proxy B (Op 2) -> Name Server Y (Op 2) +``` + +Both read same Ethereum state. + +- Agree: trusted +- Disagree: warn, don't use +- One fails: retry with another server or show single result with reduced trust + +Proxy sees client IP and session, but not query. Name server sees query, not client IP or session. + +#### Name server implementation + +1. Runs Ethereum light client (e.g., Helios) tracking SNRC +2. Receives `RSLV` via SMP proxy +3. Returns record from local state + +State proofs can be added post-MVP. + +#### Configuration + +```haskell +data NamesConfig = NamesConfig + { ethereumEndpoint :: String, + snrcAddress :: EthAddress, + cacheSeconds :: Int + } +``` + +#### Versioning + +New SMP protocol version. Older routers/clients don't advertise the capability. + +### Default routers + +Default router list updated to include name-capable routers. + +## Part 3: UI integration + +### Markdown + +- `#name` or `#name.simplex` - native names (no dot = `.simplex` implied) +- `#my-name` or `#my-name.simplex` - hyphenated names +- `#sub.name.simplex` - subdomains (explicit TLD) +- `#name.testing` - test namespace +- Rendered as clickable resolve-and-connect links + +CLI: `#` = local in group commands, global in `/c` and messages. + +`:name.simplex`, `:my-name.simplex` - contact addresses (same namespace, different link type). MVP parser supports this syntax; resolution works; UI support is post-MVP. + +### Resolution flow + +1. Normalize per ENSIP-15, compute namehash +2. `RSLV` to two name servers via two proxies +3. Compare results +4. First channel link -> short link resolution -> connection data +5. Present for joining + +### Search + +`#...simplex` triggers resolution. Disable in "More privacy" settings. + +### Verification + +On-chain link matches profile address = verified. Only name owner can set on-chain links. + +- Manual: "Verify" button resolves and compares +- Auto: optional setting, resolves on profile open + +### Display + +Show name and verification status. `#` is syntax, not part of the name. + +## Open questions + +1. **Contract upgrade mechanism**: proxy pattern with timelock? Migration path for future Community Credits payment and domain name support. diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 44949611b2..de86d1e790 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -2358,6 +2358,7 @@ export type Format = | Format.Uri | Format.HyperLink | Format.SimplexLink + | Format.SimplexName | Format.Command | Format.Mention | Format.Email @@ -2375,6 +2376,7 @@ export namespace Format { | "uri" | "hyperLink" | "simplexLink" + | "simplexName" | "command" | "mention" | "email" @@ -2431,6 +2433,11 @@ export namespace Format { smpHosts: string[] // non-empty } + export interface SimplexName extends Interface { + type: "simplexName" + nameInfo: SimplexNameInfo + } + export interface Command extends Interface { type: "command" commandStr: string @@ -3848,6 +3855,24 @@ export enum SimplexLinkType { Relay = "relay", } +export interface SimplexNameInfo { + nameType: SimplexNameType + nameTLD: SimplexTLD + domain: string + subDomain: string[] +} + +export enum SimplexNameType { + PublicGroup = "publicGroup", + Contact = "contact", +} + +export enum SimplexTLD { + Simplex = "simplex", + Testing = "testing", + Web = "web", +} + export enum SndCIStatusProgress { Partial = "partial", Complete = "complete", 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 409a187245..3bbf82d350 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -1687,6 +1687,10 @@ class Format_simplexLink(TypedDict): simplexUri: str smpHosts: list[str] # non-empty +class Format_simplexName(TypedDict): + type: Literal["simplexName"] + nameInfo: "SimplexNameInfo" + class Format_command(TypedDict): type: Literal["command"] commandStr: str @@ -1712,13 +1716,14 @@ Format = ( | Format_uri | Format_hyperLink | Format_simplexLink + | Format_simplexName | Format_command | Format_mention | Format_email | Format_phone ) -Format_Tag = Literal["bold", "italic", "strikeThrough", "snippet", "secret", "small", "colored", "uri", "hyperLink", "simplexLink", "command", "mention", "email", "phone"] +Format_Tag = Literal["bold", "italic", "strikeThrough", "snippet", "secret", "small", "colored", "uri", "hyperLink", "simplexLink", "simplexName", "command", "mention", "email", "phone"] class FormattedText(TypedDict): format: NotRequired["Format"] @@ -2687,6 +2692,16 @@ class SimplePreference(TypedDict): SimplexLinkType = Literal["contact", "invitation", "group", "channel", "relay"] +class SimplexNameInfo(TypedDict): + nameType: "SimplexNameType" + nameTLD: "SimplexTLD" + domain: str + subDomain: list[str] + +SimplexNameType = Literal["publicGroup", "contact"] + +SimplexTLD = Literal["simplex", "testing", "web"] + SndCIStatusProgress = Literal["partial", "complete"] class SndConnEvent_switchQueue(TypedDict): diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 8a91d35f05..0832fecb09 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"."e9265a7f7cb723d70b03e1b67af01f2666872a44" = "00xyzc5advpka2d2mq11f02cmcr7fa7n6mjj53symspdpx1pgfa5"; "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/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 8d9d882366..43e31c8eef 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -5547,17 +5547,25 @@ mkValidName :: String -> String mkValidName = dropWhileEnd isSpace . take 50 . reverse . fst3 . foldl' addChar ("", '\NUL', 0 :: Int) where fst3 (x, _, _) = x - addChar (r, prev, punct) c = if validChar then (c' : r, c', punct') else (r, prev, punct) + addChar (r, prev, punct) c' = if validChar then (c : r, c, punct') else (r, prev, punct) where - c' = if isSpace c then ' ' else c + c = if isSpace c' then ' ' else c' + cat = generalCategory c + isPunct = case cat of + ConnectorPunctuation -> True + DashPunctuation -> True + OtherPunctuation -> True + _ -> False punct' - | isPunctuation c = punct + 1 - | isSpace c = punct + | isPunct = punct + 1 + | c == ' ' = punct | otherwise = 0 validChar - | c == '\'' = False - | prev == '\NUL' = c > ' ' && c /= '#' && c /= '@' && validFirstChar - | isSpace prev = validFirstChar || (punct == 0 && isPunctuation c) - | isPunctuation prev = validFirstChar || isSpace c || (punct < 3 && isPunctuation c) - | otherwise = validFirstChar || isSpace c || isMark c || isPunctuation c - validFirstChar = isLetter c || isNumber c || isSymbol c + | c `elem` prohibited = False + | prev == '\NUL' = c > ' ' && validFirstNameChar + | prev == ' ' = validFirstChar || (punct == 0 && isPunct) + | punct > 0 = validFirstChar || c == ' ' + | otherwise = validFirstChar || c == ' ' || isMark c || isPunct + validFirstNameChar = isLetter c || cat == DecimalNumber || cat == OtherSymbol + validFirstChar = validFirstNameChar || cat == CurrencySymbol || cat == MathSymbol + prohibited = ".,;/\\#@'\"`~" :: String diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index 9325de41eb..9507375527 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -35,11 +35,11 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Simplex.Chat.Types -import Simplex.Messaging.Agent.Protocol (AConnectionLink (..), ConnReqUriData (..), ConnShortLink (..), ConnectionLink (..), ConnectionRequestUri (..), ContactConnType (..), SMPQueue (..), simplexConnReqUri, simplexShortLink) +import Simplex.Messaging.Agent.Protocol (AConnectionLink (..), ConnReqUriData (..), ConnShortLink (..), ConnectionLink (..), ConnectionRequestUri (..), ContactConnType (..), SMPQueue (..), SimplexNameInfo (..), simplexConnReqUri, simplexShortLink) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fstToLower, sumTypeJSON) import Simplex.Messaging.Protocol (ProtocolServer (..)) -import Simplex.Messaging.Util (decodeJSON, safeDecodeUtf8, tshow) +import Simplex.Messaging.Util (decodeJSON, safeDecodeUtf8, tshow, (<$?>)) import System.Console.ANSI.Types import qualified Text.Email.Validate as Email import qualified URI.ByteString as U @@ -59,6 +59,7 @@ data Format -- showText is Nothing for the usual Uri without text | HyperLink {showText :: Maybe Text, linkUri :: Text} | SimplexLink {showText :: Maybe Text, linkType :: SimplexLinkType, simplexUri :: AConnectionLink, smpHosts :: NonEmpty Text} + | SimplexName {nameInfo :: SimplexNameInfo} | Command {commandStr :: Text} | Mention {memberName :: Text} | Email @@ -184,6 +185,7 @@ isLink = \case Uri -> True HyperLink {} -> True SimplexLink {} -> True + SimplexName {} -> True _ -> False hasLinks :: MarkdownList -> Bool @@ -202,9 +204,9 @@ markdownP = mconcat <$> A.many' fragmentP '_' -> formattedP '_' Italic '~' -> formattedP '~' StrikeThrough '`' -> formattedP '`' Snippet - '#' -> A.char '#' *> secretP + '#' -> A.char '#' *> (secretP <|> nameRefP '#' <|> secretFallback) '!' -> styledP <|> wordP - '@' -> mentionP <|> wordP + '@' -> (A.char '@' *> nameRefP '@') <|> mentionP <|> wordP '/' -> commandP <|> wordP '[' -> sowLinkP <|> wordP _ @@ -221,14 +223,29 @@ markdownP = mconcat <$> A.many' fragmentP unmarked $ c `T.cons` s `T.snoc` c | otherwise = markdown f s secretP :: Parser Markdown - secretP = secret <$> A.takeWhile (== '#') <*> A.takeTill (== '#') <*> A.takeWhile (== '#') - secret :: Text -> Text -> Text -> Markdown - secret b s a - | T.null a || T.null s || T.head s == ' ' || T.last s == ' ' = - unmarked $ '#' `T.cons` ss - | otherwise = markdown Secret $ T.init ss + secretP = secret <$?> ((,,) <$> A.takeWhile (== '#') <*> A.takeTill (== '#') <*> A.takeWhile1 (== '#')) + secret :: (Text, Text, Text) -> Either String Markdown + secret (b, s, a) + | T.null s || T.head s == ' ' || T.last s == ' ' = Left "not secret" + | otherwise = Right $ markdown Secret $ T.init ss where ss = b <> s <> a + secretFallback :: Parser Markdown + secretFallback = unmarked . ('#' `T.cons`) <$> A.takeTill (== ' ') + nameRefP :: Char -> Parser Markdown + nameRefP pfx = nameRef <$?> A.takeTill (== ' ') + where + nameRef word + | pfx == '@' && T.all (/= '.') name = Left "not a name" + | otherwise = mkMd <$> strDecode (encodeUtf8 full) + where + (name, punct) = splitPunctuation word + full = pfx `T.cons` name + mkMd ni + | T.null punct = md' + | otherwise = md' :|: unmarked punct + where + md' = markdown (SimplexName ni) full styledP :: Parser Markdown styledP = do f <- A.char '!' *> ((A.char '-' $> Small) <|> (colored <$> colorP)) <* A.space @@ -449,6 +466,7 @@ markdownText (FormattedText f_ t) = case f_ of Uri -> t HyperLink {} -> t SimplexLink {} -> t + SimplexName {} -> t Mention _ -> t Command _ -> t Email -> t @@ -479,7 +497,6 @@ displayNameTextP_ = (,"") <$> quoted '\'' <|> splitPunctuation <$> takeNameTill takeNameTill p = A.peekChar' >>= \c -> if refChar c then A.takeTill p else fail "invalid first character in display name" - splitPunctuation s = (T.dropWhileEnd isPunctuation s, T.takeWhileEnd isPunctuation s) quoted c = A.char c *> takeNameTill (== c) <* A.char c refChar c = c > ' ' && c /= '#' && c /= '@' && c /= '\'' @@ -490,6 +507,9 @@ commandTextP = do (keyword : _) | T.all (\c -> isAlpha c || isDigit c || c == '_') keyword -> pure (cmd, punct) _ -> fail "invalid command keyword" +splitPunctuation :: Text -> (Text, Text) +splitPunctuation s = (T.dropWhileEnd isPunctuation s, T.takeWhileEnd isPunctuation s) + -- quotes names that contain spaces or end on punctuation viewName :: Text -> Text viewName s = if T.any isSpace s || maybe False (isPunctuation . snd) (T.unsnoc s) then "'" <> s <> "'" else s 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..a7880799db 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -3969,6 +3969,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 = ? @@ -6870,6 +6879,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=?) diff --git a/tests/MarkdownTests.hs b/tests/MarkdownTests.hs index a82e18f988..1db400c62a 100644 --- a/tests/MarkdownTests.hs +++ b/tests/MarkdownTests.hs @@ -10,6 +10,7 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Simplex.Chat.Markdown +import Simplex.Messaging.Agent.Protocol (SimplexNameInfo (..), SimplexNameType (..), SimplexTLD (..)) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Util ((<$$>)) import System.Console.ANSI.Types @@ -28,6 +29,7 @@ markdownTests = do textWithPhone textWithMentions textWithCommands + textWithSimplexNames multilineMarkdownList testSanitizeUri @@ -117,7 +119,7 @@ secretText = describe "secret text" do "this is # unformatted # text" <==> "this is # unformatted # text" "this is #unformatted # text" - <==> "this is #unformatted # text" + <==> "this is " <> sname NTPublicGroup TLDSimplex "unformatted" [] "unformatted" <> " # text" "this is # unformatted# text" <==> "this is # unformatted# text" "this is ## unformatted ## text" @@ -125,9 +127,9 @@ secretText = describe "secret text" do "this is#unformatted# text" <==> "this is#unformatted# text" "this is #unformatted text" - <==> "this is #unformatted text" + <==> "this is " <> sname NTPublicGroup TLDSimplex "unformatted" [] "unformatted" <> " text" "*this* is #unformatted text" - <==> bold "this" <> " is #unformatted text" + <==> bold "this" <> " is " <> sname NTPublicGroup TLDSimplex "unformatted" [] "unformatted" <> " text" it "ignored internal markdown" do "snippet: `this is #secret_text#`" <==> "snippet: " <> markdown Snippet "this is #secret_text#" @@ -297,8 +299,8 @@ textWithEmail = describe "text with Email" do "test chat@simplex.chat." <==> "test " <> email "chat@simplex.chat" <> "." "test chat@simplex.chat..." <==> "test " <> email "chat@simplex.chat" <> "..." it "ignored as email markdown" do - "chat @simplex.chat" <==> "chat " <> mention "simplex.chat" "@simplex.chat" - "this is chat @simplex.chat" <==> "this is chat " <> mention "simplex.chat" "@simplex.chat" + "chat @simplex.chat" <==> "chat " <> sname NTContact TLDWeb "simplex.chat" [] "simplex.chat" + "this is chat @simplex.chat" <==> "this is chat " <> sname NTContact TLDWeb "simplex.chat" [] "simplex.chat" "this is chat@ simplex.chat" <==> "this is chat@ " <> uri "simplex.chat" "this is chat @ simplex.chat" <==> "this is chat @ " <> uri "simplex.chat" "*this* is chat @ simplex.chat" <==> bold "this" <> " is chat @ " <> uri "simplex.chat" @@ -378,6 +380,39 @@ uri' = FormattedText $ Just Uri command' :: Text -> Text -> FormattedText command' = FormattedText . Just . Command +sname :: SimplexNameType -> SimplexTLD -> Text -> [Text] -> Text -> Markdown +sname nt ns dom sub txt = markdown (SimplexName $ SimplexNameInfo nt ns dom sub) (pfx <> txt) + where + pfx = case nt of NTPublicGroup -> "#"; NTContact -> "@" + +textWithSimplexNames :: Spec +textWithSimplexNames = describe "text with SimpleX names" do + it "channel names - simplex namespace" do + "#privacy" <==> sname NTPublicGroup TLDSimplex "privacy" [] "privacy" + "#privacy.simplex" <==> sname NTPublicGroup TLDSimplex "privacy" [] "privacy.simplex" + "#my-channel.simplex" <==> sname NTPublicGroup TLDSimplex "my-channel" [] "my-channel.simplex" + "hello #privacy!" <==> "hello " <> sname NTPublicGroup TLDSimplex "privacy" [] "privacy" <> "!" + "see #privacy.simplex now" <==> "see " <> sname NTPublicGroup TLDSimplex "privacy" [] "privacy.simplex" <> " now" + "#123" <==> sname NTPublicGroup TLDSimplex "123" [] "123" + it "channel names - subdomains" do + "#support.acme.simplex" <==> sname NTPublicGroup TLDSimplex "acme" ["support"] "support.acme.simplex" + "#a.b.acme.simplex" <==> sname NTPublicGroup TLDSimplex "acme" ["b", "a"] "a.b.acme.simplex" + it "channel names - testing namespace" do + "#test.testing" <==> sname NTPublicGroup TLDTesting "test" [] "test.testing" + "#sub.test.testing" <==> sname NTPublicGroup TLDTesting "test" ["sub"] "sub.test.testing" + it "channel names - web domains" do + "#example.com" <==> sname NTPublicGroup TLDWeb "example.com" [] "example.com" + "#news.bbc.co.uk" <==> sname NTPublicGroup TLDWeb "news.bbc.co.uk" [] "news.bbc.co.uk" + "#123.com" <==> sname NTPublicGroup TLDWeb "123.com" [] "123.com" + it "contact names" do + "@privacy.simplex" <==> sname NTContact TLDSimplex "privacy" [] "privacy.simplex" + "@my-name.simplex" <==> sname NTContact TLDSimplex "my-name" [] "my-name.simplex" + "@alice.example.com" <==> sname NTContact TLDWeb "alice.example.com" [] "alice.example.com" + it "not parsed as names" do + "#secret#" <==> markdown Secret "secret" + "##double secret##" <==> markdown Secret "#double secret#" + "#" <==> "#" + multilineMarkdownList :: Spec multilineMarkdownList = describe "multiline markdown" do it "correct markdown" do diff --git a/tests/ValidNames.hs b/tests/ValidNames.hs index 22ac4a695d..dd8433d231 100644 --- a/tests/ValidNames.hs +++ b/tests/ValidNames.hs @@ -10,15 +10,17 @@ validNameTests = describe "valid chat names" $ do testMkValidName :: IO () testMkValidName = do mkValidName "alice" `shouldBe` "alice" + mkValidName " alice" `shouldBe` "alice" + mkValidName "?alice" `shouldBe` "alice" mkValidName "алиса" `shouldBe` "алиса" mkValidName "John Doe" `shouldBe` "John Doe" - mkValidName "J.Doe" `shouldBe` "J.Doe" - mkValidName "J. Doe" `shouldBe` "J. Doe" - mkValidName "J..Doe" `shouldBe` "J..Doe" - mkValidName "J ..Doe" `shouldBe` "J ..Doe" - mkValidName "J ... Doe" `shouldBe` "J ... Doe" - mkValidName "J .... Doe" `shouldBe` "J ... Doe" - mkValidName "J . . Doe" `shouldBe` "J . Doe" + mkValidName "J.Doe" `shouldBe` "JDoe" + mkValidName "J. Doe" `shouldBe` "J Doe" + mkValidName "J..Doe" `shouldBe` "JDoe" + mkValidName "J ..Doe" `shouldBe` "J Doe" + mkValidName "J ... Doe" `shouldBe` "J Doe" + mkValidName "J .... Doe" `shouldBe` "J Doe" + mkValidName "J . . Doe" `shouldBe` "J Doe" mkValidName "@alice" `shouldBe` "alice" mkValidName "#alice" `shouldBe` "alice" mkValidName "'alice" `shouldBe` "alice" @@ -26,17 +28,32 @@ testMkValidName = do mkValidName "alice " `shouldBe` "alice" mkValidName "John Doe" `shouldBe` "John Doe" mkValidName "'John Doe'" `shouldBe` "John Doe" - mkValidName "\"John Doe\"" `shouldBe` "John Doe\"" - mkValidName "`John Doe`" `shouldBe` "`John Doe`" - mkValidName "John \"Doe\"" `shouldBe` "John \"Doe\"" - mkValidName "John `Doe`" `shouldBe` "John `Doe`" - mkValidName "alice/bob" `shouldBe` "alice/bob" - mkValidName "alice / bob" `shouldBe` "alice / bob" - mkValidName "alice /// bob" `shouldBe` "alice /// bob" - mkValidName "alice //// bob" `shouldBe` "alice /// bob" + mkValidName "\"John Doe\"" `shouldBe` "John Doe" + mkValidName "`John Doe`" `shouldBe` "John Doe" + mkValidName "John \"Doe\"" `shouldBe` "John Doe" + mkValidName "John `Doe`" `shouldBe` "John Doe" + mkValidName "alice/bob" `shouldBe` "alicebob" + mkValidName "alice / bob" `shouldBe` "alice bob" + mkValidName "alice /// bob" `shouldBe` "alice bob" + mkValidName "alice //// bob" `shouldBe` "alice bob" mkValidName "alice >>= bob" `shouldBe` "alice >>= bob" - mkValidName "alice@example.com" `shouldBe` "alice@example.com" + mkValidName "alice@example.com" `shouldBe` "aliceexamplecom" mkValidName "alice <> bob" `shouldBe` "alice <> bob" mkValidName "alice -> bob" `shouldBe` "alice -> bob" + mkValidName "alice & bob" `shouldBe` "alice & bob" + mkValidName "alice && bob" `shouldBe` "alice & bob" + mkValidName "alice & & bob" `shouldBe` "alice & bob" + mkValidName "alice-bob" `shouldBe` "alice-bob" + mkValidName "alice--bob" `shouldBe` "alice-bob" + mkValidName "alice -- bob" `shouldBe` "alice - bob" + mkValidName "alice \\ bob" `shouldBe` "alice bob" + mkValidName "alice (bob)" `shouldBe` "alice bob" + mkValidName "alice: bob" `shouldBe` "alice: bob" + mkValidName "alice 👍" `shouldBe` "alice 👍" + mkValidName "👍" `shouldBe` "👍" + mkValidName "alice >" `shouldBe` "alice >" + mkValidName "> alice" `shouldBe` "alice" + mkValidName "123" `shouldBe` "123" + mkValidName "123 alice" `shouldBe` "123 alice" mkValidName "01234567890123456789012345678901234567890123456789extra" `shouldBe` "01234567890123456789012345678901234567890123456789" mkValidName "0123456789012345678901234567890123456789012345678 extra" `shouldBe` "0123456789012345678901234567890123456789012345678" 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 17/66] 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 5aace8401ce69bce497cdd1be993d50716d453a7 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Sat, 30 May 2026 06:33:10 +0000 Subject: [PATCH 18/66] core: fix /start remote host parser when iface name contains a space (#7025) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * core: fix /start remote host parser when iface name contains a space The iface= field used jsonP (which calls takeByteString and strict-decodes the entire remaining input as JSON). When port= followed iface=, the strict decode failed on the trailing data and the text1P fallback stopped at the first space inside the JSON-quoted interface name (e.g. "Ethernet 2"), leaving unparseable junk and producing "Failed reading: empty". Replace jsonP with a bounded quotedP that consumes only up to the closing quote, leaving port=… for the next parser. * plan: document fix for /start remote host iface-with-space parser bug --- plans/2026-05-29-fix-space-in-interface.md | 13 +++++++++++++ src/Simplex/Chat/Library/Commands.hs | 3 ++- tests/RemoteTests.hs | 10 +++++++++- 3 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 plans/2026-05-29-fix-space-in-interface.md diff --git a/plans/2026-05-29-fix-space-in-interface.md b/plans/2026-05-29-fix-space-in-interface.md new file mode 100644 index 0000000000..e3d8ba7cc4 --- /dev/null +++ b/plans/2026-05-29-fix-space-in-interface.md @@ -0,0 +1,13 @@ +# Fix `/start remote host` parser when iface name contains a space + +## Problem + +Picking a non-default interface (e.g. Windows `Ethernet 2`) on the "Link a mobile" screen and refreshing the QR code returns `error chat: error chat commandError Failed reading: empty`. The desktop UI sends `/start remote host new addr=… iface="Ethernet 2" port=…`; the chat backend rejects it as an unparseable command. Without a workaround the user can't pin a specific local interface for the mobile-link controller. + +## Cause + +`rcCtrlAddressP` parses the iface value with `jsonP <|> text1P` (`src/Simplex/Chat/Library/Commands.hs:5549`). `jsonP` calls `A.takeByteString`, consuming *all* remaining input, then runs `eitherDecodeStrict'`. When `port=…` follows `iface=…` the strict decode fails because the JSON value `"Ethernet 2"` has trailing junk after it, so attoparsec backtracks to `text1P` (`takeTill (== ' ')`). `text1P` stops at the first space — inside the JSON quotes — leaving `2" port=12345` which nothing downstream can consume, `A.endOfInput` fails, the whole `A.choice` exhausts and surfaces attoparsec's `empty` message. With an iface name that has no space (`"lo"`) the bug is invisible: text1P swallows the full quoted token and the rest parses, but the interface name is stored with literal quotes so the iface preference silently never matches a real adapter anyway. + +## Fix + +Replace `jsonP` with a bounded `quotedP` that consumes only the bytes between `"…"` and leaves trailing fields for the next parser. `text1P` is kept as the unquoted fallback. Two-line change in `Commands.hs` plus a pure regression test in `tests/RemoteTests.hs` that asserts `parseChatCommand` of `/start remote host new addr=192.168.1.5 iface="Ethernet 2" port=12345` produces `RCCtrlAddress _ "Ethernet 2"` with port `12345`. diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 43e31c8eef..4bbfa8e09d 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -5526,7 +5526,8 @@ chatCommandP = addressAA = AddressSettings False <$> (Just . AutoAccept <$> (" incognito=" *> onOffP <|> pure False)) <*> autoReply businessAA = " business" *> (AddressSettings True (Just $ AutoAccept False) <$> autoReply) autoReply = optional (A.space *> msgContentP) - rcCtrlAddressP = RCCtrlAddress <$> ("addr=" *> strP) <*> (" iface=" *> (jsonP <|> text1P)) + rcCtrlAddressP = RCCtrlAddress <$> ("addr=" *> strP) <*> (" iface=" *> (quotedP <|> text1P)) + quotedP = safeDecodeUtf8 <$> (A.char '"' *> A.takeTill (== '"') <* A.char '"') text1P = safeDecodeUtf8 <$> A.takeTill (== ' ') char_ = optional . A.char diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index 51f4324bf0..e96d531805 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -16,7 +16,8 @@ import qualified Data.ByteString as B import qualified Data.ByteString.Lazy.Char8 as LB import Data.List (find, isPrefixOf) import qualified Data.Map.Strict as M -import Simplex.Chat.Controller (ChatConfig (..), versionNumber) +import Simplex.Chat.Controller (ChatCommand (..), ChatConfig (..), versionNumber) +import Simplex.Chat.Library.Commands (parseChatCommand) import qualified Simplex.Chat.Controller as Controller import Simplex.Chat.Mobile.File import Simplex.Chat.Remote (remoteFilesFolder) @@ -24,6 +25,7 @@ import Simplex.Chat.Remote.Types import Simplex.Messaging.Crypto.File (CryptoFileArgs (..)) import Simplex.Messaging.Encoding.String (strEncode) import Simplex.Messaging.Util +import Simplex.RemoteControl.Types (RCCtrlAddress (..)) import System.FilePath (()) import Test.Hspec hiding (it) import UnliftIO @@ -32,6 +34,12 @@ import UnliftIO.Directory remoteTests :: SpecWith TestParams remoteTests = describe "Remote" $ do + describe "/start remote host parser" $ do + it "parses iface name with a space followed by port=" $ \_ -> + parseChatCommand "/start remote host new addr=192.168.1.5 iface=\"Ethernet 2\" port=12345" + `shouldSatisfy` \case + Right (StartRemoteHost Nothing (Just (RCCtrlAddress _ "Ethernet 2")) (Just 12345)) -> True + _ -> False xdescribe "No compression" $ aroundWith (. ((False, False),)) runRemoteTests xdescribe "Mobile offers compression" $ aroundWith (. ((True, False),)) runRemoteTests xdescribe "Desktop offers compression" $ aroundWith (. ((False, True),)) runRemoteTests From 553f98adf4d28f2e3f4aab4e065f61e8842509f2 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Sat, 30 May 2026 08:01:16 +0000 Subject: [PATCH 19/66] desktop: don't copy non-message items when selecting message text (#6993) * desktop: don't copy non-message items when selecting message text Selecting text across messages also copied the text of event/info items (e.g. "connected") that fell inside the selection, even though those items are never highlighted as selected. getSelectedCopiedText emitted text for every merged item between the selection bounds. Event/info items have no msgContent but a non-empty text, so as interior items their text was copied. Skip items whose content has no msgContent - only real messages are copyable. * plans: add 2026-05-20-fix-copy-non-msg-items.md --- .../common/views/chat/TextSelection.kt | 3 ++ plans/2026-05-20-fix-copy-non-msg-items.md | 50 +++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 plans/2026-05-20-fix-copy-non-msg-items.md diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt index d85488cefc..db31c05dce 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt @@ -217,6 +217,9 @@ class SelectionManager { val hi = maxOf(r.startIndex, r.endIndex) return (lo..hi).mapNotNull { idx -> val ci = items.getOrNull(idx)?.newest()?.item ?: return@mapNotNull null + // Only real messages are copyable. Event/info items (e.g. "connected", calls, e2ee info) + // have no msgContent and are never highlighted as selected, so they must never be copied. + if (ci.content.msgContent == null) return@mapNotNull null if (ci.meta.itemDeleted != null && (!revealedItems.contains(ci.id) || ci.isDeletedContent)) return@mapNotNull null val sel = selectedRange(range, idx) ?: return@mapNotNull null selectedItemCopiedText(ci, sel, linkMode) diff --git a/plans/2026-05-20-fix-copy-non-msg-items.md b/plans/2026-05-20-fix-copy-non-msg-items.md new file mode 100644 index 0000000000..4f0c554357 --- /dev/null +++ b/plans/2026-05-20-fix-copy-non-msg-items.md @@ -0,0 +1,50 @@ +# Desktop: text selection copies non-message event items + +Branch: `nd/fix-copy-non-msg-items` · code commit `a536452ca` · PR [#6993](https://github.com/simplex-chat/simplex-chat/pull/6993). + +## 1. Problem statement + +The Desktop "select text in messages" feature (PR [#6725](https://github.com/simplex-chat/simplex-chat/pull/6725)) lets the user drag a selection across several message bubbles and copy it. When the selection spans a chat event/info item — a "connected" event, a member "joined"/"left" event, a call event, an e2ee-info line, a feature-change line — the copied text includes that item's text, even though the item is never shown highlighted as part of the selection. + +Expected: only real message text is copied. Observed: event/info text such as "connected" is appended into the clipboard between the selected messages. + +### Privacy note + +Event/info item text is produced from localized string resources (`generalGetString`, `RcvConnEvent.text`, etc.) — it is rendered in the language the user has chosen for the app, whereas real message text is not. A user who selects and copies a long span of messages carelessly, then pastes it into another chat or app, can therefore leak their chosen interface language through the event lines mixed into the paste. For a privacy-focused messenger this is a metadata leak, not only a cosmetic bug. + +## 2. Root cause + +`SelectionManager.getSelectedCopiedText` (`TextSelection.kt`) builds the copied string by iterating every merged-item index between the selection bounds and emitting each item's text: + +```kotlin +return (lo..hi).mapNotNull { idx -> + val ci = items.getOrNull(idx)?.newest()?.item ?: return@mapNotNull null + if (ci.meta.itemDeleted != null && ...) return@mapNotNull null + val sel = selectedRange(range, idx) ?: return@mapNotNull null + selectedItemCopiedText(ci, sel, linkMode) +} +``` + +For an *interior* item in a multi-item selection, `selectedRange` returns `0 until Int.MAX_VALUE` — the whole item is treated as selected — so its text is emitted unconditionally. The only items previously skipped were deleted ones. + +Anchor/focus character tracking (`setupItemSelection`) is wired up only for real message views (`FramedItemView`, `EmojiItemView`); event/info items never register offsets and never compute a highlight range. So an event item caught between two selected messages is invisible to the highlight but fully visible to `getSelectedCopiedText`. The copy logic and the on-screen selection disagreed. + +The distinguishing property: a real message has `ci.content.msgContent != null`; every event/info `CIContent` variant returns `msgContent == null`. + +## 3. Solution summary + +`apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt` — one guard, +3 lines. + +In `getSelectedCopiedText`, skip any item whose `content.msgContent` is null, alongside the existing deleted-item filter: + +```kotlin +if (ci.content.msgContent == null) return@mapNotNull null +``` + +Only real messages now contribute copied text — which is exactly the set of items that are selectable and highlighted, so the clipboard matches the visible selection. `content.msgContent` is the existing model property used elsewhere to tell a real message apart from an event/info item. + +## 4. Alternatives considered (and rejected) + +- **Special-case only "connected" events.** Matches the literal report but leaves the identical bug for every other event/info item (joined/left, calls, e2ee info, feature changes) — same class, same language leak. +- **Make event items non-selectable / consume the drag.** Larger change to the selection gesture; event items are already non-anchorable, and the bug is purely in the copy aggregation, not in the gesture. +- **Filter at the call site (`onCopySelection`).** Duplicates the message/non-message distinction outside the one function that owns copied-text assembly; `getSelectedCopiedText` is the correct single source. From 68fc1b5d227941677afa534b5453a9d9ec8dbe72 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Sat, 30 May 2026 08:39:14 +0000 Subject: [PATCH 20/66] core, ui: split SimplexNameDomain out of SimplexNameInfo (#7024) * core, ui: split SimplexNameDomain out of SimplexNameInfo * core: bump simplexmq to b3f28948 (SimplexNameDomain split) * core: bump simplexmq to 4e2c9fc3 (StrEncoding split) * core: bump simplexmq to ee2ff402 (#1788 squash merge) * update sha256map.nix --- apps/ios/SimpleXChat/ChatTypes.swift | 4 ++++ .../kotlin/chat/simplex/common/model/ChatModel.kt | 5 +++++ bots/api/TYPES.md | 15 ++++++++++++--- bots/src/API/Docs/Types.hs | 2 ++ cabal.project | 2 +- .../types/typescript/src/types.ts | 8 ++++++-- .../src/simplex_chat/types/_types.py | 7 +++++-- scripts/nix/sha256map.nix | 2 +- tests/MarkdownTests.hs | 4 ++-- 9 files changed, 38 insertions(+), 11 deletions(-) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 7265038f38..d2e28394a2 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -5141,6 +5141,10 @@ public enum SimplexLinkType: String, Decodable, Hashable { public struct SimplexNameInfo: Decodable, Equatable, Hashable { public var nameType: SimplexNameType + public var nameDomain: SimplexNameDomain +} + +public struct SimplexNameDomain: Decodable, Equatable, Hashable { public var nameTLD: SimplexTLD public var domain: String public var subDomain: [String] 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 aa4b677b8a..4bb7ae3d6e 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 @@ -4733,6 +4733,11 @@ enum class SimplexLinkType(val linkType: String) { @Serializable data class SimplexNameInfo( val nameType: SimplexNameType, + val nameDomain: SimplexNameDomain +) + +@Serializable +data class SimplexNameDomain( val nameTLD: SimplexTLD, val domain: String, val subDomain: List diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index af89c86411..488b7c4f05 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -165,6 +165,7 @@ This file is generated automatically. - [SecurityCode](#securitycode) - [SimplePreference](#simplepreference) - [SimplexLinkType](#simplexlinktype) +- [SimplexNameDomain](#simplexnamedomain) - [SimplexNameInfo](#simplexnameinfo) - [SimplexNameType](#simplexnametype) - [SimplexTLD](#simplextld) @@ -3447,15 +3448,23 @@ A_QUEUE: - "relay" +--- + +## SimplexNameDomain + +**Record type**: +- nameTLD: [SimplexTLD](#simplextld) +- domain: string +- subDomain: [string] + + --- ## SimplexNameInfo **Record type**: - nameType: [SimplexNameType](#simplexnametype) -- nameTLD: [SimplexTLD](#simplextld) -- domain: string -- subDomain: [string] +- nameDomain: [SimplexNameDomain](#simplexnamedomain) --- diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index 0f9e198cc1..c759a7453c 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -345,6 +345,7 @@ chatTypesDocsData = (sti @SecurityCode, STRecord, "", [], "", ""), (sti @SimplePreference, STRecord, "", [], "", ""), (sti @SimplexLinkType, STEnum, "XL", [], "", ""), + (sti @SimplexNameDomain, STRecord, "", [], "", ""), (sti @SimplexNameInfo, STRecord, "", [], "", ""), (sti @SimplexNameType, STEnum, "NT", [], "", ""), (sti @SimplexTLD, STEnum, "TLD", [], "", ""), @@ -561,6 +562,7 @@ deriving instance Generic RelayStatus deriving instance Generic ReportReason deriving instance Generic SecurityCode deriving instance Generic SimplexLinkType +deriving instance Generic SimplexNameDomain deriving instance Generic SimplexNameInfo deriving instance Generic SimplexNameType deriving instance Generic SimplexTLD diff --git a/cabal.project b/cabal.project index 728ab790c7..77e4ed838b 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: e9265a7f7cb723d70b03e1b67af01f2666872a44 + tag: ee2ff402fed4d27d31521570c910fe82e0cf116a 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 de86d1e790..6a230ecf15 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -3855,13 +3855,17 @@ export enum SimplexLinkType { Relay = "relay", } -export interface SimplexNameInfo { - nameType: SimplexNameType +export interface SimplexNameDomain { nameTLD: SimplexTLD domain: string subDomain: string[] } +export interface SimplexNameInfo { + nameType: SimplexNameType + nameDomain: SimplexNameDomain +} + export enum SimplexNameType { PublicGroup = "publicGroup", Contact = "contact", 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 3bbf82d350..c3a5e0c4fb 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -2692,12 +2692,15 @@ class SimplePreference(TypedDict): SimplexLinkType = Literal["contact", "invitation", "group", "channel", "relay"] -class SimplexNameInfo(TypedDict): - nameType: "SimplexNameType" +class SimplexNameDomain(TypedDict): nameTLD: "SimplexTLD" domain: str subDomain: list[str] +class SimplexNameInfo(TypedDict): + nameType: "SimplexNameType" + nameDomain: "SimplexNameDomain" + SimplexNameType = Literal["publicGroup", "contact"] SimplexTLD = Literal["simplex", "testing", "web"] diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 0832fecb09..f3bbf90b9f 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."e9265a7f7cb723d70b03e1b67af01f2666872a44" = "00xyzc5advpka2d2mq11f02cmcr7fa7n6mjj53symspdpx1pgfa5"; + "https://github.com/simplex-chat/simplexmq.git"."ee2ff402fed4d27d31521570c910fe82e0cf116a" = "0vka1b2bbrjg4s8j3h6732kjqjbhji0l55pzggd89ginrdjln3fg"; "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/tests/MarkdownTests.hs b/tests/MarkdownTests.hs index 1db400c62a..efa010ceb1 100644 --- a/tests/MarkdownTests.hs +++ b/tests/MarkdownTests.hs @@ -10,7 +10,7 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Simplex.Chat.Markdown -import Simplex.Messaging.Agent.Protocol (SimplexNameInfo (..), SimplexNameType (..), SimplexTLD (..)) +import Simplex.Messaging.Agent.Protocol (SimplexNameDomain (..), SimplexNameInfo (..), SimplexNameType (..), SimplexTLD (..)) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Util ((<$$>)) import System.Console.ANSI.Types @@ -381,7 +381,7 @@ command' :: Text -> Text -> FormattedText command' = FormattedText . Just . Command sname :: SimplexNameType -> SimplexTLD -> Text -> [Text] -> Text -> Markdown -sname nt ns dom sub txt = markdown (SimplexName $ SimplexNameInfo nt ns dom sub) (pfx <> txt) +sname nt ns dom sub txt = markdown (SimplexName $ SimplexNameInfo nt (SimplexNameDomain ns dom sub)) (pfx <> txt) where pfx = case nt of NTPublicGroup -> "#"; NTContact -> "@" From 16982b61114fe57af3713b4006e32d339d454e35 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sat, 30 May 2026 17:16:56 +0100 Subject: [PATCH 21/66] 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 22/66] 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 9bb2bec3fa00b3159bdea6ea63c439d7ab92c374 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 31 May 2026 17:12:12 +0100 Subject: [PATCH 23/66] plan: web previews for channels (#7022) * 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: check member role --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- apps/ios/SimpleXChat/ChatTypes.swift | 13 + .../chat/simplex/common/model/ChatModel.kt | 19 +- bots/api/TYPES.md | 23 + bots/src/API/Docs/Types.hs | 4 + .../types/typescript/src/types.ts | 13 + .../src/simplex_chat/types/_types.py | 11 + plans/2026-05-25-channel-web-preview.md | 596 ++++++++++++++++++ simplex-chat.cabal | 2 + src/Simplex/Chat/Library/Commands.hs | 6 +- src/Simplex/Chat/Library/Internal.hs | 3 +- src/Simplex/Chat/Library/Subscriber.hs | 12 +- src/Simplex/Chat/Operators.hs | 5 +- src/Simplex/Chat/Protocol.hs | 29 +- src/Simplex/Chat/Store/Connections.hs | 1 + src/Simplex/Chat/Store/Groups.hs | 41 +- src/Simplex/Chat/Store/Postgres/Migrations.hs | 4 +- .../M20260515_public_group_access.hs | 29 + .../Store/Postgres/Migrations/chat_schema.sql | 11 +- src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../M20260515_public_group_access.hs | 28 + .../SQLite/Migrations/chat_query_plans.txt | 25 +- .../Store/SQLite/Migrations/chat_schema.sql | 8 +- src/Simplex/Chat/Store/Shared.hs | 32 +- src/Simplex/Chat/Types.hs | 13 +- tests/ChatTests/Groups.hs | 6 + 25 files changed, 894 insertions(+), 44 deletions(-) create mode 100644 plans/2026-05-25-channel-web-preview.md create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations/M20260515_public_group_access.hs create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20260515_public_group_access.hs diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index d2e28394a2..7181ba2de0 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2531,10 +2531,22 @@ public enum GroupType: Codable, Hashable { } } +public struct PublicGroupAccess: Codable, Hashable { + public var groupWebPage: String? + public var groupDomain: String? + public var domainWebPage: Bool = false + public var allowEmbedding: Bool = false +} + +public struct RelayCapabilities: Codable, Hashable { + public var baseWebUrl: String? +} + public struct PublicGroupProfile: Codable, Hashable { public var groupType: GroupType public var groupLink: String public var publicGroupId: String + public var publicGroupAccess: PublicGroupAccess? } public struct GroupProfile: Codable, NamedChat, Hashable { @@ -2703,6 +2715,7 @@ public struct GroupRelay: Identifiable, Decodable, Equatable, Hashable { public var userChatRelay: UserChatRelay public var relayStatus: RelayStatus public var relayLink: String? + public var relayCap: RelayCapabilities public var id: Int64 { groupRelayId } } 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 4bb7ae3d6e..8ecfa0fd93 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 @@ -2209,11 +2209,25 @@ object GroupTypeSerializer : KSerializer { } } +@Serializable +data class PublicGroupAccess( + val groupWebPage: String? = null, + val groupDomain: String? = null, + val domainWebPage: Boolean = false, + val allowEmbedding: Boolean = false +) + +@Serializable +data class RelayCapabilities( + val baseWebUrl: String? = null +) + @Serializable data class PublicGroupProfile( val groupType: GroupType, val groupLink: String, - val publicGroupId: String + val publicGroupId: String, + val publicGroupAccess: PublicGroupAccess? = null ) @Serializable @@ -2337,7 +2351,8 @@ data class GroupRelay( val groupMemberId: Long, val userChatRelay: UserChatRelay, val relayStatus: RelayStatus, - val relayLink: String? = null + val relayLink: String? = null, + val relayCap: RelayCapabilities ) { val id: Long get() = groupRelayId } diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 488b7c4f05..4875079749 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -146,6 +146,7 @@ This file is generated automatically. - [Profile](#profile) - [ProxyClientError](#proxyclienterror) - [ProxyError](#proxyerror) +- [PublicGroupAccess](#publicgroupaccess) - [PublicGroupData](#publicgroupdata) - [PublicGroupProfile](#publicgroupprofile) - [RCErrorType](#rcerrortype) @@ -157,6 +158,7 @@ This file is generated automatically. - [RcvFileTransfer](#rcvfiletransfer) - [RcvGroupEvent](#rcvgroupevent) - [RcvMsgError](#rcvmsgerror) +- [RelayCapabilities](#relaycapabilities) - [RelayProfile](#relayprofile) - [RelayStatus](#relaystatus) - [ReportReason](#reportreason) @@ -2499,6 +2501,7 @@ UpdateRequired: - userChatRelay: [UserChatRelay](#userchatrelay) - relayStatus: [RelayStatus](#relaystatus) - relayLink: string? +- relayCap: [RelayCapabilities](#relaycapabilities) --- @@ -3068,6 +3071,17 @@ NO_SESSION: - type: "NO_SESSION" +--- + +## PublicGroupAccess + +**Record type**: +- groupWebPage: string? +- groupDomain: string? +- domainWebPage: bool +- allowEmbedding: bool + + --- ## PublicGroupData @@ -3084,6 +3098,7 @@ NO_SESSION: - groupType: [GroupType](#grouptype) - groupLink: string - publicGroupId: string +- publicGroupAccess: [PublicGroupAccess](#publicgroupaccess)? --- @@ -3341,6 +3356,14 @@ ParseError: - parseError: string +--- + +## RelayCapabilities + +**Record type**: +- baseWebUrl: string? + + --- ## RelayProfile diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index c759a7453c..8397503bbe 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -327,6 +327,7 @@ chatTypesDocsData = (sti @Profile, STRecord, "", [], "", ""), (sti @ProxyClientError, STUnion, "Proxy", [], "", ""), (sti @ProxyError, STUnion, "", [], "", ""), + (sti @PublicGroupAccess, STRecord, "", [], "", ""), (sti @PublicGroupData, STRecord, "", [], "", ""), (sti @PublicGroupProfile, STRecord, "", [], "", ""), (sti @RatchetSyncState, STEnum, "RS", [], "", ""), @@ -338,6 +339,7 @@ chatTypesDocsData = (sti @RcvFileTransfer, STRecord, "", [], "", ""), (sti @RcvGroupEvent, STUnion, "RGE", [], "", ""), (sti @RcvMsgError, STUnion, "RME", [], "", ""), + (sti @RelayCapabilities, STRecord, "", [], "", ""), (sti @RelayProfile, STRecord, "", [], "", ""), (sti @RelayStatus, STEnum, "RS", [], "", ""), (sti @ReportReason, STEnum' (dropPfxSfx "RR" ""), "", ["RRUnknown"], "", ""), @@ -546,6 +548,7 @@ deriving instance Generic PreparedGroup deriving instance Generic Profile deriving instance Generic ProxyClientError deriving instance Generic ProxyError +deriving instance Generic PublicGroupAccess deriving instance Generic PublicGroupData deriving instance Generic PublicGroupProfile deriving instance Generic RatchetSyncState @@ -557,6 +560,7 @@ deriving instance Generic RcvFileStatus deriving instance Generic RcvFileTransfer deriving instance Generic RcvGroupEvent deriving instance Generic RcvMsgError +deriving instance Generic RelayCapabilities deriving instance Generic RelayProfile deriving instance Generic RelayStatus deriving instance Generic ReportReason diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 6a230ecf15..f0cf58de64 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -2776,6 +2776,7 @@ export interface GroupRelay { userChatRelay: UserChatRelay relayStatus: RelayStatus relayLink?: string + relayCap: RelayCapabilities } export type GroupRootKey = GroupRootKey.Private | GroupRootKey.Public @@ -3356,6 +3357,13 @@ export namespace ProxyError { } } +export interface PublicGroupAccess { + groupWebPage?: string + groupDomain?: string + domainWebPage: boolean + allowEmbedding: boolean +} + export interface PublicGroupData { publicMemberCount: number // int64 } @@ -3364,6 +3372,7 @@ export interface PublicGroupProfile { groupType: GroupType groupLink: string publicGroupId: string + publicGroupAccess?: PublicGroupAccess } export type RCErrorType = @@ -3752,6 +3761,10 @@ export namespace RcvMsgError { } } +export interface RelayCapabilities { + baseWebUrl?: string +} + export interface RelayProfile { displayName: string fullName: string 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 c3a5e0c4fb..08308ed2d4 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -1949,6 +1949,7 @@ class GroupRelay(TypedDict): userChatRelay: "UserChatRelay" relayStatus: "RelayStatus" relayLink: NotRequired[str] + relayCap: "RelayCapabilities" class GroupRootKey_private(TypedDict): type: Literal["private"] @@ -2356,6 +2357,12 @@ ProxyError = ProxyError_PROTOCOL | ProxyError_BROKER | ProxyError_BASIC_AUTH | P ProxyError_Tag = Literal["PROTOCOL", "BROKER", "BASIC_AUTH", "NO_SESSION"] +class PublicGroupAccess(TypedDict): + groupWebPage: NotRequired[str] + groupDomain: NotRequired[str] + domainWebPage: bool + allowEmbedding: bool + class PublicGroupData(TypedDict): publicMemberCount: int # int64 @@ -2363,6 +2370,7 @@ class PublicGroupProfile(TypedDict): groupType: "GroupType" groupLink: str publicGroupId: str + publicGroupAccess: NotRequired["PublicGroupAccess"] class RCErrorType_internal(TypedDict): type: Literal["internal"] @@ -2631,6 +2639,9 @@ RcvMsgError = RcvMsgError_dropped | RcvMsgError_parseError RcvMsgError_Tag = Literal["dropped", "parseError"] +class RelayCapabilities(TypedDict): + baseWebUrl: NotRequired[str] + class RelayProfile(TypedDict): displayName: str fullName: str diff --git a/plans/2026-05-25-channel-web-preview.md b/plans/2026-05-25-channel-web-preview.md new file mode 100644 index 0000000000..de0f02506b --- /dev/null +++ b/plans/2026-05-25-channel-web-preview.md @@ -0,0 +1,596 @@ +# Channel Web Preview + +## Context + +SimpleX channels are public - anybody with the link to join and chat relays rebroadcasting the messages can see content. To grow channels, owners need a public web preview (like Telegram's `t.me/s/channelname`) showing the last 50 messages. This lets potential subscribers browse before joining. + +The relay already stores all messages in its database. The web preview is a periodic read-and-render loop that writes JSON files served by Caddy, with CORS controlling which domains can embed the preview. + +This feature integrates with the `.simplex` namespace (ENS-based names resolving to channel links). A channel's registered domain (`groupDomain`) lives in `PublicGroupAccess` inside `PublicGroupProfile` and is disseminated with the profile. On-chain verification of the domain is deferred until RSLV resolution protocol ships. + +## Architecture + +``` +simplex-chat CLI (--relay --web-json-dir=... --web-base-url=...) + ├── Main chat loop (existing) + ├── Relay logic (existing, gated by --relay) + └── Web preview thread (new, gated by relayWebOptions) + ├── Periodic: load publishable groups → render JSON → write files + └── Regenerate Caddy CORS config → caddy reload + +Caddy (operator-configured) + ├── Serves JSON at /.json + └── Imports generated CORS config file + +Channel page (static HTML+JS, hosted by owner or on GitHub) + ├── Fetches JSON from relay(s) with fallback + └── Renders messages, shows join button +``` + +## Data Model Changes + +### 1. Extend `PublicGroupProfile` with domain and web access settings + +**File:** `src/Simplex/Chat/Types.hs` (line 796) + +Current: +```haskell +data PublicGroupProfile = PublicGroupProfile + { groupType :: GroupType, + groupLink :: ShortLinkContact, + publicGroupId :: B64UrlByteString + } +``` + +New: +```haskell +data PublicGroupAccess = PublicGroupAccess + { groupWebPage :: Maybe Text, -- channel's web page URL (adds CORS origin) + groupDomain :: Maybe Text, -- domain for this channel (must have link set in domain record in the contract) + domainWebPage :: Bool, -- show on the domain's page (e.g. simplexnetwork.org site for simplex TLD domains, or domain site for web domains) + allowEmbeding :: Bool -- allow embedding from any origin (CORS: *) + } + +data PublicGroupProfile = PublicGroupProfile + { groupType :: GroupType, + groupLink :: ShortLinkContact, + publicGroupId :: B64UrlByteString, + publicGroupAccess :: Maybe PublicGroupAccess -- NEW: web preview settings + } +``` + +`groupDomain` stores the channel's registered `.simplex` domain name or another supported TLD. It is: +- Set by the owner after registering a name on-chain +- Disseminated to all members via `GroupProfile` (nested in `publicGroup`) +- Used by `simplexnetwork.org/c/` to route to the channel's web preview (for .simplex domain) + +JSON instances: TH-derived `$(JQ.deriveJSON defaultJSON ''PublicGroupAccess)`. Existing `$(JQ.deriveJSON defaultJSON ''PublicGroupProfile)` covers the new optional field. + +**Migration (SQLite/Postgres):** separate columns, same pattern as `group_type`/`group_link`/`public_group_id`: +```sql +ALTER TABLE group_profiles ADD COLUMN group_web_page TEXT; +ALTER TABLE group_profiles ADD COLUMN group_domain TEXT; +ALTER TABLE group_profiles ADD COLUMN domain_web_page INTEGER; +ALTER TABLE group_profiles ADD COLUMN allow_embedding INTEGER; +ALTER TABLE group_profiles ADD COLUMN group_domain_verified_at TEXT; +``` + +`group_domain_verified_at` is relay-local verification state (nullable timestamp, NULL = unverified). + +**Store changes:** + +`src/Simplex/Chat/Store/Shared.hs` line 693 - new constructor alongside `toPublicGroupProfile`: +```haskell +toPublicGroupAccess :: Maybe Text -> Maybe Text -> Maybe BoolInt -> Maybe BoolInt -> Maybe PublicGroupAccess +toPublicGroupAccess groupWebPage groupDomain domainWebPage_ allowEmbeding_ + | isJust groupWebPage || isJust groupDomain || fromBI domainWebPage_ || fromBI allowEmbeding_ = + Just PublicGroupAccess {groupWebPage, groupDomain, domainWebPage = fromBI domainWebPage_, allowEmbeding = fromBI allowEmbeding_} + | otherwise = Nothing + where fromBI = maybe False unBI +``` + +Extend `toPublicGroupProfile` to accept and pass through `Maybe PublicGroupAccess`. + +`GroupInfoRow` type (line 668) gains columns for: `group_web_page`, `group_domain`, `domain_web_page`, `allow_embedding`, `group_domain_verified_at`. + +`src/Simplex/Chat/Store/Groups.hs`: +- INSERT (line 367): add all new columns +- SELECT (line 2375): add `gp.group_web_page`, `gp.group_domain`, `gp.domain_web_page`, `gp.allow_embedding`, `gp.group_domain_verified_at` +- UPDATE (line 1922): include new columns in `updateGroupProfile_` + +### 2. `RelayCapabilities` record, extend `XGrpRelayAcpt`, new `XGrpRelayCap` + +**File:** `src/Simplex/Chat/Protocol.hs` + +New record for relay capabilities (extensible for future fields): +```haskell +data RelayCapabilities = RelayCapabilities + { baseWebUrl :: Maybe Text + } +``` + +TH-derived JSON. All fields optional so old relays produce `{}` and new fields are backward compatible. + +**`XGrpRelayAcpt`** - carries capabilities at acceptance time: + +Current (line 444): `XGrpRelayAcpt :: ShortLinkContact -> ChatMsgEvent 'Json` +New: `XGrpRelayAcpt :: ShortLinkContact -> RelayCapabilities -> ChatMsgEvent 'Json` + +Parsing: `XGrpRelayAcpt_ -> XGrpRelayAcpt <$> p "relayLink" <*> (p "relayCap" <|> pure defaultRelayCap)` +Encoding: `XGrpRelayAcpt relayLink cap -> o ["relayLink" .= relayLink, "relayCap" .= cap]` +Backward compatible: old relays omit `relayCap`, parsed as default (all `Nothing`). + +**`XGrpRelayCap`** - new message for ongoing capability updates: + +```haskell +XGrpRelayCap :: RelayCapabilities -> ChatMsgEvent 'Json +``` + +Tag: `"x.grp.relay.cap"` +Parsing: `XGrpRelayCap_ -> XGrpRelayCap <$> p "relayCap"` +Encoding: `XGrpRelayCap cap -> o ["relayCap" .= cap]` + +Sent by relay to owner only when capabilities change (not periodic). Relay detects change by comparing current config against persisted state on startup. + +### 3. Store `baseWebUrl` per relay + +**File:** `src/Simplex/Chat/Operators.hs` (line 278) + +Current: +```haskell +data GroupRelay = GroupRelay + { groupRelayId :: Int64, + groupMemberId :: Int64, + userChatRelay :: UserChatRelay, + relayStatus :: RelayStatus, + relayLink :: Maybe ShortLinkContact + } +``` + +Add: `relayCap :: Maybe RelayCapabilities` + +Stored as separate columns (same pattern as `PublicGroupAccess`): +**Migration:** `ALTER TABLE group_relays ADD COLUMN base_web_url TEXT` + +`relayCap` constructed from columns: `Just RelayCapabilities {baseWebUrl}` when any capability column is non-NULL, `Nothing` otherwise. + +**Handlers in `src/Simplex/Chat/Library/Subscriber.hs`:** +- `XGrpRelayAcpt` (line 770): store `RelayCapabilities` in relay record on acceptance +- `XGrpRelayCap` (new handler): update `RelayCapabilities` in relay record; only accepted from relay members (`isRelay m`), owner receives + +**Relay-side persistence:** relay persists its current `RelayCapabilities` (derived from `RelayWebOptions`) so it can detect config changes on restart. On startup, if persisted capabilities differ from config, relay sends `XGrpRelayCap` to all group owners it serves. + +### 4. CLI options for web preview + +**File:** `src/Simplex/Chat/Options.hs` + +New record bundling all web preview options: +```haskell +data RelayWebOptions = RelayWebOptions + { webJsonDir :: FilePath, -- --web-json-dir: where to write JSON files + webBaseUrl :: Text, -- --web-base-url: public URL prefix (sent in XGrpRelayAcpt) + webCorsFile :: FilePath, -- --web-cors-file: generated Caddy CORS config path + webUpdateInterval :: Int -- --web-update-interval: seconds (default 300) + } +``` + +Add as a proper field in `CoreChatOpts`: +```haskell +data CoreChatOpts = CoreChatOpts + { ...existing..., + relayWebOptions :: Maybe RelayWebOptions + } +``` + +Parsed from CLI: when `--web-json-dir` is provided, all other `--web-*` flags are required. `Nothing` when no web preview flags are set. Only meaningful when `--relay` is also set. + +### 5. Web preview thread startup + +**File:** `src/Simplex/Chat/Core.hs` (line 74) + +Current: +```haskell +runSimplexChat ... = do + a1 <- runReaderT (startChatController True True) cc + when (chatRelay && not testView) $ askCreateRelayAddress cc u + forM_ (postStartHook chatHooks) ($ cc) + a2 <- async $ chat u cc + waitEither_ a1 a2 +``` + +Add web preview thread as a third async when config is present: +```haskell +runSimplexChat ... = do + a1 <- runReaderT (startChatController True True) cc + when (chatRelay && not testView) $ askCreateRelayAddress cc u + forM_ (postStartHook chatHooks) ($ cc) + a2 <- async $ chat u cc + case relayWebOptions coreOptions of + Nothing -> waitEither_ a1 a2 + Just webOpts -> do + a3 <- async $ webPreviewThread webOpts cc + void $ waitAnyCancel [a1, a2, a3] +``` + +## New Types for JSON Serialization + +**File:** new module `src/Simplex/Chat/Web/Preview.hs` + +### Reuse as-is (existing ToJSON instances) + +- `GroupProfile` (Types.hs:803) - channel metadata (displayName, fullName, shortDescr, description, image, publicGroup incl. groupDomain) +- `MsgContent` (Protocol.hs:689) - tagged union: MCText, MCLink, MCImage, MCVideo, etc. +- `LinkPreview` (Protocol.hs:256) - `{uri, title, description, image, content}` +- `FormattedText` / `MarkdownList` (Markdown.hs:133/139) - parsed markdown +- `QuotedMsg` / `MsgRef` (Protocol.hs:589) - quoted message context +- `MsgMentions` = `Map MemberName CIMention` (Messages.hs:264) +- `CIMention` (Messages.hs:272) - `{memberId, memberRef}` +- `CIReactionCount` (Messages.hs:338) - `{reaction, userReacted, totalReacted}` + +### New types + +```haskell +data WebFileInfo = WebFileInfo + { fileName :: String, + fileSize :: Integer + } + +data WebMemberProfile = WebMemberProfile + { memberId :: MemberId, + displayName :: Text, + image :: Maybe ImageData + } + +data WebMessage = WebMessage + { sender :: Maybe MemberId, -- Nothing for CIChannelRcv (forwarded-from-channel) + ts :: UTCTime, + content :: MsgContent, + formattedText :: Maybe MarkdownList, + file :: Maybe WebFileInfo, + quote :: Maybe QuotedMsg, + mentions :: Map MemberName CIMention, + reactions :: [CIReactionCount], + forwarded :: Maybe CIForwardedFrom, + edited :: Bool + } + +data WebChannelPreview = WebChannelPreview + { channel :: GroupProfile, -- NOTE: render loop strips groupDomain until verified + subscriberCount :: Maybe Int, + members :: [WebMemberProfile], + messages :: [WebMessage], + updatedAt :: UTCTime + } +``` + +TH-derived JSON for `WebFileInfo`, `WebMemberProfile`, `WebMessage`, `WebChannelPreview`. + +## Render Loop + +**File:** new module `src/Simplex/Chat/Web.hs` + +Pattern from directory service's `updateListingsThread_` (Service.hs:185-194). + +```haskell +webPreviewThread :: RelayWebOptions -> ChatController -> IO () +webPreviewThread opts cc = forever $ do + u_ <- readTVarIO $ currentUser cc + forM_ u_ $ \user -> do + groups <- getWebPublishGroups cc user + corsEntries <- forM groups $ \gInfo -> do + renderGroupPreview opts cc user gInfo + pure (corsEntry gInfo) + writeCorsConfig opts corsEntries + threadDelay (webUpdateInterval opts * 1_000_000) +``` + +### Loading groups + +New store function `getWebPublishGroups`: +```sql +SELECT ... FROM groups g +JOIN group_profiles gp ON g.group_profile_id = gp.group_profile_id +WHERE gp.group_web_page IS NOT NULL + AND g.user_id = ? +``` + +Returns `[GroupInfo]`. For each, call `getGroupChat` with `CPLast 50` (Store/Messages.hs:1436) to get chat items. + +### Converting CChatItem to WebMessage + +For each `CChatItem SMDRcv (ChatItem {chatDir, meta, content, mentions, formattedText, quotedItem, reactions, file})`: + +1. **Skip if:** + - `itemDeleted meta` is `Just _` + - `itemTimed meta` is `Just _` + - `content` is not `CIRcvMsgContent mc` (skip `CIRcvGroupEvent`, `CIRcvIntegrityError`, etc.) + - `mc` is `MCReport` or `MCUnknown` + +2. **Extract sender:** + - `CIGroupRcv member` -> `Just (memberId member)`, collect member into profiles array + - `CIChannelRcv` -> `Nothing` (channel-forwarded message, no individual sender) + +3. **Extract file info:** + - `file :: Maybe (CIFile 'MDRcv)` has `fileName :: String`, `fileSize :: Integer` + - Strip `fileSource`, `fileStatus`, `fileProtocol` (download metadata irrelevant for web) + +4. **Build WebMessage:** + ```haskell + WebMessage + { sender = senderMemberId + , ts = itemTs meta + , content = mc + , formattedText = formattedText + , file = (\f -> WebFileInfo (fileName f) (fileSize f)) <$> file + , quote = quotedItem -- QuotedMsg reused directly + , mentions = mentions + , reactions = reactions + , forwarded = itemForwarded meta + , edited = itemEdited meta + } + ``` + +5. **Collect unique senders** into `[WebMemberProfile]` from `GroupMember` records in `CIGroupRcv`. + +Also include `CIGroupSnd` items (relay's own sent messages, if any - unlikely but possible for admin announcements). + +### Filtering unverified domains + +Before serializing, the render loop strips `groupDomain` from the `PublicGroupAccess` included in the profile when not verified: + +```haskell +stripUnverifiedDomain :: Maybe UTCTime -> GroupProfile -> GroupProfile +stripUnverifiedDomain verifiedAt gp = case verifiedAt of + Just _ -> gp -- domain verified, include as-is + Nothing -> gp {publicGroup = clearDomain <$> publicGroup gp} + where + clearDomain pgp = pgp {publicGroupAccess = clearAccess <$> publicGroupAccess pgp} + clearAccess acc = acc {groupDomain = ""} -- or strip the access record entirely +``` + +The `group_domain_verified_at` timestamp is loaded alongside the group info. Until RSLV ships, this column is always NULL, so all domains are stripped from web export. + +`domainWebPage` in CORS config is also gated on verified domain - unverified means no domain-site origin in CORS. + +### Writing JSON + +- Serialize `WebChannelPreview` to JSON via `Data.Aeson.encode` +- Write atomically (write to temp, rename) to `/.json` +- `publicGroupId` from `PublicGroupProfile` (base64url-encoded, existing field) + +### Generating Caddy CORS config + +Write a single file with Caddy `map` directive: + +```caddy +map {path} {cors_origin} { + /.json "https://owner-domain.com" + /.json "*" + default "" +} +header /*.json Access-Control-Allow-Origin {cors_origin} +header /*.json Access-Control-Allow-Methods "GET, OPTIONS" +``` + +CORS origin derivation from `PublicGroupAccess`: +- `allowEmbeding = True` -> `*` +- `groupWebPage = Just url` -> extract origin from URL (+ domain site origin if `domainWebPage` and domain verified) +- `groupWebPage = Nothing, domainWebPage = True` -> domain site origin only (when domain is verified) +- No web page, no embedding, no domain page -> omit from config + +After writing, run `caddy reload` if file content changed (compare hash before/after). + +## Namespace Integration + +`groupDomain` ships now in the profile (inside `PublicGroupAccess`). What's deferred is on-chain verification (RSLV protocol). + +### What ships now + +1. **`groupDomain :: Text` in `PublicGroupAccess`** - owner sets the registered domain, disseminated to all members +2. **`domainWebPage :: Bool` in `PublicGroupAccess`** - flag stored but has no effect until domain is verified +3. **Relay strips `groupDomain` from web export** - no verification means domain is cleared in JSON, no domain-site CORS origin + +### What ships with RSLV + +1. **RSLV protocol** - relay queries name servers via SMP proxy to verify domain ownership +2. **`domainWebPage` becomes functional** - enables domain-site hosting (e.g. `simplexnetwork.org/c/`) for verified domains +3. **In-app resolution** - `#name` markdown (already parsed by namespace branch) resolves and connects + +### Verification flow (relay-side) + +When owner updates profile with `groupDomain`: + +1. **Trigger:** Relay receives profile update on owner's connection containing `groupDomain` field +2. **Initiate:** Relay sends `RSLV ` through SMP proxy (async, on the same owner connection context) +3. **Pending state:** `group_domain_verified_at = NULL` in DB. Web export excludes domain while pending. +4. **Resolution arrives:** `NAME ` agent event arrives on the owner's connection (continuation bound to the connection that sent the profile update) +5. **Verify:** Check if `channelLinks` in the NAME response includes this group's `groupLink` +6. **Store result:** Set `group_domain_verified_at = ` on success, leave NULL on failure +7. **Effect:** Web render loop includes domain in JSON and enables domain-site CORS only when `group_domain_verified_at IS NOT NULL` + +Re-verification: periodic (e.g. daily or on each web update cycle) to catch expired/transferred domains. Clear `group_domain_verified_at` when re-verification fails. + +### What the namespace branch already provides + +- `SimplexNameInfo {nameType, namespace, domain, subDomain}` in Markdown.hs +- `SimplexName` variant in `Format` ADT +- Parser for `#name` / `#name.simplex` / `:name.simplex` syntax +- Forward-compatibility alerts in Kotlin/Swift UI (shows "requires newer app" until resolution is implemented) + +## UI Changes (Kotlin/Swift) + +### Kotlin types + +**File:** `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt` + +```kotlin +@Serializable +data class PublicGroupAccess( + val groupWebPage: String? = null, + val groupDomain: String? = null, + val domainWebPage: Boolean = false, + val allowEmbeding: Boolean = false +) + +// Extend existing PublicGroupProfile (currently at line 2213): +@Serializable +data class PublicGroupProfile( + val groupType: GroupType, + val groupLink: String, + val publicGroupId: String, + val publicGroupAccess: PublicGroupAccess? = null // NEW +) + +@Serializable +data class RelayCapabilities( + val baseWebUrl: String? = null +) + +// Extend existing GroupRelay: +@Serializable +data class GroupRelay( + ...existing fields..., + val relayCap: RelayCapabilities? = null // NEW +) +``` + +### Owner: Channel info page + +**File:** `GroupChatInfoView.kt` (around line 604-606) + +After existing `ChannelLinkButton(manageGroupLink)`: +```kotlin +ChannelWebPageButton(openChannelWebPage) // owner only +``` + +New nav destination opens `ChannelWebPageView`. + +### Owner: Channel web page screen + +**File:** new `apps/multiplatform/.../views/chat/group/ChannelWebPageView.kt` + +- Text field: web page URL (`groupWebPage`) +- Text field: domain (`groupDomain`) +- Toggle: allow embedding (`allowEmbeding`) +- Toggle: show on domain's page (`domainWebPage`) - stored but inert until RSLV ships +- Section: embed snippet (read-only, auto-generated from relay `baseWebUrl` values + `publicGroupId`) +- Save button -> `apiUpdateGroup` with updated `GroupProfile` + +### Subscriber: Channel info page + +In the top section (around line 607-614), after channel link QR: +```kotlin +val webPageUrl = groupInfo.groupProfile.publicGroup?.publicGroupAccess?.groupWebPage +if (webPageUrl != null) { + WebPageLinkRow(webPageUrl) // clickable, opens browser +} +``` + +## Build Configuration + +Web preview code compiles into the main `simplex-chat` library (not conditional). The thread only starts when `relayWebOptions` is set in `CoreChatOpts`. Mobile apps never set this. + +No cabal flag needed - the thread startup is gated by `Maybe RelayWebOptions` at runtime (same pattern as `chatRelay` gating relay behavior). + +## Caddy Setup (operator documentation) + +Main Caddyfile (operator writes once): +```caddy +relay.example.com { + import /etc/caddy/simplex-cors.conf + handle /preview/* { + root * /var/lib/simplex/web/preview + file_server + } +} +``` + +Relay CLI invocation: +``` +simplex-chat --relay \ + --web-json-dir /var/lib/simplex/web/preview \ + --web-base-url https://relay.example.com/preview \ + --web-cors-file /etc/caddy/simplex-cors.conf \ + --web-update-interval 300 +``` + +## Channel Page and Embed Code + +### Embed snippet (shown to owner) + +The "Channel web page" screen auto-generates this from the channel's relay `baseWebUrl` values and `publicGroupId`. Owner copies it into their page: + +```html +
+
+ +``` + +Example with real values: +```html +
+
+ +``` + +The script fetches `/a1b2c3d4.json`, renders the preview into the `div`. Tries relays in order, falls back on failure. The owner's domain must match the CORS origin configured by the relay (derived from `groupWebPage`), or `allowEmbeding` must be `True` for `*`. + +For iframe embedding (when allowed), the snippet is simpler - just an iframe pointing to the owner's hosted channel page. + +### Channel page (static JS) + +Separate repo or folder. `channel-preview.js` + minimal CSS: +- Reads config from `data-` attributes on the container div +- Fetches JSON from relays with fallback (try first, fall back to second) +- Renders: channel header (name, avatar, description, subscriber count), message list (text with FormattedText markdown, link previews, file indicators, reactions, quotes) +- Join button: `simplex://` deep link on mobile, QR code on desktop +- Reuses directory page's markdown rendering approach + +## Files to Create/Modify + +### New files +- `src/Simplex/Chat/Web/Preview.hs` - types: `WebChannelPreview`, `WebMessage`, `WebFileInfo`, `WebMemberProfile` +- `src/Simplex/Chat/Web.hs` - render loop, JSON writing, Caddy config generation +- `apps/multiplatform/.../views/chat/group/ChannelWebPageView.kt` +- `apps/ios/Shared/Views/Chat/Group/ChannelWebPageView.swift` +- Migration files (SQLite + Postgres): `group_web_page`, `group_domain`, `domain_web_page`, `allow_embedding`, `group_domain_verified_at` in group_profiles; `base_web_url` in group_relays +- Channel page static site (separate repo/folder) + +### Modified files +- `src/Simplex/Chat/Types.hs` - `PublicGroupAccess` type, extend `PublicGroupProfile` with `publicGroupAccess` +- `src/Simplex/Chat/Protocol.hs` - `RelayCapabilities` record, extend `XGrpRelayAcpt`, add `XGrpRelayCap` +- `src/Simplex/Chat/Options.hs` - `RelayWebOptions` record, `relayWebOptions :: Maybe RelayWebOptions` in `CoreChatOpts` +- `src/Simplex/Chat/Core.hs` - start web preview thread in `runSimplexChat` +- `src/Simplex/Chat/Operators.hs` - `baseWebUrl` in `GroupRelay` +- `src/Simplex/Chat/Store/Groups.hs` - read/write `PublicGroupAccess` columns; `getWebPublishGroups` +- `src/Simplex/Chat/Store/Shared.hs` - `toPublicGroupAccess`, extend `toPublicGroupProfile` and `GroupInfoRow` +- `src/Simplex/Chat/Library/Subscriber.hs` - handle `RelayCapabilities` in `XGrpRelayAcpt` and `XGrpRelayCap` +- `apps/multiplatform/.../model/ChatModel.kt` - `PublicGroupAccess`, `RelayCapabilities`, `PublicGroupProfile.publicGroupAccess`, `GroupRelay.relayCap` +- `apps/multiplatform/.../views/chat/group/GroupChatInfoView.kt` - nav link for web page +- `simplex-chat.cabal` - add `Simplex.Chat.Web.Preview`, `Simplex.Chat.Web` to exposed-modules + +## Implementation Order + +1. **Data model** - `PublicGroupAccess` in `PublicGroupProfile`, migrations (separate columns), store functions +2. **Protocol** - `RelayCapabilities`, extend `XGrpRelayAcpt`, add `XGrpRelayCap`, handlers in Subscriber.hs +3. **CLI options** - `RelayWebOptions` record, `relayWebOptions` field in `CoreChatOpts` +4. **Web types** - `WebChannelPreview`, `WebMessage`, etc. in new module +5. **Render loop** - thread startup in Core.hs, periodic JSON generation, Caddy config +6. **UI (owner)** - "Channel web page" settings screen +7. **UI (subscriber)** - web page link in channel info +8. **Channel page** - static HTML+JS template +9. **Documentation** - operator setup guide + +## Verification + +1. **Build**: `cabal build simplex-chat` with new modules compiles +2. **Unit test**: serialize `WebChannelPreview` with sample data, verify JSON matches expected structure +3. **Integration test**: create channel with `publicGroupAccess` set, run relay with `--web-json-dir`, verify JSON file appears at correct path with correct content +4. **CORS test**: verify generated config produces correct `Access-Control-Allow-Origin` for configured domains +5. **UI test**: owner can set web page URL and domain, see embed snippet; subscriber sees clickable link +6. **Channel page test**: serve static page locally against relay's JSON, verify rendering +7. **Domain stripping test**: set `groupDomain` on a channel, verify it is stripped from web export JSON (unverified, `group_domain_verified_at IS NULL`) diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 6e459d6484..18fc93eede 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_public_group_access 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_public_group_access 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 4bbfa8e09d..20e9d6a0fb 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -2524,7 +2524,8 @@ processChatCommand vr nm = \case -- generate owner key, OwnerAuth signed by root key memberId <- MemberId <$> liftIO (encodedRandomBytes gVar 12) (memberPrivKey, ownerAuth) <- liftIO $ SL.newOwnerAuth gVar (unMemberId memberId) rootPrivKey - let groupProfile' = (groupProfile :: GroupProfile) {publicGroup = Just PublicGroupProfile {groupType = GTChannel, groupLink = sLnk, publicGroupId = B64UrlByteString entityId}} + -- TODO [channel web] pass publicGroupAccess from owner's profile + let groupProfile' = (groupProfile :: GroupProfile) {publicGroup = Just PublicGroupProfile {groupType = GTChannel, groupLink = sLnk, publicGroupId = B64UrlByteString entityId, publicGroupAccess = Nothing}} userData = encodeShortLinkData $ GroupShortLinkData {groupProfile = groupProfile', publicGroupData = Just (PublicGroupData 1)} userLinkData = UserContactLinkData UserContactData {direct = False, owners = [ownerAuth], relays = [], userData} -- create connection with prepared link (single network call) @@ -2643,8 +2644,7 @@ processChatCommand vr nm = \case Nothing -> throwChatError $ CEContactNotActive ct APIAcceptMember groupId gmId role -> withUser $ \user@User {userId} -> do (gInfo, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user groupId <*> getGroupMemberById db vr user gmId - -- TODO check that user's role is > role, possibly restrict role to only observer and member - assertUserGroupRole gInfo GRModerator + assertUserGroupRole gInfo $ max GRModerator role case memberStatus m of GSMemPendingApproval | memberCategory m == GCInviteeMember -> do -- only host can approve let GroupInfo {groupProfile = GroupProfile {memberAdmission}} = gInfo diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index c6c3f92752..31a1d60502 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1048,7 +1048,8 @@ acceptRelayJoinRequestAsync cReqInvId cReqChatVRange relayLink = do - let msg = XGrpRelayAcpt relayLink + -- TODO [channel web] derive RelayCapabilities from relay config (RelayWebOptions) + let msg = XGrpRelayAcpt relayLink defaultRelayCapabilities subMode <- chatReadVar subscriptionMode vr <- chatVersionRange let chatV = vr `peerConnChatVersion` cReqChatVRange diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 08ca90f2a6..478c53c763 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -765,9 +765,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- [async agent commands] no continuation needed, but command should be asynchronous for stability allowAgentConnectionAsync user conn' confId XOk | otherwise -> messageError "x.grp.acpt: memberId is different from expected" - XGrpRelayAcpt relayLink + XGrpRelayAcpt relayLink relayCap | memberRole' membership == GROwner && isRelay m -> do - withStore' $ \db -> setRelayLinkConfId db m confId relayLink + withStore' $ \db -> do + setRelayLinkConfId db m confId relayLink + updateRelayCapabilities db m relayCap void $ getAgentConnShortLinkAsync user CFGetRelayDataAccept (Just conn') relayLink | otherwise -> messageError "x.grp.relay.acpt: only owner can add relay" XGrpRelayReject reason @@ -1036,6 +1038,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = XGrpLinkMem p -> Nothing <$ xGrpLinkMem gInfo' m'' conn' p XGrpLinkAcpt acceptance role memberId -> Nothing <$ xGrpLinkAcpt gInfo' m'' acceptance role memberId msg brokerTs XGrpRelayNew rl -> fmap ctx <$> xGrpRelayNew gInfo' m'' rl + XGrpRelayCap relayCap + | memberRole' membership == GROwner && isRelay m'' -> + Nothing <$ withStore' (\db -> updateRelayCapabilities db m'' relayCap) + | otherwise -> Nothing <$ messageWarning "x.grp.relay.cap: only owner should receive relay capabilities" XGrpMemNew memInfo msgScope -> fmap ctx <$> xGrpMemNew gInfo' m'' memInfo msgScope msg brokerTs XGrpMemIntro memInfo memRestrictions_ -> Nothing <$ xGrpMemIntro gInfo' m'' memInfo memRestrictions_ XGrpMemInv memId introInv -> Nothing <$ xGrpMemInv gInfo' m'' memId introInv @@ -2601,6 +2607,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xGrpLinkAcpt :: GroupInfo -> GroupMember -> GroupAcceptance -> GroupMemberRole -> MemberId -> RcvMessage -> UTCTime -> CM () xGrpLinkAcpt gInfo@GroupInfo {membership} m acceptance role memberId msg brokerTs + | memberRole' m < GRModerator || memberRole' m < role = + messageError "x.grp.link.acpt with insufficient member permissions" | sameMemberId memberId membership = processUserAccepted | otherwise = withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memberId) >>= \case diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index 03cc38e12a..6816a5f692 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -46,7 +46,7 @@ import Data.Time (addUTCTime) import Data.Time.Clock (UTCTime, nominalDay) import Language.Haskell.TH.Syntax (lift) import Simplex.Chat.Operators.Conditions -import Simplex.Chat.Protocol (RelayProfile (..)) +import Simplex.Chat.Protocol (RelayCapabilities (..), RelayProfile (..)) import Simplex.Chat.Types (ShortLinkContact, User) import Simplex.Chat.Types.Shared (RelayStatus) import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles) @@ -280,7 +280,8 @@ data GroupRelay = GroupRelay groupMemberId :: Int64, userChatRelay :: UserChatRelay, relayStatus :: RelayStatus, - relayLink :: Maybe ShortLinkContact + relayLink :: Maybe ShortLinkContact, + relayCap :: RelayCapabilities } deriving (Eq, Show) diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index f9c29e3552..71daeec635 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -262,6 +262,14 @@ data LinkContent = LCPage | LCImage | LCVideo {duration :: Maybe Int} | LCUnknow data ReportReason = RRSpam | RRContent | RRCommunity | RRProfile | RROther | RRUnknown Text deriving (Eq, Show) +data RelayCapabilities = RelayCapabilities + { baseWebUrl :: Maybe Text + } + deriving (Eq, Show) + +defaultRelayCapabilities :: RelayCapabilities +defaultRelayCapabilities = RelayCapabilities {baseWebUrl = Nothing} + $(pure []) instance FromJSON LinkContent where @@ -281,6 +289,12 @@ instance ToJSON LinkContent where $(JQ.deriveJSON defaultJSON ''LinkPreview) +$(JQ.deriveToJSON defaultJSON ''RelayCapabilities) + +instance FromJSON RelayCapabilities where + parseJSON = $(JQ.mkParseJSON defaultJSON ''RelayCapabilities) + omittedField = Just defaultRelayCapabilities + instance StrEncoding ReportReason where strEncode = \case RRSpam -> "spam" @@ -441,10 +455,11 @@ data ChatMsgEvent (e :: MsgEncoding) where XGrpLinkMem :: Profile -> ChatMsgEvent 'Json XGrpLinkAcpt :: GroupAcceptance -> GroupMemberRole -> MemberId -> ChatMsgEvent 'Json XGrpRelayInv :: GroupRelayInvitation -> ChatMsgEvent 'Json - XGrpRelayAcpt :: ShortLinkContact -> ChatMsgEvent 'Json + XGrpRelayAcpt :: ShortLinkContact -> RelayCapabilities -> ChatMsgEvent 'Json XGrpRelayTest :: ByteString -> Maybe ByteString -> ChatMsgEvent 'Json XGrpRelayNew :: ShortLinkContact -> ChatMsgEvent 'Json XGrpRelayReject :: RelayRejectionReason -> ChatMsgEvent 'Json + XGrpRelayCap :: RelayCapabilities -> ChatMsgEvent 'Json XGrpMemNew :: MemberInfo -> Maybe MsgScope -> ChatMsgEvent 'Json XGrpMemIntro :: MemberInfo -> Maybe MemberRestrictions -> ChatMsgEvent 'Json XGrpMemInv :: MemberId -> IntroInvitation -> ChatMsgEvent 'Json @@ -991,6 +1006,7 @@ data CMEventTag (e :: MsgEncoding) where XGrpRelayTest_ :: CMEventTag 'Json XGrpRelayNew_ :: CMEventTag 'Json XGrpRelayReject_ :: CMEventTag 'Json + XGrpRelayCap_ :: CMEventTag 'Json XGrpMemNew_ :: CMEventTag 'Json XGrpMemIntro_ :: CMEventTag 'Json XGrpMemInv_ :: CMEventTag 'Json @@ -1050,6 +1066,7 @@ instance MsgEncodingI e => StrEncoding (CMEventTag e) where XGrpRelayTest_ -> "x.grp.relay.test" XGrpRelayNew_ -> "x.grp.relay.new" XGrpRelayReject_ -> "x.grp.relay.reject" + XGrpRelayCap_ -> "x.grp.relay.cap" XGrpMemNew_ -> "x.grp.mem.new" XGrpMemIntro_ -> "x.grp.mem.intro" XGrpMemInv_ -> "x.grp.mem.inv" @@ -1110,6 +1127,7 @@ instance StrEncoding ACMEventTag where "x.grp.relay.test" -> XGrpRelayTest_ "x.grp.relay.new" -> XGrpRelayNew_ "x.grp.relay.reject" -> XGrpRelayReject_ + "x.grp.relay.cap" -> XGrpRelayCap_ "x.grp.mem.new" -> XGrpMemNew_ "x.grp.mem.intro" -> XGrpMemIntro_ "x.grp.mem.inv" -> XGrpMemInv_ @@ -1162,10 +1180,11 @@ toCMEventTag msg = case msg of XGrpLinkMem _ -> XGrpLinkMem_ XGrpLinkAcpt {} -> XGrpLinkAcpt_ XGrpRelayInv _ -> XGrpRelayInv_ - XGrpRelayAcpt _ -> XGrpRelayAcpt_ + XGrpRelayAcpt {} -> XGrpRelayAcpt_ XGrpRelayTest {} -> XGrpRelayTest_ XGrpRelayNew _ -> XGrpRelayNew_ XGrpRelayReject _ -> XGrpRelayReject_ + XGrpRelayCap _ -> XGrpRelayCap_ XGrpMemNew {} -> XGrpMemNew_ XGrpMemIntro _ _ -> XGrpMemIntro_ XGrpMemInv _ _ -> XGrpMemInv_ @@ -1318,7 +1337,8 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do XGrpLinkMem_ -> XGrpLinkMem <$> p "profile" XGrpLinkAcpt_ -> XGrpLinkAcpt <$> p "acceptance" <*> p "role" <*> p "memberId" XGrpRelayInv_ -> XGrpRelayInv <$> p "groupRelayInvitation" - XGrpRelayAcpt_ -> XGrpRelayAcpt <$> p "relayLink" + XGrpRelayAcpt_ -> XGrpRelayAcpt <$> p "relayLink" <*> (fromMaybe defaultRelayCapabilities <$> opt "relayCap") + XGrpRelayCap_ -> XGrpRelayCap <$> p "relayCap" XGrpRelayTest_ -> do B64UrlByteString challenge <- p "challenge" sig_ <- fmap (\(B64UrlByteString s) -> s) <$> opt "signature" @@ -1390,7 +1410,8 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en XGrpLinkMem profile -> o ["profile" .= profile] XGrpLinkAcpt acceptance role memberId -> o ["acceptance" .= acceptance, "role" .= role, "memberId" .= memberId] XGrpRelayInv groupRelayInv -> o ["groupRelayInvitation" .= groupRelayInv] - XGrpRelayAcpt relayLink -> o ["relayLink" .= relayLink] + XGrpRelayAcpt relayLink relayCap -> o ["relayLink" .= relayLink, "relayCap" .= relayCap] + XGrpRelayCap relayCap -> o ["relayCap" .= relayCap] XGrpRelayTest challenge sig_ -> o $ ("signature" .=? (B64UrlByteString <$> sig_)) ["challenge" .= B64UrlByteString challenge] diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 62183a0313..60d865cb30 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -139,6 +139,7 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do 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, diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 9b21f0697b..e2a9d6816a 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -89,6 +89,7 @@ module Simplex.Chat.Store.Groups updateRelayStatusFromTo, setRelayLinkAccepted, setRelayLinkConfId, + updateRelayCapabilities, getRelayConfId, updateRelayMemberData, setGroupInProgressDone, @@ -367,10 +368,11 @@ createNewGroup db vr user@User {userId} groupProfile incognitoProfile useRelays INSERT INTO group_profiles (display_name, full_name, short_descr, description, image, group_type, group_link, public_group_id, + group_web_page, group_domain, domain_web_page, allow_embedding, user_id, preferences, member_admission, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ((displayName, fullName, shortDescr, description, image, groupType_, groupLink_, publicGroupId_) + ((displayName, fullName, shortDescr, description, image, groupType_, groupLink_, publicGroupId_) :. publicGroupAccessRow publicGroup :. (userId, groupPreferences, memberAdmission, currentTs, currentTs)) profileId <- insertedRowId db DB.execute @@ -868,10 +870,11 @@ createGroup_ db userId groupProfile prepared business useRelays relayOwnStatus p INSERT INTO group_profiles (display_name, full_name, short_descr, description, image, group_type, group_link, public_group_id, + group_web_page, group_domain, domain_web_page, allow_embedding, user_id, preferences, member_admission, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ((displayName, fullName, shortDescr, description, image, groupType_, groupLink_, publicGroupId_) + ((displayName, fullName, shortDescr, description, image, groupType_, groupLink_, publicGroupId_) :. publicGroupAccessRow publicGroup :. (userId, groupPreferences, memberAdmission, currentTs, currentTs)) profileId <- insertedRowId db DB.execute @@ -1343,15 +1346,16 @@ groupRelayQuery = [sql| SELECT gr.group_relay_id, gr.group_member_id, cr.chat_relay_id, cr.address, cr.display_name, cr.full_name, cr.short_descr, cr.image, cr.domains, cr.preset, cr.tested, cr.enabled, cr.deleted, - gr.relay_status, gr.relay_link + gr.relay_status, gr.relay_link, gr.base_web_url FROM group_relays gr JOIN chat_relays cr ON cr.chat_relay_id = gr.chat_relay_id |] -toGroupRelay :: (Int64, GroupMemberId, DBEntityId, ShortLinkContact, Text, Text, Maybe Text, Maybe ImageData, Text, BoolInt) :. (Maybe BoolInt, BoolInt, BoolInt, RelayStatus, Maybe ShortLinkContact) -> GroupRelay -toGroupRelay ((groupRelayId, groupMemberId, chatRelayId, address, displayName, fullName, shortDescr, image, domains, BI preset) :. (tested, BI enabled, BI deleted, relayStatus, relayLink)) = +toGroupRelay :: (Int64, GroupMemberId, DBEntityId, ShortLinkContact, Text, Text, Maybe Text, Maybe ImageData, Text, BoolInt) :. (Maybe BoolInt, BoolInt, BoolInt, RelayStatus, Maybe ShortLinkContact, Maybe Text) -> GroupRelay +toGroupRelay ((groupRelayId, groupMemberId, chatRelayId, address, displayName, fullName, shortDescr, image, domains, BI preset) :. (tested, BI enabled, BI deleted, relayStatus, relayLink, baseWebUrl)) = let userChatRelay = UserChatRelay {chatRelayId, address, relayProfile = toRelayProfile (displayName, fullName, shortDescr, image), domains = T.splitOn "," domains, preset, tested = unBI <$> tested, enabled, deleted} - in GroupRelay {groupRelayId, groupMemberId, userChatRelay, relayStatus, relayLink} + relayCap = RelayCapabilities {baseWebUrl} + in GroupRelay {groupRelayId, groupMemberId, userChatRelay, relayStatus, relayLink, relayCap} createRelayForOwner :: DB.Connection -> VersionRangeChat -> TVar ChaChaDRG -> User -> GroupInfo -> UserChatRelay -> ExceptT StoreError IO GroupMember createRelayForOwner db vr gVar user@User {userId, userContactId} GroupInfo {groupId, membership} UserChatRelay {relayProfile = RelayProfile {displayName}} = do @@ -1491,6 +1495,18 @@ setRelayLinkConfId db m confId relayLink = do |] (relayLink, currentTs, groupMemberId' m) +updateRelayCapabilities :: DB.Connection -> GroupMember -> RelayCapabilities -> IO () +updateRelayCapabilities db m RelayCapabilities {baseWebUrl} = do + currentTs <- getCurrentTime + DB.execute + db + [sql| + UPDATE group_relays + SET base_web_url = ?, updated_at = ? + WHERE group_member_id = ? + |] + (baseWebUrl, currentTs, groupMemberId' m) + getRelayConfId :: DB.Connection -> GroupMember -> ExceptT StoreError IO ConfirmationId getRelayConfId db m = ExceptT . firstRow fromOnly (SEGroupRelayNotFoundByMemberId $ groupMemberId' m) $ @@ -2327,6 +2343,7 @@ updateGroupProfile db user@User {userId} g@GroupInfo {groupId, localDisplayName, UPDATE group_profiles SET display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, group_type = ?, group_link = ?, + group_web_page = ?, group_domain = ?, domain_web_page = ?, allow_embedding = ?, preferences = ?, member_admission = ?, updated_at = ? WHERE group_profile_id IN ( SELECT group_profile_id @@ -2334,7 +2351,7 @@ updateGroupProfile db user@User {userId} g@GroupInfo {groupId, localDisplayName, WHERE user_id = ? AND group_id = ? ) |] - ((newName, fullName, shortDescr, description, image, groupType_, groupLink_) :. (groupPreferences, memberAdmission, currentTs, userId, groupId)) + ((newName, fullName, shortDescr, description, image, groupType_, groupLink_) :. publicGroupAccessRow publicGroup :. (groupPreferences, memberAdmission, currentTs, userId, groupId)) updateGroup_ ldn currentTs = do DB.execute db @@ -2374,14 +2391,16 @@ updateGroupProfileFromMember db user g@GroupInfo {groupId} Profile {displayName [sql| SELECT gp.display_name, gp.full_name, gp.short_descr, 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, gp.preferences, gp.member_admission FROM group_profiles gp JOIN groups g ON gp.group_profile_id = g.group_profile_id WHERE g.group_id = ? |] (Only groupId) - toGroupProfile (displayName, fullName, shortDescr, description, image, groupType_, groupLink_, publicGroupId_, groupPreferences, memberAdmission) = - GroupProfile {displayName, fullName, shortDescr, description, image, publicGroup = toPublicGroupProfile groupType_ groupLink_ publicGroupId_, groupPreferences, memberAdmission} + toGroupProfile ((displayName, fullName, shortDescr, description, image, groupType_, groupLink_, publicGroupId_) :. accessRow :. (groupPreferences, memberAdmission)) = + let publicGroupAccess = toPublicGroupAccess accessRow + in GroupProfile {displayName, fullName, shortDescr, description, image, publicGroup = toPublicGroupProfile groupType_ groupLink_ publicGroupId_ publicGroupAccess, groupPreferences, memberAdmission} getGroupInfoByUserContactLinkConnReq :: DB.Connection -> VersionRangeChat -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe GroupInfo) getGroupInfoByUserContactLinkConnReq db vr user@User {userId} (cReqSchema1, cReqSchema2) = do diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 437f16a43c..a8bb0da945 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_public_group_access 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_public_group_access", m20260515_public_group_access, Just down_m20260515_public_group_access) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260515_public_group_access.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260515_public_group_access.hs new file mode 100644 index 0000000000..1fbb731626 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260515_public_group_access.hs @@ -0,0 +1,29 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260515_public_group_access where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260515_public_group_access :: Text +m20260515_public_group_access = + [r| +ALTER TABLE group_profiles ADD COLUMN group_web_page TEXT; +ALTER TABLE group_profiles ADD COLUMN group_domain TEXT; +ALTER TABLE group_profiles ADD COLUMN domain_web_page BIGINT; +ALTER TABLE group_profiles ADD COLUMN allow_embedding BIGINT; + +ALTER TABLE group_relays ADD COLUMN base_web_url TEXT; +|] + +down_m20260515_public_group_access :: Text +down_m20260515_public_group_access = + [r| +ALTER TABLE group_relays DROP COLUMN base_web_url; + +ALTER TABLE group_profiles DROP COLUMN allow_embedding; +ALTER TABLE group_profiles DROP COLUMN domain_web_page; +ALTER TABLE group_profiles DROP COLUMN group_domain; +ALTER TABLE group_profiles DROP COLUMN group_web_page; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index 6026049313..cc3543e8a8 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -849,7 +849,11 @@ CREATE TABLE test_chat_schema.group_profiles ( short_descr text, group_type text, group_link bytea, - public_group_id bytea + public_group_id bytea, + group_web_page text, + group_domain text, + domain_web_page bigint, + allow_embedding bigint ); @@ -874,7 +878,8 @@ CREATE TABLE test_chat_schema.group_relays ( relay_link bytea, conf_id bytea, created_at text DEFAULT now() NOT NULL, - updated_at text DEFAULT now() NOT NULL + updated_at text DEFAULT now() NOT NULL, + base_web_url text ); @@ -962,7 +967,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 9990ed74fd..89ef373af8 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_public_group_access 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_public_group_access", m20260515_public_group_access, Just down_m20260515_public_group_access) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260515_public_group_access.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260515_public_group_access.hs new file mode 100644 index 0000000000..69f1793d3b --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260515_public_group_access.hs @@ -0,0 +1,28 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260515_public_group_access where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20260515_public_group_access :: Query +m20260515_public_group_access = + [sql| +ALTER TABLE group_profiles ADD COLUMN group_web_page TEXT; +ALTER TABLE group_profiles ADD COLUMN group_domain TEXT; +ALTER TABLE group_profiles ADD COLUMN domain_web_page INTEGER; +ALTER TABLE group_profiles ADD COLUMN allow_embedding INTEGER; + +ALTER TABLE group_relays ADD COLUMN base_web_url TEXT; +|] + +down_m20260515_public_group_access :: Query +down_m20260515_public_group_access = + [sql| +ALTER TABLE group_relays DROP COLUMN base_web_url; + +ALTER TABLE group_profiles DROP COLUMN allow_embedding; +ALTER TABLE group_profiles DROP COLUMN domain_web_page; +ALTER TABLE group_profiles DROP COLUMN group_domain; +ALTER TABLE group_profiles DROP COLUMN group_web_page; +|] 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 a7880799db..14c9226d2c 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -143,6 +143,7 @@ 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, @@ -979,6 +980,7 @@ SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_next (group_id=? A Query: SELECT gp.display_name, gp.full_name, gp.short_descr, 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, gp.preferences, gp.member_admission FROM group_profiles gp JOIN groups g ON gp.group_profile_id = g.group_profile_id @@ -1228,8 +1230,9 @@ Query: INSERT INTO group_profiles (display_name, full_name, short_descr, description, image, group_type, group_link, public_group_id, + group_web_page, group_domain, domain_web_page, allow_embedding, user_id, preferences, member_admission, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: @@ -1752,6 +1755,7 @@ Query: UPDATE group_profiles SET display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, group_type = ?, group_link = ?, + group_web_page = ?, group_domain = ?, domain_web_page = ?, allow_embedding = ?, preferences = ?, member_admission = ?, updated_at = ? WHERE group_profile_id IN ( SELECT group_profile_id @@ -5119,6 +5123,14 @@ SEARCH group_profiles USING INTEGER PRIMARY KEY (rowid=?) LIST SUBQUERY 1 SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE group_relays + SET base_web_url = ?, updated_at = ? + WHERE group_member_id = ? + +Plan: +SEARCH group_relays USING INDEX idx_group_relays_group_member_id (group_member_id=?) + Query: UPDATE group_relays SET conf_id = ?, relay_link = ?, updated_at = ? @@ -5295,6 +5307,7 @@ 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, @@ -5331,6 +5344,7 @@ 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, @@ -5360,6 +5374,7 @@ 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, @@ -5690,7 +5705,7 @@ SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) Query: SELECT gr.group_relay_id, gr.group_member_id, cr.chat_relay_id, cr.address, cr.display_name, cr.full_name, cr.short_descr, cr.image, cr.domains, cr.preset, cr.tested, cr.enabled, cr.deleted, - gr.relay_status, gr.relay_link + gr.relay_status, gr.relay_link, gr.base_web_url FROM group_relays gr JOIN chat_relays cr ON cr.chat_relay_id = gr.chat_relay_id @@ -5707,7 +5722,7 @@ SEARCH m USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT gr.group_relay_id, gr.group_member_id, cr.chat_relay_id, cr.address, cr.display_name, cr.full_name, cr.short_descr, cr.image, cr.domains, cr.preset, cr.tested, cr.enabled, cr.deleted, - gr.relay_status, gr.relay_link + gr.relay_status, gr.relay_link, gr.base_web_url FROM group_relays gr JOIN chat_relays cr ON cr.chat_relay_id = gr.chat_relay_id WHERE gr.group_id = ? @@ -5718,7 +5733,7 @@ SEARCH cr USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT gr.group_relay_id, gr.group_member_id, cr.chat_relay_id, cr.address, cr.display_name, cr.full_name, cr.short_descr, cr.image, cr.domains, cr.preset, cr.tested, cr.enabled, cr.deleted, - gr.relay_status, gr.relay_link + gr.relay_status, gr.relay_link, gr.base_web_url FROM group_relays gr JOIN chat_relays cr ON cr.chat_relay_id = gr.chat_relay_id WHERE gr.group_member_id = ? @@ -5729,7 +5744,7 @@ SEARCH cr USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT gr.group_relay_id, gr.group_member_id, cr.chat_relay_id, cr.address, cr.display_name, cr.full_name, cr.short_descr, cr.image, cr.domains, cr.preset, cr.tested, cr.enabled, cr.deleted, - gr.relay_status, gr.relay_link + gr.relay_status, gr.relay_link, gr.base_web_url FROM group_relays gr JOIN chat_relays cr ON cr.chat_relay_id = gr.chat_relay_id WHERE gr.group_relay_id = ? diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 86c198670c..ccff26b38d 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -125,7 +125,11 @@ CREATE TABLE group_profiles( short_descr TEXT, group_type TEXT, group_link BLOB, - public_group_id BLOB + public_group_id BLOB, + group_web_page TEXT, + group_domain TEXT, + domain_web_page INTEGER, + allow_embedding INTEGER ) STRICT; CREATE TABLE groups( group_id INTEGER PRIMARY KEY, -- local group ID @@ -778,6 +782,8 @@ CREATE TABLE group_relays( conf_id BLOB, created_at TEXT NOT NULL DEFAULT(datetime('now')), updated_at TEXT NOT NULL DEFAULT(datetime('now')) + , + base_web_url TEXT ) STRICT; CREATE INDEX contact_profiles_index ON contact_profiles( display_name, diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index af0958ed35..bd51b10329 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -665,18 +665,20 @@ type BusinessChatInfoRow = (Maybe BusinessChatType, Maybe MemberId, Maybe Member type GroupKeysRow = (Maybe C.PrivateKeyEd25519, Maybe C.PublicKeyEd25519, Maybe C.PrivateKeyEd25519) -type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Text, Maybe Text, Maybe ImageData, Maybe GroupType, Maybe ShortLinkContact, Maybe B64UrlByteString) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. PreparedGroupRow :. BusinessChatInfoRow :. (BoolInt, Maybe RelayStatus, Maybe UIThemeEntityOverrides, Int64, Maybe Int64, Maybe CustomData, Maybe Int64, Int, Maybe ConnReqContact) :. GroupKeysRow :. GroupMemberRow +type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Text, Maybe Text, Maybe ImageData, Maybe GroupType, Maybe ShortLinkContact, Maybe B64UrlByteString) :. PublicGroupAccessRow :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. PreparedGroupRow :. BusinessChatInfoRow :. (BoolInt, Maybe RelayStatus, Maybe UIThemeEntityOverrides, Int64, Maybe Int64, Maybe CustomData, Maybe Int64, Int, Maybe ConnReqContact) :. GroupKeysRow :. GroupMemberRow + +type PublicGroupAccessRow = (Maybe Text, Maybe Text, Maybe BoolInt, Maybe BoolInt) type GroupMemberRow = (GroupMemberId, GroupId, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId) :. ProfileRow :. (UTCTime, UTCTime) :. (Maybe UTCTime, Int64, Int64, Int64, Maybe UTCTime, Maybe C.PublicKeyEd25519, Maybe ShortLinkContact) type ProfileRow = (ProfileId, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, Maybe Preferences) toGroupInfo :: VersionRangeChat -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo -toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image, groupType_, groupLink_, publicGroupId_) :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (BI useRelays, relayOwnStatus, uiThemes, currentMembers, publicMemberCount, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri) :. groupKeysRow :. userMemberRow) = +toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image, groupType_, groupLink_, publicGroupId_) :. accessRow :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (BI useRelays, relayOwnStatus, uiThemes, currentMembers, publicMemberCount, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri) :. groupKeysRow :. userMemberRow) = let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = vr} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} fullGroupPreferences = mergeGroupPreferences groupPreferences - publicGroup = toPublicGroupProfile groupType_ groupLink_ publicGroupId_ + publicGroup = toPublicGroupProfile groupType_ groupLink_ publicGroupId_ (toPublicGroupAccess accessRow) groupKeys = toGroupKeys publicGroupId_ groupKeysRow groupProfile = GroupProfile {displayName, fullName, shortDescr, description, image, publicGroup, groupPreferences, memberAdmission} businessChat = toBusinessChatInfo businessRow @@ -690,10 +692,25 @@ toPreparedGroup = \case Just PreparedGroup {connLinkToConnect = CCLink fullLink shortLink_, connLinkPreparedConnection, connLinkStartedConnection, welcomeSharedMsgId, requestSharedMsgId} _ -> Nothing -toPublicGroupProfile :: Maybe GroupType -> Maybe ShortLinkContact -> Maybe B64UrlByteString -> Maybe PublicGroupProfile -toPublicGroupProfile (Just groupType) (Just groupLink) (Just publicGroupId) = - Just PublicGroupProfile {groupType, groupLink, publicGroupId} -toPublicGroupProfile _ _ _ = Nothing +toPublicGroupProfile :: Maybe GroupType -> Maybe ShortLinkContact -> Maybe B64UrlByteString -> Maybe PublicGroupAccess -> Maybe PublicGroupProfile +toPublicGroupProfile (Just groupType) (Just groupLink) (Just publicGroupId) publicGroupAccess = + Just PublicGroupProfile {groupType, groupLink, publicGroupId, publicGroupAccess} +toPublicGroupProfile _ _ _ _ = Nothing + +publicGroupAccessRow :: Maybe PublicGroupProfile -> PublicGroupAccessRow +publicGroupAccessRow pgp = case pgp >>= publicGroupAccess of + Just PublicGroupAccess {groupWebPage, groupDomain, domainWebPage, allowEmbedding} -> + (groupWebPage, groupDomain, Just (BI domainWebPage), Just (BI allowEmbedding)) + Nothing -> (Nothing, Nothing, Nothing, Nothing) + +toPublicGroupAccess :: PublicGroupAccessRow -> Maybe PublicGroupAccess +toPublicGroupAccess (groupWebPage, groupDomain, domainWebPage_, allowEmbedding_) + | isJust groupWebPage || isJust groupDomain || domainWebPage || allowEmbedding = + Just PublicGroupAccess {groupWebPage, groupDomain, domainWebPage, allowEmbedding} + | otherwise = Nothing + where + domainWebPage = maybe False unBI domainWebPage_ + allowEmbedding = maybe False unBI allowEmbedding_ toGroupKeys :: Maybe B64UrlByteString -> GroupKeysRow -> Maybe GroupKeys toGroupKeys (Just publicGroupId) (rootPrivKey_, rootPubKey_, Just memberPrivKey) = @@ -760,6 +777,7 @@ groupInfoQueryFields = 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, diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index f2892898c4..189f730b67 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -793,10 +793,19 @@ instance FromField GroupType where fromField = fromTextField_ textDecode instance ToField GroupType where toField = toField . textEncode +data PublicGroupAccess = PublicGroupAccess + { groupWebPage :: Maybe Text, + groupDomain :: Maybe Text, + domainWebPage :: Bool, + allowEmbedding :: Bool + } + deriving (Eq, Show) + data PublicGroupProfile = PublicGroupProfile { groupType :: GroupType, groupLink :: ShortLinkContact, - publicGroupId :: B64UrlByteString -- group identity = sha256(genesis root key), immutable + publicGroupId :: B64UrlByteString, -- group identity = sha256(genesis root key), immutable + publicGroupAccess :: Maybe PublicGroupAccess } deriving (Eq, Show) @@ -2084,6 +2093,8 @@ instance ToJSON GroupType where toJSON = textToJSON toEncoding = textToEncoding +$(JQ.deriveJSON defaultJSON ''PublicGroupAccess) + $(JQ.deriveJSON defaultJSON ''PublicGroupProfile) $(JQ.deriveJSON defaultJSON ''GroupProfile) diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index e0ff178db4..82906110c6 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -3251,6 +3251,12 @@ testGLinkReviewMember = alice ##> "/_delete member chat #1 5" alice <## "bad chat command: member is pending" + -- moderator can't accept member with a role higher than their own + dan ##> "/_accept member #1 5 admin" + dan <## "#team: you have insufficient permissions for this action, the required role is admin" + dan ##> "/_accept member #1 5 owner" + dan <## "#team: you have insufficient permissions for this action, the required role is owner" + -- accept member dan ##> "/_accept member #1 5 member" concurrentlyN_ From e3b3cdf2d7fbdb95f6a8910442ef482e45e8583b Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 1 Jun 2026 12:15:50 +0100 Subject: [PATCH 24/66] ui: translations (#7032) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (Hungarian) Currently translated at 100.0% (2392 of 2392 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Arabic) Currently translated at 100.0% (2768 of 2768 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (Italian) Currently translated at 100.0% (2768 of 2768 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Italian) Currently translated at 100.0% (2392 of 2392 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/it/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2768 of 2768 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (2767 of 2767 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2767 of 2767 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Arabic) Currently translated at 100.0% (2767 of 2767 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (Russian) Currently translated at 100.0% (2392 of 2392 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ru/ * Translated using Weblate (Russian) Currently translated at 99.9% (2765 of 2767 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/ * Translated using Weblate (Russian) Currently translated at 99.9% (2765 of 2767 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/ * Translated using Weblate (Czech) Currently translated at 91.1% (2523 of 2767 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/cs/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2392 of 2392 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2767 of 2767 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Italian) Currently translated at 100.0% (2767 of 2767 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (German) Currently translated at 100.0% (2767 of 2767 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (German) Currently translated at 100.0% (2392 of 2392 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/de/ * Translated using Weblate (Czech) Currently translated at 94.6% (2619 of 2767 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/cs/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2392 of 2392 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Arabic) Currently translated at 100.0% (2768 of 2768 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (Italian) Currently translated at 100.0% (2768 of 2768 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Italian) Currently translated at 100.0% (2392 of 2392 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/it/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2768 of 2768 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (2767 of 2767 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2767 of 2767 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Arabic) Currently translated at 100.0% (2767 of 2767 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (Russian) Currently translated at 100.0% (2392 of 2392 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ru/ * Translated using Weblate (Russian) Currently translated at 99.9% (2765 of 2767 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/ * Translated using Weblate (Russian) Currently translated at 99.9% (2765 of 2767 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/ * Translated using Weblate (Czech) Currently translated at 91.1% (2523 of 2767 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/cs/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2392 of 2392 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2767 of 2767 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Italian) Currently translated at 100.0% (2767 of 2767 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (German) Currently translated at 100.0% (2767 of 2767 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (German) Currently translated at 100.0% (2392 of 2392 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/de/ * Translated using Weblate (Czech) Currently translated at 94.6% (2619 of 2767 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/cs/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2392 of 2392 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/es/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2767 of 2767 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2392 of 2392 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2768 of 2768 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2783 of 2783 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Italian) Currently translated at 100.0% (2783 of 2783 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (2783 of 2783 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (German) Currently translated at 100.0% (2783 of 2783 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (Arabic) Currently translated at 100.0% (2783 of 2783 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (Turkish) Currently translated at 89.6% (2145 of 2392 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/tr/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2783 of 2783 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2392 of 2392 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2793 of 2793 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (German) Currently translated at 100.0% (2392 of 2392 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/de/ * Translated using Weblate (German) Currently translated at 100.0% (2793 of 2793 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (2793 of 2793 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2795 of 2795 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (2795 of 2795 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Italian) Currently translated at 100.0% (2795 of 2795 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (German) Currently translated at 100.0% (2795 of 2795 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (Arabic) Currently translated at 100.0% (2795 of 2795 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2795 of 2795 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Czech) Currently translated at 97.1% (2716 of 2795 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/cs/ * Translated using Weblate (Russian) Currently translated at 99.0% (2769 of 2795 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/ * Translated using Weblate (Italian) Currently translated at 100.0% (2800 of 2800 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (German) Currently translated at 100.0% (2800 of 2800 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (2800 of 2800 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2392 of 2392 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2800 of 2800 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2800 of 2800 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Arabic) Currently translated at 100.0% (2800 of 2800 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2800 of 2800 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2392 of 2392 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2392 of 2392 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2800 of 2800 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2392 of 2392 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2800 of 2800 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * process localizations --------- Co-authored-by: summoner001 Co-authored-by: jonnysemon Co-authored-by: Random Co-authored-by: 大王叫我来巡山 Co-authored-by: Skyward Copied Co-authored-by: slrslr Co-authored-by: mlanp Co-authored-by: zenobit Co-authored-by: Isaac ALejandro Lopez Co-authored-by: echoloji Co-authored-by: No name Co-authored-by: Андрей Абрамов Co-authored-by: Ghost of Sparta --- .../bg.xcloc/Localized Contents/bg.xliff | 110 +++++++-- .../cs.xcloc/Localized Contents/cs.xliff | 110 +++++++-- .../de.xcloc/Localized Contents/de.xliff | 172 +++++++++---- .../en.xcloc/Localized Contents/en.xliff | 139 +++++++++-- .../es.xcloc/Localized Contents/es.xliff | 116 +++++++-- .../fi.xcloc/Localized Contents/fi.xliff | 110 +++++++-- .../fr.xcloc/Localized Contents/fr.xliff | 110 +++++++-- .../hu.xcloc/Localized Contents/hu.xliff | 200 ++++++++++----- .../it.xcloc/Localized Contents/it.xliff | 118 +++++++-- .../ja.xcloc/Localized Contents/ja.xliff | 110 +++++++-- .../nl.xcloc/Localized Contents/nl.xliff | 110 +++++++-- .../pl.xcloc/Localized Contents/pl.xliff | 110 +++++++-- .../ru.xcloc/Localized Contents/ru.xliff | 116 +++++++-- .../th.xcloc/Localized Contents/th.xliff | 110 +++++++-- .../tr.xcloc/Localized Contents/tr.xliff | 120 +++++++-- .../uk.xcloc/Localized Contents/uk.xliff | 110 +++++++-- .../Localized Contents/zh-Hans.xliff | 110 +++++++-- .../SimpleX SE/hu.lproj/Localizable.strings | 4 +- apps/ios/bg.lproj/Localizable.strings | 2 +- apps/ios/cs.lproj/Localizable.strings | 2 +- apps/ios/de.lproj/Localizable.strings | 72 +++--- apps/ios/es.lproj/Localizable.strings | 16 +- apps/ios/fi.lproj/Localizable.strings | 2 +- apps/ios/fr.lproj/Localizable.strings | 2 +- apps/ios/hu.lproj/Localizable.strings | 96 ++++---- apps/ios/it.lproj/Localizable.strings | 18 +- apps/ios/ja.lproj/Localizable.strings | 2 +- apps/ios/nl.lproj/Localizable.strings | 2 +- apps/ios/pl.lproj/Localizable.strings | 2 +- apps/ios/ru.lproj/Localizable.strings | 16 +- apps/ios/th.lproj/Localizable.strings | 2 +- apps/ios/tr.lproj/Localizable.strings | 37 ++- apps/ios/uk.lproj/Localizable.strings | 2 +- apps/ios/zh-Hans.lproj/Localizable.strings | 2 +- .../commonMain/resources/MR/ar/strings.xml | 196 +++++++++++++-- .../commonMain/resources/MR/cs/strings.xml | 228 +++++++++++++++++- .../commonMain/resources/MR/de/strings.xml | 90 ++++--- .../commonMain/resources/MR/es/strings.xml | 31 ++- .../commonMain/resources/MR/hu/strings.xml | 120 +++++---- .../commonMain/resources/MR/it/strings.xml | 42 +++- .../commonMain/resources/MR/ru/strings.xml | 11 +- .../resources/MR/zh-rCN/strings.xml | 36 ++- 42 files changed, 2516 insertions(+), 598 deletions(-) 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 71a7a427be..364cee97e5 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -715,6 +715,10 @@ swipe action Активни връзки No comment provided by engineer. + + Add + No comment provided by engineer. + Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. No comment provided by engineer. @@ -739,6 +743,14 @@ swipe action Добави профил No comment provided by engineer. + + Add relays + No comment provided by engineer. + + + Add relays to restore message delivery. + No comment provided by engineer. + Add server Добави сървър @@ -784,10 +796,6 @@ swipe action Добавени сървъри за съобщения No comment provided by engineer. - - Adding relays will be supported later. - No comment provided by engineer. - Additional accent Допълнителен акцент @@ -1157,6 +1165,10 @@ swipe action Сесия на приложението No comment provided by engineer. + + App update required + alert title + App version Версия на приложението @@ -1578,6 +1590,14 @@ in your network alert button new chat action + + Cancel and delete channel + No comment provided by engineer. + + + Cancel creating channel? + alert title + Cancel migration Отмени миграцията @@ -1722,8 +1742,8 @@ alert subtitle Channel will be deleted for you - this cannot be undone! No comment provided by engineer. - - Channel will start working with %1$d of %2$d relays. Proceed? + + Channel will start working with %1$d of %2$d relays. Continue? alert message @@ -2215,6 +2235,14 @@ This is your own one-time link! Свързване с настолно устройство No comment provided by engineer. + + Connecting via channel name requires a newer app version. + alert message + + + Connecting via contact name requires a newer app version. + alert message + Connection Връзка @@ -2360,7 +2388,7 @@ This is your own one-time link! Continue Продължи - No comment provided by engineer. + alert action Contribute @@ -2780,6 +2808,10 @@ swipe action Изтрий за мен No comment provided by engineer. + + Delete from history + No comment provided by engineer. + Delete group Изтрий група @@ -3514,6 +3546,10 @@ chat item action Error adding relay alert title + + Error adding relays + alert title + Error adding server alert title @@ -3632,6 +3668,10 @@ chat item action Грешка при изтриване на базата данни alert title + + Error deleting message + alert title + Error deleting old database Грешка при изтриване на старата база данни @@ -5677,6 +5717,10 @@ The most secure encryption. Приложението няма kод за достъп Authentication unavailable + + No available relays + No comment provided by engineer. + No chat relays No comment provided by engineer. @@ -5788,6 +5832,10 @@ The most secure encryption. Няма получени или изпратени файлове No comment provided by engineer. + + No relays + No comment provided by engineer. + No servers for private message routing. servers error @@ -6373,6 +6421,10 @@ Error: %@ Please try to disable and re-enable notfications. token info + + Please upgrade the app. + alert message + Please wait for group moderators to review your request to join the group. snd group event chat item @@ -6480,10 +6532,6 @@ Error: %@ Private routing timeout alert title - - Proceed - alert action - Profile and server connections Профилни и сървърни връзки @@ -6845,6 +6893,14 @@ swipe action Relay test failed! No comment provided by engineer. + + Relay will be removed from channel - this cannot be undone! + alert message + + + Relays added: %@. + alert message + Reliability: many relays per channel. No comment provided by engineer. @@ -6885,9 +6941,9 @@ swipe action Премахване на паролата от keychain? No comment provided by engineer. - - Remove subscriber - No comment provided by engineer. + + Remove relay? + alert title Remove subscriber? @@ -8078,6 +8134,10 @@ report reason Statistics No comment provided by engineer. + + Status + No comment provided by engineer. + Stop Спри @@ -8608,10 +8668,19 @@ your contacts and groups. Тази група вече не съществува. No comment provided by engineer. + + This group requires a newer version of the app. Please update the app to join. + alert message +alert subtitle + This is a chat relay address, it cannot be used to connect. alert message + + This is the last active relay. Removing it will prevent message delivery to subscribers. + alert message + This is your link for channel %@! new chat action @@ -8920,10 +8989,18 @@ To connect, please ask your contact to create another connection link and check Непрочетено swipe action + + Unsupported channel name + alert title + Unsupported connection link conn error description + + Unsupported contact name + alert title + Up to 100 last messages are sent to new members. На новите членове се изпращат до последните 100 съобщения. @@ -9852,6 +9929,11 @@ Repeat connection request? Your network No comment provided by engineer. + + Your new channel %1$@ is connected to %2$d of %3$d relays. +If you cancel, the channel will be deleted - you can create it again. + alert message + Your preferences Вашите настройки 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 1cc44dd7cb..5ba29ec846 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -710,6 +710,10 @@ swipe action Aktivní spojení No comment provided by engineer. + + Add + No comment provided by engineer. + Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. No comment provided by engineer. @@ -732,6 +736,14 @@ swipe action Přidat profil No comment provided by engineer. + + Add relays + No comment provided by engineer. + + + Add relays to restore message delivery. + No comment provided by engineer. + Add server Přidat server @@ -776,10 +788,6 @@ swipe action Přidané servery zpráv No comment provided by engineer. - - Adding relays will be supported later. - No comment provided by engineer. - Additional accent Další zbarvení @@ -1140,6 +1148,10 @@ swipe action App session No comment provided by engineer. + + App update required + alert title + App version Verze aplikace @@ -1542,6 +1554,14 @@ in your network alert button new chat action + + Cancel and delete channel + No comment provided by engineer. + + + Cancel creating channel? + alert title + Cancel migration Zrušit přesun @@ -1686,8 +1706,8 @@ alert subtitle Channel will be deleted for you - this cannot be undone! No comment provided by engineer. - - Channel will start working with %1$d of %2$d relays. Proceed? + + Channel will start working with %1$d of %2$d relays. Continue? alert message @@ -2127,6 +2147,14 @@ Toto je váš vlastní jednorázový odkaz! Connecting to desktop No comment provided by engineer. + + Connecting via channel name requires a newer app version. + alert message + + + Connecting via contact name requires a newer app version. + alert message + Connection Připojení @@ -2264,7 +2292,7 @@ Toto je váš vlastní jednorázový odkaz! Continue Pokračovat - No comment provided by engineer. + alert action Contribute @@ -2673,6 +2701,10 @@ swipe action Smazat pro mě No comment provided by engineer. + + Delete from history + No comment provided by engineer. + Delete group Smazat skupinu @@ -3387,6 +3419,10 @@ chat item action Error adding relay alert title + + Error adding relays + alert title + Error adding server alert title @@ -3504,6 +3540,10 @@ chat item action Chyba při mazání databáze alert title + + Error deleting message + alert title + Error deleting old database Chyba při mazání staré databáze @@ -5489,6 +5529,10 @@ The most secure encryption. Žádné heslo aplikace Authentication unavailable + + No available relays + No comment provided by engineer. + No chat relays No comment provided by engineer. @@ -5599,6 +5643,10 @@ The most secure encryption. Žádné přijaté ani odeslané soubory No comment provided by engineer. + + No relays + No comment provided by engineer. + No servers for private message routing. servers error @@ -6169,6 +6217,10 @@ Error: %@ Please try to disable and re-enable notfications. token info + + Please upgrade the app. + alert message + Please wait for group moderators to review your request to join the group. snd group event chat item @@ -6275,10 +6327,6 @@ Error: %@ Private routing timeout alert title - - Proceed - alert action - Profile and server connections Profil a připojení k serveru @@ -6634,6 +6682,14 @@ swipe action Relay test failed! No comment provided by engineer. + + Relay will be removed from channel - this cannot be undone! + alert message + + + Relays added: %@. + alert message + Reliability: many relays per channel. No comment provided by engineer. @@ -6674,9 +6730,9 @@ swipe action Odstranit přístupovou frázi z klíčenek? No comment provided by engineer. - - Remove subscriber - No comment provided by engineer. + + Remove relay? + alert title Remove subscriber? @@ -7845,6 +7901,10 @@ report reason Statistics No comment provided by engineer. + + Status + No comment provided by engineer. + Stop Zastavit @@ -8367,10 +8427,19 @@ your contacts and groups. Tato skupina již neexistuje. No comment provided by engineer. + + This group requires a newer version of the app. Please update the app to join. + alert message +alert subtitle + This is a chat relay address, it cannot be used to connect. alert message + + This is the last active relay. Removing it will prevent message delivery to subscribers. + alert message + This is your link for channel %@! new chat action @@ -8670,10 +8739,18 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Nepřečtený swipe action + + Unsupported channel name + alert title + Unsupported connection link conn error description + + Unsupported contact name + alert title + Up to 100 last messages are sent to new members. No comment provided by engineer. @@ -9567,6 +9644,11 @@ Repeat connection request? Your network No comment provided by engineer. + + Your new channel %1$@ is connected to %2$d of %3$d relays. +If you cancel, the channel will be deleted - you can create it again. + alert message + Your preferences Vaše preference 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 872fafddd7..797a489c92 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -622,7 +622,7 @@ time interval A separate TCP connection will be used **for each chat profile you have in the app**. - **Für jedes von Ihnen in der App genutzte Chat-Profil** wird eine separate TCP-Verbindung genutzt. + **Für jedes von Ihnen in der App genutzte Chat-Profil** wird eine separate TCP-Verbindung (und SOCKS-Berechtigung) genutzt. No comment provided by engineer. @@ -736,6 +736,10 @@ swipe action Aktive Verbindungen No comment provided by engineer. + + Add + No comment provided by engineer. + Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. 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. @@ -761,6 +765,14 @@ swipe action Profil hinzufügen No comment provided by engineer. + + Add relays + No comment provided by engineer. + + + Add relays to restore message delivery. + No comment provided by engineer. + Add server Server hinzufügen @@ -806,11 +818,6 @@ swipe action Nachrichtenserver hinzugefügt No comment provided by engineer. - - Adding relays will be supported later. - Das Hinzufügen von Relais wird zu einem späteren Zeitpunkt unterstützt. - No comment provided by engineer. - Additional accent Erste Akzentfarbe @@ -963,7 +970,7 @@ swipe action All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays. - Alle Ihre Kontakte, Unterhaltungen und Dateien werden sicher verschlüsselt und in Daten-Paketen auf die konfigurierten XTFP-Relais hochgeladen. + Alle Ihre Kontakte, Unterhaltungen und Dateien werden sicher verschlüsselt und in Daten-Paketen auf die konfigurierten XTFP-Router hochgeladen. No comment provided by engineer. @@ -1123,12 +1130,12 @@ swipe action Always use private routing. - Sie nutzen immer privates Routing. + Immer privates Routing nutzen. No comment provided by engineer. Always use relay - Über ein Relais verbinden + Immer über einen Router verbinden No comment provided by engineer. @@ -1186,6 +1193,10 @@ swipe action App-Sitzung No comment provided by engineer. + + App update required + alert title + App version App Version @@ -1198,7 +1209,7 @@ swipe action Appearance - Erscheinungsbild + Darstellung No comment provided by engineer. @@ -1615,6 +1626,14 @@ in Ihrem Netzwerk alert button new chat action + + Cancel and delete channel + No comment provided by engineer. + + + Cancel creating channel? + alert title + Cancel migration Migration abbrechen @@ -1772,9 +1791,8 @@ alert subtitle Der Kanal wird für Sie gelöscht. Dies kann nicht rückgängig gemacht werden! No comment provided by engineer. - - Channel will start working with %1$d of %2$d relays. Proceed? - Der Kanal wird mit %1$d von %2$d Relais gestartet. Fortfahren? + + Channel will start working with %1$d of %2$d relays. Continue? alert message @@ -1864,7 +1882,7 @@ alert subtitle Chat profile - Benutzerprofil + Chat-Profil No comment provided by engineer. @@ -2070,7 +2088,7 @@ chat toolbar Conditions accepted on: %@. - Die Nutzungsbedingungen wurden akzeptiert am: %@. + Die Nutzungsbedingungen wurden am %@ akzeptiert. No comment provided by engineer. @@ -2095,12 +2113,12 @@ chat toolbar Conditions will be accepted on: %@. - Die Nutzungsbedingungen werden akzeptiert am: %@. + Die Nutzungsbedingungen werden am %@ akzeptiert. No comment provided by engineer. Conditions will be automatically accepted for enabled operators on: %@. - Die Nutzungsbedingungen der aktivierten Betreiber werden automatisch akzeptiert am: %@. + Die Nutzungsbedingungen der aktivierten Betreiber werden am %@ automatisch akzeptiert. No comment provided by engineer. @@ -2278,6 +2296,14 @@ Das ist Ihr eigener Einmal-Link! Mit dem Desktop verbinden No comment provided by engineer. + + Connecting via channel name requires a newer app version. + alert message + + + Connecting via contact name requires a newer app version. + alert message + Connection Verbindung @@ -2433,7 +2459,7 @@ Das ist Ihr eigener Einmal-Link! Continue Weiter - No comment provided by engineer. + alert action Contribute @@ -2879,6 +2905,10 @@ swipe action Nur bei mir löschen No comment provided by engineer. + + Delete from history + No comment provided by engineer. + Delete group Gruppe löschen @@ -3242,7 +3272,7 @@ alert button Do NOT use private routing. - Sie nutzen KEIN privates Routing. + KEIN privates Routing nutzen. No comment provided by engineer. @@ -3262,7 +3292,7 @@ alert button Do not use credentials with proxy. - Verwenden Sie keine Anmeldeinformationen mit einem Proxy. + Keine Anmeldeinformationen mit einem Proxy verwenden. No comment provided by engineer. @@ -3666,6 +3696,10 @@ chat item action Fehler beim Hinzufügen des Relais alert title + + Error adding relays + alert title + Error adding server Fehler beim Hinzufügen des Servers @@ -3796,6 +3830,10 @@ chat item action Fehler beim Löschen der Datenbank alert title + + Error deleting message + alert title + Error deleting old database Fehler beim Löschen der alten Datenbank @@ -5895,12 +5933,12 @@ wer mit wem kommuniziert New SOCKS credentials will be used every time you start the app. - Jedes Mal wenn Sie die App starten, werden neue SOCKS-Anmeldeinformationen genutzt + Bei jedem Neustart der App, werden neue SOCKS-Anmeldeinformationen genutzt. No comment provided by engineer. New SOCKS credentials will be used for each server. - Für jeden Server werden neue SOCKS-Anmeldeinformationen genutzt + Für jeden Server werden neue SOCKS-Anmeldeinformationen genutzt. No comment provided by engineer. @@ -6005,6 +6043,10 @@ Die sicherste Verschlüsselung. Kein App-Passwort Authentication unavailable + + No available relays + No comment provided by engineer. + No chat relays Keine Chat-Relais @@ -6130,9 +6172,13 @@ Die sicherste Verschlüsselung. Keine herunter- oder hochgeladenen Dateien No comment provided by engineer. + + No relays + No comment provided by engineer. + No servers for private message routing. - Keine Server für privates Nachrichten-Routing. + Keine Router für privates Nachrichten-Routing. servers error @@ -6778,6 +6824,10 @@ Fehler: %@ Bitte versuchen Sie, die Benachrichtigungen zu deaktivieren und wieder zu aktivieren. token info + + Please upgrade the app. + alert message + Please wait for group moderators to review your request to join the group. Bitte warten Sie auf die Überprüfung Ihrer Anfrage durch die Gruppen-Moderatoren, um der Gruppe beitreten zu können. @@ -6855,7 +6905,7 @@ Fehler: %@ Privacy: for owners and subscribers. - Privatsphäre: für Besitzer und Abonnenten. + Privatsphäre: Für Eigentümer und Abonnenten. No comment provided by engineer. @@ -6903,11 +6953,6 @@ Fehler: %@ Zeitüberschreitung der privaten Routing-Sitzung alert title - - Proceed - Fortfahren - alert action - Profile and server connections Profil und Serververbindungen @@ -7011,7 +7056,7 @@ Fehler: %@ Protect your IP address from the messaging relays chosen by your contacts. Enable in *Network & servers* settings. - Schützen Sie Ihre IP-Adresse vor den Nachrichten-Relais, die Ihre Kontakte ausgewählt haben. + Schützen Sie Ihre IP-Adresse vor den Nachrichten-Routern, die Ihre Kontakte ausgewählt haben. Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. No comment provided by engineer. @@ -7294,7 +7339,7 @@ swipe action Relay server protects your IP address, but it can observe the duration of the call. - Relais-Server schützen Ihre IP-Adresse, aber sie können die Anrufdauer erfassen. + Relais-Server schützen Ihre IP-Adresse, können aber die Anrufdauer erfassen. No comment provided by engineer. @@ -7302,9 +7347,17 @@ swipe action Relais-Test fehlgeschlagen! No comment provided by engineer. + + Relay will be removed from channel - this cannot be undone! + alert message + + + Relays added: %@. + alert message + Reliability: many relays per channel. - Zuverlässigkeit: mehrere Relais pro Kanal. + Zuverlässigkeit: Mehrere Relais pro Kanal. No comment provided by engineer. @@ -7347,10 +7400,9 @@ swipe action Passwort aus dem Schlüsselbund entfernen? No comment provided by engineer. - - Remove subscriber - Abonnent entfernen - No comment provided by engineer. + + Remove relay? + alert title Remove subscriber? @@ -7850,7 +7902,7 @@ chat item action Security: owners hold channel keys. - Sicherheit: Eigentümer besitzen die Kanalschlüssel. + Sicherheit: Nur die Eigentümer des Kanals besitzen die Schlüssel. No comment provided by engineer. @@ -7945,12 +7997,12 @@ chat item action Send messages directly when IP address is protected and your or destination server does not support private routing. - Nachrichten werden direkt versendet, wenn die IP-Adresse geschützt ist, und Ihr oder der Zielserver kein privates Routing unterstützt. + Nachrichten werden direkt versendet, wenn die IP-Adresse geschützt ist, und Ihr oder der Ziel-Server kein privates Routing unterstützt. No comment provided by engineer. Send messages directly when your or destination server does not support private routing. - Nachrichten werden direkt versendet, wenn Ihr oder der Zielserver kein privates Routing unterstützt. + Nachrichten werden direkt versendet, wenn Ihr oder der Ziel-Server kein privates Routing unterstützt. No comment provided by engineer. @@ -8150,7 +8202,7 @@ chat item action Server requires authorization to connect to relay, check password. - Der Server erfordert eine Autorisierung, um eine Verbindung zum Relais herzustellen. Bitte Passwort überprüfen. + Der Server erfordert eine Autorisierung, um eine Verbindung zum Router herzustellen. Bitte Passwort überprüfen. relay test error @@ -8366,7 +8418,7 @@ chat item action Share relay address - Relais-Adresse teilen + Router-Adresse teilen No comment provided by engineer. @@ -8667,6 +8719,10 @@ report reason Statistiken No comment provided by engineer. + + Status + No comment provided by engineer. + Stop Beenden @@ -9254,11 +9310,20 @@ in dem Sie Ihre Kontakte und Gruppen besitzen. Diese Gruppe existiert nicht mehr. No comment provided by engineer. + + This group requires a newer version of the app. Please update the app to join. + alert message +alert subtitle + This is a chat relay address, it cannot be used to connect. Dies ist eine Chat‑Relais-Adresse, welche nicht zum Verbinden verwendet werden kann. alert message + + This is the last active relay. Removing it will prevent message delivery to subscribers. + alert message + This is your link for channel %@! Dies ist Ihr Link für den Kanal %@! @@ -9336,7 +9401,7 @@ in dem Sie Ihre Kontakte und Gruppen besitzen. To protect your IP address, private routing uses your SMP servers to deliver messages. - Zum Schutz Ihrer IP-Adresse, wird für die Nachrichten-Auslieferung privates Routing über Ihre konfigurierten SMP-Server genutzt. + Zum Schutz Ihrer IP-Adresse, wird für die Nachrichten-Auslieferung privates Routing über Ihre konfigurierten SMP-Router genutzt. No comment provided by engineer. @@ -9593,11 +9658,19 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Ungelesen swipe action + + Unsupported channel name + alert title + Unsupported connection link Verbindungs-Link wird nicht unterstützt conn error description + + Unsupported contact name + alert title + Up to 100 last messages are sent to new members. Bis zu 100 der letzten Nachrichten werden an neue Mitglieder gesendet. @@ -9785,12 +9858,12 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Use private routing with unknown servers when IP address is not protected. - Sie nutzen privates Routing mit unbekannten Servern, wenn Ihre IP-Adresse nicht geschützt ist. + Bei unbekannten Servern privates Routing nutzen, wenn Ihre IP-Adresse nicht geschützt ist. No comment provided by engineer. Use private routing with unknown servers. - Sie nutzen privates Routing mit unbekannten Servern. + Bei unbekannten Servern privates Routing nutzen. No comment provided by engineer. @@ -10120,7 +10193,7 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Without Tor or VPN, your IP address will be visible to these XFTP relays: %@. - Ohne Tor- oder VPN-Nutzung wird Ihre IP-Adresse für diese XFTP-Relais sichtbar sein: %@. + Ohne Tor- oder VPN-Nutzung wird Ihre IP-Adresse für diese XFTP-Router sichtbar sein: %@. alert message @@ -10237,7 +10310,7 @@ Verbindungsanfrage wiederholen? You can change it in Appearance settings. - Kann von Ihnen in den Erscheinungsbild-Einstellungen geändert werden. + Sie können dies in den Einstellungen unter „Darstellung“ ändern. No comment provided by engineer. @@ -10606,6 +10679,11 @@ Verbindungsanfrage wiederholen? Ihr Netzwerk No comment provided by engineer. + + Your new channel %1$@ is connected to %2$d of %3$d relays. +If you cancel, the channel will be deleted - you can create it again. + alert message + Your preferences Ihre Präferenzen @@ -11659,7 +11737,7 @@ Zuletzt empfangene Nachricht: %2$@ unknown servers - Unbekannte Relais + Unbekannte Server 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 5e95cf39cc..c108dcc904 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -736,6 +736,11 @@ swipe action Active connections No comment provided by engineer. + + Add + Add + No comment provided by engineer. + Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. @@ -761,6 +766,16 @@ swipe action Add profile No comment provided by engineer. + + Add relays + Add relays + No comment provided by engineer. + + + Add relays to restore message delivery. + Add relays to restore message delivery. + No comment provided by engineer. + Add server Add server @@ -806,11 +821,6 @@ swipe action Added message servers No comment provided by engineer. - - Adding relays will be supported later. - Adding relays will be supported later. - No comment provided by engineer. - Additional accent Additional accent @@ -1186,6 +1196,11 @@ swipe action App session No comment provided by engineer. + + App update required + App update required + alert title + App version App version @@ -1615,6 +1630,16 @@ in your network alert button new chat action + + Cancel and delete channel + Cancel and delete channel + No comment provided by engineer. + + + Cancel creating channel? + Cancel creating channel? + alert title + Cancel migration Cancel migration @@ -1772,9 +1797,9 @@ alert subtitle Channel will be deleted for you - this cannot be undone! No comment provided by engineer. - - Channel will start working with %1$d of %2$d relays. Proceed? - Channel will start working with %1$d of %2$d relays. Proceed? + + Channel will start working with %1$d of %2$d relays. Continue? + Channel will start working with %1$d of %2$d relays. Continue? alert message @@ -2278,6 +2303,16 @@ This is your own one-time link! Connecting to desktop No comment provided by engineer. + + Connecting via channel name requires a newer app version. + Connecting via channel name requires a newer app version. + alert message + + + Connecting via contact name requires a newer app version. + Connecting via contact name requires a newer app version. + alert message + Connection Connection @@ -2433,7 +2468,7 @@ This is your own one-time link! Continue Continue - No comment provided by engineer. + alert action Contribute @@ -2879,6 +2914,11 @@ swipe action Delete for me No comment provided by engineer. + + Delete from history + Delete from history + No comment provided by engineer. + Delete group Delete group @@ -3666,6 +3706,11 @@ chat item action Error adding relay alert title + + Error adding relays + Error adding relays + alert title + Error adding server Error adding server @@ -3796,6 +3841,11 @@ chat item action Error deleting database alert title + + Error deleting message + Error deleting message + alert title + Error deleting old database Error deleting old database @@ -6005,6 +6055,11 @@ The most secure encryption. No app password Authentication unavailable + + No available relays + No available relays + No comment provided by engineer. + No chat relays No chat relays @@ -6130,6 +6185,11 @@ The most secure encryption. No received or sent files No comment provided by engineer. + + No relays + No relays + No comment provided by engineer. + No servers for private message routing. No servers for private message routing. @@ -6778,6 +6838,11 @@ Error: %@ Please try to disable and re-enable notfications. token info + + Please upgrade the app. + Please upgrade the app. + alert message + Please wait for group moderators to review your request to join the group. Please wait for group moderators to review your request to join the group. @@ -6903,11 +6968,6 @@ Error: %@ Private routing timeout alert title - - Proceed - Proceed - alert action - Profile and server connections Profile and server connections @@ -7302,6 +7362,16 @@ swipe action Relay test failed! No comment provided by engineer. + + Relay will be removed from channel - this cannot be undone! + Relay will be removed from channel - this cannot be undone! + alert message + + + Relays added: %@. + Relays added: %@. + alert message + Reliability: many relays per channel. Reliability: many relays per channel. @@ -7347,10 +7417,10 @@ swipe action Remove passphrase from keychain? No comment provided by engineer. - - Remove subscriber - Remove subscriber - No comment provided by engineer. + + Remove relay? + Remove relay? + alert title Remove subscriber? @@ -8667,6 +8737,11 @@ report reason Statistics No comment provided by engineer. + + Status + Status + No comment provided by engineer. + Stop Stop @@ -9254,11 +9329,22 @@ your contacts and groups. This group no longer exists. No comment provided by engineer. + + This group requires a newer version of the app. Please update the app to join. + This group requires a newer version of the app. Please update the app to join. + alert message +alert subtitle + This is a chat relay address, it cannot be used to connect. This is a chat relay address, it cannot be used to connect. alert message + + This is the last active relay. Removing it will prevent message delivery to subscribers. + This is the last active relay. Removing it will prevent message delivery to subscribers. + alert message + This is your link for channel %@! This is your link for channel %@! @@ -9593,11 +9679,21 @@ To connect, please ask your contact to create another connection link and check Unread swipe action + + Unsupported channel name + Unsupported channel name + alert title + Unsupported connection link Unsupported connection link conn error description + + Unsupported contact name + Unsupported contact name + alert title + Up to 100 last messages are sent to new members. Up to 100 last messages are sent to new members. @@ -10606,6 +10702,13 @@ Repeat connection request? Your network No comment provided by engineer. + + Your new channel %1$@ is connected to %2$d of %3$d relays. +If you cancel, the channel will be deleted - you can create it again. + Your new channel %1$@ is connected to %2$d of %3$d relays. +If you cancel, the channel will be deleted - you can create it again. + alert message + Your preferences Your preferences 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 43d3895cc4..d93e692a63 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -7,7 +7,7 @@ (can be copied) - (puede copiarse) + (puede ser copiado) No comment provided by engineer. @@ -736,6 +736,10 @@ swipe action Conexiones activas No comment provided by engineer. + + Add + No comment provided by engineer. + Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. Añade la dirección a tu perfil para que tus contactos SimpleX puedan compartirla con otros. La actualización del perfil se enviará a tus contactos SimpleX. @@ -761,6 +765,14 @@ swipe action Añadir perfil No comment provided by engineer. + + Add relays + No comment provided by engineer. + + + Add relays to restore message delivery. + No comment provided by engineer. + Add server Añadir servidor @@ -806,11 +818,6 @@ swipe action Servidores de mensajes añadidos No comment provided by engineer. - - Adding relays will be supported later. - Añadir servidores estará disponible en una versión posterior. - No comment provided by engineer. - Additional accent Acento adicional @@ -1186,6 +1193,10 @@ swipe action por sesión No comment provided by engineer. + + App update required + alert title + App version Versión de la aplicación @@ -1615,6 +1626,14 @@ en tu red alert button new chat action + + Cancel and delete channel + No comment provided by engineer. + + + Cancel creating channel? + alert title + Cancel migration Cancelar migración @@ -1772,9 +1791,8 @@ alert subtitle El canal será eliminado para tí. ¡No puede deshacerse! No comment provided by engineer. - - Channel will start working with %1$d of %2$d relays. Proceed? - El canal comenzará a funcionar con %1$d de %2$d servidores. ¿Continuar? + + Channel will start working with %1$d of %2$d relays. Continue? alert message @@ -2278,6 +2296,14 @@ This is your own one-time link! Conectando con ordenador No comment provided by engineer. + + Connecting via channel name requires a newer app version. + alert message + + + Connecting via contact name requires a newer app version. + alert message + Connection Conexión @@ -2433,7 +2459,7 @@ This is your own one-time link! Continue Continuar - No comment provided by engineer. + alert action Contribute @@ -2879,6 +2905,10 @@ swipe action Eliminar para mí No comment provided by engineer. + + Delete from history + No comment provided by engineer. + Delete group Eliminar grupo @@ -3666,6 +3696,10 @@ chat item action Error al añadir el servidor alert title + + Error adding relays + alert title + Error adding server Error al añadir servidor @@ -3796,6 +3830,10 @@ chat item action Error al eliminar base de datos alert title + + Error deleting message + alert title + Error deleting old database Error al eliminar base de datos antigua @@ -6005,6 +6043,10 @@ El cifrado más seguro. Sin contraseña de la aplicación Authentication unavailable + + No available relays + No comment provided by engineer. + No chat relays Sin servidores de chat @@ -6130,6 +6172,10 @@ El cifrado más seguro. Sin archivos recibidos o enviados No comment provided by engineer. + + No relays + No comment provided by engineer. + No servers for private message routing. Sin servidores para enrutamiento privado. @@ -6778,6 +6824,10 @@ Error: %@ Por favor, intenta desactivar y reactivar las notificaciones. token info + + Please upgrade the app. + alert message + Please wait for group moderators to review your request to join the group. Por favor, espera a que tu solicitud sea revisada por los moderadores del grupo. @@ -6903,11 +6953,6 @@ Error: %@ Timeout enrutamiento privado alert title - - Proceed - Continuar - alert action - Profile and server connections Eliminar perfil y conexiones @@ -7302,6 +7347,14 @@ swipe action ¡El test del servidor ha fallado! No comment provided by engineer. + + Relay will be removed from channel - this cannot be undone! + alert message + + + Relays added: %@. + alert message + Reliability: many relays per channel. Fiabilidad: muchos servidores por canal. @@ -7347,10 +7400,9 @@ swipe action ¿Eliminar contraseña de Keychain? No comment provided by engineer. - - Remove subscriber - Eliminar suscriptor - No comment provided by engineer. + + Remove relay? + alert title Remove subscriber? @@ -8667,6 +8719,10 @@ report reason Estadísticas No comment provided by engineer. + + Status + No comment provided by engineer. + Stop Parar @@ -9254,11 +9310,20 @@ y los contactos son tuyos. Este grupo ya no existe. No comment provided by engineer. + + This group requires a newer version of the app. Please update the app to join. + alert message +alert subtitle + This is a chat relay address, it cannot be used to connect. Esto es una dirección de servidor, no puede usarse para conectar. alert message + + This is the last active relay. Removing it will prevent message delivery to subscribers. + alert message + This is your link for channel %@! Este es tu enlace para el canal %@! @@ -9593,11 +9658,19 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión No leído swipe action + + Unsupported channel name + alert title + Unsupported connection link Enlace de conexión no compatible conn error description + + Unsupported contact name + alert title + Up to 100 last messages are sent to new members. Hasta 100 últimos mensajes son enviados a los miembros nuevos. @@ -10606,6 +10679,11 @@ Repeat connection request? Tu red No comment provided by engineer. + + Your new channel %1$@ is connected to %2$d of %3$d relays. +If you cancel, the channel will be deleted - you can create it again. + alert message + Your preferences Mis preferencias 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 892f686bd2..5656516b7d 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -667,6 +667,10 @@ swipe action Active connections No comment provided by engineer. + + Add + No comment provided by engineer. + Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. No comment provided by engineer. @@ -688,6 +692,14 @@ swipe action Lisää profiili No comment provided by engineer. + + Add relays + No comment provided by engineer. + + + Add relays to restore message delivery. + No comment provided by engineer. + Add server Lisää palvelin @@ -728,10 +740,6 @@ swipe action Added message servers No comment provided by engineer. - - Adding relays will be supported later. - No comment provided by engineer. - Additional accent No comment provided by engineer. @@ -1069,6 +1077,10 @@ swipe action App session No comment provided by engineer. + + App update required + alert title + App version Sovellusversio @@ -1442,6 +1454,14 @@ in your network alert button new chat action + + Cancel and delete channel + No comment provided by engineer. + + + Cancel creating channel? + alert title + Cancel migration No comment provided by engineer. @@ -1580,8 +1600,8 @@ alert subtitle Channel will be deleted for you - this cannot be undone! No comment provided by engineer. - - Channel will start working with %1$d of %2$d relays. Proceed? + + Channel will start working with %1$d of %2$d relays. Continue? alert message @@ -2014,6 +2034,14 @@ This is your own one-time link! Connecting to desktop No comment provided by engineer. + + Connecting via channel name requires a newer app version. + alert message + + + Connecting via contact name requires a newer app version. + alert message + Connection Yhteys @@ -2151,7 +2179,7 @@ This is your own one-time link! Continue Jatka - No comment provided by engineer. + alert action Contribute @@ -2560,6 +2588,10 @@ swipe action Poista minulta No comment provided by engineer. + + Delete from history + No comment provided by engineer. + Delete group Poista ryhmä @@ -3273,6 +3305,10 @@ chat item action Error adding relay alert title + + Error adding relays + alert title + Error adding server alert title @@ -3389,6 +3425,10 @@ chat item action Virhe tietokannan poistamisessa alert title + + Error deleting message + alert title + Error deleting old database Virhe vanhan tietokannan poistamisessa @@ -5372,6 +5412,10 @@ The most secure encryption. Ei sovelluksen salasanaa Authentication unavailable + + No available relays + No comment provided by engineer. + No chat relays No comment provided by engineer. @@ -5482,6 +5526,10 @@ The most secure encryption. Ei vastaanotettuja tai lähetettyjä tiedostoja No comment provided by engineer. + + No relays + No comment provided by engineer. + No servers for private message routing. servers error @@ -6049,6 +6097,10 @@ Error: %@ Please try to disable and re-enable notfications. token info + + Please upgrade the app. + alert message + Please wait for group moderators to review your request to join the group. snd group event chat item @@ -6155,10 +6207,6 @@ Error: %@ Private routing timeout alert title - - Proceed - alert action - Profile and server connections Profiili- ja palvelinyhteydet @@ -6514,6 +6562,14 @@ swipe action Relay test failed! No comment provided by engineer. + + Relay will be removed from channel - this cannot be undone! + alert message + + + Relays added: %@. + alert message + Reliability: many relays per channel. No comment provided by engineer. @@ -6554,9 +6610,9 @@ swipe action Poista tunnuslause avainnipusta? No comment provided by engineer. - - Remove subscriber - No comment provided by engineer. + + Remove relay? + alert title Remove subscriber? @@ -7723,6 +7779,10 @@ report reason Statistics No comment provided by engineer. + + Status + No comment provided by engineer. + Stop Lopeta @@ -8242,10 +8302,19 @@ your contacts and groups. Tätä ryhmää ei enää ole olemassa. No comment provided by engineer. + + This group requires a newer version of the app. Please update the app to join. + alert message +alert subtitle + This is a chat relay address, it cannot be used to connect. alert message + + This is the last active relay. Removing it will prevent message delivery to subscribers. + alert message + This is your link for channel %@! new chat action @@ -8544,10 +8613,18 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Lukematon swipe action + + Unsupported channel name + alert title + Unsupported connection link conn error description + + Unsupported contact name + alert title + Up to 100 last messages are sent to new members. No comment provided by engineer. @@ -9439,6 +9516,11 @@ Repeat connection request? Your network No comment provided by engineer. + + Your new channel %1$@ is connected to %2$d of %3$d relays. +If you cancel, the channel will be deleted - you can create it again. + alert message + Your preferences Asetuksesi 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 be6a766ca1..3ea0859d76 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -715,6 +715,10 @@ swipe action Connections actives No comment provided by engineer. + + Add + No comment provided by engineer. + Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. No comment provided by engineer. @@ -739,6 +743,14 @@ swipe action Ajouter un profil No comment provided by engineer. + + Add relays + No comment provided by engineer. + + + Add relays to restore message delivery. + No comment provided by engineer. + Add server Ajouter un serveur @@ -784,10 +796,6 @@ swipe action Ajout de serveurs de messages No comment provided by engineer. - - Adding relays will be supported later. - No comment provided by engineer. - Additional accent Accent additionnel @@ -1156,6 +1164,10 @@ swipe action Session de l'app No comment provided by engineer. + + App update required + alert title + App version Version de l'app @@ -1571,6 +1583,14 @@ in your network alert button new chat action + + Cancel and delete channel + No comment provided by engineer. + + + Cancel creating channel? + alert title + Cancel migration Annuler le transfert @@ -1715,8 +1735,8 @@ alert subtitle Channel will be deleted for you - this cannot be undone! No comment provided by engineer. - - Channel will start working with %1$d of %2$d relays. Proceed? + + Channel will start working with %1$d of %2$d relays. Continue? alert message @@ -2203,6 +2223,14 @@ Il s'agit de votre propre lien unique ! Connexion au bureau No comment provided by engineer. + + Connecting via channel name requires a newer app version. + alert message + + + Connecting via contact name requires a newer app version. + alert message + Connection Connexion @@ -2355,7 +2383,7 @@ Il s'agit de votre propre lien unique ! Continue Continuer - No comment provided by engineer. + alert action Contribute @@ -2791,6 +2819,10 @@ swipe action Supprimer pour moi No comment provided by engineer. + + Delete from history + No comment provided by engineer. + Delete group Supprimer le groupe @@ -3558,6 +3590,10 @@ chat item action Error adding relay alert title + + Error adding relays + alert title + Error adding server Erreur lors de l'ajout du serveur @@ -3683,6 +3719,10 @@ chat item action Erreur lors de la suppression de la base de données alert title + + Error deleting message + alert title + Error deleting old database Erreur lors de la suppression de l'ancienne base de données @@ -5816,6 +5856,10 @@ The most secure encryption. Pas de mot de passe pour l'app Authentication unavailable + + No available relays + No comment provided by engineer. + No chat relays No comment provided by engineer. @@ -5933,6 +5977,10 @@ The most secure encryption. Aucun fichier reçu ou envoyé No comment provided by engineer. + + No relays + No comment provided by engineer. + No servers for private message routing. Pas de serveurs pour le routage privé des messages. @@ -6541,6 +6589,10 @@ Erreur : %@ Please try to disable and re-enable notfications. token info + + Please upgrade the app. + alert message + Please wait for group moderators to review your request to join the group. snd group event chat item @@ -6656,10 +6708,6 @@ Erreur : %@ Private routing timeout alert title - - Proceed - alert action - Profile and server connections Profil et connexions au serveur @@ -7038,6 +7086,14 @@ swipe action Relay test failed! No comment provided by engineer. + + Relay will be removed from channel - this cannot be undone! + alert message + + + Relays added: %@. + alert message + Reliability: many relays per channel. No comment provided by engineer. @@ -7080,9 +7136,9 @@ swipe action Supprimer la phrase secrète de la keychain ? No comment provided by engineer. - - Remove subscriber - No comment provided by engineer. + + Remove relay? + alert title Remove subscriber? @@ -8339,6 +8395,10 @@ report reason Statistiques No comment provided by engineer. + + Status + No comment provided by engineer. + Stop Arrêter @@ -8889,10 +8949,19 @@ your contacts and groups. Ce groupe n'existe plus. No comment provided by engineer. + + This group requires a newer version of the app. Please update the app to join. + alert message +alert subtitle + This is a chat relay address, it cannot be used to connect. alert message + + This is the last active relay. Removing it will prevent message delivery to subscribers. + alert message + This is your link for channel %@! new chat action @@ -9215,10 +9284,18 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Non lu swipe action + + Unsupported channel name + alert title + Unsupported connection link conn error description + + Unsupported contact name + alert title + Up to 100 last messages are sent to new members. Les 100 derniers messages sont envoyés aux nouveaux membres. @@ -10185,6 +10262,11 @@ Répéter la demande de connexion ? Your network No comment provided by engineer. + + Your new channel %1$@ is connected to %2$d of %3$d relays. +If you cancel, the channel will be deleted - you can create it again. + alert message + Your preferences Vos préférences 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 7723cabdcb..129436ecb0 100644 --- a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff +++ b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff @@ -257,12 +257,12 @@ channel relay bar %1$d/%2$d relays connected - %1$d/%2$d átjátszó kapcsolódva + %1$d/%2$d átjátszó kapcsolódott channel subscriber relay bar progress %1$d/%2$d relays connected, %3$d errors - %1$d/%2$d átjátszó kapcsolódva, %3$d hiba + %1$d/%2$d átjátszó kapcsolódott, %3$d hiba channel subscriber relay bar @@ -412,17 +412,17 @@ channel relay bar **Create 1-time link**: to create and share a new invitation link. - **Partner hozzáadása:** új meghívási hivatkozás létrehozásához, vagy egy kapott hivatkozáson keresztül történő kapcsolódáshoz. + **Partner hozzáadása**: új meghívási hivatkozás létrehozásához, vagy egy kapott hivatkozáson keresztül történő kapcsolódáshoz. No comment provided by engineer. **Create group**: to create a new group. - **Csoport létrehozása:** új csoport létrehozásához. + **Csoport létrehozása**: új csoport létrehozásához. No comment provided by engineer. **More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata. - **Privátabb:** 20 percenként ellenőrzi az új üzeneteket. Az eszköztoken meg lesz osztva a SimpleX Chat kiszolgálóval, de az nem, hogy hány partnere vagy üzenete van. + **Privátabb**: 20 percenként ellenőrzi az új üzeneteket. Az eszköztoken meg lesz osztva a SimpleX Chat kiszolgálóval, de az nem, hogy hány partnere vagy üzenete van. No comment provided by engineer. @@ -432,17 +432,17 @@ channel relay bar **Please note**: using the same database on two devices will break the decryption of messages from your connections, as a security protection. - **Megjegyzés:** ha két eszközön is ugyanazt az adatbázist használja, akkor biztonsági védelemként megszakítja a partnereitől érkező üzenetek visszafejtését. + **Megjegyzés**: ha két eszközön is ugyanazt az adatbázist használja, akkor biztonsági védelemként megszakítja a partnereitől érkező üzenetek visszafejtését. No comment provided by engineer. **Please note**: you will NOT be able to recover or change passphrase if you lose it. - **Megjegyzés:** NEM fogja tudni helyreállítani vagy módosítani a jelmondatot abban az esetben, ha elveszíti. + **Megjegyzés**: NEM fogja tudni helyreállítani vagy módosítani a jelmondatot abban az esetben, ha elveszíti. No comment provided by engineer. **Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from. - **Megjegyzés:** az eszköztoken és az értesítések el lesznek küldve a SimpleX Chat értesítési kiszolgálóra, kivéve az üzenet tartalma, mérete vagy az, hogy kitől származik. + **Megjegyzés**: az eszköztoken és az értesítések el lesznek küldve a SimpleX Chat értesítési kiszolgálóra, kivéve az üzenet tartalma, mérete vagy az, hogy kitől származik. No comment provided by engineer. @@ -457,12 +457,12 @@ channel relay bar **Warning**: Instant push notifications require passphrase saved in Keychain. - **Figyelmeztetés:** Az azonnali leküldéses értesítésekhez a kulcstartóban tárolt jelmondat megadása szükséges. + **Figyelmeztetés**: Az azonnali leküldéses értesítésekhez a kulcstartóban tárolt jelmondat megadása szükséges. No comment provided by engineer. **Warning**: the archive will be removed. - **Figyelmeztetés:** az archívum el lesz távolítva. + **Figyelmeztetés**: az archívum el lesz távolítva. No comment provided by engineer. @@ -629,7 +629,7 @@ time interval A separate TCP connection will be used **for each contact and group member**. **Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail. **Az összes partneréhez és csoporttaghoz** külön TCP-kapcsolat lesz használva. -**Megjegyzés:** ha sok kapcsolata van, akkor az akkumulátor-használat és az adatforgalom jelentősen megnövekedhet, és néhány kapcsolódási kísérlet sikertelen lehet. +**Megjegyzés**: ha sok kapcsolata van, akkor az akkumulátor-használat és az adatforgalom jelentősen megnövekedhet, és néhány kapcsolódási kísérlet sikertelen lehet. No comment provided by engineer. @@ -718,7 +718,7 @@ swipe action Acknowledged - Visszaigazolt + Visszaigazolva No comment provided by engineer. @@ -736,6 +736,10 @@ swipe action Aktív kapcsolatok száma No comment provided by engineer. + + Add + No comment provided by engineer. + Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. Cím hozzáadása a profilhoz, hogy a SimpleX partnerei megoszthassák másokkal. A profilfrissítés el lesz küldve a SimpleX partnerei számára. @@ -761,6 +765,14 @@ swipe action Profil hozzáadása No comment provided by engineer. + + Add relays + No comment provided by engineer. + + + Add relays to restore message delivery. + No comment provided by engineer. + Add server Kiszolgáló hozzáadása @@ -806,11 +818,6 @@ swipe action Hozzáadott üzenetkiszolgálók No comment provided by engineer. - - Adding relays will be supported later. - Az átjátszók hozzáadása később lesz támogatott. - No comment provided by engineer. - Additional accent További kiemelőszín @@ -1123,12 +1130,12 @@ swipe action Always use private routing. - Mindig legyen használva privát útválasztás. + Privát útválasztás használata minden esetben. No comment provided by engineer. Always use relay - Mindig legyen használva átjátszó + Átjátszó használata minden esetben No comment provided by engineer. @@ -1186,6 +1193,10 @@ swipe action Alkalmazás munkamenete No comment provided by engineer. + + App update required + alert title + App version Alkalmazás verziója @@ -1360,7 +1371,7 @@ a saját hálózatában Be free in your network. - Legyen szabad a saját hálózatában. + Váljon szabaddá a saját hálózatában. No comment provided by engineer. @@ -1470,7 +1481,7 @@ a saját hálózatában Blocked by admin - Letiltva az adminisztrátor által + Az adminisztrátor letiltotta No comment provided by engineer. @@ -1615,6 +1626,14 @@ a saját hálózatában alert button new chat action + + Cancel and delete channel + No comment provided by engineer. + + + Cancel creating channel? + alert title + Cancel migration Átköltöztetés visszavonása @@ -1754,7 +1773,7 @@ alert subtitle Channel profile was changed. If you save it, the updated profile will be sent to channel subscribers. - A csatornaprofil módosult. Ha menti, akkor a frissített profil el lesz küldve a csatorna feliratkozóinak. + Csatornaprofil módosítva. Ha menti, akkor a frissített profil el lesz küldve a csatorna feliratkozóinak. alert message @@ -1772,9 +1791,8 @@ alert subtitle A csatorna törölve lesz az Ön számára – ez a művelet nem vonható vissza! No comment provided by engineer. - - Channel will start working with %1$d of %2$d relays. Proceed? - A csatorna %2$d átjátszóból %1$d használatával kezd el működni. Folytatja? + + Channel will start working with %1$d of %2$d relays. Continue? alert message @@ -2278,6 +2296,14 @@ Ez a saját egyszer használható meghívója! Társítás számítógéppel No comment provided by engineer. + + Connecting via channel name requires a newer app version. + alert message + + + Connecting via contact name requires a newer app version. + alert message + Connection Kapcsolat @@ -2433,7 +2459,7 @@ Ez a saját egyszer használható meghívója! Continue Folytatás - No comment provided by engineer. + alert action Contribute @@ -2701,7 +2727,7 @@ Ez a saját egyszer használható meghívója! Database passphrase is different from saved in the keychain. - Az adatbázis jelmondata nem egyezik a kulcstartóba mentettől. + Az adatbázis jelmondata eltér a kulcstartóban tárolttól. No comment provided by engineer. @@ -2879,6 +2905,10 @@ swipe action Csak nálam No comment provided by engineer. + + Delete from history + No comment provided by engineer. + Delete group Csoport törlése @@ -3242,7 +3272,7 @@ alert button Do NOT use private routing. - NE legyen használva privát útválasztás. + Privát útválasztás használatának elkerülése. No comment provided by engineer. @@ -3533,7 +3563,7 @@ chat item action Encrypted message: keychain error - Titkosított üzenet: kulcstartó hiba + Titkosított üzenet: kulcstartóhiba notification @@ -3653,7 +3683,7 @@ chat item action Error accepting member - Hiba a tag befogadásakor + Hiba történt a tag befogadásakor alert title @@ -3663,7 +3693,11 @@ chat item action Error adding relay - Hiba az átjátszó hozzáadásakor + Hiba történt az átjátszó hozzáadásakor + alert title + + + Error adding relays alert title @@ -3683,7 +3717,7 @@ chat item action Error changing chat profile - Hiba a csevegési profil módosításakor + Hiba történt a csevegési profil módosításakor alert title @@ -3728,7 +3762,7 @@ chat item action Error creating channel - Hiba a csatorna létrehozásakor + Hiba történt a csatorna létrehozásakor alert title @@ -3773,7 +3807,7 @@ chat item action Error deleting chat - Hiba a csevegés törlésekor + Hiba történt a csevegés törlésekor alert title @@ -3796,6 +3830,10 @@ chat item action Hiba történt az adatbázis törlésekor alert title + + Error deleting message + alert title + Error deleting old database Hiba történt a régi adatbázis törlésekor @@ -3913,7 +3951,7 @@ chat item action Error saving channel profile - Hiba a csatornaprofil mentésekor + Hiba történt a csatornaprofil mentésekor No comment provided by engineer. @@ -3973,7 +4011,7 @@ chat item action Error setting auto-accept - Hiba az automatikus elfogadás beállításakor + Hiba történt az automatikus elfogadás beállításakor No comment provided by engineer. @@ -3983,7 +4021,7 @@ chat item action Error sharing channel - Hiba a csatorna megosztásakor + Hiba történt a csatorna megosztásakor alert title @@ -4611,7 +4649,7 @@ Hiba: %2$@ Group profile was changed. If you save it, the updated profile will be sent to group members. - Csoportprofil módosítva. Ha menti, akkor a frissített profil el lesz küldve a csoporttagoknak. + Csoportprofil módosítva. Ha menti, akkor a frissített profil el lesz küldve a csoport tagjainak. alert message @@ -4726,7 +4764,7 @@ Hiba: %2$@ How to use your servers - Hogyan használja a saját kiszolgálóit + Útmutató a saját kiszolgálók használatához No comment provided by engineer. @@ -4938,7 +4976,7 @@ További fejlesztések hamarosan! Install SimpleX Chat for terminal - A SimpleX Chat terminálhoz telepítése + SimpleX Chat telepítése a terminálhoz No comment provided by engineer. @@ -5273,7 +5311,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Let someone connect to you - Hagyja, hogy valaki elérje Önt + Legyen elérhető mások számára No comment provided by engineer. @@ -6005,6 +6043,10 @@ A legbiztonságosabb titkosítás. Nincs alkalmazás jelszó Authentication unavailable + + No available relays + No comment provided by engineer. + No chat relays Nincsenek csevegési átjátszók @@ -6130,6 +6172,10 @@ A legbiztonságosabb titkosítás. Nincsenek fogadott vagy küldött fájlok No comment provided by engineer. + + No relays + No comment provided by engineer. + No servers for private message routing. Nincsenek kiszolgálók a privát üzenet-útválasztáshoz. @@ -6778,6 +6824,10 @@ Hiba: %@ Próbálja meg letiltani és újra engedélyezni az értesítéseket. token info + + Please upgrade the app. + alert message + Please wait for group moderators to review your request to join the group. Várja meg, amíg a csoport moderátorai áttekintik a csoporthoz való csatlakozási kérését. @@ -6855,7 +6905,7 @@ Hiba: %@ Privacy: for owners and subscribers. - Adatvédelem: tulajdonosok és előfizetők számára. + Adatvédelem: tulajdonosok és feliratkozók számára. No comment provided by engineer. @@ -6903,11 +6953,6 @@ Hiba: %@ Privát útválasztás időtúllépése alert title - - Proceed - Folytatás - alert action - Profile and server connections Profil és kiszolgálókapcsolatok @@ -7289,12 +7334,12 @@ swipe action Relay server is only used if necessary. Another party can observe your IP address. - Az átjátszó csak szükség esetén lesz használva. Egy másik fél megfigyelheti az IP-címét. + Az átjátszó kiszolgáló csak szükség esetén lesz használva. Egy másik fél megfigyelheti az IP-címét. No comment provided by engineer. Relay server protects your IP address, but it can observe the duration of the call. - Az átjátszó megvédi az IP-címét, de megfigyelheti a hívás időtartamát. + Az átjátszó kiszolgáló megvédi az IP-címét, de megfigyelheti a hívás időtartamát. No comment provided by engineer. @@ -7302,6 +7347,14 @@ swipe action Nem sikerült tesztelni az átjátszót! No comment provided by engineer. + + Relay will be removed from channel - this cannot be undone! + alert message + + + Relays added: %@. + alert message + Reliability: many relays per channel. Megbízhatóság: több átjátszó is használható csatornánként. @@ -7347,10 +7400,9 @@ swipe action Eltávolítja a jelmondatot a kulcstartóból? No comment provided by engineer. - - Remove subscriber - Feliratkozó eltávolítása - No comment provided by engineer. + + Remove relay? + alert title Remove subscriber? @@ -8667,6 +8719,10 @@ report reason Statisztikák No comment provided by engineer. + + Status + No comment provided by engineer. + Stop Megállítás @@ -8971,7 +9027,7 @@ Az átjátszó címe ennek az átjátszónak a beállítására szolgált a csat Test failed at step %@. - A teszt a(z) %@ lépésnél sikertelen volt. + A teszt a(z) %@. lépésnél sikertelen volt. relay test failure server test failure @@ -9254,11 +9310,20 @@ a saját kapcsolatait és csoportjait. Ez a csoport már nem létezik. No comment provided by engineer. + + This group requires a newer version of the app. Please update the app to join. + alert message +alert subtitle + This is a chat relay address, it cannot be used to connect. Ez egy csevegési átjátszó címe, nem használható kapcsolódásra. alert message + + This is the last active relay. Removing it will prevent message delivery to subscribers. + alert message + This is your link for channel %@! Ez a saját hivatkozása a(z) %@ nevű csatornához! @@ -9593,11 +9658,19 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso Olvasatlan swipe action + + Unsupported channel name + alert title + Unsupported connection link Nem támogatott kapcsolattartási hivatkozás conn error description + + Unsupported contact name + alert title + Up to 100 last messages are sent to new members. Legfeljebb az utolsó 100 üzenet lesz elküldve az új tagok számára. @@ -10175,7 +10248,7 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso You are already connected with %@. - Ön már kapcsolódva van vele: %@. + Ön már kapcsolatban van vele: %@. No comment provided by engineer. @@ -10606,6 +10679,11 @@ Megismétli a kapcsolódási kérést? Saját hálózat No comment provided by engineer. + + Your new channel %1$@ is connected to %2$d of %3$d relays. +If you cancel, the channel will be deleted - you can create it again. + alert message + Your preferences Beállítások @@ -10815,7 +10893,7 @@ Az átjátszók hozzáférhetnek a csatornaüzenetekhez. blocked by admin - letiltva az adminisztrátor által + az adminisztrátor letiltotta blocked chat item marked deleted chat item preview text @@ -11570,7 +11648,7 @@ time to disappear reviewed by admins - áttekintve a moderátorok által + a moderátorok áttekintették No comment provided by engineer. @@ -11674,12 +11752,12 @@ utoljára fogadott üzenet: %2$@ updated channel profile - frissített csatornaprofil + frissítette a csatorna profilját rcv group event chat item updated group profile - frissítette a csoportprofilt + frissítette a csoport profilját rcv group event chat item @@ -12019,7 +12097,7 @@ utoljára fogadott üzenet: %2$@ Database passphrase is different from saved in the keychain. - Az adatbázis jelmondata nem egyezik a kulcstartóba mentettől. + Az adatbázis jelmondata eltér a kulcstartóban tárolttól. No comment provided by engineer. @@ -12139,7 +12217,7 @@ utoljára fogadott üzenet: %2$@ Wait - Várjon + Várakozás 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 4d9fce7b4f..469da88ce2 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -736,6 +736,10 @@ swipe action Connessioni attive No comment provided by engineer. + + Add + No comment provided by engineer. + Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. 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. @@ -761,6 +765,14 @@ swipe action Aggiungi profilo No comment provided by engineer. + + Add relays + No comment provided by engineer. + + + Add relays to restore message delivery. + No comment provided by engineer. + Add server Aggiungi server @@ -806,11 +818,6 @@ swipe action Server dei messaggi aggiunti No comment provided by engineer. - - Adding relays will be supported later. - L'aggiunta di relay verrà supportata prossimamente. - No comment provided by engineer. - Additional accent Principale aggiuntivo @@ -1186,6 +1193,10 @@ swipe action Sessione dell'app No comment provided by engineer. + + App update required + alert title + App version Versione dell'app @@ -1615,6 +1626,14 @@ nella tua rete alert button new chat action + + Cancel and delete channel + No comment provided by engineer. + + + Cancel creating channel? + alert title + Cancel migration Annulla migrazione @@ -1772,9 +1791,8 @@ alert subtitle Il canale verrà eliminato per te, non è reversibile! No comment provided by engineer. - - Channel will start working with %1$d of %2$d relays. Proceed? - Il canale sarà operativo con %1$d di %2$d relay. Procedere? + + Channel will start working with %1$d of %2$d relays. Continue? alert message @@ -2278,6 +2296,14 @@ Questo è il tuo link una tantum! Connessione al desktop No comment provided by engineer. + + Connecting via channel name requires a newer app version. + alert message + + + Connecting via contact name requires a newer app version. + alert message + Connection Connessione @@ -2433,7 +2459,7 @@ Questo è il tuo link una tantum! Continue Continua - No comment provided by engineer. + alert action Contribute @@ -2879,6 +2905,10 @@ swipe action Elimina per me No comment provided by engineer. + + Delete from history + No comment provided by engineer. + Delete group Elimina gruppo @@ -3666,6 +3696,10 @@ chat item action Errore di aggiunta del relay alert title + + Error adding relays + alert title + Error adding server Errore di aggiunta del server @@ -3796,6 +3830,10 @@ chat item action Errore nell'eliminazione del database alert title + + Error deleting message + alert title + Error deleting old database Errore nell'eliminazione del database vecchio @@ -6005,6 +6043,10 @@ La crittografia più sicura. Nessuna password dell'app Authentication unavailable + + No available relays + No comment provided by engineer. + No chat relays Nessun relay di chat @@ -6130,6 +6172,10 @@ La crittografia più sicura. Nessun file ricevuto o inviato No comment provided by engineer. + + No relays + No comment provided by engineer. + No servers for private message routing. Nessun server per l'instradamento dei messaggi privati. @@ -6463,12 +6509,12 @@ alert button Open new channel - Apri un canale nuovo + Apri il nuovo canale new chat action Open new chat - Apri una chat nuova + Apri la nuova chat new chat action @@ -6778,6 +6824,10 @@ Errore: %@ Prova a disattivare e riattivare le notifiche. token info + + Please upgrade the app. + alert message + Please wait for group moderators to review your request to join the group. Attendi che i moderatori del gruppo revisionino la tua richiesta di entrare nel gruppo. @@ -6903,11 +6953,6 @@ Errore: %@ Scadenza dell'instradamento privato alert title - - Proceed - Procedi - alert action - Profile and server connections Profilo e connessioni al server @@ -7302,6 +7347,14 @@ swipe action Prova del relay fallita! No comment provided by engineer. + + Relay will be removed from channel - this cannot be undone! + alert message + + + Relays added: %@. + alert message + Reliability: many relays per channel. Affidabilità: relay multipli per canale. @@ -7347,10 +7400,9 @@ swipe action Rimuovere la password dal portachiavi? No comment provided by engineer. - - Remove subscriber - Rimuovi iscritto - No comment provided by engineer. + + Remove relay? + alert title Remove subscriber? @@ -8667,6 +8719,10 @@ report reason Statistiche No comment provided by engineer. + + Status + No comment provided by engineer. + Stop Ferma @@ -9254,11 +9310,20 @@ i tuoi contatti e i tuoi gruppi. Questo gruppo non esiste più. No comment provided by engineer. + + This group requires a newer version of the app. Please update the app to join. + alert message +alert subtitle + This is a chat relay address, it cannot be used to connect. Questo è un indirizzo di relay di chat, non può essere usato per connettersi. alert message + + This is the last active relay. Removing it will prevent message delivery to subscribers. + alert message + This is your link for channel %@! Questo è il tuo link per il canale %@! @@ -9593,11 +9658,19 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Non letto swipe action + + Unsupported channel name + alert title + Unsupported connection link Link di connessione non supportato conn error description + + Unsupported contact name + alert title + Up to 100 last messages are sent to new members. Vengono inviati ai nuovi membri fino a 100 ultimi messaggi. @@ -10606,6 +10679,11 @@ Ripetere la richiesta di connessione? La tua rete No comment provided by engineer. + + Your new channel %1$@ is connected to %2$d of %3$d relays. +If you cancel, the channel will be deleted - you can create it again. + alert message + Your preferences Le tue preferenze 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 0d3a7a9088..13396b13a4 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -711,6 +711,10 @@ swipe action アクティブな接続 No comment provided by engineer. + + Add + No comment provided by engineer. + Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. No comment provided by engineer. @@ -734,6 +738,14 @@ swipe action プロフィールを追加 No comment provided by engineer. + + Add relays + No comment provided by engineer. + + + Add relays to restore message delivery. + No comment provided by engineer. + Add server サーバを追加 @@ -778,10 +790,6 @@ swipe action 追加されたメッセージサーバー No comment provided by engineer. - - Adding relays will be supported later. - No comment provided by engineer. - Additional accent No comment provided by engineer. @@ -1135,6 +1143,10 @@ swipe action App session No comment provided by engineer. + + App update required + alert title + App version アプリのバージョン @@ -1515,6 +1527,14 @@ in your network alert button new chat action + + Cancel and delete channel + No comment provided by engineer. + + + Cancel creating channel? + alert title + Cancel migration 移行を中止する @@ -1655,8 +1675,8 @@ alert subtitle Channel will be deleted for you - this cannot be undone! No comment provided by engineer. - - Channel will start working with %1$d of %2$d relays. Proceed? + + Channel will start working with %1$d of %2$d relays. Continue? alert message @@ -2107,6 +2127,14 @@ This is your own one-time link! デスクトップに接続中 No comment provided by engineer. + + Connecting via channel name requires a newer app version. + alert message + + + Connecting via contact name requires a newer app version. + alert message + Connection 接続 @@ -2246,7 +2274,7 @@ This is your own one-time link! Continue 続ける - No comment provided by engineer. + alert action Contribute @@ -2658,6 +2686,10 @@ swipe action 自分側で削除 No comment provided by engineer. + + Delete from history + No comment provided by engineer. + Delete group グループを削除 @@ -3374,6 +3406,10 @@ chat item action Error adding relay alert title + + Error adding relays + alert title + Error adding server alert title @@ -3491,6 +3527,10 @@ chat item action データベースの削除にエラー発生 alert title + + Error deleting message + alert title + Error deleting old database 古いデータベースを削除にエラー発生 @@ -5475,6 +5515,10 @@ The most secure encryption. アプリのパスワードはありません Authentication unavailable + + No available relays + No comment provided by engineer. + No chat relays No comment provided by engineer. @@ -5585,6 +5629,10 @@ The most secure encryption. 送受信済みのファイルがありません No comment provided by engineer. + + No relays + No comment provided by engineer. + No servers for private message routing. servers error @@ -6153,6 +6201,10 @@ Error: %@ Please try to disable and re-enable notfications. token info + + Please upgrade the app. + alert message + Please wait for group moderators to review your request to join the group. snd group event chat item @@ -6260,10 +6312,6 @@ Error: %@ Private routing timeout alert title - - Proceed - alert action - Profile and server connections プロフィールとサーバ接続 @@ -6618,6 +6666,14 @@ swipe action Relay test failed! No comment provided by engineer. + + Relay will be removed from channel - this cannot be undone! + alert message + + + Relays added: %@. + alert message + Reliability: many relays per channel. No comment provided by engineer. @@ -6658,9 +6714,9 @@ swipe action キーチェーンからパスフレーズを削除しますか? No comment provided by engineer. - - Remove subscriber - No comment provided by engineer. + + Remove relay? + alert title Remove subscriber? @@ -7821,6 +7877,10 @@ report reason Statistics No comment provided by engineer. + + Status + No comment provided by engineer. + Stop 停止 @@ -8339,10 +8399,19 @@ your contacts and groups. このグループはもう存在しません。 No comment provided by engineer. + + This group requires a newer version of the app. Please update the app to join. + alert message +alert subtitle + This is a chat relay address, it cannot be used to connect. alert message + + This is the last active relay. Removing it will prevent message delivery to subscribers. + alert message + This is your link for channel %@! new chat action @@ -8641,10 +8710,18 @@ To connect, please ask your contact to create another connection link and check 未読 swipe action + + Unsupported channel name + alert title + Unsupported connection link conn error description + + Unsupported contact name + alert title + Up to 100 last messages are sent to new members. No comment provided by engineer. @@ -9537,6 +9614,11 @@ Repeat connection request? Your network No comment provided by engineer. + + Your new channel %1$@ is connected to %2$d of %3$d relays. +If you cancel, the channel will be deleted - you can create it again. + alert message + Your preferences あなたの設定 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 3bf4a6f197..9f1818fba9 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -714,6 +714,10 @@ swipe action Actieve verbindingen No comment provided by engineer. + + Add + No comment provided by engineer. + Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. No comment provided by engineer. @@ -737,6 +741,14 @@ swipe action Profiel toevoegen No comment provided by engineer. + + Add relays + No comment provided by engineer. + + + Add relays to restore message delivery. + No comment provided by engineer. + Add server Server toevoegen @@ -782,10 +794,6 @@ swipe action Berichtservers toegevoegd No comment provided by engineer. - - Adding relays will be supported later. - No comment provided by engineer. - Additional accent Extra accent @@ -1153,6 +1161,10 @@ swipe action Appsessie No comment provided by engineer. + + App update required + alert title + App version App versie @@ -1568,6 +1580,14 @@ in your network alert button new chat action + + Cancel and delete channel + No comment provided by engineer. + + + Cancel creating channel? + alert title + Cancel migration Migratie annuleren @@ -1712,8 +1732,8 @@ alert subtitle Channel will be deleted for you - this cannot be undone! No comment provided by engineer. - - Channel will start working with %1$d of %2$d relays. Proceed? + + Channel will start working with %1$d of %2$d relays. Continue? alert message @@ -2203,6 +2223,14 @@ Dit is uw eigen eenmalige link! Verbinding maken met desktop No comment provided by engineer. + + Connecting via channel name requires a newer app version. + alert message + + + Connecting via contact name requires a newer app version. + alert message + Connection Verbinding @@ -2355,7 +2383,7 @@ Dit is uw eigen eenmalige link! Continue Doorgaan - No comment provided by engineer. + alert action Contribute @@ -2792,6 +2820,10 @@ swipe action Verwijder voor mij No comment provided by engineer. + + Delete from history + No comment provided by engineer. + Delete group Groep verwijderen @@ -3560,6 +3592,10 @@ chat item action Error adding relay alert title + + Error adding relays + alert title + Error adding server Fout bij toevoegen server @@ -3686,6 +3722,10 @@ chat item action Fout bij het verwijderen van de database alert title + + Error deleting message + alert title + Error deleting old database Fout bij het verwijderen van de oude database @@ -5845,6 +5885,10 @@ The most secure encryption. Geen app wachtwoord Authentication unavailable + + No available relays + No comment provided by engineer. + No chat relays No comment provided by engineer. @@ -5967,6 +6011,10 @@ The most secure encryption. Geen ontvangen of verzonden bestanden No comment provided by engineer. + + No relays + No comment provided by engineer. + No servers for private message routing. Geen servers voor het routeren van privéberichten. @@ -6585,6 +6633,10 @@ Fout: %@ Probeer meldingen uit en weer in te schakelen. token info + + Please upgrade the app. + alert message + Please wait for group moderators to review your request to join the group. Wacht totdat de moderators van de groep uw verzoek tot lidmaatschap van de groep hebben beoordeeld. @@ -6705,10 +6757,6 @@ Fout: %@ Private routing timeout alert title - - Proceed - alert action - Profile and server connections Profiel- en serververbindingen @@ -7092,6 +7140,14 @@ swipe action Relay test failed! No comment provided by engineer. + + Relay will be removed from channel - this cannot be undone! + alert message + + + Relays added: %@. + alert message + Reliability: many relays per channel. No comment provided by engineer. @@ -7134,9 +7190,9 @@ swipe action Wachtwoord van de keychain verwijderen? No comment provided by engineer. - - Remove subscriber - No comment provided by engineer. + + Remove relay? + alert title Remove subscriber? @@ -8415,6 +8471,10 @@ report reason Statistieken No comment provided by engineer. + + Status + No comment provided by engineer. + Stop Stop @@ -8969,10 +9029,19 @@ your contacts and groups. Deze groep bestaat niet meer. No comment provided by engineer. + + This group requires a newer version of the app. Please update the app to join. + alert message +alert subtitle + This is a chat relay address, it cannot be used to connect. alert message + + This is the last active relay. Removing it will prevent message delivery to subscribers. + alert message + This is your link for channel %@! new chat action @@ -9298,11 +9367,19 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak Ongelezen swipe action + + Unsupported channel name + alert title + Unsupported connection link Niet-ondersteunde verbindingslink conn error description + + Unsupported contact name + alert title + Up to 100 last messages are sent to new members. Er worden maximaal 100 laatste berichten naar nieuwe leden verzonden. @@ -10275,6 +10352,11 @@ Verbindingsverzoek herhalen? Your network No comment provided by engineer. + + Your new channel %1$@ is connected to %2$d of %3$d relays. +If you cancel, the channel will be deleted - you can create it again. + alert message + Your preferences Jouw voorkeuren 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 b232aa84af..2644708927 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -715,6 +715,10 @@ swipe action Aktywne połączenia No comment provided by engineer. + + Add + No comment provided by engineer. + Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. No comment provided by engineer. @@ -739,6 +743,14 @@ swipe action Dodaj profil No comment provided by engineer. + + Add relays + No comment provided by engineer. + + + Add relays to restore message delivery. + No comment provided by engineer. + Add server Dodaj serwer @@ -784,10 +796,6 @@ swipe action Dodano serwery wiadomości No comment provided by engineer. - - Adding relays will be supported later. - No comment provided by engineer. - Additional accent Dodatkowy akcent @@ -1158,6 +1166,10 @@ swipe action Sesja aplikacji No comment provided by engineer. + + App update required + alert title + App version Wersja aplikacji @@ -1582,6 +1594,14 @@ in your network alert button new chat action + + Cancel and delete channel + No comment provided by engineer. + + + Cancel creating channel? + alert title + Cancel migration Anuluj migrację @@ -1726,8 +1746,8 @@ alert subtitle Channel will be deleted for you - this cannot be undone! No comment provided by engineer. - - Channel will start working with %1$d of %2$d relays. Proceed? + + Channel will start working with %1$d of %2$d relays. Continue? alert message @@ -2219,6 +2239,14 @@ To jest twój jednorazowy link! Łączenie z komputerem No comment provided by engineer. + + Connecting via channel name requires a newer app version. + alert message + + + Connecting via contact name requires a newer app version. + alert message + Connection Połączenie @@ -2373,7 +2401,7 @@ To jest twój jednorazowy link! Continue Kontynuuj - No comment provided by engineer. + alert action Contribute @@ -2811,6 +2839,10 @@ swipe action Usuń dla mnie No comment provided by engineer. + + Delete from history + No comment provided by engineer. + Delete group Usuń grupę @@ -3585,6 +3617,10 @@ chat item action Error adding relay alert title + + Error adding relays + alert title + Error adding server Błąd podczas dodawania serwera @@ -3714,6 +3750,10 @@ chat item action Błąd usuwania bazy danych alert title + + Error deleting message + alert title + Error deleting old database Błąd usuwania starej bazy danych @@ -5895,6 +5935,10 @@ The most secure encryption. Brak hasła aplikacji Authentication unavailable + + No available relays + No comment provided by engineer. + No chat relays No comment provided by engineer. @@ -6018,6 +6062,10 @@ The most secure encryption. Brak odebranych lub wysłanych plików No comment provided by engineer. + + No relays + No comment provided by engineer. + No servers for private message routing. Brak serwerów prywatnej sesji routingu. @@ -6648,6 +6696,10 @@ Błąd: %@ Spróbuj wyłączyć, a następnie ponownie włączyć powiadomienia. token info + + Please upgrade the app. + alert message + Please wait for group moderators to review your request to join the group. Poczekaj, aż moderatorzy grupy rozpatrzą Twoją prośbę o dołączenie do grupy. @@ -6769,10 +6821,6 @@ Błąd: %@ Limit czasu routingu prywatnego alert title - - Proceed - alert action - Profile and server connections Profil i połączenia z serwerem @@ -7157,6 +7205,14 @@ swipe action Relay test failed! No comment provided by engineer. + + Relay will be removed from channel - this cannot be undone! + alert message + + + Relays added: %@. + alert message + Reliability: many relays per channel. No comment provided by engineer. @@ -7201,9 +7257,9 @@ swipe action Usunąć hasło z pęku kluczy? No comment provided by engineer. - - Remove subscriber - No comment provided by engineer. + + Remove relay? + alert title Remove subscriber? @@ -8501,6 +8557,10 @@ report reason Statystyki No comment provided by engineer. + + Status + No comment provided by engineer. + Stop Zatrzymaj @@ -9065,10 +9125,19 @@ your contacts and groups. Ta grupa już nie istnieje. No comment provided by engineer. + + This group requires a newer version of the app. Please update the app to join. + alert message +alert subtitle + This is a chat relay address, it cannot be used to connect. alert message + + This is the last active relay. Removing it will prevent message delivery to subscribers. + alert message + This is your link for channel %@! new chat action @@ -9399,11 +9468,19 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Nieprzeczytane swipe action + + Unsupported channel name + alert title + Unsupported connection link Nieobsługiwane łącze połączenia conn error description + + Unsupported contact name + alert title + Up to 100 last messages are sent to new members. Do nowych członków wysyłanych jest do 100 ostatnich wiadomości. @@ -10394,6 +10471,11 @@ Powtórzyć prośbę połączenia? Your network No comment provided by engineer. + + Your new channel %1$@ is connected to %2$d of %3$d relays. +If you cancel, the channel will be deleted - you can create it again. + alert message + Your preferences Twoje preferencje 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 a438327ba1..a3971c0325 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -736,6 +736,10 @@ swipe action Активные соединения No comment provided by engineer. + + Add + No comment provided by engineer. + Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. Добавьте адрес в свой профиль, чтобы Ваши SimpleX контакты могли поделиться им. Профиль будет отправлен Вашим SimpleX контактам. @@ -761,6 +765,14 @@ swipe action Добавить профиль No comment provided by engineer. + + Add relays + No comment provided by engineer. + + + Add relays to restore message delivery. + No comment provided by engineer. + Add server Добавить сервер @@ -806,11 +818,6 @@ swipe action Дополнительные серверы сообщений No comment provided by engineer. - - Adding relays will be supported later. - Добавление релеев будет поддерживаться позже. - No comment provided by engineer. - Additional accent Дополнительный акцент @@ -1186,6 +1193,10 @@ swipe action Сессия приложения No comment provided by engineer. + + App update required + alert title + App version Версия приложения @@ -1615,6 +1626,14 @@ in your network alert button new chat action + + Cancel and delete channel + No comment provided by engineer. + + + Cancel creating channel? + alert title + Cancel migration Отменить миграцию @@ -1772,9 +1791,8 @@ alert subtitle Канал будет удалён для Вас - это нельзя отменить! No comment provided by engineer. - - Channel will start working with %1$d of %2$d relays. Proceed? - Канал начнёт работу с %1$d из %2$d релеев. Продолжить? + + Channel will start working with %1$d of %2$d relays. Continue? alert message @@ -2278,6 +2296,14 @@ This is your own one-time link! Подключение к компьютеру No comment provided by engineer. + + Connecting via channel name requires a newer app version. + alert message + + + Connecting via contact name requires a newer app version. + alert message + Connection Соединение @@ -2433,7 +2459,7 @@ This is your own one-time link! Continue Продолжить - No comment provided by engineer. + alert action Contribute @@ -2879,6 +2905,10 @@ swipe action Удалить для меня No comment provided by engineer. + + Delete from history + No comment provided by engineer. + Delete group Удалить группу @@ -3666,6 +3696,10 @@ chat item action Ошибка добавления релея alert title + + Error adding relays + alert title + Error adding server Ошибка добавления сервера @@ -3796,6 +3830,10 @@ chat item action Ошибка при удалении данных чата alert title + + Error deleting message + alert title + Error deleting old database Ошибка при удалении предыдущей версии данных чата @@ -6004,6 +6042,10 @@ The most secure encryption. Нет кода доступа Authentication unavailable + + No available relays + No comment provided by engineer. + No chat relays Нет чат-релеев @@ -6129,6 +6171,10 @@ The most secure encryption. Нет полученных или отправленных файлов No comment provided by engineer. + + No relays + No comment provided by engineer. + No servers for private message routing. Нет серверов для доставки сообщений. @@ -6777,6 +6823,10 @@ Error: %@ Попробуйте выключить и снова включить уведомления. token info + + Please upgrade the app. + alert message + Please wait for group moderators to review your request to join the group. Пожалуйста, подождите, пока модераторы группы рассмотрят ваш запрос на вступление. @@ -6902,11 +6952,6 @@ Error: %@ Таймаут конфиденциальной доставки alert title - - Proceed - Продолжить - alert action - Profile and server connections Профиль и соединения на сервере @@ -7301,6 +7346,14 @@ swipe action Тест релея не пройден! No comment provided by engineer. + + Relay will be removed from channel - this cannot be undone! + alert message + + + Relays added: %@. + alert message + Reliability: many relays per channel. Надёжность: несколько релеев на каждый канал. @@ -7346,10 +7399,9 @@ swipe action Удалить пароль из Keychain? No comment provided by engineer. - - Remove subscriber - Удалить подписчика - No comment provided by engineer. + + Remove relay? + alert title Remove subscriber? @@ -8666,6 +8718,10 @@ report reason Статистика No comment provided by engineer. + + Status + No comment provided by engineer. + Stop Остановить @@ -9253,11 +9309,20 @@ your contacts and groups. Эта группа больше не существует. No comment provided by engineer. + + This group requires a newer version of the app. Please update the app to join. + alert message +alert subtitle + This is a chat relay address, it cannot be used to connect. Это адрес чат-релея, с ним нельзя соединиться. alert message + + This is the last active relay. Removing it will prevent message delivery to subscribers. + alert message + This is your link for channel %@! Это ваша ссылка на канал %@! @@ -9592,11 +9657,19 @@ To connect, please ask your contact to create another connection link and check Не прочитано swipe action + + Unsupported channel name + alert title + Unsupported connection link Ссылка не поддерживается conn error description + + Unsupported contact name + alert title + Up to 100 last messages are sent to new members. До 100 последних сообщений отправляются новым членам. @@ -10605,6 +10678,11 @@ Repeat connection request? Ваша сеть No comment provided by engineer. + + Your new channel %1$@ is connected to %2$d of %3$d relays. +If you cancel, the channel will be deleted - you can create it again. + alert message + Your preferences Ваши предпочтения @@ -11673,7 +11751,7 @@ last received msg: %2$@ updated channel profile - обновлён профиль канала + обновил профиль канала rcv group event chat item 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 04c51bbf43..cd2e30977d 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -659,6 +659,10 @@ swipe action Active connections No comment provided by engineer. + + Add + No comment provided by engineer. + Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. No comment provided by engineer. @@ -680,6 +684,14 @@ swipe action เพิ่มโปรไฟล์ No comment provided by engineer. + + Add relays + No comment provided by engineer. + + + Add relays to restore message delivery. + No comment provided by engineer. + Add server เพิ่มเซิร์ฟเวอร์ @@ -720,10 +732,6 @@ swipe action Added message servers No comment provided by engineer. - - Adding relays will be supported later. - No comment provided by engineer. - Additional accent No comment provided by engineer. @@ -1061,6 +1069,10 @@ swipe action App session No comment provided by engineer. + + App update required + alert title + App version เวอร์ชันแอป @@ -1434,6 +1446,14 @@ in your network alert button new chat action + + Cancel and delete channel + No comment provided by engineer. + + + Cancel creating channel? + alert title + Cancel migration No comment provided by engineer. @@ -1572,8 +1592,8 @@ alert subtitle Channel will be deleted for you - this cannot be undone! No comment provided by engineer. - - Channel will start working with %1$d of %2$d relays. Proceed? + + Channel will start working with %1$d of %2$d relays. Continue? alert message @@ -2005,6 +2025,14 @@ This is your own one-time link! Connecting to desktop No comment provided by engineer. + + Connecting via channel name requires a newer app version. + alert message + + + Connecting via contact name requires a newer app version. + alert message + Connection การเชื่อมต่อ @@ -2142,7 +2170,7 @@ This is your own one-time link! Continue ดำเนินการต่อ - No comment provided by engineer. + alert action Contribute @@ -2549,6 +2577,10 @@ swipe action ลบให้ฉัน No comment provided by engineer. + + Delete from history + No comment provided by engineer. + Delete group ลบกลุ่ม @@ -3259,6 +3291,10 @@ chat item action Error adding relay alert title + + Error adding relays + alert title + Error adding server alert title @@ -3374,6 +3410,10 @@ chat item action เกิดข้อผิดพลาดในการลบฐานข้อมูล alert title + + Error deleting message + alert title + Error deleting old database เกิดข้อผิดพลาดในการลบฐานข้อมูลเก่า @@ -5354,6 +5394,10 @@ The most secure encryption. ไม่มีรหัสผ่านสำหรับแอป Authentication unavailable + + No available relays + No comment provided by engineer. + No chat relays No comment provided by engineer. @@ -5463,6 +5507,10 @@ The most secure encryption. ไม่มีไฟล์ที่ได้รับหรือส่ง No comment provided by engineer. + + No relays + No comment provided by engineer. + No servers for private message routing. servers error @@ -6028,6 +6076,10 @@ Error: %@ Please try to disable and re-enable notfications. token info + + Please upgrade the app. + alert message + Please wait for group moderators to review your request to join the group. snd group event chat item @@ -6134,10 +6186,6 @@ Error: %@ Private routing timeout alert title - - Proceed - alert action - Profile and server connections การเชื่อมต่อโปรไฟล์และเซิร์ฟเวอร์ @@ -6491,6 +6539,14 @@ swipe action Relay test failed! No comment provided by engineer. + + Relay will be removed from channel - this cannot be undone! + alert message + + + Relays added: %@. + alert message + Reliability: many relays per channel. No comment provided by engineer. @@ -6531,9 +6587,9 @@ swipe action ลบรหัสผ่านออกจาก keychain หรือไม่? No comment provided by engineer. - - Remove subscriber - No comment provided by engineer. + + Remove relay? + alert title Remove subscriber? @@ -7696,6 +7752,10 @@ report reason Statistics No comment provided by engineer. + + Status + No comment provided by engineer. + Stop หยุด @@ -8214,10 +8274,19 @@ your contacts and groups. ไม่มีกลุ่มนี้แล้ว No comment provided by engineer. + + This group requires a newer version of the app. Please update the app to join. + alert message +alert subtitle + This is a chat relay address, it cannot be used to connect. alert message + + This is the last active relay. Removing it will prevent message delivery to subscribers. + alert message + This is your link for channel %@! new chat action @@ -8516,10 +8585,18 @@ To connect, please ask your contact to create another connection link and check เปลี่ยนเป็นยังไม่ได้อ่าน swipe action + + Unsupported channel name + alert title + Unsupported connection link conn error description + + Unsupported contact name + alert title + Up to 100 last messages are sent to new members. No comment provided by engineer. @@ -9408,6 +9485,11 @@ Repeat connection request? Your network No comment provided by engineer. + + Your new channel %1$@ is connected to %2$d of %3$d relays. +If you cancel, the channel will be deleted - you can create it again. + alert message + Your preferences การตั้งค่าของคุณ 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 90d537d06c..1189b53e3c 100644 --- a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff +++ b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff @@ -187,16 +187,19 @@ %d relays failed + %d aktarıcı başarısız oldu channel relay bar channel subscriber relay bar %d relays not active + %d aktarıcı etkin değil channel relay bar channel subscriber relay bar %d relays removed + %d aktarıcı kaldırıldı channel relay bar channel subscriber relay bar @@ -217,10 +220,12 @@ channel subscriber relay bar %d subscriber + %d abone channel subscriber count %d subscribers + %d abone channel subscriber count @@ -230,24 +235,29 @@ channel subscriber relay bar %1$d/%2$d relays active + %1$d/%2$d aktarıcı etkin channel creation progress channel relay bar progress %1$d/%2$d relays active, %3$d errors + %1$d/%2$d aktarıcı etkin, %3$d hata channel relay bar %1$d/%2$d relays active, %3$d failed + %1$d/%2$d aktarıcı etkin, %3$d başarısız channel creation progress with errors channel relay bar %1$d/%2$d relays active, %3$d removed + %1$d/%2$d aktarıcı etkin, %3$d kaldırıldı channel relay bar %1$d/%2$d relays connected + %1$d/%2$d aktarıcı bağlı channel subscriber relay bar progress @@ -715,6 +725,10 @@ swipe action Aktif bağlantılar No comment provided by engineer. + + Add + No comment provided by engineer. + Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. No comment provided by engineer. @@ -739,6 +753,14 @@ swipe action Profil ekle No comment provided by engineer. + + Add relays + No comment provided by engineer. + + + Add relays to restore message delivery. + No comment provided by engineer. + Add server Sunucu ekle @@ -784,10 +806,6 @@ swipe action Mesaj sunucuları eklendi No comment provided by engineer. - - Adding relays will be supported later. - No comment provided by engineer. - Additional accent Ek ana renk @@ -1157,6 +1175,10 @@ swipe action Uygulama oturumu No comment provided by engineer. + + App update required + alert title + App version Uygulama sürümü @@ -1578,6 +1600,14 @@ in your network alert button new chat action + + Cancel and delete channel + No comment provided by engineer. + + + Cancel creating channel? + alert title + Cancel migration Taşımayı iptal et @@ -1722,8 +1752,8 @@ alert subtitle Channel will be deleted for you - this cannot be undone! No comment provided by engineer. - - Channel will start working with %1$d of %2$d relays. Proceed? + + Channel will start working with %1$d of %2$d relays. Continue? alert message @@ -2215,6 +2245,14 @@ Bu senin kendi tek kullanımlık bağlantın! Bilgisayara bağlanıyor No comment provided by engineer. + + Connecting via channel name requires a newer app version. + alert message + + + Connecting via contact name requires a newer app version. + alert message + Connection Bağlantı @@ -2368,7 +2406,7 @@ Bu senin kendi tek kullanımlık bağlantın! Continue Devam et - No comment provided by engineer. + alert action Contribute @@ -2806,6 +2844,10 @@ swipe action Benden sil No comment provided by engineer. + + Delete from history + No comment provided by engineer. + Delete group Grubu sil @@ -3578,6 +3620,10 @@ chat item action Error adding relay alert title + + Error adding relays + alert title + Error adding server Sunucu eklenirken hata oluştu @@ -3706,6 +3752,10 @@ chat item action Veritabanı silinirken hata oluştu alert title + + Error deleting message + alert title + Error deleting old database Eski veritabanı silinirken hata oluştu @@ -5877,6 +5927,10 @@ The most secure encryption. Uygulama şifresi yok Authentication unavailable + + No available relays + No comment provided by engineer. + No chat relays No comment provided by engineer. @@ -6000,6 +6054,10 @@ The most secure encryption. Hiç alınmış veya gönderilmiş dosya yok No comment provided by engineer. + + No relays + No comment provided by engineer. + No servers for private message routing. Özel mesaj yönlendirmesi için hiç sunucu yok. @@ -6628,6 +6686,10 @@ Hata: %@ Lütfen bildirimleri devre dışı bırakmayı ve yeniden etkinleştirmeyi deneyin. token info + + Please upgrade the app. + alert message + Please wait for group moderators to review your request to join the group. Lütfen grup moderatörlerinin gruba katılma isteğinizi incelemesini bekleyin. @@ -6749,10 +6811,6 @@ Hata: %@ Özel yönlendirme zaman aşımı alert title - - Proceed - alert action - Profile and server connections Profil ve sunucu bağlantıları @@ -7137,6 +7195,14 @@ swipe action Relay test failed! No comment provided by engineer. + + Relay will be removed from channel - this cannot be undone! + alert message + + + Relays added: %@. + alert message + Reliability: many relays per channel. No comment provided by engineer. @@ -7180,9 +7246,9 @@ swipe action Anahtar Zinciri'ndeki parola silinsin mi? No comment provided by engineer. - - Remove subscriber - No comment provided by engineer. + + Remove relay? + alert title Remove subscriber? @@ -8475,6 +8541,10 @@ report reason İstatistikler No comment provided by engineer. + + Status + No comment provided by engineer. + Stop Dur @@ -9036,10 +9106,19 @@ your contacts and groups. Bu grup artık mevcut değildir. No comment provided by engineer. + + This group requires a newer version of the app. Please update the app to join. + alert message +alert subtitle + This is a chat relay address, it cannot be used to connect. alert message + + This is the last active relay. Removing it will prevent message delivery to subscribers. + alert message + This is your link for channel %@! new chat action @@ -9369,11 +9448,19 @@ Bağlanmak için lütfen kişinizden başka bir bağlantı oluşturmasını iste Okunmamış swipe action + + Unsupported channel name + alert title + Unsupported connection link Desteklenmeyen bağlantı bağlantısı conn error description + + Unsupported contact name + alert title + Up to 100 last messages are sent to new members. Yeni üyelere 100e kadar en son mesajlar gönderildi. @@ -10359,6 +10446,11 @@ Bağlantı isteği tekrarlansın mı? Your network No comment provided by engineer. + + Your new channel %1$@ is connected to %2$d of %3$d relays. +If you cancel, the channel will be deleted - you can create it again. + alert message + Your preferences Tercihleriniz 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 c20b26e029..49f9e21eda 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -715,6 +715,10 @@ swipe action Активні з'єднання No comment provided by engineer. + + Add + No comment provided by engineer. + Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. No comment provided by engineer. @@ -739,6 +743,14 @@ swipe action Додати профіль No comment provided by engineer. + + Add relays + No comment provided by engineer. + + + Add relays to restore message delivery. + No comment provided by engineer. + Add server Додати сервер @@ -784,10 +796,6 @@ swipe action Додано сервери повідомлень No comment provided by engineer. - - Adding relays will be supported later. - No comment provided by engineer. - Additional accent Додатковий акцент @@ -1155,6 +1163,10 @@ swipe action Сесія програми No comment provided by engineer. + + App update required + alert title + App version Версія програми @@ -1574,6 +1586,14 @@ in your network alert button new chat action + + Cancel and delete channel + No comment provided by engineer. + + + Cancel creating channel? + alert title + Cancel migration Скасувати міграцію @@ -1718,8 +1738,8 @@ alert subtitle Channel will be deleted for you - this cannot be undone! No comment provided by engineer. - - Channel will start working with %1$d of %2$d relays. Proceed? + + Channel will start working with %1$d of %2$d relays. Continue? alert message @@ -2211,6 +2231,14 @@ This is your own one-time link! Підключення до ПК No comment provided by engineer. + + Connecting via channel name requires a newer app version. + alert message + + + Connecting via contact name requires a newer app version. + alert message + Connection Підключення @@ -2363,7 +2391,7 @@ This is your own one-time link! Continue Продовжуйте - No comment provided by engineer. + alert action Contribute @@ -2801,6 +2829,10 @@ swipe action Видалити для мене No comment provided by engineer. + + Delete from history + No comment provided by engineer. + Delete group Видалити групу @@ -3572,6 +3604,10 @@ chat item action Error adding relay alert title + + Error adding relays + alert title + Error adding server Помилка додавання сервера @@ -3700,6 +3736,10 @@ chat item action Помилка видалення бази даних alert title + + Error deleting message + alert title + Error deleting old database Помилка видалення старої бази даних @@ -5867,6 +5907,10 @@ The most secure encryption. Немає пароля програми Authentication unavailable + + No available relays + No comment provided by engineer. + No chat relays No comment provided by engineer. @@ -5990,6 +6034,10 @@ The most secure encryption. Немає отриманих або відправлених файлів No comment provided by engineer. + + No relays + No comment provided by engineer. + No servers for private message routing. Немає серверів для маршрутизації приватних повідомлень. @@ -6613,6 +6661,10 @@ Error: %@ Будь ласка, спробуйте вимкнути та знову увімкнути сповіщення. token info + + Please upgrade the app. + alert message + Please wait for group moderators to review your request to join the group. Будь ласка, зачекайте, поки модератори групи розглянуть ваш запит на приєднання до групи. @@ -6734,10 +6786,6 @@ Error: %@ Тайм-аут приватної маршрутизації alert title - - Proceed - alert action - Profile and server connections З'єднання профілю та сервера @@ -7122,6 +7170,14 @@ swipe action Relay test failed! No comment provided by engineer. + + Relay will be removed from channel - this cannot be undone! + alert message + + + Relays added: %@. + alert message + Reliability: many relays per channel. No comment provided by engineer. @@ -7164,9 +7220,9 @@ swipe action Видалити парольну фразу з брелока? No comment provided by engineer. - - Remove subscriber - No comment provided by engineer. + + Remove relay? + alert title Remove subscriber? @@ -8459,6 +8515,10 @@ report reason Статистика No comment provided by engineer. + + Status + No comment provided by engineer. + Stop Зупинити @@ -9019,10 +9079,19 @@ your contacts and groups. Цієї групи більше не існує. No comment provided by engineer. + + This group requires a newer version of the app. Please update the app to join. + alert message +alert subtitle + This is a chat relay address, it cannot be used to connect. alert message + + This is the last active relay. Removing it will prevent message delivery to subscribers. + alert message + This is your link for channel %@! new chat action @@ -9350,11 +9419,19 @@ To connect, please ask your contact to create another connection link and check Непрочитане swipe action + + Unsupported channel name + alert title + Unsupported connection link Несумісне посилання для підключення conn error description + + Unsupported contact name + alert title + Up to 100 last messages are sent to new members. Новим користувачам надсилається до 100 останніх повідомлень. @@ -10340,6 +10417,11 @@ Repeat connection request? Your network No comment provided by engineer. + + Your new channel %1$@ is connected to %2$d of %3$d relays. +If you cancel, the channel will be deleted - you can create it again. + alert message + Your preferences Ваші уподобання 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 51cbb94bda..8823f17204 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 @@ -715,6 +715,10 @@ swipe action 活动连接 No comment provided by engineer. + + Add + No comment provided by engineer. + Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. No comment provided by engineer. @@ -739,6 +743,14 @@ swipe action 添加个人资料 No comment provided by engineer. + + Add relays + No comment provided by engineer. + + + Add relays to restore message delivery. + No comment provided by engineer. + Add server 添加服务器 @@ -784,10 +796,6 @@ swipe action 已添加消息服务器 No comment provided by engineer. - - Adding relays will be supported later. - No comment provided by engineer. - Additional accent 附加重音 @@ -1158,6 +1166,10 @@ swipe action 应用会话 No comment provided by engineer. + + App update required + alert title + App version 应用程序版本 @@ -1582,6 +1594,14 @@ in your network alert button new chat action + + Cancel and delete channel + No comment provided by engineer. + + + Cancel creating channel? + alert title + Cancel migration 取消迁移 @@ -1726,8 +1746,8 @@ alert subtitle Channel will be deleted for you - this cannot be undone! No comment provided by engineer. - - Channel will start working with %1$d of %2$d relays. Proceed? + + Channel will start working with %1$d of %2$d relays. Continue? alert message @@ -2219,6 +2239,14 @@ This is your own one-time link! 正连接到桌面 No comment provided by engineer. + + Connecting via channel name requires a newer app version. + alert message + + + Connecting via contact name requires a newer app version. + alert message + Connection 连接 @@ -2371,7 +2399,7 @@ This is your own one-time link! Continue 继续 - No comment provided by engineer. + alert action Contribute @@ -2809,6 +2837,10 @@ swipe action 为我删除 No comment provided by engineer. + + Delete from history + No comment provided by engineer. + Delete group 删除群组 @@ -3582,6 +3614,10 @@ chat item action Error adding relay alert title + + Error adding relays + alert title + Error adding server 添加服务器出错 @@ -3710,6 +3746,10 @@ chat item action 删除数据库错误 alert title + + Error deleting message + alert title + Error deleting old database 删除旧数据库错误 @@ -5889,6 +5929,10 @@ The most secure encryption. 没有应用程序密码 Authentication unavailable + + No available relays + No comment provided by engineer. + No chat relays No comment provided by engineer. @@ -6012,6 +6056,10 @@ The most secure encryption. 未收到或发送文件 No comment provided by engineer. + + No relays + No comment provided by engineer. + No servers for private message routing. 无私密消息路由服务器。 @@ -6642,6 +6690,10 @@ Error: %@ 请尝试禁用并重新启用通知。 token info + + Please upgrade the app. + alert message + Please wait for group moderators to review your request to join the group. 请等待群的协管审核你加入该群的请求。 @@ -6763,10 +6815,6 @@ Error: %@ 私密路由超时 alert title - - Proceed - alert action - Profile and server connections 资料和服务器连接 @@ -7150,6 +7198,14 @@ swipe action Relay test failed! No comment provided by engineer. + + Relay will be removed from channel - this cannot be undone! + alert message + + + Relays added: %@. + alert message + Reliability: many relays per channel. No comment provided by engineer. @@ -7194,9 +7250,9 @@ swipe action 从钥匙串中删除密码? No comment provided by engineer. - - Remove subscriber - No comment provided by engineer. + + Remove relay? + alert title Remove subscriber? @@ -8493,6 +8549,10 @@ report reason 统计 No comment provided by engineer. + + Status + No comment provided by engineer. + Stop 停止 @@ -9054,10 +9114,19 @@ your contacts and groups. 该群组已不存在。 No comment provided by engineer. + + This group requires a newer version of the app. Please update the app to join. + alert message +alert subtitle + This is a chat relay address, it cannot be used to connect. alert message + + This is the last active relay. Removing it will prevent message delivery to subscribers. + alert message + This is your link for channel %@! new chat action @@ -9387,11 +9456,19 @@ To connect, please ask your contact to create another connection link and check 未读 swipe action + + Unsupported channel name + alert title + Unsupported connection link 不支持的连接链接 conn error description + + Unsupported contact name + alert title + Up to 100 last messages are sent to new members. 给新成员发送了最多 100 条历史消息。 @@ -10379,6 +10456,11 @@ Repeat connection request? Your network No comment provided by engineer. + + Your new channel %1$@ is connected to %2$d of %3$d relays. +If you cancel, the channel will be deleted - you can create it again. + alert message + Your preferences 您的偏好设置 diff --git a/apps/ios/SimpleX SE/hu.lproj/Localizable.strings b/apps/ios/SimpleX SE/hu.lproj/Localizable.strings index dfb7a302b9..3aad39c5d1 100644 --- a/apps/ios/SimpleX SE/hu.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/hu.lproj/Localizable.strings @@ -29,7 +29,7 @@ "Database error" = "Adatbázishiba"; /* No comment provided by engineer. */ -"Database passphrase is different from saved in the keychain." = "Az adatbázis jelmondata nem egyezik a kulcstartóba mentettől."; +"Database passphrase is different from saved in the keychain." = "Az adatbázis jelmondata eltér a kulcstartóban tárolttól."; /* No comment provided by engineer. */ "Database passphrase is required to open chat." = "A csevegés megnyitásához adja meg az adatbázis jelmondatát."; @@ -101,7 +101,7 @@ "Unsupported format" = "Nem támogatott formátum"; /* No comment provided by engineer. */ -"Wait" = "Várjon"; +"Wait" = "Várakozás"; /* No comment provided by engineer. */ "Wrong database passphrase" = "Érvénytelen adatbázis-jelmondat"; diff --git a/apps/ios/bg.lproj/Localizable.strings b/apps/ios/bg.lproj/Localizable.strings index 17e11d1020..ec869e05b4 100644 --- a/apps/ios/bg.lproj/Localizable.strings +++ b/apps/ios/bg.lproj/Localizable.strings @@ -1364,7 +1364,7 @@ server test step */ /* No comment provided by engineer. */ "Contacts can mark messages for deletion; you will be able to view them." = "Контактите могат да маркират съобщения за изтриване; ще можете да ги разглеждате."; -/* No comment provided by engineer. */ +/* alert action */ "Continue" = "Продължи"; /* No comment provided by engineer. */ diff --git a/apps/ios/cs.lproj/Localizable.strings b/apps/ios/cs.lproj/Localizable.strings index f7e90e0c88..fc4b3f0fc6 100644 --- a/apps/ios/cs.lproj/Localizable.strings +++ b/apps/ios/cs.lproj/Localizable.strings @@ -1056,7 +1056,7 @@ server test step */ /* No comment provided by engineer. */ "Contacts can mark messages for deletion; you will be able to view them." = "Kontakty mohou označit zprávy ke smazání; vy je budete moci zobrazit."; -/* No comment provided by engineer. */ +/* alert action */ "Continue" = "Pokračovat"; /* No comment provided by engineer. */ diff --git a/apps/ios/de.lproj/Localizable.strings b/apps/ios/de.lproj/Localizable.strings index d5978b48dc..2c4e37791b 100644 --- a/apps/ios/de.lproj/Localizable.strings +++ b/apps/ios/de.lproj/Localizable.strings @@ -373,7 +373,7 @@ time interval */ "A new random profile will be shared." = "Es wird ein neues Zufallsprofil geteilt."; /* No comment provided by engineer. */ -"A separate TCP connection will be used **for each chat profile you have in the app**." = "**Für jedes von Ihnen in der App genutzte Chat-Profil** wird eine separate TCP-Verbindung genutzt."; +"A separate TCP connection will be used **for each chat profile you have in the app**." = "**Für jedes von Ihnen in der App genutzte Chat-Profil** wird eine separate TCP-Verbindung (und SOCKS-Berechtigung) genutzt."; /* No comment provided by engineer. */ "A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail." = "**Für jeden Kontakt und jedes Gruppenmitglied** wird eine separate TCP-Verbindung genutzt.\n**Bitte beachten Sie**: Wenn Sie viele Verbindungen haben, kann der Batterieverbrauch und die Datennutzung wesentlich höher sein und einige Verbindungen können scheitern."; @@ -505,9 +505,6 @@ swipe action */ /* No comment provided by engineer. */ "Added message servers" = "Nachrichtenserver hinzugefügt"; -/* No comment provided by engineer. */ -"Adding relays will be supported later." = "Das Hinzufügen von Relais wird zu einem späteren Zeitpunkt unterstützt."; - /* No comment provided by engineer. */ "Additional accent" = "Erste Akzentfarbe"; @@ -617,7 +614,7 @@ swipe action */ "All your contacts will remain connected. Profile update will be sent to your contacts." = "Alle Ihre Kontakte bleiben verbunden. Es wird eine Profilaktualisierung an Ihre Kontakte gesendet."; /* No comment provided by engineer. */ -"All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays." = "Alle Ihre Kontakte, Unterhaltungen und Dateien werden sicher verschlüsselt und in Daten-Paketen auf die konfigurierten XTFP-Relais hochgeladen."; +"All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays." = "Alle Ihre Kontakte, Unterhaltungen und Dateien werden sicher verschlüsselt und in Daten-Paketen auf die konfigurierten XTFP-Router hochgeladen."; /* No comment provided by engineer. */ "Allow" = "Erlauben"; @@ -716,10 +713,10 @@ swipe action */ "always" = "Immer"; /* No comment provided by engineer. */ -"Always use private routing." = "Sie nutzen immer privates Routing."; +"Always use private routing." = "Immer privates Routing nutzen."; /* No comment provided by engineer. */ -"Always use relay" = "Über ein Relais verbinden"; +"Always use relay" = "Immer über einen Router verbinden"; /* No comment provided by engineer. */ "An empty chat profile with the provided name is created, and the app opens as usual." = "Es wurde ein leeres Chat-Profil mit dem eingegebenen Namen erstellt und die App öffnet wie gewohnt."; @@ -764,7 +761,7 @@ swipe action */ "App version: v%@" = "App Version: v%@"; /* No comment provided by engineer. */ -"Appearance" = "Erscheinungsbild"; +"Appearance" = "Darstellung"; /* No comment provided by engineer. */ "Apply" = "Anwenden"; @@ -1182,9 +1179,6 @@ alert subtitle */ /* No comment provided by engineer. */ "Channel will be deleted for you - this cannot be undone!" = "Der Kanal wird für Sie gelöscht. Dies kann nicht rückgängig gemacht werden!"; -/* alert message */ -"Channel will start working with %d of %d relays. Proceed?" = "Der Kanal wird mit %1$d von %2$d Relais gestartet. Fortfahren?"; - /* No comment provided by engineer. */ "Channels" = "Kanäle"; @@ -1237,7 +1231,7 @@ alert subtitle */ "Chat preferences were changed." = "Die Chat-Präferenzen wurden geändert."; /* No comment provided by engineer. */ -"Chat profile" = "Benutzerprofil"; +"Chat profile" = "Chat-Profil"; /* No comment provided by engineer. */ "Chat relay" = "Chat-Relais"; @@ -1367,7 +1361,7 @@ chat toolbar */ "Completed" = "Abgeschlossen"; /* No comment provided by engineer. */ -"Conditions accepted on: %@." = "Die Nutzungsbedingungen wurden akzeptiert am: %@."; +"Conditions accepted on: %@." = "Die Nutzungsbedingungen wurden am %@ akzeptiert."; /* No comment provided by engineer. */ "Conditions are accepted for the operator(s): **%@**." = "Die Nutzungsbedingungen der/des Betreiber(s) werden akzeptiert: **%@**."; @@ -1382,10 +1376,10 @@ chat toolbar */ "Conditions will be accepted for the operator(s): **%@**." = "Die Nutzungsbedingungen der/des Betreiber(s) werden akzeptiert: **%@**."; /* No comment provided by engineer. */ -"Conditions will be accepted on: %@." = "Die Nutzungsbedingungen werden akzeptiert am: %@."; +"Conditions will be accepted on: %@." = "Die Nutzungsbedingungen werden am %@ akzeptiert."; /* No comment provided by engineer. */ -"Conditions will be automatically accepted for enabled operators on: %@." = "Die Nutzungsbedingungen der aktivierten Betreiber werden automatisch akzeptiert am: %@."; +"Conditions will be automatically accepted for enabled operators on: %@." = "Die Nutzungsbedingungen der aktivierten Betreiber werden am %@ automatisch akzeptiert."; /* No comment provided by engineer. */ "Configure ICE servers" = "ICE-Server konfigurieren"; @@ -1634,7 +1628,7 @@ server test step */ /* blocking reason */ "Content violates conditions of use" = "Inhalt verletzt Nutzungsbedingungen"; -/* No comment provided by engineer. */ +/* alert action */ "Continue" = "Weiter"; /* No comment provided by engineer. */ @@ -2169,10 +2163,10 @@ alert button */ "Do NOT send messages directly, even if your or destination server does not support private routing." = "Nachrichten werden nicht direkt versendet, selbst wenn Ihr oder der Zielserver kein privates Routing unterstützt."; /* No comment provided by engineer. */ -"Do not use credentials with proxy." = "Verwenden Sie keine Anmeldeinformationen mit einem Proxy."; +"Do not use credentials with proxy." = "Keine Anmeldeinformationen mit einem Proxy verwenden."; /* No comment provided by engineer. */ -"Do NOT use private routing." = "Sie nutzen KEIN privates Routing."; +"Do NOT use private routing." = "KEIN privates Routing nutzen."; /* No comment provided by engineer. */ "Do NOT use SimpleX for emergency calls." = "SimpleX NICHT für Notrufe nutzen."; @@ -3975,10 +3969,10 @@ servers warning */ "New server" = "Neuer Server"; /* No comment provided by engineer. */ -"New SOCKS credentials will be used every time you start the app." = "Jedes Mal wenn Sie die App starten, werden neue SOCKS-Anmeldeinformationen genutzt"; +"New SOCKS credentials will be used every time you start the app." = "Bei jedem Neustart der App, werden neue SOCKS-Anmeldeinformationen genutzt."; /* No comment provided by engineer. */ -"New SOCKS credentials will be used for each server." = "Für jeden Server werden neue SOCKS-Anmeldeinformationen genutzt"; +"New SOCKS credentials will be used for each server." = "Für jeden Server werden neue SOCKS-Anmeldeinformationen genutzt."; /* pref value */ "no" = "Nein"; @@ -4074,7 +4068,7 @@ servers warning */ "No received or sent files" = "Keine herunter- oder hochgeladenen Dateien"; /* servers error */ -"No servers for private message routing." = "Keine Server für privates Nachrichten-Routing."; +"No servers for private message routing." = "Keine Router für privates Nachrichten-Routing."; /* servers error */ "No servers to receive files." = "Keine Server für das Herunterladen von Dateien."; @@ -4548,7 +4542,7 @@ alert button */ "Privacy policy and conditions of use." = "Datenschutz- und Nutzungsbedingungen."; /* No comment provided by engineer. */ -"Privacy: for owners and subscribers." = "Privatsphäre: für Besitzer und Abonnenten."; +"Privacy: for owners and subscribers." = "Privatsphäre: Für Eigentümer und Abonnenten."; /* No comment provided by engineer. */ "Private and secure messaging." = "Private und sichere Kommunikation."; @@ -4577,9 +4571,6 @@ alert button */ /* alert title */ "Private routing timeout" = "Zeitüberschreitung der privaten Routing-Sitzung"; -/* alert action */ -"Proceed" = "Fortfahren"; - /* No comment provided by engineer. */ "Profile and server connections" = "Profil und Serververbindungen"; @@ -4644,7 +4635,7 @@ alert button */ "Protect your chat profiles with a password!" = "Ihre Chat-Profile mit einem Passwort schützen!"; /* No comment provided by engineer. */ -"Protect your IP address from the messaging relays chosen by your contacts.\nEnable in *Network & servers* settings." = "Schützen Sie Ihre IP-Adresse vor den Nachrichten-Relais, die Ihre Kontakte ausgewählt haben.\nAktivieren Sie es in den *Netzwerk & Server* Einstellungen."; +"Protect your IP address from the messaging relays chosen by your contacts.\nEnable in *Network & servers* settings." = "Schützen Sie Ihre IP-Adresse vor den Nachrichten-Routern, die Ihre Kontakte ausgewählt haben.\nAktivieren Sie es in den *Netzwerk & Server* Einstellungen."; /* No comment provided by engineer. */ "Protocol background timeout" = "Protokoll Hintergrund-Zeitüberschreitung"; @@ -4829,13 +4820,13 @@ swipe action */ "Relay server is only used if necessary. Another party can observe your IP address." = "Relais-Server werden nur genutzt, wenn sie benötigt werden. Ihre IP-Adresse kann von Anderen erfasst werden."; /* No comment provided by engineer. */ -"Relay server protects your IP address, but it can observe the duration of the call." = "Relais-Server schützen Ihre IP-Adresse, aber sie können die Anrufdauer erfassen."; +"Relay server protects your IP address, but it can observe the duration of the call." = "Relais-Server schützen Ihre IP-Adresse, können aber die Anrufdauer erfassen."; /* No comment provided by engineer. */ "Relay test failed!" = "Relais-Test fehlgeschlagen!"; /* No comment provided by engineer. */ -"Reliability: many relays per channel." = "Zuverlässigkeit: mehrere Relais pro Kanal."; +"Reliability: many relays per channel." = "Zuverlässigkeit: Mehrere Relais pro Kanal."; /* alert action */ "Remove" = "Entfernen"; @@ -4861,9 +4852,6 @@ swipe action */ /* No comment provided by engineer. */ "Remove passphrase from keychain?" = "Passwort aus dem Schlüsselbund entfernen?"; -/* No comment provided by engineer. */ -"Remove subscriber" = "Abonnent entfernen"; - /* alert title */ "Remove subscriber?" = "Abonnent entfernen?"; @@ -5223,7 +5211,7 @@ chat item action */ "security code changed" = "Sicherheitscode wurde geändert"; /* No comment provided by engineer. */ -"Security: owners hold channel keys." = "Sicherheit: Eigentümer besitzen die Kanalschlüssel."; +"Security: owners hold channel keys." = "Sicherheit: Nur die Eigentümer des Kanals besitzen die Schlüssel."; /* chat item action */ "Select" = "Auswählen"; @@ -5280,10 +5268,10 @@ chat item action */ "Send message to enable calls." = "Nachricht senden, um Anrufe zu aktivieren."; /* No comment provided by engineer. */ -"Send messages directly when IP address is protected and your or destination server does not support private routing." = "Nachrichten werden direkt versendet, wenn die IP-Adresse geschützt ist, und Ihr oder der Zielserver kein privates Routing unterstützt."; +"Send messages directly when IP address is protected and your or destination server does not support private routing." = "Nachrichten werden direkt versendet, wenn die IP-Adresse geschützt ist, und Ihr oder der Ziel-Server kein privates Routing unterstützt."; /* No comment provided by engineer. */ -"Send messages directly when your or destination server does not support private routing." = "Nachrichten werden direkt versendet, wenn Ihr oder der Zielserver kein privates Routing unterstützt."; +"Send messages directly when your or destination server does not support private routing." = "Nachrichten werden direkt versendet, wenn Ihr oder der Ziel-Server kein privates Routing unterstützt."; /* No comment provided by engineer. */ "Send notifications" = "Benachrichtigungen senden"; @@ -5406,7 +5394,7 @@ chat item action */ "server queue info: %@\n\nlast received msg: %@" = "Server-Warteschlangen-Information: %1$@\n\nZuletzt empfangene Nachricht: %2$@"; /* relay test error */ -"Server requires authorization to connect to relay, check password." = "Der Server erfordert eine Autorisierung, um eine Verbindung zum Relais herzustellen. Bitte Passwort überprüfen."; +"Server requires authorization to connect to relay, check password." = "Der Server erfordert eine Autorisierung, um eine Verbindung zum Router herzustellen. Bitte Passwort überprüfen."; /* server test error */ "Server requires authorization to create queues, check password." = "Der Server erfordert zum Erstellen von Warteschlangen eine Autorisierung. Bitte überprüfen Sie das Passwort."; @@ -5539,7 +5527,7 @@ chat item action */ "Share profile" = "Profil teilen"; /* No comment provided by engineer. */ -"Share relay address" = "Relais-Adresse teilen"; +"Share relay address" = "Router-Adresse teilen"; /* No comment provided by engineer. */ "Share SimpleX address on social media." = "Die SimpleX-Adresse auf sozialen Medien teilen."; @@ -6141,7 +6129,7 @@ server test failure */ "To protect your information, turn on SimpleX Lock.\nYou will be prompted to complete authentication before this feature is enabled." = "Um Ihre Informationen zu schützen, schalten Sie die SimpleX-Sperre ein.\nSie werden aufgefordert, die Authentifizierung abzuschließen, bevor diese Funktion aktiviert wird."; /* No comment provided by engineer. */ -"To protect your IP address, private routing uses your SMP servers to deliver messages." = "Zum Schutz Ihrer IP-Adresse, wird für die Nachrichten-Auslieferung privates Routing über Ihre konfigurierten SMP-Server genutzt."; +"To protect your IP address, private routing uses your SMP servers to deliver messages." = "Zum Schutz Ihrer IP-Adresse, wird für die Nachrichten-Auslieferung privates Routing über Ihre konfigurierten SMP-Router genutzt."; /* No comment provided by engineer. */ "To protect your privacy, SimpleX uses separate IDs for each of your contacts." = "Zum Schutz Ihrer Privatsphäre verwendet SimpleX an Stelle von Benutzerkennungen, die von allen anderen Plattformen verwendet werden, Kennungen für Nachrichtenwarteschlangen, die für jeden Ihrer Kontakte individuell sind."; @@ -6270,7 +6258,7 @@ server test failure */ "Unknown error" = "Unbekannter Fehler"; /* No comment provided by engineer. */ -"unknown servers" = "Unbekannte Relais"; +"unknown servers" = "Unbekannte Server"; /* alert title */ "Unknown servers!" = "Unbekannte Server!"; @@ -6417,10 +6405,10 @@ server test failure */ "Use only local notifications?" = "Nur lokale Benachrichtigungen nutzen?"; /* No comment provided by engineer. */ -"Use private routing with unknown servers when IP address is not protected." = "Sie nutzen privates Routing mit unbekannten Servern, wenn Ihre IP-Adresse nicht geschützt ist."; +"Use private routing with unknown servers when IP address is not protected." = "Bei unbekannten Servern privates Routing nutzen, wenn Ihre IP-Adresse nicht geschützt ist."; /* No comment provided by engineer. */ -"Use private routing with unknown servers." = "Sie nutzen privates Routing mit unbekannten Servern."; +"Use private routing with unknown servers." = "Bei unbekannten Servern privates Routing nutzen."; /* No comment provided by engineer. */ "Use relay" = "Relais verwenden"; @@ -6672,7 +6660,7 @@ server test failure */ "Without Tor or VPN, your IP address will be visible to file servers." = "Ohne Tor- oder VPN-Nutzung wird Ihre IP-Adresse für Datei-Server sichtbar sein."; /* alert message */ -"Without Tor or VPN, your IP address will be visible to these XFTP relays: %@." = "Ohne Tor- oder VPN-Nutzung wird Ihre IP-Adresse für diese XFTP-Relais sichtbar sein: %@."; +"Without Tor or VPN, your IP address will be visible to these XFTP relays: %@." = "Ohne Tor- oder VPN-Nutzung wird Ihre IP-Adresse für diese XFTP-Router sichtbar sein: %@."; /* No comment provided by engineer. */ "Wrong database passphrase" = "Falsches Datenbank-Passwort"; @@ -6759,7 +6747,7 @@ server test failure */ "You can accept calls from lock screen, without device and app authentication." = "Sie können Anrufe ohne Geräte- und App-Authentifizierung vom Sperrbildschirm aus annehmen."; /* No comment provided by engineer. */ -"You can change it in Appearance settings." = "Kann von Ihnen in den Erscheinungsbild-Einstellungen geändert werden."; +"You can change it in Appearance settings." = "Sie können dies in den Einstellungen unter „Darstellung“ ändern."; /* No comment provided by engineer. */ "You can configure servers via settings." = "Sie können die Server über die Einstellungen konfigurieren."; diff --git a/apps/ios/es.lproj/Localizable.strings b/apps/ios/es.lproj/Localizable.strings index 49826ff7f6..cf03ae6dbf 100644 --- a/apps/ios/es.lproj/Localizable.strings +++ b/apps/ios/es.lproj/Localizable.strings @@ -1,5 +1,5 @@ /* No comment provided by engineer. */ -" (can be copied)" = " (puede copiarse)"; +" (can be copied)" = " (puede ser copiado)"; /* No comment provided by engineer. */ "_italic_" = "\\_italic_"; @@ -505,9 +505,6 @@ swipe action */ /* No comment provided by engineer. */ "Added message servers" = "Servidores de mensajes añadidos"; -/* No comment provided by engineer. */ -"Adding relays will be supported later." = "Añadir servidores estará disponible en una versión posterior."; - /* No comment provided by engineer. */ "Additional accent" = "Acento adicional"; @@ -1182,9 +1179,6 @@ alert subtitle */ /* No comment provided by engineer. */ "Channel will be deleted for you - this cannot be undone!" = "El canal será eliminado para tí. ¡No puede deshacerse!"; -/* alert message */ -"Channel will start working with %d of %d relays. Proceed?" = "El canal comenzará a funcionar con %1$d de %2$d servidores. ¿Continuar?"; - /* No comment provided by engineer. */ "Channels" = "Canales"; @@ -1634,7 +1628,7 @@ server test step */ /* blocking reason */ "Content violates conditions of use" = "El contenido viola las condiciones de uso"; -/* No comment provided by engineer. */ +/* alert action */ "Continue" = "Continuar"; /* No comment provided by engineer. */ @@ -4577,9 +4571,6 @@ alert button */ /* alert title */ "Private routing timeout" = "Timeout enrutamiento privado"; -/* alert action */ -"Proceed" = "Continuar"; - /* No comment provided by engineer. */ "Profile and server connections" = "Eliminar perfil y conexiones"; @@ -4861,9 +4852,6 @@ swipe action */ /* No comment provided by engineer. */ "Remove passphrase from keychain?" = "¿Eliminar contraseña de Keychain?"; -/* No comment provided by engineer. */ -"Remove subscriber" = "Eliminar suscriptor"; - /* alert title */ "Remove subscriber?" = "¿Eliminar suscriptor?"; diff --git a/apps/ios/fi.lproj/Localizable.strings b/apps/ios/fi.lproj/Localizable.strings index b75323054a..3b1bd6523c 100644 --- a/apps/ios/fi.lproj/Localizable.strings +++ b/apps/ios/fi.lproj/Localizable.strings @@ -732,7 +732,7 @@ server test step */ /* No comment provided by engineer. */ "Contacts can mark messages for deletion; you will be able to view them." = "Kontaktit voivat merkitä viestit poistettaviksi; voit katsella niitä."; -/* No comment provided by engineer. */ +/* alert action */ "Continue" = "Jatka"; /* No comment provided by engineer. */ diff --git a/apps/ios/fr.lproj/Localizable.strings b/apps/ios/fr.lproj/Localizable.strings index 329490f34b..91cd6f3078 100644 --- a/apps/ios/fr.lproj/Localizable.strings +++ b/apps/ios/fr.lproj/Localizable.strings @@ -1357,7 +1357,7 @@ server test step */ /* blocking reason */ "Content violates conditions of use" = "Le contenu enfreint les conditions d'utilisation"; -/* No comment provided by engineer. */ +/* alert action */ "Continue" = "Continuer"; /* No comment provided by engineer. */ diff --git a/apps/ios/hu.lproj/Localizable.strings b/apps/ios/hu.lproj/Localizable.strings index 623d79433c..029fb9edd5 100644 --- a/apps/ios/hu.lproj/Localizable.strings +++ b/apps/ios/hu.lproj/Localizable.strings @@ -38,10 +38,10 @@ "[Send us email](mailto:chat@simplex.chat)" = "[Küldjön nekünk e-mailt](mailto:chat@simplex.chat)"; /* No comment provided by engineer. */ -"**Create 1-time link**: to create and share a new invitation link." = "**Partner hozzáadása:** új meghívási hivatkozás létrehozásához, vagy egy kapott hivatkozáson keresztül történő kapcsolódáshoz."; +"**Create 1-time link**: to create and share a new invitation link." = "**Partner hozzáadása**: új meghívási hivatkozás létrehozásához, vagy egy kapott hivatkozáson keresztül történő kapcsolódáshoz."; /* No comment provided by engineer. */ -"**Create group**: to create a new group." = "**Csoport létrehozása:** új csoport létrehozásához."; +"**Create group**: to create a new group." = "**Csoport létrehozása**: új csoport létrehozásához."; /* No comment provided by engineer. */ "**e2e encrypted** audio call" = "**végpontok között titkosított** hanghívás"; @@ -50,19 +50,19 @@ "**e2e encrypted** video call" = "**végpontok között titkosított** videóhívás"; /* No comment provided by engineer. */ -"**More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata." = "**Privátabb:** 20 percenként ellenőrzi az új üzeneteket. Az eszköztoken meg lesz osztva a SimpleX Chat kiszolgálóval, de az nem, hogy hány partnere vagy üzenete van."; +"**More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata." = "**Privátabb**: 20 percenként ellenőrzi az új üzeneteket. Az eszköztoken meg lesz osztva a SimpleX Chat kiszolgálóval, de az nem, hogy hány partnere vagy üzenete van."; /* No comment provided by engineer. */ "**Most private**: do not use SimpleX Chat push server. The app will check messages in background, when the system allows it, depending on how often you use the app." = "**A legprivátabb**: Az alkalmazás nem használja a SimpleX Chat push-kiszolgálóját. Az alkalmazás a háttérben ellenőrzi az üzeneteket, amikor a rendszer ezt lehetővé teszi, attól függően, hogy Ön milyen gyakran használja az alkalmazást."; /* No comment provided by engineer. */ -"**Please note**: using the same database on two devices will break the decryption of messages from your connections, as a security protection." = "**Megjegyzés:** ha két eszközön is ugyanazt az adatbázist használja, akkor biztonsági védelemként megszakítja a partnereitől érkező üzenetek visszafejtését."; +"**Please note**: using the same database on two devices will break the decryption of messages from your connections, as a security protection." = "**Megjegyzés**: ha két eszközön is ugyanazt az adatbázist használja, akkor biztonsági védelemként megszakítja a partnereitől érkező üzenetek visszafejtését."; /* No comment provided by engineer. */ -"**Please note**: you will NOT be able to recover or change passphrase if you lose it." = "**Megjegyzés:** NEM fogja tudni helyreállítani vagy módosítani a jelmondatot abban az esetben, ha elveszíti."; +"**Please note**: you will NOT be able to recover or change passphrase if you lose it." = "**Megjegyzés**: NEM fogja tudni helyreállítani vagy módosítani a jelmondatot abban az esetben, ha elveszíti."; /* No comment provided by engineer. */ -"**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from." = "**Megjegyzés:** az eszköztoken és az értesítések el lesznek küldve a SimpleX Chat értesítési kiszolgálóra, kivéve az üzenet tartalma, mérete vagy az, hogy kitől származik."; +"**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from." = "**Megjegyzés**: az eszköztoken és az értesítések el lesznek küldve a SimpleX Chat értesítési kiszolgálóra, kivéve az üzenet tartalma, mérete vagy az, hogy kitől származik."; /* No comment provided by engineer. */ "**Scan / Paste link**: to connect via a link you received." = "**Hivatkozás beolvasása / beillesztése**: egy kapott hivatkozáson keresztüli kapcsolódáshoz."; @@ -71,10 +71,10 @@ "**Test relay** to retrieve its name." = "**Átjátszó tesztelése** a nevének lekéréséhez."; /* No comment provided by engineer. */ -"**Warning**: Instant push notifications require passphrase saved in Keychain." = "**Figyelmeztetés:** Az azonnali leküldéses értesítésekhez a kulcstartóban tárolt jelmondat megadása szükséges."; +"**Warning**: Instant push notifications require passphrase saved in Keychain." = "**Figyelmeztetés**: Az azonnali leküldéses értesítésekhez a kulcstartóban tárolt jelmondat megadása szükséges."; /* No comment provided by engineer. */ -"**Warning**: the archive will be removed." = "**Figyelmeztetés:** az archívum el lesz távolítva."; +"**Warning**: the archive will be removed." = "**Figyelmeztetés**: az archívum el lesz távolítva."; /* No comment provided by engineer. */ "*bold*" = "\\*félkövér*"; @@ -226,10 +226,10 @@ channel relay bar */ "%d/%d relays active, %d removed" = "%1$d/%2$d átjátszó aktív, %3$d eltávolítva"; /* channel subscriber relay bar progress */ -"%d/%d relays connected" = "%1$d/%2$d átjátszó kapcsolódva"; +"%d/%d relays connected" = "%1$d/%2$d átjátszó kapcsolódott"; /* channel subscriber relay bar */ -"%d/%d relays connected, %d errors" = "%1$d/%2$d átjátszó kapcsolódva, %3$d hiba"; +"%d/%d relays connected, %d errors" = "%1$d/%2$d átjátszó kapcsolódott, %3$d hiba"; /* channel subscriber relay bar */ "%d/%d relays connected, %d failed" = "%1$d/%2$d átjátszó kapcsolódott, %3$d átjátszóhoz nem sikerült kapcsolódni"; @@ -376,7 +376,7 @@ time interval */ "A separate TCP connection will be used **for each chat profile you have in the app**." = "**Az összes csevegési profiljához az alkalmazásban** külön TCP-kapcsolat lesz használva."; /* No comment provided by engineer. */ -"A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail." = "**Az összes partneréhez és csoporttaghoz** külön TCP-kapcsolat lesz használva.\n**Megjegyzés:** ha sok kapcsolata van, akkor az akkumulátor-használat és az adatforgalom jelentősen megnövekedhet, és néhány kapcsolódási kísérlet sikertelen lehet."; +"A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail." = "**Az összes partneréhez és csoporttaghoz** külön TCP-kapcsolat lesz használva.\n**Megjegyzés**: ha sok kapcsolata van, akkor az akkumulátor-használat és az adatforgalom jelentősen megnövekedhet, és néhány kapcsolódási kísérlet sikertelen lehet."; /* No comment provided by engineer. */ "Abort" = "Megszakítás"; @@ -449,7 +449,7 @@ swipe action */ "accepted you" = "befogadta Önt"; /* No comment provided by engineer. */ -"Acknowledged" = "Visszaigazolt"; +"Acknowledged" = "Visszaigazolva"; /* No comment provided by engineer. */ "Acknowledgement errors" = "Visszaigazolási hibák"; @@ -505,9 +505,6 @@ swipe action */ /* No comment provided by engineer. */ "Added message servers" = "Hozzáadott üzenetkiszolgálók"; -/* No comment provided by engineer. */ -"Adding relays will be supported later." = "Az átjátszók hozzáadása később lesz támogatott."; - /* No comment provided by engineer. */ "Additional accent" = "További kiemelőszín"; @@ -716,10 +713,10 @@ swipe action */ "always" = "mindig"; /* No comment provided by engineer. */ -"Always use private routing." = "Mindig legyen használva privát útválasztás."; +"Always use private routing." = "Privát útválasztás használata minden esetben."; /* No comment provided by engineer. */ -"Always use relay" = "Mindig legyen használva átjátszó"; +"Always use relay" = "Átjátszó használata minden esetben"; /* No comment provided by engineer. */ "An empty chat profile with the provided name is created, and the app opens as usual." = "Egy üres csevegési profil lesz létrehozva a megadott névvel, és az alkalmazás a szokásos módon megnyílik."; @@ -878,7 +875,7 @@ swipe action */ "Be free\nin your network" = "Váljon szabaddá\na saját hálózatában"; /* No comment provided by engineer. */ -"Be free in your network." = "Legyen szabad a saját hálózatában."; +"Be free in your network." = "Váljon szabaddá a saját hálózatában."; /* No comment provided by engineer. */ "Because we destroyed the power to know who you are. So that your power can never be taken." = "Mert felszámoltuk a lehetőségét is annak, hogy megtudjuk, Ön kicsoda. Így az önrendelkezése soha nem kerülhet idegen kezekbe."; @@ -951,10 +948,10 @@ swipe action */ /* blocked chat item marked deleted chat item preview text */ -"blocked by admin" = "letiltva az adminisztrátor által"; +"blocked by admin" = "az adminisztrátor letiltotta"; /* No comment provided by engineer. */ -"Blocked by admin" = "Letiltva az adminisztrátor által"; +"Blocked by admin" = "Az adminisztrátor letiltotta"; /* No comment provided by engineer. */ "Blur for better privacy." = "Elhomályosítás a jobb adatvédelemért."; @@ -1171,7 +1168,7 @@ alert subtitle */ "channel profile updated" = "csatornaprofil frissítve"; /* alert message */ -"Channel profile was changed. If you save it, the updated profile will be sent to channel subscribers." = "A csatornaprofil módosult. Ha menti, akkor a frissített profil el lesz küldve a csatorna feliratkozóinak."; +"Channel profile was changed. If you save it, the updated profile will be sent to channel subscribers." = "Csatornaprofil módosítva. Ha menti, akkor a frissített profil el lesz küldve a csatorna feliratkozóinak."; /* alert title */ "Channel temporarily unavailable" = "A csatorna ideiglenesen nem érhető el"; @@ -1182,9 +1179,6 @@ alert subtitle */ /* No comment provided by engineer. */ "Channel will be deleted for you - this cannot be undone!" = "A csatorna törölve lesz az Ön számára – ez a művelet nem vonható vissza!"; -/* alert message */ -"Channel will start working with %d of %d relays. Proceed?" = "A csatorna %2$d átjátszóból %1$d használatával kezd el működni. Folytatja?"; - /* No comment provided by engineer. */ "Channels" = "Csatornák"; @@ -1634,7 +1628,7 @@ server test step */ /* blocking reason */ "Content violates conditions of use" = "A tartalom sérti a használati feltételeket"; -/* No comment provided by engineer. */ +/* alert action */ "Continue" = "Folytatás"; /* No comment provided by engineer. */ @@ -1800,7 +1794,7 @@ server test step */ "Database passphrase & export" = "Adatbázis-jelmondat és -exportálás"; /* No comment provided by engineer. */ -"Database passphrase is different from saved in the keychain." = "Az adatbázis jelmondata nem egyezik a kulcstartóba mentettől."; +"Database passphrase is different from saved in the keychain." = "Az adatbázis jelmondata eltér a kulcstartóban tárolttól."; /* No comment provided by engineer. */ "Database passphrase is required to open chat." = "A csevegés megnyitásához adja meg az adatbázis jelmondatát."; @@ -2172,7 +2166,7 @@ alert button */ "Do not use credentials with proxy." = "Ne használja a hitelesítési adatokat proxyval."; /* No comment provided by engineer. */ -"Do NOT use private routing." = "NE legyen használva privát útválasztás."; +"Do NOT use private routing." = "Privát útválasztás használatának elkerülése."; /* No comment provided by engineer. */ "Do NOT use SimpleX for emergency calls." = "NE használja a SimpleXet segélyhívásokhoz."; @@ -2356,7 +2350,7 @@ chat item action */ "Encrypted message: database migration error" = "Titkosított üzenet: adatbázis-átköltöztetési hiba"; /* notification */ -"Encrypted message: keychain error" = "Titkosított üzenet: kulcstartó hiba"; +"Encrypted message: keychain error" = "Titkosított üzenet: kulcstartóhiba"; /* notification */ "Encrypted message: no passphrase" = "Titkosított üzenet: nincs jelmondat"; @@ -2461,13 +2455,13 @@ chat item action */ "Error accepting contact request" = "Hiba történt a partneri kapcsolatkérés elfogadásakor"; /* alert title */ -"Error accepting member" = "Hiba a tag befogadásakor"; +"Error accepting member" = "Hiba történt a tag befogadásakor"; /* No comment provided by engineer. */ "Error adding member(s)" = "Hiba történt a tag(ok) hozzáadásakor"; /* alert title */ -"Error adding relay" = "Hiba az átjátszó hozzáadásakor"; +"Error adding relay" = "Hiba történt az átjátszó hozzáadásakor"; /* alert title */ "Error adding server" = "Hiba történt a kiszolgáló hozzáadásakor"; @@ -2479,7 +2473,7 @@ chat item action */ "Error changing address" = "Hiba történt a cím módosításakor"; /* alert title */ -"Error changing chat profile" = "Hiba a csevegési profil módosításakor"; +"Error changing chat profile" = "Hiba történt a csevegési profil módosításakor"; /* No comment provided by engineer. */ "Error changing connection profile" = "Hiba történt a kapcsolati profilra való váltáskor"; @@ -2506,7 +2500,7 @@ chat item action */ "Error creating address" = "Hiba történt a cím létrehozásakor"; /* alert title */ -"Error creating channel" = "Hiba a csatorna létrehozásakor"; +"Error creating channel" = "Hiba történt a csatorna létrehozásakor"; /* No comment provided by engineer. */ "Error creating group" = "Hiba történt a csoport létrehozásakor"; @@ -2533,7 +2527,7 @@ chat item action */ "Error decrypting file" = "Hiba történt a fájl visszafejtésekor"; /* alert title */ -"Error deleting chat" = "Hiba a csevegés törlésekor"; +"Error deleting chat" = "Hiba történt a csevegés törlésekor"; /* alert title */ "Error deleting chat database" = "Hiba történt a csevegési adatbázis törlésekor"; @@ -2614,7 +2608,7 @@ chat item action */ "Error resetting statistics" = "Hiba történt a statisztikák visszaállításakor"; /* No comment provided by engineer. */ -"Error saving channel profile" = "Hiba a csatornaprofil mentésekor"; +"Error saving channel profile" = "Hiba történt a csatornaprofil mentésekor"; /* alert title */ "Error saving chat list" = "Hiba történt a csevegési lista mentésekor"; @@ -2653,13 +2647,13 @@ chat item action */ "Error sending message" = "Hiba történt az üzenet elküldésekor"; /* No comment provided by engineer. */ -"Error setting auto-accept" = "Hiba az automatikus elfogadás beállításakor"; +"Error setting auto-accept" = "Hiba történt az automatikus elfogadás beállításakor"; /* No comment provided by engineer. */ "Error setting delivery receipts!" = "Hiba történt a kézbesítési jelentések beállításakor!"; /* alert title */ -"Error sharing channel" = "Hiba a csatorna megosztásakor"; +"Error sharing channel" = "Hiba történt a csatorna megosztásakor"; /* No comment provided by engineer. */ "Error starting chat" = "Hiba történt a csevegés elindításakor"; @@ -3057,7 +3051,7 @@ servers warning */ "group profile updated" = "csoportprofil frissítve"; /* alert message */ -"Group profile was changed. If you save it, the updated profile will be sent to group members." = "Csoportprofil módosítva. Ha menti, akkor a frissített profil el lesz küldve a csoporttagoknak."; +"Group profile was changed. If you save it, the updated profile will be sent to group members." = "Csoportprofil módosítva. Ha menti, akkor a frissített profil el lesz küldve a csoport tagjainak."; /* No comment provided by engineer. */ "Group welcome message" = "A csoport üdvözlőüzenete"; @@ -3129,7 +3123,7 @@ servers warning */ "How to use it" = "Használati útmutató"; /* No comment provided by engineer. */ -"How to use your servers" = "Hogyan használja a saját kiszolgálóit"; +"How to use your servers" = "Útmutató a saját kiszolgálók használatához"; /* No comment provided by engineer. */ "Hungarian interface" = "Magyar kezelőfelület"; @@ -3267,7 +3261,7 @@ servers warning */ "Initial role" = "Kezdeti szerepkör"; /* No comment provided by engineer. */ -"Install SimpleX Chat for terminal" = "A SimpleX Chat terminálhoz telepítése"; +"Install SimpleX Chat for terminal" = "SimpleX Chat telepítése a terminálhoz"; /* No comment provided by engineer. */ "Instant" = "Azonnali"; @@ -3501,7 +3495,7 @@ servers warning */ "Less traffic on mobile networks." = "Kevesebb adatforgalom a mobilhálózatokon."; /* No comment provided by engineer. */ -"Let someone connect to you" = "Hagyja, hogy valaki elérje Önt"; +"Let someone connect to you" = "Legyen elérhető mások számára"; /* email subject */ "Let's talk in SimpleX Chat" = "Beszélgessünk a SimpleX Chatben"; @@ -4548,7 +4542,7 @@ alert button */ "Privacy policy and conditions of use." = "Adatvédelmi szabályzat és felhasználási feltételek."; /* No comment provided by engineer. */ -"Privacy: for owners and subscribers." = "Adatvédelem: tulajdonosok és előfizetők számára."; +"Privacy: for owners and subscribers." = "Adatvédelem: tulajdonosok és feliratkozók számára."; /* No comment provided by engineer. */ "Private and secure messaging." = "Privát és biztonságos üzenetváltás."; @@ -4577,9 +4571,6 @@ alert button */ /* alert title */ "Private routing timeout" = "Privát útválasztás időtúllépése"; -/* alert action */ -"Proceed" = "Folytatás"; - /* No comment provided by engineer. */ "Profile and server connections" = "Profil és kiszolgálókapcsolatok"; @@ -4826,10 +4817,10 @@ swipe action */ "Relay results:" = "Átjátszóeredmények:"; /* No comment provided by engineer. */ -"Relay server is only used if necessary. Another party can observe your IP address." = "Az átjátszó csak szükség esetén lesz használva. Egy másik fél megfigyelheti az IP-címét."; +"Relay server is only used if necessary. Another party can observe your IP address." = "Az átjátszó kiszolgáló csak szükség esetén lesz használva. Egy másik fél megfigyelheti az IP-címét."; /* No comment provided by engineer. */ -"Relay server protects your IP address, but it can observe the duration of the call." = "Az átjátszó megvédi az IP-címét, de megfigyelheti a hívás időtartamát."; +"Relay server protects your IP address, but it can observe the duration of the call." = "Az átjátszó kiszolgáló megvédi az IP-címét, de megfigyelheti a hívás időtartamát."; /* No comment provided by engineer. */ "Relay test failed!" = "Nem sikerült tesztelni az átjátszót!"; @@ -4861,9 +4852,6 @@ swipe action */ /* No comment provided by engineer. */ "Remove passphrase from keychain?" = "Eltávolítja a jelmondatot a kulcstartóból?"; -/* No comment provided by engineer. */ -"Remove subscriber" = "Feliratkozó eltávolítása"; - /* alert title */ "Remove subscriber?" = "Eltávolítja a feliratkozót?"; @@ -5030,7 +5018,7 @@ swipe action */ "Review members before admitting (\"knocking\")." = "Tagok áttekintése a befogadás előtt (kopogtatás)."; /* No comment provided by engineer. */ -"reviewed by admins" = "áttekintve a moderátorok által"; +"reviewed by admins" = "a moderátorok áttekintették"; /* No comment provided by engineer. */ "Revoke" = "Visszavonás"; @@ -5919,7 +5907,7 @@ report reason */ /* relay test failure server test failure */ -"Test failed at step %@." = "A teszt a(z) %@ lépésnél sikertelen volt."; +"Test failed at step %@." = "A teszt a(z) %@. lépésnél sikertelen volt."; /* No comment provided by engineer. */ "Test notifications" = "Értesítések tesztelése"; @@ -6327,13 +6315,13 @@ server test failure */ "Update settings?" = "Frissíti a beállításokat?"; /* rcv group event chat item */ -"updated channel profile" = "frissített csatornaprofil"; +"updated channel profile" = "frissítette a csatorna profilját"; /* No comment provided by engineer. */ "Updated conditions" = "Frissített feltételek"; /* rcv group event chat item */ -"updated group profile" = "frissítette a csoportprofilt"; +"updated group profile" = "frissítette a csoport profilját"; /* profile update event chat item */ "updated profile" = "frissítette a profilját"; @@ -6714,7 +6702,7 @@ server test failure */ "You are already connected to %@." = "Ön már kapcsolódott a következőhöz: %@."; /* No comment provided by engineer. */ -"You are already connected with %@." = "Ön már kapcsolódva van vele: %@."; +"You are already connected with %@." = "Ön már kapcsolatban van vele: %@."; /* new chat sheet message */ "You are already connecting to %@." = "A kapcsolódás már folyamatban van a következőhöz: %@."; diff --git a/apps/ios/it.lproj/Localizable.strings b/apps/ios/it.lproj/Localizable.strings index 96b117eeca..c882eb662c 100644 --- a/apps/ios/it.lproj/Localizable.strings +++ b/apps/ios/it.lproj/Localizable.strings @@ -505,9 +505,6 @@ swipe action */ /* No comment provided by engineer. */ "Added message servers" = "Server dei messaggi aggiunti"; -/* No comment provided by engineer. */ -"Adding relays will be supported later." = "L'aggiunta di relay verrà supportata prossimamente."; - /* No comment provided by engineer. */ "Additional accent" = "Principale aggiuntivo"; @@ -1182,9 +1179,6 @@ alert subtitle */ /* No comment provided by engineer. */ "Channel will be deleted for you - this cannot be undone!" = "Il canale verrà eliminato per te, non è reversibile!"; -/* alert message */ -"Channel will start working with %d of %d relays. Proceed?" = "Il canale sarà operativo con %1$d di %2$d relay. Procedere?"; - /* No comment provided by engineer. */ "Channels" = "Canali"; @@ -1634,7 +1628,7 @@ server test step */ /* blocking reason */ "Content violates conditions of use" = "Il contenuto viola le condizioni di utilizzo"; -/* No comment provided by engineer. */ +/* alert action */ "Continue" = "Continua"; /* No comment provided by engineer. */ @@ -4293,10 +4287,10 @@ alert button */ "Open migration to another device" = "Apri migrazione ad un altro dispositivo"; /* new chat action */ -"Open new channel" = "Apri un canale nuovo"; +"Open new channel" = "Apri il nuovo canale"; /* new chat action */ -"Open new chat" = "Apri una chat nuova"; +"Open new chat" = "Apri la nuova chat"; /* new chat action */ "Open new group" = "Apri il nuovo gruppo"; @@ -4577,9 +4571,6 @@ alert button */ /* alert title */ "Private routing timeout" = "Scadenza dell'instradamento privato"; -/* alert action */ -"Proceed" = "Procedi"; - /* No comment provided by engineer. */ "Profile and server connections" = "Profilo e connessioni al server"; @@ -4861,9 +4852,6 @@ swipe action */ /* No comment provided by engineer. */ "Remove passphrase from keychain?" = "Rimuovere la password dal portachiavi?"; -/* No comment provided by engineer. */ -"Remove subscriber" = "Rimuovi iscritto"; - /* alert title */ "Remove subscriber?" = "Rimuovere l'iscritto?"; diff --git a/apps/ios/ja.lproj/Localizable.strings b/apps/ios/ja.lproj/Localizable.strings index b14777244f..35d8732e3f 100644 --- a/apps/ios/ja.lproj/Localizable.strings +++ b/apps/ios/ja.lproj/Localizable.strings @@ -1005,7 +1005,7 @@ server test step */ /* No comment provided by engineer. */ "Contacts can mark messages for deletion; you will be able to view them." = "連絡先はメッセージを削除対象とすることができます。あなたには閲覧可能です。"; -/* No comment provided by engineer. */ +/* alert action */ "Continue" = "続ける"; /* No comment provided by engineer. */ diff --git a/apps/ios/nl.lproj/Localizable.strings b/apps/ios/nl.lproj/Localizable.strings index e12e255488..407665bbec 100644 --- a/apps/ios/nl.lproj/Localizable.strings +++ b/apps/ios/nl.lproj/Localizable.strings @@ -1382,7 +1382,7 @@ server test step */ /* blocking reason */ "Content violates conditions of use" = "Inhoud schendt de gebruiksvoorwaarden"; -/* No comment provided by engineer. */ +/* alert action */ "Continue" = "Doorgaan"; /* No comment provided by engineer. */ diff --git a/apps/ios/pl.lproj/Localizable.strings b/apps/ios/pl.lproj/Localizable.strings index e2e46590c9..8905300160 100644 --- a/apps/ios/pl.lproj/Localizable.strings +++ b/apps/ios/pl.lproj/Localizable.strings @@ -1439,7 +1439,7 @@ server test step */ /* blocking reason */ "Content violates conditions of use" = "Treść narusza warunki użytkowania"; -/* No comment provided by engineer. */ +/* alert action */ "Continue" = "Kontynuuj"; /* No comment provided by engineer. */ diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index c2b01228a2..9f79b5dea0 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -505,9 +505,6 @@ swipe action */ /* No comment provided by engineer. */ "Added message servers" = "Дополнительные серверы сообщений"; -/* No comment provided by engineer. */ -"Adding relays will be supported later." = "Добавление релеев будет поддерживаться позже."; - /* No comment provided by engineer. */ "Additional accent" = "Дополнительный акцент"; @@ -1182,9 +1179,6 @@ alert subtitle */ /* No comment provided by engineer. */ "Channel will be deleted for you - this cannot be undone!" = "Канал будет удалён для Вас - это нельзя отменить!"; -/* alert message */ -"Channel will start working with %d of %d relays. Proceed?" = "Канал начнёт работу с %1$d из %2$d релеев. Продолжить?"; - /* No comment provided by engineer. */ "Channels" = "Каналы"; @@ -1634,7 +1628,7 @@ server test step */ /* blocking reason */ "Content violates conditions of use" = "Содержание нарушает условия использования"; -/* No comment provided by engineer. */ +/* alert action */ "Continue" = "Продолжить"; /* No comment provided by engineer. */ @@ -4577,9 +4571,6 @@ alert button */ /* alert title */ "Private routing timeout" = "Таймаут конфиденциальной доставки"; -/* alert action */ -"Proceed" = "Продолжить"; - /* No comment provided by engineer. */ "Profile and server connections" = "Профиль и соединения на сервере"; @@ -4861,9 +4852,6 @@ swipe action */ /* No comment provided by engineer. */ "Remove passphrase from keychain?" = "Удалить пароль из Keychain?"; -/* No comment provided by engineer. */ -"Remove subscriber" = "Удалить подписчика"; - /* alert title */ "Remove subscriber?" = "Удалить подписчика?"; @@ -6327,7 +6315,7 @@ server test failure */ "Update settings?" = "Обновить настройки?"; /* rcv group event chat item */ -"updated channel profile" = "обновлён профиль канала"; +"updated channel profile" = "обновил профиль канала"; /* No comment provided by engineer. */ "Updated conditions" = "Обновлённые условия"; diff --git a/apps/ios/th.lproj/Localizable.strings b/apps/ios/th.lproj/Localizable.strings index 652737b4ca..cc3abea189 100644 --- a/apps/ios/th.lproj/Localizable.strings +++ b/apps/ios/th.lproj/Localizable.strings @@ -705,7 +705,7 @@ server test step */ /* No comment provided by engineer. */ "Contacts can mark messages for deletion; you will be able to view them." = "ผู้ติดต่อสามารถทําเครื่องหมายข้อความเพื่อลบได้ คุณจะสามารถดูได้"; -/* No comment provided by engineer. */ +/* alert action */ "Continue" = "ดำเนินการต่อ"; /* No comment provided by engineer. */ diff --git a/apps/ios/tr.lproj/Localizable.strings b/apps/ios/tr.lproj/Localizable.strings index fb3bf39168..e06989afee 100644 --- a/apps/ios/tr.lproj/Localizable.strings +++ b/apps/ios/tr.lproj/Localizable.strings @@ -169,6 +169,18 @@ /* time interval */ "%d months" = "%d ay"; +/* channel relay bar +channel subscriber relay bar */ +"%d relays failed" = "%d aktarıcı başarısız oldu"; + +/* channel relay bar +channel subscriber relay bar */ +"%d relays not active" = "%d aktarıcı etkin değil"; + +/* channel relay bar +channel subscriber relay bar */ +"%d relays removed" = "%d aktarıcı kaldırıldı"; + /* time interval */ "%d sec" = "%d saniye"; @@ -178,9 +190,32 @@ /* integrity error chat item */ "%d skipped message(s)" = "%d okunmamış mesaj(lar)"; +/* channel subscriber count */ +"%d subscriber" = "%d abone"; + +/* channel subscriber count */ +"%d subscribers" = "%d abone"; + /* time interval */ "%d weeks" = "%d hafta"; +/* channel creation progress +channel relay bar progress */ +"%d/%d relays active" = "%1$d/%2$d aktarıcı etkin"; + +/* channel relay bar */ +"%d/%d relays active, %d errors" = "%1$d/%2$d aktarıcı etkin, %3$d hata"; + +/* channel creation progress with errors +channel relay bar */ +"%d/%d relays active, %d failed" = "%1$d/%2$d aktarıcı etkin, %3$d başarısız"; + +/* channel relay bar */ +"%d/%d relays active, %d removed" = "%1$d/%2$d aktarıcı etkin, %3$d kaldırıldı"; + +/* channel subscriber relay bar progress */ +"%d/%d relays connected" = "%1$d/%2$d aktarıcı bağlı"; + /* No comment provided by engineer. */ "%lld" = "%lld"; @@ -1424,7 +1459,7 @@ server test step */ /* blocking reason */ "Content violates conditions of use" = "İçerik kullanım koşullarını ihlal ediyor"; -/* No comment provided by engineer. */ +/* alert action */ "Continue" = "Devam et"; /* No comment provided by engineer. */ diff --git a/apps/ios/uk.lproj/Localizable.strings b/apps/ios/uk.lproj/Localizable.strings index a0d9490b00..4a21eb4ae8 100644 --- a/apps/ios/uk.lproj/Localizable.strings +++ b/apps/ios/uk.lproj/Localizable.strings @@ -1409,7 +1409,7 @@ server test step */ /* blocking reason */ "Content violates conditions of use" = "Вміст порушує умови використання"; -/* No comment provided by engineer. */ +/* alert action */ "Continue" = "Продовжуйте"; /* No comment provided by engineer. */ diff --git a/apps/ios/zh-Hans.lproj/Localizable.strings b/apps/ios/zh-Hans.lproj/Localizable.strings index 3893351fdd..13be5125ea 100644 --- a/apps/ios/zh-Hans.lproj/Localizable.strings +++ b/apps/ios/zh-Hans.lproj/Localizable.strings @@ -1433,7 +1433,7 @@ server test step */ /* blocking reason */ "Content violates conditions of use" = "内容违反使用条款"; -/* No comment provided by engineer. */ +/* alert action */ "Continue" = "继续"; /* No comment provided by engineer. */ 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..bd9c5d6881 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml @@ -60,7 +60,7 @@ السماح لجهات اتصالك بالاتصال بك. السماح بردود الفعل على الرسائل. يتم مسح جميع البيانات عند إدخالها. - سيتم استخدام Android Keystore لتخزين عبارة المرور بشكل آمن بعد إعادة تشغيل التطبيق أو تغيير عبارة المرور - سيسمح بتلقي الإشعارات. + سيتم استخدام Android Keystore لتخزين عبارة المرور بشكل آمن بعد إعادة تشغيل التطبيق أو تغيير عبارة المرور - سيسمح بإستلام الإشعارات. السماح لجهات اتصالك بإرسال رسائل تختفي. اسمح بالرسائل الصوتية فقط إذا سمحت جهة اتصالك بذلك. رمز مرور التطبيق @@ -76,7 +76,7 @@ عن عنوان SimpleX بناء التطبيق: %s المظهر - أضف عنوانًا إلى ملف تعريفك، حتى تتمكن جهات اتصالك من مشاركته مع أشخاص آخرين. سيتم إرسال تحديث ملف التعريف إلى جهات اتصالك. + أضف عنوانًا إلى ملف تعريفك، حتى تتمكن جهات اتصالك على SimpleX من مشاركته مع أشخاص آخرين. سيتم إرسال تحديث ملف التعريف إلى جهات اتصالك على SimpleX. ستبقى جميع جهات اتصالك متصلة. سيتم إرسال تحديث ملف التعريف إلى جهات اتصالك. رمز التطبيق عنوان @@ -525,7 +525,7 @@ فوري المضيف إخفاء - السماح بذلك في مربع الحوار التالي لتلقي الإشعارات على الفور.]]> + السماح بذلك في مربع الحوار التالي لاستلام الإشعارات على الفور.]]> ردًا على إشعارات فورية خوادم ICE (واحد لكل سطر) @@ -728,7 +728,7 @@ إيصالات تسليم الرسائل! دقائق شهور - - توصيل رسائل أكثر استقرارًا.\n- مجموعات أفضل قليلاً.\n- و اكثر! + - تسليم رسائل أكثر استقرارًا.\n- مجموعات أفضل قليلاً.\n- و اكثر! حالة الشبكة كتم ردود الفعل الرسائل ممنوعة. @@ -878,7 +878,7 @@ يرى المُستلمون التحديثات أثناء كتابتها. استلمت، ممنوع حفظ - سيتم إرسال تحديث ملف التعريف إلى جهات اتصالك. + سيتم إرسال تحديث ملف التعريف إلى جهات اتصالك على SimpleX. حفظ وإشعار جهات الاتصال حفظ وتحديث ملف تعريف المجموعة عدد البينج @@ -971,7 +971,7 @@ تدمير ذاتي مسح الرمز أرسل أسئلة وأفكار - مشاركة العنوان مع جهات الاتصال؟ + مشاركة العنوان مع جهات اتصال SimpleX؟ شارك العنوان حفظ رسالة الترحيب؟ احفظ الخوادم @@ -1060,7 +1060,7 @@ التوقف عن إرسال الملف؟ عنوان SimpleX استخدم مضيفي .onion إلى "لا" إذا كان وسيط SOCKS لا يدعمها.]]> - مشاركة مع جهات الاتصال + شارك مع جهات اتصال SimpleX إيقاف التشغيل؟ إعدادات وسيط SOCKS إيقاف التشغيل @@ -1306,7 +1306,7 @@ الإيصالات مُعطَّلة %s: %s تضم هذه المجموعة أكثر من %1$d عضو، ولا يتم إرسال إيصالات التسليم. - التوصيل + التسليم مُعطَّل تعطيل الإيصالات للمجموعات؟ فعّل (الاحتفاظ بتجاوزات المجموعة) @@ -1323,7 +1323,7 @@ افتح إعدادات التطبيق لا يمكن تشغيل SimpleX في الخلفية. ستستلم الإشعارات فقط عندما يكون التطبيق قيد التشغيل. سيتم مشاركة ملف تعريف عشوائي جديد. - ألصق الرابط المُستلَم للتواصل مع جهة اتصالك… + ألصِق الرابط المُستلَم للتواصل مع جهة اتصالك… ستتم مشاركة ملفك التعريفي %1$s. قد يغلق التطبيق بعد دقيقة واحدة في الخلفية. سماح @@ -1348,7 +1348,7 @@ افتح مجلد قاعدة البيانات سيتم تخزين عبارة المرور في الإعدادات كنص عادي بعد تغييرها أو إعادة تشغيل التطبيق. عبارة المرور مخزنة في الإعدادات كنص عادي. - يُرجى الملاحظة: يتم توصيل مُرحلات الرسائل والملفات عبر وسيط SOCKS. تستخدم المكالمات وإرسال معاينات الروابط الاتصال المباشر.]]> + يُرجى الملاحظة: يتم تسليم مُرحلات الرسائل والملفات عبر وسيط SOCKS. تستخدم المكالمات الاتصال المباشر.]]> عَمِّ الملفات المحلية عمِّ الملفات والوسائط المخزنة تطبيق سطح المكتب الجديد! @@ -1843,7 +1843,7 @@ العضو غير نشط رسالة مُحوّلة لا يوجد اتصال مباشر حتى الآن، الرسالة مُحوّلة بواسطة المُدير. - امسح / ألصِق الرابط + ألصِق رابط / امسح خوادم SMP المهيأة خوادم SMP أخرى خوادم XFTP المهيأة @@ -1936,7 +1936,7 @@ أعِد توصيل كافة الخوادم المتصلة لفرض تسليم الرسالة. يستخدم حركة مرور إضافية. خوادم موّكلة تلقي الأخطاء - تلقى الإجمالي + استلام الإجمالي أعِد توصيل جميع الخوادم أعِد توصيل الخوادم؟ عنوان الخادم @@ -2125,7 +2125,7 @@ %s.]]> شروط الاستخدام للتوجيه الخاص - لتلقي + لاستلام استخدم للملفات اعرض الشروط %s.]]> @@ -2159,12 +2159,12 @@ لا يمكن تحميل نص الشروط الحالية، يمكنك مراجعة الشروط عبر هذا الرابط: خطأ في قبول الشروط خطأ في حفظ الخوادم - على سبيل المثال، إذا تلقى أحد جهات اتصالك رسائل عبر خادم SimpleX Chat، فسيقوم تطبيقك بتسليمها عبر خادم Flux. + على سبيل المثال، إذا استلم أحد جهات اتصالك رسائل عبر خادم SimpleX Chat، فسيقوم تطبيقك بتسليمها عبر خادم Flux. لا يوجد خوادم لتوجيه الرسائل الخاصة. لا يوجد خوادم رسائل. - لا يوجد خوادم لاستقبال الملفات. + لا يوجد خوادم لاستلام الملفات. لا توجد رسالة - لا يوجد خوادم لاستقبال الرسائل. + لا يوجد خوادم لاستلام الرسائل. - فتح الدردشة عند أول رسالة غير مقروءة.\n- الانتقال إلى الرسائل المقتبسة. يمكنك تعيين اسم الاتصال، لتذكر الأشخاص الذين تمت مشاركة الرابط معهم. راجع لاحقًا @@ -2197,7 +2197,7 @@ احذف الدردشة الدردشة موجودة بالفعل! حذف الدردشة؟ - %1$s.]]> + %1$s.]]> أو استورد ملف الأرشيف لا توجد خدمة خلفية الإشعارات والبطارية @@ -2350,8 +2350,8 @@ لا يمكن قراءة عبارة المرور في Keystore. قد يكون هذا قد حدث بعد تحديث النظام غير متوافق مع التطبيق. إذا لم يكن الأمر كذلك، فيُرجى التواصل مع المطوِّرين. موافقة الانتظار سياسة الخصوصية وشروط الاستخدام. - لا يمكن الوصول إلى الدردشات الخاصة والمجموعات وجهات اتصالك لمشغلي الخادم. - باستخدام SimpleX Chat، توافق على:\n- إرسال المحتوى القانوني فقط في المجموعات العامة.\n- احترام المستخدمين الآخرين – لا سبام. + يلتزم المشغلون بما يلي:\n- الاستقلالية\n- تقليل استخدام البيانات الوصفية\n- تشغيل برمجيات مفتوحة المصدر وموثقة + أنت تلتزم بـ:\n- المحتوى القانوني فقط في المجموعات العامة\n- احترام المستخدمين الآخرين — لا للرسائل المزعجة اقبل يتطلب هذا الرابط إصدار تطبيق أحدث. يُرجى ترقية التطبيق أو اطلب من جهة اتصالك إرسال رابط متوافق. رابط كامل @@ -2564,7 +2564,7 @@ حُدِّث ملف تعريف القناة ستُحذف القناة لجميع المشتركين - لا يمكن التراجع عن هذا الإجراء! ستُحذف القناة من عِندك - لا يمكن التراجع عن هذا الإجراء! - ستبدأ القناة بالعمل مع %1$d من أصل %2$d من المُرحلات. أتود المتابعة؟ + ستبدأ القناة بالعمل مع %1$d من أصل %2$d مُرحلات. أتود الاستمرار؟ مُرحل الدردشة مُرحلات الدردشة مُرحلات الدردشة توجّه الرسائل في القنوات التي تنشئها. @@ -2654,4 +2654,160 @@ عنوان مُرحلك اسم مُرحلك ستتوقف عن تلقي الرسائل من هذه القناة، وسيتم الاحتفاظ بسجل الدردشة. + %1$d/%2$d مِن المُرحلات نشطة، و%3$d أخطاء + %1$d/%2$d مِن المُرحلات نشطة، وأُزيل %3$d + %1$d/%2$d مِن المُرحلات متصلة، وفشل %3$d + %1$d/%2$d مِن المُرحلات متصلة، وأُزيل %3$d + فشل %1$d مُرحلات + %1$d مُرحلات غير نشطة + %1$d مُرحلات أُزيلت + أضف مُرحلات لاستعادة تسليم الرسائل. + رابط اتصال لشخص واحد + اسمح للأعضاء بالدردشة مع المُدراء. + اسمح بإرسال رسائل مباشرة للمشتركين. + اسمح للمشتركين بالدردشة مع المُدراء. + فشلت كل المُرحلات + أُزيلت كل المُرحلات + لأننا دمرنا القدرة على معرفة من أنت. لكي لا تُسلب قوتك أبدًا. + كن حُرًا\nفي شبكتك + كن حُرًا في شبكتك. + الشريط السفلي + عنوان العمل التجاري + تعذّر الإذاعة + القناة لا تحتوي على محطات تقوية نشطة. يُرجى محاولة الانضمام لاحقًا. + رابط القناة + تفضلات القناة + القنوات + القناة غير متوفرة مؤقتًا + الدردشة مع المُدراء محظورة. + الدردشة مع المُدراء في القنوات العامة لا تتوفر فيها ميزة التعمية بين الطرفين (E2E) - لا تستخدمها إلا مع مُرحلات دردشة موثوقة. + عُطّل الدردشة مع الأعضاء + الدردشة مع المُدراء + اتصل عبر رابط أو رمز QR + عنوان التواصل + أنشئ رابطك + أنشئ رابطك العام + تُمنع الرسائل المباشرة بين المشتركين. + عطّل + لا ترسل السجل للمشتركين الجدد. + أصبح أسهل أن تدعي أصحابك 👋 + فعّل + فعّل + فعّل الدردشة مع المُدراء؟ + فعّل معاينة الروابط؟ + أدخل اسم ملف التعريف… + خطأ + خطأ في مشاركة القناة + لكي يتمكن أي شخص من الوصول إليك + (مِن المالك) + ابدأ + رابط المجموعة + لا يُرسل السجل للمشتركين الجدد. + غير نشط + ادعُ شخصٍ ما بشكل خاص + اجعل شخصًا ما يتواصل معك + سيتم طلب معاينة الرابط عبر وسيط SOCKS. قد لا يزال البحث عن نظام أسماء النطاقات (DNS) يتم محليًا من خلال محلّل DNS الخاص بك. + تحققَ من توقيع الرابط. + يمكن للأعضاء الدردشة مع المُدراء. + ليست مُعمّاة تمامًا بين الطرفين. يمكن لمُرحلات الدردشة رؤية هذه الرسائل.]]> + رحّل + التزامات الشبكة + خطأ في الشبكة + لا يمكن لموجّهات الشبكة معرفة مَن يتحدث مع مَن + رابط جديد لمرة واحدة + لا حساب. لا هاتف. لا بريد إلكتروني. لا هوية. التعمية الأكثر أمانًا. + لا مُرحلات نشطة + لم يتتبع أحد محادثاتك. ولم يرسم أحد خريطة للأماكن التي زرتها. لم تكن الخصوصية مجرد ميزة، بل كانت أسلوب حياة. + تنظيم غير ربحي + ليس مجرد قفل أفضل على باب شخص آخر. ولا صاحب عقار ألطف يحترم خصوصيتك، لكنه لا يزال يحتفظ بسجل لجميع الزوار. أنت لست ضيفًا. أنت في بيتك. لا يمكن لأي ملك أن يدخله — فأنت صاحب السيادة. + رابط لمرة واحدة + يمكن لمالكي القنوات فقط تغيير تفضيلات القناة. + على جوّالك، وليس على الخوادم. + افتح رابط خارجي؟ + - الموافقة على إرسال معاينات الروابط.\n- استخدام وسيط SOCKS في حال تفعيله.\n- منع التصيد الاحتيالي عبر الروابط التشعبية.\n- إزالة تتبُع الروابط. + أو أظهر رمز QR شخصيًا أو عبر مكالمة فيديو. + أو استخدم رمز QR هذا - اطبعه أو اعرضه عبر الإنترنت. + الملكية: يمكنك تشغيل المُرحلات الخاصة بك. + الخصوصية: للمالكين والمشتركين. + مُراسلة خاصة وآمنة. + منع الدردشات مع المُدراء. + منع إرسال رسائل مباشرة للمشتركين. + قنوات عامة - تحدث بحرية 🚀 + نتائج المُرحل: + الموثوقية: عِدّة مُرحلات لكل قناة. + أُزيل بواسطة المُشغل + روابط ويب آمنة + الأمن: المالكون يمتلكون مفاتيح القنوات. + قد يؤدي إرسال معاينة للرابط إلى كشف عنوان IP الخاص بك للموقع الإلكتروني. يمكنك تغيير هذا الإعداد لاحقًا من إعدادات الخصوصية. + أرسل الرابط عبر أي تطبيق مُراسلة - فهو آمن. واطلب منه لصقه في SimpleX. + أرسل ما يصل إلى آخر 100 رسالة للمشتركين الجدد. + أعِدّ الإشعارات + أعِدّ أجهزة التوجيه + شارك القناة… + شارك عبر الدردشة + ⚠️ فشل التحقق من التوقيع: %s. + (موقّع) + بلاغات المشترك + يمكن للمشتركين إضافة ردود الفعل على الرسائل. + يمكن للمشتركين الدردشة مع المُدراء. + يمكن للمشتركين حذف الرسائل المُرسلة نهائيًا. (24 ساعة) + يمكن للمشتركين الإبلاغ عن الرسائل للمشرفين. + يمكن للمشتركين إرسال رسائل مباشرة. + يمكن للمشتركين إرسال رسائل تختفي. + يمكن للمشتركين إرسال الملفات والوسائط. + يمكن للمشتركين إرسال روابط SimpleX. + يمكن للمشتركين إرسال رسائل صوتية. + تحدث مع شخصٍ ما + انقر للفتح + وصل الاتصال إلى الحد الأقصى للرسائل غير المُسلَّمة + أول شبكة تمتلك\nفيها جهات اتصالك ومجموعاتك. + ’ثم انتقلنا إلى الإنترنت، وطلبت كل منصة جزءًا منك؛ اسمك، ورقمك، وأصدقاءك. وقبلنا بأن ثمن التحدث مع الآخرين هو السماح لشخص ما بمعرفة مَن نتحدث إليهم. وكل جيل، سواء من الناس أو التقنية، عاش الأمر على هذا النحو؛ الهاتف، والبريد الإلكتروني، وتطبيقات المراسلة، ووسائل التواصل الاجتماعي. وبدا أن هذه هي الطريقة الوحيدة الممكنة. + أقدم حرية إنسانية — وهي التحدث إلى شخص آخر دون مراقبة — مبنية على بنية تحتية لا يمكنها خيانتها. + هناك طريقة أخرى. شبكة بلا أرقام هواتف، ولا أسماء مستخدمين، ولا حسابات، ولا هويات مستخدمين من أي نوع. شبكة تربط الناس وتنقل الرسائل المُعمّاة دون معرفة مَن المتصل. + لضمان استمرارية شبكة SimpleX. + الشريط العلوي + يُرسل ما يصل إلى آخر 100 رسالة للمشتركين الجدد. + استخدم هذا العنوان في ملف تعريفك على مواقع التواصل الاجتماعي أو موقعك الإلكتروني أو في توقيع بريدك الإلكتروني. + في انتظار قيام مالك القناة بإضافة المُرحلات. + جعلنا عملية الاتصال أكثر بساطة للمستخدمين الجدد. + لماذا بُنيا SimpleX. + محادثاتك ملك لك، تمامًا كما كان الحال دائمًا قبل ظهور الإنترنت. الشبكة ليست مكانًا تزوره، بل هي مكان تصنعه وتمتلكه؛ ولا يمكن لأحد أن يسلبه منك، سواء جعلته خاصًا أو عامًا. + شبكتك + ملف تعريفك + عنوانك العام + لقد وُلدت دون حساب تعريفي. + قناتك الجديدة %1$s متصلة بـ %2$d من أصل %3$d مُرحلات. إذا ألغيت، ستُحذف القناة - يمكنك إنشاؤها مرة أخرى. + أضف + أضف مُرحل + أضف مُرحلات + ألغِ واحذف القناة + %d مُرحل/ات مُحدّدة + خطأ في إضافة المُرحلات + لا مُرحلات متاحة + لا مُرحلات + لا مُرحلات مُحدّدة + المُرحلات المُضافة: %1$s. + سيُزيل المُرحل من القناة - لا يمكن التراجع عن هذا الإجراء! + أُزيل + أزِل المُرحل + إزالة المُرحل؟ + حدّد المُرحلات + هذا هو آخر مُرحل نشط. إزالته ستمنع تسليم الرسائل للمشتركين. + أغلِق التطبيق + خطأ في حذف الرسالة + من السجل + إذا اخترت أغلِق، فلن تُستلم الرسائل.\nيمكنك تغيير ذلك لاحقًا من إعدادات المظهر. + أبقِ SimpleX يعمل في الخلفية لاستلام الرسائل. + صغّر إلى اللوحة + تصغير إلى اللوحة؟ + صغّر إلى اللوحة عند إغلاق النافذة + أنهِ SimpleX + أظهر SimpleX + SimpleX + SimpleX — %d غير مقروءة + قد تكون هناك نُسخة أخرى من التطبيق قيد التشغيل أو لم تُغلق بشكل صحيح. أتريد البدء على أي حال؟ + التطبيق يعمل بالفعل + رُفض + رُفض بواسطة مُشغل المُرحل + الحالة 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..c727cc1cb0 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml @@ -1537,7 +1537,7 @@ Chyba vytváření zprávy Chyba odstranění soukromých poznámek Smazat soukromé poznámky? - Všechny zprávy budou smazány - nemůže být zvráceno! + Všechny zprávy budou smazány - nelze zvrátit! Možnosti vývojáře blokováno %s kontakt %1$s změnen na %2$s @@ -1556,8 +1556,7 @@ PC má nepodporovanou verzi. Ujistěte se, že používáte stejnou verzi na obou zařízeních PC má chybný kód pozvánky Neplatné jméno! - Databáze migrace běží. -\nMůže to trvat několik minut. + Probíhá migrace databáze. \nMůže to trvat několik minut. člen %1$s změněn na %2$s blokováno Blokováno adminem @@ -1589,7 +1588,7 @@ %s byl odpojen]]> Chyba otevření prohlížeče odstraněn profilový obrázek - nastavit novou kontaktní adresu + nastavil novou kontaktní adresu nastavil nový profilový obrázek Uložené zprávy Pomalá funkce @@ -2247,9 +2246,9 @@ Nahráno Ano Tuto akci nelze zrušit - zprávy odeslané a přijaté v tomto chatu dříve než vybraná, budou smazány. - Statistiky serverů budou obnoveny - nemůže být vráceno! + Statistiky serverů budou obnoveny - nelze vrátit! Odešlete soukromý report - Pomozte administrátorům moderovat své skupiny. + Pomozte správcům moderovat jejich skupiny. Rychlejší mazání skupin. Od %s. Můžete zmínit až %1$s členů ve zprávě! @@ -2532,7 +2531,7 @@ Filtr Obrázky Odkazy - Zprávy člena budou smazány - nemůže být zrušeno! + Zprávy člena budou smazány - nelze zrušit! bez předplatného Odebrat a smazat zprávy Hledat soubory @@ -2556,4 +2555,219 @@ Nejstarší lidská svoboda - mluvit s druhým člověkem, aniž by byl sledován - postavena na infrastruktuře, která ji nemůže zradit. Protože jsme zničili sílu vědět, kdo jste. Aby vám vaši moc nikdo nemohl vzít. Buďte svobodní ve své síti. + %1$d/%2$d relé aktivních + %1$d/%2$d relé aktivních, %3$d chyb + %1$d/%2$d relé aktivních, %3$d selhalo + %1$d/%2$d relé aktivních, %3$d odstraněno + %1$d/%2$d relé připojeno + %1$d/%2$d relé připojeno, %3$d chyb + %1$d/%2$d relé připojeno, %3$d selhalo + %1$d/%2$d relé připojeno, %3$d odstraněno + %1$d relé selhalo + %1$d relé neaktivních + %1$d relé odstraněno + %1$d odběratel + %1$d odběratelů + přijaté + Přidávání relé bude podporováno později. + Odkaz pro připojení jedné osoby + Povolit členům chat se správci. + Kanál + Kanál + Celý název kanálu: + Kanál nemá aktivní relé.\nProsím, zkuste se připojit později. + Odkaz kanálu + Odkaz kanálu + Členové kanálu + Název kanálu + Vlastnosti kanálu + kanál + Profil Kanálu je uložen na zařízeních odběratelů a na chat relé. + profil kanálu aktualizován + Kanály + Kanál dočasně nedostupný + Kanál bude smazán pro všechny odběratele - nelze zrušit! + Kanál bude pro vás odstraněn - nelze zvrátit! + Kanál začne pracovat s %1$d z %2$d relé. Pokračovat? + Chat relé + Chat relé + Chat relé + Chat relé + Chat relé předávají zprávy v kanálech, které vytvoříte. + Chat relé předávají zprávy na kanál odběratelů. + Chat se správci zakázán. + Chat se správci ve veřejných kanálech nemají šifrování E2E - používejte pouze s důvěryhodnými chat relé. + Chat se členy je zakázán + Chat se správci + Zkontrolujte relé adresu a zkuste to znovu. + Zkontrolujte jméno relé a zkuste to znovu. + Nastavit relé + Připojit + připojeno + připojuji + Připojení přes odkaz nebo QR kód + Vytvořit veřejný kanál + Vytvořit veřejný kanál + Vytvořit veřejný kanál (BETA) + Vytvořte odkaz + Vytvořte si veřejnou adresu + Vytvářím kanál + %d událostí kanálu + Dekódovací odkaz + Smazat kanál + Smazat kanál? + smazán + smazaný kanál + Smazat relé + Přímé zprávy mezi odběrateli jsou zakázány. + Zakázat + Neposílat historii novým odběratelům. + Snadněji pozvěte své přátele 👋 + Upravit profil kanálu + Povolit + Povolit + Povolte alespoň jedno chat relé pro vytvoření kanálu. + Povolit chat se správci? + Povolit náhledy odkazů? + Zadejte název profilu… + Zadejte jméno relé… + Chyba + Chyba přidávání relé + Chyba vytváření kanálu + Chyba otevírání kanálu + chyba: %s + Chyba ukládání profilu kanálu + Chyba sdílení kanálu + selhalo + selhalo + Pro spojení s kýmkoli + (od majitele) + Získat odkaz + Začít + Odkaz skupiny + Historie není odesílána novým odběratelům. + neaktivní + Špatná relé adresa! + Neplatné jméno relé! + pozván + Pozvat soukromě + Připojit ke kanálu + Opustit kanál + Opustit kanál? + Přidat + Přidat relé + Přidat relé + Povolit odběratelům odesílání přímých zpráv. + Povolit odběratelům chat se správci. + Všechna relé selhala + Všechny relé odebrány + Buďte volní\nve vaší síti + Blokovat uživatele všem? + Spodní lišta + Vysílání + Test relé pro načtení jeho jména.]]> + Obchodní adresa + Zrušit a odstranit kanál + Zrušit vytvoření kanálu? + nemůže vysílat + Zavřít aplikaci + Adresa kontaktu + %d relé vybráno + zahozeno (%1$d pokusů) + Chyba přidávání relé + Chyba odstranění zprávy + Z historie + Pokud vyberete Zavřít, zprávy nebudou přijímány.\nMůžete to změnit později v nastavení Vzhledu. + Nechat SimpleX běžet na pozadí pro přijímání zpráv. + Umožněte ostatním se s vámi spojit + Odkaz + Náhled odkazu bude vyžádán prostřednictvím SOCKS proxy. DNS vyhledávání se stále může uskutečnit lokálně pomocí vašeho DNS resolveru. + Podpis odkazu ověřen. + Členové mohou chatovat se správci. + Chyba zprávy + end-to-end nešifrované. Chat relé může vidět tyto zprávy.]]> + Přenést + Skrýt do oznamovací oblasti + Skrýt do oznamovací oblasti? + Skrýt do oznamovací oblasti při zavření okna + Chyba sítě + nové + Nový jednorázový odkaz + Nové chat relé + Žádné aktivní relé + Žádné dostupné relé + Žádné chat relé + Není povoleno žádné relé. + Žádné relé + Relé nevybráno + Ne všechny relé připojeny + Jednorázový odkaz + Pouze majitelé kanálu mohou měnit nastavení. + Na vašem telefonu, ne na serverech. + Otevřít kanál + Otevřít externí odkaz? + Otevřít nový kanál + Nebo ukažte QR osobně nebo prostřednictvím videohovoru. + Nebo použijte tento QR kód - tisk nebo zobrazit online. + MAJITEL + Majitelé + Vlastnictví: můžete provozovat vlastní relé. + Soukromí: pro majitele a odběratele. + Soukromé a bezpečné zasílání zpráv. + Zakázat chat se správci. + Zakázat odesílání přímých zpráv odběratelům. + Veřejné kanály - mluvte volně 🚀 + Opustit SimpleX + relé + RELÉ + Relé adresa + Relé adresa + Připojení relé selhalo + Relé odkaz + Relé výsledky: + Relé přidáno: %1$s. + Relé test selhal! + odstraněno + odstraněno operátorem + Odstranit relé + Odstranit relé? + Odstranit odběratele + Odstranit odběratele? + Bezpečné odkazy + Uložit a informovat odběratele kanálu + Uložit profil kanálu + Vybrat relé + Varování serveru + Nastavit oznamování + Nastavit routery + Sdílet kanál… + Sdílet adresu relé + Sdílet pomocí chatu + Zobrazit SimpleX + ⚠️ Ověření podpisu selhalo: %s. + (podepsán) + SimpleX + SimpleX - %d nepřečteno + ODBĚRATEL + Hlášení odběratelů + Odběratelé + Odběratelé mohou přidávat reakce na zprávy. + Odběratelé mohou psát správcům. + Odběratelé mohou nevratně mazat odeslané zprávy. (24 hodin) + Odběratelé mohou nahlásit zprávy moderátorům. + Odběratelé mohou posílat přímé zprávy. + Odběratelé mohou odesílat mizející zprávy. + Odběratelé mohou posílat soubory a média. + Odběratelé mohou odesílat SimpleX odkazy. + Odběratelé mohou posílat hlasové zprávy. + Odběratelé použijí relé odkaz pro připojení ke kanálu.\nRelé adresa byla použita pro nastavení tohoto kanálu. + Odběratelé budou odebráni z kanálu - Nelze zvrátit! + Promluvte si s někým + Klepněte na Přidat kanál + Klepněte pro otevření + Test selhal v kroku %s. + Test relé + Aplikace odstranila tuto zprávu po %1$d pokusech o přijetí. + Připojení dosáhlo limitu nedoručených zpráv + První síť, kde vy vlastníte\nvaše kontakty a skupiny. 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..42049da403 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -404,7 +404,7 @@ Onion-Hosts werden nicht verwendet. Für die Verbindung werden Onion-Hosts benötigt. \nBitte beachten Sie: Ohne .onion-Adresse können Sie keine Verbindung mit den Servern herstellen. - Erscheinungsbild + Darstellung Adresse erstellen Adresse löschen? @@ -495,14 +495,14 @@ Audio- & Videoanrufe Ihre Anrufe - Immer über ein Relais verbinden + Immer über einen Router verbinden Anrufe auf Sperrbildschirm: Akzeptieren Anzeigen Deaktivieren Ihre ICE-Server WebRTC-ICE-Server - Relais-Server schützen Ihre IP-Adresse, aber sie können die Anrufdauer erfassen. + Relais-Server schützen Ihre IP-Adresse, können aber die Anrufdauer erfassen. Relais-Server werden nur genutzt, wenn sie benötigt werden. Ihre IP-Adresse kann von Anderen erfasst werden. Öffnen Sie SimpleX Chat, um den Anruf anzunehmen. @@ -1437,7 +1437,7 @@ Datenbank-Ordner öffnen Das Passwort wird in Klartext in den Einstellungen gespeichert, nachdem Sie es geändert oder die App neu gestartet haben. Das Passwort wurde in Klartext in den Einstellungen gespeichert. - Bitte beachten Sie: Die Nachrichten- und Datei-Relais sind per SOCKS-Proxy verbunden. Anrufe nutzen eine direkte Verbindung.]]> + Bitte beachten Sie: Die Nachrichten- und Datei-Router sind per SOCKS-Proxy verbunden. Anrufe nutzen eine direkte Verbindung.]]> Lokale Dateien verschlüsseln Öffnen Gespeicherte Dateien & Medien verschlüsseln @@ -1702,7 +1702,7 @@ Link-Details werden heruntergeladen Archiv wird heruntergeladen Anwenden - Alle Ihre Kontakte, Unterhaltungen und Dateien werden sicher verschlüsselt und in Daten-Paketen auf die konfigurierten XTFP-Relais hochgeladen. + Alle Ihre Kontakte, Unterhaltungen und Dateien werden sicher verschlüsselt und in Daten-Paketen auf die konfigurierten XTFP-Router hochgeladen. Archivieren und Hochladen Warnung: Das Archiv wird gelöscht.]]> Überprüfen Sie Ihre Internetverbindung und probieren Sie es nochmals @@ -1840,8 +1840,8 @@ Nie Unbekannte Server Ungeschützt - Sie nutzen privates Routing mit unbekannten Servern. - Sie nutzen KEIN privates Routing. + Bei unbekannten Servern privates Routing nutzen. + KEIN privates Routing nutzen. Modus für das Nachrichten-Routing Ja Nein @@ -1849,20 +1849,19 @@ Fallback für das Nachrichten-Routing Nachrichtenstatus anzeigen Herabstufung erlauben - Sie nutzen immer privates Routing. + Immer privates Routing nutzen. Nachrichten werden nicht direkt versendet, selbst wenn Ihr oder der Ziel-Server kein privates Routing unterstützt. 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. + Zum Schutz Ihrer IP-Adresse, wird für die Nachrichten-Auslieferung privates Routing über Ihre konfigurierten SMP-Router genutzt. + Bei unbekannten Servern privates Routing nutzen, wenn Ihre IP-Adresse nicht geschützt ist. IP-Adresse schützen 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. - Ohne Tor- oder VPN-Nutzung wird Ihre IP-Adresse für diese XFTP-Relais sichtbar sein: -\n%1$s. + Ohne Tor- oder VPN-Nutzung wird Ihre IP-Adresse für diese XFTP-Router sichtbar sein: \n%1$s. Profil-Design Schwarz Farbvariante @@ -1903,8 +1902,7 @@ Gestalten Sie Ihre Chats unterschiedlich! Neue Chat-Designs Privates Nachrichten-Routing 🚀 - Schützen Sie Ihre IP-Adresse vor den Nachrichten-Relais , die Ihr Kontakt ausgewählt hat. -\nAktivieren Sie es in den *Netzwerk & Server* Einstellungen. + Schützen Sie Ihre IP-Adresse vor den Nachrichten-Routern, die Ihre Kontakte ausgewählt haben.\nAktivieren Sie es in den *Netzwerk & Server* Einstellungen. Dateien sicher herunterladen Mit reduziertem Akkuverbrauch. Keine Information @@ -2107,7 +2105,7 @@ Erstellen Laden Sie neue Versionen von GitHub herunter. Direkt aus der Chat-Liste abspielen. - Kann von Ihnen in den Erscheinungsbild-Einstellungen geändert werden. + Sie können dies in den Einstellungen unter „Darstellung“ ändern. Kontakte für spätere Chats archivieren. Ihre IP-Adresse und Verbindungen werden geschützt. Löschen Sie bis zu 20 Nachrichten auf einmal. @@ -2135,15 +2133,15 @@ Chat-Profil auswählen Archiv entfernen? Ihre Anmeldeinformationen können unverschlüsselt versendet werden. - Verwenden Sie keine Anmeldeinformationen mit einem Proxy. + Keine Anmeldeinformationen mit einem Proxy verwenden. Ihre Verbindung wurde auf %s verschoben, aber während des Profil-Wechsels trat ein Fehler auf. Stellen Sie sicher, dass die Proxy-Konfiguration richtig ist. Fehler beim Speichern des Proxys Passwort Proxy-Authentifizierung - Verwenden Sie für jede Verbindung unterschiedliche Proxy-Anmeldeinformationen. - Verwenden Sie für jedes Profil unterschiedliche Proxy-Anmeldeinformationen. - Verwenden Sie zufällige Anmeldeinformationen + Für jede Verbindung unterschiedliche Proxy-Anmeldeinformationen verwenden. + Für jedes Profil unterschiedliche Proxy-Anmeldeinformationen verwenden. + Zufällige Anmeldeinformationen verwenden Benutzername %1$d Datei-Fehler:\n%2$s %1$d Datei(en) wird/werden immer noch heruntergeladen. @@ -2179,7 +2177,7 @@ Die SimpleX-Protokolle wurden von Trail of Bits überprüft. Während des Anrufs zwischen Audio und Video wechseln Das Chat-Profil für Einmal-Einladungen wechseln - Jedes Mal wenn Sie die App starten, werden neue SOCKS-Anmeldeinformationen genutzt + Bei jedem Neustart der App, werden neue SOCKS-Anmeldeinformationen genutzt Verbesserte Sicherheit ✅ Verbesserte Nachrichten-Datumsinformation Verbesserte Nutzer-Erfahrung @@ -2215,7 +2213,7 @@ Aktualisieren Voreingestellte Server Nutzungsbedingungen einsehen - Die Nutzungsbedingungen der aktivierten Betreiber werden automatisch akzeptiert am: %s. + Die Nutzungsbedingungen der aktivierten Betreiber werden am %s automatisch akzeptiert. Ihre Server Betreiber %s Server @@ -2258,7 +2256,7 @@ nur mit einem Kontakt genutzt werden - teilen Sie ihn nur persönlich oder über einen beliebigen Messenger.]]> %s.]]> %s.]]> - Die Nutzungsbedingungen wurden akzeptiert am: %s + Die Nutzungsbedingungen wurden am %s akzeptiert. %s.]]> %s zu nutzen, müssen Sie dessen Nutzungsbedingungen akzeptieren.]]> Fehler beim Akzeptieren der Nutzungsbedingungen @@ -2274,7 +2272,7 @@ Zum Schutz vor dem Austausch Ihres Links können Sie die Sicherheitscodes Ihrer Kontakte vergleichen. %s.]]> %s.]]> - Die Nutzungsbedingungen wurden akzeptiert am: %s. + Die Nutzungsbedingungen wurden am %s akzeptiert. Der Text der aktuellen Nutzungsbedingungen konnte nicht geladen werden. Sie können die Nutzungsbedingungen unter diesem Link einsehen: Ferngesteuerte Mobiltelefone Oder importieren Sie eine Archiv-Datei @@ -2380,7 +2378,7 @@ %d Meldungen Mitglieder-Meldungen Meldungen - Inhalt verletzt Nutzungsbedingungen + Inhalt verletzt die Nutzungsbedingungen Spam Verbindung blockiert Die Datei wird vom Serverbetreiber blockiert:\n%1$s. @@ -2708,7 +2706,7 @@ Relais-Test fehlgeschlagen! Abonnent entfernen Abonnent entfernen? - Der Server erfordert eine Autorisierung, um eine Verbindung zum Relais herzustellen. Bitte Passwort überprüfen. + Der Server erfordert eine Autorisierung, um eine Verbindung zum Router herzustellen. Bitte Passwort überprüfen. Serverwarnung Relais-Adresse teilen ABONNENT @@ -2757,7 +2755,7 @@ %1$d Relais fehlgeschlagen %1$d Relais nicht aktiv %1$d Relais entfernt - Das Hinzufügen von Relais wird zu einem späteren Zeitpunkt unterstützt. + Relais hinzufügen, um die Nachrichtenübermittlung wiederherzustellen. Alle Relais fehlgeschlagen Alle Relais entfernt Broadcast nicht möglich @@ -2805,11 +2803,11 @@ Oder den QR‑Code persönlich oder per Videoanruf zeigen. Oder diesen QR‑Code verwenden – ausgedruckt oder online. Volle Kontrolle: Sie können Ihre eigenen Relais betreiben. - Privatsphäre: für Besitzer und Abonnenten. + Privatsphäre: Für Eigentümer und Abonnenten. Öffentliche Kanäle – frei sprechen 🚀 - Zuverlässigkeit: mehrere Relais pro Kanal. + Zuverlässigkeit: Mehrere Relais pro Kanal. Sichere Web-Links - Sicherheit: Eigentümer besitzen die Kanalschlüssel. + Sicherheit: Nur die Eigentümer des Kanals besitzen die Schlüssel. Den Link über einen beliebigen Messenger versenden – es ist sicher. Bitte in SimpleX einfügen. Mit Jemandem sprechen Für ein dauerhaftes SimpleX-Netzwerk. @@ -2873,4 +2871,38 @@ Untere Leiste Die Linkvorschau wird über einen SOCKS-Proxy angefordert. DNS-Abfragen können dennoch lokal über Ihren DNS-Resolver erfolgen. Obere Leiste + Ihr neuer Kanal %1$s ist mit %2$d von %3$d Relais verbunden.\nBei Abbruch, wird der Kanal gelöscht. Sie können ihn später neu erstellen. + Hinzufügen + Relais hinzufügen + Relais hinzufügen + Kanal abbrechen und löschen + %d Relais ausgewählt + Fehler beim Hinzufügen von Relais + Keine verfügbaren Relais + Keine Relais + Keine Relais ausgewählt + %1$s Relais hinzugefügt. + Relais wird aus dem Kanal entfernt. Dies kann nicht rückgängig gemacht werden! + Entfernt + Relais entfernen + Relais entfernen? + Relais auswählen + Dies ist das letzte aktive Relais. Wenn Sie es entfernen, können keine Nachrichten mehr an Abonnenten zugestellt werden. + App schließen + Wenn Sie \"Schließen\" auswählen, werden keine Nachrichten mehr empfangen.\nSie können dies später in den Einstellungen unter \"Darstellung\" ändern. + SimpleX im Hintergrund weiter ausführen, um Nachrichten zu empfangen. + In den Infobereich minimieren + In den Infobereich minimieren? + Beim Schließen des Fensters in den Infobereich minimieren + SimpleX beenden + SimpleX anzeigen + SimpleX + SimpleX — %d ungelesen + Fehler beim Löschen der Nachricht + Aus dem Nachrichtenverlauf + App ist bereits gestartet + App ist eventuell schon gestartet oder wurde nicht richtig beendet. Trotzdem starten? + Abgelehnt + Status + Vom Relais-Betreiber abgelehnt 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..8be594f9e2 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -2708,7 +2708,7 @@ %1$d servidores han fallado %1$d servidores inactivos %1$d servidores eliminados - Añadir servidores estará disponible en una versión posterior. + Añadir servidores pare retomar el envío. Enlace para un solo contacto Permitir que los miembros chateen con administradores. Permitir que los suscriptores chateen con administradores. @@ -2798,4 +2798,33 @@ Menú inferior Las previsualizaciones de enlaces se solicitan a través del proxy SOCKS. Las peticiónes DNS aún pueden usar el DNS local del sistema. Menú superior + Tu nuevo canal %1$s está conectado a %2$d de %3$d servidores.\nSi cancelas, el canal será eliminado. Puedes crearlo de nuevo. + Añadir + Añadir servidor + Añadir servidores + Cancelar y eliminar el canal + Cerrar aplicación + %d servidor(es) seleccionados + Error al añaidr servidores + Error al eliminar mensaje + Del historial + Si eliges Cerrar, los mensajes no serán recibidos.\nPuedes cambiarlo más tarde desde el menú Apariencia. + Mantener Simplex en segundo plano para recibir mensajes. + Minimizar + Minimizar? + Minimizar al cerrar la ventana + Sin servidores disponibles + Sin servidores + Sin servidores seleccionados + Salir de SimpleX + Servidores añadidos %1$s. + El servidor será eliminado del canal. ¡No puede deshacerse! + eliminado + Eliminar servidor + ¿Eliminar el servidor? + Seleccionar servidores + Ver SimpleX + SimpleX + SimpleX — %d no leído + Este es el último servidor activo. Si lo eliminas los mensajes no llegarán a los suscriptores. 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..31b8a89dc7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml @@ -38,14 +38,14 @@ %s visszavonva Előre beállított kiszolgálók hozzáadása A hívások kezdeményezése le van tiltva. - Az összes partneréhez és csoporttaghoz külön TCP-kapcsolat (és SOCKS-hitelesítési adat) lesz használva.\nMegjegyzés: ha sok kapcsolata van, akkor az akkumulátor-használat és az adatforgalom jelentősen megnövekedhet, és néhány kapcsolódási kísérlet sikertelen lehet.]]> + Az összes partneréhez és csoporttaghoz külön TCP-kapcsolat (és SOCKS-hitelesítési adat) lesz használva.\nMegjegyzés: ha sok kapcsolata van, akkor az akkumulátor-használat és az adatforgalom jelentősen megnövekedhet, és néhány kapcsolódási kísérlet sikertelen lehet.]]> hivatkozáselőnézet visszavonása Az összes csevegési profiljához az alkalmazásban külön TCP-kapcsolat (és SOCKS-hitelesítési adat) lesz használva.]]> Mindkét fél küldhet eltűnő üzeneteket. Az Android Keystore-t a jelmondat biztonságos tárolására használják – lehetővé teszi az értesítési szolgáltatás működését. Hibás az üzenet kivonata Háttér - Megjegyzés: az üzenet- és a fájlátjátszók SOCKS proxyn keresztül kapcsolódnak. A hívások pedig közvetlen kapcsolatot használnak.]]> + Megjegyzés: az üzenet- és a fájlátjátszók SOCKS proxyn keresztül kapcsolódnak. A hívások pedig közvetlen kapcsolatot használnak.]]> Alkalmazásadatok biztonsági mentése Az adatbázis előkészítése sikertelen Az összes partnerével továbbra is kapcsolatban marad. A profilfrissítés el lesz küldve a partnerei számára. @@ -91,7 +91,7 @@ Nem lehet meghívni a partnert! hibás az üzenet azonosítója Partneri kapcsolatkérések automatikus elfogadása - Megjegyzés: NEM fogja tudni helyreállítani vagy módosítani a jelmondatot abban az esetben, ha elveszíti.]]> + Megjegyzés: NEM fogja tudni helyreállítani vagy módosítani a jelmondatot abban az esetben, ha elveszíti.]]> hívás… További másodlagos szín Hozzáadás egy másik eszközhöz @@ -114,7 +114,7 @@ Az alkalmazásjelkód helyettesítve lesz egy önmegsemmisítő jelkóddal. Arab, bolgár, finn, héber, thai és ukrán – köszönet a felhasználóknak és a Weblate-nek. Engedélyezi a hangüzeneteket? - Mindig legyen használva átjátszó + Átjátszó használata minden esetben mindig A hívás már véget ért! Engedélyezés @@ -168,7 +168,7 @@ hanghívás (végpontok között NEM titkosított) letiltva Módosítja az adatbázis jelmondatát? - kapcsolódva + kapcsolódott Jelkód módosítása a következőre módosította %s szerepkörét: „%s” Fogadási cím módosítása @@ -182,9 +182,9 @@ Kapcsolódás kapcsolódott Társított hordozható eszköz - kapcsolódva + kapcsolódott Szerepkör módosítása - Kapcsolódva + Kapcsolódott Hitelesítési adat megerősítése Módosítja a fogadási címet? módosította a címet az Ön számára @@ -1022,7 +1022,7 @@ 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 + frissítette a csoport profilját SIMPLEX CHAT TÁMOGATÁSA SimpleX Chat szolgáltatás Ön megfigyelő @@ -1295,7 +1295,7 @@ Nem sikerült ellenőrizni; próbálja meg újra. Az üzenet az összes tag számára moderáltként lesz megjelölve. Értesítések fogadásához adja meg az adatbázis jelmondatát - A teszt a(z) %s lépésnél sikertelen volt. + A teszt a(z) %s. lépésnél sikertelen volt. Az alkalmazás elindításához vagy 30 másodpercnyi háttérben töltött idő után, az alkalmazáshoz való visszatéréshez hitelesítésre lesz szükség. Az üzenet az összes tag számára törölve lesz. A videó nem dekódolható. Próbálja ki egy másik videóval, vagy lépjen kapcsolatba a fejlesztőkkel. @@ -1571,8 +1571,8 @@ feloldotta %s letiltását Ön feloldotta %s letiltását letiltva - letiltva az adminisztrátor által - Letiltva az adminisztrátor által + az adminisztrátor letiltotta + Az adminisztrátor letiltotta letiltotta őt: %s Letiltás Az összes tag számára letiltja a tagot? @@ -1649,14 +1649,14 @@ Átköltöztetés ide Eszköz átköltöztetése Átköltöztetés egy másik eszközre - Figyelmeztetés: az archívum törölve lesz.]]> + Figyelmeztetés: az archívum törölve lesz.]]> Átköltöztetés egy másik eszközről Kvantumbiztos titkosítás Megpróbálhatja még egyszer. Átköltöztetés kész Átköltöztetés egy másik eszközre QR-kód használatával. Átköltöztetés - Megjegyzés: ha két eszközön is ugyanazt az adatbázist használja, akkor biztonsági védelemként megszakítja a partnereitől érkező üzenetek visszafejtését.]]> + Megjegyzés: ha két eszközön is ugyanazt az adatbázist használja, akkor biztonsági védelemként megszakítja a partnereitől érkező üzenetek visszafejtését.]]> Megpróbálhatja még egyszer. Érvénytelen hivatkozás végpontok közötti kvantumbiztos titkosítás @@ -1720,11 +1720,11 @@ Profilkép alakzata Négyzet, kör vagy bármi a kettő között. Célkiszolgáló-hiba: %1$s - Továbbítókiszolgáló: %1$s\nHiba: %2$s + Továbbító kiszolgáló: %1$s\nHiba: %2$s Hálózati problémák – az üzenet többszöri elküldési kísérlet után lejárt. A kiszolgáló verziója nem kompatibilis a hálózati beállításokkal. Érvénytelen kulcs vagy ismeretlen kapcsolat – valószínűleg ez a kapcsolat törlődött. - Továbbítókiszolgáló: %1$s\nCélkiszolgáló-hiba: %2$s + Továbbító kiszolgáló: %1$s\nCélkiszolgáló-hiba: %2$s Hiba: %1$s Kapacitás túllépés – a címzett nem kapta meg a korábban elküldött üzeneteket. Üzenetkézbesítési figyelmeztetés @@ -1738,10 +1738,10 @@ Nem Nem védett Igen - NE legyen használva privát útválasztás. + Privát útválasztás használatának elkerülése. Privát útválasztás Privát útválasztás használata az ismeretlen kiszolgálókhoz. - Mindig legyen használva privát útválasztás. + Privát útválasztás használata minden esetben. Üzenet-útválasztási mód Közvetlen üzenetküldés, ha az IP-cím védett és a saját kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. 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. @@ -1874,7 +1874,7 @@ Kiszolgáló-beállítások megnyitása Kiszolgáló címe Feltöltési hibák - Visszaigazolt + Visszaigazolva Visszaigazolási hibák kísérletek Törölt töredékek @@ -1926,7 +1926,7 @@ Ezen verzió kihagyása Ha értesítést szeretne kapni az új kiadásokról, kapcsolja be a stabil vagy béta verziók időszakos ellenőrzését. Új verzió érhető el: %s - A frissítés letöltése megszakítva + Frissítésletöltés visszavonva Béta Letiltás Letiltva @@ -2194,7 +2194,7 @@ A tagok közötti közvetlen üzenetek le vannak tiltva. Üzleti csevegések Saját ügyfeleinek adatvédelme. - %1$s.]]> + %1$s.]]> A csevegés már létezik! Csökkentse az üzenet méretét, és küldje el újra. Üzenetek ellenőrzése 10 percenként @@ -2270,7 +2270,7 @@ Tagok jelentései 1 jelentés Jelentések - %s által archivált jelentés + %s archiválta a jelentést %d jelentés Kéretlen tartalom A tartalom sérti a használati feltételeket @@ -2350,7 +2350,7 @@ Kikapcsolva Előre beállított kiszolgálók A 443-as TCP-port használata kizárólag az előre beállított kiszolgálókhoz. - Hiba a tag befogadásakor + Hiba történt a tag befogadásakor %d csevegés a tagokkal %d üzenet 1 csevegés egy taggal @@ -2382,7 +2382,7 @@ Ön befogadta ezt a tagot Befogadás tagként befogadta őt: %1$s - áttekintve a moderátorok által + a moderátorok áttekintették nem lehet üzeneteket küldeni partner letiltva csoport törölve @@ -2407,7 +2407,7 @@ Hiba történt a partneri kapcsolatkérés elutasításakor Hiba történt a csevegés megnyitásakor Hiba történt a csoport megnyitásakor - Hiba a profil módosításakor + Hiba történt a profil módosításakor Megnyitás a csatlakozáshoz Megnyitás a kapcsolódáshoz Megnyitás az elfogadáshoz @@ -2496,7 +2496,7 @@ Teljes hivatkozás megnyitása Nyomonkövetési paraméterek eltávolítása a hivatkozásokból SimpleX-átjátszó címe - Hiba a csevegés olvasottként való megjelölésekor + Hiba történt a csevegés olvasottként való megjelölésekor A célkiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal: %1$s. A továbbító kiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal: %1$s. A kiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal: %1$s. @@ -2597,7 +2597,7 @@ Ellenőrizze az átjátszó nevét, és próbálja újra. Érvénytelen az átjátszó címe! Ellenőrizze az átjátszó címét, és próbálja újra. - Hiba az átjátszó hozzáadásakor + Hiba történt az átjátszó hozzáadásakor Csevegési átjátszók A csevegési átjátszók továbbítják az üzeneteket az Ön által létrehozott csatornákban. Csevegési átjátszók @@ -2605,8 +2605,8 @@ A csevegési átjátszók továbbítják az üzeneteket a csatorna feliratkozóinak. %1$d/%2$d átjátszó aktív, %3$d sikertelen %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 + %1$d/%2$d átjátszó kapcsolódott, %3$d hiba + %1$d/%2$d átjátszó kapcsolódott ÁTJÁTSZÓ Átjátszóhivatkozás Átjátszó címe @@ -2616,7 +2616,7 @@ Ön ezen az átjátszóhivatkozáson keresztül kapcsolódott a csatornához. Feliratkozó eltávolítása Az összes feliratkozó számára letiltja a feliratkozót? - Hiba a csatorna létrehozásakor + Hiba történt a csatorna létrehozásakor Visszavonja a csatorna létrehozását? Engedélyezzen legalább egy csevegési átjátszót a csatorna létrehozásához. A(z) %1$s nevű profilja meg lesz osztva a csatorna átjátszóival és feliratkozóival.\nAz átjátszók hozzáférhetnek a csatornaüzenetekhez. @@ -2627,7 +2627,7 @@ Átjátszó címe Ez egy csevegési átjátszó címe, nem használható kapcsolódásra. %1$s nevű csatornához!]]> - Hiba a csatorna megnyitásakor + Hiba történt a csatorna megnyitásakor Az összes feliratkozó számára feloldja a feliratkozó letiltását? Átjátszó tesztelése a nevének lekéréséhez.]]> Csatorna teljes neve: @@ -2636,11 +2636,11 @@ %d csatornaesemény törölt csatorna hiba: %s - Hiba a csatornaprofil mentésekor + Hiba történt a csatornaprofil mentésekor Üzenethiba Mentés és a csatorna feliratkozóinak értesítése Csatornaprofil mentése - frissített csatornaprofil + frissítette a csatorna profilját Az alkalmazás %1$d sikertelen letöltési kísérlet után eltávolította ezt az üzenetet. eltávolítva (%1$d kísérlet) A csatorna ideiglenesen nem érhető el @@ -2658,12 +2658,12 @@ %1$d/%2$d átjátszó aktív, %3$d hiba %1$d/%2$d átjátszó kapcsolódott, %3$d átjátszóhoz nem sikerült kapcsolódni %1$d/%2$d átjátszó kapcsolódott, %3$d eltávolítva - Az átjátszók hozzáadása később lesz támogatott. + Átjátszók hozzáadása az üzenetküldés helyreállításához. Várakozás a csatorna tulajdonosára az átjátszók hozzáadásához. Üzleti cím Csatornahivatkozás Kapcsolattartási cím - Hiba a csatorna megosztásakor + Hiba történt a csatorna megosztásakor (a tulajdonostól) Csoporthivatkozás Hivatkozás aláírása ellenőrizve. @@ -2692,7 +2692,7 @@ Saját hivatkozás létrehozása Bárki számára, aki el szeretné érni Önt Partner meghívása privátban - Hagyja, hogy valaki elérje Önt + Legyen elérhető mások számára Vagy mutassa meg a QR-kódot személyesen vagy videóhíváson keresztül. Vagy használja ezt a QR-kódot – nyomtassa ki vagy mutassa meg online. Küldje el a hivatkozást bármilyen üzenetváltó alkalmazáson keresztül – ez egy biztonságos módszer – és kérje meg a partnerét, hogy illessze be a SimpleX alkalmazásba. @@ -2702,7 +2702,7 @@ Nonprofit irányítás - Hivatkozások előnézetének küldése.\n- SOCKS proxy használata, ha engedélyezve van.\n- Hiperhivatkozásokon keresztüli adathalászat megakadályozása.\n- Hivatkozások nyomonkövetési paramétereinek eltávolítása. Tulajdonjog: saját átjátszókat üzemeltethet. - Adatvédelem: tulajdonosok és előfizetők számára. + Adatvédelem: tulajdonosok és feliratkozók számára. Nyilvános csatornák – mondja el szabadon a véleményét 🚀 Megbízhatóság: több átjátszó is használható csatornánként. Biztonságos webhivatkozások @@ -2711,13 +2711,13 @@ Az új felhasználók számára egyszerűbbé tettük a kapcsolatok létrehozását. Fiók nélkül születtünk. Senki sem követte nyomon a beszélgetéseinket. Senki sem készített térképet arról, hogy merre jártunk. A magánéletünk nem csak egy funkció volt, hanem az életmódunk. - Aztán felléptünk az internetre, és minden platform kért belőlünk egy darabot - nevet, telefonszámot, baráti kapcsolatokat. Elfogadtuk, hogy a kommunikáció ára az, hogy mások megtudják, hogy kivel beszélünk. Minden generáció, az emberek és a technológia is eddig így működött - telefon, e-mail, üzenetküldő programok, közösségi média. Úgy tűnt, ez az egyetlen lehetséges mód. + Aztán felléptünk az internetre, és minden platform kért belőlünk egy darabot– nevet, telefonszámot, baráti kapcsolatokat. Elfogadtuk, hogy a kommunikáció ára az, hogy mások megtudják, hogy kivel beszélünk. Minden generáció, az emberek és a technológia is eddig így működött – telefon, e-mail, üzenetküldő programok, közösségi média. Úgy tűnt, ez az egyetlen lehetséges mód. De van egy másik lehetőség is. Egy hálózat, amelyben nincsenek telefonszámok. Nincsenek felhasználónevek. Nincsenek fiókok. Nincsenek semmiféle felhasználói azonosítók. Egy hálózat, amely összeköti az embereket és titkosított üzeneteket továbbít, anélkül, hogy tudná, ki csatlakozik hozzá. - Nem egy jobb zár mások ajtaján. Nem egy kedvesebb házmester, aki tiszteletben tartja az Ön magánéletét, de mégis nyilvántartást vezet minden látogatójáról. Ön itt nem csak egy vendég. Ön itt otthon van. Nincs az a hatalom, amely beléphetne ide - Ön itt szuverén. + Nem egy jobb zár mások ajtaján. Nem egy kedvesebb házmester, aki tiszteletben tartja az Ön magánéletét, de mégis nyilvántartást vezet minden látogatójáról. Ön itt nem csak egy vendég. Ön itt otthon van. Nincs az a hatalom, amely beléphetne ide – Ön itt szuverén. A beszélgetései Önhöz tartoznak, ahogy az internet megjelenése előtt is mindig így volt. A hálózat nem egy hely, amelyet meglátogat. Ez egy olyan hely, amelyet Ön hoz létre saját magának. És senki sem veheti el Öntől, függetlenül attól, hogy privát vagy nyilvános. - A legrégebbi emberi szabadság - beszélgetni az emberekkel, anélkül, hogy mások megfigyelnének - olyan infrastruktúrán alapul, amely nem tudja elárulni. + A legrégebbi emberi szabadság – beszélgetni az emberekkel, anélkül, hogy mások megfigyelnének – olyan infrastruktúrán alapul, amely nem tudja elárulni. Mert felszámoltuk a lehetőségét is annak, hogy megtudjuk, Ön kicsoda. Így az önrendelkezése soha nem kerülhet idegen kezekbe. - Legyen szabad a saját hálózatában. + Váljon szabaddá a saját hálózatában. Feliratkozók jelentései A közvetlen üzenetek küldése a feliratkozók között engedélyezve van. @@ -2737,7 +2737,7 @@ Az előzmények nem lesznek elküldve az új feliratkozók számára. A csevegés az adminisztrátorokkal engedélyezve van a tagok számára. A csevegés az adminisztrátorokkal engedélyezve van a feliratkozók számára. - Váljon szabaddá\na saját hálózatában. + Váljon szabaddá\na saját hálózatában A csevegés az adminisztrátorokkal le van tiltva. A nyilvános csatornákban az adminisztrátorokkal való csevegések nem rendelkeznek végpontok közötti titkosítással – csak megbízható csevegési átjátszókkal használja őket. A csevegés a tagokkal le van tiltva @@ -2758,12 +2758,46 @@ A csevegés az adminisztrátorokkal le van tiltva. Értesítések beállítása Útválasztók beállítása - A feliratkozók cseveghetnek az adminisztrátorokkal + A feliratkozók cseveghetnek az adminisztrátorokkal. Miért jött létre a SimpleX? Saját hálózat - Profil létrehozása + Saját profil létrehozása Az első hálózat, ahol Ön birtokolja\na saját kapcsolatait és csoportjait. Alsó sáv A hivatkozások előnézetét SOCKS proxyn keresztül kéri le a kliens. A DNS-lekérdezés viszont továbbra is történhet helyi szinten, a saját DNS-kiszolgálón keresztül. Felső sáv + Az új %1$s nevű csatornája %3$d átjátszóból %2$d átjátszóhoz kapcsolódik.\nHa visszavonja, akkor a csatorna törlődni fog – de később újra létrehozhatja. + Visszavonás és a csatorna törlése + Hozzáadás + Átjátszó hozzáadása + Átjátszók hozzáadása + %d átjátszó kiválasztva + Hiba történt az átjátszók hozzáadásakor + Nincsenek elérhető átjátszók + Nincsenek átjátszók + Nincsenek átjátszók kiválasztva + Átjátszók hozzáadva: %1$s. + Az átjátszó el lesz távolítva a csatornából – ez a művelet nem vonható vissza! + eltávolítva + Átjátszó eltávolítása + Eltávolítja az átjátszót? + Átjátszók kiválasztása + Ez az utolsó aktív átjátszó. Ha eltávolítja, akkor azzal megakadályozza az üzenetek eljuttatását a feliratkozóknak. + Alkalmazás bezárása + Ha a bezárás mellett dönt, az üzenetek nem fognak megérkezni.\nEzt később a „Megjelenés” beállításaiban módosíthatja. + Hagyja a SimpleX-et a háttérben futni az üzenetek fogadásához. + Kilépés a SimpleXből + SimpleX megjelenítése + SimpleX + SimpleX – %d olvasatlan üzenet + Kicsinyítés az értesítési területre + Biztosan kicsinyíteni szeretné az értesítési területre? + Kicsinyítés az értesítési területre az ablak bezárásakor + Hiba történt az üzenet törlésekor + Az előzményekből + Állapot + elutasítva + az átjátszó üzemeltetője elutasította + Az alkalmazás már fut + Lehet, hogy egy másik alkalmazáspéldány fut, vagy nem zárult be megfelelően. Így is elindítja? 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..6642553f2e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -2448,7 +2448,7 @@ Errore nel rifiuto della richiesta di contatto Entra nel gruppo Apri la chat - Apri una chat nuova + Apri la nuova chat Apri il nuovo gruppo Apri per accettare Apri per connettere @@ -2578,7 +2578,7 @@ Nome del canale Il canale verrà eliminato per tutti gli iscritti, non è reversibile! Il canale verrà eliminato per te, non è reversibile! - Il canale sarà operativo con %1$d di %2$d relay. Procedere? + Il canale sarà operativo con %1$d di %2$d relay. Continuare? Relay di chat Relay di chat Relay di chat @@ -2619,7 +2619,7 @@ Nessun relay di chat attivato. Non tutti i relay sono connessi Apri canale - Apri un canale nuovo + Apri il nuovo canale PROPRIETARIO Proprietari Indirizzo relay preimpostato @@ -2686,7 +2686,7 @@ %1$d relay falliti %1$d relay non attivi %1$d relay rimossi - L\'aggiunta di relay verrà supportata prossimamente. + Aggiungi relay per ripristinare la consegna dei messaggi. Tutti i relay falliti Tutti i relay rimossi impossibile trasmettere @@ -2802,4 +2802,38 @@ Barra inferiore L\'anteprima del link verrà richiesta via proxy SOCKS. La ricerca DNS può ancora accadere localmente tramite il tuo risolutore DNS. Barra superiore + Il tuo nuovo canale %1$s è connesso a %2$d di %3$d relay.\nSe annulli, il canale verrà eliminato. Potrai crearlo di nuovo. + Aggiungi + Aggiungi relay + Aggiungi relay + Annulla ed elimina il canale + %d relay selezionato/i + Errore di aggiunta dei relay + Nessun relay disponibile + Nessun relay + Nessun relay selezionato + Relay aggiunti: %1$s. + Il relay verrà rimosso dal canale, non è reversibile! + rimosso + Rimuovi relay + Rimuovere il relay? + Seleziona i relay + Questo è l\'ultimo relay attivo. La sua rimozione impedirà la consegna dei messaggi agli iscritti. + Chiudi l\'app + Errore di eliminazione del messaggio + Dalla cronologia + Se scegli Chiudi, i messaggi non verranno ricevuti.\nPuoi cambiarlo più tardi nelle impostazioni di Aspetto. + Tieni SimpleX attivo in secondo piano per ricevere i messaggi. + Riduci nell\'area delle notifiche + Ridurre nell\'area delle notifiche? + Riduci nell\'area delle notifiche alla chiusura della finestra + Esci da SimpleX + Mostra SimpleX + SimpleX + SimpleX — %d non letto/i + Un\'altra istanza dell\'app potrebbe essere in esecuzione o non si è chiusa correttamente. Avviare comunque? + L\'app è già in esecuzione + rifiutato + rifiutato dall\'operatore del relay + Stato 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..b1d505e644 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml @@ -2207,7 +2207,7 @@ Посмотреть условия %s.]]> Условия будут автоматически приняты для включенных операторов: %s. - Условия приняты: %s. + Условия приняты: %s Вебсайт %s.]]> %s.]]> @@ -2799,7 +2799,7 @@ Приложение удалило это сообщение после %1$d попыток его получить. Если Вы присоединились к каналам или создали их, они перестанут работать навсегда. Вы перестанете получать сообщения из этого канала. История чата сохранится. - обновлён профиль канала + обновил профиль канала ошибка ОШИБКА СОЕДИНЕНИЯ Чат с админами @@ -2858,7 +2858,7 @@ ошибка новый Все релеи недоступны - Добавление релеев будет поддерживаться позже. + Добавить релеи чтобы восстановить доставку сообщений. Ожидает, когда владелец канала добавит релеи. через %1$s Подписчики используют ссылку релея для подключения к каналу.\nАдрес релея был использован для настройки этого релея для канала. @@ -2874,4 +2874,9 @@ Нижнее меню Картинка ссылки будет загружена через SOCKS-прокси. DNS-запрос может быть локальным через Ваш резолвер. Верхнее меню + Добавить + Добавить релей + Добавить релеи + Отменить и удалить канал + Закрыть приложение 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..0901494460 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 @@ -2670,7 +2670,7 @@ %1$d 个中继失灵 %1$d 个中继不活跃 删除了 %1$d 个中继 - 目前不支持添加中继。 + 添加恢复消息传输的中继。 所有中继均失灵 删除了所有中继 无法广播 @@ -2785,4 +2785,38 @@ 底部栏 将通过 SOCKS5 代理请求链接预览。DNS 查询仍可能通过你的 DNS 解析器在本地发生。 顶部栏 + 你的新频道 %1$s 已连接到 %3$d 个中继中的 %2$d 个中继。\n如果取消,频道将被删除 —— 你可以再次创建它。 + 这是上次活跃的中继。删除它会阻止给订阅者传送消息。 + 添加 + 添加中继 + 添加中继 + 取消并删除频道 + 选中了 %d 个中继 + 添加中继出错 + 无可用中继 + 无中继 + 未选中中继 + 已添加的中继:%1$s。 + 将从频道删除中继 — 这无法撤销! + 已删除 + 删除中继 + 删除中继? + 选择中继 + 如果选择关闭将不会接收消息。\n可以之后在外观设置中更改。 + 保持 SimpleX 在后台运行以接收消息。 + 最小化到托盘 + 最小化到托盘? + 关闭窗口时最小化到托盘 + 退出 SimpleX + 显示 SimpleX + SimpleX + SimpleX — %d 则未读 + 关闭应用 + 删除消息出错 + 来自历史记录 + 另一个应用实例可能正在运行或没有正确退出。仍要启动? + 应用正在运行 + 被拒绝 + 被中继运营方拒绝 + 状态 From 5ac3e71d973749478f6b85c1b5a31f9e313ffaaa Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 1 Jun 2026 13:19:51 +0100 Subject: [PATCH 25/66] core: 6.5.4.0 (simplexmq 6.5.3.0) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cabal.project b/cabal.project index 77e4ed838b..3e32dfcd5e 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: ee2ff402fed4d27d31521570c910fe82e0cf116a + tag: b981dcb70b8c99dc3733a99b6c1dc0d0ef83a3f7 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index f3bbf90b9f..3610906390 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."ee2ff402fed4d27d31521570c910fe82e0cf116a" = "0vka1b2bbrjg4s8j3h6732kjqjbhji0l55pzggd89ginrdjln3fg"; + "https://github.com/simplex-chat/simplexmq.git"."b981dcb70b8c99dc3733a99b6c1dc0d0ef83a3f7" = "0wpri01w30rd3wwzw630yngnj9fmyb7rschl3ic1cjd926vpg9b7"; "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 18fc93eede..4990dae942 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 6.5.3.0 +version: 6.5.4.0 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat 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 26/66] 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 83f4f6cd382f0262905f8b3832f15d897052902a Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 1 Jun 2026 21:33:35 +0100 Subject: [PATCH 27/66] core: rename field in protocol (#7038) * core: rename field in protocol * update bot apis --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- apps/ios/SimpleXChat/ChatTypes.swift | 2 +- .../chat/simplex/common/model/ChatModel.kt | 2 +- bots/api/TYPES.md | 2 +- .../types/typescript/src/types.ts | 2 +- .../src/simplex_chat/types/_types.py | 2 +- plans/2026-05-25-channel-web-preview.md | 20 +++++++++---------- src/Simplex/Chat/Protocol.hs | 4 ++-- src/Simplex/Chat/Store/Groups.hs | 8 ++++---- 8 files changed, 21 insertions(+), 21 deletions(-) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 7181ba2de0..5e0c302720 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2539,7 +2539,7 @@ public struct PublicGroupAccess: Codable, Hashable { } public struct RelayCapabilities: Codable, Hashable { - public var baseWebUrl: String? + public var webDomain: String? } public struct PublicGroupProfile: Codable, Hashable { 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 8ecfa0fd93..11c0f9e7f6 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 @@ -2219,7 +2219,7 @@ data class PublicGroupAccess( @Serializable data class RelayCapabilities( - val baseWebUrl: String? = null + val webDomain: String? = null ) @Serializable diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 4875079749..a87bcae5e4 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -3361,7 +3361,7 @@ ParseError: ## RelayCapabilities **Record type**: -- baseWebUrl: string? +- webDomain: string? --- diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index f0cf58de64..5e671169de 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -3762,7 +3762,7 @@ export namespace RcvMsgError { } export interface RelayCapabilities { - baseWebUrl?: string + webDomain?: string } export interface RelayProfile { 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 08308ed2d4..66ba77c062 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -2640,7 +2640,7 @@ RcvMsgError = RcvMsgError_dropped | RcvMsgError_parseError RcvMsgError_Tag = Literal["dropped", "parseError"] class RelayCapabilities(TypedDict): - baseWebUrl: NotRequired[str] + webDomain: NotRequired[str] class RelayProfile(TypedDict): displayName: str diff --git a/plans/2026-05-25-channel-web-preview.md b/plans/2026-05-25-channel-web-preview.md index de0f02506b..561d3948e7 100644 --- a/plans/2026-05-25-channel-web-preview.md +++ b/plans/2026-05-25-channel-web-preview.md @@ -19,7 +19,7 @@ simplex-chat CLI (--relay --web-json-dir=... --web-base-url=...) └── Regenerate Caddy CORS config → caddy reload Caddy (operator-configured) - ├── Serves JSON at /.json + ├── Serves JSON at https:///group/.json └── Imports generated CORS config file Channel page (static HTML+JS, hosted by owner or on GitHub) @@ -105,7 +105,7 @@ Extend `toPublicGroupProfile` to accept and pass through `Maybe PublicGroupAcces New record for relay capabilities (extensible for future fields): ```haskell data RelayCapabilities = RelayCapabilities - { baseWebUrl :: Maybe Text + { webDomain :: Maybe Text } ``` @@ -132,7 +132,7 @@ Encoding: `XGrpRelayCap cap -> o ["relayCap" .= cap]` Sent by relay to owner only when capabilities change (not periodic). Relay detects change by comparing current config against persisted state on startup. -### 3. Store `baseWebUrl` per relay +### 3. Store `webDomain` per relay **File:** `src/Simplex/Chat/Operators.hs` (line 278) @@ -152,7 +152,7 @@ Add: `relayCap :: Maybe RelayCapabilities` Stored as separate columns (same pattern as `PublicGroupAccess`): **Migration:** `ALTER TABLE group_relays ADD COLUMN base_web_url TEXT` -`relayCap` constructed from columns: `Just RelayCapabilities {baseWebUrl}` when any capability column is non-NULL, `Nothing` otherwise. +`relayCap` constructed from columns: `Just RelayCapabilities {webDomain}` when any capability column is non-NULL, `Nothing` otherwise. **Handlers in `src/Simplex/Chat/Library/Subscriber.hs`:** - `XGrpRelayAcpt` (line 770): store `RelayCapabilities` in relay record on acceptance @@ -168,7 +168,7 @@ New record bundling all web preview options: ```haskell data RelayWebOptions = RelayWebOptions { webJsonDir :: FilePath, -- --web-json-dir: where to write JSON files - webBaseUrl :: Text, -- --web-base-url: public URL prefix (sent in XGrpRelayAcpt) + webDomain :: Text, -- --web-base-url: public URL prefix (sent in XGrpRelayAcpt) webCorsFile :: FilePath, -- --web-cors-file: generated Caddy CORS config path webUpdateInterval :: Int -- --web-update-interval: seconds (default 300) } @@ -443,7 +443,7 @@ data class PublicGroupProfile( @Serializable data class RelayCapabilities( - val baseWebUrl: String? = null + val webDomain: String? = null ) // Extend existing GroupRelay: @@ -473,7 +473,7 @@ New nav destination opens `ChannelWebPageView`. - Text field: domain (`groupDomain`) - Toggle: allow embedding (`allowEmbeding`) - Toggle: show on domain's page (`domainWebPage`) - stored but inert until RSLV ships -- Section: embed snippet (read-only, auto-generated from relay `baseWebUrl` values + `publicGroupId`) +- Section: embed snippet (read-only, auto-generated from relay `webDomain` values + `publicGroupId`) - Save button -> `apiUpdateGroup` with updated `GroupProfile` ### Subscriber: Channel info page @@ -518,12 +518,12 @@ simplex-chat --relay \ ### Embed snippet (shown to owner) -The "Channel web page" screen auto-generates this from the channel's relay `baseWebUrl` values and `publicGroupId`. Owner copies it into their page: +The "Channel web page" screen auto-generates this from the channel's relay `webDomain` values and `publicGroupId`. Owner copies it into their page: ```html
+ data-relays=",">
``` @@ -565,7 +565,7 @@ Separate repo or folder. `channel-preview.js` + minimal CSS: - `src/Simplex/Chat/Protocol.hs` - `RelayCapabilities` record, extend `XGrpRelayAcpt`, add `XGrpRelayCap` - `src/Simplex/Chat/Options.hs` - `RelayWebOptions` record, `relayWebOptions :: Maybe RelayWebOptions` in `CoreChatOpts` - `src/Simplex/Chat/Core.hs` - start web preview thread in `runSimplexChat` -- `src/Simplex/Chat/Operators.hs` - `baseWebUrl` in `GroupRelay` +- `src/Simplex/Chat/Operators.hs` - `webDomain` in `GroupRelay` - `src/Simplex/Chat/Store/Groups.hs` - read/write `PublicGroupAccess` columns; `getWebPublishGroups` - `src/Simplex/Chat/Store/Shared.hs` - `toPublicGroupAccess`, extend `toPublicGroupProfile` and `GroupInfoRow` - `src/Simplex/Chat/Library/Subscriber.hs` - handle `RelayCapabilities` in `XGrpRelayAcpt` and `XGrpRelayCap` diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 71daeec635..9b8f6da766 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -263,12 +263,12 @@ data ReportReason = RRSpam | RRContent | RRCommunity | RRProfile | RROther | RRU deriving (Eq, Show) data RelayCapabilities = RelayCapabilities - { baseWebUrl :: Maybe Text + { webDomain :: Maybe Text } deriving (Eq, Show) defaultRelayCapabilities :: RelayCapabilities -defaultRelayCapabilities = RelayCapabilities {baseWebUrl = Nothing} +defaultRelayCapabilities = RelayCapabilities {webDomain = Nothing} $(pure []) diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index e2a9d6816a..8c207d99a7 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -1352,9 +1352,9 @@ groupRelayQuery = |] toGroupRelay :: (Int64, GroupMemberId, DBEntityId, ShortLinkContact, Text, Text, Maybe Text, Maybe ImageData, Text, BoolInt) :. (Maybe BoolInt, BoolInt, BoolInt, RelayStatus, Maybe ShortLinkContact, Maybe Text) -> GroupRelay -toGroupRelay ((groupRelayId, groupMemberId, chatRelayId, address, displayName, fullName, shortDescr, image, domains, BI preset) :. (tested, BI enabled, BI deleted, relayStatus, relayLink, baseWebUrl)) = +toGroupRelay ((groupRelayId, groupMemberId, chatRelayId, address, displayName, fullName, shortDescr, image, domains, BI preset) :. (tested, BI enabled, BI deleted, relayStatus, relayLink, webDomain)) = let userChatRelay = UserChatRelay {chatRelayId, address, relayProfile = toRelayProfile (displayName, fullName, shortDescr, image), domains = T.splitOn "," domains, preset, tested = unBI <$> tested, enabled, deleted} - relayCap = RelayCapabilities {baseWebUrl} + relayCap = RelayCapabilities {webDomain} in GroupRelay {groupRelayId, groupMemberId, userChatRelay, relayStatus, relayLink, relayCap} createRelayForOwner :: DB.Connection -> VersionRangeChat -> TVar ChaChaDRG -> User -> GroupInfo -> UserChatRelay -> ExceptT StoreError IO GroupMember @@ -1496,7 +1496,7 @@ setRelayLinkConfId db m confId relayLink = do (relayLink, currentTs, groupMemberId' m) updateRelayCapabilities :: DB.Connection -> GroupMember -> RelayCapabilities -> IO () -updateRelayCapabilities db m RelayCapabilities {baseWebUrl} = do +updateRelayCapabilities db m RelayCapabilities {webDomain} = do currentTs <- getCurrentTime DB.execute db @@ -1505,7 +1505,7 @@ updateRelayCapabilities db m RelayCapabilities {baseWebUrl} = do SET base_web_url = ?, updated_at = ? WHERE group_member_id = ? |] - (baseWebUrl, currentTs, groupMemberId' m) + (webDomain, currentTs, groupMemberId' m) getRelayConfId :: DB.Connection -> GroupMember -> ExceptT StoreError IO ConfirmationId getRelayConfId db m = From 7725b068334e73e815fefd0e82ecef91871d87c0 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 1 Jun 2026 21:34:48 +0100 Subject: [PATCH 28/66] 6.5.4.1 --- simplex-chat.cabal | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 4990dae942..f3612c88cf 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 6.5.4.0 +version: 6.5.4.1 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat From cfd25b8c73432c398f8176ea7747c7c6409a42ba Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 1 Jun 2026 22:54:38 +0100 Subject: [PATCH 29/66] 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 e593894b1e9d8811ca8566beca14b9ee8d547693 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 2 Jun 2026 07:04:23 +0100 Subject: [PATCH 30/66] ios: update core 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..adf31a4422 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-Gci0HSXijHT9PyNdCZeAwi-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi.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-Gci0HSXijHT9PyNdCZeAwi-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi.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-Gci0HSXijHT9PyNdCZeAwi-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi.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-Gci0HSXijHT9PyNdCZeAwi-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi.a */, ); path = Libraries; sourceTree = ""; From 6c0a362351c6b8d4a3d9713e5d981d3d54f55ca4 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Tue, 2 Jun 2026 07:23:26 +0100 Subject: [PATCH 31/66] ui: show channel web link (#7039) * ui: show channel web link * fix link --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- .../Views/Chat/Group/GroupChatInfoView.swift | 9 +++++++++ .../common/views/chat/group/GroupChatInfoView.kt | 14 ++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 34479fc6cb..0a448a2772 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -325,6 +325,15 @@ struct GroupChatInfoView: View { .lineLimit(4) .fixedSize(horizontal: false, vertical: true) } + if let webPage = groupInfo.groupProfile.publicGroup?.publicGroupAccess?.groupWebPage, + let url = URL(string: webPage) { + Link(destination: url) { + Text(webPage) + .font(.subheadline) + .lineLimit(1) + .truncationMode(.tail) + } + } if groupInfo.useRelays, let count = groupInfo.groupSummary.publicMemberCount, count > 0 { 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..7b9d6aa92e 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 @@ -12,6 +12,7 @@ import SectionView import androidx.compose.animation.* import androidx.compose.animation.core.animateDpAsState import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* @@ -23,6 +24,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.AnnotatedString import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -922,6 +924,18 @@ private fun GroupChatInfoHeader(cInfo: ChatInfo, groupInfo: GroupInfo) { modifier = Modifier.combinedClickable(onClick = copyDisplayName, onLongClick = copyDisplayName).onRightClick(copyDisplayName) ) ChatInfoDescription(cInfo, displayName, copyNameToClipboard) + val webPage = groupInfo.groupProfile.publicGroup?.publicGroupAccess?.groupWebPage + if (webPage != null) { + val uriHandler = LocalUriHandler.current + Text( + webPage, + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.clickable { uriHandler.openUriCatching(webPage) } + ) + } if (groupInfo.useRelays) { val count = groupInfo.groupSummary.publicMemberCount if (count != null && count > 0) { From e92afb68d52bff0d2a035b3594d16badb3f58649 Mon Sep 17 00:00:00 2001 From: SimpleX Chat Date: Tue, 2 Jun 2026 14:06:13 +0000 Subject: [PATCH 32/66] 6.5.4: android 353, desktop 145, ios 334 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 40 +++++++++---------- apps/multiplatform/gradle.properties | 8 ++-- .../types/typescript/package.json | 2 +- packages/simplex-chat-nodejs/package.json | 4 +- .../src/simplex_chat/_version.py | 4 +- .../flatpak/chat.simplex.simplex.metainfo.xml | 22 ++++++++++ 6 files changed, 51 insertions(+), 29 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index adf31a4422..2728f031b3 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -2073,7 +2073,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 333; + CURRENT_PROJECT_VERSION = 334; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2098,7 +2098,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES_THIN; - MARKETING_VERSION = 6.5.3; + MARKETING_VERSION = 6.5.4; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2123,7 +2123,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 333; + CURRENT_PROJECT_VERSION = 334; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2148,7 +2148,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.5.3; + MARKETING_VERSION = 6.5.4; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2165,11 +2165,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 333; + CURRENT_PROJECT_VERSION = 334; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.5.3; + MARKETING_VERSION = 6.5.4; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2185,11 +2185,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 333; + CURRENT_PROJECT_VERSION = 334; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.5.3; + MARKETING_VERSION = 6.5.4; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2210,7 +2210,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 333; + CURRENT_PROJECT_VERSION = 334; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2225,7 +2225,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.5.3; + MARKETING_VERSION = 6.5.4; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2247,7 +2247,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 333; + CURRENT_PROJECT_VERSION = 334; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2262,7 +2262,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.5.3; + MARKETING_VERSION = 6.5.4; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2284,7 +2284,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 333; + CURRENT_PROJECT_VERSION = 334; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2310,7 +2310,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.5.3; + MARKETING_VERSION = 6.5.4; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2335,7 +2335,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 333; + CURRENT_PROJECT_VERSION = 334; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2362,7 +2362,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.5.3; + MARKETING_VERSION = 6.5.4; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2389,7 +2389,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 333; + CURRENT_PROJECT_VERSION = 334; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2404,7 +2404,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.5.3; + MARKETING_VERSION = 6.5.4; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2423,7 +2423,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 333; + CURRENT_PROJECT_VERSION = 334; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2438,7 +2438,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.5.3; + MARKETING_VERSION = 6.5.4; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 3d4bf66913..a2b63a5810 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,13 +24,13 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=6.5.3 -android.version_code=351 +android.version_name=6.5.4 +android.version_code=353 android.bundle=false -desktop.version_name=6.5.3 -desktop.version_code=144 +desktop.version_name=6.5.4 +desktop.version_code=145 kotlin.version=2.1.20 gradle.plugin.version=8.7.0 diff --git a/packages/simplex-chat-client/types/typescript/package.json b/packages/simplex-chat-client/types/typescript/package.json index c929125033..ad7ab04462 100644 --- a/packages/simplex-chat-client/types/typescript/package.json +++ b/packages/simplex-chat-client/types/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@simplex-chat/types", - "version": "0.7.0", + "version": "0.8.0", "description": "TypeScript types for SimpleX Chat bot libraries", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/simplex-chat-nodejs/package.json b/packages/simplex-chat-nodejs/package.json index 5166283e75..0dff1f7f1d 100644 --- a/packages/simplex-chat-nodejs/package.json +++ b/packages/simplex-chat-nodejs/package.json @@ -1,6 +1,6 @@ { "name": "simplex-chat", - "version": "6.5.2", + "version": "6.5.4", "main": "dist/index.js", "types": "dist/index.d.ts", "files": [ @@ -24,7 +24,7 @@ "docs": "typedoc" }, "dependencies": { - "@simplex-chat/types": "^0.7.0", + "@simplex-chat/types": "^0.8.0", "extract-zip": "^2.0.1", "fast-deep-equal": "^3.1.3", "node-addon-api": "^8.5.0" diff --git a/packages/simplex-chat-python/src/simplex_chat/_version.py b/packages/simplex-chat-python/src/simplex_chat/_version.py index bd182d0240..2ae4ce941e 100644 --- a/packages/simplex-chat-python/src/simplex_chat/_version.py +++ b/packages/simplex-chat-python/src/simplex_chat/_version.py @@ -5,5 +5,5 @@ Bump both together for normal releases. For wrapper-only fixes use a PEP 440 post-release: __version__ = "6.5.2.post1", LIBS_VERSION unchanged. """ -__version__ = "6.5.2" # PEP 440 — read by hatchling for wheel metadata -LIBS_VERSION = "6.5.2" # simplex-chat-libs release tag (no 'v' prefix) +__version__ = "6.5.4" # PEP 440 — read by hatchling for wheel metadata +LIBS_VERSION = "6.5.4" # simplex-chat-libs release tag (no 'v' prefix) diff --git a/scripts/flatpak/chat.simplex.simplex.metainfo.xml b/scripts/flatpak/chat.simplex.simplex.metainfo.xml index b55a08df26..3f35d652fe 100644 --- a/scripts/flatpak/chat.simplex.simplex.metainfo.xml +++ b/scripts/flatpak/chat.simplex.simplex.metainfo.xml @@ -38,6 +38,28 @@
+ + https://simplex.chat/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.html + +

New in v6.5.4:

+

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 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 33/66] 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 656b1a3b6468e1b3295489ca855ccd5e4404be42 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Thu, 4 Jun 2026 07:53:27 +0000 Subject: [PATCH 34/66] simplex-chat-nodejs: bump libraries (#7042) --- packages/simplex-chat-nodejs/src/download-libs.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/simplex-chat-nodejs/src/download-libs.js b/packages/simplex-chat-nodejs/src/download-libs.js index db042d48a2..72761a1ac5 100644 --- a/packages/simplex-chat-nodejs/src/download-libs.js +++ b/packages/simplex-chat-nodejs/src/download-libs.js @@ -4,7 +4,7 @@ const path = require('path'); const extract = require('extract-zip'); const GITHUB_REPO = 'simplex-chat/simplex-chat-libs'; -const RELEASE_TAG = 'v6.5.2'; +const RELEASE_TAG = 'v6.5.4'; const BACKEND = (process.env.SIMPLEX_BACKEND || process.env.npm_config_simplex_backend || 'sqlite').toLowerCase(); if (BACKEND !== 'sqlite' && BACKEND !== 'postgres') { From 5b93cb0e3fe6b07d84f18168b2b90d95487137c2 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sat, 6 Jun 2026 20:48:11 +0100 Subject: [PATCH 35/66] core: store context to pass configuration parameters (#7057) * core: store context to pass configuration parameters * fix directory * fix test * comment * order --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- .../src/Directory/Service.hs | 16 +- .../src/Directory/Store.hs | 35 +- .../src/Directory/Store/Migrate.hs | 23 +- .../src/Directory/Util.hs | 6 +- src/Simplex/Chat/Controller.hs | 6 + src/Simplex/Chat/Library/Commands.hs | 636 +++++++++--------- src/Simplex/Chat/Library/Internal.hs | 198 +++--- src/Simplex/Chat/Library/Subscriber.hs | 250 +++---- src/Simplex/Chat/Store/Connections.hs | 26 +- src/Simplex/Chat/Store/ContactRequest.hs | 20 +- src/Simplex/Chat/Store/Delivery.hs | 8 +- src/Simplex/Chat/Store/Direct.hs | 88 +-- src/Simplex/Chat/Store/Files.hs | 18 +- src/Simplex/Chat/Store/Groups.hs | 414 ++++++------ src/Simplex/Chat/Store/Messages.hs | 130 ++-- src/Simplex/Chat/Store/Profiles.hs | 18 +- src/Simplex/Chat/Store/Shared.hs | 36 +- src/Simplex/Chat/Types.hs | 4 + tests/ChatTests/Utils.hs | 6 +- 19 files changed, 977 insertions(+), 961 deletions(-) diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 577cc99752..63e1a0ff69 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -204,7 +204,7 @@ linkCheckThread_ opts env@ServiceState {eventQ} threadDelay $ linkCheckInterval opts * 1000000 u <- readTVarIO $ currentUser cc forM_ u $ \user -> - withDB' "linkCheckThread" cc (\db -> getAllGroupRegs_ db user) >>= \case + withDB' "linkCheckThread" cc (\db -> getAllGroupRegs_ db (storeCxt cc) user) >>= \case Left e -> logError $ "linkCheckThread error: " <> T.pack e Right grs -> forM_ grs $ \(gInfo, gr) -> unless (groupRemoved $ groupRegStatus gr) $ @@ -462,7 +462,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName getOwnerGroupMember :: GroupId -> GroupReg -> IO (Either String GroupMember) getOwnerGroupMember gId GroupReg {dbOwnerMemberId} = case dbOwnerMemberId of - Just mId -> withDB "getGroupMember" cc $ \db -> withExceptT show $ getGroupMember db (vr cc) user gId mId + Just mId -> withDB "getGroupMember" cc $ \db -> withExceptT show $ getGroupMember db (storeCxt cc) user gId mId Nothing -> pure $ Left "no owner member in group registration" deServiceJoinedGroup :: ContactId -> GroupInfo -> GroupMember -> IO () @@ -556,7 +556,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName Right (CRConnectionPlan _ _ (CPGroupLink (GLPKnown {groupInfo = g'}))) -> case dbOwnerMemberId gr of Just ownerGMId -> - withDB "getGroupMember" cc (\db -> withExceptT show $ getGroupMember db (vr cc) user groupId ownerGMId) >>= \case + withDB "getGroupMember" cc (\db -> withExceptT show $ getGroupMember db (storeCxt cc) user groupId ownerGMId) >>= \case Right ownerMember | let GroupMember {memberRole = role} = ownerMember, role >= GROwner -> setGroupStatus notifyAdminUsers st env cc groupId (GRSPendingApproval n') (`updatedNotification` g') @@ -813,7 +813,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName _ -> False checkValidOwner dbOwnerMemberId owners onValid = case dbOwnerMemberId of Just ownerGMId -> - withDB "checkGroupLink" cc (\db -> withExceptT show $ getGroupMember db (vr cc) user groupId ownerGMId) >>= \case + withDB "checkGroupLink" cc (\db -> withExceptT show $ getGroupMember db (storeCxt cc) user groupId ownerGMId) >>= \case Right GroupMember {memberId, memberPubKey} | any (\GroupLinkOwner {memberId = mId, memberKey} -> memberId == mId && memberPubKey == Just memberKey) owners -> onValid _ -> setGroupStatus logError st env cc groupId GRSSuspendedBadRoles $ \gr' -> @@ -985,7 +985,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName addGroupReg notifyAdminUsers st cc ct gInfo GRSProposed $ \_ -> pure () sendChatCmd cc (APIConnectPreparedGroup gId False (Just ownerContact) Nothing) >>= \case Right CRStartedConnectionToGroup {groupInfo = gInfo'} -> - withDB "getGroupMember" cc (\db -> withExceptT show $ getGroupMemberByMemberId db (vr cc) user gInfo' mId) >>= \case + withDB "getGroupMember" cc (\db -> withExceptT show $ getGroupMemberByMemberId db (storeCxt cc) user gInfo' mId) >>= \case Right ownerMember -> void $ setGroupRegOwner cc gId ownerMember Left e -> do @@ -998,7 +998,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName deReregistration ct g@GroupInfo {groupId, groupProfile = GroupProfile {publicGroup = pg_}} profileChanged LinkOwnerSig {ownerId = Just (B64UrlByteString oIdBytes)} = do let mId = MemberId oIdBytes gt = maybe "group" groupTypeStr' pg_ - withDB "getGroupMemberByMemberId" cc (\db -> withExceptT show $ getGroupMemberByMemberId db (vr cc) user g mId) >>= \case + withDB "getGroupMemberByMemberId" cc (\db -> withExceptT show $ getGroupMemberByMemberId db (storeCxt cc) user g mId) >>= \case Right ownerMember@GroupMember {memberRole = role, memberStatus} -> if | role >= GROwner && memberStatus /= GSMemUnknown -> @@ -1451,7 +1451,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName getOwnersInfo :: [(GroupInfo, GroupReg)] -> IO [((GroupInfo, GroupReg), Maybe (Either String Contact))] getOwnersInfo gs = fmap (either (\e -> map (,Just (Left e)) gs) id) $ withDB' "getOwnersInfo" cc $ \db -> - mapM (\g@(_, gr) -> fmap ((g,) . Just . first show) $ runExceptT $ getContact db (vr cc) user $ dbContactId gr) gs + mapM (\g@(_, gr) -> fmap ((g,) . Just . first show) $ runExceptT $ getContact db (storeCxt cc) user $ dbContactId gr) gs sendGroupsInfo :: Contact -> ChatItemId -> Bool -> ([(GroupInfo, GroupReg)], Int) -> IO () sendGroupsInfo ct ciId isAdmin (gs, n) = do @@ -1519,7 +1519,7 @@ updateGroupListingFiles cc u dir = Left e -> logError $ "generateListing error: failed to read groups: " <> T.pack e getContact' :: ChatController -> User -> ContactId -> IO (Either String Contact) -getContact' cc user ctId = withDB "getContact" cc $ \db -> withExceptT show $ getContact db (vr cc) user ctId +getContact' cc user ctId = withDB "getContact" cc $ \db -> withExceptT show $ getContact db (storeCxt cc) user ctId getGroupLink' :: ChatController -> User -> GroupInfo -> IO (Either String GroupLink) getGroupLink' cc user gInfo = diff --git a/apps/simplex-directory-service/src/Directory/Store.hs b/apps/simplex-directory-service/src/Directory/Store.hs index b5f7220724..4036bd8cf1 100644 --- a/apps/simplex-directory-service/src/Directory/Store.hs +++ b/apps/simplex-directory-service/src/Directory/Store.hs @@ -85,7 +85,6 @@ import Data.Time.Clock.System (systemEpochDay) import Directory.Search import Directory.Util import Simplex.Chat.Controller -import Simplex.Chat.Protocol (supportedChatVRange) import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Store import Simplex.Chat.Store.Groups @@ -315,28 +314,28 @@ getGroupReg_ db gId = getGroupAndReg :: ChatController -> User -> GroupId -> IO (Either String (GroupInfo, GroupReg)) getGroupAndReg cc user@User {userId, userContactId} gId = withDB "getGroupAndReg" cc $ \db -> - ExceptT $ firstRow (toGroupInfoReg (vr cc) user) ("group " ++ show gId ++ " not found") $ + ExceptT $ firstRow (toGroupInfoReg (storeCxt cc) user) ("group " ++ show gId ++ " not found") $ DB.query db (groupReqQuery <> " AND g.group_id = ?") (userId, userContactId, gId) getUserGroupReg :: ChatController -> User -> ContactId -> UserGroupRegId -> IO (Either String (GroupInfo, GroupReg)) getUserGroupReg cc user@User {userId, userContactId} ctId ugrId = withDB "getUserGroupReg" cc $ \db -> - ExceptT $ firstRow (toGroupInfoReg (vr cc) user) ("group " ++ show ugrId ++ " not found") $ + ExceptT $ firstRow (toGroupInfoReg (storeCxt cc) user) ("group " ++ show ugrId ++ " not found") $ DB.query db (groupReqQuery <> " AND r.contact_id = ? AND r.user_group_reg_id = ?") (userId, userContactId, ctId, ugrId) getUserGroupRegs :: ChatController -> User -> ContactId -> IO (Either String [(GroupInfo, GroupReg)]) getUserGroupRegs cc user@User {userId, userContactId} ctId = withDB' "getUserGroupRegs" cc $ \db -> - map (toGroupInfoReg (vr cc) user) + map (toGroupInfoReg (storeCxt cc) user) <$> DB.query db (groupReqQuery <> " AND r.contact_id = ? ORDER BY r.user_group_reg_id") (userId, userContactId, ctId) getAllListedGroups :: ChatController -> User -> IO (Either String [(GroupInfo, GroupReg, Maybe GroupLink)]) -getAllListedGroups cc user = withDB' "getAllListedGroups" cc $ \db -> getAllListedGroups_ db (vr cc) user +getAllListedGroups cc user = withDB' "getAllListedGroups" cc $ \db -> getAllListedGroups_ db (storeCxt cc) user -getAllListedGroups_ :: DB.Connection -> VersionRangeChat -> User -> IO [(GroupInfo, GroupReg, Maybe GroupLink)] -getAllListedGroups_ db vr' user@User {userId, userContactId} = +getAllListedGroups_ :: DB.Connection -> StoreCxt -> User -> IO [(GroupInfo, GroupReg, Maybe GroupLink)] +getAllListedGroups_ db cxt user@User {userId, userContactId} = DB.query db (groupReqQuery <> " AND r.group_reg_status = ?") (userId, userContactId, GRSActive) - >>= mapM (withGroupLink . toGroupInfoReg vr' user) + >>= mapM (withGroupLink . toGroupInfoReg cxt user) where withGroupLink (g, gr) = (g,gr,) . eitherToMaybe <$> runExceptT (getGroupLink db user g) @@ -382,7 +381,7 @@ searchListedGroups cc user@User {userId, userContactId} searchType lastGroup_ pa countQuery' = countQuery <> " JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id WHERE r.group_reg_status = ? " orderBy = " ORDER BY g.summary_current_members_count DESC, r.group_reg_id ASC " where - groups = (map (toGroupInfoReg (vr cc) user) <$>) + groups = (map (toGroupInfoReg (storeCxt cc) user) <$>) count = maybeFirstRow' 0 fromOnly listedGroupQuery = groupReqQuery <> " AND r.group_reg_status = ? " countQuery = "SELECT COUNT(1) FROM groups g JOIN sx_directory_group_regs r ON g.group_id = r.group_id " @@ -395,22 +394,22 @@ searchListedGroups cc user@User {userId, userContactId} searchType lastGroup_ pa ) |] -getAllGroupRegs_ :: DB.Connection -> User -> IO [(GroupInfo, GroupReg)] -getAllGroupRegs_ db user@User {userId, userContactId} = - map (toGroupInfoReg supportedChatVRange user) +getAllGroupRegs_ :: DB.Connection -> StoreCxt -> User -> IO [(GroupInfo, GroupReg)] +getAllGroupRegs_ db cxt user@User {userId, userContactId} = + map (toGroupInfoReg cxt user) <$> DB.query db groupReqQuery (userId, userContactId) getDuplicateGroupRegs :: ChatController -> User -> Text -> IO (Either String [(GroupInfo, GroupReg)]) getDuplicateGroupRegs cc user@User {userId, userContactId} displayName = withDB' "getDuplicateGroupRegs" cc $ \db -> - map (toGroupInfoReg (vr cc) user) + map (toGroupInfoReg (storeCxt cc) user) <$> DB.query db (groupReqQuery <> " AND gp.display_name = ?") (userId, userContactId, displayName) listLastGroups :: ChatController -> User -> Int -> IO (Either String ([(GroupInfo, GroupReg)], Int)) listLastGroups cc user@User {userId, userContactId} count = withDB' "getUserGroupRegs" cc $ \db -> do gs <- - map (toGroupInfoReg (vr cc) user) + map (toGroupInfoReg (storeCxt cc) user) <$> DB.query db (groupReqQuery <> " ORDER BY group_reg_id DESC LIMIT ?") (userId, userContactId, count) n <- maybeFirstRow' 0 fromOnly $ DB.query_ db "SELECT COUNT(1) FROM sx_directory_group_regs" pure (gs, n) @@ -419,14 +418,14 @@ listPendingGroups :: ChatController -> User -> Int -> IO (Either String ([(Group listPendingGroups cc user@User {userId, userContactId} count = withDB' "getUserGroupRegs" cc $ \db -> do gs <- - map (toGroupInfoReg (vr cc) user) + map (toGroupInfoReg (storeCxt cc) user) <$> DB.query db (groupReqQuery <> " AND r.group_reg_status LIKE 'pending_approval%' ORDER BY group_reg_id DESC LIMIT ?") (userId, userContactId, count) n <- maybeFirstRow' 0 fromOnly $ DB.query_ db "SELECT COUNT(1) FROM sx_directory_group_regs WHERE group_reg_status LIKE 'pending_approval%'" pure (gs, n) -toGroupInfoReg :: VersionRangeChat -> User -> (GroupInfoRow :. GroupRegRow) -> (GroupInfo, GroupReg) -toGroupInfoReg vr' User {userContactId} (groupRow :. grRow) = - (toGroupInfo vr' userContactId [] groupRow, rowToGroupReg grRow) +toGroupInfoReg :: StoreCxt -> User -> (GroupInfoRow :. GroupRegRow) -> (GroupInfo, GroupReg) +toGroupInfoReg cxt User {userContactId} (groupRow :. grRow) = + (toGroupInfo cxt userContactId [] groupRow, rowToGroupReg grRow) type GroupRegRow = (GroupId, UserGroupRegId, ContactId, Maybe GroupMemberId, GroupRegStatus, BoolInt, UTCTime) diff --git a/apps/simplex-directory-service/src/Directory/Store/Migrate.hs b/apps/simplex-directory-service/src/Directory/Store/Migrate.hs index aa101d7bf7..d501fbd5c3 100644 --- a/apps/simplex-directory-service/src/Directory/Store/Migrate.hs +++ b/apps/simplex-directory-service/src/Directory/Store/Migrate.hs @@ -18,10 +18,9 @@ import Directory.Listing import Directory.Options import Directory.Store import Simplex.Chat (createChatDatabase) -import Simplex.Chat.Controller (ChatConfig (..), ChatDatabase (..)) +import Simplex.Chat.Controller (ChatConfig (..), ChatDatabase (..), mkStoreCxt) import Simplex.Chat.Options (CoreChatOpts (..)) import Simplex.Chat.Options.DB -import Simplex.Chat.Protocol (supportedChatVRange) import Simplex.Chat.Store.Groups (getHostMember) import Simplex.Chat.Store.Profiles (getUsers) import Simplex.Chat.Store.Shared (getGroupInfo) @@ -62,7 +61,7 @@ checkDirectoryLog opts cfg = runDirectoryMigrations opts cfg st gs <- readDirectoryLogData logFile withActiveUser st $ \user -> withTransaction st $ \db -> do - mapM_ (verifyGroupRegistration db user) gs + mapM_ (verifyGroupRegistration (mkStoreCxt cfg) db user) gs putStrLn $ show (length gs) <> " group registrations OK" importDirectoryLogToDB :: DirectoryOpts -> ChatConfig -> IO () @@ -73,7 +72,7 @@ importDirectoryLogToDB opts cfg = do ctRegs <- TM.emptyIO withActiveUser st $ \user -> withTransaction st $ \db -> do forM_ gs $ \gr -> - whenM (verifyGroupRegistration db user gr) $ do + whenM (verifyGroupRegistration (mkStoreCxt cfg) db user gr) $ do putStrLn $ "importing group " <> show (dbGroupId gr) insertGroupReg db =<< fixUserGroupRegId ctRegs gr renamePath logFile (logFile ++ ".bak") @@ -101,28 +100,28 @@ exportDBToDirectoryLog opts cfg = runDirectoryMigrations opts cfg st withActiveUser st $ \user -> do gs <- withFile logFile WriteMode $ \h -> withTransaction st $ \db -> do - gs <- getAllGroupRegs_ db user + gs <- getAllGroupRegs_ db (mkStoreCxt cfg) user forM_ gs $ \(_, gr) -> - whenM (verifyGroupRegistration db user gr) $ + whenM (verifyGroupRegistration (mkStoreCxt cfg) db user gr) $ B.hPutStrLn h $ strEncode $ GRCreate gr pure gs putStrLn $ show (length gs) <> " group registrations exported" saveGroupListingFiles :: DirectoryOpts -> ChatConfig -> IO () -saveGroupListingFiles opts _cfg = case webFolder opts of +saveGroupListingFiles opts cfg = case webFolder opts of Nothing -> exit "use --web-folder to generate listings" Just dir -> withChatStore opts $ \st -> withActiveUser st $ \user -> withTransaction st $ \db -> - getAllListedGroups_ db supportedChatVRange user >>= generateListing dir + getAllListedGroups_ db (mkStoreCxt cfg) user >>= generateListing dir -verifyGroupRegistration :: DB.Connection -> User -> GroupReg -> IO Bool -verifyGroupRegistration db user GroupReg {dbGroupId = gId, dbContactId = ctId, dbOwnerMemberId, groupRegStatus} = - runExceptT (getGroupInfo db supportedChatVRange user gId) >>= \case +verifyGroupRegistration :: StoreCxt -> DB.Connection -> User -> GroupReg -> IO Bool +verifyGroupRegistration cxt db user GroupReg {dbGroupId = gId, dbContactId = ctId, dbOwnerMemberId, groupRegStatus} = + runExceptT (getGroupInfo db cxt user gId) >>= \case Left e -> False <$ putStrLn ("Error: loading group " <> show gId <> " (skipping): " <> show e) Right GroupInfo {localDisplayName} -> do let groupRef = show gId <> " " <> T.unpack localDisplayName - runExceptT (getHostMember db supportedChatVRange user gId) >>= \case + runExceptT (getHostMember db cxt user gId) >>= \case Left e -> False <$ putStrLn ("Error: loading host member of group " <> groupRef <> " (skipping): " <> show e) Right GroupMember {groupMemberId = mId', memberContactId = ctId'} -> case dbOwnerMemberId of Nothing -> True <$ putStrLn ("Warning: group " <> groupRef <> " has no owner member ID, host member ID is " <> show mId' <> ", registration status: " <> B.unpack (strEncode groupRegStatus)) diff --git a/apps/simplex-directory-service/src/Directory/Util.hs b/apps/simplex-directory-service/src/Directory/Util.hs index a4b79a1bef..52d376a945 100644 --- a/apps/simplex-directory-service/src/Directory/Util.hs +++ b/apps/simplex-directory-service/src/Directory/Util.hs @@ -15,9 +15,9 @@ import Simplex.Messaging.Agent.Store.Common (withTransaction) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Util (catchAll) -vr :: ChatController -> VersionRangeChat -vr ChatController {config = ChatConfig {chatVRange}} = chatVRange -{-# INLINE vr #-} +storeCxt :: ChatController -> StoreCxt +storeCxt ChatController {config} = mkStoreCxt config +{-# INLINE storeCxt #-} withDB' :: Text -> ChatController -> (DB.Connection -> IO a) -> IO (Either String a) withDB' cxt cc a = withDB cxt cc $ ExceptT . fmap Right . a diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index fe5b67f041..c92c1f9e09 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -169,6 +169,12 @@ data ChatConfig = ChatConfig chatHooks :: ChatHooks } +-- | 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 +mkStoreCxt ChatConfig {chatVRange} = StoreCxt chatVRange +{-# INLINE mkStoreCxt #-} + data RandomAgentServers = RandomAgentServers { smpServers :: NonEmpty (ServerCfg 'PSMP), xftpServers :: NonEmpty (ServerCfg 'PXFTP) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 20e9d6a0fb..f35a9ef177 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -327,8 +327,8 @@ execChatCommand rh s retryNum = execChatCommand' :: ChatCommand -> Int -> CM' (Either ChatError ChatResponse) execChatCommand' cmd retryNum = handleCommandError $ do - vr <- chatVersionRange - processChatCommand vr (NRMInteractive' retryNum) cmd + cxt <- chatStoreCxt + processChatCommand cxt (NRMInteractive' retryNum) cmd execRemoteCommand :: RemoteHostId -> ChatCommand -> ByteString -> Int -> CM' (Either ChatError ChatResponse) execRemoteCommand rhId cmd s retryNum = handleCommandError $ getRemoteHostClient rhId >>= \rh -> processRemoteCommand rhId rh cmd s retryNum @@ -345,8 +345,8 @@ parseChatCommand :: ByteString -> Either String ChatCommand parseChatCommand = A.parseOnly chatCommandP . B.dropWhileEnd isSpace -- | Chat API commands interpreted in context of a local zone -processChatCommand :: VersionRangeChat -> NetworkRequestMode -> ChatCommand -> CM ChatResponse -processChatCommand vr nm = \case +processChatCommand :: StoreCxt -> NetworkRequestMode -> ChatCommand -> CM ChatResponse +processChatCommand cxt nm = \case ShowActiveUser -> withUser' $ pure . CRActiveUser CreateActiveUser NewUser {profile, pastTimestamp, userChatRelay} -> do forM_ profile $ \Profile {displayName} -> checkValidName displayName @@ -411,26 +411,26 @@ processChatCommand vr nm = \case SetActiveUser uName viewPwd_ -> do tryAllErrors (withFastStore (`getUserIdByName` uName)) >>= \case Left _ -> throwChatError CEUserUnknown - Right userId -> processChatCommand vr nm $ APISetActiveUser userId viewPwd_ + Right userId -> processChatCommand cxt nm $ APISetActiveUser userId viewPwd_ SetAllContactReceipts onOff -> withUser $ \_ -> withFastStore' (`updateAllContactReceipts` onOff) >> ok_ APISetUserContactReceipts userId' settings -> withUser $ \user -> do user' <- privateGetUser userId' validateUserPassword user user' Nothing withFastStore' $ \db -> updateUserContactReceipts db user' settings ok user - SetUserContactReceipts settings -> withUser $ \User {userId} -> processChatCommand vr nm $ APISetUserContactReceipts userId settings + SetUserContactReceipts settings -> withUser $ \User {userId} -> processChatCommand cxt nm $ APISetUserContactReceipts userId settings APISetUserGroupReceipts userId' settings -> withUser $ \user -> do user' <- privateGetUser userId' validateUserPassword user user' Nothing withFastStore' $ \db -> updateUserGroupReceipts db user' settings ok user - SetUserGroupReceipts settings -> withUser $ \User {userId} -> processChatCommand vr nm $ APISetUserGroupReceipts userId settings + SetUserGroupReceipts settings -> withUser $ \User {userId} -> processChatCommand cxt nm $ APISetUserGroupReceipts userId settings APISetUserAutoAcceptMemberContacts userId' onOff -> withUser $ \user -> do user' <- privateGetUser userId' validateUserPassword user user' Nothing withFastStore' $ \db -> updateUserAutoAcceptMemberContacts db user' onOff ok user - SetUserAutoAcceptMemberContacts onOff -> withUser $ \User {userId} -> processChatCommand vr nm $ APISetUserAutoAcceptMemberContacts userId onOff + SetUserAutoAcceptMemberContacts onOff -> withUser $ \User {userId} -> processChatCommand cxt nm $ APISetUserAutoAcceptMemberContacts userId onOff APIHideUser userId' (UserPwd viewPwd) -> withUser $ \user -> do user' <- privateGetUser userId' case viewPwdHash user' of @@ -456,10 +456,10 @@ processChatCommand vr nm = \case setUserPrivacy user user' {viewPwdHash = Nothing, showNtfs = True} APIMuteUser userId' -> setUserNotifications userId' False APIUnmuteUser userId' -> setUserNotifications userId' True - HideUser viewPwd -> withUser $ \User {userId} -> processChatCommand vr nm $ APIHideUser userId viewPwd - 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 + HideUser viewPwd -> withUser $ \User {userId} -> processChatCommand cxt nm $ APIHideUser userId viewPwd + UnhideUser viewPwd -> withUser $ \User {userId} -> processChatCommand cxt nm $ APIUnhideUser userId viewPwd + MuteUser -> withUser $ \User {userId} -> processChatCommand cxt nm $ APIMuteUser userId + UnmuteUser -> withUser $ \User {userId} -> processChatCommand cxt nm $ APIUnmuteUser userId APIDeleteUser userId' delSMPQueues viewPwd_ -> withUser $ \user -> do user' <- privateGetUser userId' validateUserPassword user user' viewPwd_ @@ -529,7 +529,7 @@ processChatCommand vr nm = \case ExportArchive -> do ts <- liftIO getCurrentTime let filePath = "simplex-chat." <> formatTime defaultTimeLocale "%FT%H%M%SZ" ts <> ".zip" - processChatCommand vr nm $ APIExportArchive $ ArchiveConfig filePath Nothing Nothing + processChatCommand cxt nm $ APIExportArchive $ ArchiveConfig filePath Nothing Nothing APIImportArchive cfg -> checkChatStopped $ do fileErrs <- lift $ importArchive cfg setStoreChanged @@ -558,16 +558,16 @@ processChatCommand vr nm = \case tags <- withFastStore' (`getUserChatTags` user) pure $ CRChatTags user tags APIGetChats {userId, pendingConnections, pagination, query} -> withUserId' userId $ \user -> do - (errs, previews) <- partitionEithers <$> withFastStore' (\db -> getChatPreviews db vr user pendingConnections pagination query) + (errs, previews) <- partitionEithers <$> withFastStore' (\db -> getChatPreviews db cxt user pendingConnections pagination query) unless (null errs) $ toView $ CEvtChatErrors (map ChatErrorStore errs) pure $ CRApiChats user previews APIGetChat (ChatRef cType cId scope_) contentFilter pagination search -> withUser $ \user -> case cType of -- TODO optimize queries calculating ChatStats, currently they're disabled CTDirect -> do - (directChat, navInfo) <- withFastStore (\db -> getDirectChat db vr user cId contentFilter pagination search) + (directChat, navInfo) <- withFastStore (\db -> getDirectChat db cxt user cId contentFilter pagination search) pure $ CRApiChat user (AChat SCTDirect directChat) navInfo CTGroup -> do - (groupChat, navInfo) <- withFastStore (\db -> getGroupChat db vr user cId scope_ contentFilter pagination search) + (groupChat, navInfo) <- withFastStore (\db -> getGroupChat db cxt user cId scope_ contentFilter pagination search) groupChat' <- checkSupportChatAttention user groupChat pure $ CRApiChat user (AChat SCTGroup groupChat') navInfo CTLocal -> do @@ -583,7 +583,7 @@ processChatCommand vr nm = \case case correctedMemAttention (groupMemberId' scopeMem) suppChat chatItems of Just newMemAttention -> do (gInfo', scopeMem') <- - withFastStore' $ \db -> setSupportChatMemberAttention db vr user gInfo scopeMem newMemAttention + withFastStore' $ \db -> setSupportChatMemberAttention db cxt user gInfo scopeMem newMemAttention pure (groupChat {chatInfo = GroupChat gInfo' (Just $ GCSIMemberSupport (Just scopeMem'))} :: Chat 'CTGroup) Nothing -> pure groupChat _ -> pure groupChat @@ -600,11 +600,11 @@ processChatCommand vr nm = \case APIGetChatContentTypes chatRef -> withUser $ \user -> CRChatContentTypes <$> withStore (\db -> getChatContentTypes db user chatRef) APIGetChatItems pagination search -> withUser $ \user -> do - chatItems <- withFastStore $ \db -> getAllChatItems db vr user pagination search + chatItems <- withFastStore $ \db -> getAllChatItems db cxt user pagination search pure $ CRChatItems user Nothing chatItems APIGetChatItemInfo chatRef itemId -> withUser $ \user -> do (aci@(AChatItem cType dir _ ci), versions) <- withFastStore $ \db -> - (,) <$> getAChatItem db vr user chatRef itemId <*> liftIO (getChatItemVersions db itemId) + (,) <$> getAChatItem db cxt user chatRef itemId <*> liftIO (getChatItemVersions db itemId) let itemVersions = if null versions then maybeToList $ mkItemVersion ci else versions memberDeliveryStatuses <- case (cType, dir) of (SCTGroup, SMDSnd) -> L.nonEmpty <$> withFastStore' (`getGroupSndStatuses` itemId) @@ -615,10 +615,10 @@ processChatCommand vr nm = \case getForwardedFromItem :: User -> ChatItem c d -> CM (Maybe AChatItem) getForwardedFromItem user ChatItem {meta = CIMeta {itemForwarded}} = case itemForwarded of Just (CIFFContact _ _ (Just ctId) (Just fwdItemId)) -> - Just <$> withFastStore (\db -> getAChatItem db vr user (ChatRef CTDirect ctId Nothing) fwdItemId) + Just <$> withFastStore (\db -> getAChatItem db cxt user (ChatRef CTDirect ctId Nothing) fwdItemId) Just (CIFFGroup _ _ (Just gId) (Just fwdItemId)) -> -- TODO [knocking] getAChatItem doesn't differentiate how to read based on scope - it should, instead of using group filter - Just <$> withFastStore (\db -> getAChatItem db vr user (ChatRef CTGroup gId Nothing) fwdItemId) + Just <$> withFastStore (\db -> getAChatItem db cxt user (ChatRef CTGroup gId Nothing) fwdItemId) _ -> pure Nothing APISendMessages sendRef live itemTTL cms -> withUser $ \user -> mapM_ assertAllowedContent' cms >> case sendRef of SRDirect chatId -> do @@ -631,7 +631,7 @@ processChatCommand vr nm = \case Nothing -> pure () withGroupLock "sendMessage" chatId $ do (gInfo, cmrs) <- withFastStore $ \db -> do - g <- getGroupInfo db vr user chatId + g <- getGroupInfo db cxt user chatId (g,) <$> mapM (composedMessageReqMentions db user g) cms sendGroupContentMessages user gInfo gsScope asGroup live itemTTL cmrs APICreateChatTag (ChatTagData emoji text) -> withUser $ \user -> withFastStore' $ \db -> do @@ -659,18 +659,18 @@ processChatCommand vr nm = \case createNoteFolderContentItems user folderId (L.map composedMessageReq cms) APIReportMessage gId reportedItemId reportReason reportText -> withUser $ \user -> withGroupLock "reportMessage" gId $ do - gInfo <- withFastStore $ \db -> getGroupInfo db vr user gId + gInfo <- withFastStore $ \db -> getGroupInfo db cxt user gId let mc = MCReport reportText reportReason cm = ComposedMessage {fileSource = Nothing, quotedItemId = Just reportedItemId, msgContent = mc, mentions = M.empty} sendGroupContentMessages user gInfo (Just $ GCSMemberSupport Nothing) False False Nothing [composedMessageReq cm] ReportMessage {groupName, contactName_, reportReason, reportedMessage} -> withUser $ \user -> do gId <- withFastStore $ \db -> getGroupIdByName db user groupName reportedItemId <- withFastStore $ \db -> getGroupChatItemIdByText db user gId contactName_ reportedMessage - processChatCommand vr nm $ APIReportMessage gId reportedItemId reportReason "" + processChatCommand cxt nm $ APIReportMessage gId reportedItemId reportReason "" APIUpdateChatItem (ChatRef cType chatId scope) itemId live (UpdatedMessage mc mentions) -> withUser $ \user -> assertAllowedContent mc >> case cType of CTDirect -> withContactLock "updateChatItem" chatId $ do unless (null mentions) $ throwCmdError "mentions are not supported in this chat" - ct@Contact {contactId} <- withFastStore $ \db -> getContact db vr user chatId + ct@Contact {contactId} <- withFastStore $ \db -> getContact db cxt user chatId assertDirectAllowed user MDSnd ct XMsgUpdate_ cci <- withFastStore $ \db -> getDirectCIWithReactions db user ct itemId case cci of @@ -694,7 +694,7 @@ processChatCommand vr nm = \case _ -> throwChatError CEInvalidChatItemUpdate CChatItem SMDRcv _ -> throwChatError CEInvalidChatItemUpdate CTGroup -> withGroupLock "updateChatItem" chatId $ do - gInfo@GroupInfo {groupId, membership} <- withFastStore $ \db -> getGroupInfo db vr user chatId + gInfo@GroupInfo {groupId, membership} <- withFastStore $ \db -> getGroupInfo db cxt user chatId when (isNothing scope) $ assertUserGroupRole gInfo GRAuthor let (_, ft_) = msgContentTexts mc if prohibitedSimplexLinks gInfo membership mc ft_ @@ -706,8 +706,8 @@ processChatCommand vr nm = \case CChatItem SMDSnd ci@ChatItem {meta = CIMeta {itemSharedMsgId, itemTimed, itemLive, editable, showGroupAsSender}, content = ciContent} -> do case (ciContent, itemSharedMsgId, editable) of (CISndMsgContent oldMC, Just itemSharedMId, True) -> do - chatScopeInfo <- mapM (getChatScopeInfo vr user) scope - recipients <- getGroupRecipients vr user gInfo chatScopeInfo groupKnockingVersion + chatScopeInfo <- mapM (getChatScopeInfo cxt user) scope + recipients <- getGroupRecipients cxt user gInfo chatScopeInfo groupKnockingVersion let changed = mc /= oldMC if changed || fromMaybe False itemLive then do @@ -763,7 +763,7 @@ processChatCommand vr nm = \case CTGroup -> withGroupLock "deleteChatItem" chatId $ do (gInfo, items) <- getCommandGroupChatItems user chatId itemIds -- TODO [knocking] check scope for all items? - chatScopeInfo <- mapM (getChatScopeInfo vr user) scope + chatScopeInfo <- mapM (getChatScopeInfo cxt user) scope deletions <- case mode of CIDMInternal | publicGroupEditor gInfo (membership gInfo) -> throwChatError CEInvalidChatItemDelete @@ -771,7 +771,7 @@ processChatCommand vr nm = \case CIDMInternalMark -> do markGroupCIsDeleted user gInfo chatScopeInfo items Nothing =<< liftIO getCurrentTime CIDMBroadcast -> do - recipients <- getGroupRecipients vr user gInfo chatScopeInfo groupKnockingVersion + recipients <- getGroupRecipients cxt user gInfo chatScopeInfo groupKnockingVersion assertDeletable items assertUserGroupRole gInfo GRObserver -- can still delete messages sent earlier let msgIds = itemsMsgIds items @@ -780,7 +780,7 @@ processChatCommand vr nm = \case delGroupChatItems user gInfo chatScopeInfo items False CIDMHistory -> do unless (publicGroupEditor gInfo (membership gInfo)) $ throwChatError CEInvalidChatItemDelete - recipients <- getGroupRecipients vr user gInfo chatScopeInfo groupKnockingVersion + recipients <- getGroupRecipients cxt user gInfo chatScopeInfo groupKnockingVersion let msgIds = itemsMsgIds items events = L.nonEmpty $ map (\msgId -> XMsgDel msgId Nothing (toMsgScope gInfo <$> chatScopeInfo) True) msgIds mapM_ (sendGroupMessages user gInfo Nothing False recipients) events @@ -808,12 +808,12 @@ processChatCommand vr nm = \case APIDeleteMemberChatItem gId itemIds -> withUser $ \user -> withGroupLock "deleteChatItem" gId $ do (gInfo, items) <- getCommandGroupChatItems user gId itemIds -- TODO [knocking] check scope is Nothing for all items? (prohibit moderation in support chats?) - ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo + ms <- withFastStore' $ \db -> getGroupMembers db cxt user gInfo let recipients = filter memberCurrent ms deletions <- delGroupChatItemsForMembers user gInfo Nothing recipients items pure $ CRChatItemsDeleted user deletions True False APIArchiveReceivedReports gId -> withUser $ \user -> withFastStore $ \db -> do - g <- getGroupInfo db vr user gId + g <- getGroupInfo db cxt user gId deleteTs <- liftIO getCurrentTime ciIds <- liftIO $ markReceivedGroupReportsDeleted db user g deleteTs pure $ CRGroupChatItemsDeleted user g ciIds True (Just $ membership g) @@ -827,7 +827,7 @@ processChatCommand vr nm = \case CIDMInternalMark -> markGroupCIsDeleted user gInfo Nothing items Nothing =<< liftIO getCurrentTime CIDMHistory -> throwChatError CEInvalidChatItemDelete CIDMBroadcast -> do - ms <- withFastStore' $ \db -> getGroupModerators db vr user gInfo + ms <- withFastStore' $ \db -> getGroupModerators db cxt user gInfo let recipients = filter memberCurrent ms delGroupChatItemsForMembers user gInfo Nothing recipients items pure $ CRChatItemsDeleted user deletions True False @@ -838,7 +838,7 @@ processChatCommand vr nm = \case APIChatItemReaction (ChatRef cType chatId scope) itemId add reaction -> withUser $ \user -> case cType of CTDirect -> withContactLock "chatItemReaction" chatId $ - withFastStore (\db -> (,) <$> getContact db vr user chatId <*> getDirectChatItem db user chatId itemId) >>= \case + withFastStore (\db -> (,) <$> getContact db cxt user chatId <*> getDirectChatItem db user chatId itemId) >>= \case (ct, CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId = Just itemSharedMId}}) -> do unless (featureAllowed SCFReactions forUser ct) $ throwCmdError $ "feature not allowed " <> T.unpack (chatFeatureNameText CFReactions) @@ -859,10 +859,10 @@ processChatCommand vr nm = \case withGroupLock "chatItemReaction" chatId $ do -- TODO [knocking] check chat item scope? (g@GroupInfo {membership}, CChatItem md ci) <- withFastStore $ \db -> do - g <- getGroupInfo db vr user chatId + g <- getGroupInfo db cxt user chatId (g,) <$> getGroupCIWithReactions db user g itemId - chatScopeInfo <- mapM (getChatScopeInfo vr user) scope - recipients <- getGroupRecipients vr user g chatScopeInfo groupKnockingVersion + chatScopeInfo <- mapM (getChatScopeInfo cxt user) scope + recipients <- getGroupRecipients cxt user g chatScopeInfo groupKnockingVersion case ci of ChatItem {meta = CIMeta {itemSharedMsgId = Just itemSharedMId}} -> do unless (groupFeatureAllowed SGFReactions g) $ @@ -893,7 +893,7 @@ processChatCommand vr nm = \case APIGetReactionMembers userId groupId itemId reaction -> withUserId userId $ \user -> do memberReactions <- withStore $ \db -> do CChatItem _ ChatItem {meta = CIMeta {itemSharedMsgId = Just itemSharedMId}} <- getGroupChatItem db user groupId itemId - liftIO $ getReactionMembers db vr user groupId itemSharedMId reaction + liftIO $ getReactionMembers db cxt user groupId itemSharedMId reaction pure $ CRReactionMembers user memberReactions -- TODO [knocking] forward from scope? APIPlanForwardChatItems (ChatRef fromCType fromChatId _scope) itemIds -> withUser $ \user -> case fromCType of @@ -957,7 +957,7 @@ processChatCommand vr nm = \case case L.nonEmpty cmrs of Just cmrs' -> withGroupLock "forwardChatItem, to group" toChatId $ do - gInfo <- withFastStore $ \db -> getGroupInfo db vr user toChatId + gInfo <- withFastStore $ \db -> getGroupInfo db cxt user toChatId sendGroupContentMessages user gInfo toScope sendAsGroup False itemTTL cmrs' Nothing -> pure $ CRNewChatItems user [] CTLocal -> do @@ -1091,7 +1091,7 @@ processChatCommand vr nm = \case pure $ prefix <> formattedDate <> ext APIShareChatMsgContent (ChatRef CTGroup groupId _) toSendRef -> withUser $ \user -> do GroupInfo {groupProfile = gp@GroupProfile {publicGroup}, membership = GroupMember {memberId, memberRole}, groupKeys} <- - withFastStore $ \db -> getGroupInfo db vr user groupId + withFastStore $ \db -> getGroupInfo db cxt user groupId case publicGroup of Nothing -> throwCmdError "not a public group" Just PublicGroupProfile {groupLink} -> do @@ -1113,11 +1113,11 @@ processChatCommand vr nm = \case shareChatBinding :: User -> SendRef -> CM (Maybe (ChatBinding, ByteString)) shareChatBinding u = \case SRDirect contactId -> do - ct <- withFastStore $ \db -> getContact db vr u contactId + ct <- withFastStore $ \db -> getContact db cxt u contactId forM (contactConn ct) $ \conn -> (CBDirect,) <$> withAgent (`getConnectionRatchetAdHash` aConnId conn) SRGroup toGroupId _ asGroup -> do - GroupInfo {groupProfile = GroupProfile {publicGroup}, membership = m} <- withFastStore $ \db -> getGroupInfo db vr u toGroupId + GroupInfo {groupProfile = GroupProfile {publicGroup}, membership = m} <- withFastStore $ \db -> getGroupInfo db cxt u toGroupId pure $ mkBinding m <$> publicGroup where mkBinding GroupMember {memberId} PublicGroupProfile {publicGroupId = pgId} @@ -1125,7 +1125,7 @@ processChatCommand vr nm = \case | otherwise = (CBGroup, smpEncode (pgId, memberId)) APIShareChatMsgContent _ _ -> throwCmdError "sharing is only supported for public groups" APIUserRead userId -> withUserId userId $ \user -> withFastStore' (`setUserChatsRead` user) >> ok user - UserRead -> withUser $ \User {userId} -> processChatCommand vr nm $ APIUserRead userId + UserRead -> withUser $ \User {userId} -> processChatCommand cxt nm $ APIUserRead userId APIChatRead chatRef@(ChatRef cType chatId scope_) -> withUser $ \_ -> case cType of CTDirect -> do user <- withFastStore $ \db -> getUserByContactId db chatId @@ -1139,7 +1139,7 @@ processChatCommand vr nm = \case CTGroup -> do (user, gInfo) <- withFastStore $ \db -> do user <- getUserByGroupId db chatId - gInfo <- getGroupInfo db vr user chatId + gInfo <- getGroupInfo db cxt user chatId pure (user, gInfo) ts <- liftIO getCurrentTime case scope_ of @@ -1151,10 +1151,10 @@ processChatCommand vr nm = \case forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt ok user Just scope -> do - scopeInfo <- getChatScopeInfo vr user scope + scopeInfo <- getChatScopeInfo cxt user scope (gInfo', m', timedItems) <- withFastStore' $ \db -> do timedItems <- getGroupUnreadTimedItems db user chatId (Just scope) - (gInfo', m') <- updateSupportChatItemsRead db vr user gInfo scopeInfo + (gInfo', m') <- updateSupportChatItemsRead db cxt user gInfo scopeInfo timedItems' <- setGroupChatItemsDeleteAt db user chatId timedItems ts pure (gInfo', m', timedItems') forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt @@ -1169,7 +1169,7 @@ processChatCommand vr nm = \case CTDirect -> do (user, ct) <- withFastStore $ \db -> do user <- getUserByContactId db chatId - ct <- getContact db vr user chatId + ct <- getContact db cxt user chatId pure (user, ct) timedItems <- withFastStore' $ \db -> do timedItems <- updateDirectChatItemsReadList db user chatId itemIds @@ -1179,11 +1179,11 @@ processChatCommand vr nm = \case CTGroup -> do (user, gInfo) <- withFastStore $ \db -> do user <- getUserByGroupId db chatId - gInfo <- getGroupInfo db vr user chatId + gInfo <- getGroupInfo db cxt user chatId pure (user, gInfo) - chatScopeInfo <- mapM (getChatScopeInfo vr user) scope + chatScopeInfo <- mapM (getChatScopeInfo cxt user) scope (timedItems, gInfo') <- withFastStore $ \db -> do - (timedItems, gInfo') <- updateGroupChatItemsReadList db vr user gInfo chatScopeInfo itemIds + (timedItems, gInfo') <- updateGroupChatItemsReadList db cxt user gInfo chatScopeInfo itemIds timedItems' <- liftIO $ setGroupChatItemsDeleteAt db user chatId timedItems =<< getCurrentTime pure (timedItems', gInfo') forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt @@ -1194,13 +1194,13 @@ processChatCommand vr nm = \case APIChatUnread (ChatRef cType chatId scope) unreadChat -> withUser $ \user -> case cType of CTDirect -> do withFastStore $ \db -> do - ct <- getContact db vr user chatId + ct <- getContact db cxt user chatId liftIO $ updateContactUnreadChat db user ct unreadChat ok user -- TODO [knocking] set support chat as unread? CTGroup | isNothing scope -> do withFastStore $ \db -> do - gInfo <- getGroupInfo db vr user chatId + gInfo <- getGroupInfo db cxt user chatId liftIO $ updateGroupUnreadChat db user gInfo unreadChat ok user CTLocal -> do @@ -1211,7 +1211,7 @@ processChatCommand vr nm = \case _ -> throwCmdError "not supported" APIDeleteChat cRef@(ChatRef cType chatId scope) cdm -> withUser $ \user@User {userId} -> case cType of CTDirect -> do - ct <- withFastStore $ \db -> getContact db vr user chatId + ct <- withFastStore $ \db -> getContact db cxt user chatId filesInfo <- withFastStore' $ \db -> getContactFileInfo db user ct withContactLock "deleteChat direct" chatId $ case cdm of @@ -1231,17 +1231,17 @@ processChatCommand vr nm = \case ct' <- withFastStore $ \db -> do liftIO $ deleteContactConnections db user ct liftIO $ void $ updateContactStatus db user ct CSDeletedByUser - getContact db vr user chatId + getContact db cxt user chatId pure $ CRContactDeleted user ct' CDMMessages -> do - void $ processChatCommand vr nm $ APIClearChat cRef + void $ processChatCommand cxt nm $ APIClearChat cRef withFastStore' $ \db -> setContactChatDeleted db user ct True pure $ CRContactDeleted user ct {chatDeleted = True} where sendDelDeleteConns ct notify = do let doSendDel = contactReady ct && contactActive ct && notify when doSendDel $ void (sendDirectContactMessage user ct XDirectDel) `catchAllErrors` const (pure ()) - contactConnIds <- map aConnId <$> withFastStore' (\db -> getContactConnections db vr userId ct) + contactConnIds <- map aConnId <$> withFastStore' (\db -> getContactConnections db cxt userId ct) deleteAgentConnectionsAsync' contactConnIds doSendDel CTContactConnection -> withConnectionLock "deleteChat contactConnection" chatId $ do conn@PendingContactConnection {pccAgentConnId = AgentConnId acId} <- withFastStore $ \db -> getPendingContactConnection db userId chatId @@ -1249,7 +1249,7 @@ processChatCommand vr nm = \case withFastStore' $ \db -> deletePendingContactConnection db userId chatId pure $ CRContactConnectionDeleted user conn CTGroup | isNothing scope -> do - gInfo@GroupInfo {membership} <- withFastStore $ \db -> getGroupInfo db vr user chatId + gInfo@GroupInfo {membership} <- withFastStore $ \db -> getGroupInfo db cxt user chatId let isOwner = memberRole' membership == GROwner canDelete = isOwner || not (memberCurrent membership) unless canDelete $ throwChatError $ CEGroupUserRole gInfo GROwner @@ -1273,25 +1273,25 @@ processChatCommand vr nm = \case where getRecipients gInfo | useRelays' gInfo = do - relays <- withFastStore' $ \db -> getGroupRelayMembers db vr user gInfo + relays <- withFastStore' $ \db -> getGroupRelayMembers db cxt user gInfo pure (relays, relays) | otherwise = do - ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo + ms <- withFastStore' $ \db -> getGroupMembers db cxt user gInfo pure (ms, filter memberCurrentOrPending ms) _ -> throwCmdError "not supported" APIClearChat (ChatRef cType chatId scope) -> withUser $ \user@User {userId} -> case cType of CTDirect -> do - ct <- withFastStore $ \db -> getContact db vr user chatId + ct <- withFastStore $ \db -> getContact db cxt user chatId filesInfo <- withFastStore' $ \db -> getContactFileInfo db user ct deleteCIFiles user filesInfo withFastStore' $ \db -> deleteContactCIs db user ct pure $ CRChatCleared user (AChatInfo SCTDirect $ DirectChat ct) CTGroup | isNothing scope -> do - gInfo <- withFastStore $ \db -> getGroupInfo db vr user chatId + gInfo <- withFastStore $ \db -> getGroupInfo db cxt user chatId filesInfo <- withFastStore' $ \db -> getGroupFileInfo db user gInfo deleteCIFiles user filesInfo withFastStore' $ \db -> deleteGroupChatItemsMessages db user gInfo - membersToDelete <- withFastStore' $ \db -> getGroupMembersForExpiration db vr user gInfo + membersToDelete <- withFastStore' $ \db -> getGroupMembersForExpiration db cxt user gInfo forM_ membersToDelete $ \m -> withFastStore' $ \db -> deleteGroupMember db user m pure $ CRChatCleared user (AChatInfo SCTGroup $ GroupChat gInfo Nothing) CTLocal -> do @@ -1352,7 +1352,7 @@ processChatCommand vr nm = \case withFastStore $ \db -> do cReq@UserContactRequest {contactId_} <- getContactRequest db user connReqId ct_ <- forM contactId_ $ \contactId -> do - ct <- getContact db vr user contactId + ct <- getContact db cxt user contactId deleteContact db user ct pure ct liftIO $ deleteContactRequest db user connReqId @@ -1361,7 +1361,7 @@ processChatCommand vr nm = \case pure $ CRContactRequestRejected user cReq ct_ APISendCallInvitation contactId callType -> withUser $ \user -> do -- party initiating call - ct <- withFastStore $ \db -> getContact db vr user contactId + ct <- withFastStore $ \db -> getContact db cxt user contactId assertDirectAllowed user MDSnd ct XCallInv_ if featureAllowed SCFCalls forUser ct then do @@ -1383,7 +1383,7 @@ processChatCommand vr nm = \case else throwCmdError $ "feature not allowed " <> T.unpack (chatFeatureNameText CFCalls) SendCallInvitation cName callType -> withUser $ \user -> do contactId <- withFastStore $ \db -> getContactIdByName db user cName - processChatCommand vr nm $ APISendCallInvitation contactId callType + processChatCommand cxt nm $ APISendCallInvitation contactId callType APIRejectCall contactId -> -- party accepting call withCurrentCall contactId $ \user ct Call {chatItemId, callState} -> case callState of @@ -1450,23 +1450,23 @@ processChatCommand vr nm = \case _ -> Nothing rcvCallInvitation (contactId, callUUID, callTs, peerCallType, sharedKey) = runExceptT . withFastStore $ \db -> do user <- getUserByContactId db contactId - contact <- getContact db vr user contactId + contact <- getContact db cxt user contactId pure RcvCallInvitation {user, contact, callType = peerCallType, sharedKey, callUUID, callTs} APICallStatus contactId receivedStatus -> withCurrentCall contactId $ \user ct call -> updateCallItemStatus user ct call receivedStatus Nothing $> Just call APIUpdateProfile userId profile -> withUserId userId (`updateProfile` profile) APISetContactPrefs contactId prefs' -> withUser $ \user -> do - ct <- withFastStore $ \db -> getContact db vr user contactId + ct <- withFastStore $ \db -> getContact db cxt user contactId updateContactPrefs user ct prefs' APISetContactAlias contactId localAlias -> withUser $ \user@User {userId} -> do ct' <- withFastStore $ \db -> do - ct <- getContact db vr user contactId + ct <- getContact db cxt user contactId liftIO $ updateContactAlias db userId ct localAlias pure $ CRContactAliasUpdated user ct' APISetGroupAlias gId localAlias -> withUser $ \user@User {userId} -> do gInfo' <- withFastStore $ \db -> do - gInfo <- getGroupInfo db vr user gId + gInfo <- getGroupInfo db cxt user gId liftIO $ updateGroupAlias db userId gInfo localAlias pure $ CRGroupAliasUpdated user gInfo' APISetConnectionAlias connId localAlias -> withUser $ \user@User {userId} -> do @@ -1484,23 +1484,23 @@ processChatCommand vr nm = \case APISetChatUIThemes (ChatRef cType chatId scope) uiThemes -> withUser $ \user -> case cType of CTDirect -> do withFastStore $ \db -> do - ct <- getContact db vr user chatId + ct <- getContact db cxt user chatId liftIO $ setContactUIThemes db user ct uiThemes ok user CTGroup | isNothing scope -> do withFastStore $ \db -> do - g <- getGroupInfo db vr user chatId + g <- getGroupInfo db cxt user chatId liftIO $ setGroupUIThemes db user g uiThemes ok user _ -> throwCmdError "not supported" APISetGroupCustomData groupId customData_ -> withUser $ \user -> do withFastStore $ \db -> do - g <- getGroupInfo db vr user groupId + g <- getGroupInfo db cxt user groupId liftIO $ setGroupCustomData db user g customData_ ok user APISetContactCustomData contactId customData_ -> withUser $ \user -> do withFastStore $ \db -> do - ct <- getContact db vr user contactId + ct <- getContact db cxt user contactId liftIO $ setContactCustomData db user ct customData_ ok user APIGetNtfToken -> withUser' $ \_ -> crNtfToken <$> withAgent getNtfToken @@ -1521,7 +1521,7 @@ processChatCommand vr nm = \case let agentConnId = AgentConnId ntfConnId mkNtfConn user connEntity = NtfConn {user, agentConnId, agentDbQueueId = ntfDbQueueId, connEntity, expectedMsg_ = expectedMsgInfo <$> nMsgMeta} getUserByAConnId db agentConnId - $>>= \user -> fmap (mkNtfConn user) . eitherToMaybe <$> runExceptT (getConnectionEntity db vr user agentConnId) + $>>= \user -> fmap (mkNtfConn user) . eitherToMaybe <$> runExceptT (getConnectionEntity db cxt user agentConnId) APIGetConnNtfMessages connMsgs -> withUser $ \_ -> do msgs <- lift $ withAgent' (`getConnectionMessages` connMsgs) let ntfMsgs = L.map receivedMsgInfo msgs @@ -1537,7 +1537,7 @@ processChatCommand vr nm = \case [] -> throwCmdError "no servers" _ -> do srvs' <- mapM aUserServer srvs - processChatCommand vr nm $ APISetUserServers userId $ L.map (updatedServers p srvs') userServers + processChatCommand cxt nm $ APISetUserServers userId $ L.map (updatedServers p srvs') userServers where aUserServer :: AProtoServerWithAuth -> CM (AUserServer p) aUserServer (AProtoServerWithAuth p' srv) = case testEquality p p' of @@ -1546,7 +1546,7 @@ processChatCommand vr nm = \case APITestProtoServer userId srv@(AProtoServerWithAuth _ server) -> withUserId userId $ \user -> lift $ CRServerTestResult user srv <$> withAgent' (\a -> testProtocolServer a nm (aUserId user) server) TestProtoServer srv -> withUser $ \User {userId} -> - processChatCommand vr nm $ APITestProtoServer userId srv + processChatCommand cxt nm $ APITestProtoServer userId srv APITestChatRelay userId address -> withUserId userId $ \user -> do let failAt step e = pure $ CRChatRelayTestResult user Nothing (Just $ RelayTestFailure step e) r <- tryAllErrors $ getShortLinkConnReq nm user address @@ -1566,7 +1566,7 @@ processChatCommand vr nm = \case subMode <- chatReadVar subscriptionMode connId <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq PQSupportOff conn@Connection {connId = testCId} <- withFastStore $ \db -> - createRelayTestConnection db vr user connId ConnPrepared chatV subMode + createRelayTestConnection db cxt user connId ConnPrepared chatV subMode challenge <- drgRandomBytes 32 testVar <- newEmptyTMVarIO let acId = aConnId conn @@ -1586,9 +1586,9 @@ processChatCommand vr nm = \case Right (Just Nothing) -> pure $ CRChatRelayTestResult user (Just relayProfile) Nothing Right (Just (Just failure)) -> pure $ CRChatRelayTestResult user (Just relayProfile) (Just failure) TestChatRelay address -> withUser $ \User {userId} -> - processChatCommand vr nm $ APITestChatRelay userId address + processChatCommand cxt nm $ APITestChatRelay userId address APIAllowRelayGroup groupId -> withUser $ \user -> do - gInfo' <- withStore $ \db -> allowRelayGroup db vr user groupId + gInfo' <- withStore $ \db -> allowRelayGroup db cxt user groupId pure $ CRRelayGroupAllowed user gInfo' GetUserChatRelays -> withUser $ \user -> do srvs <- withFastStore (`getUserServers` user) @@ -1601,7 +1601,7 @@ processChatCommand vr nm = \case [] -> throwCmdError "no relays" _ -> do let relays' = map aUserRelay relays - processChatCommand vr nm $ APISetUserServers userId $ L.map (updatedRelays relays') userServers + processChatCommand cxt nm $ APISetUserServers userId $ L.map (updatedRelays relays') userServers where aUserRelay :: CLINewRelay -> AUserChatRelay aUserRelay CLINewRelay {address, name} = AUCR SDBNew $ newChatRelay (mkRelayProfile name Nothing) [""] address @@ -1630,7 +1630,7 @@ processChatCommand vr nm = \case SetServerOperators operatorsRoles -> do ops <- serverOperators <$> withFastStore getServerOperators ops' <- mapM (updateOp ops) operatorsRoles - processChatCommand vr nm $ APISetServerOperators ops' + processChatCommand cxt nm $ APISetServerOperators ops' where updateOp :: [ServerOperator] -> ServerOperatorRoles -> CM ServerOperator updateOp ops r = @@ -1695,14 +1695,14 @@ processChatCommand vr nm = \case expireChat user globalTTL = do currentTs <- liftIO getCurrentTime case cType of - CTDirect -> expireContactChatItems user vr globalTTL chatId + CTDirect -> expireContactChatItems user cxt globalTTL chatId CTGroup | isNothing scope -> let createdAtCutoff = addUTCTime (-43200 :: NominalDiffTime) currentTs - in expireGroupChatItems user vr globalTTL createdAtCutoff chatId + in expireGroupChatItems user cxt globalTTL createdAtCutoff chatId _ -> throwCmdError "not supported" SetChatTTL chatName newTTL -> withUser' $ \user@User {userId} -> do chatRef <- getChatRef user chatName - processChatCommand vr nm $ APISetChatTTL userId chatRef newTTL + processChatCommand cxt nm $ APISetChatTTL userId chatRef newTTL GetChatTTL chatName -> withUser' $ \user -> do -- TODO [knocking] support scope in CLI apis ChatRef cType chatId _ <- getChatRef user chatName @@ -1722,18 +1722,18 @@ processChatCommand vr nm = \case lift $ setChatItemsExpiration user newTTL ttlCount ok user SetChatItemTTL newTTL_ -> withUser' $ \User {userId} -> do - processChatCommand vr nm $ APISetChatItemTTL userId newTTL_ + processChatCommand cxt nm $ APISetChatItemTTL userId newTTL_ APIGetChatItemTTL userId -> withUserId' userId $ \user -> do ttl <- withFastStore' (`getChatItemTTL` user) pure $ CRChatItemTTL user (Just ttl) GetChatItemTTL -> withUser' $ \User {userId} -> do - processChatCommand vr nm $ APIGetChatItemTTL userId + processChatCommand cxt nm $ APIGetChatItemTTL userId APISetNetworkConfig cfg -> withUser' $ \_ -> lift (withAgent' (`setNetworkConfig` cfg)) >> ok_ APIGetNetworkConfig -> withUser' $ \_ -> CRNetworkConfig <$> lift getNetworkConfig SetNetworkConfig simpleNetCfg -> do cfg <- (`updateNetworkConfig` simpleNetCfg) <$> lift getNetworkConfig - void . processChatCommand vr nm $ APISetNetworkConfig cfg + void . processChatCommand cxt nm $ APISetNetworkConfig cfg pure $ CRNetworkConfig cfg APISetNetworkInfo info -> lift (withAgent' (`setUserNetworkInfo` info)) >> ok_ ReconnectAllServers -> withUser' $ \_ -> lift (withAgent' reconnectAllServers) >> ok_ @@ -1743,7 +1743,7 @@ processChatCommand vr nm = \case APISetChatSettings (ChatRef cType chatId scope) chatSettings -> withUser $ \user -> case cType of CTDirect -> do ct <- withFastStore $ \db -> do - ct <- getContact db vr user chatId + ct <- getContact db cxt user chatId liftIO $ updateContactSettings db user chatId chatSettings pure ct forM_ (contactConnId ct) $ \connId -> @@ -1751,7 +1751,7 @@ processChatCommand vr nm = \case ok user CTGroup | isNothing scope -> do ms <- withFastStore $ \db -> do - gInfo <- getGroupInfo db vr user chatId + gInfo <- getGroupInfo db cxt user chatId ms <- liftIO $ getMembers db gInfo liftIO $ updateGroupSettings db user chatId chatSettings pure ms @@ -1760,19 +1760,19 @@ processChatCommand vr nm = \case ok user where getMembers db gInfo - | useRelays' gInfo = getGroupRelayMembers db vr user gInfo - | otherwise = getGroupMembers db vr user gInfo + | useRelays' gInfo = getGroupRelayMembers db cxt user gInfo + | otherwise = getGroupMembers db cxt user gInfo _ -> throwCmdError "not supported" APISetMemberSettings gId gMemberId settings -> withUser $ \user -> do m <- withFastStore $ \db -> do liftIO $ updateGroupMemberSettings db user gId gMemberId settings - getGroupMember db vr user gId gMemberId + getGroupMember db cxt user gId gMemberId let ntfOn = not (memberBlocked m) toggleNtf m ntfOn ok user APIContactInfo contactId -> withUser $ \user@User {userId} -> do -- [incognito] print user's incognito profile for this contact - ct@Contact {activeConn} <- withFastStore $ \db -> getContact db vr user contactId + ct@Contact {activeConn} <- withFastStore $ \db -> getContact db cxt user contactId incognitoProfile <- case activeConn of Nothing -> pure Nothing Just Connection {customUserProfileId} -> @@ -1780,14 +1780,14 @@ processChatCommand vr nm = \case connectionStats <- mapM (withAgent . flip getConnectionServers) (contactConnId ct) pure $ CRContactInfo user ct connectionStats (fmap fromLocalProfile incognitoProfile) APIContactQueueInfo contactId -> withUser $ \user -> do - ct@Contact {activeConn} <- withFastStore $ \db -> getContact db vr user contactId + ct@Contact {activeConn} <- withFastStore $ \db -> getContact db cxt user contactId case activeConn of Just conn -> getConnQueueInfo user conn Nothing -> throwChatError $ CEContactNotActive ct APIGroupInfo gId -> withUser $ \user -> - CRGroupInfo user <$> withFastStore (\db -> getGroupInfo db vr user gId) + CRGroupInfo user <$> withFastStore (\db -> getGroupInfo db cxt user gId) APIGetUpdatedGroupLinkData groupId -> withUser $ \user -> do - gInfo@GroupInfo {groupProfile = p, groupSummary = GroupSummary {publicMemberCount = localCount}} <- withFastStore $ \db -> getGroupInfo db vr user groupId + gInfo@GroupInfo {groupProfile = p, groupSummary = GroupSummary {publicMemberCount = localCount}} <- withFastStore $ \db -> getGroupInfo db cxt user groupId case p of GroupProfile {publicGroup = Just PublicGroupProfile {groupLink = sLnk}} | useRelays' gInfo -> do (_, cData@(ContactLinkData _ UserContactData {relays = currentRelayLinks})) <- getShortLinkConnReq' nm user sLnk @@ -1801,44 +1801,44 @@ processChatCommand vr nm = \case pure $ CRGroupInfo user gInfo' _ -> throwCmdError "group link data not available" APIGroupMemberInfo gId gMemberId -> withUser $ \user -> do - (g, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db vr user gId gMemberId + (g, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db cxt user gId <*> getGroupMember db cxt user gId gMemberId connectionStats <- mapM (withAgent . flip getConnectionServers) (memberConnId m) pure $ CRGroupMemberInfo user g m connectionStats APIGroupMemberQueueInfo gId gMemberId -> withUser $ \user -> do - GroupMember {activeConn} <- withFastStore $ \db -> getGroupMember db vr user gId gMemberId + GroupMember {activeConn} <- withFastStore $ \db -> getGroupMember db cxt user gId gMemberId case activeConn of Just conn -> getConnQueueInfo user conn Nothing -> throwChatError CEGroupMemberNotActive APISwitchContact contactId -> withUser $ \user -> do - ct <- withFastStore $ \db -> getContact db vr user contactId + ct <- withFastStore $ \db -> getContact db cxt user contactId case contactConnId ct of Just connId -> do connectionStats <- withAgent $ \a -> switchConnectionAsync a "" connId pure $ CRContactSwitchStarted user ct connectionStats Nothing -> throwChatError $ CEContactNotActive ct APISwitchGroupMember gId gMemberId -> withUser $ \user -> do - (g, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db vr user gId gMemberId + (g, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db cxt user gId <*> getGroupMember db cxt user gId gMemberId case memberConnId m of Just connId -> do connectionStats <- withAgent (\a -> switchConnectionAsync a "" connId) pure $ CRGroupMemberSwitchStarted user g m connectionStats _ -> throwChatError CEGroupMemberNotActive APIAbortSwitchContact contactId -> withUser $ \user -> do - ct <- withFastStore $ \db -> getContact db vr user contactId + ct <- withFastStore $ \db -> getContact db cxt user contactId case contactConnId ct of Just connId -> do connectionStats <- withAgent $ \a -> abortConnectionSwitch a connId pure $ CRContactSwitchAborted user ct connectionStats Nothing -> throwChatError $ CEContactNotActive ct APIAbortSwitchGroupMember gId gMemberId -> withUser $ \user -> do - (g, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db vr user gId gMemberId + (g, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db cxt user gId <*> getGroupMember db cxt user gId gMemberId case memberConnId m of Just connId -> do connectionStats <- withAgent $ \a -> abortConnectionSwitch a connId pure $ CRGroupMemberSwitchAborted user g m connectionStats _ -> throwChatError CEGroupMemberNotActive APISyncContactRatchet contactId force -> withUser $ \user -> withContactLock "syncContactRatchet" contactId $ do - ct <- withFastStore $ \db -> getContact db vr user contactId + ct <- withFastStore $ \db -> getContact db cxt user contactId case contactConn ct of Just conn@Connection {pqSupport} -> do cStats@ConnectionStats {ratchetSyncState = rss} <- withAgent $ \a -> synchronizeRatchet a (aConnId conn) pqSupport force @@ -1846,7 +1846,7 @@ processChatCommand vr nm = \case pure $ CRContactRatchetSyncStarted user ct cStats Nothing -> throwChatError $ CEContactNotActive ct APISyncGroupMemberRatchet gId gMemberId force -> withUser $ \user -> withGroupLock "syncGroupMemberRatchet" gId $ do - (g, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db vr user gId gMemberId + (g, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db cxt user gId <*> getGroupMember db cxt user gId gMemberId case memberConnId m of Just connId -> do cStats@ConnectionStats {ratchetSyncState = rss} <- withAgent $ \a -> synchronizeRatchet a connId PQSupportOff force @@ -1855,7 +1855,7 @@ processChatCommand vr nm = \case pure $ CRGroupMemberRatchetSyncStarted user g' m' cStats _ -> throwChatError CEGroupMemberNotActive APIGetContactCode contactId -> withUser $ \user -> do - ct@Contact {activeConn} <- withFastStore $ \db -> getContact db vr user contactId + ct@Contact {activeConn} <- withFastStore $ \db -> getContact db cxt user contactId case activeConn of Just conn@Connection {connId} -> do code <- getConnectionCode $ aConnId conn @@ -1869,7 +1869,7 @@ processChatCommand vr nm = \case pure $ CRContactCode user ct' code Nothing -> throwChatError $ CEContactNotActive ct APIGetGroupMemberCode gId gMemberId -> withUser $ \user -> do - (g, m@GroupMember {activeConn}) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db vr user gId gMemberId + (g, m@GroupMember {activeConn}) <- withFastStore $ \db -> (,) <$> getGroupInfo db cxt user gId <*> getGroupMember db cxt user gId gMemberId case activeConn of Just conn@Connection {connId} -> do code <- getConnectionCode $ aConnId conn @@ -1883,24 +1883,24 @@ processChatCommand vr nm = \case pure $ CRGroupMemberCode user g m' code _ -> throwChatError CEGroupMemberNotActive APIVerifyContact contactId code -> withUser $ \user -> do - ct@Contact {activeConn} <- withFastStore $ \db -> getContact db vr user contactId + ct@Contact {activeConn} <- withFastStore $ \db -> getContact db cxt user contactId case activeConn of Just conn -> verifyConnectionCode user conn code Nothing -> throwChatError $ CEContactNotActive ct APIVerifyGroupMember gId gMemberId code -> withUser $ \user -> do - GroupMember {activeConn} <- withFastStore $ \db -> getGroupMember db vr user gId gMemberId + GroupMember {activeConn} <- withFastStore $ \db -> getGroupMember db cxt user gId gMemberId case activeConn of Just conn -> verifyConnectionCode user conn code _ -> throwChatError CEGroupMemberNotActive APIEnableContact contactId -> withUser $ \user -> do - ct@Contact {activeConn} <- withFastStore $ \db -> getContact db vr user contactId + ct@Contact {activeConn} <- withFastStore $ \db -> getContact db cxt user contactId case activeConn of Just conn -> do withFastStore' $ \db -> setAuthErrCounter db user conn 0 ok user Nothing -> throwChatError $ CEContactNotActive ct APIEnableGroupMember gId gMemberId -> withUser $ \user -> do - GroupMember {activeConn} <- withFastStore $ \db -> getGroupMember db vr user gId gMemberId + GroupMember {activeConn} <- withFastStore $ \db -> getGroupMember db cxt user gId gMemberId case activeConn of Just conn -> do withFastStore' $ \db -> setAuthErrCounter db user conn 0 @@ -1910,16 +1910,16 @@ processChatCommand vr nm = \case SetSendReceipts cName rcptsOn_ -> updateChatSettings cName (\cs -> cs {sendRcpts = rcptsOn_}) SetShowMemberMessages gName mName showMessages -> withUser $ \user -> do (gId, mId) <- getGroupAndMemberId user gName mName - gInfo <- withFastStore $ \db -> getGroupInfo db vr user gId - m <- withFastStore $ \db -> getGroupMember db vr user gId mId + gInfo <- withFastStore $ \db -> getGroupInfo db cxt user gId + m <- withFastStore $ \db -> getGroupMember db cxt user gId mId let GroupInfo {membership = GroupMember {memberRole = membershipRole}} = gInfo when (membershipRole >= GRModerator) $ throwChatError $ CECantBlockMemberForSelf gInfo m showMessages let settings = (memberSettings m) {showMessages} - processChatCommand vr nm $ APISetMemberSettings gId mId settings + processChatCommand cxt nm $ APISetMemberSettings gId mId settings ContactInfo cName -> withContactName cName APIContactInfo ShowGroupInfo gName -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName - processChatCommand vr nm $ APIGroupInfo groupId + processChatCommand cxt nm $ APIGroupInfo groupId GroupMemberInfo gName mName -> withMemberName gName mName APIGroupMemberInfo ContactQueueInfo cName -> withContactName cName APIContactQueueInfo GroupMemberQueueInfo gName mName -> withMemberName gName mName APIGroupMemberQueueInfo @@ -1950,7 +1950,7 @@ processChatCommand vr nm = \case conn <- withFastStore' $ \db -> createDirectConnection db user connId ccLink' Nothing ConnNew incognitoProfile subMode initialChatVersion PQSupportOn pure $ CRInvitation user ccLink' conn AddContact incognito -> withUser $ \User {userId} -> - processChatCommand vr nm $ APIAddContact userId incognito + processChatCommand cxt nm $ APIAddContact userId incognito APISetConnectionIncognito connId incognito -> withUser $ \user@User {userId} -> do conn <- withFastStore $ \db -> getPendingContactConnection db userId connId let PendingContactConnection {pccConnStatus, customUserProfileId} = conn @@ -2008,7 +2008,7 @@ processChatCommand vr nm = \case groupPreferences = maybe defaultBusinessGroupPrefs businessGroupPrefs preferences groupProfile = businessGroupProfile profile groupPreferences gVar <- asks random - (gInfo, hostMember_) <- withStore $ \db -> createPreparedGroup db gVar vr user groupProfile True ccLink welcomeSharedMsgId False GRMember Nothing + (gInfo, hostMember_) <- withStore $ \db -> createPreparedGroup db gVar cxt user groupProfile True ccLink welcomeSharedMsgId False GRMember Nothing hostMember <- maybe (throwCmdError "no host member") pure hostMember_ void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart) let cd = CDGroupRcv gInfo Nothing hostMember @@ -2021,7 +2021,7 @@ processChatCommand vr nm = \case _ -> Chat cInfo [] emptyChatStats pure $ CRNewPreparedChat user $ AChat SCTGroup chat ACCL _ (CCLink cReq _) -> do - ct <- withStore $ \db -> createPreparedContact db vr user profile accLink welcomeSharedMsgId + ct <- withStore $ \db -> createPreparedContact db cxt user profile accLink welcomeSharedMsgId void $ createChatItem user (CDDirectSnd ct) False CIChatBanner Nothing (Just epochStart) let cd = CDDirectRcv ct createItem sharedMsgId content = createChatItem user cd False content sharedMsgId Nothing @@ -2040,7 +2040,7 @@ processChatCommand vr nm = \case let useRelays = not direct subRole <- if useRelays then asks $ channelSubscriberRole . config else pure GRMember gVar <- asks random - (gInfo, hostMember_) <- withStore $ \db -> createPreparedGroup db gVar vr user gp False ccLink welcomeSharedMsgId useRelays subRole publicMemberCount_ + (gInfo, hostMember_) <- withStore $ \db -> createPreparedGroup db gVar cxt user gp False ccLink welcomeSharedMsgId useRelays subRole publicMemberCount_ void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart) let cd = maybe (CDChannelRcv gInfo Nothing) (CDGroupRcv gInfo Nothing) hostMember_ cInfo = GroupChat gInfo Nothing @@ -2051,40 +2051,40 @@ processChatCommand vr nm = \case _ -> Chat cInfo [] emptyChatStats pure $ CRNewPreparedChat user $ AChat SCTGroup chat APIChangePreparedContactUser contactId newUserId -> withUser $ \user -> do - ct@Contact {preparedContact} <- withFastStore $ \db -> getContact db vr user contactId + ct@Contact {preparedContact} <- withFastStore $ \db -> getContact db cxt user contactId when (isNothing preparedContact) $ throwCmdError "contact doesn't have link to connect" when (isJust $ contactConn ct) $ throwCmdError "contact already has connection" newUser <- privateGetUser newUserId - ct' <- withFastStore $ \db -> updatePreparedContactUser db vr user ct newUser + ct' <- withFastStore $ \db -> updatePreparedContactUser db cxt user ct newUser -- create changed feature items (new user may have different preferences) lift $ createContactChangedFeatureItems user ct ct' pure $ CRContactUserChanged user ct newUser ct' APIChangePreparedGroupUser groupId newUserId -> withUser $ \user -> do - gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId + gInfo <- withFastStore $ \db -> getGroupInfo db cxt user groupId when (isNothing $ preparedGroup gInfo) $ throwCmdError "group doesn't have link to connect" hostMember_ <- if useRelays' gInfo then pure Nothing else do - hostMember <- withFastStore $ \db -> getHostMember db vr user groupId + hostMember <- withFastStore $ \db -> getHostMember db cxt user groupId when (isJust $ memberConn hostMember) $ throwCmdError "host member already has connection" pure $ Just hostMember newUser <- privateGetUser newUserId - gInfo' <- withFastStore $ \db -> updatePreparedGroupUser db vr user gInfo hostMember_ newUser + gInfo' <- withFastStore $ \db -> updatePreparedGroupUser db cxt user gInfo hostMember_ newUser pure $ CRGroupUserChanged user gInfo newUser gInfo' APIConnectPreparedContact contactId incognito msgContent_ -> withUser $ \user -> do - ct@Contact {preparedContact} <- withFastStore $ \db -> getContact db vr user contactId + ct@Contact {preparedContact} <- withFastStore $ \db -> getContact db cxt user contactId case preparedContact of Nothing -> throwCmdError "contact doesn't have link to connect" Just PreparedContact {connLinkToConnect = ACCL SCMInvitation ccLink} -> do (_, customUserProfile) <- connectViaInvitation user incognito ccLink (Just contactId) `catchAllErrors` \e -> do -- get updated contact, in case connection was started - in UI it would lock ability to change -- user or incognito profile for contact, in case server received request while client got network error - ct' <- withFastStore $ \db -> getContact db vr user contactId + ct' <- withFastStore $ \db -> getContact db cxt user contactId toView $ CEvtChatInfoUpdated user (AChatInfo SCTDirect $ DirectChat ct') throwError e -- get updated contact with connection - ct' <- withFastStore $ \db -> getContact db vr user contactId + ct' <- withFastStore $ \db -> getContact db cxt user contactId -- create changed feature items (connecting incognito sends default preferences, instead of user preferences) lift . when incognito $ createContactChangedFeatureItems user ct ct' forM_ msgContent_ $ \mc -> do @@ -2103,13 +2103,13 @@ processChatCommand vr nm = \case r <- connectViaContact user (Just $ PCEContact ct) incognito ccLink welcomeSharedMsgId msg_ `catchAllErrors` \e -> do -- get updated contact, in case connection was started - in UI it would lock ability to change -- user or incognito profile for contact, in case server received request while client got network error - ct' <- withFastStore $ \db -> getContact db vr user contactId + ct' <- withFastStore $ \db -> getContact db cxt user contactId toView $ CEvtChatInfoUpdated user (AChatInfo SCTDirect $ DirectChat ct') throwError e case r of CVRSentInvitation _conn customUserProfile -> do -- get updated contact with connection - ct' <- withFastStore $ \db -> getContact db vr user contactId + ct' <- withFastStore $ \db -> getContact db cxt user contactId -- create changed feature items (connecting incognito sends default preferences, instead of user preferences) lift . when incognito $ createContactChangedFeatureItems user ct ct' forM_ msg_ $ \(sharedMsgId, mc) -> do @@ -2118,7 +2118,7 @@ processChatCommand vr nm = \case pure $ CRStartedConnectionToContact user ct' customUserProfile CVRConnectedContact ct' -> pure $ CRContactAlreadyExists user ct' APIConnectPreparedGroup {groupId, incognito, ownerContact, msgContent_} -> withUser $ \user -> do - gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId + gInfo <- withFastStore $ \db -> getGroupInfo db cxt user groupId case gInfo of GroupInfo {preparedGroup = Nothing} -> throwCmdError "group doesn't have link to connect" GroupInfo {useRelays = BoolDef True, preparedGroup = Just PreparedGroup {connLinkToConnect}} -> do @@ -2141,14 +2141,14 @@ processChatCommand vr nm = \case gVar <- asks random (_, memberPrivKey) <- liftIO $ atomically $ C.generateKeyPair gVar gInfo' <- withFastStore $ \db -> do - gInfo' <- updatePreparedRelayedGroup db vr user gInfo mainCReq cReqHash incognitoProfile rootKey memberPrivKey publicMemberCount_ + gInfo' <- updatePreparedRelayedGroup db cxt user gInfo mainCReq cReqHash incognitoProfile rootKey memberPrivKey publicMemberCount_ -- Pre-emptively create owner members with trusted keys from link data forM_ owners $ \OwnerAuth {ownerId, ownerKey} -> do let ctId_ = case ownerContact of Just GroupOwnerContact {contactId, memberId} | memberId == MemberId ownerId -> Just contactId _ -> Nothing - void $ createLinkOwnerMember db vr user gInfo' ctId_ (MemberId ownerId) ownerKey + void $ createLinkOwnerMember db cxt user gInfo' ctId_ (MemberId ownerId) ownerKey pure gInfo' rs <- withGroupLock "connectPreparedGroup" groupId $ mapConcurrently (connectToRelay user gInfo') relays @@ -2166,7 +2166,7 @@ processChatCommand vr nm = \case else do gInfo'' <- withFastStore $ \db -> do liftIO $ setPreparedGroupStartedConnection db groupId - getGroupInfo db vr user groupId + getGroupInfo db cxt user groupId -- Async retry failed relays with temporary errors let retryable = [(l, m) | r@(l, m, _) <- failed, isTempErr r] void $ mapConcurrently (uncurry $ retryRelayConnectionAsync gInfo') retryable @@ -2186,7 +2186,7 @@ processChatCommand vr nm = \case newConnIds <- getAgentConnShortLinkAsync user CFGetRelayDataJoin Nothing relayLink withStore' $ \db -> createRelayMemberConnectionAsync db user gInfo' relayMember relayLink newConnIds subMode GroupInfo {preparedGroup = Just PreparedGroup {connLinkToConnect, welcomeSharedMsgId, requestSharedMsgId}} -> do - hostMember <- withFastStore $ \db -> getHostMember db vr user groupId + hostMember <- withFastStore $ \db -> getHostMember db cxt user groupId msg_ <- forM msgContent_ $ \mc -> case requestSharedMsgId of Just smId -> pure (smId, mc) Nothing -> do @@ -2196,7 +2196,7 @@ processChatCommand vr nm = \case r <- connectViaContact user (Just $ PCEGroup gInfo hostMember) incognito connLinkToConnect welcomeSharedMsgId msg_ `catchAllErrors` \e -> do -- get updated group info, in case connection was started (connLinkPreparedConnection) - in UI it would lock ability to change -- user or incognito profile for group or business chat, in case server received request while client got network error - gInfo' <- withFastStore $ \db -> getGroupInfo db vr user groupId + gInfo' <- withFastStore $ \db -> getGroupInfo db cxt user groupId toView $ CEvtChatInfoUpdated user (AChatInfo SCTGroup $ GroupChat gInfo' Nothing) throwError e case r of @@ -2204,7 +2204,7 @@ processChatCommand vr nm = \case -- get updated group info (connLinkStartedConnection and incognito membership) gInfo' <- withFastStore $ \db -> do liftIO $ setPreparedGroupStartedConnection db groupId - getGroupInfo db vr user groupId + getGroupInfo db cxt user groupId forM_ msg_ $ \(sharedMsgId, mc) -> do ci <- createChatItem user (CDGroupSnd gInfo' Nothing) False (CISndMsgContent mc) (Just sharedMsgId) Nothing toView $ CEvtNewChatItems user [ci] @@ -2230,7 +2230,7 @@ processChatCommand vr nm = \case connectWithPlan user incognito ccLink plan Connect _ Nothing -> throwChatError CEInvalidConnReq APIConnectContactViaAddress userId incognito contactId -> withUserId userId $ \user -> do - ct@Contact {profile = LocalProfile {contactLink}} <- withFastStore $ \db -> getContact db vr user contactId + ct@Contact {profile = LocalProfile {contactLink}} <- withFastStore $ \db -> getContact db cxt user contactId ccLink <- case contactLink of Just (CLFull cReq) -> pure $ CCLink cReq Nothing Just (CLShort sLnk) -> do @@ -2240,7 +2240,7 @@ processChatCommand vr nm = \case connectContactViaAddress user incognito ct ccLink `catchAllErrors` \e -> do -- get updated contact, in case connection was started - in UI it would lock ability to change incognito choice -- on next connection attempt, in case server received request while client got network error - ct' <- withFastStore $ \db -> getContact db vr user contactId + ct' <- withFastStore $ \db -> getContact db cxt user contactId toView $ CEvtChatInfoUpdated user (AChatInfo SCTDirect $ DirectChat ct') throwError e ConnectSimplex incognito -> withUser $ \user -> do @@ -2249,9 +2249,9 @@ processChatCommand vr nm = \case DeleteContact cName cdm -> withContactName cName $ \ctId -> APIDeleteChat (ChatRef CTDirect ctId Nothing) cdm ClearContact cName -> withContactName cName $ \chatId -> APIClearChat $ ChatRef CTDirect chatId Nothing APIListContacts userId -> withUserId userId $ \user -> - CRContactsList user <$> withFastStore' (\db -> getUserContacts db vr user) + CRContactsList user <$> withFastStore' (\db -> getUserContacts db cxt user) ListContacts -> withUser $ \User {userId} -> - processChatCommand vr nm $ APIListContacts userId + processChatCommand cxt nm $ APIListContacts userId APICreateMyAddress userId -> withUserId userId $ \user@User {userChatRelay} -> do withFastStore' (\db -> runExceptT $ getUserAddress db user) >>= \case Left SEUserContactLinkNotFound -> pure () @@ -2270,9 +2270,9 @@ processChatCommand vr nm = \case withFastStore $ \db -> createUserContactLink db user connId ccLink'' subMode pure $ CRUserContactLinkCreated user ccLink'' CreateMyAddress -> withUser $ \User {userId} -> - processChatCommand vr nm $ APICreateMyAddress userId + processChatCommand cxt nm $ APICreateMyAddress userId APIDeleteMyAddress userId -> withUserId userId $ \user@User {profile = p} -> do - conn <- withFastStore $ \db -> getUserAddressConnection db vr user + conn <- withFastStore $ \db -> getUserAddressConnection db cxt user withChatLock "deleteMyAddress" $ do deleteAgentConnectionAsync $ aConnId conn withFastStore' (`deleteUserAddress` user) @@ -2283,11 +2283,11 @@ processChatCommand vr nm = \case _ -> user pure $ CRUserContactLinkDeleted user' DeleteMyAddress -> withUser $ \User {userId} -> - processChatCommand vr nm $ APIDeleteMyAddress userId + processChatCommand cxt nm $ APIDeleteMyAddress userId APIShowMyAddress userId -> withUserId' userId $ \user -> CRUserContactLink user <$> withFastStore (`getUserAddress` user) ShowMyAddress -> withUser' $ \User {userId} -> - processChatCommand vr nm $ APIShowMyAddress userId + processChatCommand cxt nm $ APIShowMyAddress userId APIAddMyAddressShortLink userId -> withUserId' userId $ \user -> CRUserContactLink user <$> (withFastStore (`getUserAddress` user) >>= setMyAddressData user) APISetProfileAddress userId False -> withUserId userId $ \user@User {profile = p} -> do @@ -2299,7 +2299,7 @@ processChatCommand vr nm = \case let p' = (fromLocalProfile p :: Profile) {contactLink = Just $ profileContactLink ucl} updateProfile_ user p' True $ withFastStore' $ \db -> setUserProfileContactLink db user $ Just ucl SetProfileAddress onOff -> withUser $ \User {userId} -> - processChatCommand vr nm $ APISetProfileAddress userId onOff + processChatCommand cxt nm $ APISetProfileAddress userId onOff APISetAddressSettings userId settings@AddressSettings {businessAddress, autoAccept} -> withUserId userId $ \user -> do ucl@UserContactLink {userContactLinkId, shortLinkDataSet, addressSettings} <- withFastStore (`getUserAddress` user) forM_ autoAccept $ \AutoAccept {acceptIncognito} -> do @@ -2313,43 +2313,43 @@ processChatCommand vr nm = \case withFastStore' $ \db -> updateUserAddressSettings db userContactLinkId settings pure $ CRUserContactLinkUpdated user ucl'' SetAddressSettings settings -> withUser $ \User {userId} -> - processChatCommand vr nm $ APISetAddressSettings userId settings + processChatCommand cxt nm $ APISetAddressSettings userId settings AcceptContact incognito cName -> withUser $ \User {userId} -> do connReqId <- withFastStore $ \db -> getContactRequestIdByName db userId cName - processChatCommand vr nm $ APIAcceptContact incognito connReqId + processChatCommand cxt nm $ APIAcceptContact incognito connReqId RejectContact cName -> withUser $ \User {userId} -> do connReqId <- withFastStore $ \db -> getContactRequestIdByName db userId cName - processChatCommand vr nm $ APIRejectContact connReqId + processChatCommand cxt nm $ APIRejectContact connReqId ForwardMessage toChatName fromContactName forwardedMsg -> withUser $ \user -> do contactId <- withFastStore $ \db -> getContactIdByName db user fromContactName forwardedItemId <- withFastStore $ \db -> getDirectChatItemIdByText' db user contactId forwardedMsg toChatRef <- getChatRef user toChatName asGroup <- getSendAsGroup user toChatRef - processChatCommand vr nm $ APIForwardChatItems toChatRef asGroup (ChatRef CTDirect contactId Nothing) (forwardedItemId :| []) Nothing + processChatCommand cxt nm $ APIForwardChatItems toChatRef asGroup (ChatRef CTDirect contactId Nothing) (forwardedItemId :| []) Nothing ForwardGroupMessage toChatName fromGroupName fromMemberName_ forwardedMsg -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user fromGroupName forwardedItemId <- withFastStore $ \db -> getGroupChatItemIdByText db user groupId fromMemberName_ forwardedMsg toChatRef <- getChatRef user toChatName asGroup <- getSendAsGroup user toChatRef - processChatCommand vr nm $ APIForwardChatItems toChatRef asGroup (ChatRef CTGroup groupId Nothing) (forwardedItemId :| []) Nothing + processChatCommand cxt nm $ APIForwardChatItems toChatRef asGroup (ChatRef CTGroup groupId Nothing) (forwardedItemId :| []) Nothing ForwardLocalMessage toChatName forwardedMsg -> withUser $ \user -> do folderId <- withFastStore (`getUserNoteFolderId` user) forwardedItemId <- withFastStore $ \db -> getLocalChatItemIdByText' db user folderId forwardedMsg toChatRef <- getChatRef user toChatName asGroup <- getSendAsGroup user toChatRef - processChatCommand vr nm $ APIForwardChatItems toChatRef asGroup (ChatRef CTLocal folderId Nothing) (forwardedItemId :| []) Nothing + processChatCommand cxt nm $ APIForwardChatItems toChatRef asGroup (ChatRef CTLocal folderId Nothing) (forwardedItemId :| []) Nothing SharePublicGroup shareGroupName toChatName -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user shareGroupName toChatRef <- getChatRef user toChatName sendRef <- case toChatRef of ChatRef CTDirect ctId _ -> pure $ SRDirect ctId ChatRef CTGroup gId scope_ -> do - gInfo <- withFastStore $ \db -> getGroupInfo db vr user gId + gInfo <- withFastStore $ \db -> getGroupInfo db cxt user gId pure $ SRGroup gId scope_ (useRelays' gInfo) _ -> throwCmdError "unsupported share target" - processChatCommand vr nm (APIShareChatMsgContent (ChatRef CTGroup groupId Nothing) sendRef) >>= \case + processChatCommand cxt nm (APIShareChatMsgContent (ChatRef CTGroup groupId Nothing) sendRef) >>= \case CRChatMsgContent _ mc -> - processChatCommand vr nm $ APISendMessages sendRef False Nothing [composedMessage Nothing mc] + processChatCommand cxt nm $ APISendMessages sendRef False Nothing [composedMessage Nothing mc] r -> pure r SendMessage sendName msg -> withUser $ \user -> do let mc = MCText msg @@ -2358,57 +2358,57 @@ processChatCommand vr nm = \case withFastStore' (\db -> runExceptT $ getContactIdByName db user name) >>= \case Right ctId -> do let sendRef = SRDirect ctId - processChatCommand vr nm $ APISendMessages sendRef False Nothing [composedMessage Nothing mc] + processChatCommand cxt nm $ APISendMessages sendRef False Nothing [composedMessage Nothing mc] Left _ -> - withFastStore' (\db -> runExceptT $ getActiveMembersByName db vr user name) >>= \case + withFastStore' (\db -> runExceptT $ getActiveMembersByName db cxt user name) >>= \case Right [(gInfo, member)] -> do let GroupInfo {localDisplayName = gName} = gInfo GroupMember {localDisplayName = mName} = member - processChatCommand vr nm $ SendMemberContactMessage gName mName msg + processChatCommand cxt nm $ SendMemberContactMessage gName mName msg Right (suspectedMember : _) -> throwChatError $ CEContactNotFound name (Just suspectedMember) _ -> throwChatError $ CEContactNotFound name Nothing SNGroup name scope_ -> do (gInfo, cScope_, mentions) <- withFastStore $ \db -> do - gInfo <- getGroupInfoByName db vr user name + gInfo <- getGroupInfoByName db cxt user name let gId = groupId' gInfo cScope_ <- forM scope_ $ \(GSNMemberSupport mName_) -> GCSMemberSupport <$> mapM (getGroupMemberIdByName db user gId) mName_ (gInfo, cScope_,) <$> liftIO (getMessageMentions db user gId msg) let sendRef = SRGroup (groupId' gInfo) cScope_ (sendAsGroup' gInfo cScope_) - processChatCommand vr nm $ APISendMessages sendRef False Nothing [ComposedMessage Nothing Nothing mc mentions] + processChatCommand cxt nm $ APISendMessages sendRef False Nothing [ComposedMessage Nothing Nothing mc mentions] SNLocal -> do folderId <- withFastStore (`getUserNoteFolderId` user) - processChatCommand vr nm $ APICreateChatItems folderId [composedMessage Nothing mc] + processChatCommand cxt nm $ APICreateChatItems folderId [composedMessage Nothing mc] SendMemberContactMessage gName mName msg -> withUser $ \user -> do (gId, mId) <- getGroupAndMemberId user gName mName - m <- withFastStore $ \db -> getGroupMember db vr user gId mId + m <- withFastStore $ \db -> getGroupMember db cxt user gId mId let mc = MCText msg case memberContactId m of Nothing -> do - g <- withFastStore $ \db -> getGroupInfo db vr user gId + g <- withFastStore $ \db -> getGroupInfo db cxt user gId unless (groupFeatureUserAllowed SGFDirectMessages g) $ throwCmdError "direct messages not allowed" toView $ CEvtNoMemberContactCreating user g m - processChatCommand vr nm (APICreateMemberContact gId mId) >>= \case + processChatCommand cxt nm (APICreateMemberContact gId mId) >>= \case CRNewMemberContact _ ct@Contact {contactId} _ _ -> do toViewTE $ TENewMemberContact user ct g m - processChatCommand vr nm $ APISendMemberContactInvitation contactId (Just mc) + processChatCommand cxt nm $ APISendMemberContactInvitation contactId (Just mc) cr -> pure cr Just ctId -> do let sendRef = SRDirect ctId - processChatCommand vr nm $ APISendMessages sendRef False Nothing [composedMessage Nothing mc] + processChatCommand cxt nm $ APISendMessages sendRef False Nothing [composedMessage Nothing mc] AcceptMemberContact cName -> withUser $ \user -> do contactId <- withFastStore $ \db -> getContactIdByName db user cName - processChatCommand vr nm $ APIAcceptMemberContact contactId + processChatCommand cxt nm $ APIAcceptMemberContact contactId SendLiveMessage chatName msg -> withUser $ \user -> do (chatRef, mentions) <- getChatRefAndMentions user chatName msg withSendRef user chatRef $ \sendRef -> do let mc = MCText msg - processChatCommand vr nm $ APISendMessages sendRef True Nothing [ComposedMessage Nothing Nothing mc mentions] + processChatCommand cxt nm $ APISendMessages sendRef True Nothing [ComposedMessage Nothing Nothing mc mentions] SendMessageBroadcast mc -> withUser $ \user -> do - contacts <- withFastStore' $ \db -> getUserContacts db vr user + contacts <- withFastStore' $ \db -> getUserContacts db cxt user withChatLock "sendMessageBroadcast" $ do let ctConns_ = L.nonEmpty $ foldr addContactConn [] contacts case ctConns_ of @@ -2451,28 +2451,28 @@ processChatCommand vr nm = \case contactId <- withFastStore $ \db -> getContactIdByName db user cName quotedItemId <- withFastStore $ \db -> getDirectChatItemIdByText db userId contactId msgDir quotedMsg let mc = MCText msg - processChatCommand vr nm $ APISendMessages (SRDirect contactId) False Nothing [ComposedMessage Nothing (Just quotedItemId) mc M.empty] + processChatCommand cxt nm $ APISendMessages (SRDirect contactId) False Nothing [ComposedMessage Nothing (Just quotedItemId) mc M.empty] DeleteMessage chatName deletedMsg -> withUser $ \user -> do chatRef <- getChatRef user chatName deletedItemId <- getSentChatItemIdByText user chatRef deletedMsg - processChatCommand vr nm $ APIDeleteChatItem chatRef (deletedItemId :| []) CIDMBroadcast + processChatCommand cxt nm $ APIDeleteChatItem chatRef (deletedItemId :| []) CIDMBroadcast DeleteMemberMessage gName mName deletedMsg -> withUser $ \user -> do gId <- withFastStore $ \db -> getGroupIdByName db user gName deletedItemId <- withFastStore $ \db -> getGroupChatItemIdByText db user gId (Just mName) deletedMsg - processChatCommand vr nm $ APIDeleteMemberChatItem gId (deletedItemId :| []) + processChatCommand cxt nm $ APIDeleteMemberChatItem gId (deletedItemId :| []) EditMessage chatName editedMsg msg -> withUser $ \user -> do (chatRef, mentions) <- getChatRefAndMentions user chatName msg editedItemId <- getSentChatItemIdByText user chatRef editedMsg let mc = MCText msg - processChatCommand vr nm $ APIUpdateChatItem chatRef editedItemId False $ UpdatedMessage mc mentions + processChatCommand cxt nm $ APIUpdateChatItem chatRef editedItemId False $ UpdatedMessage mc mentions UpdateLiveMessage chatName chatItemId live msg -> withUser $ \user -> do (chatRef, mentions) <- getChatRefAndMentions user chatName msg let mc = MCText msg - processChatCommand vr nm $ APIUpdateChatItem chatRef chatItemId live $ UpdatedMessage mc mentions + processChatCommand cxt nm $ APIUpdateChatItem chatRef chatItemId live $ UpdatedMessage mc mentions ReactToMessage add reaction chatName msg -> withUser $ \user -> do chatRef <- getChatRef user chatName chatItemId <- getChatItemIdByText user chatRef msg - processChatCommand vr nm $ APIChatItemReaction chatRef chatItemId add reaction + processChatCommand cxt nm $ APIChatItemReaction chatRef chatItemId add reaction APINewGroup userId incognito gProfile -> withUserId userId $ \user -> do g <- asks random memberId <- liftIO $ MemberId <$> encodedRandomBytes g 12 @@ -2480,7 +2480,7 @@ processChatCommand vr nm = \case createNewGroupItems user gInfo pure $ CRGroupCreated user gInfo NewGroup incognito gProfile -> withUser $ \User {userId} -> - processChatCommand vr nm $ APINewGroup userId incognito gProfile + processChatCommand cxt nm $ APINewGroup userId incognito gProfile APINewPublicGroup userId incognito relayIds groupProfile -> withUserId userId $ \user -> do (gProfile', memberId, groupKeys, setupLink) <- prepareGroupLink user gInfo <- newGroup user incognito gProfile' True memberId (Just groupKeys) (Just 1) @@ -2540,16 +2540,16 @@ processChatCommand vr nm = \case pure (gLink, results) pure (groupProfile', memberId, groupKeys, setupLink) NewPublicGroup incognito relayIds gProfile -> withUser $ \User {userId} -> - processChatCommand vr nm $ APINewPublicGroup userId incognito relayIds gProfile + processChatCommand cxt nm $ APINewPublicGroup userId incognito relayIds gProfile APIGetGroupRelays groupId -> withUser $ \user -> do (gInfo, relays) <- withFastStore $ \db -> do - gInfo <- getGroupInfo db vr user groupId + gInfo <- getGroupInfo db cxt user groupId relays <- liftIO $ getGroupRelays db gInfo pure (gInfo, relays) pure $ CRGroupRelays user gInfo relays APIAddGroupRelays groupId relayIds -> withUser $ \user -> withGroupLock "addGroupRelays" groupId $ do (gInfo, existingRelays) <- withFastStore $ \db -> do - gi <- getGroupInfo db vr user groupId + gi <- getGroupInfo db cxt user groupId rs <- liftIO $ getGroupRelays db gi pure (gi, rs) assertUserGroupRole gInfo GROwner @@ -2580,7 +2580,7 @@ processChatCommand vr nm = \case _ -> False APIAddMember groupId contactId memRole -> withUser $ \user -> withGroupLock "addMember" groupId $ do -- TODO for large groups: no need to load all members to determine if contact is a member - (group, contact) <- withFastStore $ \db -> (,) <$> getGroup db vr user groupId <*> getContact db vr user contactId + (group, contact) <- withFastStore $ \db -> (,) <$> getGroup db cxt user groupId <*> getContact db cxt user contactId let Group gInfo members = group Contact {localDisplayName = cName} = contact when (useRelays' gInfo) $ throwCmdError "can't invite contact to channel" @@ -2612,8 +2612,8 @@ processChatCommand vr nm = \case APIJoinGroup groupId enableNtfs -> withUser $ \user@User {userId} -> do withGroupLock "joinGroup" groupId $ do (invitation, ct) <- withFastStore $ \db -> do - inv@ReceivedGroupInvitation {fromMember} <- getGroupInvitation db vr user groupId - (inv,) <$> getContactViaMember db vr user fromMember + inv@ReceivedGroupInvitation {fromMember} <- getGroupInvitation db cxt user groupId + (inv,) <$> getContactViaMember db cxt user fromMember let ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership, chatSettings}} = invitation GroupMember {memberId = membershipMemId} = membership Contact {activeConn} = ct @@ -2624,7 +2624,7 @@ processChatCommand vr nm = \case agentConnId <- case memberConn fromMember of Nothing -> do agentConnId <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True connRequest PQSupportOff - let chatV = vr `peerConnChatVersion` peerChatVRange + let chatV = vr cxt `peerConnChatVersion` peerChatVRange void $ withFastStore' $ \db -> createMemberConnection db userId fromMember agentConnId chatV peerChatVRange subMode pure agentConnId Just conn -> pure $ aConnId conn @@ -2643,7 +2643,7 @@ processChatCommand vr nm = \case pure $ CRUserAcceptedGroupSent user g {membership = membership {memberStatus = GSMemAccepted}} Nothing Nothing -> throwChatError $ CEContactNotActive ct APIAcceptMember groupId gmId role -> withUser $ \user@User {userId} -> do - (gInfo, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user groupId <*> getGroupMemberById db vr user gmId + (gInfo, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db cxt user groupId <*> getGroupMemberById db cxt user gmId assertUserGroupRole gInfo $ max GRModerator role case memberStatus m of GSMemPendingApproval | memberCategory m == GCInviteeMember -> do -- only host can approve @@ -2652,14 +2652,14 @@ processChatCommand vr nm = \case Just mConn -> case memberAdmission >>= review of Just MCAll -> do - introduceToModerators vr user gInfo m + introduceToModerators cxt user gInfo m withFastStore' $ \db -> updateGroupMemberStatus db userId m GSMemPendingReview let m' = m {memberStatus = GSMemPendingReview} pure $ CRMemberAccepted user gInfo m' Nothing -> do let msg = XGrpLinkAcpt GAAccepted role (memberId' m) void $ sendDirectMemberMessage mConn msg groupId - introduceToRemaining vr user gInfo m {memberRole = role} + introduceToRemaining cxt user gInfo m {memberRole = role} when (groupFeatureAllowed SGFHistory gInfo) $ sendHistory user gInfo m (m', gInfo') <- withFastStore' $ \db -> do m' <- updateGroupMemberAccepted db user m GSMemConnected role @@ -2674,7 +2674,7 @@ processChatCommand vr nm = \case Nothing -> throwChatError CEGroupMemberNotActive GSMemPendingReview -> do let scope = Just $ GCSMemberSupport $ Just (groupMemberId' m) - modMs <- withFastStore' $ \db -> getGroupModerators db vr user gInfo + modMs <- withFastStore' $ \db -> getGroupModerators db cxt user gInfo let rcpModMs' = filter memberCurrent modMs msg = XGrpLinkAcpt GAAccepted role (memberId' m) void $ sendGroupMessage user gInfo scope ([m] <> rcpModMs') msg @@ -2683,7 +2683,7 @@ processChatCommand vr nm = \case let msg2 = XMsgNew $ mcSimple (MCText acceptedToGroupMessage) void $ sendDirectMemberMessage mConn msg2 groupId when (memberCategory m == GCInviteeMember) $ do - introduceToRemaining vr user gInfo m {memberRole = role} + introduceToRemaining cxt user gInfo m {memberRole = role} when (groupFeatureAllowed SGFHistory gInfo) $ sendHistory user gInfo m (m', gInfo') <- withFastStore' $ \db -> do m' <- updateGroupMemberAccepted db user m newMemberStatus role @@ -2701,7 +2701,7 @@ processChatCommand vr nm = \case _ -> GSMemAnnounced _ -> throwCmdError "member should be pending approval and invitee, or pending review and not invitee" APIDeleteMemberSupportChat groupId gmId -> withUser $ \user -> do - (gInfo, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user groupId <*> getGroupMemberById db vr user gmId + (gInfo, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db cxt user groupId <*> getGroupMemberById db cxt user gmId when (isNothing $ supportChat m) $ throwCmdError "member has no support chat" when (memberPending m) $ throwCmdError "member is pending" (gInfo', m') <- withFastStore' $ \db -> do @@ -2715,7 +2715,7 @@ processChatCommand vr nm = \case APIMembersRole groupId memberIds newRole -> withUser $ \user -> withGroupLock "memberRole" groupId $ do -- TODO [relays] possible optimization is to read only required members + relays - g@(Group gInfo members) <- withFastStore $ \db -> getGroup db vr user groupId + g@(Group gInfo members) <- withFastStore $ \db -> getGroup db cxt user groupId when (selfSelected gInfo) $ throwCmdError "can't change role for self" let (invitedMems, currentMems, unchangedMems, maxRole, anyAdmin, anyPending) = selectMembers members when (length invitedMems + length currentMems + length unchangedMems /= length memberIds) $ throwChatError CEGroupMemberNotFound @@ -2753,7 +2753,7 @@ processChatCommand vr nm = \case where changeRole :: GroupMember -> CM GroupMember changeRole m@GroupMember {groupMemberId, memberContactId, localDisplayName = cName} = do - withFastStore (\db -> (,) <$> mapM (getContact db vr user) memberContactId <*> liftIO (getMemberInvitation db user groupMemberId)) >>= \case + withFastStore (\db -> (,) <$> mapM (getContact db cxt user) memberContactId <*> liftIO (getMemberInvitation db user groupMemberId)) >>= \case (Just ct, Just cReq) -> do sendGrpInvitation user ct gInfo (m :: GroupMember) {memberRole = newRole} cReq withFastStore' $ \db -> updateGroupMemberRole db user m newRole @@ -2785,7 +2785,7 @@ processChatCommand vr nm = \case APIBlockMembersForAll groupId memberIds blockFlag -> withUser $ \user -> withGroupLock "blockForAll" groupId $ do -- TODO [relays] possible optimization is to read only required members + relays - Group gInfo members <- withFastStore $ \db -> getGroup db vr user groupId + Group gInfo members <- withFastStore $ \db -> getGroup db cxt user groupId when (selfSelected gInfo) $ throwCmdError "can't block/unblock self" -- TODO [relays] consider sending restriction to all members (remove filtering), as we do in delivery jobs let (blockMems, remainingMems, maxRole, anyAdmin, anyPending) = selectMembers members @@ -2834,7 +2834,7 @@ processChatCommand vr nm = \case APIRemoveMembers {groupId, groupMemberIds, withMessages} -> withUser $ \user -> withGroupLock "removeMembers" groupId $ do -- TODO [relays] possible optimization is to read only required members + relays - Group gInfo members <- withFastStore $ \db -> getGroup db vr user groupId + Group gInfo members <- withFastStore $ \db -> getGroup db cxt user groupId let (count, invitedMems, pendingApprvMems, pendingRvwMems, currentMems, maxRole, anyAdmin) = selectMembers gmIds members gmIds = S.fromList $ L.toList groupMemberIds memCount = length groupMemberIds @@ -2857,7 +2857,7 @@ processChatCommand vr nm = \case gInfo' <- if useRelays' gInfo then updatePublicGroupData user gInfo - else withFastStore $ \db -> getGroupInfo db vr user groupId + else withFastStore $ \db -> getGroupInfo db cxt user groupId let acis' = map (updateACIGroupInfo gInfo') acis unless (null acis') $ toView $ CEvtNewChatItems user acis' unless (null errs) $ toView $ CEvtChatErrors errs @@ -2929,7 +2929,7 @@ processChatCommand vr nm = \case | groupFeatureUserAllowed SGFFullDelete gInfo = deleteGroupMembersCIs user gInfo ms membership | otherwise = markGroupMembersCIsDeleted user gInfo ms membership APILeaveGroup groupId -> withUser $ \user@User {userId} -> do - gInfo@GroupInfo {membership} <- withFastStore $ \db -> getGroupInfo db vr user groupId + gInfo@GroupInfo {membership} <- withFastStore $ \db -> getGroupInfo db cxt user groupId filesInfo <- withFastStore' $ \db -> getGroupFileInfo db user gInfo withGroupLock "leaveGroup" groupId $ do cancelFilesInProgress user filesInfo @@ -2968,26 +2968,26 @@ processChatCommand vr nm = \case pure msg getRecipients user gInfo | useRelays' gInfo = do - relays <- withFastStore' $ \db -> getGroupRelayMembers db vr user gInfo + relays <- withFastStore' $ \db -> getGroupRelayMembers db cxt user gInfo pure (relays, relays) | otherwise = do - ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo + ms <- withFastStore' $ \db -> getGroupMembers db cxt user gInfo pure (ms, filter memberCurrentOrPending ms) APIListMembers groupId -> withUser $ \user -> - CRGroupMembers user <$> withFastStore (\db -> getGroup db vr user groupId) + CRGroupMembers user <$> withFastStore (\db -> getGroup db cxt user groupId) -- -- validate: prohibit to delete/archive if member is pending (has to communicate approval or rejection) -- APIDeleteGroupConversations groupId _gcId -> withUser $ \user -> do - -- _gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId + -- _gInfo <- withFastStore $ \db -> getGroupInfo db cxt user groupId -- ok_ -- CRGroupConversationsArchived -- APIArchiveGroupConversations groupId _gcId -> withUser $ \user -> do - -- _gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId + -- _gInfo <- withFastStore $ \db -> getGroupInfo db cxt user groupId -- ok_ -- CRGroupConversationsDeleted AddMember gName cName memRole -> withUser $ \user -> do (groupId, contactId) <- withFastStore $ \db -> (,) <$> getGroupIdByName db user gName <*> getContactIdByName db user cName - processChatCommand vr nm $ APIAddMember groupId contactId memRole + processChatCommand cxt nm $ APIAddMember groupId contactId memRole JoinGroup gName enableNtfs -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName - processChatCommand vr nm $ APIJoinGroup groupId enableNtfs + processChatCommand cxt nm $ APIJoinGroup groupId enableNtfs AcceptMember gName gMemberName memRole -> withMemberName gName gMemberName $ \gId gMemberId -> APIAcceptMember gId gMemberId memRole MemberRole gName gMemberName memRole -> withMemberName gName gMemberName $ \gId gMemberId -> APIMembersRole gId [gMemberId] memRole BlockForAll gName gMemberName blocked -> withMemberName gName gMemberName $ \gId gMemberId -> APIBlockMembersForAll gId [gMemberId] blocked @@ -2996,45 +2996,45 @@ processChatCommand vr nm = \case gId <- getGroupIdByName db user gName gMemberIds <- mapM (getGroupMemberIdByName db user gId) gMemberNames pure (gId, gMemberIds) - processChatCommand vr nm $ APIRemoveMembers gId gMemberIds withMessages + processChatCommand cxt nm $ APIRemoveMembers gId gMemberIds withMessages LeaveGroup gName -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName - processChatCommand vr nm $ APILeaveGroup groupId + processChatCommand cxt nm $ APILeaveGroup groupId AllowRelayGroup gName -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName - processChatCommand vr nm $ APIAllowRelayGroup groupId + processChatCommand cxt nm $ APIAllowRelayGroup groupId DeleteGroup gName -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName - processChatCommand vr nm $ APIDeleteChat (ChatRef CTGroup groupId Nothing) (CDMFull True) + processChatCommand cxt nm $ APIDeleteChat (ChatRef CTGroup groupId Nothing) (CDMFull True) ClearGroup gName -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName - processChatCommand vr nm $ APIClearChat (ChatRef CTGroup groupId Nothing) + processChatCommand cxt nm $ APIClearChat (ChatRef CTGroup groupId Nothing) ListMembers gName -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName - processChatCommand vr nm $ APIListMembers groupId + processChatCommand cxt nm $ APIListMembers groupId ListMemberSupportChats gName -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName - (Group gInfo members) <- withFastStore $ \db -> getGroup db vr user groupId + (Group gInfo members) <- withFastStore $ \db -> getGroup db cxt user groupId let memberSupportChats = filter (isJust . supportChat) members pure $ CRMemberSupportChats user gInfo memberSupportChats APIListGroups userId contactId_ search_ -> withUserId userId $ \user -> - CRGroupsList user <$> withFastStore' (\db -> getBaseGroupDetails db vr user contactId_ search_) + CRGroupsList user <$> withFastStore' (\db -> getBaseGroupDetails db cxt user contactId_ search_) ListGroups cName_ search_ -> withUser $ \user@User {userId} -> do - ct_ <- forM cName_ $ \cName -> withFastStore $ \db -> getContactByName db vr user cName - processChatCommand vr nm $ APIListGroups userId (contactId' <$> ct_) search_ + ct_ <- forM cName_ $ \cName -> withFastStore $ \db -> getContactByName db cxt user cName + processChatCommand cxt nm $ APIListGroups userId (contactId' <$> ct_) search_ APIUpdateGroupProfile groupId p' -> withUser $ \user -> do - gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId + gInfo <- withFastStore $ \db -> getGroupInfo db cxt user groupId runUpdateGroupProfile user gInfo p' UpdateGroupNames gName GroupProfile {displayName, fullName, shortDescr} -> updateGroupProfileByName gName $ \p -> p {displayName, fullName, shortDescr} ShowGroupProfile gName -> withUser $ \user -> - CRGroupProfile user <$> withFastStore (\db -> getGroupInfoByName db vr user gName) + CRGroupProfile user <$> withFastStore (\db -> getGroupInfoByName db cxt user gName) UpdateGroupDescription gName description -> updateGroupProfileByName gName $ \p -> p {description} ShowGroupDescription gName -> withUser $ \user -> - CRGroupDescription user <$> withFastStore (\db -> getGroupInfoByName db vr user gName) + CRGroupDescription user <$> withFastStore (\db -> getGroupInfoByName db cxt user gName) APICreateGroupLink groupId mRole -> withUser $ \user -> withGroupLock "createGroupLink" groupId $ do - gInfo@GroupInfo {groupProfile} <- withFastStore $ \db -> getGroupInfo db vr user groupId + gInfo@GroupInfo {groupProfile} <- withFastStore $ \db -> getGroupInfo db cxt user groupId assertUserGroupRole gInfo GRAdmin when (mRole > GRMember) $ throwChatError $ CEGroupMemberInitialRole gInfo mRole groupLinkId <- GroupLinkId <$> drgRandomBytes 16 @@ -3049,7 +3049,7 @@ processChatCommand vr nm = \case gLink <- withFastStore $ \db -> createGroupLink db gVar user gInfo connId ccLink' groupLinkId mRole subMode pure $ CRGroupLinkCreated user gInfo gLink APIGroupLinkMemberRole groupId mRole' -> withUser $ \user -> withGroupLock "groupLinkMemberRole" groupId $ do - gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId + gInfo <- withFastStore $ \db -> getGroupInfo db cxt user groupId gLnk@GroupLink {acceptMemberRole} <- withFastStore $ \db -> getGroupLink db user gInfo assertUserGroupRole gInfo GRAdmin when (mRole' > GRMember) $ throwChatError $ CEGroupMemberInitialRole gInfo mRole' @@ -3059,22 +3059,22 @@ processChatCommand vr nm = \case else pure gLnk pure $ CRGroupLink user gInfo gLnk' APIDeleteGroupLink groupId -> withUser $ \user -> withGroupLock "deleteGroupLink" groupId $ do - gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId + gInfo <- withFastStore $ \db -> getGroupInfo db cxt user groupId deleteGroupLink' user gInfo pure $ CRGroupLinkDeleted user gInfo APIGetGroupLink groupId -> withUser $ \user -> do - gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId + gInfo <- withFastStore $ \db -> getGroupInfo db cxt user groupId gLnk <- withFastStore $ \db -> getGroupLink db user gInfo pure $ CRGroupLink user gInfo gLnk APIAddGroupShortLink groupId -> withUser $ \user -> do (gInfo, gLink) <- withFastStore $ \db -> do - gInfo <- getGroupInfo db vr user groupId + gInfo <- getGroupInfo db cxt user groupId gLink <- getGroupLink db user gInfo pure (gInfo, gLink) gLink' <- setGroupLinkData nm user gInfo gLink pure $ CRGroupLink user gInfo gLink' APICreateMemberContact gId gMemberId -> withUser $ \user -> do - (g, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db vr user gId gMemberId + (g, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db cxt user gId <*> getGroupMember db cxt user gId gMemberId assertUserGroupRole g GRAuthor unless (groupFeatureUserAllowed SGFDirectMessages g) $ throwCmdError "direct messages not allowed" case memberConn m of @@ -3092,7 +3092,7 @@ processChatCommand vr nm = \case pure $ CRNewMemberContact user ct g m _ -> throwChatError CEGroupMemberNotActive APISendMemberContactInvitation contactId msgContent_ -> withUser $ \user -> do - (g@GroupInfo {groupId}, m, ct, cReq) <- withFastStore $ \db -> getMemberContact db vr user contactId + (g@GroupInfo {groupId}, m, ct, cReq) <- withFastStore $ \db -> getMemberContact db cxt user contactId when (contactGrpInvSent ct) $ throwCmdError "x.grp.direct.inv already sent" case memberConn m of Just mConn -> do @@ -3107,17 +3107,17 @@ processChatCommand vr nm = \case pure $ CRNewMemberContactSentInv user ct' g m _ -> throwChatError CEGroupMemberNotActive APIAcceptMemberContact contactId -> withUser $ \user -> do - (g, mConn, ct, groupDirectInv) <- withFastStore $ \db -> getMemberContactInvited db vr user contactId + (g, mConn, ct, groupDirectInv) <- withFastStore $ \db -> getMemberContactInvited db cxt user contactId when (groupDirectInvStartedConnection groupDirectInv) $ throwCmdError "connection already started" connectMemberContact user g mConn ct groupDirectInv `catchAllErrors` \e -> do -- get updated contact, in case connection was started - ct' <- withFastStore $ \db -> getContact db vr user contactId + ct' <- withFastStore $ \db -> getContact db cxt user contactId toView $ CEvtChatInfoUpdated user (AChatInfo SCTDirect $ DirectChat ct') throwError e -- get updated contact (groupDirectInvStartedConnection) with connection ct' <- withFastStore $ \db -> do liftIO $ setMemberContactStartedConnection db ct - getContact db vr user contactId + getContact db cxt user contactId pure $ CRMemberContactAccepted user ct' where connectMemberContact user gInfo mConn Contact {activeConn} GroupDirectInvitation {groupDirectInvLink = cReq} = @@ -3139,7 +3139,7 @@ processChatCommand vr nm = \case acId <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq PQSupportOff conn <- withStore $ \db -> do connId <- liftIO $ createMemberContactConn db user acId Nothing gInfo mConn ConnPrepared contactId subMode - getConnectionById db vr user connId + getConnectionById db cxt user connId joinPreparedConn subMode conn joinPreparedConn subMode conn = do -- [incognito] send membership incognito profile @@ -3150,66 +3150,66 @@ processChatCommand vr nm = \case void $ withFastStore' $ \db -> updateConnectionStatusFromTo db conn ConnPrepared newStatus CreateGroupLink gName mRole -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName - processChatCommand vr nm $ APICreateGroupLink groupId mRole + processChatCommand cxt nm $ APICreateGroupLink groupId mRole GroupLinkMemberRole gName mRole -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName - processChatCommand vr nm $ APIGroupLinkMemberRole groupId mRole + processChatCommand cxt nm $ APIGroupLinkMemberRole groupId mRole DeleteGroupLink gName -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName - processChatCommand vr nm $ APIDeleteGroupLink groupId + processChatCommand cxt nm $ APIDeleteGroupLink groupId ShowGroupLink gName -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName - processChatCommand vr nm $ APIGetGroupLink groupId + processChatCommand cxt nm $ APIGetGroupLink groupId SendGroupMessageQuote gName cName quotedMsg msg -> withUser $ \user -> do (gInfo, quotedItemId, mentions) <- withFastStore $ \db -> do - gInfo <- getGroupInfoByName db vr user gName + gInfo <- getGroupInfoByName db cxt user gName let gId = groupId' gInfo qiId <- getGroupChatItemIdByText db user gId cName quotedMsg (gInfo, qiId,) <$> liftIO (getMessageMentions db user gId msg) let mc = MCText msg - processChatCommand vr nm $ APISendMessages (SRGroup (groupId' gInfo) Nothing (sendAsGroup' gInfo Nothing)) False Nothing [ComposedMessage Nothing (Just quotedItemId) mc mentions] + processChatCommand cxt nm $ APISendMessages (SRGroup (groupId' gInfo) Nothing (sendAsGroup' gInfo Nothing)) False Nothing [ComposedMessage Nothing (Just quotedItemId) mc mentions] ClearNoteFolder -> withUser $ \user -> do folderId <- withFastStore (`getUserNoteFolderId` user) - processChatCommand vr nm $ APIClearChat (ChatRef CTLocal folderId Nothing) + processChatCommand cxt nm $ APIClearChat (ChatRef CTLocal folderId Nothing) LastChats count_ -> withUser' $ \user -> do let count = fromMaybe 5000 count_ - (errs, previews) <- partitionEithers <$> withFastStore' (\db -> getChatPreviews db vr user False (PTLast count) clqNoFilters) + (errs, previews) <- partitionEithers <$> withFastStore' (\db -> getChatPreviews db cxt user False (PTLast count) clqNoFilters) unless (null errs) $ toView $ CEvtChatErrors (map ChatErrorStore errs) pure $ CRChats previews LastMessages (Just chatName) count search -> withUser $ \user -> do chatRef <- getChatRef user chatName - chatResp <- processChatCommand vr nm $ APIGetChat chatRef Nothing (CPLast count) search + chatResp <- processChatCommand cxt nm $ APIGetChat chatRef Nothing (CPLast count) search pure $ CRChatItems user (Just chatName) (aChatItems . chat $ chatResp) LastMessages Nothing count search -> withUser $ \user -> do - chatItems <- withFastStore $ \db -> getAllChatItems db vr user (CPLast count) search + chatItems <- withFastStore $ \db -> getAllChatItems db cxt user (CPLast count) search pure $ CRChatItems user Nothing chatItems LastChatItemId (Just chatName) index -> withUser $ \user -> do chatRef <- getChatRef user chatName - chatResp <- processChatCommand vr nm $ APIGetChat chatRef Nothing (CPLast $ index + 1) Nothing + chatResp <- processChatCommand cxt nm $ APIGetChat chatRef Nothing (CPLast $ index + 1) Nothing pure $ CRChatItemId user (fmap aChatItemId . listToMaybe . aChatItems . chat $ chatResp) LastChatItemId Nothing index -> withUser $ \user -> do - chatItems <- withFastStore $ \db -> getAllChatItems db vr user (CPLast $ index + 1) Nothing + chatItems <- withFastStore $ \db -> getAllChatItems db cxt user (CPLast $ index + 1) Nothing pure $ CRChatItemId user (fmap aChatItemId . listToMaybe $ chatItems) ShowChatItem (Just itemId) -> withUser $ \user -> do chatItem <- withFastStore $ \db -> do chatRef <- getChatRefViaItemId db user itemId - getAChatItem db vr user chatRef itemId + getAChatItem db cxt user chatRef itemId pure $ CRChatItems user Nothing ((: []) chatItem) ShowChatItem Nothing -> withUser $ \user -> do - chatItems <- withFastStore $ \db -> getAllChatItems db vr user (CPLast 1) Nothing + chatItems <- withFastStore $ \db -> getAllChatItems db cxt user (CPLast 1) Nothing pure $ CRChatItems user Nothing chatItems ShowChatItemInfo chatName msg -> withUser $ \user -> do chatRef <- getChatRef user chatName itemId <- getChatItemIdByText user chatRef msg - processChatCommand vr nm $ APIGetChatItemInfo chatRef itemId + processChatCommand cxt nm $ APIGetChatItemInfo chatRef itemId ShowLiveItems on -> withUser $ \_ -> asks showLiveItems >>= atomically . (`writeTVar` on) >> ok_ SendFile chatName f -> withUser $ \user -> do chatRef <- getChatRef user chatName case chatRef of - ChatRef CTLocal folderId _ -> processChatCommand vr nm $ APICreateChatItems folderId [composedMessage (Just f) (MCFile "")] - _ -> withSendRef user chatRef $ \sendRef -> processChatCommand vr nm $ APISendMessages sendRef False Nothing [composedMessage (Just f) (MCFile "")] + ChatRef CTLocal folderId _ -> processChatCommand cxt nm $ APICreateChatItems folderId [composedMessage (Just f) (MCFile "")] + _ -> withSendRef user chatRef $ \sendRef -> processChatCommand cxt nm $ APISendMessages sendRef False Nothing [composedMessage (Just f) (MCFile "")] SendImage chatName f@(CryptoFile fPath _) -> withUser $ \user -> do chatRef <- getChatRef user chatName withSendRef user chatRef $ \sendRef -> do @@ -3218,7 +3218,7 @@ processChatCommand vr nm = \case fileSize <- getFileSize filePath unless (fileSize <= maxImageSize) $ throwChatError CEFileImageSize {filePath} -- TODO include file description for preview - processChatCommand vr nm $ APISendMessages sendRef False Nothing [composedMessage (Just f) (MCImage "" fixedImagePreview)] + processChatCommand cxt nm $ APISendMessages sendRef False Nothing [composedMessage (Just f) (MCImage "" fixedImagePreview)] ForwardFile chatName fileId -> forwardFile chatName fileId SendFile ForwardImage chatName fileId -> forwardFile chatName fileId SendImage SendFileDescription _chatName _f -> throwCmdError "TODO" @@ -3245,18 +3245,18 @@ processChatCommand vr nm = \case | otherwise -> do cancelSndFile user ftm fts True cref_ <- withFastStore' $ \db -> lookupChatRefByFileId db user fileId - aci_ <- withFastStore $ \db -> lookupChatItemByFileId db vr user fileId + aci_ <- withFastStore $ \db -> lookupChatItemByFileId db cxt user fileId case (cref_, aci_) of (Nothing, _) -> pure $ CRSndFileCancelled user Nothing ftm fts (Just (ChatRef CTDirect contactId _), Just aci) -> do - (contact, sharedMsgId) <- withFastStore $ \db -> (,) <$> getContact db vr user contactId <*> getSharedMsgIdByFileId db userId fileId + (contact, sharedMsgId) <- withFastStore $ \db -> (,) <$> getContact db cxt user contactId <*> getSharedMsgIdByFileId db userId fileId void . sendDirectContactMessage user contact $ XFileCancel sharedMsgId pure $ CRSndFileCancelled user (Just aci) ftm fts (Just (ChatRef CTGroup groupId scope), Just aci) -> do - (gInfo, sharedMsgId) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user groupId <*> getSharedMsgIdByFileId db userId fileId - chatScopeInfo <- mapM (getChatScopeInfo vr user) scope - recipients <- getGroupRecipients vr user gInfo chatScopeInfo groupKnockingVersion + (gInfo, sharedMsgId) <- withFastStore $ \db -> (,) <$> getGroupInfo db cxt user groupId <*> getSharedMsgIdByFileId db userId fileId + chatScopeInfo <- mapM (getChatScopeInfo cxt user) scope + recipients <- getGroupRecipients cxt user gInfo chatScopeInfo groupKnockingVersion void . sendGroupMessage user gInfo scope recipients $ XFileCancel sharedMsgId pure $ CRSndFileCancelled user (Just aci) ftm fts (Just _, _) -> throwChatError $ CEFileInternal "invalid chat ref for file transfer" @@ -3269,7 +3269,7 @@ processChatCommand vr nm = \case | otherwise -> case xftpRcvFile of Nothing -> do cancelRcvFileTransfer user ftr - ci <- withFastStore $ \db -> lookupChatItemByFileId db vr user fileId + ci <- withFastStore $ \db -> lookupChatItemByFileId db cxt user fileId pure $ CRRcvFileCancelled user ci ftr Just XFTPRcvFile {agentRcvFileId} -> do forM_ (liveRcvFileTransferPath ftr) $ \filePath -> do @@ -3280,7 +3280,7 @@ processChatCommand vr nm = \case aci_ <- resetRcvCIFileStatus user fileId CIFSRcvInvitation pure $ CRRcvFileCancelled user aci_ ftr FileStatus fileId -> withUser $ \user -> do - withFastStore (\db -> lookupChatItemByFileId db vr user fileId) >>= \case + withFastStore (\db -> lookupChatItemByFileId db cxt user fileId) >>= \case Nothing -> do fileStatus <- withFastStore $ \db -> getFileTransferProgress db user fileId pure $ CRFileTransferStatus user fileStatus @@ -3309,7 +3309,7 @@ processChatCommand vr nm = \case let p = (fromLocalProfile profile :: Profile) {preferences = Just . setPreference f (Just allowed) $ preferences' user} updateProfile user p SetContactFeature (ACF f) cName allowed_ -> withUser $ \user -> do - ct@Contact {userPreferences} <- withFastStore $ \db -> getContactByName db vr user cName + ct@Contact {userPreferences} <- withFastStore $ \db -> getContactByName db cxt user cName let prefs' = setPreference f allowed_ $ Just userPreferences updateContactPrefs user ct prefs' SetGroupFeature (AGFNR f) gName enabled -> @@ -3329,7 +3329,7 @@ processChatCommand vr nm = \case p = (fromLocalProfile profile :: Profile) {preferences = Just . setPreference' SCFTimedMessages (Just pref) $ preferences' user} updateProfile user p SetContactTimedMessages cName timedMessagesEnabled_ -> withUser $ \user -> do - ct@Contact {userPreferences = userPreferences@Preferences {timedMessages}} <- withFastStore $ \db -> getContactByName db vr user cName + ct@Contact {userPreferences = userPreferences@Preferences {timedMessages}} <- withFastStore $ \db -> getContactByName db cxt user cName let currentTTL = timedMessages >>= \TimedMessagesPreference {ttl} -> ttl pref_ = tmeToPref currentTTL <$> timedMessagesEnabled_ prefs' = setPreference' SCFTimedMessages pref_ $ Just userPreferences @@ -3444,7 +3444,7 @@ processChatCommand vr nm = \case _ -> throwCmdError "not supported" pure $ ChatRef cType chatId Nothing getSendAsGroup :: User -> ChatRef -> CM ShowGroupAsSender - getSendAsGroup user' (ChatRef CTGroup chatId scope) = (`sendAsGroup'` scope) <$> withFastStore (\db -> getGroupInfo db vr user' chatId) + getSendAsGroup user' (ChatRef CTGroup chatId scope) = (`sendAsGroup'` scope) <$> withFastStore (\db -> getGroupInfo db cxt user' chatId) getSendAsGroup _ _ = pure False getChatRefAndMentions :: User -> ChatName -> Text -> CM (ChatRef, Map MemberName GroupMemberId) getChatRefAndMentions user cName msg = do @@ -3463,13 +3463,13 @@ processChatCommand vr nm = \case checkStoreNotChanged :: CM ChatResponse -> CM ChatResponse checkStoreNotChanged = ifM (asks chatStoreChanged >>= readTVarIO) (throwChatError CEChatStoreChanged) withUserName :: UserName -> (UserId -> ChatCommand) -> CM ChatResponse - withUserName uName cmd = withFastStore (`getUserIdByName` uName) >>= processChatCommand vr nm . cmd + withUserName uName cmd = withFastStore (`getUserIdByName` uName) >>= processChatCommand cxt nm . cmd withContactName :: ContactName -> (ContactId -> ChatCommand) -> CM ChatResponse withContactName cName cmd = withUser $ \user -> - withFastStore (\db -> getContactIdByName db user cName) >>= processChatCommand vr nm . cmd + withFastStore (\db -> getContactIdByName db user cName) >>= processChatCommand cxt nm . cmd withMemberName :: GroupName -> ContactName -> (GroupId -> GroupMemberId -> ChatCommand) -> CM ChatResponse withMemberName gName mName cmd = withUser $ \user -> - getGroupAndMemberId user gName mName >>= processChatCommand vr nm . uncurry cmd + getGroupAndMemberId user gName mName >>= processChatCommand cxt nm . uncurry cmd getConnectionCode :: ConnId -> CM Text getConnectionCode connId = verificationCode <$> withAgent (`getConnectionRatchetAdHash` connId) verifyConnectionCode :: User -> Connection -> Maybe Text -> CM ChatResponse @@ -3503,7 +3503,7 @@ processChatCommand vr nm = \case -- TODO PQ the error above should be CEIncompatibleConnReqVersion, also the same API should be called in Plan Just (agentV, pqSup') -> do let chatV = agentToChatVersion agentV - withFastStore' (\db -> getConnectionEntityByConnReq db vr user cReqs) >>= \case + withFastStore' (\db -> getConnectionEntityByConnReq db cxt user cReqs) >>= \case Nothing -> joinNewConn chatV Just (RcvDirectMsgConnection conn@Connection {connStatus, contactConnInitiated, customUserProfileId} _ct_) | connStatus == ConnNew && contactConnInitiated -> joinNewConn chatV -- own connection link @@ -3547,7 +3547,7 @@ processChatCommand vr nm = \case ConnPrepared -> joinPreparedConn' xContactId conn (Just $ Just gInfo) _ -> connect' groupLinkId xContactId (Just $ Just gInfo) -- why not "already connected" for host member? Nothing -> - withFastStore' (\db -> getConnReqContactXContactId db vr user cReqHash1 cReqHash2) >>= \case + withFastStore' (\db -> getConnReqContactXContactId db cxt user cReqHash1 cReqHash2) >>= \case Right ct@Contact {activeConn} -> case groupLinkId of Nothing -> case activeConn of Just conn@Connection {connStatus = ConnPrepared, xContactId} -> joinPreparedConn' xContactId conn Nothing @@ -3602,7 +3602,7 @@ processChatCommand vr nm = \case let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq conn <- withFastStore' $ \db -> createConnReqConnection db userId connId (Just $ PCEContact ct) cReq cReqHash shortLink newXContactId (NewIncognito <$> incognitoProfile) Nothing subMode chatV pqSup void $ joinContact user conn cReq incognitoProfile newXContactId Nothing Nothing Nothing pqSup - ct' <- withStore $ \db -> getContact db vr user contactId + ct' <- withStore $ \db -> getContact db cxt user contactId pure $ CRSentInvitationToContact user ct' incognitoProfile Just conn@Connection {connStatus, xContactId = xContactId_, customUserProfileId} -> case connStatus of ConnPrepared -> do @@ -3611,14 +3611,14 @@ processChatCommand vr nm = \case localIncognitoProfile <- forM customUserProfileId $ \pId -> withFastStore $ \db -> getProfileById db userId pId let incognitoProfile = fromLocalProfile <$> localIncognitoProfile void $ joinContact user conn cReq incognitoProfile xContactId Nothing Nothing Nothing PQSupportOn - ct' <- withStore $ \db -> getContact db vr user contactId + ct' <- withStore $ \db -> getContact db cxt user contactId pure $ CRSentInvitationToContact user ct' incognitoProfile _ -> throwCmdError "contact already has connection" connectToRelay :: User -> GroupInfo -> ShortLinkContact -> CM (ShortLinkContact, GroupMember, Either ChatError ()) connectToRelay user gInfo relayLink = do gVar <- asks random -- Save relayLink to re-use relay member record on retry (check by relayLink) - relayMember <- withFastStore $ \db -> getCreateRelayForMember db vr gVar user gInfo relayLink + relayMember <- withFastStore $ \db -> getCreateRelayForMember db cxt gVar user gInfo relayLink r <- tryAllErrors $ do (fd@FixedLinkData {rootKey = relayKey, linkEntityId}, cData) <- getShortLinkConnReq nm user relayLink relayLinkData_ <- liftIO $ decodeLinkUserData cData @@ -3629,11 +3629,11 @@ processChatCommand vr nm = \case let cReq = linkConnReq fd relayLinkToConnect = CCLink cReq (Just relayLink) void $ connectViaContact user (Just $ PCEGroup gInfo relayMember) (incognitoMembership gInfo) relayLinkToConnect Nothing Nothing - relayMember' <- withFastStore $ \db -> getGroupMember db vr user (groupId' gInfo) (groupMemberId' relayMember) + relayMember' <- withFastStore $ \db -> getGroupMember db cxt user (groupId' gInfo) (groupMemberId' relayMember) pure (relayLink, relayMember', r) syncSubscriberRelays :: User -> GroupInfo -> [ShortLinkContact] -> CM () syncSubscriberRelays user gInfo currentRelayLinks = void . tryAllErrors $ do - localRelayMembers <- withFastStore' $ \db -> getGroupRelayMembers db vr user gInfo + localRelayMembers <- withFastStore' $ \db -> getGroupRelayMembers db cxt user gInfo let activeRelayMembers = filter memberCurrent localRelayMembers memberRelayLink GroupMember {relayLink = rl} = rl localRelayLinks = mapMaybe memberRelayLink activeRelayMembers @@ -3703,7 +3703,7 @@ processChatCommand vr nm = \case | otherwise = do when (n /= n') $ checkValidName n' -- read contacts before user update to correctly merge preferences - contacts <- withFastStore' $ \db -> getUserContacts db vr user + contacts <- withFastStore' $ \db -> getUserContacts db cxt user user' <- updateUser asks currentUser >>= atomically . (`writeTVar` Just user') withChatLock "updateProfile" $ do @@ -3755,7 +3755,7 @@ processChatCommand vr nm = \case (conn, MsgFlags {notification = hasNotification XInfo_}, (vrValue msgBody, [msgId])) setMyAddressData :: User -> UserContactLink -> CM UserContactLink setMyAddressData user@User {userChatRelay} ucl@UserContactLink {userContactLinkId, connLinkContact = CCLink connFullLink _sLnk_, addressSettings} = do - conn <- withFastStore $ \db -> getUserAddressConnection db vr user + conn <- withFastStore $ \db -> getUserAddressConnection db cxt user let shortLinkProfile = userProfileDirect user Nothing Nothing True -- TODO [short links] do not save address to server if data did not change, spinners, error handling userData @@ -3789,12 +3789,12 @@ processChatCommand vr nm = \case gInfo' <- withStore $ \db -> updateGroupProfile db user gInfo p' msg <- case businessChat of Just BusinessChatInfo {businessId} -> do - ms <- withStore' $ \db -> getGroupMembers db vr user gInfo' + ms <- withStore' $ \db -> getGroupMembers db cxt user gInfo' let (newMs, oldMs) = partition (\m -> maxVersion (memberChatVRange m) >= businessChatPrefsVersion) ms -- this is a fallback to send the members with the old version correct profile of the business when preferences change unless (null oldMs) $ do GroupMember {memberProfile = LocalProfile {displayName, fullName, shortDescr, image}} <- - withStore $ \db -> getGroupMemberByMemberId db vr user gInfo' businessId + withStore $ \db -> getGroupMemberByMemberId db cxt user gInfo' businessId let p'' = p' {displayName, fullName, shortDescr, image} :: GroupProfile recipients = filter memberCurrentOrPending oldMs void $ sendGroupMessage user gInfo' Nothing recipients (XGrpInfo p'') @@ -3807,9 +3807,9 @@ processChatCommand vr nm = \case sendGroupMessage user gInfo' Nothing recipients (XGrpInfo p') where getRecipients - | useRelays' gInfo' = withFastStore' $ \db -> getGroupRelayMembers db vr user gInfo' + | useRelays' gInfo' = withFastStore' $ \db -> getGroupRelayMembers db cxt user gInfo' | otherwise = do - ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo' + ms <- withFastStore' $ \db -> getGroupMembers db cxt user gInfo' pure $ filter memberCurrentOrPending ms let cd = CDGroupSnd gInfo' Nothing unless (sameGroupProfileInfo p p') $ do @@ -3870,13 +3870,13 @@ processChatCommand vr nm = \case updateGroupProfileByName :: GroupName -> (GroupProfile -> GroupProfile) -> CM ChatResponse updateGroupProfileByName gName update = withUser $ \user -> do gInfo@GroupInfo {groupProfile = p} <- withStore $ \db -> - getGroupIdByName db user gName >>= getGroupInfo db vr user + getGroupIdByName db user gName >>= getGroupInfo db cxt user runUpdateGroupProfile user gInfo $ update p withCurrentCall :: ContactId -> (User -> Contact -> Call -> CM (Maybe Call)) -> CM ChatResponse withCurrentCall ctId action = do (user, ct) <- withStore $ \db -> do user <- getUserByContactId db ctId - (user,) <$> getContact db vr user ctId + (user,) <$> getContact db cxt user ctId calls <- asks currentCalls withContactLock "currentCall" ctId $ atomically (TM.lookup ctId calls) >>= \case @@ -3918,7 +3918,7 @@ processChatCommand vr nm = \case FTSnd {fileTransferMeta = FileTransferMeta {filePath, xftpSndFile}} -> forward filePath $ xftpSndFile >>= \XFTPSndFile {cryptoArgs} -> cryptoArgs _ -> throwChatError CEFileNotReceived {fileId} where - forward path cfArgs = processChatCommand vr nm $ sendCommand chatName $ CryptoFile path cfArgs + forward path cfArgs = processChatCommand cxt nm $ sendCommand chatName $ CryptoFile path cfArgs getGroupAndMemberId :: User -> GroupName -> ContactName -> CM (GroupId, GroupMemberId) getGroupAndMemberId user gName groupMemberName = withStore $ \db -> do @@ -3930,7 +3930,7 @@ processChatCommand vr nm = \case checkValidName displayName -- [incognito] generate incognito profile for group membership incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing - withFastStore $ \db -> createNewGroup db vr user gProfile incognitoProfile useRelays memberId groupKeys_ publicMemberCount_ + withFastStore $ \db -> createNewGroup db cxt user gProfile incognitoProfile useRelays memberId groupKeys_ publicMemberCount_ createNewGroupItems :: User -> GroupInfo -> CM () createNewGroupItems user gInfo = do let cd = CDGroupSnd gInfo Nothing @@ -3973,9 +3973,9 @@ processChatCommand vr nm = \case subMode <- chatReadVar subscriptionMode connId <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq PQSupportOff (relayMember, conn, groupRelay) <- withFastStore $ \db -> do - relayMember <- createRelayForOwner db vr gVar user gInfo relay + relayMember <- createRelayForOwner db cxt gVar user gInfo relay groupRelay <- createGroupRelayRecord db gInfo relayMember relay - conn <- createRelayConnection db vr user (groupMemberId' relayMember) connId ConnPrepared chatV subMode + conn <- createRelayConnection db cxt user (groupMemberId' relayMember) connId ConnPrepared chatV subMode pure (relayMember, conn, groupRelay) let GroupMember {memberRole = userRole, memberId = userMemberId} = membership allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo @@ -4047,15 +4047,15 @@ processChatCommand vr nm = \case (chatId, chatSettings) <- case cType of CTDirect -> withFastStore $ \db -> do ctId <- getContactIdByName db user name - Contact {chatSettings} <- getContact db vr user ctId + Contact {chatSettings} <- getContact db cxt user ctId pure (ctId, chatSettings) CTGroup -> withFastStore $ \db -> do gId <- getGroupIdByName db user name - GroupInfo {chatSettings} <- getGroupInfo db vr user gId + GroupInfo {chatSettings} <- getGroupInfo db cxt user gId pure (gId, chatSettings) _ -> throwCmdError "not supported" - processChatCommand vr nm $ APISetChatSettings (ChatRef cType chatId Nothing) $ updateSettings chatSettings + processChatCommand cxt nm $ APISetChatSettings (ChatRef cType chatId Nothing) $ updateSettings chatSettings connectPlan :: User -> AConnectionLink -> Bool -> Maybe LinkOwnerSig -> CM (ACreatedConnLink, ConnectionPlan) connectPlan user (ACL SCMInvitation cLink) _ sig_ = case cLink of CLFull cReq -> invitationReqAndPlan cReq Nothing Nothing Nothing @@ -4071,10 +4071,10 @@ processChatCommand vr nm = \case where knownLinkPlans l' = withFastStore $ \db -> do let inv cReq = ACCL SCMInvitation $ CCLink cReq (Just l') - liftIO (getConnectionEntityViaShortLink db vr user l') >>= \case + liftIO (getConnectionEntityViaShortLink db cxt user l') >>= \case Just (cReq, ent) -> pure $ Just (inv cReq, invitationEntityPlan Nothing Nothing ent) -- deleted contact is returned as known, as invitation link cannot be re-used too connect anyway - Nothing -> bimap inv (CPInvitationLink . ILPKnown) <$$> getContactViaShortLinkToConnect db vr user l' + Nothing -> bimap inv (CPInvitationLink . ILPKnown) <$$> getContactViaShortLinkToConnect db cxt user l' invitationReqAndPlan cReq sLnk_ cld ov = do plan <- invitationRequestPlan user cReq cld ov `catchAllErrors` (pure . CPError) pure (ACCL SCMInvitation (CCLink cReq sLnk_), plan) @@ -4089,7 +4089,7 @@ processChatCommand vr nm = \case Just r -> pure r Nothing -> do (FixedLinkData {linkConnReq = cReq, rootKey}, cData) <- getShortLinkConnReq nm user l' - withFastStore' (\db -> getContactWithoutConnViaShortAddress db vr user l') >>= \case + withFastStore' (\db -> getContactWithoutConnViaShortAddress db cxt user l') >>= \case Just ct' | not (contactDeleted ct') -> pure (con cReq, CPContactAddress (CAPContactViaAddress ct')) _ -> do contactSLinkData_ <- liftIO $ decodeLinkUserData cData @@ -4102,9 +4102,9 @@ processChatCommand vr nm = \case liftIO (getUserContactLinkViaShortLink db user l') >>= \case Just UserContactLink {connLinkContact = CCLink cReq _} -> pure $ Just (con cReq, CPContactAddress CAPOwnLink) Nothing -> - getContactViaShortLinkToConnect db vr user l' >>= \case + getContactViaShortLinkToConnect db cxt user l' >>= \case Just (cReq, ct') -> pure $ if contactDeleted ct' then Nothing else Just (con cReq, CPContactAddress (CAPKnown ct')) - Nothing -> (gPlan =<<) <$> getGroupViaShortLinkToConnect db vr user l' + Nothing -> (gPlan =<<) <$> getGroupViaShortLinkToConnect db cxt user l' CCTGroup -> groupShortLinkPlan CCTChannel -> groupShortLinkPlan CCTRelay -> throwCmdError "chat relay links are not supported in this version" @@ -4140,9 +4140,9 @@ processChatCommand vr nm = \case Just GroupShortLinkData {groupProfile = GroupProfile {publicGroup = Just PublicGroupProfile {groupType}}} -> groupType /= GTChannel _ -> False knownLinkPlans = withFastStore $ \db -> - liftIO (getGroupInfoViaUserShortLink db vr user l') >>= \case + liftIO (getGroupInfoViaUserShortLink db cxt user l') >>= \case Just (cReq, g) -> pure $ Just (con cReq, CPGroupLink (GLPOwnLink g)) - Nothing -> (gPlan =<<) <$> getGroupViaShortLinkToConnect db vr user l' + Nothing -> (gPlan =<<) <$> getGroupViaShortLinkToConnect db cxt user l' resolveKnownGroup g = do (fd@FixedLinkData {rootKey = rk}, cData@(ContactLinkData _ UserContactData {owners})) <- getShortLinkConnReq' nm user l' groupSLinkData_ <- liftIO $ decodeLinkUserData cData @@ -4158,13 +4158,13 @@ processChatCommand vr nm = \case case plan of CPError e -> eToView e; _ -> pure () case plan of CPContactAddress (CAPContactViaAddress Contact {contactId}) -> - processChatCommand vr nm $ APIConnectContactViaAddress userId incognito contactId - _ -> processChatCommand vr nm $ APIConnect userId incognito $ Just ccLink + processChatCommand cxt nm $ APIConnectContactViaAddress userId incognito contactId + _ -> processChatCommand cxt nm $ APIConnect userId incognito $ Just ccLink | otherwise = pure $ CRConnectionPlan user ccLink plan invitationRequestPlan :: User -> ConnReqInvitation -> Maybe ContactShortLinkData -> Maybe OwnerVerification -> CM ConnectionPlan invitationRequestPlan user cReq cld ov = do maybe (CPInvitationLink (ILPOk cld ov)) (invitationEntityPlan cld ov) - <$> withFastStore' (\db -> getConnectionEntityByConnReq db vr user $ invCReqSchemas cReq) + <$> withFastStore' (\db -> getConnectionEntityByConnReq db cxt user $ invCReqSchemas cReq) where invCReqSchemas :: ConnReqInvitation -> (ConnReqInvitation, ConnReqInvitation) invCReqSchemas (CRInvitationUri crData e2e) = @@ -4196,9 +4196,9 @@ processChatCommand vr nm = \case withFastStore' (\db -> getUserContactLinkByConnReq db user cReqSchemas) >>= \case Just _ -> pure $ CPContactAddress CAPOwnLink Nothing -> - withFastStore' (\db -> getContactConnEntityByConnReqHash db vr user cReqHashes) >>= \case + withFastStore' (\db -> getContactConnEntityByConnReqHash db cxt user cReqHashes) >>= \case Nothing -> - withFastStore' (\db -> getContactWithoutConnViaAddress db vr user cReqSchemas) >>= \case + withFastStore' (\db -> getContactWithoutConnViaAddress db cxt user cReqSchemas) >>= \case Just ct | not (contactDeleted ct) -> pure $ CPContactAddress (CAPContactViaAddress ct) _ -> pure $ CPContactAddress (CAPOk cld ov) Just (RcvDirectMsgConnection Connection {connStatus} Nothing) @@ -4215,11 +4215,11 @@ processChatCommand vr nm = \case groupJoinRequestPlan user (CRContactUri crData) linkInfo gld ov = do let cReqSchemas = contactCReqSchemas crData cReqHashes = bimap contactCReqHash contactCReqHash cReqSchemas - withFastStore' (\db -> getGroupInfoByUserContactLinkConnReq db vr user cReqSchemas) >>= \case + withFastStore' (\db -> getGroupInfoByUserContactLinkConnReq db cxt user cReqSchemas) >>= \case Just g -> pure $ CPGroupLink (GLPOwnLink g) Nothing -> do - connEnt_ <- withFastStore' $ \db -> getContactConnEntityByConnReqHash db vr user cReqHashes - gInfo_ <- withFastStore' $ \db -> getGroupInfoByGroupLinkHash db vr user cReqHashes + connEnt_ <- withFastStore' $ \db -> getContactConnEntityByConnReqHash db cxt user cReqHashes + gInfo_ <- withFastStore' $ \db -> getGroupInfoByGroupLinkHash db cxt user cReqHashes case (gInfo_, connEnt_) of (Nothing, Nothing) -> pure $ CPGroupLink (GLPOk linkInfo gld ov) -- TODO [short links] RcvDirectMsgConnection branches are deprecated? (old group link protocol?) @@ -4274,7 +4274,7 @@ processChatCommand vr nm = \case shortenShortLink' =<< withAgent (\a -> setConnShortLink a nm (aConnId' conn) SCMInvitation userLinkData Nothing) updateCIGroupInvitationStatus :: User -> GroupInfo -> CIGroupInvitationStatus -> CM () updateCIGroupInvitationStatus user GroupInfo {groupId} newStatus = do - AChatItem _ _ cInfo ChatItem {content, meta = CIMeta {itemId}} <- withFastStore $ \db -> getChatItemByGroupId db vr user groupId + AChatItem _ _ cInfo ChatItem {content, meta = CIMeta {itemId}} <- withFastStore $ \db -> getChatItemByGroupId db cxt user groupId case (cInfo, content) of (DirectChat ct@Contact {contactId}, CIRcvGroupInvitation ciGroupInv@CIGroupInvitation {status} memRole) | status == CIGISPending -> do @@ -4297,7 +4297,7 @@ processChatCommand vr nm = \case sendContactContentMessages :: User -> ContactId -> Bool -> Maybe Int -> NonEmpty ComposedMessageReq -> CM ChatResponse sendContactContentMessages user contactId live itemTTL cmrs = do assertMultiSendable live cmrs - ct <- withFastStore $ \db -> getContact db vr user contactId + ct <- withFastStore $ \db -> getContact db cxt user contactId assertDirectAllowed user MDSnd ct XMsgNew_ assertVoiceAllowed ct processComposedMessages ct @@ -4354,8 +4354,8 @@ processChatCommand vr nm = \case sendGroupContentMessages :: User -> GroupInfo -> Maybe GroupChatScope -> ShowGroupAsSender -> Bool -> Maybe Int -> NonEmpty ComposedMessageReq -> CM ChatResponse sendGroupContentMessages user gInfo scope showGroupAsSender live itemTTL cmrs = do assertMultiSendable live cmrs - chatScopeInfo <- mapM (getChatScopeInfo vr user) scope - recipients <- getGroupRecipients vr user gInfo chatScopeInfo modsCompatVersion + chatScopeInfo <- mapM (getChatScopeInfo cxt user) scope + recipients <- getGroupRecipients cxt user gInfo chatScopeInfo modsCompatVersion sendGroupContentMessages_ user gInfo scope showGroupAsSender chatScopeInfo recipients live itemTTL cmrs where hasReport = any (\(ComposedMessage {msgContent}, _, _, _) -> isReport msgContent) cmrs @@ -4500,7 +4500,7 @@ processChatCommand vr nm = \case throwError err getCommandDirectChatItems :: User -> Int64 -> NonEmpty ChatItemId -> CM (Contact, [CChatItem 'CTDirect]) getCommandDirectChatItems user ctId itemIds = do - ct <- withFastStore $ \db -> getContact db vr user ctId + ct <- withFastStore $ \db -> getContact db cxt user ctId (errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getDirectCI db) (L.toList itemIds)) unless (null errs) $ toView $ CEvtChatErrors errs pure (ct, items) @@ -4509,7 +4509,7 @@ processChatCommand vr nm = \case getDirectCI db itemId = runExceptT . withExceptT ChatErrorStore $ getDirectChatItem db user ctId itemId getCommandGroupChatItems :: User -> Int64 -> NonEmpty ChatItemId -> CM (GroupInfo, [CChatItem 'CTGroup]) getCommandGroupChatItems user gId itemIds = do - gInfo <- withFastStore $ \db -> getGroupInfo db vr user gId + gInfo <- withFastStore $ \db -> getGroupInfo db cxt user gId (errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getGroupCI db gInfo) (L.toList itemIds)) unless (null errs) $ toView $ CEvtChatErrors errs pure (gInfo, items) @@ -4568,7 +4568,7 @@ processChatCommand vr nm = \case withSendRef user chatRef a = case chatRef of ChatRef CTDirect cId _ -> a $ SRDirect cId ChatRef CTGroup gId scope -> do - gInfo <- withFastStore $ \db -> getGroupInfo db vr user gId + gInfo <- withFastStore $ \db -> getGroupInfo db cxt user gId a $ SRGroup gId scope (sendAsGroup' gInfo scope) _ -> throwCmdError "not supported" getSharedMsgId :: CM SharedMsgId @@ -4759,17 +4759,17 @@ cleanupManager = do timedItems <- withStore' $ \db -> getTimedItems db user startTimedThreadCutoff forM_ timedItems $ \(itemRef, deleteAt) -> startTimedItemThread user itemRef deleteAt `catchAllErrors` const (pure ()) cleanupDeletedContacts user = do - vr <- chatVersionRange - contacts <- withStore' $ \db -> getDeletedContacts db vr user + cxt <- chatStoreCxt + contacts <- withStore' $ \db -> getDeletedContacts db cxt user forM_ contacts $ \ct -> withStore (\db -> deleteContactWithoutGroups db user ct) `catchAllErrors` eToView cleanupInProgressGroups user = do - vr <- chatVersionRange + cxt <- chatStoreCxt ts <- liftIO getCurrentTime -- older than 30 minutes to avoid deleting a newly created group let cutoffTs = addUTCTime (- 1800) ts - inProgressGroups <- withStore' $ \db -> getInProgressGroups db vr user cutoffTs + inProgressGroups <- withStore' $ \db -> getInProgressGroups db cxt user cutoffTs forM_ inProgressGroups $ \gInfo -> deleteInProgressGroup user gInfo `catchAllErrors` eToView cleanupStaleRelayTestConns user = do @@ -4814,8 +4814,8 @@ runRelayGroupLinkChecks user = do liftIO $ threadDelay' $ diffToMicroseconds interval where checkRelayServedGroups = do - vr <- chatVersionRange - relayGroups <- withStore' $ \db -> getRelayServedGroups db vr user + cxt <- chatStoreCxt + relayGroups <- withStore' $ \db -> getRelayServedGroups db cxt user forM_ relayGroups $ \gInfo@GroupInfo {groupProfile = gp} -> flip catchAllErrors eToView $ do case publicGroup gp of Just PublicGroupProfile {groupLink = sLnk} -> do @@ -4833,24 +4833,24 @@ runRelayGroupLinkChecks user = do _ -> pure () _ -> pure () checkRelayInactiveGroups = do - vr <- chatVersionRange + cxt <- chatStoreCxt ttl <- asks (relayInactiveTTL . config) - inactiveGroups <- withStore' $ \db -> getRelayInactiveGroups db vr user ttl + inactiveGroups <- withStore' $ \db -> getRelayInactiveGroups db cxt user ttl forM_ inactiveGroups $ \gInfo -> flip catchAllErrors eToView $ deleteGroupConnections user gInfo False expireChatItems :: User -> Int64 -> Bool -> CM () expireChatItems user@User {userId} globalTTL sync = do currentTs <- liftIO getCurrentTime - vr <- chatVersionRange + cxt <- chatStoreCxt -- this is to keep group messages created during last 12 hours even if they're expired according to item_ts let createdAtCutoff = addUTCTime (-43200 :: NominalDiffTime) currentTs lift waitChatStartedAndActivated contactIds <- withStore' $ \db -> getUserContactsToExpire db user globalTTL - loop contactIds $ expireContactChatItems user vr globalTTL + loop contactIds $ expireContactChatItems user cxt globalTTL lift waitChatStartedAndActivated groupIds <- withStore' $ \db -> getUserGroupsToExpire db user globalTTL - loop groupIds $ expireGroupChatItems user vr globalTTL createdAtCutoff + loop groupIds $ expireGroupChatItems user cxt globalTTL createdAtCutoff where loop :: [Int64] -> (Int64 -> CM ()) -> CM () loop [] _ = pure () @@ -4866,11 +4866,11 @@ expireChatItems user@User {userId} globalTTL sync = do expire <- atomically $ TM.lookup userId expireFlags when (expire == Just True) $ threadDelay 100000 >> a -expireContactChatItems :: User -> VersionRangeChat -> Int64 -> ContactId -> CM () -expireContactChatItems user vr globalTTL ctId = +expireContactChatItems :: User -> StoreCxt -> Int64 -> ContactId -> CM () +expireContactChatItems user cxt globalTTL ctId = -- reading contacts and groups inside the loop, -- to allow ttl changing while processing and to reduce memory usage - tryAllErrors (withStore $ \db -> getContact db vr user ctId) >>= mapM_ process + tryAllErrors (withStore $ \db -> getContact db cxt user ctId) >>= mapM_ process where process ct@Contact {chatItemTTL} = withExpirationDate globalTTL chatItemTTL $ \expirationDate -> do @@ -4879,9 +4879,9 @@ expireContactChatItems user vr globalTTL ctId = deleteCIFiles user filesInfo withStore' $ \db -> deleteContactExpiredCIs db user ct expirationDate -expireGroupChatItems :: User -> VersionRangeChat -> Int64 -> UTCTime -> GroupId -> CM () -expireGroupChatItems user vr globalTTL createdAtCutoff groupId = - tryAllErrors (withStore $ \db -> getGroupInfo db vr user groupId) >>= mapM_ process +expireGroupChatItems :: User -> StoreCxt -> Int64 -> UTCTime -> GroupId -> CM () +expireGroupChatItems user cxt globalTTL createdAtCutoff groupId = + tryAllErrors (withStore $ \db -> getGroupInfo db cxt user groupId) >>= mapM_ process where process gInfo@GroupInfo {chatItemTTL} = withExpirationDate globalTTL chatItemTTL $ \expirationDate -> do @@ -4889,7 +4889,7 @@ expireGroupChatItems user vr globalTTL createdAtCutoff groupId = filesInfo <- withStore' $ \db -> getGroupExpiredFileInfo db user gInfo expirationDate createdAtCutoff deleteCIFiles user filesInfo withStore' $ \db -> deleteGroupExpiredCIs db user gInfo expirationDate createdAtCutoff - membersToDelete <- withStore' $ \db -> getGroupMembersForExpiration db vr user gInfo + membersToDelete <- withStore' $ \db -> getGroupMembersForExpiration db cxt user gInfo forM_ membersToDelete $ \m -> withStore' $ \db -> deleteGroupMember db user m withExpirationDate :: Int64 -> Maybe Int64 -> (UTCTime -> CM ()) -> CM () diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 31a1d60502..f2c448d5b8 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -473,12 +473,12 @@ deleteGroupCIs user gInfo chatScopeInfo items byGroupMember_ deletedTs = do deleteCIFiles user ciFilesInfo (errs, deletions) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (deleteItem db) items) unless (null errs) $ toView $ CEvtChatErrors errs - vr <- chatVersionRange + cxt <- chatStoreCxt deletions' <- case chatScopeInfo of Nothing -> pure deletions Just scopeInfo@GCSIMemberSupport {groupMember_} -> do let decStats = countDeletedUnreadItems groupMember_ deletions - gInfo' <- withFastStore' $ \db -> updateGroupScopeUnreadStats db vr user gInfo scopeInfo decStats + gInfo' <- withFastStore' $ \db -> updateGroupScopeUnreadStats db cxt user gInfo scopeInfo decStats pure $ map (updateDeletionGroupInfo gInfo') deletions pure deletions' where @@ -689,7 +689,7 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI unless (fileStatus == RFSNew) $ case fileStatus of RFSCancelled _ -> throwChatError $ CEFileCancelled fName _ -> throwChatError $ CEFileAlreadyReceiving fName - vr <- chatVersionRange + cxt <- chatStoreCxt case (xftpRcvFile, fileConnReq) of -- XFTP (Just XFTPRcvFile {userApprovedRelays = approvedBeforeReady}, _) -> do @@ -698,7 +698,7 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI (ci, rfd) <- withStore $ \db -> do -- marking file as accepted and reading description in the same transaction -- to prevent race condition with appending description - ci <- xftpAcceptRcvFT db vr user fileId filePath userApproved + ci <- xftpAcceptRcvFT db cxt user fileId filePath userApproved rfd <- getRcvFileDescrByRcvFileId db fileId pure (ci, rfd) receiveViaCompleteFD user fileId rfd userApproved cryptoArgs @@ -709,10 +709,10 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI chatRef <- withStore $ \db -> getChatRefByFileId db user fileId case (chatRef, grpMemberId) of (ChatRef CTDirect contactId _, Nothing) -> do - ct <- withStore $ \db -> getContact db vr user contactId + ct <- withStore $ \db -> getContact db cxt user contactId acceptFile $ \msg -> void $ sendDirectContactMessage user ct msg (ChatRef CTGroup groupId _, Just memId) -> do - GroupMember {activeConn} <- withStore $ \db -> getGroupMember db vr user groupId memId + GroupMember {activeConn} <- withStore $ \db -> getGroupMember db cxt user groupId memId case activeConn of Just conn -> do acceptFile $ \msg -> void $ sendDirectMemberMessage conn msg groupId @@ -723,12 +723,12 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI acceptFile send = do filePath <- getRcvFilePath fileId filePath_ fName True inline <- receiveInline - vr <- chatVersionRange + cxt <- chatStoreCxt if | inline -> do -- accepting inline (ci, sharedMsgId) <- withStore $ \db -> - liftM2 (,) (acceptRcvInlineFT db vr user fileId filePath) (getSharedMsgIdByFileId db userId fileId) + liftM2 (,) (acceptRcvInlineFT db cxt user fileId filePath) (getSharedMsgIdByFileId db userId fileId) send $ XFileAcptInv sharedMsgId Nothing fName pure ci | fileInline == Just IFMSent -> throwChatError $ CEFileAlreadyReceiving fName @@ -804,13 +804,13 @@ getNetworkConfig = withAgent' $ liftIO . getFastNetworkConfig resetRcvCIFileStatus :: User -> FileTransferId -> CIFileStatus 'MDRcv -> CM (Maybe AChatItem) resetRcvCIFileStatus user fileId ciFileStatus = do - vr <- chatVersionRange + cxt <- chatStoreCxt withStore $ \db -> do liftIO $ do updateCIFileStatus db user fileId ciFileStatus updateRcvFileStatus db fileId FSNew updateRcvFileAgentId db fileId Nothing - lookupChatItemByFileId db vr user fileId + lookupChatItemByFileId db cxt user fileId receiveViaURI :: User -> FileDescriptionURI -> CryptoFile -> CM RcvFileTransfer receiveViaURI user@User {userId} FileDescriptionURI {description} cf@CryptoFile {cryptoArgs} = do @@ -828,11 +828,11 @@ receiveViaURI user@User {userId} FileDescriptionURI {description} cf@CryptoFile startReceivingFile :: User -> FileTransferId -> CM () startReceivingFile user fileId = do - vr <- chatVersionRange + cxt <- chatStoreCxt ci <- withStore $ \db -> do liftIO $ updateRcvFileStatus db fileId FSConnected liftIO $ updateCIFileStatus db user fileId $ CIFSRcvTransfer 0 1 - getChatItemByFileId db vr user fileId + getChatItemByFileId db cxt user fileId toView $ CEvtRcvFileStart user ci getRcvFilePath :: FileTransferId -> Maybe FilePath -> String -> Bool -> CM FilePath @@ -883,8 +883,8 @@ acceptContactRequest nm user@User {userId} UserContactRequest {agentInvitationId subMode <- chatReadVar subscriptionMode let pqSup = PQSupportOn pqSup' = pqSup `CR.pqSupportAnd` pqSupport - vr <- chatVersionRange - let chatV = vr `peerConnChatVersion` cReqChatVRange + cxt <- chatStoreCxt + let chatV = vr cxt `peerConnChatVersion` cReqChatVRange (ct, conn, incognitoProfile) <- case contactId_ of Nothing -> do incognitoProfile <- if incognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing @@ -893,7 +893,7 @@ acceptContactRequest nm user@User {userId} UserContactRequest {agentInvitationId createContactFromRequest db user userContactLinkId_ connId chatV cReqChatVRange cName profileId cp xContactId incognitoProfile subMode pqSup' False pure (ct, conn, incognitoProfile) Just contactId -> do - ct <- withFastStore $ \db -> getContact db vr user contactId + ct <- withFastStore $ \db -> getContact db cxt user contactId case contactConn ct of Nothing -> do incognitoProfile <- if incognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing @@ -920,15 +920,15 @@ acceptContactRequestAsync incognitoProfile = do subMode <- chatReadVar subscriptionMode let profileToSend = userProfileDirect user (fromIncognitoProfile <$> incognitoProfile) (Just ct) True - vr <- chatVersionRange - let chatV = vr `peerConnChatVersion` cReqChatVRange + cxt <- chatStoreCxt + let chatV = vr cxt `peerConnChatVersion` cReqChatVRange (cmdId, acId) <- agentAcceptContactAsync user True cReqInvId (XInfo profileToSend) subMode cReqPQSup chatV currentTs <- liftIO getCurrentTime withStore $ \db -> do forM_ xContactId $ \xcId -> liftIO $ setContactAcceptedXContactId db ct xcId Connection {connId} <- liftIO $ createAcceptedContactConn db user (Just uclId) contactId acId chatV cReqChatVRange cReqPQSup incognitoProfile subMode currentTs liftIO $ setCommandConnId db user cmdId connId - getContact db vr user contactId + getContact db cxt user contactId acceptGroupJoinRequestAsync :: User -> Int64 -> GroupInfo -> InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe MemberId -> Maybe SharedMsgId -> GroupAcceptance -> GroupMemberRole -> Maybe IncognitoProfile -> Maybe MemberKey -> CM GroupMember acceptGroupJoinRequestAsync @@ -964,12 +964,12 @@ acceptGroupJoinRequestAsync groupSize = Just currentMemCount } subMode <- chatReadVar subscriptionMode - vr <- chatVersionRange - let chatV = vr `peerConnChatVersion` cReqChatVRange + cxt <- chatStoreCxt + let chatV = vr cxt `peerConnChatVersion` cReqChatVRange connIds <- agentAcceptContactAsync user True cReqInvId msg subMode PQSupportOff chatV withStore $ \db -> do liftIO $ createJoiningMemberConnection db user uclId connIds chatV cReqChatVRange groupMemberId subMode - getGroupMemberById db vr user groupMemberId + getGroupMemberById db cxt user groupMemberId acceptGroupJoinSendRejectAsync :: User -> Int64 -> GroupInfo -> InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> GroupRejectionReason -> CM GroupMember acceptGroupJoinSendRejectAsync @@ -994,12 +994,12 @@ acceptGroupJoinSendRejectAsync rejectionReason } subMode <- chatReadVar subscriptionMode - vr <- chatVersionRange - let chatV = vr `peerConnChatVersion` cReqChatVRange + cxt <- chatStoreCxt + let chatV = vr cxt `peerConnChatVersion` cReqChatVRange connIds <- agentAcceptContactAsync user False cReqInvId msg subMode PQSupportOff chatV withStore $ \db -> do liftIO $ createJoiningMemberConnection db user uclId connIds chatV cReqChatVRange groupMemberId subMode - getGroupMemberById db vr user groupMemberId + getGroupMemberById db cxt user groupMemberId acceptBusinessJoinRequestAsync :: User -> Int64 -> GroupInfo -> GroupMember -> UserContactRequest -> CM (GroupInfo, GroupMember) acceptBusinessJoinRequestAsync @@ -1008,7 +1008,7 @@ acceptBusinessJoinRequestAsync gInfo@GroupInfo {membership = GroupMember {memberRole = userRole, memberId = userMemberId}} clientMember@GroupMember {groupMemberId, memberId} UserContactRequest {agentInvitationId = AgentInvId cReqInvId, cReqChatVRange, xContactId} = do - vr <- chatVersionRange + cxt <- chatStoreCxt let userProfile@Profile {displayName, preferences} = fromLocalProfile $ profile' user -- TODO [short links] take groupPreferences from group info groupPreferences = maybe defaultBusinessGroupPrefs businessGroupPrefs preferences @@ -1027,7 +1027,7 @@ acceptBusinessJoinRequestAsync groupSize = Just 1 } subMode <- chatReadVar subscriptionMode - let chatV = vr `peerConnChatVersion` cReqChatVRange + let chatV = vr cxt `peerConnChatVersion` cReqChatVRange connIds <- agentAcceptContactAsync user True cReqInvId msg subMode PQSupportOff chatV withStore' $ \db -> do forM_ xContactId $ \xcId -> setBusinessChatAcceptedXContactId db gInfo xcId @@ -1051,28 +1051,28 @@ acceptRelayJoinRequestAsync -- TODO [channel web] derive RelayCapabilities from relay config (RelayWebOptions) let msg = XGrpRelayAcpt relayLink defaultRelayCapabilities subMode <- chatReadVar subscriptionMode - vr <- chatVersionRange - let chatV = vr `peerConnChatVersion` cReqChatVRange + cxt <- chatStoreCxt + let chatV = vr cxt `peerConnChatVersion` cReqChatVRange connIds <- agentAcceptContactAsync user True cReqInvId msg subMode PQSupportOff chatV withStore $ \db -> do liftIO $ createJoiningMemberConnection db user uclId connIds chatV cReqChatVRange groupMemberId subMode gInfo' <- liftIO $ updateRelayOwnStatusFromTo db gInfo RSInvited RSAccepted - ownerMember' <- getGroupMemberById db vr user groupMemberId + ownerMember' <- getGroupMemberById db cxt user groupMemberId pure (gInfo', ownerMember') rejectRelayInvitationAsync :: User -> Int64 - -> VersionRangeChat + -> StoreCxt -> GroupRelayInvitation -> InvitationId -> VersionRangeChat -> Int64 -> RelayRejectionReason -> CM () -rejectRelayInvitationAsync user uclId vr groupRelayInv invId reqChatVRange initialDelay reason = do +rejectRelayInvitationAsync user uclId cxt groupRelayInv invId reqChatVRange initialDelay reason = do (_gInfo, ownerMember) <- withStore $ \db -> - createRelayRequestGroup db vr user groupRelayInv invId reqChatVRange initialDelay GSMemInvited RSRejected + createRelayRequestGroup db cxt user groupRelayInv invId reqChatVRange initialDelay GSMemInvited RSRejected let GroupMember {groupMemberId} = ownerMember msg = XGrpRelayReject reason subMode <- chatReadVar subscriptionMode @@ -1086,15 +1086,15 @@ businessGroupProfile :: Profile -> GroupPreferences -> GroupProfile businessGroupProfile Profile {displayName, fullName, shortDescr, image} groupPreferences = GroupProfile {displayName, fullName, description = Nothing, shortDescr, image, publicGroup = Nothing, groupPreferences = Just groupPreferences, memberAdmission = Nothing} -introduceToModerators :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM () -introduceToModerators vr user gInfo@GroupInfo {groupId} m@GroupMember {memberRole, memberId} = do +introduceToModerators :: StoreCxt -> User -> GroupInfo -> GroupMember -> CM () +introduceToModerators cxt user gInfo@GroupInfo {groupId} m@GroupMember {memberRole, memberId} = do forM_ (memberConn m) $ \mConn -> do let msg = if maxVersion (memberChatVRange m) >= groupKnockingVersion then XGrpLinkAcpt GAPendingReview memberRole memberId else XMsgNew $ mcSimple (MCText pendingReviewMessage) void $ sendDirectMemberMessage mConn msg groupId - modMs <- withStore' $ \db -> getGroupModerators db vr user gInfo + modMs <- withStore' $ \db -> getGroupModerators db cxt user gInfo let rcpModMs = filter shouldIntroduceToMod modMs introduceMember user gInfo m rcpModMs (Just $ MSMember $ memberId' m) where @@ -1104,15 +1104,15 @@ introduceToModerators vr user gInfo@GroupInfo {groupId} m@GroupMember {memberRol && groupMemberId' mem /= groupMemberId' m && maxVersion (memberChatVRange mem) >= groupKnockingVersion -introduceToAll :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM () -introduceToAll vr user gInfo m = do - (members, vector) <- withStore $ \db -> liftM2 (,) (liftIO $ getGroupMembers db vr user gInfo) (getMemberRelationsVector db m) +introduceToAll :: StoreCxt -> User -> GroupInfo -> GroupMember -> CM () +introduceToAll cxt user gInfo m = do + (members, vector) <- withStore $ \db -> liftM2 (,) (liftIO $ getGroupMembers db cxt user gInfo) (getMemberRelationsVector db m) let recipients = filter (shouldIntroduce m vector) members introduceMember user gInfo m recipients Nothing -introduceToRemaining :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM () -introduceToRemaining vr user gInfo m = do - (members, vector) <- withStore $ \db -> liftM2 (,) (liftIO $ getGroupMembers db vr user gInfo) (getMemberRelationsVector db m) +introduceToRemaining :: StoreCxt -> User -> GroupInfo -> GroupMember -> CM () +introduceToRemaining cxt user gInfo m = do + (members, vector) <- withStore $ \db -> liftM2 (,) (liftIO $ getGroupMembers db cxt user gInfo) (getMemberRelationsVector db m) let recipients = filter (shouldIntroduce m vector) members introduceMember user gInfo m recipients Nothing @@ -1166,10 +1166,10 @@ memberIntroEvt gInfo reMember = -- Used in groups with relays to introduce moderators and above to a new member, -- and to announce the new member to moderators and above. -- This doesn't create introduction records in db, compared to above methods. -introduceInChannel :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM () +introduceInChannel :: StoreCxt -> User -> GroupInfo -> GroupMember -> CM () introduceInChannel _ _ _ GroupMember {activeConn = Nothing} = throwChatError $ CEInternalError "member connection not active" -introduceInChannel vr user gInfo subscriber@GroupMember {activeConn = Just conn} = do - modMs <- withStore' $ \db -> getGroupModerators db vr user gInfo +introduceInChannel cxt user gInfo subscriber@GroupMember {activeConn = Just conn} = do + modMs <- withStore' $ \db -> getGroupModerators db cxt user gInfo void $ sendGroupMessage' user gInfo modMs $ XGrpMemNew (memberInfo gInfo subscriber) Nothing let introEvts = map (memberIntroEvt gInfo) modMs forM_ (L.nonEmpty introEvts) $ \introEvts' -> @@ -1328,9 +1328,9 @@ setGroupLinkData' nm user gInfo = setGroupLinkData :: NetworkRequestMode -> User -> GroupInfo -> GroupLink -> CM GroupLink setGroupLinkData nm user gInfo gLink = do - vr <- chatVersionRange + cxt <- chatStoreCxt (conn, groupRelays) <- withFastStore $ \db -> - (,) <$> getGroupLinkConnection db vr user gInfo <*> liftIO (getConnectedGroupRelays db gInfo) + (,) <$> getGroupLinkConnection db cxt user gInfo <*> liftIO (getConnectedGroupRelays db gInfo) let (userLinkData, crClientData) = groupLinkData gInfo gLink groupRelays linkType = if useRelays' gInfo then CCTChannel else CCTGroup sLnk <- shortenShortLink' . setShortLinkType_ linkType =<< withAgent (\a -> setConnShortLink a nm (aConnId conn) SCMContact userLinkData (Just crClientData)) @@ -1338,17 +1338,17 @@ setGroupLinkData nm user gInfo gLink = do setGroupLinkDataAsync :: User -> GroupInfo -> GroupLink -> CM () setGroupLinkDataAsync user gInfo gLink = do - vr <- chatVersionRange + cxt <- chatStoreCxt (conn, groupRelays) <- withStore $ \db -> - (,) <$> getGroupLinkConnection db vr user gInfo <*> liftIO (getConnectedGroupRelays db gInfo) + (,) <$> getGroupLinkConnection db cxt user gInfo <*> liftIO (getConnectedGroupRelays db gInfo) let (userLinkData, crClientData) = groupLinkData gInfo gLink groupRelays setAgentConnShortLinkAsync user conn userLinkData (Just crClientData) connectToRelayAsync :: User -> GroupInfo -> ShortLinkContact -> CM () connectToRelayAsync user gInfo relayLink = do - vr <- chatVersionRange + cxt <- chatStoreCxt gVar <- asks random - relayMember@GroupMember {activeConn} <- withFastStore $ \db -> getCreateRelayForMember db vr gVar user gInfo relayLink + relayMember@GroupMember {activeConn} <- withFastStore $ \db -> getCreateRelayForMember db cxt gVar user gInfo relayLink case activeConn of Just _ -> pure () Nothing -> do @@ -1359,9 +1359,9 @@ connectToRelayAsync user gInfo relayLink = do updatePublicGroupData :: User -> GroupInfo -> CM GroupInfo updatePublicGroupData user gInfo | useRelays' gInfo && memberRole' (membership gInfo) == GROwner = do - vr <- chatVersionRange + cxt <- chatStoreCxt (gInfo', gLink) <- withStore $ \db -> do - gInfo' <- updatePublicMemberCount db vr user gInfo + gInfo' <- updatePublicMemberCount db cxt user gInfo gLink <- getGroupLink db user gInfo' pure (gInfo', gLink) setGroupLinkDataAsync user gInfo' gLink @@ -1371,12 +1371,12 @@ updatePublicGroupData user gInfo updateGroupFromLinkData :: User -> GroupInfo -> GroupShortLinkData -> CM (GroupInfo, Bool) updateGroupFromLinkData user gInfo@GroupInfo {groupProfile = p, groupSummary = GroupSummary {publicMemberCount = localCount}} GroupShortLinkData {groupProfile, publicGroupData} | profileChanged || countChanged = do - vr <- chatVersionRange + cxt <- chatStoreCxt withStore $ \db -> do g <- if profileChanged then updateGroupProfile db user gInfo groupProfile else pure gInfo g' <- case publicGroupData of Just PublicGroupData {publicMemberCount} | countChanged -> - setPublicMemberCount db vr user g publicMemberCount + setPublicMemberCount db cxt user g publicMemberCount _ -> pure g pure (g', profileChanged) | otherwise = pure (gInfo, False) @@ -1455,14 +1455,14 @@ shortenCreatedLink (CCLink cReq sLnk) = CCLink cReq <$> mapM shortenShortLink' s deleteGroupLink' :: User -> GroupInfo -> CM () deleteGroupLink' user gInfo = do - vr <- chatVersionRange - conn <- withStore $ \db -> getGroupLinkConnection db vr user gInfo + cxt <- chatStoreCxt + conn <- withStore $ \db -> getGroupLinkConnection db cxt user gInfo deleteGroupLink_ user gInfo conn deleteGroupLinkIfExists :: User -> GroupInfo -> CM () deleteGroupLinkIfExists user gInfo = do - vr <- chatVersionRange - conn_ <- eitherToMaybe <$> withStore' (\db -> runExceptT $ getGroupLinkConnection db vr user gInfo) + cxt <- chatStoreCxt + conn_ <- eitherToMaybe <$> withStore' (\db -> runExceptT $ getGroupLinkConnection db cxt user gInfo) mapM_ (deleteGroupLink_ user gInfo) conn_ deleteGroupLink_ :: User -> GroupInfo -> Connection -> CM () @@ -1497,16 +1497,16 @@ deleteTimedItem user (ChatRef cType chatId scope, itemId) deleteAt = do ts <- liftIO getCurrentTime liftIO $ threadDelay' $ diffToMicroseconds $ diffUTCTime deleteAt ts lift waitChatStartedAndActivated - vr <- chatVersionRange + cxt <- chatStoreCxt case cType of CTDirect -> do - (ct, ci) <- withStore $ \db -> (,) <$> getContact db vr user chatId <*> getDirectChatItem db user chatId itemId + (ct, ci) <- withStore $ \db -> (,) <$> getContact db cxt user chatId <*> getDirectChatItem db user chatId itemId deletions <- deleteDirectCIs user ct [ci] toView $ CEvtChatItemsDeleted user deletions True True CTGroup -> do - (gInfo, ci) <- withStore $ \db -> (,) <$> getGroupInfo db vr user chatId <*> getGroupChatItem db user chatId itemId + (gInfo, ci) <- withStore $ \db -> (,) <$> getGroupInfo db cxt user chatId <*> getGroupChatItem db user chatId itemId deletedTs <- liftIO getCurrentTime - chatScopeInfo <- mapM (getChatScopeInfo vr user) scope + chatScopeInfo <- mapM (getChatScopeInfo cxt user) scope deletions <- deleteGroupCIs user gInfo chatScopeInfo [ci] Nothing deletedTs toView $ CEvtChatItemsDeleted user deletions True True _ -> eToView $ ChatError $ CEInternalError "bad deleteTimedItem cType" @@ -1623,25 +1623,25 @@ parseChatMessage conn s = do errType = CEInvalidChatMessage conn Nothing (safeDecodeUtf8 s) {-# INLINE parseChatMessage #-} -getChatScopeInfo :: VersionRangeChat -> User -> GroupChatScope -> CM GroupChatScopeInfo -getChatScopeInfo vr user = \case +getChatScopeInfo :: StoreCxt -> User -> GroupChatScope -> CM GroupChatScopeInfo +getChatScopeInfo cxt user = \case GCSMemberSupport Nothing -> pure $ GCSIMemberSupport Nothing GCSMemberSupport (Just gmId) -> do - supportMem <- withFastStore $ \db -> getGroupMemberById db vr user gmId + supportMem <- withFastStore $ \db -> getGroupMemberById db cxt user gmId pure $ GCSIMemberSupport (Just supportMem) -getGroupRecipients :: VersionRangeChat -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> VersionChat -> CM [GroupMember] -getGroupRecipients vr user gInfo@GroupInfo {membership} scopeInfo modsCompatVersion +getGroupRecipients :: StoreCxt -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> VersionChat -> CM [GroupMember] +getGroupRecipients cxt user gInfo@GroupInfo {membership} scopeInfo modsCompatVersion | useRelays' gInfo && not (isRelay membership) = do unless (memberCurrent membership && memberActive membership) $ throwChatError $ CECommandError "not current member" - withFastStore' $ \db -> getGroupRelayMembers db vr user gInfo + withFastStore' $ \db -> getGroupRelayMembers db cxt user gInfo | otherwise = case scopeInfo of Nothing -> do unless (memberCurrent membership && memberActive membership) $ throwChatError $ CECommandError "not current member" - ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo + ms <- withFastStore' $ \db -> getGroupMembers db cxt user gInfo pure $ filter memberCurrent ms Just (GCSIMemberSupport Nothing) -> do - modMs <- withFastStore' $ \db -> getGroupModerators db vr user gInfo + modMs <- withFastStore' $ \db -> getGroupModerators db cxt user gInfo let rcpModMs' = filter (\m -> compatible m && memberCurrent m) modMs when (null rcpModMs') $ throwChatError $ CECommandError "no admins support this message" pure rcpModMs' @@ -1651,7 +1651,7 @@ getGroupRecipients vr user gInfo@GroupInfo {membership} scopeInfo modsCompatVers if memberStatus supportMem == GSMemPendingApproval then pure [supportMem] else do - modMs <- withFastStore' $ \db -> getGroupModerators db vr user gInfo + modMs <- withFastStore' $ \db -> getGroupModerators db cxt user gInfo let rcpModMs' = filter (\m -> compatible m && memberCurrent m) modMs pure $ [supportMem] <> rcpModMs' where @@ -1677,8 +1677,8 @@ mkGroupChatScope gInfo@GroupInfo {membership} m | otherwise = pure (gInfo, m, Nothing) -mkGetMessageChatScope :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> MsgContent -> Maybe MsgScope -> CM (GroupInfo, GroupMember, Maybe GroupChatScopeInfo) -mkGetMessageChatScope vr user gInfo@GroupInfo {membership} m mc msgScope_ = +mkGetMessageChatScope :: StoreCxt -> User -> GroupInfo -> GroupMember -> MsgContent -> Maybe MsgScope -> CM (GroupInfo, GroupMember, Maybe GroupChatScopeInfo) +mkGetMessageChatScope cxt user gInfo@GroupInfo {membership} m mc msgScope_ = mkGroupChatScope gInfo m >>= \case groupScope@(_gInfo', _m', Just _scopeInfo) -> pure groupScope (_, _, Nothing) @@ -1693,7 +1693,7 @@ mkGetMessageChatScope vr user gInfo@GroupInfo {membership} m mc msgScope_ = (gInfo', scopeInfo) <- mkGroupSupportChatInfo gInfo pure (gInfo', m, Just scopeInfo) | otherwise -> do - referredMember <- withStore $ \db -> getGroupMemberByMemberId db vr user gInfo mId + referredMember <- withStore $ \db -> getGroupMemberByMemberId db cxt user gInfo mId -- TODO [knocking] return patched _referredMember'? (_referredMember', scopeInfo) <- mkMemberSupportChatInfo referredMember pure (gInfo, m, Just scopeInfo) @@ -1807,8 +1807,8 @@ cancelSndFileTransfer user@User {userId} ft@SndFileTransfer {fileId, connId, fil withStore' $ \db -> updateSndFileStatus db ft FSCancelled when sendCancel $ case fileInline of Just _ -> do - vr <- chatVersionRange - (sharedMsgId, conn) <- withStore $ \db -> (,) <$> getSharedMsgIdByFileId db userId fileId <*> getConnectionById db vr user connId + cxt <- chatStoreCxt + (sharedMsgId, conn) <- withStore $ \db -> (,) <$> getSharedMsgIdByFileId db userId fileId <*> getConnectionById db cxt user connId void $ sendDirectMessage_ conn (BFileChunk sharedMsgId FileChunkCancel) (ConnectionId connId) _ -> throwChatError $ CEException "cancelSndFileTransfer: cancelling file via a separate connection is deprecated" @@ -1992,13 +1992,13 @@ batchSndMessagesJSON mode = batchMessages mode maxEncodedMsgLength . L.toList encodeConnInfo :: MsgEncodingI e => ChatMsgEvent e -> CM ByteString encodeConnInfo chatMsgEvent = do - vr <- chatVersionRange - encodeConnInfoPQ PQSupportOff (maxVersion vr) chatMsgEvent + cxt <- chatStoreCxt + encodeConnInfoPQ PQSupportOff (maxVersion (vr cxt)) chatMsgEvent encodeConnInfoPQ :: MsgEncodingI e => PQSupport -> VersionChat -> ChatMsgEvent e -> CM ByteString encodeConnInfoPQ pqSup v chatMsgEvent = do - vr <- chatVersionRange - let info = ChatMessage {chatVRange = vr, msgId = Nothing, chatMsgEvent} + cxt <- chatStoreCxt + let info = ChatMessage {chatVRange = vr cxt, msgId = Nothing, chatMsgEvent} case encodeChatMessage maxEncodedInfoLength info of ECMEncoded connInfo -> case pqSup of PQSupportOn | v >= pqEncryptionCompressionVersion && B.length connInfo > maxCompressedInfoLength -> do @@ -2312,8 +2312,8 @@ saveGroupRcvMsg user groupId authorMember conn@Connection {connId} agentMsgMeta withStore (\db -> createNewMessageAndRcvMsgDelivery db (GroupId groupId) newMsg sharedMsgId_ rcvMsgDelivery $ Just amGroupMemId) `catchAllErrors` \e -> case e of ChatErrorStore (SEDuplicateGroupMessage _ _ _ (Just forwardedByGroupMemberId)) -> do - vr <- chatVersionRange - fm <- withStore $ \db -> getGroupMember db vr user groupId forwardedByGroupMemberId + cxt <- chatStoreCxt + fm <- withStore $ \db -> getGroupMember db cxt user groupId forwardedByGroupMemberId forM_ (memberConn fm) $ \fmConn -> void $ sendDirectMemberMessage fmConn (XGrpMemCon amMemId) groupId throwError e @@ -2333,8 +2333,8 @@ saveGroupFwdRcvMsg user gInfo@GroupInfo {groupId} forwardingMember refAuthorMemb | useRelays' gInfo -> pure Nothing -- with chat relays, duplicates are expected | otherwise -> case (authorGroupMemberId, forwardedByGroupMemberId) of (Just authorGMId, Nothing) -> do - vr <- chatVersionRange - am@GroupMember {memberId = amMemberId} <- withStore $ \db -> getGroupMember db vr user groupId authorGMId + cxt <- chatStoreCxt + am@GroupMember {memberId = amMemberId} <- withStore $ \db -> getGroupMember db cxt user groupId authorGMId if maybe False (\ref -> sameMemberId (memberId' ref) am) refAuthorMember_ then forM_ (memberConn forwardingMember) $ \fmConn -> void $ sendDirectMemberMessage fmConn (XGrpMemCon amMemberId) groupId @@ -2376,9 +2376,9 @@ saveSndChatItems :: CM [Either ChatError (ChatItem c 'MDSnd)] saveSndChatItems user cd showGroupAsSender itemsData itemTimed live = do createdAt <- liftIO getCurrentTime - vr <- chatVersionRange + cxt <- chatStoreCxt when (contactChatDeleted cd || any (\NewSndChatItemData {content} -> ciRequiresAttention content) (rights itemsData)) $ - void (withStore' $ \db -> updateChatTsStats db vr user cd createdAt Nothing) + void (withStore' $ \db -> updateChatTsStats db cxt user cd createdAt Nothing) lift $ withStoreBatch (\db -> map (bindRight $ createItem db createdAt) itemsData) where createItem :: DB.Connection -> UTCTime -> NewSndChatItemData c -> IO (Either ChatError (ChatItem c 'MDSnd)) @@ -2404,14 +2404,14 @@ ciContentNoParse content = (content, (ciContentToText content, Nothing)) saveRcvChatItem' :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> UTCTime -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) -> Maybe (CIFile 'MDRcv) -> Maybe CITimed -> Bool -> Map MemberName MsgMention -> CM (ChatItem c 'MDRcv, ChatInfo c) saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, msgSigned, forwardedByMember} sharedMsgId_ brokerTs (content, (t, ft_)) ciFile itemTimed live mentions = do createdAt <- liftIO getCurrentTime - vr <- chatVersionRange + cxt <- chatStoreCxt withStore' $ \db -> do (mentions' :: Map MemberName CIMention, userMention) <- case toChatInfo cd of GroupChat g@GroupInfo {membership} _ -> groupMentions db g membership _ -> pure (M.empty, False) cInfo' <- if (ciRequiresAttention content || contactChatDeleted cd) - then updateChatTsStats db vr user cd createdAt (memberChatStats userMention) + then updateChatTsStats db cxt user cd createdAt (memberChatStats userMention) else pure $ toChatInfo cd let showAsGroup = case cd of CDChannelRcv {} -> True; _ -> False hasLink_ = ciContentHasLink content ft_ @@ -2704,13 +2704,13 @@ createChatItems :: createChatItems user itemTs_ dirsCIContents = do createdAt <- liftIO getCurrentTime let itemTs = fromMaybe createdAt itemTs_ - vr <- chatVersionRange' - void . withStoreBatch' $ \db -> map (updateChat db vr createdAt) dirsCIContents + cxt <- chatStoreCxt' + void . withStoreBatch' $ \db -> map (updateChat db cxt createdAt) dirsCIContents withStoreBatch' $ \db -> concatMap (createACIs db itemTs createdAt) dirsCIContents where - updateChat :: DB.Connection -> VersionRangeChat -> UTCTime -> (ChatDirection c d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId)]) -> IO () - updateChat db vr createdAt (cd, _, contents) - | any (ciRequiresAttention . fst) contents || contactChatDeleted cd = void $ updateChatTsStats db vr user cd createdAt memberChatStats + updateChat :: DB.Connection -> StoreCxt -> UTCTime -> (ChatDirection c d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId)]) -> IO () + updateChat db cxt createdAt (cd, _, contents) + | any (ciRequiresAttention . fst) contents || contactChatDeleted cd = void $ updateChatTsStats db cxt user cd createdAt memberChatStats | otherwise = pure () where memberChatStats :: Maybe (Int, MemberAttention, Int) @@ -2749,8 +2749,8 @@ createLocalChatItems :: UTCTime -> CM [ChatItem 'CTLocal 'MDSnd] createLocalChatItems user cd itemsData createdAt = do - vr <- chatVersionRange - void $ withStore' $ \db -> updateChatTsStats db vr user cd createdAt Nothing + cxt <- chatStoreCxt + void $ withStore' $ \db -> updateChatTsStats db cxt user cd createdAt Nothing (errs, items) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (createItem db) $ L.toList itemsData) unless (null errs) $ toView $ CEvtChatErrors errs pure items @@ -2800,6 +2800,14 @@ waitChatStartedAndActivated = do activated <- readTVar chatActivated unless (isJust started && activated) retry +chatStoreCxt :: CM StoreCxt +chatStoreCxt = lift chatStoreCxt' +{-# INLINE chatStoreCxt #-} + +chatStoreCxt' :: CM' StoreCxt +chatStoreCxt' = mkStoreCxt <$> asks config +{-# INLINE chatStoreCxt' #-} + chatVersionRange :: CM VersionRangeChat chatVersionRange = lift chatVersionRange' {-# INLINE chatVersionRange #-} diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 478c53c763..e25e665bea 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -115,10 +115,10 @@ processAgentMessage _ "" (ERR e) = processAgentMessage corrId connId msg = do lockEntity <- critical connId (withStore (`getChatLockEntity` AgentConnId connId)) withEntityLock "processAgentMessage" lockEntity $ do - vr <- chatVersionRange + cxt <- chatStoreCxt -- getUserByAConnId never throws logical errors, only SEDBBusyError can be thrown here critical connId (withStore' (`getUserByAConnId` AgentConnId connId)) >>= \case - Just user -> processAgentMessageConn vr user corrId connId msg `catchAllErrors` eToView + Just user -> processAgentMessageConn cxt user corrId connId msg `catchAllErrors` eToView _ -> throwChatError $ CENoConnectionUser (AgentConnId connId) -- CRITICAL error will be shown to the user as alert with restart button in Android/desktop apps. @@ -169,27 +169,27 @@ processAgentMsgSndFile _corrId aFileId msg = do process :: User -> FileTransferId -> CM () process user fileId = do (ft@FileTransferMeta {xftpRedirectFor, cancelled}, sfts) <- withStore $ \db -> getSndFileTransfer db user fileId - vr <- chatVersionRange + cxt <- chatStoreCxt unless cancelled $ case msg of SFPROG sndProgress sndTotal -> do let status = CIFSSndTransfer {sndProgress, sndTotal} ci <- withStore $ \db -> do liftIO $ updateCIFileStatus db user fileId status - lookupChatItemByFileId db vr user fileId + lookupChatItemByFileId db cxt user fileId toView $ CEvtSndFileProgressXFTP user ci ft sndProgress sndTotal SFDONE sndDescr rfds -> do withStore' $ \db -> setSndFTPrivateSndDescr db user fileId (fileDescrText sndDescr) - ci <- withStore $ \db -> lookupChatItemByFileId db vr user fileId + ci <- withStore $ \db -> lookupChatItemByFileId db cxt user fileId case ci of Nothing -> do lift $ withAgent' (`xftpDeleteSndFileInternal` aFileId) withStore' $ \db -> createExtraSndFTDescrs db user fileId (map fileDescrText rfds) case rfds of - [] -> sendFileError (FileErrOther "no receiver descriptions") "no receiver descriptions" vr ft + [] -> sendFileError (FileErrOther "no receiver descriptions") "no receiver descriptions" cxt ft rfd : _ -> case [fd | fd@(FD.ValidFileDescription FD.FileDescription {chunks = [_]}) <- rfds] of [] -> case xftpRedirectFor of Nothing -> xftpSndFileRedirect user fileId rfd >>= toView . CEvtSndFileRedirectStartXFTP user ft - Just _ -> sendFileError (FileErrOther "chaining redirects") "Prohibit chaining redirects" vr ft + Just _ -> sendFileError (FileErrOther "chaining redirects") "Prohibit chaining redirects" cxt ft rfds' -> do -- we have 1 chunk - use it as URI whether it is redirect or not ft' <- maybe (pure ft) (\fId -> withStore $ \db -> getFileTransferMeta db user fId) xftpRedirectFor @@ -222,13 +222,13 @@ processAgentMsgSndFile _corrId aFileId msg = do sendFileDescriptions (GroupId groupId) rfdsMemberFTs' sharedMsgId ci' <- withStore $ \db -> do liftIO $ updateCIFileStatus db user fileId CIFSSndComplete - getChatItemByFileId db vr user fileId + getChatItemByFileId db cxt user fileId lift $ withAgent' (`xftpDeleteSndFileInternal` aFileId) toView $ CEvtSndFileCompleteXFTP user ci' ft where getRecipients - | useRelays' g = withStore' $ \db -> getGroupRelayMembers db vr user g - | otherwise = withStore' $ \db -> getGroupMembers db vr user g + | useRelays' g = withStore' $ \db -> getGroupRelayMembers db cxt user g + | otherwise = withStore' $ \db -> getGroupMembers db cxt user g memberFTs :: [GroupMember] -> [(Connection, SndFileTransfer)] memberFTs ms = M.elems $ M.intersectionWith (,) (M.fromList mConns') (M.fromList sfts') where @@ -241,10 +241,10 @@ processAgentMsgSndFile _corrId aFileId msg = do logWarn $ "Sent file warning: " <> err ci <- withStore $ \db -> do liftIO $ updateCIFileStatus db user fileId (CIFSSndWarning $ agentFileError e) - lookupChatItemByFileId db vr user fileId + lookupChatItemByFileId db cxt user fileId toView $ CEvtSndFileWarning user ci ft err SFERR e -> - sendFileError (agentFileError e) (tshow e) vr ft + sendFileError (agentFileError e) (tshow e) cxt ft where fileDescrText :: FilePartyI p => ValidFileDescription p -> T.Text fileDescrText = safeDecodeUtf8 . strEncode @@ -269,12 +269,12 @@ processAgentMsgSndFile _corrId aFileId msg = do toMsgReq :: (Connection, (ConnOrGroupId, Maybe MsgSigning, ChatMsgEvent 'Json)) -> SndMessage -> ChatMsgReq toMsgReq (conn, _) SndMessage {msgId, msgBody} = (conn, MsgFlags {notification = hasNotification XMsgFileDescr_}, (vrValue msgBody, [msgId])) - sendFileError :: FileError -> Text -> VersionRangeChat -> FileTransferMeta -> CM () - sendFileError ferr err vr ft = do + sendFileError :: FileError -> Text -> StoreCxt -> FileTransferMeta -> CM () + sendFileError ferr err cxt ft = do logError $ "Sent file error: " <> err ci <- withStore $ \db -> do liftIO $ updateFileCancelled db user fileId (CIFSSndError ferr) - lookupChatItemByFileId db vr user fileId + lookupChatItemByFileId db cxt user fileId lift $ withAgent' (`xftpDeleteSndFileInternal` aFileId) toView $ CEvtSndFileError user ci ft err @@ -309,13 +309,13 @@ processAgentMsgRcvFile _corrId aFileId msg = do process :: User -> FileTransferId -> CM () process user fileId = do ft <- withStore $ \db -> getRcvFileTransfer db user fileId - vr <- chatVersionRange + cxt <- chatStoreCxt unless (rcvFileCompleteOrCancelled ft) $ case msg of RFPROG rcvProgress rcvTotal -> do let status = CIFSRcvTransfer {rcvProgress, rcvTotal} ci <- withStore $ \db -> do liftIO $ updateCIFileStatus db user fileId status - lookupChatItemByFileId db vr user fileId + lookupChatItemByFileId db cxt user fileId toView $ CEvtRcvFileProgressXFTP user ci rcvProgress rcvTotal ft RFDONE xftpPath -> case liveRcvFileTransferPath ft of @@ -327,13 +327,13 @@ processAgentMsgRcvFile _corrId aFileId msg = do liftIO $ do updateRcvFileStatus db fileId FSComplete updateCIFileStatus db user fileId CIFSRcvComplete - lookupChatItemByFileId db vr user fileId + lookupChatItemByFileId db cxt user fileId agentXFTPDeleteRcvFile aFileId fileId toView $ maybe (CEvtRcvStandaloneFileComplete user fsTargetPath ft) (CEvtRcvFileComplete user) ci_ RFWARN e -> do ci <- withStore $ \db -> do liftIO $ updateCIFileStatus db user fileId (CIFSRcvWarning $ agentFileError e) - lookupChatItemByFileId db vr user fileId + lookupChatItemByFileId db cxt user fileId toView $ CEvtRcvFileWarning user ci e ft RFERR e | e == FILE NOT_APPROVED -> do @@ -344,20 +344,20 @@ processAgentMsgRcvFile _corrId aFileId msg = do | otherwise -> do aci_ <- withStore $ \db -> do liftIO $ updateFileCancelled db user fileId (CIFSRcvError $ agentFileError e) - lookupChatItemByFileId db vr user fileId + lookupChatItemByFileId db cxt user fileId forM_ aci_ cleanupACIFile agentXFTPDeleteRcvFile aFileId fileId toView $ CEvtRcvFileError user aci_ e ft type ShouldDeleteGroupConns = Bool -processAgentMessageConn :: VersionRangeChat -> User -> ACorrId -> ConnId -> AEvent 'AEConn -> CM () -processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = do +processAgentMessageConn :: StoreCxt -> User -> ACorrId -> ConnId -> AEvent 'AEConn -> CM () +processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = do -- Missing connection/entity errors here will be sent to the view but not shown as CRITICAL alert, -- as in this case no need to ACK message - we can't process messages for this connection anyway. -- SEDBException will be re-trown as CRITICAL as it is likely to indicate a temporary database condition -- that will be resolved with app restart. - entity <- critical agentConnId $ withStore (\db -> getConnectionEntity db vr user $ AgentConnId agentConnId) >>= updateConnStatus + entity <- critical agentConnId $ withStore (\db -> getConnectionEntity db cxt user $ AgentConnId agentConnId) >>= updateConnStatus case agentMessage of END -> case entity of RcvDirectMsgConnection _ (Just ct) -> toView $ CEvtContactAnotherClient user ct @@ -562,7 +562,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- XGrpLinkInv here means we are connecting via business contact card, so we replace contact with group (gInfo, host) <- withStore $ \db -> do liftIO $ deleteContactCardKeepConn db connId ct - createGroupInvitedViaLink db vr user conn'' glInv + createGroupInvitedViaLink db cxt user conn'' glInv void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart) -- [incognito] send saved profile incognitoProfile <- forM customUserProfileId $ \pId -> withStore (\db -> getProfileById db userId pId) @@ -614,7 +614,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = when (connChatVersion < batchSend2Version) $ forM_ (autoReply $ addressSettings ucl) $ \mc -> sendAutoReply ct' mc Nothing -- old versions only -- TODO REMOVE LEGACY vvv forM_ gli_ $ \GroupLinkInfo {groupId, memberRole = gLinkMemRole} -> do - groupInfo <- withStore $ \db -> getGroupInfo db vr user groupId + groupInfo <- withStore $ \db -> getGroupInfo db cxt user groupId subMode <- chatReadVar subscriptionMode groupConnIds <- createAgentConnectionAsync user CFCreateConnGrpInv True SCMInvitation subMode gVar <- asks random @@ -727,7 +727,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- [async agent commands] group link auto-accept continuation on receiving INV CFCreateConnGrpInv -> do (ct, groupLinkId) <- withStore $ \db -> do - ct <- getContactViaMember db vr user m + ct <- getContactViaMember db cxt user m liftIO $ setNewContactMemberConnRequest db user m cReq liftIO $ (ct,) <$> getGroupLinkId db user gInfo sendGrpInvitation ct m groupLinkId @@ -795,7 +795,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = pgId = fmap (\PublicGroupProfile {publicGroupId} -> publicGroupId), useRelays' gInfo == isJust rcvPG && pgId rcvPG == pgId curPG -> do -- XGrpLinkInv here means we are connecting via prepared group, and we have to update user and host member records - (gInfo', m') <- withStore $ \db -> updatePreparedUserAndHostMembersInvited db vr user gInfo m glInv + (gInfo', m') <- withStore $ \db -> updatePreparedUserAndHostMembersInvited db cxt user gInfo m glInv -- [incognito] send saved profile incognitoProfile <- forM customUserProfileId $ \pId -> withStore (\db -> getProfileById db userId pId) let profileToSend = userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) @@ -803,7 +803,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView $ CEvtGroupLinkConnecting user gInfo' m' | otherwise -> messageError "x.grp.link.inv: publicGroupId mismatch" XGrpLinkReject glRjct@GroupLinkRejection {rejectionReason} -> do - (gInfo', m') <- withStore $ \db -> updatePreparedUserAndHostMembersRejected db vr user gInfo m glRjct + (gInfo', m') <- withStore $ \db -> updatePreparedUserAndHostMembersRejected db cxt user gInfo m glRjct toView $ CEvtGroupLinkConnecting user gInfo' m' toViewTE $ TEGroupLinkRejected user gInfo' rejectionReason _ -> messageError "CONF from host member in prepared group must have x.grp.link.inv or x.grp.link.reject" @@ -877,7 +877,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = where firstConnectedHost | useRelays' gInfo = do - relayMems <- withStore' $ \db -> getGroupRelayMembers db vr user gInfo + relayMems <- withStore' $ \db -> getGroupRelayMembers db cxt user gInfo let numConnected = length $ filter (\GroupMember {memberStatus = ms} -> ms == GSMemConnected) relayMems pure $ numConnected == 1 | otherwise = pure True @@ -907,13 +907,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = when (connChatVersion < batchSend2Version) $ getAutoReplyMsg >>= mapM_ (\mc -> sendGroupAutoReply mc Nothing) if useRelays' gInfo'' then do - introduceInChannel vr user gInfo'' m' + introduceInChannel cxt user gInfo'' m' when (groupFeatureAllowed SGFHistory gInfo'') $ sendHistory user gInfo'' m' else case mStatus of GSMemPendingApproval -> pure () - GSMemPendingReview -> introduceToModerators vr user gInfo'' m' + GSMemPendingReview -> introduceToModerators cxt user gInfo'' m' _ -> do - introduceToAll vr user gInfo'' m' + introduceToAll cxt user gInfo'' m' let memberIsCustomer = case businessChat gInfo'' of Just BusinessChatInfo {chatType = BCCustomer, customerId} -> memberId' m' == customerId _ -> False @@ -936,12 +936,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = sendXGrpMemCon = \case GCPreMember -> forM_ (invitedByGroupMemberId membership) $ \hostId -> do - host <- withStore $ \db -> getGroupMember db vr user groupId hostId + host <- withStore $ \db -> getGroupMember db cxt user groupId hostId forM_ (memberConn host) $ \hostConn -> void $ sendDirectMemberMessage hostConn (XGrpMemCon memberId) groupId GCPostMember -> forM_ (invitedByGroupMemberId m) $ \invitingMemberId -> do - im <- withStore $ \db -> getGroupMember db vr user groupId invitingMemberId + im <- withStore $ \db -> getGroupMember db cxt user groupId invitingMemberId forM_ (memberConn im) $ \imConn -> void $ sendDirectMemberMessage imConn (XGrpMemCon memberId) groupId _ -> messageWarning "sendXGrpMemCon: member category GCPreMember or GCPostMember is expected" @@ -1202,7 +1202,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = (confId, m', relay) <- withStore $ \db -> do confId <- getRelayConfId db m liftIO $ updateGroupMemberStatus db userId m GSMemAccepted - (m', relay) <- setRelayLinkAccepted db vr user m (MemberKey relayKey) relayProfile + (m', relay) <- setRelayLinkAccepted db cxt user m (MemberKey relayKey) relayProfile pure (confId, m', relay) allowAgentConnectionAsync user conn confId XOk toView $ CEvtGroupRelayUpdated user gInfo m' relay @@ -1290,7 +1290,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = FileChunkCancel -> unless (rcvFileCompleteOrCancelled ft) $ do cancelRcvFileTransfer user ft - ci <- withStore $ \db -> getChatItemByFileId db vr user fileId + ci <- withStore $ \db -> getChatItemByFileId db cxt user fileId toView $ CEvtRcvFileSndCancelled user ci ft FileChunk {chunkNo, chunkBytes = chunk} -> do case integrity of @@ -1313,7 +1313,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = updateRcvFileStatus db fileId FSComplete updateCIFileStatus db user fileId CIFSRcvComplete deleteRcvFileChunks db ft - getChatItemByFileId db vr user fileId + getChatItemByFileId db cxt user fileId toView $ CEvtRcvFileComplete user ci mapM_ (deleteAgentConnectionAsync . aConnId) conn_ RcvChunkDuplicate -> withAckMessage' "file msg" agentConnId meta $ pure () @@ -1338,7 +1338,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case (ucGroupId_, auData) of (Just groupId, UserContactLinkData UserContactData {relays = relayLinks}) -> do (gInfo, gLink, relays, relaysChanged, newlyActiveLinks) <- withStore $ \db -> do - gInfo <- getGroupInfo db vr user groupId + gInfo <- getGroupInfo db cxt user groupId gLink <- getGroupLink db user gInfo relays <- liftIO $ getGroupRelays db gInfo (relays', changed, newlyActive) <- liftIO $ foldrM (updateRelay db) ([], False, []) relays @@ -1351,7 +1351,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- dedicated subscriber count). when (fromMaybe 0 publicMemberCount > 1) $ forM_ (L.nonEmpty newlyActiveLinks) $ \newlyActive -> do - allRelayMembers <- withFastStore' $ \db -> getGroupRelayMembers db vr user gInfo + allRelayMembers <- withFastStore' $ \db -> getGroupRelayMembers db cxt user gInfo let recipients = filter (\GroupMember {memberStatus, relayLink} -> @@ -1401,7 +1401,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = AddressSettings {autoAccept} = addressSettings isSimplexTeam = sameConnReqContact connReq adminContactReq gVar <- asks random - withStore (\db -> createOrUpdateContactRequest db gVar vr user uclId ucl isSimplexTeam invId chatVRange p xContactId_ welcomeMsgId_ requestMsg_ reqPQSup) >>= \case + withStore (\db -> createOrUpdateContactRequest db gVar cxt user uclId ucl isSimplexTeam invId chatVRange p xContactId_ welcomeMsgId_ requestMsg_ reqPQSup) >>= \case RSAcceptedRequest _ucr re -> case re of REContact ct -> -- TODO [short links] update request msg @@ -1533,7 +1533,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- ##### Group link join requests (don't create contact requests) ##### Just gli@GroupLinkInfo {groupId, memberRole = gLinkMemRole} -> do -- TODO [short links] deduplicate request by xContactId? - gInfo <- withStore $ \db -> getGroupInfo db vr user groupId + gInfo <- withStore $ \db -> getGroupInfo db cxt user groupId if useRelays' gInfo then messageWarning $ "processContactConnMessage (group " <> groupName' gInfo <> "): ignored direct join request from " <> displayName <> " (group uses relays)" else do @@ -1559,10 +1559,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = rejected <- withStore' $ \db -> isRelayGroupRejected db user groupLink initialDelay <- asks $ initialInterval . relayRequestRetryInterval . config if rejected - then rejectRelayInvitationAsync user uclId vr groupRelayInv invId chatVRange initialDelay RRRRejoinRejected + then rejectRelayInvitationAsync user uclId cxt groupRelayInv invId chatVRange initialDelay RRRRejoinRejected else do (_gInfo, _ownerMember) <- withStore $ \db -> - createRelayRequestGroup db vr user groupRelayInv invId chatVRange initialDelay GSMemAccepted RSInvited + createRelayRequestGroup db cxt user groupRelayInv invId chatVRange initialDelay GSMemAccepted RSInvited lift $ void $ getRelayRequestWorker True xGrpRelayTest :: InvitationId -> VersionRangeChat -> ByteString -> CM () xGrpRelayTest invId chatVRange challenge = do @@ -1577,7 +1577,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let chatV = chatVR `peerConnChatVersion` chatVRange (cmdId, acId) <- agentAcceptContactAsync user True invId msg subMode PQSupportOff chatV withStore $ \db -> do - Connection {connId = testCId} <- createRelayTestConnection db vr user acId ConnAccepted chatV subMode + Connection {connId = testCId} <- createRelayTestConnection db cxt user acId ConnAccepted chatV subMode liftIO $ setCommandConnId db user cmdId testCId -- TODO [relays] owner, relays: TBC how to communicate member rejection rules from owner to relays -- TODO [relays] relay: TBC communicate rejection when memberId already exists (currently checked in createJoiningMember) @@ -1586,7 +1586,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = (_ucl, gLinkInfo_) <- withStore $ \db -> getUserContactLinkById db userId uclId case gLinkInfo_ of Just GroupLinkInfo {groupId, memberRole = gLinkMemRole} -> do - gInfo <- withStore $ \db -> getGroupInfo db vr user groupId + gInfo <- withStore $ \db -> getGroupInfo db cxt user groupId mem <- acceptGroupJoinRequestAsync user uclId gInfo invId chatVRange p Nothing (Just joiningMemberId) Nothing GAAccepted gLinkMemRole Nothing (Just joiningMemberKey) (gInfo', mem', scopeInfo) <- mkGroupChatScope gInfo mem createInternalChatItem user (CDGroupRcv gInfo' scopeInfo mem') (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing @@ -1756,7 +1756,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- sendProbe -> sendProbeHashes (currently) -- sendProbeHashes -> sendProbe (reversed - change order in code, may add delay) sendProbe probe - ms <- map COMGroupMember <$> withStore' (\db -> getMatchingMembers db vr user ct) + ms <- map COMGroupMember <$> withStore' (\db -> getMatchingMembers db cxt user ct) sendProbeHashes ms probe probeId else sendProbe . Probe =<< liftIO (encodedRandomBytes gVar 32) where @@ -1772,7 +1772,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = then do (probe, probeId) <- withStore $ \db -> createSentProbe db gVar userId $ COMGroupMember m sendProbe probe - cs <- map COMContact <$> withStore' (\db -> getMatchingMemberContacts db vr user m) + cs <- map COMContact <$> withStore' (\db -> getMatchingMemberContacts db cxt user m) sendProbeHashes cs probe probeId else sendProbe . Probe =<< liftIO (encodedRandomBytes gVar 32) where @@ -1845,7 +1845,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = messageFileDescription Contact {contactId} sharedMsgId fileDescr = do (fileId, aci) <- withStore $ \db -> do fileId <- getFileIdBySharedMsgId db userId contactId sharedMsgId - aci <- getChatItemByFileId db vr user fileId + aci <- getChatItemByFileId db cxt user fileId pure (fileId, aci) processFDMessage fileId aci fileDescr @@ -1853,7 +1853,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = groupMessageFileDescription g@GroupInfo {groupId} m_ sharedMsgId fileDescr = do (fileId, aci) <- withStore $ \db -> do fileId <- getGroupFileIdBySharedMsgId db userId groupId sharedMsgId - aci <- getChatItemByFileId db vr user fileId + aci <- getChatItemByFileId db cxt user fileId pure (fileId, aci) case aci of AChatItem SCTGroup SMDRcv (GroupChat _g scopeInfo) ChatItem {chatDir} @@ -2014,7 +2014,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = cci <- case itemMemberId of Just itemMemberId' -> getGroupMemberCIBySharedMsgId db user g itemMemberId' sharedMsgId Nothing -> getGroupChatItemBySharedMsgId db user g Nothing sharedMsgId - scopeInfo <- getGroupChatScopeInfoForItem db vr user g (cChatItemId cci) + scopeInfo <- getGroupChatScopeInfoForItem db cxt user g (cChatItemId cci) pure (cci, scopeInfo) if ciReactionAllowed ci then do @@ -2052,13 +2052,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- no delivery task - message already forwarded by relay pure Nothing Just m@GroupMember {memberId} -> do - (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m content msgScope_ + (gInfo', m', scopeInfo) <- mkGetMessageChatScope cxt user gInfo m content msgScope_ if blockedByAdmin m' then createBlockedByAdmin gInfo' (Just m') scopeInfo $> Nothing else case prohibitedGroupContent gInfo' m' scopeInfo content ft_ fInv_ False of Just f -> rejected gInfo' (Just m') scopeInfo f $> Nothing Nothing -> - withStore' (\db -> getCIModeration db vr user gInfo' memberId sharedMsgId_) >>= \case + withStore' (\db -> getCIModeration db cxt user gInfo' memberId sharedMsgId_) >>= \case Just ciModeration -> do applyModeration gInfo' m' scopeInfo ciModeration withStore' $ \db -> deleteCIModeration db gInfo' memberId sharedMsgId_ @@ -2148,7 +2148,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = else case m_ of Just m -> do let mentions' = if memberBlocked m then [] else mentions - (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m mc msgScope_ + (gInfo', m', scopeInfo) <- mkGetMessageChatScope cxt user gInfo m mc msgScope_ pure (gInfo', CDGroupRcv gInfo' scopeInfo m', mentions', scopeInfo) Nothing -> pure (gInfo, CDChannelRcv gInfo Nothing, mentions, Nothing) case m_ >>= \m -> prohibitedGroupContent gInfo' m scopeInfo mc ft_ (Nothing :: Maybe String) False of @@ -2179,7 +2179,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = else case m_ of Just m -> getGroupMemberCIBySharedMsgId db user gInfo (memberId' m) sharedMsgId Nothing -> getGroupChatItemBySharedMsgId db user gInfo Nothing sharedMsgId - (cci,) <$> getGroupChatScopeInfoForItem db vr user gInfo (cChatItemId cci) + (cci,) <$> getGroupChatScopeInfoForItem db cxt user gInfo (cChatItemId cci) case cci of CChatItem SMDRcv ci@ChatItem {chatDir = CIGroupRcv m', meta = CIMeta {itemLive}, content = CIRcvMsgContent oldMC} | isSender m' -> updateCI False ci scopeInfo oldMC itemLive (Just $ memberId' m') @@ -2291,7 +2291,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | otherwise = a delete :: CChatItem 'CTGroup -> Bool -> Maybe GroupMember -> CM (Maybe DeliveryTaskContext) delete cci asGroup byGroupMember = do - scopeInfo <- withStore $ \db -> getGroupChatScopeInfoForItem db vr user gInfo (cChatItemId cci) + scopeInfo <- withStore $ \db -> getGroupChatScopeInfoForItem db cxt user gInfo (cChatItemId cci) let fullDelete | asGroup = groupFeatureAllowed SGFFullDelete gInfo | otherwise = maybe False (\m -> groupFeatureMemberAllowed SGFFullDelete m gInfo) m_ @@ -2359,14 +2359,14 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = (fileId,) <$> getRcvFileTransfer db user fileId unless (rcvFileCompleteOrCancelled ft) $ do cancelRcvFileTransfer user ft - ci <- withStore $ \db -> getChatItemByFileId db vr user fileId + ci <- withStore $ \db -> getChatItemByFileId db cxt user fileId toView $ CEvtRcvFileSndCancelled user ci ft xFileAcptInv :: Contact -> SharedMsgId -> Maybe ConnReqInvitation -> String -> CM () xFileAcptInv ct sharedMsgId fileConnReq_ fName = do (fileId, AChatItem _ _ _ ci) <- withStore $ \db -> do fileId <- getDirectFileIdBySharedMsgId db user ct sharedMsgId - (fileId,) <$> getChatItemByFileId db vr user fileId + (fileId,) <$> getChatItemByFileId db cxt user fileId assertSMPAcceptNotProhibited ci ft@FileTransferMeta {fileName, fileSize, fileInline, cancelled} <- withStore (\db -> getFileTransferMeta db user fileId) -- [async agent commands] no continuation needed, but command should be asynchronous for stability @@ -2375,7 +2375,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- receiving inline Nothing -> do event <- withStore $ \db -> do - ci' <- updateDirectCIFileStatus db vr user fileId $ CIFSSndTransfer 0 1 + ci' <- updateDirectCIFileStatus db cxt user fileId $ CIFSSndTransfer 0 1 sft <- createSndDirectInlineFT db ct ft pure $ CEvtSndFileStart user ci' sft toView event @@ -2403,7 +2403,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = forM_ sft_ $ \sft@SndFileTransfer {fileId} -> do ci@(AChatItem _ _ _ ChatItem {file}) <- withStore $ \db -> do liftIO $ updateSndFileStatus db sft FSComplete - updateDirectCIFileStatus db vr user fileId CIFSSndComplete + updateDirectCIFileStatus db cxt user fileId CIFSSndComplete case file of Just CIFile {fileProtocol = FPXFTP} -> do ft <- withStore $ \db -> getFileTransferMeta db user fileId @@ -2441,7 +2441,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xFileCancelGroup g@GroupInfo {groupId} m_ sharedMsgId = do (fileId, aci) <- withStore $ \db -> do fileId <- getGroupFileIdBySharedMsgId db userId groupId sharedMsgId - (fileId,) <$> getChatItemByFileId db vr user fileId + (fileId,) <$> getChatItemByFileId db cxt user fileId case aci of AChatItem SCTGroup SMDRcv (GroupChat _g scopeInfo) ChatItem {chatDir} | validSender m_ chatDir -> do @@ -2457,7 +2457,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xFileAcptInvGroup GroupInfo {groupId} m@GroupMember {activeConn} sharedMsgId fileConnReq_ fName = do (fileId, AChatItem _ _ _ ci) <- withStore $ \db -> do fileId <- getGroupFileIdBySharedMsgId db userId groupId sharedMsgId - (fileId,) <$> getChatItemByFileId db vr user fileId + (fileId,) <$> getChatItemByFileId db cxt user fileId assertSMPAcceptNotProhibited ci -- TODO check that it's not already accepted ft@FileTransferMeta {fileName, fileSize, fileInline, cancelled} <- withStore (\db -> getFileTransferMeta db user fileId) @@ -2466,7 +2466,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = (Nothing, Just conn) -> do -- receiving inline event <- withStore $ \db -> do - ci' <- updateDirectCIFileStatus db vr user fileId $ CIFSSndTransfer 0 1 + ci' <- updateDirectCIFileStatus db cxt user fileId $ CIFSSndTransfer 0 1 sft <- liftIO $ createSndGroupInlineFT db m conn ft pure $ CEvtSndFileStart user ci' sft toView event @@ -2492,7 +2492,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = when (fromRole < GRAdmin || fromRole < memRole) $ throwChatError (CEGroupContactRole c) when (fromMemId == memId) $ throwChatError CEGroupDuplicateMemberId -- [incognito] if direct connection with host is incognito, create membership using the same incognito profile - (gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership}, hostId) <- withStore $ \db -> createGroupInvitation db vr user ct inv customUserProfileId + (gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership}, hostId) <- withStore $ \db -> createGroupInvitation db cxt user ct inv customUserProfileId void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart) let GroupMember {groupMemberId, memberId = membershipMemId} = membership if sameGroupLinkId groupLinkId groupLinkId' @@ -2533,7 +2533,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = then do (ct', contactConns) <- withStore' $ \db -> do ct' <- updateContactStatus db user c CSDeleted - (ct',) <$> getContactConnections db vr userId ct' + (ct',) <$> getContactConnections db cxt userId ct' deleteAgentConnectionsAsync $ map aConnId contactConns forM_ contactConns $ \conn -> withStore' $ \db -> updateConnectionStatus db conn ConnDeleted activeConn' <- forM (contactConn ct') $ \conn -> pure conn {connStatus = ConnDeleted} @@ -2542,7 +2542,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci] toView $ CEvtContactDeletedByContact user ct'' else do - contactConns <- withStore' $ \db -> getContactConnections db vr userId c + contactConns <- withStore' $ \db -> getContactConnections db cxt userId c deleteAgentConnectionsAsync $ map aConnId contactConns withStore $ \db -> deleteContact db user c where @@ -2611,7 +2611,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = messageError "x.grp.link.acpt with insufficient member permissions" | sameMemberId memberId membership = processUserAccepted | otherwise = - withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memberId) >>= \case + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db cxt user gInfo memberId) >>= \case Left _ -> messageError "x.grp.link.acpt error: referenced member does not exist" Right referencedMember -> do (referencedMember', gInfo') <- withStore' $ \db -> do @@ -2655,7 +2655,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = GAPendingApproval -> messageWarning "x.grp.link.acpt: unexpected group acceptance - pending approval" introduceToRemainingMembers acceptedMember = do - introduceToRemaining vr user gInfo acceptedMember + introduceToRemaining cxt user gInfo acceptedMember when (groupFeatureAllowed SGFHistory gInfo) $ sendHistory user gInfo acceptedMember maybeCreateGroupDescrLocal :: GroupInfo -> GroupMember -> CM () @@ -2677,7 +2677,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView $ CEvtGroupMemberUpdated user gInfo m m' pure m' Just mContactId -> do - mCt <- withStore $ \db -> getContact db vr user mContactId + mCt <- withStore $ \db -> getContact db cxt user mContactId if canUpdateProfile mCt then do (m', ct') <- withStore $ \db -> updateContactMemberProfile db user m mCt p' @@ -2725,7 +2725,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = contactMerge <- readTVarIO =<< asks contactMergeEnabled -- [incognito] unless connected incognito when (contactMerge && not (contactOrMemberIncognito cgm2)) $ do - cgm1s <- withStore' $ \db -> matchReceivedProbe db vr user cgm2 probe + cgm1s <- withStore' $ \db -> matchReceivedProbe db cxt user cgm2 probe let cgm1s' = filter (not . contactOrMemberIncognito) cgm1s probeMatches cgm1s' cgm2 where @@ -2741,7 +2741,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = contactMerge <- readTVarIO =<< asks contactMergeEnabled -- [incognito] unless connected incognito when (contactMerge && not (contactOrMemberIncognito cgm1)) $ do - cgm2Probe_ <- withStore' $ \db -> matchReceivedProbeHash db vr user cgm1 probeHash + cgm2Probe_ <- withStore' $ \db -> matchReceivedProbeHash db cxt user cgm1 probeHash forM_ cgm2Probe_ $ \(cgm2, probe) -> unless (contactOrMemberIncognito cgm2) . void $ probeMatch cgm1 cgm2 probe @@ -2771,7 +2771,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xInfoProbeOk :: ContactOrMember -> Probe -> CM () xInfoProbeOk cgm1 probe = do - cgm2 <- withStore' $ \db -> matchSentProbe db vr user cgm1 probe + cgm2 <- withStore' $ \db -> matchSentProbe db cxt user cgm1 probe case cgm1 of COMContact c1 -> case cgm2 of @@ -2920,14 +2920,14 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = associateMemberWithContact c1 m2@GroupMember {groupId} = do g <- withStore $ \db -> do liftIO $ associateMemberWithContactRecord db user c1 m2 - getGroupInfo db vr user groupId + getGroupInfo db cxt user groupId toView $ CEvtContactAndMemberAssociated user c1 g m2 c1 pure c1 associateContactWithMember :: GroupMember -> Contact -> CM Contact associateContactWithMember m1@GroupMember {groupId} c2 = do (c2', g) <- withStore $ \db -> - liftM2 (,) (associateContactWithMemberRecord db vr user m1 c2) (getGroupInfo db vr user groupId) + liftM2 (,) (associateContactWithMemberRecord db cxt user m1 c2) (getGroupInfo db cxt user groupId) toView $ CEvtContactAndMemberAssociated user c2 g m1 c2' pure c2' @@ -2937,15 +2937,15 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = conn' <- updatePeerChatVRange activeConn chatVRange case chatMsgEvent of XInfo p -> do - ct <- withStore $ \db -> createDirectContact db vr user conn' p + ct <- withStore $ \db -> createDirectContact db cxt user conn' p toView $ CEvtContactConnecting user ct pure (conn', Nothing) XGrpLinkInv glInv -> do - (gInfo, host) <- withStore $ \db -> createGroupInvitedViaLink db vr user conn' glInv + (gInfo, host) <- withStore $ \db -> createGroupInvitedViaLink db cxt user conn' glInv toView $ CEvtGroupLinkConnecting user gInfo host pure (conn', Just gInfo) XGrpLinkReject glRjct@GroupLinkRejection {rejectionReason} -> do - (gInfo, host) <- withStore $ \db -> createGroupRejectedViaLink db vr user conn' glRjct + (gInfo, host) <- withStore $ \db -> createGroupRejectedViaLink db cxt user conn' glRjct toView $ CEvtGroupLinkConnecting user gInfo host toViewTE $ TEGroupLinkRejected user gInfo rejectionReason pure (conn', Just gInfo) @@ -2958,10 +2958,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = if sameMemberId memId (membership gInfo) then pure Nothing else do - withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db cxt user gInfo memId) >>= \case Right unknownMember@GroupMember {memberStatus = GSMemUnknown} -> do (updatedMember, gInfo') <- withStore $ \db -> do - updatedMember <- updateUnknownMemberAnnounced db vr user m unknownMember memInfo initialStatus + updatedMember <- updateUnknownMemberAnnounced db cxt user m unknownMember memInfo initialStatus gInfo' <- if memberPending updatedMember then liftIO $ increaseGroupMembersRequireAttention db user gInfo @@ -3014,10 +3014,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xGrpMemIntro gInfo@GroupInfo {chatSettings} m@GroupMember {memberRole, localDisplayName = c} memInfo@(MemberInfo memId _ memChatVRange _ _) memRestrictions = do case memberCategory m of GCHostMember -> - withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db cxt user gInfo memId) >>= \case Right existingMember | useRelays' gInfo -> do - updatedMember <- withStore $ \db -> updatePreparedChannelMember db vr user existingMember memInfo + updatedMember <- withStore $ \db -> updatePreparedChannelMember db cxt user existingMember memInfo toView $ CEvtGroupMemberUpdated user gInfo existingMember updatedMember | otherwise -> messageError "x.grp.mem.intro ignored: member already exists" @@ -3038,7 +3038,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = subMode <- chatReadVar subscriptionMode -- [async agent commands] commands should be asynchronous, continuation is to send XGrpMemInv - have to remember one has completed and process on second groupConnIds <- createConn subMode - let chatV = maybe (minVersion vr) (\peerVR -> vr `peerConnChatVersion` fromChatVRange peerVR) memChatVRange + let chatV = maybe (minVersion (vr cxt)) (\peerVR -> vr cxt `peerConnChatVersion` fromChatVRange peerVR) memChatVRange void $ withStore $ \db -> do reMember <- createIntroReMember db user gInfo memInfo memRestrictions createIntroReMemberConn db user m reMember chatV memInfo groupConnIds subMode @@ -3049,7 +3049,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = sendXGrpMemInv :: Int64 -> Maybe ConnReqInvitation -> XGrpMemIntroCont -> CM () sendXGrpMemInv hostConnId directConnReq XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} = do - hostConn <- withStore $ \db -> getConnectionById db vr user hostConnId + hostConn <- withStore $ \db -> getConnectionById db cxt user hostConnId let msg = XGrpMemInv memberId IntroInvitation {groupConnReq, directConnReq} void $ sendDirectMemberMessage hostConn msg groupId withStore' $ \db -> updateGroupMemberStatusById db userId groupMemberId GSMemIntroInvited @@ -3058,7 +3058,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xGrpMemInv gInfo m memId introInv = do case memberCategory m of GCInviteeMember -> - withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db cxt user gInfo memId) >>= \case Left _ -> messageError "x.grp.mem.inv error: referenced member does not exist" Right reMember -> sendGroupMemberMessage gInfo reMember $ XGrpMemFwd (memberInfo gInfo m) introInv _ -> messageError "x.grp.mem.inv can be only sent by invitee member" @@ -3069,7 +3069,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = checkHostRole m memRole toMember <- withStore $ \db -> do toMember <- - getGroupMemberByMemberId db vr user gInfo memId + getGroupMemberByMemberId db cxt user gInfo memId -- TODO if the missed messages are correctly sent as soon as there is connection before anything else is sent -- the situation when member does not exist is an error -- member receiving x.grp.mem.fwd should have also received x.grp.mem.new prior to that. @@ -3093,7 +3093,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = directConnIds <- forM directConnReq $ \dcr -> joinAgentConnectionAsync user Nothing True dcr dm subMode let customUserProfileId = localProfileId <$> incognitoMembershipProfile gInfo mcvr = maybe chatInitialVRange fromChatVRange memChatVRange - chatV = vr `peerConnChatVersion` mcvr + chatV = vr cxt `peerConnChatVersion` mcvr withStore' $ \db -> createIntroToMemberContact db user m toMember chatV mcvr groupConnIds directConnIds customUserProfileId subMode xGrpMemRole :: GroupInfo -> GroupMember -> MemberId -> GroupMemberRole -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) @@ -3102,7 +3102,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let gInfo' = gInfo {membership = membership {memberRole = memRole}} in changeMemberRole gInfo' membership $ RGEUserRole memRole | otherwise = - withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db cxt user gInfo memId) >>= \case Right member -> changeMemberRole gInfo member $ RGEMemberRole (groupMemberId' member) (fromLocalProfile $ memberProfile member) memRole Left _ -> messageError "x.grp.mem.role with unknown member ID" $> Nothing where @@ -3133,7 +3133,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | membershipMemId == memId = pure Nothing -- ignore - XGrpMemRestrict can be sent to restricted member for efficiency | otherwise = do unknownRole <- unknownMemberRole gInfo - withStore (\db -> getCreateUnknownGMByMemberId db vr user gInfo memId "" unknownRole True) >>= \case + withStore (\db -> getCreateUnknownGMByMemberId db cxt user gInfo memId "" unknownRole True) >>= \case Nothing -> messageError "x.grp.mem.restrict: no member" $> Nothing -- shouldn't happen Just (bm, unknown) -> do let GroupMember {groupMemberId = bmId, memberRole, blockedByAdmin, memberProfile = bmp} = bm @@ -3157,7 +3157,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xGrpMemCon :: GroupInfo -> GroupMember -> MemberId -> CM () xGrpMemCon gInfo sendingMem memId = do - refMem <- withStore $ \db -> getGroupMemberByMemberId db vr user gInfo memId + refMem <- withStore $ \db -> getGroupMemberByMemberId db cxt user gInfo memId -- Updating vectors in separate transactions to avoid deadlocks. withStore $ \db -> setMemberVectorRelationConnected db sendingMem refMem MRSubjectConnected withStore $ \db -> setMemberVectorRelationConnected db refMem sendingMem MRReferencedConnected @@ -3179,7 +3179,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView $ CEvtDeletedMemberUser user gInfo {membership = membership'} m withMessages msgSigned pure $ Just DJSGroup {jobSpec = DJRelayRemoved} else - withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db cxt user gInfo memId) >>= \case Left _ -> do messageError "x.grp.mem.del with unknown member ID" pure $ Just DJSGroup {jobSpec = DJDeliveryJob {includePending = True}} @@ -3323,7 +3323,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case memberContactId of Nothing -> createNewContact subMode Just mContactId -> do - mCt <- withStore $ \db -> getContact db vr user mContactId + mCt <- withStore $ \db -> getContact db cxt user mContactId let Contact {activeConn, contactGrpInvSent} = mCt forM_ activeConn $ \Connection {connId} -> if contactGrpInvSent @@ -3350,7 +3350,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = mCt' <- withStore $ \db -> do updateMemberContactInvited db user mCt groupDirectInv void $ liftIO $ createMemberContactConn db user acId (Just cmdId) g mConn ConnJoined mContactId subMode - getContact db vr user mContactId + getContact db cxt user mContactId securityCodeChanged mCt' createItems mCt' m | otherwise = do @@ -3358,7 +3358,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = mCt' <- withStore $ \db -> do updateMemberContactInvited db user mCt groupDirectInv void $ liftIO $ createMemberContactConn db user acId Nothing g mConn ConnPrepared mContactId subMode - getContact db vr user mContactId + getContact db cxt user mContactId securityCodeChanged mCt' createInternalChatItem user (CDDirectRcv mCt') (CIRcvDirectEvent $ RDEGroupInvLinkReceived gp) Nothing createItems mCt' m @@ -3369,7 +3369,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = (mCt, m') <- withStore $ \db -> do (mContactId, m') <- liftIO $ createMemberContactInvited db user g m groupDirectInv void $ liftIO $ createMemberContactConn db user acId (Just cmdId) g mConn ConnJoined mContactId subMode - mCt <- getContact db vr user mContactId + mCt <- getContact db cxt user mContactId pure (mCt, m') createInternalChatItem user (CDDirectSnd mCt) CIChatBanner (Just epochStart) createItems mCt m' @@ -3378,7 +3378,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = (mCt, m') <- withStore $ \db -> do (mContactId, m') <- liftIO $ createMemberContactInvited db user g m groupDirectInv void $ liftIO $ createMemberContactConn db user acId Nothing g mConn ConnPrepared mContactId subMode - mCt <- getContact db vr user mContactId + mCt <- getContact db cxt user mContactId pure (mCt, m') createInternalChatItem user (CDDirectSnd mCt) CIChatBanner (Just epochStart) createInternalChatItem user (CDDirectRcv mCt) (CIRcvDirectEvent $ RDEGroupInvLinkReceived gp) Nothing @@ -3409,7 +3409,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = FwdMember memberId memberName -> do unknownRole <- unknownMemberRole gInfo let allowCreate = toCMEventTag chatMsgEvent /= XGrpLeave_ - withStore (\db -> getCreateUnknownGMByMemberId db vr user gInfo memberId memberName unknownRole allowCreate) >>= \case + withStore (\db -> getCreateUnknownGMByMemberId db cxt 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 $ @@ -3536,7 +3536,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- SENT and RCVD events are received for messages that may be batched in single scope, -- so we can look up scope of first item scopeInfo <- case cis of - (ci : _) -> getGroupChatScopeInfoForItem db vr user gInfo (chatItemId' ci) + (ci : _) -> getGroupChatScopeInfoForItem db cxt user gInfo (chatItemId' ci) _ -> pure Nothing pure $ map (gItem scopeInfo) cis unless (null acis) $ toView $ CEvtChatItemsStatusesUpdated user acis @@ -3560,14 +3560,14 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = deleteGroupConnections :: User -> GroupInfo -> Bool -> CM () deleteGroupConnections user gInfo@GroupInfo {membership} waitDelivery = do - vr <- chatVersionRange + cxt <- chatStoreCxt -- member records are not deleted to keep history - members <- getMembers vr + members <- getMembers cxt deleteMembersConnections' user members waitDelivery where - getMembers vr - | useRelays' gInfo, not (isRelay membership) = withStore' $ \db -> getGroupRelayMembers db vr user gInfo - | otherwise = withStore' $ \db -> getGroupMembers db vr user gInfo + getMembers cxt + | useRelays' gInfo, not (isRelay membership) = withStore' $ \db -> getGroupRelayMembers db cxt user gInfo + | otherwise = withStore' $ \db -> getGroupMembers db cxt user gInfo startDeliveryTaskWorkers :: CM () startDeliveryTaskWorkers = do @@ -3587,20 +3587,20 @@ getDeliveryTaskWorker hasWork deliveryKey = do runDeliveryTaskWorker :: AgentClient -> DeliveryWorkerKey -> Worker -> CM () runDeliveryTaskWorker a deliveryKey Worker {doWork} = do delay <- asks $ deliveryWorkerDelay . config - vr <- chatVersionRange + cxt <- chatStoreCxt -- TODO [relays] in future may be required to read groupInfo and user on each iteration for up to date state -- TODO - same for delivery jobs (runDeliveryJobWorker) gInfo <- withStore $ \db -> do user <- getUserByGroupId db groupId - getGroupInfo db vr user groupId + getGroupInfo db cxt user groupId forever $ do unless (delay == 0) $ liftIO $ threadDelay' delay lift $ waitForWork doWork - runDeliveryTaskOperation vr gInfo + runDeliveryTaskOperation cxt gInfo where (groupId, workerScope) = deliveryKey - runDeliveryTaskOperation :: VersionRangeChat -> GroupInfo -> CM () - runDeliveryTaskOperation vr gInfo = do + runDeliveryTaskOperation :: StoreCxt -> GroupInfo -> CM () + runDeliveryTaskOperation cxt gInfo = do withWork_ a doWork (withStore' $ \db -> getNextDeliveryTask db deliveryKey) $ \task -> processDeliveryTask task `catchAllErrors` \e -> do @@ -3616,7 +3616,7 @@ 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, taskIds, largeTaskIds) = batchDeliveryTasks1 (vr cxt) maxEncodedMsgLength nextTasks withStore' $ \db -> do createMsgDeliveryJob db gInfo jobScope (singleSenderGMId_ nextTasks) body forM_ taskIds $ \taskId -> updateDeliveryTaskStatus db taskId DTSProcessed @@ -3658,19 +3658,19 @@ getDeliveryJobWorker hasWork deliveryKey = do runDeliveryJobWorker :: AgentClient -> DeliveryWorkerKey -> Worker -> CM () runDeliveryJobWorker a deliveryKey Worker {doWork} = do delay <- asks $ deliveryWorkerDelay . config - vr <- chatVersionRange + cxt <- chatStoreCxt (user, gInfo) <- withStore $ \db -> do user <- getUserByGroupId db groupId - gInfo <- getGroupInfo db vr user groupId + gInfo <- getGroupInfo db cxt user groupId pure (user, gInfo) forever $ do unless (delay == 0) $ liftIO $ threadDelay' delay lift $ waitForWork doWork - runDeliveryJobOperation vr user gInfo + runDeliveryJobOperation cxt user gInfo where (groupId, workerScope) = deliveryKey - runDeliveryJobOperation :: VersionRangeChat -> User -> GroupInfo -> CM () - runDeliveryJobOperation vr user gInfo = do + runDeliveryJobOperation :: StoreCxt -> User -> GroupInfo -> CM () + runDeliveryJobOperation cxt user gInfo = do withWork_ a doWork (withStore' $ \db -> getNextDeliveryJob db deliveryKey) $ \job -> processDeliveryJob job `catchAllErrors` \e -> do @@ -3708,7 +3708,7 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do where sendLoop :: Int -> Maybe GroupMemberId -> CM () sendLoop bucketSize cursorGMId_ = do - mems <- withStore' $ \db -> getGroupMembersByCursor db vr user gInfo cursorGMId_ singleSenderGMId_ bucketSize + mems <- withStore' $ \db -> getGroupMembersByCursor db cxt user gInfo cursorGMId_ singleSenderGMId_ bucketSize unless (null mems) $ do deliver body mems let cursorGMId' = groupMemberId' $ last mems @@ -3716,7 +3716,7 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do unless (length mems < bucketSize) $ sendLoop bucketSize (Just cursorGMId') 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 + modMs <- withStore' $ \db -> getGroupModerators db cxt user gInfo let moderatorFilter m = memberCurrent m && maxVersion (memberChatVRange m) >= groupKnockingVersion @@ -3726,14 +3726,14 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do if Just scopeGMId == singleSenderGMId_ then pure modMs' else do - scopeMem <- withStore $ \db -> getGroupMemberById db vr user scopeGMId + scopeMem <- withStore $ \db -> getGroupMemberById db cxt user scopeGMId pure $ scopeMem : modMs' unless (null mems) $ deliver body mems -- 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 + sender <- withStore $ \db -> getGroupMemberById db cxt user singleSenderGMId ms <- buildMemberList sender unless (null ms) $ deliver body ms where @@ -3743,14 +3743,14 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do let introducedMemsIdxs = getRelationsIndexes MRIntroduced vec case jobScope of DJSGroup {jobSpec} -> do - ms <- withStore' $ \db -> getGroupMembersByIndexes db vr user gInfo introducedMemsIdxs + ms <- withStore' $ \db -> getGroupMembersByIndexes db cxt user gInfo introducedMemsIdxs pure $ filter shouldForwardTo ms where shouldForwardTo m | jobSpecImpliedPending jobSpec = memberCurrentOrPending m | otherwise = memberCurrent m DJSMemberSupport scopeGMId -> do - ms <- withStore' $ \db -> getSupportScopeMembersByIndexes db vr user gInfo scopeGMId introducedMemsIdxs + ms <- withStore' $ \db -> getSupportScopeMembersByIndexes db cxt user gInfo scopeGMId introducedMemsIdxs pure $ filter shouldForwardTo ms where shouldForwardTo m = groupMemberId' m == scopeGMId || currentModerator m @@ -3801,7 +3801,7 @@ getRelayRequestWorker hasWork = do runRelayRequestWorker :: AgentClient -> Worker -> CM () runRelayRequestWorker a Worker {doWork} = do - vr <- chatVersionRange + cxt <- chatStoreCxt (user, uclId) <- withStore $ \db -> do user <- getRelayUser db UserContactLink {userContactLinkId} <- getUserAddress db user @@ -3809,10 +3809,10 @@ runRelayRequestWorker a Worker {doWork} = do delayThreads <- liftIO TM.emptyIO forever $ do lift $ waitForWork doWork - runRelayRequestOperation delayThreads vr user uclId + runRelayRequestOperation delayThreads cxt user uclId where - runRelayRequestOperation :: TM.TMap GroupId (TMVar (Weak ThreadId)) -> VersionRangeChat -> User -> Int64 -> CM () - runRelayRequestOperation delayThreads vr user uclId = + runRelayRequestOperation :: TM.TMap GroupId (TMVar (Weak ThreadId)) -> StoreCxt -> User -> Int64 -> CM () + runRelayRequestOperation delayThreads cxt user uclId = withWork_ a doWork getReadyRelayRequest $ \(groupId, rrd) -> do ChatConfig {relayRequestExpiry} <- asks config @@ -3861,7 +3861,7 @@ runRelayRequestWorker a Worker {doWork} = do processRelayRequest :: GroupId -> RelayRequestData -> CM () processRelayRequest groupId rrd = do (gInfo, groupLink_) <- withStore $ \db -> do - gInfo <- getGroupInfo db vr user groupId + gInfo <- getGroupInfo db cxt user groupId groupLink_ <- liftIO $ runExceptT $ getGroupLink db user gInfo pure (gInfo, groupLink_) -- Check if relay link already exists (recovery case) @@ -3889,7 +3889,7 @@ runRelayRequestWorker a Worker {doWork} = do gInfo' <- withStore $ \db -> do void $ updateGroupProfile db user gInfo gp updateRelayGroupKeys db user gInfo pg rootKey memberPrivKey owners - getGroupInfo db vr user groupId + getGroupInfo db cxt user groupId pure (gInfo', sLnk) where validateGroupProfile :: GroupProfile -> CM () @@ -3921,5 +3921,5 @@ runRelayRequestWorker a Worker {doWork} = do pure (sigKeys, sLnk) acceptOwnerConnection :: RelayRequestData -> GroupInfo -> ShortLinkContact -> CM () acceptOwnerConnection RelayRequestData {relayInvId, reqChatVRange} gi relayLink = do - ownerMember <- withStore $ \db -> getHostMember db vr user groupId + ownerMember <- withStore $ \db -> getHostMember db cxt user groupId void $ acceptRelayJoinRequestAsync user uclId gi ownerMember relayInvId reqChatVRange relayLink diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 60d865cb30..abc40f1e6e 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -74,8 +74,8 @@ getChatLockEntity db agentConnId = do -- TODO consider whether ConnFailed connections should be excluded: -- - from receiving: getConnectionEntity, getContactConnEntityByConnReqHash -- - from subscribing: getContactConnsToSub, getUCLConnsToSub, getMemberConnsToSub, getPendingConnsToSub -getConnectionEntity :: DB.Connection -> VersionRangeChat -> User -> AgentConnId -> ExceptT StoreError IO ConnectionEntity -getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do +getConnectionEntity :: DB.Connection -> StoreCxt -> User -> AgentConnId -> ExceptT StoreError IO ConnectionEntity +getConnectionEntity db cxt user@User {userId, userContactId} agentConnId = do c@Connection {connType, entityId} <- getConnection_ case entityId of Nothing -> @@ -90,7 +90,7 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do where getConnection_ :: ExceptT StoreError IO Connection getConnection_ = ExceptT $ do - firstRow (toConnection vr) (SEConnectionNotFound agentConnId) $ + firstRow (toConnection cxt) (SEConnectionNotFound agentConnId) $ DB.query db [sql| @@ -172,7 +172,7 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do liftIO $ bitraverse (addGroupChatTags db) pure gm toGroupAndMember :: Connection -> GroupInfoRow :. GroupMemberRow -> (GroupInfo, GroupMember) toGroupAndMember c (groupInfoRow :. memberRow) = - let groupInfo = toGroupInfo vr userContactId [] groupInfoRow + let groupInfo = toGroupInfo cxt userContactId [] groupInfoRow member = toGroupMember userContactId memberRow in (groupInfo, (member :: GroupMember) {activeConn = Just c}) getUserContact_ :: Int64 -> ExceptT StoreError IO UserContact @@ -191,17 +191,17 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do userContact_ [(cReq, groupId)] = Right UserContact {userContactLinkId, connReqContact = cReq, groupId} userContact_ _ = Left SEUserContactLinkNotFound -getConnectionEntityByConnReq :: DB.Connection -> VersionRangeChat -> User -> (ConnReqInvitation, ConnReqInvitation) -> IO (Maybe ConnectionEntity) -getConnectionEntityByConnReq db vr user@User {userId} (cReqSchema1, cReqSchema2) = do +getConnectionEntityByConnReq :: DB.Connection -> StoreCxt -> User -> (ConnReqInvitation, ConnReqInvitation) -> IO (Maybe ConnectionEntity) +getConnectionEntityByConnReq db cxt user@User {userId} (cReqSchema1, cReqSchema2) = do connId_ <- maybeFirstRow fromOnly $ DB.query db "SELECT agent_conn_id FROM connections WHERE user_id = ? AND conn_req_inv IN (?,?) LIMIT 1" (userId, cReqSchema1, cReqSchema2) - maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db vr user) connId_ + maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db cxt user) connId_ -getConnectionEntityViaShortLink :: DB.Connection -> VersionRangeChat -> User -> ShortLinkInvitation -> IO (Maybe (ConnReqInvitation, ConnectionEntity)) -getConnectionEntityViaShortLink db vr user@User {userId} shortLink = fmap eitherToMaybe $ runExceptT $ do +getConnectionEntityViaShortLink :: DB.Connection -> StoreCxt -> User -> ShortLinkInvitation -> IO (Maybe (ConnReqInvitation, ConnectionEntity)) +getConnectionEntityViaShortLink db cxt user@User {userId} shortLink = fmap eitherToMaybe $ runExceptT $ do (cReq, connId) <- ExceptT getConnReqConnId - (cReq,) <$> getConnectionEntity db vr user connId + (cReq,) <$> getConnectionEntity db cxt user connId where getConnReqConnId = firstRow' toConnReqConnId (SEInternalError "connection not found") $ @@ -222,8 +222,8 @@ getConnectionEntityViaShortLink db vr user@User {userId} shortLink = fmap either -- multiple connections can have same via_contact_uri_hash if request was repeated; -- this function searches for latest connection with contact so that "known contact" plan would be chosen; -- deleted connections are filtered out to allow re-connecting via same contact address -getContactConnEntityByConnReqHash :: DB.Connection -> VersionRangeChat -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe ConnectionEntity) -getContactConnEntityByConnReqHash db vr user@User {userId} (cReqHash1, cReqHash2) = do +getContactConnEntityByConnReqHash :: DB.Connection -> StoreCxt -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe ConnectionEntity) +getContactConnEntityByConnReqHash db cxt user@User {userId} (cReqHash1, cReqHash2) = do connId_ <- maybeFirstRow fromOnly $ DB.query @@ -240,7 +240,7 @@ getContactConnEntityByConnReqHash db vr user@User {userId} (cReqHash1, cReqHash2 ) c |] (userId, cReqHash1, cReqHash2, ConnDeleted) - maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db vr user) connId_ + maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db cxt user) connId_ getContactConnsToSub :: DB.Connection -> User -> Bool -> IO [ConnId] getContactConnsToSub db User {userId} filterToSubscribe = diff --git a/src/Simplex/Chat/Store/ContactRequest.hs b/src/Simplex/Chat/Store/ContactRequest.hs index 1e0ca8bdc5..27cb970b73 100644 --- a/src/Simplex/Chat/Store/ContactRequest.hs +++ b/src/Simplex/Chat/Store/ContactRequest.hs @@ -49,7 +49,7 @@ import Database.SQLite.Simple.QQ (sql) createOrUpdateContactRequest :: DB.Connection -> TVar ChaChaDRG -> - VersionRangeChat -> + StoreCxt -> User -> Int64 -> UserContactLink -> @@ -65,7 +65,7 @@ createOrUpdateContactRequest :: createOrUpdateContactRequest db gVar - vr + cxt user@User {userId, userContactId} uclId UserContactLink {addressSettings = AddressSettings {businessAddress}} @@ -89,7 +89,7 @@ createOrUpdateContactRequest Nothing -> liftIO (getAcceptedBusinessChat xContactId) >>= \case Just gInfo@GroupInfo {businessChat = Just BusinessChatInfo {customerId}} -> do - clientMember <- getGroupMemberByMemberId db vr user gInfo customerId + clientMember <- getGroupMemberByMemberId db cxt user gInfo customerId cr <- liftIO $ getContactRequestByXContactId xContactId pure $ RSAcceptedRequest cr (REBusinessChat gInfo clientMember) Just GroupInfo {businessChat = Nothing} -> throwError SEInvalidBusinessChatContactRequest @@ -104,7 +104,7 @@ createOrUpdateContactRequest getAcceptedContact :: XContactId -> IO (Maybe Contact) getAcceptedContact xContactId = do ct_ <- - maybeFirstRow (toContact vr user []) $ + maybeFirstRow (toContact cxt user []) $ DB.query db [sql| @@ -128,7 +128,7 @@ createOrUpdateContactRequest getAcceptedBusinessChat :: XContactId -> IO (Maybe GroupInfo) getAcceptedBusinessChat xContactId = do g_ <- - maybeFirstRow (toGroupInfo vr userContactId []) $ + maybeFirstRow (toGroupInfo cxt userContactId []) $ DB.query db (groupInfoQuery <> " WHERE g.business_xcontact_id = ? AND g.user_id = ? AND mu.contact_id = ?") @@ -200,12 +200,12 @@ createOrUpdateContactRequest "UPDATE contact_requests SET contact_id = ? WHERE contact_request_id = ?" (contactId, contactRequestId) ucr <- getContactRequest db user contactRequestId - ct <- getContact db vr user contactId + ct <- getContact db cxt user contactId pure $ RSCurrentRequest Nothing ucr (Just $ REContact ct) createBusinessChat = do let groupPreferences = maybe defaultBusinessGroupPrefs businessGroupPrefs $ preferences' user (gInfo@GroupInfo {groupId}, clientMember) <- - createBusinessRequestGroup db vr gVar user cReqChatVRange profile profileId ldn groupPreferences + createBusinessRequestGroup db cxt gVar user cReqChatVRange profile profileId ldn groupPreferences liftIO $ DB.execute db @@ -278,13 +278,13 @@ createOrUpdateContactRequest getRequestEntity UserContactRequest {contactRequestId, contactId_, businessGroupId_} = case (contactId_, businessGroupId_) of (Just contactId, Nothing) -> do - ct <- getContact db vr user contactId + ct <- getContact db cxt user contactId pure $ Just (REContact ct) (Nothing, Just businessGroupId) -> do - gInfo <- getGroupInfo db vr user businessGroupId + gInfo <- getGroupInfo db cxt user businessGroupId case gInfo of GroupInfo {businessChat = Just BusinessChatInfo {customerId}} -> do - clientMember <- getGroupMemberByMemberId db vr user gInfo customerId + clientMember <- getGroupMemberByMemberId db cxt user gInfo customerId pure $ Just (REBusinessChat gInfo clientMember) _ -> throwError SEInvalidBusinessChatContactRequest (Nothing, Nothing) -> pure Nothing diff --git a/src/Simplex/Chat/Store/Delivery.hs b/src/Simplex/Chat/Store/Delivery.hs index 393e008835..e60d51ac85 100644 --- a/src/Simplex/Chat/Store/Delivery.hs +++ b/src/Simplex/Chat/Store/Delivery.hs @@ -332,8 +332,8 @@ updateDeliveryJobStatus_ db jobId status errReason_ = do (status, errReason_, currentTs, jobId) -- TODO [relays] possible improvement is to prioritize owners and "active" members -getGroupMembersByCursor :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> Maybe GroupMemberId -> Maybe GroupMemberId -> Int -> IO [GroupMember] -getGroupMembersByCursor db vr user@User {userContactId} GroupInfo {groupId} cursorGMId_ singleSenderGMId_ count = do +getGroupMembersByCursor :: DB.Connection -> StoreCxt -> User -> GroupInfo -> Maybe GroupMemberId -> Maybe GroupMemberId -> Int -> IO [GroupMember] +getGroupMembersByCursor db cxt user@User {userContactId} GroupInfo {groupId} cursorGMId_ singleSenderGMId_ count = do gmIds :: [Int64] <- map fromOnly <$> case cursorGMId_ of Nothing -> @@ -351,13 +351,13 @@ getGroupMembersByCursor db vr user@User {userContactId} GroupInfo {groupId} curs :. (cursorGMId, count) ) #if defined(dbPostgres) - map (toContactMember vr user) <$> + map (toContactMember cxt user) <$> DB.query db (groupMemberQuery <> " WHERE m.group_member_id IN ?") (Only (In gmIds)) #else - rights <$> mapM (runExceptT . getGroupMemberById db vr user) gmIds + rights <$> mapM (runExceptT . getGroupMemberById db cxt user) gmIds #endif where query = diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 60f898e52e..1c2f35f2bf 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -243,8 +243,8 @@ createRelayMemberConnectionAsync db user@User {userId} gInfo GroupMember {groupM where customUserProfileId_ = localProfileId <$> incognitoMembershipProfile gInfo -createRelayTestConnection :: DB.Connection -> VersionRangeChat -> User -> ConnId -> ConnStatus -> VersionChat -> SubscriptionMode -> ExceptT StoreError IO Connection -createRelayTestConnection db vr user@User {userId} agentConnId connStatus chatV subMode = do +createRelayTestConnection :: DB.Connection -> StoreCxt -> User -> ConnId -> ConnStatus -> VersionChat -> SubscriptionMode -> ExceptT StoreError IO Connection +createRelayTestConnection db cxt user@User {userId} agentConnId connStatus chatV subMode = do currentTs <- liftIO getCurrentTime liftIO $ DB.execute @@ -261,7 +261,7 @@ createRelayTestConnection db vr user@User {userId} agentConnId connStatus chatV :. (BI True, currentTs, currentTs) ) connId <- liftIO $ insertedRowId db - getConnectionById db vr user connId + getConnectionById db cxt user connId updateConnLinkData :: DB.Connection -> User -> Connection -> ConnReqContact -> ConnReqUriHash -> Maybe GroupLinkId -> VersionChat -> PQSupport -> IO () updateConnLinkData db User {userId} Connection {connId} cReq cReqHash groupLinkId_ chatV pqSup = do @@ -285,13 +285,13 @@ setPreparedGroupStartedConnection db groupId = do "UPDATE groups SET conn_link_started_connection = ?, updated_at = ? WHERE group_id = ?" (BI True, currentTs, groupId) -getConnReqContactXContactId :: DB.Connection -> VersionRangeChat -> User -> ConnReqUriHash -> ConnReqUriHash -> IO (Either (Maybe Connection) Contact) -getConnReqContactXContactId db vr user@User {userId} cReqHash1 cReqHash2 = - getContactByConnReqHash db vr user cReqHash1 cReqHash2 >>= maybe (Left <$> getConnection) (pure . Right) +getConnReqContactXContactId :: DB.Connection -> StoreCxt -> User -> ConnReqUriHash -> ConnReqUriHash -> IO (Either (Maybe Connection) Contact) +getConnReqContactXContactId db cxt user@User {userId} cReqHash1 cReqHash2 = + getContactByConnReqHash db cxt user cReqHash1 cReqHash2 >>= maybe (Left <$> getConnection) (pure . Right) where getConnection :: IO (Maybe Connection) getConnection = - maybeFirstRow (toConnection vr) $ + maybeFirstRow (toConnection cxt) $ DB.query db [sql| @@ -305,10 +305,10 @@ getConnReqContactXContactId db vr user@User {userId} cReqHash1 cReqHash2 = |] (userId, cReqHash1, userId, cReqHash2) -getContactByConnReqHash :: DB.Connection -> VersionRangeChat -> User -> ConnReqUriHash -> ConnReqUriHash -> IO (Maybe Contact) -getContactByConnReqHash db vr user@User {userId} cReqHash1 cReqHash2 = do +getContactByConnReqHash :: DB.Connection -> StoreCxt -> User -> ConnReqUriHash -> ConnReqUriHash -> IO (Maybe Contact) +getContactByConnReqHash db cxt user@User {userId} cReqHash1 cReqHash2 = do ct <- - maybeFirstRow (toContact vr user []) $ + maybeFirstRow (toContact cxt user []) $ DB.query db [sql| @@ -394,18 +394,18 @@ createIncognitoProfile db User {userId} p = do createdAt <- getCurrentTime createIncognitoProfile_ db userId createdAt p -createPreparedContact :: DB.Connection -> VersionRangeChat -> User -> Profile -> ACreatedConnLink -> Maybe SharedMsgId -> ExceptT StoreError IO Contact -createPreparedContact db vr user p connLinkToConnect welcomeSharedMsgId = do +createPreparedContact :: DB.Connection -> StoreCxt -> User -> Profile -> ACreatedConnLink -> Maybe SharedMsgId -> ExceptT StoreError IO Contact +createPreparedContact db cxt user p connLinkToConnect welcomeSharedMsgId = do currentTs <- liftIO getCurrentTime let prepared = Just (connLinkToConnect, welcomeSharedMsgId) ctUserPreferences = newContactUserPrefs user p contactId <- createContact_ db user p ctUserPreferences prepared "" currentTs - getContact db vr user contactId + getContact db cxt user contactId -updatePreparedContactUser :: DB.Connection -> VersionRangeChat -> User -> Contact -> User -> ExceptT StoreError IO Contact +updatePreparedContactUser :: DB.Connection -> StoreCxt -> User -> Contact -> User -> ExceptT StoreError IO Contact updatePreparedContactUser db - vr + cxt user Contact {contactId, localDisplayName = oldLDN, profile = profile@LocalProfile {profileId, displayName}} newUser@User {userId = newUserId} = do @@ -438,15 +438,15 @@ updatePreparedContactUser |] (newUserId, currentTs, contactId) safeDeleteLDN db user oldLDN - getContact db vr newUser contactId + getContact db cxt newUser contactId -createDirectContact :: DB.Connection -> VersionRangeChat -> User -> Connection -> Profile -> ExceptT StoreError IO Contact -createDirectContact db vr user Connection {connId, localAlias} p = do +createDirectContact :: DB.Connection -> StoreCxt -> User -> Connection -> Profile -> ExceptT StoreError IO Contact +createDirectContact db cxt user Connection {connId, localAlias} p = do currentTs <- liftIO getCurrentTime let ctUserPreferences = newContactUserPrefs user p contactId <- createContact_ db user p ctUserPreferences Nothing localAlias currentTs liftIO $ DB.execute db "UPDATE connections SET contact_id = ?, updated_at = ? WHERE connection_id = ?" (contactId, currentTs, connId) - getContact db vr user contactId + getContact db cxt user contactId deleteContactConnections :: DB.Connection -> User -> Contact -> IO () deleteContactConnections db User {userId} Contact {contactId} = do @@ -500,13 +500,13 @@ deleteContactWithoutGroups db user@User {userId} ct@Contact {contactId, localDis deleteUnusedIncognitoProfileById_ db user profileId -- TODO remove in future versions: only used for legacy contact cleanup -getDeletedContacts :: DB.Connection -> VersionRangeChat -> User -> IO [Contact] -getDeletedContacts db vr user@User {userId} = do +getDeletedContacts :: DB.Connection -> StoreCxt -> User -> IO [Contact] +getDeletedContacts db cxt user@User {userId} = do contactIds <- map fromOnly <$> DB.query db "SELECT contact_id FROM contacts WHERE user_id = ? AND deleted = 1" (Only userId) - rights <$> mapM (runExceptT . getDeletedContact db vr user) contactIds + rights <$> mapM (runExceptT . getDeletedContact db cxt user) contactIds -getDeletedContact :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ExceptT StoreError IO Contact -getDeletedContact db vr user contactId = getContact_ db vr user contactId True +getDeletedContact :: DB.Connection -> StoreCxt -> User -> Int64 -> ExceptT StoreError IO Contact +getDeletedContact db cxt user contactId = getContact_ db cxt user contactId True deleteContactProfile_ :: DB.Connection -> UserId -> ContactId -> IO () deleteContactProfile_ db userId contactId = @@ -756,15 +756,15 @@ updateContactLDN_ db user@User {userId} contactId displayName newName updatedAt (newName, updatedAt, userId, contactId) safeDeleteLDN db user displayName -getContactByName :: DB.Connection -> VersionRangeChat -> User -> ContactName -> ExceptT StoreError IO Contact -getContactByName db vr user localDisplayName = do +getContactByName :: DB.Connection -> StoreCxt -> User -> ContactName -> ExceptT StoreError IO Contact +getContactByName db cxt user localDisplayName = do cId <- getContactIdByName db user localDisplayName - getContact db vr user cId + getContact db cxt user cId -getUserContacts :: DB.Connection -> VersionRangeChat -> User -> IO [Contact] -getUserContacts db vr user@User {userId} = do +getUserContacts :: DB.Connection -> StoreCxt -> User -> IO [Contact] +getUserContacts db cxt user@User {userId} = do contactIds <- map fromOnly <$> DB.query db "SELECT contact_id FROM contacts WHERE user_id = ? AND deleted = 0" (Only userId) - contacts <- rights <$> mapM (runExceptT . getContact db vr user) contactIds + contacts <- rights <$> mapM (runExceptT . getContact db cxt user) contactIds pure $ filter (\Contact {activeConn} -> isJust activeConn) contacts getUserContactLinkIdByCReq :: DB.Connection -> Int64 -> ExceptT StoreError IO (Maybe Int64) @@ -890,22 +890,22 @@ getContactIdByName db User {userId} cName = ExceptT . firstRow fromOnly (SEContactNotFoundByName cName) $ DB.query db "SELECT contact_id FROM contacts WHERE user_id = ? AND local_display_name = ? AND deleted = 0" (userId, cName) -getContactViaShortLinkToConnect :: forall c. ConnectionModeI c => DB.Connection -> VersionRangeChat -> User -> ConnShortLink c -> ExceptT StoreError IO (Maybe (ConnectionRequestUri c, Contact)) -getContactViaShortLinkToConnect db vr user@User {userId} shortLink = do +getContactViaShortLinkToConnect :: forall c. ConnectionModeI c => DB.Connection -> StoreCxt -> User -> ConnShortLink c -> ExceptT StoreError IO (Maybe (ConnectionRequestUri c, Contact)) +getContactViaShortLinkToConnect db cxt user@User {userId} shortLink = do liftIO (maybeFirstRow id $ DB.query db "SELECT contact_id, conn_full_link_to_connect FROM contacts WHERE user_id = ? AND conn_short_link_to_connect = ?" (userId, shortLink)) >>= \case Just (ctId :: Int64, Just (ACR cMode cReq)) -> case testEquality cMode (sConnectionMode @c) of - Just Refl -> Just . (cReq,) <$> getContact db vr user ctId + Just Refl -> Just . (cReq,) <$> getContact db cxt user ctId Nothing -> pure Nothing _ -> pure Nothing -getContact :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ExceptT StoreError IO Contact -getContact db vr user contactId = getContact_ db vr user contactId False +getContact :: DB.Connection -> StoreCxt -> User -> Int64 -> ExceptT StoreError IO Contact +getContact db cxt user contactId = getContact_ db cxt user contactId False -getContact_ :: DB.Connection -> VersionRangeChat -> User -> Int64 -> Bool -> ExceptT StoreError IO Contact -getContact_ db vr user@User {userId} contactId deleted = do +getContact_ :: DB.Connection -> StoreCxt -> User -> Int64 -> Bool -> ExceptT StoreError IO Contact +getContact_ db cxt user@User {userId} contactId deleted = do chatTags <- liftIO $ getDirectChatTags db contactId - ExceptT . firstRow (toContact vr user chatTags) (SEContactNotFound contactId) $ + ExceptT . firstRow (toContact cxt user chatTags) (SEContactNotFound contactId) $ DB.query db [sql| @@ -932,8 +932,8 @@ getUserByContactRequestId db contactRequestId = ExceptT . firstRow toUser (SEUserNotFoundByContactRequestId contactRequestId) $ DB.query db (userQuery <> " JOIN contact_requests cr ON cr.user_id = u.user_id WHERE cr.contact_request_id = ?") (Only contactRequestId) -getContactConnections :: DB.Connection -> VersionRangeChat -> UserId -> Contact -> IO [Connection] -getContactConnections db vr userId Contact {contactId} = +getContactConnections :: DB.Connection -> StoreCxt -> UserId -> Contact -> IO [Connection] +getContactConnections db cxt userId Contact {contactId} = connections =<< liftIO getConnections_ where getConnections_ = @@ -950,11 +950,11 @@ getContactConnections db vr userId Contact {contactId} = |] (userId, userId, contactId) connections [] = pure [] - connections rows = pure $ map (toConnection vr) rows + connections rows = pure $ map (toConnection cxt) rows -getConnectionById :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ExceptT StoreError IO Connection -getConnectionById db vr User {userId} connId = ExceptT $ do - firstRow (toConnection vr) (SEConnectionNotFoundById connId) $ +getConnectionById :: DB.Connection -> StoreCxt -> User -> Int64 -> ExceptT StoreError IO Connection +getConnectionById db cxt User {userId} connId = ExceptT $ do + firstRow (toConnection cxt) (SEConnectionNotFoundById connId) $ DB.query db [sql| diff --git a/src/Simplex/Chat/Store/Files.hs b/src/Simplex/Chat/Store/Files.hs index 951fce8958..5289a3b304 100644 --- a/src/Simplex/Chat/Store/Files.hs +++ b/src/Simplex/Chat/Store/Files.hs @@ -570,19 +570,19 @@ getRcvFileTransfer_ db userId fileId = do Just fp -> pure fp cancelled = maybe False unBI cancelled_ -acceptRcvInlineFT :: DB.Connection -> VersionRangeChat -> User -> FileTransferId -> FilePath -> ExceptT StoreError IO AChatItem -acceptRcvInlineFT db vr user fileId filePath = do +acceptRcvInlineFT :: DB.Connection -> StoreCxt -> User -> FileTransferId -> FilePath -> ExceptT StoreError IO AChatItem +acceptRcvInlineFT db cxt user fileId filePath = do liftIO $ acceptRcvFT_ db user fileId filePath False (Just IFMOffer) =<< getCurrentTime - getChatItemByFileId db vr user fileId + getChatItemByFileId db cxt user fileId startRcvInlineFT :: DB.Connection -> User -> RcvFileTransfer -> FilePath -> Maybe InlineFileMode -> IO () startRcvInlineFT db user RcvFileTransfer {fileId} filePath rcvFileInline = acceptRcvFT_ db user fileId filePath False rcvFileInline =<< getCurrentTime -xftpAcceptRcvFT :: DB.Connection -> VersionRangeChat -> User -> FileTransferId -> FilePath -> Bool -> ExceptT StoreError IO AChatItem -xftpAcceptRcvFT db vr user fileId filePath userApprovedRelays = do +xftpAcceptRcvFT :: DB.Connection -> StoreCxt -> User -> FileTransferId -> FilePath -> Bool -> ExceptT StoreError IO AChatItem +xftpAcceptRcvFT db cxt user fileId filePath userApprovedRelays = do liftIO $ acceptRcvFT_ db user fileId filePath userApprovedRelays Nothing =<< getCurrentTime - getChatItemByFileId db vr user fileId + getChatItemByFileId db cxt user fileId acceptRcvFT_ :: DB.Connection -> User -> FileTransferId -> FilePath -> Bool -> Maybe InlineFileMode -> UTCTime -> IO () acceptRcvFT_ db User {userId} fileId filePath userApprovedRelays rcvFileInline currentTs = do @@ -860,9 +860,9 @@ getLocalCryptoFile db userId fileId sent = pure $ CryptoFile filePath fileCryptoArgs _ -> throwError $ SEFileNotFound fileId -updateDirectCIFileStatus :: forall d. MsgDirectionI d => DB.Connection -> VersionRangeChat -> User -> Int64 -> CIFileStatus d -> ExceptT StoreError IO AChatItem -updateDirectCIFileStatus db vr user fileId fileStatus = do - aci@(AChatItem cType d cInfo ci) <- getChatItemByFileId db vr user fileId +updateDirectCIFileStatus :: forall d. MsgDirectionI d => DB.Connection -> StoreCxt -> User -> Int64 -> CIFileStatus d -> ExceptT StoreError IO AChatItem +updateDirectCIFileStatus db cxt user fileId fileStatus = do + aci@(AChatItem cType d cInfo ci) <- getChatItemByFileId db cxt user fileId case (cType, testEquality d $ msgDirection @d) of (SCTDirect, Just Refl) -> do liftIO $ updateCIFileStatus db user fileId fileStatus diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 8c207d99a7..4e38ef83e2 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -247,9 +247,9 @@ createGroupLink db gVar user@User {userId} groupInfo@GroupInfo {groupId, localDi void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId ConnNew initialChatVersion chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode PQSupportOff getGroupLink db user groupInfo -getGroupLinkConnection :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> ExceptT StoreError IO Connection -getGroupLinkConnection db vr User {userId} groupInfo@GroupInfo {groupId} = - ExceptT . firstRow (toConnection vr) (SEGroupLinkNotFound groupInfo) $ +getGroupLinkConnection :: DB.Connection -> StoreCxt -> User -> GroupInfo -> ExceptT StoreError IO Connection +getGroupLinkConnection db cxt User {userId} groupInfo@GroupInfo {groupId} = + ExceptT . firstRow (toConnection cxt) (SEGroupLinkNotFound groupInfo) $ DB.query db [sql| @@ -344,8 +344,8 @@ setGroupLinkShortLink db gLnk@GroupLink {userContactLinkId, connLinkContact = CC pure gLnk {connLinkContact = CCLink connFullLink (Just shortLink), shortLinkDataSet = True, shortLinkLargeDataSet = BoolDef True} -- | creates completely new group with a single member - the current user -createNewGroup :: DB.Connection -> VersionRangeChat -> User -> GroupProfile -> Maybe Profile -> Bool -> MemberId -> Maybe GroupKeys -> Maybe Int64 -> ExceptT StoreError IO GroupInfo -createNewGroup db vr user@User {userId} groupProfile incognitoProfile useRelays memberId groupKeys publicMemberCount_ = ExceptT $ do +createNewGroup :: DB.Connection -> StoreCxt -> User -> GroupProfile -> Maybe Profile -> Bool -> MemberId -> Maybe GroupKeys -> Maybe Int64 -> ExceptT StoreError IO GroupInfo +createNewGroup db cxt user@User {userId} groupProfile incognitoProfile useRelays memberId groupKeys publicMemberCount_ = ExceptT $ do let GroupProfile {displayName, fullName, shortDescr, description, image, publicGroup, groupPreferences, memberAdmission} = groupProfile (groupType_, groupLink_, publicGroupId_) = case publicGroup of Just PublicGroupProfile {groupType, groupLink, publicGroupId} -> (Just groupType, Just groupLink, Just publicGroupId) @@ -389,7 +389,7 @@ createNewGroup db vr user@User {userId} groupProfile incognitoProfile useRelays ) insertedRowId db let memberPubKey = C.publicKey . memberPrivKey <$> groupKeys - membership <- createContactMemberInv_ db user groupId Nothing user (MemberIdRole memberId GROwner) GCUserMember GSMemCreator IBUser customUserProfileId memberPubKey currentTs vr + membership <- createContactMemberInv_ db user groupId Nothing user (MemberIdRole memberId GROwner) GCUserMember GSMemCreator IBUser customUserProfileId memberPubKey currentTs (vr cxt) let chatSettings = ChatSettings {enableNtfs = MFAll, sendRcpts = Nothing, favorite = False} pure GroupInfo @@ -419,13 +419,13 @@ createNewGroup db vr user@User {userId} groupProfile incognitoProfile useRelays } -- | creates a new group record for the group the current user was invited to, or returns an existing one -createGroupInvitation :: DB.Connection -> VersionRangeChat -> User -> Contact -> GroupInvitation -> Maybe ProfileId -> ExceptT StoreError IO (GroupInfo, GroupMemberId) +createGroupInvitation :: DB.Connection -> StoreCxt -> User -> Contact -> GroupInvitation -> Maybe ProfileId -> ExceptT StoreError IO (GroupInfo, GroupMemberId) createGroupInvitation _ _ _ Contact {localDisplayName, activeConn = Nothing} _ _ = throwError $ SEContactNotReady localDisplayName -createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activeConn = Just Connection {peerChatVRange}} GroupInvitation {fromMember, invitedMember, connRequest, groupProfile, business} incognitoProfileId = do +createGroupInvitation db cxt user@User {userId} contact@Contact {contactId, activeConn = Just Connection {peerChatVRange}} GroupInvitation {fromMember, invitedMember, connRequest, groupProfile, business} incognitoProfileId = do liftIO getInvitationGroupId_ >>= \case Nothing -> createGroupInvitation_ Just gId -> do - gInfo@GroupInfo {membership, groupProfile = p'} <- getGroupInfo db vr user gId + gInfo@GroupInfo {membership, groupProfile = p'} <- getGroupInfo db cxt user gId hostId <- getHostMemberId_ db user gId let GroupMember {groupMemberId, memberId, memberRole} = membership MemberIdRole {memberId = invMemberId, memberRole = invMemberRole} = invitedMember @@ -464,9 +464,9 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ |] ((profileId, localDisplayName, connRequest, userId, BI True, currentTs, currentTs, currentTs, currentTs) :. businessChatInfoRow business) insertedRowId db - let hostVRange = adjustedMemberVRange vr peerChatVRange + let hostVRange = adjustedMemberVRange (vr cxt) peerChatVRange GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId Nothing contact fromMember GCHostMember GSMemInvited IBUnknown Nothing Nothing currentTs hostVRange - membership <- createContactMemberInv_ db user groupId (Just groupMemberId) user invitedMember GCUserMember GSMemInvited (IBContact contactId) incognitoProfileId Nothing currentTs vr + membership <- createContactMemberInv_ db user groupId (Just groupMemberId) user invitedMember GCUserMember GSMemInvited (IBContact contactId) incognitoProfileId Nothing currentTs (vr cxt) let chatSettings = ChatSettings {enableNtfs = MFAll, sendRcpts = Nothing, favorite = False} pure ( GroupInfo @@ -608,8 +608,8 @@ deleteContactCardKeepConn db connId Contact {contactId, profile = LocalProfile { DB.execute db "DELETE FROM contacts WHERE contact_id = ?" (Only contactId) DB.execute db "DELETE FROM contact_profiles WHERE contact_profile_id = ?" (Only profileId) -createPreparedGroup :: DB.Connection -> TVar ChaChaDRG -> VersionRangeChat -> User -> GroupProfile -> Bool -> CreatedLinkContact -> Maybe SharedMsgId -> Bool -> GroupMemberRole -> Maybe Int64 -> ExceptT StoreError IO (GroupInfo, Maybe GroupMember) -createPreparedGroup db gVar vr user@User {userId, userContactId} groupProfile business connLinkToConnect welcomeSharedMsgId useRelays userMemberRole publicMemberCount_ = do +createPreparedGroup :: DB.Connection -> TVar ChaChaDRG -> StoreCxt -> User -> GroupProfile -> Bool -> CreatedLinkContact -> Maybe SharedMsgId -> Bool -> GroupMemberRole -> Maybe Int64 -> ExceptT StoreError IO (GroupInfo, Maybe GroupMember) +createPreparedGroup db gVar cxt user@User {userId, userContactId} groupProfile business connLinkToConnect welcomeSharedMsgId useRelays userMemberRole publicMemberCount_ = do currentTs <- liftIO getCurrentTime let prepared = Just (connLinkToConnect, welcomeSharedMsgId) (groupId, groupLDN) <- createGroup_ db userId groupProfile prepared Nothing useRelays Nothing publicMemberCount_ currentTs @@ -623,11 +623,11 @@ createPreparedGroup db gVar vr user@User {userId, userContactId} groupProfile bu else pure $ MemberId $ encodeUtf8 groupLDN <> "_user_unknown_id" let userMember = MemberIdRole userMemberId userMemberRole -- TODO [member keys] user key must be included here. Should key be added when group is prepared? - membership <- createContactMemberInv_ db user groupId hostMemberId_ user userMember GCUserMember GSMemUnknown IBUnknown Nothing Nothing currentTs vr - hostMember_ <- forM hostMemberId_ $ getGroupMember db vr user groupId + membership <- createContactMemberInv_ db user groupId hostMemberId_ user userMember GCUserMember GSMemUnknown IBUnknown Nothing Nothing currentTs (vr cxt) + hostMember_ <- forM hostMemberId_ $ getGroupMember db cxt user groupId forM_ hostMember_ $ \hostMember -> when business $ liftIO $ setGroupBusinessChatInfo groupId membership hostMember - g <- getGroupInfo db vr user groupId + g <- getGroupInfo db cxt user groupId pure (g, hostMember_) where insertHost_ currentTs groupId groupLDN = do @@ -667,13 +667,13 @@ updateBusinessChatInfo db groupId businessChatInfo = |] (businessChatInfoRow businessChatInfo :. (Only groupId)) -updatePreparedGroupUser :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> Maybe GroupMember -> User -> ExceptT StoreError IO GroupInfo -updatePreparedGroupUser db vr user gInfo@GroupInfo {groupId, membership} hostMember_ newUser@User {userId = newUserId} = do +updatePreparedGroupUser :: DB.Connection -> StoreCxt -> User -> GroupInfo -> Maybe GroupMember -> User -> ExceptT StoreError IO GroupInfo +updatePreparedGroupUser db cxt user gInfo@GroupInfo {groupId, membership} hostMember_ newUser@User {userId = newUserId} = do currentTs <- liftIO getCurrentTime updateGroup gInfo currentTs liftIO $ updateMembership membership currentTs forM_ hostMember_ $ \hostMember -> updateHostMember hostMember currentTs - getGroupInfo db vr newUser groupId + getGroupInfo db cxt newUser groupId where updateGroup GroupInfo {localDisplayName = oldGroupLDN, groupProfile = GroupProfile {displayName = groupDisplayName}} currentTs = ExceptT . withLocalDisplayName db newUserId groupDisplayName $ \newGroupLDN -> runExceptT $ do @@ -739,21 +739,21 @@ updatePreparedGroupUser db vr user gInfo@GroupInfo {groupId, membership} hostMem (newUserId, currentTs, hostProfileId) safeDeleteLDN db user oldHostLDN -updatePreparedUserAndHostMembersInvited :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMember -> GroupLinkInvitation -> ExceptT StoreError IO (GroupInfo, GroupMember) -updatePreparedUserAndHostMembersInvited db vr user gInfo hostMember GroupLinkInvitation {fromMember, fromMemberName, invitedMember, groupProfile, accepted, business} = do +updatePreparedUserAndHostMembersInvited :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupMember -> GroupLinkInvitation -> ExceptT StoreError IO (GroupInfo, GroupMember) +updatePreparedUserAndHostMembersInvited db cxt user gInfo hostMember GroupLinkInvitation {fromMember, fromMemberName, invitedMember, groupProfile, accepted, business} = do let fromMemberProfile = profileFromName fromMemberName initialStatus = maybe GSMemAccepted (acceptanceToStatus $ memberAdmission groupProfile) accepted - updatePreparedUserAndHostMembers' db vr user gInfo hostMember fromMember fromMemberProfile invitedMember groupProfile business initialStatus + updatePreparedUserAndHostMembers' db cxt user gInfo hostMember fromMember fromMemberProfile invitedMember groupProfile business initialStatus -updatePreparedUserAndHostMembersRejected :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMember -> GroupLinkRejection -> ExceptT StoreError IO (GroupInfo, GroupMember) -updatePreparedUserAndHostMembersRejected db vr user gInfo hostMember GroupLinkRejection {fromMember = fromMember@MemberIdRole {memberId}, invitedMember, groupProfile} = do +updatePreparedUserAndHostMembersRejected :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupMember -> GroupLinkRejection -> ExceptT StoreError IO (GroupInfo, GroupMember) +updatePreparedUserAndHostMembersRejected db cxt user gInfo hostMember GroupLinkRejection {fromMember = fromMember@MemberIdRole {memberId}, invitedMember, groupProfile} = do let fromMemberProfile = profileFromName $ nameFromMemberId memberId - updatePreparedUserAndHostMembers' db vr user gInfo hostMember fromMember fromMemberProfile invitedMember groupProfile Nothing GSMemRejected + updatePreparedUserAndHostMembers' db cxt user gInfo hostMember fromMember fromMemberProfile invitedMember groupProfile Nothing GSMemRejected -updatePreparedUserAndHostMembers' :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMember -> MemberIdRole -> Profile -> MemberIdRole -> GroupProfile -> Maybe BusinessChatInfo -> GroupMemberStatus -> ExceptT StoreError IO (GroupInfo, GroupMember) +updatePreparedUserAndHostMembers' :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupMember -> MemberIdRole -> Profile -> MemberIdRole -> GroupProfile -> Maybe BusinessChatInfo -> GroupMemberStatus -> ExceptT StoreError IO (GroupInfo, GroupMember) updatePreparedUserAndHostMembers' db - vr + cxt user gInfo@GroupInfo {groupId, membership, groupProfile = gp, businessChat} hostMember @@ -772,7 +772,7 @@ updatePreparedUserAndHostMembers' void $ updateGroupProfile db user gInfo groupProfile when (isJust businessChat && isJust business) $ liftIO $ updateBusinessChatInfo db groupId business - gInfo' <- getGroupInfo db vr user groupId + gInfo' <- getGroupInfo db cxt user groupId pure (gInfo', hostMember') where updateUserMember currentTs = do @@ -803,23 +803,23 @@ updatePreparedUserAndHostMembers' WHERE group_member_id = ? |] (memberId, memberRole, currentTs, gmId) - getGroupMemberById db vr user gmId + getGroupMemberById db cxt user gmId -createGroupInvitedViaLink :: DB.Connection -> VersionRangeChat -> User -> Connection -> GroupLinkInvitation -> ExceptT StoreError IO (GroupInfo, GroupMember) -createGroupInvitedViaLink db vr user conn GroupLinkInvitation {fromMember, fromMemberName, invitedMember, groupProfile, accepted, business} = do +createGroupInvitedViaLink :: DB.Connection -> StoreCxt -> User -> Connection -> GroupLinkInvitation -> ExceptT StoreError IO (GroupInfo, GroupMember) +createGroupInvitedViaLink db cxt user conn GroupLinkInvitation {fromMember, fromMemberName, invitedMember, groupProfile, accepted, business} = do let fromMemberProfile = profileFromName fromMemberName initialStatus = maybe GSMemAccepted (acceptanceToStatus $ memberAdmission groupProfile) accepted - createGroupViaLink' db vr user conn fromMember fromMemberProfile invitedMember groupProfile business initialStatus + createGroupViaLink' db cxt user conn fromMember fromMemberProfile invitedMember groupProfile business initialStatus -createGroupRejectedViaLink :: DB.Connection -> VersionRangeChat -> User -> Connection -> GroupLinkRejection -> ExceptT StoreError IO (GroupInfo, GroupMember) -createGroupRejectedViaLink db vr user conn GroupLinkRejection {fromMember = fromMember@MemberIdRole {memberId}, invitedMember, groupProfile} = do +createGroupRejectedViaLink :: DB.Connection -> StoreCxt -> User -> Connection -> GroupLinkRejection -> ExceptT StoreError IO (GroupInfo, GroupMember) +createGroupRejectedViaLink db cxt user conn GroupLinkRejection {fromMember = fromMember@MemberIdRole {memberId}, invitedMember, groupProfile} = do let fromMemberProfile = profileFromName $ nameFromMemberId memberId - createGroupViaLink' db vr user conn fromMember fromMemberProfile invitedMember groupProfile Nothing GSMemRejected + createGroupViaLink' db cxt user conn fromMember fromMemberProfile invitedMember groupProfile Nothing GSMemRejected -createGroupViaLink' :: DB.Connection -> VersionRangeChat -> User -> Connection -> MemberIdRole -> Profile -> MemberIdRole -> GroupProfile -> Maybe BusinessChatInfo -> GroupMemberStatus -> ExceptT StoreError IO (GroupInfo, GroupMember) +createGroupViaLink' :: DB.Connection -> StoreCxt -> User -> Connection -> MemberIdRole -> Profile -> MemberIdRole -> GroupProfile -> Maybe BusinessChatInfo -> GroupMemberStatus -> ExceptT StoreError IO (GroupInfo, GroupMember) createGroupViaLink' db - vr + cxt user@User {userId, userContactId} Connection {connId, customUserProfileId} fromMember @@ -834,9 +834,9 @@ createGroupViaLink' liftIO $ DB.execute db "UPDATE connections SET conn_type = ?, group_member_id = ?, updated_at = ? WHERE connection_id = ?" (ConnMember, hostMemberId, currentTs, connId) -- using IBUnknown since host is created without contact -- TODO [member keys] this is currently not used with public groups. If it needs to be used, member keys need to be added - void $ createContactMemberInv_ db user groupId (Just hostMemberId) user invitedMember GCUserMember membershipStatus IBUnknown customUserProfileId Nothing currentTs vr + void $ createContactMemberInv_ db user groupId (Just hostMemberId) user invitedMember GCUserMember membershipStatus IBUnknown customUserProfileId Nothing currentTs (vr cxt) liftIO $ setViaGroupLinkUri db groupId connId - (,) <$> getGroupInfo db vr user groupId <*> getGroupMemberById db vr user hostMemberId + (,) <$> getGroupInfo db cxt user groupId <*> getGroupMemberById db cxt user hostMemberId where insertHost_ currentTs groupId = do (localDisplayName, profileId) <- createNewMemberProfile_ db user fromMemberProfile currentTs @@ -897,10 +897,10 @@ setGroupInvitationChatItemId db User {userId} groupId chatItemId = do -- TODO return the last connection that is ready, not any last connection -- requires updating connection status -getGroup :: DB.Connection -> VersionRangeChat -> User -> GroupId -> ExceptT StoreError IO Group -getGroup db vr user groupId = do - gInfo <- getGroupInfo db vr user groupId - members <- liftIO $ getGroupMembers db vr user gInfo +getGroup :: DB.Connection -> StoreCxt -> User -> GroupId -> ExceptT StoreError IO Group +getGroup db cxt user groupId = do + gInfo <- getGroupInfo db cxt user groupId + members <- liftIO $ getGroupMembers db cxt user gInfo pure $ Group gInfo members deleteGroupChatItems :: DB.Connection -> User -> GroupInfo -> IO () @@ -994,18 +994,18 @@ deleteGroupProfile_ db userId groupId = |] (userId, groupId) -getInProgressGroups :: DB.Connection -> VersionRangeChat -> User -> UTCTime -> IO [GroupInfo] -getInProgressGroups db vr user@User {userId} createdAtCutoff = do +getInProgressGroups :: DB.Connection -> StoreCxt -> User -> UTCTime -> IO [GroupInfo] +getInProgressGroups db cxt user@User {userId} createdAtCutoff = do groupIds <- map fromOnly <$> DB.query db "SELECT group_id FROM groups WHERE user_id = ? AND creating_in_progress = 1 AND created_at <= ?" (userId, createdAtCutoff) - rights <$> mapM (runExceptT . getGroupInfo db vr user) groupIds + rights <$> mapM (runExceptT . getGroupInfo db cxt user) groupIds -getBaseGroupDetails :: DB.Connection -> VersionRangeChat -> User -> Maybe ContactId -> Maybe Text -> IO [GroupInfo] -getBaseGroupDetails db vr User {userId, userContactId} _contactId_ search_ = do - map (toGroupInfo vr userContactId []) +getBaseGroupDetails :: DB.Connection -> StoreCxt -> User -> Maybe ContactId -> Maybe Text -> IO [GroupInfo] +getBaseGroupDetails db cxt User {userId, userContactId} _contactId_ search_ = do + map (toGroupInfo cxt userContactId []) <$> DB.query db (groupInfoQuery <> " " <> condition) (userId, userContactId, search, search, search, search) where condition = @@ -1033,22 +1033,22 @@ getContactGroupPreferences db User {userId} Contact {contactId} = do |] (userId, contactId) -getGroupInfoByName :: DB.Connection -> VersionRangeChat -> User -> GroupName -> ExceptT StoreError IO GroupInfo -getGroupInfoByName db vr user gName = do +getGroupInfoByName :: DB.Connection -> StoreCxt -> User -> GroupName -> ExceptT StoreError IO GroupInfo +getGroupInfoByName db cxt user gName = do gId <- getGroupIdByName db user gName - getGroupInfo db vr user gId + getGroupInfo db cxt user gId -getGroupMember :: DB.Connection -> VersionRangeChat -> User -> GroupId -> GroupMemberId -> ExceptT StoreError IO GroupMember -getGroupMember db vr user@User {userId} groupId groupMemberId = - ExceptT . firstRow (toContactMember vr user) (SEGroupMemberNotFound groupMemberId) $ +getGroupMember :: DB.Connection -> StoreCxt -> User -> GroupId -> GroupMemberId -> ExceptT StoreError IO GroupMember +getGroupMember db cxt user@User {userId} groupId groupMemberId = + ExceptT . firstRow (toContactMember cxt user) (SEGroupMemberNotFound groupMemberId) $ DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.group_member_id = ? AND m.user_id = ?") (groupId, groupMemberId, userId) -getHostMember :: DB.Connection -> VersionRangeChat -> User -> GroupId -> ExceptT StoreError IO GroupMember -getHostMember db vr user groupId = - ExceptT . firstRow (toContactMember vr user) (SEGroupHostMemberNotFound groupId) $ +getHostMember :: DB.Connection -> StoreCxt -> User -> GroupId -> ExceptT StoreError IO GroupMember +getHostMember db cxt user groupId = + ExceptT . firstRow (toContactMember cxt user) (SEGroupHostMemberNotFound groupId) $ DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.member_category = ?") @@ -1087,46 +1087,46 @@ toMentionedMember (groupMemberId, memberId, memberRole, displayName, localAlias) let memberRef = Just CIMentionMember {groupMemberId, displayName, localAlias, memberRole} in CIMention {memberId, memberRef} -getGroupMemberById :: DB.Connection -> VersionRangeChat -> User -> GroupMemberId -> ExceptT StoreError IO GroupMember -getGroupMemberById db vr user@User {userId} groupMemberId = - ExceptT . firstRow (toContactMember vr user) (SEGroupMemberNotFound groupMemberId) $ +getGroupMemberById :: DB.Connection -> StoreCxt -> User -> GroupMemberId -> ExceptT StoreError IO GroupMember +getGroupMemberById db cxt user@User {userId} groupMemberId = + ExceptT . firstRow (toContactMember cxt user) (SEGroupMemberNotFound groupMemberId) $ DB.query db (groupMemberQuery <> " WHERE m.group_member_id = ? AND m.user_id = ?") (groupMemberId, userId) -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) $ +getGroupMemberByIndex :: DB.Connection -> StoreCxt -> User -> GroupInfo -> Int64 -> ExceptT StoreError IO GroupMember +getGroupMemberByIndex db cxt user GroupInfo {groupId} indexInGroup = + ExceptT . firstRow (toContactMember cxt user) (SEGroupMemberNotFoundByIndex indexInGroup) $ DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.index_in_group = ?") (groupId, indexInGroup) -getSupportScopeMemberByIndex :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMemberId -> Int64 -> ExceptT StoreError IO GroupMember -getSupportScopeMemberByIndex db vr user GroupInfo {groupId} scopeGMId indexInGroup = - ExceptT . firstRow (toContactMember vr user) (SEGroupMemberNotFoundByIndex indexInGroup) $ +getSupportScopeMemberByIndex :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupMemberId -> Int64 -> ExceptT StoreError IO GroupMember +getSupportScopeMemberByIndex db cxt user GroupInfo {groupId} scopeGMId indexInGroup = + ExceptT . firstRow (toContactMember cxt user) (SEGroupMemberNotFoundByIndex indexInGroup) $ DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.index_in_group = ? AND (m.member_role IN (?,?,?) OR m.group_member_id = ?)") (groupId, indexInGroup, GRModerator, GRAdmin, GROwner, scopeGMId) -getGroupMemberByMemberId :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> MemberId -> ExceptT StoreError IO GroupMember -getGroupMemberByMemberId db vr user GroupInfo {groupId} memberId = - ExceptT . firstRow (toContactMember vr user) (SEGroupMemberNotFoundByMemberId memberId) $ +getGroupMemberByMemberId :: DB.Connection -> StoreCxt -> User -> GroupInfo -> MemberId -> ExceptT StoreError IO GroupMember +getGroupMemberByMemberId db cxt user GroupInfo {groupId} memberId = + ExceptT . firstRow (toContactMember cxt user) (SEGroupMemberNotFoundByMemberId memberId) $ DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.member_id = ?") (groupId, memberId) -getCreateUnknownGMByMemberId :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> MemberId -> ContactName -> GroupMemberRole -> Bool -> ExceptT StoreError IO (Maybe (GroupMember, Bool)) -getCreateUnknownGMByMemberId db vr user gInfo memberId memberName unknownMemberRole allowCreate = do - liftIO (runExceptT $ getGroupMemberByMemberId db vr user gInfo memberId) >>= \case +getCreateUnknownGMByMemberId :: DB.Connection -> StoreCxt -> User -> GroupInfo -> MemberId -> ContactName -> GroupMemberRole -> Bool -> ExceptT StoreError IO (Maybe (GroupMember, Bool)) +getCreateUnknownGMByMemberId db cxt user gInfo memberId memberName unknownMemberRole allowCreate = do + liftIO (runExceptT $ getGroupMemberByMemberId db cxt user gInfo memberId) >>= \case Right m -> pure $ Just (m, False) Left (SEGroupMemberNotFoundByMemberId _) | allowCreate -> do let name = if T.null memberName then nameFromMemberId memberId else memberName - m <- createNewUnknownGroupMember db vr user gInfo memberId name unknownMemberRole + m <- createNewUnknownGroupMember db cxt user gInfo memberId name unknownMemberRole pure $ Just (m, True) | otherwise -> pure Nothing Left e -> throwError e @@ -1145,59 +1145,59 @@ getGroupMemberIdViaMemberId db User {userId} GroupInfo {groupId} memberId = "SELECT group_member_id FROM group_members WHERE user_id = ? AND group_id = ? AND member_id = ?" (userId, groupId, memberId) -getGroupMembers :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] -getGroupMembers db vr user@User {userId, userContactId} GroupInfo {groupId} = - map (toContactMember vr user) +getGroupMembers :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] +getGroupMembers db cxt user@User {userId, userContactId} GroupInfo {groupId} = + map (toContactMember cxt user) <$> DB.query db (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?)") (userId, groupId, userContactId) -getGroupMembersByIndexes :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> [Int64] -> IO [GroupMember] -getGroupMembersByIndexes db vr user gInfo indexesInGroup = do +getGroupMembersByIndexes :: DB.Connection -> StoreCxt -> User -> GroupInfo -> [Int64] -> IO [GroupMember] +getGroupMembersByIndexes db cxt user gInfo indexesInGroup = do #if defined(dbPostgres) let GroupInfo {groupId} = gInfo - map (toContactMember vr user) <$> + map (toContactMember cxt user) <$> DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.index_in_group IN ?") (groupId, In indexesInGroup) #else - rights <$> mapM (runExceptT . getGroupMemberByIndex db vr user gInfo) indexesInGroup + rights <$> mapM (runExceptT . getGroupMemberByIndex db cxt user gInfo) indexesInGroup #endif -getSupportScopeMembersByIndexes :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMemberId -> [Int64] -> IO [GroupMember] -getSupportScopeMembersByIndexes db vr user gInfo scopeGMId indexesInGroup = do +getSupportScopeMembersByIndexes :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupMemberId -> [Int64] -> IO [GroupMember] +getSupportScopeMembersByIndexes db cxt user gInfo scopeGMId indexesInGroup = do #if defined(dbPostgres) let GroupInfo {groupId} = gInfo - map (toContactMember vr user) <$> + map (toContactMember cxt user) <$> DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.index_in_group IN ? AND (m.member_role IN (?,?,?) OR m.group_member_id = ?)") (groupId, In indexesInGroup, GRModerator, GRAdmin, GROwner, scopeGMId) #else - rights <$> mapM (runExceptT . getSupportScopeMemberByIndex db vr user gInfo scopeGMId) indexesInGroup + rights <$> mapM (runExceptT . getSupportScopeMemberByIndex db cxt user gInfo scopeGMId) indexesInGroup #endif -getGroupModerators :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] -getGroupModerators db vr user@User {userId, userContactId} GroupInfo {groupId} = do - map (toContactMember vr user) +getGroupModerators :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] +getGroupModerators db cxt user@User {userId, userContactId} GroupInfo {groupId} = do + map (toContactMember 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 IN (?,?,?)") (userId, groupId, userContactId, GRModerator, GRAdmin, GROwner) -getGroupRelayMembers :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] -getGroupRelayMembers db vr user@User {userId, userContactId} GroupInfo {groupId} = do - map (toContactMember vr user) +getGroupRelayMembers :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] +getGroupRelayMembers db cxt user@User {userId, userContactId} GroupInfo {groupId} = do + map (toContactMember cxt user) <$> DB.query db (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND m.contact_id IS DISTINCT FROM ? AND m.member_role = ?") (userId, groupId, userContactId, GRRelay) -getGroupMembersForExpiration :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] -getGroupMembersForExpiration db vr user@User {userId, userContactId} GroupInfo {groupId} = do - map (toContactMember vr user) +getGroupMembersForExpiration :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] +getGroupMembersForExpiration db cxt user@User {userId, userContactId} GroupInfo {groupId} = do + map (toContactMember cxt user) <$> DB.query db ( groupMemberQuery @@ -1212,14 +1212,14 @@ getGroupMembersForExpiration db vr user@User {userId, userContactId} GroupInfo { ) (groupId, userId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted, GSMemUnknown) -getGroupInvitation :: DB.Connection -> VersionRangeChat -> User -> GroupId -> ExceptT StoreError IO ReceivedGroupInvitation -getGroupInvitation db vr user groupId = +getGroupInvitation :: DB.Connection -> StoreCxt -> User -> GroupId -> ExceptT StoreError IO ReceivedGroupInvitation +getGroupInvitation db cxt user groupId = getConnRec_ user >>= \case Just connRequest -> do - groupInfo@GroupInfo {membership} <- getGroupInfo db vr user groupId + groupInfo@GroupInfo {membership} <- getGroupInfo db cxt user groupId when (memberStatus membership /= GSMemInvited) $ throwError SEGroupAlreadyJoined hostId <- getHostMemberId_ db user groupId - fromMember <- getGroupMember db vr user groupId hostId + fromMember <- getGroupMember db cxt user groupId hostId pure ReceivedGroupInvitation {fromMember, connRequest, groupInfo} _ -> throwError SEGroupInvitationNotFound where @@ -1357,8 +1357,8 @@ toGroupRelay ((groupRelayId, groupMemberId, chatRelayId, address, displayName, f relayCap = RelayCapabilities {webDomain} in GroupRelay {groupRelayId, groupMemberId, userChatRelay, relayStatus, relayLink, relayCap} -createRelayForOwner :: DB.Connection -> VersionRangeChat -> TVar ChaChaDRG -> User -> GroupInfo -> UserChatRelay -> ExceptT StoreError IO GroupMember -createRelayForOwner db vr gVar user@User {userId, userContactId} GroupInfo {groupId, membership} UserChatRelay {relayProfile = RelayProfile {displayName}} = do +createRelayForOwner :: DB.Connection -> StoreCxt -> TVar ChaChaDRG -> User -> GroupInfo -> UserChatRelay -> ExceptT StoreError IO GroupMember +createRelayForOwner db cxt gVar user@User {userId, userContactId} GroupInfo {groupId, membership} UserChatRelay {relayProfile = RelayProfile {displayName}} = do currentTs <- liftIO getCurrentTime let relayProfile = profileFromName displayName (localDisplayName, memProfileId) <- createNewMemberProfile_ db user relayProfile currentTs @@ -1377,14 +1377,14 @@ createRelayForOwner db vr gVar user@User {userId, userContactId} GroupInfo {grou :. (userId, localDisplayName, memProfileId, currentTs, currentTs) ) liftIO $ insertedRowId db - getGroupMemberById db vr user groupMemberId + getGroupMemberById db cxt user groupMemberId -getCreateRelayForMember :: DB.Connection -> VersionRangeChat -> TVar ChaChaDRG -> User -> GroupInfo -> ShortLinkContact -> ExceptT StoreError IO GroupMember -getCreateRelayForMember db vr gVar user@User {userId, userContactId} GroupInfo {groupId, localDisplayName = groupLDN} relayLink = +getCreateRelayForMember :: DB.Connection -> StoreCxt -> TVar ChaChaDRG -> User -> GroupInfo -> ShortLinkContact -> ExceptT StoreError IO GroupMember +getCreateRelayForMember db cxt gVar user@User {userId, userContactId} GroupInfo {groupId, localDisplayName = groupLDN} relayLink = liftIO getGroupMemberByRelayLink >>= maybe createRelayMember pure where getGroupMemberByRelayLink = - maybeFirstRow (toContactMember vr user) $ + maybeFirstRow (toContactMember cxt user) $ DB.query db #if defined(dbPostgres) @@ -1415,10 +1415,10 @@ getCreateRelayForMember db vr gVar user@User {userId, userContactId} GroupInfo { :. (userId, localDisplayName, profileId, currentTs, currentTs, relayLink) ) insertedRowId db - getGroupMember db vr user groupId groupMemberId + getGroupMember db cxt user groupId groupMemberId -createRelayConnection :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ConnId -> ConnStatus -> VersionChat -> SubscriptionMode -> ExceptT StoreError IO Connection -createRelayConnection db vr user@User {userId} groupMemberId agentConnId connStatus chatV subMode = do +createRelayConnection :: DB.Connection -> StoreCxt -> User -> Int64 -> ConnId -> ConnStatus -> VersionChat -> SubscriptionMode -> ExceptT StoreError IO Connection +createRelayConnection db cxt user@User {userId} groupMemberId agentConnId connStatus chatV subMode = do currentTs <- liftIO getCurrentTime liftIO $ DB.execute @@ -1435,7 +1435,7 @@ createRelayConnection db vr user@User {userId} groupMemberId agentConnId connSta :. (currentTs, currentTs) ) connId <- liftIO $ insertedRowId db - getConnectionById db vr user connId + getConnectionById db cxt user connId updateRelayStatus :: DB.Connection -> GroupRelay -> RelayStatus -> IO GroupRelay updateRelayStatus db relay@GroupRelay {groupRelayId} relayStatus = @@ -1452,8 +1452,8 @@ updateRelayStatus_ db relayId relayStatus = do currentTs <- getCurrentTime DB.execute db "UPDATE group_relays SET relay_status = ?, updated_at = ? WHERE group_relay_id = ?" (relayStatus, currentTs, relayId) -setRelayLinkAccepted :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> MemberKey -> Profile -> ExceptT StoreError IO (GroupMember, GroupRelay) -setRelayLinkAccepted db vr user m (MemberKey relayKey) profile = do +setRelayLinkAccepted :: DB.Connection -> StoreCxt -> User -> GroupMember -> MemberKey -> Profile -> ExceptT StoreError IO (GroupMember, GroupRelay) +setRelayLinkAccepted db cxt user m (MemberKey relayKey) profile = do let gmId = groupMemberId' m currentTs <- liftIO getCurrentTime liftIO $ DB.execute @@ -1473,7 +1473,7 @@ setRelayLinkAccepted db vr user m (MemberKey relayKey) profile = do |] (relayKey, currentTs, gmId) void $ updateMemberProfile db user m profile - (,) <$> getGroupMemberById db vr user gmId <*> getGroupRelayByGMId db gmId + (,) <$> getGroupMemberById db cxt user gmId <*> getGroupRelayByGMId db gmId setRelayLinkConfId :: DB.Connection -> GroupMember -> ConfirmationId -> ShortLinkContact -> IO () setRelayLinkConfId db m confId relayLink = do @@ -1541,8 +1541,8 @@ setGroupInProgressDone db GroupInfo {groupId} = do "UPDATE groups SET creating_in_progress = 0, updated_at = ? WHERE group_id = ?" (currentTs, groupId) -createRelayRequestGroup :: DB.Connection -> VersionRangeChat -> User -> GroupRelayInvitation -> InvitationId -> VersionRangeChat -> Int64 -> GroupMemberStatus -> RelayStatus -> ExceptT StoreError IO (GroupInfo, GroupMember) -createRelayRequestGroup db vr user@User {userId} GroupRelayInvitation {fromMember, fromMemberProfile, relayMemberId, groupLink} invId reqChatVRange initialDelay memberStatus relayStatus = do +createRelayRequestGroup :: DB.Connection -> StoreCxt -> User -> GroupRelayInvitation -> InvitationId -> VersionRangeChat -> Int64 -> GroupMemberStatus -> RelayStatus -> ExceptT StoreError IO (GroupInfo, GroupMember) +createRelayRequestGroup db cxt user@User {userId} GroupRelayInvitation {fromMember, fromMemberProfile, relayMemberId, groupLink} invId reqChatVRange initialDelay memberStatus relayStatus = do currentTs <- liftIO getCurrentTime -- Create group with placeholder profile let Profile {displayName = fromMemberLDN} = fromMemberProfile @@ -1562,9 +1562,9 @@ createRelayRequestGroup db vr user@User {userId} GroupRelayInvitation {fromMembe ownerMemberId <- insertOwner_ currentTs groupId let relayMember = MemberIdRole relayMemberId GRRelay -- TODO [member keys] should relays use member keys? - _membership <- createContactMemberInv_ db user groupId (Just ownerMemberId) user relayMember GCUserMember memberStatus IBUnknown Nothing Nothing currentTs vr - ownerMember <- getGroupMember db vr user groupId ownerMemberId - g <- getGroupInfo db vr user groupId + _membership <- createContactMemberInv_ db user groupId (Just ownerMemberId) user relayMember GCUserMember memberStatus IBUnknown Nothing Nothing currentTs (vr cxt) + ownerMember <- getGroupMember db cxt user groupId ownerMemberId + g <- getGroupInfo db cxt user groupId pure (g, ownerMember) where setRelayRequestData_ groupId currentTs = @@ -1616,8 +1616,8 @@ updateRelayOwnStatus_ db GroupInfo {groupId} relayStatus = do -- 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 -> VersionRangeChat -> User -> GroupId -> ExceptT StoreError IO GroupInfo -allowRelayGroup db vr user@User {userId} groupId = do +allowRelayGroup :: DB.Connection -> StoreCxt -> User -> GroupId -> ExceptT StoreError IO GroupInfo +allowRelayGroup db cxt user@User {userId} groupId = do currentTs <- liftIO getCurrentTime liftIO $ DB.execute @@ -1630,7 +1630,7 @@ allowRelayGroup db vr user@User {userId} groupId = do AND relay_own_status = ? |] (RSInactive, currentTs, currentTs, userId, groupId, RSRejected) - getGroupInfo db vr user groupId + getGroupInfo db cxt user groupId isRelayGroupRejected :: DB.Connection -> User -> ShortLinkContact -> IO Bool isRelayGroupRejected db User {userId} groupLink = @@ -1649,9 +1649,9 @@ isRelayGroupRejected db User {userId} groupLink = (userId, groupLink, RSRejected) ) -getRelayServedGroups :: DB.Connection -> VersionRangeChat -> User -> IO [GroupInfo] -getRelayServedGroups db vr User {userId, userContactId} = do - map (toGroupInfo vr userContactId []) +getRelayServedGroups :: DB.Connection -> StoreCxt -> User -> IO [GroupInfo] +getRelayServedGroups db cxt User {userId, userContactId} = do + map (toGroupInfo cxt userContactId []) <$> DB.query db ( groupInfoQuery @@ -1659,10 +1659,10 @@ getRelayServedGroups db vr User {userId, userContactId} = do ) (userId, userContactId, RSAccepted, RSActive) -getRelayInactiveGroups :: DB.Connection -> VersionRangeChat -> User -> NominalDiffTime -> IO [GroupInfo] -getRelayInactiveGroups db vr User {userId, userContactId} ttl = do +getRelayInactiveGroups :: DB.Connection -> StoreCxt -> User -> NominalDiffTime -> IO [GroupInfo] +getRelayInactiveGroups db cxt User {userId, userContactId} ttl = do cutoffTs <- addUTCTime (- ttl) <$> getCurrentTime - map (toGroupInfo vr userContactId []) + map (toGroupInfo cxt userContactId []) <$> DB.query db ( groupInfoQuery @@ -1774,10 +1774,10 @@ createJoiningMemberConnection Connection {connId} <- createConnection_ db userId ConnMember (Just groupMemberId) agentConnId ConnNew chatV cReqChatVRange Nothing (Just uclId) Nothing 0 createdAt subMode PQSupportOff setCommandConnId db user cmdId connId -createBusinessRequestGroup :: DB.Connection -> VersionRangeChat -> TVar ChaChaDRG -> User -> VersionRangeChat -> Profile -> Int64 -> Text -> GroupPreferences -> ExceptT StoreError IO (GroupInfo, GroupMember) +createBusinessRequestGroup :: DB.Connection -> StoreCxt -> TVar ChaChaDRG -> User -> VersionRangeChat -> Profile -> Int64 -> Text -> GroupPreferences -> ExceptT StoreError IO (GroupInfo, GroupMember) createBusinessRequestGroup db - vr + cxt gVar user@User {userId, userContactId} cReqChatVRange @@ -1789,8 +1789,8 @@ createBusinessRequestGroup (groupId, membership@GroupMember {memberId = userMemberId}) <- insertGroup_ currentTs (groupMemberId, memberId) <- insertClientMember_ currentTs groupId membership liftIO $ DB.execute db "UPDATE groups SET business_member_id = ?, customer_member_id = ? WHERE group_id = ?" (userMemberId, memberId, groupId) - groupInfo <- getGroupInfo db vr user groupId - clientMember <- getGroupMemberById db vr user groupMemberId + groupInfo <- getGroupInfo db cxt user groupId + clientMember <- getGroupMemberById db cxt user groupMemberId pure (groupInfo, clientMember) where insertGroup_ currentTs = do @@ -1813,7 +1813,7 @@ createBusinessRequestGroup groupId <- liftIO $ insertedRowId db memberId <- liftIO $ encodedRandomBytes gVar 12 -- TODO [member keys] we could support member keys in business groups to allow binding agreements (though identity keys would be better for it. - membership <- createContactMemberInv_ db user groupId Nothing user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser Nothing Nothing currentTs vr + membership <- createContactMemberInv_ db user groupId Nothing user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser Nothing Nothing currentTs (vr cxt) pure (groupId, membership) VersionRange minV maxV = cReqChatVRange insertClientMember_ currentTs groupId membership = @@ -1837,8 +1837,8 @@ createBusinessRequestGroup groupMemberId <- liftIO $ insertedRowId db pure (groupMemberId, MemberId memId) -getContactViaMember :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> ExceptT StoreError IO Contact -getContactViaMember db vr user@User {userId} GroupMember {groupMemberId} = do +getContactViaMember :: DB.Connection -> StoreCxt -> User -> GroupMember -> ExceptT StoreError IO Contact +getContactViaMember db cxt user@User {userId} GroupMember {groupMemberId} = do contactId <- ExceptT $ firstRow fromOnly (SEContactNotFoundByMemberId groupMemberId) $ @@ -1852,7 +1852,7 @@ getContactViaMember db vr user@User {userId} GroupMember {groupMemberId} = do LIMIT 1 |] (userId, groupMemberId) - getContact db vr user contactId + getContact db cxt user contactId setNewContactMemberConnRequest :: DB.Connection -> User -> GroupMember -> ConnReqInvitation -> IO () setNewContactMemberConnRequest db User {userId} GroupMember {groupMemberId} connRequest = do @@ -1879,18 +1879,18 @@ createMemberConnectionAsync db user@User {userId} groupMemberId (cmdId, agentCon -- This is called once before connecting to relays, unlike createConnReqConnection -> setPreparedGroupLinkInfo_, -- which is used in single-connection flows. updatePreparedRelayedGroup :: - DB.Connection -> VersionRangeChat -> User -> GroupInfo -> ConnReqContact -> ConnReqUriHash -> Maybe Profile -> + DB.Connection -> StoreCxt -> User -> GroupInfo -> ConnReqContact -> ConnReqUriHash -> Maybe Profile -> C.PublicKeyEd25519 -> C.PrivateKeyEd25519 -> Maybe Int64 -> ExceptT StoreError IO GroupInfo -updatePreparedRelayedGroup db vr user@User {userId} gInfo cReq cReqHash incognitoProfile rootPubKey memberPrivKey publicMemberCount_ = do +updatePreparedRelayedGroup db cxt user@User {userId} gInfo cReq cReqHash incognitoProfile rootPubKey memberPrivKey publicMemberCount_ = do currentTs <- liftIO getCurrentTime customUserProfileId <- liftIO $ mapM (createIncognitoProfile_ db userId currentTs) incognitoProfile liftIO $ setPreparedGroupLinkInfo_ db gInfo cReq cReqHash customUserProfileId publicMemberCount_ currentTs liftIO $ updateGroupMemberKeys db (groupId' gInfo) rootPubKey memberPrivKey (groupMemberId' $ membership gInfo) - getGroupInfo db vr user (groupId' gInfo) + getGroupInfo db cxt user (groupId' gInfo) -updatePublicMemberCount :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> ExceptT StoreError IO GroupInfo -updatePublicMemberCount db vr user GroupInfo {groupId} = do +updatePublicMemberCount :: DB.Connection -> StoreCxt -> User -> GroupInfo -> ExceptT StoreError IO GroupInfo +updatePublicMemberCount db cxt user GroupInfo {groupId} = do liftIO $ do totalCount <- fromMaybe 0 <$> maybeFirstRow fromOnly (DB.query db "SELECT summary_current_members_count FROM groups WHERE group_id = ?" (Only groupId)) @@ -1906,13 +1906,13 @@ updatePublicMemberCount db vr user GroupInfo {groupId} = do let publicCount = max 0 (totalCount - relayCount) :: Int64 currentTs <- getCurrentTime DB.execute db "UPDATE groups SET public_member_count = ?, updated_at = ? WHERE group_id = ?" (publicCount, currentTs, groupId) - getGroupInfo db vr user groupId + getGroupInfo db cxt user groupId -setPublicMemberCount :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> Int64 -> ExceptT StoreError IO GroupInfo -setPublicMemberCount db vr user GroupInfo {groupId} publicCount = do +setPublicMemberCount :: DB.Connection -> StoreCxt -> User -> GroupInfo -> Int64 -> ExceptT StoreError IO GroupInfo +setPublicMemberCount db cxt user GroupInfo {groupId} publicCount = do currentTs <- liftIO getCurrentTime liftIO $ DB.execute db "UPDATE groups SET public_member_count = ?, updated_at = ? WHERE group_id = ?" (publicCount, currentTs, groupId) - getGroupInfo db vr user groupId + getGroupInfo db cxt user groupId updateGroupMemberKeys :: DB.Connection -> GroupId -> C.PublicKeyEd25519 -> C.PrivateKeyEd25519 -> GroupMemberId -> IO () updateGroupMemberKeys db groupId rootPubKey memberPrivKey membershipGMId = do @@ -2402,8 +2402,8 @@ updateGroupProfileFromMember db user g@GroupInfo {groupId} Profile {displayName let publicGroupAccess = toPublicGroupAccess accessRow in GroupProfile {displayName, fullName, shortDescr, description, image, publicGroup = toPublicGroupProfile groupType_ groupLink_ publicGroupId_ publicGroupAccess, groupPreferences, memberAdmission} -getGroupInfoByUserContactLinkConnReq :: DB.Connection -> VersionRangeChat -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe GroupInfo) -getGroupInfoByUserContactLinkConnReq db vr user@User {userId} (cReqSchema1, cReqSchema2) = do +getGroupInfoByUserContactLinkConnReq :: DB.Connection -> StoreCxt -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe GroupInfo) +getGroupInfoByUserContactLinkConnReq db cxt user@User {userId} (cReqSchema1, cReqSchema2) = do -- fmap join is to support group_id = NULL if non-group contact request is sent to this function (e.g., if client data is appended). groupId_ <- fmap join . maybeFirstRow fromOnly $ @@ -2415,12 +2415,12 @@ getGroupInfoByUserContactLinkConnReq db vr user@User {userId} (cReqSchema1, cReq WHERE user_id = ? AND conn_req_contact IN (?,?) |] (userId, cReqSchema1, cReqSchema2) - maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getGroupInfo db vr user) groupId_ + maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getGroupInfo db cxt user) groupId_ -getGroupInfoViaUserShortLink :: DB.Connection -> VersionRangeChat -> User -> ShortLinkContact -> IO (Maybe (ConnReqContact, GroupInfo)) -getGroupInfoViaUserShortLink db vr user@User {userId} shortLink = fmap eitherToMaybe $ runExceptT $ do +getGroupInfoViaUserShortLink :: DB.Connection -> StoreCxt -> User -> ShortLinkContact -> IO (Maybe (ConnReqContact, GroupInfo)) +getGroupInfoViaUserShortLink db cxt user@User {userId} shortLink = fmap eitherToMaybe $ runExceptT $ do (cReq, groupId) <- ExceptT getConnReqGroup - (cReq,) <$> getGroupInfo db vr user groupId + (cReq,) <$> getGroupInfo db cxt user groupId where getConnReqGroup = firstRow' toConnReqGroupId (SEInternalError "group link not found") $ @@ -2437,14 +2437,14 @@ getGroupInfoViaUserShortLink db vr user@User {userId} shortLink = fmap eitherToM (cReq, Just groupId) -> Right (cReq, groupId) _ -> Left $ SEInternalError "no conn req or group ID" -getGroupViaShortLinkToConnect :: DB.Connection -> VersionRangeChat -> User -> ShortLinkContact -> ExceptT StoreError IO (Maybe (ConnReqContact, GroupInfo)) -getGroupViaShortLinkToConnect db vr user@User {userId} shortLink = +getGroupViaShortLinkToConnect :: DB.Connection -> StoreCxt -> User -> ShortLinkContact -> ExceptT StoreError IO (Maybe (ConnReqContact, GroupInfo)) +getGroupViaShortLinkToConnect db cxt user@User {userId} shortLink = liftIO (maybeFirstRow id $ DB.query db "SELECT group_id, conn_full_link_to_connect FROM groups WHERE user_id = ? AND conn_short_link_to_connect = ?" (userId, shortLink)) >>= \case - Just (gId :: Int64, Just cReq) -> Just . (cReq,) <$> getGroupInfo db vr user gId + Just (gId :: Int64, Just cReq) -> Just . (cReq,) <$> getGroupInfo db cxt user gId _ -> pure Nothing -getGroupInfoByGroupLinkHash :: DB.Connection -> VersionRangeChat -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe GroupInfo) -getGroupInfoByGroupLinkHash db vr user@User {userId, userContactId} (groupLinkHash1, groupLinkHash2) = do +getGroupInfoByGroupLinkHash :: DB.Connection -> StoreCxt -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe GroupInfo) +getGroupInfoByGroupLinkHash db cxt user@User {userId, userContactId} (groupLinkHash1, groupLinkHash2) = do groupId_ <- maybeFirstRow fromOnly $ DB.query @@ -2458,7 +2458,7 @@ getGroupInfoByGroupLinkHash db vr user@User {userId, userContactId} (groupLinkHa LIMIT 1 |] (userId, groupLinkHash1, groupLinkHash2, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted, GSMemUnknown) - maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getGroupInfo db vr user) groupId_ + maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getGroupInfo db cxt user) groupId_ getGroupIdByName :: DB.Connection -> User -> GroupName -> ExceptT StoreError IO GroupId getGroupIdByName db User {userId} gName = @@ -2470,8 +2470,8 @@ getGroupMemberIdByName db User {userId} groupId groupMemberName = ExceptT . firstRow fromOnly (SEGroupMemberNameNotFound groupId groupMemberName) $ DB.query db "SELECT group_member_id FROM group_members WHERE user_id = ? AND group_id = ? AND local_display_name = ?" (userId, groupId, groupMemberName) -getActiveMembersByName :: DB.Connection -> VersionRangeChat -> User -> ContactName -> ExceptT StoreError IO [(GroupInfo, GroupMember)] -getActiveMembersByName db vr user@User {userId} groupMemberName = do +getActiveMembersByName :: DB.Connection -> StoreCxt -> User -> ContactName -> ExceptT StoreError IO [(GroupInfo, GroupMember)] +getActiveMembersByName db cxt user@User {userId} groupMemberName = do groupMemberIds :: [(GroupId, GroupMemberId)] <- liftIO $ DB.query @@ -2484,17 +2484,17 @@ getActiveMembersByName db vr user@User {userId} groupMemberName = do |] (userId, groupMemberName, GSMemConnected, GSMemComplete, GCUserMember) possibleMembers <- forM groupMemberIds $ \(groupId, groupMemberId) -> do - groupInfo <- getGroupInfo db vr user groupId - groupMember <- getGroupMember db vr user groupId groupMemberId + groupInfo <- getGroupInfo db cxt user groupId + groupMember <- getGroupMember db cxt user groupId groupMemberId pure (groupInfo, groupMember) pure $ sortOn (Down . ts . fst) possibleMembers where ts GroupInfo {chatTs, updatedAt} = fromMaybe updatedAt chatTs -getMatchingContacts :: DB.Connection -> VersionRangeChat -> User -> Contact -> IO [Contact] -getMatchingContacts db vr user@User {userId} Contact {contactId, profile = LocalProfile {displayName, fullName, shortDescr, image}} = do +getMatchingContacts :: DB.Connection -> StoreCxt -> User -> Contact -> IO [Contact] +getMatchingContacts db cxt user@User {userId} Contact {contactId, profile = LocalProfile {displayName, fullName, shortDescr, image}} = do contactIds <- map fromOnly <$> DB.query db q (userId, contactId, CSActive, displayName, fullName, shortDescr, image) - rights <$> mapM (runExceptT . getContact db vr user) contactIds + rights <$> mapM (runExceptT . getContact db cxt user) contactIds where -- this query is different from one in getMatchingMemberContacts -- it checks that it's not the same contact @@ -2509,10 +2509,10 @@ getMatchingContacts db vr user@User {userId} Contact {contactId, profile = Local AND p.short_descr IS NOT DISTINCT FROM ? AND p.image IS NOT DISTINCT FROM ? |] -getMatchingMembers :: DB.Connection -> VersionRangeChat -> User -> Contact -> IO [GroupMember] -getMatchingMembers db vr user@User {userId} Contact {profile = LocalProfile {displayName, fullName, shortDescr, image}} = do +getMatchingMembers :: DB.Connection -> StoreCxt -> User -> Contact -> IO [GroupMember] +getMatchingMembers db cxt user@User {userId} Contact {profile = LocalProfile {displayName, fullName, shortDescr, image}} = do memberIds <- map fromOnly <$> DB.query db q (userId, GCUserMember, displayName, fullName, shortDescr, image) - filter memberCurrent . rights <$> mapM (runExceptT . getGroupMemberById db vr user) memberIds + filter memberCurrent . rights <$> mapM (runExceptT . getGroupMemberById db cxt user) memberIds where -- only match with members without associated contact q = @@ -2526,11 +2526,11 @@ getMatchingMembers db vr user@User {userId} Contact {profile = LocalProfile {dis AND p.short_descr IS NOT DISTINCT FROM ? AND p.image IS NOT DISTINCT FROM ? |] -getMatchingMemberContacts :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> IO [Contact] +getMatchingMemberContacts :: DB.Connection -> StoreCxt -> User -> GroupMember -> IO [Contact] getMatchingMemberContacts _ _ _ GroupMember {memberContactId = Just _} = pure [] -getMatchingMemberContacts db vr user@User {userId} GroupMember {memberProfile = LocalProfile {displayName, fullName, shortDescr, image}} = do +getMatchingMemberContacts db cxt user@User {userId} GroupMember {memberProfile = LocalProfile {displayName, fullName, shortDescr, image}} = do contactIds <- map fromOnly <$> DB.query db q (userId, CSActive, displayName, fullName, shortDescr, image) - rights <$> mapM (runExceptT . getContact db vr user) contactIds + rights <$> mapM (runExceptT . getContact db cxt user) contactIds where q = [sql| @@ -2563,8 +2563,8 @@ createSentProbeHash db userId probeId to = do "INSERT INTO sent_probe_hashes (sent_probe_id, contact_id, group_member_id, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" (probeId, ctId, gmId, userId, currentTs, currentTs) -matchReceivedProbe :: DB.Connection -> VersionRangeChat -> User -> ContactOrMember -> Probe -> IO [ContactOrMember] -matchReceivedProbe db vr user@User {userId} from (Probe probe) = do +matchReceivedProbe :: DB.Connection -> StoreCxt -> User -> ContactOrMember -> Probe -> IO [ContactOrMember] +matchReceivedProbe db cxt user@User {userId} from (Probe probe) = do let probeHash = C.sha256Hash probe cgmIds <- DB.query @@ -2585,7 +2585,7 @@ matchReceivedProbe db vr user@User {userId} from (Probe probe) = do "INSERT INTO received_probes (contact_id, group_member_id, probe, probe_hash, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?)" (ctId, gmId, Binary probe, Binary probeHash, userId, currentTs, currentTs) let cgmIds' = filterFirstContactId cgmIds - catMaybes <$> mapM (getContactOrMember_ db vr user) cgmIds' + catMaybes <$> mapM (getContactOrMember_ db cxt user) cgmIds' where filterFirstContactId :: [(Maybe ContactId, Maybe GroupId, Maybe GroupMemberId)] -> [(Maybe ContactId, Maybe GroupId, Maybe GroupMemberId)] filterFirstContactId cgmIds = do @@ -2595,8 +2595,8 @@ matchReceivedProbe db vr user@User {userId} from (Probe probe) = do (x : _) -> [x] ctIds' <> memIds -matchReceivedProbeHash :: DB.Connection -> VersionRangeChat -> User -> ContactOrMember -> ProbeHash -> IO (Maybe (ContactOrMember, Probe)) -matchReceivedProbeHash db vr user@User {userId} from (ProbeHash probeHash) = do +matchReceivedProbeHash :: DB.Connection -> StoreCxt -> User -> ContactOrMember -> ProbeHash -> IO (Maybe (ContactOrMember, Probe)) +matchReceivedProbeHash db cxt user@User {userId} from (ProbeHash probeHash) = do probeIds <- maybeFirstRow id $ DB.query @@ -2616,11 +2616,11 @@ matchReceivedProbeHash db vr user@User {userId} from (ProbeHash probeHash) = do db "INSERT INTO received_probes (contact_id, group_member_id, probe_hash, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" (ctId, gmId, Binary probeHash, userId, currentTs, currentTs) - pure probeIds $>>= \(Only probe :. cgmIds) -> (,Probe probe) <$$> getContactOrMember_ db vr user cgmIds + pure probeIds $>>= \(Only probe :. cgmIds) -> (,Probe probe) <$$> getContactOrMember_ db cxt user cgmIds -matchSentProbe :: DB.Connection -> VersionRangeChat -> User -> ContactOrMember -> Probe -> IO (Maybe ContactOrMember) -matchSentProbe db vr user@User {userId} _from (Probe probe) = do - cgmIds $>>= getContactOrMember_ db vr user +matchSentProbe :: DB.Connection -> StoreCxt -> User -> ContactOrMember -> Probe -> IO (Maybe ContactOrMember) +matchSentProbe db cxt user@User {userId} _from (Probe probe) = do + cgmIds $>>= getContactOrMember_ db cxt user where (ctId, gmId) = contactOrMemberIds _from cgmIds = @@ -2639,11 +2639,11 @@ matchSentProbe db vr user@User {userId} _from (Probe probe) = do |] (userId, Binary probe, ctId, gmId) -getContactOrMember_ :: DB.Connection -> VersionRangeChat -> User -> (Maybe ContactId, Maybe GroupId, Maybe GroupMemberId) -> IO (Maybe ContactOrMember) -getContactOrMember_ db vr user ids = +getContactOrMember_ :: DB.Connection -> StoreCxt -> User -> (Maybe ContactId, Maybe GroupId, Maybe GroupMemberId) -> IO (Maybe ContactOrMember) +getContactOrMember_ db cxt user ids = fmap eitherToMaybe . runExceptT $ case ids of - (Just ctId, _, _) -> COMContact <$> getContact db vr user ctId - (_, Just gId, Just gmId) -> COMGroupMember <$> getGroupMember db vr user gId gmId + (Just ctId, _, _) -> COMContact <$> getContact db cxt user ctId + (_, Just gId, Just gmId) -> COMGroupMember <$> getGroupMember db cxt user gId gmId _ -> throwError $ SEInternalError "" associateMemberWithContactRecord :: DB.Connection -> User -> Contact -> GroupMember -> IO () @@ -2664,10 +2664,10 @@ associateMemberWithContactRecord when (memProfileId /= profileId) $ deleteUnusedProfile_ db userId memProfileId when (memLDN /= localDisplayName) $ deleteUnusedDisplayName_ db userId memLDN -associateContactWithMemberRecord :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> Contact -> ExceptT StoreError IO Contact +associateContactWithMemberRecord :: DB.Connection -> StoreCxt -> User -> GroupMember -> Contact -> ExceptT StoreError IO Contact associateContactWithMemberRecord db - vr + cxt user@User {userId} GroupMember {groupId, groupMemberId, localDisplayName = memLDN, memberProfile = LocalProfile {profileId = memProfileId}} Contact {contactId, localDisplayName, profile = LocalProfile {profileId}} = do @@ -2691,7 +2691,7 @@ associateContactWithMemberRecord (memLDN, memProfileId, currentTs, userId, contactId) when (profileId /= memProfileId) $ deleteUnusedProfile_ db userId profileId when (localDisplayName /= memLDN) $ deleteUnusedDisplayName_ db userId localDisplayName - getContact db vr user contactId + getContact db cxt user contactId deleteUnusedDisplayName_ :: DB.Connection -> UserId -> ContactName -> IO () deleteUnusedDisplayName_ db userId localDisplayName = @@ -2847,15 +2847,15 @@ createMemberContact mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = Just ctConn, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, preparedContact = Nothing, contactRequestId = Nothing, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False, groupDirectInv = Nothing, chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, chatDeleted = False, customData = Nothing} -getMemberContact :: DB.Connection -> VersionRangeChat -> User -> ContactId -> ExceptT StoreError IO (GroupInfo, GroupMember, Contact, ConnReqInvitation) -getMemberContact db vr user contactId = do - ct <- getContact db vr user contactId +getMemberContact :: DB.Connection -> StoreCxt -> User -> ContactId -> ExceptT StoreError IO (GroupInfo, GroupMember, Contact, ConnReqInvitation) +getMemberContact db cxt user contactId = do + ct <- getContact db cxt user contactId let Contact {contactGroupMemberId, activeConn} = ct case (activeConn, contactGroupMemberId) of (Just Connection {connId}, Just groupMemberId) -> do cReq <- getConnReqInv db connId - m@GroupMember {groupId} <- getGroupMemberById db vr user groupMemberId - g <- getGroupInfo db vr user groupId + m@GroupMember {groupId} <- getGroupMemberById db cxt user groupMemberId + g <- getGroupInfo db cxt user groupId pure (g, m, ct, cReq) _ -> throwError $ SEMemberContactGroupMemberNotFound contactId @@ -2964,13 +2964,13 @@ createMemberContactConn forM_ cmdId_ $ \cmdId -> setCommandConnId db user cmdId connId pure connId -getMemberContactInvited :: DB.Connection -> VersionRangeChat -> User -> ContactId -> ExceptT StoreError IO (GroupInfo, Connection, Contact, GroupDirectInvitation) -getMemberContactInvited db vr user contactId = do - ct@Contact {groupDirectInv = groupDirectInv_} <- getContact db vr user contactId +getMemberContactInvited :: DB.Connection -> StoreCxt -> User -> ContactId -> ExceptT StoreError IO (GroupInfo, Connection, Contact, GroupDirectInvitation) +getMemberContactInvited db cxt user contactId = do + ct@Contact {groupDirectInv = groupDirectInv_} <- getContact db cxt user contactId case groupDirectInv_ of Just groupDirectInv@GroupDirectInvitation {fromGroupId_ = Just groupId, fromGroupMemberId_ = Just _gmId, fromGroupMemberConnId_ = Just mConnId} -> do - g <- getGroupInfo db vr user groupId - mConn <- getConnectionById db vr user mConnId + g <- getGroupInfo db cxt user groupId + mConn <- getConnectionById db cxt user mConnId pure (g, mConn, ct, groupDirectInv) _ -> throwError $ SEMemberContactGroupMemberNotFound contactId @@ -3032,8 +3032,8 @@ setXGrpLinkMemReceived db mId xGrpLinkMemReceived = do "UPDATE group_members SET xgrplinkmem_received = ?, updated_at = ? WHERE group_member_id = ?" (BI xGrpLinkMemReceived, currentTs, mId) -createNewUnknownGroupMember :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> MemberId -> Text -> GroupMemberRole -> ExceptT StoreError IO GroupMember -createNewUnknownGroupMember db vr user@User {userId, userContactId} GroupInfo {groupId} memberId memberName unknownMemberRole = do +createNewUnknownGroupMember :: DB.Connection -> StoreCxt -> User -> GroupInfo -> MemberId -> Text -> GroupMemberRole -> ExceptT StoreError IO GroupMember +createNewUnknownGroupMember db cxt user@User {userId, userContactId} GroupInfo {groupId} memberId memberName unknownMemberRole = do currentTs <- liftIO getCurrentTime let memberProfile = profileFromName memberName (localDisplayName, profileId) <- createNewMemberProfile_ db user memberProfile currentTs @@ -3053,12 +3053,12 @@ createNewUnknownGroupMember db vr user@User {userId, userContactId} GroupInfo {g :. (minV, maxV) ) groupMemberId <- liftIO $ insertedRowId db - getGroupMemberById db vr user groupMemberId + getGroupMemberById db cxt user groupMemberId where - VersionRange minV maxV = vr + VersionRange minV maxV = vr cxt -createLinkOwnerMember :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> Maybe ContactId -> MemberId -> C.PublicKeyEd25519 -> ExceptT StoreError IO GroupMember -createLinkOwnerMember db vr user@User {userId, userContactId} GroupInfo {groupId} contactId_ memberId ownerKey = do +createLinkOwnerMember :: DB.Connection -> StoreCxt -> User -> GroupInfo -> Maybe ContactId -> MemberId -> C.PublicKeyEd25519 -> ExceptT StoreError IO GroupMember +createLinkOwnerMember db cxt user@User {userId, userContactId} GroupInfo {groupId} contactId_ memberId ownerKey = do currentTs <- liftIO getCurrentTime let memberProfile = profileFromName $ nameFromMemberId memberId (localDisplayName, profileId) <- createNewMemberProfile_ db user memberProfile currentTs @@ -3078,15 +3078,15 @@ createLinkOwnerMember db vr user@User {userId, userContactId} GroupInfo {groupId :. (minV, maxV) ) groupMemberId <- liftIO $ insertedRowId db - getGroupMemberById db vr user groupMemberId + getGroupMemberById db cxt user groupMemberId where - VersionRange minV maxV = vr + VersionRange minV maxV = vr cxt -- member_pub_key is not updated here — introduced members are owners -- whose keys are loaded from link data (trusted out-of-band). -- Updating from an in-band message would allow a compromised relay to substitute keys. -updatePreparedChannelMember :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> MemberInfo -> ExceptT StoreError IO GroupMember -updatePreparedChannelMember db vr user@User {userId} member@GroupMember {groupMemberId, memberChatVRange} MemberInfo {memberRole, v, profile} = do +updatePreparedChannelMember :: DB.Connection -> StoreCxt -> User -> GroupMember -> MemberInfo -> ExceptT StoreError IO GroupMember +updatePreparedChannelMember db cxt user@User {userId} member@GroupMember {groupMemberId, memberChatVRange} MemberInfo {memberRole, v, profile} = do _ <- updateMemberProfile db user member profile currentTs <- liftIO getCurrentTime liftIO $ @@ -3102,12 +3102,12 @@ updatePreparedChannelMember db vr user@User {userId} member@GroupMember {groupMe WHERE user_id = ? AND group_member_id = ? |] (memberRole, GSMemIntroduced, minV, maxV, currentTs, userId, groupMemberId) - getGroupMemberById db vr user groupMemberId + getGroupMemberById db cxt user groupMemberId where VersionRange minV maxV = maybe memberChatVRange fromChatVRange v -updateUnknownMemberAnnounced :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> GroupMember -> MemberInfo -> GroupMemberStatus -> ExceptT StoreError IO GroupMember -updateUnknownMemberAnnounced db vr user@User {userId} invitingMember unknownMember@GroupMember {groupMemberId, memberChatVRange} MemberInfo {memberRole, v, profile, memberKey} status = do +updateUnknownMemberAnnounced :: DB.Connection -> StoreCxt -> User -> GroupMember -> GroupMember -> MemberInfo -> GroupMemberStatus -> ExceptT StoreError IO GroupMember +updateUnknownMemberAnnounced db cxt user@User {userId} invitingMember unknownMember@GroupMember {groupMemberId, memberChatVRange} MemberInfo {memberRole, v, profile, memberKey} status = do _ <- updateMemberProfile db user unknownMember profile currentTs <- liftIO getCurrentTime liftIO $ @@ -3128,7 +3128,7 @@ updateUnknownMemberAnnounced db vr user@User {userId} invitingMember unknownMemb ( (memberRole, GCPostMember, status, groupMemberId' invitingMember) :. (minV, maxV, memberPubKey_, currentTs, userId, groupMemberId) ) - getGroupMemberById db vr user groupMemberId + getGroupMemberById db cxt user groupMemberId where VersionRange minV maxV = maybe memberChatVRange fromChatVRange v memberPubKey_ = (\(MemberKey k) -> k) <$> memberKey diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 5d433088a4..76e0a0fd97 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -396,8 +396,8 @@ data MemberAttention | MAReset deriving (Show) -updateChatTsStats :: DB.Connection -> VersionRangeChat -> User -> ChatDirection c d -> UTCTime -> Maybe (Int, MemberAttention, Int) -> IO (ChatInfo c) -updateChatTsStats db vr user@User {userId} chatDirection chatTs chatStats_ = case toChatInfo chatDirection of +updateChatTsStats :: DB.Connection -> StoreCxt -> User -> ChatDirection c d -> UTCTime -> Maybe (Int, MemberAttention, Int) -> IO (ChatInfo c) +updateChatTsStats db cxt user@User {userId} chatDirection chatTs chatStats_ = case toChatInfo chatDirection of DirectChat ct@Contact {contactId} -> do DB.execute db @@ -506,7 +506,7 @@ updateChatTsStats db vr user@User {userId} chatDirection chatTs chatStats_ = cas WHERE group_member_id = ? |] (chatTs, unread, mentions, groupMemberId) - m_ <- runExceptT $ getGroupMemberById db vr user groupMemberId + m_ <- runExceptT $ getGroupMemberById db cxt user groupMemberId pure $ either (const m) id m_ -- Left shouldn't happen, but types require it LocalChat nf@NoteFolder {noteFolderId} -> do DB.execute @@ -520,8 +520,8 @@ setSupportChatTs :: DB.Connection -> GroupMemberId -> UTCTime -> IO () setSupportChatTs db groupMemberId chatTs = DB.execute db "UPDATE group_members SET support_chat_ts = ? WHERE group_member_id = ?" (chatTs, groupMemberId) -setSupportChatMemberAttention :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMember -> Int64 -> IO (GroupInfo, GroupMember) -setSupportChatMemberAttention db vr user g m memberAttention = do +setSupportChatMemberAttention :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupMember -> Int64 -> IO (GroupInfo, GroupMember) +setSupportChatMemberAttention db cxt user g m memberAttention = do m' <- updateGMAttention g' <- updateGroupMembersRequireAttention db user g m m' pure (g', m') @@ -532,7 +532,7 @@ setSupportChatMemberAttention db vr user g m memberAttention = do db "UPDATE group_members SET support_chat_items_member_attention = ?, updated_at = ? WHERE group_member_id = ?" (memberAttention, currentTs, groupMemberId' m) - m_ <- runExceptT $ getGroupMemberById db vr user (groupMemberId' m) + m_ <- runExceptT $ getGroupMemberById db cxt user (groupMemberId' m) pure $ either (const m) id m_ -- Left shouldn't happen, but types require it createNewSndChatItem :: DB.Connection -> User -> ChatDirection c 'MDSnd -> ShowGroupAsSender -> SndMessage -> CIContent 'MDSnd -> Maybe (CIQuote c) -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> UTCTime -> IO ChatItemId @@ -723,8 +723,8 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe ciQuoteGroup [] = ciQuote Nothing $ CIQGroupRcv Nothing ciQuoteGroup ((Only itemId :. memberRow) : _) = ciQuote itemId . CIQGroupRcv . Just $ toGroupMember userContactId memberRow -getChatPreviews :: DB.Connection -> VersionRangeChat -> User -> Bool -> PaginationByTime -> ChatListQuery -> IO [Either StoreError AChat] -getChatPreviews db vr user withPCC pagination query = do +getChatPreviews :: DB.Connection -> StoreCxt -> User -> Bool -> PaginationByTime -> ChatListQuery -> IO [Either StoreError AChat] +getChatPreviews db cxt user withPCC pagination query = do directChats <- findDirectChatPreviews_ db user pagination query groupChats <- findGroupChatPreviews_ db user pagination query localChats <- findLocalChatPreviews_ db user pagination query @@ -746,8 +746,8 @@ getChatPreviews db vr user withPCC pagination query = do PTBefore _ count -> take count . sortBy (comparing $ Down . ts) getChatPreview :: AChatPreviewData -> ExceptT StoreError IO AChat getChatPreview (ACPD cType cpd) = case cType of - SCTDirect -> getDirectChatPreview_ db vr user cpd - SCTGroup -> getGroupChatPreview_ db vr user cpd + SCTDirect -> getDirectChatPreview_ db cxt user cpd + SCTGroup -> getGroupChatPreview_ db cxt user cpd SCTLocal -> getLocalChatPreview_ db user cpd SCTContactRequest -> let (ContactRequestPD _ chat) = cpd in pure chat SCTContactConnection -> let (ContactConnectionPD _ chat) = cpd in pure chat @@ -864,9 +864,9 @@ findDirectChatPreviews_ db User {userId} pagination clq = PTAfter ts count -> DB.query db (query <> " AND ct.chat_ts > ? ORDER BY ct.chat_ts ASC LIMIT ?") (params :. (ts, count)) PTBefore ts count -> DB.query db (query <> " AND ct.chat_ts < ? ORDER BY ct.chat_ts DESC LIMIT ?") (params :. (ts, count)) -getDirectChatPreview_ :: DB.Connection -> VersionRangeChat -> User -> ChatPreviewData 'CTDirect -> ExceptT StoreError IO AChat -getDirectChatPreview_ db vr user (DirectChatPD _ contactId lastItemId_ stats) = do - contact <- getContact db vr user contactId +getDirectChatPreview_ :: DB.Connection -> StoreCxt -> User -> ChatPreviewData 'CTDirect -> ExceptT StoreError IO AChat +getDirectChatPreview_ db cxt user (DirectChatPD _ contactId lastItemId_ stats) = do + contact <- getContact db cxt user contactId ts <- liftIO getCurrentTime lastItem <- case lastItemId_ of Just lastItemId -> do @@ -975,9 +975,9 @@ findGroupChatPreviews_ db User {userId} pagination clq = PTAfter ts count -> DB.query db (query <> " AND g.chat_ts > ? ORDER BY g.chat_ts ASC LIMIT ?") (params :. (ts, count)) PTBefore ts count -> DB.query db (query <> " AND g.chat_ts < ? ORDER BY g.chat_ts DESC LIMIT ?") (params :. (ts, count)) -getGroupChatPreview_ :: DB.Connection -> VersionRangeChat -> User -> ChatPreviewData 'CTGroup -> ExceptT StoreError IO AChat -getGroupChatPreview_ db vr user (GroupChatPD _ groupId lastItemId_ stats) = do - groupInfo <- getGroupInfo db vr user groupId +getGroupChatPreview_ :: DB.Connection -> StoreCxt -> User -> ChatPreviewData 'CTGroup -> ExceptT StoreError IO AChat +getGroupChatPreview_ db cxt user (GroupChatPD _ groupId lastItemId_ stats) = do + groupInfo <- getGroupInfo db cxt user groupId ts <- liftIO getCurrentTime lastItem <- case lastItemId_ of Just lastItemId -> do @@ -1213,10 +1213,10 @@ getChatContentTypes db User {userId} (ChatRef cType chatId chatScope_) = case cT ("SELECT DISTINCT msg_content_tag FROM chat_items WHERE user_id = ? AND " <> cond <> " AND msg_content_tag IS NOT NULL ORDER BY msg_content_tag") ((userId, chatId) :. params) -getDirectChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> Maybe MsgContentTag -> ChatPagination -> Maybe Text -> ExceptT StoreError IO (Chat 'CTDirect, Maybe NavigationInfo) -getDirectChat db vr user contactId contentFilter pagination search_ = do +getDirectChat :: DB.Connection -> StoreCxt -> User -> Int64 -> Maybe MsgContentTag -> ChatPagination -> Maybe Text -> ExceptT StoreError IO (Chat 'CTDirect, Maybe NavigationInfo) +getDirectChat db cxt user contactId contentFilter pagination search_ = do let search = fromMaybe "" search_ - ct <- getContact db vr user contactId + ct <- getContact db cxt user contactId case pagination of CPLast count -> (,Nothing) <$> getDirectChatLast_ db user ct contentFilter count search CPAfter afterId count -> (,Nothing) <$> getDirectChatAfter_ db user ct contentFilter afterId count search @@ -1433,11 +1433,11 @@ getContactNavInfo_ db User {userId} Contact {contactId} afterCI = do :. (userId, contactId, ciCreatedAt afterCI, cChatItemId afterCI) ) -getGroupChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> Maybe GroupChatScope -> Maybe MsgContentTag -> ChatPagination -> Maybe Text -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) -getGroupChat db vr user groupId scope_ contentFilter pagination search_ = do +getGroupChat :: DB.Connection -> StoreCxt -> User -> Int64 -> Maybe GroupChatScope -> Maybe MsgContentTag -> ChatPagination -> Maybe Text -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) +getGroupChat db cxt user groupId scope_ contentFilter pagination search_ = do let search = fromMaybe "" search_ - g <- getGroupInfo db vr user groupId - scopeInfo <- mapM (getCreateGroupChatScopeInfo db vr user g) scope_ + g <- getGroupInfo db cxt user groupId + scopeInfo <- mapM (getCreateGroupChatScopeInfo db cxt user g) scope_ case pagination of CPLast count -> (,Nothing) <$> getGroupChatLast_ db user g scopeInfo contentFilter count search emptyChatStats CPAfter afterId count -> (,Nothing) <$> getGroupChatAfter_ db user g scopeInfo contentFilter afterId count search @@ -1447,31 +1447,31 @@ getGroupChat db vr user groupId scope_ contentFilter pagination search_ = do unless (T.null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search" getGroupChatInitial_ db user g scopeInfo contentFilter count -getCreateGroupChatScopeInfo :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupChatScope -> ExceptT StoreError IO GroupChatScopeInfo -getCreateGroupChatScopeInfo db vr user GroupInfo {membership} = \case +getCreateGroupChatScopeInfo :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupChatScope -> ExceptT StoreError IO GroupChatScopeInfo +getCreateGroupChatScopeInfo db cxt user GroupInfo {membership} = \case GCSMemberSupport Nothing -> do when (isNothing $ supportChat membership) $ do ts <- liftIO getCurrentTime liftIO $ setSupportChatTs db (groupMemberId' membership) ts pure $ GCSIMemberSupport {groupMember_ = Nothing} GCSMemberSupport (Just gmId) -> do - m <- getGroupMemberById db vr user gmId + m <- getGroupMemberById db cxt user gmId when (isNothing $ supportChat m) $ do ts <- liftIO getCurrentTime liftIO $ setSupportChatTs db gmId ts pure GCSIMemberSupport {groupMember_ = Just m} -getGroupChatScopeInfoForItem :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> ChatItemId -> ExceptT StoreError IO (Maybe GroupChatScopeInfo) -getGroupChatScopeInfoForItem db vr user g itemId = - getGroupChatScopeForItem_ db itemId >>= mapM (getGroupChatScopeInfo db vr user g) +getGroupChatScopeInfoForItem :: DB.Connection -> StoreCxt -> User -> GroupInfo -> ChatItemId -> ExceptT StoreError IO (Maybe GroupChatScopeInfo) +getGroupChatScopeInfoForItem db cxt user g itemId = + getGroupChatScopeForItem_ db itemId >>= mapM (getGroupChatScopeInfo db cxt user g) -getGroupChatScopeInfo :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupChatScope -> ExceptT StoreError IO GroupChatScopeInfo -getGroupChatScopeInfo db vr user GroupInfo {membership} = \case +getGroupChatScopeInfo :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupChatScope -> ExceptT StoreError IO GroupChatScopeInfo +getGroupChatScopeInfo db cxt user GroupInfo {membership} = \case GCSMemberSupport Nothing -> case supportChat membership of Nothing -> throwError $ SEInternalError "no moderators support chat" Just _supportChat -> pure $ GCSIMemberSupport {groupMember_ = Nothing} GCSMemberSupport (Just gmId) -> do - m <- getGroupMemberById db vr user gmId + m <- getGroupMemberById db cxt user gmId case supportChat m of Nothing -> throwError $ SEInternalError "no support chat" Just _supportChat -> pure GCSIMemberSupport {groupMember_ = Just m} @@ -2077,8 +2077,8 @@ updateGroupChatItemsRead db User {userId} GroupInfo {groupId} = do |] (CISRcvRead, currentTs, userId, groupId, CISRcvNew) -updateSupportChatItemsRead :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupChatScopeInfo -> IO (GroupInfo, GroupMember) -updateSupportChatItemsRead db vr user@User {userId} g@GroupInfo {groupId, membership} scopeInfo = do +updateSupportChatItemsRead :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupChatScopeInfo -> IO (GroupInfo, GroupMember) +updateSupportChatItemsRead db cxt user@User {userId} g@GroupInfo {groupId, membership} scopeInfo = do currentTs <- getCurrentTime case scopeInfo of GCSIMemberSupport {groupMember_} -> do @@ -2116,7 +2116,7 @@ updateSupportChatItemsRead db vr user@User {userId} g@GroupInfo {groupId, member WHERE group_member_id = ? |] (currentTs, groupMemberId) - m_ <- runExceptT $ getGroupMemberById db vr user groupMemberId + m_ <- runExceptT $ getGroupMemberById db cxt user groupMemberId pure $ either (const m) id m_ -- Left shouldn't happen, but types require it getGroupUnreadTimedItems :: DB.Connection -> User -> GroupId -> Maybe GroupChatScope -> IO [(ChatItemId, Int)] @@ -2144,8 +2144,8 @@ getGroupUnreadTimedItems db User {userId} groupId scope = |] (userId, groupId, GCSTMemberSupport_, groupMemberId_, CISRcvNew) -updateGroupChatItemsReadList :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> NonEmpty ChatItemId -> ExceptT StoreError IO ([(ChatItemId, Int)], GroupInfo) -updateGroupChatItemsReadList db vr user@User {userId} g@GroupInfo {groupId} scopeInfo_ itemIds = do +updateGroupChatItemsReadList :: DB.Connection -> StoreCxt -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> NonEmpty ChatItemId -> ExceptT StoreError IO ([(ChatItemId, Int)], GroupInfo) +updateGroupChatItemsReadList db cxt user@User {userId} g@GroupInfo {groupId} scopeInfo_ itemIds = do currentTs <- liftIO getCurrentTime -- Possible improvement is to differentiate retrieval queries for each scope, -- but we rely on UI to not pass item IDs from incorrect scope. @@ -2154,7 +2154,7 @@ updateGroupChatItemsReadList db vr user@User {userId} g@GroupInfo {groupId} scop Nothing -> pure g Just scopeInfo@GCSIMemberSupport {groupMember_} -> do let decStats = countReadItems groupMember_ readItemsData - liftIO $ updateGroupScopeUnreadStats db vr user g scopeInfo decStats + liftIO $ updateGroupScopeUnreadStats db cxt user g scopeInfo decStats pure (timedItems readItemsData, g') where getUpdateGroupItem :: UTCTime -> ChatItemId -> IO (Maybe (ChatItemId, Maybe Int, Maybe UTCTime, Maybe GroupMemberId, Maybe BoolInt)) @@ -2189,8 +2189,8 @@ updateGroupChatItemsReadList db vr user@User {userId} g@GroupInfo {groupId} scop addTimedItem acc (itemId, Just ttl, Nothing, _, _) = (itemId, ttl) : acc addTimedItem acc _ = acc -updateGroupScopeUnreadStats :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupChatScopeInfo -> (Int, Int, Int) -> IO GroupInfo -updateGroupScopeUnreadStats db vr user g@GroupInfo {membership} scopeInfo (unread, unanswered, mentions) = +updateGroupScopeUnreadStats :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupChatScopeInfo -> (Int, Int, Int) -> IO GroupInfo +updateGroupScopeUnreadStats db cxt user g@GroupInfo {membership} scopeInfo (unread, unanswered, mentions) = case scopeInfo of GCSIMemberSupport {groupMember_} -> case groupMember_ of Nothing -> do @@ -2228,7 +2228,7 @@ updateGroupScopeUnreadStats db vr user g@GroupInfo {membership} scopeInfo (unrea |] #endif (unread, unanswered, mentions, currentTs, groupMemberId) - m_ <- runExceptT $ getGroupMemberById db vr user groupMemberId + m_ <- runExceptT $ getGroupMemberById db cxt user groupMemberId pure $ either (const m) id m_ -- Left shouldn't happen, but types require it setGroupChatItemsDeleteAt :: DB.Connection -> User -> GroupId -> [(ChatItemId, Int)] -> UTCTime -> IO [(ChatItemId, UTCTime)] @@ -2403,8 +2403,8 @@ toGroupChatItem ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} -getAllChatItems :: DB.Connection -> VersionRangeChat -> User -> ChatPagination -> Maybe Text -> ExceptT StoreError IO [AChatItem] -getAllChatItems db vr user@User {userId} pagination search_ = do +getAllChatItems :: DB.Connection -> StoreCxt -> User -> ChatPagination -> Maybe Text -> ExceptT StoreError IO [AChatItem] +getAllChatItems db cxt user@User {userId} pagination search_ = do itemRefs <- rights . map toChatItemRef <$> case pagination of CPLast count -> liftIO $ getAllChatItemsLast_ count @@ -2416,12 +2416,12 @@ getAllChatItems db vr user@User {userId} pagination search_ = do liftIO getFirstUnreadItemId_ >>= \case Just itemId -> liftIO . getAllChatItemsAround_ itemId count . aChatItemTs =<< getAChatItem_ itemId Nothing -> liftIO $ getAllChatItemsLast_ count - mapM (uncurry (getAChatItem db vr user)) itemRefs + mapM (uncurry (getAChatItem db cxt user)) itemRefs where search = fromMaybe "" search_ getAChatItem_ itemId = do chatRef <- getChatRefViaItemId db user itemId - getAChatItem db vr user chatRef itemId + getAChatItem db cxt user chatRef itemId getAllChatItemsLast_ count = reverse <$> DB.query @@ -3208,8 +3208,8 @@ deleteLocalChatItem db User {userId} NoteFolder {noteFolderId} ci = do |] (userId, noteFolderId, itemId) -getChatItemByFileId :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ExceptT StoreError IO AChatItem -getChatItemByFileId db vr user@User {userId} fileId = do +getChatItemByFileId :: DB.Connection -> StoreCxt -> User -> Int64 -> ExceptT StoreError IO AChatItem +getChatItemByFileId db cxt user@User {userId} fileId = do (chatRef, itemId) <- ExceptT . firstRow' toChatItemRef (SEChatItemNotFoundByFileId fileId) $ DB.query @@ -3222,16 +3222,16 @@ getChatItemByFileId db vr user@User {userId} fileId = do LIMIT 1 |] (userId, fileId) - getAChatItem db vr user chatRef itemId + getAChatItem db cxt user chatRef itemId -lookupChatItemByFileId :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ExceptT StoreError IO (Maybe AChatItem) -lookupChatItemByFileId db vr user fileId = do - fmap Just (getChatItemByFileId db vr user fileId) `catchError` \case +lookupChatItemByFileId :: DB.Connection -> StoreCxt -> User -> Int64 -> ExceptT StoreError IO (Maybe AChatItem) +lookupChatItemByFileId db cxt user fileId = do + fmap Just (getChatItemByFileId db cxt user fileId) `catchError` \case SEChatItemNotFoundByFileId {} -> pure Nothing e -> throwError e -getChatItemByGroupId :: DB.Connection -> VersionRangeChat -> User -> GroupId -> ExceptT StoreError IO AChatItem -getChatItemByGroupId db vr user@User {userId} groupId = do +getChatItemByGroupId :: DB.Connection -> StoreCxt -> User -> GroupId -> ExceptT StoreError IO AChatItem +getChatItemByGroupId db cxt user@User {userId} groupId = do (chatRef, itemId) <- ExceptT . firstRow' toChatItemRef (SEChatItemNotFoundByGroupId groupId) $ DB.query @@ -3244,7 +3244,7 @@ getChatItemByGroupId db vr user@User {userId} groupId = do LIMIT 1 |] (userId, groupId) - getAChatItem db vr user chatRef itemId + getAChatItem db cxt user chatRef itemId getChatRefViaItemId :: DB.Connection -> User -> ChatItemId -> ExceptT StoreError IO ChatRef getChatRefViaItemId db User {userId} itemId = do @@ -3257,17 +3257,17 @@ getChatRefViaItemId db User {userId} itemId = do (Nothing, Just groupId) -> Right $ ChatRef CTGroup groupId Nothing (_, _) -> Left $ SEBadChatItem itemId Nothing -getAChatItem :: DB.Connection -> VersionRangeChat -> User -> ChatRef -> ChatItemId -> ExceptT StoreError IO AChatItem -getAChatItem db vr user (ChatRef cType chatId scope) itemId = do +getAChatItem :: DB.Connection -> StoreCxt -> User -> ChatRef -> ChatItemId -> ExceptT StoreError IO AChatItem +getAChatItem db cxt user (ChatRef cType chatId scope) itemId = do aci <- case cType of CTDirect -> do - ct <- getContact db vr user chatId + ct <- getContact db cxt user chatId (CChatItem msgDir ci) <- getDirectChatItem db user chatId itemId pure $ AChatItem SCTDirect msgDir (DirectChat ct) ci CTGroup -> do - gInfo <- getGroupInfo db vr user chatId + gInfo <- getGroupInfo db cxt user chatId (CChatItem msgDir ci) <- getGroupChatItem db user chatId itemId - scopeInfo <- mapM (getGroupChatScopeInfo db vr user gInfo) scope + scopeInfo <- mapM (getGroupChatScopeInfo db cxt user gInfo) scope pure $ AChatItem SCTGroup msgDir (GroupChat gInfo scopeInfo) ci CTLocal -> do nf <- getNoteFolder db user chatId @@ -3443,8 +3443,8 @@ setGroupReaction db GroupInfo {groupId} m itemMemberId itemSharedMId sent reacti |] (groupId, groupMemberId' m, itemSharedMId, itemMemberId, BI sent, reaction) -getReactionMembers :: DB.Connection -> VersionRangeChat -> User -> GroupId -> SharedMsgId -> MsgReaction -> IO [MemberReaction] -getReactionMembers db vr user groupId itemSharedMId reaction = do +getReactionMembers :: DB.Connection -> StoreCxt -> User -> GroupId -> SharedMsgId -> MsgReaction -> IO [MemberReaction] +getReactionMembers db cxt user groupId itemSharedMId reaction = do reactions <- DB.query db @@ -3458,7 +3458,7 @@ getReactionMembers db vr user groupId itemSharedMId reaction = do where toMemberReaction :: (GroupMemberId, UTCTime) -> ExceptT StoreError IO MemberReaction toMemberReaction (groupMemberId, reactionTs) = do - groupMember <- getGroupMemberById db vr user groupMemberId + groupMember <- getGroupMemberById db cxt user groupMemberId pure MemberReaction {groupMember, reactionTs} getTimedItems :: DB.Connection -> User -> UTCTime -> IO [((ChatRef, ChatItemId), UTCTime)] @@ -3556,9 +3556,9 @@ createCIModeration db GroupInfo {groupId} moderatorMember itemMemberId itemShare |] (groupId, groupMemberId' moderatorMember, itemMemberId, itemSharedMId, msgId, moderatedAtTs) -getCIModeration :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> MemberId -> Maybe SharedMsgId -> IO (Maybe CIModeration) +getCIModeration :: DB.Connection -> StoreCxt -> User -> GroupInfo -> MemberId -> Maybe SharedMsgId -> IO (Maybe CIModeration) getCIModeration _ _ _ _ _ Nothing = pure Nothing -getCIModeration db vr user GroupInfo {groupId} itemMemberId (Just sharedMsgId) = do +getCIModeration db cxt user GroupInfo {groupId} itemMemberId (Just sharedMsgId) = do r_ <- maybeFirstRow id $ DB.query @@ -3572,7 +3572,7 @@ getCIModeration db vr user GroupInfo {groupId} itemMemberId (Just sharedMsgId) = (groupId, itemMemberId, sharedMsgId) case r_ of Just (moderationId, moderatorId, createdByMsgId, moderatedAt) -> do - runExceptT (getGroupMember db vr user groupId moderatorId) >>= \case + runExceptT (getGroupMember db cxt user groupId moderatorId) >>= \case Right moderatorMember -> pure (Just CIModeration {moderationId, moderatorMember, createdByMsgId, moderatedAt}) _ -> pure Nothing _ -> pure Nothing diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index cff3e68234..d432067866 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -380,9 +380,9 @@ createUserContactLink db User {userId} agentConnId (CCLink cReq shortLink) subMo userContactLinkId <- insertedRowId db void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId ConnNew initialChatVersion chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode CR.PQSupportOff -getUserAddressConnection :: DB.Connection -> VersionRangeChat -> User -> ExceptT StoreError IO Connection -getUserAddressConnection db vr User {userId} = do - ExceptT . firstRow (toConnection vr) SEUserContactLinkNotFound $ +getUserAddressConnection :: DB.Connection -> StoreCxt -> User -> ExceptT StoreError IO Connection +getUserAddressConnection db cxt User {userId} = do + ExceptT . firstRow (toConnection cxt) SEUserContactLinkNotFound $ DB.query db [sql| @@ -525,8 +525,8 @@ setUserContactLinkShortLink db userContactLinkId shortLink = |] (shortLink, BI True, BI True, BI False, userContactLinkId) -getContactWithoutConnViaAddress :: DB.Connection -> VersionRangeChat -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe Contact) -getContactWithoutConnViaAddress db vr user@User {userId} (cReqSchema1, cReqSchema2) = do +getContactWithoutConnViaAddress :: DB.Connection -> StoreCxt -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe Contact) +getContactWithoutConnViaAddress db cxt user@User {userId} (cReqSchema1, cReqSchema2) = do ctId_ <- maybeFirstRow fromOnly $ DB.query @@ -539,10 +539,10 @@ getContactWithoutConnViaAddress db vr user@User {userId} (cReqSchema1, cReqSchem WHERE cp.user_id = ? AND cp.contact_link IN (?,?) AND c.connection_id IS NULL |] (userId, cReqSchema1, cReqSchema2) - maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getContact db vr user) ctId_ + maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getContact db cxt user) ctId_ -getContactWithoutConnViaShortAddress :: DB.Connection -> VersionRangeChat -> User -> ShortLinkContact -> IO (Maybe Contact) -getContactWithoutConnViaShortAddress db vr user@User {userId} shortLink = do +getContactWithoutConnViaShortAddress :: DB.Connection -> StoreCxt -> User -> ShortLinkContact -> IO (Maybe Contact) +getContactWithoutConnViaShortAddress db cxt user@User {userId} shortLink = do ctId_ <- maybeFirstRow fromOnly $ DB.query @@ -555,7 +555,7 @@ getContactWithoutConnViaShortAddress db vr user@User {userId} shortLink = do WHERE cp.user_id = ? AND cp.contact_link = ? AND c.connection_id IS NULL |] (userId, shortLink) - maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getContact db vr user) ctId_ + maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getContact db cxt user) ctId_ updateUserAddressSettings :: DB.Connection -> Int64 -> AddressSettings -> IO () updateUserAddressSettings db userContactLinkId AddressSettings {businessAddress, autoAccept, autoReply} = diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index bd51b10329..f7b525243c 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -228,12 +228,12 @@ type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, Maybe Int64, BoolInt, May type MaybeConnectionRow = (Maybe Int64, Maybe ConnId, Maybe Int, Maybe Int64, Maybe Int64, Maybe BoolInt, Maybe GroupLinkId, Maybe XContactId) :. (Maybe Int64, Maybe ConnStatus, Maybe ConnType, Maybe BoolInt, Maybe LocalAlias) :. EntityIdsRow :. (Maybe UTCTime, Maybe Text, Maybe UTCTime, Maybe PQSupport, Maybe PQEncryption, Maybe PQEncryption, Maybe PQEncryption, Maybe Int, Maybe Int, Maybe VersionChat, Maybe VersionChat, Maybe VersionChat) -toConnection :: VersionRangeChat -> ConnectionRow -> Connection -toConnection vr ((connId, acId, connLevel, viaContact, viaUserContactLink, BI viaGroupLink, groupLinkId, xContactId) :. (customUserProfileId, connStatus, connType, BI contactConnInitiated, localAlias) :. (contactId, groupMemberId, userContactLinkId) :. (createdAt, code_, verifiedAt_, pqSupport, pqEncryption, pqSndEnabled, pqRcvEnabled, authErrCounter, quotaErrCounter, chatV, minVer, maxVer)) = +toConnection :: StoreCxt -> ConnectionRow -> Connection +toConnection cxt ((connId, acId, connLevel, viaContact, viaUserContactLink, BI viaGroupLink, groupLinkId, xContactId) :. (customUserProfileId, connStatus, connType, BI contactConnInitiated, localAlias) :. (contactId, groupMemberId, userContactLinkId) :. (createdAt, code_, verifiedAt_, pqSupport, pqEncryption, pqSndEnabled, pqRcvEnabled, authErrCounter, quotaErrCounter, chatV, minVer, maxVer)) = Connection { connId, agentConnId = AgentConnId acId, - connChatVersion = fromMaybe (vr `peerConnChatVersion` peerChatVRange) chatV, + connChatVersion = fromMaybe (vr cxt `peerConnChatVersion` peerChatVRange) chatV, peerChatVRange = peerChatVRange, connLevel, viaContact, @@ -263,9 +263,9 @@ toConnection vr ((connId, acId, connLevel, viaContact, viaUserContactLink, BI vi entityId_ ConnMember = groupMemberId entityId_ ConnUserContact = userContactLinkId -toMaybeConnection :: VersionRangeChat -> MaybeConnectionRow -> Maybe Connection -toMaybeConnection vr ((Just connId, Just agentConnId, Just connLevel, viaContact, viaUserContactLink, Just viaGroupLink, groupLinkId, xContactId) :. (customUserProfileId, Just connStatus, Just connType, Just contactConnInitiated, Just localAlias) :. (contactId, groupMemberId, userContactLinkId) :. (Just createdAt, code_, verifiedAt_, Just pqSupport, Just pqEncryption, pqSndEnabled_, pqRcvEnabled_, Just authErrCounter, Just quotaErrCounter, connChatVersion, Just minVer, Just maxVer)) = - Just $ toConnection vr ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, xContactId) :. (customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, userContactLinkId) :. (createdAt, code_, verifiedAt_, pqSupport, pqEncryption, pqSndEnabled_, pqRcvEnabled_, authErrCounter, quotaErrCounter, connChatVersion, minVer, maxVer)) +toMaybeConnection :: StoreCxt -> MaybeConnectionRow -> Maybe Connection +toMaybeConnection cxt ((Just connId, Just agentConnId, Just connLevel, viaContact, viaUserContactLink, Just viaGroupLink, groupLinkId, xContactId) :. (customUserProfileId, Just connStatus, Just connType, Just contactConnInitiated, Just localAlias) :. (contactId, groupMemberId, userContactLinkId) :. (Just createdAt, code_, verifiedAt_, Just pqSupport, Just pqEncryption, pqSndEnabled_, pqRcvEnabled_, Just authErrCounter, Just quotaErrCounter, connChatVersion, Just minVer, Just maxVer)) = + Just $ toConnection cxt ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, xContactId) :. (customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, userContactLinkId) :. (createdAt, code_, verifiedAt_, pqSupport, pqEncryption, pqSndEnabled_, pqRcvEnabled_, authErrCounter, quotaErrCounter, connChatVersion, minVer, maxVer)) toMaybeConnection _ _ = Nothing createConnection_ :: DB.Connection -> UserId -> ConnType -> Maybe Int64 -> ConnId -> ConnStatus -> VersionChat -> VersionRangeChat -> Maybe ContactId -> Maybe Int64 -> Maybe ProfileId -> Int -> UTCTime -> SubscriptionMode -> PQSupport -> IO Connection @@ -488,10 +488,10 @@ type ContactRow' = (ProfileId, ContactName, ContactName, Text, Maybe Text, Maybe type ContactRow = Only ContactId :. ContactRow' -toContact :: VersionRangeChat -> User -> [ChatTagId] -> ContactRow :. MaybeConnectionRow -> Contact -toContact vr user chatTags ((Only contactId :. (profileId, localDisplayName, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL)) :. connRow) = +toContact :: StoreCxt -> User -> [ChatTagId] -> ContactRow :. MaybeConnectionRow -> Contact +toContact cxt user chatTags ((Only contactId :. (profileId, localDisplayName, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL)) :. connRow) = let profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, preferences, localAlias} - activeConn = toMaybeConnection vr connRow + activeConn = toMaybeConnection cxt connRow chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} incognito = maybe False connIncognito activeConn mergedPreferences = contactUserPreferences user userPreferences preferences incognito @@ -673,9 +673,9 @@ type GroupMemberRow = (GroupMemberId, GroupId, Int64, MemberId, VersionChat, Ver type ProfileRow = (ProfileId, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, Maybe Preferences) -toGroupInfo :: VersionRangeChat -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo -toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image, groupType_, groupLink_, publicGroupId_) :. accessRow :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (BI useRelays, relayOwnStatus, uiThemes, currentMembers, publicMemberCount, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri) :. groupKeysRow :. userMemberRow) = - let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = vr} +toGroupInfo :: StoreCxt -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo +toGroupInfo cxt userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image, groupType_, groupLink_, publicGroupId_) :. accessRow :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (BI useRelays, relayOwnStatus, uiThemes, currentMembers, publicMemberCount, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri) :. groupKeysRow :. userMemberRow) = + let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = vr cxt} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} fullGroupPreferences = mergeGroupPreferences groupPreferences publicGroup = toPublicGroupProfile groupType_ groupLink_ publicGroupId_ (toPublicGroupAccess accessRow) @@ -756,9 +756,9 @@ groupMemberQuery = LEFT JOIN connections c ON c.group_member_id = m.group_member_id |] -toContactMember :: VersionRangeChat -> User -> (GroupMemberRow :. MaybeConnectionRow) -> GroupMember -toContactMember vr User {userContactId} (memberRow :. connRow) = - (toGroupMember userContactId memberRow) {activeConn = toMaybeConnection vr connRow} +toContactMember :: StoreCxt -> User -> (GroupMemberRow :. MaybeConnectionRow) -> GroupMember +toContactMember cxt User {userContactId} (memberRow :. connRow) = + (toGroupMember userContactId memberRow) {activeConn = toMaybeConnection cxt connRow} rowToLocalProfile :: ProfileRow -> LocalProfile rowToLocalProfile (profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, preferences) = @@ -875,10 +875,10 @@ addGroupChatTags db g@GroupInfo {groupId} = do chatTags <- getGroupChatTags db groupId pure (g :: GroupInfo) {chatTags} -getGroupInfo :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ExceptT StoreError IO GroupInfo -getGroupInfo db vr User {userId, userContactId} groupId = ExceptT $ do +getGroupInfo :: DB.Connection -> StoreCxt -> User -> Int64 -> ExceptT StoreError IO GroupInfo +getGroupInfo db cxt User {userId, userContactId} groupId = ExceptT $ do chatTags <- getGroupChatTags db groupId - firstRow (toGroupInfo vr userContactId chatTags) (SEGroupNotFound groupId) $ + firstRow (toGroupInfo cxt userContactId chatTags) (SEGroupNotFound groupId) $ DB.query db (groupInfoQuery <> " WHERE g.group_id = ? AND g.user_id = ? AND mu.contact_id = ?") diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 189f730b67..b4264d121d 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -2033,6 +2033,10 @@ type VersionChat = Version ChatVersion type VersionRangeChat = VersionRange ChatVersion +-- | Store-wide context passed to store functions in place of the bare `vr` +-- parameter. Built from config by mkStoreCxt; more fields are added here over time. +newtype StoreCxt = StoreCxt {vr :: VersionRangeChat} + pattern VersionChat :: Word16 -> VersionChat pattern VersionChat v = Version v diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 4b28229348..27c36568ec 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -23,7 +23,7 @@ import Data.List (isPrefixOf, isSuffixOf) import Data.Maybe (fromMaybe) import Data.String import qualified Data.Text as T -import Simplex.Chat.Controller (ChatConfig (..), ChatController (..)) +import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), mkStoreCxt) import Simplex.Chat.Markdown (viewName) import Simplex.Chat.Messages.CIContent (e2eInfoNoPQText, e2eInfoPQText) import Simplex.Chat.Protocol @@ -699,10 +699,10 @@ getCtConn cc contactId = getTestCCContact cc contactId >>= maybe (fail "no conne getTestCCContact :: TestCC -> ContactId -> IO Contact getTestCCContact cc contactId = do - let TestCC {chatController = ChatController {config = ChatConfig {chatVRange = vr}}} = cc + let TestCC {chatController = ChatController {config}} = cc withCCTransaction cc $ \db -> withCCUser cc $ \user -> - runExceptT (getContact db vr user contactId) >>= either (fail . show) pure + runExceptT (getContact db (mkStoreCxt config) user contactId) >>= either (fail . show) pure lastItemId :: HasCallStack => TestCC -> IO String lastItemId cc = do From 95349430c5f334d8560237576f871da25c0f4e46 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 7 Jun 2026 12:01:40 +0100 Subject: [PATCH 36/66] core: support signature verification in p2p groups (forward compatibility) (#7058) * core: support signature verification in p2p groups (forward compatibility) * encoding * mirror encoding * comment --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- src/Simplex/Chat/Library/Subscriber.hs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index e25e665bea..87b560d1ab 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -3462,8 +3462,11 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = Just sm@SignedMsg {chatBinding, signatures, signedBody} | GroupMember {memberPubKey = Just pubKey, memberId} <- member -> case chatBinding of - CBGroup | Just GroupKeys {publicGroupId} <- groupKeys gInfo -> - let prefix = smpEncode chatBinding <> smpEncode (publicGroupId, memberId) + CBGroup -> + let prefix = smpEncode chatBinding <> bindingData + bindingData = case groupKeys gInfo of + Just GroupKeys {publicGroupId} -> smpEncode (publicGroupId, memberId) + Nothing -> smpEncode (memberId, pubKey) -- forward compatibility for verifying signed messages in p2p groups in signed MSSVerified <$ guard (all (\(MsgSignature KRMember sig) -> C.verify (C.APublicVerifyKey C.SEd25519 pubKey) sig (prefix <> signedBody)) signatures) _ -> signed MSSSignedNoKey <$ guard signatureOptional | otherwise -> signed MSSSignedNoKey <$ guard (signatureOptional || unverifiedAllowed membership member tag) 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 37/66] 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 38/66] 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 39/66] 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 f144bf45607e8838c250bae424660658844457f4 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Sun, 7 Jun 2026 23:05:11 +0000 Subject: [PATCH 40/66] android, desktop, ios: fix trailing dot in saved name for files without extension (#7016) --- apps/ios/SimpleXChat/ImageUtils.swift | 2 +- .../kotlin/chat/simplex/common/views/helpers/Utils.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/ios/SimpleXChat/ImageUtils.swift b/apps/ios/SimpleXChat/ImageUtils.swift index f93b090517..2d36a7a53a 100644 --- a/apps/ios/SimpleXChat/ImageUtils.swift +++ b/apps/ios/SimpleXChat/ImageUtils.swift @@ -297,7 +297,7 @@ private func uniqueCombine(_ fileName: String, fullPath: Bool = false) -> String let name = ns.deletingPathExtension let ext = ns.pathExtension let suffix = (n == 0) ? "" : "_\(n)" - let f = "\(name)\(suffix).\(ext)" + let f = ext.isEmpty ? "\(name)\(suffix)" : "\(name)\(suffix).\(ext)" return (FileManager.default.fileExists(atPath: fullPath ? f : getAppFilePath(f).path)) ? tryCombine(fileName, n + 1) : f } return tryCombine(fileName, 0) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index 424d500085..23c622bc34 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -383,7 +383,7 @@ fun uniqueCombine(fileName: String, dir: File): String { val ext = orig.extension fun tryCombine(n: Int): String { val suffix = if (n == 0) "" else "_$n" - val f = "$name$suffix.$ext" + val f = if (ext.isEmpty()) "$name$suffix" else "$name$suffix.$ext" return if (File(dir, f).exists()) tryCombine(n + 1) else f } return tryCombine(0) 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 41/66] 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 42/66] 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 43/66] 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 44/66] 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 ad23da63d0f3c5388c08a5d7db8f4e3ddf2c6915 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 15 Jun 2026 22:25:08 +0100 Subject: [PATCH 45/66] core: supporter badges using anonymous BBS credentials (#7040) * core: supporter badges using anonymous BBS credentials * badges in profiles * badge in profiles * process badges * update simplexmq * update simplexmq * change types * fix migration * migration * update simplexmq * fix bot API, schema * fix postgresql build * refactor * postgresql schema * correctly set badges in all cases * badges ffi * plan, bot types * FFI * FFI: export badge symbols * add extra field * refactor badge types to GADT * configurable badge key * add badge to profile, test * ui: badge images * generate badge key and sign badge * badge sign in CLI * fix commands, ui * rename badges * Binary * image size, migration * update badge images, add public key * send badges in more cases * update UI, tests * bot types, schema * postgres schema * tone down badges * revert formula * refactor badges * smaller badges * badge position * better badge position * simpler * position * move position * update simplexmq * show badge after name * badge layout * fix badge * debug badge height * shift badge * fix badge in member name * bigger badge * badge layout * differentiate badge colors * more avatars for the user's profiles * refactor * remove color filter * alerts * multiple keys, old expired * use new BBS api * update badge keys, bot api * presentation header * simplify * parser * update iOS images * update public keys * query plans * update simplexmq * refactor badge types * simplexmq * bot api types * update simplexmq - commoncrypto flag * update simplexmq * pass commoncrypto flag to simplexmq in nix iOS build * ios ui * update core library, fixes * badge layout * badge size * badge gap * remove extensions * simplify * share badge in more events, reverify badge if verification failed * larger files with badges * allow sending larger files * simpler * update simplexmq * better decoder for badge keys * update simplexmq --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> Co-authored-by: shum --- .../badge-investor.imageset/Contents.json | 21 + .../badge-investor.svg | 12 + .../badge-legend.imageset/Contents.json | 21 + .../badge-legend.imageset/badge-legend.svg | 12 + .../badge-supporter.imageset/Contents.json | 21 + .../badge-supporter.svg | 12 + .../Shared/Views/Chat/ChatInfoToolbar.swift | 2 +- apps/ios/Shared/Views/Chat/ChatInfoView.swift | 24 +- .../Views/Chat/ChatItem/CIFileView.swift | 11 +- .../Views/Chat/ChatItem/CIImageView.swift | 15 +- .../Views/Chat/ChatItem/CIVideoView.swift | 18 +- .../Views/Chat/ChatItem/FramedItemView.swift | 6 +- .../Shared/Views/Chat/ChatItemInfoView.swift | 34 +- apps/ios/Shared/Views/Chat/ChatView.swift | 12 +- .../Chat/ComposeMessage/ComposeView.swift | 4 +- .../Chat/Group/AddGroupMembersView.swift | 9 +- .../Views/Chat/Group/ChannelMembersView.swift | 2 +- .../Views/Chat/Group/GroupChatInfoView.swift | 2 +- .../Chat/Group/GroupMemberInfoView.swift | 21 +- .../Chat/Group/MemberSupportChatToolbar.swift | 2 +- .../Views/Chat/Group/MemberSupportView.swift | 2 +- .../Views/ChatList/ChatPreviewView.swift | 10 +- .../Views/ChatList/ContactRequestView.swift | 16 +- .../Shared/Views/ChatList/UserPicker.swift | 3 +- .../Views/Contacts/ContactListNavLink.swift | 10 +- apps/ios/Shared/Views/Helpers/NameBadge.swift | 174 ++++++++ .../ios/Shared/Views/Helpers/ShareSheet.swift | 14 +- .../Shared/Views/NewChat/NewChatView.swift | 10 +- .../Views/UserSettings/SettingsView.swift | 4 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 20 +- apps/ios/SimpleXChat/ChatTypes.swift | 87 +++- apps/ios/SimpleXChat/FileUtils.swift | 27 +- .../views/chatlist/UserPicker.android.kt | 3 +- .../chat/simplex/common/model/ChatModel.kt | 96 +++- .../simplex/common/views/chat/ChatInfoView.kt | 27 +- .../common/views/chat/ChatItemInfoView.kt | 18 +- .../simplex/common/views/chat/ChatView.kt | 21 +- .../simplex/common/views/chat/ComposeView.kt | 17 +- .../views/chat/group/AddGroupMembersView.kt | 5 +- .../views/chat/group/ChannelMembersView.kt | 3 +- .../views/chat/group/GroupChatInfoView.kt | 6 +- .../views/chat/group/GroupMemberInfoView.kt | 28 +- .../views/chat/group/MemberSupportChatView.kt | 4 +- .../views/chat/group/MemberSupportView.kt | 4 +- .../common/views/chat/item/CIFileView.kt | 11 +- .../common/views/chat/item/CIImageView.kt | 12 +- .../common/views/chat/item/CIVideoView.kt | 18 +- .../common/views/chat/item/ChatItemView.kt | 2 +- .../common/views/chat/item/FramedItemView.kt | 6 +- .../common/views/chatlist/ChatPreviewView.kt | 16 +- .../views/chatlist/ContactRequestView.kt | 3 +- .../views/chatlist/ShareListNavLinkView.kt | 4 +- .../common/views/chatlist/UserPicker.kt | 9 +- .../views/contacts/ContactPreviewView.kt | 6 +- .../common/views/helpers/AlertManager.kt | 15 +- .../common/views/helpers/ChatInfoImage.kt | 148 +++++++ .../simplex/common/views/helpers/Utils.kt | 25 +- .../common/views/newchat/ConnectPlan.kt | 3 + .../common/views/newchat/NewChatView.kt | 8 +- .../common/views/usersettings/SettingsView.kt | 5 +- .../commonMain/resources/MR/base/strings.xml | 8 + .../resources/MR/images/badge_investor.svg | 12 + .../resources/MR/images/badge_legend.svg | 12 + .../resources/MR/images/badge_supporter.svg | 12 + .../views/chatlist/UserPicker.desktop.kt | 21 +- apps/simplex-chat/Main.hs | 8 +- .../src/Directory/Store.hs | 61 +-- bots/api/TYPES.md | 62 ++- bots/src/API/Docs/Commands.hs | 1 + bots/src/API/Docs/Types.hs | 11 + bots/src/API/TypeInfo.hs | 4 + cabal.project | 2 +- flake.nix | 8 + libsimplex.dll.def | 2 + .../types/typescript/src/types.ts | 37 +- .../src/simplex_chat/types/_types.py | 24 +- plans/2026-06-01-supporter-badges-v1.md | 80 ++++ scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 6 + src/Simplex/Chat.hs | 12 + src/Simplex/Chat/Badges.hs | 414 ++++++++++++++++++ src/Simplex/Chat/Badges/CLI.hs | 87 ++++ src/Simplex/Chat/Controller.hs | 7 +- src/Simplex/Chat/Core.hs | 2 +- src/Simplex/Chat/Library/Commands.hs | 113 +++-- src/Simplex/Chat/Library/Internal.hs | 55 ++- src/Simplex/Chat/Library/Subscriber.hs | 61 +-- src/Simplex/Chat/Mobile.hs | 5 + src/Simplex/Chat/Mobile/Badges.hs | 74 ++++ src/Simplex/Chat/ProfileGenerator.hs | 2 +- src/Simplex/Chat/Protocol.hs | 6 +- src/Simplex/Chat/Store/Connections.hs | 28 +- src/Simplex/Chat/Store/ContactRequest.hs | 43 +- src/Simplex/Chat/Store/Delivery.hs | 3 +- src/Simplex/Chat/Store/Direct.hs | 116 ++--- src/Simplex/Chat/Store/Groups.hs | 212 +++++---- src/Simplex/Chat/Store/Messages.hs | 48 +- src/Simplex/Chat/Store/Postgres/Migrations.hs | 4 +- .../Migrations/M20260516_supporter_badges.hs | 35 ++ .../Store/Postgres/Migrations/chat_schema.sql | 11 +- src/Simplex/Chat/Store/Profiles.hs | 95 ++-- src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../Migrations/M20260516_supporter_badges.hs | 34 ++ .../SQLite/Migrations/chat_query_plans.txt | 131 ++++-- .../Store/SQLite/Migrations/chat_schema.sql | 11 +- src/Simplex/Chat/Store/Shared.hs | 80 ++-- src/Simplex/Chat/Types.hs | 56 ++- src/Simplex/Chat/View.hs | 80 +++- tests/BadgeTests.hs | 142 ++++++ tests/Bots/BroadcastTests.hs | 2 +- tests/Bots/DirectoryTests.hs | 2 +- tests/ChatTests/Profiles.hs | 235 +++++++++- tests/ChatTests/Utils.hs | 2 +- tests/MobileTests.hs | 23 + tests/ProtocolTests.hs | 4 +- tests/Test.hs | 2 + 116 files changed, 3121 insertions(+), 654 deletions(-) create mode 100644 apps/ios/Shared/Assets.xcassets/badge-investor.imageset/Contents.json create mode 100644 apps/ios/Shared/Assets.xcassets/badge-investor.imageset/badge-investor.svg create mode 100644 apps/ios/Shared/Assets.xcassets/badge-legend.imageset/Contents.json create mode 100644 apps/ios/Shared/Assets.xcassets/badge-legend.imageset/badge-legend.svg create mode 100644 apps/ios/Shared/Assets.xcassets/badge-supporter.imageset/Contents.json create mode 100644 apps/ios/Shared/Assets.xcassets/badge-supporter.imageset/badge-supporter.svg create mode 100644 apps/ios/Shared/Views/Helpers/NameBadge.swift create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/badge_investor.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/badge_legend.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/badge_supporter.svg create mode 100644 plans/2026-06-01-supporter-badges-v1.md create mode 100644 src/Simplex/Chat/Badges.hs create mode 100644 src/Simplex/Chat/Badges/CLI.hs create mode 100644 src/Simplex/Chat/Mobile/Badges.hs create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations/M20260516_supporter_badges.hs create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20260516_supporter_badges.hs create mode 100644 tests/BadgeTests.hs diff --git a/apps/ios/Shared/Assets.xcassets/badge-investor.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/badge-investor.imageset/Contents.json new file mode 100644 index 0000000000..9d066d386e --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/badge-investor.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "badge-investor.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/Shared/Assets.xcassets/badge-investor.imageset/badge-investor.svg b/apps/ios/Shared/Assets.xcassets/badge-investor.imageset/badge-investor.svg new file mode 100644 index 0000000000..330da9b50d --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/badge-investor.imageset/badge-investor.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/ios/Shared/Assets.xcassets/badge-legend.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/badge-legend.imageset/Contents.json new file mode 100644 index 0000000000..b8b9a000d6 --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/badge-legend.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "badge-legend.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/Shared/Assets.xcassets/badge-legend.imageset/badge-legend.svg b/apps/ios/Shared/Assets.xcassets/badge-legend.imageset/badge-legend.svg new file mode 100644 index 0000000000..7f892cd25c --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/badge-legend.imageset/badge-legend.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/ios/Shared/Assets.xcassets/badge-supporter.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/badge-supporter.imageset/Contents.json new file mode 100644 index 0000000000..443575f1c7 --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/badge-supporter.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "badge-supporter.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/Shared/Assets.xcassets/badge-supporter.imageset/badge-supporter.svg b/apps/ios/Shared/Assets.xcassets/badge-supporter.imageset/badge-supporter.svg new file mode 100644 index 0000000000..9ebdc15c11 --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/badge-supporter.imageset/badge-supporter.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift index e158b9374f..00c8d7070b 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift @@ -47,7 +47,7 @@ struct ChatInfoToolbar: View { } .padding(.trailing, 4) let t = Text(cInfo.displayName).font(.headline) - (cInfo.contact?.verified == true ? contactVerifiedShield + t : t) + NameWithBadge((cInfo.contact?.verified == true ? contactVerifiedShield + t : t), cInfo.nameBadge, .headline) .lineLimit(1) .if (cInfo.fullName != "" && cInfo.displayName != cInfo.fullName) { v in VStack(spacing: 0) { diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index c17d8e23a8..b21def7944 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -374,25 +374,17 @@ struct ChatInfoView: View { // show actual display name, alias can be edited in this view let displayName = contact.profile.displayName.trimmingCharacters(in: .whitespacesAndNewlines) let fullName = cInfo.fullName.trimmingCharacters(in: .whitespacesAndNewlines) - if contact.verified { - ( - Text(Image(systemName: "checkmark.shield")) - .foregroundColor(theme.colors.secondary) - .font(.title2) - + textSpace - + Text(displayName) - .font(.largeTitle) - ) + let badge = cInfo.nameBadge + // the shield is smaller (.title2) than the name (.largeTitle), so on the shared baseline it + // sits low; raise it by half the cap-height difference to center it with the capitals + let shieldRaise = (UIFont.preferredFont(forTextStyle: .largeTitle).capHeight - UIFont.preferredFont(forTextStyle: .title2).capHeight) / 2 + let nameText = contact.verified + ? Text(Image(systemName: "checkmark.shield")).foregroundColor(theme.colors.secondary).font(.title2).baselineOffset(shieldRaise) + textSpace + Text(displayName).font(.largeTitle) + : Text(displayName).font(.largeTitle) + NameWithBadge(nameText, badge, .largeTitle) { if let badge { showBadgeInfoAlert(displayName, badge) } } .multilineTextAlignment(.center) .lineLimit(2) .padding(.bottom, 2) - } else { - Text(displayName) - .font(.largeTitle) - .multilineTextAlignment(.center) - .lineLimit(2) - .padding(.bottom, 2) - } if fullName != "" && fullName != displayName && fullName != cInfo.displayName.trimmingCharacters(in: .whitespacesAndNewlines) { Text(cInfo.fullName) .font(.title2) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index 639de1dbc9..75a5baafee 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -16,6 +16,7 @@ struct CIFileView: View { @EnvironmentObject var theme: AppTheme let file: CIFile? let edited: Bool + let senderProfile: LocalProfile? var smallViewSize: CGFloat? var body: some View { @@ -85,7 +86,7 @@ struct CIFileView: View { if let file = file { switch (file.fileStatus) { case .rcvInvitation, .rcvAborted: - if fileSizeValid(file) { + if fileSizeValid(file, senderProfile) { Task { logger.debug("CIFileView fileAction - in .rcvInvitation, .rcvAborted, in Task") if let user = m.currentUser { @@ -93,7 +94,7 @@ struct CIFileView: View { } } } else { - let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: getMaxFileSize(file.fileProtocol), countStyle: .binary) + let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: getMaxFileSize(file.fileProtocol, senderProfile), countStyle: .binary) AlertManager.shared.showAlertMsg( title: "Large file!", message: "Your contact sent a file that is larger than currently supported maximum size (\(prettyMaxFileSize))." @@ -165,7 +166,7 @@ struct CIFileView: View { case .sndError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10) case .sndWarning: fileIcon("doc.fill", innerIcon: "exclamationmark.triangle.fill", innerIconSize: 10) case .rcvInvitation: - if fileSizeValid(file) { + if fileSizeValid(file, senderProfile) { fileIcon("arrow.down.doc.fill", color: theme.colors.primary) } else { fileIcon("doc.fill", color: .orange, innerIcon: "exclamationmark", innerIconSize: 12) @@ -227,9 +228,9 @@ struct CIFileView: View { } } -func fileSizeValid(_ file: CIFile?) -> Bool { +func fileSizeValid(_ file: CIFile?, _ senderProfile: LocalProfile?) -> Bool { if let file = file { - return file.fileSize <= getMaxFileSize(file.fileProtocol) + return file.fileSize <= getMaxFileSize(file.fileProtocol, senderProfile) } return false } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift index b56f1f9f2a..972e9c4ec6 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift @@ -14,6 +14,7 @@ import SimpleXChat struct CIImageView: View { @EnvironmentObject var m: ChatModel let chatItem: ChatItem + let senderProfile: LocalProfile? var scrollToItem: ((ChatItem.ID) -> Void)? = nil var preview: UIImage? let maxWidth: CGFloat @@ -51,10 +52,18 @@ struct CIImageView: View { if let file = file { switch file.fileStatus { case .rcvInvitation, .rcvAborted: - Task { - if let user = m.currentUser { - await receiveFile(user: user, fileId: file.fileId) + if fileSizeValid(file, senderProfile) { + Task { + if let user = m.currentUser { + await receiveFile(user: user, fileId: file.fileId) + } } + } else { + let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: getMaxFileSize(file.fileProtocol, senderProfile), countStyle: .binary) + AlertManager.shared.showAlertMsg( + title: "Large file!", + message: "Your contact sent a file that is larger than currently supported maximum size (\(prettyMaxFileSize))." + ) } case .rcvAccepted: switch file.fileProtocol { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift index e1172dab92..912fde4043 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift @@ -16,6 +16,7 @@ import Combine struct CIVideoView: View { @EnvironmentObject var m: ChatModel private let chatItem: ChatItem + private let senderProfile: LocalProfile? private let preview: UIImage? @State private var duration: Int @State private var progress: Int = 0 @@ -35,8 +36,9 @@ struct CIVideoView: View { private var sizeMultiplier: CGFloat { smallView ? 0.38 : 1 } @State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0 - init(chatItem: ChatItem, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?, smallView: Bool = false, showFullscreenPlayer: Binding) { + init(chatItem: ChatItem, senderProfile: LocalProfile?, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?, smallView: Bool = false, showFullscreenPlayer: Binding) { self.chatItem = chatItem + self.senderProfile = senderProfile self.preview = preview self._duration = State(initialValue: duration) self.maxWidth = maxWidth @@ -421,10 +423,18 @@ struct CIVideoView: View { // TODO encrypt: where file size is checked? private func receiveFileIfValidSize(file: CIFile, receiveFile: @escaping (User, Int64, Bool, Bool) async -> Void) { - Task { - if let user = m.currentUser { - await receiveFile(user, file.fileId, false, false) + if fileSizeValid(file, senderProfile) { + Task { + if let user = m.currentUser { + await receiveFile(user, file.fileId, false, false) + } } + } else { + let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: getMaxFileSize(file.fileProtocol, senderProfile), countStyle: .binary) + AlertManager.shared.showAlertMsg( + title: "Large file!", + message: "Your contact sent a file that is larger than currently supported maximum size (\(prettyMaxFileSize))." + ) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index d09289c1d5..372c7df8a3 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -127,7 +127,7 @@ struct FramedItemView: View { } else { switch (chatItem.content.msgContent) { case let .image(text, _): - CIImageView(chatItem: chatItem, scrollToItem: scrollToItem, preview: preview, maxWidth: maxWidth, imgWidth: imgWidth, showFullScreenImage: $showFullscreenGallery) + CIImageView(chatItem: chatItem, senderProfile: ciSenderProfile(chatItem, chat.chatInfo), scrollToItem: scrollToItem, preview: preview, maxWidth: maxWidth, imgWidth: imgWidth, showFullScreenImage: $showFullscreenGallery) .overlay(DetermineWidth()) if text == "" && !chatItem.meta.isLive { Color.clear @@ -142,7 +142,7 @@ struct FramedItemView: View { ciMsgContentView(chatItem) } case let .video(text, _, duration): - CIVideoView(chatItem: chatItem, preview: preview, duration: duration, maxWidth: maxWidth, videoWidth: videoWidth, showFullscreenPlayer: $showFullscreenGallery) + CIVideoView(chatItem: chatItem, senderProfile: ciSenderProfile(chatItem, chat.chatInfo), preview: preview, duration: duration, maxWidth: maxWidth, videoWidth: videoWidth, showFullscreenPlayer: $showFullscreenGallery) .overlay(DetermineWidth()) if text == "" && !chatItem.meta.isLive { Color.clear @@ -349,7 +349,7 @@ struct FramedItemView: View { } @ViewBuilder private func ciFileView(_ ci: ChatItem, _ text: String) -> some View { - CIFileView(file: chatItem.file, edited: chatItem.meta.itemEdited) + CIFileView(file: chatItem.file, edited: chatItem.meta.itemEdited, senderProfile: ciSenderProfile(chatItem, chat.chatInfo)) .overlay(DetermineWidth()) if text != "" || ci.meta.isLive { ciMsgContentView (chatItem) diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift index 3858d15252..bd0e549d38 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -387,23 +387,31 @@ struct ChatItemInfoView: View { Text("you") .italic() .foregroundColor(theme.colors.onBackground) - Text(forwardedFromItem.chatInfo.chatViewName) - .foregroundColor(theme.colors.secondary) - .lineLimit(1) + NameWithBadge( + Text(forwardedFromItem.chatInfo.chatViewName).foregroundColor(theme.colors.secondary), + forwardedFromItem.chatInfo.nameBadge + ) + .lineLimit(1) } } else if case let .groupRcv(groupMember) = forwardedFromItem.chatItem.chatDir { VStack(alignment: .leading) { - Text(groupMember.chatViewName) - .foregroundColor(theme.colors.onBackground) - .lineLimit(1) - Text(forwardedFromItem.chatInfo.chatViewName) - .foregroundColor(theme.colors.secondary) - .lineLimit(1) + NameWithBadge( + Text(groupMember.chatViewName).foregroundColor(theme.colors.onBackground), + groupMember.nameBadge + ) + .lineLimit(1) + NameWithBadge( + Text(forwardedFromItem.chatInfo.chatViewName).foregroundColor(theme.colors.secondary), + forwardedFromItem.chatInfo.nameBadge + ) + .lineLimit(1) } } else { - Text(forwardedFromItem.chatInfo.chatViewName) - .foregroundColor(theme.colors.onBackground) - .lineLimit(1) + NameWithBadge( + Text(forwardedFromItem.chatInfo.chatViewName).foregroundColor(theme.colors.onBackground), + forwardedFromItem.chatInfo.nameBadge + ) + .lineLimit(1) } } } @@ -451,7 +459,7 @@ struct ChatItemInfoView: View { HStack{ MemberProfileImage(member, size: 30) .padding(.trailing, 2) - Text(member.chatViewName) + NameWithBadge(Text(member.chatViewName), member.nameBadge) .lineLimit(1) Spacer() if sentViaProxy == true { diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 66148034df..efe26fdf89 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -981,8 +981,8 @@ struct ChatView: View { let v = VStack(spacing: 8) { ChatInfoImage(chat: chat, size: alertProfileImageSize) - Text(chat.chatInfo.displayName) - .font(.title3) + let badge = chat.chatInfo.nameBadge + NameWithBadge(Text(chat.chatInfo.displayName).font(.title3), badge, .title3) { if let badge { showBadgeInfoAlert(chat.chatInfo.displayName, badge) } } .multilineTextAlignment(.center) .lineLimit(2) .fixedSize(horizontal: false, vertical: true) @@ -2003,7 +2003,7 @@ struct ChatView: View { Group { if #available(iOS 16.0, *) { MemberLayout(spacing: 16, msgWidth: msgWidth) { - Text(name) + NameWithBadge(Text(name), ci.meta.showGroupAsSender ? nil : member.nameBadge, .caption1) .lineLimit(1) Text(role) .fontWeight(.semibold) @@ -2012,7 +2012,7 @@ struct ChatView: View { } } else { HStack(spacing: 16) { - Text(name) + NameWithBadge(Text(name), ci.meta.showGroupAsSender ? nil : member.nameBadge, .caption1) .lineLimit(1) Text(role) .fontWeight(.semibold) @@ -2026,7 +2026,7 @@ struct ChatView: View { alignment: chatItem.chatDir.sent ? .trailing : .leading ) } else { - Text(memberNames(member, prevMember, memCount)) + NameWithBadge(Text(memberNames(member, prevMember, memCount)), memCount == 1 ? member.nameBadge : nil, .caption1) .lineLimit(2) } } @@ -2311,7 +2311,7 @@ struct ChatView: View { } else { saveButton(file: fileSource) } - } else if let file = ci.file, case .rcvInvitation = file.fileStatus, fileSizeValid(file) { + } else if let file = ci.file, case .rcvInvitation = file.fileStatus, fileSizeValid(file, ciSenderProfile(ci, chat.chatInfo)) { downloadButton(file: file) } if ci.meta.editable && !mc.isVoice && !live { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 5242923258..e308a145b9 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -1247,7 +1247,9 @@ struct ComposeView: View { } private var maxFileSize: Int64 { - getMaxFileSize(.xftp) + // the user's active badge raises the limit, but not in incognito chats where no badge is presented + let incognito = chat.chatInfo.profileChangeProhibited ? chat.chatInfo.incognito : incognitoDefault + return getMaxFileSize(.xftp, incognito ? nil : chatModel.currentUser?.profile) } // Spec: spec/client/compose.md#sendLiveMessage diff --git a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift index 6b18c0c5ef..b59fd51fe8 100644 --- a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift +++ b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift @@ -220,9 +220,12 @@ struct AddGroupMembersViewCommon: View { HStack{ ProfileImage(imageStr: contact.image, size: 30) .padding(.trailing, 2) - Text(ChatInfo.direct(contact: contact).chatViewName) - .foregroundColor(prohibitedToInviteIncognito ? theme.colors.secondary : theme.colors.onBackground) - .lineLimit(1) + NameWithBadge( + Text(ChatInfo.direct(contact: contact).chatViewName) + .foregroundColor(prohibitedToInviteIncognito ? theme.colors.secondary : theme.colors.onBackground), + contact.active ? contact.profile.localBadge : nil + ) + .lineLimit(1) Spacer() Image(systemName: icon) .foregroundColor(iconColor) diff --git a/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift b/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift index abcadc6c3f..50144e2bc5 100644 --- a/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift +++ b/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift @@ -56,7 +56,7 @@ struct ChannelMembersView: View { MemberProfileImage(member, size: 38) .padding(.trailing, 2) VStack(alignment: .leading) { - displayName + NameWithBadge(displayName, member.nameBadge) .lineLimit(1) if user { Text("you") diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 0a448a2772..da895b325c 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -502,7 +502,7 @@ struct GroupChatInfoView: View { // TODO server connection status VStack(alignment: .leading) { let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : theme.colors.onBackground) - (member.verified ? memberVerifiedShield + t : t) + NameWithBadge((member.verified ? memberVerifiedShield + t : t), member.nameBadge) .lineLimit(1) (user ? Text ("you: ") + Text(member.memberStatus.shortText) : Text(memberConnStatus(member))) .lineLimit(1) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index dc14c7520b..28693e8d8a 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -522,25 +522,14 @@ struct GroupMemberInfoView: View { // show alias if set, alias cannot be edited in this view let displayName = mem.displayName.trimmingCharacters(in: .whitespacesAndNewlines) let fullName = mem.fullName.trimmingCharacters(in: .whitespacesAndNewlines) - if mem.verified { - ( - Text(Image(systemName: "checkmark.shield")) - .foregroundColor(theme.colors.secondary) - .font(.title2) - + textSpace - + Text(displayName) - .font(.largeTitle) - ) + let badge = mem.nameBadge + let nameText = mem.verified + ? Text(Image(systemName: "checkmark.shield")).foregroundColor(theme.colors.secondary).font(.title2) + textSpace + Text(displayName).font(.largeTitle) + : Text(displayName).font(.largeTitle) + NameWithBadge(nameText, badge, .largeTitle) { if let badge { showBadgeInfoAlert(displayName, badge) } } .multilineTextAlignment(.center) .lineLimit(2) .padding(.bottom, 2) - } else { - Text(displayName) - .font(.largeTitle) - .multilineTextAlignment(.center) - .lineLimit(2) - .padding(.bottom, 2) - } if fullName != "" && fullName != displayName && fullName != mem.memberProfile.displayName.trimmingCharacters(in: .whitespacesAndNewlines) { Text(mem.fullName) .font(.title2) diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift index 23001e64bf..74cb702d21 100644 --- a/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift +++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift @@ -20,7 +20,7 @@ struct MemberSupportChatToolbar: View { MemberProfileImage(groupMember, size: imageSize) .padding(.trailing, 4) let t = Text(groupMember.chatViewName).font(.headline) - (groupMember.verified ? memberVerifiedShield + t : t) + NameWithBadge((groupMember.verified ? memberVerifiedShield + t : t), groupMember.nameBadge, .headline) .lineLimit(1) } .foregroundColor(theme.colors.onBackground) diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift index 880933985c..0263a39a90 100644 --- a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift +++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift @@ -172,7 +172,7 @@ struct MemberSupportView: View { .padding(.trailing, 2) VStack(alignment: .leading) { let t = Text(member.chatViewName).foregroundColor(theme.colors.onBackground) - (member.verified ? memberVerifiedShield + t : t) + NameWithBadge((member.verified ? memberVerifiedShield + t : t), member.nameBadge) .lineLimit(1) Text(memberStatus(member)) .lineLimit(1) diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 243d804685..a6e7fc5870 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -173,7 +173,9 @@ struct ChatPreviewView: View { : !contact.sndReady ? theme.colors.secondary : nil - previewTitle(contact.verified == true ? verifiedIcon + t : t).foregroundColor(color) + NameWithBadge((contact.verified == true ? verifiedIcon + t : t).foregroundColor(color), chat.chatInfo.nameBadge, .title3) + .lineLimit(1) + .frame(alignment: .topLeading) case let .group(groupInfo, _): let color = if deleting { theme.colors.secondary @@ -424,11 +426,11 @@ struct ChatPreviewView: View { } case let .image(_, image): smallContentPreview(size: dynamicMediaSize) { - CIImageView(chatItem: ci, preview: imageFromBase64(image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery) + CIImageView(chatItem: ci, senderProfile: ciSenderProfile(ci, chat.chatInfo), preview: imageFromBase64(image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery) } case let .video(_,image, duration): smallContentPreview(size: dynamicMediaSize) { - CIVideoView(chatItem: ci, preview: imageFromBase64(image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery) + CIVideoView(chatItem: ci, senderProfile: ciSenderProfile(ci, chat.chatInfo), preview: imageFromBase64(image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery) } case let .voice(_, duration): smallContentPreviewVoice(size: dynamicMediaSize) { @@ -436,7 +438,7 @@ struct ChatPreviewView: View { } case .file: smallContentPreviewFile(size: dynamicMediaSize) { - CIFileView(file: ci.file, edited: ci.meta.itemEdited, smallViewSize: dynamicMediaSize) + CIFileView(file: ci.file, edited: ci.meta.itemEdited, senderProfile: ciSenderProfile(ci, chat.chatInfo), smallViewSize: dynamicMediaSize) } case let .chat(_, chatLink, ownerSig): smallContentPreview(size: dynamicMediaSize, borderColor: chatLink.image != nil ? .secondary : .clear) { diff --git a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift index 9276bbfc78..341bc10655 100644 --- a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift +++ b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift @@ -22,12 +22,16 @@ struct ContactRequestView: View { .padding(.leading, 4) VStack(alignment: .leading, spacing: 0) { HStack(alignment: .top) { - Text(contactRequest.chatViewName) - .font(.title3) - .fontWeight(.bold) - .foregroundColor(theme.colors.primary) - .padding(.leading, 8) - .frame(alignment: .topLeading) + NameWithBadge( + Text(contactRequest.chatViewName) + .font(.title3) + .fontWeight(.bold) + .foregroundColor(theme.colors.primary), + chat.chatInfo.nameBadge, + .title3 + ) + .padding(.leading, 8) + .frame(alignment: .topLeading) Spacer() formatTimestampText(contactRequest.updatedAt) .font(.subheadline) diff --git a/apps/ios/Shared/Views/ChatList/UserPicker.swift b/apps/ios/Shared/Views/ChatList/UserPicker.swift index 63d28e3624..8c230dc56a 100644 --- a/apps/ios/Shared/Views/ChatList/UserPicker.swift +++ b/apps/ios/Shared/Views/ChatList/UserPicker.swift @@ -129,7 +129,8 @@ struct UserPicker: View { } } .padding(.trailing, 6) - Text(u.user.displayName).font(.title2).lineLimit(1) + NameWithBadge(Text(u.user.displayName).font(.title2), u.user.profile.localBadge, .title2) + .lineLimit(1) } .padding(rowPadding) .modifier(ListRow { diff --git a/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift b/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift index fcfcde2c07..9214e3ecde 100644 --- a/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift +++ b/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift @@ -200,10 +200,9 @@ struct ContactListNavLink: View { private func previewTitle(_ contact: Contact, titleColor: Color) -> some View { let t = Text(chat.chatInfo.chatViewName).foregroundColor(titleColor) - return ( - contact.verified == true - ? verifiedIcon + t - : t + return NameWithBadge( + contact.verified == true ? verifiedIcon + t : t, + chat.chatInfo.nameBadge ) .lineLimit(1) } @@ -318,8 +317,7 @@ struct ContactListNavLink: View { HStack{ ProfileImage(imageStr: chat.chatInfo.image, size: 30) - Text(chat.chatInfo.chatViewName) - .foregroundColor(color) + NameWithBadge(Text(chat.chatInfo.chatViewName).foregroundColor(color), chat.chatInfo.nameBadge) .lineLimit(1) Spacer() diff --git a/apps/ios/Shared/Views/Helpers/NameBadge.swift b/apps/ios/Shared/Views/Helpers/NameBadge.swift new file mode 100644 index 0000000000..67f6d6d6b2 --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/NameBadge.swift @@ -0,0 +1,174 @@ +// +// NameBadge.swift +// SimpleX +// +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +// The badge is sized to a fraction of the font size (em), NOT the font's cap-height metric: the metric +// underestimates the rendered capital letters, so a cap-height-tall badge looks too small. These ratios +// are calibrated visually to match caps - the same constants as the Compose (Android/desktop) app. +private let fontCapHeightRatio: CGFloat = 0.85 +// fraction of the badge height pushed below the text baseline (like the undershoot of round letters) +private let badgeBaselineOffsetRatio: CGFloat = 0.05 + +// A contact/member name with the supporter badge right after it. The name keeps its own styling +// (font, weight, color, even a verification shield concatenated into the Text); the badge is sized to +// the given text style and sits on the name's baseline. Use this everywhere a name may carry a badge. +// Pass onTap to make the badge open the info alert. The badge hides itself for a nil/long-expired badge. +struct NameWithBadge: View { + let name: Text + var badge: LocalBadge? + var textStyle: UIFont.TextStyle = .body + var onTap: (() -> Void)? = nil + + init(_ name: Text, _ badge: LocalBadge?, _ textStyle: UIFont.TextStyle = .body, onTap: (() -> Void)? = nil) { + self.name = name + self.badge = badge + self.textStyle = textStyle + self.onTap = onTap + } + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 0) { + name + NameBadge(badge, textStyle, onTap: onTap) + } + } +} + +// The badge glyph alone, sized to the given text style and sitting on the text baseline in an +// HStack(alignment: .firstTextBaseline). Renders nothing for a nil badge or a long-expired one +// (ExpiredOld); a failed or unknown-key badge shows a warning glyph. Prefer NameWithBadge; use this +// directly only where the name is not a single Text. Pass onTap to open the badge info alert. +struct NameBadge: View { + var badge: LocalBadge? + var textStyle: UIFont.TextStyle = .body + var onTap: (() -> Void)? = nil + + init(_ badge: LocalBadge?, _ textStyle: UIFont.TextStyle = .body, onTap: (() -> Void)? = nil) { + self.badge = badge + self.textStyle = textStyle + self.onTap = onTap + } + + var body: some View { + if let badge, badge.status != .expiredOld { + // the leading padding is the gap to the name; it lives here so an absent badge adds no gap. + // the alignment guide pushes the badge bottom slightly below the baseline (round-letter undershoot) + let v = glyph(badge) + .frame(height: badgeHeight) + .alignmentGuide(.firstTextBaseline) { $0.height * (1 - badgeBaselineOffsetRatio) } + .padding(.leading, badgeGap) + if let onTap { + v.onTapGesture(perform: onTap) + } else { + v + } + } + } + + private var badgeHeight: CGFloat { + UIFont.preferredFont(forTextStyle: textStyle).pointSize * fontCapHeightRatio + } + + // the gap to the name, matching the verification shield's gap (textSpace - one space in the name's font) + private var badgeGap: CGFloat { + let font = UIFont.preferredFont(forTextStyle: textStyle) + return (" " as NSString).size(withAttributes: [.font: font]).width + } + + @ViewBuilder private func glyph(_ badge: LocalBadge) -> some View { + switch badge.status { + case .failed, .unknownKey: + Image(systemName: "exclamationmark.triangle.fill") + .resizable().scaledToFit() + .foregroundColor(.orange) + default: + Image(badgeImageName(badge.badge.badgeType)) + .resizable().scaledToFit() + .opacity(badge.status == .expired ? 0.4 : 1) + } + } +} + +private func badgeImageName(_ t: BadgeType) -> String { + switch t { + case .legend: "badge-legend" + case .investor: "badge-investor" + default: "badge-supporter" // supporter + unknown + } +} + +// The badge as an inline attachment for a UIKit label, for the custom alert where the name is a UILabel +// and the SwiftUI NameBadge can't be used. Sized to the font's cap height with its bottom on the baseline, +// preceded by a space for the gap to the name. Returns nil for a nil/long-expired badge. Mirrors NameBadge's glyph. +func nameBadgeAttachment(_ badge: LocalBadge?, font: UIFont) -> NSAttributedString? { + guard let badge, badge.status != .expiredOld else { return nil } + var image: UIImage? + switch badge.status { + case .failed, .unknownKey: + image = UIImage(systemName: "exclamationmark.triangle.fill")? + .withTintColor(.systemOrange, renderingMode: .alwaysOriginal) + default: + image = UIImage(named: badgeImageName(badge.badge.badgeType)) + if badge.status == .expired, let img = image { + // a recently expired badge is dimmed, matching NameBadge's 0.4 opacity + image = UIGraphicsImageRenderer(size: img.size).image { _ in + img.draw(at: .zero, blendMode: .normal, alpha: 0.4) + } + } + } + guard let image else { return nil } + let attachment = NSTextAttachment() + attachment.image = image + let h = font.pointSize * fontCapHeightRatio + // text coordinates: a negative y drops the image below the baseline by badgeBaselineOffsetRatio of its height + attachment.bounds = CGRect(x: 0, y: -h * badgeBaselineOffsetRatio, width: h * image.size.width / image.size.height, height: h) + let s = NSMutableAttributedString(string: " ") // the gap to the name + s.append(NSAttributedString(attachment: attachment)) + return s +} + +func showBadgeInfoAlert(_ name: String, _ badge: LocalBadge) { + switch badge.status { + case .failed: + showAlert( + NSLocalizedString("Unverified badge", comment: "badge alert title"), + message: NSLocalizedString("This badge could not be verified and may not be genuine.", comment: "badge alert") + ) + case .unknownKey: + showAlert( + NSLocalizedString("Badge cannot be verified", comment: "badge alert title"), + message: NSLocalizedString("The badge is signed with a key that this version of the app does not recognize. Update the app to verify this badge.", comment: "badge alert") + ) + default: + // a verified badge's type is signed and can't be faked, so the real (possibly unknown) type name is the title + let t = badge.badge.badgeType.text + let title = t.prefix(1).uppercased() + t.dropFirst() + if case .investor = badge.badge.badgeType { + let message = String.localizedStringWithFormat(NSLocalizedString("%@ invested in SimpleX Chat crowdfunding.", comment: "badge alert"), name) + showAlert(title, message: message) { + [ UIAlertAction(title: NSLocalizedString("Learn more", comment: "badge alert button"), style: .default) { _ in + if let url = URL(string: "https://simplex.chat/crowdfunding") { + UIApplication.shared.open(url) + } + }, + okAlertAction ] + } + } else { + // supporter, legend and unknown types use the supporter wording + let supports = + if badge.status == .expired, let expiry = badge.badge.badgeExpiry { + String.localizedStringWithFormat(NSLocalizedString("%1$@ supported SimpleX Chat. The badge expired on %2$@.", comment: "badge alert"), name, expiry.formatted(date: .abbreviated, time: .omitted)) + } else { + String.localizedStringWithFormat(NSLocalizedString("%@ supports SimpleX Chat.", comment: "badge alert"), name) + } + let v7 = NSLocalizedString("You can support SimpleX starting from v7 of the app.", comment: "badge alert") + showAlert(title, message: supports + "\n\n" + v7) + } + } +} diff --git a/apps/ios/Shared/Views/Helpers/ShareSheet.swift b/apps/ios/Shared/Views/Helpers/ShareSheet.swift index 9f2fc833ba..82d17cd2b1 100644 --- a/apps/ios/Shared/Views/Helpers/ShareSheet.swift +++ b/apps/ios/Shared/Views/Helpers/ShareSheet.swift @@ -7,6 +7,7 @@ // import SwiftUI +import SimpleXChat func getTopViewController() -> UIViewController? { let keyWindowScene = UIApplication.shared.connectedScenes.first { $0.activationState == .foregroundActive } as? UIWindowScene @@ -134,6 +135,7 @@ class OpenChatAlertViewController: UIViewController { private let profileName: String private let profileFullName: String private let profileImage: UIView + private let profileBadge: LocalBadge? private let subtitle: String? private let information: String? private let cancelTitle: String @@ -145,6 +147,7 @@ class OpenChatAlertViewController: UIViewController { profileName: String, profileFullName: String, profileImage: UIView, + profileBadge: LocalBadge? = nil, subtitle: String? = nil, information: String? = nil, cancelTitle: String = "Cancel", @@ -155,6 +158,7 @@ class OpenChatAlertViewController: UIViewController { self.profileName = profileName self.profileFullName = profileFullName self.profileImage = profileImage + self.profileBadge = profileBadge self.subtitle = subtitle self.information = information self.cancelTitle = cancelTitle @@ -190,12 +194,18 @@ class OpenChatAlertViewController: UIViewController { // Name label let nameLabel = UILabel() - nameLabel.text = profileName nameLabel.font = UIFont.preferredFont(forTextStyle: .headline) nameLabel.textColor = .label nameLabel.numberOfLines = 2 nameLabel.textAlignment = .center nameLabel.translatesAutoresizingMaskIntoConstraints = false + if let badge = nameBadgeAttachment(profileBadge, font: nameLabel.font) { + let s = NSMutableAttributedString(string: profileName) + s.append(badge) + nameLabel.attributedText = s + } else { + nameLabel.text = profileName + } var profileViews = [profileImage, nameLabel] @@ -365,6 +375,7 @@ func showOpenChatAlert( profileName: String, profileFullName: String, profileImage: Content, + profileBadge: LocalBadge? = nil, theme: AppTheme, subtitle: String? = nil, information: String? = nil, @@ -383,6 +394,7 @@ func showOpenChatAlert( profileName: profileName, profileFullName: profileFullName, profileImage: hostedView, + profileBadge: profileBadge, subtitle: subtitle, information: information, cancelTitle: cancelTitle, diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index 4a7e50d7d2..67fd353ebc 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -560,9 +560,11 @@ private struct ActiveProfilePicker: View { HStack { ProfileImage(imageStr: user.image, size: 30) .padding(.trailing, 2) - Text(user.chatViewName) - .foregroundColor(theme.colors.onBackground) - .lineLimit(1) + NameWithBadge( + Text(user.chatViewName).foregroundColor(theme.colors.onBackground), + user.profile.localBadge + ) + .lineLimit(1) Spacer() if selectedProfile == user, !incognitoEnabled { Image(systemName: "checkmark") @@ -1160,6 +1162,7 @@ private func showPrepareContactAlert( : "person.crop.circle.fill", size: alertProfileImageSize ), + profileBadge: contactShortLinkData.localBadge, theme: theme, information: ownerVerificationMessage(ownerVerification), cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"), @@ -1253,6 +1256,7 @@ private func showOpenKnownContactAlert( iconName: contact.chatIconName, size: alertProfileImageSize ), + profileBadge: contact.active ? contact.profile.localBadge : nil, theme: theme, cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"), confirmTitle: diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index a903329454..c1bc699261 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -526,12 +526,14 @@ func settingsRow(_ icon: String, color: Color/* = .secondary*/, struct ProfilePreview: View { var profileOf: NamedChat var color = Color(uiColor: .tertiarySystemGroupedBackground) + var badge: LocalBadge? = nil var body: some View { HStack { ProfileImage(imageStr: profileOf.image, size: 44, color: color) .padding(.trailing, 6) - profileName(profileOf).lineLimit(1) + NameWithBadge(profileName(profileOf), badge, .title2) + .lineLimit(1) } } } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 2728f031b3..e2915963e4 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -59,6 +59,7 @@ 5C7505A227B65FDB00BE3227 /* CIMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */; }; 5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */; }; 5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */; }; + CE11BADE0000000000000002 /* NameBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE11BADE0000000000000001 /* NameBadge.swift */; }; 5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E88279CBCB3000C6508 /* ChatModel.swift */; }; 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 5C8F01CC27A6F0D8007D2C8D /* CodeScanner */; }; 5C93292F29239A170090FFF9 /* ProtocolServersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C93292E29239A170090FFF9 /* ProtocolServersView.swift */; }; @@ -183,8 +184,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.4.1-Gci0HSXijHT9PyNdCZeAwi-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa.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 */; }; @@ -413,6 +414,7 @@ 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMetaView.swift; sourceTree = ""; }; 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavLinkPlain.swift; sourceTree = ""; }; 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoToolbar.swift; sourceTree = ""; }; + CE11BADE0000000000000001 /* NameBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameBadge.swift; sourceTree = ""; }; 5C764E88279CBCB3000C6508 /* ChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModel.swift; sourceTree = ""; }; 5C84FE9129A216C800D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; 5C84FE9329A2179C00D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = "nl.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; @@ -561,8 +563,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.4.1-Gci0HSXijHT9PyNdCZeAwi-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa.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 +733,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -818,8 +820,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa.a */, ); path = Libraries; sourceTree = ""; @@ -880,6 +882,7 @@ CEDB245A2C9CD71800FBC5F6 /* StickyScrollView.swift */, CEFB2EDE2CA1BCC7004B1ECE /* SheetRepresentable.swift */, CEA6E91B2CBD21B0002B5DB4 /* UserDefault.swift */, + CE11BADE0000000000000001 /* NameBadge.swift */, ); path = Helpers; sourceTree = ""; @@ -1559,6 +1562,7 @@ 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */, 3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */, 5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */, + CE11BADE0000000000000002 /* NameBadge.swift in Sources */, B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */, 5C10D88A28F187F300E58BF0 /* FullScreenMediaView.swift in Sources */, D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */, diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 5e0c302720..bfe25c6d42 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -136,6 +136,8 @@ public struct Profile: Codable, NamedChat, Hashable { public var contactLink: String? public var preferences: Preferences? public var peerType: ChatPeerType? + // the badge proof from the wire profile - opaque to the UI, round-tripped to the core (apiPrepareContact) + public var badge: BadgeProof? public var localAlias: String { get { "" } } var profileViewName: String { @@ -158,6 +160,7 @@ public struct LocalProfile: Codable, NamedChat, Hashable { contactLink: String? = nil, preferences: Preferences? = nil, peerType: ChatPeerType? = nil, + localBadge: LocalBadge? = nil, localAlias: String ) { self.profileId = profileId @@ -168,6 +171,7 @@ public struct LocalProfile: Codable, NamedChat, Hashable { self.contactLink = contactLink self.preferences = preferences self.peerType = peerType + self.localBadge = localBadge self.localAlias = localAlias } @@ -179,6 +183,7 @@ public struct LocalProfile: Codable, NamedChat, Hashable { public var contactLink: String? public var preferences: Preferences? public var peerType: ChatPeerType? + public var localBadge: LocalBadge? public var localAlias: String var profileViewName: String { @@ -201,6 +206,70 @@ public enum ChatPeerType: String, Codable { case bot } +// Supporter badge. The credential/proof bytes stay core-side; the UI only sees the disclosed type + status. +// Unknown types keep their string so a verified badge's real name can be shown, while the icon falls back to supporter. +public enum BadgeType: Hashable { + case supporter + case legend + case investor + case unknown(String) + + // the disclosed (signed) type name, shown to the user for verified badges + public var text: String { + switch self { + case .supporter: "supporter" + case .legend: "legend" + case .investor: "investor" + case let .unknown(s): s + } + } +} + +extension BadgeType: Codable { + public init(from decoder: Decoder) throws { + switch try decoder.singleValueContainer().decode(String.self) { + case "supporter": self = .supporter + case "legend": self = .legend + case "investor": self = .investor + case let s: self = .unknown(s) + } + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.singleValueContainer() + try c.encode(text) + } +} + +public enum BadgeStatus: String, Codable { + case active + case expired + // expired over a month ago - the badge is not shown at all + case expiredOld + case failed + // signed with a key index this app version does not know - shown as a warning + case unknownKey +} + +public struct BadgeInfo: Codable, Hashable { + public var badgeType: BadgeType + public var badgeExpiry: Date? + public var badgeExtra: String +} + +public struct LocalBadge: Codable, Hashable { + public var badge: BadgeInfo + public var status: BadgeStatus +} + +// the wire proof carried on a profile - opaque to the UI, only round-tripped back to the core (apiPrepareContact) +public struct BadgeProof: Codable, Hashable { + public var badgeKeyIdx: Int + public var presHeader: String + public var proof: String + public var badgeInfo: BadgeInfo +} + public func toLocalProfile (_ profileId: Int64, _ profile: Profile, _ localAlias: String) -> LocalProfile { LocalProfile( profileId: profileId, @@ -1457,6 +1526,17 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { } } + // the badge shown for a chat's name: an active contact's or a contact request's (groups have none) + public var nameBadge: LocalBadge? { + get { + switch self { + case let .direct(contact): return contact.active ? contact.profile.localBadge : nil + case let .contactRequest(contactRequest): return contactRequest.profile.localBadge + default: return nil + } + } + } + public var displayName: String { get { switch self { @@ -2263,7 +2343,7 @@ public struct UserContactRequest: Decodable, NamedChat, Hashable { public var userContactLinkId_: Int64? public var cReqChatVRange: VersionRange var localDisplayName: ContactName - var profile: Profile + public var profile: LocalProfile var createdAt: Date public var updatedAt: Date @@ -2281,7 +2361,7 @@ public struct UserContactRequest: Decodable, NamedChat, Hashable { userContactLinkId_: 1, cReqChatVRange: VersionRange(1, 1), localDisplayName: "alice", - profile: Profile.sampleData, + profile: LocalProfile.sampleData, createdAt: .now, updatedAt: .now ) @@ -2625,6 +2705,8 @@ public struct ContactShortLinkData: Codable, Hashable { public var profile: Profile public var message: MsgContent? public var business: Bool + // set by the core when building the connection plan: the link profile's badge, verified and crypto-free + public var localBadge: LocalBadge? } public struct GroupSummary: Decodable, Hashable { @@ -2781,6 +2863,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable { public var fullName: String { get { memberProfile.fullName } } public var image: String? { get { memberProfile.image } } public var contactLink: String? { get { memberProfile.contactLink } } + public var nameBadge: LocalBadge? { memberProfile.localBadge } public var verified: Bool { activeConn?.connectionCode != nil } public var blocked: Bool { blockedByAdmin || !memberSettings.showMessages } diff --git a/apps/ios/SimpleXChat/FileUtils.swift b/apps/ios/SimpleXChat/FileUtils.swift index 3d0dd663c1..c44bcee5bd 100644 --- a/apps/ios/SimpleXChat/FileUtils.swift +++ b/apps/ios/SimpleXChat/FileUtils.swift @@ -29,6 +29,10 @@ public let MAX_VIDEO_SIZE_AUTO_RCV: Int64 = 1_047_552 // 1023KB // Spec: spec/services/files.md#MAX_FILE_SIZE_XFTP public let MAX_FILE_SIZE_XFTP: Int64 = 1_073_741_824 // 1GB +// raised XFTP receive limits for files from a sender with a supporter badge (also investor) or a legend badge +public let MAX_FILE_SIZE_XFTP_SUPPORTER: Int64 = 2_147_483_648 // 2GB +public let MAX_FILE_SIZE_XFTP_LEGEND: Int64 = 5_368_709_120 // 5GB + public let MAX_FILE_SIZE_LOCAL: Int64 = Int64.max public let MAX_FILE_SIZE_SMP: Int64 = 8000000 @@ -273,11 +277,26 @@ public func cleanupFile(_ aChatItem: AChatItem) { } } -public func getMaxFileSize(_ fileProtocol: FileProtocol) -> Int64 { +public func getMaxFileSize(_ fileProtocol: FileProtocol, _ senderProfile: LocalProfile? = nil) -> Int64 { switch fileProtocol { - case .xftp: return MAX_FILE_SIZE_XFTP - case .smp: return MAX_FILE_SIZE_SMP - case .local: return MAX_FILE_SIZE_LOCAL + case .smp: MAX_FILE_SIZE_SMP + case .local: MAX_FILE_SIZE_LOCAL + // a sender's active badge raises the XFTP limit: legend to 5GB, any other (supporter/investor) to 2GB + case .xftp: + if let badge = senderProfile?.localBadge, badge.status == .active { + badge.badge.badgeType == .legend ? MAX_FILE_SIZE_XFTP_LEGEND : MAX_FILE_SIZE_XFTP_SUPPORTER + } else { + MAX_FILE_SIZE_XFTP + } + } +} + +// the profile of whoever sent a received chat item - the group member, or the direct chat's contact +public func ciSenderProfile(_ ci: ChatItem, _ chatInfo: ChatInfo) -> LocalProfile? { + switch (ci.chatDir, chatInfo) { + case let (.groupRcv(groupMember), _): return groupMember.memberProfile + case let (.directRcv, .direct(contact)): return contact.profile + default: return nil } } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.android.kt index a09ca2792b..9649e37c0f 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.android.kt @@ -95,8 +95,9 @@ fun UserPickerUserBox( } } val user = userInfo.user - Text( + NameWithBadge( user.displayName, + user.profile.localBadge, fontWeight = if (user.activeUser) FontWeight.Bold else FontWeight.Normal, maxLines = 1, overflow = TextOverflow.Ellipsis, 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 11c0f9e7f6..19b36067ed 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 @@ -1430,6 +1430,17 @@ data class Chat( @Serializable sealed class ChatInfo: SomeChat, NamedChat { + // the badge shown for a chat's name: an active contact's or a contact request's (groups have none). + // a badge that expired over a month ago (ExpiredOld) is not shown at all. + val nameBadge: LocalBadge? get() { + val badge = when { + this is Direct && contact.active -> contact.profile.localBadge + this is ContactRequest -> contactRequest.profile.localBadge + else -> null + } + return if (badge?.status == BadgeStatus.ExpiredOld) null else badge + } + @Serializable @SerialName("direct") data class Direct(val contact: Contact): ChatInfo() { override val chatType get() = ChatType.Direct @@ -1994,7 +2005,10 @@ data class Profile( override val localAlias : String = "", val contactLink: String? = null, val preferences: ChatPreferences? = null, - val peerType: ChatPeerType? = null + val peerType: ChatPeerType? = null, + // the badge proof from the wire profile: not interpreted by the UI (display uses crypto-free LocalBadge), + // but preserved so passing a link profile back to the core (apiPrepareContact) keeps the proof + val badge: BadgeProof? = null ): NamedChat { val profileViewName: String get() { @@ -2022,7 +2036,8 @@ data class LocalProfile( override val localAlias: String, val contactLink: String? = null, val preferences: ChatPreferences? = null, - val peerType: ChatPeerType? = null + val peerType: ChatPeerType? = null, + val localBadge: LocalBadge? = null ): NamedChat { val profileViewName: String = localAlias.ifEmpty { if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)" } @@ -2046,6 +2061,70 @@ enum class ChatPeerType { @SerialName("bot") Bot } +// Supporter badge. The credential/proof bytes stay core-side; the UI only sees the disclosed type + status. +// Unknown types keep their string so a verified badge's real name can be shown, while the icon falls back to supporter. +@Serializable(with = BadgeTypeSerializer::class) +sealed class BadgeType { + @Serializable @SerialName("supporter") object Supporter: BadgeType() + @Serializable @SerialName("legend") object Legend: BadgeType() + @Serializable @SerialName("investor") object Investor: BadgeType() + @Serializable @SerialName("unknown") data class Unknown(val type: String): BadgeType() + + // the disclosed (signed) type name, shown to the user for verified badges + val text: String + get() = when (this) { + is Supporter -> "supporter" + is Legend -> "legend" + is Investor -> "investor" + is Unknown -> type + } +} + +object BadgeTypeSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("BadgeType", PrimitiveKind.STRING) + override fun deserialize(decoder: Decoder): BadgeType = + when (val v = decoder.decodeString()) { + "supporter" -> BadgeType.Supporter + "legend" -> BadgeType.Legend + "investor" -> BadgeType.Investor + else -> BadgeType.Unknown(v) + } + override fun serialize(encoder: Encoder, value: BadgeType) = encoder.encodeString(value.text) +} + +@Serializable +enum class BadgeStatus { + @SerialName("active") Active, + @SerialName("expired") Expired, + // expired over a month ago - the badge is not shown at all + @SerialName("expiredOld") ExpiredOld, + @SerialName("failed") Failed, + // signed with a key index this app version does not know - shown as a warning + @SerialName("unknownKey") UnknownKey +} + +@Serializable +data class BadgeInfo( + val badgeType: BadgeType, + val badgeExpiry: Instant? = null, + val badgeExtra: String = "" +) + +@Serializable +data class LocalBadge( + val badge: BadgeInfo, + val status: BadgeStatus +) + +// the wire proof carried on a profile - opaque to the UI, only round-tripped back to the core (apiPrepareContact) +@Serializable +data class BadgeProof( + val badgeKeyIdx: Int, + val presHeader: String, + val proof: String, + val badgeInfo: BadgeInfo +) + @Serializable data class UserProfileUpdateSummary( val updateSuccesses: Int, @@ -2278,7 +2357,9 @@ enum class MemberCriteria { data class ContactShortLinkData ( val profile: Profile, val message: MsgContent?, - val business: Boolean + val business: Boolean, + // set by the core when building the connection plan: the link profile's badge, verified and crypto-free + val localBadge: LocalBadge? = null ) @Serializable @@ -2409,6 +2490,11 @@ data class GroupMember ( override val image: String? get() = memberProfile.image val contactLink: String? = memberProfile.contactLink val verified get() = activeConn?.connectionCode != null + // the badge shown for a member's name; a badge that expired over a month ago (ExpiredOld) is not shown + val nameBadge: LocalBadge? get() { + val badge = memberProfile.localBadge + return if (badge?.status == BadgeStatus.ExpiredOld) null else badge + } val blocked get() = blockedByAdmin || !memberSettings.showMessages override val localAlias: String = memberProfile.localAlias @@ -2727,7 +2813,7 @@ class UserContactRequest ( val contactRequestId: Long, val cReqChatVRange: VersionRange, override val localDisplayName: String, - val profile: Profile, + val profile: LocalProfile, override val createdAt: Instant, override val updatedAt: Instant ): SomeChat, NamedChat { @@ -2753,7 +2839,7 @@ class UserContactRequest ( contactRequestId = 1, cReqChatVRange = VersionRange(1, 1), localDisplayName = "alice", - profile = Profile.sampleData, + profile = LocalProfile.sampleData, createdAt = Clock.System.now(), updatedAt = Clock.System.now() ) 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..97101f253e 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 @@ -710,19 +710,32 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) { ) { ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight) val displayName = contact.profile.displayName.trim() + val badge = cInfo.nameBadge val text = buildAnnotatedString { if (contact.verified) { appendInlineContent(id = "shieldIcon") } append(displayName) - } - val inlineContent: Map = mapOf( - "shieldIcon" to InlineTextContent( - Placeholder(24.sp, 24.sp, PlaceholderVerticalAlign.TextCenter) - ) { - Icon(painterResource(MR.images.ic_verified_user), null, tint = MaterialTheme.colors.secondary) + if (badge != null) { + append(" ") + appendInlineContent(id = "nameBadge") } - ) + } + val nameFontSize = MaterialTheme.typography.h1.fontSize + val uriHandler = LocalUriHandler.current + val inlineContent: Map = buildMap { + put( + "shieldIcon", + InlineTextContent( + Placeholder(24.sp, 24.sp, PlaceholderVerticalAlign.TextCenter) + ) { + Icon(painterResource(MR.images.ic_verified_user), null, tint = MaterialTheme.colors.secondary) + } + ) + if (badge != null) { + put("nameBadge", nameBadgeInline(badge, nameFontSize) { showBadgeInfoAlert(displayName, badge, uriHandler) }) + } + } val clipboard = LocalClipboardManager.current val copyNameToClipboard = fun (name: String) { clipboard.setText(AnnotatedString(name)) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt index 9c36f4896b..affe5ce326 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt @@ -170,9 +170,10 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools @Composable fun ForwardedFromSender(forwardedFromItem: AChatItem) { @Composable - fun ItemText(text: String, fontStyle: FontStyle = FontStyle.Normal, color: Color = MaterialTheme.colors.onBackground) { - Text( + fun ItemText(text: String, fontStyle: FontStyle = FontStyle.Normal, color: Color = MaterialTheme.colors.onBackground, badge: LocalBadge? = null) { + NameWithBadge( text, + badge, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.body1, @@ -191,13 +192,13 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools if (forwardedFromItem.chatItem.chatDir.sent) { ItemText(text = stringResource(MR.strings.sender_you_pronoun), fontStyle = FontStyle.Italic) Spacer(Modifier.height(7.dp)) - ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.secondary) + ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.secondary, badge = forwardedFromItem.chatInfo.nameBadge) } else if (forwardedFromItem.chatItem.chatDir is CIDirection.GroupRcv) { - ItemText(text = forwardedFromItem.chatItem.chatDir.groupMember.chatViewName) + ItemText(text = forwardedFromItem.chatItem.chatDir.groupMember.chatViewName, badge = forwardedFromItem.chatItem.chatDir.groupMember.nameBadge) Spacer(Modifier.height(7.dp)) - ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.secondary) + ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.secondary, badge = forwardedFromItem.chatInfo.nameBadge) } else { - ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.onBackground) + ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.onBackground, badge = forwardedFromItem.chatInfo.nameBadge) } } } @@ -344,9 +345,10 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools ) { MemberProfileImage(size = 36.dp, member) Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON)) - Text( + NameWithBadge( member.chatViewName, - modifier = Modifier.weight(10f, fill = true), + member.nameBadge, + Modifier.weight(10f, fill = true), maxLines = 1, overflow = TextOverflow.Ellipsis ) 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..68e5ee3394 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 @@ -21,6 +21,7 @@ import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource +import androidx.compose.foundation.text.appendInlineContent import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.style.TextOverflow @@ -1564,8 +1565,8 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo if ((cInfo as? ChatInfo.Direct)?.contact?.verified == true) { ContactVerifiedShield() } - Text( - cInfo.displayName, fontWeight = FontWeight.SemiBold, + NameWithBadge( + cInfo.displayName, cInfo.nameBadge, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis ) } @@ -2016,8 +2017,10 @@ fun BoxScope.ChatItemsList( } else { null to 1 } - Text( + // the name and the badge are one element, so SpaceBetween separates them from the role, not from each other + NameWithBadge( memberNames(member, prevMember, memCount), + if (prevMember == null && memCount == 1) member.nameBadge else null, Modifier .padding(start = (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + DEFAULT_PADDING_HALF) .weight(1f, false), @@ -2284,8 +2287,18 @@ fun BoxScope.ChatItemsList( .background(MaterialTheme.appColors.receivedMessage) ) { ChatInfoImage(chatInfo, size = alertProfileImageSize, iconColor = MaterialTheme.colors.secondaryVariant.mixWith(MaterialTheme.colors.onBackground, 0.97f)) + val bannerBadge = chatInfo.nameBadge + val uriHandler = LocalUriHandler.current Text( - chatInfo.displayName, + buildAnnotatedString { + append(chatInfo.displayName) + if (bannerBadge != null) { + append(" ") + appendInlineContent(id = "nameBadge") + } + }, + inlineContent = + if (bannerBadge != null) mapOf("nameBadge" to nameBadgeInline(bannerBadge, MaterialTheme.typography.h3.fontSize) { showBadgeInfoAlert(chatInfo.displayName, bannerBadge, uriHandler) }) else emptyMap(), style = MaterialTheme.typography.h3, color = MaterialTheme.colors.onBackground, textAlign = TextAlign.Center, 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..6d598a166b 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 @@ -113,7 +113,9 @@ data class ComposeState( val inProgress: Boolean = false, val progressByTimeout: Boolean = false, val useLinkPreviews: Boolean, - val mentions: MentionedMembers = emptyMap() + val mentions: MentionedMembers = emptyMap(), + // the max file size the user may attach, raised by their active badge unless the chat is incognito; kept in sync on chat switch + val maxFileSize: Long = getMaxFileSize(FileProtocol.XFTP) ) { constructor(editingItem: ChatItem, liveMessage: LiveMessage? = null, useLinkPreviews: Boolean): this( ComposeMessage( @@ -251,8 +253,6 @@ data class ComposeState( } } -private val maxFileSize = getMaxFileSize(FileProtocol.XFTP) - sealed class RecordingState { object NotStarted: RecordingState() class Started(val filePath: String, val progressMs: Int = 0): RecordingState() @@ -300,6 +300,7 @@ fun MutableState.onFilesAttached(uris: List) { fun MutableState.processPickedFile(uri: URI?, text: String?) { if (uri != null) { + val maxFileSize = value.maxFileSize val fileSize = getFileSize(uri) if (fileSize != null && fileSize <= maxFileSize) { val fileName = getFileName(uri) @@ -318,6 +319,7 @@ fun MutableState.processPickedFile(uri: URI?, text: String?) { } suspend fun MutableState.processPickedMedia(uris: List, text: String?) { + val maxFileSize = value.maxFileSize val content = ArrayList() val imagesPreview = ArrayList() uris.forEach { uri -> @@ -487,7 +489,7 @@ fun ComposeView( if (live) { composeState.value = composeState.value.copy(inProgress = false, progressByTimeout = false) } else { - composeState.value = ComposeState(useLinkPreviews = useLinkPreviews) + composeState.value = ComposeState(useLinkPreviews = useLinkPreviews, maxFileSize = composeState.value.maxFileSize) resetLinkPreview() } recState.value = RecordingState.NotStarted @@ -1094,7 +1096,7 @@ fun ComposeView( if (composeState.value.contextItem != ComposeContextItem.NoContextItem || composeState.value.preview != ComposePreview.NoPreview) return val lastEditable = chatsCtx.chatItems.value.findLast { it.meta.editable } if (lastEditable != null) { - composeState.value = ComposeState(editingItem = lastEditable, useLinkPreviews = useLinkPreviews) + composeState.value = ComposeState(editingItem = lastEditable, useLinkPreviews = useLinkPreviews).copy(maxFileSize = composeState.value.maxFileSize) } } @@ -1322,6 +1324,11 @@ fun ComposeView( chatModel.removeLiveDummy() CIFile.cachedRemoteFileRequests.clear() } + // keep the attach size limit in sync with the chat: the user's active badge raises it, but not in incognito chats where no badge is presented + LaunchedEffect(chat.chatInfo) { + val incognito = if (chat.chatInfo.profileChangeProhibited) chat.chatInfo.incognito else chatModel.controller.appPrefs.incognito.get() + composeState.value = composeState.value.copy(maxFileSize = getMaxFileSize(FileProtocol.XFTP, if (incognito) null else chatModel.currentUser.value?.profile)) + } if (appPlatform.isDesktop) { // Don't enable this on Android, it breaks it, This method only works on desktop. For Android there is a `KeyChangeEffect(chatModel.chatId.value)` DisposableEffect(Unit) { 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..45d336be75 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 @@ -354,9 +354,10 @@ fun ContactCheckRow( ) { ProfileImage(size = 36.dp, contact.image) Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON)) - Text( + NameWithBadge( contact.chatViewName, - modifier = Modifier.weight(10f, fill = true), + if (contact.active) contact.profile.localBadge else null, + Modifier.weight(10f, fill = true), maxLines = 1, overflow = TextOverflow.Ellipsis, color = if (prohibitedToInviteIncognito) MaterialTheme.colors.secondary else Color.Unspecified 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..64f02d3376 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 @@ -94,8 +94,9 @@ private fun ChannelMemberRow(member: GroupMember, user: Boolean, showRole: Boole if (member.verified) { MemberVerifiedShield() } - Text( + NameWithBadge( member.chatViewName, + member.nameBadge, maxLines = 1, overflow = TextOverflow.Ellipsis, color = if (member.memberIncognito) Indigo else Color.Unspecified 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 7b9d6aa92e..770dfa64fb 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 @@ -1078,8 +1078,10 @@ fun MemberRow(member: GroupMember, user: Boolean = false, infoPage: Boolean = tr if (member.verified) { MemberVerifiedShield() } - Text( - if (showlocalAliasAndFullName) member.localAliasAndFullName else member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis, + NameWithBadge( + if (showlocalAliasAndFullName) member.localAliasAndFullName else member.chatViewName, + member.nameBadge, + maxLines = 1, overflow = TextOverflow.Ellipsis, color = if (member.memberIncognito) Indigo else Color.Unspecified ) } 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..fe45be92b7 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 @@ -18,6 +18,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -735,19 +736,32 @@ fun GroupMemberInfoHeader(member: GroupMember) { ) { MemberProfileImage(size = 192.dp, member, color = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight) val displayName = member.displayName.trim() // alias if set + val badge = member.nameBadge val text = buildAnnotatedString { if (member.verified) { appendInlineContent(id = "shieldIcon") } append(displayName) - } - val inlineContent: Map = mapOf( - "shieldIcon" to InlineTextContent( - Placeholder(24.sp, 24.sp, PlaceholderVerticalAlign.TextCenter) - ) { - Icon(painterResource(MR.images.ic_verified_user), null, tint = MaterialTheme.colors.secondary) + if (badge != null) { + append(" ") + appendInlineContent(id = "nameBadge") } - ) + } + val nameFontSize = MaterialTheme.typography.h1.fontSize + val uriHandler = LocalUriHandler.current + val inlineContent: Map = buildMap { + put( + "shieldIcon", + InlineTextContent( + Placeholder(24.sp, 24.sp, PlaceholderVerticalAlign.TextCenter) + ) { + Icon(painterResource(MR.images.ic_verified_user), null, tint = MaterialTheme.colors.secondary) + } + ) + if (badge != null) { + put("nameBadge", nameBadgeInline(badge, nameFontSize) { showBadgeInfoAlert(displayName, badge, uriHandler) }) + } + } val clipboard = LocalClipboardManager.current val copyNameToClipboard = fun(name: String) { clipboard.setText(AnnotatedString(name)) 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..3d3096b4f5 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 @@ -137,8 +137,8 @@ fun MemberSupportChatToolbarTitle(member: GroupMember, imageSize: Dp = 40.dp, ic if (member.verified) { MemberVerifiedShield() } - Text( - member.displayName, fontWeight = FontWeight.SemiBold, + NameWithBadge( + member.displayName, member.nameBadge, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt index 3d76c845ad..7ca277df94 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt @@ -234,8 +234,8 @@ fun SupportChatRow(member: GroupMember) { if (member.verified) { MemberVerifiedShield() } - Text( - member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis, + NameWithBadge( + member.chatViewName, member.nameBadge, maxLines = 1, overflow = TextOverflow.Ellipsis, color = if (member.memberIncognito) Indigo else Color.Unspecified ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index afd55ed928..02bee37c24 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -33,6 +33,7 @@ fun CIFileView( edited: Boolean, showMenu: MutableState, smallView: Boolean = false, + senderProfile: LocalProfile?, receiveFile: (Long) -> Unit ) { val saveFileLauncher = rememberSaveFileLauncher(ciFile = file) @@ -71,12 +72,12 @@ fun CIFileView( if (file != null) { when { file.fileStatus is CIFileStatus.RcvInvitation || file.fileStatus is CIFileStatus.RcvAborted -> { - if (fileSizeValid(file)) { + if (fileSizeValid(file, senderProfile)) { receiveFile(file.fileId) } else { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.large_file), - String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol))) + String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol, senderProfile))) ) } } @@ -151,7 +152,7 @@ fun CIFileView( is CIFileStatus.SndError -> fileIcon(innerIcon = painterResource(MR.images.ic_close)) is CIFileStatus.SndWarning -> fileIcon(innerIcon = painterResource(MR.images.ic_warning_filled)) is CIFileStatus.RcvInvitation -> - if (fileSizeValid(file)) + if (fileSizeValid(file, senderProfile)) fileIcon(innerIcon = painterResource(MR.images.ic_arrow_downward), color = MaterialTheme.colors.primary, topPadding = 10.sp.toDp()) else fileIcon(innerIcon = painterResource(MR.images.ic_priority_high), color = WarningOrange) @@ -225,7 +226,9 @@ fun CIFileView( } } -fun fileSizeValid(file: CIFile): Boolean = file.fileSize <= getMaxFileSize(file.fileProtocol) +// whether a received file is within the size we accept from its sender +fun fileSizeValid(file: CIFile, senderProfile: LocalProfile?): Boolean = + file.fileSize <= getMaxFileSize(file.fileProtocol, senderProfile) fun showFileErrorAlert(err: FileError, temporary: Boolean = false) { val title: String = generalGetString(if (temporary) MR.strings.temporary_file_error else MR.strings.file_error) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt index 8bfbea9fa6..ed9a0e6007 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt @@ -35,6 +35,7 @@ fun CIImageView( imageProvider: () -> ImageGalleryProvider, showMenu: MutableState, smallView: Boolean, + senderProfile: LocalProfile?, receiveFile: (Long) -> Unit ) { val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) } @@ -160,13 +161,6 @@ fun CIImageView( } } - fun fileSizeValid(): Boolean { - if (file != null) { - return file.fileSize <= getMaxFileSize(file.fileProtocol) - } - return false - } - suspend fun imageAndFilePath(file: CIFile?): Triple? { val res = getLoadedImage(file) if (res != null) { @@ -213,12 +207,12 @@ fun CIImageView( if (file != null) { when { file.fileStatus is CIFileStatus.RcvInvitation || file.fileStatus is CIFileStatus.RcvAborted -> - if (fileSizeValid()) { + if (fileSizeValid(file, senderProfile)) { receiveFile(file.fileId) } else { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.large_file), - String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol))) + String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol, senderProfile))) ) } file.fileStatus is CIFileStatus.RcvAccepted -> diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt index 8289149ad9..f8dfba4c6c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt @@ -34,6 +34,7 @@ fun CIVideoView( imageProvider: () -> ImageGalleryProvider, showMenu: MutableState, smallView: Boolean = false, + senderProfile: LocalProfile?, receiveFile: (Long) -> Unit ) { val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) } @@ -84,7 +85,7 @@ fun CIVideoView( if (file != null) { when (file.fileStatus) { CIFileStatus.RcvInvitation, CIFileStatus.RcvAborted -> - receiveFileIfValidSize(file, receiveFile) + receiveFileIfValidSize(file, senderProfile, receiveFile) CIFileStatus.RcvAccepted -> when (file.fileProtocol) { FileProtocol.XFTP -> @@ -114,7 +115,7 @@ fun CIVideoView( DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(duration * 1000L) }, remember { mutableStateOf(0L) }/*, soundEnabled*/) } if (showDownloadButton(file?.fileStatus) && !blurred.value && file != null) { - PlayButton(error = false, sizeMultiplier, { showMenu.value = true }) { receiveFileIfValidSize(file, receiveFile) } + PlayButton(error = false, sizeMultiplier, { showMenu.value = true }) { receiveFileIfValidSize(file, senderProfile, receiveFile) } } } } @@ -546,20 +547,13 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean) { private fun showDownloadButton(status: CIFileStatus?): Boolean = status is CIFileStatus.RcvInvitation || status is CIFileStatus.RcvAborted -private fun fileSizeValid(file: CIFile?): Boolean { - if (file != null) { - return file.fileSize <= getMaxFileSize(file.fileProtocol) - } - return false -} - -private fun receiveFileIfValidSize(file: CIFile, receiveFile: (Long) -> Unit) { - if (fileSizeValid(file)) { +private fun receiveFileIfValidSize(file: CIFile, senderProfile: LocalProfile?, receiveFile: (Long) -> Unit) { + if (fileSizeValid(file, senderProfile)) { receiveFile(file.fileId) } else { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.large_file), - String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol))) + String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol, senderProfile))) ) } } 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..2c04911e39 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 @@ -447,7 +447,7 @@ fun ChatItemView( } if (cItem.file != null && (getLoadedFilePath(cItem.file) != null || (chatModel.connectedToRemote() && cachedRemoteReqs[cItem.file.fileSource] != false && cItem.file.loaded))) { SaveContentItemAction(cItem, saveFileLauncher, showMenu) - } else if (cItem.file != null && cItem.file.fileStatus is CIFileStatus.RcvInvitation && fileSizeValid(cItem.file)) { + } else if (cItem.file != null && cItem.file.fileStatus is CIFileStatus.RcvInvitation && fileSizeValid(cItem.file, ciSenderProfile(cItem, chat.chatInfo))) { ItemAction(stringResource(MR.strings.download_file), painterResource(MR.images.ic_arrow_downward), onClick = { withBGApi { Log.d(TAG, "ChatItemView downloadFileAction") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index f55c49fdd1..5c07fe3abf 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -201,7 +201,7 @@ fun FramedItemView( @Composable fun ciFileView(ci: ChatItem, text: String) { - CIFileView(ci.file, ci.meta.itemEdited, showMenu, false, receiveFile) + CIFileView(ci.file, ci.meta.itemEdited, showMenu, false, ciSenderProfile(ci, chatInfo), receiveFile) if (text != "" || ci.meta.isLive) { CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode = linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } @@ -312,7 +312,7 @@ fun FramedItemView( } else { when (val mc = ci.content.msgContent) { is MsgContent.MCImage -> { - CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, false, receiveFile) + CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, false, ciSenderProfile(ci, chatInfo), receiveFile) if (mc.text == "" && !ci.meta.isLive) { metaColor = Color.White } else { @@ -320,7 +320,7 @@ fun FramedItemView( } } is MsgContent.MCVideo -> { - CIVideoView(image = mc.image, mc.duration, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, smallView = false, receiveFile = receiveFile) + CIVideoView(image = mc.image, mc.duration, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, smallView = false, senderProfile = ciSenderProfile(ci, chatInfo), receiveFile = receiveFile) if (mc.text == "" && !ci.meta.isLive) { metaColor = Color.White } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index d749865e10..2c7e443b4d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -152,7 +152,15 @@ fun ChatPreviewView( } else { Color.Unspecified } - chatPreviewTitleText(color = color) + NameWithBadge( + cInfo.chatViewName, + cInfo.nameBadge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.h3, + fontWeight = FontWeight.Bold, + color = color + ) } } is ChatInfo.Group -> { @@ -316,13 +324,13 @@ fun ChatPreviewView( } } is MsgContent.MCImage -> SmallContentPreview { - CIImageView(image = mc.image, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true) { + CIImageView(image = mc.image, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) { val user = chatModel.currentUser.value ?: return@CIImageView withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) } } } is MsgContent.MCVideo -> SmallContentPreview { - CIVideoView(image = mc.image, mc.duration, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true) { + CIVideoView(image = mc.image, mc.duration, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) { val user = chatModel.currentUser.value ?: return@CIVideoView withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) } } @@ -334,7 +342,7 @@ fun ChatPreviewView( } } is MsgContent.MCFile -> SmallContentPreviewFile { - CIFileView(ci.file, false, remember { mutableStateOf(false) }, smallView = true) { + CIFileView(ci.file, false, remember { mutableStateOf(false) }, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) { val user = chatModel.currentUser.value ?: return@CIFileView withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactRequestView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactRequestView.kt index 901761f65c..96e7fbacd0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactRequestView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactRequestView.kt @@ -27,8 +27,9 @@ fun ContactRequestView(contactRequest: ChatInfo.ContactRequest) { .padding(start = 8.dp, end = 8.sp.toDp()) .weight(1F) ) { - Text( + NameWithBadge( contactRequest.chatViewName, + contactRequest.nameBadge, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.h3, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt index 47668c4fb3..07058b5787 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt @@ -104,8 +104,8 @@ private fun SharePreviewView(chat: Chat, disabled: Boolean) { } else { ProfileImage(size = 42.dp, chat.chatInfo.image) } - Text( - chat.chatInfo.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis, + NameWithBadge( + chat.chatInfo.chatViewName, chat.chatInfo.nameBadge, maxLines = 1, overflow = TextOverflow.Ellipsis, color = if (disabled) MaterialTheme.colors.secondary else if (chat.chatInfo.incognito) Indigo else Color.Unspecified ) } 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..568cdfe574 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 @@ -210,7 +210,7 @@ fun UserPicker( } } else if (currentUser != null) { SectionItemView({ onUserClicked(currentUser) }, 80.dp, padding = PaddingValues(start = 16.dp, end = DEFAULT_PADDING), disabled = stopped) { - ProfilePreview(currentUser.profile, iconColor = iconColor, stopped = stopped) + ProfilePreview(currentUser.profile, iconColor = iconColor, stopped = stopped, badge = currentUser.profile.localBadge) } } } @@ -468,10 +468,11 @@ fun UserProfileRow(u: User, enabled: Boolean = remember { chatModel.chatRunning image = u.image, size = 54.dp * fontSizeSqrtMultiplier ) - Text( + // the end padding is on the row, not the name, so the badge stays right after the name + NameWithBadge( u.displayName, - modifier = Modifier - .padding(start = 10.dp, end = 8.dp), + u.profile.localBadge, + Modifier.padding(start = 10.dp, end = 8.dp), color = if (enabled) MenuTextColor else MaterialTheme.colors.secondary, fontWeight = if (u.activeUser) FontWeight.Medium else FontWeight.Normal ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt index 636887275c..524fbedb1c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt @@ -54,8 +54,9 @@ fun ContactPreviewView( if (cInfo.contact.verified) { VerifiedIcon() } - Text( + NameWithBadge( cInfo.chatViewName, + cInfo.nameBadge, maxLines = 1, overflow = TextOverflow.Ellipsis, color = textColor @@ -63,8 +64,9 @@ fun ContactPreviewView( } is ChatInfo.ContactRequest -> Row(verticalAlignment = Alignment.CenterVertically) { - Text( + NameWithBadge( cInfo.chatViewName, + cInfo.nameBadge, maxLines = 1, overflow = TextOverflow.Ellipsis, color = textColor 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..c855259ffb 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 @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.appendInlineContent import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material.* @@ -15,10 +16,12 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.* import chat.simplex.common.model.ChatModel +import chat.simplex.common.model.LocalBadge import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.res.MR @@ -272,6 +275,7 @@ class AlertManager { profileName: String, profileFullName: String, profileImage: @Composable () -> Unit, + profileBadge: LocalBadge? = null, subtitle: String? = null, information: String? = null, confirmText: String? = generalGetString(MR.strings.connect_plan_open_chat), @@ -299,8 +303,17 @@ class AlertManager { ) { profileImage() Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + val nameFontSize = MaterialTheme.typography.h4.fontSize Text( - profileName, + buildAnnotatedString { + append(profileName) + if (profileBadge != null) { + append(" ") + appendInlineContent(id = "nameBadge") + } + }, + inlineContent = + if (profileBadge != null) mapOf("nameBadge" to nameBadgeInline(profileBadge, nameFontSize)) else emptyMap(), textAlign = TextAlign.Center, style = MaterialTheme.typography.h4, lineHeight = 20.sp, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt index 5f3a73e7ea..d2ee1db09c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt @@ -3,10 +3,14 @@ package chat.simplex.common.views.helpers import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.shape.* import androidx.compose.material.Icon +import androidx.compose.material.LocalTextStyle import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -14,15 +18,28 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.* import androidx.compose.ui.graphics.* import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.UriHandler +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.model.BadgeStatus +import chat.simplex.common.model.BadgeType import chat.simplex.common.model.ChatInfo +import chat.simplex.common.model.LocalBadge +import chat.simplex.common.model.localDate import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource import kotlin.math.max +import kotlin.math.roundToInt @Composable fun ChatInfoImage(chatInfo: ChatInfo, size: Dp, iconColor: Color = MaterialTheme.colors.secondaryVariant, shadow: Boolean = false) { @@ -103,6 +120,137 @@ fun ProfileImage( } } +// badge height in em: calibrated visually so the badge top matches capital letters and digits +// (Inter's declared cap height is 2048/2816 = 0.727em, but the rendered text is taller than the metrics predict) +private const val fontCapHeightRatio = 0.95f + +// fraction of the badge height pushed below the text baseline (like the undershoot of round letters) +private const val badgeBaselineOffsetRatio = 0.05f + +// the badge glyph's width / height (the SVGs are cropped to the glyph: 300 x 399) +private const val badgeAspectRatio = 300f / 399f + +// A contact/member name with the badge right after it: the badge is baseline-aligned with the name +// and sized to its font (fontSize if given, otherwise style.fontSize), and a truncated name keeps it visible. +@Composable +fun NameWithBadge( + name: String, + badge: LocalBadge?, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + fontSize: TextUnit = TextUnit.Unspecified, + fontStyle: FontStyle? = null, + fontWeight: FontWeight? = null, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, + style: TextStyle = LocalTextStyle.current +) { + Row(modifier) { + Text( + name, + Modifier.alignByBaseline().weight(1f, fill = false), + color = color, + fontSize = fontSize, + fontStyle = fontStyle, + fontWeight = fontWeight, + overflow = overflow, + maxLines = maxLines, + style = style + ) + NameBadge(badge, if (fontSize.isSpecified) fontSize else style.fontSize) + } +} + +// Badge next to the contact name in a Row: top aligned with capital letters, bottom just below the text baseline. +// Use NameWithBadge unless the row needs special arrangement; then the name Text must use Modifier.alignByBaseline(). +@Composable +fun RowScope.NameBadge(badge: LocalBadge?, fontSize: TextUnit = LocalTextStyle.current.fontSize) { + // a badge that expired over a month ago (ExpiredOld) is not shown + if (badge == null || badge.status == BadgeStatus.ExpiredOld) return + val height = with(LocalDensity.current) { (if (fontSize.isSpecified) fontSize else 14.sp).toDp() } * fontCapHeightRatio + BadgeGlyph( + badge, + // the alignment line sits badgeBaselineOffsetRatio above the badge's bottom edge, + // so the Row places the badge that much below the text baseline; + // 6.dp matches the visible gap between the name and the verification shield: + // the shield has 3.dp end padding plus ~17% internal glyph margin, the badge artwork has none + Modifier.alignBy { (it.measuredHeight * (1 - badgeBaselineOffsetRatio)).roundToInt() }.padding(start = 6.dp).height(height).aspectRatio(badgeAspectRatio) + ) +} + +// badge inside a Text via appendInlineContent(id): bottom on the baseline, cap-height tall. +// precede with append(" ") for the space between the name and the badge. +fun nameBadgeInline(badge: LocalBadge, fontSize: TextUnit, onBadgeClick: (() -> Unit)? = null): InlineTextContent { + val height = fontSize * fontCapHeightRatio + return InlineTextContent( + Placeholder(height * badgeAspectRatio, height, PlaceholderVerticalAlign.AboveBaseline) + ) { + // the placeholder bottom sits on the baseline and can't extend below it, + // so the badge is drawn shifted down by badgeBaselineOffsetRatio instead + BadgeGlyph(badge, Modifier.fillMaxSize().graphicsLayer { translationY = size.height * badgeBaselineOffsetRatio }, onBadgeClick) + } +} + +@Composable +private fun BadgeGlyph(badge: LocalBadge, modifier: Modifier, onBadgeClick: (() -> Unit)? = null) { + val mod = modifier.let { if (onBadgeClick != null) it.clickable(onClick = onBadgeClick) else it } + if (badge.status == BadgeStatus.Failed || badge.status == BadgeStatus.UnknownKey) { + Icon(painterResource(MR.images.ic_warning_filled), contentDescription = null, tint = WarningOrange, modifier = mod) + } else { + Image( + painterResource(badgeImage(badge.badge.badgeType)), + contentDescription = null, + contentScale = ContentScale.Fit, + alpha = if (badge.status == BadgeStatus.Expired) 0.4f else 1f, + modifier = mod + ) + } +} + +fun showBadgeInfoAlert(name: String, badge: LocalBadge, uriHandler: UriHandler) { + // a verified badge's type is signed and can't be faked, so the real (possibly unknown) type name is the title + val title = badge.badge.badgeType.text.replaceFirstChar { it.uppercase() } + when { + badge.status == BadgeStatus.Failed -> + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.badge_unverified_title), + text = generalGetString(MR.strings.badge_unverified_desc) + ) + badge.status == BadgeStatus.UnknownKey -> + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.badge_unknown_key_title), + text = generalGetString(MR.strings.badge_unknown_key_desc) + ) + badge.badge.badgeType is BadgeType.Investor -> + AlertManager.shared.showAlertDialog( + title = title, + text = String.format(generalGetString(MR.strings.badge_invested), name), + confirmText = generalGetString(MR.strings.ok), + dismissText = generalGetString(MR.strings.learn_more), + onDismiss = { uriHandler.openUriCatching("https://simplex.chat/crowdfunding") } + ) + else -> { + // Supporter, Legend and unknown types use the supporter wording + val expiry = badge.badge.badgeExpiry + val supports = + if (badge.status == BadgeStatus.Expired && expiry != null) + String.format(generalGetString(MR.strings.badge_supported_simplex), name, localDate(expiry)) + else + String.format(generalGetString(MR.strings.badge_supports_simplex), name) + AlertManager.shared.showAlertMsg( + title = title, + text = supports + "\n\n" + generalGetString(MR.strings.badge_support_from_v7) + ) + } + } +} + +private fun badgeImage(t: BadgeType): ImageResource = when (t) { + is BadgeType.Legend -> MR.images.badge_legend + is BadgeType.Investor -> MR.images.badge_investor + else -> MR.images.badge_supporter // Supporter + Unknown +} + @Composable fun ProfileImage(size: Dp, image: ImageResource) { Image( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index 23c622bc34..86f2f13313 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -126,6 +126,10 @@ const val MAX_FILE_SIZE_SMP: Long = 8000000 const val MAX_FILE_SIZE_XFTP: Long = 1_073_741_824 // 1GB +// raised XFTP receive limits for files from a sender with a supporter badge (also investor) or a legend badge +const val MAX_FILE_SIZE_XFTP_SUPPORTER: Long = 2_147_483_648 // 2GB +const val MAX_FILE_SIZE_XFTP_LEGEND: Long = 5_368_709_120 // 5GB + const val MAX_FILE_SIZE_LOCAL: Long = Long.MAX_VALUE expect fun getAppFileUri(fileName: String): URI @@ -442,14 +446,25 @@ fun directoryFileCountAndSize(dir: String): Pair { // count, size in return fileCount to bytes } -fun getMaxFileSize(fileProtocol: FileProtocol): Long { - return when (fileProtocol) { - FileProtocol.XFTP -> MAX_FILE_SIZE_XFTP - FileProtocol.SMP -> MAX_FILE_SIZE_SMP - FileProtocol.LOCAL -> MAX_FILE_SIZE_LOCAL +fun getMaxFileSize(fileProtocol: FileProtocol, senderProfile: LocalProfile? = null): Long = when (fileProtocol) { + FileProtocol.SMP -> MAX_FILE_SIZE_SMP + FileProtocol.LOCAL -> MAX_FILE_SIZE_LOCAL + // a sender's active badge raises the XFTP limit: legend to 5GB, any other (supporter/investor) to 2GB + FileProtocol.XFTP -> { + val badge = senderProfile?.localBadge + if (badge == null || badge.status != BadgeStatus.Active) MAX_FILE_SIZE_XFTP + else if (badge.badge.badgeType == BadgeType.Legend) MAX_FILE_SIZE_XFTP_LEGEND + else MAX_FILE_SIZE_XFTP_SUPPORTER } } +// the profile of whoever sent a received chat item - the group member, or the direct chat's contact +fun ciSenderProfile(ci: ChatItem, chatInfo: ChatInfo): LocalProfile? = when (val dir = ci.chatDir) { + is CIDirection.GroupRcv -> dir.groupMember.memberProfile + is CIDirection.DirectRcv -> (chatInfo as? ChatInfo.Direct)?.contact?.profile + else -> null +} + expect suspend fun getBitmapFromVideo(uri: URI, timestamp: Long? = null, random: Boolean = true, withAlertOnException: Boolean = true): VideoPlayerInterface.PreviewAndDuration fun showWrongUriAlert() { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt index 9fd5dd5b4a..e5dbe01d68 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt @@ -474,6 +474,8 @@ private fun showOpenKnownContactAlert(chatModel: ChatModel, rhId: Long?, close: icon = contact.chatIconName ) }, + // the alert shows the badge inline, so it skips the long-expired (ExpiredOld) badge here too + profileBadge = if (contact.active && contact.profile.localBadge?.status != BadgeStatus.ExpiredOld) contact.profile.localBadge else null, confirmText = generalGetString(if (contact.nextConnectPrepared) MR.strings.connect_plan_open_new_chat else MR.strings.connect_plan_open_chat), onConfirm = { openKnownContact(chatModel, rhId, close, contact) @@ -633,6 +635,7 @@ fun showPrepareContactAlert( else MR.images.ic_account_circle_filled ) }, + profileBadge = if (contactShortLinkData.localBadge?.status == BadgeStatus.ExpiredOld) null else contactShortLinkData.localBadge, information = ownerVerificationMessage(ownerVerification), confirmText = generalGetString(MR.strings.connect_plan_open_new_chat), onConfirm = { 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 b1ab8eb24e..be16ced1f5 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 @@ -230,7 +230,8 @@ private fun ProfilePickerOption( disabled: Boolean, onSelected: () -> Unit, image: @Composable () -> Unit, - onInfo: (() -> Unit)? = null + onInfo: (() -> Unit)? = null, + badge: LocalBadge? = null ) { Row( Modifier @@ -243,7 +244,7 @@ private fun ProfilePickerOption( ) { image() TextIconSpaced(false) - Text(title, modifier = Modifier.align(Alignment.CenterVertically)) + NameWithBadge(title, badge, Modifier.align(Alignment.CenterVertically)) if (onInfo != null) { Spacer(Modifier.padding(6.dp)) Column(Modifier @@ -365,7 +366,8 @@ fun ActiveProfilePicker( } } }, - image = { ProfileImage(size = 42.dp, image = user.image) } + image = { ProfileImage(size = 42.dp, image = user.image) }, + badge = user.profile.localBadge ) } 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..22270ea5bb 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 @@ -309,12 +309,13 @@ fun AppVersionItem(showVersion: () -> Unit) { Text(appVersionInfo.first + (if (appVersionInfo.second != null) " (" + appVersionInfo.second + ")" else "")) } -@Composable fun ProfilePreview(profileOf: NamedChat, size: Dp = 60.dp, iconColor: Color = MaterialTheme.colors.secondaryVariant, textColor: Color = MaterialTheme.colors.onBackground, stopped: Boolean = false) { +@Composable fun ProfilePreview(profileOf: NamedChat, size: Dp = 60.dp, iconColor: Color = MaterialTheme.colors.secondaryVariant, textColor: Color = MaterialTheme.colors.onBackground, stopped: Boolean = false, badge: LocalBadge? = null) { ProfileImage(size = size, image = profileOf.image, color = iconColor) Spacer(Modifier.padding(horizontal = 8.dp)) Column(Modifier.height(size), verticalArrangement = Arrangement.Center) { - Text( + NameWithBadge( profileOf.displayName, + badge, style = MaterialTheme.typography.caption, fontWeight = FontWeight.Bold, color = if (stopped) MaterialTheme.colors.secondary else textColor, 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 cd0508f95a..ecca74fae2 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -3105,4 +3105,12 @@ SimpleX — %d unread Minimize to tray when closing window Keep SimpleX running in the background to receive messages. + %s supports SimpleX Chat. + %1$s supported SimpleX Chat. The badge expired on %2$s. + You can support SimpleX starting from v7 of the app. + %s invested in SimpleX Chat crowdfunding. + Unverified badge + This badge could not be verified and may not be genuine. + Badge cannot be verified + The badge is signed with a key that this version of the app does not recognize. Update the app to verify this badge. \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_investor.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_investor.svg new file mode 100644 index 0000000000..330da9b50d --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_investor.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_legend.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_legend.svg new file mode 100644 index 0000000000..7f892cd25c --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_legend.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_supporter.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_supporter.svg new file mode 100644 index 0000000000..9ebdc15c11 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_supporter.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.desktop.kt index 52e845b422..43428bab72 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.desktop.kt @@ -12,7 +12,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -67,15 +66,17 @@ actual fun UserPickerUsersSection( } } - Text( - user.displayName, - fontSize = 12.sp, - fontWeight = if (user.activeUser) FontWeight.Bold else FontWeight.Normal, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.width(65.dp), - textAlign = TextAlign.Center - ) + Row(Modifier.width(65.dp), horizontalArrangement = Arrangement.Center) { + Text( + user.displayName, + fontSize = 12.sp, + fontWeight = if (user.activeUser) FontWeight.Bold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.alignByBaseline().weight(1f, fill = false) + ) + NameBadge(user.profile.localBadge, 12.sp) + } } } } diff --git a/apps/simplex-chat/Main.hs b/apps/simplex-chat/Main.hs index 41321edc68..f0501fef4f 100644 --- a/apps/simplex-chat/Main.hs +++ b/apps/simplex-chat/Main.hs @@ -1,8 +1,14 @@ module Main where import Server (simplexChatServer) +import Simplex.Chat.Badges.CLI (runBadgeCommand) import Simplex.Chat.Terminal (terminalChatConfig) import Simplex.Chat.Terminal.Main (simplexChatCLI) +import System.Environment (getArgs) main :: IO () -main = simplexChatCLI terminalChatConfig (Just simplexChatServer) +main = do + args <- getArgs + case args of + ("badge" : _) -> runBadgeCommand args + _ -> simplexChatCLI terminalChatConfig (Just simplexChatServer) diff --git a/apps/simplex-directory-service/src/Directory/Store.hs b/apps/simplex-directory-service/src/Directory/Store.hs index 4036bd8cf1..89c5178f7d 100644 --- a/apps/simplex-directory-service/src/Directory/Store.hs +++ b/apps/simplex-directory-service/src/Directory/Store.hs @@ -313,43 +313,48 @@ getGroupReg_ db gId = getGroupAndReg :: ChatController -> User -> GroupId -> IO (Either String (GroupInfo, GroupReg)) getGroupAndReg cc user@User {userId, userContactId} gId = - withDB "getGroupAndReg" cc $ \db -> - ExceptT $ firstRow (toGroupInfoReg (storeCxt cc) user) ("group " ++ show gId ++ " not found") $ - DB.query db (groupReqQuery <> " AND g.group_id = ?") (userId, userContactId, gId) + withDB "getGroupAndReg" cc $ \db -> do + currentTs <- liftIO getCurrentTime + ExceptT $ firstRow (toGroupInfoReg currentTs (storeCxt cc) user) ("group " ++ show gId ++ " not found") $ + DB.query db (groupReqQuery <> " AND g.group_id = ?") (userId, userContactId, gId) getUserGroupReg :: ChatController -> User -> ContactId -> UserGroupRegId -> IO (Either String (GroupInfo, GroupReg)) getUserGroupReg cc user@User {userId, userContactId} ctId ugrId = - withDB "getUserGroupReg" cc $ \db -> - ExceptT $ firstRow (toGroupInfoReg (storeCxt cc) user) ("group " ++ show ugrId ++ " not found") $ + withDB "getUserGroupReg" cc $ \db -> do + currentTs <- liftIO getCurrentTime + ExceptT $ firstRow (toGroupInfoReg currentTs (storeCxt cc) user) ("group " ++ show ugrId ++ " not found") $ DB.query db (groupReqQuery <> " AND r.contact_id = ? AND r.user_group_reg_id = ?") (userId, userContactId, ctId, ugrId) getUserGroupRegs :: ChatController -> User -> ContactId -> IO (Either String [(GroupInfo, GroupReg)]) getUserGroupRegs cc user@User {userId, userContactId} ctId = - withDB' "getUserGroupRegs" cc $ \db -> - map (toGroupInfoReg (storeCxt cc) user) + withDB' "getUserGroupRegs" cc $ \db -> do + currentTs <- getCurrentTime + map (toGroupInfoReg currentTs (storeCxt cc) user) <$> DB.query db (groupReqQuery <> " AND r.contact_id = ? ORDER BY r.user_group_reg_id") (userId, userContactId, ctId) getAllListedGroups :: ChatController -> User -> IO (Either String [(GroupInfo, GroupReg, Maybe GroupLink)]) getAllListedGroups cc user = withDB' "getAllListedGroups" cc $ \db -> getAllListedGroups_ db (storeCxt cc) user getAllListedGroups_ :: DB.Connection -> StoreCxt -> User -> IO [(GroupInfo, GroupReg, Maybe GroupLink)] -getAllListedGroups_ db cxt user@User {userId, userContactId} = +getAllListedGroups_ db cxt user@User {userId, userContactId} = do + currentTs <- getCurrentTime DB.query db (groupReqQuery <> " AND r.group_reg_status = ?") (userId, userContactId, GRSActive) - >>= mapM (withGroupLink . toGroupInfoReg cxt user) + >>= mapM (withGroupLink . toGroupInfoReg currentTs cxt user) where withGroupLink (g, gr) = (g,gr,) . eitherToMaybe <$> runExceptT (getGroupLink db user g) searchListedGroups :: ChatController -> User -> SearchType -> Maybe GroupId -> Int -> IO (Either String ([(GroupInfo, GroupReg)], Int)) searchListedGroups cc user@User {userId, userContactId} searchType lastGroup_ pageSize = - withDB' "searchListedGroups" cc $ \db -> + withDB' "searchListedGroups" cc $ \db -> do + currentTs <- getCurrentTime case searchType of STAll -> case lastGroup_ of Nothing -> do - gs <- groups $ DB.query db (listedGroupQuery <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, pageSize) + gs <- groups currentTs $ DB.query db (listedGroupQuery <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, pageSize) n <- count $ DB.query db countQuery' (Only GRSActive) pure (gs, n) Just gId -> do - gs <- groups $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, pageSize) + gs <- groups currentTs $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, pageSize) n <- count $ DB.query db (countQuery' <> " AND r.group_id > ?") (GRSActive, gId) pure (gs, n) where @@ -357,11 +362,11 @@ searchListedGroups cc user@User {userId, userContactId} searchType lastGroup_ pa orderBy = " ORDER BY g.summary_current_members_count DESC, r.group_reg_id ASC " STRecent -> case lastGroup_ of Nothing -> do - gs <- groups $ DB.query db (listedGroupQuery <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, pageSize) + gs <- groups currentTs $ DB.query db (listedGroupQuery <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, pageSize) n <- count $ DB.query db countQuery' (Only GRSActive) pure (gs, n) Just gId -> do - gs <- groups $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, pageSize) + gs <- groups currentTs $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, pageSize) n <- count $ DB.query db (countQuery' <> " AND r.group_id > ?") (GRSActive, gId) pure (gs, n) where @@ -369,11 +374,11 @@ searchListedGroups cc user@User {userId, userContactId} searchType lastGroup_ pa orderBy = " ORDER BY r.created_at DESC, r.group_reg_id ASC " STSearch search -> case lastGroup_ of Nothing -> do - gs <- groups $ DB.query db (listedGroupQuery <> searchCond <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, s, s, s, s, pageSize) + gs <- groups currentTs $ DB.query db (listedGroupQuery <> searchCond <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, s, s, s, s, pageSize) n <- count $ DB.query db (countQuery' <> searchCond) (GRSActive, s, s, s, s) pure (gs, n) Just gId -> do - gs <- groups $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> searchCond <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, s, s, s, s, pageSize) + gs <- groups currentTs $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> searchCond <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, s, s, s, s, pageSize) n <- count $ DB.query db (countQuery' <> " AND r.group_id > ? " <> searchCond) (GRSActive, gId, s, s, s, s) pure (gs, n) where @@ -381,7 +386,7 @@ searchListedGroups cc user@User {userId, userContactId} searchType lastGroup_ pa countQuery' = countQuery <> " JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id WHERE r.group_reg_status = ? " orderBy = " ORDER BY g.summary_current_members_count DESC, r.group_reg_id ASC " where - groups = (map (toGroupInfoReg (storeCxt cc) user) <$>) + groups currentTs = (map (toGroupInfoReg currentTs (storeCxt cc) user) <$>) count = maybeFirstRow' 0 fromOnly listedGroupQuery = groupReqQuery <> " AND r.group_reg_status = ? " countQuery = "SELECT COUNT(1) FROM groups g JOIN sx_directory_group_regs r ON g.group_id = r.group_id " @@ -395,21 +400,24 @@ searchListedGroups cc user@User {userId, userContactId} searchType lastGroup_ pa |] getAllGroupRegs_ :: DB.Connection -> StoreCxt -> User -> IO [(GroupInfo, GroupReg)] -getAllGroupRegs_ db cxt user@User {userId, userContactId} = - map (toGroupInfoReg cxt user) +getAllGroupRegs_ db cxt user@User {userId, userContactId} = do + currentTs <- getCurrentTime + map (toGroupInfoReg currentTs cxt user) <$> DB.query db groupReqQuery (userId, userContactId) getDuplicateGroupRegs :: ChatController -> User -> Text -> IO (Either String [(GroupInfo, GroupReg)]) getDuplicateGroupRegs cc user@User {userId, userContactId} displayName = - withDB' "getDuplicateGroupRegs" cc $ \db -> - map (toGroupInfoReg (storeCxt cc) user) + withDB' "getDuplicateGroupRegs" cc $ \db -> do + currentTs <- getCurrentTime + map (toGroupInfoReg currentTs (storeCxt cc) user) <$> DB.query db (groupReqQuery <> " AND gp.display_name = ?") (userId, userContactId, displayName) listLastGroups :: ChatController -> User -> Int -> IO (Either String ([(GroupInfo, GroupReg)], Int)) listLastGroups cc user@User {userId, userContactId} count = withDB' "getUserGroupRegs" cc $ \db -> do + currentTs <- getCurrentTime gs <- - map (toGroupInfoReg (storeCxt cc) user) + map (toGroupInfoReg currentTs (storeCxt cc) user) <$> DB.query db (groupReqQuery <> " ORDER BY group_reg_id DESC LIMIT ?") (userId, userContactId, count) n <- maybeFirstRow' 0 fromOnly $ DB.query_ db "SELECT COUNT(1) FROM sx_directory_group_regs" pure (gs, n) @@ -417,15 +425,16 @@ listLastGroups cc user@User {userId, userContactId} count = listPendingGroups :: ChatController -> User -> Int -> IO (Either String ([(GroupInfo, GroupReg)], Int)) listPendingGroups cc user@User {userId, userContactId} count = withDB' "getUserGroupRegs" cc $ \db -> do + currentTs <- getCurrentTime gs <- - map (toGroupInfoReg (storeCxt cc) user) + map (toGroupInfoReg currentTs (storeCxt cc) user) <$> DB.query db (groupReqQuery <> " AND r.group_reg_status LIKE 'pending_approval%' ORDER BY group_reg_id DESC LIMIT ?") (userId, userContactId, count) n <- maybeFirstRow' 0 fromOnly $ DB.query_ db "SELECT COUNT(1) FROM sx_directory_group_regs WHERE group_reg_status LIKE 'pending_approval%'" pure (gs, n) -toGroupInfoReg :: StoreCxt -> User -> (GroupInfoRow :. GroupRegRow) -> (GroupInfo, GroupReg) -toGroupInfoReg cxt User {userContactId} (groupRow :. grRow) = - (toGroupInfo cxt userContactId [] groupRow, rowToGroupReg grRow) +toGroupInfoReg :: UTCTime -> StoreCxt -> User -> (GroupInfoRow :. GroupRegRow) -> (GroupInfo, GroupReg) +toGroupInfoReg currentTs cxt User {userContactId} (groupRow :. grRow) = + (toGroupInfo currentTs cxt userContactId [] groupRow, rowToGroupReg grRow) type GroupRegRow = (GroupId, UserGroupRegId, ContactId, Maybe GroupMemberId, GroupRegStatus, BoolInt, UTCTime) diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index a87bcae5e4..60cee67d78 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -10,6 +10,10 @@ This file is generated automatically. - [AgentCryptoError](#agentcryptoerror) - [AgentErrorType](#agenterrortype) - [AutoAccept](#autoaccept) +- [BadgeInfo](#badgeinfo) +- [BadgeProof](#badgeproof) +- [BadgeStatus](#badgestatus) +- [BadgeType](#badgetype) - [BlockingInfo](#blockinginfo) - [BlockingReason](#blockingreason) - [BrokerErrorType](#brokererrortype) @@ -122,6 +126,7 @@ This file is generated automatically. - [LinkContent](#linkcontent) - [LinkOwnerSig](#linkownersig) - [LinkPreview](#linkpreview) +- [LocalBadge](#localbadge) - [LocalProfile](#localprofile) - [MemberCriteria](#membercriteria) - [MsgChatLink](#msgchatlink) @@ -353,6 +358,49 @@ INACTIVE: - acceptIncognito: bool +--- + +## BadgeInfo + +**Record type**: +- badgeType: [BadgeType](#badgetype) +- badgeExpiry: UTCTime? +- badgeExtra: string + + +--- + +## BadgeProof + +**Record type**: +- badgeKeyIdx: int +- presHeader: string +- proof: string +- badgeInfo: [BadgeInfo](#badgeinfo) + + +--- + +## BadgeStatus + +**Enum type**: +- "active" +- "expired" +- "expiredOld" +- "failed" +- "unknownKey" + + +--- + +## BadgeType + +**Enum type**: +- "supporter" +- "legend" +- "investor" + + --- ## BlockingInfo @@ -1766,6 +1814,7 @@ ContactViaAddress: - profile: [Profile](#profile) - message: [MsgContent](#msgcontent)? - business: bool +- localBadge: [LocalBadge](#localbadge)? --- @@ -2672,6 +2721,15 @@ Unknown: - content: [LinkContent](#linkcontent)? +--- + +## LocalBadge + +**Record type**: +- badge: [BadgeInfo](#badgeinfo) +- status: [BadgeStatus](#badgestatus) + + --- ## LocalProfile @@ -2685,6 +2743,7 @@ Unknown: - contactLink: string? - preferences: [Preferences](#preferences)? - peerType: [ChatPeerType](#chatpeertype)? +- localBadge: [LocalBadge](#localbadge)? - localAlias: string @@ -3029,6 +3088,7 @@ count= - contactLink: string? - preferences: [Preferences](#preferences)? - peerType: [ChatPeerType](#chatpeertype)? +- badge: [BadgeProof](#badgeproof)? --- @@ -4213,7 +4273,7 @@ Handshake: - cReqChatVRange: [VersionRange](#versionrange) - localDisplayName: string - profileId: int64 -- profile: [Profile](#profile) +- profile: [LocalProfile](#localprofile) - createdAt: UTCTime - updatedAt: UTCTime - xContactId: string? diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 8894609758..1cd7c78913 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -202,6 +202,7 @@ cliCommands = "AbortSwitchGroupMember", "AcceptContact", "AcceptMember", + "AddBadge", "AddContact", "AddMember", "AllowRelayGroup", diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index 8397503bbe..7b268f4ec5 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -34,6 +34,7 @@ import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared import Simplex.Chat.Operators import Simplex.Messaging.Agent.Store.Entity (DBStored (..)) +import Simplex.Chat.Badges (BadgeInfo (..), BadgeProof (..), BadgeStatus (..), BadgeType (..), JSONBadge (..)) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared @@ -183,6 +184,7 @@ ciQuoteType = chatTypesDocsData :: [(SumTypeInfo, SumTypeJsonEncoding, String, [ConsName], Expr, Text)] chatTypesDocsData = [ ((sti @(Chat 'CTDirect)) {typeName = "AChat"}, STRecord, "", [], "", ""), + ((sti @JSONBadge) {typeName = "LocalBadge"}, STRecord, "", [], "", ""), ((sti @JSONChatInfo) {typeName = "ChatInfo"}, STUnion, "JCInfo", ["JCInfoInvalidJSON"], "", ""), ((sti @JSONCIContent) {typeName = "CIContent"}, STUnion, "JCI", ["JCIInvalidJSON"], "", ""), ((sti @JSONCIDeleted) {typeName = "CIDeleted"}, STUnion, "JCID", [], "", ""), @@ -207,6 +209,7 @@ chatTypesDocsData = (sti @AgentCryptoError, STUnion, "", ["RATCHET_EARLIER", "RATCHET_SKIPPED"], "", ""), -- TODO add fields to types (sti @AgentErrorType, STUnion, "", [], "", ""), (sti @AutoAccept, STRecord, "", [], "", ""), + (sti @BadgeProof, STRecord, "", [], "", ""), (sti @BlockingInfo, STRecord, "", [], "", ""), (sti @BlockingReason, STEnum, "BR", [], "", ""), (sti @BrokerErrorType, STUnion, "", [], "", ""), @@ -216,6 +219,8 @@ chatTypesDocsData = (sti @ChatDeleteMode, STUnion, "CDM", [], Param "type" <> Choice "self" [("messages", "")] (OnOffParam "notify" "notify" (Just True)), ""), (sti @ChatError, STUnion, "Chat", ["ChatErrorDatabase", "ChatErrorRemoteHost", "ChatErrorRemoteCtrl"], "", ""), (sti @ChatErrorType, STUnion, "CE", ["CEContactNotFound", "CEServerProtocol", "CECallState", "CEInvalidChatMessage"], "", ""), + (sti @BadgeStatus, STEnum, "BS", [], "", ""), + (sti @BadgeType, STEnum, "BT", ["BTUnknown"], "", ""), (sti @ChatFeature, STEnum, "CF", [], "", ""), (sti @ChatItemDeletion, STRecord, "", [], "", "Message deletion result."), (sti @ChatPeerType, STEnum, "CPT", [], "", ""), @@ -303,6 +308,7 @@ chatTypesDocsData = (sti @LinkContent, STUnion, "LC", [], "", ""), (sti @LinkOwnerSig, STRecord, "", [], "", ""), (sti @LinkPreview, STRecord, "", [], "", ""), + (sti @BadgeInfo, STRecord, "", [], "", ""), (sti @LocalProfile, STRecord, "", [], "", ""), (sti @MemberCriteria, STEnum1, "MC", [], "", ""), (sti @MsgChatLink, STUnion, "MCL", [], "", "Connection link sent in a message - only short links are allowed."), @@ -422,11 +428,14 @@ deriving instance Generic AddressSettings deriving instance Generic AgentCryptoError deriving instance Generic AgentErrorType deriving instance Generic AutoAccept +deriving instance Generic BadgeProof deriving instance Generic BlockingInfo deriving instance Generic BlockingReason deriving instance Generic BrokerErrorType deriving instance Generic BusinessChatInfo deriving instance Generic BusinessChatType +deriving instance Generic BadgeStatus +deriving instance Generic BadgeType deriving instance Generic ChatBotCommand deriving instance Generic ChatDeleteMode deriving instance Generic ChatError @@ -515,6 +524,7 @@ deriving instance Generic HandshakeError deriving instance Generic InlineFileMode deriving instance Generic InvitationLinkPlan deriving instance Generic InvitedBy +deriving instance Generic JSONBadge deriving instance Generic JSONChatInfo deriving instance Generic JSONCIContent deriving instance Generic JSONCIDeleted @@ -524,6 +534,7 @@ deriving instance Generic JSONCIStatus deriving instance Generic LinkContent deriving instance Generic LinkOwnerSig deriving instance Generic LinkPreview +deriving instance Generic BadgeInfo deriving instance Generic LocalProfile deriving instance Generic MemberCriteria deriving instance Generic MsgChatLink diff --git a/bots/src/API/TypeInfo.hs b/bots/src/API/TypeInfo.hs index 36e87db62d..8dfba2bbb0 100644 --- a/bots/src/API/TypeInfo.hs +++ b/bots/src/API/TypeInfo.hs @@ -198,7 +198,11 @@ toTypeInfo tr = "AgentInvId", "AgentRcvFileId", "AgentSndFileId", + "BadgeMasterKey", "B64UrlByteString", + "BBSProof", + "BBSPresHeader", + "BBSSignature", "CbNonce", "ConnectionLink", "ConnShortLink", diff --git a/cabal.project b/cabal.project index 3e32dfcd5e..d3b9eeffa5 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: b981dcb70b8c99dc3733a99b6c1dc0d0ef83a3f7 + tag: 9f9b6c8e88524fb5fd063f47617a679ea53ac7c0 source-repository-package type: git diff --git a/flake.nix b/flake.nix index 43f4e8912a..fdd041bd88 100644 --- a/flake.nix +++ b/flake.nix @@ -406,6 +406,8 @@ "chat_send_remote_cmd_retry" "chat_valid_name" "chat_json_length" + "chat_badge_keygen" + "chat_badge_issue" "chat_write_file" ]; postInstall = '' @@ -525,6 +527,8 @@ "chat_send_remote_cmd_retry" "chat_valid_name" "chat_json_length" + "chat_badge_keygen" + "chat_badge_issue" "chat_write_file" ]; postInstall = '' @@ -591,6 +595,7 @@ packages.simplex-chat.flags.swift = true; packages.simplexmq.flags.swift = true; packages.direct-sqlcipher.flags.commoncrypto = true; + packages.simplexmq.flags.commoncrypto = true; packages.entropy.flags.DoNotGetEntropy = true; packages.simplex-chat.flags.client_library = true; packages.simplexmq.flags.client_library = true; @@ -607,6 +612,7 @@ pkgs' = pkgs; extra-modules = [{ packages.direct-sqlcipher.flags.commoncrypto = true; + packages.simplexmq.flags.commoncrypto = true; packages.entropy.flags.DoNotGetEntropy = true; packages.simplex-chat.flags.client_library = true; packages.simplexmq.flags.client_library = true; @@ -626,6 +632,7 @@ packages.simplex-chat.flags.swift = true; packages.simplexmq.flags.swift = true; packages.direct-sqlcipher.flags.commoncrypto = true; + packages.simplexmq.flags.commoncrypto = true; packages.entropy.flags.DoNotGetEntropy = true; packages.simplex-chat.flags.client_library = true; packages.simplexmq.flags.client_library = true; @@ -641,6 +648,7 @@ pkgs' = pkgs; extra-modules = [{ packages.direct-sqlcipher.flags.commoncrypto = true; + packages.simplexmq.flags.commoncrypto = true; packages.entropy.flags.DoNotGetEntropy = true; packages.simplex-chat.flags.client_library = true; packages.simplexmq.flags.client_library = true; diff --git a/libsimplex.dll.def b/libsimplex.dll.def index 76e6f9f3ee..ec4125193f 100644 --- a/libsimplex.dll.def +++ b/libsimplex.dll.def @@ -16,6 +16,8 @@ EXPORTS chat_password_hash chat_valid_name chat_json_length + chat_badge_keygen + chat_badge_issue chat_encrypt_media chat_decrypt_media chat_write_file diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 5e671169de..883728f943 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -186,6 +186,33 @@ export interface AutoAccept { acceptIncognito: boolean } +export interface BadgeInfo { + badgeType: BadgeType + badgeExpiry?: string // ISO-8601 timestamp + badgeExtra: string +} + +export interface BadgeProof { + badgeKeyIdx: number // int + presHeader: string + proof: string + badgeInfo: BadgeInfo +} + +export enum BadgeStatus { + Active = "active", + Expired = "expired", + ExpiredOld = "expiredOld", + Failed = "failed", + UnknownKey = "unknownKey", +} + +export enum BadgeType { + Supporter = "supporter", + Legend = "legend", + Investor = "investor", +} + export interface BlockingInfo { reason: BlockingReason notice?: ClientNotice @@ -2044,6 +2071,7 @@ export interface ContactShortLinkData { profile: Profile message?: MsgContent business: boolean + localBadge?: LocalBadge } export enum ContactStatus { @@ -2940,6 +2968,11 @@ export interface LinkPreview { content?: LinkContent } +export interface LocalBadge { + badge: BadgeInfo + status: BadgeStatus +} + export interface LocalProfile { profileId: number // int64 displayName: string @@ -2949,6 +2982,7 @@ export interface LocalProfile { contactLink?: string preferences?: Preferences peerType?: ChatPeerType + localBadge?: LocalBadge localAlias: string } @@ -3299,6 +3333,7 @@ export interface Profile { contactLink?: string preferences?: Preferences peerType?: ChatPeerType + badge?: BadgeProof } export type ProxyClientError = @@ -4882,7 +4917,7 @@ export interface UserContactRequest { cReqChatVRange: VersionRange localDisplayName: string profileId: number // int64 - profile: Profile + profile: LocalProfile createdAt: string // ISO-8601 timestamp updatedAt: string // ISO-8601 timestamp xContactId?: string 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 66ba77c062..855a967215 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -138,6 +138,21 @@ AgentErrorType_Tag = Literal["CMD", "CONN", "NO_USER", "SMP", "NTF", "XFTP", "FI class AutoAccept(TypedDict): acceptIncognito: bool +class BadgeInfo(TypedDict): + badgeType: "BadgeType" + badgeExpiry: NotRequired[str] # ISO-8601 timestamp + badgeExtra: str + +class BadgeProof(TypedDict): + badgeKeyIdx: int # int + presHeader: str + proof: str + badgeInfo: "BadgeInfo" + +BadgeStatus = Literal["active", "expired", "expiredOld", "failed", "unknownKey"] + +BadgeType = Literal["supporter", "legend", "investor"] + class BlockingInfo(TypedDict): reason: "BlockingReason" notice: NotRequired["ClientNotice"] @@ -1441,6 +1456,7 @@ class ContactShortLinkData(TypedDict): profile: "Profile" message: NotRequired["MsgContent"] business: bool + localBadge: NotRequired["LocalBadge"] ContactStatus = Literal["active", "deleted", "deletedByUser"] @@ -2059,6 +2075,10 @@ class LinkPreview(TypedDict): image: str content: NotRequired["LinkContent"] +class LocalBadge(TypedDict): + badge: "BadgeInfo" + status: "BadgeStatus" + class LocalProfile(TypedDict): profileId: int # int64 displayName: str @@ -2068,6 +2088,7 @@ class LocalProfile(TypedDict): contactLink: NotRequired[str] preferences: NotRequired["Preferences"] peerType: NotRequired["ChatPeerType"] + localBadge: NotRequired["LocalBadge"] localAlias: str MemberCriteria = Literal["all"] @@ -2318,6 +2339,7 @@ class Profile(TypedDict): contactLink: NotRequired[str] preferences: NotRequired["Preferences"] peerType: NotRequired["ChatPeerType"] + badge: NotRequired["BadgeProof"] class ProxyClientError_protocolError(TypedDict): type: Literal["protocolError"] @@ -3431,7 +3453,7 @@ class UserContactRequest(TypedDict): cReqChatVRange: "VersionRange" localDisplayName: str profileId: int # int64 - profile: "Profile" + profile: "LocalProfile" createdAt: str # ISO-8601 timestamp updatedAt: str # ISO-8601 timestamp xContactId: NotRequired[str] diff --git a/plans/2026-06-01-supporter-badges-v1.md b/plans/2026-06-01-supporter-badges-v1.md new file mode 100644 index 0000000000..29a47a103e --- /dev/null +++ b/plans/2026-06-01-supporter-badges-v1.md @@ -0,0 +1,80 @@ +# Supporter Badges v1 - Verification + +Badge verification in stable so that v6.5 users can see and verify badges from v7 users. Badge purchase and issuance is v2. + +## Why BBS+ + +BBS+ signatures (IETF draft-irtf-cfrg-bbs-signatures) allow a holder of a signed credential to generate zero-knowledge proofs that selectively disclose some signed attributes while hiding others. Each proof uses a random nonce, making different proofs from the same credential computationally unlinkable - a verifier seeing two proofs cannot determine they came from the same credential. This means a supporter badge shown to different contacts cannot be correlated, preserving SimpleX's unlinkable identity model. + +The server that signs the credential sees the master secret during signing but cannot link any received proof back to any signing session - this is the core zero-knowledge property. + +## References + +- IETF draft: https://datatracker.ietf.org/doc/draft-irtf-cfrg-bbs-signatures/ +- libbbs: https://github.com/Fraunhofer-AISEC/libbbs (Apache-2.0, Fraunhofer-AISEC) +- blst: https://github.com/supranational/blst (Apache-2.0, audited by NCC Group) - internal dependency of libbbs for BLS12-381 curve operations + +Both are vendored verbatim into simplexmq so that users and maintainers can verify the source matches upstream. Only libbbs API is called directly. + +## Crypto + +3 signed messages: `[ms, expiry, level]`. `ms` undisclosed (index 0), `expiry` and `level` disclosed (indexes 1, 2). Proof size: 304 bytes (272 base + 32 per undisclosed). + +Server public key (`srvPK`, 96 bytes) hardcoded in app. + +## libbbs integration + +Vendor libbbs + blst C sources into simplexmq. Haskell FFI bindings following the SNTRUP761 pattern (`Simplex.Messaging.Crypto.BBS.Bindings`). + +Full FFI surface for testing the complete flow: + +- `bbs_keygen_full` - generate keypair +- `bbs_sign` - sign messages +- `bbs_proof_gen` - generate ZK proof with selective disclosure +- `bbs_proof_verify` - verify proof +- `bbs_sha256_ciphersuite` - ciphersuite constant + +Unit tests: keygen, sign, proof gen, proof verify roundtrip. Verify proof size. Verify rejection of tampered proofs. Verify two proofs from same credential don't correlate (different presentation headers produce different proofs that both verify). + +Use blst portable C fallback for now (avoids per-arch assembly). + +## Profile type + +Add optional `badge` field to `Profile`. The `SupporterBadge` type uses base64-encoded newtypes for binary fields, following the `KEMPublicKey`/`KEMCiphertext` pattern from SNTRUP761 bindings: + +```haskell +data SupporterBadge = SupporterBadge + { proof :: BBSProof + , proofNonce :: ByteString + , badgeExpiry :: UTCTime + , badgeType :: Text + } +``` + +`badgeType` is a string: `"supporter"`, `"business"`, `"legend"`, `"cf_investor"`. Displayed in UI as Supporter, Business, Legend, Crowdfunding Investor. `BBSProof` is a newtype over `ByteString` with `StrEncoding` instances for base64url JSON encoding. + +Backward compatible: `omitNothingFields` means older clients ignore it, newer clients without badge send `Nothing`. + +## DB + +- `badge` fields on `contact_profiles` and `group_member_profiles` to store received badge data +- `badge_status` column on `contacts` and `group_members` to store verification result +- `badge` fields on user profile (`users` or `contact_profiles` for own profile) for when badge issuance is added in v2 + +## Verification + +On receiving profile with `badge` (in Subscriber.hs, `XInfo`/`XGrpMemInfo`/`XContact` handlers): + +1. `bbs_proof_verify(srvPK, proof, "", proofNonce, disclosed=[1,2], [expiry, level])` +2. Check `expiry >= now` +3. Store badge + verification status on contact/member + +## UI + +Badge icon next to display name for verified contacts/members. Different icons per level string. Expired badges shown differently or hidden. + +## Not in v1 + +- Badge purchase, issuance, credential storage, proof generation - v2 +- Service framework - v2 +- Payment platform integration - v2 diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 3610906390..ac230b7af1 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."b981dcb70b8c99dc3733a99b6c1dc0d0ef83a3f7" = "0wpri01w30rd3wwzw630yngnj9fmyb7rschl3ic1cjd926vpg9b7"; + "https://github.com/simplex-chat/simplexmq.git"."9f9b6c8e88524fb5fd063f47617a679ea53ac7c0" = "01jdjndx0h2ardzi9dd21q0n36lvwbdkhp7nzdrz01c3hh0br9bd"; "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 f3612c88cf..3a1a8ff24b 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -38,6 +38,8 @@ library exposed-modules: Simplex.Chat Simplex.Chat.AppSettings + Simplex.Chat.Badges + Simplex.Chat.Badges.CLI Simplex.Chat.Call Simplex.Chat.Controller Simplex.Chat.Delivery @@ -51,6 +53,7 @@ library Simplex.Chat.Messages.CIContent Simplex.Chat.Messages.CIContent.Events Simplex.Chat.Mobile + Simplex.Chat.Mobile.Badges Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared Simplex.Chat.Mobile.WebRTC @@ -134,6 +137,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_public_group_access + Simplex.Chat.Store.Postgres.Migrations.M20260516_supporter_badges else exposed-modules: Simplex.Chat.Archive @@ -290,6 +294,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_public_group_access + Simplex.Chat.Store.SQLite.Migrations.M20260516_supporter_badges other-modules: Paths_simplex_chat hs-source-dirs: @@ -550,6 +555,7 @@ test-suite simplex-chat-test main-is: Test.hs other-modules: APIDocs + BadgeTests Bots.BroadcastTests Bots.DirectoryTests ChatClient diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index c3658a1c94..5adaaca150 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -29,6 +29,7 @@ import Data.Maybe (fromMaybe, mapMaybe) import Data.Text (Text) import Data.Time.Clock (getCurrentTime, nominalDay) import Simplex.Chat.Controller +import Simplex.Chat.Badges (BBSPublicKeyStr (..)) import Simplex.Chat.Library.Commands import Simplex.Chat.Operators import Simplex.Chat.Operators.Presets @@ -65,6 +66,17 @@ defaultChatConfig = tbqSize = 1024 }, chatVRange = supportedChatVRange, + badgePublicKeys = + M.fromList + [ (1, toBBSPublicKey "mW_5Zp1wHnXDF56wOZwFcRjGrf0GLLsfyymIQDqYoWfjfvS7oQWSfi7hH65N8JhuE9x8wbKXHidnQLO4GnOSMP_bRKUMH1qIzv5SQKFHNM8G4PaWcTcri8iZLc-3xhSI"), + (2, toBBSPublicKey "odGCB7uVDXTURsHgSvSciByV4Q3-3ZvEB8myDsDJqm-PwOYc5-At36uc7n_pyUDxEQEHr9i4RJgFih2FSArPW-EQBXNPNf4wTtA0znn74qLEGc4fh9pVYPEIm_ZGbnsJ"), + (3, toBBSPublicKey "txkT2003WMjc43KvYvPKEcR970NLmw5UZY51eUqgk91sgp53idt1HTlKYvnrEttJDFMlctYf1-bpri0e9DhBQ-xk1J4WoLN2uif_1OcA1pGCobpk9lwtsq1Idek4biy0"), + (4, toBBSPublicKey "q_YzegihaLYrEm9z3cAghsfDGNZfXuEpQGMJERJQS4M0Szl4gvSC_fV_muKc3NIMA_8iYuBN8qyvb5U55RctCRn3kleFQ4sqf-WBgoydX6UVo7BsYcUbXWWEFZXlOGIH"), + (5, toBBSPublicKey "oqymHASH_okefShrnz4HnTooUNlE1WoDRnSrgd0bTCpOacgJWBsMpwZpdmYlX-vQAKAC_zmI4VdKoOznnhW-sdUXZw6bthCi5JYjGxCR1Co27i1tix5UXCTbR5Jp901-"), + (6, toBBSPublicKey "kDqaB6zKSRp_97QPFj5JPDlo0vzfSTLSp9goFx1qajv4q4H6dR6BbkmWZ4xx_9Q2AxmcpqcV0ethz1OH-Jk_Sz2J1mIz1PUVM9LkdLhi_PNtqhezzO5dbVs-HJ1fNqe6"), + (7, toBBSPublicKey "rl36D5mg2N3NmmEybxE_RBeU9YZ_zeXNPfp7ZMLtUEuf2Mo4OQM_Up1v5rX_IqICD-AIJcuyptEBsELx_PJQzpmiNuG5I4cWO6HkRKtc6fVFvgZMrDJjaascPd1CIyxX"), + (8, toBBSPublicKey "joM3Bnt7JPt5JiwQwERHGjro2iVZ0mPD_clUh4hzkhxvbjuFrWuTmfSNA8PWBqGKEGNl13aRi1pMf6yY14E27c5C71JxWm7T-rZaBrGPEUWifhD-qidWuf3PU7KJCCWd") + ], confirmMigrations = MCConsole, -- this property should NOT use operator = Nothing -- non-operator servers can be passed via options diff --git a/src/Simplex/Chat/Badges.hs b/src/Simplex/Chat/Badges.hs new file mode 100644 index 0000000000..e861d27f11 --- /dev/null +++ b/src/Simplex/Chat/Badges.hs @@ -0,0 +1,414 @@ +{-# LANGUAGE CPP #-} +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DerivingStrategies #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE ExistentialQuantification #-} +{-# LANGUAGE FlexibleInstances #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE GeneralizedNewtypeDeriving #-} +{-# LANGUAGE KindSignatures #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE StandaloneDeriving #-} +{-# LANGUAGE TemplateHaskell #-} + +module Simplex.Chat.Badges + ( BadgeType (..), + BadgeStatus (..), + BadgeInfo (..), + BadgeCredential (..), + BadgeProof (..), + LocalBadge (..), + JSONBadge (..), + BBSPublicKeyStr (..), + localBadgeInfo, + localBadgeStatus, + maxXFTPFileSize, + maxFileSizeSupporter, + maxFileSizeLegend, + BadgePresHeaderTag (..), + BadgePresHeader (..), + BadgePurchase (..), + BadgeMasterKey (..), + BadgeRequest (..), + VerifiedBadgeRequest (..), + bbsBadgeHeader, + generateMasterKey, + verifyPayment, + issueBadge, + verifyCredential, + generateBadgeProof, + badgeProof, + verifyBadge, + verifyBadge_, + mkBadgeStatus, + BadgeRow, + badgeToRow, + localBadgeToRow, + rowToBadge, + ) where + +import Control.Concurrent.STM +import Crypto.Random (ChaChaDRG) +import Data.Aeson (FromJSON (..), ToJSON (..)) +import qualified Data.Aeson.TH as JQ +import qualified Data.Attoparsec.ByteString.Char8 as A +import Data.ByteString.Char8 (ByteString) +import qualified Data.ByteString.Char8 as B +import Data.Either (fromRight) +import Data.Int (Int64) +import Data.Map.Strict (Map) +import qualified Data.Map.Strict as M +import Data.String +import Data.Text (Text) +import Data.Text.Encoding (encodeUtf8) +import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, nominalDay) +import Simplex.FileTransfer.Description (gb, maxFileSize) +import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..), fromTextField_) +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.BBS +import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple.FromField (FromField (..)) +import Database.PostgreSQL.Simple.ToField (ToField (..)) +#else +import Database.SQLite.Simple.FromField (FromField (..)) +import Database.SQLite.Simple.ToField (ToField (..)) +#endif + +-- Badge type + +data BadgeType + = BTSupporter + | BTLegend + | BTInvestor + | BTUnknown Text + deriving (Eq, Show) + +instance TextEncoding BadgeType where + textEncode = \case + BTSupporter -> "supporter" + BTLegend -> "legend" + BTInvestor -> "investor" + BTUnknown tag -> tag + textDecode s = Just $ case s of + "supporter" -> BTSupporter + "legend" -> BTLegend + "investor" -> BTInvestor + tag -> BTUnknown tag + +instance ToJSON BadgeType where + toJSON = textToJSON + toEncoding = textToEncoding + +instance FromJSON BadgeType where + parseJSON = textParseJSON "BadgeType" + +-- Badge status + +data BadgeStatus = BSActive | BSExpired | BSExpiredOld | BSFailed | BSUnknownKey + deriving (Eq, Show) + +-- Disclosed badge content (BBS messages 1, 2, 3) + +data BadgeInfo = BadgeInfo + { badgeType :: BadgeType, + badgeExpiry :: Maybe UTCTime, + badgeExtra :: Text + } + deriving (Eq, Show) + +-- a badge expired longer than this ago is BSExpiredOld and is not shown in the UI +badgeOldInterval :: NominalDiffTime +badgeOldInterval = 31 * nominalDay + +-- the verification outcome of a received proof: Just True = verified, Just False = failed, +-- Nothing = the proof's key index is not among this app version's configured keys (BSUnknownKey). +mkBadgeStatus :: UTCTime -> Maybe Bool -> BadgeInfo -> BadgeStatus +mkBadgeStatus now verified BadgeInfo {badgeExpiry} = case verified of + Nothing -> BSUnknownKey + Just False -> BSFailed + Just True -> case badgeExpiry of + Just e + | addUTCTime badgeOldInterval e < now -> BSExpiredOld + | e < now -> BSExpired + _ -> BSActive + +-- A badge credential (own, secret) and a proof (a presentation) are independent records. +-- badgeKeyIdx is the issuer key index: it tells verifiers which configured key to use. +-- Only proofs ride the wire (in a profile); credentials come from the badge service. Neither is +-- ever serialized as a sum - each travels as its own record, so the JSON carries no credential/proof tag. + +data BadgeCredential = BadgeCredential + { badgeKeyIdx :: Int, + masterKey :: BadgeMasterKey, + signature :: BBSSignature, + badgeInfo :: BadgeInfo + } + deriving (Eq, Show) + +data BadgeProof = BadgeProof + { badgeKeyIdx :: Int, + presHeader :: BBSPresHeader, + proof :: BBSProof, + badgeInfo :: BadgeInfo + } + deriving (Eq, Show) + +-- Local badge: a stored badge plus its display status (the in-memory sum; never serialized as a sum). +-- OwnBadge - the user's own credential (loaded from the DB). +-- PeerBadge - a verified peer proof (from the DB, or received over the wire). +-- ShownBadge - decoded from a crypto-free profile JSON for display only: no crypto, so it cannot be sent. +data LocalBadge + = OwnBadge BadgeCredential BadgeStatus + | PeerBadge BadgeProof BadgeStatus + | ShownBadge BadgeInfo BadgeStatus + deriving (Eq, Show) + +localBadgeInfo :: LocalBadge -> BadgeInfo +localBadgeInfo = \case + OwnBadge BadgeCredential {badgeInfo} _ -> badgeInfo + PeerBadge BadgeProof {badgeInfo} _ -> badgeInfo + ShownBadge i _ -> i + +localBadgeStatus :: LocalBadge -> BadgeStatus +localBadgeStatus = \case + OwnBadge _ st -> st + PeerBadge _ st -> st + ShownBadge _ st -> st + +-- XFTP file size limit raised by an active badge: a legend badge to 5GB, any other to 2GB, otherwise the default. +maxFileSizeSupporter :: Int64 +maxFileSizeSupporter = gb 2 + +maxFileSizeLegend :: Int64 +maxFileSizeLegend = gb 5 + +maxXFTPFileSize :: Maybe LocalBadge -> Int64 +maxXFTPFileSize = \case + Just b | localBadgeStatus b == BSActive -> case badgeType (localBadgeInfo b) of + BTLegend -> maxFileSizeLegend + _ -> maxFileSizeSupporter + _ -> maxFileSize + +-- Presentation header: a tag char + payload. PHTest is unbound - a fresh random nonce per +-- presentation, not bound to any context; the 'T' tag marks it so master rejects it. +-- PHUnknown is the forward-compat catch-all for tags this version does not interpret. + +data BadgePresHeaderTag = PHTestTag | PHUnknownTag Char + +instance StrEncoding BadgePresHeaderTag where + strEncode = B.singleton . \case + PHTestTag -> 'T' + PHUnknownTag c -> c + strP = tag <$> A.anyChar + where + tag = \case + 'T' -> PHTestTag + c -> PHUnknownTag c + +data BadgePresHeader + = PHTest ByteString + | PHUnknown Char ByteString + +instance StrEncoding BadgePresHeader where + strEncode = \case + PHTest nonce -> strEncode PHTestTag <> nonce + PHUnknown c b -> strEncode (PHUnknownTag c) <> b + strP = + strP >>= \case + PHTestTag -> PHTest <$> A.takeByteString + PHUnknownTag c -> PHUnknown c <$> A.takeByteString + +-- v6.5.x accepts both; v7 will reject PHTest/PHUnknown +badgePresHeaderAccepted :: BadgePresHeader -> Bool +badgePresHeaderAccepted = \case + PHTest _ -> True + PHUnknown _ _ -> True + +-- Payment proof + +data BadgePurchase + = BPAppleReceipt Text + | BPGoogleReceipt Text + | BPStripeSession + | BPRedeemCode Text + deriving (Eq, Show) + +-- Master key + +newtype BadgeMasterKey = BadgeMasterKey ByteString + deriving newtype (Eq, Show, StrEncoding) + +instance ToJSON BadgeMasterKey where + toJSON = strToJSON + toEncoding = strToJEncoding + +instance FromJSON BadgeMasterKey where + parseJSON = strParseJSON "BadgeMasterKey" + +generateMasterKey :: TVar ChaChaDRG -> IO BadgeMasterKey +generateMasterKey drg = BadgeMasterKey <$> atomically (C.randomBytes 32 drg) + +-- Workflow types + +data BadgeRequest = BadgeRequest + { masterKey :: BadgeMasterKey, + badgeInfo :: BadgeInfo + } + deriving (Show) + +newtype VerifiedBadgeRequest = VerifiedBadgeRequest BadgeRequest + deriving (Show) + +-- Constants + +bbsBadgeHeader :: BBSHeader +bbsBadgeHeader = BBSHeader "SimpleX badges v1" + +bbsBadgeMessageCount :: Int +bbsBadgeMessageCount = 4 + +bbsBadgeDisclosedIndexes :: [Int] +bbsBadgeDisclosedIndexes = [1, 2, 3] + +-- Message encoding + +encodeExpiry :: Maybe UTCTime -> ByteString +encodeExpiry = maybe "lifetime" strEncode + +badgeMessages :: BadgeMasterKey -> BadgeInfo -> [ByteString] +badgeMessages (BadgeMasterKey ms) info = ms : badgeInfoMessages info + +badgeInfoMessages :: BadgeInfo -> [ByteString] +badgeInfoMessages BadgeInfo {badgeType, badgeExpiry, badgeExtra} = + [encodeExpiry badgeExpiry, encodeUtf8 (textEncode badgeType), encodeUtf8 badgeExtra] + +-- Payment verification (stub - always passes) + +verifyPayment :: BadgePurchase -> BadgeRequest -> IO (Maybe VerifiedBadgeRequest) +verifyPayment _payment req = pure $ Just (VerifiedBadgeRequest req) + +-- Server-side: issue a badge credential, recording which issuer key signed it + +issueBadge :: Int -> BBSSecretKey -> VerifiedBadgeRequest -> IO (Either String BadgeCredential) +issueBadge keyIdx sk (VerifiedBadgeRequest BadgeRequest {masterKey, badgeInfo}) + | badgeExtra badgeInfo /= "" = pure $ Left "badgeExtra must be empty (reserved)" + | otherwise = fmap (\sig -> BadgeCredential keyIdx masterKey sig badgeInfo) <$> bbsSign sk bbsBadgeHeader (badgeMessages masterKey badgeInfo) + +-- Client-side: verify the credential received from server + +verifyCredential :: BBSPublicKey -> BadgeCredential -> IO Bool +verifyCredential pk (BadgeCredential _ masterKey signature badgeInfo) = + bbsVerify pk signature bbsBadgeHeader (badgeMessages masterKey badgeInfo) + +-- Client-side: generate a proof for a contact/group; the proof carries the credential's key index + +generateBadgeProof :: BBSPublicKey -> BadgeCredential -> BBSPresHeader -> IO (Either String BadgeProof) +generateBadgeProof pk (BadgeCredential keyIdx masterKey signature badgeInfo) ph = + fmap (\p -> BadgeProof keyIdx ph p badgeInfo) <$> bbsProofGen pk signature bbsBadgeHeader ph bbsBadgeDisclosedIndexes (badgeMessages masterKey badgeInfo) + +-- application-level proof generation with a semantic presentation header +badgeProof :: BBSPublicKey -> BadgeCredential -> BadgePresHeader -> IO (Either String BadgeProof) +badgeProof pk cred ph = generateBadgeProof pk cred (BBSPresHeader $ strEncode ph) + +-- Recipient-side: verify a badge proof with the configured key its index points to. +-- Nothing means the key index is not in the configured keys (this app version can't verify it). + +verifyBadge :: Map Int BBSPublicKey -> BadgeProof -> IO (Maybe Bool) +verifyBadge keys b@(BadgeProof keyIdx _ _ _) = case M.lookup keyIdx keys of + Nothing -> pure Nothing + Just pk -> Just <$> verifyBadgeWith pk b + +verifyBadgeWith :: BBSPublicKey -> BadgeProof -> IO Bool +verifyBadgeWith pk (BadgeProof _ ph@(BBSPresHeader phBytes) proof badgeInfo) + | either (const False) badgePresHeaderAccepted (strDecode phBytes) = + bbsProofVerify pk proof bbsBadgeHeader ph bbsBadgeDisclosedIndexes bbsBadgeMessageCount (badgeInfoMessages badgeInfo) + | otherwise = pure False + +verifyBadge_ :: Map Int BBSPublicKey -> Maybe BadgeProof -> IO (Maybe Bool) +verifyBadge_ keys = maybe (pure (Just False)) (verifyBadge keys) + +-- DB + +instance FromField BadgeType where fromField = fromTextField_ textDecode + +instance ToField BadgeType where toField = toField . textEncode + +-- (proof, pres_header, expiry, type, verified, extra, master_key, signature, key_idx) - binary columns wrapped in Binary (BLOB/bytea) +type BadgeRow = (Maybe (Binary ByteString), Maybe (Binary ByteString), Maybe UTCTime, Maybe Text, Maybe BoolInt, Maybe Text, Maybe (Binary ByteString), Maybe (Binary ByteString), Maybe Int) + +-- receive/store sites have a wire proof + a computed verification outcome; +-- the status here only drives the stored verified flag, the display status is recomputed on load +badgeToRow :: Maybe BadgeProof -> Maybe Bool -> BadgeRow +badgeToRow badge verified = localBadgeToRow $ (`PeerBadge` st) <$> badge + where + st = case verified of + Just True -> BSActive + Just False -> BSFailed + Nothing -> BSUnknownKey + +localBadgeToRow :: Maybe LocalBadge -> BadgeRow +localBadgeToRow (Just lb) = case lb of + OwnBadge (BadgeCredential idx (BadgeMasterKey mk) (BBSSignature sg) BadgeInfo {badgeType, badgeExpiry, badgeExtra}) st -> + (Nothing, Nothing, badgeExpiry, Just (textEncode badgeType), verifiedField st, Just badgeExtra, Just (Binary mk), Just (Binary sg), Just idx) + PeerBadge (BadgeProof idx (BBSPresHeader ph) (BBSProof p) BadgeInfo {badgeType, badgeExpiry, badgeExtra}) st -> + (Just (Binary p), Just (Binary ph), badgeExpiry, Just (textEncode badgeType), verifiedField st, Just badgeExtra, Nothing, Nothing, Just idx) + ShownBadge BadgeInfo {badgeType, badgeExpiry, badgeExtra} st -> + (Nothing, Nothing, badgeExpiry, Just (textEncode badgeType), verifiedField st, Just badgeExtra, Nothing, Nothing, Nothing) + where + verifiedField st = case st of + BSFailed -> Just (BI False) + BSUnknownKey -> Nothing + _ -> Just (BI True) +localBadgeToRow Nothing = (Nothing, Nothing, Nothing, Nothing, Just (BI False), Nothing, Nothing, Nothing, Nothing) + +rowToBadge :: UTCTime -> BadgeRow -> Maybe LocalBadge +rowToBadge now (p_, ph_, badgeExpiry, type_, verified_, extra_, mk_, sg_, idx_) = do + btText <- type_ + bt <- textDecode btText + let info = BadgeInfo {badgeType = bt, badgeExpiry, badgeExtra = maybe "" id extra_} + -- NULL badge_verified means the key index was unknown when stored (Nothing) + st = mkBadgeStatus now (unBI <$> verified_) info + case (mk_, sg_, p_, ph_, idx_) of + (Just (Binary mk), Just (Binary sg), _, _, Just idx) -> Just $ OwnBadge (BadgeCredential idx (BadgeMasterKey mk) (BBSSignature sg) info) st + (_, _, Just (Binary p), Just (Binary ph), Just idx) -> Just $ PeerBadge (BadgeProof idx (BBSPresHeader ph) (BBSProof p) info) st + _ -> Just $ ShownBadge info st + +-- JSON + +$(JQ.deriveJSON (enumJSON $ dropPrefix "BS") ''BadgeStatus) + +$(JQ.deriveJSON defaultJSON ''BadgeInfo) + +$(JQ.deriveJSON defaultJSON ''BadgeRequest) + +-- Each record is a plain JSON object (defaultJSON), platform-independent and with no credential/proof +-- tag - the context (a proof in a profile, a credential from the service) determines which it is. + +$(JQ.deriveJSON defaultJSON ''BadgeCredential) + +$(JQ.deriveJSON defaultJSON ''BadgeProof) + +-- LocalBadge is sent to the UI/clients WITHOUT crypto - only disclosed info + status. The credential/proof +-- bytes stay core-side. FromJSON reconstructs a display-only badge (empty proof) for read-only consumers +-- (remote host, UI echoes); the authoritative badge is loaded from the DB (rowToBadge), never from this JSON. +data JSONBadge = JSONBadge {badge :: BadgeInfo, status :: BadgeStatus} + +$(JQ.deriveJSON defaultJSON ''JSONBadge) + +instance ToJSON LocalBadge where + toJSON lb = toJSON $ JSONBadge (localBadgeInfo lb) (localBadgeStatus lb) + toEncoding lb = toEncoding $ JSONBadge (localBadgeInfo lb) (localBadgeStatus lb) + +instance FromJSON LocalBadge where + parseJSON v = do + JSONBadge info st <- parseJSON v + pure $ ShownBadge info st + +newtype BBSPublicKeyStr = BBSPublicKeyStr {toBBSPublicKey :: BBSPublicKey} + +instance IsString BBSPublicKeyStr where + fromString = BBSPublicKeyStr . fromRight (error "bad base64 in BBSPublicKey") . strDecode . B.pack diff --git a/src/Simplex/Chat/Badges/CLI.hs b/src/Simplex/Chat/Badges/CLI.hs new file mode 100644 index 0000000000..8a7cd84b61 --- /dev/null +++ b/src/Simplex/Chat/Badges/CLI.hs @@ -0,0 +1,87 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +-- | Offline operator tooling for supporter badges, invoked as `simplex-chat badge ...`. +-- keygen - the issuer keypair: the "secret" signs, the "public" goes into the app config. +-- master-key - the user's master secret (their unlinkability secret; generated client-side in the real flow). +-- sign - bind a user master secret to a badge with the issuer secret, printed as one-line JSON for `/badge add`. +module Simplex.Chat.Badges.CLI (runBadgeCommand) where + +import qualified Data.Aeson as J +import qualified Data.ByteString.Char8 as B +import qualified Data.ByteString.Lazy.Char8 as LB +import qualified Data.Text as T +import Data.Time.Clock (UTCTime) +import Data.Time.Format (defaultTimeLocale, parseTimeM) +import Options.Applicative +import Simplex.Chat.Badges +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.BBS (BBSPublicKey (..), BBSSecretKey (..), bbsKeyGen) +import Simplex.Messaging.Encoding.String (strDecode, strEncode, textDecode) +import System.Exit (die) + +bbsSecretLen :: Int +bbsSecretLen = 32 + +data BadgeCommand + = Keygen + | MasterKey + | Sign Int BBSSecretKey BadgeMasterKey BadgeType (Maybe UTCTime) + +runBadgeCommand :: [String] -> IO () +runBadgeCommand args = + handleParseResult (execParserPure defaultPrefs badgeInfo args) >>= \case + Keygen -> keygen + MasterKey -> genMasterKey + Sign keyIdx sk ms badgeType badgeExpiry -> sign keyIdx sk ms badgeType badgeExpiry + where + badgeInfo = info (helper <*> hsubparser badgeCmd) fullDesc + badgeCmd = command "badge" (info (helper <*> badgeCommandP) (progDesc "SimpleX supporter badge tooling")) + +badgeCommandP :: Parser BadgeCommand +badgeCommandP = + hsubparser $ + command "keygen" (info (pure Keygen) (progDesc "generate an issuer keypair (issuer secret + public, base64url)")) + <> command "master-key" (info (pure MasterKey) (progDesc "generate a user master secret (base64url)")) + <> command "sign" (info signP (progDesc "sign a badge for a user master secret, printed as one-line JSON")) + where + signP = + Sign + <$> option auto (long "key-idx" <> metavar "KEY_IDX" <> help "index of the issuer key in the app config") + <*> option (eitherReader secretR) (long "secret" <> metavar "ISSUER_SECRET" <> help "issuer secret from keygen (base64url)") + <*> option (eitherReader (strDecode . B.pack)) (long "master" <> metavar "MASTER" <> help "user master secret from master-key (base64url)") + <*> option (eitherReader badgeTypeR) (long "type" <> metavar "TYPE" <> help "badge type (supporter, legend, investor)") + <*> option (eitherReader expireR) (long "expire" <> metavar "lifetime|YYYY-MM-DD" <> help "expiry date, or 'lifetime'") + secretR s = do + sk@(BBSSecretKey b) <- strDecode (B.pack s) + if B.length b == bbsSecretLen + then Right sk + else Left "bad issuer secret - use the 'secret' value from keygen" + badgeTypeR = maybe (Left "invalid badge type") Right . textDecode . T.pack + expireR = \case + "lifetime" -> Right Nothing + s -> maybe (Left "use 'lifetime' or YYYY-MM-DD") (Right . Just) $ parseTimeM True defaultTimeLocale "%Y-%m-%d" s + +keygen :: IO () +keygen = + bbsKeyGen >>= \case + Left e -> die $ "keygen failed: " <> e + Right (BBSPublicKey pk, BBSSecretKey sk) -> do + B.putStrLn $ "secret " <> strEncode sk + B.putStrLn $ "public " <> strEncode pk + +genMasterKey :: IO () +genMasterKey = do + drg <- C.newRandom + mk <- generateMasterKey drg + B.putStrLn $ strEncode mk + +sign :: Int -> BBSSecretKey -> BadgeMasterKey -> BadgeType -> Maybe UTCTime -> IO () +sign keyIdx secretKey masterKey badgeType badgeExpiry = do + let req = VerifiedBadgeRequest (BadgeRequest {masterKey, badgeInfo = BadgeInfo {badgeType, badgeExpiry, badgeExtra = ""}} :: BadgeRequest) + issueBadge keyIdx secretKey req >>= \case + Left e -> die $ "sign failed: " <> e + -- single-line JSON (master secret + signature + info), pasted into the app via `/badge add` + Right cred -> LB.putStrLn $ J.encode cred diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index c92c1f9e09..fbb8536fcf 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -81,6 +81,8 @@ import Simplex.Messaging.Agent.Store.DB (SQLError) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Client (HostMode (..), SMPProxyFallback (..), SMPProxyMode (..), SMPWebPortServers (..), SocksMode (..)) import qualified Simplex.Messaging.Crypto as C +import Simplex.Chat.Badges (BadgeCredential) +import Simplex.Messaging.Crypto.BBS (BBSPublicKey) import Simplex.Messaging.Crypto.File (CryptoFile (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Crypto.Ratchet (PQEncryption) @@ -137,6 +139,8 @@ coreVersionInfo simplexmqCommit = data ChatConfig = ChatConfig { agentConfig :: AgentConfig, chatVRange :: VersionRangeChat, + -- issuer public keys by index: credentials and proofs name the key that signed them, for rotation + badgePublicKeys :: Map Int BBSPublicKey, confirmMigrations :: MigrationConfirmation, presetServers :: PresetServers, shortLinkPresetServers :: NonEmpty SMPServer, @@ -172,7 +176,7 @@ data ChatConfig = ChatConfig -- | 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 -mkStoreCxt ChatConfig {chatVRange} = StoreCxt chatVRange +mkStoreCxt ChatConfig {chatVRange, badgePublicKeys} = StoreCxt chatVRange badgePublicKeys {-# INLINE mkStoreCxt #-} data RandomAgentServers = RandomAgentServers @@ -575,6 +579,7 @@ data ChatCommand | SetBotCommands [ChatBotCommand] | UpdateProfile ContactName (Maybe Text) -- UserId (not used in UI) | UpdateProfileImage (Maybe ImageData) -- UserId (not used in UI) + | AddBadge BadgeCredential -- attach an issued badge credential (testing; credential from `simplex-chat badge sign`) | ShowProfileImage | SetUserFeature AChatFeature FeatureAllowed -- UserId (not used in UI) | SetContactFeature AChatFeature ContactName (Maybe FeatureAllowed) diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index 3c1ce9bc26..e51f7a40e8 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -138,7 +138,7 @@ createActiveUser cc CoreChatOpts {chatRelay} = \case 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} + mkProfile displayName = Profile {displayName, fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing, badge = Nothing} createUser onError p = execChatCommand' (CreateActiveUser NewUser {profile = Just p, pastTimestamp = False, userChatRelay = chatRelay}) 0 `runReaderT` cc >>= \case Right (CRActiveUser user) -> pure user diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index f35a9ef177..43f480b5c4 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -55,6 +55,7 @@ import Data.Type.Equality import qualified Data.UUID as UUID import qualified Data.UUID.V4 as V4 import Simplex.Chat.Library.Subscriber +import Simplex.Chat.Badges (BadgeCredential (..), LocalBadge (..), maxXFTPFileSize, mkBadgeStatus, verifyCredential) import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Delivery (DeliveryJobScope (..), DeliveryJobSpec (..), DeliveryWorkerScope (..)) @@ -363,16 +364,16 @@ processChatCommand cxt nm = \case user <- withFastStore $ \db -> do user <- createUserRecordAt db (AgentUserId auId) p userChatRelay True ts mapM_ (setUserServers db user ts) uss - createPresetContactCards db user `catchAllErrors` \_ -> pure () + createPresetContactCards db cxt user `catchAllErrors` \_ -> pure () createNoteFolder db user pure user atomically . writeTVar u $ Just user pure $ CRActiveUser user where - createPresetContactCards :: DB.Connection -> User -> ExceptT StoreError IO () - createPresetContactCards db user = do - createContact db user simplexStatusContactProfile - createContact db user simplexTeamContactProfile + createPresetContactCards :: DB.Connection -> StoreCxt -> User -> ExceptT StoreError IO () + createPresetContactCards db cxt user = do + createContact db cxt user simplexStatusContactProfile + createContact db cxt user simplexTeamContactProfile chooseServers :: Maybe User -> CM ([UpdatedUserOperatorServers], (NonEmpty (ServerCfg 'PSMP), NonEmpty (ServerCfg 'PXFTP))) chooseServers user_ = do as <- asks randomAgentServers @@ -1941,7 +1942,8 @@ processChatCommand cxt nm = \case -- [incognito] generate profile for connection incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing subMode <- chatReadVar subscriptionMode - let userData = contactShortLinkData (userProfileDirect user incognitoProfile Nothing True) Nothing + linkProfile <- presentUserBadge user incognitoProfile $ userProfileDirect user incognitoProfile Nothing True + let userData = contactShortLinkData linkProfile 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 @@ -1963,7 +1965,7 @@ processChatCommand cxt nm = \case updatePCCIncognito db user conn (Just pId) sLnk pure $ CRConnectionIncognitoUpdated user conn' (Just incognitoProfile) (ConnNew, Just pId, False) -> do - sLnk <- updatePCCShortLinkData conn $ userProfileDirect user Nothing Nothing True + sLnk <- updatePCCShortLinkData conn =<< presentUserBadge user Nothing (userProfileDirect user Nothing Nothing True) conn' <- withFastStore' $ \db -> do deletePCCIncognitoProfile db user pId updatePCCIncognito db user conn Nothing sLnk @@ -1982,9 +1984,10 @@ processChatCommand cxt nm = \case recreateConn user conn@PendingContactConnection {customUserProfileId, connLinkInv} newUser = do subMode <- chatReadVar subscriptionMode let short = isJust $ connShortLink' =<< connLinkInv - userLinkData_ - | short = Just $ UserInvLinkData $ contactShortLinkData (userProfileDirect newUser Nothing Nothing True) Nothing - | otherwise = Nothing + userLinkData_ <- + if short + then Just . UserInvLinkData . (`contactShortLinkData` Nothing) <$> presentUserBadge newUser Nothing (userProfileDirect newUser Nothing Nothing True) + else pure Nothing -- TODO [certs rcv] (agConnId, (ccLink, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId newUser) True False SCMInvitation userLinkData_ Nothing IKPQOn subMode ccLink' <- shortenCreatedLink ccLink @@ -2259,10 +2262,11 @@ processChatCommand cxt nm = \case Right _ -> throwError $ ChatErrorStore SEDuplicateContactLink subMode <- chatReadVar subscriptionMode -- TODO [relays] relay: add identity, key to link data? - let userData - | isTrue userChatRelay = relayShortLinkData (userProfileDirect user Nothing Nothing True) - | otherwise = contactShortLinkData (userProfileDirect user Nothing Nothing True) Nothing - userLinkData = UserContactLinkData UserContactData {direct = True, owners = [], relays = [], userData} + userData <- + if isTrue userChatRelay + then pure $ relayShortLinkData (userProfileDirect user Nothing Nothing True) + else (`contactShortLinkData` Nothing) <$> presentUserBadge user Nothing (userProfileDirect user Nothing Nothing True) + let 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 ccLink' <- shortenCreatedLink ccLink @@ -3143,7 +3147,7 @@ processChatCommand cxt nm = \case joinPreparedConn subMode conn joinPreparedConn subMode conn = do -- [incognito] send membership incognito profile - let p = userProfileDirect user (fromLocalProfile <$> incognitoMembershipProfile gInfo) Nothing True + p <- presentUserBadge user (incognitoMembershipProfile gInfo) $ 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 let newStatus = if sqSecured then ConnSndReady else ConnJoined @@ -3293,6 +3297,7 @@ processChatCommand cxt nm = \case fileStatus <- withFastStore $ \db -> getFileTransferProgress db user fileId pure $ CRFileTransferStatus user fileStatus ShowProfile -> withUser $ \user@User {profile} -> pure $ CRUserProfile user (fromLocalProfile profile) + AddBadge cred -> withUser $ \user -> addUserBadge user cred >> ok user SetBotCommands commands -> withUser $ \user@User {profile} -> do let LocalProfile {preferences} = profile prefs = Just (fromMaybe emptyChatPrefs preferences :: Preferences) {commands = Just commands} @@ -3520,7 +3525,7 @@ processChatCommand cxt nm = \case conn <- withFastStore' $ \db -> createDirectConnection' db userId connId ccLink contactId_ ConnPrepared incognitoProfile subMode chatV pqSup' joinPreparedConn conn incognitoProfile chatV joinPreparedConn conn incognitoProfile chatV = do - let profileToSend = userProfileDirect user incognitoProfile Nothing True + profileToSend <- presentUserBadge user incognitoProfile $ 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 let newStatus = if sqSecured then ConnSndReady else ConnJoined @@ -3624,7 +3629,7 @@ processChatCommand cxt nm = \case relayLinkData_ <- liftIO $ decodeLinkUserData cData case (relayLinkData_, linkEntityId) of (Just RelayShortLinkData {relayProfile = p}, Just entityId) -> - withFastStore $ \db -> updateRelayMemberData db user relayMember (MemberId entityId) (MemberKey relayKey) p + withFastStore $ \db -> updateRelayMemberData db cxt user relayMember (MemberId entityId) (MemberKey relayKey) p _ -> throwChatError $ CEException "relay link: no relay link data or entity id" let cReq = linkConnReq fd relayLinkToConnect = CCLink cReq (Just relayLink) @@ -3667,11 +3672,12 @@ processChatCommand cxt nm = \case joinContact :: User -> Connection -> ConnReqContact -> Maybe Profile -> XContactId -> Maybe SharedMsgId -> Maybe (SharedMsgId, MsgContent) -> Maybe (Maybe GroupInfo) -> PQSupport -> CM Connection joinContact user conn@Connection {connChatVersion = chatV} cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ pqSup = do -- gInfo_ is Maybe (Maybe GroupInfo), where Just Nothing means "some unknown group", e.g. when joining via link without profile - let profileToSend = case gInfo_ of - Just gInfo_' -> - let allowSimplexLinks = maybe True (groupFeatureUserAllowed SGFSimplexLinks) gInfo_' - in userProfileInGroup' user allowSimplexLinks incognitoProfile - Nothing -> userProfileDirect user incognitoProfile Nothing True + profileToSend <- + presentUserBadge user incognitoProfile $ case gInfo_ of + Just gInfo_' -> + let allowSimplexLinks = maybe True (groupFeatureUserAllowed SGFSimplexLinks) gInfo_' + in userProfileInGroup' user allowSimplexLinks incognitoProfile + Nothing -> userProfileDirect user incognitoProfile Nothing True chatEvent <- case gInfo_ of Just (Just gInfo) | useRelays' gInfo -> do let GroupInfo {membership = GroupMember {memberId}} = gInfo @@ -3688,12 +3694,12 @@ processChatCommand cxt nm = \case contactMember Contact {contactId} = find $ \GroupMember {memberContactId = cId, memberStatus = s} -> cId == Just contactId && s /= GSMemRejected && s /= GSMemRemoved && s /= GSMemLeft - checkSndFile :: CryptoFile -> CM Integer - checkSndFile (CryptoFile f cfArgs) = do + checkSndFile :: Maybe LocalBadge -> CryptoFile -> CM Integer + checkSndFile sndBadge (CryptoFile f cfArgs) = do fsFilePath <- lift $ toFSFilePath f unlessM (doesFileExist fsFilePath) . throwChatError $ CEFileNotFound f fileSize <- liftIO $ CF.getFileContentsSize $ CryptoFile fsFilePath cfArgs - when (fromInteger fileSize > maxFileSize) $ throwChatError $ CEFileSize f + when (fromInteger fileSize > maxXFTPFileSize sndBadge) $ throwChatError $ CEFileSize f pure fileSize updateProfile :: User -> Profile -> CM ChatResponse updateProfile user p' = updateProfile_ user p' True $ withFastStore $ \db -> updateUserProfile db user p' @@ -3723,7 +3729,7 @@ processChatCommand cxt nm = \case case changedCts_ of Nothing -> pure $ UserProfileUpdateSummary 0 0 [] Just changedCts -> do - let idsEvts = L.map ctSndEvent changedCts + idsEvts <- mapM ctSndEvent changedCts msgReqs_ <- lift $ L.zipWith ctMsgReq changedCts <$> createSndMessages idsEvts (errs, cts) <- partitionEithers . L.toList . L.zipWith (second . const) changedCts <$> deliverMessagesB msgReqs_ unless (null errs) $ toView $ CEvtChatErrors errs @@ -3747,8 +3753,11 @@ processChatCommand cxt nm = \case mergedProfile = userProfileDirect user Nothing (Just ct) False ct' = updateMergedPreferences user' ct mergedProfile' = userProfileDirect user' Nothing (Just ct') False - ctSndEvent :: ChangedProfileContact -> (ConnOrGroupId, Maybe MsgSigning, ChatMsgEvent 'Json) - ctSndEvent ChangedProfileContact {mergedProfile', conn = Connection {connId}} = (ConnectionId connId, Nothing, XInfo mergedProfile') + -- non-incognito (filtered above), so the user's badge is presented; a profile update keeps the badge instead of clearing it + ctSndEvent :: ChangedProfileContact -> CM (ConnOrGroupId, Maybe MsgSigning, ChatMsgEvent 'Json) + ctSndEvent ChangedProfileContact {mergedProfile', conn = Connection {connId}} = do + p <- presentUserBadge user' Nothing mergedProfile' + pure (ConnectionId connId, Nothing, XInfo p) ctMsgReq :: ChangedProfileContact -> Either ChatError SndMessage -> Either ChatError ChatMsgReq ctMsgReq ChangedProfileContact {conn} = fmap $ \SndMessage {msgId, msgBody} -> @@ -3756,9 +3765,9 @@ processChatCommand cxt nm = \case setMyAddressData :: User -> UserContactLink -> CM UserContactLink setMyAddressData user@User {userChatRelay} ucl@UserContactLink {userContactLinkId, connLinkContact = CCLink connFullLink _sLnk_, addressSettings} = do conn <- withFastStore $ \db -> getUserAddressConnection db cxt user - let shortLinkProfile = userProfileDirect user Nothing Nothing True - -- TODO [short links] do not save address to server if data did not change, spinners, error handling - userData + shortLinkProfile <- presentUserBadge user Nothing $ userProfileDirect user Nothing Nothing True + -- TODO [short links] do not save address to server if data did not change, spinners, error handling + let userData | isTrue userChatRelay = relayShortLinkData shortLinkProfile | otherwise = contactShortLinkData shortLinkProfile $ Just addressSettings userLinkData = UserContactLinkData UserContactData {direct = True, owners = [], relays = [], userData} @@ -3779,7 +3788,8 @@ processChatCommand cxt nm = \case mergedProfile' = userProfileDirect user (fromLocalProfile <$> incognitoProfile) (Just ct') False when (mergedProfile' /= mergedProfile) $ withContactLock "updateContactPrefs" (contactId' ct) $ do - void (sendDirectContactMessage user ct' $ XInfo mergedProfile') `catchAllErrors` eToView + p <- presentUserBadge user incognitoProfile mergedProfile' + void (sendDirectContactMessage user ct' $ XInfo p) `catchAllErrors` eToView lift . when (directOrUsed ct') $ createSndFeatureItems user ct ct' pure $ CRContactPrefsUpdated user ct ct' runUpdateGroupProfile :: User -> GroupInfo -> GroupProfile -> CM ChatResponse @@ -4065,7 +4075,7 @@ processChatCommand cxt nm = \case Just r -> pure r Nothing -> do (FixedLinkData {linkConnReq = cReq, rootKey}, cData) <- getShortLinkConnReq nm user l' - contactSLinkData_ <- liftIO $ decodeLinkUserData cData + contactSLinkData_ <- mapM linkDataBadge =<< liftIO (decodeLinkUserData cData) let ov = verifyLinkOwner rootKey [] l sig_ invitationReqAndPlan cReq (Just l') contactSLinkData_ ov where @@ -4092,7 +4102,7 @@ processChatCommand cxt nm = \case withFastStore' (\db -> getContactWithoutConnViaShortAddress db cxt user l') >>= \case Just ct' | not (contactDeleted ct') -> pure (con cReq, CPContactAddress (CAPContactViaAddress ct')) _ -> do - contactSLinkData_ <- liftIO $ decodeLinkUserData cData + contactSLinkData_ <- mapM linkDataBadge =<< liftIO (decodeLinkUserData cData) let ContactLinkData _ UserContactData {owners} = cData ov = verifyLinkOwner rootKey owners l' sig_ plan <- contactRequestPlan user cReq contactSLinkData_ ov @@ -4261,7 +4271,7 @@ processChatCommand cxt nm = \case contactShortLinkData p settings = let msg = autoReply =<< settings business = maybe False businessAddress settings - contactData = ContactShortLinkData p msg business + contactData = ContactShortLinkData p msg business Nothing in encodeShortLinkData contactData relayShortLinkData :: Profile -> UserLinkData relayShortLinkData Profile {displayName, fullName, shortDescr, image} = @@ -4325,7 +4335,8 @@ processChatCommand cxt nm = \case setupSndFileTransfers = forM cmrs $ \(ComposedMessage {fileSource = file_}, _, _, _) -> case file_ of Just file -> do - fileSize <- checkSndFile file + let User {profile = LocalProfile {localBadge}} = user + fileSize <- checkSndFile (if contactConnIncognito ct then Nothing else localBadge) file (fInv, ciFile) <- xftpSndFileTransfer user file fileSize 1 $ CGContact ct pure (Just fInv, Just ciFile) Nothing -> pure (Nothing, Nothing) @@ -4406,7 +4417,8 @@ processChatCommand cxt nm = \case setupSndFileTransfers n = forM cmrs $ \(ComposedMessage {fileSource = file_}, _, _, _) -> case file_ of Just file -> do - fileSize <- checkSndFile file + let User {profile = LocalProfile {localBadge}} = user + fileSize <- checkSndFile (if incognitoMembership gInfo then Nothing else localBadge) file (fInv, ciFile) <- xftpSndFileTransfer user file fileSize n $ CGGroup gInfo recipients pure (Just fInv, Just ciFile) Nothing -> pure (Nothing, Nothing) @@ -4641,6 +4653,28 @@ createContactsSndFeatureItems user cts = CUPContact {preference} -> preference CUPUser {preference} -> preference +-- attach an issued badge credential to the user's own profile and present it to all current contacts. +-- the credential is stored once; every profile send generates a fresh single-use proof (see presentUserBadge). +addUserBadge :: User -> BadgeCredential -> CM () +addUserBadge user cred@(BadgeCredential keyIdx _ _ info) = do + keys <- asks $ badgePublicKeys . config + key <- maybe (throwCmdError "unknown badge key index") pure $ M.lookup keyIdx keys + verified <- liftIO $ verifyCredential key cred + unless verified $ throwCmdError "badge credential does not verify against configured key" + now <- liftIO getCurrentTime + user' <- withFastStore' $ \db -> setUserBadge db user (Just (OwnBadge cred (mkBadgeStatus now (Just True) info))) + asks currentUser >>= atomically . (`writeTVar` Just user') + cxt <- asks $ mkStoreCxt . config + contacts <- withFastStore' $ \db -> getUserContacts db cxt user' + withChatLock "addUserBadge" $ forM_ contacts $ \ct -> + case contactSendConn_ ct of + Right conn + | not (connIncognito conn) -> do + let ct' = updateMergedPreferences user' ct + p <- presentUserBadge user' Nothing $ userProfileDirect user' Nothing (Just ct') False + void (sendDirectContactMessage user' ct' (XInfo p)) `catchAllErrors` eToView + _ -> pure () + assertDirectAllowed :: User -> MsgDirection -> Contact -> CMEventTag e -> CM () assertDirectAllowed user dir ct event = unless (allowedChatEvent || anyDirectOrUsed ct) . unlessM directMessagesAllowed $ @@ -5241,6 +5275,7 @@ chatCommandP = "/show profile image" $> ShowProfileImage, ("/profile " <|> "/p ") *> (uncurry UpdateProfile <$> profileNameDescr), ("/profile" <|> "/p") $> ShowProfile, + "/badge add " *> (AddBadge <$> jsonP), "/set bot commands " *> (SetBotCommands <$> botCommandsP), "/delete bot commands" $> SetBotCommands [], "/set voice #" *> (SetGroupFeatureRole (AGFR SGFVoice) <$> displayNameP <*> _strP <*> optional memberRole), @@ -5378,7 +5413,7 @@ chatCommandP = quoted = A.char '\'' *> A.takeTill (== '\'') <* A.char '\'' newUserP userChatRelay = do (cName, shortDescr) <- profileNameDescr - let profile = Just Profile {displayName = cName, fullName = "", shortDescr, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing} + let profile = Just Profile {displayName = cName, fullName = "", shortDescr, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing, badge = Nothing} pure NewUser {profile, pastTimestamp = False, userChatRelay} newBotUserP = do files_ <- optional $ "files=" *> onOffP <* A.space @@ -5386,7 +5421,7 @@ chatCommandP = 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} + profile = Just Profile {displayName = cName, fullName = "", shortDescr, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences, badge = Nothing} pure NewUser {profile, pastTimestamp = False, userChatRelay = False} jsonP :: J.FromJSON a => Parser a jsonP = J.eitherDecodeStrict' <$?> A.takeByteString diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index f2c448d5b8..68e870a7c5 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -53,6 +53,7 @@ import Data.Text.Encoding (encodeUtf8) import Data.Time (addUTCTime) import Data.Time.Calendar (fromGregorian) import Data.Time.Clock (UTCTime (..), diffUTCTime, getCurrentTime, nominalDiffTimeToSeconds, secondsToDiffTime) +import Simplex.Chat.Badges (BadgeCredential (..), BadgePresHeader (..), BadgeProof (..), BadgeStatus (..), LocalBadge (..), badgeProof, mkBadgeStatus, verifyBadge) import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Files @@ -906,7 +907,7 @@ acceptContactRequest nm user@User {userId} UserContactRequest {agentInvitationId Just conn@Connection {customUserProfileId} -> do incognitoProfile <- forM customUserProfileId $ \pId -> withFastStore $ \db -> getProfileById db userId pId pure (ct, conn, ExistingIncognito <$> incognitoProfile) - let profileToSend = userProfileDirect user (fromIncognitoProfile <$> incognitoProfile) (Just ct) True + profileToSend <- presentUserBadge user incognitoProfile $ 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) @@ -919,7 +920,7 @@ acceptContactRequestAsync UserContactRequest {agentInvitationId = AgentInvId cReqInvId, cReqChatVRange, xContactId, pqSupport = cReqPQSup} incognitoProfile = do subMode <- chatReadVar subscriptionMode - let profileToSend = userProfileDirect user (fromIncognitoProfile <$> incognitoProfile) (Just ct) True + profileToSend <- presentUserBadge user incognitoProfile $ userProfileDirect user (fromIncognitoProfile <$> incognitoProfile) (Just ct) True cxt <- chatStoreCxt let chatV = vr cxt `peerConnChatVersion` cReqChatVRange (cmdId, acId) <- agentAcceptContactAsync user True cReqInvId (XInfo profileToSend) subMode cReqPQSup chatV @@ -947,8 +948,9 @@ acceptGroupJoinRequestAsync memberKey_ = do gVar <- asks random let initialStatus = acceptanceToStatus (memberAdmission groupProfile) gAccepted + cxt <- chatStoreCxt (groupMemberId, memberId) <- withStore $ \db -> - createJoiningMember db gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ cReqMemberId_ welcomeMsgId_ gLinkMemRole initialStatus memberKey_ + createJoiningMember db cxt gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ cReqMemberId_ welcomeMsgId_ gLinkMemRole initialStatus memberKey_ let currentMemCount = fromIntegral $ currentMembers $ groupSummary gInfo let Profile {displayName} = userProfileInGroup user gInfo (fromIncognitoProfile <$> incognitoProfile) GroupMember {memberRole = userRole, memberId = userMemberId} = membership @@ -964,7 +966,6 @@ acceptGroupJoinRequestAsync groupSize = Just currentMemCount } subMode <- chatReadVar subscriptionMode - cxt <- chatStoreCxt let chatV = vr cxt `peerConnChatVersion` cReqChatVRange connIds <- agentAcceptContactAsync user True cReqInvId msg subMode PQSupportOff chatV withStore $ \db -> do @@ -982,8 +983,9 @@ acceptGroupJoinSendRejectAsync cReqXContactId_ rejectionReason = do gVar <- asks random + cxt <- chatStoreCxt (groupMemberId, memberId) <- withStore $ \db -> - createJoiningMember db gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ Nothing Nothing GRObserver GSMemRejected Nothing + createJoiningMember db cxt gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ Nothing Nothing GRObserver GSMemRejected Nothing let GroupMember {memberRole = userRole, memberId = userMemberId} = membership msg = XGrpLinkReject $ @@ -994,7 +996,6 @@ acceptGroupJoinSendRejectAsync rejectionReason } subMode <- chatReadVar subscriptionMode - cxt <- chatStoreCxt let chatV = vr cxt `peerConnChatVersion` cReqChatVRange connIds <- agentAcceptContactAsync user False cReqInvId msg subMode PQSupportOff chatV withStore $ \db -> do @@ -1197,8 +1198,8 @@ memberInfo g m@GroupMember {memberId, memberRole, memberProfile, memberPubKey, a allowSimplexLinks = groupFeatureMemberAllowed SGFSimplexLinks m g redactedMemberProfile :: Bool -> Profile -> Profile -redactedMemberProfile allowSimplexLinks Profile {displayName, fullName, shortDescr, image, peerType} = - Profile {displayName, fullName, shortDescr = removeSimplexLink =<< shortDescr, image, contactLink = Nothing, preferences = Nothing, peerType} +redactedMemberProfile allowSimplexLinks Profile {displayName, fullName, shortDescr, image, peerType, badge} = + Profile {displayName, fullName, shortDescr = removeSimplexLink =<< shortDescr, image, contactLink = Nothing, preferences = Nothing, peerType, badge} where removeSimplexLink s | allowSimplexLinks = Just s @@ -1895,6 +1896,33 @@ sendDirectContactMessages' user ct events = do forM_ pqEnc_ $ \pqEnc' -> void $ createContactPQSndItem user ct conn pqEnc' pure sndMsgs' +-- present the user's own badge on an outgoing profile: a fresh, single-use proof from the stored credential. +-- the send's incognito profile (when set) suppresses it - an incognito identity must never carry the badge. +-- a long-expired badge is not presented at all (receivers would hide it anyway). +presentUserBadge :: User -> Maybe i -> Profile -> CM Profile +presentUserBadge User {profile = LocalProfile {localBadge}} incognitoProfile p = case (incognitoProfile, localBadge) of + (Nothing, Just (OwnBadge cred@(BadgeCredential keyIdx _ _ _) st)) | st == BSActive || st == BSExpired -> do + keys <- asks $ badgePublicKeys . config + case M.lookup keyIdx keys of + Nothing -> p <$ logError "presentUserBadge: badge key index not in config" + Just key -> do + nonce <- drgRandomBytes 16 + liftIO (badgeProof key cred (PHTest nonce)) >>= \case + Right proof -> pure p {badge = Just proof} + Left e -> p <$ logError ("presentUserBadge: proof generation failed: " <> T.pack e) + _ -> pure p + +-- receiving side of contact/invitation link data: verify the badge proof from the link profile +-- and set the crypto-free display badge for the UI (the raw proof stays in profile for APIPrepareContact) +linkDataBadge :: ContactShortLinkData -> CM ContactShortLinkData +linkDataBadge cld@ContactShortLinkData {profile = Profile {badge}} = case badge of + Nothing -> pure cld + Just b@(BadgeProof _ _ _ info) -> do + keys <- asks $ badgePublicKeys . config + verified <- liftIO $ verifyBadge keys b + now <- liftIO getCurrentTime + pure (cld :: ContactShortLinkData) {localBadge = Just $ ShownBadge info (mkBadgeStatus now verified info)} + sendDirectContactMessage :: MsgEncodingI e => User -> Contact -> ChatMsgEvent e -> CM (SndMessage, Int64) sendDirectContactMessage user ct chatMsgEvent = do conn@Connection {connId} <- liftEither $ contactSendConn_ ct @@ -2102,8 +2130,9 @@ sendGroupMessages user gInfo scope asGroup members events = do sendProfileUpdate = do let members' = filter (`supportsVersion` memberProfileUpdateVersion) members allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo - profileUpdateEvent = XInfo $ redactedMemberProfile allowSimplexLinks $ fromLocalProfile p - void $ sendGroupMessage' user gInfo members' profileUpdateEvent + -- shouldSendProfileUpdate excludes incognito membership, so the badge is presented + profileUpdate <- presentUserBadge user Nothing $ redactedMemberProfile allowSimplexLinks $ fromLocalProfile p + void $ sendGroupMessage' user gInfo members' $ XInfo profileUpdate currentTs <- liftIO getCurrentTime withStore' $ \db -> updateUserMemberProfileSentAt db user gInfo currentTs @@ -2837,7 +2866,8 @@ simplexTeamContactProfile = image = Just simplexChatImage, contactLink = Just $ CLFull adminContactReq, peerType = Nothing, - preferences = Nothing + preferences = Nothing, + badge = Nothing } simplexStatusContactProfile :: Profile @@ -2849,7 +2879,8 @@ simplexStatusContactProfile = image = Just (ImageData "data:image/jpg;base64,/9j/4AAQSkZJRgABAQAASABIAAD/4QBYRXhpZgAATU0AKgAAAAgAAgESAAMAAAABAAEAAIdpAAQAAAABAAAAJgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAr6ADAAQAAAABAAAArwAAAAD/7QA4UGhvdG9zaG9wIDMuMAA4QklNBAQAAAAAAAA4QklNBCUAAAAAABDUHYzZjwCyBOmACZjs+EJ+/8AAEQgArwCvAwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAQEBAQEBAgEBAgMCAgIDBAMDAwMEBgQEBAQEBgcGBgYGBgYHBwcHBwcHBwgICAgICAkJCQkJCwsLCwsLCwsLC//bAEMBAgICAwMDBQMDBQsIBggLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLC//dAAQAC//aAAwDAQACEQMRAD8A/v4ooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAP/Q/v4ooooAKKKKACiiigAoorE8R+ItF8J6Jc+IvEVwlrZ2iGSWWQ4CgVUISlJRirtmdatTo05VaslGMU223ZJLVtvokbdFfl3of/BRbS734rtpup2Ig8LSsIYrjnzkOcea3bafTqBX6cafqFjq1jFqemSrPbzqHjkQ5VlPIINetm2Q43LXD65T5eZXX+XquqPiuC/Efh/itYh5HiVUdGTjJWaflJJ6uEvsy2fqXKKKK8c+5Ciq17e2mnWkl/fyLDDCpd3c4VVHJJJr8c/2kf8Ago34q8M3mpTfByG3fT7CGSJZrlC3nStwJF5GFU8gd69LA5VicXTrVaMfdpxcpPokk397toj4LjvxKyLhGjRqZxValVkowhFc05O9m0tPdjfV7dN2kfq346+J3w9+GWlPrXxA1m00i1QZL3Uqxj8Mnn8K/Mj4tf8ABYD4DeEJ5dM+Gmn3niq4TIE0YEFtn/ffBI+imv51vHfxA8b/ABR1+bxT8RNUuNXvp3LtJcOWCk84VeigdgBXI18LXzupLSkrL72fzrxH9IXNsTKVPKKMaMOkpe/P8fdXpaXqfqvrf/BYH9p6+1w3+iafo1jZA8WrRPKSPeTcpz9BX1l8J/8Ags34PvxDp/xn8M3OmSnAe709hcQfUoSHA/A1/PtSE4/GuKGZ4mLvz39T4TL/ABe4swlZ1ljpTvvGaUo/dbT/ALdsf2rfCX9pT4HfHGzF18M/EdnqTYBaFXCzJn+9G2GH5V7nX8IOm6hqGkX8eraLcy2d3EcpPbuY5FPsykGv6gf+CWf7QPxB+OPwX1Ky+JF22pX3h69+yJdyf62WJlDrvPdlzjPevdwGae3l7OcbP8D+i/DTxm/1ixkcqx2H5K7TalF3jLlV2rPWLtqtWvM/T2iiivYP3c//0f7+KKKKACiiigAooooAK/Fv/goX8Qvi2fFcXgfWrRtP8NDEls0bZS7YfxORxlT0Xt1r9pK8u+L/AMI/Cfxp8F3HgvxbFujlGYpgB5kMg6Op9R+tfR8K5vQy3MYYnE01KK0843+0vNf8NZn5f4wcFZhxTwziMpy3FOjVeqSdo1Lf8u5u11GXk97Xuro/mBFyDX3t+yL+2Be/CW+h8B+OHafw7cyALIxJa0Ldx6p6jt1FfMvx/wDgR4w/Z+8YN4d8RoZrSbLWd4owk6D+TDuK8KF0K/pLFYHA51geWVp0pq6a/Brs1/wH2P8ALvJsz4h4D4h9tR5qGLoS5ZRls11jJbSjJferSi9mf1uafqFlqtlFqWmyrPBOoeORDlWU8gg069vrPTbSS/v5FhghUu7ucKqjqSa/CH9j79sm++EuoQ/D/wAeSNceHbmRVjlZstZk9x6p6jt2q3+15+2fffFS8n8AfD2V7bw9CxWWZThrwj+Se3evxB+G2Zf2n9TX8Lf2nTl/+S/u/PbU/v2P0nuGv9Vf7cf+9/D9Xv73tLd/+ffXn7afF7pqftbfth3nxUu5vAXgGR7fw/A5WWUHDXZX19E9B361+Z/xKm3eCL9R3UfzFbQul6Cn+I/A3ivxR8LPEXivSbVn07RoVkurg8Iu5gAue7HPSv1HOsrwmVcN4uhRSjBUp6vq3Fq7fVt/5I/gTNeI884x4kjmeYOVWtKSdop2hCPvWjFbQjFNv5ybbuz4Toqa0ge9uoLOIhWnkSNSxwAXIUEnsBnmv0+/aK/4Jg+O/gj8Hoviz4b1n/hJFt40l1G2ig2NDG4yZEIJ3KvfgHHNfxVTw9SpGUoK6W5+xZVw1mWZYfEYrA0XOFBKU2raJ31te72b0T0R+XRIAyegr+gr/glx+yZoHhjwBc/tKfFywiafUY2OmpeIGS3sVGWmIbgF+TkjhR71+YP7DX7Lt9+1H8ZLfR75WTw5pBS61ScDKsoIKwg+snf0Ffqd/wAFSv2o4Phf4Ltv2WvhmVtrjUbRBfvA2Ps1kOFhAHQyAc9ML9a9HL6UacHi6q0W3mz9Q8M8owuV4KvxpnEL0aN40Yv/AJeVXpp5LZPo7v7J+M/7U/jX4e/EL4/+JfFXwrsI9P0Ke5K26RKESTZw0oUcAOeQBX7J/wDBFU5+HPjYf9RWH/0SK/nqACgKOgr+hT/giouPh143b11SH/0SKWVzc8YpPrf8jHwexk8XxzSxVRJSn7WTSVknKMnoui7H7a0UUV9cf3Mf/9L+/iiiigAoorzX4wfGD4afAP4bav8AF74v6xbaD4d0K3e6vb26cJHHGgyevUnoAOSeBTjFyajFXYHpVFf55Xxt/wCDu34nj9vzS/G3wX0Qz/ArQ2ksLnSp1CXurQyMA15uPMTqBmJD2+914/uU/Y//AGxfgH+3P8ENL+P37OutxazoWpoNwHyzW02PmhmjPKSKeCD9RxXqY/JcXg4QqV4WUvw8n2ZnCrGTaTPqGiiivKNDy/4u/CLwd8afBtx4N8ZW4kilBMUoH7yGTs6HsR+tfzjftA/AXxl+z54yfw34jQzWkuXs7xF/dzR/0YdxX9OPiDxBofhPQ7vxN4mu4rDT7CF57m4ncJHFFGMszMcAAAZJNf53n/Bav/g5W1H4ufGjTvg5+xB5F14E8JX4l1HVriIE6xNE2GjhLDKQdRuGC55HHX9L8Os+x2ExP1eKcsO/iX8vmvPy6/ifg3jZ4NYDjDBPFUEqeYU17k/50vsT8n0lvF+V0fq0LhTUgnA4r4y/ZG/bJ+FX7YXw9HjDwBP5N/ahV1LTZeJrSUjoR3U/wsOK+sRdL/n/APXX9G0nCrBTpu6Z/mVmuSYvLcXUwOPpOnWg7SjJWaf9ap7NarQ+pf2dP2evGH7Q3i4aLogNvp1uQ15esMpEnoPVj2Ffrd+1V8GvDnw5/YU8X+APh/Z7IrewEjYGXlZGUs7nqSQM18C/sO/ti6b8F7o/Dnx6qpoN9LvS6RRvglbjL45ZT69vpX7wX1poHjjwxNYzbL3TdUt2jbaQySRSrg4PoQa/nnxXxGaTxLwmIjy4e3uW2lpu33Xbp87v+7Po58I8L4nhfFVMuqKeY1oTp1nJe9S5k0oxWtoPfmXxve1uVfwqKA0YHYiv6Ev+CZ37bVv490eP9mb4zXAn1GKJo9Murg5F3bgYMLk9XUcD+8tflR+1/wDsn+Nv2XfiNdadqFs8vh28md9Mv1GY3iJyEY9nXoQa+UrC/v8ASr+DVdJnktbq2dZYZomKvG6nIZSOhFfztQrVMJW1Xqu5+Z8PZ5mvBWeSc4NSg+WrTeinHqv1jL56ptP+s7xHZ/A//gnR8EfE/jTwra+RHqF5JdxWpbLTXcwwkSnrsGPwXNfyrfEDx54l+J/jXU/iB4wna51LVZ3nmdj3Y8KPQKOAPQV2vxX/AGhvjT8corC3+K2vz6vFpq7beNgERT3YqvBY92NeNVeOxirNRpq0Fsju8RePKWfTo4TLqPscFRXuU9F7z+KTSuvJK7srvqwr+ir/AIIuaVd2/wAH/FesSIRDd6uFjb+8Y41Dfka/BX4YfCzx78ZfGVr4C+G+nyajqV22Aqj5I17u7dFUdya/r+/ZV+Aenfs2fBLSPhbZyC4ntVaW7nAx5tzKd0jfTJwPYV1ZLQk63tbaI+w8AOHcXiM8ebcjVClGS5ujlJWUV3sm27baX3R9FUUUV9Uf2gf/0/7+KKKKACv4If8Ag8QT9vN9W8IsVk/4Z+WJedOL7f7Xyd32/HGNu3yc/LnPev73q84+Lnwj+G/x3+HGr/CT4uaRba74d123e1vbK6QPHJG4weD0I6gjkHkV6WUY9YLFQxDgpJdP8vMipDmi0f4W1frt/wAEhP8Agrt8af8AglD8b38V+Fo21zwPr7xp4i0B3KpcRoeJoTyEnjBO04+boeK+m/8AguZ/wQz+I3/BMD4kyfEn4Ww3fiD4Oa5KzWWolC76XKx4tbphwOuI3PDAc81/PdX7LCeFzHC3VpU5f18mjympU5eZ/t9fsk/tb/Av9tv4G6N+0F+z3rUWs6BrEQYFCPNt5cfPDMnVJEPDKf5V794h8Q6F4T0O78TeJ7uGw06wiae4uZ3EcUUaDLMzHAAA6k1/j9f8EiP+Cunxv/4JTfHAeKPCZfWfAuuyRx+IvD8jkRTxg486Lsk8YJ2n+Loa/V7/AILy/wDBxZd/t2eHl/Zc/Y6mu9I+Gl1DDNrWoSBoLvUpGAY2+OqQoeH/AL5GOlfneI4OxCxio0taT+12Xn59u53xxMeW73ND/g4M/wCDgzVP2yNV1H9jz9j3UZrD4ZWE7waxrEDlH110ONiEYItgQe/7z6V/I6AAMDgCgAKNo6Cv0j/4Jkf8Ex/j/wD8FOvj/Y/Cj4UWE9voFvNGdf18xk2um2pPzEt0MhGdiZyTX6FhsNhctwvLH3YR1bfXzfn/AEjhlKVSR77/AMEMf2Rf2v8A9qr9tPRrb9mNpdL0fSp438UaxKjNYW+nk/PHKOA7uoIjTrnniv7Lfj98CvG37PPjiXwj4uiLxNl7S7UYjuIuzD39R1Ffvt+wn+wd+z5/wTy+A+n/AAF/Z70pbKyt1V728cA3V/c4w0079WYnoOijgV7V8cPgb4G+Pngqfwb41twwYEwXCgebBJ2ZT/MdDXi5N4mTwmYWqRvhXpb7S/vL9V28z8c8YfBXC8XYL61hbQx9Ne7LpNfyT8v5ZfZfkfyXi5r9Lf2Jv24bn4S3UHwz+JkzT+HZ5AsNy5LNZlu3vHn8q+KPj38CPHf7PPjabwn4yt2ELMxtLsD91cRg8Mp6Z9R2rxAXAPANfuePyzL89y/2c7TpTV1JdOzT6Nf8Bn8C5FnGfcEZ79Yw96OJpPlnCS0a6xkusX/k4u9mf2IeK/B/w++Mngt9C8U2ltrWi6lEGCuA6OrDhlPY+hHNfztftw/8E4tN+AGlTfE34ba3HJo0koVdMvGC3CFv4Ym/5aAenBArvf2PP2+9R+CGmv4B+JSy6joEUbtaOp3TQOBkRj1Rjx7V8uftEftH+Nf2i/G7+KPEzmG0hyllZqT5cEef1Y9zX4LT8GMTisynhsY7UI6qot5J7Jefe+i87o/prxI8YuEM/wCF6WM+rc2ZSXKo6qVJrdykvih/Ktebsmnb4DkilicxyqVYdQRzXUaN4R1HVMSzjyIf7zDk/QV6dIlpJIJ5Y1Z16MRk1+qf7DX7Ed58ULmH4p/Fe2kt/D8Dq9paSDabwjncf+mf/oX0rKXg3lOR+0zDPMW6lCL92EVyufZN3vfyjbvdI/AeFsJnHFOPp5TktD97L4pP4YLrJu2iXnq3ok20es/8Erv2f/G/gf8AtD4ozj7Bo2pwiFIpY/3t2VOQ4J5VFzx659q/aKq9paWthax2VlGsUMShERBtVVHAAA6AVYr4LNcdTxWIdSjRjSpqyjGKslFber7t6tn+k3APB1LhjJaOUUqsqjjdylJ/FKTvJpfZV9orbzd2yiiivNPsj//U/v4ooooAKKKKAPO/iz8Jvh18c/h1q/wm+LGk2+ueHtdt3tb2yukDxyxuMEEHoR1B6g81/lm/8Fy/+CFfxG/4Jh/ENvid8J4bzxF8Htdmke1vliaRtHctxbXTAEBecRyHAbGDzX+q54j8R6B4Q0C88U+KbyHT9N0+F7i5ubhxHFFFGMszMcAADqa/zM/+Dhb/AIL06p+3f4rvP2Tf2Xr6S0+Eui3DR397GcHXriM8N7W6EfIP4jz6V9fwfPGLFctD+H9q+3/D9jmxKjy+9ufyq0UAY4or9ZPMP0v/AOCX3/BLf9oT/gqP8d4Phf8ACa0lsvDtjLG3iDxDJGTa6bbse56NKwB8uPOSfav9ZX9hD9hT4Df8E8v2fdK/Z7+AenLbWNkoe8vHUfab+6I+eeZhyWY9B0UcCv8AKC/4JUf8FV/j1/wSu+PCfEf4aSHUvC+rPHH4i0CViIL63U43D+7MgJKN+B4r/Wd/Yy/bM+BH7eHwH0j9oL9n7Vo9S0fU4182LI8+0nx88MydVdTxz16ivzbjZ43nipfwOlu/n59uh6GE5Labn1ZRRRXwB2Hi3x3+BPgj9oHwJceCPGcIIYFre4UfvYJezKf5jvX8vH7QvwB8d/s4eOZfB/jKEtDIS9neKP3VxFngqfX1Hav6gvj58e/An7PHgK48ceN7gLtBW2twf3txL2RR/M9hX8rX7Qn7Rnjz9o3x5L418ZyhUXKWlqh/dW8WeFUevqe5r988G4Zu3Ut/ueu/839z/wBu6fM/jj6UdPhlwo8y/wCFTS3Lb+H/ANPf/bPtf9unlQuAec077SPWueFznrTxc1+/eyP4udE/XX9g79h24+K8tv8AF74qQvD4fgkDWdo64N4V53H/AKZg/wDfX0r+ge0tLWwtY7KyjWKGJQiIgwqqOAAOwFfzc/sIft2XnwO1KH4ZfEeVp/Ct5L8k7Es9k7YHH/TMnkjt1r+kDTNT07WtOg1fSJ0ubW5QSRSxncjowyCCOoNfyr4q0s3jmreYfwtfZW+Hl/8Akv5r6/Kx/or9HSXDX+rqhkqtidPb81vac/d/3P5Lab/auXqKKK/Lz+gwooooA//V/v4ooooAKxfEniTQPB2gXnirxVew6dpunQvcXV1cOI4oYoxlndjgAADJJrar/PV/4Ozf+CiX7Xlr8Yrf9hCx0u98GfDaS0iv5L1GZT4iZs5HmKceTERgx9d3LcYr08py2eOxMaEXbu/L9SKk1CN2fIX/AAcD/wDBfrXv27vFF1+yx+ylqFzpnwl0id476+icxSa/MhwGOMEWykHYv8fU9hX8qoAAwOAKUAAYFfqj/wAEnf8AglH8cv8Agqp8ek+Hvw/R9M8I6NJFJ4k19lzHZW7k/ImeGmcAhF/E8V+xUKGFyzC2Xuwju/1fds8tuVSXmM/4JQ/8Epfjr/wVU+Pcfw5+HiPpXhPSXjl8ReIZEJhsoGP3E7PO4B2J+J4r7o/4Li/8EC/H3/BL/UYPjH8Hp7vxV8JNQMcL3sy7rnTLkgDbcFRjZI3KPwATg9q/0rP2MP2MPgL+wZ8BdI/Z5/Z60hNM0bS4x5kpANxeTn7887gAvI55JPToOK9y+J/ww8AfGfwBqvwu+KOlW+t6Brdu9re2V0gkilicYIIP6HqDXwVbjSu8YqlNfulpy9139e3Y7VhY8tnuf4VdfqD/AMErP+Cpvx1/4Jb/ALQNn8S/h7cS6j4VvpUj8QeH2kIt723zgsB0WVRyjetffn/BeH/ghJ4x/wCCZvjlvjP8EYbvXPg5rk7GKcqZJdGmc5FvOwH+rOcRyH0wea/nCr9ApVcNmOGuvehL+vk0cLUqcvM/24v2Mf20PgH+3l8CdK/aA/Z61iPVNI1FF86LI+0Wc+PnhnTqjqeOevUcV3nx/wD2gfh/+zp4CuPHHjq5CBQVtrZT+9uJeyIP5noBX+Ud/wAEL/25f2t/2NP2u7A/s7xPrPhzW5Yk8T6LOzCyls1PzTE9I5UXJRupPHIr+p39o79pXx/+0v8AEGbxv42l2RrlLO0QnyreLPCqPX1PUmvM4b8KauYZg5VJWwkdW/tP+6vPu+i8z8r8VvF3D8L4P6vhbTx017sekF/PL/21fafkjV/aF/aN8e/tHePZ/GvjOc+XuK2lopPlW8WeFUevqe9eFfasDmsL7UB1r9kv+Cen/BPuX4mPa/Gv41Wrw6HE4k0/T5FwbsjkO4PPl56D+L6V/QWbZjlnDmW+1q2hSgrRit2+kYrq/wDh2fw9kXDmdcZ526NK9SvUfNOctorrKT6JdF6JIh/Yq/4JyXXxq8MSfEn4wtPpukXkLLp1vH8s0hYcTHPRR1Ud6+KP2nP2bvHX7MXj+Twl4pUz2U+Xsb5QRHcRZ/Rh/Etf2D2trbWNtHZ2caxRRKEREGFVRwAAOgFeSfHL4G+Af2gvAVz4A8f2wmt5huimUDzYJB0dD2I/Wv5/yrxgx0c3niMcr4abtyL7C6OPdrr/ADeWlv604g+jdlFTh6ngsrfLjaauqj/5eS6xn2i/s2+Hz1v/ABi+d3r9O/2DP28r/wCBGpRfDT4lSvdeFL2UBJmYs9izcZX1j7kduor48/ah/Zr8bfsu/EWTwZ4pHn2c4MtheqMJcQ5IB9mHRhXzd9oAFf0Djsuy3iHLeSdqlGorpr8Gn0a/4DW6P5DyrMc74Mzz2tG9LE0XaUXs11jJdYv/ACaezP7pdK1bTNd02DWdGnS6tLlBJFLEwZHRuQQR1FaFfix/wSG1n47X3hPVLHXUL+BoT/oEtxneLjPzLD6pjr2B6d6/aev424nyP+yMyrZf7RT5Huvv17NdV0Z/pTwPxP8A6w5Lh82dGVJ1FrGXdaNp9YveL6oKKKK8A+sP/9b+/iiiigAr4E/4KI/8E4f2b/8AgpZ8DLr4M/H7SklljV5NJ1aJQLzTblhxLC/Uc43L0YcGvvuitKNadKaqU3aS2Ymk1Zn+Vt8Nf+DZH9vDxJ/wUEn/AGQfGti+m+DdMkF5eeNlTNjLpRb5Xgz964cfL5XVWyTx1/0lv2L/ANif9nv9gn4H6b8Bv2dNDh0jSrFF8+YKDcXs4GGmuJOskjHPJ6dBxX1lgZz3pa9bNc+xWPjGFV2iui6vu/60M6dKMNgooorxTU4T4m/DHwB8ZfAeqfDH4paRba7oGtQPbXtjeRiWGaJxghlII/wr/M//AOCw/wDwbq/En9kb9o7Ttc/ZhQ6h8KvGl4VgknkUyaJIxy0UmTueMDmNgCexr/SN/aA/aA+Hf7N3w6u/iL8RbtYYIFIggBHm3Ev8Mca9yfyA5NfyB/tTftZfEX9qv4gSeL/GEv2exgLJYWEZPlW8WeOO7H+Ju9fsXhRwnmOZYl4hNwwi+Jv7T/lj5930Xnofj3iv4nYThrCPD0bTxs17kekV/PPy7L7T8rn58fs1fs1/Df8AZg8Dp4U8CwB7qYK19fuAZrmQDkseyjsvQV9GfaWrAWcjvUnnt6mv62w+Cp0KapUo2itkfwFmOLxWPxNTGYyo51Zu8pN6t/1stktEftx/wTa/YHsfi6sHx2+L8aT6BFJnT7DcGFy6dWlAzhQf4T171/SBaWltY20dlZRrFDEoREQYVVHAAA6AV/Hv+xJ+3N4y/ZO8Wi0ui+oeE9QkX7dYk5KdjLFzw49Ohr+tj4c/Efwb8WPB1l498A30eoaZqEYkiljOevVWHZh0IPIr+TPGXLs6p5p9Zxz5sO9KbXwxX8rXSXd/a3Wmi/t76P8AmHD08l+qZZDkxUdaydueT/mT0vDsl8Oz1d33FFFFfjR/QB4x8dPgN8O/2hvA1x4F+Idms8MgJhmAxLbydnjbqCP1r8RPg3/wSV8Z/wDC9r7T/izMreDNIlEkM8TYfUVPKpgcoAPv+/Ar+iKivrsh43zbKMLWweCq2hUXXXlf80eza0/HdJnwPFHhpkHEGOw+YZlQ5qlJ7rTnXSM/5op6/hs2jD8NeGdA8HaHbeGvC9nFYWFmgjhghUIiKOwArcoor5Oc5Tk5Sd292fd06cacVCCtFaJLZLsgoooqSz//1/7+KKKKACiiigAooooAK8J/aK/aG+H37M/wzvPiX8QrgRwwDbb26kebczH7saDuSep7DmvdW3bTt69s1/Hj/wAFS9c/acu/2hbiw+Psf2fTYWf+w47bd9ha2zw0ZPWQj7+eQfav0Dw44PpcRZssLXqqFOK5pK9pSS6RXfu+i1PzvxN4zrcN5PLF4ei51JPli7XjFv7U327Lq9Dwr9qv9rn4lftZ+Pv+Ev8AG8i29na7ksNPiJ8m2iJ7Ak5Y/wATHrXy/wDacDJNYfn45PFftR/wTX/4Ju6j8aryz+OXxttpLXwtbSrJY2Mi7W1Bl53MD0hB/wC+vpX9jZpmGU8LZT7WolTo01aMVu30jFdW/wDNvqz+HcryTOeLs4dODdSvUd5Tlsl1lJ9Eui9Elsix/wAE8/8Agmpc/Hq3HxZ+OcFxY+F8f6Daj93Jen++eMiMdum76V88ft4fsM+LP2RvGH9p6MJtS8G6gxNnfMMmFj/yxmIAAYfwnuPev7DbGxs9Ms4tP0+JYIIFCRxoNqqq8AADoBXL+P8AwB4R+KHhG+8C+OrGPUNM1CMxTQyjIIPcehHUEdDX8x4PxqzWOdvH11fDS0dJbKPRp/zrdvrtta39V47wCyWeQRy7D6YqOqrPeUuqkv5Hsl9ndXd7/wACwuGHevvT9iL9u7x1+yP4n+wMDqXhPUJVN/YMTlOxlh/uuB+BqH9vD9hXxl+yD4v/ALS03zNT8HajIfsV8VyYSf8AljNjgMOx/iHvX59C6bHav6fjDKeJsqurVcPVX9ecZRfzTP5LdLOeE850vRxNJ/15SjJfJo/v3+GnxJ8HfF3wRp/xC8BXiX2l6lEJYZEPr1Vh2YdCDyDXd1/PD/wRa8KftJW8moeKfPNp8N7kMBBdKT9ouR/Hbgn5QP4m6Gv6Hq/iHjXh6lkmb1svoVlUjF6Nbq/2ZdOZdbfhsf6AcC8SVs9yahmWIoOlOS1T2dvtR68r3V/x3ZRRRXyh9eFFFFABRRRQB//Q/v4ooooAKKKKACiiigAr5u/aj/Zg+HX7VvwyuPh14+i2N/rLO8jA861mHR0Pp2YdCOK+kaK6sDjq+DxEMVhZuFSDumt00cmOwOHxuHnhcVBTpzVpJ7NM/nF/ZW/4I2eINL+MV9rH7Rk0Vz4d0G5H2GCA8anjlXfuiDjcvJJ46V/RfY2FlpdlFpumxJBbwII444wFVEUYAAHAAFW6K9/injHM+Ia8a+Y1L8qsorSK7tLu3q3+iSPn+E+C8q4dw86GW07czvJvWT7Jvstkv1bYUUUV8sfVnEfEb4c+Dvix4Mv/AAB49sY9Q0vUYjFNDIMjB7j0YdQRyDX4HeH/APgiNJB+0LKNe1vzvhzARcxBeLyUEn/R27ADu46jtmv6KKK+r4d42zjI6Vajl1ZxjUVmt7P+aN9pW0uv0R8lxJwNk2e1aFfMqCnKk7p7XX8srbxvrZ/qzn/CnhXw/wCCPDll4R8K2sdlp2nQrBbwRDCoiDAAFdBRRXy05ynJzm7t6tvqfVwhGEVCCsloktkgoooqSgooooAKKKKAP//R/v4ooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAP/Z"), contactLink = Just (either error CLFull $ strDecode "simplex:/contact/#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FShQuD-rPokbDvkyotKx5NwM8P3oUXHxA%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA6fSx1k9zrOmF0BJpCaTarZvnZpMTAVQhd3RkDQ35KT0%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"), peerType = Just CPTBot, - preferences = Nothing + preferences = Nothing, + badge = Nothing } timeItToView :: String -> CM' a -> CM' a diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 87b560d1ab..f8cb2b861c 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -437,9 +437,10 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- [incognito] send saved profile (conn'', gInfo_) <- saveConnInfo conn' connInfo incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) - let profileToSend = case gInfo_ of - Just gInfo -> userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) - Nothing -> userProfileDirect user (fromLocalProfile <$> incognitoProfile) Nothing True + profileToSend <- + presentUserBadge user incognitoProfile $ case gInfo_ of + Just gInfo -> userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) + Nothing -> userProfileDirect user (fromLocalProfile <$> incognitoProfile) Nothing True -- [async agent commands] no continuation needed, but command should be asynchronous for stability allowAgentConnectionAsync user conn'' confId $ XInfo profileToSend INFO pqSupport connInfo -> do @@ -555,7 +556,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = ct' <- processContactProfileUpdate ct profile False `catchAllErrors` const (pure ct) -- [incognito] send incognito profile incognitoProfile <- forM customUserProfileId $ \profileId -> withStore $ \db -> getProfileById db userId profileId - let p = userProfileDirect user (fromLocalProfile <$> incognitoProfile) (Just ct') True + p <- presentUserBadge user incognitoProfile $ userProfileDirect user (fromLocalProfile <$> incognitoProfile) (Just ct') True allowAgentConnectionAsync user conn'' confId $ XInfo p void $ withStore' $ \db -> resetMemberContactFields db ct' XGrpLinkInv glInv -> do @@ -566,7 +567,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart) -- [incognito] send saved profile incognitoProfile <- forM customUserProfileId $ \pId -> withStore (\db -> getProfileById db userId pId) - let profileToSend = userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) + profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) allowAgentConnectionAsync user conn'' confId $ XInfo profileToSend toView $ CEvtBusinessLinkConnecting user gInfo host ct _ -> messageError "CONF for existing contact must have x.grp.mem.info or x.info" @@ -798,7 +799,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = (gInfo', m') <- withStore $ \db -> updatePreparedUserAndHostMembersInvited db cxt user gInfo m glInv -- [incognito] send saved profile incognitoProfile <- forM customUserProfileId $ \pId -> withStore (\db -> getProfileById db userId pId) - let profileToSend = userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) + profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) allowAgentConnectionAsync user conn' confId $ XInfo profileToSend toView $ CEvtGroupLinkConnecting user gInfo' m' | otherwise -> messageError "x.grp.link.inv: publicGroupId mismatch" @@ -813,7 +814,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = | sameMemberId memId m -> do let GroupMember {memberId = membershipMemId} = membership allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo - membershipProfile = redactedMemberProfile allowSimplexLinks $ fromLocalProfile $ memberProfile membership + membershipProfile <- presentUserBadge user (incognitoMembershipProfile gInfo) $ redactedMemberProfile allowSimplexLinks $ fromLocalProfile $ memberProfile membership -- TODO update member profile -- [async agent commands] no continuation needed, but command should be asynchronous for stability allowAgentConnectionAsync user conn' confId $ XGrpMemInfo membershipMemId membershipProfile @@ -921,7 +922,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = where sendXGrpLinkMem gInfo'' = do let incognitoProfile = ExistingIncognito <$> incognitoMembershipProfile gInfo'' - profileToSend = userProfileInGroup user gInfo (fromIncognitoProfile <$> incognitoProfile) + profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo (fromIncognitoProfile <$> incognitoProfile) void $ sendDirectMemberMessage conn (XGrpLinkMem profileToSend) groupId _ -> do unless (memberPending m) $ withStore' $ \db -> updateGroupMemberStatus db userId m GSMemConnected @@ -1170,7 +1171,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = relayLinkData_ <- liftIO $ decodeLinkUserData cData case (relayLinkData_, linkEntityId) of (Just RelayShortLinkData {relayProfile = p}, Just entityId) -> - withStore $ \db -> updateRelayMemberData db user m (MemberId entityId) (MemberKey relayKey) p + withStore $ \db -> updateRelayMemberData db cxt user m (MemberId entityId) (MemberKey relayKey) p _ -> throwChatError $ CEException "relay link: no relay link data or entity id" case cReq of CRContactUri crData@ConnReqUriData {crClientData} -> do @@ -1184,8 +1185,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- Update connection with data derived from cReq, now available after getConnShortLinkAsync withStore' $ \db -> updateConnLinkData db user conn cReq cReqHash groupLinkId chatV pqSup let GroupMember {memberId = membershipMemId} = membership - incognitoProfile = fromLocalProfile <$> incognitoMembershipProfile gInfo - profileToSend = userProfileInGroup user gInfo incognitoProfile + incognitoProfile = incognitoMembershipProfile gInfo + profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) memberPubKey <- case groupKeys gInfo of Just GroupKeys {memberPrivKey} -> pure $ C.publicKey memberPrivKey Nothing -> throwChatError $ CEInternalError "no group keys for channel membership" @@ -2550,14 +2551,15 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = processContactProfileUpdate :: Contact -> Profile -> Bool -> CM Contact processContactProfileUpdate c@Contact {profile = lp} p' createItems - | p /= p' = do + -- a failed/unknown-key badge is re-verified even when content is unchanged, so it heals after an app update adds the key + | contentChanged || badgeNeedsReverify lp = do c' <- withStore $ \db -> if userTTL == rcvTTL - then updateContactProfile db user c p' + then updateContactProfile db cxt user c p' else do c' <- liftIO $ updateContactUserPreferences db user c ctUserPrefs' - updateContactProfile db user c' p' - when (directOrUsed c' && createItems) $ do + updateContactProfile db cxt user c' p' + when (contentChanged && directOrUsed c' && createItems) $ do createProfileUpdatedItem c' lift $ createRcvFeatureItems user c c' toView $ CEvtContactUpdated user c c' @@ -2565,6 +2567,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = | otherwise = pure c where + contentChanged = not (sameProfileContent p p') p = fromLocalProfile lp Contact {userPreferences = ctUserPrefs@Preferences {timedMessages = ctUserTMPref}} = c userTTL = prefParam $ getPreference SCFTimedMessages ctUserPrefs @@ -2667,22 +2670,23 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = processMemberProfileUpdate :: GroupInfo -> GroupMember -> Profile -> Maybe (RcvMessage, UTCTime) -> CM GroupMember processMemberProfileUpdate gInfo m@GroupMember {memberProfile = p, memberContactId} p' msgTs_ - | redactedMemberProfile allowSimplexLinks (fromLocalProfile p) /= redactedMemberProfile allowSimplexLinks p' = do - updateBusinessChatProfile gInfo + -- a failed/unknown-key badge is re-verified even when content is unchanged, so it heals after an app update adds the key + | contentChanged || badgeNeedsReverify p = do + when contentChanged $ updateBusinessChatProfile gInfo case memberContactId of Nothing -> do - m' <- withStore $ \db -> updateMemberProfile db user m p' + m' <- withStore $ \db -> updateMemberProfile db cxt user m p' unless (muteEventInChannel gInfo m') $ do - forM_ msgTs_ $ createProfileUpdatedItem m' + when contentChanged $ forM_ msgTs_ $ createProfileUpdatedItem m' toView $ CEvtGroupMemberUpdated user gInfo m m' pure m' Just mContactId -> do mCt <- withStore $ \db -> getContact db cxt user mContactId if canUpdateProfile mCt then do - (m', ct') <- withStore $ \db -> updateContactMemberProfile db user m mCt p' + (m', ct') <- withStore $ \db -> updateContactMemberProfile db cxt user m mCt p' unless (muteEventInChannel gInfo m') $ do - forM_ msgTs_ $ createProfileUpdatedItem m' + when contentChanged $ forM_ msgTs_ $ createProfileUpdatedItem m' toView $ CEvtGroupMemberUpdated user gInfo m m' toView $ CEvtContactUpdated user mCt ct' pure m' @@ -2696,6 +2700,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = | otherwise = pure m where + contentChanged = not (sameProfileContent (redactedMemberProfile allowSimplexLinks (fromLocalProfile p)) (redactedMemberProfile allowSimplexLinks p')) allowSimplexLinks = groupFeatureMemberAllowed SGFSimplexLinks m gInfo updateBusinessChatProfile g@GroupInfo {businessChat} = case businessChat of Just bc | isMainBusinessMember bc m -> do @@ -2976,7 +2981,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = | otherwise -> messageError "x.grp.mem.new error: member already exists" $> Nothing Left _ -> do (newMember, gInfo') <- withStore $ \db -> do - newMember <- createNewGroupMember db user gInfo m memInfo GCPostMember initialStatus + newMember <- createNewGroupMember db cxt user gInfo m memInfo GCPostMember initialStatus gInfo' <- if memberPending newMember then liftIO $ increaseGroupMembersRequireAttention db user gInfo @@ -3028,7 +3033,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = MemberInfo mId mRole v p _ | mRole == GROwner -> MemberInfo mId mRole v p Nothing _ -> memInfo - void $ withStore $ \db -> createIntroReMember db user gInfo memInfo' memRestrictions + void $ withStore $ \db -> createIntroReMember db cxt user gInfo memInfo' memRestrictions | otherwise -> do when (memberRole < GRAdmin) $ throwChatError (CEGroupContactRole c) case memChatVRange of @@ -3040,7 +3045,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = groupConnIds <- createConn subMode let chatV = maybe (minVersion (vr cxt)) (\peerVR -> vr cxt `peerConnChatVersion` fromChatVRange peerVR) memChatVRange void $ withStore $ \db -> do - reMember <- createIntroReMember db user gInfo memInfo memRestrictions + reMember <- createIntroReMember db cxt user gInfo memInfo memRestrictions createIntroReMemberConn db user m reMember chatV memInfo groupConnIds subMode | otherwise -> messageError "x.grp.mem.intro: member chat version range incompatible" _ -> messageError "x.grp.mem.intro can be only sent by host member" @@ -3075,7 +3080,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- member receiving x.grp.mem.fwd should have also received x.grp.mem.new prior to that. -- For now, this branch compensates for the lack of delayed message delivery. `catchError` \case - SEGroupMemberNotFoundByMemberId _ -> createNewGroupMember db user gInfo m memInfo GCPostMember GSMemAnnounced + SEGroupMemberNotFoundByMemberId _ -> createNewGroupMember db cxt user gInfo m memInfo GCPostMember GSMemAnnounced e -> throwError e -- TODO [knocking] separate pending statuses from GroupMemberStatus? -- TODO add GSMemIntroInvitedPending, GSMemConnectedPending, etc.? @@ -3085,8 +3090,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = pure toMember subMode <- chatReadVar subscriptionMode -- [incognito] send membership incognito profile, create direct connection as incognito - let membershipProfile = redactedMemberProfile allowSimplexLinks $ fromLocalProfile $ memberProfile membership - allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo + let allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo + membershipProfile <- presentUserBadge user (incognitoMembershipProfile gInfo) $ redactedMemberProfile allowSimplexLinks $ fromLocalProfile $ memberProfile membership dm <- encodeConnInfo $ XGrpMemInfo membershipMemId membershipProfile -- [async agent commands] no continuation needed, but commands should be asynchronous for stability groupConnIds <- joinAgentConnectionAsync user Nothing (chatHasNtfs chatSettings) groupConnReq dm subMode @@ -3385,7 +3390,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = createItems mCt m' joinConn subMode = do -- [incognito] send membership incognito profile - let p = userProfileDirect user (fromLocalProfile <$> incognitoMembershipProfile g) Nothing True + p <- presentUserBadge user (incognitoMembershipProfile g) $ userProfileDirect user (fromLocalProfile <$> incognitoMembershipProfile g) Nothing True -- TODO PQ should negotitate contact connection with PQSupportOn? (use encodeConnInfoPQ) dm <- encodeConnInfo $ XInfo p joinAgentConnectionAsync user Nothing True connReq dm subMode diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 281fc6b03b..d932194934 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -38,6 +38,7 @@ import Simplex.Chat import Simplex.Chat.Controller import Simplex.Chat.Library.Commands import Simplex.Chat.Markdown (ParsedMarkdown (..), parseMaybeMarkdownList, parseUri, sanitizeUri) +import Simplex.Chat.Mobile.Badges import Simplex.Chat.Mobile.File import Simplex.Chat.Mobile.Shared import Simplex.Chat.Mobile.WebRTC @@ -136,6 +137,10 @@ foreign export ccall "chat_valid_name" cChatValidName :: CString -> IO CString foreign export ccall "chat_json_length" cChatJsonLength :: CString -> IO CInt +foreign export ccall "chat_badge_keygen" cChatBadgeKeygen :: IO CJSONString + +foreign export ccall "chat_badge_issue" cChatBadgeIssue :: CString -> IO CJSONString + foreign export ccall "chat_encrypt_media" cChatEncryptMedia :: StablePtr ChatController -> CString -> Ptr Word8 -> CInt -> IO CString foreign export ccall "chat_decrypt_media" cChatDecryptMedia :: CString -> Ptr Word8 -> CInt -> IO CString diff --git a/src/Simplex/Chat/Mobile/Badges.hs b/src/Simplex/Chat/Mobile/Badges.hs new file mode 100644 index 0000000000..91e90e16c3 --- /dev/null +++ b/src/Simplex/Chat/Mobile/Badges.hs @@ -0,0 +1,74 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TypeApplications #-} + +module Simplex.Chat.Mobile.Badges + ( cChatBadgeKeygen, + cChatBadgeIssue, + BadgeResult (..), + BadgeIssueReq (..), + IssuerKeyPair (..), + ) +where + +import Data.Aeson (FromJSON (..), ToJSON (..)) +import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ +import qualified Data.ByteString as B +import Data.Text (Text) +import qualified Data.Text as T +import Foreign.C (CString) +import Simplex.Chat.Badges +import Simplex.Chat.Mobile.Shared (CJSONString, newCStringFromLazyBS) +import Simplex.Messaging.Crypto.BBS (BBSPublicKey, BBSSecretKey, bbsKeyGen) +import Simplex.Messaging.Parsers (defaultJSON) + +-- FFI envelope for a generated issuer keypair (the BBS keypair tuple serialized with named fields) +data IssuerKeyPair = IssuerKeyPair + { publicKey :: BBSPublicKey, + secretKey :: BBSSecretKey + } + +data BadgeIssueReq = BadgeIssueReq + { badgeKeyIdx :: Int, + secretKey :: BBSSecretKey, + request :: BadgeRequest + } + +data BadgeResult r + = BadgeResult {result :: r} + | BadgeError {error :: Text} + +$(JQ.deriveJSON defaultJSON ''IssuerKeyPair) + +$(JQ.deriveJSON defaultJSON ''BadgeIssueReq) + +$(pure []) + +instance ToJSON r => ToJSON (BadgeResult r) where + toEncoding = $(JQ.mkToEncoding (defaultJSON {J.sumEncoding = J.UntaggedValue}) ''BadgeResult) + toJSON = $(JQ.mkToJSON (defaultJSON {J.sumEncoding = J.UntaggedValue}) ''BadgeResult) + +instance FromJSON r => FromJSON (BadgeResult r) where + parseJSON = $(JQ.mkParseJSON (defaultJSON {J.sumEncoding = J.UntaggedValue}) ''BadgeResult) + +cChatBadgeKeygen :: IO CJSONString +cChatBadgeKeygen = + bbsKeyGen >>= \case + Right (pk, sk) -> encodeResult $ BadgeResult (IssuerKeyPair pk sk) + Left e -> encodeResult @IssuerKeyPair $ BadgeError (T.pack e) + +cChatBadgeIssue :: CString -> IO CJSONString +cChatBadgeIssue cReq = do + bs <- B.packCString cReq + encodeResult @BadgeCredential =<< case J.eitherDecodeStrict' bs of + Left e -> pure $ BadgeError (T.pack e) + Right BadgeIssueReq {badgeKeyIdx, secretKey, request} -> + either (BadgeError . T.pack) BadgeResult <$> issueBadge badgeKeyIdx secretKey (VerifiedBadgeRequest request) + +encodeResult :: ToJSON r => BadgeResult r -> IO CJSONString +encodeResult = newCStringFromLazyBS . J.encode diff --git a/src/Simplex/Chat/ProfileGenerator.hs b/src/Simplex/Chat/ProfileGenerator.hs index b0903589de..7d272481f6 100644 --- a/src/Simplex/Chat/ProfileGenerator.hs +++ b/src/Simplex/Chat/ProfileGenerator.hs @@ -10,7 +10,7 @@ generateRandomProfile :: IO Profile generateRandomProfile = do adjective <- pick adjectives noun <- pickNoun adjective 2 - pure $ Profile {displayName = adjective <> noun, fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing} + pure $ Profile {displayName = adjective <> noun, fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing, badge = Nothing} where pick :: [a] -> IO a pick xs = (xs !!) <$> randomRIO (0, length xs - 1) diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 9b8f6da766..f8ccaa74e7 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -49,6 +49,7 @@ import Data.Time.Clock.System (systemToUTCTime, utcToSystemTime) import Data.Type.Equality import Data.Typeable (Typeable) import Data.Word (Word32) +import Simplex.Chat.Badges (LocalBadge) import Simplex.Chat.Call import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Types @@ -1483,7 +1484,10 @@ instance FromField (ChatMessage 'Json) where data ContactShortLinkData = ContactShortLinkData { profile :: Profile, message :: Maybe MsgContent, - business :: Bool + business :: Bool, + -- set by the receiving client for the UI: the link profile's badge, verified and crypto-free. + -- never part of the published link data (the link carries the proof inside profile). + localBadge :: Maybe LocalBadge } deriving (Show) diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index abc40f1e6e..e5ebf8e2bd 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -29,7 +29,8 @@ import Control.Monad.IO.Class import Data.Bitraversable (bitraverse) import Data.Int (Int64) import Data.Maybe (fromMaybe) -import Data.Time.Clock (getCurrentTime) +import Data.Time.Clock (UTCTime, getCurrentTime) +import Simplex.Chat.Badges (rowToBadge) import Simplex.Chat.Protocol import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Groups @@ -104,8 +105,9 @@ getConnectionEntity db cxt user@User {userId, userContactId} agentConnId = do (userId, agentConnId, ConnDeleted) getContactRec_ :: Int64 -> Connection -> ExceptT StoreError IO Contact getContactRec_ contactId c = ExceptT $ do + currentTs <- getCurrentTime chatTags <- getDirectChatTags db contactId - firstRow (toContact' contactId c chatTags) (SEInternalError "referenced contact not found") $ + firstRow (toContact' currentTs contactId c chatTags) (SEInternalError "referenced contact not found") $ DB.query db [sql| @@ -113,15 +115,16 @@ getConnectionEntity db cxt user@User {userId, userContactId} agentConnId = do c.contact_profile_id, c.local_display_name, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, c.contact_used, c.contact_status, c.enable_ntfs, c.send_rcpts, c.favorite, p.preferences, c.user_preferences, c.created_at, c.updated_at, c.chat_ts, c.conn_full_link_to_connect, c.conn_short_link_to_connect, c.welcome_shared_msg_id, c.request_shared_msg_id, c.contact_request_id, c.contact_group_member_id, c.contact_grp_inv_sent, c.grp_direct_inv_link, c.grp_direct_inv_from_group_id, c.grp_direct_inv_from_group_member_id, c.grp_direct_inv_from_member_conn_id, c.grp_direct_inv_started_connection, - c.ui_themes, c.chat_deleted, c.custom_data, c.chat_item_ttl + c.ui_themes, c.chat_deleted, c.custom_data, c.chat_item_ttl, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx FROM contacts c JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id WHERE c.user_id = ? AND c.contact_id = ? AND c.contact_status = ? AND c.deleted = 0 |] (userId, contactId, CSActive) - toContact' :: Int64 -> Connection -> [ChatTagId] -> ContactRow' -> Contact - toContact' contactId conn chatTags ((profileId, localDisplayName, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL)) = - let profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, preferences, localAlias} + toContact' :: UTCTime -> Int64 -> Connection -> [ChatTagId] -> ContactRow' -> Contact + toContact' currentTs contactId conn chatTags ((profileId, localDisplayName, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL) :. badgeRow) = + let profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localBadge = rowToBadge currentTs badgeRow, preferences, localAlias} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito conn activeConn = Just conn @@ -130,9 +133,10 @@ getConnectionEntity db cxt user@User {userId, userContactId} agentConnId = do in Contact {contactId, localDisplayName, profile, activeConn, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, preparedContact, contactRequestId, contactGroupMemberId, contactGrpInvSent, groupDirectInv, chatTags, chatItemTTL, uiThemes, chatDeleted, customData} getGroupAndMember_ :: Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember) getGroupAndMember_ groupMemberId c = do + currentTs <- liftIO getCurrentTime gm <- ExceptT $ - firstRow (toGroupAndMember c) (SEInternalError "referenced group member not found") $ + firstRow (toGroupAndMember currentTs c) (SEInternalError "referenced group member not found") $ DB.query db [sql| @@ -152,11 +156,13 @@ getConnectionEntity db cxt user@User {userId, userContactId} agentConnId = do 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, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, + pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, 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 GroupMember 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, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, 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 FROM group_members m @@ -170,10 +176,10 @@ getConnectionEntity db cxt user@User {userId, userContactId} agentConnId = do |] (groupMemberId, userId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted) liftIO $ bitraverse (addGroupChatTags db) pure gm - toGroupAndMember :: Connection -> GroupInfoRow :. GroupMemberRow -> (GroupInfo, GroupMember) - toGroupAndMember c (groupInfoRow :. memberRow) = - let groupInfo = toGroupInfo cxt userContactId [] groupInfoRow - member = toGroupMember userContactId memberRow + toGroupAndMember :: UTCTime -> Connection -> GroupInfoRow :. GroupMemberRow -> (GroupInfo, GroupMember) + toGroupAndMember currentTs c (groupInfoRow :. memberRow) = + let groupInfo = toGroupInfo currentTs cxt userContactId [] groupInfoRow + member = toGroupMember currentTs userContactId memberRow in (groupInfo, (member :: GroupMember) {activeConn = Just c}) getUserContact_ :: Int64 -> ExceptT StoreError IO UserContact getUserContact_ userContactLinkId = ExceptT $ do diff --git a/src/Simplex/Chat/Store/ContactRequest.hs b/src/Simplex/Chat/Store/ContactRequest.hs index 27cb970b73..9c5fe0cd91 100644 --- a/src/Simplex/Chat/Store/ContactRequest.hs +++ b/src/Simplex/Chat/Store/ContactRequest.hs @@ -24,6 +24,7 @@ import Control.Monad.IO.Class import Crypto.Random (ChaChaDRG) import Data.Int (Int64) import Data.Time.Clock (getCurrentTime) +import Simplex.Chat.Badges (badgeToRow, verifyBadge_) import Simplex.Chat.Protocol (MsgContent, businessChatsVersion) import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Groups @@ -72,7 +73,7 @@ createOrUpdateContactRequest isSimplexTeam invId cReqChatVRange@(VersionRange minV maxV) - profile@Profile {displayName, fullName, shortDescr, image, contactLink, preferences} + profile@Profile {displayName, fullName, shortDescr, image, contactLink, badge, preferences} xContactId_ welcomeMsgId_ requestMsg_ @@ -103,8 +104,9 @@ createOrUpdateContactRequest where getAcceptedContact :: XContactId -> IO (Maybe Contact) getAcceptedContact xContactId = do + currentTs <- getCurrentTime ct_ <- - maybeFirstRow (toContact cxt user []) $ + maybeFirstRow (toContact currentTs cxt user []) $ DB.query db [sql| @@ -114,6 +116,7 @@ createOrUpdateContactRequest cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, -- Connection 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, @@ -127,26 +130,29 @@ createOrUpdateContactRequest mapM (addDirectChatTags db) ct_ getAcceptedBusinessChat :: XContactId -> IO (Maybe GroupInfo) getAcceptedBusinessChat xContactId = do + currentTs <- getCurrentTime g_ <- - maybeFirstRow (toGroupInfo cxt userContactId []) $ + maybeFirstRow (toGroupInfo currentTs cxt userContactId []) $ DB.query db (groupInfoQuery <> " WHERE g.business_xcontact_id = ? AND g.user_id = ? AND mu.contact_id = ?") (xContactId, userId, userContactId) mapM (addGroupChatTags db) g_ getContactRequestByXContactId :: XContactId -> IO (Maybe UserContactRequest) - getContactRequestByXContactId xContactId = - maybeFirstRow toContactRequest $ + getContactRequestByXContactId xContactId = do + currentTs <- getCurrentTime + maybeFirstRow (toContactRequest currentTs) $ DB.query db [sql| SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, - cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, cr.xcontact_id, + cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version + cr.peer_chat_min_version, cr.peer_chat_max_version, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx FROM contact_requests cr JOIN contact_profiles p USING (contact_profile_id) WHERE cr.user_id = ? @@ -157,12 +163,13 @@ createOrUpdateContactRequest createContactRequest :: ExceptT StoreError IO RequestStage createContactRequest = do currentTs <- liftIO $ getCurrentTime + badgeVerified <- liftIO $ verifyBadge_ (badgeKeys cxt) badge ExceptT $ withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do liftIO $ DB.execute db - "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" - (displayName, fullName, shortDescr, image, contactLink, userId, preferences, currentTs, currentTs) + "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, local_alias, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" + ((displayName, fullName, shortDescr, image, contactLink, userId) :. ("" :: LocalAlias, preferences, currentTs, currentTs) :. badgeToRow badge badgeVerified) profileId <- liftIO $ insertedRowId db liftIO $ DB.execute @@ -214,7 +221,7 @@ createOrUpdateContactRequest ucr <- getContactRequest db user contactRequestId pure $ RSCurrentRequest Nothing ucr (Just $ REBusinessChat gInfo clientMember) updateContactRequest :: UserContactRequest -> ExceptT StoreError IO RequestStage - updateContactRequest ucr@UserContactRequest {contactRequestId, contactId_, localDisplayName = oldLdn, profile = Profile {displayName = oldDisplayName}} = do + updateContactRequest ucr@UserContactRequest {contactRequestId, contactId_, localDisplayName = oldLdn, profile = LocalProfile {displayName = oldDisplayName}} = do currentTs <- liftIO getCurrentTime liftIO $ updateProfile currentTs updateRequest currentTs @@ -222,7 +229,8 @@ createOrUpdateContactRequest re_ <- getRequestEntity ucr' pure $ RSCurrentRequest (Just ucr) ucr' re_ where - updateProfile currentTs = + updateProfile currentTs = do + badgeVerified <- liftIO $ verifyBadge_ (badgeKeys cxt) badge DB.execute db [sql| @@ -232,7 +240,16 @@ createOrUpdateContactRequest short_descr = ?, image = ?, contact_link = ?, - updated_at = ? + updated_at = ?, + badge_proof = ?, + badge_pres_header = ?, + badge_expiry = ?, + badge_type = ?, + badge_verified = ?, + badge_extra = ?, + badge_master_key = ?, + badge_signature = ?, + badge_key_idx = ? WHERE contact_profile_id IN ( SELECT contact_profile_id FROM contact_requests @@ -240,7 +257,7 @@ createOrUpdateContactRequest AND contact_request_id = ? ) |] - (displayName, fullName, shortDescr, image, contactLink, currentTs, userId, contactRequestId) + ((displayName, fullName, shortDescr, image, contactLink, currentTs) :. badgeToRow badge badgeVerified :. (userId, contactRequestId)) updateRequest currentTs = if displayName == oldDisplayName then diff --git a/src/Simplex/Chat/Store/Delivery.hs b/src/Simplex/Chat/Store/Delivery.hs index e60d51ac85..204b5325ed 100644 --- a/src/Simplex/Chat/Store/Delivery.hs +++ b/src/Simplex/Chat/Store/Delivery.hs @@ -351,7 +351,8 @@ getGroupMembersByCursor db cxt user@User {userContactId} GroupInfo {groupId} cur :. (cursorGMId, count) ) #if defined(dbPostgres) - map (toContactMember cxt user) <$> + currentTs <- getCurrentTime + map (toContactMember currentTs cxt user) <$> DB.query db (groupMemberQuery <> " WHERE m.group_member_id IN ?") diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 1c2f35f2bf..5068c5c61c 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -105,6 +105,7 @@ import Data.Maybe (fromMaybe, isJust, isNothing) import Data.Text (Text) import Data.Time.Clock (UTCTime (..), getCurrentTime) import Data.Type.Equality +import Simplex.Chat.Badges (badgeToRow) import Simplex.Chat.Messages import Simplex.Chat.Store.Shared import Simplex.Chat.Types @@ -307,8 +308,9 @@ getConnReqContactXContactId db cxt user@User {userId} cReqHash1 cReqHash2 = getContactByConnReqHash :: DB.Connection -> StoreCxt -> User -> ConnReqUriHash -> ConnReqUriHash -> IO (Maybe Contact) getContactByConnReqHash db cxt user@User {userId} cReqHash1 cReqHash2 = do + currentTs <- getCurrentTime ct <- - maybeFirstRow (toContact cxt user []) $ + maybeFirstRow (toContact currentTs cxt user []) $ DB.query db [sql| @@ -318,6 +320,7 @@ getContactByConnReqHash db cxt user@User {userId} cReqHash1 cReqHash2 = do cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, -- Connection 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, @@ -399,7 +402,7 @@ createPreparedContact db cxt user p connLinkToConnect welcomeSharedMsgId = do currentTs <- liftIO getCurrentTime let prepared = Just (connLinkToConnect, welcomeSharedMsgId) ctUserPreferences = newContactUserPrefs user p - contactId <- createContact_ db user p ctUserPreferences prepared "" currentTs + contactId <- createContact_ db cxt user p ctUserPreferences prepared "" currentTs getContact db cxt user contactId updatePreparedContactUser :: DB.Connection -> StoreCxt -> User -> Contact -> User -> ExceptT StoreError IO Contact @@ -444,7 +447,7 @@ createDirectContact :: DB.Connection -> StoreCxt -> User -> Connection -> Profil createDirectContact db cxt user Connection {connId, localAlias} p = do currentTs <- liftIO getCurrentTime let ctUserPreferences = newContactUserPrefs user p - contactId <- createContact_ db user p ctUserPreferences Nothing localAlias currentTs + contactId <- createContact_ db cxt user p ctUserPreferences Nothing localAlias currentTs liftIO $ DB.execute db "UPDATE connections SET contact_id = ?, updated_at = ? WHERE connection_id = ?" (contactId, currentTs, connId) getContact db cxt user contactId @@ -552,22 +555,25 @@ deleteUnusedProfile_ db userId profileId = :. (userId, profileId, userId, profileId, profileId) ) -updateContactProfile :: DB.Connection -> User -> Contact -> Profile -> ExceptT StoreError IO Contact -updateContactProfile db user@User {userId} c p' - | displayName == newName = do - liftIO $ updateContactProfile_ db userId profileId p' - pure c {profile, mergedPreferences} - | otherwise = - ExceptT . withLocalDisplayName db userId newName $ \ldn -> do - currentTs <- getCurrentTime - updateContactProfile_' db userId profileId p' currentTs - updateContactLDN_ db user contactId localDisplayName ldn currentTs - pure $ Right c {localDisplayName = ldn, profile, mergedPreferences} +updateContactProfile :: DB.Connection -> StoreCxt -> User -> Contact -> Profile -> ExceptT StoreError IO Contact +updateContactProfile db cxt user@User {userId} c p' = do + currentTs <- liftIO getCurrentTime + badgeVerified <- liftIO $ profileBadgeVerified (badgeKeys cxt) lp p' + let profile = toLocalProfile profileId p' localAlias currentTs badgeVerified + updateContactProfile' currentTs badgeVerified profile where - Contact {contactId, localDisplayName, profile = LocalProfile {profileId, displayName, localAlias}, userPreferences} = c + Contact {contactId, localDisplayName, profile = lp@LocalProfile {profileId, displayName, localAlias}, userPreferences} = c Profile {displayName = newName, preferences} = p' - profile = toLocalProfile profileId p' localAlias mergedPreferences = contactUserPreferences user userPreferences preferences $ contactConnIncognito c + updateContactProfile' currentTs badgeVerified profile + | displayName == newName = do + liftIO $ updateContactProfile_' db userId profileId p' badgeVerified currentTs + pure c {profile, mergedPreferences} + | otherwise = + ExceptT . withLocalDisplayName db userId newName $ \ldn -> do + updateContactProfile_' db userId profileId p' badgeVerified currentTs + updateContactLDN_ db user contactId localDisplayName ldn currentTs + pure $ Right c {localDisplayName = ldn, profile, mergedPreferences} updateContactUserPreferences :: DB.Connection -> User -> Contact -> Preferences -> IO Contact updateContactUserPreferences db user@User {userId} c@Contact {contactId} userPreferences = do @@ -694,55 +700,58 @@ setQuotaErrCounter db User {userId} Connection {connId} counter = do updatedAt <- getCurrentTime DB.execute db "UPDATE connections SET quota_err_counter = ?, updated_at = ? WHERE user_id = ? AND connection_id = ?" (counter, updatedAt, userId, connId) -updateContactProfile_ :: DB.Connection -> UserId -> ProfileId -> Profile -> IO () -updateContactProfile_ db userId profileId profile = do +updateContactProfile_ :: DB.Connection -> UserId -> ProfileId -> Profile -> Maybe Bool -> IO () +updateContactProfile_ db userId profileId profile badgeVerified = do currentTs <- getCurrentTime - updateContactProfile_' db userId profileId profile currentTs + updateContactProfile_' db userId profileId profile badgeVerified currentTs -updateContactProfile_' :: DB.Connection -> UserId -> ProfileId -> Profile -> UTCTime -> IO () -updateContactProfile_' db userId profileId Profile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType} updatedAt = do +updateContactProfile_' :: DB.Connection -> UserId -> ProfileId -> Profile -> Maybe Bool -> UTCTime -> IO () +updateContactProfile_' db userId profileId Profile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType, badge} badgeVerified updatedAt = DB.execute db [sql| UPDATE contact_profiles - SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = ?, preferences = ?, chat_peer_type = ?, updated_at = ? + SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = ?, preferences = ?, chat_peer_type = ?, updated_at = ?, + badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ? WHERE user_id = ? AND contact_profile_id = ? |] - (displayName, fullName, shortDescr, image, contactLink, preferences, peerType, updatedAt, userId, profileId) + ((displayName, fullName, shortDescr, image, contactLink, preferences, peerType, updatedAt) :. badgeToRow badge badgeVerified :. (userId, profileId)) -- update only member profile fields (when member doesn't have associated contact - we can reset contactLink and prefs) -updateMemberContactProfileReset_ :: DB.Connection -> UserId -> ProfileId -> Profile -> IO () -updateMemberContactProfileReset_ db userId profileId profile = do +updateMemberContactProfileReset_ :: DB.Connection -> UserId -> ProfileId -> Profile -> Maybe Bool -> IO () +updateMemberContactProfileReset_ db userId profileId profile badgeVerified = do currentTs <- getCurrentTime - updateMemberContactProfileReset_' db userId profileId profile currentTs + updateMemberContactProfileReset_' db userId profileId profile badgeVerified currentTs -updateMemberContactProfileReset_' :: DB.Connection -> UserId -> ProfileId -> Profile -> UTCTime -> IO () -updateMemberContactProfileReset_' db userId profileId Profile {displayName, fullName, shortDescr, image} updatedAt = do +updateMemberContactProfileReset_' :: DB.Connection -> UserId -> ProfileId -> Profile -> Maybe Bool -> UTCTime -> IO () +updateMemberContactProfileReset_' db userId profileId Profile {displayName, fullName, shortDescr, image, badge} badgeVerified updatedAt = DB.execute db [sql| UPDATE contact_profiles - SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = NULL, preferences = NULL, updated_at = ? + SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = NULL, preferences = NULL, updated_at = ?, + badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ? WHERE user_id = ? AND contact_profile_id = ? |] - (displayName, fullName, shortDescr, image, updatedAt, userId, profileId) + ((displayName, fullName, shortDescr, image, updatedAt) :. badgeToRow badge badgeVerified :. (userId, profileId)) -- update only member profile fields (when member has associated contact - we keep contactLink and prefs) -updateMemberContactProfile_ :: DB.Connection -> UserId -> ProfileId -> Profile -> IO () -updateMemberContactProfile_ db userId profileId profile = do +updateMemberContactProfile_ :: DB.Connection -> UserId -> ProfileId -> Profile -> Maybe Bool -> IO () +updateMemberContactProfile_ db userId profileId profile badgeVerified = do currentTs <- getCurrentTime - updateMemberContactProfile_' db userId profileId profile currentTs + updateMemberContactProfile_' db userId profileId profile badgeVerified currentTs -updateMemberContactProfile_' :: DB.Connection -> UserId -> ProfileId -> Profile -> UTCTime -> IO () -updateMemberContactProfile_' db userId profileId Profile {displayName, fullName, shortDescr, image} updatedAt = do +updateMemberContactProfile_' :: DB.Connection -> UserId -> ProfileId -> Profile -> Maybe Bool -> UTCTime -> IO () +updateMemberContactProfile_' db userId profileId Profile {displayName, fullName, shortDescr, image, badge} badgeVerified updatedAt = DB.execute db [sql| UPDATE contact_profiles - SET display_name = ?, full_name = ?, short_descr = ?, image = ?, updated_at = ? + SET display_name = ?, full_name = ?, short_descr = ?, image = ?, updated_at = ?, + badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ? WHERE user_id = ? AND contact_profile_id = ? |] - (displayName, fullName, shortDescr, image, updatedAt, userId, profileId) + ((displayName, fullName, shortDescr, image, updatedAt) :. badgeToRow badge badgeVerified :. (userId, profileId)) updateContactLDN_ :: DB.Connection -> User -> Int64 -> ContactName -> ContactName -> UTCTime -> IO () updateContactLDN_ db user@User {userId} contactId displayName newName updatedAt = do @@ -773,18 +782,21 @@ getUserContactLinkIdByCReq db contactRequestId = DB.query db "SELECT user_contact_link_id FROM contact_requests WHERE contact_request_id = ?" (Only contactRequestId) getContactRequest :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO UserContactRequest -getContactRequest db User {userId} contactRequestId = - ExceptT . firstRow toContactRequest (SEContactRequestNotFound contactRequestId) $ +getContactRequest db User {userId} contactRequestId = do + currentTs <- liftIO getCurrentTime + ExceptT . firstRow (toContactRequest currentTs) (SEContactRequestNotFound contactRequestId) $ DB.query db (contactRequestQuery <> " WHERE cr.user_id = ? AND cr.contact_request_id = ?") (userId, contactRequestId) getContactRequest' :: DB.Connection -> User -> Int64 -> IO (Maybe UserContactRequest) -getContactRequest' db User {userId} contactRequestId = - maybeFirstRow toContactRequest $ +getContactRequest' db User {userId} contactRequestId = do + currentTs <- getCurrentTime + maybeFirstRow (toContactRequest currentTs) $ DB.query db (contactRequestQuery <> " WHERE cr.user_id = ? AND cr.contact_request_id = ?") (userId, contactRequestId) getBusinessContactRequest :: DB.Connection -> User -> GroupId -> IO (Maybe UserContactRequest) -getBusinessContactRequest db _user groupId = - maybeFirstRow toContactRequest $ +getBusinessContactRequest db _user groupId = do + currentTs <- getCurrentTime + maybeFirstRow (toContactRequest currentTs) $ DB.query db (contactRequestQuery <> " WHERE cr.business_group_id = ?") (Only groupId) contactRequestQuery :: Query @@ -793,10 +805,11 @@ contactRequestQuery = SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, - cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, cr.xcontact_id, + cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version + cr.peer_chat_min_version, cr.peer_chat_max_version, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx FROM contact_requests cr JOIN contact_profiles p USING (contact_profile_id) |] @@ -832,7 +845,7 @@ deleteContactRequest db User {userId} contactRequestId = do (userId, userId, contactRequestId, userId) DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND contact_request_id = ?" (userId, contactRequestId) -createContactFromRequest :: DB.Connection -> User -> Maybe Int64 -> ConnId -> VersionChat -> VersionRangeChat -> ContactName -> ProfileId -> Profile -> Maybe XContactId -> Maybe IncognitoProfile -> SubscriptionMode -> PQSupport -> Bool -> IO (Contact, Connection) +createContactFromRequest :: DB.Connection -> User -> Maybe Int64 -> ConnId -> VersionChat -> VersionRangeChat -> ContactName -> ProfileId -> LocalProfile -> Maybe XContactId -> Maybe IncognitoProfile -> SubscriptionMode -> PQSupport -> Bool -> IO (Contact, Connection) createContactFromRequest db user@User {userId, profile = LocalProfile {preferences}} uclId_ agentConnId connChatVersion cReqChatVRange localDisplayName profileId profile xContactId incognitoProfile subMode pqSup contactUsed = do currentTs <- getCurrentTime let userPreferences = fromMaybe emptyChatPrefs $ incognitoProfile >> preferences @@ -848,7 +861,7 @@ createContactFromRequest db user@User {userId, profile = LocalProfile {preferenc Contact { contactId, localDisplayName, - profile = toLocalProfile profileId profile "", + profile, activeConn = Just conn, contactUsed, contactStatus = CSActive, @@ -904,8 +917,9 @@ getContact db cxt user contactId = getContact_ db cxt user contactId False getContact_ :: DB.Connection -> StoreCxt -> User -> Int64 -> Bool -> ExceptT StoreError IO Contact getContact_ db cxt user@User {userId} contactId deleted = do + currentTs <- liftIO getCurrentTime chatTags <- liftIO $ getDirectChatTags db contactId - ExceptT . firstRow (toContact cxt user chatTags) (SEContactNotFound contactId) $ + ExceptT . firstRow (toContact currentTs cxt user chatTags) (SEContactNotFound contactId) $ DB.query db [sql| @@ -915,6 +929,7 @@ getContact_ db cxt user@User {userId} contactId deleted = do cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, -- Connection 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, @@ -928,8 +943,9 @@ getContact_ db cxt user@User {userId} contactId deleted = do (userId, contactId, BI deleted) getUserByContactRequestId :: DB.Connection -> Int64 -> ExceptT StoreError IO User -getUserByContactRequestId db contactRequestId = - ExceptT . firstRow toUser (SEUserNotFoundByContactRequestId contactRequestId) $ +getUserByContactRequestId db contactRequestId = do + now <- liftIO getCurrentTime + ExceptT . firstRow (toUser now) (SEUserNotFoundByContactRequestId contactRequestId) $ DB.query db (userQuery <> " JOIN contact_requests cr ON cr.user_id = u.user_id WHERE cr.contact_request_id = ?") (Only contactRequestId) getContactConnections :: DB.Connection -> StoreCxt -> UserId -> Contact -> IO [Connection] diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 4e38ef83e2..c6b5684945 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -196,6 +196,7 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (NominalDiffTime, UTCTime (..), addUTCTime, getCurrentTime) import Data.Text.Encoding (encodeUtf8) +import Simplex.Chat.Badges (BadgeRow, badgeToRow, verifyBadge_) import Simplex.Chat.Messages import Simplex.Chat.Operators import Simplex.Chat.Protocol hiding (Binary) @@ -225,12 +226,12 @@ import Database.SQLite.Simple (Only (..), Query, (:.) (..)) import Database.SQLite.Simple.QQ (sql) #endif -type MaybeGroupMemberRow = (Maybe GroupMemberId, Maybe GroupId, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId) :. (Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe LocalAlias, Maybe Preferences) :. (Maybe UTCTime, Maybe UTCTime) :. (Maybe UTCTime, Maybe Int64, Maybe Int64, Maybe Int64, Maybe UTCTime, Maybe C.PublicKeyEd25519, Maybe ShortLinkContact) +type MaybeGroupMemberRow = (Maybe GroupMemberId, Maybe GroupId, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId) :. ((Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe LocalAlias, Maybe Preferences) :. BadgeRow) :. (Maybe UTCTime, Maybe UTCTime) :. (Maybe UTCTime, Maybe Int64, Maybe Int64, Maybe Int64, Maybe UTCTime, Maybe C.PublicKeyEd25519, Maybe ShortLinkContact) -toMaybeGroupMember :: Int64 -> MaybeGroupMemberRow -> Maybe GroupMember -toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just indexInGroup, Just memberId, Just minVer, Just maxVer, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, Just localDisplayName, memberContactId, Just memberContactProfileId) :. (Just profileId, Just displayName, Just fullName, shortDescr, image, contactLink, peerType, Just localAlias, contactPreferences) :. (Just createdAt, Just updatedAt) :. (supportChatTs, Just supportChatUnread, Just supportChatUnanswered, Just supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink)) = - Just $ toGroupMember userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. (profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, contactPreferences) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnread, supportChatUnanswered, supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink)) -toMaybeGroupMember _ _ = Nothing +toMaybeGroupMember :: UTCTime -> Int64 -> MaybeGroupMemberRow -> Maybe GroupMember +toMaybeGroupMember now userContactId ((Just groupMemberId, Just groupId, Just indexInGroup, Just memberId, Just minVer, Just maxVer, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, Just localDisplayName, memberContactId, Just memberContactProfileId) :. ((Just profileId, Just displayName, Just fullName, shortDescr, image, contactLink, peerType, Just localAlias, contactPreferences) :. badgeRow) :. (Just createdAt, Just updatedAt) :. (supportChatTs, Just supportChatUnread, Just supportChatUnanswered, Just supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink)) = + Just $ toGroupMember now userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. ((profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, contactPreferences) :. badgeRow) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnread, supportChatUnanswered, supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink)) +toMaybeGroupMember _ _ _ = Nothing createGroupLink :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> ConnId -> CreatedLinkContact -> GroupLinkId -> GroupMemberRole -> SubscriptionMode -> ExceptT StoreError IO GroupLink createGroupLink db gVar user@User {userId} groupInfo@GroupInfo {groupId, localDisplayName} agentConnId (CCLink cReq shortLink) groupLinkId memberRole subMode = do @@ -634,7 +635,7 @@ createPreparedGroup db gVar cxt user@User {userId, userContactId} groupProfile b randHostId <- liftIO $ encodedRandomBytes gVar 12 let memberId = MemberId $ encodeUtf8 groupLDN <> "_unknown_host_" <> randHostId hostProfile = profileFromName $ nameFromBS randHostId - (localDisplayName, profileId) <- createNewMemberProfile_ db user hostProfile currentTs + (localDisplayName, profileId, _) <- createNewMemberProfile_ db cxt user hostProfile currentTs indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ do DB.execute @@ -789,7 +790,7 @@ updatePreparedUserAndHostMembers' |] (memberId, memberRole, membershipStatus, currentTs, groupMemberId' membership) updateHostMember currentTs = do - _ <- updateMemberProfile db user hostMember fromMemberProfile + _ <- updateMemberProfile db cxt user hostMember fromMemberProfile let MemberIdRole memberId memberRole = fromMember gmId = groupMemberId' hostMember liftIO $ @@ -839,7 +840,7 @@ createGroupViaLink' (,) <$> getGroupInfo db cxt user groupId <*> getGroupMemberById db cxt user hostMemberId where insertHost_ currentTs groupId = do - (localDisplayName, profileId) <- createNewMemberProfile_ db user fromMemberProfile currentTs + (localDisplayName, profileId, _) <- createNewMemberProfile_ db cxt user fromMemberProfile currentTs let MemberIdRole {memberId, memberRole} = fromMember indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ do @@ -1005,7 +1006,8 @@ getInProgressGroups db cxt user@User {userId} createdAtCutoff = do getBaseGroupDetails :: DB.Connection -> StoreCxt -> User -> Maybe ContactId -> Maybe Text -> IO [GroupInfo] getBaseGroupDetails db cxt User {userId, userContactId} _contactId_ search_ = do - map (toGroupInfo cxt userContactId []) + currentTs <- getCurrentTime + map (toGroupInfo currentTs cxt userContactId []) <$> DB.query db (groupInfoQuery <> " " <> condition) (userId, userContactId, search, search, search, search) where condition = @@ -1039,16 +1041,18 @@ getGroupInfoByName db cxt user gName = do getGroupInfo db cxt user gId getGroupMember :: DB.Connection -> StoreCxt -> User -> GroupId -> GroupMemberId -> ExceptT StoreError IO GroupMember -getGroupMember db cxt user@User {userId} groupId groupMemberId = - ExceptT . firstRow (toContactMember cxt user) (SEGroupMemberNotFound groupMemberId) $ +getGroupMember db cxt user@User {userId} groupId groupMemberId = do + currentTs <- liftIO getCurrentTime + ExceptT . firstRow (toContactMember currentTs cxt user) (SEGroupMemberNotFound groupMemberId) $ DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.group_member_id = ? AND m.user_id = ?") (groupId, groupMemberId, userId) getHostMember :: DB.Connection -> StoreCxt -> User -> GroupId -> ExceptT StoreError IO GroupMember -getHostMember db cxt user groupId = - ExceptT . firstRow (toContactMember cxt user) (SEGroupHostMemberNotFound groupId) $ +getHostMember db cxt user groupId = do + currentTs <- liftIO getCurrentTime + ExceptT . firstRow (toContactMember currentTs cxt user) (SEGroupHostMemberNotFound groupId) $ DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.member_category = ?") @@ -1088,32 +1092,36 @@ toMentionedMember (groupMemberId, memberId, memberRole, displayName, localAlias) in CIMention {memberId, memberRef} getGroupMemberById :: DB.Connection -> StoreCxt -> User -> GroupMemberId -> ExceptT StoreError IO GroupMember -getGroupMemberById db cxt user@User {userId} groupMemberId = - ExceptT . firstRow (toContactMember cxt user) (SEGroupMemberNotFound groupMemberId) $ +getGroupMemberById db cxt user@User {userId} groupMemberId = do + currentTs <- liftIO getCurrentTime + ExceptT . firstRow (toContactMember currentTs cxt user) (SEGroupMemberNotFound groupMemberId) $ DB.query db (groupMemberQuery <> " WHERE m.group_member_id = ? AND m.user_id = ?") (groupMemberId, userId) getGroupMemberByIndex :: DB.Connection -> StoreCxt -> User -> GroupInfo -> Int64 -> ExceptT StoreError IO GroupMember -getGroupMemberByIndex db cxt user GroupInfo {groupId} indexInGroup = - ExceptT . firstRow (toContactMember cxt user) (SEGroupMemberNotFoundByIndex indexInGroup) $ +getGroupMemberByIndex db cxt user GroupInfo {groupId} indexInGroup = do + currentTs <- liftIO getCurrentTime + ExceptT . firstRow (toContactMember currentTs cxt user) (SEGroupMemberNotFoundByIndex indexInGroup) $ DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.index_in_group = ?") (groupId, indexInGroup) getSupportScopeMemberByIndex :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupMemberId -> Int64 -> ExceptT StoreError IO GroupMember -getSupportScopeMemberByIndex db cxt user GroupInfo {groupId} scopeGMId indexInGroup = - ExceptT . firstRow (toContactMember cxt user) (SEGroupMemberNotFoundByIndex indexInGroup) $ +getSupportScopeMemberByIndex db cxt user GroupInfo {groupId} scopeGMId indexInGroup = do + currentTs <- liftIO getCurrentTime + ExceptT . firstRow (toContactMember currentTs cxt user) (SEGroupMemberNotFoundByIndex indexInGroup) $ DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.index_in_group = ? AND (m.member_role IN (?,?,?) OR m.group_member_id = ?)") (groupId, indexInGroup, GRModerator, GRAdmin, GROwner, scopeGMId) getGroupMemberByMemberId :: DB.Connection -> StoreCxt -> User -> GroupInfo -> MemberId -> ExceptT StoreError IO GroupMember -getGroupMemberByMemberId db cxt user GroupInfo {groupId} memberId = - ExceptT . firstRow (toContactMember cxt user) (SEGroupMemberNotFoundByMemberId memberId) $ +getGroupMemberByMemberId db cxt user GroupInfo {groupId} memberId = do + currentTs <- liftIO getCurrentTime + ExceptT . firstRow (toContactMember currentTs cxt user) (SEGroupMemberNotFoundByMemberId memberId) $ DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.member_id = ?") @@ -1146,8 +1154,9 @@ getGroupMemberIdViaMemberId db User {userId} GroupInfo {groupId} memberId = (userId, groupId, memberId) getGroupMembers :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] -getGroupMembers db cxt user@User {userId, userContactId} GroupInfo {groupId} = - map (toContactMember cxt user) +getGroupMembers db cxt user@User {userId, userContactId} GroupInfo {groupId} = do + currentTs <- getCurrentTime + map (toContactMember currentTs cxt user) <$> DB.query db (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?)") @@ -1156,8 +1165,9 @@ getGroupMembers db cxt user@User {userId, userContactId} GroupInfo {groupId} = getGroupMembersByIndexes :: DB.Connection -> StoreCxt -> User -> GroupInfo -> [Int64] -> IO [GroupMember] getGroupMembersByIndexes db cxt user gInfo indexesInGroup = do #if defined(dbPostgres) + currentTs <- getCurrentTime let GroupInfo {groupId} = gInfo - map (toContactMember cxt user) <$> + map (toContactMember currentTs cxt user) <$> DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.index_in_group IN ?") @@ -1169,8 +1179,9 @@ getGroupMembersByIndexes db cxt user gInfo indexesInGroup = do getSupportScopeMembersByIndexes :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupMemberId -> [Int64] -> IO [GroupMember] getSupportScopeMembersByIndexes db cxt user gInfo scopeGMId indexesInGroup = do #if defined(dbPostgres) + currentTs <- getCurrentTime let GroupInfo {groupId} = gInfo - map (toContactMember cxt user) <$> + map (toContactMember currentTs cxt user) <$> DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.index_in_group IN ? AND (m.member_role IN (?,?,?) OR m.group_member_id = ?)") @@ -1181,7 +1192,8 @@ getSupportScopeMembersByIndexes db cxt user gInfo scopeGMId indexesInGroup = do getGroupModerators :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] getGroupModerators db cxt user@User {userId, userContactId} GroupInfo {groupId} = do - map (toContactMember cxt user) + currentTs <- getCurrentTime + map (toContactMember currentTs 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 IN (?,?,?)") @@ -1189,7 +1201,8 @@ getGroupModerators db cxt user@User {userId, userContactId} GroupInfo {groupId} getGroupRelayMembers :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] getGroupRelayMembers db cxt user@User {userId, userContactId} GroupInfo {groupId} = do - map (toContactMember cxt user) + currentTs <- getCurrentTime + map (toContactMember currentTs cxt user) <$> DB.query db (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND m.contact_id IS DISTINCT FROM ? AND m.member_role = ?") @@ -1197,7 +1210,8 @@ getGroupRelayMembers db cxt user@User {userId, userContactId} GroupInfo {groupId getGroupMembersForExpiration :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] getGroupMembersForExpiration db cxt user@User {userId, userContactId} GroupInfo {groupId} = do - map (toContactMember cxt user) + currentTs <- getCurrentTime + map (toContactMember currentTs cxt user) <$> DB.query db ( groupMemberQuery @@ -1361,7 +1375,7 @@ createRelayForOwner :: DB.Connection -> StoreCxt -> TVar ChaChaDRG -> User -> Gr createRelayForOwner db cxt gVar user@User {userId, userContactId} GroupInfo {groupId, membership} UserChatRelay {relayProfile = RelayProfile {displayName}} = do currentTs <- liftIO getCurrentTime let relayProfile = profileFromName displayName - (localDisplayName, memProfileId) <- createNewMemberProfile_ db user relayProfile currentTs + (localDisplayName, memProfileId, _) <- createNewMemberProfile_ db cxt user relayProfile currentTs groupMemberId <- createWithRandomId' db gVar $ \memId -> runExceptT $ do indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ @@ -1380,11 +1394,12 @@ createRelayForOwner db cxt gVar user@User {userId, userContactId} GroupInfo {gro getGroupMemberById db cxt user groupMemberId getCreateRelayForMember :: DB.Connection -> StoreCxt -> TVar ChaChaDRG -> User -> GroupInfo -> ShortLinkContact -> ExceptT StoreError IO GroupMember -getCreateRelayForMember db cxt gVar user@User {userId, userContactId} GroupInfo {groupId, localDisplayName = groupLDN} relayLink = - liftIO getGroupMemberByRelayLink >>= maybe createRelayMember pure +getCreateRelayForMember db cxt gVar user@User {userId, userContactId} GroupInfo {groupId, localDisplayName = groupLDN} relayLink = do + currentTs <- liftIO getCurrentTime + liftIO (getGroupMemberByRelayLink currentTs) >>= maybe createRelayMember pure where - getGroupMemberByRelayLink = - maybeFirstRow (toContactMember cxt user) $ + getGroupMemberByRelayLink currentTs = + maybeFirstRow (toContactMember currentTs cxt user) $ DB.query db #if defined(dbPostgres) @@ -1399,7 +1414,7 @@ getCreateRelayForMember db cxt gVar user@User {userId, userContactId} GroupInfo randRelayId <- liftIO $ encodedRandomBytes gVar 12 let memberId = MemberId $ encodeUtf8 groupLDN <> "_unknown_relay_" <> randRelayId relayProfile = profileFromName $ nameFromBS randRelayId - (localDisplayName, profileId) <- createNewMemberProfile_ db user relayProfile currentTs + (localDisplayName, profileId, _) <- createNewMemberProfile_ db cxt user relayProfile currentTs indexInGroup <- getUpdateNextIndexInGroup_ db groupId groupMemberId <- liftIO $ do DB.execute @@ -1472,7 +1487,7 @@ setRelayLinkAccepted db cxt user m (MemberKey relayKey) profile = do WHERE group_member_id = ? |] (relayKey, currentTs, gmId) - void $ updateMemberProfile db user m profile + void $ updateMemberProfile db cxt user m profile (,) <$> getGroupMemberById db cxt user gmId <*> getGroupRelayByGMId db gmId setRelayLinkConfId :: DB.Connection -> GroupMember -> ConfirmationId -> ShortLinkContact -> IO () @@ -1519,8 +1534,8 @@ getRelayConfId db m = |] (Only (groupMemberId' m)) -updateRelayMemberData :: DB.Connection -> User -> GroupMember -> MemberId -> MemberKey -> Profile -> ExceptT StoreError IO () -updateRelayMemberData db user m memberId (MemberKey relayKey) profile = do +updateRelayMemberData :: DB.Connection -> StoreCxt -> User -> GroupMember -> MemberId -> MemberKey -> Profile -> ExceptT StoreError IO () +updateRelayMemberData db cxt user m memberId (MemberKey relayKey) profile = do currentTs <- liftIO getCurrentTime liftIO $ DB.execute @@ -1531,7 +1546,7 @@ updateRelayMemberData db user m memberId (MemberKey relayKey) profile = do WHERE group_member_id = ? |] (memberId, relayKey, currentTs, groupMemberId' m) - void $ updateMemberProfile db user m profile + void $ updateMemberProfile db cxt user m profile setGroupInProgressDone :: DB.Connection -> GroupInfo -> IO () setGroupInProgressDone db GroupInfo {groupId} = do @@ -1584,7 +1599,7 @@ createRelayRequestGroup db cxt user@User {userId} GroupRelayInvitation {fromMemb insertOwner_ currentTs groupId = do let MemberIdRole {memberId, memberRole} = fromMember VersionRange minV maxV = reqChatVRange - (localDisplayName, profileId) <- createNewMemberProfile_ db user fromMemberProfile currentTs + (localDisplayName, profileId, _) <- createNewMemberProfile_ db cxt user fromMemberProfile currentTs indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ do DB.execute @@ -1651,7 +1666,8 @@ isRelayGroupRejected db User {userId} groupLink = getRelayServedGroups :: DB.Connection -> StoreCxt -> User -> IO [GroupInfo] getRelayServedGroups db cxt User {userId, userContactId} = do - map (toGroupInfo cxt userContactId []) + currentTs <- getCurrentTime + map (toGroupInfo currentTs cxt userContactId []) <$> DB.query db ( groupInfoQuery @@ -1661,8 +1677,9 @@ getRelayServedGroups db cxt User {userId, userContactId} = do getRelayInactiveGroups :: DB.Connection -> StoreCxt -> User -> NominalDiffTime -> IO [GroupInfo] getRelayInactiveGroups db cxt User {userId, userContactId} ttl = do - cutoffTs <- addUTCTime (- ttl) <$> getCurrentTime - map (toGroupInfo cxt userContactId []) + currentTs <- getCurrentTime + let cutoffTs = addUTCTime (- ttl) currentTs + map (toGroupInfo currentTs cxt userContactId []) <$> DB.query db ( groupInfoQuery @@ -1697,14 +1714,15 @@ createNewContactMemberAsync db gVar user@User {userId, userContactId} GroupInfo :. (minV, maxV) ) -createJoiningMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe MemberId -> Maybe SharedMsgId -> GroupMemberRole -> GroupMemberStatus -> Maybe MemberKey -> ExceptT StoreError IO (GroupMemberId, MemberId) +createJoiningMember :: DB.Connection -> StoreCxt -> TVar ChaChaDRG -> User -> GroupInfo -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe MemberId -> Maybe SharedMsgId -> GroupMemberRole -> GroupMemberStatus -> Maybe MemberKey -> ExceptT StoreError IO (GroupMemberId, MemberId) createJoiningMember db + cxt gVar User {userId, userContactId} GroupInfo {groupId, membership} cReqChatVRange - Profile {displayName, fullName, shortDescr, image, contactLink, preferences} + Profile {displayName, fullName, shortDescr, image, contactLink, badge, preferences} cReqXContactId_ cReqMemberId_ welcomeMsgId_ @@ -1712,12 +1730,13 @@ createJoiningMember memberStatus memberKey_ = do currentTs <- liftIO getCurrentTime + badgeVerified <- liftIO $ verifyBadge_ (badgeKeys cxt) badge ExceptT . withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do liftIO $ DB.execute db - "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" - (displayName, fullName, shortDescr, image, contactLink, userId, preferences, currentTs, currentTs) + "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" + ((displayName, fullName, shortDescr, image, contactLink, userId, preferences, currentTs, currentTs) :. badgeToRow badge badgeVerified) profileId <- liftIO $ insertedRowId db case cReqMemberId_ of Just memberId -> do @@ -2053,10 +2072,10 @@ increaseGroupMembersRequireAttention db User {userId} g@GroupInfo {groupId, memb pure g {membersRequireAttention = membersRequireAttention + 1} -- | add new member with profile -createNewGroupMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> MemberInfo -> GroupMemberCategory -> GroupMemberStatus -> ExceptT StoreError IO GroupMember -createNewGroupMember db user gInfo invitingMember memInfo@MemberInfo {profile} memCategory memStatus = do +createNewGroupMember :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupMember -> MemberInfo -> GroupMemberCategory -> GroupMemberStatus -> ExceptT StoreError IO GroupMember +createNewGroupMember db cxt user gInfo invitingMember memInfo@MemberInfo {profile} memCategory memStatus = do currentTs <- liftIO getCurrentTime - (localDisplayName, memProfileId) <- createNewMemberProfile_ db user profile currentTs + (localDisplayName, memProfileId, badgeVerified) <- createNewMemberProfile_ db cxt user profile currentTs let newMember = NewGroupMember { memInfo, @@ -2069,19 +2088,20 @@ createNewGroupMember db user gInfo invitingMember memInfo@MemberInfo {profile} m memContactId = Nothing, memProfileId } - createNewMember_ db user gInfo newMember currentTs + createNewMember_ db user gInfo newMember badgeVerified currentTs -createNewMemberProfile_ :: DB.Connection -> User -> Profile -> UTCTime -> ExceptT StoreError IO (Text, ProfileId) -createNewMemberProfile_ db User {userId} Profile {displayName, fullName, shortDescr, image, contactLink, preferences} createdAt = +createNewMemberProfile_ :: DB.Connection -> StoreCxt -> User -> Profile -> UTCTime -> ExceptT StoreError IO (Text, ProfileId, Maybe Bool) +createNewMemberProfile_ db cxt User {userId} Profile {displayName, fullName, shortDescr, image, contactLink, badge, preferences} createdAt = ExceptT . withLocalDisplayName db userId displayName $ \ldn -> do + badgeVerified <- verifyBadge_ (badgeKeys cxt) badge DB.execute db - "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" - (displayName, fullName, shortDescr, image, contactLink, userId, preferences, createdAt, createdAt) + "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" + ((displayName, fullName, shortDescr, image, contactLink, userId, preferences, createdAt, createdAt) :. badgeToRow badge badgeVerified) profileId <- insertedRowId db - pure $ Right (ldn, profileId) + pure $ Right (ldn, profileId, badgeVerified) -createNewMember_ :: DB.Connection -> User -> GroupInfo -> NewGroupMember -> UTCTime -> ExceptT StoreError IO GroupMember +createNewMember_ :: DB.Connection -> User -> GroupInfo -> NewGroupMember -> Maybe Bool -> UTCTime -> ExceptT StoreError IO GroupMember createNewMember_ db User {userId, userContactId} @@ -2097,6 +2117,7 @@ createNewMember_ memContactId = memberContactId, memProfileId = memberContactProfileId } + badgeVerified createdAt = do let invitedById = fromInvitedBy userContactId invitedBy activeConn = Nothing @@ -2134,7 +2155,7 @@ createNewMember_ invitedBy, invitedByGroupMemberId = memInvitedByGroupMemberId, localDisplayName, - memberProfile = toLocalProfile memberContactProfileId memberProfile "", + memberProfile = toLocalProfile memberContactProfileId memberProfile "" createdAt badgeVerified, memberContactId, memberContactProfileId, activeConn, @@ -2248,18 +2269,19 @@ getMemberRelationsVector db GroupMember {groupMemberId} = "SELECT member_relations_vector FROM group_members WHERE group_member_id = ?" (Only groupMemberId) -createIntroReMember :: DB.Connection -> User -> GroupInfo -> MemberInfo -> Maybe MemberRestrictions -> ExceptT StoreError IO GroupMember +createIntroReMember :: DB.Connection -> StoreCxt -> User -> GroupInfo -> MemberInfo -> Maybe MemberRestrictions -> ExceptT StoreError IO GroupMember createIntroReMember db + cxt user gInfo memInfo@(MemberInfo _ _ _ memberProfile _) memRestrictions_ = do currentTs <- liftIO getCurrentTime - (localDisplayName, memProfileId) <- createNewMemberProfile_ db user memberProfile currentTs + (localDisplayName, memProfileId, badgeVerified) <- createNewMemberProfile_ db cxt user memberProfile currentTs let memRestriction = restriction <$> memRestrictions_ newMember = NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memRestriction, memInvitedBy = IBUnknown, memInvitedByGroupMemberId = Nothing, localDisplayName, memContactId = Nothing, memProfileId} - createNewMember_ db user gInfo newMember currentTs + createNewMember_ db user gInfo newMember badgeVerified currentTs createIntroReMemberConn :: DB.Connection -> User -> GroupMember -> GroupMember -> VersionChat -> MemberInfo -> (CommandId, ConnId) -> SubscriptionMode -> ExceptT StoreError IO GroupMember createIntroReMemberConn @@ -2983,41 +3005,47 @@ setMemberContactStartedConnection db Contact {contactId} = do "UPDATE contacts SET grp_direct_inv_started_connection = ?, updated_at = ? WHERE contact_id = ?" (BI True, currentTs, contactId) -updateMemberProfile :: DB.Connection -> User -> GroupMember -> Profile -> ExceptT StoreError IO GroupMember -updateMemberProfile db user@User {userId} m p' - | displayName == newName = do - liftIO $ updateMemberContactProfileReset_ db userId profileId p' - pure m {memberProfile = profile} - | otherwise = - ExceptT . withLocalDisplayName db userId newName $ \ldn -> do - currentTs <- getCurrentTime - updateMemberContactProfileReset_' db userId profileId p' currentTs - DB.execute - db - "UPDATE group_members SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND group_member_id = ?" - (ldn, currentTs, userId, groupMemberId) - safeDeleteLDN db user localDisplayName - pure $ Right m {localDisplayName = ldn, memberProfile = profile} +updateMemberProfile :: DB.Connection -> StoreCxt -> User -> GroupMember -> Profile -> ExceptT StoreError IO GroupMember +updateMemberProfile db cxt user@User {userId} m p' = do + currentTs <- liftIO getCurrentTime + badgeVerified <- liftIO $ profileBadgeVerified (badgeKeys cxt) (memberProfile m) p' + let memberProfile = toLocalProfile profileId p' localAlias currentTs badgeVerified + updateMemberProfile' currentTs badgeVerified memberProfile where GroupMember {groupMemberId, localDisplayName, memberProfile = LocalProfile {profileId, displayName, localAlias}} = m Profile {displayName = newName} = p' - profile = toLocalProfile profileId p' localAlias + updateMemberProfile' currentTs badgeVerified memberProfile + | displayName == newName = do + liftIO $ updateMemberContactProfileReset_' db userId profileId p' badgeVerified currentTs + pure m {memberProfile} + | otherwise = + ExceptT . withLocalDisplayName db userId newName $ \ldn -> do + updateMemberContactProfileReset_' db userId profileId p' badgeVerified currentTs + DB.execute + db + "UPDATE group_members SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND group_member_id = ?" + (ldn, currentTs, userId, groupMemberId) + safeDeleteLDN db user localDisplayName + pure $ Right m {localDisplayName = ldn, memberProfile} -updateContactMemberProfile :: DB.Connection -> User -> GroupMember -> Contact -> Profile -> ExceptT StoreError IO (GroupMember, Contact) -updateContactMemberProfile db user@User {userId} m ct@Contact {contactId} p' - | displayName == newName = do - liftIO $ updateMemberContactProfile_ db userId profileId p' - pure (m {memberProfile = profile}, ct {profile} :: Contact) - | otherwise = - ExceptT . withLocalDisplayName db userId newName $ \ldn -> do - currentTs <- getCurrentTime - updateMemberContactProfile_' db userId profileId p' currentTs - updateContactLDN_ db user contactId localDisplayName ldn currentTs - pure $ Right (m {localDisplayName = ldn, memberProfile = profile}, ct {localDisplayName = ldn, profile} :: Contact) +updateContactMemberProfile :: DB.Connection -> StoreCxt -> User -> GroupMember -> Contact -> Profile -> ExceptT StoreError IO (GroupMember, Contact) +updateContactMemberProfile db cxt user@User {userId} m ct@Contact {contactId} p' = do + currentTs <- liftIO getCurrentTime + badgeVerified <- liftIO $ profileBadgeVerified (badgeKeys cxt) (memberProfile m) p' + let profile = toLocalProfile profileId p' localAlias currentTs badgeVerified + updateContactMemberProfile' currentTs badgeVerified profile where GroupMember {localDisplayName, memberProfile = LocalProfile {profileId, displayName, localAlias}} = m Profile {displayName = newName} = p' - profile = toLocalProfile profileId p' localAlias + updateContactMemberProfile' currentTs badgeVerified profile + | displayName == newName = do + liftIO $ updateMemberContactProfile_' db userId profileId p' badgeVerified currentTs + pure (m {memberProfile = profile}, ct {profile} :: Contact) + | otherwise = + ExceptT . withLocalDisplayName db userId newName $ \ldn -> do + updateMemberContactProfile_' db userId profileId p' badgeVerified currentTs + updateContactLDN_ db user contactId localDisplayName ldn currentTs + pure $ Right (m {localDisplayName = ldn, memberProfile = profile}, ct {localDisplayName = ldn, profile} :: Contact) getXGrpLinkMemReceived :: DB.Connection -> GroupMemberId -> ExceptT StoreError IO Bool getXGrpLinkMemReceived db mId = @@ -3036,7 +3064,7 @@ createNewUnknownGroupMember :: DB.Connection -> StoreCxt -> User -> GroupInfo -> createNewUnknownGroupMember db cxt user@User {userId, userContactId} GroupInfo {groupId} memberId memberName unknownMemberRole = do currentTs <- liftIO getCurrentTime let memberProfile = profileFromName memberName - (localDisplayName, profileId) <- createNewMemberProfile_ db user memberProfile currentTs + (localDisplayName, profileId, _) <- createNewMemberProfile_ db cxt user memberProfile currentTs indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ DB.execute @@ -3061,7 +3089,7 @@ createLinkOwnerMember :: DB.Connection -> StoreCxt -> User -> GroupInfo -> Maybe createLinkOwnerMember db cxt user@User {userId, userContactId} GroupInfo {groupId} contactId_ memberId ownerKey = do currentTs <- liftIO getCurrentTime let memberProfile = profileFromName $ nameFromMemberId memberId - (localDisplayName, profileId) <- createNewMemberProfile_ db user memberProfile currentTs + (localDisplayName, profileId, _) <- createNewMemberProfile_ db cxt user memberProfile currentTs indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ DB.execute @@ -3087,7 +3115,7 @@ createLinkOwnerMember db cxt user@User {userId, userContactId} GroupInfo {groupI -- Updating from an in-band message would allow a compromised relay to substitute keys. updatePreparedChannelMember :: DB.Connection -> StoreCxt -> User -> GroupMember -> MemberInfo -> ExceptT StoreError IO GroupMember updatePreparedChannelMember db cxt user@User {userId} member@GroupMember {groupMemberId, memberChatVRange} MemberInfo {memberRole, v, profile} = do - _ <- updateMemberProfile db user member profile + _ <- updateMemberProfile db cxt user member profile currentTs <- liftIO getCurrentTime liftIO $ DB.execute @@ -3108,7 +3136,7 @@ updatePreparedChannelMember db cxt user@User {userId} member@GroupMember {groupM updateUnknownMemberAnnounced :: DB.Connection -> StoreCxt -> User -> GroupMember -> GroupMember -> MemberInfo -> GroupMemberStatus -> ExceptT StoreError IO GroupMember updateUnknownMemberAnnounced db cxt user@User {userId} invitingMember unknownMember@GroupMember {groupMemberId, memberChatVRange} MemberInfo {memberRole, v, profile, memberKey} status = do - _ <- updateMemberProfile db user unknownMember profile + _ <- updateMemberProfile db cxt user unknownMember profile currentTs <- liftIO getCurrentTime liftIO $ DB.execute diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 76e0a0fd97..edbe7a6acb 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -652,7 +652,8 @@ insertChatItemMessage_ :: DB.Connection -> ChatItemId -> MessageId -> UTCTime -> insertChatItemMessage_ db ciId msgId ts = DB.execute db "INSERT INTO chat_item_messages (chat_item_id, message_id, created_at, updated_at) VALUES (?,?,?,?)" (ciId, msgId, ts, ts) getChatItemQuote_ :: ChatTypeQuotable c => DB.Connection -> User -> ChatDirection c 'MDRcv -> QuotedMsg -> IO (CIQuote c) -getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRef = MsgRef {msgId, sentAt, sent, memberId}, content} = +getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRef = MsgRef {msgId, sentAt, sent, memberId}, content} = do + currentTs <- getCurrentTime case chatDirection of CDDirectRcv Contact {contactId} -> getDirectChatItemQuote_ contactId (not sent) CDGroupRcv GroupInfo {groupId, membership = GroupMember {memberId = userMemberId}} _s sender@GroupMember {groupMemberId = senderGMId, memberId = senderMemberId} -> @@ -660,13 +661,13 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe Just mId | mId == userMemberId -> (`ciQuote` CIQGroupSnd) <$> getUserGroupChatItemId_ groupId | mId == senderMemberId -> (`ciQuote` CIQGroupRcv (Just sender)) <$> getGroupChatItemId_ groupId senderGMId - | otherwise -> getGroupChatItemQuote_ groupId mId + | otherwise -> getGroupChatItemQuote_ currentTs groupId mId _ -> pure . ciQuote Nothing $ CIQGroupRcv Nothing CDChannelRcv GroupInfo {groupId, membership = GroupMember {memberId = userMemberId}} _s -> case memberId of Just mId | mId == userMemberId -> (`ciQuote` CIQGroupSnd) <$> getUserGroupChatItemId_ groupId - | otherwise -> getGroupChatItemQuote_ groupId mId + | otherwise -> getGroupChatItemQuote_ currentTs groupId mId _ -> pure . ciQuote Nothing $ CIQGroupRcv Nothing where ciQuote :: Maybe ChatItemId -> CIQDirection c -> CIQuote c @@ -695,8 +696,8 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe db "SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND shared_msg_id = ? AND item_sent = ? AND group_member_id = ?" (userId, groupId, msgId, MDRcv, groupMemberId) - getGroupChatItemQuote_ :: Int64 -> MemberId -> IO (CIQuote 'CTGroup) - getGroupChatItemQuote_ groupId mId = do + getGroupChatItemQuote_ :: UTCTime -> Int64 -> MemberId -> IO (CIQuote 'CTGroup) + getGroupChatItemQuote_ currentTs groupId mId = do ciQuoteGroup <$> DB.query db @@ -706,6 +707,7 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe 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, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, 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 FROM group_members m @@ -721,7 +723,7 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe where ciQuoteGroup :: [Only (Maybe ChatItemId) :. GroupMemberRow] -> CIQuote 'CTGroup ciQuoteGroup [] = ciQuote Nothing $ CIQGroupRcv Nothing - ciQuoteGroup ((Only itemId :. memberRow) : _) = ciQuote itemId . CIQGroupRcv . Just $ toGroupMember userContactId memberRow + ciQuoteGroup ((Only itemId :. memberRow) : _) = ciQuote itemId . CIQGroupRcv . Just $ toGroupMember currentTs userContactId memberRow getChatPreviews :: DB.Connection -> StoreCxt -> User -> Bool -> PaginationByTime -> ChatListQuery -> IO [Either StoreError AChat] getChatPreviews db cxt user withPCC pagination query = do @@ -1111,22 +1113,25 @@ toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentTex ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} getContactRequestChatPreviews_ :: DB.Connection -> User -> PaginationByTime -> ChatListQuery -> IO [AChatPreviewData] -getContactRequestChatPreviews_ db User {userId} pagination clq = case clq of - CLQFilters {favorite = False, unread = False} -> map toPreview <$> getPreviews "" - CLQFilters {favorite = True, unread = False} -> pure [] - CLQFilters {favorite = False, unread = True} -> map toPreview <$> getPreviews "" - CLQFilters {favorite = True, unread = True} -> map toPreview <$> getPreviews "" - CLQSearch {search} -> map toPreview <$> getPreviews search +getContactRequestChatPreviews_ db User {userId} pagination clq = do + currentTs <- getCurrentTime + case clq of + CLQFilters {favorite = False, unread = False} -> map (toPreview currentTs) <$> getPreviews "" + CLQFilters {favorite = True, unread = False} -> pure [] + CLQFilters {favorite = False, unread = True} -> map (toPreview currentTs) <$> getPreviews "" + CLQFilters {favorite = True, unread = True} -> map (toPreview currentTs) <$> getPreviews "" + CLQSearch {search} -> map (toPreview currentTs) <$> getPreviews search where query = [sql| SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, - cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, cr.xcontact_id, + cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version + cr.peer_chat_min_version, cr.peer_chat_max_version, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx FROM contact_requests cr JOIN contact_profiles p ON p.contact_profile_id = cr.contact_profile_id JOIN user_contact_links uc ON uc.user_contact_link_id = cr.user_contact_link_id @@ -1148,9 +1153,9 @@ getContactRequestChatPreviews_ db User {userId} pagination clq = case clq of PTLast count -> DB.query db (query <> " ORDER BY cr.updated_at DESC LIMIT ?") (params search :. Only count) PTAfter ts count -> DB.query db (query <> " AND cr.updated_at > ? ORDER BY cr.updated_at ASC LIMIT ?") (params search :. (ts, count)) PTBefore ts count -> DB.query db (query <> " AND cr.updated_at < ? ORDER BY cr.updated_at DESC LIMIT ?") (params search :. (ts, count)) - toPreview :: ContactRequestRow -> AChatPreviewData - toPreview cReqRow = - let cReq@UserContactRequest {updatedAt} = toContactRequest cReqRow + toPreview :: UTCTime -> ContactRequestRow -> AChatPreviewData + toPreview now cReqRow = + let cReq@UserContactRequest {updatedAt} = toContactRequest now cReqRow aChat = AChat SCTContactRequest $ Chat (ContactRequest cReq) [] emptyChatStats in ACPD SCTContactRequest $ ContactRequestPD updatedAt aChat @@ -2358,9 +2363,9 @@ toGroupChatItem ) = do chatItem $ fromRight invalid $ dbParseACIContent itemContentText where - member_ = toMaybeGroupMember userContactId memberRow_ - quotedMember_ = toMaybeGroupMember userContactId quotedMemberRow_ - deletedByGroupMember_ = toMaybeGroupMember userContactId deletedByGroupMemberRow_ + member_ = toMaybeGroupMember currentTs userContactId memberRow_ + quotedMember_ = toMaybeGroupMember currentTs userContactId quotedMemberRow_ + deletedByGroupMember_ = toMaybeGroupMember currentTs userContactId deletedByGroupMemberRow_ invalid = ACIContent msgDir $ CIInvalidJSON itemContentText chatItem itemContent = case (itemContent, itemStatus, member_, fileStatus_) of (ACIContent SMDSnd ciContent, ACIStatus SMDSnd ciStatus, _, Just (AFS SMDSnd fileStatus)) -> @@ -3036,6 +3041,7 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do 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, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, 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, -- quoted ChatItem @@ -3044,12 +3050,14 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do rm.group_member_id, rm.group_id, rm.index_in_group, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, rm.member_status, rm.show_messages, rm.member_restriction, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.short_descr, rp.image, rp.contact_link, rp.chat_peer_type, rp.local_alias, rp.preferences, + rp.badge_proof, rp.badge_pres_header, rp.badge_expiry, rp.badge_type, rp.badge_verified, rp.badge_extra, rp.badge_master_key, rp.badge_signature, rp.badge_key_idx, rm.created_at, rm.updated_at, rm.support_chat_ts, rm.support_chat_items_unread, rm.support_chat_items_member_attention, rm.support_chat_items_mentions, rm.support_chat_last_msg_from_member_ts, rm.member_pub_key, rm.relay_link, -- deleted by GroupMember dbm.group_member_id, dbm.group_id, dbm.index_in_group, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, dbp.display_name, dbp.full_name, dbp.short_descr, dbp.image, dbp.contact_link, dbp.chat_peer_type, dbp.local_alias, dbp.preferences, + dbp.badge_proof, dbp.badge_pres_header, dbp.badge_expiry, dbp.badge_type, dbp.badge_verified, dbp.badge_extra, dbp.badge_master_key, dbp.badge_signature, dbp.badge_key_idx, dbm.created_at, dbm.updated_at, dbm.support_chat_ts, dbm.support_chat_items_unread, dbm.support_chat_items_member_attention, dbm.support_chat_items_mentions, dbm.support_chat_last_msg_from_member_ts, dbm.member_pub_key, dbm.relay_link FROM chat_items i diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index a8bb0da945..4c9a1b1c91 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_public_group_access +import Simplex.Chat.Store.Postgres.Migrations.M20260516_supporter_badges 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_public_group_access", m20260515_public_group_access, Just down_m20260515_public_group_access) + ("20260515_public_group_access", m20260515_public_group_access, Just down_m20260515_public_group_access), + ("20260516_supporter_badges", m20260516_supporter_badges, Just down_m20260516_supporter_badges) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260516_supporter_badges.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260516_supporter_badges.hs new file mode 100644 index 0000000000..ffc3122e3f --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260516_supporter_badges.hs @@ -0,0 +1,35 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260516_supporter_badges where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260516_supporter_badges :: Text +m20260516_supporter_badges = + [r| +ALTER TABLE contact_profiles ADD COLUMN badge_proof BYTEA; +ALTER TABLE contact_profiles ADD COLUMN badge_pres_header BYTEA; +ALTER TABLE contact_profiles ADD COLUMN badge_expiry TIMESTAMPTZ; +ALTER TABLE contact_profiles ADD COLUMN badge_type TEXT; +ALTER TABLE contact_profiles ADD COLUMN badge_verified SMALLINT; +ALTER TABLE contact_profiles ADD COLUMN badge_extra TEXT; +ALTER TABLE contact_profiles ADD COLUMN badge_master_key BYTEA; +ALTER TABLE contact_profiles ADD COLUMN badge_signature BYTEA; +ALTER TABLE contact_profiles ADD COLUMN badge_key_idx BIGINT; +|] + +down_m20260516_supporter_badges :: Text +down_m20260516_supporter_badges = + [r| +ALTER TABLE contact_profiles DROP COLUMN badge_key_idx; +ALTER TABLE contact_profiles DROP COLUMN badge_signature; +ALTER TABLE contact_profiles DROP COLUMN badge_master_key; +ALTER TABLE contact_profiles DROP COLUMN badge_extra; +ALTER TABLE contact_profiles DROP COLUMN badge_verified; +ALTER TABLE contact_profiles DROP COLUMN badge_type; +ALTER TABLE contact_profiles DROP COLUMN badge_proof; +ALTER TABLE contact_profiles DROP COLUMN badge_pres_header; +ALTER TABLE contact_profiles DROP COLUMN badge_expiry; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index cc3543e8a8..68c43efa19 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -531,7 +531,16 @@ CREATE TABLE test_chat_schema.contact_profiles ( preferences text, contact_link bytea, short_descr text, - chat_peer_type text + chat_peer_type text, + badge_proof bytea, + badge_pres_header bytea, + badge_expiry timestamp with time zone, + badge_type text, + badge_verified smallint, + badge_extra text, + badge_master_key bytea, + badge_signature bytea, + badge_key_idx bigint ); diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index d432067866..bfd198d885 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -43,6 +43,7 @@ module Simplex.Chat.Store.Profiles updateUserGroupReceipts, updateUserAutoAcceptMemberContacts, updateUserProfile, + setUserBadge, setUserProfileContactLink, getUserContactProfiles, createUserContactLink, @@ -97,6 +98,7 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time.Clock (UTCTime (..), getCurrentTime) +import Simplex.Chat.Badges (LocalBadge, localBadgeToRow) import Simplex.Chat.Call import Simplex.Chat.Messages import Simplex.Chat.Operators @@ -162,7 +164,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 currentTs $ (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) :. localBadgeToRow Nothing -- TODO [mentions] getUsersInfo :: DB.Connection -> IO [UserInfo] @@ -196,8 +198,9 @@ getUsersInfo db = getUsers db >>= mapM getUserInfo pure UserInfo {user, unreadCount = fromMaybe 0 ctCount + fromMaybe 0 gCount} getUsers :: DB.Connection -> IO [User] -getUsers db = - map toUser <$> DB.query_ db userQuery +getUsers db = do + now <- getCurrentTime + map (toUser now) <$> DB.query_ db userQuery setActiveUser :: DB.Connection -> User -> IO User setActiveUser db user@User {userId} = do @@ -214,13 +217,15 @@ getNextActiveOrder db = do else pure $ order + 1 getUser :: DB.Connection -> UserId -> ExceptT StoreError IO User -getUser db userId = - ExceptT . firstRow toUser (SEUserNotFound userId) $ +getUser db userId = do + now <- liftIO getCurrentTime + ExceptT . firstRow (toUser now) (SEUserNotFound userId) $ DB.query db (userQuery <> " WHERE u.user_id = ?") (Only userId) getRelayUser :: DB.Connection -> ExceptT StoreError IO User -getRelayUser db = - ExceptT . firstRow toUser SERelayUserNotFound $ +getRelayUser db = do + now <- liftIO getCurrentTime + ExceptT . firstRow (toUser now) SERelayUserNotFound $ DB.query_ db (userQuery <> " WHERE u.is_user_chat_relay = 1") getUserIdByName :: DB.Connection -> UserName -> ExceptT StoreError IO Int64 @@ -229,38 +234,45 @@ getUserIdByName db uName = DB.query db "SELECT user_id FROM users WHERE local_display_name = ?" (Only uName) getUserByAConnId :: DB.Connection -> AgentConnId -> IO (Maybe User) -getUserByAConnId db agentConnId = - maybeFirstRow toUser $ +getUserByAConnId db agentConnId = do + now <- getCurrentTime + maybeFirstRow (toUser now) $ DB.query db (userQuery <> " JOIN connections c ON c.user_id = u.user_id WHERE c.agent_conn_id = ?") (Only agentConnId) getUserByASndFileId :: DB.Connection -> AgentSndFileId -> IO (Maybe User) -getUserByASndFileId db aSndFileId = - maybeFirstRow toUser $ +getUserByASndFileId db aSndFileId = do + now <- getCurrentTime + maybeFirstRow (toUser now) $ DB.query db (userQuery <> " JOIN files f ON f.user_id = u.user_id WHERE f.agent_snd_file_id = ?") (Only aSndFileId) getUserByARcvFileId :: DB.Connection -> AgentRcvFileId -> IO (Maybe User) -getUserByARcvFileId db aRcvFileId = - maybeFirstRow toUser $ +getUserByARcvFileId db aRcvFileId = do + now <- getCurrentTime + maybeFirstRow (toUser now) $ DB.query db (userQuery <> " JOIN files f ON f.user_id = u.user_id JOIN rcv_files r ON r.file_id = f.file_id WHERE r.agent_rcv_file_id = ?") (Only aRcvFileId) getUserByContactId :: DB.Connection -> ContactId -> ExceptT StoreError IO User -getUserByContactId db contactId = - ExceptT . firstRow toUser (SEUserNotFoundByContactId contactId) $ +getUserByContactId db contactId = do + now <- liftIO getCurrentTime + ExceptT . firstRow (toUser now) (SEUserNotFoundByContactId contactId) $ DB.query db (userQuery <> " JOIN contacts ct ON ct.user_id = u.user_id WHERE ct.contact_id = ? AND ct.deleted = 0") (Only contactId) getUserByGroupId :: DB.Connection -> GroupId -> ExceptT StoreError IO User -getUserByGroupId db groupId = - ExceptT . firstRow toUser (SEUserNotFoundByGroupId groupId) $ +getUserByGroupId db groupId = do + now <- liftIO getCurrentTime + ExceptT . firstRow (toUser now) (SEUserNotFoundByGroupId groupId) $ DB.query db (userQuery <> " JOIN groups g ON g.user_id = u.user_id WHERE g.group_id = ?") (Only groupId) getUserByNoteFolderId :: DB.Connection -> NoteFolderId -> ExceptT StoreError IO User -getUserByNoteFolderId db contactId = - ExceptT . firstRow toUser (SEUserNotFoundByContactId contactId) $ +getUserByNoteFolderId db contactId = do + now <- liftIO getCurrentTime + ExceptT . firstRow (toUser now) (SEUserNotFoundByContactId contactId) $ DB.query db (userQuery <> " JOIN note_folders nf ON nf.user_id = u.user_id WHERE nf.note_folder_id = ?") (Only contactId) getUserByFileId :: DB.Connection -> FileTransferId -> ExceptT StoreError IO User -getUserByFileId db fileId = - ExceptT . firstRow toUser (SEUserNotFoundByFileId fileId) $ +getUserByFileId db fileId = do + now <- liftIO getCurrentTime + ExceptT . firstRow (toUser now) (SEUserNotFoundByFileId fileId) $ DB.query db (userQuery <> " JOIN files f ON f.user_id = u.user_id WHERE f.file_id = ?") (Only fileId) getUserFileInfo :: DB.Connection -> User -> IO [CIFileInfo] @@ -309,10 +321,10 @@ updateUserAutoAcceptMemberContacts db User {userId} autoAccept = updateUserProfile :: DB.Connection -> User -> Profile -> ExceptT StoreError IO User updateUserProfile db user p' | displayName == newName = liftIO $ do - updateContactProfile_ db userId profileId p' currentTs <- getCurrentTime + updateUserProfileFields_' db userId profileId p' currentTs userMemberProfileUpdatedAt' <- updateUserMemberProfileUpdatedAt_ currentTs - pure user {profile, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'} + pure user {profile = (toLocalProfile profileId p' localAlias currentTs (Just False)) {localBadge}, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'} | otherwise = checkConstraint SEDuplicateName . liftIO $ do currentTs <- getCurrentTime @@ -322,9 +334,9 @@ updateUserProfile db user p' db "INSERT INTO display_names (local_display_name, ldn_base, user_id, created_at, updated_at) VALUES (?,?,?,?,?)" (newName, newName, userId, currentTs, currentTs) - updateContactProfile_' db userId profileId p' currentTs + updateUserProfileFields_' db userId profileId p' currentTs updateContactLDN_ db user userContactId localDisplayName newName currentTs - pure user {localDisplayName = newName, profile, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'} + pure user {localDisplayName = newName, profile = (toLocalProfile profileId p' localAlias currentTs (Just False)) {localBadge}, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'} where updateUserMemberProfileUpdatedAt_ currentTs | userMemberProfileChanged = do @@ -332,11 +344,38 @@ updateUserProfile db user p' pure $ Just currentTs | otherwise = pure userMemberProfileUpdatedAt userMemberProfileChanged = newName /= displayName || fn' /= fullName || d' /= shortDescr || img' /= image - User {userId, userContactId, localDisplayName, profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, localAlias}, userMemberProfileUpdatedAt} = user + User {userId, userContactId, localDisplayName, profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, localBadge, localAlias}, userMemberProfileUpdatedAt} = user Profile {displayName = newName, fullName = fn', shortDescr = d', image = img', preferences} = p' - profile = toLocalProfile profileId p' localAlias fullPreferences = fullPreferences' preferences +-- own profile field update; leaves the badge columns alone (the credential is owned by setUserBadge/addUserBadge) +updateUserProfileFields_' :: DB.Connection -> UserId -> ProfileId -> Profile -> UTCTime -> IO () +updateUserProfileFields_' db userId profileId Profile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType} updatedAt = + DB.execute + db + [sql| + UPDATE contact_profiles + SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = ?, preferences = ?, chat_peer_type = ?, updated_at = ? + WHERE user_id = ? AND contact_profile_id = ? + |] + ((displayName, fullName, shortDescr, image, contactLink, preferences, peerType, updatedAt) :. (userId, profileId)) + +-- store the user's own badge credential; touches only the badge columns. +-- bumps user_member_profile_updated_at so groups receive the updated profile (with the badge) on the next message. +setUserBadge :: DB.Connection -> User -> Maybe LocalBadge -> IO User +setUserBadge db user@User {userId, profile = p@LocalProfile {profileId}} localBadge = do + ts <- getCurrentTime + DB.execute + db + [sql| + UPDATE contact_profiles + SET badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?, updated_at = ? + WHERE user_id = ? AND contact_profile_id = ? + |] + (localBadgeToRow localBadge :. (ts, userId, profileId)) + DB.execute db "UPDATE users SET user_member_profile_updated_at = ? WHERE user_id = ?" (ts, userId) + pure (user :: User) {profile = p {localBadge}, userMemberProfileUpdatedAt = Just ts} + setUserProfileContactLink :: DB.Connection -> User -> Maybe UserContactLink -> IO User setUserProfileContactLink db user@User {userId, profile = p@LocalProfile {profileId}} ucl_ = do ts <- getCurrentTime @@ -366,7 +405,7 @@ getUserContactProfiles db User {userId} = (Only userId) where toContactProfile :: (ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe Preferences) -> Profile - toContactProfile (displayName, fullName, shortDescr, image, contactLink, peerType, preferences) = Profile {displayName, fullName, shortDescr, image, contactLink, peerType, preferences} + toContactProfile (displayName, fullName, shortDescr, image, contactLink, peerType, preferences) = Profile {displayName, fullName, shortDescr, image, contactLink, peerType, preferences, badge = Nothing} createUserContactLink :: DB.Connection -> User -> ConnId -> CreatedLinkContact -> SubscriptionMode -> ExceptT StoreError IO () createUserContactLink db User {userId} agentConnId (CCLink cReq shortLink) subMode = diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 89ef373af8..5bf628b062 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_public_group_access +import Simplex.Chat.Store.SQLite.Migrations.M20260516_supporter_badges 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_public_group_access", m20260515_public_group_access, Just down_m20260515_public_group_access) + ("20260515_public_group_access", m20260515_public_group_access, Just down_m20260515_public_group_access), + ("20260516_supporter_badges", m20260516_supporter_badges, Just down_m20260516_supporter_badges) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260516_supporter_badges.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260516_supporter_badges.hs new file mode 100644 index 0000000000..d263d63a2b --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260516_supporter_badges.hs @@ -0,0 +1,34 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260516_supporter_badges where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20260516_supporter_badges :: Query +m20260516_supporter_badges = + [sql| +ALTER TABLE contact_profiles ADD COLUMN badge_proof BLOB; +ALTER TABLE contact_profiles ADD COLUMN badge_pres_header BLOB; +ALTER TABLE contact_profiles ADD COLUMN badge_expiry TEXT; +ALTER TABLE contact_profiles ADD COLUMN badge_type TEXT; +ALTER TABLE contact_profiles ADD COLUMN badge_verified INTEGER; +ALTER TABLE contact_profiles ADD COLUMN badge_extra TEXT; +ALTER TABLE contact_profiles ADD COLUMN badge_master_key BLOB; +ALTER TABLE contact_profiles ADD COLUMN badge_signature BLOB; +ALTER TABLE contact_profiles ADD COLUMN badge_key_idx INTEGER; +|] + +down_m20260516_supporter_badges :: Query +down_m20260516_supporter_badges = + [sql| +ALTER TABLE contact_profiles DROP COLUMN badge_key_idx; +ALTER TABLE contact_profiles DROP COLUMN badge_signature; +ALTER TABLE contact_profiles DROP COLUMN badge_master_key; +ALTER TABLE contact_profiles DROP COLUMN badge_extra; +ALTER TABLE contact_profiles DROP COLUMN badge_verified; +ALTER TABLE contact_profiles DROP COLUMN badge_type; +ALTER TABLE contact_profiles DROP COLUMN badge_expiry; +ALTER TABLE contact_profiles DROP COLUMN badge_proof; +ALTER TABLE contact_profiles DROP COLUMN badge_pres_header; +|] 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 14c9226d2c..803e012773 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -125,6 +125,7 @@ Query: cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, -- Connection 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, @@ -156,11 +157,13 @@ Query: 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, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, + pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, 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 GroupMember 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, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, 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 FROM group_members m @@ -394,10 +397,11 @@ Query: SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, - cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, cr.xcontact_id, + cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version + cr.peer_chat_min_version, cr.peer_chat_max_version, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx FROM contact_requests cr JOIN contact_profiles p USING (contact_profile_id) WHERE cr.user_id = ? @@ -462,7 +466,16 @@ Query: short_descr = ?, image = ?, contact_link = ?, - updated_at = ? + updated_at = ?, + badge_proof = ?, + badge_pres_header = ?, + badge_expiry = ?, + badge_type = ?, + badge_verified = ?, + badge_extra = ?, + badge_master_key = ?, + badge_signature = ?, + badge_key_idx = ? WHERE contact_profile_id IN ( SELECT contact_profile_id FROM contact_requests @@ -673,7 +686,8 @@ Query: c.contact_profile_id, c.local_display_name, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, c.contact_used, c.contact_status, c.enable_ntfs, c.send_rcpts, c.favorite, p.preferences, c.user_preferences, c.created_at, c.updated_at, c.chat_ts, c.conn_full_link_to_connect, c.conn_short_link_to_connect, c.welcome_shared_msg_id, c.request_shared_msg_id, c.contact_request_id, c.contact_group_member_id, c.contact_grp_inv_sent, c.grp_direct_inv_link, c.grp_direct_inv_from_group_id, c.grp_direct_inv_from_group_member_id, c.grp_direct_inv_from_member_conn_id, c.grp_direct_inv_started_connection, - c.ui_themes, c.chat_deleted, c.custom_data, c.chat_item_ttl + c.ui_themes, c.chat_deleted, c.custom_data, c.chat_item_ttl, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx FROM contacts c JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id WHERE c.user_id = ? AND c.contact_id = ? AND c.contact_status = ? AND c.deleted = 0 @@ -1024,6 +1038,7 @@ Query: 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, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, 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 FROM group_members m @@ -1308,6 +1323,7 @@ Query: 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, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, 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, -- quoted ChatItem @@ -1316,12 +1332,14 @@ Query: rm.group_member_id, rm.group_id, rm.index_in_group, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, rm.member_status, rm.show_messages, rm.member_restriction, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.short_descr, rp.image, rp.contact_link, rp.chat_peer_type, rp.local_alias, rp.preferences, + rp.badge_proof, rp.badge_pres_header, rp.badge_expiry, rp.badge_type, rp.badge_verified, rp.badge_extra, rp.badge_master_key, rp.badge_signature, rp.badge_key_idx, rm.created_at, rm.updated_at, rm.support_chat_ts, rm.support_chat_items_unread, rm.support_chat_items_member_attention, rm.support_chat_items_mentions, rm.support_chat_last_msg_from_member_ts, rm.member_pub_key, rm.relay_link, -- deleted by GroupMember dbm.group_member_id, dbm.group_id, dbm.index_in_group, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, dbp.display_name, dbp.full_name, dbp.short_descr, dbp.image, dbp.contact_link, dbp.chat_peer_type, dbp.local_alias, dbp.preferences, + dbp.badge_proof, dbp.badge_pres_header, dbp.badge_expiry, dbp.badge_type, dbp.badge_verified, dbp.badge_extra, dbp.badge_master_key, dbp.badge_signature, dbp.badge_key_idx, dbm.created_at, dbm.updated_at, dbm.support_chat_ts, dbm.support_chat_items_unread, dbm.support_chat_items_member_attention, dbm.support_chat_items_mentions, dbm.support_chat_last_msg_from_member_ts, dbm.member_pub_key, dbm.relay_link FROM chat_items i @@ -1374,6 +1392,7 @@ Query: cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, -- Connection 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, @@ -1930,6 +1949,7 @@ Query: cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, -- Connection 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, @@ -2011,10 +2031,11 @@ Query: SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, - cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, cr.xcontact_id, + cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version + cr.peer_chat_min_version, cr.peer_chat_max_version, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx FROM contact_requests cr JOIN contact_profiles p ON p.contact_profile_id = cr.contact_profile_id JOIN user_contact_links uc ON uc.user_contact_link_id = cr.user_contact_link_id @@ -2040,10 +2061,11 @@ Query: SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, - cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, cr.xcontact_id, + cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version + cr.peer_chat_min_version, cr.peer_chat_max_version, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx FROM contact_requests cr JOIN contact_profiles p ON p.contact_profile_id = cr.contact_profile_id JOIN user_contact_links uc ON uc.user_contact_link_id = cr.user_contact_link_id @@ -2069,10 +2091,11 @@ Query: SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, - cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, cr.xcontact_id, + cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version + cr.peer_chat_min_version, cr.peer_chat_max_version, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx FROM contact_requests cr JOIN contact_profiles p ON p.contact_profile_id = cr.contact_profile_id JOIN user_contact_links uc ON uc.user_contact_link_id = cr.user_contact_link_id @@ -3602,7 +3625,8 @@ Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) Query: - SELECT cp.contact_profile_id, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, cp.preferences -- , ct.user_preferences + SELECT cp.contact_profile_id, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, cp.preferences, + cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx FROM contact_profiles cp WHERE cp.user_id = ? AND cp.contact_profile_id = ? @@ -4976,6 +5000,14 @@ Query: Plan: SEARCH connections_sync USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE contact_profiles + SET badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?, updated_at = ? + WHERE user_id = ? AND contact_profile_id = ? + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE contact_profiles SET contact_link = ?, updated_at = ? @@ -4994,7 +5026,8 @@ SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE contact_profiles - SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = NULL, preferences = NULL, updated_at = ? + SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = ?, preferences = ?, chat_peer_type = ?, updated_at = ?, + badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ? WHERE user_id = ? AND contact_profile_id = ? Plan: @@ -5002,7 +5035,17 @@ SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE contact_profiles - SET display_name = ?, full_name = ?, short_descr = ?, image = ?, updated_at = ? + SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = NULL, preferences = NULL, updated_at = ?, + badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ? + WHERE user_id = ? AND contact_profile_id = ? + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE contact_profiles + SET display_name = ?, full_name = ?, short_descr = ?, image = ?, updated_at = ?, + badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ? WHERE user_id = ? AND contact_profile_id = ? Plan: @@ -5319,6 +5362,7 @@ Query: 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, + pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, 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 @@ -5356,6 +5400,7 @@ Query: 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, + pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, 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 @@ -5386,6 +5431,7 @@ Query: 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, + pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, 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 @@ -5404,10 +5450,11 @@ Query: SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, - cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, cr.xcontact_id, + cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version + cr.peer_chat_min_version, cr.peer_chat_max_version, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx FROM contact_requests cr JOIN contact_profiles p USING (contact_profile_id) WHERE cr.business_group_id = ? @@ -5419,10 +5466,11 @@ Query: SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, - cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, cr.xcontact_id, + cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version + cr.peer_chat_min_version, cr.peer_chat_max_version, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx FROM contact_requests cr JOIN contact_profiles p USING (contact_profile_id) WHERE cr.user_id = ? AND cr.contact_request_id = ? @@ -5434,6 +5482,7 @@ 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, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, 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, @@ -5461,6 +5510,7 @@ 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, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, 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, @@ -5481,6 +5531,7 @@ 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, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, 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, @@ -5500,6 +5551,7 @@ 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, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, 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, @@ -5519,6 +5571,7 @@ 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, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, 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, @@ -5538,6 +5591,7 @@ 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, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, 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, @@ -5557,6 +5611,7 @@ 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, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, 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, @@ -5576,6 +5631,7 @@ 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, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, 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, @@ -5595,6 +5651,7 @@ 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, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, 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, @@ -5614,6 +5671,7 @@ 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, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, 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, @@ -5633,6 +5691,7 @@ 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, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, 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, @@ -5823,7 +5882,8 @@ 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.ui_themes, u.is_user_chat_relay, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx 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 @@ -5835,7 +5895,8 @@ 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.ui_themes, u.is_user_chat_relay, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx 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 @@ -5848,7 +5909,8 @@ 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.ui_themes, u.is_user_chat_relay, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx 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 @@ -5861,7 +5923,8 @@ 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.ui_themes, u.is_user_chat_relay, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx 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 @@ -5875,7 +5938,8 @@ 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.ui_themes, u.is_user_chat_relay, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx 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 @@ -5888,7 +5952,8 @@ 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.ui_themes, u.is_user_chat_relay, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx 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 @@ -5901,7 +5966,8 @@ 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.ui_themes, u.is_user_chat_relay, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx 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 @@ -5914,7 +5980,8 @@ 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.ui_themes, u.is_user_chat_relay, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx 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 @@ -5927,7 +5994,8 @@ 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.ui_themes, u.is_user_chat_relay, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx 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 @@ -5939,7 +6007,8 @@ 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.ui_themes, u.is_user_chat_relay, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx 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 @@ -6531,11 +6600,15 @@ Query: INSERT INTO contact_profiles (display_name, full_name, short_descr, image Plan: SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) -Query: INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, chat_peer_type, user_id, local_alias, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?) +Query: INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, chat_peer_type, user_id, local_alias, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) -Query: INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?) +Query: INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, local_alias, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) +Plan: +SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) + +Query: INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index ccff26b38d..2d7ea7ff70 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -19,7 +19,16 @@ CREATE TABLE contact_profiles( preferences TEXT, contact_link BLOB, short_descr TEXT, - chat_peer_type TEXT + chat_peer_type TEXT, + badge_proof BLOB, + badge_pres_header BLOB, + badge_expiry TEXT, + badge_type TEXT, + badge_verified INTEGER, + badge_extra TEXT, + badge_master_key BLOB, + badge_signature BLOB, + badge_key_idx INTEGER ) STRICT; CREATE TABLE users( user_id INTEGER PRIMARY KEY, diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index f7b525243c..bd0d22f379 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -32,6 +32,7 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (UTCTime (..), getCurrentTime) import Data.Type.Equality +import Simplex.Chat.Badges (BadgeRow, badgeToRow, rowToBadge, verifyBadge_) import Simplex.Chat.Messages import Simplex.Chat.Remote.Types import Simplex.Chat.Types @@ -406,18 +407,19 @@ setCommandConnId db User {userId} cmdId connId = do |] (connId, updatedAt, userId, cmdId) -createContact :: DB.Connection -> User -> Profile -> ExceptT StoreError IO () -createContact db user profile = do +createContact :: DB.Connection -> StoreCxt -> User -> Profile -> ExceptT StoreError IO () +createContact db cxt user profile = do currentTs <- liftIO getCurrentTime - void $ createContact_ db user profile emptyChatPrefs Nothing "" currentTs + void $ createContact_ db cxt user profile emptyChatPrefs Nothing "" currentTs -createContact_ :: DB.Connection -> User -> Profile -> Preferences -> Maybe (ACreatedConnLink, Maybe SharedMsgId) -> LocalAlias -> UTCTime -> ExceptT StoreError IO ContactId -createContact_ db User {userId} Profile {displayName, fullName, shortDescr, image, contactLink, peerType, preferences} ctUserPreferences prepared localAlias currentTs = +createContact_ :: DB.Connection -> StoreCxt -> User -> Profile -> Preferences -> Maybe (ACreatedConnLink, Maybe SharedMsgId) -> LocalAlias -> UTCTime -> ExceptT StoreError IO ContactId +createContact_ db cxt User {userId} Profile {displayName, fullName, shortDescr, image, contactLink, peerType, badge, preferences} ctUserPreferences prepared localAlias currentTs = ExceptT . withLocalDisplayName db userId displayName $ \ldn -> do + badgeVerified <- verifyBadge_ (badgeKeys cxt) badge DB.execute db - "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, chat_peer_type, user_id, local_alias, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)" - ((displayName, fullName, shortDescr, image, contactLink, peerType) :. (userId, localAlias, preferences, currentTs, currentTs)) + "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, chat_peer_type, user_id, local_alias, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" + ((displayName, fullName, shortDescr, image, contactLink, peerType) :. (userId, localAlias, preferences, currentTs, currentTs) :. badgeToRow badge badgeVerified) profileId <- insertedRowId db DB.execute db @@ -484,13 +486,13 @@ type PreparedContactRow = (Maybe AConnectionRequestUri, Maybe AConnShortLink, Ma type GroupDirectInvitationRow = (Maybe ConnReqInvitation, Maybe GroupId, Maybe GroupMemberId, Maybe Int64, BoolInt) -type ContactRow' = (ProfileId, ContactName, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, BoolInt, ContactStatus) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. PreparedContactRow :. (Maybe Int64, Maybe GroupMemberId, BoolInt) :. GroupDirectInvitationRow :. (Maybe UIThemeEntityOverrides, BoolInt, Maybe CustomData, Maybe Int64) +type ContactRow' = (ProfileId, ContactName, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, BoolInt, ContactStatus) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. PreparedContactRow :. (Maybe Int64, Maybe GroupMemberId, BoolInt) :. GroupDirectInvitationRow :. (Maybe UIThemeEntityOverrides, BoolInt, Maybe CustomData, Maybe Int64) :. BadgeRow type ContactRow = Only ContactId :. ContactRow' -toContact :: StoreCxt -> User -> [ChatTagId] -> ContactRow :. MaybeConnectionRow -> Contact -toContact cxt user chatTags ((Only contactId :. (profileId, localDisplayName, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL)) :. connRow) = - let profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, preferences, localAlias} +toContact :: UTCTime -> StoreCxt -> User -> [ChatTagId] -> ContactRow :. MaybeConnectionRow -> Contact +toContact now cxt user chatTags ((Only contactId :. (profileId, localDisplayName, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL) :. badgeRow) :. connRow) = + let profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localBadge = rowToBadge now badgeRow, preferences, localAlias} activeConn = toMaybeConnection cxt connRow chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} incognito = maybe False connIncognito activeConn @@ -516,22 +518,24 @@ toGroupDirectInvitation (Just groupDirectInvLink, fromGroupId_, fromGroupMemberI Just $ GroupDirectInvitation {groupDirectInvLink, fromGroupId_, fromGroupMemberId_, fromGroupMemberConnId_, groupDirectInvStartedConnection} getProfileById :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO LocalProfile -getProfileById db userId profileId = - ExceptT . firstRow rowToLocalProfile (SEProfileNotFound profileId) $ +getProfileById db userId profileId = do + currentTs <- liftIO getCurrentTime + ExceptT . firstRow (rowToLocalProfile currentTs) (SEProfileNotFound profileId) $ DB.query db [sql| - SELECT cp.contact_profile_id, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, cp.preferences -- , ct.user_preferences + SELECT cp.contact_profile_id, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, cp.preferences, + cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx FROM contact_profiles cp WHERE cp.user_id = ? AND cp.contact_profile_id = ? |] (userId, profileId) -type ContactRequestRow = (Int64, ContactName, AgentInvId, Maybe ContactId, Maybe GroupId, Maybe Int64) :. (Int64, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType) :. (Maybe XContactId, PQSupport, Maybe SharedMsgId, Maybe SharedMsgId, Maybe Preferences, UTCTime, UTCTime, VersionChat, VersionChat) +type ContactRequestRow = (Int64, ContactName, AgentInvId, Maybe ContactId, Maybe GroupId, Maybe Int64) :. (Int64, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias) :. (Maybe XContactId, PQSupport, Maybe SharedMsgId, Maybe SharedMsgId, Maybe Preferences, UTCTime, UTCTime, VersionChat, VersionChat) :. BadgeRow -toContactRequest :: ContactRequestRow -> UserContactRequest -toContactRequest ((contactRequestId, localDisplayName, agentInvitationId, contactId_, businessGroupId_, userContactLinkId_) :. (profileId, displayName, fullName, shortDescr, image, contactLink, peerType) :. (xContactId, pqSupport, welcomeSharedMsgId, requestSharedMsgId, preferences, createdAt, updatedAt, minVer, maxVer)) = do - let profile = Profile {displayName, fullName, shortDescr, image, contactLink, peerType, preferences} +toContactRequest :: UTCTime -> ContactRequestRow -> UserContactRequest +toContactRequest now ((contactRequestId, localDisplayName, agentInvitationId, contactId_, businessGroupId_, userContactLinkId_) :. (profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias) :. (xContactId, pqSupport, welcomeSharedMsgId, requestSharedMsgId, preferences, createdAt, updatedAt, minVer, maxVer) :. badgeRow) = do + let profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, preferences, localBadge = rowToBadge now badgeRow, localAlias} cReqChatVRange = fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer in UserContactRequest {contactRequestId, agentInvitationId, contactId_, businessGroupId_, userContactLinkId_, cReqChatVRange, localDisplayName, profileId, profile, xContactId, pqSupport, welcomeSharedMsgId, requestSharedMsgId, createdAt, updatedAt} @@ -539,17 +543,18 @@ 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.ui_themes, u.is_user_chat_relay, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx 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)) = +toUser :: UTCTime -> (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) :. BadgeRow -> User +toUser now ((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) :. badgeRow) = User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, activeOrder, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, autoAcceptMemberContacts = BoolDef autoAcceptMemberContacts, viewPwdHash, userMemberProfileUpdatedAt, uiThemes, userChatRelay = BoolDef userChatRelay} where - profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, preferences = userPreferences, localAlias = ""} + profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localBadge = rowToBadge now badgeRow, preferences = userPreferences, localAlias = ""} fullPreferences = fullPreferences' userPreferences viewPwdHash = UserPwdHash <$> viewPwdHash_ <*> viewPwdSalt_ @@ -671,11 +676,11 @@ type PublicGroupAccessRow = (Maybe Text, Maybe Text, Maybe BoolInt, Maybe BoolIn type GroupMemberRow = (GroupMemberId, GroupId, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId) :. ProfileRow :. (UTCTime, UTCTime) :. (Maybe UTCTime, Int64, Int64, Int64, Maybe UTCTime, Maybe C.PublicKeyEd25519, Maybe ShortLinkContact) -type ProfileRow = (ProfileId, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, Maybe Preferences) +type ProfileRow = (ProfileId, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, Maybe Preferences) :. BadgeRow -toGroupInfo :: StoreCxt -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo -toGroupInfo cxt userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image, groupType_, groupLink_, publicGroupId_) :. accessRow :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (BI useRelays, relayOwnStatus, uiThemes, currentMembers, publicMemberCount, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri) :. groupKeysRow :. userMemberRow) = - let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = vr cxt} +toGroupInfo :: UTCTime -> StoreCxt -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo +toGroupInfo now cxt userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image, groupType_, groupLink_, publicGroupId_) :. accessRow :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (BI useRelays, relayOwnStatus, uiThemes, currentMembers, publicMemberCount, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri) :. groupKeysRow :. userMemberRow) = + let membership = (toGroupMember now userContactId userMemberRow) {memberChatVRange = vr cxt} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} fullGroupPreferences = mergeGroupPreferences groupPreferences publicGroup = toPublicGroupProfile groupType_ groupLink_ publicGroupId_ (toPublicGroupAccess accessRow) @@ -718,9 +723,9 @@ toGroupKeys (Just publicGroupId) (rootPrivKey_, rootPubKey_, Just memberPrivKey) <$> (GRKPrivate <$> rootPrivKey_ <|> GRKPublic <$> rootPubKey_) toGroupKeys _ _ = Nothing -toGroupMember :: Int64 -> GroupMemberRow -> GroupMember -toGroupMember userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. profileRow :. (createdAt, updatedAt) :. (supportChatTs_, supportChatUnread, supportChatMemberAttention, supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink)) = - let memberProfile = rowToLocalProfile profileRow +toGroupMember :: UTCTime -> Int64 -> GroupMemberRow -> GroupMember +toGroupMember now userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. profileRow :. (createdAt, updatedAt) :. (supportChatTs_, supportChatUnread, supportChatMemberAttention, supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink)) = + let memberProfile = rowToLocalProfile now profileRow memberSettings = GroupMemberSettings {showMessages} blockedByAdmin = maybe False mrsBlocked memberRestriction_ invitedBy = toInvitedBy userContactId invitedById @@ -745,6 +750,7 @@ groupMemberQuery = 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, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, 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, @@ -756,13 +762,13 @@ groupMemberQuery = LEFT JOIN connections c ON c.group_member_id = m.group_member_id |] -toContactMember :: StoreCxt -> User -> (GroupMemberRow :. MaybeConnectionRow) -> GroupMember -toContactMember cxt User {userContactId} (memberRow :. connRow) = - (toGroupMember userContactId memberRow) {activeConn = toMaybeConnection cxt connRow} +toContactMember :: UTCTime -> StoreCxt -> User -> (GroupMemberRow :. MaybeConnectionRow) -> GroupMember +toContactMember now cxt User {userContactId} (memberRow :. connRow) = + (toGroupMember now userContactId memberRow) {activeConn = toMaybeConnection cxt connRow} -rowToLocalProfile :: ProfileRow -> LocalProfile -rowToLocalProfile (profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, preferences) = - LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, preferences} +rowToLocalProfile :: UTCTime -> ProfileRow -> LocalProfile +rowToLocalProfile now ((profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, preferences) :. badgeRow) = + LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localBadge = rowToBadge now badgeRow, localAlias, preferences} toBusinessChatInfo :: BusinessChatInfoRow -> Maybe BusinessChatInfo toBusinessChatInfo (Just chatType, Just businessId, Just customerId) = Just BusinessChatInfo {chatType, businessId, customerId} @@ -789,6 +795,7 @@ groupInfoQueryFields = 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, + pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, 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 |] @@ -877,8 +884,9 @@ addGroupChatTags db g@GroupInfo {groupId} = do getGroupInfo :: DB.Connection -> StoreCxt -> User -> Int64 -> ExceptT StoreError IO GroupInfo getGroupInfo db cxt User {userId, userContactId} groupId = ExceptT $ do + currentTs <- getCurrentTime chatTags <- getGroupChatTags db groupId - firstRow (toGroupInfo cxt userContactId chatTags) (SEGroupNotFound groupId) $ + firstRow (toGroupInfo currentTs cxt userContactId chatTags) (SEGroupNotFound groupId) $ DB.query db (groupInfoQuery <> " WHERE g.group_id = ? AND g.user_id = ? AND mu.contact_id = ?") diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index b4264d121d..7155d407e8 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -40,6 +40,7 @@ import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy as LB import Data.Functor (($>)) import Data.Int (Int64) +import Data.Map.Strict (Map) import Data.Maybe (fromMaybe, isJust, isNothing) import Data.Text (Text) import qualified Data.Text as T @@ -47,6 +48,8 @@ import Data.Text.Encoding (encodeUtf8) import Data.Time.Clock (UTCTime) import Data.Typeable (Typeable) import Data.Word (Word16) +import Simplex.Chat.Badges (BadgeInfo (..), BadgeProof (..), BadgeStatus (..), LocalBadge (..), localBadgeInfo, localBadgeStatus, mkBadgeStatus, verifyBadge) +import Simplex.Messaging.Crypto.BBS (BBSPublicKey) import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme @@ -367,7 +370,7 @@ data UserContactRequest = UserContactRequest cReqChatVRange :: VersionRangeChat, localDisplayName :: ContactName, profileId :: Int64, - profile :: Profile, + profile :: LocalProfile, createdAt :: UTCTime, updatedAt :: UTCTime, xContactId :: Maybe XContactId, @@ -685,7 +688,8 @@ data Profile = Profile image :: Maybe ImageData, contactLink :: Maybe ConnLinkContact, preferences :: Maybe Preferences, - peerType :: Maybe ChatPeerType + peerType :: Maybe ChatPeerType, + badge :: Maybe BadgeProof -- fields that should not be read into this data type to prevent sending them as part of profile to contacts: -- - contact_profile_id -- - incognito @@ -718,7 +722,7 @@ instance TextEncoding ChatPeerType where profileFromName :: ContactName -> Profile profileFromName displayName = - Profile {displayName, fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, preferences = Nothing, peerType = Nothing} + Profile {displayName, fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, preferences = Nothing, peerType = Nothing, badge = Nothing} -- check if profiles match ignoring preferences profilesMatch :: LocalProfile -> LocalProfile -> Bool @@ -727,6 +731,15 @@ profilesMatch LocalProfile {displayName = n2, fullName = fn2, image = i2} = n1 == n2 && fn1 == fn2 && i1 == i2 +-- equal for profile-update detection: badge proofs are re-generated for every presentation, +-- so compare badges by disclosed info (not proof bytes) - a re-presentation of the same badge is a no-op +sameProfileContent :: Profile -> Profile -> Bool +sameProfileContent p@Profile {badge = b} p'@Profile {badge = b'} = + p {badge = Nothing} == p' {badge = Nothing} && (proofInfo <$> b) == (proofInfo <$> b') + where + proofInfo :: BadgeProof -> BadgeInfo + proofInfo (BadgeProof _ _ _ info) = info + data IncognitoProfile = NewIncognito Profile | ExistingIncognito LocalProfile fromIncognitoProfile :: IncognitoProfile -> Profile @@ -758,6 +771,7 @@ data LocalProfile = LocalProfile contactLink :: Maybe ConnLinkContact, preferences :: Maybe Preferences, peerType :: Maybe ChatPeerType, + localBadge :: Maybe LocalBadge, localAlias :: LocalAlias } deriving (Eq, Show) @@ -765,13 +779,37 @@ data LocalProfile = LocalProfile localProfileId :: LocalProfile -> ProfileId localProfileId LocalProfile {profileId} = profileId -toLocalProfile :: ProfileId -> Profile -> LocalAlias -> LocalProfile -toLocalProfile profileId Profile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType} localAlias = - LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, preferences, peerType, localAlias} +toLocalProfile :: ProfileId -> Profile -> LocalAlias -> UTCTime -> Maybe Bool -> LocalProfile +toLocalProfile profileId Profile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType, badge} localAlias now verified = + LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, preferences, peerType, localBadge, localAlias} + where + localBadge = (\b@(BadgeProof _ _ _ info) -> PeerBadge b (mkBadgeStatus now verified info)) <$> badge fromLocalProfile :: LocalProfile -> Profile -fromLocalProfile LocalProfile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType} = - Profile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType} +fromLocalProfile LocalProfile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType, localBadge} = + Profile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType, badge = localBadge >>= wireBadge} + where + -- any stored peer proof rides the wire (receivers verify independently); the own credential is presented fresh, and a display-only badge never sends + wireBadge :: LocalBadge -> Maybe BadgeProof + wireBadge = \case + PeerBadge b _ -> Just b + OwnBadge _ _ -> Nothing + ShownBadge _ _ -> Nothing + +profileBadgeVerified :: Map Int BBSPublicKey -> LocalProfile -> Profile -> IO (Maybe Bool) +profileBadgeVerified keys LocalProfile {localBadge} Profile {badge = newBadge} = + case (localBadge, newBadge) of + (_, Nothing) -> pure (Just False) + -- an unchanged badge that verified before stays verified; failed or unknown-key badges + -- are re-verified, so an unknown key heals once an app update adds it + (Just lb, Just (BadgeProof _ _ _ newInfo)) + | localBadgeInfo lb == newInfo && localBadgeStatus lb `notElem` [BSFailed, BSUnknownKey] -> pure (Just True) + (_, Just newB) -> verifyBadge keys newB + +-- a failed or unknown-key badge is re-verified on the next profile update even when its disclosed content +-- is unchanged, so it heals once an app update adds the issuer key +badgeNeedsReverify :: LocalProfile -> Bool +badgeNeedsReverify LocalProfile {localBadge} = maybe False ((`elem` [BSFailed, BSUnknownKey]) . localBadgeStatus) localBadge data GroupType = GTChannel @@ -2035,7 +2073,7 @@ type VersionRangeChat = VersionRange ChatVersion -- | Store-wide context passed to store functions in place of the bare `vr` -- parameter. Built from config by mkStoreCxt; more fields are added here over time. -newtype StoreCxt = StoreCxt {vr :: VersionRangeChat} +data StoreCxt = StoreCxt {vr :: VersionRangeChat, badgeKeys :: Map Int BBSPublicKey} pattern VersionChat :: Word16 -> VersionChat pattern VersionChat v = Version v diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 838d15245a..004f6af825 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -43,6 +43,7 @@ import Simplex.Chat.Controller import Simplex.Chat.Help import Simplex.Chat.Library.Commands (maxImageSize) import Simplex.Chat.Markdown +import Simplex.Chat.Badges (BadgeInfo (..), BadgeStatus (..), BadgeType (..), LocalBadge, localBadgeInfo, localBadgeStatus) import Simplex.Chat.Messages hiding (NewChatItem (..)) import Simplex.Chat.Messages.CIContent import Simplex.Chat.Operators @@ -111,7 +112,7 @@ chatErrorToView isCmd ChatConfig {logLevel, testView} = viewChatError isCmd logL chatResponseToView :: (Maybe RemoteHostId, Maybe User) -> ChatConfig -> Bool -> CurrentTime -> TimeZone -> Maybe RemoteHostId -> ChatResponse -> [StyledString] chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveItems ts tz outputRH = \case - CRActiveUser User {profile, uiThemes} -> viewUserProfile (fromLocalProfile profile) <> viewUITheme uiThemes + CRActiveUser User {profile = p@LocalProfile {localBadge}, uiThemes} -> viewUserProfile localBadge (fromLocalProfile p) <> viewUITheme uiThemes CRUsersList users -> viewUsersList users CRChatStarted -> ["chat started"] CRChatRunning -> ["chat is running"] @@ -193,7 +194,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte CRSentGroupInvitation u g c _ -> ttyUser u $ viewSentGroupInvitation g c CRFileTransferStatus u ftStatus -> ttyUser u $ viewFileTransferStatus ftStatus CRFileTransferStatusXFTP u ci -> ttyUser u $ viewFileTransferStatusXFTP ci - CRUserProfile u p -> ttyUser u $ viewUserProfile p + CRUserProfile u@User {profile = LocalProfile {localBadge}} p -> ttyUser u $ viewUserProfile localBadge p CRUserProfileNoChange u -> ttyUser u ["user profile did not change"] CRUserPrivacy u u' -> ttyUserPrefix hu outputRH u $ viewUserPrivacy u u' CRVersionInfo info _ _ -> viewVersionInfo logLevel info @@ -452,7 +453,7 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView} CEvtRcvFileProgressXFTP {} -> [] CEvtContactUpdated {user = u, fromContact = c, toContact = c'} -> ttyUser u $ viewContactUpdated c c' <> viewContactPrefsUpdated u c c' CEvtGroupMemberUpdated {} -> [] - CEvtReceivedContactRequest u UserContactRequest {localDisplayName = c, profile} _chat -> ttyUser u $ viewReceivedContactRequest c profile + CEvtReceivedContactRequest u UserContactRequest {localDisplayName = c, profile} _chat -> ttyUser u $ viewReceivedContactRequest c (fromLocalProfile profile) CEvtRcvFileStart u ci -> ttyUser u $ receivingFile_' hu testView "started" ci CEvtRcvFileComplete u ci -> ttyUser u $ receivingFile_' hu testView "completed" ci CEvtRcvStandaloneFileComplete u _ ft -> ttyUser u $ receivingFileStandalone "completed" ft @@ -618,8 +619,8 @@ 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) - | activeUser || isNothing viewPwdHash = Just $ ttyFullName n fullName shortDescr <> infoStr <> bot + userInfo (UserInfo User {localDisplayName = n, profile = LocalProfile {fullName, shortDescr, peerType, localBadge}, activeUser, showNtfs, viewPwdHash} count) + | activeUser || isNothing viewPwdHash = Just $ ttyFullNameBadge n fullName shortDescr localBadge <> infoStr <> bot | otherwise = Nothing where infoStr = if null info then "" else " (" <> mconcat (intersperse ", " info) <> ")" @@ -1507,9 +1508,9 @@ viewContactAndMemberAssociated ct g m ct' = "use " <> ttyToContact' ct' <> highlight' "" <> " to send messages" ] -viewUserProfile :: Profile -> [StyledString] -viewUserProfile Profile {displayName, fullName, shortDescr, peerType, preferences} = - [ "user profile: " <> ttyFullName displayName fullName shortDescr <> bot, +viewUserProfile :: Maybe LocalBadge -> Profile -> [StyledString] +viewUserProfile localBadge Profile {displayName, fullName, shortDescr, peerType, preferences} = + [ "user profile: " <> ttyFullNameBadge displayName fullName shortDescr localBadge <> bot, "use " <> highlight' "/p []" <> " to change it" ] ++ viewCommands @@ -1752,9 +1753,22 @@ smpProxyModeStr :: SMPProxyMode -> SMPProxyFallback -> String smpProxyModeStr SPMNever _ = "private message routing disabled." smpProxyModeStr mode fallback = T.unpack $ safeDecodeUtf8 $ "private message routing mode: " <> strEncode mode <> ", fallback: " <> strEncode fallback +viewContactBadge :: Maybe LocalBadge -> [StyledString] +viewContactBadge = maybe [] $ \lb -> + let BadgeInfo {badgeType, badgeExpiry} = localBadgeInfo lb + st = case localBadgeStatus lb of + BSActive -> "active" + BSExpired -> "expired" + BSExpiredOld -> "expired (old)" + BSFailed -> "verification failed" + BSUnknownKey -> "unknown key" + expiry = maybe "no expiry" (("expires " <>) . T.pack . formatTime defaultTimeLocale "%Y-%m-%d") badgeExpiry + in [plain (textEncode badgeType <> " badge - " <> st), plain expiry] + viewContactInfo :: Contact -> Maybe ConnectionStats -> Maybe Profile -> [StyledString] -viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, contactLink}, activeConn, uiThemes, customData} stats incognitoProfile = +viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, contactLink, localBadge}, activeConn, uiThemes, customData} stats incognitoProfile = ["contact ID: " <> sShow contactId] + <> viewContactBadge localBadge <> maybe [] viewConnectionStats stats <> maybe [] (\l -> ["contact address: " <> (plain . strEncode) (simplexChatContact' l)]) contactLink <> maybe @@ -1787,10 +1801,11 @@ viewCustomData :: Maybe CustomData -> [StyledString] viewCustomData = maybe [] (\(CustomData v) -> ["custom data: " <> viewJSON (J.Object v)]) viewGroupMemberInfo :: GroupInfo -> GroupMember -> Maybe ConnectionStats -> [StyledString] -viewGroupMemberInfo GroupInfo {groupId} m@GroupMember {groupMemberId, memberProfile = LocalProfile {localAlias, contactLink}, activeConn} stats = +viewGroupMemberInfo GroupInfo {groupId} m@GroupMember {groupMemberId, memberProfile = LocalProfile {localAlias, contactLink, localBadge}, activeConn} stats = [ "group ID: " <> sShow groupId, "member ID: " <> sShow groupMemberId ] + <> viewContactBadge localBadge <> maybe ["member not connected"] viewConnectionStats stats <> maybe [] (\l -> ["contact address: " <> (plain . strEncode) (simplexChatContact' l)]) contactLink <> ["alias: " <> plain localAlias | localAlias /= ""] @@ -2785,9 +2800,47 @@ ttyContact = styled (colored Green) . viewName ttyContact' :: Contact -> StyledString ttyContact' Contact {localDisplayName = c} = ttyContact c +-- Supporter badge: a colored star marks an active badge (only the star is colored). +-- supporter cyan, legend blue, investor yellow, unknown cyan; business has no star. +badgeStarColor :: BadgeType -> Maybe Color +badgeStarColor = \case + BTSupporter -> Just Cyan + BTLegend -> Just Blue + BTInvestor -> Just Yellow + BTUnknown _ -> Just Cyan + +-- (star color, type word) for an active, colorable badge +activeBadge :: Maybe LocalBadge -> Maybe (Color, Text) +activeBadge lb_ = do + lb <- lb_ + case localBadgeStatus lb of + BSActive -> let BadgeInfo {badgeType} = localBadgeInfo lb in (\col -> (col, textEncode badgeType)) <$> badgeStarColor badgeType + _ -> Nothing + +badgeStar :: Color -> StyledString +badgeStar col = styled (colored col) ("*" :: Text) + +-- " *" (space + colored star) for sender prefixes, "" if no active badge +badgeStarSep :: Maybe LocalBadge -> StyledString +badgeStarSep lb_ = maybe "" (\(c, _) -> " " <> badgeStar c) (activeBadge lb_) + +-- name + badge for full-name contexts: "alice (Alice, * supporter)" / "alice (* supporter)" / "alice (Alice)" / "alice" +ttyFullNameBadge :: ContactName -> Text -> Maybe Text -> Maybe LocalBadge -> StyledString +ttyFullNameBadge c fullName shortDescr lb_ = ttyContact c <> optFullNameBadge c fullName shortDescr lb_ + +optFullNameBadge :: ContactName -> Text -> Maybe Text -> Maybe LocalBadge -> StyledString +optFullNameBadge c fullName shortDescr lb_ = case activeBadge lb_ of + Nothing -> optFullName c fullName shortDescr + Just (color, typeWord) -> " (" <> nameInner <> badgeStar color <> plain (" " <> typeWord) <> ")" + where + nameInner = maybe "" (\t -> plain (t <> ", ")) innerName + innerName + | T.null fullName || c == fullName = shortDescr + | otherwise = Just fullName + ttyFullContact :: Contact -> StyledString -ttyFullContact Contact {localDisplayName, profile = LocalProfile {fullName, shortDescr}} = - ttyFullName localDisplayName fullName shortDescr +ttyFullContact Contact {localDisplayName, profile = LocalProfile {fullName, shortDescr, localBadge}} = + ttyFullNameBadge localDisplayName fullName shortDescr localBadge ttyMember :: GroupMember -> StyledString ttyMember GroupMember {localDisplayName} = ttyContact localDisplayName @@ -2816,7 +2869,8 @@ ttyQuotedMember (Just GroupMember {localDisplayName = c}) = "> " <> ttyFrom (vie ttyQuotedMember Nothing = ">" ttyFromContact :: Contact -> StyledString -ttyFromContact ct@Contact {localDisplayName = c} = ctIncognito ct <> ttyFrom (viewName c <> "> ") +ttyFromContact ct@Contact {localDisplayName = c, profile = LocalProfile {localBadge}} = + ctIncognito ct <> ttyFrom (viewName c) <> badgeStarSep localBadge <> ttyFrom "> " ttyFromContactEdited :: Contact -> StyledString ttyFromContactEdited ct@Contact {localDisplayName = c} = ctIncognito ct <> ttyFrom (viewName c <> "> [edited] ") diff --git a/tests/BadgeTests.hs b/tests/BadgeTests.hs new file mode 100644 index 0000000000..90e3e9ae7a --- /dev/null +++ b/tests/BadgeTests.hs @@ -0,0 +1,142 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DisambiguateRecordFields #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module BadgeTests (badgeTests) where + +import Data.Map.Strict (Map) +import qualified Data.Map.Strict as M +import Data.Time.Clock (UTCTime, addUTCTime, getCurrentTime, nominalDay) +import Data.Time.Clock.POSIX (posixSecondsToUTCTime) +import qualified Data.Aeson as J +import qualified Simplex.Messaging.Crypto as C +import Simplex.Chat.Badges +import Simplex.Messaging.Crypto.BBS +import Test.Hspec + +badgeTests :: Spec +badgeTests = do + it "full workflow: request, issue, verify credential, generate and verify proof" testFullWorkflow + it "should reject badge with tampered type" testTamperedType + it "should reject badge with tampered expiry" testTamperedExpiry + it "should reject badge with wrong server key" testWrongKey + it "should report a key index missing from configured keys" testUnknownKeyIdx + it "should compute badge status correctly" testExpiryCheck + it "should treat lifetime badges as always active" testLifetimeBadge + it "should accept unknown badge types" testUnknownBadgeType + it "credential serializes to a paste-able token and back" testCredentialSerialization + +proofOf :: BadgeProof -> BBSProof +proofOf (BadgeProof _ _ p _) = p + +proofInfo :: BadgeProof -> BadgeInfo +proofInfo (BadgeProof _ _ _ i) = i + +testKeyIdx :: Int +testKeyIdx = 1 + +keysFor :: BBSPublicKey -> Map Int BBSPublicKey +keysFor = M.singleton testKeyIdx + +testFullWorkflow :: IO () +testFullWorkflow = do + Right (pk, sk) <- bbsKeyGen + drg <- C.newRandom + mk <- generateMasterKey drg + let req = BadgeRequest {masterKey = mk, badgeInfo = BadgeInfo {badgeType = BTSupporter, badgeExpiry = Just futureTime, badgeExtra = ""}} + Just vreq <- verifyPayment (BPRedeemCode "TEST") req + Right cred <- issueBadge testKeyIdx sk vreq + let BadgeCredential idx mk' _ _ = cred + idx `shouldBe` testKeyIdx + mk' `shouldBe` mk + verifyCredential pk cred >>= (`shouldBe` True) + Right badge <- generateBadgeProof pk cred (BBSPresHeader "nonce-1") + -- the proof inherits the credential's key index, so receivers find the right key + let BadgeProof {badgeKeyIdx} = badge + badgeKeyIdx `shouldBe` testKeyIdx + verifyBadge (keysFor pk) badge >>= (`shouldBe` Just True) + Right badge2 <- generateBadgeProof pk cred (BBSPresHeader "nonce-2") + verifyBadge (keysFor pk) badge2 >>= (`shouldBe` Just True) + proofOf badge `shouldNotBe` proofOf badge2 + +testTamperedType :: IO () +testTamperedType = do + (pk, BadgeProof idx ph p info) <- issueBadgeProof BTSupporter (Just futureTime) + verifyBadge (keysFor pk) (BadgeProof idx ph p info {badgeType = BTLegend}) >>= (`shouldBe` Just False) + +testTamperedExpiry :: IO () +testTamperedExpiry = do + (pk, BadgeProof idx ph p info) <- issueBadgeProof BTSupporter (Just futureTime) + verifyBadge (keysFor pk) (BadgeProof idx ph p info {badgeExpiry = Just pastTime}) >>= (`shouldBe` Just False) + +testWrongKey :: IO () +testWrongKey = do + (_, badge) <- issueBadgeProof BTSupporter (Just futureTime) + Right (pk2, _) <- bbsKeyGen + verifyBadge (keysFor pk2) badge >>= (`shouldBe` Just False) + +testUnknownKeyIdx :: IO () +testUnknownKeyIdx = do + (pk, badge) <- issueBadgeProof BTSupporter (Just futureTime) + -- a key index not in the configured keys cannot be verified at all (Nothing) + verifyBadge (M.singleton (testKeyIdx + 1) pk) badge >>= (`shouldBe` Nothing) + +testExpiryCheck :: IO () +testExpiryCheck = do + now <- getCurrentTime + let info expiry = BadgeInfo {badgeType = BTSupporter, badgeExpiry = expiry, badgeExtra = ""} + futureInfo = info (Just futureTime) + mkBadgeStatus now (Just True) futureInfo `shouldBe` BSActive + mkBadgeStatus now (Just True) (info (Just (addUTCTime (-nominalDay) now))) `shouldBe` BSExpired + mkBadgeStatus now (Just True) (info (Just pastTime)) `shouldBe` BSExpiredOld + mkBadgeStatus now (Just False) futureInfo `shouldBe` BSFailed + mkBadgeStatus now Nothing futureInfo `shouldBe` BSUnknownKey + +testLifetimeBadge :: IO () +testLifetimeBadge = do + now <- getCurrentTime + (pk, badge) <- issueBadgeProof BTInvestor Nothing + verifyBadge (keysFor pk) badge >>= (`shouldBe` Just True) + mkBadgeStatus now (Just True) (proofInfo badge) `shouldBe` BSActive + +testUnknownBadgeType :: IO () +testUnknownBadgeType = do + (pk, badge) <- issueBadgeProof (BTUnknown "future_type") (Just futureTime) + verifyBadge (keysFor pk) badge >>= (`shouldBe` Just True) + +testCredentialSerialization :: IO () +testCredentialSerialization = do + Right (pk, sk) <- bbsKeyGen + drg <- C.newRandom + mk <- generateMasterKey drg + let mkCred expiry = do + Right cred <- issueBadge testKeyIdx sk (VerifiedBadgeRequest BadgeRequest {masterKey = mk, badgeInfo = BadgeInfo {badgeType = BTSupporter, badgeExpiry = expiry, badgeExtra = ""}}) + pure cred + dated <- mkCred (Just futureTime) + lifetime <- mkCred Nothing + J.eitherDecode (J.encode dated) `shouldBe` Right dated + J.eitherDecode (J.encode lifetime) `shouldBe` Right lifetime + -- a decoded credential still verifies against the issuing key + case J.eitherDecode (J.encode dated) of + Right cred -> verifyCredential pk cred >>= (`shouldBe` True) + Left e -> expectationFailure e + +-- Helpers + +futureTime :: UTCTime +futureTime = posixSecondsToUTCTime 4102444800 -- 2099-12-31 + +pastTime :: UTCTime +pastTime = posixSecondsToUTCTime 1577836800 -- 2020-01-01 + +issueBadgeProof :: BadgeType -> Maybe UTCTime -> IO (BBSPublicKey, BadgeProof) +issueBadgeProof bt expiry = do + Right (pk, sk) <- bbsKeyGen + drg <- C.newRandom + mk <- generateMasterKey drg + let vreq = VerifiedBadgeRequest BadgeRequest {masterKey = mk, badgeInfo = BadgeInfo {badgeType = bt, badgeExpiry = expiry, badgeExtra = ""}} + Right cred <- issueBadge testKeyIdx sk vreq + Right badge <- generateBadgeProof pk cred (BBSPresHeader "test-nonce") + pure (pk, badge) diff --git a/tests/Bots/BroadcastTests.hs b/tests/Bots/BroadcastTests.hs index f56a4d803d..051ee6b304 100644 --- a/tests/Bots/BroadcastTests.hs +++ b/tests/Bots/BroadcastTests.hs @@ -33,7 +33,7 @@ withBroadcastBot opts test = bot = simplexChatCore testCfg (mkChatOpts opts) $ broadcastBot opts broadcastBotProfile :: Profile -broadcastBotProfile = Profile {displayName = "broadcast_bot", fullName = "Broadcast Bot", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing} +broadcastBotProfile = Profile {displayName = "broadcast_bot", fullName = "Broadcast Bot", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing, badge = Nothing} mkBotOpts :: TestParams -> [KnownContact] -> BroadcastBotOpts mkBotOpts ps publishers = diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 7fdd34061f..a3a48e7d29 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -96,7 +96,7 @@ directoryServiceTests = do it "should update subscriber count periodically" testLinkCheckUpdatesCount directoryProfile :: Profile -directoryProfile = Profile {displayName = "SimpleX Directory", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing} +directoryProfile = Profile {displayName = "SimpleX Directory", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing, badge = Nothing} mkDirectoryOpts :: TestParams -> [KnownContact] -> Maybe KnownGroup -> Maybe FilePath -> DirectoryOpts mkDirectoryOpts TestParams {tmpPath = ps} superUsers ownersGroup webFolder = diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 2502f3e262..0e2052b259 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -1,4 +1,5 @@ {-# LANGUAGE CPP #-} +{-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} @@ -18,11 +19,18 @@ import Control.Monad.Except import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Char8 as B import qualified Data.Text as T -import Simplex.Chat.Controller (ChatConfig (..), ChatHooks (..), defaultChatHooks) +import Data.Time.Clock (UTCTime, addUTCTime, getCurrentTime, nominalDay) +import Data.Time.Clock.POSIX (posixSecondsToUTCTime) +import Data.Time.Format (defaultTimeLocale, formatTime) +import qualified Data.Map.Strict as M +import Simplex.Chat.Badges (BadgeCredential, BadgeInfo (..), BadgePurchase (..), BadgeRequest (..), BadgeType (..), generateMasterKey, issueBadge, verifyPayment) +import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatHooks (..), defaultChatHooks, mkStoreCxt) import Simplex.Chat.Options (ChatOpts (..), CoreChatOpts (..)) import Simplex.Chat.Protocol (currentChatVersion) import Simplex.Chat.Store.Shared (createContact) import Simplex.Chat.Types (ConnStatus (..), Profile (..), GroupRejectionReason (..)) +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.BBS (BBSPublicKey, BBSSecretKey, bbsKeyGen) import Simplex.Chat.Types.Shared (GroupMemberRole (..)) import Simplex.Chat.Types.UITheme import Simplex.Messaging.Agent.Env.SQLite @@ -40,6 +48,13 @@ chatProfileTests = do it "update user profile and notify contacts" testUpdateProfile it "update user profile with image" testUpdateProfileImage it "use multiword profile names" testMultiWordProfileNames + it "present supporter badge to contacts" testUserBadgeBroadcast + it "supporter badge sent to contact connecting after attach" testUserBadgeOnConnect + it "supporter badge sent to member joining via group link" testUserBadgeGroupLink + it "expired supporter badge shows as expired" testUserBadgeExpired + it "long-expired supporter badge is not presented" testUserBadgeExpiredOld + it "incognito connection does not carry supporter badge" testUserBadgeIncognito + it "supporter badge sent to contact connecting via address" testUserBadgeContactAddress describe "user contact link" $ do it "create and connect via contact link" testUserContactLink it "retry connecting via contact link" testRetryConnectingViaContactLink @@ -185,6 +200,210 @@ testUpdateProfile = bob <## "use @cat to send messages" ] +-- the test issuer key under index 1 in the test config +testBadgeKeys :: BBSPublicKey -> M.Map Int BBSPublicKey +testBadgeKeys = M.singleton 1 + +-- issue a supporter badge credential with the given expiry (test issuer) +issueTestBadge :: BBSSecretKey -> Maybe UTCTime -> IO BadgeCredential +issueTestBadge sk badgeExpiry = do + drg <- C.newRandom + mk <- generateMasterKey drg + let info = BadgeInfo {badgeType = BTSupporter, badgeExpiry, badgeExtra = ""} + Just vreq <- verifyPayment (BPRedeemCode "TEST") BadgeRequest {masterKey = mk, badgeInfo = info} + Right cred <- issueBadge 1 sk vreq + pure cred + +-- the same single-line JSON `simplex-chat badge sign` prints, pasted into the app +addTestBadge :: HasCallStack => TestCC -> BadgeCredential -> IO () +addTestBadge cc cred = do + cc ##> ("/badge add " <> T.unpack (encodeJSON cred)) + cc <## "ok" + +testUserBadgeBroadcast :: HasCallStack => TestParams -> IO () +testUserBadgeBroadcast ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg2 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile (test sk) ps + where + test sk alice bob = do + connectUsers alice bob + addTestBadge alice =<< issueTestBadge sk Nothing + -- own badge is shown (add succeeded) + alice ##> "/p" + alice <## "user profile: alice (Alice, * supporter)" + alice <## "use /p [] to change it" + -- the badge XInfo is delivered in order before this message, so the contact has stored it + alice #> "@bob hi" + bob <# "alice *> hi" + +testUserBadgeOnConnect :: HasCallStack => TestParams -> IO () +testUserBadgeOnConnect ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg2 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile (test sk) ps + where + test sk alice bob = do + addTestBadge alice =<< issueTestBadge sk Nothing + -- a contact connecting after the badge is attached receives it in the connection handshake + alice ##> "/c" + inv <- getInvitation alice + bob ##> ("/c " <> inv) + bob <## "confirmation sent!" + concurrently_ + (bob <## "alice (Alice, * supporter): contact is connected") + (alice <## "bob (Bob): contact is connected") + bob ##> "/i alice" + bob <## "contact ID: 2" + bob <## "supporter badge - active" + bob <## "no expiry" + bob <## "receiving messages via: localhost" + bob <## "sending messages via: localhost" + bob <## "you've shared main profile with this contact" + bob <## "connection not verified, use /code command to see security code" + bob <## "quantum resistant end-to-end encryption" + bob <## currentChatVRangeInfo + +testUserBadgeGroupLink :: HasCallStack => TestParams -> IO () +testUserBadgeGroupLink ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg2 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile (test sk) ps + where + test sk alice bob = do + addTestBadge alice =<< issueTestBadge sk Nothing + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob (Bob): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: bob joined the group", + do + bob <## "#team: joining the group..." + bob <## "#team: you joined the group" + ] + -- the host's profile (x.grp.link.mem) is sent over the same connection as group messages, + -- so receiving a message guarantees the badge arrived + alice #> "#team hello" + bob <# "#team alice> hello" + -- no prior contact: the host's badge arrives via the group link handshake + bob ##> "/i #team alice" + bob <## "group ID: 1" + bob <##. "member ID: " + bob <## "supporter badge - active" + bob <## "no expiry" + bob <## "receiving messages via: localhost" + bob <## "sending messages via: localhost" + bob <## "connection not verified, use /code command to see security code" + bob <## currentChatVRangeInfo + +testUserBadgeContactAddress :: HasCallStack => TestParams -> IO () +testUserBadgeContactAddress ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg2 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile (test sk) ps + where + test sk alice bob = do + addTestBadge alice =<< issueTestBadge sk Nothing + alice ##> "/ad" + (shortLink, cLink) <- getContactLinks alice True + -- the address link data carries the badge proof; the connect plan returns it verified, without crypto + bob ##> ("/_connect plan 1 " <> shortLink) + bob <## "contact address: ok to connect" + sLinkData <- getTermLine bob + sLinkData `shouldContain` "\"proof\":" + sLinkData `shouldContain` "\"localBadge\":{\"badge\":{\"badgeType\":\"supporter\"" + sLinkData `shouldContain` "\"status\":\"active\"" + bob ##> ("/c " <> cLink) + alice <#? bob + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request, you can send messages to contact" + concurrently_ + (bob <## "alice (Alice, * supporter): contact is connected") + (alice <## "bob (Bob): contact is connected") + bob ##> "/i alice" + bob <## "contact ID: 2" + bob <## "supporter badge - active" + bob <## "no expiry" + bob <## "receiving messages via: localhost" + bob <## "sending messages via: localhost" + bob <## "you've shared main profile with this contact" + bob <## "connection not verified, use /code command to see security code" + bob <## "quantum resistant end-to-end encryption" + bob <## currentChatVRangeInfo + +testUserBadgeExpired :: HasCallStack => TestParams -> IO () +testUserBadgeExpired ps = do + Right (pk, sk) <- bbsKeyGen + -- expired recently (within 31 days), so the badge is still presented and shown as expired + expiry <- addUTCTime (-2 * nominalDay) <$> getCurrentTime + testChatCfg2 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile (test sk expiry) ps + where + test sk expiry alice bob = do + addTestBadge alice =<< issueTestBadge sk (Just expiry) + -- expired badge: no star + alice ##> "/p" + alice <## "user profile: alice (Alice)" + alice <## "use /p [] to change it" + connectUsers alice bob + bob ##> "/i alice" + bob <## "contact ID: 2" + bob <## "supporter badge - expired" + bob <## ("expires " <> formatTime defaultTimeLocale "%Y-%m-%d" expiry) + bob <## "receiving messages via: localhost" + bob <## "sending messages via: localhost" + bob <## "you've shared main profile with this contact" + bob <## "connection not verified, use /code command to see security code" + bob <## "quantum resistant end-to-end encryption" + bob <## currentChatVRangeInfo + +testUserBadgeExpiredOld :: HasCallStack => TestParams -> IO () +testUserBadgeExpiredOld ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg2 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile (test sk) ps + where + test sk alice bob = do + addTestBadge alice =<< issueTestBadge sk (Just pastDate) + -- a badge that expired over a month ago is not presented to contacts at all + connectUsers alice bob + bob ##> "/i alice" + bob <## "contact ID: 2" + bob <## "receiving messages via: localhost" + bob <## "sending messages via: localhost" + bob <## "you've shared main profile with this contact" + bob <## "connection not verified, use /code command to see security code" + bob <## "quantum resistant end-to-end encryption" + bob <## currentChatVRangeInfo + pastDate = posixSecondsToUTCTime 1577836800 -- 2020-01-01 + +testUserBadgeIncognito :: HasCallStack => TestParams -> IO () +testUserBadgeIncognito ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg2 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile (test sk) ps + where + test sk alice bob = do + addTestBadge alice =<< issueTestBadge sk Nothing + -- an incognito identity must not carry the badge + bob ##> "/connect" + inv <- getInvitation bob + alice ##> ("/connect incognito " <> inv) + alice <## "confirmation sent!" + aliceIncognito <- getTermLine alice + concurrentlyN_ + [ bob <## (aliceIncognito <> ": contact is connected"), + do + alice <## ("bob (Bob): contact is connected, your incognito profile for this contact is " <> aliceIncognito) + alice <## "use /i bob to print out this incognito profile again" + ] + bob ##> ("/i " <> aliceIncognito) + bob <## "contact ID: 2" + bob <## "receiving messages via: localhost" + bob <## "sending messages via: localhost" + bob <## "you've shared main profile with this contact" + bob <## "connection not verified, use /code command to see security code" + bob <## "quantum resistant end-to-end encryption" + bob <## currentChatVRangeInfo + testUpdateProfileImage :: HasCallStack => TestParams -> IO () testUpdateProfileImage = testChat2 aliceProfile bobProfile $ @@ -279,7 +498,7 @@ testMultiWordProfileNames = aliceProfile' = baseProfile {displayName = "Alice Jones"} bobProfile' = baseProfile {displayName = "Bob James"} cathProfile' = baseProfile {displayName = "Cath Johnson"} - baseProfile = Profile {displayName = "", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = defaultPrefs} + baseProfile = Profile {displayName = "", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = defaultPrefs, badge = Nothing} testUserContactLink :: HasCallStack => TestParams -> IO () testUserContactLink = @@ -1187,13 +1406,13 @@ testPlanAddressContactViaAddress = Left _ -> error "error parsing contact link" Right cReq -> do let profile = aliceProfile {contactLink = Just cReq} - void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db user profile + void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> let TestCC {chatController = ChatController {config}} = bob in runExceptT $ createContact db (mkStoreCxt config) user profile bob @@@ [("@alice", "")] bob ##> "/delete @alice" bob <## "alice: contact is deleted" - void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db user profile + void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> let TestCC {chatController = ChatController {config}} = bob in runExceptT $ createContact db (mkStoreCxt config) user profile bob @@@ [("@alice", "")] bob ##> ("/_connect plan 1 " <> cLink) @@ -1208,7 +1427,7 @@ testPlanAddressContactViaAddress = alice ##> "/delete @bob" alice <## "bob: contact is deleted" - void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db user profile + void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> let TestCC {chatController = ChatController {config}} = bob in runExceptT $ createContact db (mkStoreCxt config) user profile bob @@@ [("@alice", "")] -- GUI api @@ -1249,13 +1468,13 @@ testPlanAddressContactViaShortAddress = Left _ -> error "error parsing contact link" Right shortLink -> do let profile = aliceProfile {contactLink = Just shortLink} - void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db user profile + void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> let TestCC {chatController = ChatController {config}} = bob in runExceptT $ createContact db (mkStoreCxt config) user profile bob @@@ [("@alice", "")] bob ##> "/delete @alice" bob <## "alice: contact is deleted" - void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db user profile + void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> let TestCC {chatController = ChatController {config}} = bob in runExceptT $ createContact db (mkStoreCxt config) user profile bob @@@ [("@alice", "")] bob ##> ("/_connect plan 1 " <> sLink) @@ -1270,7 +1489,7 @@ testPlanAddressContactViaShortAddress = alice ##> "/delete @bob" alice <## "bob: contact is deleted" - void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db user profile + void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> let TestCC {chatController = ChatController {config}} = bob in runExceptT $ createContact db (mkStoreCxt config) user profile bob @@@ [("@alice", "")] -- GUI api diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 27c36568ec..b83b79c3a9 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -85,7 +85,7 @@ chatRelayProfile :: Profile chatRelayProfile = mkProfile "relay" "Relay" 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} +mkProfile displayName descr image = Profile {displayName, fullName = "", shortDescr = Just descr, image, contactLink = Nothing, peerType = Nothing, preferences = defaultPrefs, badge = Nothing} it :: HasCallStack => String -> (ps -> Expectation) -> SpecWith (Arg (ps -> Expectation)) it name test = diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index d57411a598..bc0cc30a78 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -32,8 +32,10 @@ import Foreign.Storable (peek) import GHC.IO.Encoding (setLocaleEncoding, setFileSystemEncoding, setForeignEncoding) import JSONFixtures import Simplex.Chat +import Simplex.Chat.Badges (BadgeInfo (..), BadgeRequest (..), BadgeType (..), generateMasterKey, verifyCredential) import Simplex.Chat.Controller (ChatController (..), ChatDatabase (..)) import Simplex.Chat.Mobile hiding (error) +import Simplex.Chat.Mobile.Badges hiding (error) import Simplex.Chat.Mobile.File import Simplex.Chat.Mobile.Shared import Simplex.Chat.Mobile.WebRTC @@ -81,6 +83,8 @@ mobileTests = do describe "Parsers" $ do it "should parse server address" testChatParseServer it "should parse and sanitize URI" testChatParseUri + describe "Badges" $ do + it "should generate key and issue badge via C API, verify credential" testBadgeKeygenIssueCApi noActiveUser :: LB.ByteString noActiveUser = @@ -308,6 +312,25 @@ testChatParseUri :: TestParams -> IO () testChatParseUri _ = do pure () +-- Generate a server keypair and issue a badge credential via the C FFI, +-- constructing the request from the typed records, then verify the issued +-- credential's BBS signature on the Haskell side. +testBadgeKeygenIssueCApi :: TestParams -> IO () +testBadgeKeygenIssueCApi _ = do + g <- C.newRandom + IssuerKeyPair {publicKey, secretKey} <- ffiResult =<< (peekCString =<< cChatBadgeKeygen) + mk <- generateMasterKey g + let req = BadgeIssueReq {badgeKeyIdx = 1, secretKey, request = BadgeRequest {masterKey = mk, badgeInfo = BadgeInfo {badgeType = BTSupporter, badgeExpiry = Nothing, badgeExtra = ""}}} + cred <- ffiResult =<< (peekCString =<< cChatBadgeIssue =<< newCString (LB.unpack (J.encode req))) + verifyCredential publicKey cred `shouldReturn` True + +-- Decode an FFI `BadgeResult` envelope, returning the result or failing on error. +ffiResult :: FromJSON r => String -> IO r +ffiResult s = case J.eitherDecode (LB.pack s) of + Right (BadgeResult r) -> pure r + Right (BadgeError e) -> error $ "badge FFI error: " <> show e + Left e -> error $ "badge FFI decode failed: " <> e <> " in " <> s + jDecode :: FromJSON a => String -> IO (Maybe a) jDecode = pure . J.decode . LB.pack diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index aef41e90d2..10f8808015 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -104,7 +104,7 @@ testGroupPreferences :: Maybe GroupPreferences testGroupPreferences = Just GroupPreferences {timedMessages = Nothing, directMessages = Nothing, reactions = Just ReactionsGroupPreference {enable = FEOn}, voice = Just VoiceGroupPreference {enable = FEOn, role = Nothing}, files = Nothing, fullDelete = Nothing, simplexLinks = Nothing, history = Nothing, reports = Nothing, support = Nothing, sessions = Nothing, comments = Nothing, commands = Nothing} testProfile :: Profile -testProfile = Profile {displayName = "alice", fullName = "Alice", shortDescr = Nothing, image = Just (ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="), peerType = Nothing, contactLink = Nothing, preferences = testChatPreferences} +testProfile = Profile {displayName = "alice", fullName = "Alice", shortDescr = Nothing, image = Just (ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="), peerType = Nothing, contactLink = Nothing, preferences = testChatPreferences, badge = Nothing} testGroupProfile :: GroupProfile testGroupProfile = GroupProfile {displayName = "team", fullName = "Team", description = Nothing, shortDescr = Nothing, image = Nothing, publicGroup = Nothing, groupPreferences = testGroupPreferences, memberAdmission = Nothing} @@ -218,7 +218,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do #==# XInfo testProfile it "x.info with empty full name" $ "{\"v\":\"1\",\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"\",\"displayName\":\"alice\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" - #==# XInfo Profile {displayName = "alice", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = testChatPreferences} + #==# XInfo Profile {displayName = "alice", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = testChatPreferences, badge = Nothing} it "x.contact with xContactId" $ "{\"v\":\"1\",\"event\":\"x.contact\",\"params\":{\"contactReqId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" #==# XContact testProfile (Just $ XContactId "\1\2\3\4") Nothing Nothing diff --git a/tests/Test.hs b/tests/Test.hs index 639708441e..874428bc1f 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -11,6 +11,7 @@ import ChatTests.DBUtils import ChatTests.Utils (xdescribe'') import Control.Logger.Simple import Data.Time.Clock.System +import BadgeTests import JSONTests import MarkdownTests import MemberRelationsTests @@ -60,6 +61,7 @@ main = do #endif around tmpBracket $ describe "WebRTC encryption" webRTCTests #endif + describe "Supporter badges" badgeTests describe "SimpleX chat markdown" markdownTests describe "JSON Tests" jsonTests describe "Member relations" memberRelationsTests From 17fcb435760bba0b689642dcda91d98bf60a5487 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 15 Jun 2026 22:29:07 +0100 Subject: [PATCH 46/66] core: 6.5.5.0 (simplexmq 6.5.4.0) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cabal.project b/cabal.project index d3b9eeffa5..d6151cc620 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: 9f9b6c8e88524fb5fd063f47617a679ea53ac7c0 + tag: 376d6a261a1074717aed65ad97bb6f2a9532011b source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index ac230b7af1..150a2a4e6f 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."9f9b6c8e88524fb5fd063f47617a679ea53ac7c0" = "01jdjndx0h2ardzi9dd21q0n36lvwbdkhp7nzdrz01c3hh0br9bd"; + "https://github.com/simplex-chat/simplexmq.git"."376d6a261a1074717aed65ad97bb6f2a9532011b" = "1j83kzjcgjr7ngbamby96r90yal80c6kv79l9shy05mppmp73f4y"; "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 3a1a8ff24b..20b71a052d 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 6.5.4.1 +version: 6.5.5.0 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat From 732acf0474c22cf7cff228f9ca3877984882b882 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 16 Jun 2026 06:53:55 +0100 Subject: [PATCH 47/66] desktop: shorter "close to tray" setting --- .../common/src/commonMain/resources/MR/base/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 ecca74fae2..d6d31dd4d1 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -3103,8 +3103,8 @@ 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 %s supports SimpleX Chat. %1$s supported SimpleX Chat. The badge expired on %2$s. You can support SimpleX starting from v7 of the app. 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 48/66] 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 49/66] 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 50/66] 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 2d23c2f3921882e1746322710642e831bba9af4c Mon Sep 17 00:00:00 2001 From: SimpleX Chat Date: Wed, 17 Jun 2026 11:28:14 +0000 Subject: [PATCH 51/66] 6.5.5: android 355, desktop 146, ios 335 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 56 +++++++++---------- apps/multiplatform/gradle.properties | 8 +-- .../types/typescript/package.json | 2 +- packages/simplex-chat-nodejs/package.json | 4 +- .../simplex-chat-nodejs/src/download-libs.js | 2 +- .../src/simplex_chat/_version.py | 4 +- .../flatpak/chat.simplex.simplex.metainfo.xml | 22 ++++++++ 7 files changed, 60 insertions(+), 38 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index e2915963e4..0739e9522d 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -184,8 +184,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.4.1-3s3jwLbCMWj9Jo7I40UgTa-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.5.0-57WEoB2fiWaFgCB1XksYmP-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.5.0-57WEoB2fiWaFgCB1XksYmP-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.5.0-57WEoB2fiWaFgCB1XksYmP.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.5.0-57WEoB2fiWaFgCB1XksYmP.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 */; }; @@ -563,8 +563,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.4.1-3s3jwLbCMWj9Jo7I40UgTa-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.5.0-57WEoB2fiWaFgCB1XksYmP-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.5.0-57WEoB2fiWaFgCB1XksYmP-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.5.0-57WEoB2fiWaFgCB1XksYmP.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.5.0-57WEoB2fiWaFgCB1XksYmP.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 = ""; }; @@ -733,8 +733,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.5.0-57WEoB2fiWaFgCB1XksYmP-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.5.0-57WEoB2fiWaFgCB1XksYmP.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -820,8 +820,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.5.0-57WEoB2fiWaFgCB1XksYmP-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.5.0-57WEoB2fiWaFgCB1XksYmP.a */, ); path = Libraries; sourceTree = ""; @@ -2077,7 +2077,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 334; + CURRENT_PROJECT_VERSION = 335; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2102,7 +2102,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES_THIN; - MARKETING_VERSION = 6.5.4; + MARKETING_VERSION = 6.5.5; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2127,7 +2127,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 334; + CURRENT_PROJECT_VERSION = 335; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2152,7 +2152,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.5.4; + MARKETING_VERSION = 6.5.5; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2169,11 +2169,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 334; + CURRENT_PROJECT_VERSION = 335; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.5.4; + MARKETING_VERSION = 6.5.5; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2189,11 +2189,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 334; + CURRENT_PROJECT_VERSION = 335; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.5.4; + MARKETING_VERSION = 6.5.5; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2214,7 +2214,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 334; + CURRENT_PROJECT_VERSION = 335; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2229,7 +2229,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.5.4; + MARKETING_VERSION = 6.5.5; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2251,7 +2251,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 334; + CURRENT_PROJECT_VERSION = 335; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2266,7 +2266,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.5.4; + MARKETING_VERSION = 6.5.5; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2288,7 +2288,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 334; + CURRENT_PROJECT_VERSION = 335; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2314,7 +2314,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.5.4; + MARKETING_VERSION = 6.5.5; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2339,7 +2339,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 334; + CURRENT_PROJECT_VERSION = 335; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2366,7 +2366,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.5.4; + MARKETING_VERSION = 6.5.5; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2393,7 +2393,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 334; + CURRENT_PROJECT_VERSION = 335; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2408,7 +2408,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.5.4; + MARKETING_VERSION = 6.5.5; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2427,7 +2427,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 334; + CURRENT_PROJECT_VERSION = 335; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2442,7 +2442,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.5.4; + MARKETING_VERSION = 6.5.5; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index a2b63a5810..61c744bf86 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,13 +24,13 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=6.5.4 -android.version_code=353 +android.version_name=6.5.5 +android.version_code=355 android.bundle=false -desktop.version_name=6.5.4 -desktop.version_code=145 +desktop.version_name=6.5.5 +desktop.version_code=146 kotlin.version=2.1.20 gradle.plugin.version=8.7.0 diff --git a/packages/simplex-chat-client/types/typescript/package.json b/packages/simplex-chat-client/types/typescript/package.json index ad7ab04462..756e181307 100644 --- a/packages/simplex-chat-client/types/typescript/package.json +++ b/packages/simplex-chat-client/types/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@simplex-chat/types", - "version": "0.8.0", + "version": "0.9.0", "description": "TypeScript types for SimpleX Chat bot libraries", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/simplex-chat-nodejs/package.json b/packages/simplex-chat-nodejs/package.json index 0dff1f7f1d..40ef106103 100644 --- a/packages/simplex-chat-nodejs/package.json +++ b/packages/simplex-chat-nodejs/package.json @@ -1,6 +1,6 @@ { "name": "simplex-chat", - "version": "6.5.4", + "version": "6.5.5", "main": "dist/index.js", "types": "dist/index.d.ts", "files": [ @@ -24,7 +24,7 @@ "docs": "typedoc" }, "dependencies": { - "@simplex-chat/types": "^0.8.0", + "@simplex-chat/types": "^0.9.0", "extract-zip": "^2.0.1", "fast-deep-equal": "^3.1.3", "node-addon-api": "^8.5.0" diff --git a/packages/simplex-chat-nodejs/src/download-libs.js b/packages/simplex-chat-nodejs/src/download-libs.js index 72761a1ac5..4fad0f45a9 100644 --- a/packages/simplex-chat-nodejs/src/download-libs.js +++ b/packages/simplex-chat-nodejs/src/download-libs.js @@ -4,7 +4,7 @@ const path = require('path'); const extract = require('extract-zip'); const GITHUB_REPO = 'simplex-chat/simplex-chat-libs'; -const RELEASE_TAG = 'v6.5.4'; +const RELEASE_TAG = 'v6.5.5'; const BACKEND = (process.env.SIMPLEX_BACKEND || process.env.npm_config_simplex_backend || 'sqlite').toLowerCase(); if (BACKEND !== 'sqlite' && BACKEND !== 'postgres') { diff --git a/packages/simplex-chat-python/src/simplex_chat/_version.py b/packages/simplex-chat-python/src/simplex_chat/_version.py index 2ae4ce941e..77acd0e8b3 100644 --- a/packages/simplex-chat-python/src/simplex_chat/_version.py +++ b/packages/simplex-chat-python/src/simplex_chat/_version.py @@ -5,5 +5,5 @@ Bump both together for normal releases. For wrapper-only fixes use a PEP 440 post-release: __version__ = "6.5.2.post1", LIBS_VERSION unchanged. """ -__version__ = "6.5.4" # PEP 440 — read by hatchling for wheel metadata -LIBS_VERSION = "6.5.4" # simplex-chat-libs release tag (no 'v' prefix) +__version__ = "6.5.5" # PEP 440 — read by hatchling for wheel metadata +LIBS_VERSION = "6.5.5" # simplex-chat-libs release tag (no 'v' prefix) diff --git a/scripts/flatpak/chat.simplex.simplex.metainfo.xml b/scripts/flatpak/chat.simplex.simplex.metainfo.xml index 3f35d652fe..0d93315435 100644 --- a/scripts/flatpak/chat.simplex.simplex.metainfo.xml +++ b/scripts/flatpak/chat.simplex.simplex.metainfo.xml @@ -38,6 +38,28 @@
+ + https://simplex.chat/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.html + +

New in v6.5.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 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 52/66] 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 53/66] 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) %}