From 1d0247b7ddd6c53f3fd51f3d94cec3cb9656ec30 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 18 Oct 2026 08:51:33 +0100 Subject: [PATCH 01/15] core: preserve profile peer type when connecting incognito (#7751) * core: preserve profile peer type when connecting incognito * Apply batched suggestions from code review Co-authored-by: Evgeny * fix --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- src/Simplex/Chat/Library/Commands.hs | 16 ++++----- src/Simplex/Chat/Library/Internal.hs | 7 ++-- src/Simplex/Chat/Library/Subscriber.hs | 3 +- src/Simplex/Chat/ProfileGenerator.hs | 9 ++--- .../SQLite/Migrations/chat_query_plans.txt | 4 +-- src/Simplex/Chat/Store/Shared.hs | 8 ++--- tests/ChatTests/Profiles.hs | 34 ++++++++++++++++++- 7 files changed, 58 insertions(+), 23 deletions(-) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index fee1a8e4d9..07839dd032 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -462,7 +462,7 @@ processChatCommand cxt nm = \case checkValidName displayName checkProfileImageSize image checkProfileSize p - p@Profile {displayName} <- liftIO $ maybe generateRandomProfile pure profile + p@Profile {displayName} <- liftIO $ maybe (generateRandomProfile Nothing) pure profile u <- asks currentUser users <- withFastStore' getUsers forM_ users $ \User {profile = LocalProfile {displayName = n}, activeUser, viewPwdHash, userChatRelay = userChatRelay'} -> do @@ -2132,7 +2132,7 @@ processChatCommand cxt nm = \case Welcome -> withUser $ pure . CRWelcome APIAddContact userId incognito -> withUserId userId $ \user -> do -- [incognito] generate profile for connection - incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing + incognitoProfile <- if incognito then Just <$> liftIO (generateIncognitoProfile user) else pure Nothing subMode <- chatReadVar subscriptionMode (preparedReq, preparedParams@PreparedLinkParams {plpLinkKey = LinkKey linkKey}) <- withAgent $ \a -> prepareInvitationLink a (aUserId user) IKUsePQ Nothing linkProfile <- presentUserBadge user incognitoProfile (Just $ PHLink linkKey) $ userProfileDirect user incognitoProfile Nothing True @@ -2150,7 +2150,7 @@ processChatCommand cxt nm = \case let PendingContactConnection {pccConnStatus, customUserProfileId, connLinkInv} = conn case (pccConnStatus, customUserProfileId, incognito) of (ConnNew, Nothing, True) -> do - incognitoProfile <- liftIO generateRandomProfile + incognitoProfile <- liftIO $ generateIncognitoProfile user sLnk <- updatePCCShortLinkData conn $ userProfileDirect user (Just incognitoProfile) Nothing True conn' <- withFastStore' $ \db -> do pId <- createIncognitoProfile db user incognitoProfile @@ -2329,7 +2329,7 @@ processChatCommand cxt nm = \case publicMemberCount_ = (\PublicGroupData {publicMemberCount} -> publicMemberCount) <$> publicGroupData_ -- Prepare group record once before connecting to relays (updatePreparedRelayedGroup): -- set group link info and incognito profile, generate and store membership keys - incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing + incognitoProfile <- if incognito then Just <$> liftIO (generateIncognitoProfile user) else pure Nothing let cReqHash = contactCReqHash $ CRContactUri crData {crScheme = SSSimplex} e2e g'@(GIK gInfo' _) <- withFastStore $ \db -> do g'@(GIK gInfo' _) <- updatePreparedRelayedGroup db cxt user gInfo mainCReq cReqHash incognitoProfile rootKey publicMemberCount_ @@ -3886,7 +3886,7 @@ processChatCommand cxt nm = \case pqSup' = PQSupportOn joinNewConn chatV = do -- [incognito] generate profile to send - incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing + incognitoProfile <- if incognito then Just <$> liftIO (generateIncognitoProfile user) else pure Nothing (connId, binding) <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq pqSup' let ccLink = CCLink cReq $ serverShortLink <$> sLnk_ conn <- withFastStore' $ \db -> createDirectConnection' db userId connId ccLink contactId_ ConnPrepared incognitoProfile subMode chatV pqSup' @@ -3958,7 +3958,7 @@ processChatCommand cxt nm = \case -- [incognito] generate profile to send, or use membership profile for relay groups incognitoProfile_ <- case gInfo_ of Just (Just (GIK gInfo _)) | useRelays' gInfo -> pure $ ExistingIncognito <$> incognitoMembershipProfile gInfo - _ -> if incognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing + _ -> if incognito then Just . NewIncognito <$> liftIO (generateIncognitoProfile user) else pure Nothing let incognitoProfile = fromIncognitoProfile <$> incognitoProfile_ subMode <- chatReadVar subscriptionMode let sLnk' = serverShortLink <$> sLnk @@ -3974,7 +3974,7 @@ processChatCommand cxt nm = \case (connId, chatV, reqHeader) <- prepareContact user cReq pqSup newXContactId <- XContactId <$> drgRandomBytes 16 -- [incognito] generate profile to send - incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing + incognitoProfile <- if incognito then Just <$> liftIO (generateIncognitoProfile user) else pure Nothing subMode <- chatReadVar subscriptionMode let cReqHash = contactCReqHash cReq conn <- withFastStore' $ \db -> createConnReqConnection db userId connId (Just $ PCEContact ct) cReq reqHeader cReqHash shortLink newXContactId (NewIncognito <$> incognitoProfile) Nothing subMode chatV pqSup @@ -4345,7 +4345,7 @@ processChatCommand cxt nm = \case when (isPublicGroup groupKeys && isJust (memberAdmission >>= review)) $ throwCmdError "Admission review is not supported in channels" when (not (isPublicGroup groupKeys) && isJust publicGroup) $ throwCmdError "publicGroup is not allowed in groups" -- [incognito] generate incognito profile for group membership - incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing + incognitoProfile <- if incognito then Just <$> liftIO (generateIncognitoProfile user) else pure Nothing withFastStore $ \db -> createNewGroup db cxt user gProfile incognitoProfile memberId groupKeys publicMemberCount_ createNewGroupItems :: User -> GroupInfo -> CM () createNewGroupItems user gInfo = do diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 418d454ada..ced5c8c676 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -994,7 +994,7 @@ acceptContactRequest nm user@User {userId} UserContactRequest {agentInvitationId let chatV = vr cxt `peerConnChatVersion` cReqChatVRange (ct, conn, incognitoProfile, getPresHeader) <- case contactId_ of Nothing -> do - incognitoProfile <- if incognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing + incognitoProfile <- if incognito then Just . NewIncognito <$> liftIO (generateIncognitoProfile user) else pure Nothing (connId, binding) <- withAgent $ \a -> prepareConnectionToAccept a (aUserId user) True invId pqSup' (ct, conn) <- withStore' $ \db -> createContactFromRequest db user userContactLinkId_ connId chatV cReqChatVRange cName profileId cp xContactId incognitoProfile subMode pqSup' False @@ -1003,7 +1003,7 @@ acceptContactRequest nm user@User {userId} UserContactRequest {agentInvitationId 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 + incognitoProfile <- if incognito then Just . NewIncognito <$> liftIO (generateIncognitoProfile user) else pure Nothing (connId, binding) <- withAgent $ \a -> prepareConnectionToAccept a (aUserId user) True invId pqSup' currentTs <- liftIO getCurrentTime conn <- withStore' $ \db -> do @@ -1336,6 +1336,9 @@ introduceInChannel cxt user g@(GIK gInfo _) subscriber@GroupMember {activeConn = sendIntros ms = forM_ (L.nonEmpty $ map (memberIntroEvt gInfo) ms) $ \evts -> sendGroupMemberMessages user g conn evts +generateIncognitoProfile :: User -> IO Profile +generateIncognitoProfile User {profile = LocalProfile {peerType}} = generateRandomProfile peerType + userProfileInGroup :: User -> GroupInfo -> Maybe Profile -> Profile userProfileInGroup user g = userProfileInGroup' user (Just g) {-# INLINE userProfileInGroup #-} diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 9047ca1532..ead232b3be 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -57,7 +57,6 @@ import Simplex.Chat.Messages 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) import Simplex.Chat.Protocol import Simplex.Chat.Store import Simplex.Chat.Store.Connections @@ -1535,7 +1534,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag Just AutoAccept {acceptIncognito} -> do incognitoProfile <- if not shortLinkDataSet && acceptIncognito - then Just . NewIncognito <$> liftIO generateRandomProfile + then Just . NewIncognito <$> liftIO (generateIncognitoProfile user) else pure Nothing ct' <- acceptContactRequestAsync user uclId ct ucr incognitoProfile toView $ CEvtAcceptingContactRequest user ct' diff --git a/src/Simplex/Chat/ProfileGenerator.hs b/src/Simplex/Chat/ProfileGenerator.hs index b460a73d3d..bc1d069540 100644 --- a/src/Simplex/Chat/ProfileGenerator.hs +++ b/src/Simplex/Chat/ProfileGenerator.hs @@ -1,16 +1,17 @@ +{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} module Simplex.Chat.ProfileGenerator where import Data.Text (Text) -import Simplex.Chat.Types (Profile (..)) +import Simplex.Chat.Types (ChatPeerType, Profile (..)) import System.Random (randomRIO) -generateRandomProfile :: IO Profile -generateRandomProfile = do +generateRandomProfile :: Maybe ChatPeerType -> IO Profile +generateRandomProfile peerType = do adjective <- pick adjectives noun <- pickNoun adjective 2 - pure $ Profile {displayName = adjective <> noun, fullName = "", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing, badge = Nothing, contactDomain = Nothing} + pure $ Profile {displayName = adjective <> noun, fullName = "", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType, preferences = Nothing, badge = Nothing, contactDomain = Nothing} where pick :: [a] -> IO a pick xs = (xs !!) <$> randomRIO (0, length xs - 1) 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 7f08ffda73..fefef85c38 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -5108,8 +5108,8 @@ Query: Plan: Query: - INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, user_id, incognito, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?) + INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, chat_peer_type, user_id, incognito, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?) Plan: SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 75a49090ed..bf41d2de6e 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -323,14 +323,14 @@ createConnection_ db userId connType entityId acId connStatus connChatVersion pe ent ct = if connType == ct then entityId else Nothing createIncognitoProfile_ :: DB.Connection -> UserId -> UTCTime -> Profile -> IO Int64 -createIncognitoProfile_ db userId createdAt Profile {displayName, fullName, shortDescr, description, image} = do +createIncognitoProfile_ db userId createdAt Profile {displayName, fullName, shortDescr, description, image, peerType} = do DB.execute db [sql| - INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, user_id, incognito, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?) + INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, chat_peer_type, user_id, incognito, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?) |] - (displayName, fullName, shortDescr, description, image, userId, Just (BI True), createdAt, createdAt) + (displayName, fullName, shortDescr, description, image, peerType, userId, Just (BI True), createdAt, createdAt) insertedRowId db updateConnSupportPQ :: DB.Connection -> Int64 -> PQSupport -> PQEncryption -> IO () diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 3779006d8d..91ec9a54da 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -32,7 +32,7 @@ import Simplex.Chat.Options (ChatOpts (..), CoreChatOpts (..)) import Simplex.Chat.Protocol (ChatBinding (..), ChatMsgEvent (XInfo), ContactShortLinkData (ContactShortLinkData), LinkOwnerSig, MsgChatLink (..), MsgContent (..), encodeChatBinding) import Simplex.Chat.Store.Groups (getGroupMembers) import Simplex.Chat.Store.Shared (createContact, getGroupInfoKeys) -import Simplex.Chat.Types (AgentConnId (..), ConnStatus (..), GroupId, GroupInfo (..), GroupInfoKeys (..), GroupMember (..), Profile (..), GroupRejectionReason (..), fromLocalProfile, profileFromName, userProfileDirect, pattern VersionChat) +import Simplex.Chat.Types (AgentConnId (..), ChatPeerType (..), ConnStatus (..), GroupId, GroupInfo (..), GroupInfoKeys (..), GroupMember (..), Profile (..), GroupRejectionReason (..), fromLocalProfile, profileFromName, userProfileDirect, pattern VersionChat) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.BBS (BBSPublicKey, BBSSecretKey, bbsKeyGen) import Simplex.Chat.Types.Shared (GroupMemberRole (..)) @@ -133,6 +133,8 @@ chatProfileTests = do it "connection incognito unchanged errors" testConnectionIncognitoUnchangedErrors it "set, reset, set connection incognito" testSetResetSetConnectionIncognito it "join group incognito" testJoinGroupIncognito + it "join group incognito as bot" $ testJoinGroupIncognitoPeerType (Just CPTBot) + it "join group incognito as human" $ testJoinGroupIncognitoPeerType Nothing it "can't invite contact to whom user connected incognito to a group" testCantInviteContactIncognito it "can't see global preferences update" testCantSeeGlobalPrefsUpdateIncognito it "deleting contact first, group second deletes incognito profile" testDeleteContactThenGroupDeletesIncognitoProfile @@ -4905,6 +4907,36 @@ testShortLinkConnectPreparedGroupIncognito = testChat3 aliceProfile bobProfile c alice <# "#team cath> 3" bob ?<# "#team cath> 3" +testJoinGroupIncognitoPeerType :: HasCallStack => Maybe ChatPeerType -> TestParams -> IO () +testJoinGroupIncognitoPeerType peerType_ = testChat3 aliceProfile bobProfile {peerType = peerType_} cathProfile test + where + test alice bob cath = do + createGroup2 "team" alice cath + alice ##> "/create link #team" + (shortLink, fullLink) <- getGroupLinks alice "team" GRMember True + bob ##> ("/_connect plan 1 " <> shortLink) + bob <## "group link: ok to connect directly" + groupSLinkData <- getTermLine bob + bob ##> ("/_prepare group 1 " <> fullLink <> " " <> shortLink <> " " <> groupSLinkData) + bob <## "#team: group is prepared" + bob ##> "/_connect group #1 incognito=on" + bobIncognito <- getTermLine bob + bob <## "#team: connection started incognito" + alice <## (bobIncognito <> ": accepting request to join group #team...") + concurrentlyN_ + [ alice <## ("#team: " <> bobIncognito <> " joined the group"), + do + bob <## "#team: joining the group..." + bob <## ("#team: you joined the group incognito as " <> bobIncognito) + bob <## "#team: member cath (Catherine) is connected", + do + cath <## ("#team: alice added " <> bobIncognito <> " to the group (connecting...)") + cath <## ("#team: new member " <> bobIncognito <> " is connected") + ] + forM_ [alice, cath] $ \cc -> do + GroupMember {memberProfile} <- getTestMember cc "team" (T.pack bobIncognito) + peerType (fromLocalProfile memberProfile) `shouldBe` peerType_ + testShortLinkChangePreparedGroupUser :: HasCallStack => TestParams -> IO () testShortLinkChangePreparedGroupUser = testChat3 aliceProfile bobProfile cathProfile test where From 5a5a927821db2f4547bdbe75d82ee028d0d071c0 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 18 Oct 2026 11:40:08 +0100 Subject: [PATCH 02/15] ci: reduce the number of runners (#7750) Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- .github/workflows/build.yml | 128 ++++++++++++++++++---------- .github/workflows/packages.yml | 20 +---- scripts/ci/packages_release.py | 12 +-- scripts/ci/test_packages_release.py | 9 +- 4 files changed, 94 insertions(+), 75 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 37395dbaf3..be1784a237 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -176,9 +176,9 @@ jobs: env: image_cache: ghcr.io/simplex-chat/simplex-chat-build-cache:${{ matrix.os }}-${{ matrix.arch }}-ghc${{ matrix.ghc }} source_hashes: dist-newstyle/sources.sha256 - concurrency: + concurrency: &linux-concurrency group: ${{ github.workflow }}-${{ github.ref }}-linux-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.ghc }}-${{ needs.maybe-release.outputs.libs_tag }} - cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/v') && !needs.maybe-release.outputs.libs_tag }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} strategy: fail-fast: false matrix: @@ -205,22 +205,7 @@ jobs: should_run: true ghc: ${{ needs.variables.outputs.GHC_VER }} hash: 'sha256:98ff7968124952e719a8a69bb3cccdd217f5fe758108ac4f21ad22e1df44d237' - - os: 22.04 - os_underscore: 22_04 - arch: aarch64 - runner: "ubuntu-22.04-arm" - should_run: true - ghc: ${{ needs.variables.outputs.GHC_VER }} - hash: 'sha256:6a62a4157b8775eaf4959cb629e757d32d39d1f4c8ac1b0ddc2510b555cf72f3' - libs: true - - os: 24.04 - os_underscore: 24_04 - arch: aarch64 - runner: "ubuntu-24.04-arm" - should_run: true - ghc: ${{ needs.variables.outputs.GHC_VER }} - hash: 'sha256:68434214381cb38287104e629fe8ee720167dd98cbb36ab1cbbab342515fa6ab' - steps: + steps: &linux-steps - name: Checkout Code if: matrix.should_run == true uses: actions/checkout@v3 @@ -398,7 +383,7 @@ jobs: done strip /out/simplex-chat - # Also for pull requests that change the chat core, for the package tests (packages-pr). + # Also for pull requests that change the chat core, for the package tests. - name: Build library id: build-lib if: matrix.libs && (needs.maybe-release.outputs.libs_tag || (needs.maybe-release.outputs.core_changed && matrix.arch == 'x86_64')) @@ -457,14 +442,6 @@ jobs: !dist-lib/src key: ${{ steps.keys.outputs.dist_lib }}-${{ env.branch }}-${{ github.sha }} - - name: Upload library artifact - if: matrix.libs && needs.maybe-release.outputs.core_changed && matrix.arch == 'x86_64' - uses: actions/upload-artifact@v7 - with: - name: simplex-chat-libs-linux-${{ matrix.arch }} - path: release-libs/simplex-chat-libs-linux-${{ matrix.arch }}.zip - if-no-files-found: error - - name: Upload library if: needs.maybe-release.outputs.libs_tag && matrix.libs uses: softprops/action-gh-release@v2 @@ -612,6 +589,50 @@ jobs: exit 1 fi + - name: Setup Node.js + id: setup-node + if: ${{ !cancelled() && needs.maybe-release.outputs.core_changed && steps.build-lib.outcome == 'success' }} + uses: actions/setup-node@v7 + with: + node-version: 22 + + - name: Setup Python + id: setup-python + if: ${{ !cancelled() && steps.setup-node.outcome == 'success' }} + uses: actions/setup-python@v7 + with: + python-version: "3.12" + + - name: Build simplex-chat (npm) and add-on + id: build-nodejs + if: ${{ !cancelled() && steps.setup-python.outcome == 'success' }} + shell: bash + working-directory: packages/simplex-chat-nodejs + run: | + (cd ../simplex-chat-client/types/typescript && npm install --no-audit --no-fund && npx tsc) + npm install --ignore-scripts --no-save --no-audit --no-fund ../simplex-chat-client/types/typescript + npx --yes node-gyp rebuild + npx tsc && cp src/simplex.* dist + + - name: Test simplex-chat (npm) + id: test-nodejs + if: ${{ !cancelled() && steps.build-nodejs.outcome == 'success' }} + timeout-minutes: 30 + shell: bash + working-directory: packages/simplex-chat-nodejs + env: + SIMPLEX_LIBS_DIR: ${{ github.workspace }}/release-libs/simplex-chat-libs-linux-${{ matrix.arch }}/libs + run: npx jest --forceExit --testNamePattern '^(?!.*use preset servers)' + + - name: Test simplex-chat (PyPI) + if: ${{ !cancelled() && steps.test-nodejs.outcome == 'success' }} + timeout-minutes: 30 + shell: bash + working-directory: packages/simplex-chat-python + run: | + python -m pip install -e ".[test]" + python -m pytest -q + - name: Remove -O2 build directory id: remove-o2 if: env.cache_o0 == 'true' && !cancelled() && steps.build-cli.outcome == 'success' @@ -657,6 +678,40 @@ jobs: !dist-newstyle/src key: ${{ steps.keys.outputs.dist }}-O0-${{ env.branch }}-${{ github.sha }} + build-linux-push: + name: "ubuntu-${{ matrix.os }}-${{ matrix.arch }} (CLI,Desktop), GHC: ${{ matrix.ghc }}" + needs: [maybe-release, variables] + if: github.event_name != 'pull_request' + runs-on: ${{ matrix.runner }} + permissions: + contents: write + packages: write + env: + image_cache: ghcr.io/simplex-chat/simplex-chat-build-cache:${{ matrix.os }}-${{ matrix.arch }}-ghc${{ matrix.ghc }} + source_hashes: dist-newstyle/sources.sha256 + cache_o0: false + concurrency: *linux-concurrency + strategy: + fail-fast: false + matrix: + include: + - os: 22.04 + os_underscore: 22_04 + arch: aarch64 + runner: "ubuntu-22.04-arm" + should_run: true + ghc: ${{ needs.variables.outputs.GHC_VER }} + hash: 'sha256:6a62a4157b8775eaf4959cb629e757d32d39d1f4c8ac1b0ddc2510b555cf72f3' + libs: true + - os: 24.04 + os_underscore: 24_04 + arch: aarch64 + runner: "ubuntu-24.04-arm" + should_run: true + ghc: ${{ needs.variables.outputs.GHC_VER }} + hash: 'sha256:68434214381cb38287104e629fe8ee720167dd98cbb36ab1cbbab342515fa6ab' + steps: *linux-steps + # ================================= # Linux PostgreSQL Library Build # ================================= @@ -783,7 +838,7 @@ jobs: runs-on: ${{ matrix.os }} concurrency: group: ${{ github.workflow }}-${{ github.ref }}-macos-${{ matrix.os }}-${{ needs.maybe-release.outputs.libs_tag }} - cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/v') && !needs.maybe-release.outputs.libs_tag }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} strategy: fail-fast: false matrix: @@ -1082,7 +1137,7 @@ jobs: runs-on: ${{ matrix.os }} concurrency: group: ${{ github.workflow }}-${{ github.ref }}-windows-${{ matrix.os }}-${{ needs.maybe-release.outputs.libs_tag }} - cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/v') && !needs.maybe-release.outputs.libs_tag }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} strategy: fail-fast: false matrix: @@ -1338,7 +1393,7 @@ jobs: # A new library release is tested with the Node.js and Python packages and updates them in a release PR. packages: - needs: [maybe-release, build-linux, build-linux-postgres, build-macos, build-windows] + needs: [maybe-release, build-linux, build-linux-push, build-linux-postgres, build-macos, build-windows] if: ${{ !cancelled() && needs.maybe-release.outputs.libs_tag }} # the called workflow can't be granted more than its caller, and its publish job needs these permissions: @@ -1348,16 +1403,3 @@ jobs: with: libs_release: ${{ needs.maybe-release.outputs.libs_tag }} secrets: inherit - -# A pull request that changes the chat core is tested with the Node.js and Python packages. - - packages-pr: - needs: [maybe-release, build-linux] - # build-linux uploads the library before its chat tests run - if: ${{ !cancelled() && needs.maybe-release.outputs.core_changed }} - permissions: - contents: read - id-token: write - uses: ./.github/workflows/packages.yml - with: - libs_artifact: true diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index 01c2c1be33..2e58fb4d34 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -39,13 +39,9 @@ on: python_version: description: "simplex-chat PyPI version, bumped when needed by default" required: false - # build.yml: a pull request that changes the chat core, or a new library release + # build.yml: a new library release workflow_call: inputs: - libs_artifact: - description: "test with simplex-chat-libs-linux-x86_64 built by the calling run" - type: boolean - default: false libs_release: description: "test with this simplex-chat-libs release" type: string @@ -119,12 +115,11 @@ jobs: EVENT: ${{ github.event_name }} BASE: ${{ github.event.pull_request.base.sha }} RELEASE_MISSING: ${{ steps.release.outputs.missing }} - LIBS_ARTIFACT: ${{ inputs.libs_artifact }} LIBS_RELEASE: ${{ inputs.libs_release }} run: | if [ -n "$BASE" ]; then git fetch -q --depth=1 origin "$BASE"; fi python3 scripts/ci/packages_release.py build-plan --event "$EVENT" --base "$BASE" \ - --release-missing "$RELEASE_MISSING" --libs-artifact "$LIBS_ARTIFACT" --libs-release "$LIBS_RELEASE" >> "$GITHUB_OUTPUT" + --release-missing "$RELEASE_MISSING" --libs-release "$LIBS_RELEASE" >> "$GITHUB_OUTPUT" build: name: "build ${{ matrix.platform }}" @@ -163,13 +158,6 @@ jobs: npx tsc && cp src/simplex.* dist cp build/Release/simplex.node build/simplex-chat-nodejs-${{ matrix.platform }}.node - - name: Download libsimplex of this run - if: needs.addon.outputs.test && inputs.libs_artifact - uses: actions/download-artifact@v8 - with: - name: simplex-chat-libs-${{ matrix.platform }} - path: ${{ runner.temp }}/libsimplex - - name: Download libsimplex release if: needs.addon.outputs.test && inputs.libs_release env: @@ -177,13 +165,13 @@ jobs: run: curl -fsSL --create-dirs -o "$RUNNER_TEMP/libsimplex/simplex-chat-libs-${{ matrix.platform }}.zip" "$URL" - name: Unpack libsimplex - if: needs.addon.outputs.test && (inputs.libs_artifact || inputs.libs_release) + if: needs.addon.outputs.test && inputs.libs_release run: python -m zipfile -e "$RUNNER_TEMP/libsimplex/simplex-chat-libs-${{ matrix.platform }}.zip" "$RUNNER_TEMP/libsimplex" - name: Test simplex-chat (npm) if: needs.addon.outputs.test env: - SIMPLEX_LIBS_DIR: ${{ (inputs.libs_artifact || inputs.libs_release) && format('{0}/libsimplex/libs', runner.temp) || '' }} + SIMPLEX_LIBS_DIR: ${{ inputs.libs_release && format('{0}/libsimplex/libs', runner.temp) || '' }} # A failed test can leave chat controllers running, and jest then waits for them. # Tests on the public preset servers fail in CI when a server is slow to connect. run: npx jest --forceExit --testNamePattern '^(?!.*use preset servers)' diff --git a/scripts/ci/packages_release.py b/scripts/ci/packages_release.py index 488d35bb8c..b13b9cc00d 100644 --- a/scripts/ci/packages_release.py +++ b/scripts/ci/packages_release.py @@ -54,8 +54,6 @@ ADDON_RUNNERS = { "windows-x86_64": "windows-latest", } ADDON_PLATFORMS = list(ADDON_RUNNERS) -# build.yml builds libsimplex of pull requests on this platform only -CORE_TEST_PLATFORM = "linux-x86_64" ADDON_ASSETS = [f"simplex-chat-nodejs-{platform}.node" for platform in ADDON_PLATFORMS] SHA256_HEX = re.compile(r"^[0-9a-f]{64}$") RELEASES_FILE = "releases.toml" @@ -275,12 +273,10 @@ class BuildPlan: def build_plan( - event: str, release_missing: bool, libs_artifact: bool, libs_release: str, core_changed: bool, addon_changed: bool + event: str, release_missing: bool, libs_release: str, core_changed: bool, addon_changed: bool ) -> BuildPlan: """Each package test runs once, against the libsimplex of the change: a pull request that changes the core - is tested by build.yml with its own libsimplex (libs_artifact), a new library release with that release.""" - if libs_artifact: - return BuildPlan([CORE_TEST_PLATFORM], test=True) + is tested by build.yml with its own libsimplex, a new library release with that release.""" if libs_release: return BuildPlan(ADDON_PLATFORMS, test=True) if event == "pull_request": @@ -382,11 +378,10 @@ def changes(base: str) -> None: def print_build_plan(args: argparse.Namespace) -> None: - pull_request = args.event == "pull_request" and not args.libs_artifact + pull_request = args.event == "pull_request" result = build_plan( args.event, release_missing=args.release_missing, - libs_artifact=args.libs_artifact, libs_release=args.libs_release, core_changed=pull_request and changed_since(args.base, CORE_SOURCES), addon_changed=pull_request and changed_since(args.base, ADDON_SOURCES), @@ -637,7 +632,6 @@ def main(argv: list[str]) -> None: build_parser.add_argument("--event", required=True, help="github.event_name") build_parser.add_argument("--base", default="", help="the base commit of a pull request") build_parser.add_argument("--release-missing", type=github_bool, default=False) - build_parser.add_argument("--libs-artifact", type=github_bool, default=False) build_parser.add_argument("--libs-release", default="") prepare_parser = commands.add_parser("prepare") for package in ["nodejs", "types", "python"]: diff --git a/scripts/ci/test_packages_release.py b/scripts/ci/test_packages_release.py index 634260cabc..1838525c74 100644 --- a/scripts/ci/test_packages_release.py +++ b/scripts/ci/test_packages_release.py @@ -205,8 +205,8 @@ class PlanTest(unittest.TestCase): class BuildPlanTest(unittest.TestCase): - def plan(self, event="push", release_missing=False, libs_artifact=False, libs_release="", core=False, addon=False): - return build_plan(event, release_missing, libs_artifact, libs_release, core, addon) + def plan(self, event="push", release_missing=False, libs_release="", core=False, addon=False): + return build_plan(event, release_missing, libs_release, core, addon) def test_push_builds_a_missing_addon_release_and_tests_it(self): self.assertEqual(self.plan(release_missing=True), BuildPlan(ADDON_PLATFORMS, test=True)) @@ -220,11 +220,6 @@ class BuildPlanTest(unittest.TestCase): self.assertEqual(self.plan("pull_request", core=True), BuildPlan([], test=False)) self.assertEqual(self.plan("pull_request", core=True, addon=True), BuildPlan(ADDON_PLATFORMS, test=False)) - def test_libsimplex_of_the_pull_request_is_tested_on_its_platform(self): - self.assertEqual( - self.plan("pull_request", libs_artifact=True, core=True), BuildPlan(["linux-x86_64"], test=True) - ) - def test_new_library_release_is_tested_on_every_platform(self): self.assertEqual(self.plan(libs_release="core-1.0.0.2"), BuildPlan(ADDON_PLATFORMS, test=True)) From 056346020487d4c78ac538957c6baef9865c884a Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 18 Oct 2026 22:44:33 +0100 Subject: [PATCH 03/15] core, ui: support block markdown (#7734) * plan: support block markdown * update plans * core, ui: markdown plans and implementation * better * simplify * markdown tests and fixes * implement * fixes * fix test * bold * block markdown rendering * fix selection and rtl * fixes --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- .../Views/Chat/ChatItem/FramedItemView.swift | 7 +- .../Views/Chat/ChatItem/MsgContentView.swift | 461 +++++++++- .../Shared/Views/Chat/ChatItemInfoView.swift | 19 +- apps/ios/Shared/Views/Chat/ChatView.swift | 7 +- .../Chat/ComposeMessage/ContextItemView.swift | 2 +- .../Views/Chat/Group/GroupWelcomeView.swift | 7 +- .../Views/ChatList/ChatPreviewView.swift | 2 +- apps/ios/SimpleXChat/API.swift | 12 +- apps/ios/SimpleXChat/ChatTypes.swift | 119 ++- apps/ios/SimpleXChat/Notifications.swift | 9 +- apps/ios/spec/client/chat-view.md | 2 +- .../chat/simplex/common/model/ChatModel.kt | 98 +- .../simplex/common/platform/NtfManager.kt | 7 +- .../simplex/common/views/chat/ChatInfoView.kt | 6 +- .../common/views/chat/ChatItemInfoView.kt | 12 +- .../common/views/chat/ContextItemView.kt | 2 +- .../common/views/chat/TextSelection.kt | 97 +- .../views/chat/group/WelcomeMessageView.kt | 7 +- .../common/views/chat/item/FramedItemView.kt | 13 +- .../common/views/chat/item/MarkdownBlocks.kt | 248 +++++ .../common/views/chat/item/TextItemView.kt | 183 +++- .../common/views/chatlist/ChatPreviewView.kt | 1 + .../common/views/helpers/TextEditor.kt | 9 +- .../images/vertical_logo@1x.png | Bin 0 -> 1234 bytes .../images/vertical_logo@2x.png | Bin 0 -> 1617 bytes .../images/vertical_logo@3x.png | Bin 0 -> 2010 bytes .../chat/simplex/app/MarkdownBlocksTest.kt | 133 +++ apps/multiplatform/spec/client/chat-view.md | 2 +- .../src/Directory/Listing.hs | 15 +- .../src/Directory/Service.hs | 8 +- bots/api/TYPES.md | 144 +++ bots/src/API/Docs/Types.hs | 12 +- .../types/typescript/src/types.ts | 197 ++++ .../src/simplex_chat/types/_types.py | 142 ++- plans/2026-10-13-block-markdown-rendering.md | 415 +++++++++ plans/2026-10-13-block-markdown.md | 867 ++++++++++++++++++ src/Simplex/Chat/Help.hs | 4 +- src/Simplex/Chat/Library/Commands.hs | 14 +- src/Simplex/Chat/Library/Internal.hs | 44 +- src/Simplex/Chat/Library/Subscriber.hs | 5 +- src/Simplex/Chat/Markdown.hs | 598 ++++++++++-- src/Simplex/Chat/Messages.hs | 13 +- src/Simplex/Chat/Mobile.hs | 4 +- src/Simplex/Chat/Store/Messages.hs | 30 +- src/Simplex/Chat/Styled.hs | 2 +- src/Simplex/Chat/View.hs | 2 +- src/Simplex/Chat/Web.hs | 24 +- tests/ChatTests/Groups.hs | 6 + tests/JSONFixtures.hs | 10 +- tests/JSONTests.hs | 26 +- tests/MarkdownTests.hs | 616 ++++++++++++- website/src/directory.html | 51 ++ website/src/js/channel-preview.jsc | 70 +- website/src/js/directory.jsc | 9 +- website/src/js/simplex-lib.jsc | 159 +++- 55 files changed, 4641 insertions(+), 311 deletions(-) create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownBlocks.kt create mode 100644 apps/multiplatform/common/src/commonMain/moko-resources/images/vertical_logo@1x.png create mode 100644 apps/multiplatform/common/src/commonMain/moko-resources/images/vertical_logo@2x.png create mode 100644 apps/multiplatform/common/src/commonMain/moko-resources/images/vertical_logo@3x.png create mode 100644 apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/MarkdownBlocksTest.kt create mode 100644 plans/2026-10-13-block-markdown-rendering.md create mode 100644 plans/2026-10-13-block-markdown.md diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index f822970ea4..b6bac78d46 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -334,7 +334,7 @@ struct FramedItemView: View { @inline(__always) private func ciQuotedMsgTextView(_ qi: CIQuote, lines: Int, stripLink: String? = nil, prefix: NSAttributedString? = nil) -> some View { - MsgContentView(chat: chat, text: qi.text, formattedText: qi.formattedText, textStyle: .subheadline, prefix: prefix, stripLink: stripLink) + MsgContentView(chat: chat, text: qi.text, formattedText: qi.formattedText, markdownBlocks: qi.markdownBlocks, textStyle: .subheadline, prefix: prefix, stripLink: stripLink) .lineLimit(lines) .padding(.bottom, 6) } @@ -364,13 +364,16 @@ struct FramedItemView: View { chat: chat, text: text, formattedText: ft, + markdownBlocks: text == "" ? nil : ci.markdownBlocks, textStyle: .body, meta: ci.meta, mentions: ci.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, rightToLeft: rtl, prefix: txtPrefix, - stripLink: stripLink + stripLink: stripLink, + blockLayout: true, + cardColor: ci.chatDir.sent ? theme.appColors.sentQuote : theme.appColors.receivedQuote ) .environment(\.containerBackground, UIColor(chatItemFrameColor(ci, theme))) .multilineTextAlignment(rtl ? .trailing : .leading) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index f95e31369e..10bd784b7e 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -32,6 +32,7 @@ struct MsgContentView: View { @EnvironmentObject var theme: AppTheme var text: String var formattedText: [FormattedText]? = nil + var markdownBlocks: [MarkdownBlock]? = nil var textStyle: UIFont.TextStyle var sender: String? = nil var meta: CIMeta? = nil @@ -40,6 +41,8 @@ struct MsgContentView: View { var rightToLeft = false var prefix: NSAttributedString? = nil var stripLink: String? = nil + var blockLayout = false + var cardColor: Color? = nil @State private var showSecrets: Set = [] @State private var typingIdx = 0 @State private var timer: Timer? @@ -51,7 +54,7 @@ struct MsgContentView: View { @AppStorage(DEFAULT_PRIVACY_SHOW_SIGNATURE) private var showSignature = true var body: some View { - let v = msgContentView() + let v = contentView() if meta?.isLive == true { v.onAppear { let descr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body) @@ -95,11 +98,67 @@ struct MsgContentView: View { typingIdx = 0 } + @ViewBuilder + private func contentView() -> some View { + let fts = if let stripLink { stripFormattedTextLink(formattedText, stripLink) } else { formattedText } + if blockLayout, let fts, !fts.isEmpty, let blocks = markdownBlocks { + MarkdownBlocksView( + formattedText: fts, + blocks: blocks, + textStyle: textStyle, + cardColor: cardColor ?? theme.appColors.receivedQuote, + rightToLeft: rightToLeft, + blockText: { index, range, font, secretStart, last in blockTextView(fts, blocks, index, range, font, secretStart, last) }, + metaLine: { metaLineView() } + ) + } else { + msgContentView() + } + } + + private func blockTextView(_ fts: [FormattedText], _ blocks: [MarkdownBlock], _ index: Int, _ range: Range, _ font: UIFont, _ secretStart: Int, _ last: Bool) -> some View { + let r = messageText( + text, + fts, + markdownBlocks: blocks, + textStyle: textStyle, + sender: index == 0 ? sender : nil, + mentions: mentions, + userMemberId: userMemberId, + showSecrets: showSecrets, + commands: chat.chatInfo.useCommands && chat.chatInfo.sndReady, + backgroundColor: containerBackground, + prefix: index == 0 ? prefix : nil, + range: range, + baseFont: font, + secretStart: secretStart + ) + let s = r.string + let t: Text + if last, let mt = meta { + if mt.isLive { + s.append(typingIndicator(mt.recent)) + } + t = Text(AttributedString(s)) + reserveSpaceForMeta(mt) + } else { + t = Text(AttributedString(s)) + } + return msgTextResultView(r, t, showSecrets: $showSecrets, sendCommand: { cmd in sendCommandMsg(chat, cmd) }) + } + + @ViewBuilder + private func metaLineView() -> some View { + if let mt = meta { + (mt.isLive ? Text(AttributedString(typingIndicator(mt.recent))) : Text(verbatim: "")) + reserveSpaceForMeta(mt, newLine: false) + } + } + @inline(__always) private func msgContentView() -> some View { let r = messageText( text, formattedText, + markdownBlocks: markdownBlocks, textStyle: textStyle, sender: sender, mentions: mentions, @@ -131,8 +190,8 @@ struct MsgContentView: View { } @inline(__always) - private func reserveSpaceForMeta(_ mt: CIMeta) -> Text { - (rightToLeft ? textNewLine : Text(verbatim: " ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp, showSignature: showSignature) + private func reserveSpaceForMeta(_ mt: CIMeta, newLine: Bool = true) -> Text { + (rightToLeft && newLine ? textNewLine : Text(verbatim: " ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp, showSignature: showSignature) } } @@ -317,8 +376,11 @@ private struct FullProfileDescriptionView: View { .listRowBackground(Color.clear) Section { - let r = markdownText(description, showSecrets: showSecrets, backgroundColor: theme.colors.background) - msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets) + let parsed = parseSimpleXMarkdownText(description) + MarkdownView( + text: description, formattedText: parsed?.formattedText, markdownBlocks: parsed?.markdownBlocks, + backgroundColor: theme.colors.background, cardColor: theme.appColors.receivedQuote, showSecrets: $showSecrets + ) .frame(maxWidth: .infinity, alignment: .leading) .fixedSize(horizontal: false, vertical: true) } @@ -338,9 +400,11 @@ func markdownText( showSecrets: Set? = nil, backgroundColor: Color ) -> MsgTextResult { - messageText( + let parsed = parseSimpleXMarkdownText(s) + return messageText( s, - parseSimpleXMarkdown(s), + parsed?.formattedText, + markdownBlocks: parsed?.markdownBlocks, textStyle: textStyle, sender: sender, preview: preview, @@ -396,6 +460,7 @@ func markdownProfileDescription( func messageText( _ text: String, _ formattedText: [FormattedText]?, + markdownBlocks: [MarkdownBlock]? = nil, textStyle: UIFont.TextStyle = .body, sender: String?, preview: Bool = false, @@ -405,21 +470,24 @@ func messageText( commands: Bool = false, backgroundColor: UIColor, prefix: NSAttributedString? = nil, - stripLink: String? = nil + stripLink: String? = nil, + range: Range? = nil, + baseFont: UIFont? = nil, + secretStart: Int = 0 ) -> MsgTextResult { let text = if let stripLink { stripTextLink(text, stripLink) } else { text } let formattedText = if let stripLink { stripFormattedTextLink(formattedText, stripLink) } else { formattedText } let res = NSMutableAttributedString() - let descr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: textStyle) - let font = UIFont.preferredFont(forTextStyle: textStyle) + let descr = baseFont?.fontDescriptor ?? UIFontDescriptor.preferredFontDescriptor(withTextStyle: textStyle) + let font = baseFont ?? UIFont.preferredFont(forTextStyle: textStyle) let plain: [NSAttributedString.Key: Any] = [ .font: font, .foregroundColor: UIColor.label ] let secretColor = backgroundColor.withAlphaComponent(1) - var link: [NSAttributedString.Key: Any]? var hasSecrets = false var handleTaps = false + var secretIdx: Int = secretStart if let sender { if preview { @@ -437,19 +505,57 @@ func messageText( } if let fts = formattedText, fts.count > 0 { + if let range { + let start = min(max(range.lowerBound, 0), fts.count) + appendSegments(fts[start.. (descr: UIFontDescriptor, font: UIFont) { + if !heading { return (descr, font) } + let d = descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold]]) + return (d, UIFont(descriptor: d, size: d.pointSize)) + } + + func appendSegments(_ fts: ArraySlice, _ heading: Bool) { + let (descr, font) = segmentFonts(heading) + let plain: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: UIColor.label + ] + var link: [NSAttributedString.Key: Any]? var bold: UIFont? var italic: UIFont? var snippet: UIFont? var small: UIFont? var mention: UIFont? - var secretIdx: Int = 0 for ft in fts { var t = ft.text var attrs = plain switch (ft.format) { - case .bold: - bold = bold ?? UIFont(descriptor: descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold]]), size: descr.pointSize) - attrs[.font] = bold + case let .bold(singleStar): + if singleStar && markdownBlocks != nil { + italic = italic ?? UIFont(descriptor: descr.withSymbolicTraits(.traitItalic) ?? descr, size: descr.pointSize) + attrs[.font] = italic + } else { + bold = bold ?? UIFont(descriptor: descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold]]), size: descr.pointSize) + attrs[.font] = bold + } case .italic: italic = italic ?? UIFont(descriptor: descr.withSymbolicTraits(.traitItalic) ?? descr, size: descr.pointSize) attrs[.font] = italic @@ -496,7 +602,7 @@ func messageText( if let text { t = text } if !preview { attrs[linkAttrKey] = uri - attrs[webLinkAttrKey] = true + if !ft.isEmailLink { attrs[webLinkAttrKey] = true } handleTaps = true } case let .simplexLink(text, linkType, simplexUri, smpHosts): @@ -563,24 +669,319 @@ func messageText( attrs[modalAttrKey] = Format.modal(modalName: modalName, text: text) handleTaps = true } + case .escape: t = "" + case let .markup(token): + if range != nil { t = token.drawnText } else if markdownBlocks != nil { t = token.previewText } case .unknown: () case .none: () } res.append(NSAttributedString(string: t, attributes: attrs)) } - } else { - res.append(NSMutableAttributedString(string: text, attributes: plain)) - } - - return (string: res, hasSecrets: hasSecrets, handleTaps: handleTaps) - func linkAttrs() -> [NSAttributedString.Key: Any] { - link = link ?? [ - .font: font, - .foregroundColor: uiLinkColor, - .underlineStyle: NSUnderlineStyle.single.rawValue - ] - return link! + func linkAttrs() -> [NSAttributedString.Key: Any] { + link = link ?? [ + .font: font, + .foregroundColor: uiLinkColor, + .underlineStyle: NSUnderlineStyle.single.rawValue + ] + return link! + } + } +} + +let swipeExclusionSpace = "swipeExclusion" + +private let maxGridCells = 1000 + +struct SwipeExclusionFrames: PreferenceKey { + static var defaultValue: [CGRect] = [] + + static func reduce(value: inout [CGRect], nextValue: () -> [CGRect]) { + value += nextValue() + } +} + +struct MarkdownView: View { + var text: String + var formattedText: [FormattedText]? + var markdownBlocks: [MarkdownBlock]? + var sender: String? = nil + var mentions: [String: CIMention]? = nil + var userMemberId: String? = nil + var backgroundColor: Color + var cardColor: Color + @Binding var showSecrets: Set + + var body: some View { + if let fts = formattedText, !fts.isEmpty, let blocks = markdownBlocks { + MarkdownBlocksView( + formattedText: fts, + blocks: blocks, + textStyle: .body, + cardColor: cardColor, + rightToLeft: isRightToLeft(text), + blockText: { index, range, font, secretStart, _ in + let r = messageText( + text, fts, markdownBlocks: blocks, sender: index == 0 ? sender : nil, mentions: mentions, userMemberId: userMemberId, + showSecrets: showSecrets, backgroundColor: UIColor(backgroundColor), range: range, baseFont: font, secretStart: secretStart + ) + msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets) + }, + metaLine: { EmptyView() } + ) + } else { + let r = messageText(text, formattedText, sender: sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, backgroundColor: UIColor(backgroundColor)) + msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets) + } + } +} + +private struct BlocksLayout { + let font: UIFont + let starts: [Int] + let secrets: [Int] + let lastIsText: Bool + + init(_ fts: [FormattedText], _ blocks: [MarkdownBlock], _ textStyle: UIFont.TextStyle) { + font = UIFont.preferredFont(forTextStyle: textStyle) + starts = blocks.reduce(into: [0]) { $0.append($0[$0.count - 1] + $1.textRanges.count) } + secrets = fts.reduce(into: [0]) { $0.append($0[$0.count - 1] + ($1.isSecret ? 1 : 0)) } + lastIsText = switch blocks.last { + case .paragraph, .heading, .quote, .list: true + default: false + } + } +} + +struct MarkdownBlocksView: View { + @AppStorage(DEFAULT_CHAT_ITEM_ROUNDNESS) private var roundness = defaultChatItemRoundness + var formattedText: [FormattedText] + var blocks: [MarkdownBlock] + var textStyle: UIFont.TextStyle + var cardColor: Color + var rightToLeft: Bool + @ViewBuilder var blockText: (_ index: Int, _ range: Range, _ font: UIFont, _ secretStart: Int, _ last: Bool) -> BlockText + @ViewBuilder var metaLine: () -> MetaLine + + var body: some View { + let l = BlocksLayout(formattedText, blocks, textStyle) + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(blocks.enumerated()), id: \.offset) { k, block in + if k > 0 && paragraphBreak(blocks[k - 1].endIndex, block.startIndex) { + Color.clear.frame(height: l.font.lineHeight / 2) + } + blockView(block, l.starts[k], l) + } + if !l.lastIsText { + metaLine() + } + } + .multilineTextAlignment(.leading) + .environment(\.layoutDirection, rightToLeft ? .rightToLeft : .leftToRight) + } + + private func text(_ index: Int, _ r: SegmentRange, _ font: UIFont, _ l: BlocksLayout) -> BlockText { + blockText(index, r.from.. some View { + switch block { + case let .paragraph(content): + text(start, content, l.font, l) + case let .heading(level, content): + text(start, content, headingFont(l.font, level), l) + case let .quote(content): + HStack(alignment: .top, spacing: 10) { + RoundedRectangle(cornerRadius: 2).fill(Color(uiColor: .label).opacity(0.16)).frame(width: 4) + text(start, content, l.font, l).opacity(0.72) + } + .fixedSize(horizontal: false, vertical: true) + case let .list(items): + ForEach(Array(listEntries(items, 0).enumerated()), id: \.offset) { j, e in + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(e.entry.number ?? "•").font(.system(size: l.font.pointSize)) + text(start + j, e.entry.content, l.font, l) + } + .padding(.leading, 20 * CGFloat(e.level)) + } + case let .codeBlock(_, content): + CodeBlockCard(color: cardColor, radius: 12 * roundness, copyText: codeText(content)) { + text(start, content, monospacedFont(l.font), l).fixedSize(horizontal: true, vertical: false) + } + case let .table(columns, header, rows): + tableView(columns, [header] + rows, start, l) + case .rule: + Rectangle().fill(Color(uiColor: .secondaryLabel).opacity(0.35)).frame(height: 1).padding(.vertical, 6) + } + } + + @ViewBuilder private func tableView(_ columns: [TableAlign], _ tableRows: [[SegmentRange]], _ start: Int, _ l: BlocksLayout) -> some View { + let rowStarts = tableRows.reduce(into: [start]) { $0.append($0[$0.count - 1] + $1.count) } + let columnCount = max(columns.count, tableRows.map(\.count).max() ?? 0) + let lineColor = Color(uiColor: .secondaryLabel).opacity(0.35) + let radius = 12 * roundness + if #available(iOS 16, *) { + if columnCount * tableRows.count <= maxGridCells { + HScroll { + Grid(alignment: .leading, horizontalSpacing: 0, verticalSpacing: 0) { + ForEach(Array(tableRows.enumerated()), id: \.offset) { r, row in + GridRow { + ForEach(0.. some View { + let source = tableSource(tableRows) + return CodeBlockCard(color: cardColor, radius: radius, copyText: source) { + Text(verbatim: source).font(.system(size: l.font.pointSize * 0.875, design: .monospaced)).fixedSize(horizontal: true, vertical: false) + } + } + + private func paragraphBreak(_ from: Int, _ to: Int) -> Bool { + let start = min(max(from, 0), formattedText.count) + return formattedText[start.. String { + let start = min(max(r.from, 0), formattedText.count) + return formattedText[start.. String { + let start = min(max((tableRows.first?.first?.from ?? 1) - 1, 0), formattedText.count) + let end = min((tableRows.last?.last?.to ?? 0) + 1, formattedText.count) + return formattedText[start.. UIFont { + let scale: CGFloat = switch level { + case 1: 1.5 + case 2: 1.3 + case 3: 1.15 + default: 1 + } + return UIFont(descriptor: boldFont(font).fontDescriptor, size: font.pointSize * scale) +} + +private func boldFont(_ font: UIFont) -> UIFont { + UIFont(descriptor: font.fontDescriptor.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold]]), size: font.pointSize) +} + +private func monospacedFont(_ font: UIFont) -> UIFont { + UIFont.monospacedSystemFont(ofSize: font.pointSize * 0.875, weight: .regular) +} + +private func cellAlignment(_ align: TableAlign) -> Alignment { + switch align { + case .right: .trailing + case .center: .center + default: .leading + } +} + +private struct CodeBlockCard: View { + @EnvironmentObject var theme: AppTheme + var color: Color + var radius: CGFloat + var copyText: String + @ViewBuilder var content: () -> Content + @State private var showCopy = false + + var body: some View { + HScroll { + content() + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + .frame(maxWidth: .infinity, alignment: .leading) + .overlay(alignment: .topTrailing) { + if showCopy { + Button { + UIPasteboard.general.string = copyText + showCopy = false + } label: { + Image(systemName: "doc.on.doc") + .foregroundColor(theme.colors.primary) + .frame(width: 28, height: 28) + .background(color) + .clipShape(RoundedRectangle(cornerRadius: radius * 2 / 3)) + } + .padding(4) + } + } + .background(color) + .clipShape(RoundedRectangle(cornerRadius: radius)) + .onTapGesture { showCopy.toggle() } + } +} + +private struct HScroll: View { + @ViewBuilder var content: () -> Content + + var body: some View { + if #available(iOS 16, *) { + ViewThatFits(in: .horizontal) { + content() + scrollView() + } + } else { + scrollView() + } + } + + private func scrollView() -> some View { + ScrollView(.horizontal, showsIndicators: false) { + content() + } + .background { + GeometryReader { g in + Color.clear.preference(key: SwipeExclusionFrames.self, value: [g.frame(in: .named(swipeExclusionSpace))]) + } + } } } @@ -608,7 +1009,9 @@ func stripTextLink(_ text: String, _ link: String) -> String { func stripFormattedTextLink(_ ft: [FormattedText]?, _ link: String) -> [FormattedText]? { guard var ft, ft.last?.text == link else { return ft } ft.removeLast() - if let i = ft.indices.last, ft[i].format == nil, ft[i].text.hasSuffix("\n") { + if let i = ft.indices.last, case .markup = ft[i].format { + ft.removeLast() + } else if let i = ft.indices.last, ft[i].format == nil, ft[i].text.hasSuffix("\n") { ft[i].text = String(ft[i].text.dropLast()) if ft[i].text.isEmpty { ft.removeLast() } } diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift index ffcf525300..3fff0e34d1 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -267,7 +267,7 @@ struct ChatItemInfoView: View { private func itemVersionView(_ itemVersion: ChatItemVersion, _ maxWidth: CGFloat, current: Bool) -> some View { let backgroundColor = chatItemFrameColor(ci, theme) return VStack(alignment: .leading, spacing: 4) { - textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil, backgroundColor: backgroundColor) + textBubble(itemVersion.msgContent.text, itemVersion.formattedText, itemVersion.markdownBlocks, nil, backgroundColor: backgroundColor) .padding(.horizontal, 12) .padding(.vertical, 6) .background(backgroundColor) @@ -295,9 +295,12 @@ struct ChatItemInfoView: View { .frame(maxWidth: maxWidth, alignment: .leading) } - @ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ sender: String? = nil, backgroundColor: Color) -> some View { + @ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ markdownBlocks: [MarkdownBlock]?, _ sender: String? = nil, backgroundColor: Color) -> some View { if text != "" { - TextBubble(text: text, formattedText: formattedText, sender: sender, mentions: ci.mentions, userMemberId: userMemberId, backgroundColor: backgroundColor) + TextBubble( + text: text, formattedText: formattedText, markdownBlocks: markdownBlocks, sender: sender, mentions: ci.mentions, userMemberId: userMemberId, + backgroundColor: backgroundColor, cardColor: ci.chatDir.sent ? theme.appColors.sentQuote : theme.appColors.receivedQuote + ) } else { Text("no text") .italic() @@ -309,15 +312,19 @@ struct ChatItemInfoView: View { @EnvironmentObject var theme: AppTheme var text: String var formattedText: [FormattedText]? + var markdownBlocks: [MarkdownBlock]? var sender: String? = nil var mentions: [String: CIMention]? var userMemberId: String? var backgroundColor: Color + var cardColor: Color @State private var showSecrets: Set = [] var body: some View { - let r = messageText(text, formattedText, sender: sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, backgroundColor: UIColor(backgroundColor)) - return msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets) + MarkdownView( + text: text, formattedText: formattedText, markdownBlocks: markdownBlocks, sender: sender, mentions: mentions, userMemberId: userMemberId, + backgroundColor: backgroundColor, cardColor: cardColor, showSecrets: $showSecrets + ) } } @@ -342,7 +349,7 @@ struct ChatItemInfoView: View { private func quotedMsgView(_ qi: CIQuote, _ maxWidth: CGFloat) -> some View { let backgroundColor = quotedMsgFrameColor(qi, theme) return VStack(alignment: .leading, spacing: 4) { - textBubble(qi.text, qi.formattedText, qi.getSender(nil), backgroundColor: backgroundColor) + textBubble(qi.text, qi.formattedText, qi.markdownBlocks, qi.getSender(nil), backgroundColor: backgroundColor) .padding(.horizontal, 12) .padding(.vertical, 6) .background(quotedMsgFrameColor(qi, theme)) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 29a1be3d65..2a903fcd91 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -1735,6 +1735,7 @@ struct ChatView: View { @State private var markReadTask: Task? = nil @State private var actionSheet: SomeActionSheet? = nil @State private var swipeOffset: CGFloat = 0 + @State private var swipeExclusionFrames: [CGRect] = [] var revealed: Bool { revealedItems.contains(chatItem.id) } @@ -2156,12 +2157,14 @@ struct ChatView: View { .padding(.bottom, 4) } } + .coordinateSpace(name: swipeExclusionSpace) + .onPreferenceChange(SwipeExclusionFrames.self) { swipeExclusionFrames = $0 } .offset(x: swipeOffset) .contentShape(Rectangle()) .simultaneousGesture( - DragGesture(minimumDistance: 10) + DragGesture(minimumDistance: 10, coordinateSpace: .named(swipeExclusionSpace)) .onChanged { value in - guard canReply else { return } + guard canReply, !swipeExclusionFrames.contains(where: { $0.contains(value.startLocation) }) else { return } let x = value.translation.width if x < 0 { swipeOffset = max(x * 0.63, -56) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift index 1f328b2061..13614692cb 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift @@ -71,7 +71,7 @@ struct ContextItemView: View { } private func contextMsgPreview(_ contextItem: ChatItem) -> some View { - let r = messageText(contextItem.text, contextItem.formattedText, sender: nil, preview: true, mentions: contextItem.mentions, userMemberId: nil, showSecrets: nil, backgroundColor: UIColor(background), stripLink: contextItem.content.msgContent?.chatLinkStr) + let r = messageText(contextItem.text, contextItem.formattedText, markdownBlocks: contextItem.markdownBlocks, sender: nil, preview: true, mentions: contextItem.mentions, userMemberId: nil, showSecrets: nil, backgroundColor: UIColor(background), stripLink: contextItem.content.msgContent?.chatLinkStr) let t = attachment() + Text(AttributedString(r.string)) return t.if(r.hasSecrets, transform: hiddenSecretsView) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift index f58f2c213d..1bef869a66 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift @@ -59,8 +59,11 @@ struct GroupWelcomeView: View { } private func textPreview() -> some View { - let r = markdownText(welcomeText, showSecrets: showSecrets, backgroundColor: theme.colors.background) - return msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets) + let parsed = parseSimpleXMarkdownText(welcomeText) + return MarkdownView( + text: welcomeText, formattedText: parsed?.formattedText, markdownBlocks: parsed?.markdownBlocks, + backgroundColor: theme.colors.background, cardColor: theme.appColors.receivedQuote, showSecrets: $showSecrets + ) .frame(minHeight: 130, alignment: .topLeading) .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 8156ea81c7..7f50f09a31 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -299,7 +299,7 @@ struct ChatPreviewView: View { func chatItemPreview(_ cItem: ChatItem) -> (Text, Bool) { let (itemText, itemFormattedText) = chatItemPreviewText(cItem) - let r = messageText(itemText, itemFormattedText, sender: cItem.meta.showGroupAsSender ? nil : cItem.memberDisplayName, preview: true, mentions: cItem.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, showSecrets: nil, backgroundColor: UIColor(theme.colors.background), prefix: prefix()) + let r = messageText(itemText, itemFormattedText, markdownBlocks: itemFormattedText == nil ? nil : cItem.markdownBlocks, sender: cItem.meta.showGroupAsSender ? nil : cItem.memberDisplayName, preview: true, mentions: cItem.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, showSecrets: nil, backgroundColor: UIColor(theme.colors.background), prefix: prefix()) return (Text(AttributedString(r.string)), r.hasSecrets) func chatItemPreviewText(_ ci: ChatItem) -> (String, [FormattedText]?) { diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index 3b860d524b..04e5973085 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -149,12 +149,15 @@ public func recvSimpleXMsg(_ ctrl: chat_ctrl? = nil, messageTi } public func parseSimpleXMarkdown(_ s: String) -> [FormattedText]? { + parseSimpleXMarkdownText(s)?.formattedText +} + +public func parseSimpleXMarkdownText(_ s: String) -> ParsedMarkdown? { var c = s.cString(using: .utf8)! if let cjson = chat_parse_markdown(&c) { if let d = dataFromCString(cjson) { do { - let r = try jsonDecoder.decode(ParsedMarkdown.self, from: d) - return r.formattedText + return try jsonDecoder.decode(ParsedMarkdown.self, from: d) } catch { logger.error("parseSimpleXMarkdown jsonDecoder.decode error: \(error.localizedDescription)") } @@ -176,8 +179,9 @@ public func parseBadgeCode(_ s: String) -> String? { return canonical.isEmpty ? nil : canonical } -struct ParsedMarkdown: Decodable { - var formattedText: [FormattedText]? +public struct ParsedMarkdown: Decodable { + public var formattedText: [FormattedText]? + public var markdownBlocks: [MarkdownBlock]? } public func parseServerAddress(_ s: String) -> ServerAddress? { diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index a4d65a6d10..8bdd952372 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -3793,11 +3793,12 @@ public struct CIReaction: Decodable, Hashable { } public struct ChatItem: Identifiable, Decodable, Hashable { - public init(chatDir: CIDirection, meta: CIMeta, content: CIContent, formattedText: [FormattedText]? = nil, mentions: [String: CIMention]? = nil, quotedItem: CIQuote? = nil, reactions: [CIReactionCount] = [], file: CIFile? = nil) { + public init(chatDir: CIDirection, meta: CIMeta, content: CIContent, formattedText: [FormattedText]? = nil, markdownBlocks: [MarkdownBlock]? = nil, mentions: [String: CIMention]? = nil, quotedItem: CIQuote? = nil, reactions: [CIReactionCount] = [], file: CIFile? = nil) { self.chatDir = chatDir self.meta = meta self.content = content self.formattedText = formattedText + self.markdownBlocks = markdownBlocks self.mentions = mentions self.quotedItem = quotedItem self.reactions = reactions @@ -3808,6 +3809,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable { public var meta: CIMeta public var content: CIContent public var formattedText: [FormattedText]? + public var markdownBlocks: [MarkdownBlock]? public var mentions: [String: CIMention]? public var quotedItem: CIQuote? public var reactions: [CIReactionCount] @@ -3817,7 +3819,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable { public var isLiveDummy: Bool = false private enum CodingKeys: String, CodingKey { - case chatDir, meta, content, formattedText, mentions, quotedItem, reactions, file + case chatDir, meta, content, formattedText, markdownBlocks, mentions, quotedItem, reactions, file } public var id: Int64 { meta.itemId } @@ -4859,6 +4861,7 @@ public struct CIQuote: Decodable, ItemContent, Hashable { public var sentAt: Date public var content: MsgContent public var formattedText: [FormattedText]? + public var markdownBlocks: [MarkdownBlock]? public var text: String { switch (content.text, content) { case let ("", .voice(_, duration)): return durationText(duration) @@ -5672,14 +5675,119 @@ public struct FormattedText: Decodable, Hashable { public var linkUri: String? { switch format { case .uri: text - case let .hyperLink(_, linkUri): linkUri + case let .hyperLink(_, linkUri): isEmailLink ? nil : linkUri default: nil } } + + public var isEmailLink: Bool { + if case let .hyperLink(_, linkUri) = format { linkUri.hasPrefix("mailto:") } else { false } + } +} + +public enum BlockToken: Decodable, Hashable { + case lineBreak + case softBreak + case hardBreak + case paragraphBreak(lineBreaks: Int) + case indent + case headingMark(level: Int) + case headingClose + case headingUnderline(level: Int) + case listMark(number: String?) + case quoteMark + case fenceMark(language: String?) + case rowMark + case cellMark + case delimiterRow(columns: [TableAlign]) + case ruleMark + + public var drawnText: String { + switch self { + case .softBreak: " " + case .lineBreak, .hardBreak: "\n" + case let .paragraphBreak(lineBreaks): String(repeating: "\n", count: lineBreaks) + default: "" + } + } + + public var previewText: String { + switch self { + case .softBreak, .hardBreak, .cellMark: " " + case .lineBreak, .paragraphBreak: "\n" + case let .listMark(number): (number ?? "•") + " " + default: "" + } + } +} + +public enum MarkdownBlock: Decodable, Hashable { + case paragraph(content: SegmentRange) + case heading(level: Int, content: SegmentRange) + case codeBlock(language: String?, content: SegmentRange) + case quote(content: SegmentRange) + case list(items: [ListEntry]) + case table(columns: [TableAlign], header: [SegmentRange], rows: [[SegmentRange]]) + case rule(mark: SegmentRange) +} + +public struct ListEntry: Decodable, Hashable { + public var number: String? + public var content: SegmentRange + public var items: [ListEntry] +} + +public struct SegmentRange: Decodable, Hashable { + public var from: Int + public var to: Int +} + +public enum TableAlign: String, Decodable, Hashable { + case none + case left + case center + case right +} + +public struct BlockTextRange { + public var range: SegmentRange + public var inRow: Bool +} + +extension MarkdownBlock { + public var textRanges: [BlockTextRange] { + switch self { + case let .paragraph(content), let .heading(_, content), let .codeBlock(_, content), let .quote(content): + [BlockTextRange(range: content, inRow: false)] + case let .list(items): + listEntries(items, 0).map { BlockTextRange(range: $0.entry.content, inRow: false) } + case let .table(_, header, rows): + ([header] + rows).flatMap { row in row.enumerated().map { c, cell in BlockTextRange(range: cell, inRow: c > 0) } } + case .rule: + [] + } + } +} + +public func listEntries(_ items: [ListEntry], _ level: Int) -> [(entry: ListEntry, level: Int)] { + items.flatMap { [(entry: $0, level: level)] + listEntries($0.items, level + 1) } +} + +public func blockTextRanges(_ blocks: [MarkdownBlock]) -> [BlockTextRange] { + blocks.flatMap { $0.textRanges } +} + +public func headingSegments(_ size: Int, _ blocks: [MarkdownBlock]) -> [Bool] { + var bold = [Bool](repeating: false, count: size) + for case let .heading(_, content) in blocks { + let from = min(max(content.from, 0), size) + for i in from.. String { if let md = cItem.formattedText { var res = "" for ft in md { - if case .secret = ft.format { - res = res + "..." - } else { - res = res + ft.text + switch ft.format { + case .secret: res = res + "..." + case let .markup(token): res = res + token.previewText + case .escape: () + default: res = res + ft.text } } return res diff --git a/apps/ios/spec/client/chat-view.md b/apps/ios/spec/client/chat-view.md index b63d336c34..38db89bea0 100644 --- a/apps/ios/spec/client/chat-view.md +++ b/apps/ios/spec/client/chat-view.md @@ -189,7 +189,7 @@ Renders emoji-only messages (messages containing only emoji characters) in a lar **File**: [`Shared/Views/Chat/ChatItem/MsgContentView.swift`](../../Shared/Views/Chat/ChatItem/MsgContentView.swift) -Renders message text with SimpleX markdown formatting (bold, italic, code, links, mentions). +Renders message text with SimpleX markdown formatting (bold, italic, code, links, mentions). Blocks of `markdownBlocks` (headings, lists, quotes, code blocks, tables, rules) are drawn as a column of views by `MarkdownBlocksView`; code blocks and tables scroll sideways. ### DeletedItemView / MarkedDeletedItemView 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 bf856c13af..09c494dc76 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 @@ -3407,6 +3407,7 @@ data class ChatItem ( val meta: CIMeta, val content: CIContent, val formattedText: List? = null, + val markdownBlocks: List? = null, val mentions: Map? = null, val quotedItem: CIQuote? = null, val reactions: List, @@ -4412,7 +4413,8 @@ class CIQuote ( val sharedMsgId: String? = null, val sentAt: Instant, val content: MsgContent, - val formattedText: List? = null + val formattedText: List? = null, + val markdownBlocks: List? = null ): ItemContent { override val text: String by lazy { if (content.text == "" && content is MsgContent.MCVoice) @@ -5164,7 +5166,7 @@ class FormattedText(val text: String, val format: Format? = null) { val linkUri: String? get() = when (format) { is Format.Uri -> text - is Format.HyperLink -> format.linkUri + is Format.HyperLink -> if (format.isEmail) null else format.linkUri else -> null } @@ -5175,7 +5177,7 @@ class FormattedText(val text: String, val format: Format? = null) { @Serializable sealed class Format { - @Serializable @SerialName("bold") class Bold: Format() + @Serializable @SerialName("bold") class Bold(val singleStar: Boolean = false): Format() @Serializable @SerialName("italic") class Italic: Format() @Serializable @SerialName("strikeThrough") class StrikeThrough: Format() @Serializable @SerialName("snippet") class Snippet: Format() @@ -5183,7 +5185,9 @@ sealed class Format { @Serializable @SerialName("small") class Small: Format() @Serializable @SerialName("colored") class Colored(val color: FormatColor): Format() @Serializable @SerialName("uri") class Uri: Format() - @Serializable @SerialName("hyperLink") class HyperLink(val showText: String?, val linkUri: String): Format() + @Serializable @SerialName("hyperLink") class HyperLink(val showText: String?, val linkUri: String): Format() { + val isEmail: Boolean get() = linkUri.startsWith("mailto:") + } @Serializable @SerialName("simplexLink") class SimplexLink(val showText: String?, val linkType: SimplexLinkType, val simplexUri: String, val smpHosts: List): Format() { val simplexLinkText: String get() = "${linkType.description} $viaHosts" @@ -5200,6 +5204,8 @@ sealed class Format { } @Serializable @SerialName("email") class Email: Format() @Serializable @SerialName("phone") class Phone: Format() + @Serializable @SerialName("escape") class Escape: Format() + @Serializable @SerialName("markup") class Markup(val token: BlockToken): Format() @Serializable @SerialName("unknown") class Unknown: Format() val style: SpanStyle @Composable get() = when (this) { @@ -5219,6 +5225,8 @@ sealed class Format { is Email -> linkStyle is Phone -> linkStyle is Modal -> linkStyle + is Escape -> SpanStyle() + is Markup -> SpanStyle() is Unknown -> SpanStyle() } @@ -5229,6 +5237,87 @@ sealed class Format { } } +@Serializable +sealed class BlockToken { + @Serializable @SerialName("lineBreak") class LineBreak: BlockToken() + @Serializable @SerialName("softBreak") class SoftBreak: BlockToken() + @Serializable @SerialName("hardBreak") class HardBreak: BlockToken() + @Serializable @SerialName("paragraphBreak") class ParagraphBreak(val lineBreaks: Int): BlockToken() + @Serializable @SerialName("indent") class Indent: BlockToken() + @Serializable @SerialName("headingMark") class HeadingMark(val level: Int): BlockToken() + @Serializable @SerialName("headingClose") class HeadingClose: BlockToken() + @Serializable @SerialName("headingUnderline") class HeadingUnderline(val level: Int): BlockToken() + @Serializable @SerialName("listMark") class ListMark(val number: String? = null): BlockToken() + @Serializable @SerialName("quoteMark") class QuoteMark: BlockToken() + @Serializable @SerialName("fenceMark") class FenceMark(val language: String? = null): BlockToken() + @Serializable @SerialName("rowMark") class RowMark: BlockToken() + @Serializable @SerialName("cellMark") class CellMark: BlockToken() + @Serializable @SerialName("delimiterRow") class DelimiterRow(val columns: List): BlockToken() + @Serializable @SerialName("ruleMark") class RuleMark: BlockToken() + + val drawnText: String get() = when (this) { + is SoftBreak -> " " + is LineBreak, is HardBreak -> "\n" + is ParagraphBreak -> "\n".repeat(lineBreaks) + else -> "" + } + + val previewText: String get() = when (this) { + is SoftBreak, is HardBreak, is CellMark -> " " + is LineBreak, is ParagraphBreak -> "\n" + is ListMark -> (number ?: "•") + " " + else -> "" + } +} + +@Serializable +sealed class MarkdownBlock { + @Serializable @SerialName("paragraph") class Paragraph(val content: SegmentRange): MarkdownBlock() + @Serializable @SerialName("heading") class Heading(val level: Int, val content: SegmentRange): MarkdownBlock() + @Serializable @SerialName("codeBlock") class CodeBlock(val language: String? = null, val content: SegmentRange): MarkdownBlock() + @Serializable @SerialName("quote") class Quote(val content: SegmentRange): MarkdownBlock() + @Serializable @SerialName("list") class ItemList(val items: List): MarkdownBlock() + @Serializable @SerialName("table") class Table(val columns: List, val header: List, val rows: List>): MarkdownBlock() + @Serializable @SerialName("rule") class Rule(val mark: SegmentRange): MarkdownBlock() +} + +@Serializable +class ListEntry(val number: String? = null, val content: SegmentRange, val items: List) + +@Serializable +class SegmentRange(val from: Int, val to: Int) + +@Serializable +enum class TableAlign { + @SerialName("none") None, + @SerialName("left") Left, + @SerialName("center") Center, + @SerialName("right") Right +} + +class BlockTextRange(val range: SegmentRange, val inRow: Boolean) + +val MarkdownBlock.textRanges: List get() = when (this) { + is MarkdownBlock.Paragraph -> listOf(BlockTextRange(content, false)) + is MarkdownBlock.Heading -> listOf(BlockTextRange(content, false)) + is MarkdownBlock.CodeBlock -> listOf(BlockTextRange(content, false)) + is MarkdownBlock.Quote -> listOf(BlockTextRange(content, false)) + is MarkdownBlock.ItemList -> listEntries(items, 0).map { (entry, _) -> BlockTextRange(entry.content, false) } + is MarkdownBlock.Table -> (listOf(header) + rows).flatMap { row -> row.mapIndexed { c, cell -> BlockTextRange(cell, c > 0) } } + is MarkdownBlock.Rule -> emptyList() +} + +fun listEntries(items: List, level: Int): List> = + items.flatMap { listOf(it to level) + listEntries(it.items, level + 1) } + +fun blockTextRanges(blocks: List): List = blocks.flatMap { it.textRanges } + +fun headingSegments(size: Int, blocks: List): BooleanArray { + val bold = BooleanArray(size) + blocks.forEach { if (it is MarkdownBlock.Heading) for (i in it.content.from.coerceIn(0, size) until it.content.to.coerceIn(0, size)) bold[i] = true } + return bold +} + @Serializable enum class SimplexLinkType(val linkType: String) { contact("contact"), @@ -5714,6 +5803,7 @@ data class ChatItemVersion( val chatItemVersionId: Long, val msgContent: MsgContent, val formattedText: List?, + val markdownBlocks: List? = null, val itemVersionTs: Instant, val createdAt: Instant, ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt index ee47403cd3..290d93db71 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt @@ -136,7 +136,12 @@ abstract class NtfManager { return if (md != null) { var res = "" for (ft in md) { - res += if (ft.format is Format.Secret) "..." else ft.text + res += when (ft.format) { + is Format.Secret -> "..." + is Format.Markup -> ft.format.token.previewText + is Format.Escape -> "" + else -> ft.text + } } res } else { 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 9c019ad535..82575984f6 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 @@ -791,8 +791,9 @@ fun ProfileDescriptionText(shortDescr: String?, description: String?, style: Tex val linkMode = chatModel.simplexLinkMode.value if (descr == null) { if (short != null) { + val parsed = parseToMarkdownText(short) MarkdownText( - short, parseToMarkdown(short), toggleSecrets = true, style = style, maxLines = 4, + short, parsed?.formattedText, parsed?.markdownBlocks, toggleSecrets = true, style = style, maxLines = 4, overflow = TextOverflow.Ellipsis, uriHandler = uriHandler, modifier = modifier, linkMode = linkMode ) } @@ -801,8 +802,9 @@ fun ProfileDescriptionText(shortDescr: String?, description: String?, style: Tex val truncated = firstLine.length > 100 val multiline = descr.length > firstLine.length if (short == null && !truncated && !multiline) { + val parsed = parseToMarkdownText(descr) MarkdownText( - descr, parseToMarkdown(descr), toggleSecrets = true, style = style, maxLines = 4, + descr, parsed?.formattedText, parsed?.markdownBlocks, toggleSecrets = true, style = style, maxLines = 4, overflow = TextOverflow.Ellipsis, uriHandler = uriHandler, modifier = modifier, linkMode = linkMode ) } else { 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 e4f93a0b8a..0c7f1937f7 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 @@ -52,17 +52,19 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools val selection = remember { mutableStateOf(CIInfoTab.History) } @Composable - fun TextBubble(text: String, formattedText: List?, sender: String?, showMenu: MutableState, mentions: Map? = null, userMemberId: String? = null, ) { + fun TextBubble(text: String, formattedText: List?, markdownBlocks: List?, sender: String?, showMenu: MutableState, mentions: Map? = null, userMemberId: String? = null, ) { if (text != "") { MarkdownText( - text, if (text.isEmpty()) emptyList() else formattedText, + text, if (text.isEmpty()) emptyList() else formattedText, markdownBlocks, sender = sender, mentions = mentions, userMemberId = userMemberId, senderBold = true, toggleSecrets = true, linkMode = SimplexLinkMode.DESCRIPTION, uriHandler = uriHandler, - onLinkLongClick = { showMenu.value = true } + onLinkLongClick = { showMenu.value = true }, + blockLayout = true, + cardColor = if (sent) appColors.sentQuote else appColors.receivedQuote ) } else { Text( @@ -85,7 +87,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools .onRightClick { showMenu.value = true } ) { Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { - TextBubble(text, ciVersion.formattedText, sender = null, showMenu = showMenu, mentions = ci.mentions, + TextBubble(text, ciVersion.formattedText, ciVersion.markdownBlocks, sender = null, showMenu = showMenu, mentions = ci.mentions, userMemberId = when { chatInfo is ChatInfo.Group -> chatInfo.groupInfo.membership.memberId else -> null @@ -137,7 +139,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools .onRightClick { showMenu.value = true } ) { Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { - TextBubble(text, qi.formattedText, sender = qi.sender(null), showMenu) + TextBubble(text, qi.formattedText, qi.markdownBlocks, sender = qi.sender(null), showMenu) } } Row(Modifier.padding(start = 12.dp, top = 3.dp, bottom = 16.dp)) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt index b477f56118..7f92ca0651 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt @@ -57,7 +57,7 @@ fun ContextItemView( } } else null MarkdownText( - contextItem.text, contextItem.formattedText, + contextItem.text, contextItem.formattedText, contextItem.markdownBlocks, sender = null, toggleSecrets = false, maxLines = lines, 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 20f5acd3e0..e04f75d799 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 @@ -37,7 +37,7 @@ import androidx.compose.ui.unit.dp import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.views.chat.item.itemPrefixText -import chat.simplex.common.views.chat.item.itemSegmentDisplayText +import chat.simplex.common.views.chat.item.itemDisplay import chat.simplex.common.views.helpers.generalGetString import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource @@ -267,23 +267,22 @@ private fun selectedItemCopiedText(ci: ChatItem, sel: IntRange, linkMode: Simple if (sel.first < prefix.length) { sb.append(prefix, sel.first, minOf(prefix.length, sel.last + 1)) } - val formattedText = ci.formattedText ?: run { + val parts = itemDisplay(ci, linkMode)?.parts ?: run { val start = (sel.first - prefix.length).coerceAtLeast(0).coerceAtMost(ci.text.length) val end = (sel.last + 1 - prefix.length).coerceAtMost(ci.text.length) if (start < end) sb.append(ci.text, start, end) return sb.toString() } var displayOffset = prefix.length - for (ft in formattedText) { - val segDisplay = itemSegmentDisplayText(ft, ci, linkMode) + for ((segDisplay, source) in parts) { val displayEnd = displayOffset + segDisplay.length val overlapStart = maxOf(displayOffset, sel.first) val overlapEnd = minOf(displayEnd, sel.last + 1) if (overlapStart < overlapEnd) { - if (ft.text.length == segDisplay.length) { - sb.append(ft.text, overlapStart - displayOffset, overlapEnd - displayOffset) + if (source.length == segDisplay.length) { + sb.append(source, overlapStart - displayOffset, overlapEnd - displayOffset) } else { - sb.append(ft.text) + sb.append(source) } } displayOffset = displayEnd @@ -293,12 +292,11 @@ private fun selectedItemCopiedText(ci: ChatItem, sel: IntRange, linkMode: Simple // Snaps a boundary offset to include full transformed segments. private fun snapOffset(ci: ChatItem, offset: Int, linkMode: SimplexLinkMode, expandRight: Boolean): Int { - val formattedText = ci.formattedText ?: return offset + val parts = itemDisplay(ci, linkMode)?.parts ?: return offset var displayOffset = itemPrefixText(ci).length - for (ft in formattedText) { - val segDisplay = itemSegmentDisplayText(ft, ci, linkMode) + for ((segDisplay, source) in parts) { val displayEnd = displayOffset + segDisplay.length - if (offset > displayOffset && offset < displayEnd && ft.text.length != segDisplay.length) { + if (offset > displayOffset && offset < displayEnd && source.length != segDisplay.length) { return if (expandRight) displayEnd else displayOffset } displayOffset = displayEnd @@ -425,18 +423,55 @@ private fun resolveIndexAtY(listState: LazyListState, localY: Float): Int? { return idx } -class ItemSelection( +class TextSelection( val highlightRange: IntRange?, val positionModifier: Modifier, - val onTextLayoutResult: ((TextLayoutResult) -> Unit)? + val onTextLayoutResult: (TextLayoutResult) -> Unit ) +class SelectionText(var base: Int) { + var bounds: Rect? = null + var layout: TextLayoutResult? = null +} + +class ItemSelection( + private val highlightRange: IntRange?, + private val texts: MutableMap?, + private val bases: List +) { + fun text(index: Int): TextSelection? { + val texts = texts ?: return null + val base = bases.getOrNull(index) ?: return null + val t = texts.getOrPut(index) { SelectionText(base) } + t.base = base + val highlight = highlightRange?.let { (it.first - base).coerceAtLeast(0) .. (it.last - base) }?.takeUnless { it.isEmpty() } + return TextSelection( + highlight, + Modifier.onGloballyPositioned { + val pos = it.positionInWindow() + t.bounds = Rect(pos.x, pos.y, pos.x + it.size.width, pos.y + it.size.height) + }, + { t.layout = it } + ) + } +} + +private class TextHit(val text: SelectionText, val bounds: Rect, val layout: TextLayoutResult, val offset: Int) + +private fun textAt(texts: Collection, x: Float, y: Float): TextHit? = + texts.mapNotNull { t -> + val b = t.bounds ?: return@mapNotNull null + val l = t.layout?.takeIf { it.layoutInput.text.isNotEmpty() } ?: return@mapNotNull null + Triple(t, b, l) + }.minByOrNull { (_, b, _) -> maxOf(b.top - y, 0f, y - b.bottom) * 4 + maxOf(b.left - x, 0f, x - b.right) } + ?.let { (t, b, l) -> TextHit(t, b, l, l.getOffsetForPosition(Offset(x - b.left, y - b.top))) } + // Sets up selection tracking for a text item: anchor/focus offset resolution, // highlight range computation, and position/layout result capture. @Composable -fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int, isLive: Boolean): ItemSelection { - val boundsState = remember { mutableStateOf(null) } - val layoutResultState = remember { mutableStateOf(null) } +fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int, isLive: Boolean, bases: List): ItemSelection { + val texts = remember { HashMap() } + texts.keys.retainAll { it < bases.size } if (selectionManager != null && selectionIndex >= 0 && !isLive) { val isAnchor = remember(selectionIndex) { @@ -444,12 +479,8 @@ fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int, } LaunchedEffect(isAnchor.value) { if (!isAnchor.value) return@LaunchedEffect - val bounds = boundsState.value ?: return@LaunchedEffect - val layout = layoutResultState.value ?: return@LaunchedEffect - val offset = layout.getOffsetForPosition( - Offset(selectionManager.anchorWindowX - bounds.left, selectionManager.anchorWindowY - bounds.top) - ) - selectionManager.setAnchorOffset(offset) + val hit = textAt(texts.values, selectionManager.anchorWindowX, selectionManager.anchorWindowY) ?: return@LaunchedEffect + selectionManager.setAnchorOffset(hit.text.base + hit.offset) } val isFocus = remember(selectionIndex) { @@ -459,10 +490,9 @@ fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int, LaunchedEffect(Unit) { snapshotFlow { selectionManager.focusWindowY to selectionManager.focusWindowX } .collect { (py, px) -> - val bounds = boundsState.value ?: return@collect - val layout = layoutResultState.value ?: return@collect - val offset = layout.getOffsetForPosition(Offset(px - bounds.left, py - bounds.top)) - val charBox = layout.getBoundingBox(offset.coerceIn(0, layout.layoutInput.text.length - 1)) + val hit = textAt(texts.values, px, py) ?: return@collect + val bounds = hit.bounds + val charBox = hit.layout.getBoundingBox(hit.offset.coerceIn(0, hit.layout.layoutInput.text.length - 1)) val ls = selectionManager.listState?.value val itemInfo = ls?.layoutInfo?.visibleItemsInfo?.find { it.index == selectionIndex } val charRect = if (ls != null && itemInfo != null) { @@ -474,7 +504,7 @@ fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int, bottom = bounds.top + charBox.bottom - itemWindowY ) } else Rect.Zero - selectionManager.updateFocusOffset(offset, charRect) + selectionManager.updateFocusOffset(hit.text.base + hit.offset, charRect) } } } @@ -484,18 +514,7 @@ fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int, remember(selectionIndex) { derivedStateOf { selectedRange(selectionManager.range, selectionIndex) } }.value } else null - val positionModifier = if (selectionManager != null) { - Modifier.onGloballyPositioned { - val pos = it.positionInWindow() - boundsState.value = Rect(pos.x, pos.y, pos.x + it.size.width, pos.y + it.size.height) - } - } else Modifier - - val onTextLayoutResult: ((TextLayoutResult) -> Unit)? = if (selectionManager != null) { - { layoutResultState.value = it } - } else null - - return ItemSelection(highlightRange, positionModifier, onTextLayoutResult) + return ItemSelection(highlightRange, if (selectionManager != null) texts else null, bases) } // Sets up full-item selection for emoji items (no character-level tracking). diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt index ec8b545647..ef847c93cb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt @@ -146,13 +146,16 @@ private fun TextPreview(text: String, linkMode: SimplexLinkMode, markdown: Boole val uriHandler = LocalUriHandler.current Column { SelectionContainer(Modifier.fillMaxWidth()) { + val parsed = if (markdown) remember(text) { parseToMarkdownText(text) } else null MarkdownText( text, - formattedText = if (markdown) remember(text) { parseToMarkdown(text) } else null, + formattedText = parsed?.formattedText, + markdownBlocks = parsed?.markdownBlocks, toggleSecrets = false, modifier = Modifier.fillMaxHeight().padding(horizontal = DEFAULT_PADDING), linkMode = linkMode, uriHandler = uriHandler, - style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground, lineHeight = 22.sp) + style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground, lineHeight = 22.sp), + blockLayout = true ) } } 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 fd422b649a..ca370c1b52 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 @@ -61,6 +61,7 @@ fun FramedItemView( MarkdownText( qi.text, qi.formattedText, + qi.markdownBlocks, toggleSecrets = true, maxLines = lines, overflow = TextOverflow.Ellipsis, @@ -457,11 +458,12 @@ fun CIMarkdownText( ) { val chatInfo = chat.chatInfo val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text - val selection = setupItemSelection(LocalSelectionManager.current, LocalItemContext.current.selectionIndex, ci.meta.isLive == true) + val bases = remember(ci, linkMode) { itemDisplay(ci, linkMode)?.bases ?: listOf(0) } + val selection = setupItemSelection(LocalSelectionManager.current, LocalItemContext.current.selectionIndex, ci.meta.isLive == true, bases) - Box(Modifier.padding(vertical = 7.dp, horizontal = 12.dp).then(selection.positionModifier)) { + Box(Modifier.padding(vertical = 7.dp, horizontal = 12.dp)) { MarkdownText( - text, if (text.isEmpty()) emptyList() else ci.formattedText, toggleSecrets = true, + text, if (text.isEmpty()) emptyList() else ci.formattedText, if (text.isEmpty()) null else ci.markdownBlocks, toggleSecrets = true, sendCommandMsg = if (chatInfo.useCommands && chat.chatInfo.sndReady) { { msg -> sendCommandMsg(chatsCtx, chat, msg) } } else null, meta = ci.meta, chatTTL = chatTTL, linkMode = linkMode, mentions = ci.mentions, userMemberId = when { @@ -470,8 +472,9 @@ fun CIMarkdownText( }, uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix, stripLink = stripLink, - selectionRange = selection.highlightRange, - onTextLayoutResult = selection.onTextLayoutResult + selection = selection, + blockLayout = true, + cardColor = if (ci.chatDir.sent) MaterialTheme.appColors.sentQuote else MaterialTheme.appColors.receivedQuote ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownBlocks.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownBlocks.kt new file mode 100644 index 0000000000..75a778e567 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownBlocks.kt @@ -0,0 +1,248 @@ +package chat.simplex.common.views.chat.item + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.waitForUpOrCancellation +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +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.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.takeOrElse +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.takeOrElse +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.views.helpers.* +import chat.simplex.res.* +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource + +@Composable +fun MarkdownBlocks( + formattedText: List, + blocks: List, + style: TextStyle, + modifier: Modifier, + cardColor: Color, + blockText: @Composable (index: Int, range: SegmentRange, style: TextStyle, modifier: Modifier, softWrap: Boolean, last: Boolean) -> Unit, + metaLine: @Composable () -> Unit +) { + val roundness = remember { appPreferences.chatItemRoundness.state }.value.coerceIn(0f, 1f) + val cardShape = RoundedCornerShape(12.dp * roundness) + val lineColor = MaterialTheme.colors.secondary.copy(alpha = 0.35f) + val textColor = style.color.takeOrElse { MaterialTheme.colors.onSurface } + val fontSize = style.fontSize.takeOrElse { 16.sp } + val lineHeight = style.lineHeight.takeOrElse { 22.sp } + val starts = remember(blocks) { blocks.runningFold(0) { n, b -> n + b.textRanges.size } } + val lastIsText = when (blocks.lastOrNull()) { + is MarkdownBlock.Paragraph, is MarkdownBlock.Heading, is MarkdownBlock.Quote, is MarkdownBlock.ItemList -> true + else -> false + } + @Composable + fun BlockText(index: Int, range: SegmentRange, style: TextStyle, modifier: Modifier = Modifier, softWrap: Boolean = true) { + blockText(index, range, style, modifier, softWrap, lastIsText && index == starts.last() - 1) + } + Column(modifier) { + blocks.forEachIndexed { k, block -> + if (k > 0 && paragraphBreak(formattedText, blocks[k - 1].end, block.start)) { + Spacer(Modifier.height(with(LocalDensity.current) { (lineHeight.toPx() / 2).toDp() })) + } + val start = starts[k] + when (block) { + is MarkdownBlock.Paragraph -> BlockText(start, block.content, style) + is MarkdownBlock.Heading -> { + val scale = when (block.level) { + 1 -> 1.5f + 2 -> 1.3f + 3 -> 1.15f + else -> 1f + } + BlockText(start, block.content, style.copy(fontSize = fontSize * scale, lineHeight = lineHeight * scale, fontWeight = FontWeight.Bold)) + } + is MarkdownBlock.Quote -> Row(Modifier.height(IntrinsicSize.Min)) { + Box(Modifier.width(4.dp).fillMaxHeight().clip(RoundedCornerShape(2.dp)).background(textColor.copy(alpha = if (MaterialTheme.colors.isLight) 0.16f else 0.22f))) + Spacer(Modifier.width(10.dp)) + BlockText(start, block.content, style.copy(color = textColor.copy(alpha = 0.7f))) + } + is MarkdownBlock.ItemList -> listEntries(block.items, 0).forEachIndexed { j, (entry, level) -> + Row(Modifier.padding(start = 20.dp * level)) { + Text(entry.number ?: "•", Modifier.widthIn(min = 14.dp), style = style) + Spacer(Modifier.width(6.dp)) + BlockText(start + j, entry.content, style, Modifier.weight(1f, fill = false)) + } + } + is MarkdownBlock.CodeBlock -> CodeBlockCard(cardShape, cardColor, roundness, codeText(formattedText, block.content)) { m -> + BlockText(start, block.content, style.copy(fontFamily = FontFamily.Monospace, fontSize = fontSize * 0.875f, lineHeight = lineHeight * 0.9f), m, softWrap = false) + } + is MarkdownBlock.Table -> { + val tableRows = listOf(block.header) + block.rows + val rowStarts = tableRows.runningFold(start) { n, row -> n + row.size } + Box(Modifier.horizontalScroll(rememberScrollState())) { + TableGrid( + block.columns, tableRows.map { it.size }, cardColor, MaterialTheme.colors.secondary.copy(alpha = 0.1f), lineColor, + Modifier.clip(cardShape).border(1.dp, lineColor, cardShape) + ) { + tableRows.forEachIndexed { r, row -> + row.forEachIndexed { c, cell -> + BlockText( + rowStarts[r] + c, cell, if (r == 0) style.copy(fontWeight = FontWeight.Bold) else style, + Modifier.padding(horizontal = 12.dp, vertical = 4.dp), softWrap = false + ) + } + } + } + } + } + is MarkdownBlock.Rule -> Box(Modifier.padding(vertical = 6.dp).fillMaxWidth().height(1.dp).background(lineColor)) + } + } + if (!lastIsText) metaLine() + } +} + +private val MarkdownBlock.start: Int get() = when (this) { + is MarkdownBlock.Rule -> mark.from + else -> textRanges.firstOrNull()?.range?.from ?: 0 +} + +private val MarkdownBlock.end: Int get() = when (this) { + is MarkdownBlock.Rule -> mark.to + else -> textRanges.lastOrNull()?.range?.to ?: 0 +} + +private fun paragraphBreak(fts: List, from: Int, to: Int): Boolean = + (from.coerceIn(0, fts.size) until to.coerceIn(0, fts.size)).any { (fts[it].format as? Format.Markup)?.token is BlockToken.ParagraphBreak } + +private fun codeText(fts: List, r: SegmentRange): String = + (r.from.coerceIn(0, fts.size) until r.to.coerceIn(0, fts.size)).joinToString("") { i -> + when (val f = fts[i].format) { + is Format.Markup -> f.token.drawnText + is Format.Escape -> "" + else -> fts[i].text + } + } + +@Composable +private fun CodeBlockCard(shape: Shape, color: Color, roundness: Float, copyText: String, content: @Composable (Modifier) -> Unit) { + var tapped by remember { mutableStateOf(false) } + val interactionSource = remember { MutableInteractionSource() } + val hovered by interactionSource.collectIsHoveredAsState() + val clipboard = LocalClipboardManager.current + Box( + Modifier + .fillMaxWidth() + .clip(shape) + .background(color) + .hoverable(interactionSource) + .pointerInput(Unit) { + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false) + if (waitForUpOrCancellation() != null) tapped = !tapped + } + } + ) { + content(Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp, vertical = 8.dp)) + if (hovered || tapped) { + Box(Modifier.matchParentSize(), contentAlignment = Alignment.TopEnd) { + Box( + Modifier + .padding(4.dp) + .size(28.dp) + .clip(RoundedCornerShape(8.dp * roundness)) + .background(color) + .clickable { + clipboard.setText(AnnotatedString(copyText)) + showToast(generalGetString(MR.strings.copied)) + }, + contentAlignment = Alignment.Center + ) { + Icon(painterResource(MR.images.ic_content_copy), stringResource(MR.strings.copy_verb), Modifier.size(18.dp), tint = MaterialTheme.colors.primary) + } + } + } + } +} + +private class TableGridLines { + var xs: List = emptyList() + var ys: List = emptyList() +} + +@Composable +private fun TableGrid( + columns: List, + rowSizes: List, + headerColor: Color, + stripeColor: Color, + lineColor: Color, + modifier: Modifier, + content: @Composable () -> Unit +) { + val lines = remember { TableGridLines() } + Layout( + content, + modifier.drawBehind { + val xs = lines.xs + val ys = lines.ys + if (ys.size < 2) return@drawBehind + drawRect(headerColor, Offset.Zero, Size(size.width, ys[1].toFloat())) + for (r in 2 until ys.size - 1 step 2) { + drawRect(stripeColor, Offset(0f, ys[r].toFloat()), Size(size.width, (ys[r + 1] - ys[r]).toFloat())) + } + val stroke = 1.dp.toPx() + xs.drop(1).dropLast(1).forEach { x -> drawLine(lineColor, Offset(x.toFloat(), 0f), Offset(x.toFloat(), size.height), stroke) } + ys.drop(1).dropLast(1).forEach { y -> drawLine(lineColor, Offset(0f, y.toFloat()), Offset(size.width, y.toFloat()), stroke) } + } + ) { measurables, _ -> + val cells = rowSizes.flatMapIndexed { r, n -> List(n) { c -> r to c } }.take(measurables.size) + val widths = IntArray(maxOf(columns.size, rowSizes.maxOrNull() ?: 0)) + cells.forEachIndexed { k, (_, c) -> widths[c] = maxOf(widths[c], measurables[k].maxIntrinsicWidth(Constraints.Infinity)) } + val placeables = cells.mapIndexed { k, (_, c) -> measurables[k].measure(Constraints(maxWidth = widths[c])) } + val heights = IntArray(rowSizes.size) + placeables.forEachIndexed { k, p -> heights[cells[k].first] = maxOf(heights[cells[k].first], p.height) } + val xs = widths.toList().runningFold(0) { a, w -> a + w } + val ys = heights.toList().runningFold(0) { a, h -> a + h } + lines.xs = xs + lines.ys = ys + layout(xs.last(), ys.last()) { + placeables.forEachIndexed { k, p -> + val (r, c) = cells[k] + val free = widths[c] - p.width + val x = xs[c] + when (columns.getOrNull(c)) { + TableAlign.Right -> free + TableAlign.Center -> free / 2 + else -> 0 + } + p.place(x, ys[r]) + } + } + } +} 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 c198451464..9b3162744d 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 @@ -12,8 +12,20 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.ImageShader +import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.graphics.drawscope.scale import androidx.compose.ui.input.pointer.* import androidx.compose.ui.layout.* import androidx.compose.ui.platform.* @@ -22,11 +34,14 @@ import androidx.compose.ui.text.AnnotatedString.Range import androidx.compose.ui.text.font.* import androidx.compose.ui.text.style.* import androidx.compose.ui.unit.LayoutDirection +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.CurrentColors import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.ui.theme.appColors +import chat.simplex.common.views.chat.ItemSelection import chat.simplex.common.views.chat.SelectionHighlightColor import chat.simplex.common.views.helpers.* import chat.simplex.res.* @@ -53,14 +68,17 @@ private fun showFullProfileDescription(description: String) { ModalManager.end.showModalCloseable { _ -> ColumnWithScrollBar { AppBarTitle(generalGetString(MR.strings.profile_description__field)) + val parsed = parseToMarkdownText(description) MarkdownText( description, - parseToMarkdown(description), + parsed?.formattedText, + parsed?.markdownBlocks, toggleSecrets = true, style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground, lineHeight = 22.sp), uriHandler = LocalUriHandler.current, linkMode = chatModel.simplexLinkMode.value, modifier = Modifier.padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING), + blockLayout = true, ) } } @@ -85,10 +103,10 @@ private fun typing(w: FontWeight = FontWeight.Light): AnnotatedString = AnnotatedString(".", SpanStyle(fontWeight = w)) // Display text for a single formatted segment — must be coordinated with MarkdownText. -fun itemSegmentDisplayText(ft: FormattedText, ci: ChatItem, linkMode: SimplexLinkMode): String = +fun segmentDisplayText(ft: FormattedText, mentions: Map?, linkMode: SimplexLinkMode): String = when (ft.format) { is Format.Mention -> { - val mention = ci.mentions?.get(ft.format.memberName) + val mention = mentions?.get(ft.format.memberName) if (mention?.memberRef != null) { val name = if (mention.memberRef.localAlias.isNullOrEmpty()) mention.memberRef.displayName else "${mention.memberRef.localAlias} (${mention.memberRef.displayName})" @@ -104,13 +122,37 @@ fun itemSegmentDisplayText(ft: FormattedText, ci: ChatItem, linkMode: SimplexLin if (t != null) "$t ${ft.format.viaHosts}" else ft.text } is Format.Command -> ft.text + is Format.Escape -> "" + is Format.Markup -> ft.format.token.drawnText else -> ft.text } -// Full display text for a chat item — joins segment display texts. -fun itemDisplayText(ci: ChatItem, linkMode: SimplexLinkMode): String { - val formattedText = ci.formattedText ?: return ci.text - return formattedText.joinToString("") { itemSegmentDisplayText(it, ci, linkMode) } +class ItemDisplay(val parts: List>, val bases: List) + +fun itemDisplay(ci: ChatItem, linkMode: SimplexLinkMode): ItemDisplay? { + val fts = ci.formattedText ?: return null + val segment = { ft: FormattedText -> + val display = segmentDisplayText(ft, ci.mentions, linkMode) + display to if (ft.format is Format.Markup) display else ft.text + } + val blocks = ci.markdownBlocks ?: return ItemDisplay(fts.map(segment), listOf(0)) + val parts = mutableListOf>() + var offset = itemPrefixText(ci).length + val bases = blockTextRanges(blocks).mapIndexed { i, t -> + if (i > 0) { + val separator = if (t.inRow) "\t" else "\n" + parts += separator to separator + offset += 1 + } + val base = if (i == 0) 0 else offset + for (j in t.range.from.coerceIn(0, fts.size) until t.range.to.coerceIn(0, fts.size)) { + val part = segment(fts[j]) + parts += part + offset += part.first.length + } + base + } + return ItemDisplay(parts, bases) } // Display-only prefix rendered before ci.text (e.g. "Spam: " for reports). @@ -120,11 +162,12 @@ fun itemPrefixText(ci: ChatItem): String = when (val mc = ci.content.msgContent) else -> "" } -// Text transformations in MarkdownText must match itemSegmentDisplayText above +// Text transformations in MarkdownText must match segmentDisplayText above @Composable fun MarkdownText ( text: CharSequence, formattedText: List? = null, + markdownBlocks: List? = null, sender: String? = null, meta: CIMeta? = null, chatTTL: Int? = null, @@ -145,15 +188,44 @@ fun MarkdownText ( showTimestamp: Boolean = true, prefix: AnnotatedString? = null, stripLink: String? = null, - selectionRange: IntRange? = null, - onTextLayoutResult: ((TextLayoutResult) -> Unit)? = null + selection: ItemSelection? = null, + textIndex: Int = 0, + blockLayout: Boolean = false, + range: IntRange? = null, + softWrap: Boolean = true, + cardColor: Color? = null, + parentDirection: LayoutDirection? = null ) { val text = if (stripLink != null) stripTextLink(text.toString(), stripLink) else text val formattedText = if (stripLink != null) stripFormattedTextLink(formattedText, stripLink) else formattedText val textLayoutDirection = remember (text) { if (isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr } - val reserve = if (textLayoutDirection != LocalLayoutDirection.current && meta != null) { + if (blockLayout && formattedText != null && markdownBlocks != null && range == null) { + val direction = LocalLayoutDirection.current + CompositionLocalProvider(LocalLayoutDirection provides textLayoutDirection) { + MarkdownBlocks( + formattedText, markdownBlocks, style, modifier, cardColor ?: MaterialTheme.appColors.receivedQuote, + blockText = { index, r, blockStyle, blockModifier, blockSoftWrap, last -> + MarkdownText( + text, formattedText, markdownBlocks, sender = if (index == 0) sender else null, meta = if (last) meta else null, chatTTL = chatTTL, + mentions = mentions, userMemberId = userMemberId, toggleSecrets = toggleSecrets, sendCommandMsg = sendCommandMsg, style = blockStyle, + uriHandler = uriHandler, senderBold = senderBold, modifier = blockModifier, linkMode = linkMode, onLinkLongClick = onLinkLongClick, + showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = if (index == 0) prefix else null, + selection = selection, textIndex = index, range = r.from until r.to, softWrap = blockSoftWrap, parentDirection = direction + ) + }, + metaLine = { + MarkdownText( + text, formattedText, markdownBlocks, meta = meta, chatTTL = chatTTL, toggleSecrets = toggleSecrets, style = style, + linkMode = linkMode, showViaProxy = showViaProxy, showTimestamp = showTimestamp, range = IntRange.EMPTY + ) + } + ) + } + return + } + val reserve = if (textLayoutDirection != (parentDirection ?: LocalLayoutDirection.current) && meta != null) { "\n" } else if (meta != null) { reserveSpaceForMeta(meta, chatTTL, null, secondaryColor = MaterialTheme.colors.secondary, showViaProxy = showViaProxy, showTimestamp = showTimestamp) @@ -196,6 +268,7 @@ fun MarkdownText ( } ) } + val textSelection = selection?.text(textIndex) if (formattedText == null) { var selectableEnd = 0 val annotatedText = buildAnnotatedString { @@ -210,9 +283,9 @@ fun MarkdownText ( } if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) } } - val clampedRange = selectionRange?.let { it.first .. minOf(it.last, selectableEnd) } - if (onTextLayoutResult != null) { - SelectableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, selectionRange = clampedRange, onTextLayoutResult = onTextLayoutResult) + val clampedRange = textSelection?.highlightRange?.let { it.first .. minOf(it.last, selectableEnd - 1) }?.takeUnless { it.isEmpty() } + if (textSelection != null) { + SelectableText(annotatedText, style = style, modifier = modifier.then(textSelection.positionModifier), maxLines = maxLines, overflow = overflow, selectionRange = clampedRange, onTextLayoutResult = textSelection.onTextLayoutResult) } else { Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf()) } @@ -222,29 +295,38 @@ fun MarkdownText ( var hasSecrets = false var hasCommands = false var hasModals = false + val hiddenSecrets = mutableListOf() val annotatedText = buildAnnotatedString { inlineContent?.first?.invoke(this) appendSender(this, sender, senderBold) if (prefix != null) append(prefix) - for ((i, ft) in formattedText.withIndex()) { + val headings = if (range == null && markdownBlocks != null) headingSegments(formattedText.size, markdownBlocks) else null + fun appendSecret(s: String) { + val start = length + withStyle(hiddenSecretStyle) { append(s) } + hiddenSecrets += start until length + } + for (i in range?.let { it.first.coerceAtLeast(0) .. minOf(it.last, formattedText.lastIndex) } ?: formattedText.indices) { + val ft = formattedText[i] + val heading = headings?.get(i) == true + if (heading) pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) if (ft.format == null) append(ft.text) else when(ft.format) { - is Format.Bold -> withStyle(ft.format.style) { append(ft.text) } + is Format.Bold -> withStyle(if (markdownBlocks != null && ft.format.singleStar) Format.Italic().style else ft.format.style) { append(ft.text) } is Format.Italic -> withStyle(ft.format.style) { append(ft.text) } is Format.StrikeThrough -> withStyle(ft.format.style) { append(ft.text) } is Format.Snippet -> withStyle(ft.format.style) { append(ft.text) } is Format.Small -> withStyle(ft.format.style) { append(ft.text) } is Format.Colored -> withStyle(ft.format.style) { append(ft.text) } is Format.Secret -> { - val ftStyle = ft.format.style if (toggleSecrets) { hasSecrets = true val key = i.toString() withAnnotation(tag = "SECRET", annotation = key) { - if (showSecrets[key] == true) append(ft.text) else withStyle(ftStyle) { append(ft.text) } + if (showSecrets[key] == true) append(ft.text) else appendSecret(ft.text) } } else { - withStyle(ftStyle) { append(ft.text) } + appendSecret(ft.text) } } is Format.Mention -> { @@ -290,7 +372,7 @@ fun MarkdownText ( is Format.HyperLink -> { hasLinks = true val ftStyle = Format.linkStyle - withAnnotation(tag = "WEB_URL", annotation = ft.format.linkUri) { + withAnnotation(tag = if (ft.format.isEmail) "OTHER_URL" else "WEB_URL", annotation = ft.format.linkUri) { withStyle(ftStyle) { append(ft.format.showText ?: ft.text) } } } @@ -338,8 +420,15 @@ fun MarkdownText ( withStyle(ftStyle) { append(ft.text) } } } + is Format.Escape -> {} + is Format.Markup -> append( + if (range != null) ft.format.token.drawnText + else if (markdownBlocks != null) ft.format.token.previewText + else ft.text + ) is Format.Unknown -> append(ft.text) } + if (heading) pop() } selectableEnd = this.length if (meta?.isLive == true) { @@ -350,10 +439,21 @@ fun MarkdownText ( withStyle(reserveTimestampStyle) { append("\n" + metaText) } else */if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) } } - val clampedRange = selectionRange?.let { it.first .. minOf(it.last, selectableEnd) } + val clampedRange = textSelection?.highlightRange?.let { it.first .. minOf(it.last, selectableEnd - 1) }?.takeUnless { it.isEmpty() } + val layoutResult = remember { mutableStateOf(null) } + val onTextLayout: (TextLayoutResult) -> Unit = { r -> + layoutResult.value = r + textSelection?.onTextLayoutResult?.invoke(r) + } + val textModifier = modifier + .then(textSelection?.positionModifier ?: Modifier) + .then(if (hiddenSecrets.isEmpty()) Modifier else Modifier.drawWithContent { + drawContent() + drawSecretPattern(hiddenSecrets, layoutResult.value) + }) if ((hasLinks && uriHandler != null) || hasSecrets || (hasCommands && sendCommandMsg != null) || hasModals) { val icon = remember { mutableStateOf(PointerIcon.Text) } - ClickableText(annotatedText, style = style, selectionRange = clampedRange, modifier = modifier.pointerHoverIcon(icon.value), maxLines = maxLines, overflow = overflow, + ClickableText(annotatedText, style = style, selectionRange = clampedRange, modifier = textModifier.pointerHoverIcon(icon.value), softWrap = softWrap, maxLines = maxLines, overflow = overflow, onLongClick = { offset -> if (hasLinks) { val withAnnotation: (String, (Range) -> Unit) -> Unit = { tag, f -> @@ -410,13 +510,38 @@ fun MarkdownText ( || annotatedText.hasStringAnnotations(tag = "OTHER_URL", start = offset, end = offset) || annotatedText.hasStringAnnotations(tag = "MODAL", start = offset, end = offset) }, - onTextLayout = { onTextLayoutResult?.invoke(it) } + onTextLayout = onTextLayout ) } else { - if (onTextLayoutResult != null) { - SelectableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, selectionRange = clampedRange, onTextLayoutResult = onTextLayoutResult) + if (textSelection != null) { + SelectableText(annotatedText, style = style, modifier = textModifier, softWrap = softWrap, maxLines = maxLines, overflow = overflow, selectionRange = clampedRange, onTextLayoutResult = onTextLayout) } else { - Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf()) + Text(annotatedText, style = style, modifier = textModifier, softWrap = softWrap, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf(), onTextLayout = onTextLayout) + } + } + } + } +} + +private val hiddenSecretStyle = SpanStyle(color = Color.Transparent) + +private val secretTile: ImageBitmap? by lazy { MR.images.vertical_logo.toComposeImageBitmap() } + +private fun ContentDrawScope.drawSecretPattern(ranges: List, layout: TextLayoutResult?) { + val tile = secretTile ?: return + if (layout == null) return + val brush = ShaderBrush(ImageShader(tile, TileMode.Repeated, TileMode.Repeated)) + val tileScale = 8.dp.toPx() / tile.width + val saturation = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0.65f) }) + val textLength = layout.layoutInput.text.length + ranges.forEach { r -> + val path = layout.getPathForRange(r.first.coerceIn(0, textLength), (r.last + 1).coerceIn(0, textLength)) + val bounds = path.getBounds() + val side = (bounds.width + bounds.height) / tileScale + clipPath(path) { + rotate(45f, bounds.center) { + scale(tileScale, bounds.center) { + drawRect(brush, Offset(bounds.center.x - side / 2, bounds.center.y - side / 2), Size(side, side), alpha = 0.35f, colorFilter = saturation) } } } @@ -500,6 +625,7 @@ private fun SelectableText( text: AnnotatedString, style: TextStyle, modifier: Modifier = Modifier, + softWrap: Boolean = true, maxLines: Int = Int.MAX_VALUE, overflow: TextOverflow = TextOverflow.Clip, selectionRange: IntRange? = null, @@ -511,6 +637,7 @@ private fun SelectableText( text = text, modifier = modifier.pointerHoverIcon(PointerIcon.Text).then(selectionHighlight(selectionRange, text.length, layoutResult)), style = style, + softWrap = softWrap, maxLines = maxLines, overflow = overflow, onTextLayout = { @@ -624,7 +751,9 @@ fun stripFormattedTextLink(ft: List?, link: String): List= 0 && result[i].format == null && result[i].text.endsWith("\n")) { + if (i >= 0 && result[i].format is Format.Markup) { + result.removeAt(i) + } else if (i >= 0 && result[i].format == null && result[i].text.endsWith("\n")) { result[i] = FormattedText(result[i].text.dropLast(1), null) if (result[i].text.isEmpty()) result.removeAt(result.lastIndex) } 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 7e11fdfb97..89d2dfb811 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 @@ -277,6 +277,7 @@ fun ChatPreviewView( MarkdownText( text, formattedText, + if (formattedText == null) null else ci.markdownBlocks, sender = when { cInfo is ChatInfo.Group && !ci.chatDir.sent && !ci.meta.showGroupAsSender -> ci.memberDisplayName else -> null 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 8ae27ca17f..7d0851e65b 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 @@ -132,13 +132,16 @@ fun PlainTextEditor( @Serializable data class ParsedFormattedText( - val formattedText: List? = null + val formattedText: List? = null, + val markdownBlocks: List? = null ) -fun parseToMarkdown(text: String): List? { +fun parseToMarkdown(text: String): List? = parseToMarkdownText(text)?.formattedText + +fun parseToMarkdownText(text: String): ParsedFormattedText? { val formatted = chatParseMarkdown(text) return try { - json.decodeFromString(ParsedFormattedText.serializer(), formatted).formattedText + json.decodeFromString(ParsedFormattedText.serializer(), formatted) } catch (e: Exception) { Log.e(TAG, "Failed to parse into markdown: " + e.stackTraceToString()) null diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/images/vertical_logo@1x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/vertical_logo@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..f916e43ea99bbda5ae2bba007dc73ac5a77c8ea9 GIT binary patch literal 1234 zcmeAS@N?(olHy`uVBq!ia0vp^93afW1|*O0@9PFqEa{HEjtmSN`?>!lvI6-E$sR$z z3=CCj3=9n|3=F@3LJcn%7)lKo7+xhXFj&oCU=S~uvn$XBDAAG{;hE;^%b*2hb1<+n z3NbJPS&Tr)z$nE4G7ZRL@M4sPvx68lplX;H7}_%#SfFa6fHVkr05M1pgl1mAh%j*h z6I?aN0%imoq%CcqSRjz%EbxddW?Q&?xfOIj~R9FF-xv3?I3Kh9IdBs*0wn|_XRzNmLSYJs2tfVB{Rw=?aK*2e`C{@8s z&p^*W$&O1wLBXadCCw_x#SN+*$g@?-C@Cqh($_C9FV`zK*2^zS*Eh7ZwA42+(l;{F z1**_3uFNY*tkBIXR)!b?Gsh*hIJqdZpd>RtPXT0ZVp4u-iLH_n$Rap^xU(cP4PjGW zG1OZ?59)(t^bPe4^s#A6t;oco4I~562KE=kIvbE-R*^xe#rZj)0j?1ok@jPI{ed6ij7$PAl+HdcDm_guZ{q+zHC5s>IKHUmCc6RUB zq1n;Rw@y#?Pe5G*->#Pr7U`|C^33e=aOAz!5F$1ClFIYf&5!Th-TnC9L7x5lj`X|N z{ASYc^4PO_UcmGnJ_n_QKMML5&JgcLY(2J1Hq5vaU>p5IWx2^6@tu+_ zcWPrcZ>~EK#+{T`*e4-;-N0M=?eiX$azFjxd6TA`nd|b&PibHUB)wIukE-|oopnYXt(t9jmHI>-IvAhdr_44 Zw_a>YvSZQGceg<~)zj6_Wt~$(69AmVcuD{O literal 0 HcmV?d00001 diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/images/vertical_logo@2x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/vertical_logo@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..bb35878f0c4aec928c3e16b4d10603dfc2a14748 GIT binary patch literal 1617 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`Ea{HEjtmSN`?>!lvI6-E$sR$z z3=CCj3=9n|3=F@3LJcn%7)lKo7+xhXFj&oCU=S~uvn$XBDAAG{;hE;^%b*2hb1<+n z3NbJPS&Tr)z$nE4G7ZRL@M4sPvx68lplX;H7}_%#SfFa6fHVkr05M1pgl1mAh%j*h z6I`{x0%imor0saV%ts)_S>O>_%)r1c48n{Iv*t)JFfg6S42dX-@b$4u&d=3LOvz75 z)vL%Y0O?||sjvbvb5lza6)JLb@`|l0Y?Z(&tblBgu)dN4SV>8?tx|+H?jfSfFg)+ zA4CH}eP~{0i5*M}nlQSq2!uSs8e~Cq4gN)$slb2)yUP&jEMzex^&sC_1!PvF=0vz; z=BDNqgZyF)bQo3%bdApWIr&Aw1&PV2c0hZ;T9Aa$RfDaG#AOYV1XQDqJ}9n{;vSN) zz@osEWyfWs4^O3bT#H{kng>kN$(}BbAr^w6!TYm=14aJ5t6l%|;*l+#nW0kbp6s(d z3km~YEKt}{Cd7VMCWhtvntz;D9VusYWY;OaA zByogiaiKfoYH7f+x{psgA+gg5&h{$>*JJ1i;JQTS)Y`{$NNWO9q7#AFix?`Ztq?9b!9ac`yU z+pocMcC5pb>LRBo@e(tN#kiROSwKaBkUWE^am z#r!NlTeUmd+-yhep)>K8Pmb_T3Ee1D%zAA7ri|}eUa2{!<-ho>>X<(3*bLoE&*EEH zGH*R>xhiOUkn6}h!GpSo%?>>hNuP85Xw_8fKhtzsRCYW6ZC5#x#r^+Oa~x}8wgc0- z-@n=7?C0!$ZL9fE!k&YNb8pM?XFBbXCA-C}YwbVoh&y-c$@$G;=b8%)BGYQ?rUvJ& z@|yqpj;Qpc2HDi9I{F(|@Pz4REsyijO)a~>y;PW?Y{gIGzpU96|F4|c)AblsP!lvI6-E$sR$z z3=CCj3=9n|3=F@3LJcn%7)lKo7+xhXFj&oCU=S~uvn$XBDAAG{;hE;^%b*2hb1<+n z3NbJPS&Tr)z$nE4G7ZRL@M4sPvx68lplX;H7}_%#SfFa6fHVkr05M1pgl1mAh%j*h z6I`{#0%imoq)m`tVjYm;EbxddW?XQ2>tmIipR1RclAn~S zSCLx)(#2p?VFhI7rj{fsROII56h?X&sHg;q@=(~U%$M(T(8_%FTW^V-_X+1Qs2Nx-^fT8 zs6w~6GOr}DLN~8i8Da>`9GBGMxocyBTg2d!hJD@#aEl5J>s=?Mo;<5%w0;&~S2ZoS-?$NXYbm!&85Si-7?*y=IAIZWLLl5_CyHc&UTSHYTncpN`MX{qXkP z2ODGS-Q_nHHGDU*HhypOxo&^4gMDQ7owCDz3HSf?D`#)ap8t5IYKSWv&a8aKkp6M|-K7HaH#4u(Ikr-vNKJIl zcecwFDZ)w8Pr}`#!%j}?-kfv!gJJK4jb3XEpS;}u+~rkFSoNAuCeNmLBs9i;Uz_?u z@ASIU4Xxb|H$A&vdq`we1Lx=6Gj;^UNINDA%Oq{ywdT^6KPSJMA64JveTu3 zk@`MvK7E1rrm9bCy!$L%BEE4;+>aLa`N8HF9tLdPak#g(0h_5G)?SslFv)fnHErX>_-IAUE#dE`!H?fy9%a1&mZeR7e z@?MO&*{53#7Y~08dp`S%z~|zg+cpLNi`uq|>z=7w_H2sKX}^zdYYg6G{tR1U^)K=3 zMrOh1NvXY)E^YXEQR#bCqLJTu0ZB3CB|@)0$n}`u#m%d> zXH5Kgi1mc#1$S3Dr6V_IwH!KMJ?+OO2d;;K6N;y)MJ#JvzVi&P=#+jD@mdwlT`onZ zethAz>9R_2zS(9SX8O|cqraPK4A;*Xckz}ok=4b#)h5!9{|HOxHuWyE+j*|-)uCfm z0usgFj$OE6u;_>Ts+o%><#PR>m%UZnR?R(5Aokbl=-QI6_XKWrpBLDEZh=Kr-??W@ zVR=8)tq)ww5w36Cd638Yhw3%M<6M`y%^Wzr&zp;LOJr-eH5F|+&l_w$b@vQuleQh{ z8q=rkWk0kgNy6FW!tvV)O|@&)i*6pPc%*0L8l=FX`80vC^HZAQcdb`jZPHRbzdroi zrx~?zdgZU;S5~11gr4uSH&p$3*fVSCh4KTPMRtCUA0D_|ocB{qn&pst=)AZapc>25 L)z4*}Q$iB}rr7 "},{"text":"q1"},{"format":{"type":"markup","token":{"type":"softBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"quoteMark"}},"text":"> "},{"text":"q2"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"quoteMark"}},"text":">"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"quoteMark"}},"text":"> "},{"format":{"type":"bold","singleStar":false},"text":"q3"}],"markdownBlocks":[{"type":"quote","content":{"from":1,"to":10}}]}""") + + private val codeBlockAndRule = parsed("""{"formattedText":[{"format":{"type":"markup","token":{"type":"fenceMark","language":"bash"}},"text":"```bash"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"text":"ls -l"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"text":"pwd"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"fenceMark"}},"text":"```"},{"format":{"type":"markup","token":{"type":"lineBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"ruleMark"}},"text":"---"}],"markdownBlocks":[{"type":"codeBlock","language":"bash","content":{"from":2,"to":6}},{"type":"rule","mark":{"from":9,"to":10}}]}""") + + private val tableAligned = parsed("""{"formattedText":[{"format":{"type":"markup","token":{"type":"rowMark"}},"text":"| "},{"text":"a"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" | "},{"text":"b"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" | "},{"text":"c"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" | "},{"text":"d"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" |"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"delimiterRow","columns":["none","left","right","center"]}},"text":"|---|:--|--:|:-:|"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"rowMark"}},"text":"| "},{"text":"xxx"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" | "},{"format":{"type":"bold","singleStar":false},"text":"yyy"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" | "},{"text":"zzz"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" |"}],"markdownBlocks":[{"type":"table","columns":["none","left","right","center"],"header":[{"from":1,"to":2},{"from":3,"to":4},{"from":5,"to":6},{"from":7,"to":8}],"rows":[[{"from":13,"to":14},{"from":15,"to":16},{"from":17,"to":18}]]}]}""") + + private val gaps = parsed("""{"formattedText":[{"format":{"type":"markup","token":{"type":"paragraphBreak","lineBreaks":2}},"text":"\n\n"},{"text":"a"},{"format":{"type":"markup","token":{"type":"paragraphBreak","lineBreaks":3}},"text":"\n\n\n"},{"format":{"type":"markup","token":{"type":"headingMark","level":1}},"text":"# "},{"text":"h"},{"format":{"type":"markup","token":{"type":"lineBreak"}},"text":"\n"}],"markdownBlocks":[{"type":"paragraph","content":{"from":1,"to":2}},{"type":"heading","level":1,"content":{"from":4,"to":5}}]}""") + + private val escapes = parsed("""{"formattedText":[{"text":"text "},{"format":{"type":"escape"},"text":"\\"},{"text":"*not"},{"format":{"type":"escape"},"text":"\\"},{"text":"*\nmore"}]}""") + + @Test + fun testDecodeTokens() { + val tokens = (headingAndParagraph.formattedText!! + nestedList.formattedText!! + codeBlockAndRule.formattedText!! + tableAligned.formattedText!! + gaps.formattedText!!) + .mapNotNull { (it.format as? Format.Markup)?.token } + assertEquals(1, tokens.filterIsInstance().first().level) + assertEquals(listOf(null, null, "2."), tokens.filterIsInstance().map { it.number }) + assertEquals(listOf("bash", null), tokens.filterIsInstance().map { it.language }) + assertEquals(listOf(TableAlign.None, TableAlign.Left, TableAlign.Right, TableAlign.Center), tokens.filterIsInstance().first().columns) + assertEquals(listOf(2, 3), tokens.filterIsInstance().map { it.lineBreaks }) + listOf( + BlockToken.LineBreak::class, BlockToken.SoftBreak::class, BlockToken.HardBreak::class, BlockToken.Indent::class, + BlockToken.RowMark::class, BlockToken.CellMark::class, BlockToken.RuleMark::class + ).forEach { kind -> assert(tokens.any { kind.isInstance(it) }) { "no $kind" } } + assertIs((quote.formattedText!![0].format as Format.Markup).token) + val bold = headingAndParagraph.formattedText!!.mapNotNull { it.format as? Format.Bold } + quote.formattedText!!.mapNotNull { it.format as? Format.Bold } + assertEquals(listOf(true, false), bold.map { it.singleStar }) + assertEquals(listOf("text ", "", "*not", "", "*\nmore"), escapes.formattedText!!.map(::display)) + assertNull(escapes.markdownBlocks) + } + + @Test + fun testDecodeBlocks() { + val heading = assertIs(headingAndParagraph.markdownBlocks!![0]) + assertEquals(1, heading.level) + assertEquals(1 to 3, heading.content.pair) + assertEquals(4 to 9, assertIs(headingAndParagraph.markdownBlocks!![1]).content.pair) + val list = assertIs(nestedList.markdownBlocks!!.single()) + assertEquals(listOf(null, "2."), list.items.map { it.number }) + assertEquals(7 to 8, list.items[0].items.single().content.pair) + assertEquals(1 to 10, assertIs(quote.markdownBlocks!!.single()).content.pair) + val code = assertIs(codeBlockAndRule.markdownBlocks!![0]) + assertEquals("bash", code.language) + assertEquals(2 to 6, code.content.pair) + assertEquals(9 to 10, assertIs(codeBlockAndRule.markdownBlocks!![1]).mark.pair) + val table = assertIs(tableAligned.markdownBlocks!!.single()) + assertEquals(listOf(TableAlign.None, TableAlign.Left, TableAlign.Right, TableAlign.Center), table.columns) + assertEquals(listOf(1 to 2, 3 to 4, 5 to 6, 7 to 8), table.header.map { it.pair }) + assertEquals(listOf(listOf(13 to 14, 15 to 16, 17 to 18)), table.rows.map { row -> row.map { it.pair } }) + } + + @Test + fun testBlockTextRanges() { + assertEquals(listOf(1 to 3, 4 to 9), texts(headingAndParagraph).map { it.first }) + assertEquals(listOf(1 to 5, 7 to 8, 10 to 11), texts(nestedList).map { it.first }) + assertEquals(listOf(1 to 10), texts(quote).map { it.first }) + assertEquals(listOf(2 to 6), texts(codeBlockAndRule).map { it.first }) + assertEquals( + listOf(1 to 2 to false, 3 to 4 to true, 5 to 6 to true, 7 to 8 to true, 13 to 14 to false, 15 to 16 to true, 17 to 18 to true), + texts(tableAligned) + ) + } + + @Test + fun testListEntryLevels() { + val list = assertIs(nestedList.markdownBlocks!!.single()) + assertEquals(listOf(1 to 0, 7 to 1, 10 to 0), listEntries(list.items, 0).map { (entry, level) -> entry.content.from to level }) + } + + @Test + fun testHeadingSegments() { + assertEquals(listOf(1, 2), headingSegments(9, headingAndParagraph.markdownBlocks!!).withIndex().filter { it.value }.map { it.index }) + assertEquals(listOf(4), headingSegments(6, gaps.markdownBlocks!!).withIndex().filter { it.value }.map { it.index }) + assertEquals(listOf(1), headingSegments(2, headingAndParagraph.markdownBlocks!!).withIndex().filter { it.value }.map { it.index }) + } + + @Test + fun testTokenTexts() { + val tokens = listOf( + BlockToken.SoftBreak(), BlockToken.LineBreak(), BlockToken.HardBreak(), BlockToken.ParagraphBreak(3), BlockToken.Indent(), + BlockToken.HeadingMark(2), BlockToken.ListMark("1."), BlockToken.ListMark(null), BlockToken.QuoteMark(), BlockToken.FenceMark(null), + BlockToken.RowMark(), BlockToken.CellMark(), BlockToken.DelimiterRow(listOf(TableAlign.None)), BlockToken.RuleMark() + ) + assertEquals(listOf(" ", "\n", "\n", "\n\n\n", "", "", "", "", "", "", "", "", "", ""), tokens.map { it.drawnText }) + assertEquals(listOf(" ", "\n", " ", "\n", "", "", "1. ", "• ", "", "", "", " ", "", ""), tokens.map { it.previewText }) + assertEquals("", display(FormattedText("\\", Format.Escape()))) + assertEquals("Title x\nPara a b c", preview(headingAndParagraph)) + assertEquals("• x y\n• z\n2. w", preview(nestedList)) + } + + @Test + fun testItemDisplay() { + val item = ChatItem.getSampleData(text = "> q1\n> q2\n>\n> **q3**").copy(formattedText = quote.formattedText, markdownBlocks = quote.markdownBlocks) + val display = itemDisplay(item, SimplexLinkMode.DESCRIPTION)!! + assertEquals("q1 q2\n\nq3", display.parts.joinToString("") { it.first }) + assertEquals(listOf(0), display.bases) + val table = item.copy(formattedText = tableAligned.formattedText, markdownBlocks = tableAligned.markdownBlocks) + val tableDisplay = itemDisplay(table, SimplexLinkMode.DESCRIPTION)!! + assertEquals("a\tb\tc\td\nxxx\tyyy\tzzz", tableDisplay.parts.joinToString("") { it.first }) + assertEquals(listOf(0, 2, 4, 6, 8, 12, 16), tableDisplay.bases) + val plain = item.copy(formattedText = escapes.formattedText, markdownBlocks = null) + val plainDisplay = itemDisplay(plain, SimplexLinkMode.DESCRIPTION)!! + assertEquals(listOf("text " to "text ", "" to "\\", "*not" to "*not", "" to "\\", "*\nmore" to "*\nmore"), plainDisplay.parts) + assertEquals(listOf(0), plainDisplay.bases) + } + + private val SegmentRange.pair: Pair get() = from to to + + private fun parsed(s: String): ParsedFormattedText = json.decodeFromString(ParsedFormattedText.serializer(), s) + + private fun display(ft: FormattedText): String = segmentDisplayText(ft, null, SimplexLinkMode.DESCRIPTION) + + private fun texts(p: ParsedFormattedText): List, Boolean>> = + blockTextRanges(p.markdownBlocks!!).map { it.range.pair to it.inRow } + + private fun preview(p: ParsedFormattedText): String = + p.formattedText!!.joinToString("") { ft -> (ft.format as? Format.Markup)?.token?.previewText ?: ft.text } +} diff --git a/apps/multiplatform/spec/client/chat-view.md b/apps/multiplatform/spec/client/chat-view.md index 3bad2e09c4..c7d6f0674f 100644 --- a/apps/multiplatform/spec/client/chat-view.md +++ b/apps/multiplatform/spec/client/chat-view.md @@ -316,7 +316,7 @@ Key sections: group profile, group link, member list with roles, group preferenc | `MarkdownFileView.kt` | Markdown file viewer, formatted or as text | | `IntegrityErrorItemView.kt` | Message integrity error | | `MarkedDeletedItemView.kt` | Marked-as-deleted / moderated message | -| `TextItemView.kt` | Plain text message with markdown | +| `TextItemView.kt` | Plain text message with markdown; blocks of `markdownBlocks` drawn as a column of views by `MarkdownBlocks` (`MarkdownBlocks.kt`) | ### `views/chat/group/` diff --git a/apps/simplex-directory-service/src/Directory/Listing.hs b/apps/simplex-directory-service/src/Directory/Listing.hs index dd7bbb509a..65b47d6c29 100644 --- a/apps/simplex-directory-service/src/Directory/Listing.hs +++ b/apps/simplex-directory-service/src/Directory/Listing.hs @@ -25,6 +25,7 @@ import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy as LB import Data.Int (Int64) import Data.List (isPrefixOf) +import Data.List.NonEmpty (NonEmpty) import Data.Maybe (catMaybes, fromMaybe) import Data.Text (Text) import qualified Data.Text as T @@ -92,7 +93,9 @@ data DirectoryEntry = DirectoryEntry simplexName :: Maybe Text, groupLink :: PublicLink, shortDescr :: Maybe MarkdownList, + shortDescrBlocks :: Maybe (NonEmpty MarkdownBlock), welcomeMessage :: Maybe MarkdownList, + welcomeMessageBlocks :: Maybe (NonEmpty MarkdownBlock), imageFile :: Maybe String, activeAt :: Maybe UTCTime, createdAt :: Maybe UTCTime @@ -133,6 +136,8 @@ groupDirectoryEntry now g@GroupInfo {groupProfile, chatTs, createdAt, groupSumma linkLine = groupLinkLine displayName $ decodeUtf8 linkText in Just $ maybe linkLine (<> "\n\n" <> linkLine) description _ -> description + shortDescr' = toFormattedText <$> shortDescr + welcomeMessage' = toFormattedText <$> description' entry groupLink = let de = DirectoryEntry @@ -140,8 +145,10 @@ groupDirectoryEntry now g@GroupInfo {groupProfile, chatTs, createdAt, groupSumma displayName, simplexName = shortNameInfoStr . SimplexNameInfo NTPublicGroup <$> verifiedGroupDomain g, groupLink, - shortDescr = toFormattedText <$> shortDescr, - welcomeMessage = toFormattedText <$> description', + shortDescr = fst <$> shortDescr', + shortDescrBlocks = snd =<< shortDescr', + welcomeMessage = fst <$> welcomeMessage', + welcomeMessageBlocks = snd =<< welcomeMessage', imageFile = fst <$> imgData, activeAt = recentRoundedTime 900 now $ fromMaybe createdAt chatTs, createdAt = recentRoundedTime 86400 now createdAt @@ -194,5 +201,5 @@ generateListing dir gs = do where saveListing newDir f = LB.writeFile (newDir f) . J.encode . DirectoryListing . map fst -toFormattedText :: Text -> MarkdownList -toFormattedText t = fromMaybe [FormattedText Nothing t] $ parseMaybeMarkdownList t +toFormattedText :: Text -> (MarkdownList, Maybe (NonEmpty MarkdownBlock)) +toFormattedText t = let ParsedMarkdown ft blocks = parsedMarkdown t in (fromMaybe [FormattedText Nothing t] ft, blocks) diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 76c3f43400..7d4efb0497 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -1157,10 +1157,10 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o -- "Use */filter " <> tshow gId <> " * to change spam filter level: no (disable), basic, moderate, strong.", -- "Or use */filter " <> tshow gId <> " [name[=noimage]] [captcha[=noimage]] [observer[=noimage]]* for advanced filter configuration." ] - <> ["/'filter " <> tshow gId <> " name' - enable name filter" | isNothing (rejectNames a)] - <> ["/'filter " <> tshow gId <> " captcha' - enable captcha challenge" | isNothing (passCaptcha a)] - <> ["/'filter " <> tshow gId <> " name captcha' - enable both" | isNothing (rejectNames a) || isNothing (passCaptcha a)] - <> ["/'filter " <> tshow gId <> " off' - disable filter" | isJust (rejectNames a) || isJust (passCaptcha a)] + <> ["/'filter " <> tshow gId <> " name' - enable name filter " | isNothing (rejectNames a)] + <> ["/'filter " <> tshow gId <> " captcha' - enable captcha challenge " | isNothing (passCaptcha a)] + <> ["/'filter " <> tshow gId <> " name captcha' - enable both " | isNothing (rejectNames a) || isNothing (passCaptcha a)] + <> ["/'filter " <> tshow gId <> " off' - disable filter " | isJust (rejectNames a) || isJust (passCaptcha a)] showCondition = \case Nothing -> "_disabled_" Just PCAll -> "_enabled_" diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 6ca0d67ac4..7b3985e132 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -18,6 +18,7 @@ This file is generated automatically. - [BadgeServiceErrorCode](#badgeserviceerrorcode) - [BadgeStatus](#badgestatus) - [BadgeType](#badgetype) +- [BlockToken](#blocktoken) - [BlockingInfo](#blockinginfo) - [BlockingReason](#blockingreason) - [BrokerErrorType](#brokererrortype) @@ -131,8 +132,10 @@ This file is generated automatically. - [LinkContent](#linkcontent) - [LinkOwnerSig](#linkownersig) - [LinkPreview](#linkpreview) +- [ListEntry](#listentry) - [LocalBadge](#localbadge) - [LocalProfile](#localprofile) +- [MarkdownBlock](#markdownblock) - [MemberCriteria](#membercriteria) - [MsgChatLink](#msgchatlink) - [MsgContent](#msgcontent) @@ -182,6 +185,7 @@ This file is generated automatically. - [RoleGroupPreference](#rolegrouppreference) - [SMPAgentError](#smpagenterror) - [SecurityCode](#securitycode) +- [SegmentRange](#segmentrange) - [SimplePreference](#simplepreference) - [SimplexDomain](#simplexdomain) - [SimplexDomainClaim](#simplexdomainclaim) @@ -201,6 +205,7 @@ This file is generated automatically. - [SubscriptionStatus](#subscriptionstatus) - [SupportGroupPreference](#supportgrouppreference) - [SwitchPhase](#switchphase) +- [TableAlign](#tablealign) - [TimedMessagesGroupPreference](#timedmessagesgrouppreference) - [TimedMessagesPreference](#timedmessagespreference) - [TransportError](#transporterror) @@ -513,6 +518,64 @@ CredentialNotVerified: - "investor" +--- + +## BlockToken + +**Discriminated union type**: + +LineBreak: +- type: "lineBreak" + +SoftBreak: +- type: "softBreak" + +HardBreak: +- type: "hardBreak" + +ParagraphBreak: +- type: "paragraphBreak" +- lineBreaks: int + +Indent: +- type: "indent" + +HeadingMark: +- type: "headingMark" +- level: int + +HeadingClose: +- type: "headingClose" + +HeadingUnderline: +- type: "headingUnderline" +- level: int + +ListMark: +- type: "listMark" +- number: string? + +QuoteMark: +- type: "quoteMark" + +FenceMark: +- type: "fenceMark" +- language: string? + +RowMark: +- type: "rowMark" + +CellMark: +- type: "cellMark" + +DelimiterRow: +- type: "delimiterRow" +- columns: [[TableAlign](#tablealign)] + +RuleMark: +- type: "ruleMark" + + --- ## BlockingInfo @@ -1007,6 +1070,7 @@ GroupLink: - sentAt: UTCTime - content: [MsgContent](#msgcontent) - formattedText: [[FormattedText](#formattedtext)]? +- markdownBlocks: [[MarkdownBlock](#markdownblock)]? --- @@ -1505,6 +1569,7 @@ ContactConnection: - content: [CIContent](#cicontent) - mentions: {string : [CIMention](#cimention)} - formattedText: [[FormattedText](#formattedtext)]? +- markdownBlocks: [[MarkdownBlock](#markdownblock)]? - quotedItem: [CIQuote](#ciquote)? - reactions: [[CIReactionCount](#cireactioncount)] - file: [CIFile](#cifile)? @@ -2283,10 +2348,13 @@ NO_FILE: ## Format +The format markup is a token of block Markdown syntax; its text is the syntax. The format escape is the backslash of a backslash escape. Bold singleStar: written with single stars. + **Discriminated union type**: Bold: - type: "bold" +- singleStar: bool Italic: - type: "italic" @@ -2340,6 +2408,13 @@ Email: Phone: - type: "phone" +Escape: +- type: "escape" + +Markup: +- type: "markup" +- token: [BlockToken](#blocktoken) + --- @@ -2877,6 +2952,16 @@ Unknown: - content: [LinkContent](#linkcontent)? +--- + +## ListEntry + +**Record type**: +- number: string? +- content: [SegmentRange](#segmentrange) +- items: [[ListEntry](#listentry)] + + --- ## LocalBadge @@ -2906,6 +2991,45 @@ Unknown: - contactDomainVerified: bool? +--- + +## MarkdownBlock + +**Discriminated union type**: + +Paragraph: +- type: "paragraph" +- content: [SegmentRange](#segmentrange) + +Heading: +- type: "heading" +- level: int +- content: [SegmentRange](#segmentrange) + +CodeBlock: +- type: "codeBlock" +- language: string? +- content: [SegmentRange](#segmentrange) + +Quote: +- type: "quote" +- content: [SegmentRange](#segmentrange) + +List: +- type: "list" +- items: [[ListEntry](#listentry)] + +Table: +- type: "table" +- columns: [[TableAlign](#tablealign)] +- header: [[SegmentRange](#segmentrange)] +- rows: [[[SegmentRange](#segmentrange)]] + +Rule: +- type: "rule" +- mark: [SegmentRange](#segmentrange) + + --- ## MemberCriteria @@ -3787,6 +3911,15 @@ A_SERVICE: - verifiedAt: UTCTime +--- + +## SegmentRange + +**Record type**: +- from: int +- to: int + + --- ## SimplePreference @@ -4408,6 +4541,17 @@ NoSub: - "completed" +--- + +## TableAlign + +**Enum type**: +- "none" +- "left" +- "center" +- "right" + + --- ## TimedMessagesGroupPreference diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index 3adcaef783..ab76cc6581 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -224,6 +224,7 @@ chatTypesDocsData = (sti @BadgeServiceErrorCode, STEnum' (consSep "BSE" '_'), "", ["BSEUnknown"], "", ""), (sti @BlockingInfo, STRecord, "", [], "", ""), (sti @BlockingReason, STEnum, "BR", [], "", ""), + (sti @BlockToken, STUnion, "", [], "", ""), (sti @BrokerErrorType, STUnion, "", [], "", ""), (sti @BusinessChatInfo, STRecord, "", [], "", ""), (sti @BusinessChatType, STEnum, "BC", [], "", ""), @@ -285,7 +286,7 @@ chatTypesDocsData = (sti @FileStatus, STEnum, "FS", [], "", ""), (sti @FileTransferMeta, STRecord, "", [], "", ""), (sti @FileType, STEnum' (consLower "FT"), "", [], "", ""), - (sti @Format, STUnion, "", ["Unknown"], "", ""), + (sti @Format, STUnion, "", ["Unknown"], "", "The format markup is a token of block Markdown syntax; its text is the syntax. The format escape is the backslash of a backslash escape. Bold singleStar: written with single stars."), (sti @FormattedText, STRecord, "", [], "", ""), (sti @FullGroupPreferences, STRecord, "", [], "", ""), (sti @FullPreferences, STRecord, "", [], "", ""), @@ -321,8 +322,10 @@ chatTypesDocsData = (sti @LinkContent, STUnion, "LC", [], "", ""), (sti @LinkOwnerSig, STRecord, "", [], "", ""), (sti @LinkPreview, STRecord, "", [], "", ""), + (sti @ListEntry, STRecord, "", [], "", ""), (sti @BadgeInfo, STRecord, "", [], "", ""), (sti @LocalProfile, STRecord, "", [], "", ""), + (sti @MarkdownBlock, STUnion, "", ["UnknownBlock"], "", ""), (sti @MemberCriteria, STEnum1, "MC", [], "", ""), (sti @MsgChatLink, STUnion, "MCL", [], "", "Connection link sent in a message - only short links are allowed."), (sti @MsgContent, STUnion, "MC", [], "", ""), @@ -371,6 +374,7 @@ chatTypesDocsData = (sti @ReportReason, STEnum' (dropPfxSfx "RR" ""), "", ["RRUnknown"], "", ""), (sti @RoleGroupPreference, STRecord, "", [], "", ""), (sti @SecurityCode, STRecord, "", [], "", ""), + (sti @SegmentRange, STRecord, "", [], "", ""), (sti @SimplePreference, STRecord, "", [], "", ""), (sti @SimplexDomain, STRecord, "", [], "", ""), (sti @SimplexDomainClaim, STRecord, "", [], "", ""), @@ -391,6 +395,7 @@ chatTypesDocsData = (sti @SubscriptionStatus, STUnion, "SS", [], "", ""), (sti @SupportGroupPreference, STRecord, "", [], "", ""), (sti @SwitchPhase, STEnum, "SP", [], "", ""), + (sti @TableAlign, STEnum, "TA", [], "", ""), (sti @TimedMessagesGroupPreference, STRecord, "", [], "", ""), (sti @TimedMessagesPreference, STRecord, "", [], "", ""), (sti @TransportError, STUnion, "TE", [], "", ""), @@ -457,6 +462,7 @@ deriving instance Generic BadgeRedeemError deriving instance Generic BadgeServiceErrorCode deriving instance Generic BlockingInfo deriving instance Generic BlockingReason +deriving instance Generic BlockToken deriving instance Generic BrokerErrorType deriving instance Generic BusinessChatInfo deriving instance Generic BusinessChatType @@ -561,8 +567,10 @@ deriving instance Generic JSONCIStatus deriving instance Generic LinkContent deriving instance Generic LinkOwnerSig deriving instance Generic LinkPreview +deriving instance Generic ListEntry deriving instance Generic BadgeInfo deriving instance Generic LocalProfile +deriving instance Generic MarkdownBlock deriving instance Generic MemberCriteria deriving instance Generic MsgChatLink deriving instance Generic MsgContent @@ -610,6 +618,7 @@ deriving instance Generic RemoteCtrlSessionState deriving instance Generic RemoteCtrlStopReason deriving instance Generic ReportReason deriving instance Generic SecurityCode +deriving instance Generic SegmentRange deriving instance Generic SimplexDomain deriving instance Generic SimplexDomainClaim deriving instance Generic SimplexDomainError @@ -629,6 +638,7 @@ deriving instance Generic StoreError deriving instance Generic SubscriptionStatus deriving instance Generic SupportGroupPreference deriving instance Generic SwitchPhase +deriving instance Generic TableAlign deriving instance Generic TimedMessagesGroupPreference deriving instance Generic TimedMessagesPreference deriving instance Generic TransportError diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 35711c62cf..cef7f41de5 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -343,6 +343,112 @@ export enum BadgeType { Investor = "investor", } +export type BlockToken = + | BlockToken.LineBreak + | BlockToken.SoftBreak + | BlockToken.HardBreak + | BlockToken.ParagraphBreak + | BlockToken.Indent + | BlockToken.HeadingMark + | BlockToken.HeadingClose + | BlockToken.HeadingUnderline + | BlockToken.ListMark + | BlockToken.QuoteMark + | BlockToken.FenceMark + | BlockToken.RowMark + | BlockToken.CellMark + | BlockToken.DelimiterRow + | BlockToken.RuleMark + +export namespace BlockToken { + export type Tag = + | "lineBreak" + | "softBreak" + | "hardBreak" + | "paragraphBreak" + | "indent" + | "headingMark" + | "headingClose" + | "headingUnderline" + | "listMark" + | "quoteMark" + | "fenceMark" + | "rowMark" + | "cellMark" + | "delimiterRow" + | "ruleMark" + + interface Interface { + type: Tag + } + + export interface LineBreak extends Interface { + type: "lineBreak" + } + + export interface SoftBreak extends Interface { + type: "softBreak" + } + + export interface HardBreak extends Interface { + type: "hardBreak" + } + + export interface ParagraphBreak extends Interface { + type: "paragraphBreak" + lineBreaks: number // int + } + + export interface Indent extends Interface { + type: "indent" + } + + export interface HeadingMark extends Interface { + type: "headingMark" + level: number // int + } + + export interface HeadingClose extends Interface { + type: "headingClose" + } + + export interface HeadingUnderline extends Interface { + type: "headingUnderline" + level: number // int + } + + export interface ListMark extends Interface { + type: "listMark" + number?: string + } + + export interface QuoteMark extends Interface { + type: "quoteMark" + } + + export interface FenceMark extends Interface { + type: "fenceMark" + language?: string + } + + export interface RowMark extends Interface { + type: "rowMark" + } + + export interface CellMark extends Interface { + type: "cellMark" + } + + export interface DelimiterRow extends Interface { + type: "delimiterRow" + columns: TableAlign[] + } + + export interface RuleMark extends Interface { + type: "ruleMark" + } +} + export interface BlockingInfo { reason: BlockingReason notice?: ClientNotice @@ -998,6 +1104,7 @@ export interface CIQuote { sentAt: string // ISO-8601 timestamp content: MsgContent formattedText?: FormattedText[] + markdownBlocks?: MarkdownBlock[] } export interface CIReaction { @@ -1760,6 +1867,7 @@ export interface ChatItem { content: CIContent mentions: {[key: string]: CIMention} formattedText?: FormattedText[] + markdownBlocks?: MarkdownBlock[] quotedItem?: CIQuote reactions: CIReactionCount[] file?: CIFile @@ -2569,6 +2677,7 @@ export enum FileType { Normal = "normal", Roster = "roster", } +// The format markup is a token of block Markdown syntax; its text is the syntax. The format escape is the backslash of a backslash escape. Bold singleStar: written with single stars. export type Format = | Format.Bold @@ -2586,6 +2695,8 @@ export type Format = | Format.Mention | Format.Email | Format.Phone + | Format.Escape + | Format.Markup export namespace Format { export type Tag = @@ -2604,6 +2715,8 @@ export namespace Format { | "mention" | "email" | "phone" + | "escape" + | "markup" interface Interface { type: Tag @@ -2611,6 +2724,7 @@ export namespace Format { export interface Bold extends Interface { type: "bold" + singleStar: boolean } export interface Italic extends Interface { @@ -2678,6 +2792,15 @@ export namespace Format { export interface Phone extends Interface { type: "phone" } + + export interface Escape extends Interface { + type: "escape" + } + + export interface Markup extends Interface { + type: "markup" + token: BlockToken + } } export interface FormattedText { @@ -3142,6 +3265,12 @@ export interface LinkPreview { content?: LinkContent } +export interface ListEntry { + number?: string + content: SegmentRange + items: ListEntry[] +} + export interface LocalBadge { badge: BadgeInfo status: BadgeStatus @@ -3163,6 +3292,62 @@ export interface LocalProfile { contactDomainVerified?: boolean } +export type MarkdownBlock = + | MarkdownBlock.Paragraph + | MarkdownBlock.Heading + | MarkdownBlock.CodeBlock + | MarkdownBlock.Quote + | MarkdownBlock.List + | MarkdownBlock.Table + | MarkdownBlock.Rule + +export namespace MarkdownBlock { + export type Tag = "paragraph" | "heading" | "codeBlock" | "quote" | "list" | "table" | "rule" + + interface Interface { + type: Tag + } + + export interface Paragraph extends Interface { + type: "paragraph" + content: SegmentRange + } + + export interface Heading extends Interface { + type: "heading" + level: number // int + content: SegmentRange + } + + export interface CodeBlock extends Interface { + type: "codeBlock" + language?: string + content: SegmentRange + } + + export interface Quote extends Interface { + type: "quote" + content: SegmentRange + } + + export interface List extends Interface { + type: "list" + items: ListEntry[] // non-empty + } + + export interface Table extends Interface { + type: "table" + columns: TableAlign[] // non-empty + header: SegmentRange[] + rows: SegmentRange[][] + } + + export interface Rule extends Interface { + type: "rule" + mark: SegmentRange + } +} + export enum MemberCriteria { All = "all", } @@ -4216,6 +4401,11 @@ export interface SecurityCode { verifiedAt: string // ISO-8601 timestamp } +export interface SegmentRange { + from: number // int + to: number // int +} + export interface SimplePreference { allow: FeatureAllowed } @@ -5131,6 +5321,13 @@ export enum SwitchPhase { Completed = "completed", } +export enum TableAlign { + None = "none", + Left = "left", + Center = "center", + Right = "right", +} + export interface TimedMessagesGroupPreference { enable: GroupFeatureEnabled ttl?: number // int 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 f732d7faf2..373cf58d43 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -230,6 +230,77 @@ BadgeStatus = Literal["active", "expired", "expiredOld", "failed", "unknownKey"] BadgeType = Literal["supporter", "legend", "investor"] +class BlockToken_lineBreak(TypedDict): + type: Literal["lineBreak"] + +class BlockToken_softBreak(TypedDict): + type: Literal["softBreak"] + +class BlockToken_hardBreak(TypedDict): + type: Literal["hardBreak"] + +class BlockToken_paragraphBreak(TypedDict): + type: Literal["paragraphBreak"] + lineBreaks: int # int + +class BlockToken_indent(TypedDict): + type: Literal["indent"] + +class BlockToken_headingMark(TypedDict): + type: Literal["headingMark"] + level: int # int + +class BlockToken_headingClose(TypedDict): + type: Literal["headingClose"] + +class BlockToken_headingUnderline(TypedDict): + type: Literal["headingUnderline"] + level: int # int + +class BlockToken_listMark(TypedDict): + type: Literal["listMark"] + number: NotRequired[str] + +class BlockToken_quoteMark(TypedDict): + type: Literal["quoteMark"] + +class BlockToken_fenceMark(TypedDict): + type: Literal["fenceMark"] + language: NotRequired[str] + +class BlockToken_rowMark(TypedDict): + type: Literal["rowMark"] + +class BlockToken_cellMark(TypedDict): + type: Literal["cellMark"] + +class BlockToken_delimiterRow(TypedDict): + type: Literal["delimiterRow"] + columns: list["TableAlign"] + +class BlockToken_ruleMark(TypedDict): + type: Literal["ruleMark"] + +BlockToken = ( + BlockToken_lineBreak + | BlockToken_softBreak + | BlockToken_hardBreak + | BlockToken_paragraphBreak + | BlockToken_indent + | BlockToken_headingMark + | BlockToken_headingClose + | BlockToken_headingUnderline + | BlockToken_listMark + | BlockToken_quoteMark + | BlockToken_fenceMark + | BlockToken_rowMark + | BlockToken_cellMark + | BlockToken_delimiterRow + | BlockToken_ruleMark +) + +BlockToken_Tag = Literal["lineBreak", "softBreak", "hardBreak", "paragraphBreak", "indent", "headingMark", "headingClose", "headingUnderline", "listMark", "quoteMark", "fenceMark", "rowMark", "cellMark", "delimiterRow", "ruleMark"] + class BlockingInfo(TypedDict): reason: "BlockingReason" notice: NotRequired["ClientNotice"] @@ -692,6 +763,7 @@ class CIQuote(TypedDict): sentAt: str # ISO-8601 timestamp content: "MsgContent" formattedText: NotRequired[list["FormattedText"]] + markdownBlocks: NotRequired[list["MarkdownBlock"]] # non-empty class CIReaction(TypedDict): chatDir: "CIDirection" @@ -1224,6 +1296,7 @@ class ChatItem(TypedDict): content: "CIContent" mentions: dict[str, "CIMention"] formattedText: NotRequired[list["FormattedText"]] + markdownBlocks: NotRequired[list["MarkdownBlock"]] # non-empty quotedItem: NotRequired["CIQuote"] reactions: list["CIReactionCount"] file: NotRequired["CIFile"] @@ -1795,8 +1868,11 @@ class FileTransferMeta(TypedDict): FileType = Literal["normal", "roster"] +# The format markup is a token of block Markdown syntax; its text is the syntax. The format escape is the backslash of a backslash escape. Bold singleStar: written with single stars. + class Format_bold(TypedDict): type: Literal["bold"] + singleStar: bool class Format_italic(TypedDict): type: Literal["italic"] @@ -1850,6 +1926,13 @@ class Format_email(TypedDict): class Format_phone(TypedDict): type: Literal["phone"] +class Format_escape(TypedDict): + type: Literal["escape"] + +class Format_markup(TypedDict): + type: Literal["markup"] + token: "BlockToken" + Format = ( Format_bold | Format_italic @@ -1866,9 +1949,11 @@ Format = ( | Format_mention | Format_email | Format_phone + | Format_escape + | Format_markup ) -Format_Tag = Literal["bold", "italic", "strikeThrough", "snippet", "secret", "small", "colored", "uri", "hyperLink", "simplexLink", "simplexName", "command", "mention", "email", "phone"] +Format_Tag = Literal["bold", "italic", "strikeThrough", "snippet", "secret", "small", "colored", "uri", "hyperLink", "simplexLink", "simplexName", "command", "mention", "email", "phone", "escape", "markup"] class FormattedText(TypedDict): format: NotRequired["Format"] @@ -2191,6 +2276,11 @@ class LinkPreview(TypedDict): image: str content: NotRequired["LinkContent"] +class ListEntry(TypedDict): + number: NotRequired[str] + content: "SegmentRange" + items: list["ListEntry"] + class LocalBadge(TypedDict): badge: "BadgeInfo" status: "BadgeStatus" @@ -2210,6 +2300,50 @@ class LocalProfile(TypedDict): contactDomain: NotRequired["SimplexDomainClaim"] contactDomainVerified: NotRequired[bool] +class MarkdownBlock_paragraph(TypedDict): + type: Literal["paragraph"] + content: "SegmentRange" + +class MarkdownBlock_heading(TypedDict): + type: Literal["heading"] + level: int # int + content: "SegmentRange" + +class MarkdownBlock_codeBlock(TypedDict): + type: Literal["codeBlock"] + language: NotRequired[str] + content: "SegmentRange" + +class MarkdownBlock_quote(TypedDict): + type: Literal["quote"] + content: "SegmentRange" + +class MarkdownBlock_list(TypedDict): + type: Literal["list"] + items: list["ListEntry"] # non-empty + +class MarkdownBlock_table(TypedDict): + type: Literal["table"] + columns: list["TableAlign"] # non-empty + header: list["SegmentRange"] + rows: list[list["SegmentRange"]] + +class MarkdownBlock_rule(TypedDict): + type: Literal["rule"] + mark: "SegmentRange" + +MarkdownBlock = ( + MarkdownBlock_paragraph + | MarkdownBlock_heading + | MarkdownBlock_codeBlock + | MarkdownBlock_quote + | MarkdownBlock_list + | MarkdownBlock_table + | MarkdownBlock_rule +) + +MarkdownBlock_Tag = Literal["paragraph", "heading", "codeBlock", "quote", "list", "table", "rule"] + MemberCriteria = Literal["all"] # Connection link sent in a message - only short links are allowed. @@ -2937,6 +3071,10 @@ class SecurityCode(TypedDict): securityCode: str verifiedAt: str # ISO-8601 timestamp +class SegmentRange(TypedDict): + from: int # int + to: int # int + class SimplePreference(TypedDict): allow: "FeatureAllowed" @@ -3573,6 +3711,8 @@ class SupportGroupPreference(TypedDict): SwitchPhase = Literal["started", "confirmed", "secured", "completed"] +TableAlign = Literal["none", "left", "center", "right"] + class TimedMessagesGroupPreference(TypedDict): enable: "GroupFeatureEnabled" ttl: NotRequired[int] # int diff --git a/plans/2026-10-13-block-markdown-rendering.md b/plans/2026-10-13-block-markdown-rendering.md new file mode 100644 index 0000000000..fd5a3be739 --- /dev/null +++ b/plans/2026-10-13-block-markdown-rendering.md @@ -0,0 +1,415 @@ +# Block Markdown: rendering in the apps and on the web + +Date: 2026-10-13. Revised: 2026-10-16, for master. Revised: 2026-10-17, drawing from markdownBlocks. Revised: 2026-10-18, drawing by index ranges (S5). Revised: 2026-10-18, a column of block views (approved mockup https://claude.ai/artifact/7Twe2qJcdULBbcqhSPuh6k, v3). + +Where a whole message is shown, the apps draw `formattedText` with `markdownBlocks` (`2026-10-13-block-markdown.md`, sections 2 and 4) as a column of block views; each text view is the segment loop of master over the index range of one block, list entry or table cell (section 3). Previews, quotes, the compose context, the profile teaser and notifications stay one text. A message without blocks is drawn as now. On the web, HTML blocks are drawn by the page script. + +References are to master at 382dc7480. + +``` +K/ apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ +I/ apps/ios/Shared/Views/ +S/ apps/ios/SimpleXChat/ +W/ website/src/js/ +``` + +## 1. Scope + +``` +blocks drawn every list drawn by MarkdownText (K/views/chat/item/TextItemView.kt:125) + every list drawn by messageText (I/Chat/ChatItem/MsgContentView.swift:396) + desktop selection (K/views/chat/TextSelection.kt:264-305) + copied text of a selection: as drawn, by the same block drawing + the Copy action of an item: its source text, as now +tokens by kind notification texts (K/platform/NtfManager.kt:134-148, S/Notifications.swift:200-210) +token form compose state: parsedMessage, links, mentions, link sanitizing, as now + K/views/chat/ComposeView.kt:111, 130, 1024-1070 + I/Chat/ComposeMessage/ComposeView.swift:560, 1240, 1990 +HTML blocks renderMarkdown (W/simplex-lib.jsc:57-128) +``` + +- Per text view, as in one text now: + - taps + - long presses + - secrets + - mentions + - commands +- Per message: + - the typing indicator and the meta reserve: at the end of the last view when it is text, otherwise in a line below + - the meta reserve after text: a line break when the text direction differs from the bubble's, as now; spaces in the line below (iOS `reserveSpaceForMeta(mt, newLine: false)`) + - text direction: the column in the text direction, text aligned to its start +- Code blocks and tables scroll sideways; a drag that starts in them does not swipe the item. + +## 2. Types + +Kotlin, `K/model/ChatModel.kt`: + +``` +Format (:5177) @Serializable @SerialName("bold") class Bold(val singleStar: Boolean = false): Format() (:5178) + @Serializable @SerialName("escape") class Escape: Format() + @Serializable @SerialName("markup") class Markup(val token: BlockToken): Format() + style of Escape and Markup: SpanStyle() +BlockToken @Serializable sealed class, next to Format + @SerialName("lineBreak") class LineBreak + @SerialName("softBreak") class SoftBreak + @SerialName("hardBreak") class HardBreak + @SerialName("paragraphBreak") class ParagraphBreak(val lineBreaks: Int) + @SerialName("indent") class Indent + @SerialName("headingMark") class HeadingMark(val level: Int) + @SerialName("headingClose") class HeadingClose + @SerialName("headingUnderline") class HeadingUnderline(val level: Int) + @SerialName("listMark") class ListMark(val number: String? = null) + @SerialName("quoteMark") class QuoteMark + @SerialName("fenceMark") class FenceMark(val language: String? = null) + @SerialName("rowMark") class RowMark + @SerialName("cellMark") class CellMark + @SerialName("delimiterRow") class DelimiterRow(val columns: List) + @SerialName("ruleMark") class RuleMark +MarkdownBlock @Serializable sealed class + @SerialName("paragraph") class Paragraph(val content: SegmentRange) + @SerialName("heading") class Heading(val level: Int, val content: SegmentRange) + @SerialName("codeBlock") class CodeBlock(val language: String? = null, val content: SegmentRange) + @SerialName("quote") class Quote(val content: SegmentRange) + @SerialName("list") class ItemList(val items: List) + @SerialName("table") class Table(val columns: List, val header: List, val rows: List>) + @SerialName("rule") class Rule(val mark: SegmentRange) +ListEntry @Serializable class ListEntry(val number: String? = null, val content: SegmentRange, val items: List) +SegmentRange @Serializable class SegmentRange(val from: Int, val to: Int) +TableAlign @Serializable enum class: none, left, center, right +BlockToken.drawnText the text of the token in a block view (section 3) +BlockToken.previewText the text of the token in one text (section 3) +BlockTextRange class BlockTextRange(val range: SegmentRange, val inRow: Boolean) +MarkdownBlock.textRanges the ranges of the text views of a block, in source order + Paragraph, Heading, CodeBlock, Quote: content + ItemList: the contents of the entries, a parent before its children + Table: the cells of the header, then of each row; inRow for each cell after the first of a row + Rule: none +listEntries (items, level) -> List>: the entries with their levels, a parent before its children +blockTextRanges (blocks) -> the textRanges of all blocks +headingSegments (size, blocks) -> BooleanArray: the segments in heading contents +segmentDisplayText (ft: FormattedText, mentions: Map?, linkMode: SimplexLinkMode) -> String + the body of itemSegmentDisplayText (K/views/chat/item/TextItemView.kt:88-108), with mentions instead of ci + Escape and Markup: the text drawn for the token in a block view + replaces itemSegmentDisplayText +itemDisplay (ci: ChatItem, linkMode: SimplexLinkMode) -> ItemDisplay?, null without formattedText + parts: per segment the displayed and the copied text (Markup: its drawn text both); + between text views a part "\t" (between cells of a row) or "\n" + bases: per text view the offset of its first character in the selection offsets of the item; 0 for the first + without markdownBlocks: the segments, bases [0] +MarkdownBlocks composable: the column of block views (section 3) +``` + +- Kotlin name `ItemList`: `List` is `kotlin.collections.List` in the scope of `MarkdownBlock`; JSON tag "list". + +Kotlin records, field `markdownBlocks: List? = null` after `formattedText`: + +``` +ChatItem K/model/ChatModel.kt:3409 +CIQuote K/model/ChatModel.kt:4415 +ChatItemVersion K/model/ChatModel.kt:5716 +ParsedFormattedText K/views/helpers/TextEditor.kt:134-136 +``` + +iOS, `S/ChatTypes.swift`: + +``` +Format (:5681) case bold(singleStar: Bool) + case escape + case markup(token: BlockToken) +BlockToken enum: Decodable, Equatable, Hashable + cases as the Kotlin classes, associated values as their fields +MarkdownBlock enum: Decodable, Equatable, Hashable + cases as the Kotlin classes, list as `list` +ListEntry struct: Decodable, Equatable, Hashable +SegmentRange struct: Decodable, Equatable, Hashable +TableAlign enum: String, Decodable, Hashable +drawnText, previewText, BlockTextRange, textRanges, listEntries, blockTextRanges, headingSegments: as Kotlin +MarkdownBlocksView SwiftUI view, generic over the block text view, in I/Chat/ChatItem/MsgContentView.swift (section 3) +MarkdownView SwiftUI view: one text, or MarkdownBlocksView when blocks are present +``` + +- The `case .bold:` pattern (I/Chat/ChatItem/MsgContentView.swift:450) binds `singleStar`. + +iOS records, field `markdownBlocks: [MarkdownBlock]?` after `formattedText`: + +``` +ChatItem S/ChatTypes.swift:3810 +CIQuote S/ChatTypes.swift:4861 +ChatItemVersion S/ChatTypes.swift:6400 +ParsedMarkdown S/API.swift:179-181 +``` + +- `FormattedText` is unchanged. +- With an unknown block or token tag, the item is invalid, as with an unknown `Format` tag. + +## 3. Drawing + +Segment loop: + +``` +segment loop master's loop over formattedText + Kotlin: MarkdownText (K/views/chat/item/TextItemView.kt) + Swift: messageText (I/Chat/ChatItem/MsgContentView.swift), the loop in a nested function appendSegments +range a block view: the range of its block, entry or cell; otherwise all indices +font Kotlin: the style passed by the column + Swift: parameter baseFont; the fonts of formats and links derived from it +Bold singleStar italic, when markdownBlocks is present +HyperLink linkUri "mailto:" (e-mail autolink): opened as an e-mail link + Kotlin: annotation OTHER_URL; Swift: without webLinkAttrKey + FormattedText.linkUri: null for it, so no link preview +``` + +Modes of the segment loop (Kotlin `MarkdownText`, Swift `messageText`): + +``` +block view parameter range (a text view of the column) + tokens by drawnText: SoftBreak " ", LineBreak and HardBreak "\n", ParagraphBreak n × "\n", other markup nothing +one text markdownBlocks present, no range (previews, quotes, compose context, profile teaser) + tokens by previewText: SoftBreak, HardBreak and CellMark " "; LineBreak and ParagraphBreak "\n"; + ListMark the number or "•" and " "; other markup nothing + segments in heading contents (headingSegments) bold +no blocks markup drawn as its text; Escape nothing +``` + +The column (Kotlin `MarkdownBlocks`, `blockLayout = true` of `MarkdownText`; Swift `MarkdownBlocksView`, `blockLayout` of `MsgContentView`, and `MarkdownView`): + +``` +order blocks in source order; text view index i = the i-th of blockTextRanges +spacing a ParagraphBreak between two blocks: half a line; otherwise none +Paragraph text view, body font +Heading text view, bold; size × 1.5 (level 1), 1.3 (2), 1.15 (3), 1 (4-6); line height scaled alike +List per entry (listEntries): a row of the marker ("•" or the number) and the text view; indent 20 per level +Quote a 4 wide bar (text colour at 16%), 10 gap, the text view at 70-72% opacity +CodeBlock card in the bubble's quote colour, corners 12 × the bubble roundness setting, the bubble's full width + the text view: monospace, size × 0.875, no wrapping, scrolls sideways + copy icon at the top right: on hover (desktop) or after a tap (mobile); copies the drawn text of the range +Table rounded frame (12 × roundness, 1 line), scrolls sideways + columns as wide as their widest cell; header row bold on the quote colour; alternate body rows tinted + lines between rows and columns; a cell aligned by its column + a missing cell at a row end: an empty cell (GFM 0.29 example 204) + Kotlin: a Layout measuring cells by max intrinsic width, composing only the present cells + iOS 16+: Grid, columns × rows at most 1000 (maxGridCells); otherwise, and on iOS 15: the table as typed in a code card +Rule 1 line, 6 above and below +first view gets the sender and the report prefix +last view gets the meta reserve and the typing indicator when it is text; otherwise a line with them below the column +ranges limited to the length of fts +``` + +- Quote colour: `sentQuote` in a sent bubble, `receivedQuote` otherwise (Kotlin `AppColors`, Swift `theme.appColors`). +- Swipe to reply: Kotlin: the horizontal scroll consumes the drag before `swipeable` of the item. iOS: scrolling blocks report their frames (`SwipeExclusionFrames`) in the item's coordinate space; the item's drag gesture ignores drags that start in them. +- iOS: a horizontal scroll only when the content is wider than the bubble (`ViewThatFits` on iOS 16+). + +Secrets (Kotlin, as iOS `hiddenSecretsView`): a hidden secret is drawn transparent, and its text path is painted with the tile `vertical_logo` (8 dp, rotated 45°, saturation 0.65, alpha 0.35), added to `moko-resources/images` from the iOS asset. + +Desktop selection (K/views/chat/TextSelection.kt): + +``` +setupItemSelection (selectionManager, selectionIndex, isLive, bases) +text views each registers its bounds and layout with its base (ItemSelection.text(index)) +pointer offset the view under the pointer, or the nearest; base + its local offset +highlight per view, the selected offsets of the item within the view, shifted by its base +copy, snap itemDisplay(ci).parts: copied text as before, with "\t" and "\n" between views +``` + +Notifications, `hideSecrets` (K/platform/NtfManager.kt, S/Notifications.swift): Secret "...", markup by previewText, Escape nothing, other segments their text. + +## 4. Call sites + +Kotlin: + +``` +MarkdownText (K/views/chat/item/TextItemView.kt) parameters markdownBlocks, blockLayout, range, softWrap, cardColor, selection, textIndex, parentDirection (the bubble's direction, for the meta reserve of block views); section 3 +CIMarkdownText (K/views/chat/item/FramedItemView.kt:445) ci.markdownBlocks; column; quote colour by direction; selection with itemDisplay bases +ciQuotedMsgTextView (K/views/chat/item/FramedItemView.kt:61) qi.markdownBlocks; one text +item preview (K/views/chatlist/ChatPreviewView.kt:261-265, 277) null where formattedText is null, otherwise ci.markdownBlocks; one text +TextBubble (K/views/chat/ChatItemInfoView.kt:55-57) the markdownBlocks of the item or the version; column +ContextItemView (K/views/chat/ContextItemView.kt:59) contextItem.markdownBlocks; one text +ProfileDescriptionText (K/views/chat/ChatInfoView.kt:794-795, 804-805) blocks of parseToMarkdownText; one text +showFullProfileDescription (K/views/chat/item/TextItemView.kt:56-58) blocks of parseToMarkdownText; column +WelcomeMessageView (K/views/chat/group/WelcomeMessageView.kt:149-151) blocks of parseToMarkdownText; column +desktop selection (K/views/chat/TextSelection.kt:264-305) section 3; itemDisplay +notifications (K/platform/NtfManager.kt:134-148) section 3 +parseToMarkdownText (K/views/helpers/TextEditor.kt) new: (text: String) -> ParsedFormattedText? +parseToMarkdown (K/views/helpers/TextEditor.kt:138-146) the formattedText of parseToMarkdownText +``` + +iOS: + +``` +messageText (I/Chat/ChatItem/MsgContentView.swift) parameters markdownBlocks, range, baseFont, secretStart; section 3 +MsgContentView (I/Chat/ChatItem/MsgContentView.swift) vars markdownBlocks, blockLayout, cardColor; MarkdownBlocksView rightToLeft from its rightToLeft, MarkdownView from isRightToLeft(text) +item content (I/Chat/ChatItem/MsgContentView.swift:624) chatItem.markdownBlocks +quote (I/Chat/ChatItem/FramedItemView.swift:337) qi.markdownBlocks; one text +FramedItemView (I/Chat/ChatItem/FramedItemView.swift:366) the markdownBlocks of the item; column; quote colour by direction +chatItemPreviewText (I/ChatList/ChatPreviewView.swift:305-316, 302) nil where formattedText is nil, otherwise ci.markdownBlocks; one text +TextBubble (I/Chat/ChatItemInfoView.swift:298-319) the markdownBlocks of the item or the version; MarkdownView +ContextItemView (I/Chat/ComposeMessage/ContextItemView.swift:74) contextItem.markdownBlocks; one text +markdownText (I/Chat/ChatItem/MsgContentView.swift:331-352) blocks of parseSimpleXMarkdownText; one text +FullProfileDescriptionView, GroupWelcomeView textPreview MarkdownView with blocks of parseSimpleXMarkdownText +item swipe (I/Chat/ChatView.swift chatItemWithMenu) ignores drags starting in SwipeExclusionFrames +notifications (S/Notifications.swift:200-210) section 3 +parseSimpleXMarkdownText (S/API.swift) new: (_ s: String) -> ParsedMarkdown? +parseSimpleXMarkdown (S/API.swift:151-162) the formattedText of parseSimpleXMarkdownText +``` + +Without blocks: + +``` +K/views/chatlist/ChatPreviewView.kt:233 draft: formattedText null +K/views/chat/ChatInfoView.kt:811-813 teaser with Format.Modal appended +I/Chat/ChatItem/MsgContentView.swift:380-383 teaser with Format.modal appended +``` + +Unchanged: + +``` +K/views/chat/item/FramedItemView.kt:349 short emoji test: formattedText == null +I/Chat/ChatItem/FramedItemView.swift:145 short emoji test: formattedText == nil +K/views/chat/ComposeView.kt:130 edit of a sent item: the token form, for links and mentions +K/views/usersettings/MarkdownHelpView.kt inline formats +I/UserSettings/MarkdownHelp.swift inline formats +``` + +## 5. Web + +`renderMarkdown(fts, blocks)` (`W/simplex-lib.jsc:57-128`): + +``` +blocks absent as now; escape: nothing +blocks invalid as now; escape: nothing + invalid: not an array + an unknown type + a range not of integers with 0 <= from <= to <= fts.length + a range starting before the end of the previous range, in source order + a list deeper than 7 levels +blocks in source order, each block one element below +range each segment drawn as now + SoftBreak: " " + LineBreak, HardBreak:
+ other markup, escape: nothing + Bold, singleStar: +gaps a ParagraphBreak between two blocks:
; otherwise nothing +Paragraph
: the range +Heading
: the range; N: the level 1-3, otherwise 4 +CodeBlock
: the range, then a copy button (class md-copy, icons md-icon-copy and md-icon-done)
+Quote            
: the range +List each entry, in source order:
: "•" or the number; : the range + N: 1.25 × the level below the top, at most 6 levels +Table
: the header row in ' + cells.map((r, c) => cell(tag, c, r)).join('') + Array.from({ length: Math.max(0, width - cells.length) }, (_, k) => cell(tag, cells.length + k, null)).join('') + ''; + gap(b.header.length > 0 ? b.header[0].from : pos); + html += '
, the body rows in + each cell: style="text-align: A;"; A: the column alignment, none as left, any other value as left + rows padded with empty cells to the column count when columns Ă— rows <= 1000 (MAX_TABLE_CELLS) +Rule
+number escaped text +copy handleCodeClick: a click on the button copies the text of the
 (innerText); a click elsewhere in the card toggles the class tapped
+```
+
+Styles, as the apps (section 3), in each page: `.simplex-preview-text .md-*` in W/channel-preview.jsc (colours from its `--sp-*` variables, card `--sp-quote`, corners 10px for its 18px bubbles), `#directory .entry .md-*` in website/src/directory.html (light and `.dark` values, corners 4px as its entries). The flex items around the text get `min-width: 0` (`.simplex-preview-msg-col`, `#directory .entry .text-container`), so that wide blocks scroll inside the message. `copyToClipboard` moved from W/channel-preview.jsc to W/simplex-lib.jsc.
+
+Call sites:
+
+```
+W/channel-preview.jsc:880-887       the welcome message object: markdownBlocks: data.welcomeMessageBlocks
+W/channel-preview.jsc:990           renderMarkdown(shortDescr, data.shortDescriptionBlocks)
+W/channel-preview.jsc:1522          renderMarkdown(msg.formattedText, msg.markdownBlocks)
+W/directory.jsc:232                 shortDescrBlocks, welcomeMessageBlocks from the entry
+W/directory.jsc:251-255             renderMarkdown with the blocks of each list
+W/directory.jsc:254, 260            

kept: set by innerHTML, the block elements become children of the

+``` + +- The meta span after a message with a block at its end (`W/channel-preview.jsc:1522`) is drawn on its own line. +- Click handlers: W/channel-preview.jsc `setupSecretToggles` calls `handleCodeClick`; W/directory.jsc `initDirectory` adds it on `#directory`. + +## 6. Docs + +``` +apps/multiplatform/spec/client/chat-view.md TextItemView.kt: blocks of markdownBlocks drawn as a column (MarkdownBlocks.kt) +apps/ios/spec/client/chat-view.md MsgContentView: the same (MarkdownBlocksView) +``` + +## 7. Tests + +`apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/MarkdownBlocksTest.kt`: + +``` +decoding each block token in the JSON of the core + each block, a list with nested entries + each TableAlign value + with and without language + with and without number + Bold with and without singleStar; Escape +text ranges blockTextRanges for each block kind: order, inRow of table cells +list entries listEntries: order and levels +headings headingSegments, also with a shortened list +token texts drawnText and previewText of each token kind; Escape; one-text previews of a heading, paragraph and list +selection itemDisplay: displayed and copied texts and bases of a quote and a table; Escape without blocks +``` + +Checks on each platform of the apps: + +1. A sent message with each block kind: drawn by section 3. +2. A received message with each block kind: drawn by section 3. +3. A text without blocks: drawn as before. +4. Inline parts in each block with content: actions as in a paragraph. + +``` +inline parts a link + a mention + a secret + a command +blocks a heading + a list item + a quote + a table cell +``` + +5. A message with blocks in a quote, the chat list preview, the compose context, item info and a notification. +6. Edit of a sent message with blocks: the compose field has the source text. +7. Desktop: selection inside a block, across blocks, across items, and its copy. +8. Live message with an open code fence. +9. Live message with a growing table. +10. Chat link item with text ending with a heading: no empty line at the end of the text. +11. A profile description and a welcome message with a list. +12. iOS: the cases of `MarkdownBlocksTest.kt` drawn equal to Android. + +Web: + +13. A channel page with a message of each block kind. +14. A directory entry with a welcome message with a list. +15. Relay JSON with hostile values: `number` with markup, `columns` with markup, a range out of bounds, a range not of integers, an unknown block type. + +Not automated: + +``` +iOS the Swift views: check 12 only +web script checks 13-15 only +``` + +## Interfaces + +``` +app types Format: Markup, Escape, Bold singleStar + BlockToken + MarkdownBlock + ListEntry + SegmentRange + TableAlign + BlockTextRange +records markdownBlocks: ChatItem, CIQuote, ChatItemVersion, ParsedFormattedText (Kotlin), ParsedMarkdown (Swift) +functions textRanges, blockTextRanges, listEntries, headingSegments + segmentDisplayText, itemDisplay (Kotlin) + parseToMarkdownText (Kotlin), parseSimpleXMarkdownText (Swift) + the segment loop: range; Swift: baseFont, secretStart, nested function appendSegments +views MarkdownBlocks (Kotlin), MarkdownBlocksView and MarkdownView (Swift) +selection ItemSelection per text view (Kotlin) +resources vertical_logo@1x, @2x, @3x.png (Kotlin, from the iOS asset) +files MarkdownBlocksTest.kt, MarkdownBlocks.kt +``` + +## Depends on + +``` +block Markdown plan, "Types" constructors, fields, JSON tags +block Markdown plan, "Pass 2: blocks" ranges +block Markdown plan, "Records and parse sites" fields +release both plans together +``` diff --git a/plans/2026-10-13-block-markdown.md b/plans/2026-10-13-block-markdown.md new file mode 100644 index 0000000000..d5a39caf36 --- /dev/null +++ b/plans/2026-10-13-block-markdown.md @@ -0,0 +1,867 @@ +# Block Markdown: core + +Date: 2026-10-13. Revised: 2026-10-16, for master. Revised: 2026-10-17, two passes over the whole text. Revised: 2026-10-18, CommonMark syntax (S4, S6), soft breaks (S5), block lines then inline content. + +Each message text is parsed in two passes. In pass 1, the lines of the text are grouped into blocks. The content of the lines of a block is joined with "\n" and parsed by the inline parser of master; each "\n" is then replaced by the break token and the syntax tokens of the next line. Pass 2 divides the tokens at the breaks between blocks and builds the block structure. `formattedText` is the joined token list; the text of each token is its source. `markdownBlocks` is the block structure: index ranges into `formattedText`. + +References are to master at 382dc7480. Haskell paths without a directory are in `src/Simplex/Chat/`. + +## 1. Syntax + +### Lines + +``` +line break "\n", with an optional "\r" before it +final one line break at the text end is removed before pass 1, as by T.lines (Markdown.hs:162) +line the text between two line breaks, or between a line break and the text start or end +hard break two or more spaces, or an odd number of backslashes, before a line break, when the next line is a continuation of the block (section 3) + CommonMark 0.31.2 section 6.7, examples 633, 634 +soft break a line break before a line that is a continuation of a paragraph, a list entry or a quote, without a hard break + CommonMark 0.31.2, section "Soft line breaks" +indentation spaces and tabs at a line start + tab stops every 4 columns +blank line white space only +``` + +- Drawing of line breaks in messages with blocks: rendering plan, section 3. + +### Line kinds + +Tested in this order at a line start, at any indentation: + +``` +code line a line inside a code block, until its closing fence +table start a line with "|", when the next line is a delimiter row with the same number of cells +fence 3 or more "`", then an info string without "`" + or 3 or more "~", then an info string + when a line follows it; a fence line at the text end is text +heading 1 to 6 "#", a space, then text with a character other than white space + closing sequence: a space or a tab, one or more "#", then white space only, at the line end +underline "=" only, or "-" only, then white space only, when the open block is a paragraph + CommonMark 0.31.2 section 4.3 (setext heading underline) +rule 3 or more of one of "-", "*", "_", with spaces and tabs between and after them +list item a marker, a space, then text with a character other than white space + marker: "-", "*", "+", or 1 to 9 digits and "." or ")" +quote ">", then text with a character other than white space + or blank quote lines before such a line + after a paragraph line: such a line, or one blank quote line before it +table row a line with "|", while a table is open +blank white space only +paragraph any other line +``` + +- Delimiter row: a line with "|" where every cell, trimmed, is `:?-+:?`. +- The line after a closing fence is tested anew. + +### Blocks + +``` +CodeBlock a fence line, the code lines after it, and the closing fence line + closing fence: at least as many of the character of the opening fence, then white space only, at any indentation + without a closing fence the block is closed at the end of the text + in a code line the white space up to the column of the opening fence is syntax + a tab that crosses that column is syntax + language: the first word of the info string + language is absent for an empty info string +Heading one heading line + level: the number of "#" + the closing sequence is syntax + or: the lines of a paragraph and the underline after them + level: 1 for "=", 2 for "-" +Rule one rule line +List consecutive list items, blank lines between them included +ListEntry a list item line and its continuation lines (section 3) + content: the text after the marker and the space, then the continuation lines without their indentation + number: the digits and the delimiter, as written + number is absent for "-", "*" and "+" +Quote consecutive quote lines, and the paragraph lines that are continuations of them (section 3) + CommonMark 0.31.2 section 5.1, example 247 + text of a quote line: after ">" and one optional space or tab + a text of white space only is syntax + the lines with texts of white space only at its start and at its end are outside its content +Table a table start line, its delimiter row, and the table rows after them + columns: one per delimiter cell + "-" below: one or more "-" + ":-:" TACenter + "-:" TARight + ":-" TALeft + "-" TANone + header: the cells of the table start line + rows: the cells of each table row + cells are divided at each "|" + the "|" at the line start and at the line end are optional + in a row longer than the header, the last cell is the text from the start of its column to the end of the row, without a "|" at the line end + the white space around the text of a cell is syntax +Paragraph consecutive paragraph lines (section 3) + a paragraph line is text as written, indentation included, as now +``` + +List nesting: + +``` +parent the nearest entry before it with a smaller column, in the same List +depth at most 7 (maxListDepth) + an entry below depth 7: added at depth 7 +blank line the List continues +other block the List ends +``` + +Content: + +``` +inline the lines of a paragraph, of a list entry, of a quote: one text (section 3, inlineLines) + the text of a heading line + each cell +code each code line after its syntax: one segment without format +``` + +### Inline formats across line breaks + +Decided 2026-10-17. + +``` +formats Bold, Italic, StrikeThrough, Snippet, Secret, Small, Colored +across a format is continued into the next line when that line is a continuation of the block (section 3) + one segment of the format per line + the line break and the syntax of the next line between them + never across a blank line + "*", "_", "~": across lines when the content does not start or end with white space; otherwise as now, within the line +within headings and cells: within one line + links, SimpleX links, names, mentions, commands, e-mails, phones: within one line +``` + +Bold, decided 2026-10-18: + +``` +*x* Bold, singleStar = True +**x** Bold, singleStar = False +``` + +- `*x*` is drawn as italic in messages with blocks (rendering plan, section 3). + +### Inline syntax of GFM + +Decided 2026-10-18 (S4, S6). In all messages, with and without blocks: + +``` +~~x~~ StrikeThrough GFM example 491 +__x__ Bold, singleStar = False GFM example 391 +`x` Snippet CommonMark section 6.1, GFM example 339 +`` x ` y `` a run of backticks, the content, a run of the same length + one space removed at each end when the content starts and ends with a space and is not spaces only + a space at one end only: kept (CommonMark example 332); spaces only: kept (example 334) + master's check of spaces at the ends (Markdown.hs:231-235): not applied to Snippet + as [x](x) (sowLinkP, Markdown.hs:281-293), its text "" GFM example 603 + x an e-mail address: HyperLink {showText = Just x, linkUri = "mailto:" <> x} +\c c an ASCII punctuation character: Escape "\", then c without format CommonMark examples 12, 14 + not in code blocks and snippets + "\" before a line break: a hard break (section 1, Lines) +``` + +### Text, as now + +``` +raw HTML +images ![alt](uri) +indented code +a list marker inside a quote +a fence inside a quote +a quote marker inside a quote +nested inline formats +``` + +### Incomplete syntax in a live message + +``` +fence line at the text end paragraph line + a code block once a line follows +open code fence code to the end of the text +table header row paragraph line + a table once the delimiter row is complete +rule characters at the text end rule + text once another character is added to the line +block marker before its text text + a block once text is added +open format at the text end text + a format once it is closed +``` + +- Each version of a live message is parsed from its whole text (`Store/Messages.hs:2583`). + +## 2. Types + +`Markdown.hs`: + +```haskell +data Format + = Bold {singleStar :: BoolDef} + | ... -- present constructors, unchanged + | Phone + | Escape + | Markup {token :: BlockToken} + | Unknown {json :: J.Value} + +data BlockToken + = LineBreak + | SoftBreak + | HardBreak + | ParagraphBreak {lineBreaks :: Int} + | Indent + | HeadingMark {level :: Int} + | HeadingClose + | HeadingUnderline {level :: Int} + | ListMark {number :: Maybe Text} + | QuoteMark + | FenceMark {language :: Maybe Text} + | RowMark + | CellMark + | DelimiterRow {columns :: [TableAlign]} + | RuleMark + deriving (Eq, Show) + +data MarkdownBlock + = Paragraph {content :: SegmentRange} + | Heading {level :: Int, content :: SegmentRange} + | CodeBlock {language :: Maybe Text, content :: SegmentRange} + | Quote {content :: SegmentRange} + | List {items :: NonEmpty ListEntry} + | Table {columns :: NonEmpty TableAlign, header :: [SegmentRange], rows :: [[SegmentRange]]} + | Rule {mark :: SegmentRange} + | UnknownBlock {json :: J.Value} + deriving (Eq, Show) + +data ListEntry = ListEntry {number :: Maybe Text, content :: SegmentRange, items :: [ListEntry]} + deriving (Eq, Show) + +data SegmentRange = SegmentRange {from :: Int, to :: Int} + deriving (Eq, Show) + +data TableAlign = TANone | TALeft | TACenter | TARight + deriving (Eq, Show) + +data ParsedMarkdown = ParsedMarkdown + { formattedText :: Maybe MarkdownList, + markdownBlocks :: Maybe (NonEmpty MarkdownBlock) + } +``` + +- `FormattedText` is unchanged. +- `MarkdownList` is unchanged: `[FormattedText]`. +- Markup token: a segment with format `Markup`. +- `Escape`: the backslash of a backslash escape; text `"\"`. +- `markdownText` (`Markdown.hs:472-491`): + - a markup token, `Escape`: its text + - `Bold`: `*x*` when `singleStar`, `**x**` otherwise (`Markdown.hs:476`) + - `Snippet`: delimited by a backtick run longer than any backtick run in its text, with a space inside each delimiter when the text starts or ends with a backtick, or starts and ends with a space and is not spaces only (`Markdown.hs:479`) +- `SegmentRange`: `[from, to)` into `formattedText`. +- JSON of `BlockToken` and `MarkdownBlock`: `sumTypeJSON fstToLower`, as `Format` (`Markdown.hs:536`). +- An unknown block tag is decoded as `UnknownBlock`, as in `Format` (`Markdown.hs:538-539`). +- An unknown token tag: the format is decoded as `Unknown`. +- `Bold` without `singleStar` in JSON is decoded with `singleStar = False`: the field type is `BoolDef` (`Types.hs:2313-2318`). +- JSON of `ListEntry`, `SegmentRange` and `ParsedMarkdown`: `defaultJSON`. +- JSON of `TableAlign`: `enumJSON (dropPrefix "TA")`. +- Field names equal to local names in importing modules (`-Werror=name-shadowing`, simplex-chat.cabal:362) are hidden in those imports. + +Values: + +``` +bold {"type":"bold","singleStar":true} +escape {"type":"escape"} +lineBreak {"type":"markup","token":{"type":"lineBreak"}} +softBreak {"type":"markup","token":{"type":"softBreak"}} +hardBreak {"type":"markup","token":{"type":"hardBreak"}} +headingClose {"type":"markup","token":{"type":"headingClose"}} +headingUnderline {"type":"markup","token":{"type":"headingUnderline","level":1}} +paragraphBreak {"type":"markup","token":{"type":"paragraphBreak","lineBreaks":2}} +headingMark {"type":"markup","token":{"type":"headingMark","level":2}} +listMark {"type":"markup","token":{"type":"listMark","number":"2."}} +fenceMark {"type":"markup","token":{"type":"fenceMark","language":"bash"}} +delimiterRow {"type":"markup","token":{"type":"delimiterRow","columns":["none","center"]}} +paragraph {"type":"paragraph","content":{"from":1,"to":4}} +codeBlock {"type":"codeBlock","language":"bash","content":{"from":2,"to":5}} +list {"type":"list","items":[{"number":"1.","content":{"from":4,"to":9},"items":[]}]} +table {"type":"table","columns":["none"],"header":[{"from":1,"to":2}],"rows":[[{"from":6,"to":7}]]} +rule {"type":"rule","mark":{"from":2,"to":3}} +``` + +- `language` and `number` are omitted when absent. + +## 3. Pass 1: tokens + +``` +input the text without its final line break +output segments in source order + the concatenation of their texts is the input +``` + +Block parsers, tested in this order at a line start: + +``` +tableP fenceP headingP ruleP listItemP quoteP paragraphP +``` + +- Block start (`blockStartP`): a table start, a fence, a heading line, a rule, a list item line, a quote start. +- Between blocks: `LineBreak`, or `ParagraphBreak` with the blank lines. + +Continuation lines: + +``` +block next line its syntax tokens +paragraph text, not a block start, not an underline none: the indentation is content +list item text, not a block start Indent, when indented +quote a quote line with text QuoteMark + a blank quote line QuoteMark: the whole line + after a line with text: a line with text, none + without ">", not a block start +table a line with "|", not a block start the row tokens +code each line before the closing fence Indent, then the code +``` + +- An unindented line after a list item is a continuation of it (B11; CommonMark 0.31.2 section 5.2, rule 5). +- A paragraph line after a quote line is a continuation of the quote (CommonMark 0.31.2 section 5.1, example 247). + +Inline content of a paragraph, a list item, a quote (`inlineLines`): + +``` +lines the text of each line after its syntax tokens +hard break two or more spaces, or an odd number of "\", at the end of a line with a next line: removed from the line +runs consecutive lines with text; a blank line is a run alone +parse each run joined with "\n", parsed by markdownP; the runs joined with "\n" +breaks each "\n" replaced by the break token and the syntax tokens of the next line + a segment with "\n" is divided there; empty parts without a format are dropped +break HardBreak: after a hard break, or with a blank line before or after it + SoftBreak: otherwise +``` + +- `markdownP` keeps each "\n" of its input in the texts of its segments. + +Texts of the markup tokens: + +``` +LineBreak a line break between two blocks +SoftBreak a soft break (section 1) +HardBreak a hard break (section 1): the spaces or the backslash, and the line break + the line break before and after a blank quote line inside a quote + the line break between the lines of a code block, between the lines of a table, before a setext underline +ParagraphBreak n a line break and the blank lines after it, each with its line break + the blank lines at the text start, with their line breaks + n: the number of "\n" +Indent the indentation of a continuation line of a list item + in a code line: the white space up to the column of the opening fence +HeadingMark the indentation, the "#" run, the space +HeadingClose the closing sequence of a heading line (section 1) +HeadingUnderline the underline line (section 1); level 1 for "=", 2 for "-" +ListMark the indentation, the marker, the space +QuoteMark the indentation, ">", the optional space or tab + in a quote line with a text of white space only: also that text +FenceMark the opening or the closing fence line +RowMark the indentation, the "|" at the line start, the white space before the first cell + empty in a row without them +CellMark after a cell before the last: its white space, the "|", the white space before the next cell + after the last cell: its white space, the "|" at the line end, the white space after it + empty after a last cell without them +DelimiterRow the delimiter row +RuleMark the rule line +``` + +- Inside a code block the lines are divided by `HardBreak` only, blank code lines and spaces at line ends included. +- Spaces at a line end before a line break other than a hard break: content, as now. + +Tokens of a line, in order: + +``` +paragraph line inline content, the indentation included +heading HeadingMark, inline content, HeadingClose when present +underline HeadingUnderline +list item ListMark, inline content +continuation Indent when indented, inline content +quote line QuoteMark, inline content +fence line FenceMark +code line Indent when indented, one segment without format when not empty +table row RowMark, then for each cell: inline content, CellMark +delimiter row DelimiterRow +rule RuleMark +``` + +Changes to `Markdown.hs`: + +``` +parseMaybeMarkdownList (148-167) formattedText of parsedMarkdown +parsedMarkdown new: the command rule (149-157), as now + T.lines (162), intercalate (163): replaced by pass 1 + the join (164-167): joinSegments, markup tokens not joined +documentP, block parsers new (above) +inlineLines new (above) +fragmentP (209) '\n': "\n" without format +'*' case (214) doubleP '*' (Bold False); then formattedP '*' (Bold True) +'_' case (215) doubleP '_' (Bold False); then formattedP '_' Italic +'~' case (216) doubleP '~' StrikeThrough; then formattedP '~' StrikeThrough +'`' case (217) code span (section 1), in place of formattedP '`' Snippet +'<' case new: autolink (section 1); then wordP +'\' case new: escape (section 1); then wordP +formattedP (228-230) content to the delimiter within the line, as now + without the delimiter in the line: across lines (section 1) +boldP (236-241) doubleP: "**", "__", "~~" + white space at the content ends by isSpace +secret (245-249) white space at the content ends by isSpace +styledP (267-272) the space after the style: not "\n" + white space at the content ends by isSpace +sowLinkP (281-293) the format of the link: hyperLinkFormat, shared with autolinks +wordP (320) to a space, "\n", or a backslash before an ASCII punctuation character +secretFallback (251) to a space or "\n" +nameRefP (253) to a space or "\n" +inParens (294) within one line +displayNameTextP_ (512-517) a quoted name: within one line +parseMarkdown (176-177) unchanged: the inline parser, used by msgPlain (View.hs:2474) +``` + +## 4. Pass 2: blocks + +``` +blocks the tokens divided at LineBreak and ParagraphBreak; empty parts dropped +kind by the first token of a block + HeadingMark Heading + ListMark list entry: the column of its indentation, its number + QuoteMark Quote + FenceMark CodeBlock + RowMark Table + RuleMark Rule: mark, the range of the RuleMark token + other Paragraph; Heading when the last token is HeadingUnderline +lists consecutive list entries: one List, nested by column (section 1) +``` + +Ranges: + +``` +content from the first segment without Markup to the last + the breaks and the markup between them included +code from the token after the line break of the opening fence to the line break before the closing fence, excluded + without a closing fence: to the end +cell from the token after its RowMark or CellMark to its CellMark + empty for an empty cell +row from a RowMark to the next RowMark +columns the DelimiterRow +``` + +### Texts without blocks + +Decided 2026-10-17. + +``` +condition pass 1 yields no markup other than LineBreak, SoftBreak, HardBreak and ParagraphBreak + or more than 2000 markup tokens (maxMarkupTokens; decided 2026-10-18) +result each markup token a segment without format, joined by joinSegments + Nothing when no segment has a format, as now + markdownBlocks: Nothing +``` + +- `Escape` is an inline format: kept in texts without blocks. + +### Example + +Text: + +``` +# Plan +- buy *milk* + and eggs + - nested + +> a +> b +``` + +`formattedText`: + +``` +0 headingMark 1 "# " +1 "Plan" +2 lineBreak "\n" +3 listMark "- " +4 "buy " +5 bold "milk" +6 softBreak "\n" +7 indent " " +8 "and eggs" +9 lineBreak "\n" +10 listMark " - " +11 "nested" +12 paragraphBreak 2 "\n\n" +13 quoteMark "> " +14 "a" +15 softBreak "\n" +16 quoteMark "> " +17 "b" +``` + +`markdownBlocks`: + +``` +Heading 1 [1, 2) +List + ListEntry [4, 9) + ListEntry [11, 12) +Quote [14, 18) +``` + +Format across a line break, `"> *a\n> b*"`: + +``` +0 quoteMark "> " +1 bold "a" +2 softBreak "\n" +3 quoteMark "> " +4 bold "b" +Quote [1, 5) +``` + +## 5. Records and parse sites + +`Markdown.hs`: + +```haskell +parsedMarkdown :: Text -> ParsedMarkdown +parseMaybeMarkdownList :: Text -> Maybe MarkdownList -- formattedText of parsedMarkdown +``` + +Field `markdownBlocks :: Maybe (NonEmpty MarkdownBlock)` after `formattedText`: + +``` +ChatItem Messages.hs:265 +CIQuote Messages.hs:614 +ChatItemVersion Messages.hs:1362 +ParsedMarkdown Markdown.hs:143 +WebMessage Web.hs:103 +``` + +Other new fields, of the same type: + +``` +WebChannelPreview Web.hs:114-115 shortDescriptionBlocks, welcomeMessageBlocks +DirectoryEntry Directory/Listing.hs:94-95 shortDescrBlocks, welcomeMessageBlocks +``` + +`(Text, Maybe MarkdownList)` becomes `(Text, ParsedMarkdown)`: + +``` +Library/Internal.hs:365, 368 msgContentTexts, ciContentTexts +Library/Internal.hs:2993 itemTexts +Library/Internal.hs:3030, 3034, 3037 saveRcvChatItem, ciContentNoParse, saveRcvChatItem' +Library/Internal.hs:3088 mkChatItem_ +Library/Internal.hs:3406, 3416 createLocalChatItems +Library/Commands.hs:5155, 5263 ComposedMessageReq and its list +``` + +- `ciContentNoParse` (`Library/Internal.hs:3034-3035`): `ParsedMarkdown Nothing Nothing`. + +Parse sites: + +``` +Store/Messages.hs:693, 2305 CIQuote: parsedMarkdown of the quoted text +Store/Messages.hs:1122, 2334, 2426, 2583 ChatItem: parsedMarkdown of the item text +Store/Messages.hs:3373 ChatItemVersion +Store/Messages.hs:1284, 1630, 1870, 2864, 2936 markdownBlocks = Nothing, with formattedText = Nothing +Library/Internal.hs:226 CIQuote: markdownBlocks of the quoted item +Library/Commands.hs:4931 CIQuote: markdownBlocks of the quoted item +Messages.hs:1369-1379 mkItemVersion: markdownBlocks of the item +Web.hs:252-253, 325 parsedMarkdown of the description texts; its two fields set the two sibling fields of the record + toFormattedText (426-431) removed: equal to parseMaybeMarkdownList +Directory/Listing.hs:143-144, 197-198 toFormattedText returns the list and the blocks; they set the two sibling fields of the record +Store/Messages.hs:693, 2305 mkCIQuote (Messages.hs): CIQuote with the parsed text of its content +Mobile.hs:396 chatParseMarkdown: J.encode . parsedMarkdown . safeDecodeUtf8 +``` + +- In `updatedMentionNames` (`Library/Internal.hs:291-322`), segments are mapped one to one, so the ranges of `markdownBlocks` are valid for its result. + +## 6. Consumers + +Unchanged: + +``` +mentionedNames (Markdown.hs:71-76) a mention in code is text +hasLinks (Markdown.hs:192-193) a link in code is text +styleMarkdownList, styleFormat (Styled.hs:38-47) markup: its text without style (sgr, Styled.hs:71-79) +updatedMentionNames (Library/Internal.hs:291-322) segments mapped one to one +prohibitedSimplexLinks (Library/Internal.hs:426-429) +removeSimplexLink (Library/Internal.hs:1369-1380) the kept text includes the markup texts +hasObfuscatedSimplexLink (Markdown.hs:195-203) a SimpleX link in code is found in the whole text +msgText (Terminal/Output.hs:231-237) +Directory/Listing.hs:52-60 +Directory/Service.hs:599-600 +Directory/Events.hs:106 +msgPlain (View.hs:2474) parseMarkdownList per line, as now +``` + +`Bold {singleStar}` sites: + +``` +sgr (Styled.hs:73) Bold {}: bold, as now +bold' (View.hs:2172) styled (Bold False) +Help.hs:312 markdown (Bold True) "bold text" +``` + +## 6a. Directory service + +``` +spam filter reply (Directory/Service.hs:1160-1163) each command line ends with two spaces: hard breaks between the command lines +tests/Bots/DirectoryTests.hs unchanged: the terminal output of the tests has no trailing spaces +``` + +## 7. Docs and libraries + +`bots/src/API/Docs/Types.hs`: + +``` +chatTypesDocsData, next to :288-289 (sti @BlockToken, STUnion, "", [], "", "") + (sti @MarkdownBlock, STUnion, "", ["UnknownBlock"], "", "") + (sti @ListEntry, STRecord, "", [], "", "") + (sti @SegmentRange, STRecord, "", [], "", "") + (sti @TableAlign, STEnum, "TA", [], "", "") +the Format entry (:288) description: markup tokens, their text as source syntax; Escape; Bold singleStar +next to :521-522 deriving instance Generic for BlockToken, MarkdownBlock, ListEntry, SegmentRange, TableAlign +``` + +Written by the "Bot API docs" test (`tests/APIDocs.hs`): + +``` +bots/api/TYPES.md +the type files of the TypeScript library +the type files of the Python library +``` + +## 8. Compatibility + +- Protocol: unchanged. +- Storage: unchanged. +- A peer with an older core shows the source text with inline formats. +- Items stored earlier are parsed when read (`Store/Messages.hs:1122, 2334, 2426`). Texts drawn as blocks after the change: + +``` +text now after +# of users: 5 text heading +8) nice text list item "8)" +- a text list item ++ a text list item +* a text list item +> cmd text quote +--- text rule +*** text rule +___ text rule +a line with "|" above "-|-" text table +*a text bold, across lines +b* +Title text heading 2 +--- +Title text heading 1 +=== +~~~ fences text code block +\# a text "# a", the "\" not drawn +~~x~~ text strike-through +__x__ text bold +``x`` text code +` a ` text code "a" + text link +``` + +- A quote continued by a line without ">": the line is drawn in the quote. + +- In texts with blocks, after the change: `*x*` drawn as italic; single line breaks in paragraphs, list items and quotes drawn as spaces (rendering plan, section 3). +- `has_link` of earlier items is unchanged. +- A link in code is text. +- A SimpleX link in code is found by `hasObfuscatedSimplexLink` (`Library/Internal.hs:429, 1375, 1380`). +- A SimpleX link in code of a compose field: not found by the app checks of the segments (`ComposeView.kt:435-445`, `ShareListView.kt:81-84`, `ChatUtils.swift:104-113`); the core rejects the message where SimpleX links are prohibited (`Library/Internal.hs:429`), accepted. +- A group link in code of a directory group description: not found by `descriptionContainsLink` (`Directory/Listing.hs:60`); the link line is added to the listing (`Directory/Listing.hs:129-134`), accepted. +- A format across lines, rewritten by `updatedMentionNames`: delimiters on each line, `"> *a\n> b*"` written as `"> *a*\n> *b*"`. + +Costs: + +``` +JSON each line break and each block syntax: one markup segment, in texts with blocks + markdownBlocks: the ranges of the blocks +parsing one parse of the whole text, on each read of an item +``` + +Consumers of earlier releases: + +``` +web pages a markup token: its text, by the default case (website/src/js/simplex-lib.jsc:119-120) + new fields ignored +bots the text of each segment, as now + the Format union has one more type; records have new fields +apps remote desktop only: minRemoteCtrlVersion and minRemoteHostVersion (Remote.hs:77-82) are the version of the release, set at its version change +``` + +## 9. Tests + +`tests/MarkdownTests.hs`: + +``` +operators ==>> (:48-49), <<== (:51-52), <<==>> (:54-55): unchanged + multilineMarkdownList (:463-481): unchanged +new operator ===> for parsedMarkdown: formattedText and markdownBlocks +new spec blockMarkdown +``` + +`blockMarkdown`, the tokens and the blocks of each text: + +``` +heading each level + "#", "# ", "#x", "#\tx#": no blocks + closing sequence: "# a #", "# a ## "; "# a#": "a#" as content +setext "a\n===": heading 1; "a\n---": heading 2; "a\nb\n=": heading 1 of two lines + "- - -" under a text line: rule + "===" under a list item, a quote, a heading, at the text start: text +list each bullet marker + numbers as written, with each delimiter + nesting by 2, 3 and 4 columns + tab indentation + depth above 7 + an indented continuation line + an unindented continuation line + a blank line between items: one list + a paragraph after a list + a list item after a heading +quote ">" with and without a space after it + ">" alone: no blocks + "> a\n>\n> b": one quote with an empty line + ">\n> a\n>": the empty lines outside the content + "> > b": "> b" is text + "> a\nb": one quote, SoftBreak + "> a\nb\n> c": one quote + "> a\n\nb": a quote and a paragraph +code closed + "~~~" fences; "```" not closed by "~~~"; an info string with "`" after "~~~" + open at the end of the text + an empty code line, blank code lines at its end + an info string of two words + indentation of the fence + a tab across the column of the fence + a closing fence with white space after it + inline syntax in code: text + "```", "```\n", "a\n```": no blocks + "```\n```", "```\n\n```", "```\n \n```", "```\n```\nx": code blocks +rule each character + "- - -" + "---" under a text line +table each alignment + a row shorter than the header + a row longer than the header, with and without "|" at its end + an empty cell, a cell of white space only, "||" between cells + rows without "|" at the line start: empty RowMark + indented rows + "a | b\n- | -": a table + a header row without a delimiter row: no blocks + a table ended by a line without "|" +paragraph a paragraph line with indentation: Indent as text + blank lines before the first block and after the last block +across bold, italic, strike-through, snippet, secret, colored across a line break: in a paragraph, a quote, a list item + not across a blank line, a heading line, a cell + an unclosed format: the delimiter and the rest of the line as text; formats on the next lines kept + a delimiter with white space at the content end across lines: as now, within the line +breaks ParagraphBreak counts + CR before each line break, in each block kind + final line break removed + hard break: two and three spaces, and a backslash, before a line break in a paragraph, a list item, a quote + one space before a line break: SoftBreak, the space as content + two spaces or a backslash before a blank line, at the text end, in code: content + SoftBreak in a paragraph, a list entry, a quote; HardBreak between table rows and code lines; LineBreak between blocks +escapes "C:\dir\file.txt": text, one word +bold "*x*": singleStar True + "**x**", "__x__": singleStar False +gfm "~~x~~": StrikeThrough + "`` x ` y ``": Snippet "x ` y"; "```a```" in a line: Snippet "a"; "`` a`": text + "` a `": Snippet "a"; "` a`": Snippet " a"; "` `": Snippet " "; "` a `": Snippet " a " + "": HyperLink, text ""; "": HyperLink to "mailto:a@b.c"; "": text +escapes "\*a*": Escape, "*a*" as text; "\# a" at a line start: no heading + "a\_b": Escape inside a word; "\a": text + in code and snippets: text + in a text without blocks: kept +command "/link 1": as now +live "```", then "```\nls": no blocks, then a code block + "---", then "---x" + an opening fence, then three backticks in the same line + a delimiter row, then more text in it +``` + +Properties, for each text above: + +``` +source T.concat (map markdownText formattedText) is the text without its final line break, except: + colour names written as digits, as now (Markdown.hs:295-309, 494-503) + formats across lines, delimited on each line; an empty part at a line break written as empty delimiters + ~~x~~ written ~x~ + __x__ written **x** + code spans written with the delimiters of markdownText (section 2) +ranges within formattedText, in source order, not overlapping +content each segment without markup is in one content range or one cell +``` + +Consumers: + +``` +mentionedNames a mention in a list item, a quote, a heading, a table cell is found + a mention in code and in a snippet is not found +hasLinks a URI in a list item and a table cell is found; in code not found +SimpleX links in a list item and a table cell: a SimplexLink segment + in code: no segment; found by hasObfuscatedSimplexLink + the text without SimpleX link segments, as removeSimplexLink (Library/Internal.hs:1369-1380) +markdownText each markup token +``` + +- The consumers above are tested on parsed text in `tests/MarkdownTests.hs`; chat tests are not added. + +Other modules: + +``` +tests/ChatTests/Groups.hs:8427 testUpdatedMentionNames + text: a list, a table, a code block + a renamed mention outside code + the same mention in code + the new text: the source with the new name outside code +tests/JSONFixtures.hs a message with a heading, a list and a bold segment, in both encodings +tests/JSONTests.hs the fixtures, for owsf2tagged + a round trip of each block and each markup token + an unknown block tag is UnknownBlock +``` + +Linear time: + +``` +phone numbers the scan ends after 22 characters (master: to the end of the digit groups) +link text "[x]" the scan ends at "[" or a line break +link target "(x)" the scan ends at white space +autolink "" the scan ends at "<" or white space +segment joining each run of equal formats joined once (T.concat) +block starts a table start: two lines; a fence: one line and a line break +table cells one pass over the row and cell marks +``` + +- Tests: the bounded scans keep master's results; texts of 4000 repetitions of each adversarial pattern are parsed; 1000 list items have blocks, 1001 have none. + +## Interfaces + +``` +types Format: Markup, Escape, Bold {singleStar} + BlockToken + MarkdownBlock + ListEntry + SegmentRange + TableAlign + ParsedMarkdown: markdownBlocks +records markdownBlocks: ChatItem, CIQuote, ChatItemVersion, WebMessage + shortDescriptionBlocks, welcomeMessageBlocks: WebChannelPreview + shortDescrBlocks, welcomeMessageBlocks: DirectoryEntry +functions parsedMarkdown + parseMaybeMarkdownList: formattedText of parsedMarkdown +constants maxListDepth 7 + maxMarkupTokens 2000 +``` + +`2026-10-13-block-markdown-rendering.md`: + +``` +the Kotlin types +the iOS types +the web page script +the drawing of the blocks +``` diff --git a/src/Simplex/Chat/Help.hs b/src/Simplex/Chat/Help.hs index 56ed65fb4b..5b0b5b1b3a 100644 --- a/src/Simplex/Chat/Help.hs +++ b/src/Simplex/Chat/Help.hs @@ -22,7 +22,7 @@ import Data.Text (Text) import qualified Data.Text as T import Simplex.Chat.Markdown import Simplex.Chat.Styled -import Simplex.Chat.Types (LocalProfile (..), User (..)) +import Simplex.Chat.Types (BoolDef (..), LocalProfile (..), User (..)) import System.Console.ANSI.Types highlight :: Text -> Markdown @@ -309,7 +309,7 @@ markdownInfo = map styleMarkdown [ green "Markdown:", - indent <> highlight "*bold* " <> " - " <> markdown Bold "bold text", + indent <> highlight "*bold* " <> " - " <> markdown (Bold $ BoolDef True) "bold text", indent <> highlight "_italic_ " <> " - " <> markdown Italic "italic text" <> " (shown as underlined)", indent <> highlight "~strikethrough~" <> " - " <> markdown StrikeThrough "strikethrough text" <> " (shown as inverse)", indent <> highlight "`code snippet` " <> " - " <> markdown Snippet "a + b // no *markdown* here", diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 07839dd032..788b29237d 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -834,7 +834,7 @@ processChatCommand cxt nm = \case CTGroup -> withGroupLock "updateChatItem" chatId $ do g@(GIK gInfo@GroupInfo {groupId, membership} _) <- withFastStore $ \db -> getGroupInfoKeys db cxt user chatId when (isNothing scope) $ assertUserGroupRole gInfo GRAuthor - let (_, ft_) = msgContentTexts mc + let (_, ParsedMarkdown ft_ _) = msgContentTexts mc if prohibitedSimplexLinks gInfo membership mc ft_ then throwCmdError ("feature not allowed " <> T.unpack (groupFeatureNameText GFSimplexLinks)) else do @@ -4923,12 +4923,12 @@ processChatCommand cxt nm = \case fl_ <- liftIO $ ciffForwardLink db ciff pure (mcForward fl_ mc, Nothing) (Just qiId, Nothing) -> do - CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, file} <- + CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, markdownBlocks, file} <- getDirectChatItem db user contactId qiId (origQmc, qd, sent) <- quoteData qci let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Nothing} qmc = quoteContent mc origQmc file - quotedItem = CIQuote {chatDir = qd, itemId = Just qiId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText} + quotedItem = CIQuote {chatDir = qd, itemId = Just qiId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText, markdownBlocks} pure (mcQuote QuotedMsg {msgRef, content = qmc} mc, Just quotedItem) (Just _, Just _) -> throwError SEInvalidQuote pure (mc' {file = fInv_, ttl = ttl' <$> timed_, live = justTrue live}, quotedItem_) @@ -4970,7 +4970,7 @@ processChatCommand cxt nm = \case findProhibited :: [ComposedMessageReq] -> Maybe GroupFeature findProhibited = foldr' - (\(ComposedMessage {fileSource, msgContent = mc}, _, (_, ft), _) acc -> prohibitedGroupContent gInfo membership chatScopeInfo mc ft fileSource True <|> acc) + (\(ComposedMessage {fileSource, msgContent = mc}, _, (_, ParsedMarkdown ft _), _) acc -> prohibitedGroupContent gInfo membership chatScopeInfo mc ft fileSource True <|> acc) Nothing processComposedMessages :: CM ChatResponse processComposedMessages = do @@ -5152,7 +5152,7 @@ processChatCommand cxt nm = \case prepareLocalItemsData :: NonEmpty ComposedMessageReq -> NonEmpty (Maybe (CIFile 'MDSnd)) -> - NonEmpty (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, Maybe MarkdownList)) + NonEmpty (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, ParsedMarkdown)) prepareLocalItemsData = L.zipWith $ \(ComposedMessage {msgContent = mc}, itemForwarded, ts, _) f -> (CISndMsgContent mc, f, itemForwarded, ts) @@ -5260,7 +5260,7 @@ updatedRelays relays UserOperatorServers {operator, smpServers, xftpServers, cha disableRelay relay@UserChatRelay {preset} = AUCR SDBStored $ if preset then relay {enabled = False} else relay {deleted = True} -type ComposedMessageReq = (ComposedMessage, Maybe CIForwardedFrom, (Text, Maybe MarkdownList), Map MemberName CIMention) +type ComposedMessageReq = (ComposedMessage, Maybe CIForwardedFrom, (Text, ParsedMarkdown), Map MemberName CIMention) composedMessage :: Maybe CryptoFile -> MsgContent -> ComposedMessage composedMessage f mc = ComposedMessage {fileSource = f, quotedItemId = Nothing, msgContent = mc, mentions = M.empty} @@ -5270,7 +5270,7 @@ composedMessageReq cm@ComposedMessage {msgContent = mc} = (cm, Nothing, msgConte composedMessageReqMentions :: DB.Connection -> User -> GroupInfo -> ComposedMessage -> ExceptT StoreError IO ComposedMessageReq composedMessageReqMentions db user g cm@ComposedMessage {msgContent = mc, mentions} = do - let ts@(_, ft_) = msgContentTexts mc + let ts@(_, ParsedMarkdown ft_ _) = msgContentTexts mc (cm,Nothing,ts,) <$> getCIMentions db user g ft_ mentions data ChangedProfileContact = ChangedProfileContact diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index ced5c8c676..d4e9893bd9 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -217,13 +217,13 @@ prepareGroupMsg db user g@GroupInfo {membership} msgScope showGroupAsSender mc m fl_ <- liftIO $ ciffForwardLink db ciff pure (mcForward fl_ mc, Nothing) (Just quotedItemId, Nothing) -> do - CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, mentions = quoteMentions, file} <- + CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, markdownBlocks, mentions = quoteMentions, file} <- getGroupCIWithReactions db user g quotedItemId (origQmc, qd, sent, member_) <- quoteData qci membership let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = memberId' <$> member_} qmc = quoteContent mc origQmc file (qmc', ft', _) = updatedMentionNames qmc formattedText quoteMentions - quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc', formattedText = ft'} + quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc', formattedText = ft', markdownBlocks} pure (mcQuote QuotedMsg {msgRef, content = qmc'} mc, Just quotedItem) (Just _, Just _) -> throwError SEInvalidQuote let mc'' = mc' {mentions = MsgMentions mentions, file = fInv_, ttl = ttl' <$> timed_, live = justTrue live, scope = msgScope, asGroup = justTrue showGroupAsSender} @@ -362,11 +362,11 @@ getMessageMentions db user gId msg = case parseMaybeMarkdownList msg of fmap (name,) . eitherToMaybe <$> runExceptT (getGroupMemberIdByName db user gId name) -msgContentTexts :: MsgContent -> (Text, Maybe MarkdownList) -msgContentTexts mc = let t = msgContentText mc in (t, parseMaybeMarkdownList t) +msgContentTexts :: MsgContent -> (Text, ParsedMarkdown) +msgContentTexts mc = let t = msgContentText mc in (t, parsedMarkdown t) -ciContentTexts :: CIContent d -> (Text, Maybe MarkdownList) -ciContentTexts content = let t = ciContentToText content in (t, parseMaybeMarkdownList t) +ciContentTexts :: CIContent d -> (Text, ParsedMarkdown) +ciContentTexts content = let t = ciContentToText content in (t, parsedMarkdown t) quoteContent :: forall d. MsgContent -> MsgContent -> Maybe (CIFile d) -> MsgContent quoteContent mc qmc ciFile_ @@ -2993,7 +2993,7 @@ saveSndChatItem' user cd msg content ciFile quotedItem itemForwarded itemTimed l data NewSndChatItemData c = NewSndChatItemData { msg :: SndMessage, content :: CIContent 'MDSnd, - itemTexts :: (Text, Maybe MarkdownList), + itemTexts :: (Text, ParsedMarkdown), itemMentions :: Map MemberName CIMention, ciFile :: Maybe (CIFile 'MDSnd), quotedItem :: Maybe (CIQuote c), @@ -3018,8 +3018,8 @@ saveSndChatItems user cd showGroupAsSender itemsData itemTimed live = do lift $ withStoreBatch (\db -> map (bindRight $ createItem db createdAt) itemsData) where createItem :: DB.Connection -> UTCTime -> NewSndChatItemData c -> IO (Either ChatError (ChatItem c 'MDSnd)) - createItem db createdAt NewSndChatItemData {msg = msg@SndMessage {sharedMsgId, signedMsg_}, content, itemTexts, itemMentions, ciFile, quotedItem, itemForwarded} = do - let hasLink_ = ciContentHasLink content (snd itemTexts) + createItem db createdAt NewSndChatItemData {msg = msg@SndMessage {sharedMsgId, signedMsg_}, content, itemTexts = itemTexts@(_, ParsedMarkdown ft_ _), itemMentions, ciFile, quotedItem, itemForwarded} = do + let hasLink_ = ciContentHasLink content ft_ ciId <- createNewSndChatItem db user cd showGroupAsSender msg content quotedItem itemForwarded itemTimed live hasLink_ createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt let ci = mkChatItem_ cd showGroupAsSender ciId content itemTexts ciFile quotedItem (Just sharedMsgId) itemForwarded itemTimed live False hasLink_ createdAt Nothing (toMsgVerified (signMessagesRequired cd) (MSSVerified <$ signedMsg_)) createdAt @@ -3030,15 +3030,15 @@ saveSndChatItems user cd showGroupAsSender itemsData itemTimed live = do saveRcvChatItemNoParse :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> UTCTime -> CIContent 'MDRcv -> CM (ChatItem c 'MDRcv, ChatInfo c) saveRcvChatItemNoParse user cd msg brokerTs = saveRcvChatItem user cd msg brokerTs . ciContentNoParse -saveRcvChatItem :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> UTCTime -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) -> CM (ChatItem c 'MDRcv, ChatInfo c) +saveRcvChatItem :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> UTCTime -> (CIContent 'MDRcv, (Text, ParsedMarkdown)) -> CM (ChatItem c 'MDRcv, ChatInfo c) saveRcvChatItem user cd msg@RcvMessage {sharedMsgId_} brokerTs content = saveRcvChatItem' user cd msg sharedMsgId_ brokerTs content Nothing Nothing False M.empty -ciContentNoParse :: CIContent 'MDRcv -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) -ciContentNoParse content = (content, (ciContentToText content, Nothing)) +ciContentNoParse :: CIContent 'MDRcv -> (CIContent 'MDRcv, (Text, ParsedMarkdown)) +ciContentNoParse content = (content, (ciContentToText content, ParsedMarkdown Nothing 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 +saveRcvChatItem' :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> UTCTime -> (CIContent 'MDRcv, (Text, ParsedMarkdown)) -> 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, ts@(_, ParsedMarkdown ft_ _)) ciFile itemTimed live mentions = do createdAt <- liftIO getCurrentTime cxt <- chatStoreCxt withStore' $ \db -> do @@ -3054,7 +3054,7 @@ saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, msgSigned, forwardedByMem itemForwarded <- rcvForwardedFrom db user cd msg (ciId, quotedItem) <- createNewRcvChatItem db user cd msg sharedMsgId_ content itemForwarded itemTimed live userMention hasLink_ brokerTs createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt - let ci = mkChatItem_ cd showAsGroup ciId content (t, ft_) ciFile quotedItem sharedMsgId_ itemForwarded itemTimed live userMention hasLink_ brokerTs forwardedByMember (toMsgVerified (signMessagesRequired cd) msgSigned) createdAt + let ci = mkChatItem_ cd showAsGroup ciId content ts ciFile quotedItem sharedMsgId_ itemForwarded itemTimed live userMention hasLink_ brokerTs forwardedByMember (toMsgVerified (signMessagesRequired cd) msgSigned) createdAt ci' <- case toChatInfo cd of GroupChat g _ | not (null mentions') -> createGroupCIMentions db g ci mentions' _ -> pure ci @@ -3084,15 +3084,15 @@ saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, msgSigned, forwardedByMem -- TODO [mentions] optimize by avoiding unnecessary parsing mkChatItem :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ShowGroupAsSender -> ChatItemId -> CIContent d -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> Maybe MsgVerified -> UTCTime -> ChatItem c d mkChatItem cd showGroupAsSender ciId content file quotedItem sharedMsgId itemForwarded itemTimed live userMention itemTs forwardedByMember msgVerified currentTs = - let ts@(_, ft_) = ciContentTexts content + let ts@(_, ParsedMarkdown ft_ _) = ciContentTexts content hasLink_ = ciContentHasLink content ft_ in mkChatItem_ cd showGroupAsSender ciId content ts file quotedItem sharedMsgId itemForwarded itemTimed live userMention hasLink_ itemTs forwardedByMember msgVerified currentTs -mkChatItem_ :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ShowGroupAsSender -> ChatItemId -> CIContent d -> (Text, Maybe MarkdownList) -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> Maybe MsgVerified -> UTCTime -> ChatItem c d -mkChatItem_ cd showGroupAsSender ciId content (itemText, formattedText) file quotedItem sharedMsgId itemForwarded itemTimed live userMention hasLink_ itemTs forwardedByMember msgVerified currentTs = +mkChatItem_ :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ShowGroupAsSender -> ChatItemId -> CIContent d -> (Text, ParsedMarkdown) -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> Maybe MsgVerified -> UTCTime -> ChatItem c d +mkChatItem_ cd showGroupAsSender ciId content (itemText, ParsedMarkdown formattedText markdownBlocks) file quotedItem sharedMsgId itemForwarded itemTimed live userMention hasLink_ itemTs forwardedByMember msgVerified currentTs = let itemStatus = ciCreateStatus content meta = mkCIMeta ciId content itemText itemStatus Nothing sharedMsgId itemForwarded Nothing False itemTimed (justTrue live) userMention hasLink_ currentTs itemTs forwardedByMember showGroupAsSender msgVerified currentTs currentTs - in ChatItem {chatDir = toCIDirection cd, meta, content, mentions = M.empty, formattedText, quotedItem, reactions = [], file} + in ChatItem {chatDir = toCIDirection cd, meta, content, mentions = M.empty, formattedText, markdownBlocks, quotedItem, reactions = [], file} ciContentHasLink :: CIContent d -> Maybe MarkdownList -> Bool ciContentHasLink content ft_ = case ciMsgContent content of @@ -3406,7 +3406,7 @@ memberAttentionChange unread brokerTs_ rcvMem_ = \case createLocalChatItems :: User -> ChatDirection 'CTLocal 'MDSnd -> - NonEmpty (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, Maybe MarkdownList)) -> + NonEmpty (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, ParsedMarkdown)) -> UTCTime -> CM [ChatItem 'CTLocal 'MDSnd] createLocalChatItems user cd itemsData createdAt = do @@ -3416,8 +3416,8 @@ createLocalChatItems user cd itemsData createdAt = do unless (null errs) $ toView $ CEvtChatErrors errs pure items where - createItem :: DB.Connection -> (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, Maybe MarkdownList)) -> IO (ChatItem 'CTLocal 'MDSnd) - createItem db (content, ciFile, itemForwarded, ts@(_, ft_)) = do + createItem :: DB.Connection -> (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, ParsedMarkdown)) -> IO (ChatItem 'CTLocal 'MDSnd) + createItem db (content, ciFile, itemForwarded, ts@(_, ParsedMarkdown ft_ _)) = do let hasLink_ = ciContentHasLink content ft_ ciId <- createNewChatItem_ db user cd False Nothing Nothing content (Nothing, Nothing, Nothing, Nothing, Nothing) itemForwarded Nothing False False hasLink_ createdAt Nothing Nothing Nothing Nothing createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index ead232b3be..dfcd1005ab 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -52,6 +52,7 @@ import Simplex.Chat.Controller import Simplex.Chat.Delivery import Simplex.Chat.Files (getChatTempDirectory, safeFileNameStr) import Simplex.Chat.Library.Internal +import Simplex.Chat.Markdown (ParsedMarkdown (..)) import Simplex.Chat.Web (channelContentChanged, channelProfileUpdated, channelRemoved) import Simplex.Chat.Messages import Simplex.Chat.Messages.Batch (batchDeliveryTasks1, batchProfiles, batchProfilesWithBody, encodeBinaryBatch, encodeFwdElement, maxBatchElementSize) @@ -2245,7 +2246,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag | otherwise = (\GroupMember {memberId} -> encodeChatBinding CBGroup (smpEncode (publicGroupId, memberId))) <$> m_ GroupInfo {groupProfile = GroupProfile {publicGroup}} = gInfo sentAsGroup = asGroup_ == Just True - ts@(_, ft_) = msgContentTexts content + ts@(_, ParsedMarkdown ft_ _) = msgContentTexts content -- m' is Maybe GroupMember saveRcvCI gInfo' m' scopeInfo = let itemMember_ = if sentAsGroup then Nothing else m' @@ -2334,7 +2335,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag pure $ Just $ infoToDeliveryContext gInfo' scopeInfo showGroupAsSender where content = CIRcvMsgContent mc - ts@(_, ft_) = msgContentTexts mc + ts@(_, ParsedMarkdown ft_ _) = msgContentTexts mc live = fromMaybe False live_ updateRcvChatItem = do (cci, scopeInfo) <- withStore $ \db -> do diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index c90cd54fab..996a9449cd 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -19,17 +19,19 @@ import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ import qualified Data.Attoparsec.ByteString.Char8 as AB +import Data.Attoparsec.Combinator (lookAhead) import Data.Attoparsec.Text (Parser) import qualified Data.Attoparsec.Text as A +import Data.Bifunctor (first) import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B -import Data.Char (isAlpha, isAscii, isDigit, isPunctuation, isSpace) -import Data.Either (fromRight) +import Data.Char (isAlpha, isAscii, isDigit, isPunctuation, isSpace, isSymbol) +import Data.Either (fromRight, isLeft, lefts) import Data.Functor (($>)) -import Data.List (foldl', intercalate) -import Data.List.NonEmpty (NonEmpty) +import Data.List (intercalate) +import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L -import Data.Maybe (fromMaybe, isNothing, mapMaybe) +import Data.Maybe (fromMaybe, isJust, isNothing, listToMaybe, mapMaybe) import Data.Semigroup (sconcat) import Data.String import Data.Text (Text) @@ -49,7 +51,7 @@ data Markdown = Markdown (Maybe Format) Text | Markdown :|: Markdown deriving (Eq, Show) data Format - = Bold + = Bold {singleStar :: BoolDef} | Italic | StrikeThrough | Snippet @@ -65,9 +67,49 @@ data Format | Mention {memberName :: Text} | Email | Phone + | Escape + | Markup {token :: BlockToken} | Unknown {json :: J.Value} deriving (Eq, Show) +data BlockToken + = LineBreak + | SoftBreak + | HardBreak + | ParagraphBreak {lineBreaks :: Int} + | Indent + | HeadingMark {level :: Int} + | HeadingClose + | HeadingUnderline {level :: Int} + | ListMark {number :: Maybe Text} + | QuoteMark + | FenceMark {language :: Maybe Text} + | RowMark + | CellMark + | DelimiterRow {columns :: [TableAlign]} + | RuleMark + deriving (Eq, Show) + +data MarkdownBlock + = Paragraph {content :: SegmentRange} + | Heading {level :: Int, content :: SegmentRange} + | CodeBlock {language :: Maybe Text, content :: SegmentRange} + | Quote {content :: SegmentRange} + | List {items :: NonEmpty ListEntry} + | Table {columns :: NonEmpty TableAlign, header :: [SegmentRange], rows :: [[SegmentRange]]} + | Rule {mark :: SegmentRange} + | UnknownBlock {json :: J.Value} + deriving (Eq, Show) + +data ListEntry = ListEntry {number :: Maybe Text, content :: SegmentRange, items :: [ListEntry]} + deriving (Eq, Show) + +data SegmentRange = SegmentRange {from :: Int, to :: Int} + deriving (Eq, Show) + +data TableAlign = TANone | TALeft | TACenter | TARight + deriving (Eq, Show) + mentionedNames :: MarkdownList -> [Text] mentionedNames = mapMaybe (\(FormattedText f _) -> mentionedName =<< f) where @@ -140,31 +182,120 @@ instance IsString FormattedText where type MarkdownList = [FormattedText] -data ParsedMarkdown = ParsedMarkdown {formattedText :: Maybe MarkdownList} +data ParsedMarkdown = ParsedMarkdown {formattedText :: Maybe MarkdownList, markdownBlocks :: Maybe (NonEmpty MarkdownBlock)} unmarked :: Text -> Markdown unmarked = Markdown Nothing parseMaybeMarkdownList :: Text -> Maybe MarkdownList -parseMaybeMarkdownList s = case ls of - [] -> Nothing +parseMaybeMarkdownList s = let ParsedMarkdown {formattedText} = parsedMarkdown s in formattedText + +parsedMarkdown :: Text -> ParsedMarkdown +parsedMarkdown s = case T.lines s of + [] -> ParsedMarkdown Nothing Nothing [l] - | T.null cmd -> Nothing - | isCmd -> Just [FormattedText (Just $ Command cmd) l] + | T.null cmd -> ParsedMarkdown Nothing Nothing + | isCmd -> ParsedMarkdown (Just [FormattedText (Just $ Command cmd) l]) Nothing where (isCmd, cmd) = case T.uncons $ T.dropWhile (== ' ') l of Just (c, rest) -> (c == '/', rest) Nothing -> (False, "") _ - | all (isNothing . format) ml -> Nothing - | otherwise -> Just . reverse $ foldl' acc [] ml + | any isBlockSyntax tokens && length [f | FormattedText f _ <- tokens, isMarkup f] <= maxMarkupTokens -> ParsedMarkdown (Just tokens) (L.nonEmpty $ markdownBlocksOf tokens) + | all (isNothing . format) inline -> ParsedMarkdown Nothing Nothing + | otherwise -> ParsedMarkdown (Just inline) Nothing where - ls = T.lines s - ml = intercalate ["\n"] $ map (markdownToList . parseMarkdown) ls - acc [] m = [m] - acc ms@(FormattedText f t : ms') ft@(FormattedText f' t') - | f == f' = FormattedText f (t <> t') : ms' - | otherwise = ft : ms + s' = fromMaybe s $ T.stripSuffix "\r\n" s <|> T.stripSuffix "\n" s + tokens = joinSegments $ fromRight [FormattedText Nothing s'] $ A.parseOnly (documentP <* A.endOfInput) s' + inline = joinSegments $ map breakAsText tokens + breakAsText ft@(FormattedText f t) = if isMarkup f then FormattedText Nothing t else ft + +isBlockSyntax :: FormattedText -> Bool +isBlockSyntax = \case + FormattedText (Just (Markup t)) _ -> case t of + LineBreak -> False + SoftBreak -> False + HardBreak -> False + ParagraphBreak _ -> False + _ -> True + _ -> False + +joinSegments :: MarkdownList -> MarkdownList +joinSegments = map joinGroup . L.groupBy (\(FormattedText f _) (FormattedText f' _) -> f == f' && not (isMarkup f)) + +joinGroup :: NonEmpty FormattedText -> FormattedText +joinGroup g@(FormattedText f _ :| _) = FormattedText f $ T.concat [t | FormattedText _ t <- L.toList g] + +isMarkup :: Maybe Format -> Bool +isMarkup = \case + Just (Markup _) -> True + _ -> False + +markdownBlocksOf :: MarkdownList -> [MarkdownBlock] +markdownBlocksOf = groupLists . mapMaybe (blockOf <=< L.nonEmpty) . splitBlocks . zip [0 ..] + where + splitBlocks ts = case break (isBlockBreak . snd) ts of + (b, _ : rest) -> b : splitBlocks rest + (b, []) -> [b] + isBlockBreak = \case + FormattedText (Just (Markup LineBreak)) _ -> True + FormattedText (Just (Markup (ParagraphBreak _))) _ -> True + _ -> False + +blockOf :: NonEmpty (Int, FormattedText) -> Maybe (Either (Int, Maybe Text, SegmentRange) MarkdownBlock) +blockOf ts@((i, FormattedText f t) :| _) = case f of + Just (Markup tkn) -> case tkn of + HeadingMark lvl -> Right . Heading lvl <$> contentRange + ListMark num -> (\r -> Left (indentColumn $ T.takeWhile isIndentChar t, num, r)) <$> contentRange + QuoteMark -> Right . Quote <$> contentRange + FenceMark lang -> Just $ Right $ CodeBlock lang $ SegmentRange (i + 2) (max (i + 2) codeEnd) + RowMark -> Right <$> tableBlock (L.toList ts) + RuleMark -> Just $ Right $ Rule $ SegmentRange i (i + 1) + _ -> paragraph + _ -> paragraph + where + contentRange = (\is -> SegmentRange (L.head is) (L.last is + 1)) <$> L.nonEmpty [j | (j, FormattedText f' _) <- L.toList ts, not (isMarkup f')] + codeEnd = case L.last ts of + (j, FormattedText (Just (Markup (FenceMark _))) _) -> j - 1 + (j, _) -> j + 1 + paragraph = case L.last ts of + (_, FormattedText (Just (Markup (HeadingUnderline lvl))) _) -> Right . Heading lvl <$> contentRange + _ -> Right . Paragraph <$> contentRange + +tableBlock :: [(Int, FormattedText)] -> Maybe MarkdownBlock +tableBlock ts = do + cols <- L.nonEmpty $ concat [aligns | (_, FormattedText (Just (Markup (DelimiterRow aligns))) _) <- ts] + headerRow : bodyRows <- pure $ tableRows [(i, tkn) | (i, FormattedText (Just (Markup tkn)) _) <- ts, tkn == RowMark || tkn == CellMark] + pure $ Table cols headerRow bodyRows + where + tableRows = \case + (i, RowMark) : marks -> + let (cellMarks, marks') = span ((== CellMark) . snd) marks + js = map fst cellMarks + in zipWith (\a b -> SegmentRange (a + 1) b) (i : js) js : tableRows marks' + _ : marks -> tableRows marks + [] -> [] + +groupLists :: [Either (Int, Maybe Text, SegmentRange) MarkdownBlock] -> [MarkdownBlock] +groupLists = \case + Right b : bs -> b : groupLists bs + bs@(Left _ : _) -> + let (entries, rest) = span isLeft bs + in maybe id ((:) . List) (L.nonEmpty $ nestEntries 1 $ lefts entries) (groupLists rest) + [] -> [] + +nestEntries :: Int -> [(Int, Maybe Text, SegmentRange)] -> [ListEntry] +nestEntries depth = \case + (column, num, r) : rest -> + let (children, siblings) = if depth < maxListDepth then span (\(c, _, _) -> c > column) rest else ([], rest) + in ListEntry num r (nestEntries (depth + 1) children) : nestEntries depth siblings + [] -> [] + +maxListDepth :: Int +maxListDepth = 7 + +maxMarkupTokens :: Int +maxMarkupTokens = 2000 parseMarkdownList :: Text -> MarkdownList parseMarkdownList = markdownToList . parseMarkdown @@ -173,6 +304,13 @@ markdownToList :: Markdown -> MarkdownList markdownToList (Markdown f s) = [FormattedText f s] markdownToList (m1 :|: m2) = markdownToList m1 <> markdownToList m2 +joinMarkdown :: [Markdown] -> Markdown +joinMarkdown ms = case map joinGroup $ L.groupBy sameFormat $ filter (\(FormattedText _ t) -> not $ T.null t) $ concatMap markdownToList ms of + [] -> mempty + fts -> foldr1 (:|:) [Markdown f t | FormattedText f t <- fts] + where + sameFormat (FormattedText f _) (FormattedText f' _) = f == f' + parseMarkdown :: Text -> Markdown parseMarkdown s = fromRight (unmarked s) $ A.parseOnly (markdownP <* A.endOfInput) s @@ -203,54 +341,76 @@ hasObfuscatedSimplexLink t = <|> pure False markdownP :: Parser Markdown -markdownP = mconcat <$> A.many' fragmentP +markdownP = joinMarkdown <$> A.many' fragmentP where fragmentP :: Parser Markdown fragmentP = A.peekChar >>= \case Just c -> case c of ' ' -> unmarked <$> A.takeWhile (== ' ') + '\n' -> unmarked <$> A.string "\n" '+' -> phoneP <|> wordP - '*' -> boldP <|> formattedP '*' Bold - '_' -> formattedP '_' Italic - '~' -> formattedP '~' StrikeThrough - '`' -> formattedP '`' Snippet + '*' -> doubleP '*' (Bold $ BoolDef False) <|> formattedP '*' (Bold $ BoolDef True) + '_' -> doubleP '_' (Bold $ BoolDef False) <|> formattedP '_' Italic + '~' -> doubleP '~' StrikeThrough <|> formattedP '~' StrikeThrough + '`' -> codeSpanP '#' -> A.char '#' *> (secretP <|> nameRefP '#' <|> secretFallback) '!' -> styledP <|> wordP '@' -> (A.char '@' *> nameRefP '@') <|> mentionP <|> wordP '/' -> commandP <|> wordP '[' -> sowLinkP <|> wordP + '<' -> autolinkP <|> wordP + '\\' -> escapeP <|> wordP _ | isDigit c -> phoneP <|> wordP | otherwise -> wordP Nothing -> fail "" formattedP :: Char -> Format -> Parser Markdown formattedP c f = do - s <- A.char c *> A.takeTill (== c) - (A.char c $> md c f s) <|> noFormat (c `T.cons` s) + s <- A.char c *> A.takeTill (\x -> x == c || x == '\n') + (A.char c $> md c f s) <|> (markdown f <$> acrossLinesP s) <|> noFormat (c `T.cons` s) + where + acrossLinesP s = do + s' <- ((s <> "\n") <>) <$> (A.char '\n' *> A.takeTill (== c) <* A.char c) + if T.null s || isSpace (T.head s) || isSpace (T.last s') then fail "not formatted" else pure s' md :: Char -> Format -> Text -> Markdown md c f s | T.null s || T.head s == ' ' || T.last s == ' ' = unmarked $ c `T.cons` s `T.snoc` c | otherwise = markdown f s - boldP :: Parser Markdown - boldP = do - s <- A.string "**" *> A.takeTill (== '*') <* A.string "**" - if T.null s || T.head s == ' ' || T.last s == ' ' - then fail "not bold" - else pure $ markdown Bold s + doubleP :: Char -> Format -> Parser Markdown + doubleP c f = do + s <- A.string cc *> A.takeTill (== c) <* A.string cc + if T.null s || isSpace (T.head s) || isSpace (T.last s) + then fail "not formatted" + else pure $ markdown f s + where + cc = T.pack [c, c] + codeSpanP :: Parser Markdown + codeSpanP = do + ticks <- A.takeWhile1 (== '`') + let pieceP = A.takeWhile1 (/= '`') <|> (A.takeWhile1 (== '`') >>= \r -> r <$ guard (r /= ticks)) + (markdown Snippet . stripSpaces . T.concat <$> A.many' pieceP <* A.string ticks) <|> pure (unmarked ticks) + where + stripSpaces s + | T.isPrefixOf " " s && T.isSuffixOf " " s && T.any (/= ' ') s = T.drop 1 $ T.dropEnd 1 s + | otherwise = s + escapeP :: Parser Markdown + escapeP = do + c <- A.char '\\' *> A.satisfy (\x -> isAscii x && (isPunctuation x || isSymbol x)) + pure $ markdown Escape "\\" <> unmarked (T.singleton c) secretP :: Parser Markdown 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" + | T.null s || isSpace (T.head s) || isSpace (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 (== ' ') + secretFallback = unmarked . ('#' `T.cons`) <$> A.takeTill (\c -> c == ' ' || c == '\n') nameRefP :: Char -> Parser Markdown - nameRefP pfx = nameRef <$?> A.takeTill (== ' ') + nameRefP pfx = nameRef <$?> A.takeTill (\c -> c == ' ' || c == '\n') where nameRef word | pfx == '@' && T.all (/= '.') name = Left "not a name" @@ -265,9 +425,9 @@ markdownP = mconcat <$> A.many' fragmentP md' = markdown (SimplexName ni) full styledP :: Parser Markdown styledP = do - f <- A.char '!' *> ((A.char '-' $> Small) <|> (colored <$> colorP)) <* A.space - s <- ((<>) <$> A.takeWhile1 (\c -> c /= ' ' && c /= '!') <*> A.takeTill (== '!')) <* A.char '!' - if T.null s || T.last s == ' ' + f <- A.char '!' *> ((A.char '-' $> Small) <|> (colored <$> colorP)) <* A.satisfy (\c -> isSpace c && c /= '\n') + s <- ((<>) <$> A.takeWhile1 (\c -> not (isSpace c) && c /= '!') <*> A.takeTill (== '!')) <* A.char '!' + if T.null s || isSpace (T.last s) then fail "not styled" else pure $ markdown f s mentionP = prefixedStringP '@' displayNameTextP_ Mention @@ -279,19 +439,24 @@ markdownP = mconcat <$> A.many' fragmentP res = markdown (format str) (pfx `T.cons` origStr) pure $ if T.null punct then res else res :|: unmarked punct sowLinkP = do - t <- '[' `inParens` ']' - l <- '(' `inParens` ')' + t <- enclosedP '[' ']' (\c -> c == '[' || c == '\n') + l <- enclosedP '(' ')' isSpace let hasPunct = T.any (\c -> isPunctuation c && c /= '-' && c /= '_') t when (hasPunct && t /= l && ("https://" <> t) /= l) $ fail "punctuation in hyperlink text" - f <- case strDecode $ encodeUtf8 l of - Right lnk@(ACL _ cLink) -> case cLink of - CLShort _ -> pure $ simplexUriFormat (Just t) lnk - CLFull _ -> fail "full SimpleX link in hyperlink" - Left _ -> case parseUri $ encodeUtf8 l of - Right _ -> pure $ HyperLink (Just t) l - Left e -> fail $ "not uri: " <> T.unpack e + f <- hyperLinkFormat t l pure $ markdown f $ T.concat ["[", t, "](", l, ")"] - inParens open close = A.char open *> A.takeWhile1 (/= close) <* A.char close + autolinkP = do + l <- enclosedP '<' '>' (\c -> c == '<' || isSpace c) + f <-if isEmail l then pure (HyperLink (Just l) ("mailto:" <> l)) else hyperLinkFormat l l + pure $ markdown f $ "<" <> l <> ">" + hyperLinkFormat t l = case strDecode $ encodeUtf8 l of + Right lnk@(ACL _ cLink) -> case cLink of + CLShort _ -> pure $ simplexUriFormat (Just t) lnk + CLFull _ -> fail "full SimpleX link in hyperlink" + Left _ -> case parseUri $ encodeUtf8 l of + Right _ -> pure $ HyperLink (Just t) l + Left e -> fail $ "not uri: " <> T.unpack e + enclosedP open close stop = A.char open *> A.takeWhile1 (\c -> c /= close && not (stop c)) <* A.char close colorP = A.anyChar >>= \case 'r' -> optional "ed" $> Red @@ -310,14 +475,16 @@ markdownP = mconcat <$> A.many' fragmentP phoneP = do country <- optional $ T.cons <$> A.char '+' <*> A.takeWhile1 isDigit code <- optional $ conc4 <$> phoneSep <*> "(" <*> A.takeWhile1 isDigit <*> ")" - segments <- mconcat <$> A.many' ((<>) <$> phoneSep <*> A.takeWhile1 isDigit) - let s = fromMaybe "" country <> fromMaybe "" code <> segments - len = T.length s + s <- segmentsP $ fromMaybe "" country <> fromMaybe "" code + let len = T.length s if 7 <= len && len <= 22 then pure $ markdown Phone s else fail "not phone" + segmentsP s + | T.length s > 22 = fail "not phone" + | otherwise = optional ((<>) <$> phoneSep <*> A.takeWhile1 isDigit) >>= maybe (pure s) (segmentsP . (s <>)) conc4 s1 s2 s3 s4 = s1 <> s2 <> s3 <> s4 phoneSep = " " <|> "-" <|> "." <|> "" wordP :: Parser Markdown - wordP = wordMD <$> A.takeTill (== ' ') + wordP = wordMD . T.concat <$> A.many' (A.takeWhile1 (\c -> c /= ' ' && c /= '\n' && c /= '\\') <|> (notP escapeP *> A.string "\\")) wordMD :: Text -> Markdown wordMD s | T.null s = unmarked s @@ -377,6 +544,307 @@ markdownP = mconcat <$> A.many' fragmentP strEncodeText :: StrEncoding a => a -> Text strEncodeText = safeDecodeUtf8 . strEncode +documentP :: Parser MarkdownList +documentP = do + blanks <- blankLinesP + ([paragraphBreak blanks | not (T.null blanks)] <>) <$> blocksP + where + blocksP = (<>) <$> blockP <*> A.option [] ((:) <$> blockBreakP <*> blocksP) + +blockBreakP :: Parser FormattedText +blockBreakP = do + br <- lineEndP + blanks <- blankLinesP + pure $ if T.null blanks then markupText LineBreak br else paragraphBreak (br <> blanks) + +blankLinesP :: Parser Text +blankLinesP = (<>) <$> (T.concat <$> A.many' ((<>) <$> A.takeWhile isIndentChar <*> lineEndP)) <*> A.option "" (A.takeWhile1 isIndentChar <* A.endOfInput) + +paragraphBreak :: Text -> FormattedText +paragraphBreak t = markupText (ParagraphBreak $ T.count "\n" t) t + +markupText :: BlockToken -> Text -> FormattedText +markupText = FormattedText . Just . Markup + +blockP :: Parser MarkdownList +blockP = tableP <|> fenceP <|> headingP <|> ruleP <|> listItemP <|> quoteP <|> paragraphP + +blockStartP :: Parser () +blockStartP = void tableStartP <|> void fenceStartP <|> void headingMarkP <|> void ruleP <|> void listMarkP <|> quoteStartP + +paragraphLineP :: Parser () +paragraphLineP = hasTextP *> notP blockStartP + +hasTextP :: Parser () +hasTextP = lookAhead lineTextP >>= guard . not . isBlank + +notP :: Parser a -> Parser () +notP p = ((True <$ lookAhead p) <|> pure False) >>= guard . not + +tableP :: Parser MarkdownList +tableP = do + (headerLine, br, delimiter, aligns) <- tableStartP + let row = tableRowTokens $ length aligns + bodyRows <- A.many' $ (\br' l -> markupText HardBreak br' : row l) <$> lineEndP <* notP blockStartP <*> (lineTextP >>= \l -> l <$ guard (T.any (== '|') l)) + pure $ row headerLine <> [markupText HardBreak br, markupText (DelimiterRow aligns) delimiter] <> concat bodyRows + +tableStartP :: Parser (Text, Text, Text, [TableAlign]) +tableStartP = do + headerLine <- lineTextP + br <- lineEndP + delimiter <- lineTextP + aligns <- maybe mzero pure $ tableStart headerLine delimiter + pure (headerLine, br, delimiter, aligns) + +tableRowTokens :: Int -> Text -> MarkdownList +tableRowTokens n t = case map cellParts cellTexts of + (before, c, after) : more -> markupText RowMark (lead <> before) : cellTokens c after more + [] -> [markupText RowMark lead, markupText CellMark trail] + where + (lead, allCells, trail) = tableLine t + cellTexts = case splitAt (n - 1) allCells of + (ts, rest@(_ : _ : _)) -> ts <> [T.intercalate "|" rest] + _ -> allCells + cellTokens c after = \case + (before', c', after') : more -> cellText c <> [markupText CellMark (after <> "|" <> before')] <> cellTokens c' after' more + [] -> cellText c <> [markupText CellMark (after <> trail)] + cellText c = if T.null c then [] else parseMarkdownList c + cellParts cell = + let before = T.takeWhile isSpace cell + rest = T.drop (T.length before) cell + after = T.takeWhileEnd isSpace rest + in (before, T.dropEnd (T.length after) rest, after) + +fenceP :: Parser MarkdownList +fenceP = do + (l, (col, n, ch, lang)) <- fenceStartP + codeLines <- A.many' $ (,) <$> lineEndP <*> (lineTextP >>= \cl -> cl <$ guard (not $ closingFence n ch cl)) + closing <- optional $ (,) <$> lineEndP <*> lineTextP + pure $ markupText (FenceMark lang) l : concatMap (codeLine col) codeLines <> maybe [] (\(br, cl) -> [markupText HardBreak br, markupText (FenceMark Nothing) cl]) closing + where + codeLine col (br, cl) = + let (ind, code) = dropColumns col cl + in markupText HardBreak br : [markupText Indent ind | not (T.null ind)] <> [FormattedText Nothing code | not (T.null code)] + +fenceStartP :: Parser (Text, (Int, Int, Char, Maybe Text)) +fenceStartP = do + l <- lineTextP + fence <- maybe mzero pure $ fenceLine l + (l, fence) <$ lookAhead lineEndP + +headingP :: Parser MarkdownList +headingP = do + (lvl, syntax) <- headingMarkP + (headingText, closing) <- headingClosing <$> lineTextP + pure $ markupText (HeadingMark lvl) syntax : parseMarkdownList headingText <> [markupText HeadingClose closing | not (T.null closing)] + +headingMarkP :: Parser (Int, Text) +headingMarkP = do + ind <- A.takeWhile isIndentChar + hashes <- A.takeWhile1 (== '#') + guard $ T.length hashes <= 6 + sp <- A.string " " + hasTextP + pure (T.length hashes, ind <> hashes <> sp) + +ruleP :: Parser MarkdownList +ruleP = lineTextP >>= \l -> [markupText RuleMark l] <$ guard (isRule l) + +listItemP :: Parser MarkdownList +listItemP = do + (num, syntax) <- listMarkP + l <- lineTextP + ls <- A.many' $ (\br ind l' -> (br, [markupText Indent ind | not (T.null ind)], l')) <$> lineEndP <* paragraphLineP <*> A.takeWhile isIndentChar <*> lineTextP + pure $ markupText (ListMark num) syntax : inlineLines l ls + +listMarkP :: Parser (Maybe Text, Text) +listMarkP = do + ind <- A.takeWhile isIndentChar + (num, marker) <- ((Nothing,) . T.singleton <$> A.satisfy (`elem` ("-*+" :: String))) <|> numberedP + sp <- A.string " " + hasTextP + pure (num, ind <> marker <> sp) + where + numberedP = do + digits <- A.takeWhile1 isDigit + guard $ T.length digits <= 9 + marker <- T.snoc digits <$> A.satisfy (\c -> c == '.' || c == ')') + pure (Just marker, marker) + +quoteP :: Parser MarkdownList +quoteP = do + leading <- A.many' $ (\l br -> [markupText QuoteMark l, markupText HardBreak br]) <$> blankQuoteLineP <*> lineEndP + (mark, l) <- quoteTextP + ls <- nextLinesP True + pure $ concat leading <> (markupText QuoteMark mark : inlineLines l ls) + where + quoteTextP = (,) <$> quoteMarkP <* hasTextP <*> lineTextP + nextLinesP afterText = A.option [] $ do + br <- lineEndP + (syntax, l, isText) <- quoteLineP <|> blankLineP <|> (if afterText then lazyLineP else mzero) + ((br, syntax, l) :) <$> nextLinesP isText + quoteLineP = (\(m, l) -> ([markupText QuoteMark m], l, True)) <$> quoteTextP + blankLineP = (\l -> ([markupText QuoteMark l], "", False)) <$> blankQuoteLineP + lazyLineP = ([],,True) <$> (notP quoteMarkP *> paragraphLineP *> lineTextP) + +quoteMarkP :: Parser Text +quoteMarkP = do + ind <- A.takeWhile isIndentChar + sp <- A.char '>' *> A.option "" (T.singleton <$> A.satisfy isIndentChar) + pure $ ind <> ">" <> sp + +blankQuoteLineP :: Parser Text +blankQuoteLineP = (<>) <$> quoteMarkP <*> A.takeWhile isIndentChar <* lookAhead (void lineEndP <|> A.endOfInput) + +quoteStartP :: Parser () +quoteStartP = optional (blankQuoteLineP *> lineEndP) *> quoteMarkP *> hasTextP + +paragraphP :: Parser MarkdownList +paragraphP = do + l <- lineTextP + ls <- A.many' $ (,[],) <$> lineEndP <* notP underlineP <* paragraphLineP <*> lineTextP + underline <- A.option [] $ (\br (lvl, u) -> [markupText HardBreak br, markupText (HeadingUnderline lvl) u]) <$> lineEndP <*> underlineP + pure $ inlineLines l ls <> underline + +underlineP :: Parser (Int, Text) +underlineP = lineTextP >>= \l -> maybe mzero (pure . (,l)) (underlineLevel l) + +inlineLines :: Text -> [(Text, MarkdownList, Text)] -> MarkdownList +inlineLines l0 nextLines = insertBreaks breaks $ intercalate ["\n"] $ map (parseMarkdownList . T.intercalate "\n") $ runs lineTexts + where + (lineTexts, breaks) = go l0 nextLines + go l = \case + (br, syntax, l') : more -> + let (l1, brk) = if isBlank l || isBlank l' then (l, markupText HardBreak br) else breakAfter l br + (ls, bs) = go l' more + in (l1 : ls, (brk : syntax) : bs) + [] -> ([l], []) + runs = \case + l : ls | not (isBlank l) -> let (run, ls') = break isBlank ls in (l : run) : runs ls' + l : ls -> [l] : runs ls + [] -> [] + +breakAfter :: Text -> Text -> (Text, FormattedText) +breakAfter l br + | T.length spaces >= 2 = (T.dropEnd (T.length spaces) l, markupText HardBreak (spaces <> br)) + | odd (T.length $ T.takeWhileEnd (== '\\') l) = (T.dropEnd 1 l, markupText HardBreak ("\\" <> br)) + | otherwise = (l, markupText SoftBreak br) + where + spaces = T.takeWhileEnd (== ' ') l + +insertBreaks :: [MarkdownList] -> MarkdownList -> MarkdownList +insertBreaks breaks = \case + FormattedText f t : fts -> case (T.breakOn "\n" t, breaks) of + ((t1, t2), brk : breaks') | not (T.null t2) -> segment f t1 <> brk <> insertBreaks breaks' (FormattedText f (T.drop 1 t2) : fts) + _ -> segment f t <> insertBreaks breaks fts + [] -> [] + where + segment f t = [FormattedText f t | isJust f || not (T.null t)] + +lineTextP :: Parser Text +lineTextP = T.concat <$> A.many' (A.takeWhile1 (\c -> c /= '\n' && c /= '\r') <|> (A.string "\r" <* (A.peekChar >>= guard . (/= Just '\n')))) + +lineEndP :: Parser Text +lineEndP = A.string "\r\n" <|> A.string "\n" + +isIndentChar :: Char -> Bool +isIndentChar c = c == ' ' || c == '\t' + +isBlank :: Text -> Bool +isBlank = T.all isSpace + +indentColumn :: Text -> Int +indentColumn = T.foldl' (\col c -> if c == '\t' then nextTabStop col else col + 1) 0 + +nextTabStop :: Int -> Int +nextTabStop col = (col `div` 4 + 1) * 4 + +dropColumns :: Int -> Text -> (Text, Text) +dropColumns n = go 0 + where + go col s + | col >= n = ("", s) + | otherwise = case T.uncons s of + Just (' ', s') -> first (T.cons ' ') $ go (col + 1) s' + Just ('\t', s') -> first (T.cons '\t') $ go (nextTabStop col) s' + _ -> ("", s) + +fenceLine :: Text -> Maybe (Int, Int, Char, Maybe Text) +fenceLine t = do + let (ind, s) = T.span isIndentChar t + ch <- T.find (\c -> c == '`' || c == '~') (T.take 1 s) + let ticks = T.length $ T.takeWhile (== ch) s + info = T.drop ticks s + guard $ ticks >= 3 && (ch == '~' || T.all (/= '`') info) + pure (indentColumn ind, ticks, ch, listToMaybe $ T.words info) + +closingFence :: Int -> Char -> Text -> Bool +closingFence n ch t = + let s = T.dropWhile isIndentChar t + ticks = T.length $ T.takeWhile (== ch) s + in ticks >= n && T.all isSpace (T.drop ticks s) + +headingClosing :: Text -> (Text, Text) +headingClosing c = + let trimmed = T.dropWhileEnd isIndentChar c + hashes = T.takeWhileEnd (== '#') trimmed + before = T.dropEnd (T.length hashes) trimmed + c' = T.dropWhileEnd isIndentChar before + in if not (T.null hashes) && T.any (not . isSpace) c' && T.any isIndentChar (T.takeEnd 1 before) + then (c', T.drop (T.length c') c) + else (c, "") + +underlineLevel :: Text -> Maybe Int +underlineLevel t = case T.uncons s of + Just ('=', _) | T.all (== '=') s -> Just 1 + Just ('-', _) | T.all (== '-') s -> Just 2 + _ -> Nothing + where + s = T.dropWhileEnd isSpace $ T.dropWhile isIndentChar t + +isRule :: Text -> Bool +isRule t = case T.uncons s of + Just (c, _) -> c `elem` ("-*_" :: String) && T.length s >= 3 && T.all (== c) s + Nothing -> False + where + s = T.filter (not . isIndentChar) t + +tableStart :: Text -> Text -> Maybe [TableAlign] +tableStart t d = do + guard $ T.any (== '|') t + aligns <- delimiterRow d + guard $ length (tableCells t) == length aligns + pure aligns + +delimiterRow :: Text -> Maybe [TableAlign] +delimiterRow t + | T.any (== '|') t = mapM cellAlign $ tableCells t + | otherwise = Nothing + where + cellAlign cell = + let s = T.strip cell + (left, s') = maybe (False, s) (True,) $ T.stripPrefix ":" s + (right, dashes) = maybe (False, s') (True,) $ T.stripSuffix ":" s' + in if not (T.null dashes) && T.all (== '-') dashes + then Just $ case (left, right) of + (True, True) -> TACenter + (False, True) -> TARight + (True, False) -> TALeft + (False, False) -> TANone + else Nothing + +tableCells :: Text -> [Text] +tableCells t = let (_, cells, _) = tableLine t in cells + +tableLine :: Text -> (Text, [Text], Text) +tableLine t = (ind <> lead, T.splitOn "|" body, trail) + where + (ind, s) = T.span isIndentChar t + (lead, s') = maybe ("", s) ("|",) $ T.stripPrefix "|" s + ws = T.takeWhileEnd isSpace s' + s'' = T.dropEnd (T.length ws) s' + (body, trail) = maybe (s'', ws) (,"|" <> ws) $ T.stripSuffix "|" s'' + parseUri :: ByteString -> Either Text U.URI parseUri s = case U.parseURI U.laxURIParserOptions s of Left e -> Left $ "Invalid URI: " <> tshow e @@ -473,10 +941,10 @@ markdownText :: FormattedText -> Text markdownText (FormattedText f_ t) = case f_ of Nothing -> t Just f -> case f of - Bold -> around '*' + Bold (BoolDef singleStar) -> if singleStar then around '*' else "**" <> t <> "**" Italic -> around '_' StrikeThrough -> around '~' - Snippet -> around '`' + Snippet -> codeSpan Secret -> around '#' Small -> "!- " <> t <> "!" Colored (FormatColor c) -> color c @@ -488,9 +956,16 @@ markdownText (FormattedText f_ t) = case f_ of Command _ -> t Email -> t Phone -> t + Escape -> t + Markup _ -> t Unknown _ -> t where around c = c `T.cons` t `T.snoc` c + codeSpan = + let ticks = T.replicate (1 + maximum (0 : map T.length (filter (T.isPrefixOf "`") $ T.group t))) "`" + pad = T.isPrefixOf "`" t || T.isSuffixOf "`" t || (T.isPrefixOf " " t && T.isSuffixOf " " t && not (T.all (== ' ') t)) + sp = if pad then " " else "" + in ticks <> sp <> t <> sp <> ticks color c = case colorStr c of Just cStr -> cStr <> t `T.snoc` '!' Nothing -> t @@ -514,7 +989,7 @@ displayNameTextP_ = (,"") <$> quoted '\'' <|> splitPunctuation <$> takeNameTill takeNameTill p = A.peekChar' >>= \c -> if refChar c then A.takeTill p else fail "invalid first character in display name" - quoted c = A.char c *> takeNameTill (== c) <* A.char c + quoted c = A.char c *> takeNameTill (\x -> x == c || x == '\n') <* A.char c refChar c = c > ' ' && c /= '#' && c /= '@' && c /= '\'' commandTextP :: Parser (Text, Text) @@ -533,11 +1008,24 @@ viewName s = if T.any isSpace s || maybe False (isPunctuation . snd) (T.unsnoc s $(JQ.deriveJSON (enumJSON $ dropPrefix "XL") ''SimplexLinkType) +$(JQ.deriveJSON (enumJSON $ dropPrefix "TA") ''TableAlign) + +$(JQ.deriveJSON (sumTypeJSON fstToLower) ''BlockToken) + $(JQ.deriveToJSON (sumTypeJSON fstToLower) ''Format) instance FromJSON Format where parseJSON v = $(JQ.mkParseJSON (sumTypeJSON fstToLower) ''Format) v <|> pure (Unknown v) +$(JQ.deriveJSON defaultJSON ''SegmentRange) + +$(JQ.deriveJSON defaultJSON ''ListEntry) + +$(JQ.deriveToJSON (sumTypeJSON fstToLower) ''MarkdownBlock) + +instance FromJSON MarkdownBlock where + parseJSON v = $(JQ.mkParseJSON (sumTypeJSON fstToLower) ''MarkdownBlock) v <|> pure (UnknownBlock v) + $(JQ.deriveJSON defaultJSON ''FormattedText) $(JQ.deriveToJSON defaultJSON ''ParsedMarkdown) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 50035b55be..d298a1d3c1 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -263,6 +263,7 @@ data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem -- The key is a name used in the message text, used to look up CIMention. mentions :: Map MemberName CIMention, formattedText :: Maybe MarkdownList, + markdownBlocks :: Maybe (NonEmpty MarkdownBlock), quotedItem :: Maybe (CIQuote c), reactions :: [CIReactionCount], file :: Maybe (CIFile d) @@ -611,10 +612,16 @@ data CIQuote (c :: ChatType) = CIQuote sharedMsgId :: Maybe SharedMsgId, -- Nothing for the messages from the old clients sentAt :: UTCTime, content :: MsgContent, - formattedText :: Maybe MarkdownList + formattedText :: Maybe MarkdownList, + markdownBlocks :: Maybe (NonEmpty MarkdownBlock) } deriving (Show) +mkCIQuote :: CIQDirection c -> Maybe ChatItemId -> Maybe SharedMsgId -> UTCTime -> MsgContent -> CIQuote c +mkCIQuote chatDir itemId sharedMsgId sentAt content = CIQuote {chatDir, itemId, sharedMsgId, sentAt, content, formattedText, markdownBlocks} + where + ParsedMarkdown formattedText markdownBlocks = parsedMarkdown $ msgContentText content + quoteItemId :: CIQuote c -> Maybe ChatItemId quoteItemId CIQuote {itemId} = itemId @@ -1360,13 +1367,14 @@ data ChatItemVersion = ChatItemVersion { chatItemVersionId :: Int64, msgContent :: MsgContent, formattedText :: Maybe MarkdownList, + markdownBlocks :: Maybe (NonEmpty MarkdownBlock), itemVersionTs :: UTCTime, createdAt :: UTCTime } deriving (Eq, Show) mkItemVersion :: ChatItem c d -> Maybe ChatItemVersion -mkItemVersion ChatItem {content, formattedText, meta} = version <$> ciMsgContent content +mkItemVersion ChatItem {content, formattedText, markdownBlocks, meta} = version <$> ciMsgContent content where CIMeta {itemId, itemTs, createdAt} = meta version mc = @@ -1374,6 +1382,7 @@ mkItemVersion ChatItem {content, formattedText, meta} = version <$> ciMsgContent { chatItemVersionId = itemId, msgContent = mc, formattedText, + markdownBlocks, itemVersionTs = itemTs, createdAt = createdAt } diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 085df84956..94b4f013ea 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -41,7 +41,7 @@ import Simplex.Chat import Simplex.Chat.Badges.Code (badgeCodeText, parseBadgeCode) import Simplex.Chat.Controller import Simplex.Chat.Library.Commands -import Simplex.Chat.Markdown (ParsedMarkdown (..), parseMaybeMarkdownList, parseUri, sanitizeUri) +import Simplex.Chat.Markdown (parseUri, parsedMarkdown, sanitizeUri) import Simplex.Chat.Mobile.Badges import Simplex.Chat.Mobile.File import Simplex.Chat.Mobile.Shared @@ -393,7 +393,7 @@ chatRecvMsgWait :: ChatController -> Int -> IO JSONByteString chatRecvMsgWait cc time = fromMaybe "" <$> timeout time (chatRecvMsg cc) chatParseMarkdown :: ByteString -> JSONByteString -chatParseMarkdown = J.encode . ParsedMarkdown . parseMaybeMarkdownList . safeDecodeUtf8 +chatParseMarkdown = J.encode . parsedMarkdown . safeDecodeUtf8 {-# INLINE chatParseMarkdown #-} chatParseServer :: ByteString -> JSONByteString diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index b8909da1b3..5ad9107e6f 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -690,7 +690,7 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe _ -> pure . ciQuote Nothing $ CIQGroupRcv Nothing where ciQuote :: Maybe ChatItemId -> CIQDirection c -> CIQuote c - ciQuote itemId dir = CIQuote dir itemId msgId sentAt content . parseMaybeMarkdownList $ msgContentText content + ciQuote itemId dir = mkCIQuote dir itemId msgId sentAt content getDirectChatItemQuote_ :: Int64 -> Bool -> IO (CIQuote 'CTDirect) getDirectChatItemQuote_ contactId userSent = do fmap ciQuoteDirect . maybeFirstRow fromOnly $ @@ -1119,7 +1119,9 @@ toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentTex _ -> Nothing cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTLocal d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTLocal cItem d chatDir ciStatus content file = - CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, mentions = M.empty, formattedText = parseMaybeMarkdownList itemText, quotedItem = Nothing, reactions = [], file} + CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, mentions = M.empty, formattedText, markdownBlocks, quotedItem = Nothing, reactions = [], file} + where + ParsedMarkdown formattedText markdownBlocks = parsedMarkdown itemText badItem = Left $ SEBadChatItem itemId (Just itemTs) ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTLocal d ciMeta content status = @@ -1282,6 +1284,7 @@ safeToDirectItem currentTs itemId = \case content = CIInvalidJSON errorText, mentions = M.empty, formattedText = Nothing, + markdownBlocks = Nothing, quotedItem = Nothing, reactions = [], file = Nothing @@ -1628,6 +1631,7 @@ safeToGroupItem currentTs itemId = \case content = CIInvalidJSON errorText, mentions = M.empty, formattedText = Nothing, + markdownBlocks = Nothing, quotedItem = Nothing, reactions = [], file = Nothing @@ -1868,6 +1872,7 @@ safeToLocalItem currentTs itemId = \case content = CIInvalidJSON errorText, mentions = M.empty, formattedText = Nothing, + markdownBlocks = Nothing, quotedItem = Nothing, reactions = [], file = Nothing @@ -2302,7 +2307,7 @@ toDirectQuote qr@(_, _, _, _, quotedSent) = toQuote qr $ direction . unBI <$> qu toQuote :: QuoteRow -> Maybe (CIQDirection c) -> Maybe (CIQuote c) toQuote (quotedItemId, quotedSharedMsgId, quotedSentAt, quotedMsgContent, _) dir = - CIQuote <$> dir <*> pure quotedItemId <*> pure quotedSharedMsgId <*> quotedSentAt <*> quotedMsgContent <*> (parseMaybeMarkdownList . msgContentText <$> quotedMsgContent) + mkCIQuote <$> dir <*> pure quotedItemId <*> pure quotedSharedMsgId <*> quotedSentAt <*> quotedMsgContent -- this function can be changed so it never fails, not only avoid failure on invalid json toDirectChatItem :: UTCTime -> ChatItemRow :. QuoteRow -> Either StoreError (CChatItem 'CTDirect) @@ -2331,7 +2336,9 @@ toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentT _ -> Nothing cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTDirect d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTDirect cItem d chatDir ciStatus content file = - CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, mentions = M.empty, formattedText = parseMaybeMarkdownList itemText, quotedItem = toDirectQuote quoteRow, reactions = [], file} + CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, mentions = M.empty, formattedText, markdownBlocks, quotedItem = toDirectQuote quoteRow, reactions = [], file} + where + ParsedMarkdown formattedText markdownBlocks = parsedMarkdown itemText badItem = Left $ SEBadChatItem itemId (Just itemTs) ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTDirect d ciMeta content status = @@ -2423,7 +2430,9 @@ toGroupChatItem _ -> Nothing cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTGroup d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTGroup cItem d chatDir ciStatus content file = - CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, mentions = M.empty, formattedText = parseMaybeMarkdownList itemText, quotedItem = toGroupQuote quoteRow quotedMember_, reactions = [], file} + CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, mentions = M.empty, formattedText, markdownBlocks, quotedItem = toGroupQuote quoteRow quotedMember_, reactions = [], file} + where + ParsedMarkdown formattedText markdownBlocks = parsedMarkdown itemText badItem = Left $ SEBadChatItem itemId (Just itemTs) ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTGroup d ciMeta content status = @@ -2569,6 +2578,7 @@ updateDirectChatItem' db User {userId} contactId ci newContent edited live timed updatedChatItem :: ChatItem c d -> CIContent d -> Bool -> Bool -> Maybe CITimed -> UTCTime -> ChatItem c d updatedChatItem ci@ChatItem {meta = meta@CIMeta {itemStatus, itemEdited, itemTimed, itemLive}} newContent edited live timed_ currentTs = let newText = ciContentToText newContent + ParsedMarkdown formattedText markdownBlocks = parsedMarkdown newText edited' = itemEdited || edited live' = (live &&) <$> itemLive timed' = case timed_ of @@ -2580,7 +2590,7 @@ updatedChatItem ci@ChatItem {meta = meta@CIMeta {itemStatus, itemEdited, itemTim let deleteAt' = addUTCTime (realToFrac ttl) currentTs in Just CITimed {ttl, deleteAt = Just deleteAt'} _ -> itemTimed - in ci {content = newContent, meta = meta {itemText = newText, itemEdited = edited', itemTimed = timed', itemLive = live'}, formattedText = parseMaybeMarkdownList newText} + in ci {content = newContent, meta = meta {itemText = newText, itemEdited = edited', itemTimed = timed', itemLive = live'}, formattedText, markdownBlocks} -- this function assumes that direct item with correct chat direction already exists, -- it should be checked before calling it @@ -2861,7 +2871,7 @@ updateGroupChatItemModerated db User {userId} GroupInfo {groupId} ci m@GroupMemb WHERE user_id = ? AND group_id = ? AND chat_item_id = ? |] (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} + pure ci {content = toContent, meta = (meta ci) {itemText = toText, itemDeleted = Just (CIModerated (Just deletedTs) m), editable = False, deletable = False}, formattedText = Nothing, markdownBlocks = Nothing} deleteMemberCIs :: DB.Connection -> User -> GroupInfo -> GroupMember -> IO () deleteMemberCIs db User {userId} GroupInfo {groupId, membership} member = do @@ -2933,7 +2943,7 @@ updateGroupCIBlockedByAdmin db User {userId} GroupInfo {groupId} ci deletedTs = WHERE user_id = ? AND group_id = ? AND chat_item_id = ? |] (DBCIBlockedByAdmin, deletedTs, currentTs, userId, groupId, itemId) - pure $ ci {meta = (meta ci) {itemDeleted = Just (CIBlockedByAdmin $ Just deletedTs), editable = False, deletable = False}, formattedText = Nothing} + pure $ ci {meta = (meta ci) {itemDeleted = Just (CIBlockedByAdmin $ Just deletedTs), editable = False, deletable = False}, formattedText = Nothing, markdownBlocks = Nothing} pattern DBCINotDeleted :: Int pattern DBCINotDeleted = 0 @@ -3370,8 +3380,8 @@ getChatItemVersions db itemId = do where toChatItemVersion :: (Int64, MsgContent, UTCTime, UTCTime) -> ChatItemVersion toChatItemVersion (chatItemVersionId, msgContent, itemVersionTs, createdAt) = - let formattedText = parseMaybeMarkdownList $ msgContentText msgContent - in ChatItemVersion {chatItemVersionId, msgContent, formattedText, itemVersionTs, createdAt} + let ParsedMarkdown formattedText markdownBlocks = parsedMarkdown $ msgContentText msgContent + in ChatItemVersion {chatItemVersionId, msgContent, formattedText, markdownBlocks, itemVersionTs, createdAt} directCIWithReactions :: DB.Connection -> Contact -> CChatItem 'CTDirect -> IO (CChatItem 'CTDirect) directCIWithReactions db ct cci@(CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId}}) = case itemSharedMsgId of diff --git a/src/Simplex/Chat/Styled.hs b/src/Simplex/Chat/Styled.hs index 2dd1faa391..e1925ce835 100644 --- a/src/Simplex/Chat/Styled.hs +++ b/src/Simplex/Chat/Styled.hs @@ -70,7 +70,7 @@ sShow = plain . show sgr :: Format -> [SGR] sgr = \case - Bold -> [SetConsoleIntensity BoldIntensity] + Bold {} -> [SetConsoleIntensity BoldIntensity] Italic -> [SetUnderlining SingleUnderline, SetItalicized True] StrikeThrough -> [SetSwapForegroundBackground True] Colored (FormatColor c) -> [SetColor Foreground Vivid c] diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 18d2286135..650209eadd 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -2169,7 +2169,7 @@ viewGroupDescription GroupInfo {groupProfile = GroupProfile {description}} = maybe ["No welcome message!"] ((bold' "Welcome message:" :) . map plain . T.lines) description bold' :: String -> StyledString -bold' = styled Bold +bold' = styled (Bold $ BoolDef False) viewContactAliasUpdated :: Contact -> [StyledString] viewContactAliasUpdated ct@Contact {profile = LocalProfile {localAlias}} diff --git a/src/Simplex/Chat/Web.hs b/src/Simplex/Chat/Web.hs index 80f7432da2..db184f5a8a 100644 --- a/src/Simplex/Chat/Web.hs +++ b/src/Simplex/Chat/Web.hs @@ -31,6 +31,7 @@ import Control.Monad import Control.Monad.Except (runExceptT) import Data.Either (rights) import Data.Int (Int64) +import Data.List.NonEmpty (NonEmpty) import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ import qualified Data.ByteString.Char8 as B @@ -44,7 +45,7 @@ import qualified Data.Text as T import qualified Data.Text.IO as TIO import Data.Time.Clock (UTCTime, getCurrentTime) import Simplex.Chat.Controller (ChatController (..), CorsOrigin (..), PublishableGroup (..), WebPreviewConfig (..), WebPreviewState (..), storeCxt) -import Simplex.Chat.Markdown (FormattedText (..), MarkdownList, parseMaybeMarkdownList) +import Simplex.Chat.Markdown (MarkdownBlock, MarkdownList, ParsedMarkdown (..), parsedMarkdown) import Simplex.Chat.Messages ( CChatItem (..), CIDirection (..), @@ -101,6 +102,7 @@ data WebMessage = WebMessage ts :: UTCTime, content :: MsgContent, formattedText :: Maybe MarkdownList, + markdownBlocks :: Maybe (NonEmpty MarkdownBlock), file :: Maybe WebFileInfo, quote :: Maybe QuotedMsg, reactions :: [CIReactionCount], @@ -112,7 +114,9 @@ data WebMessage = WebMessage data WebChannelPreview = WebChannelPreview { channel :: GroupProfile, shortDescription :: Maybe MarkdownList, + shortDescriptionBlocks :: Maybe (NonEmpty MarkdownBlock), welcomeMessage :: Maybe MarkdownList, + welcomeMessageBlocks :: Maybe (NonEmpty MarkdownBlock), members :: [WebMemberProfile], subscribers :: Maybe Int64, messages :: [WebMessage], @@ -247,10 +251,14 @@ renderGroupPreview WebPreviewConfig {webJsonDir, webPreviewItemCount} cc user gI let rendered = mapMaybe toRenderedItem $ rights items msgs = map fst rendered senders = collectSenders $ map memberToProfile owners <> concatMap snd rendered + ParsedMarkdown shortDescription shortDescriptionBlocks = maybe (ParsedMarkdown Nothing Nothing) parsedMarkdown sd + ParsedMarkdown welcomeMessage welcomeMessageBlocks = maybe (ParsedMarkdown Nothing Nothing) parsedMarkdown wd preview = WebChannelPreview { channel = gp, - shortDescription = toFormattedText =<< sd, - welcomeMessage = toFormattedText =<< wd, + shortDescription, + shortDescriptionBlocks, + welcomeMessage, + welcomeMessageBlocks, members = senders, subscribers, messages = msgs, @@ -307,7 +315,7 @@ channelRemoved cc gId = void $ tryPutTMVar wakeSignal () toRenderedItem :: CChatItem 'CTGroup -> Maybe (WebMessage, [WebMemberProfile]) -toRenderedItem (CChatItem _ ChatItem {chatDir, meta = CIMeta {itemTs, itemTimed, itemForwarded, itemEdited}, content, formattedText, quotedItem, reactions, file}) +toRenderedItem (CChatItem _ ChatItem {chatDir, meta = CIMeta {itemTs, itemTimed, itemForwarded, itemEdited}, content, formattedText, markdownBlocks, quotedItem, reactions, file}) | isJust itemTimed = Nothing | otherwise = case ciMsgContent content of Just mc | not (isReport mc) -> @@ -323,6 +331,7 @@ toRenderedItem (CChatItem _ ChatItem {chatDir, meta = CIMeta {itemTs, itemTimed, ts = itemTs, content = mc, formattedText, + markdownBlocks, file = webFileInfo <$> file, quote = quotedItem >>= ciQuoteToQuotedMsg, reactions, @@ -423,13 +432,6 @@ removeStaleFiles dir activeFiles = do removeFile (dir f) `catchOwn'` \(e :: SomeException) -> logError $ "web preview: error removing stale file " <> T.pack f <> ": " <> tshow e -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 diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 521fc44d9a..dcb3321702 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -8442,6 +8442,12 @@ testUpdatedMentionNames = do `shouldBe` "hello @'Alice Jones' @robert" test (mm [("alice", Just "alice"), ("cath", Just "alice")]) "hello @alice @cath" `shouldBe` "hello @alice @alice_1" + it "replace changed names outside code" $ \_ -> do + let t = "- hello\n| x | @bob |\n|---|---|\n```\n@bob\n```" + t' = "- hello\n| x | @robert |\n|---|---|\n```\n@bob\n```" + (mc', ft', _) = updatedMentionNames (MCText t) (parseMaybeMarkdownList t) (mm [("bob", Just "robert")]) + msgContentText mc' `shouldBe` t' + ft' `shouldBe` parseMaybeMarkdownList t' where test mentionsMap t = let (mc', _, _) = updatedMentionNames (MCText t) (parseMaybeMarkdownList t) mentionsMap diff --git a/tests/JSONFixtures.hs b/tests/JSONFixtures.hs index bbdf8c14b0..e7948ac190 100644 --- a/tests/JSONFixtures.hs +++ b/tests/JSONFixtures.hs @@ -38,7 +38,13 @@ userJSON :: LB.ByteString userJSON = "{\"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,\"autoAcceptGroupInvitations\":false,\"userChatRelay\":false}" parsedMarkdownSwift :: LB.ByteString -parsedMarkdownSwift = "{\"formattedText\":[{\"format\":{\"_owsf\":true,\"bold\":{}},\"text\":\"hello\"}]}" +parsedMarkdownSwift = "{\"formattedText\":[{\"format\":{\"_owsf\":true,\"bold\":{\"singleStar\":true}},\"text\":\"hello\"}]}" parsedMarkdownTagged :: LB.ByteString -parsedMarkdownTagged = "{\"formattedText\":[{\"format\":{\"type\":\"bold\"},\"text\":\"hello\"}]}" +parsedMarkdownTagged = "{\"formattedText\":[{\"format\":{\"type\":\"bold\",\"singleStar\":true},\"text\":\"hello\"}]}" + +markdownBlocksSwift :: LB.ByteString +markdownBlocksSwift = "{\"formattedText\":[{\"format\":{\"_owsf\":true,\"markup\":{\"token\":{\"_owsf\":true,\"headingMark\":{\"level\":2}}}},\"text\":\"## \"},{\"text\":\"a\"},{\"format\":{\"_owsf\":true,\"markup\":{\"token\":{\"_owsf\":true,\"lineBreak\":{}}}},\"text\":\"\\n\"},{\"format\":{\"_owsf\":true,\"markup\":{\"token\":{\"_owsf\":true,\"listMark\":{}}}},\"text\":\"- \"},{\"format\":{\"_owsf\":true,\"bold\":{\"singleStar\":true}},\"text\":\"b\"}],\"markdownBlocks\":[{\"_owsf\":true,\"heading\":{\"level\":2,\"content\":{\"from\":1,\"to\":2}}},{\"_owsf\":true,\"list\":{\"items\":[{\"content\":{\"from\":4,\"to\":5},\"items\":[]}]}}]}" + +markdownBlocksTagged :: LB.ByteString +markdownBlocksTagged = "{\"formattedText\":[{\"format\":{\"type\":\"markup\",\"token\":{\"type\":\"headingMark\",\"level\":2}},\"text\":\"## \"},{\"text\":\"a\"},{\"format\":{\"type\":\"markup\",\"token\":{\"type\":\"lineBreak\"}},\"text\":\"\\n\"},{\"format\":{\"type\":\"markup\",\"token\":{\"type\":\"listMark\"}},\"text\":\"- \"},{\"format\":{\"type\":\"bold\",\"singleStar\":true},\"text\":\"b\"}],\"markdownBlocks\":[{\"type\":\"heading\",\"level\":2,\"content\":{\"from\":1,\"to\":2}},{\"type\":\"list\",\"items\":[{\"content\":{\"from\":4,\"to\":5},\"items\":[]}]}]}" diff --git a/tests/JSONTests.hs b/tests/JSONTests.hs index 52d722d26a..4f37e507f7 100644 --- a/tests/JSONTests.hs +++ b/tests/JSONTests.hs @@ -1,4 +1,5 @@ {-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} module JSONTests where @@ -13,7 +14,9 @@ import qualified Data.ByteString.Lazy.Char8 as LB import GHC.Generics (Generic) import Generic.Random (genericArbitraryU) import JSONFixtures +import Simplex.Chat.Markdown (Format (Bold), MarkdownBlock (UnknownBlock), ParsedMarkdown (..), parsedMarkdown) import Simplex.Chat.Remote.Protocol (owsf2tagged) +import Simplex.Chat.Types (BoolDef (..)) import Simplex.Messaging.Parsers import Test.Hspec import Test.Hspec.QuickCheck (modifyMaxSuccess) @@ -27,6 +30,7 @@ owsf2TaggedJSONTest = do chatStartedSwift `to` chatStartedTagged connectionsDiffSwift `to` connectionsDiffTagged parsedMarkdownSwift `to` parsedMarkdownTagged + markdownBlocksSwift `to` markdownBlocksTagged where to :: LB.ByteString -> LB.ByteString -> IO () owsf `to` tagged = @@ -77,7 +81,23 @@ owsf2TaggedSomeTypeTests = modifyMaxSuccess (const 10000) $ do it "should convert to tagged via TH encoding" $ property $ \x -> (join . fmap (JT.parseMaybe thParseJSON . owsf2tagged) . J.decode . toLazyByteString . J.fromEncoding . thToEncoding) x == Just (x :: SomeType) +markdownBlocksJSONTests :: Spec +markdownBlocksJSONTests = do + it "should encode tokens and blocks" $ + J.encode (parsedMarkdown "## a\n- *b*") `shouldBe` markdownBlocksTagged + it "should decode tokens and blocks of each kind" $ do + let ParsedMarkdown ft blocks = parsedMarkdown "# a #\nb\n===\n- *c*\n 1. d\n> e\n\n| x | y |\n|:-|-:|\n| 1 |\n```bash\ncode\n```\n---\ne \nf\\\ng\n- \\*h\n i" + J.eitherDecode (J.encode ft) `shouldBe` Right ft + J.eitherDecode (J.encode blocks) `shouldBe` Right blocks + it "should decode an unknown block" $ + J.eitherDecode "{\"type\":\"future\"}" `shouldBe` Right (UnknownBlock $ J.object ["type" J..= ("future" :: String)]) + it "should decode bold without singleStar" $ do + J.eitherDecode "{\"type\":\"bold\"}" `shouldBe` Right (Bold $ BoolDef False) + (JT.parseEither J.parseJSON . owsf2tagged =<< J.eitherDecode "{\"_owsf\":true,\"bold\":{}}") `shouldBe` Right (Bold $ BoolDef False) + jsonTests :: Spec -jsonTests = describe "owsf2tagged" $ do - it "should convert chat types" owsf2TaggedJSONTest - describe "SomeType" owsf2TaggedSomeTypeTests +jsonTests = do + describe "owsf2tagged" $ do + it "should convert chat types" owsf2TaggedJSONTest + describe "SomeType" owsf2TaggedSomeTypeTests + describe "Markdown blocks" markdownBlocksJSONTests diff --git a/tests/MarkdownTests.hs b/tests/MarkdownTests.hs index 903e3b3773..9c0de7e6e2 100644 --- a/tests/MarkdownTests.hs +++ b/tests/MarkdownTests.hs @@ -1,15 +1,22 @@ {-# LANGUAGE BlockArguments #-} +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedLists #-} {-# LANGUAGE OverloadedStrings #-} module MarkdownTests where +import Control.Applicative ((<|>)) +import Control.Monad (forM_) +import Data.List (intercalate) import Data.List.NonEmpty (NonEmpty) +import qualified Data.List.NonEmpty as L +import Data.Maybe (fromMaybe) import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Simplex.Chat.Markdown +import Simplex.Chat.Types (BoolDef (..)) import Simplex.Messaging.Agent.Protocol (SimplexDomain (..), SimplexNameInfo (..), SimplexNameType (..), SimplexTLD (..)) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Util ((<$$>)) @@ -32,6 +39,21 @@ markdownTests = do textWithCommands textWithSimplexNames multilineMarkdownList + inlineMarkdownGFM + backslashEscapes + textsWithoutBlocks + blockMarkdown + breaksInBlocks + headings + thematicBreaks + lists + quotes + codeBlocks + tables + formatsAcrossLines + liveMessages + markdownConsumers + linearTimeParsing testSanitizeUri infixr 1 ==>, <==, <==>, ==>>, <<==, <<==>> @@ -54,8 +76,17 @@ s <<== ft = T.concat (map markdownText ft) `shouldBe` s (<<==>>) :: Text -> MarkdownList -> Expectation s <<==>> ft = (s ==>> ft) >> (s <<== ft) +boldF :: Format +boldF = Bold $ BoolDef True + +bold2F :: Format +bold2F = Bold $ BoolDef False + bold :: Text -> Markdown -bold = markdown Bold +bold = markdown boldF + +bold2 :: Text -> Markdown +bold2 = markdown bold2F textFormat :: Spec textFormat = describe "text format (bold)" do @@ -76,17 +107,17 @@ textFormat = describe "text format (bold)" do <==> "this is " <> bold "bold" <> " " it "correct markdown with double asterisk" do "this is **bold formatted** text" - ==> "this is " <> bold "bold formatted" <> " text" + <==> "this is " <> bold2 "bold formatted" <> " text" "**bold formatted** text" - ==> bold "bold formatted" <> " text" + <==> bold2 "bold formatted" <> " text" "this is **bold**" - ==> "this is " <> bold "bold" + <==> "this is " <> bold2 "bold" " **bold** text" - ==> " " <> bold "bold" <> " text" + <==> " " <> bold2 "bold" <> " text" "this is **bold** " - ==> "this is " <> bold "bold" <> " " + <==> "this is " <> bold2 "bold" <> " " "this is **bold** " - ==> "this is " <> bold "bold" <> " " + <==> "this is " <> bold2 "bold" <> " " it "ignored as markdown" do "this is * unformatted * text" <==> "this is * unformatted * text" @@ -478,7 +509,576 @@ multilineMarkdownList = describe "multiline markdown" do it "command markdown" do "/link 1" <<==>> [command' "link 1" "/link 1"] " /link 1" <<==>> [command' "link 1" " /link 1"] - "*0*\n/7 /+" <<==>> [FormattedText (Just Bold) "0", "\n", command' "7" "/7", " ", command' "+" "/+"] + "*0*\n/7 /+" <<==>> [FormattedText (Just boldF) "0", "\n", command' "7" "/7", " ", command' "+" "/+"] + +inlineMarkdownGFM :: Spec +inlineMarkdownGFM = describe "inline markdown of GFM" do + it "strike-through and bold with double delimiters" do + "~~struck~~ text" ==> markdown StrikeThrough "struck" <> " text" + "~struck~ text" <==> markdown StrikeThrough "struck" <> " text" + "__bold__ text" ==> bold2 "bold" <> " text" + "**a**b" <==> bold2 "a" <> "b" + "~~ a~~" ==> "~~ a~~" + "__ a__" ==> "__ a__" + "a__b__" ==> "a__b__" + markdownText (fmt boldF "a") `shouldBe` "*a*" + markdownText (fmt bold2F "a") `shouldBe` "**a**" + it "code spans" do + "`` x ` y ``" ==> markdown Snippet "x ` y" + "``x ` y``" <== markdown Snippet "x ` y" + "` a `" ==> markdown Snippet "a" + "` a`" <==> markdown Snippet " a" + "` `" <==> markdown Snippet " " + "` a `" <==> markdown Snippet " a " + "``a``" ==> markdown Snippet "a" + "```a```" ==> markdown Snippet "a" + "``` a ```" ==> markdown Snippet "a" + "`` a`" <==> "`` a`" + "`a``b`" ==> markdown Snippet "a``b" + "```a``b```" <== markdown Snippet "a``b" + markdownText (fmt Snippet "`a") `shouldBe` "`` `a ``" + it "autolinks" do + "" <==> web "https://simplex.chat" "https://simplex.chat" "" + "mail " <==> "mail " <> web "chat@simplex.chat" "mailto:chat@simplex.chat" "" + "" <==> "" + " "" <==> "" + +backslashEscapes :: Spec +backslashEscapes = describe "backslash escapes" do + it "an escaped ASCII punctuation character is text" do + "\\*not bold*" <==> markdown Escape "\\" <> "*not bold*" + "a\\_b_" <==> "a" <> markdown Escape "\\" <> "_b_" + "\\\\" <==> markdown Escape "\\" <> "\\" + "\\`a`" <==> markdown Escape "\\" <> "`a`" + it "a backslash before other characters is text" do + "\\a" <==> "\\a" + "\\ a" <==> "\\ a" + "a\\" <==> "a\\" + "C:\\dir\\file.txt" <==> "C:\\dir\\file.txt" + it "escapes are text in code" do + "`\\*`" <==> markdown Snippet "\\*" + "```\n\\*a\n```" <===> ([fence "```", hardBr, "\\*a", hardBr, fence "```"], [CodeBlock Nothing (rng 2 3)]) + it "escaped block syntax is text" do + "\\# a\n- b" <===> ([fmt Escape "\\", "# a", lineBr, bullet "- ", "b"], [Paragraph (rng 0 2), List [entry 4 5 []]]) + "\\- a\n- b" <===> ([fmt Escape "\\", "- a", lineBr, bullet "- ", "b"], [Paragraph (rng 0 2), List [entry 4 5 []]]) + "\\> a\n- b" <===> ([fmt Escape "\\", "> a", lineBr, bullet "- ", "b"], [Paragraph (rng 0 2), List [entry 4 5 []]]) + "1\\. a\n- b" <===> (["1", fmt Escape "\\", ". a", lineBr, bullet "- ", "b"], [Paragraph (rng 0 3), List [entry 5 6 []]]) + it "escapes are kept in texts without blocks" do + "\\*a*" <<==>> [fmt Escape "\\", "*a*"] + "\\*a*\nb" <<==>> [fmt Escape "\\", "*a*\nb"] + +textsWithoutBlocks :: Spec +textsWithoutBlocks = describe "texts without blocks, as on master" do + it "no format" do + parseMaybeMarkdownList "a\nb" `shouldBe` Nothing + parseMaybeMarkdownList "a\n\nb" `shouldBe` Nothing + parseMaybeMarkdownList "a\n" `shouldBe` Nothing + parseMaybeMarkdownList "a \nb" `shouldBe` Nothing + parseMaybeMarkdownList "\n\na" `shouldBe` Nothing + it "line breaks are text" do + "*a*\n\nb" <<==>> [fmt boldF "a", "\n\nb"] + "*a*\nb" <<==>> [fmt boldF "a", "\nb"] + "*a*\r\nb" <<==>> [fmt boldF "a", "\r\nb"] + "*a* \nb" <<==>> [fmt boldF "a", " \nb"] + "*a*\\\nb" <<==>> [fmt boldF "a", "\\\nb"] + " a\n*b*" <<==>> [" a\n", fmt boldF "b"] + "\n\n*a*" <<==>> ["\n\n", fmt boldF "a"] + it "the final line break is removed" do + "*a*\n" ==>> [fmt boldF "a"] + "*a*\r\n" ==>> [fmt boldF "a"] + "*a*\n\n" ==>> [fmt boldF "a", "\n"] + +infix 1 ===>, <===> + +(===>) :: Text -> (MarkdownList, [MarkdownBlock]) -> Expectation +s ===> (ft, blocks) = do + let ParsedMarkdown ft' blocks' = parsedMarkdown s + (ft', blocks') `shouldBe` (Just ft, L.nonEmpty blocks) + rangesCoverContent ft blocks + +(<===>) :: Text -> (MarkdownList, [MarkdownBlock]) -> Expectation +s <===> parsed@(ft, _) = (s ===> parsed) >> (withoutFinalBreak <<== ft) + where + withoutFinalBreak = fromMaybe s $ T.stripSuffix "\r\n" s <|> T.stripSuffix "\n" s + +noBlocks :: Text -> Expectation +noBlocks s = let ParsedMarkdown _ blocks = parsedMarkdown s in blocks `shouldBe` Nothing + +rangesCoverContent :: MarkdownList -> [MarkdownBlock] -> Expectation +rangesCoverContent ft blocks = do + ranges `shouldSatisfy` all (\(SegmentRange a b) -> 0 <= a && a <= b && b <= length ft) + zip ranges (drop 1 ranges) `shouldSatisfy` all (\(SegmentRange _ b, SegmentRange a _) -> b <= a) + [i | SegmentRange a b <- ranges, i <- [a .. b - 1], i `elem` contentIndices] `shouldBe` contentIndices + where + ranges = concatMap blockRanges blocks + contentIndices = [i | (i, FormattedText f _) <- zip [0 ..] ft, not (isMarkup f)] + blockRanges = \case + Paragraph r -> [r] + Heading _ r -> [r] + CodeBlock _ r -> [r] + Quote r -> [r] + List entries -> concatMap entryRanges entries + Table _ headerCells bodyRows -> headerCells <> concat bodyRows + Rule r -> [r] + UnknownBlock _ -> [] + entryRanges (ListEntry _ r children) = r : concatMap entryRanges children + +fmt :: Format -> Text -> FormattedText +fmt = FormattedText . Just + +mk :: BlockToken -> Text -> FormattedText +mk = markupText + +lineBr :: FormattedText +lineBr = mk LineBreak "\n" + +softBr :: FormattedText +softBr = mk SoftBreak "\n" + +hardBr :: FormattedText +hardBr = mk HardBreak "\n" + +heading1 :: FormattedText +heading1 = mk (HeadingMark 1) "# " + +bullet :: Text -> FormattedText +bullet = mk (ListMark Nothing) + +numbered :: Text -> FormattedText +numbered n = mk (ListMark $ Just n) (n <> " ") + +quoteMark :: Text -> FormattedText +quoteMark = mk QuoteMark + +fence :: Text -> FormattedText +fence = mk (FenceMark Nothing) + +rng :: Int -> Int -> SegmentRange +rng = SegmentRange + +entry :: Int -> Int -> [ListEntry] -> ListEntry +entry a b = ListEntry Nothing (rng a b) + +blockMarkdown :: Spec +blockMarkdown = describe "block markdown" do + it "blocks and their ranges" do + "# Plan\n- buy *milk*\n and eggs\n - nested\n\n> a\n> b" + <===> ( [ mk (HeadingMark 1) "# ", + "Plan", + mk LineBreak "\n", + mk (ListMark Nothing) "- ", + "buy ", + FormattedText (Just boldF) "milk", + mk SoftBreak "\n", + mk Indent " ", + "and eggs", + mk LineBreak "\n", + mk (ListMark Nothing) " - ", + "nested", + mk (ParagraphBreak 2) "\n\n", + mk QuoteMark "> ", + "a", + mk SoftBreak "\n", + mk QuoteMark "> ", + "b" + ], + [Heading 1 (rng 1 2), List [entry 4 9 [entry 11 12 []]], Quote (rng 14 18)] + ) + +breaksInBlocks :: Spec +breaksInBlocks = describe "line breaks in texts with blocks" do + it "soft breaks in paragraphs, list items and quotes" do + "a\nb\nc\n# d" <===> (["a", softBr, "b", softBr, "c", lineBr, heading1, "d"], [Paragraph (rng 0 5), Heading 1 (rng 7 8)]) + " a\n b\n# c" <===> ([" a", softBr, " b", lineBr, heading1, "c"], [Paragraph (rng 0 3), Heading 1 (rng 5 6)]) + "- a\nb" <===> ([bullet "- ", "a", softBr, "b"], [List [entry 1 4 []]]) + "> a\nb" <===> ([quoteMark "> ", "a", softBr, "b"], [Quote (rng 1 4)]) + it "hard breaks by two or more spaces and by a backslash" do + "a \nb\n- c" <===> (["a", mk HardBreak " \n", "b", lineBr, bullet "- ", "c"], [Paragraph (rng 0 3), List [entry 5 6 []]]) + "a \nb\n- c" <===> (["a", mk HardBreak " \n", "b", lineBr, bullet "- ", "c"], [Paragraph (rng 0 3), List [entry 5 6 []]]) + "a\\\nb\n- c" <===> (["a", mk HardBreak "\\\n", "b", lineBr, bullet "- ", "c"], [Paragraph (rng 0 3), List [entry 5 6 []]]) + "a\\\\\\\nb\n- c" <===> (["a", fmt Escape "\\", "\\", mk HardBreak "\\\n", "b", lineBr, bullet "- ", "c"], [Paragraph (rng 0 5), List [entry 7 8 []]]) + "- a \n b" <===> ([bullet "- ", "a", mk HardBreak " \n", mk Indent " ", "b"], [List [entry 1 5 []]]) + "- a\\\n b" <===> ([bullet "- ", "a", mk HardBreak "\\\n", mk Indent " ", "b"], [List [entry 1 5 []]]) + "> a \n> b" <===> ([quoteMark "> ", "a", mk HardBreak " \n", quoteMark "> ", "b"], [Quote (rng 1 5)]) + "> a\\\n> b" <===> ([quoteMark "> ", "a", mk HardBreak "\\\n", quoteMark "> ", "b"], [Quote (rng 1 5)]) + it "spaces and backslashes that are content" do + "a \nb\n- c" <===> (["a ", softBr, "b", lineBr, bullet "- ", "c"], [Paragraph (rng 0 3), List [entry 5 6 []]]) + "a\\\\\nb\n- c" <===> (["a", fmt Escape "\\", "\\", softBr, "b", lineBr, bullet "- ", "c"], [Paragraph (rng 0 5), List [entry 7 8 []]]) + "a \n\n- c" <===> (["a ", mk (ParagraphBreak 2) "\n\n", bullet "- ", "c"], [Paragraph (rng 0 1), List [entry 3 4 []]]) + "a\\\n\n- c" <===> (["a\\", mk (ParagraphBreak 2) "\n\n", bullet "- ", "c"], [Paragraph (rng 0 1), List [entry 3 4 []]]) + "a \n- c" <===> (["a ", lineBr, bullet "- ", "c"], [Paragraph (rng 0 1), List [entry 3 4 []]]) + "# h\na " <===> ([heading1, "h", lineBr, "a "], [Heading 1 (rng 1 2), Paragraph (rng 3 4)]) + "# h\na\\" <===> ([heading1, "h", lineBr, "a\\"], [Heading 1 (rng 1 2), Paragraph (rng 3 4)]) + it "line and paragraph breaks between blocks" do + "## a\nb" <===> ([mk (HeadingMark 2) "## ", "a", lineBr, "b"], [Heading 2 (rng 1 2), Paragraph (rng 3 4)]) + "# a\n\nb" <===> ([heading1, "a", mk (ParagraphBreak 2) "\n\n", "b"], [Heading 1 (rng 1 2), Paragraph (rng 3 4)]) + "# a\n\n\nb" <===> ([heading1, "a", mk (ParagraphBreak 3) "\n\n\n", "b"], [Heading 1 (rng 1 2), Paragraph (rng 3 4)]) + "# a\n \n\t\nb" <===> ([heading1, "a", mk (ParagraphBreak 3) "\n \n\t\n", "b"], [Heading 1 (rng 1 2), Paragraph (rng 3 4)]) + it "blank lines at the text start and end" do + "\n\n# a" <===> ([mk (ParagraphBreak 2) "\n\n", heading1, "a"], [Heading 1 (rng 2 3)]) + "\n \n# a" <===> ([mk (ParagraphBreak 2) "\n \n", heading1, "a"], [Heading 1 (rng 2 3)]) + "# a\n\n" <===> ([heading1, "a", lineBr], [Heading 1 (rng 1 2)]) + "# a\n\n\n" <===> ([heading1, "a", mk (ParagraphBreak 2) "\n\n"], [Heading 1 (rng 1 2)]) + "# a\n " <===> ([heading1, "a", mk (ParagraphBreak 1) "\n "], [Heading 1 (rng 1 2)]) + it "CR before line breaks" do + "a\r\nb\r\n- c" <===> (["a", mk SoftBreak "\r\n", "b", mk LineBreak "\r\n", bullet "- ", "c"], [Paragraph (rng 0 3), List [entry 5 6 []]]) + "a \r\nb\r\n- c" <===> (["a", mk HardBreak " \r\n", "b", mk LineBreak "\r\n", bullet "- ", "c"], [Paragraph (rng 0 3), List [entry 5 6 []]]) + "# a\r\n- b" <===> ([heading1, "a", mk LineBreak "\r\n", bullet "- ", "b"], [Heading 1 (rng 1 2), List [entry 4 5 []]]) + "# a\r\n\r\nb" <===> ([heading1, "a", mk (ParagraphBreak 2) "\r\n\r\n", "b"], [Heading 1 (rng 1 2), Paragraph (rng 3 4)]) + "- a\r\n b" <===> ([bullet "- ", "a", mk SoftBreak "\r\n", mk Indent " ", "b"], [List [entry 1 5 []]]) + "> a\r\n> b" <===> ([quoteMark "> ", "a", mk SoftBreak "\r\n", quoteMark "> ", "b"], [Quote (rng 1 5)]) + "a\r\n===" <===> (["a", mk HardBreak "\r\n", mk (HeadingUnderline 1) "==="], [Heading 1 (rng 0 1)]) + "```\r\na\r\n```" <===> ([fence "```", mk HardBreak "\r\n", "a", mk HardBreak "\r\n", fence "```"], [CodeBlock Nothing (rng 2 3)]) + "|a|\r\n|-|\r\n|b|" <===> ([mk RowMark "|", "a", mk CellMark "|", mk HardBreak "\r\n", mk (DelimiterRow [TANone]) "|-|", mk HardBreak "\r\n", mk RowMark "|", "b", mk CellMark "|"], [Table [TANone] [rng 1 2] [[rng 7 8]]]) + +headings :: Spec +headings = describe "headings" do + it "levels 1 to 6" do + forM_ ([1 .. 6] :: [Int]) $ \n -> do + let hashes = T.replicate n "#" + (hashes <> " a") <===> ([mk (HeadingMark n) (hashes <> " "), "a"], [Heading n (rng 1 2)]) + noBlocks "####### a" + it "a space and text after the marker" do + noBlocks "#" + noBlocks "# " + noBlocks "#a" + noBlocks "#\ta" + noBlocks "#\tx#" + "#\n- b" <===> (["#", lineBr, bullet "- ", "b"], [Paragraph (rng 0 1), List [entry 3 4 []]]) + it "indentation and inline content" do + " # a" <===> ([mk (HeadingMark 1) " # ", "a"], [Heading 1 (rng 1 2)]) + "# *a* b" <===> ([heading1, fmt boldF "a", " b"], [Heading 1 (rng 1 3)]) + it "closing sequences" do + "# a #" <===> ([heading1, "a", mk HeadingClose " #"], [Heading 1 (rng 1 2)]) + "# a ## " <===> ([heading1, "a", mk HeadingClose " ## "], [Heading 1 (rng 1 2)]) + "# a #\nb" <===> ([heading1, "a", mk HeadingClose " #", lineBr, "b"], [Heading 1 (rng 1 2), Paragraph (rng 4 5)]) + "# a#" <===> ([heading1, "a#"], [Heading 1 (rng 1 2)]) + "# a \\#" <===> ([heading1, "a ", fmt Escape "\\", "#"], [Heading 1 (rng 1 4)]) + it "one line, after and before other blocks" do + "a\n# b" <===> (["a", lineBr, heading1, "b"], [Paragraph (rng 0 1), Heading 1 (rng 3 4)]) + "# a\n# b" <===> ([heading1, "a", lineBr, heading1, "b"], [Heading 1 (rng 1 2), Heading 1 (rng 4 5)]) + it "setext headings" do + "a\n===" <===> (["a", hardBr, mk (HeadingUnderline 1) "==="], [Heading 1 (rng 0 1)]) + "a\n---" <===> (["a", hardBr, mk (HeadingUnderline 2) "---"], [Heading 2 (rng 0 1)]) + "a\n-" <===> (["a", hardBr, mk (HeadingUnderline 2) "-"], [Heading 2 (rng 0 1)]) + "a\nb\n=" <===> (["a", softBr, "b", hardBr, mk (HeadingUnderline 1) "="], [Heading 1 (rng 0 3)]) + "a\nb\n---" <===> (["a", softBr, "b", hardBr, mk (HeadingUnderline 2) "---"], [Heading 2 (rng 0 3)]) + "a\n === " <===> (["a", hardBr, mk (HeadingUnderline 1) " === "], [Heading 1 (rng 0 1)]) + "a\n---\nb" <===> (["a", hardBr, mk (HeadingUnderline 2) "---", lineBr, "b"], [Heading 2 (rng 0 1), Paragraph (rng 4 5)]) + it "underlines only under paragraphs" do + "a\n- - -" <===> (["a", lineBr, mk RuleMark "- - -"], [Paragraph (rng 0 1), Rule (rng 2 3)]) + "a\n***" <===> (["a", lineBr, mk RuleMark "***"], [Paragraph (rng 0 1), Rule (rng 2 3)]) + "# a\n===" <===> ([heading1, "a", lineBr, "==="], [Heading 1 (rng 1 2), Paragraph (rng 3 4)]) + "> a\n===" <===> ([quoteMark "> ", "a", softBr, "==="], [Quote (rng 1 4)]) + "- a\n===" <===> ([bullet "- ", "a", softBr, "==="], [List [entry 1 4 []]]) + noBlocks "a\n= =" + noBlocks "a\n\n===" + noBlocks "===" + noBlocks "===\na" + +thematicBreaks :: Spec +thematicBreaks = describe "thematic breaks" do + it "3 or more of one character, with spaces between and around" do + forM_ (["---", "***", "___", "- - -", "* * *", "_ _ _", "-- -", " ***", "*** ", "----------"] :: [Text]) $ \r -> + r <===> ([mk RuleMark r], [Rule (rng 0 1)]) + it "other lines are text" do + noBlocks "--" + noBlocks "*-*" + noBlocks "---x" + noBlocks "-- a" + it "between other blocks" do + "a\n***\nb" <===> (["a", lineBr, mk RuleMark "***", lineBr, "b"], [Paragraph (rng 0 1), Rule (rng 2 3), Paragraph (rng 4 5)]) + "- a\n---" <===> ([bullet "- ", "a", lineBr, mk RuleMark "---"], [List [entry 1 2 []], Rule (rng 3 4)]) + +lists :: Spec +lists = describe "lists" do + it "bullet markers" do + forM_ (["- ", "* ", "+ "] :: [Text]) $ \m -> (m <> "a") <===> ([bullet m, "a"], [List [entry 1 2 []]]) + "- a\n* b\n+ c" <===> ([bullet "- ", "a", lineBr, bullet "* ", "b", lineBr, bullet "+ ", "c"], [List [entry 1 2 [], entry 4 5 [], entry 7 8 []]]) + it "numbers as written" do + "1. a\n2) b" <===> ([numbered "1.", "a", lineBr, numbered "2)", "b"], [List [ListEntry (Just "1.") (rng 1 2) [], ListEntry (Just "2)") (rng 4 5) []]]) + "3. a\n7. b" <===> ([numbered "3.", "a", lineBr, numbered "7.", "b"], [List [ListEntry (Just "3.") (rng 1 2) [], ListEntry (Just "7.") (rng 4 5) []]]) + "123456789. a" <===> ([numbered "123456789.", "a"], [List [ListEntry (Just "123456789.") (rng 1 2) []]]) + it "markers without a space and text are text" do + noBlocks "-a" + noBlocks "- " + noBlocks "1.a" + noBlocks "1. " + noBlocks "1234567890. a" + noBlocks "a. b" + it "nesting by the column of the marker" do + "- a\n - b" <===> ([bullet "- ", "a", lineBr, bullet " - ", "b"], [List [entry 1 2 [entry 4 5 []]]]) + "1. a\n - b" <===> ([numbered "1.", "a", lineBr, bullet " - ", "b"], [List [ListEntry (Just "1.") (rng 1 2) [entry 4 5 []]]]) + "- a\n - b" <===> ([bullet "- ", "a", lineBr, bullet " - ", "b"], [List [entry 1 2 [entry 4 5 []]]]) + "- a\n\t- b" <===> ([bullet "- ", "a", lineBr, bullet "\t- ", "b"], [List [entry 1 2 [entry 4 5 []]]]) + "- a\n - b\n- c" <===> ([bullet "- ", "a", lineBr, bullet " - ", "b", lineBr, bullet "- ", "c"], [List [entry 1 2 [entry 4 5 []], entry 7 8 []]]) + "- a\n - b\n - c" <===> ([bullet "- ", "a", lineBr, bullet " - ", "b", lineBr, bullet " - ", "c"], [List [entry 1 2 [entry 4 5 [], entry 7 8 []]]]) + " - a\n- b" <===> ([bullet " - ", "a", lineBr, bullet "- ", "b"], [List [entry 1 2 [], entry 4 5 []]]) + it "depth at most 7" do + let ks = [1 .. 8] :: [Int] + marker k = T.replicate (2 * (k - 1)) " " <> "- " + e k = entry (3 * k - 2) (3 * k - 1) + T.intercalate "\n" [marker k <> T.pack (show k) | k <- ks] + <===> ( intercalate [lineBr] [[bullet (marker k), FormattedText Nothing (T.pack $ show k)] | k <- ks], + [List [e 1 [e 2 [e 3 [e 4 [e 5 [e 6 [e 7 [], e 8 []]]]]]]]] + ) + it "continuation lines" do + "- a\n b" <===> ([bullet "- ", "a", softBr, mk Indent " ", "b"], [List [entry 1 5 []]]) + "- a\n b" <===> ([bullet "- ", "a", softBr, mk Indent " ", "b"], [List [entry 1 5 []]]) + "- a\n\tb" <===> ([bullet "- ", "a", softBr, mk Indent "\t", "b"], [List [entry 1 5 []]]) + "- a\nb" <===> ([bullet "- ", "a", softBr, "b"], [List [entry 1 4 []]]) + "- a\n b\n - c" <===> ([bullet "- ", "a", softBr, mk Indent " ", "b", lineBr, bullet " - ", "c"], [List [entry 1 5 [entry 7 8 []]]]) + "- *a* b" <===> ([bullet "- ", fmt boldF "a", " b"], [List [entry 1 3 []]]) + it "blank lines between items" do + "- a\n\n- b" <===> ([bullet "- ", "a", mk (ParagraphBreak 2) "\n\n", bullet "- ", "b"], [List [entry 1 2 [], entry 4 5 []]]) + "- a\n\n\n- b" <===> ([bullet "- ", "a", mk (ParagraphBreak 3) "\n\n\n", bullet "- ", "b"], [List [entry 1 2 [], entry 4 5 []]]) + it "lists and other blocks" do + "- a\n\nb" <===> ([bullet "- ", "a", mk (ParagraphBreak 2) "\n\n", "b"], [List [entry 1 2 []], Paragraph (rng 3 4)]) + "a\n- b" <===> (["a", lineBr, bullet "- ", "b"], [Paragraph (rng 0 1), List [entry 3 4 []]]) + "# a\n- b" <===> ([heading1, "a", lineBr, bullet "- ", "b"], [Heading 1 (rng 1 2), List [entry 4 5 []]]) + "- a\n# b" <===> ([bullet "- ", "a", lineBr, heading1, "b"], [List [entry 1 2 []], Heading 1 (rng 4 5)]) + "- a\n> b" <===> ([bullet "- ", "a", lineBr, quoteMark "> ", "b"], [List [entry 1 2 []], Quote (rng 4 5)]) + "- a\n# h\n- b" <===> ([bullet "- ", "a", lineBr, heading1, "h", lineBr, bullet "- ", "b"], [List [entry 1 2 []], Heading 1 (rng 4 5), List [entry 7 8 []]]) + +quotes :: Spec +quotes = describe "quotes" do + it "quote marks" do + "> a" <===> ([quoteMark "> ", "a"], [Quote (rng 1 2)]) + ">a" <===> ([quoteMark ">", "a"], [Quote (rng 1 2)]) + ">\ta" <===> ([quoteMark ">\t", "a"], [Quote (rng 1 2)]) + "> a" <===> ([quoteMark "> ", " a"], [Quote (rng 1 2)]) + " > a" <===> ([quoteMark " > ", "a"], [Quote (rng 1 2)]) + ">a\n> b" <===> ([quoteMark ">", "a", softBr, quoteMark "> ", "b"], [Quote (rng 1 5)]) + it "quote marks without text are text" do + noBlocks ">" + noBlocks "> " + noBlocks ">\n>" + it "block syntax inside a quote is text" do + "> > b\nc" <===> ([quoteMark "> ", "> b", softBr, "c"], [Quote (rng 1 4)]) + "> # a" <===> ([quoteMark "> ", "# a"], [Quote (rng 1 2)]) + "> - a" <===> ([quoteMark "> ", "- a"], [Quote (rng 1 2)]) + it "blank quote lines" do + "> a\n>\n> b" <===> ([quoteMark "> ", "a", hardBr, quoteMark ">", hardBr, quoteMark "> ", "b"], [Quote (rng 1 7)]) + "> a\n>\n>\n> b" <===> ([quoteMark "> ", "a", hardBr, quoteMark ">", hardBr, quoteMark ">", hardBr, quoteMark "> ", "b"], [Quote (rng 1 9)]) + "> a\n> \n> b" <===> ([quoteMark "> ", "a", hardBr, quoteMark "> ", hardBr, quoteMark "> ", "b"], [Quote (rng 1 7)]) + ">\n> a\n>" <===> ([quoteMark ">", hardBr, quoteMark "> ", "a", hardBr, quoteMark ">"], [Quote (rng 3 4)]) + it "lines without quote marks after a line with text" do + "> a\nb" <===> ([quoteMark "> ", "a", softBr, "b"], [Quote (rng 1 4)]) + "> a\nb\n> c" <===> ([quoteMark "> ", "a", softBr, "b", softBr, quoteMark "> ", "c"], [Quote (rng 1 7)]) + "> a\nb\n>\n> c" <===> ([quoteMark "> ", "a", softBr, "b", hardBr, quoteMark ">", hardBr, quoteMark "> ", "c"], [Quote (rng 1 9)]) + "> a\n>\nb" <===> ([quoteMark "> ", "a", hardBr, quoteMark ">", lineBr, "b"], [Quote (rng 1 2), Paragraph (rng 5 6)]) + it "quotes and other blocks" do + "> a\n\nb" <===> ([quoteMark "> ", "a", mk (ParagraphBreak 2) "\n\n", "b"], [Quote (rng 1 2), Paragraph (rng 3 4)]) + "> a\n\n> b" <===> ([quoteMark "> ", "a", mk (ParagraphBreak 2) "\n\n", quoteMark "> ", "b"], [Quote (rng 1 2), Quote (rng 4 5)]) + "> a\n- b" <===> ([quoteMark "> ", "a", lineBr, bullet "- ", "b"], [Quote (rng 1 2), List [entry 4 5 []]]) + "> a\n# b" <===> ([quoteMark "> ", "a", lineBr, heading1, "b"], [Quote (rng 1 2), Heading 1 (rng 4 5)]) + "> a\nb | c\n-|-" + <===> ( [quoteMark "> ", "a", lineBr, mk RowMark "", "b", mk CellMark " | ", "c", mk CellMark "", hardBr, mk (DelimiterRow [TANone, TANone]) "-|-"], + [Quote (rng 1 2), Table [TANone, TANone] [rng 4 5, rng 6 7] []] + ) + it "quotes after paragraph lines" do + "a\n> b" <===> (["a", lineBr, quoteMark "> ", "b"], [Paragraph (rng 0 1), Quote (rng 3 4)]) + "a\n>\n> b" <===> (["a", lineBr, quoteMark ">", hardBr, quoteMark "> ", "b"], [Paragraph (rng 0 1), Quote (rng 5 6)]) + "a\n>\n>\n> b" <===> (["a", softBr, ">", lineBr, quoteMark ">", hardBr, quoteMark "> ", "b"], [Paragraph (rng 0 3), Quote (rng 7 8)]) + parseMaybeMarkdownList (T.intercalate "\n" $ "a" : replicate 7000 ">") `shouldBe` Nothing + +codeBlocks :: Spec +codeBlocks = describe "code blocks" do + it "fences and languages" do + "```\nx *y*\n```" <===> ([fence "```", hardBr, "x *y*", hardBr, fence "```"], [CodeBlock Nothing (rng 2 3)]) + "~~~\na\n\nb\n~~~" <===> ([fence "~~~", hardBr, "a", hardBr, hardBr, "b", hardBr, fence "~~~"], [CodeBlock Nothing (rng 2 6)]) + "```bash\necho 1" <===> ([mk (FenceMark $ Just "bash") "```bash", hardBr, "echo 1"], [CodeBlock (Just "bash") (rng 2 3)]) + "```bash script\nx\n```" <===> ([mk (FenceMark $ Just "bash") "```bash script", hardBr, "x", hardBr, fence "```"], [CodeBlock (Just "bash") (rng 2 3)]) + "``` bash\nx\n```" <===> ([mk (FenceMark $ Just "bash") "``` bash", hardBr, "x", hardBr, fence "```"], [CodeBlock (Just "bash") (rng 2 3)]) + "~~~ a`b\nc\n~~~" <===> ([mk (FenceMark $ Just "a`b") "~~~ a`b", hardBr, "c", hardBr, fence "~~~"], [CodeBlock (Just "a`b") (rng 2 3)]) + noBlocks "``` a`b\nc\n```" + it "closing fences" do + "```\na\n~~~\nb\n```" <===> ([fence "```", hardBr, "a", hardBr, "~~~", hardBr, "b", hardBr, fence "```"], [CodeBlock Nothing (rng 2 7)]) + "```\na\n``` " <===> ([fence "```", hardBr, "a", hardBr, fence "``` "], [CodeBlock Nothing (rng 2 3)]) + "```\na\n`````" <===> ([fence "```", hardBr, "a", hardBr, fence "`````"], [CodeBlock Nothing (rng 2 3)]) + "````\na\n```\n````" <===> ([fence "````", hardBr, "a", hardBr, "```", hardBr, fence "````"], [CodeBlock Nothing (rng 2 5)]) + "```\na\n``` b" <===> ([fence "```", hardBr, "a", hardBr, "``` b"], [CodeBlock Nothing (rng 2 5)]) + it "blank and indented code lines" do + "```\n\na\n\n```" <===> ([fence "```", hardBr, hardBr, "a", hardBr, hardBr, fence "```"], [CodeBlock Nothing (rng 2 5)]) + "```\na\n\n```" <===> ([fence "```", hardBr, "a", hardBr, hardBr, fence "```"], [CodeBlock Nothing (rng 2 4)]) + " ```\n a\n ```" <===> ([fence " ```", hardBr, mk Indent " ", " a", hardBr, fence " ```"], [CodeBlock Nothing (rng 2 4)]) + " ```\n\ta\n ```" <===> ([fence " ```", hardBr, mk Indent "\t", "a", hardBr, fence " ```"], [CodeBlock Nothing (rng 2 4)]) + " ```\n a\n```" <===> ([fence " ```", hardBr, mk Indent " ", "a", hardBr, fence "```"], [CodeBlock Nothing (rng 2 4)]) + it "code is text" do + "```\n*a* `b` @c https://simplex.chat\n```" <===> ([fence "```", hardBr, "*a* `b` @c https://simplex.chat", hardBr, fence "```"], [CodeBlock Nothing (rng 2 3)]) + "```\na \nb\\\n```" <===> ([fence "```", hardBr, "a ", hardBr, "b\\", hardBr, fence "```"], [CodeBlock Nothing (rng 2 5)]) + it "a fence followed by a line is a code block" do + "```\n```" <===> ([fence "```", hardBr, fence "```"], [CodeBlock Nothing (rng 2 2)]) + "```\n```\nx" <===> ([fence "```", hardBr, fence "```", lineBr, "x"], [CodeBlock Nothing (rng 2 2), Paragraph (rng 4 5)]) + "```\n\n```" <===> ([fence "```", hardBr, hardBr, fence "```"], [CodeBlock Nothing (rng 2 2)]) + "```\n \n```" <===> ([fence "```", hardBr, " ", hardBr, fence "```"], [CodeBlock Nothing (rng 2 3)]) + "```\n\n" <===> ([fence "```", hardBr], [CodeBlock Nothing (rng 2 2)]) + it "a fence line at the text end is text" do + noBlocks "```" + noBlocks "```\n" + noBlocks "a\n```" + "a\n```\nb" <===> (["a", lineBr, fence "```", hardBr, "b"], [Paragraph (rng 0 1), CodeBlock Nothing (rng 4 5)]) + it "code blocks and other blocks" do + "a\n```\nb\n```" <===> (["a", lineBr, fence "```", hardBr, "b", hardBr, fence "```"], [Paragraph (rng 0 1), CodeBlock Nothing (rng 4 5)]) + "```\na\n```\nb" <===> ([fence "```", hardBr, "a", hardBr, fence "```", lineBr, "b"], [CodeBlock Nothing (rng 2 3), Paragraph (rng 6 7)]) + "- a\n```\nb\n```" <===> ([bullet "- ", "a", lineBr, fence "```", hardBr, "b", hardBr, fence "```"], [List [entry 1 2 []], CodeBlock Nothing (rng 5 6)]) + +tables :: Spec +tables = describe "tables" do + it "alignments" do + "| a | b | c | d |\n|---|:--|--:|:-:|" + <===> ( [mk RowMark "| ", "a", mk CellMark " | ", "b", mk CellMark " | ", "c", mk CellMark " | ", "d", mk CellMark " |", hardBr, mk (DelimiterRow [TANone, TALeft, TARight, TACenter]) "|---|:--|--:|:-:|"], + [Table [TANone, TALeft, TARight, TACenter] [rng 1 2, rng 3 4, rng 5 6, rng 7 8] []] + ) + "a | b\n:- | -:" <===> ([mk RowMark "", "a", mk CellMark " | ", "b", mk CellMark "", hardBr, mk (DelimiterRow [TALeft, TARight]) ":- | -:"], [Table [TALeft, TARight] [rng 1 2, rng 3 4] []]) + it "rows and cells" do + "| a | b |\n|---|:-:|\n| 1 | 2 |" + <===> ( [mk RowMark "| ", "a", mk CellMark " | ", "b", mk CellMark " |", hardBr, mk (DelimiterRow [TANone, TACenter]) "|---|:-:|", hardBr, mk RowMark "| ", "1", mk CellMark " | ", "2", mk CellMark " |"], + [Table [TANone, TACenter] [rng 1 2, rng 3 4] [[rng 9 10, rng 11 12]]] + ) + "|a|b|\n|-|-|\n|c|" <===> (header2 <> [hardBr, mk RowMark "|", "c", mk CellMark "|"], [Table [TANone, TANone] [rng 1 2, rng 3 4] [[rng 9 10]]]) + "|a|b|\n|-|-|\n|c|d|e|" <===> (header2 <> [hardBr, mk RowMark "|", "c", mk CellMark "|", "d|e", mk CellMark "|"], [Table [TANone, TANone] [rng 1 2, rng 3 4] [[rng 9 10, rng 11 12]]]) + "|a|b|\n|-|-|\n|c|d|e" <===> (header2 <> [hardBr, mk RowMark "|", "c", mk CellMark "|", "d|e", mk CellMark ""], [Table [TANone, TANone] [rng 1 2, rng 3 4] [[rng 9 10, rng 11 12]]]) + "|a|b|\n|-|-|\n||c|" <===> (header2 <> [hardBr, mk RowMark "|", mk CellMark "|", "c", mk CellMark "|"], [Table [TANone, TANone] [rng 1 2, rng 3 4] [[rng 9 9, rng 10 11]]]) + "|a|b|\n|-|-|\n| |c|" <===> (header2 <> [hardBr, mk RowMark "| ", mk CellMark "|", "c", mk CellMark "|"], [Table [TANone, TANone] [rng 1 2, rng 3 4] [[rng 9 9, rng 10 11]]]) + "|a|\n|-|\n||" <===> ([mk RowMark "|", "a", mk CellMark "|", hardBr, mk (DelimiterRow [TANone]) "|-|", hardBr, mk RowMark "|", mk CellMark "|"], [Table [TANone] [rng 1 2] [[rng 7 7]]]) + " | a |\n |---|\n | b |" <===> ([mk RowMark " | ", "a", mk CellMark " |", hardBr, mk (DelimiterRow [TANone]) " |---|", hardBr, mk RowMark " | ", "b", mk CellMark " |"], [Table [TANone] [rng 1 2] [[rng 7 8]]]) + it "inline content in cells" do + "| *a* | @b |\n|---|---|" <===> ([mk RowMark "| ", fmt boldF "a", mk CellMark " | ", fmt (Mention "b") "@b", mk CellMark " |", hardBr, mk (DelimiterRow [TANone, TANone]) "|---|---|"], [Table [TANone, TANone] [rng 1 2, rng 3 4] []]) + it "lines that are not tables" do + noBlocks "| a |\n| b |" + noBlocks "| a | b |\n|---|" + noBlocks "| a |\n|---|x" + noBlocks "a\n-|-" + it "tables and other blocks" do + "a | b\n- | -\nc" <===> ([mk RowMark "", "a", mk CellMark " | ", "b", mk CellMark "", hardBr, mk (DelimiterRow [TANone, TANone]) "- | -", lineBr, "c"], [Table [TANone, TANone] [rng 1 2, rng 3 4] [], Paragraph (rng 8 9)]) + "| a |\n|---|\n- b | c" <===> ([mk RowMark "| ", "a", mk CellMark " |", hardBr, mk (DelimiterRow [TANone]) "|---|", lineBr, bullet "- ", "b | c"], [Table [TANone] [rng 1 2] [], List [entry 7 8 []]]) + "x\n| a |\n|---|" <===> (["x", lineBr, mk RowMark "| ", "a", mk CellMark " |", hardBr, mk (DelimiterRow [TANone]) "|---|"], [Paragraph (rng 0 1), Table [TANone] [rng 3 4] []]) + where + header2 = [mk RowMark "|", "a", mk CellMark "|", "b", mk CellMark "|", hardBr, mk (DelimiterRow [TANone, TANone]) "|-|-|"] + +formatsAcrossLines :: Spec +formatsAcrossLines = describe "formats across line breaks" do + it "each delimited format, in a paragraph" do + "*a\nb*" ==>> [fmt boldF "a", "\n", fmt boldF "b"] + "**a\nb**" ==>> [fmt bold2F "a", "\n", fmt bold2F "b"] + "__a\nb__" ==>> [fmt bold2F "a", "\n", fmt bold2F "b"] + "_a\nb_" ==>> [fmt Italic "a", "\n", fmt Italic "b"] + "~a\nb~" ==>> [fmt StrikeThrough "a", "\n", fmt StrikeThrough "b"] + "~~a\nb~~" ==>> [fmt StrikeThrough "a", "\n", fmt StrikeThrough "b"] + "`a\nb`" ==>> [fmt Snippet "a", "\n", fmt Snippet "b"] + "#a\nb#" ==>> [fmt Secret "a", "\n", fmt Secret "b"] + "!1 a\nb!" ==>> [fmt (colored Red) "a", "\n", fmt (colored Red) "b"] + "!- a\nb!" ==>> [fmt Small "a", "\n", fmt Small "b"] + "*a\nb\nc*" ==>> [fmt boldF "a", "\n", fmt boldF "b", "\n", fmt boldF "c"] + "*a \nb*" ==>> [fmt boldF "a", " \n", fmt boldF "b"] + "# h\n*a\nb*" ===> ([heading1, "h", lineBr, fmt boldF "a", softBr, fmt boldF "b"], [Heading 1 (rng 1 2), Paragraph (rng 3 6)]) + it "in a list item and a quote" do + "- *a\n b*" ===> ([bullet "- ", fmt boldF "a", softBr, mk Indent " ", fmt boldF "b"], [List [entry 1 5 []]]) + "- *a\nb*" ===> ([bullet "- ", fmt boldF "a", softBr, fmt boldF "b"], [List [entry 1 4 []]]) + "> *a\n> b*" ===> (quoted, [Quote (rng 1 5)]) + "> *a*\n> *b*" <===> (quoted, [Quote (rng 1 5)]) + "> *a\nb*" ===> ([quoteMark "> ", fmt boldF "a", softBr, fmt boldF "b"], [Quote (rng 1 4)]) + it "not across blocks, blank lines and cells" do + parseMaybeMarkdownList "*a\n\nb*" `shouldBe` Nothing + "# *a\nb*" <===> ([heading1, "*a", lineBr, "b*"], [Heading 1 (rng 1 2), Paragraph (rng 3 4)]) + "- *a\n- b*" <===> ([bullet "- ", "*a", lineBr, bullet "- ", "b*"], [List [entry 1 2 [], entry 4 5 []]]) + "> *a\n>\n> b*" <===> ([quoteMark "> ", "*a", hardBr, quoteMark ">", hardBr, quoteMark "> ", "b*"], [Quote (rng 1 7)]) + "| *a | b* |\n|---|---|" <===> ([mk RowMark "| ", "*a", mk CellMark " | ", "b*", mk CellMark " |", hardBr, mk (DelimiterRow [TANone, TANone]) "|---|---|"], [Table [TANone, TANone] [rng 1 2, rng 3 4] []]) + it "a format starting or ending at a line break keeps its empty part" do + "# h\n`\na`" ===> ([heading1, "h", lineBr, fmt Snippet "", softBr, fmt Snippet "a"], [Heading 1 (rng 1 2), Paragraph (rng 3 6)]) + "# h\n``\n``" <===> ([heading1, "h", lineBr, fmt Snippet "", softBr, fmt Snippet ""], [Heading 1 (rng 1 2), Paragraph (rng 3 6)]) + T.concat (map markdownText $ fromMaybe [] $ parseMaybeMarkdownList "# h\n`\na`") `shouldBe` "# h\n``\n`a`" + it "an unclosed delimiter is text to the line end" do + "*a\n_b_" ==>> ["*a\n", fmt Italic "b"] + parseMaybeMarkdownList "*a\nb" `shouldBe` Nothing + it "white space at the content ends: as on master, within the line" do + parseMaybeMarkdownList "*a\n*" `shouldBe` Nothing + parseMaybeMarkdownList "*a\nb *" `shouldBe` Nothing + parseMaybeMarkdownList "_ a\nb_" `shouldBe` Nothing + parseMaybeMarkdownList "**a\n**" `shouldBe` Nothing + "5 * 3\n*b*" ==>> ["5 * 3\n", fmt boldF "b"] + it "links, mentions and names within one line" do + parseMaybeMarkdownList "[a\nb](https://simplex.chat)" `shouldBe` Nothing + parseMaybeMarkdownList "@'a\nb'" `shouldBe` Nothing + "https://simplex.chat\nb" ==>> [uri' "https://simplex.chat", "\nb"] + "@alice\nb" ==>> [fmt (Mention "alice") "@alice", "\nb"] + where + quoted = [quoteMark "> ", fmt boldF "a", softBr, quoteMark "> ", fmt boldF "b"] + +liveMessages :: Spec +liveMessages = describe "incomplete syntax of live messages" do + it "a code block once a line follows the fence" do + noBlocks "```" + noBlocks "```js" + "```\nls" <===> ([fence "```", hardBr, "ls"], [CodeBlock Nothing (rng 2 3)]) + "``` a ```" ==>> [fmt Snippet "a"] + it "a rule until another character is added" do + "---" <===> ([mk RuleMark "---"], [Rule (rng 0 1)]) + noBlocks "---x" + it "a table once the delimiter row has a cell" do + noBlocks "| a |" + noBlocks "| a |\n|" + "| a |\n|-" <===> ([mk RowMark "| ", "a", mk CellMark " |", hardBr, mk (DelimiterRow [TANone]) "|-"], [Table [TANone] [rng 1 2] []]) + noBlocks "| a |\n|---|x" + it "block marks before their text" do + noBlocks "-" + noBlocks "1." + noBlocks ">" + "# a" <===> ([heading1, "a"], [Heading 1 (rng 1 2)]) + "- a" <===> ([bullet "- ", "a"], [List [entry 1 2 []]]) + "1. a" <===> ([numbered "1.", "a"], [List [ListEntry (Just "1.") (rng 1 2) []]]) + "> a" <===> ([quoteMark "> ", "a"], [Quote (rng 1 2)]) + +linearTimeParsing :: Spec +linearTimeParsing = describe "bounded scans for linear-time parsing" do + it "phone numbers: at most 22 characters, as on master" do + "1 1 1 1 1 1 1 1 1 1 1 1" <==> "1 " <> phone "1 1 1 1 1 1 1 1 1 1 1" + it "link texts stop at \"[\", link targets and autolinks at white space" do + "[a [b](https://simplex.chat)" <==> "[a " <> web "b" "https://simplex.chat" "[b](https://simplex.chat)" + "[a](https://simplex.chat x)" <==> "[a](https://simplex.chat x)" + "" <==> "" + it "a text with more than 2000 markup tokens has no blocks" do + let ParsedMarkdown _ blocks = parsedMarkdown $ T.replicate 1000 "- a\n" + length <$> blocks `shouldBe` Just 1 + noBlocks $ T.replicate 1001 "- a\n" + length . filter (\(FormattedText f _) -> f == Just boldF) <$> parseMaybeMarkdownList (T.replicate 1001 "- *a*\n") `shouldBe` Just 1001 + it "large adversarial texts" do + forM_ (["1 ", "[ ", "[a]( ", "< ", "a\n", ">\n", "- a\n", "|\n", "|-|\n", "*a\n"] :: [Text]) $ \p -> + length (show $ parseMaybeMarkdownList $ "|a|\n|-|\n" <> T.replicate 4000 p) `shouldSatisfy` (> 0) + +markdownConsumers :: Spec +markdownConsumers = describe "consumers of parsed Markdown" do + it "mentionedNames: mentions in blocks, not in code" do + mentions "- @alice" `shouldBe` ["alice"] + mentions "> @alice" `shouldBe` ["alice"] + mentions "# @alice" `shouldBe` ["alice"] + mentions "| @alice |\n|---|" `shouldBe` ["alice"] + mentions "```\n@alice\n```" `shouldBe` [] + mentions "`@alice`" `shouldBe` [] + it "hasLinks: links in blocks, not in code" do + links "- https://simplex.chat" `shouldBe` True + links "| https://simplex.chat |\n|---|" `shouldBe` True + links "```\nhttps://simplex.chat\n```" `shouldBe` False + it "SimpleX links in blocks and in code" do + simplexLinks ("- " <> addr) `shouldBe` True + simplexLinks ("| " <> addr <> " |\n|---|") `shouldBe` True + simplexLinks ("```\n" <> addr <> "\n```") `shouldBe` False + hasObfuscatedSimplexLink ("```\n" <> addr <> "\n```") `shouldBe` True + textWithoutSimplexLinks ("- check [this link](" <> addr <> ") out") `shouldBe` "- check out" + it "markdownText of markup tokens is their text" do + forM_ ([LineBreak, SoftBreak, HardBreak, ParagraphBreak 2, Indent, HeadingMark 1, HeadingClose, HeadingUnderline 1, ListMark Nothing, QuoteMark, FenceMark Nothing, RowMark, CellMark, DelimiterRow [TANone], RuleMark] :: [BlockToken]) $ \tkn -> + markdownText (mk tkn "x") `shouldBe` "x" + markdownText (fmt Escape "\\") `shouldBe` "\\" + where + parsed = fromMaybe [] . parseMaybeMarkdownList + mentions = mentionedNames . parsed + links = hasLinks . parsed + isSimplexLinkFormat (FormattedText f _) = maybe False isSimplexLink f + simplexLinks = any isSimplexLinkFormat . parsed + textWithoutSimplexLinks = T.concat . map (\(FormattedText _ t) -> t) . filter (not . isSimplexLinkFormat) . parsed + addr = "https://smp6.simplex.im/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw" testSanitizeUri :: Spec testSanitizeUri = describe "sanitizeUri" $ do diff --git a/website/src/directory.html b/website/src/directory.html index 3505cccc4b..41dea04862 100644 --- a/website/src/directory.html +++ b/website/src/directory.html @@ -123,6 +123,57 @@ active_directory: true color: magenta; } + #directory .entry { + --md-card: rgba(139, 135, 134, 0.12); + --md-line: rgba(139, 135, 134, 0.35); + --md-bar: rgba(0, 0, 0, 0.16); + --md-stripe: rgba(139, 135, 134, 0.08); + --md-link: #0053D0; + } + + .dark #directory .entry { + --md-card: rgba(255, 255, 255, 0.08); + --md-line: rgba(139, 135, 134, 0.45); + --md-bar: rgba(255, 255, 255, 0.22); + --md-stripe: rgba(139, 135, 134, 0.12); + --md-link: #70F0F9; + } + + #directory .entry .text-container { min-width: 0; } + #directory .entry .md-gap { height: 0.7em; } + #directory .entry .md-h1 { font-size: 1.5em; line-height: 1.3; font-weight: 700; } + #directory .entry .md-h2 { font-size: 1.3em; line-height: 1.3; font-weight: 700; } + #directory .entry .md-h3 { font-size: 1.15em; line-height: 1.3; font-weight: 700; } + #directory .entry .md-h4 { font-weight: 700; } + #directory .entry .md-entry { display: flex; gap: 6px; } + #directory .entry .md-mark { flex: none; min-width: 1.1em; } + #directory .entry .md-text { flex: 1; min-width: 0; } + #directory .entry .md-quote { display: flex; gap: 10px; } + #directory .entry .md-bar { flex: none; width: 4px; border-radius: 2px; background: var(--md-bar); } + #directory .entry .md-quote > .md-text { opacity: 0.7; } + #directory .entry .md-code { position: relative; background: var(--md-card); border-radius: 4px; overflow: hidden; } + #directory .entry .md-code pre { margin: 0; padding: 8px 12px; overflow-x: auto; white-space: pre; font-family: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 0.875em; line-height: 1.45; } + #directory .entry .md-copy { position: absolute; top: 4px; right: 4px; width: 28px; height: 28px; padding: 0; display: grid; place-items: center; border: 0; border-radius: 4px; background: var(--md-card); color: var(--md-link); cursor: pointer; opacity: 0; pointer-events: none; transition: opacity 0.15s; } + #directory .entry .md-code:hover .md-copy, + #directory .entry .md-code.tapped .md-copy, + #directory .entry .md-copy:focus-visible { opacity: 1; pointer-events: auto; } + #directory .entry .md-copy svg { width: 18px; height: 18px; } + #directory .entry .md-icon-done, + #directory .entry .md-copy.copied .md-icon-copy { display: none; } + #directory .entry .md-copy.copied .md-icon-done { display: block; } + #directory .entry .md-table { overflow-x: auto; } + #directory .entry .md-frame { display: inline-block; vertical-align: top; border: 1px solid var(--md-line); border-radius: 4px; overflow: hidden; } + #directory .entry .md-frame table { border-collapse: collapse; font-variant-numeric: tabular-nums; } + #directory .entry .md-frame th, + #directory .entry .md-frame td { padding: 4px 12px; white-space: nowrap; border-right: 1px solid var(--md-line); border-bottom: 1px solid var(--md-line); } + #directory .entry .md-frame th:last-child, + #directory .entry .md-frame td:last-child { border-right: 0; } + #directory .entry .md-frame tr:last-child th, + #directory .entry .md-frame tr:last-child td { border-bottom: 0; } + #directory .entry .md-frame th { font-weight: 700; background: var(--md-card); } + #directory .entry .md-frame tr:nth-child(odd) td { background: var(--md-stripe); } + #directory .entry .md-rule { border: 0; border-top: 1px solid var(--md-line); margin: 6px 0; } + .pagination { display: flex; justify-content: center; diff --git a/website/src/js/channel-preview.jsc b/website/src/js/channel-preview.jsc index b0a1696010..d754d36b9c 100644 --- a/website/src/js/channel-preview.jsc +++ b/website/src/js/channel-preview.jsc @@ -23,6 +23,9 @@ const STYLE = ` --sp-color-white: #000; --sp-qr-fg: #062D56; --sp-qr-bg: #ffffff; + --sp-md-line: rgba(139, 135, 134, 0.35); + --sp-md-bar: rgba(0, 0, 0, 0.16); + --sp-md-stripe: rgba(139, 135, 134, 0.08); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 15px; line-height: 1.4; @@ -58,6 +61,9 @@ const STYLE = ` --sp-color-white: #fff; --sp-qr-fg: #FFFBFA; --sp-qr-bg: transparent; + --sp-md-line: rgba(139, 135, 134, 0.45); + --sp-md-bar: rgba(255, 255, 255, 0.22); + --sp-md-stripe: rgba(139, 135, 134, 0.12); } .simplex-preview-header { @@ -497,6 +503,44 @@ const STYLE = ` .simplex-preview-text .black { color: var(--sp-color-black); } .simplex-preview-text .white { color: var(--sp-color-white); } +.simplex-preview-msg-col { + min-width: 0; +} + +.simplex-preview-text .md-gap { height: 0.7em; } +.simplex-preview-text .md-h1 { font-size: 1.5em; line-height: 1.3; font-weight: 700; } +.simplex-preview-text .md-h2 { font-size: 1.3em; line-height: 1.3; font-weight: 700; } +.simplex-preview-text .md-h3 { font-size: 1.15em; line-height: 1.3; font-weight: 700; } +.simplex-preview-text .md-h4 { font-weight: 700; } +.simplex-preview-text .md-entry { display: flex; gap: 6px; } +.simplex-preview-text .md-mark { flex: none; min-width: 1.1em; } +.simplex-preview-text .md-text { flex: 1; min-width: 0; } +.simplex-preview-text .md-quote { display: flex; gap: 10px; } +.simplex-preview-text .md-bar { flex: none; width: 4px; border-radius: 2px; background: var(--sp-md-bar); } +.simplex-preview-text .md-quote > .md-text { opacity: 0.7; } +.simplex-preview-text .md-code { position: relative; background: var(--sp-quote); border-radius: 10px; overflow: hidden; } +.simplex-preview-text .md-code pre { margin: 0; padding: 8px 12px; overflow-x: auto; white-space: pre; font-family: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 0.875em; line-height: 1.45; } +.simplex-preview-text .md-copy { position: absolute; top: 4px; right: 4px; width: 28px; height: 28px; padding: 0; display: grid; place-items: center; border: 0; border-radius: 7px; background: var(--sp-quote); color: var(--sp-link); cursor: pointer; opacity: 0; pointer-events: none; transition: opacity 0.15s; } +.simplex-preview-text .md-code:hover .md-copy, +.simplex-preview-text .md-code.tapped .md-copy, +.simplex-preview-text .md-copy:focus-visible { opacity: 1; pointer-events: auto; } +.simplex-preview-text .md-copy svg { width: 18px; height: 18px; } +.simplex-preview-text .md-icon-done, +.simplex-preview-text .md-copy.copied .md-icon-copy { display: none; } +.simplex-preview-text .md-copy.copied .md-icon-done { display: block; } +.simplex-preview-text .md-table { overflow-x: auto; } +.simplex-preview-text .md-frame { display: inline-block; vertical-align: top; border: 1px solid var(--sp-md-line); border-radius: 10px; overflow: hidden; } +.simplex-preview-text .md-frame table { border-collapse: collapse; font-variant-numeric: tabular-nums; } +.simplex-preview-text .md-frame th, +.simplex-preview-text .md-frame td { padding: 4px 12px; white-space: nowrap; border-right: 1px solid var(--sp-md-line); border-bottom: 1px solid var(--sp-md-line); } +.simplex-preview-text .md-frame th:last-child, +.simplex-preview-text .md-frame td:last-child { border-right: 0; } +.simplex-preview-text .md-frame tr:last-child th, +.simplex-preview-text .md-frame tr:last-child td { border-bottom: 0; } +.simplex-preview-text .md-frame th { font-weight: 700; background: var(--sp-quote); } +.simplex-preview-text .md-frame tr:nth-child(odd) td { background: var(--sp-md-stripe); } +.simplex-preview-text .md-rule { border: 0; border-top: 1px solid var(--sp-md-line); margin: 6px 0; } + .simplex-preview-main { flex: 1; min-width: 0; @@ -885,6 +929,7 @@ function render(container, data, channelLink, showAppBadges) { ts: messages.length > 0 ? messages[0].ts : new Date().toISOString(), content: { type: 'text', text: typeof welcome === 'string' ? welcome : '' }, formattedText: Array.isArray(welcome) ? welcome : null, + markdownBlocks: data.welcomeMessageBlocks, reactions: [] }; allMessages = [welcomeMsg].concat(messages); @@ -987,7 +1032,7 @@ function renderInfoContent(container, data, channelLink, subscriberCount, showAp if (shortDescr) { const descrDiv = document.createElement('div'); descrDiv.className = 'simplex-preview-info-descr'; - descrDiv.innerHTML = Array.isArray(shortDescr) ? renderMarkdown(shortDescr) : escapeHtml(shortDescr); + descrDiv.innerHTML = Array.isArray(shortDescr) ? renderMarkdown(shortDescr, data.shortDescriptionBlocks) : escapeHtml(shortDescr); container.appendChild(descrDiv); } @@ -1068,25 +1113,6 @@ function qrColor(value, fallback) { return value === 'transparent' ? '#0000' : value; } -// navigator.clipboard is undefined outside secure contexts; fall back to execCommand -function copyToClipboard(text) { - if (navigator.clipboard && navigator.clipboard.writeText) { - return navigator.clipboard.writeText(text); - } - return new Promise(function(resolve, reject) { - const ta = document.createElement('textarea'); - ta.value = text; - ta.style.position = 'fixed'; - ta.style.top = '-9999px'; - document.body.appendChild(ta); - ta.select(); - let ok = false; - try { ok = document.execCommand('copy'); } catch (e) {} - document.body.removeChild(ta); - ok ? resolve() : reject(new Error('copy failed')); - }); -} - function renderDesktopConversion(container, channelLink, showAppBadges) { if (showAppBadges) { renderAppBadges(container); @@ -1190,6 +1216,7 @@ function setupSecretToggles(container) { container.addEventListener('click', (e) => { const secret = e.target.closest('.secret'); if (secret) secret.classList.toggle('visible'); + handleCodeClick(e); }); } @@ -1246,6 +1273,7 @@ function renderMessages(container, messages, membersMap, channel) { } const col = document.createElement('div'); + col.className = 'simplex-preview-msg-col'; const bubble = renderBubble(msg, member, showTail, membersMap, channel); col.appendChild(bubble); @@ -1519,7 +1547,7 @@ function appendTextBlock(bubble, msg) { textDiv.className = 'simplex-preview-text'; const meta = renderMetaHTML(msg); if (msg.formattedText && msg.formattedText.length > 0) { - textDiv.innerHTML = renderMarkdown(msg.formattedText) + meta; + textDiv.innerHTML = renderMarkdown(msg.formattedText, msg.markdownBlocks) + meta; } else { textDiv.innerHTML = escapeHtml(msg.content.text || '') + meta; } diff --git a/website/src/js/directory.jsc b/website/src/js/directory.jsc index 5ca3b7fbcf..dd07735260 100644 --- a/website/src/js/directory.jsc +++ b/website/src/js/directory.jsc @@ -32,6 +32,7 @@ async function initDirectory() { liveBtn.addEventListener('click', () => renderEntries('live', byActiveAtDesc, liveBtn)); newBtn.addEventListener('click', () => renderEntries('new', byCreatedAtDesc, newBtn)); topBtn.addEventListener('click', () => renderEntries('top', bySortPriority, topBtn)); + document.getElementById('directory').addEventListener('click', handleCodeClick); window.addEventListener('popstate', applyHash); function applyHash() { @@ -229,7 +230,7 @@ function displayEntries(entries) { for (let entry of entries) { try { - const { entryType, displayName, simplexName, groupLink, shortDescr, welcomeMessage, imageFile } = entry; + const { entryType, displayName, simplexName, groupLink, shortDescr, shortDescrBlocks, welcomeMessage, welcomeMessageBlocks, imageFile } = entry; const entryDiv = document.createElement('div'); entryDiv.className = 'entry w-full flex flex-col items-start md:flex-row rounded-[4px] overflow-hidden shadow-[0px_20px_30px_rgba(0,0,0,0.12)] dark:shadow-none bg-white dark:bg-[#0B2A59] mb-8'; @@ -248,11 +249,11 @@ function displayEntries(entries) { textContainer.appendChild(simplexNameElement); } - const welcomeMessageHTML = welcomeMessage ? renderMarkdown(welcomeMessage) : undefined; - const shortDescrHTML = shortDescr ? renderMarkdown(shortDescr) : undefined; + const welcomeMessageHTML = welcomeMessage ? renderMarkdown(welcomeMessage, welcomeMessageBlocks) : undefined; + const shortDescrHTML = shortDescr ? renderMarkdown(shortDescr, shortDescrBlocks) : undefined; if (shortDescrHTML && welcomeMessageHTML?.includes(shortDescrHTML) !== true) { const descrElement = document.createElement('p'); - descrElement.innerHTML = renderMarkdown(shortDescr); + descrElement.innerHTML = shortDescrHTML; textContainer.appendChild(descrElement); } diff --git a/website/src/js/simplex-lib.jsc b/website/src/js/simplex-lib.jsc index d05dd6a931..73e304f9dd 100644 --- a/website/src/js/simplex-lib.jsc +++ b/website/src/js/simplex-lib.jsc @@ -54,7 +54,157 @@ function targetBlank(uri) { return isCurrentSite(uri) ? '' : ' target="_blank" rel="noopener"' } -function renderMarkdown(fts) { +const MAX_LIST_DEPTH = 7; + +const MAX_TABLE_CELLS = 1000; + +const COPY_BUTTON = + ''; + +// navigator.clipboard is undefined outside secure contexts; fall back to execCommand +function copyToClipboard(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + return navigator.clipboard.writeText(text); + } + return new Promise(function(resolve, reject) { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.top = '-9999px'; + document.body.appendChild(ta); + ta.select(); + let ok = false; + try { ok = document.execCommand('copy'); } catch (e) {} + document.body.removeChild(ta); + ok ? resolve() : reject(new Error('copy failed')); + }); +} + +function handleCodeClick(e) { + const card = e.target.closest('.md-code'); + if (!card) return; + const button = e.target.closest('.md-copy'); + if (!button) { + card.classList.toggle('tapped'); + return; + } + const pre = card.querySelector('pre'); + if (!pre) return; + copyToClipboard(pre.innerText).then(() => { + button.classList.add('copied'); + setTimeout(() => button.classList.remove('copied'), 1500); + }).catch(() => {}); +} + +function validBlocks(fts, blocks) { + let pos = 0; + const range = (r) => { + const valid = !!r && Number.isInteger(r.from) && Number.isInteger(r.to) && pos <= r.from && r.from <= r.to && r.to <= fts.length; + if (valid) pos = r.to; + return valid; + }; + const entries = (items, depth) => + Array.isArray(items) && (items.length === 0 || depth <= MAX_LIST_DEPTH) && items.every((e) => !!e && range(e.content) && entries(e.items, depth + 1)); + const rows = (rs) => Array.isArray(rs) && rs.every((row) => Array.isArray(row) && row.every(range)); + return Array.isArray(blocks) && blocks.every((b) => { + switch (b && b.type) { + case 'paragraph': + case 'heading': + case 'codeBlock': + case 'quote': + return range(b.content); + case 'list': + return entries(b.items, 1); + case 'table': + return Array.isArray(b.columns) && rows([b.header]) && rows(b.rows); + case 'rule': + return range(b.mark); + default: + return false; + } + }); +} + +function renderBlocks(fts, blocks) { + let html = ''; + let pos = 0; + const segments = (r) => renderSegments(fts.slice(r.from, r.to), true); + const isParagraphBreak = (ft) => ft.format && ft.format.type === 'markup' && ft.format.token && ft.format.token.type === 'paragraphBreak'; + const gap = (to) => { + if (html && fts.slice(pos, to).some(isParagraphBreak)) html += '

'; + pos = Math.max(pos, to); + }; + const block = (r, open, close) => { + gap(r.from); + html += open + segments(r) + close; + pos = r.to; + }; + const entry = (e, level) => { + html += `
${escapeHtml(String(e.number ?? '•'))}${segments(e.content)}
`; + pos = e.content.to; + e.items.forEach((item) => entry(item, level + 1)); + }; + const table = (b) => { + const rows = [b.header].concat(b.rows); + const width = b.columns.length * rows.length <= MAX_TABLE_CELLS ? b.columns.length : 0; + const align = (c) => (b.columns[c] === 'center' || b.columns[c] === 'right' ? b.columns[c] : 'left'); + const cell = (tag, c, r) => `<${tag} style="text-align: ${align(c)};">${r ? segments(r) : ''}`; + const row = (cells, tag) => + '
' + row(b.header, 'th') + b.rows.map((r) => row(r, 'td')).join('') + '
'; + const last = rows[rows.length - 1]; + pos = last.length > 0 ? last[last.length - 1].to : pos; + }; + for (const b of blocks) { + switch (b.type) { + case 'paragraph': + block(b.content, '
', '
'); + break; + case 'heading': + block(b.content, `
`, '
'); + break; + case 'codeBlock': + block(b.content, '
', '
' + COPY_BUTTON + '
'); + break; + case 'quote': + block(b.content, '
', '
'); + break; + case 'list': + if (b.items.length > 0) gap(b.items[0].content.from); + b.items.forEach((item) => entry(item, 0)); + break; + case 'table': + table(b); + break; + case 'rule': + block(b.mark, '
', ''); + break; + } + } + return html; +} + +function markupHtml(token) { + switch (token && token.type) { + case 'softBreak': + return ' '; + case 'lineBreak': + case 'hardBreak': + return '
'; + default: + return ''; + } +} + +function renderMarkdown(fts, blocks) { + return validBlocks(fts, blocks) ? renderBlocks(fts, blocks) : renderSegments(fts, false); +} + +function renderSegments(fts, withBlocks) { let html = ''; for (const ft of fts) { const { format, text } = ft; @@ -65,7 +215,7 @@ function renderMarkdown(fts) { try { switch (format.type) { case 'bold': - html += `${escapeHtml(text)}`; + html += withBlocks && format.singleStar ? `${escapeHtml(text)}` : `${escapeHtml(text)}`; break; case 'italic': html += `${escapeHtml(text)}`; @@ -113,6 +263,11 @@ function renderMarkdown(fts) { case 'phone': html += `${escapeHtml(text)}`; break; + case 'escape': + break; + case 'markup': + html += withBlocks ? markupHtml(format.token) : escapeHtml(text); + break; case 'unknown': html += escapeHtml(text); break; From a766d0af0f4048ecec96ab367461f80e38f7586f Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 19 Oct 2026 07:50:09 +0100 Subject: [PATCH 04/15] ci: package releases with drafts in simplex-chat-libs (#7754) * ci: package releases with drafts in simplex-chat-libs * test release * core: 7.1.0.12 * packages: release nodejs and python libraries * fixes --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- .github/workflows/build.yml | 438 +++++++++++--- .github/workflows/packages.yml | 306 +--------- docs/contributing/RELEASES.md | 153 +++++ .../types/typescript/README.md | 2 + .../types/typescript/package.json | 3 +- packages/simplex-chat-nodejs/package.json | 4 +- packages/simplex-chat-nodejs/src/release.ts | 14 +- packages/simplex-chat-python/README.md | 9 - .../src/simplex_chat/_version.py | 18 +- plans/2026-10-18-package-releases.md | 364 ++++++++++++ scripts/ci/package-tests.sh | 81 +++ scripts/ci/packages_release.py | 548 ++++++++++++++---- scripts/ci/test_packages_release.py | 414 +++++++++++-- simplex-chat.cabal | 2 +- 14 files changed, 1797 insertions(+), 559 deletions(-) create mode 100644 docs/contributing/RELEASES.md create mode 100644 plans/2026-10-18-package-releases.md create mode 100755 scripts/ci/package-tests.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index be1784a237..074a6feb48 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -33,6 +33,7 @@ env: release: ${{ startsWith(github.ref, 'refs/tags/v') }} branch: ${{ github.base_ref || github.ref_name }} cache_o0: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/stable') }} + release_branch: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/stable') && github.ref_name || '' }} ZSTD_CLEVEL: 9 # This workflow uses custom actions (prepare-build and prepare-release) defined in: @@ -74,7 +75,8 @@ jobs: # ============================= # Create release, but only if it's triggered by tag push. -# Create simplex-chat-libs release core-, but only if the head commit of a push to master or stable changes the version in simplex-chat.cabal. +# On pushes to master or stable: claim the simplex-chat-libs release core- for the branch +# and create its draft, create the draft of a missing add-on release, and find the package versions to publish. # On pull requests and other pushes, this job will always complete. # Secrets: @@ -87,8 +89,15 @@ jobs: maybe-release: runs-on: ubuntu-latest outputs: - libs_tag: ${{ steps.libs_commit.outputs.tag }} + libs_tag: ${{ steps.libs.outputs.tag }} core_changed: ${{ steps.core.outputs.core }} + addon_tag: ${{ steps.addon.outputs.tag }} + addon_draft: ${{ steps.addon.outputs.draft }} + libs_latest: ${{ steps.latest_libs.outputs.tag }} + publish_types: ${{ steps.libs.outputs.tag && steps.publish.outputs.types_version || '' }} + publish_nodejs: ${{ steps.publish.outputs.nodejs_version }} + publish_python: ${{ steps.publish.outputs.python_version }} + publish: ${{ steps.publish.outputs.nodejs_version || steps.publish.outputs.python_version }} steps: - name: Clone project if: github.event_name == 'push' || github.event_name == 'pull_request' @@ -129,38 +138,68 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Check version change - id: check_version - if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/stable' + - name: Library release + id: libs + if: env.release_branch shell: bash - run: | - version=$(sed -n 's/^version: *//p' simplex-chat.cabal) - prev_version=$(git show HEAD^1:simplex-chat.cabal | sed -n 's/^version: *//p') - if [ "$version" != "$prev_version" ]; then - echo "version=$version" >> $GITHUB_OUTPUT - fi - - - name: Record libs release - id: libs_commit - if: steps.check_version.outputs.version env: GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} LIBS_REPOSITORY: ${{ github.repository }}-libs - VERSION: ${{ steps.check_version.outputs.version }} run: | - commit=$(python3 scripts/ci/packages_release.py record --core "$VERSION" --commit "$GITHUB_SHA") - echo "tag=core-$VERSION" >> "$GITHUB_OUTPUT" - echo "commit=$commit" >> "$GITHUB_OUTPUT" + version=$(sed -n 's/^version: *//p' simplex-chat.cabal) + tag=core-$version + claim_output=$(python3 scripts/ci/packages_release.py claim-core "$version" "$GITHUB_SHA" "$release_branch") + eval "$claim_output" + if [ "$claim" = skip ]; then + echo "::notice title=Library release::core $version is recorded for another commit" + exit 0 + fi + status_output=$(python3 scripts/ci/packages_release.py release-status "$tag") + eval "$status_output" + if [ "$status" = absent ]; then + gh release create "$tag" --repo "$LIBS_REPOSITORY" --draft --title "$tag" --notes "" ${commit:+--target "$commit"} + fi + if [ "$status" != published ]; then + echo "tag=$tag" >> "$GITHUB_OUTPUT" + fi - # creates the tag on the commit that recorded the release - - name: Create libs release - if: steps.check_version.outputs.version - uses: softprops/action-gh-release@v2 - with: - repository: ${{ github.repository }}-libs - tag_name: ${{ steps.libs_commit.outputs.tag }} - target_commitish: ${{ steps.libs_commit.outputs.commit }} - token: ${{ secrets.NODEJS_REPO_TOKEN }} + - name: Add-on release + id: addon + if: env.release_branch + shell: bash + env: + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + run: | + addon_output=$(python3 scripts/ci/packages_release.py addon-release) + eval "$addon_output" + if [ "$status" = absent ]; then + tag=nodejs-addon-${GITHUB_SHA::7} + gh release create "$tag" --repo "$LIBS_REPOSITORY" --draft --title "$tag" --notes "$notes" + fi + if [ "$status" != published ]; then + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "draft=true" >> "$GITHUB_OUTPUT" + fi + + - name: Latest library + id: latest_libs + if: steps.addon.outputs.draft + env: + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + run: python3 scripts/ci/packages_release.py latest-libs --branch "$release_branch" >> "$GITHUB_OUTPUT" + + - name: Versions to publish + id: publish + if: env.release_branch + env: + GH_TOKEN: ${{ github.token }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + NPM_SCOPE: ${{ vars.NPM_SCOPE }} + PYPI_PROJECT: ${{ vars.PYPI_PROJECT }} + PYPI_URL: ${{ vars.PYPI_URL }} + run: python3 scripts/ci/packages_release.py publish-plan >> "$GITHUB_OUTPUT" # ========================= # Linux Build @@ -444,12 +483,11 @@ jobs: - name: Upload library if: needs.maybe-release.outputs.libs_tag && matrix.libs - uses: softprops/action-gh-release@v2 - with: - repository: ${{ github.repository }}-libs - tag_name: ${{ needs.maybe-release.outputs.libs_tag }} - files: release-libs/*.zip - token: ${{ secrets.NODEJS_REPO_TOKEN }} + shell: bash + env: + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + TAG: ${{ needs.maybe-release.outputs.libs_tag }} + run: gh release upload "$TAG" release-libs/*.zip --clobber --repo "${{ github.repository }}-libs" - name: Build CLI deb if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true @@ -591,7 +629,7 @@ jobs: - name: Setup Node.js id: setup-node - if: ${{ !cancelled() && needs.maybe-release.outputs.core_changed && steps.build-lib.outcome == 'success' }} + if: ${{ !cancelled() && ((needs.maybe-release.outputs.core_changed && steps.build-lib.outcome == 'success') || (matrix.libs && (needs.maybe-release.outputs.addon_draft || needs.maybe-release.outputs.publish))) }} uses: actions/setup-node@v7 with: node-version: 22 @@ -603,35 +641,42 @@ jobs: with: python-version: "3.12" - - name: Build simplex-chat (npm) and add-on - id: build-nodejs - if: ${{ !cancelled() && steps.setup-python.outcome == 'success' }} - shell: bash - working-directory: packages/simplex-chat-nodejs - run: | - (cd ../simplex-chat-client/types/typescript && npm install --no-audit --no-fund && npx tsc) - npm install --ignore-scripts --no-save --no-audit --no-fund ../simplex-chat-client/types/typescript - npx --yes node-gyp rebuild - npx tsc && cp src/simplex.* dist - - - name: Test simplex-chat (npm) - id: test-nodejs - if: ${{ !cancelled() && steps.build-nodejs.outcome == 'success' }} + - name: Package tests (pull request) + if: ${{ !cancelled() && steps.setup-python.outcome == 'success' && needs.maybe-release.outputs.core_changed }} timeout-minutes: 30 shell: bash - working-directory: packages/simplex-chat-nodejs env: SIMPLEX_LIBS_DIR: ${{ github.workspace }}/release-libs/simplex-chat-libs-linux-${{ matrix.arch }}/libs - run: npx jest --forceExit --testNamePattern '^(?!.*use preset servers)' + run: | + scripts/ci/package-tests.sh build linux-${{ matrix.arch }} + scripts/ci/package-tests.sh test - - name: Test simplex-chat (PyPI) - if: ${{ !cancelled() && steps.test-nodejs.outcome == 'success' }} + - name: Add-on + if: ${{ !cancelled() && steps.setup-python.outcome == 'success' && needs.maybe-release.outputs.addon_draft }} + timeout-minutes: 30 + shell: bash + env: + LIBS_RELEASE: ${{ needs.maybe-release.outputs.libs_latest }} + ADDON_TAG: ${{ needs.maybe-release.outputs.addon_tag }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + run: scripts/ci/package-tests.sh addon linux-${{ matrix.arch }} + + - name: Package tests + id: package-tests + if: ${{ !cancelled() && steps.setup-python.outcome == 'success' && needs.maybe-release.outputs.publish }} timeout-minutes: 30 shell: bash - working-directory: packages/simplex-chat-python run: | - python -m pip install -e ".[test]" - python -m pytest -q + scripts/ci/package-tests.sh publish linux-${{ matrix.arch }} ${{ needs.maybe-release.outputs.publish_nodejs && 'nodejs' || '' }} ${{ needs.maybe-release.outputs.publish_python && 'python' || '' }} + echo "linux-${{ matrix.arch }}" > "$RUNNER_TEMP/package-tests" + + - name: Package tests result + if: ${{ !cancelled() && steps.package-tests.outcome == 'success' }} + uses: actions/upload-artifact@v7 + with: + name: package-tests-linux-${{ matrix.arch }} + path: ${{ runner.temp }}/package-tests - name: Remove -O2 build directory id: remove-o2 @@ -821,12 +866,11 @@ jobs: (cd postgres-libs && zip -r simplex-chat-libs-linux-x86_64-postgres.zip libs) - name: Upload postgres library - uses: softprops/action-gh-release@v2 - with: - repository: ${{ github.repository }}-libs - tag_name: ${{ needs.maybe-release.outputs.libs_tag }} - files: postgres-libs/*.zip - token: ${{ secrets.NODEJS_REPO_TOKEN }} + shell: bash + env: + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + TAG: ${{ needs.maybe-release.outputs.libs_tag }} + run: gh release upload "$TAG" postgres-libs/*.zip --clobber --repo "${{ github.repository }}-libs" # ========================= # MacOS Build @@ -1084,12 +1128,52 @@ jobs: - name: Upload library if: ${{ !cancelled() && steps.mac_lib_build.outcome == 'success' }} - uses: softprops/action-gh-release@v2 + shell: bash + env: + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + TAG: ${{ needs.maybe-release.outputs.libs_tag }} + run: gh release upload "$TAG" release-libs/*.zip --clobber --repo "${{ github.repository }}-libs" + + - name: Setup Node.js + id: setup-node + if: ${{ !cancelled() && (needs.maybe-release.outputs.addon_draft || needs.maybe-release.outputs.publish) }} + uses: actions/setup-node@v7 with: - repository: ${{ github.repository }}-libs - tag_name: ${{ needs.maybe-release.outputs.libs_tag }} - files: release-libs/*.zip - token: ${{ secrets.NODEJS_REPO_TOKEN }} + node-version: 22 + + - name: Setup Python + id: setup-python + if: ${{ !cancelled() && steps.setup-node.outcome == 'success' }} + uses: actions/setup-python@v7 + with: + python-version: "3.12" + + - name: Add-on + if: ${{ !cancelled() && steps.setup-python.outcome == 'success' && needs.maybe-release.outputs.addon_draft }} + timeout-minutes: 30 + shell: bash + env: + LIBS_RELEASE: ${{ needs.maybe-release.outputs.libs_latest }} + ADDON_TAG: ${{ needs.maybe-release.outputs.addon_tag }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + run: scripts/ci/package-tests.sh addon macos-${{ matrix.arch }} + + - name: Package tests + id: package-tests + if: ${{ !cancelled() && steps.setup-python.outcome == 'success' && needs.maybe-release.outputs.publish }} + timeout-minutes: 30 + shell: bash + run: | + scripts/ci/package-tests.sh publish macos-${{ matrix.arch }} ${{ needs.maybe-release.outputs.publish_nodejs && 'nodejs' || '' }} ${{ needs.maybe-release.outputs.publish_python && 'python' || '' }} + echo "macos-${{ matrix.arch }}" > "$RUNNER_TEMP/package-tests" + + - name: Package tests result + if: ${{ !cancelled() && steps.package-tests.outcome == 'success' }} + uses: actions/upload-artifact@v7 + with: + name: package-tests-macos-${{ matrix.arch }} + path: ${{ runner.temp }}/package-tests - name: Remove -O2 build directory id: remove-o2 @@ -1305,12 +1389,52 @@ jobs: - name: Upload library if: needs.maybe-release.outputs.libs_tag - uses: softprops/action-gh-release@v2 + shell: bash + env: + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + TAG: ${{ needs.maybe-release.outputs.libs_tag }} + run: gh release upload "$TAG" release-libs/*.zip --clobber --repo "${{ github.repository }}-libs" + + - name: Setup Node.js + id: setup-node + if: ${{ !cancelled() && (needs.maybe-release.outputs.addon_draft || needs.maybe-release.outputs.publish) }} + uses: actions/setup-node@v7 with: - repository: ${{ github.repository }}-libs - tag_name: ${{ needs.maybe-release.outputs.libs_tag }} - files: release-libs/*.zip - token: ${{ secrets.NODEJS_REPO_TOKEN }} + node-version: 22 + + - name: Setup Python + id: setup-python + if: ${{ !cancelled() && steps.setup-node.outcome == 'success' }} + uses: actions/setup-python@v7 + with: + python-version: "3.12" + + - name: Add-on + if: ${{ !cancelled() && steps.setup-python.outcome == 'success' && needs.maybe-release.outputs.addon_draft }} + timeout-minutes: 30 + shell: bash + env: + LIBS_RELEASE: ${{ needs.maybe-release.outputs.libs_latest }} + ADDON_TAG: ${{ needs.maybe-release.outputs.addon_tag }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + run: scripts/ci/package-tests.sh addon windows-x86_64 + + - name: Package tests + id: package-tests + if: ${{ !cancelled() && steps.setup-python.outcome == 'success' && needs.maybe-release.outputs.publish }} + timeout-minutes: 30 + shell: bash + run: | + scripts/ci/package-tests.sh publish windows-x86_64 ${{ needs.maybe-release.outputs.publish_nodejs && 'nodejs' || '' }} ${{ needs.maybe-release.outputs.publish_python && 'python' || '' }} + echo "windows-x86_64" > "$RUNNER_TEMP/package-tests" + + - name: Package tests result + if: ${{ !cancelled() && steps.package-tests.outcome == 'success' }} + uses: actions/upload-artifact@v7 + with: + name: package-tests-windows-x86_64 + path: ${{ runner.temp }}/package-tests - name: Upload CLI if: startsWith(github.ref, 'refs/tags/v') @@ -1387,19 +1511,169 @@ jobs: key: ${{ steps.keys.outputs.dist }}-O0-${{ env.branch }}-${{ github.sha }} # ========================= -# Packages release +# Releases and packages # ========================= -# A new library release is tested with the Node.js and Python packages and updates them in a release PR. - - packages: + releases: needs: [maybe-release, build-linux, build-linux-push, build-linux-postgres, build-macos, build-windows] - if: ${{ !cancelled() && needs.maybe-release.outputs.libs_tag }} - # the called workflow can't be granted more than its caller, and its publish job needs these + if: ${{ !cancelled() && (needs.maybe-release.outputs.libs_tag || needs.maybe-release.outputs.addon_draft) }} + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + steps: + - name: Checkout Code + uses: actions/checkout@v6 + + - name: Publish library release + if: needs.maybe-release.outputs.libs_tag + run: python3 scripts/ci/packages_release.py publish-release "${{ needs.maybe-release.outputs.libs_tag }}" + + - name: Publish add-on release + if: ${{ !cancelled() && needs.maybe-release.outputs.addon_draft }} + run: python3 scripts/ci/packages_release.py publish-release "${{ needs.maybe-release.outputs.addon_tag }}" + +# Publishing uses trusted publishing (OIDC) for this workflow on npm and PyPI, +# or the NPM_TOKEN and PYPI_API_TOKEN secrets when they are set. +# Test repositories: variables NPM_SCOPE, PYPI_PROJECT and PYPI_URL / PYPI_UPLOAD_URL (TestPyPI) +# publish under other names. + + publish: + needs: [maybe-release, build-linux, build-linux-push, build-macos, build-windows, releases] + if: ${{ !cancelled() && (needs.maybe-release.outputs.publish || needs.maybe-release.outputs.publish_types) }} + runs-on: ubuntu-latest permissions: contents: read id-token: write - uses: ./.github/workflows/packages.yml - with: - libs_release: ${{ needs.maybe-release.outputs.libs_tag }} - secrets: inherit + # set by a publish step after it succeeds, so a failed publish still reports the earlier ones + outputs: + types: ${{ steps.types.outputs.version }} + nodejs: ${{ steps.nodejs.outputs.version }} + python: ${{ steps.python.outputs.version }} + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_SCOPE: ${{ vars.NPM_SCOPE }} + PYPI_PROJECT: ${{ vars.PYPI_PROJECT }} + PYPI_URL: ${{ vars.PYPI_URL }} + steps: + - name: Checkout Code + uses: actions/checkout@v6 + + - name: Download package tests results + if: needs.maybe-release.outputs.publish + uses: actions/download-artifact@v8 + with: + pattern: package-tests-* + path: ${{ runner.temp }}/package-tests + + - name: Check package tests and pins + if: needs.maybe-release.outputs.publish + env: + GH_TOKEN: ${{ github.token }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + run: | + passed=$(find "$RUNNER_TEMP/package-tests" -mindepth 1 -maxdepth 1 -type d -name 'package-tests-*' | wc -l) + if [ "$passed" -ne 5 ]; then + echo "::error::package tests passed on $passed of 5 platforms" + exit 1 + fi + python3 scripts/ci/packages_release.py check-pins + + - name: Versions to publish + id: plan + env: + GH_TOKEN: ${{ github.token }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + run: python3 scripts/ci/packages_release.py publish-plan >> "$GITHUB_OUTPUT" + + - name: Setup Node.js + if: steps.plan.outputs.types_version || steps.plan.outputs.nodejs_version + uses: actions/setup-node@v7 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + + - name: Setup Python + if: steps.plan.outputs.python_version + uses: actions/setup-python@v7 + with: + python-version: "3.12" + + # Before the test names: the build installs @simplex-chat/types from this repository. + - name: Build and test simplex-chat (npm) + if: steps.plan.outputs.nodejs_version + working-directory: packages/simplex-chat-nodejs + run: | + (cd ../simplex-chat-client/types/typescript && npm install --no-audit --no-fund && npx tsc) + npm install --ignore-scripts --no-save --no-audit --no-fund ../simplex-chat-client/types/typescript + npx tsc && cp src/simplex.* dist + npx jest tests/libs.test.ts tests/api.unit.test.ts + + - name: Use test package names + if: vars.NPM_SCOPE || vars.PYPI_PROJECT + run: python3 scripts/ci/packages_release.py test-names + + - name: Publish @simplex-chat/types + id: types + if: steps.plan.outputs.types_version && needs.maybe-release.outputs.publish_types && needs.releases.result == 'success' + working-directory: packages/simplex-chat-client/types/typescript + env: + VERSION: ${{ steps.plan.outputs.types_version }} + DIST_TAG: ${{ steps.plan.outputs.types_dist_tag }} + run: | + npm install --no-audit --no-fund + npm publish --provenance --access public --tag "$DIST_TAG" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Publish simplex-chat (npm) + id: nodejs + if: steps.plan.outputs.nodejs_version + working-directory: packages/simplex-chat-nodejs + env: + VERSION: ${{ steps.plan.outputs.nodejs_version }} + DIST_TAG: ${{ steps.plan.outputs.nodejs_dist_tag }} + run: | + npm publish --provenance --access public --tag "$DIST_TAG" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Build and test simplex-chat (PyPI) + if: steps.plan.outputs.python_version + working-directory: packages/simplex-chat-python + run: | + python -m pip install build -e ".[test]" + python -m pytest -q + python -m build + + - name: Publish simplex-chat (PyPI) + if: steps.plan.outputs.python_version + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: packages/simplex-chat-python/dist + repository-url: ${{ vars.PYPI_UPLOAD_URL || 'https://upload.pypi.org/legacy/' }} + password: ${{ secrets.PYPI_API_TOKEN }} + + - name: Report the published PyPI version + id: python + if: steps.plan.outputs.python_version + env: + VERSION: ${{ steps.plan.outputs.python_version }} + run: echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + record: + needs: publish + if: ${{ !cancelled() && (needs.publish.outputs.types || needs.publish.outputs.nodejs || needs.publish.outputs.python) }} + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v6 + + - name: Record published versions + env: + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + TYPES: ${{ needs.publish.outputs.types }} + NODEJS: ${{ needs.publish.outputs.nodejs }} + PYTHON: ${{ needs.publish.outputs.python }} + run: | + python3 scripts/ci/packages_release.py record --commit "$GITHUB_SHA" \ + --types "$TYPES" --nodejs "$NODEJS" --python "$PYTHON" diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index 2e58fb4d34..e5ed17c4f8 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -1,60 +1,6 @@ name: packages -# Releases simplex-chat (npm, PyPI) and @simplex-chat/types (npm) from master and stable: -# 1. A missing Node.js add-on release (nodejs-addon-) is built and uploaded to simplex-chat-libs. -# 2. Versions that are not published yet are published, and recorded in releases.toml of simplex-chat-libs. -# npm prerelease versions get dist-tag beta, others latest. -# 3. The release PR packages-release/ pins new library and add-on releases with their SHA-256 -# and bumps the versions of changed packages. Merging it publishes them (step 2). -# Package tests run on every platform with the pinned libsimplex. build.yml runs them with the libsimplex it builds: -# for pull requests that change the chat core (linux-x86_64), and for a new library release before its release PR. - -# Publishing uses trusted publishing (OIDC) for this workflow on npm and PyPI, -# or the NPM_TOKEN and PYPI_API_TOKEN secrets when they are set. -# Test repositories: variables NPM_SCOPE, PYPI_PROJECT and PYPI_URL / PYPI_UPLOAD_URL (TestPyPI) -# publish under other names. - -# Secrets: -# ------- -# NODEJS_REPO_TOKEN -# Only select repositories: simplex-chat-libs -# Permissions: -# * Contents (Read and Write) -# RELEASE_PR_TOKEN -# Only select repositories: simplex-chat -# Permissions: -# * Contents (Read and Write) -# * Pull requests (Read and Write) -# A PR opened with GITHUB_TOKEN does not run the checks of other workflows. - on: - workflow_dispatch: - inputs: - nodejs_version: - description: "simplex-chat npm version, bumped when needed by default" - required: false - types_version: - description: "@simplex-chat/types version, bumped when needed by default" - required: false - python_version: - description: "simplex-chat PyPI version, bumped when needed by default" - required: false - # build.yml: a new library release - workflow_call: - inputs: - libs_release: - description: "test with this simplex-chat-libs release" - type: string - default: "" - push: - branches: - - master - - stable - paths: - - "packages/simplex-chat-nodejs/**" - - "packages/simplex-chat-client/types/typescript/**" - - "packages/simplex-chat-python/**" - - ".github/workflows/packages.yml" pull_request: paths: - "packages/simplex-chat-nodejs/**" @@ -79,47 +25,19 @@ jobs: addon: runs-on: ubuntu-latest outputs: - tag: ${{ steps.tag.outputs.tag }} - release_branch: ${{ steps.branch.outputs.release_branch }} - missing: ${{ steps.release.outputs.missing }} platforms: ${{ steps.plan.outputs.platforms }} test: ${{ steps.plan.outputs.test }} steps: - name: Checkout Code uses: actions/checkout@v6 - - name: Add-on release tag - id: tag - run: echo "tag=$(python3 scripts/ci/packages_release.py addon-tag)" >> "$GITHUB_OUTPUT" - - - name: Release branch - id: branch - if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/stable') - run: echo "release_branch=true" >> "$GITHUB_OUTPUT" - - - name: Check the add-on release - id: release - if: steps.branch.outputs.release_branch - env: - GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} - LIBS_REPOSITORY: ${{ github.repository }}-libs - TAG: ${{ steps.tag.outputs.tag }} - run: | - if ! gh release view "$TAG" --repo "$LIBS_REPOSITORY" > /dev/null 2>&1; then - echo "missing=true" >> "$GITHUB_OUTPUT" - fi - - name: Build plan id: plan env: - EVENT: ${{ github.event_name }} BASE: ${{ github.event.pull_request.base.sha }} - RELEASE_MISSING: ${{ steps.release.outputs.missing }} - LIBS_RELEASE: ${{ inputs.libs_release }} run: | - if [ -n "$BASE" ]; then git fetch -q --depth=1 origin "$BASE"; fi - python3 scripts/ci/packages_release.py build-plan --event "$EVENT" --base "$BASE" \ - --release-missing "$RELEASE_MISSING" --libs-release "$LIBS_RELEASE" >> "$GITHUB_OUTPUT" + git fetch -q --depth=1 origin "$BASE" + python3 scripts/ci/packages_release.py build-plan --base "$BASE" >> "$GITHUB_OUTPUT" build: name: "build ${{ matrix.platform }}" @@ -134,7 +52,6 @@ jobs: defaults: run: shell: bash - working-directory: packages/simplex-chat-nodejs steps: - name: Checkout Code uses: actions/checkout@v6 @@ -149,222 +66,9 @@ jobs: with: python-version: "3.12" - # The local types: the dependency can be bumped to a version that is not published yet. - name: Build simplex-chat (npm) and add-on - run: | - (cd ../simplex-chat-client/types/typescript && npm install --no-audit --no-fund && npx tsc) - npm install --ignore-scripts --no-save --no-audit --no-fund ../simplex-chat-client/types/typescript - npx --yes node-gyp rebuild - npx tsc && cp src/simplex.* dist - cp build/Release/simplex.node build/simplex-chat-nodejs-${{ matrix.platform }}.node + run: scripts/ci/package-tests.sh build ${{ matrix.platform }} - - name: Download libsimplex release - if: needs.addon.outputs.test && inputs.libs_release - env: - URL: https://github.com/${{ github.repository }}-libs/releases/download/${{ inputs.libs_release }}/simplex-chat-libs-${{ matrix.platform }}.zip - run: curl -fsSL --create-dirs -o "$RUNNER_TEMP/libsimplex/simplex-chat-libs-${{ matrix.platform }}.zip" "$URL" - - - name: Unpack libsimplex - if: needs.addon.outputs.test && inputs.libs_release - run: python -m zipfile -e "$RUNNER_TEMP/libsimplex/simplex-chat-libs-${{ matrix.platform }}.zip" "$RUNNER_TEMP/libsimplex" - - - name: Test simplex-chat (npm) + - name: Test simplex-chat (npm and PyPI) if: needs.addon.outputs.test - env: - SIMPLEX_LIBS_DIR: ${{ inputs.libs_release && format('{0}/libsimplex/libs', runner.temp) || '' }} - # A failed test can leave chat controllers running, and jest then waits for them. - # Tests on the public preset servers fail in CI when a server is slow to connect. - run: npx jest --forceExit --testNamePattern '^(?!.*use preset servers)' - - - name: Test simplex-chat (PyPI) - if: needs.addon.outputs.test - working-directory: packages/simplex-chat-python - run: | - python -m pip install -e ".[test]" - python -m pytest -q - - - name: Upload add-on artifact - uses: actions/upload-artifact@v7 - with: - name: simplex-nodejs-addon-${{ matrix.platform }} - path: packages/simplex-chat-nodejs/build/simplex-chat-nodejs-${{ matrix.platform }}.node - if-no-files-found: error - - publish: - needs: addon - # Trusted publishing accepts this workflow only when it is not called by another one (build.yml). - if: needs.addon.outputs.release_branch && contains(github.workflow_ref, '/.github/workflows/packages.yml@') - runs-on: ubuntu-latest - permissions: - contents: read - id-token: write - # set by a publish step after it succeeds, so a failed publish still reports the earlier ones - outputs: - types: ${{ steps.types.outputs.version }} - nodejs: ${{ steps.nodejs.outputs.version }} - python: ${{ steps.python.outputs.version }} - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - steps: - - name: Checkout Code - uses: actions/checkout@v6 - - - name: Versions to publish - id: plan - env: - GH_TOKEN: ${{ github.token }} - LIBS_REPOSITORY: ${{ github.repository }}-libs - run: python3 scripts/ci/packages_release.py publish-plan >> "$GITHUB_OUTPUT" - - - name: Setup Node.js - if: steps.plan.outputs.types_version || steps.plan.outputs.nodejs_version - uses: actions/setup-node@v7 - with: - node-version: 24 - registry-url: https://registry.npmjs.org - - - name: Setup Python - if: steps.plan.outputs.python_version - uses: actions/setup-python@v7 - with: - python-version: "3.12" - - # Before the test names: the build installs @simplex-chat/types from this repository. - - name: Build and test simplex-chat (npm) - if: steps.plan.outputs.nodejs_version - working-directory: packages/simplex-chat-nodejs - run: | - (cd ../simplex-chat-client/types/typescript && npm install --no-audit --no-fund && npx tsc) - npm install --ignore-scripts --no-save --no-audit --no-fund ../simplex-chat-client/types/typescript - npx tsc && cp src/simplex.* dist - npx jest tests/libs.test.ts tests/api.unit.test.ts - - - name: Use test package names - if: vars.NPM_SCOPE || vars.PYPI_PROJECT - run: python3 scripts/ci/packages_release.py test-names - - - name: Publish @simplex-chat/types - id: types - if: steps.plan.outputs.types_version - working-directory: packages/simplex-chat-client/types/typescript - env: - VERSION: ${{ steps.plan.outputs.types_version }} - DIST_TAG: ${{ steps.plan.outputs.types_dist_tag }} - run: | - npm install --no-audit --no-fund - npm publish --provenance --access public --tag "$DIST_TAG" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - - name: Publish simplex-chat (npm) - id: nodejs - if: steps.plan.outputs.nodejs_version - working-directory: packages/simplex-chat-nodejs - env: - VERSION: ${{ steps.plan.outputs.nodejs_version }} - DIST_TAG: ${{ steps.plan.outputs.nodejs_dist_tag }} - run: | - npm publish --provenance --access public --tag "$DIST_TAG" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - - name: Build and test simplex-chat (PyPI) - if: steps.plan.outputs.python_version - working-directory: packages/simplex-chat-python - run: | - python -m pip install build -e ".[test]" - python -m pytest -q - python -m build - - - name: Publish simplex-chat (PyPI) - if: steps.plan.outputs.python_version - uses: pypa/gh-action-pypi-publish@release/v1 - with: - packages-dir: packages/simplex-chat-python/dist - repository-url: ${{ vars.PYPI_UPLOAD_URL || 'https://upload.pypi.org/legacy/' }} - password: ${{ secrets.PYPI_API_TOKEN }} - - - name: Report the published PyPI version - id: python - if: steps.plan.outputs.python_version - env: - VERSION: ${{ steps.plan.outputs.python_version }} - run: echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - # The only job here that writes to simplex-chat-libs; publish runs npm and pip installs without its token. - record: - needs: publish - if: ${{ !cancelled() && (needs.publish.outputs.types || needs.publish.outputs.nodejs || needs.publish.outputs.python) }} - runs-on: ubuntu-latest - steps: - - name: Checkout Code - uses: actions/checkout@v6 - - - name: Record published versions - env: - GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} - LIBS_REPOSITORY: ${{ github.repository }}-libs - TYPES: ${{ needs.publish.outputs.types }} - NODEJS: ${{ needs.publish.outputs.nodejs }} - PYTHON: ${{ needs.publish.outputs.python }} - run: | - python3 scripts/ci/packages_release.py record --commit "$GITHUB_SHA" \ - --types "$TYPES" --nodejs "$NODEJS" --python "$PYTHON" - - release: - needs: [addon, build] - # A merged release PR changes only versions and pins. - if: >- - !cancelled() && needs.addon.outputs.release_branch && needs.build.result != 'failure' - && !startsWith(github.event.head_commit.message, 'packages: release') - runs-on: ubuntu-latest - steps: - - name: Checkout Code - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - # Outside the checkout: the release PR commits every change in it. - - name: Download add-on artifacts - if: needs.addon.outputs.missing - uses: actions/download-artifact@v8 - with: - pattern: simplex-nodejs-addon-* - merge-multiple: true - path: ${{ runner.temp }}/addons - - - name: Create add-on release - if: needs.addon.outputs.missing - uses: softprops/action-gh-release@v3 - with: - repository: ${{ github.repository }}-libs - tag_name: ${{ needs.addon.outputs.tag }} - files: ${{ runner.temp }}/addons/*.node - token: ${{ secrets.NODEJS_REPO_TOKEN }} - - - name: Update pins and versions - id: prepare - env: - GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} - LIBS_REPOSITORY: ${{ github.repository }}-libs - NODEJS_VERSION: ${{ inputs.nodejs_version }} - TYPES_VERSION: ${{ inputs.types_version }} - PYTHON_VERSION: ${{ inputs.python_version }} - run: | - python3 scripts/ci/packages_release.py prepare \ - --nodejs-version "$NODEJS_VERSION" \ - --types-version "$TYPES_VERSION" \ - --python-version "$PYTHON_VERSION" \ - --summary "$RUNNER_TEMP/summary.md" - echo "title=$(head -n 1 "$RUNNER_TEMP/summary.md")" >> "$GITHUB_OUTPUT" - - - name: Create or update release PR - uses: peter-evans/create-pull-request@v8 - with: - token: ${{ secrets.RELEASE_PR_TOKEN }} - branch: packages-release/${{ github.ref_name }} - base: ${{ github.ref_name }} - title: ${{ steps.prepare.outputs.title }} - commit-message: ${{ steps.prepare.outputs.title }} - body-path: ${{ runner.temp }}/summary.md - author: SimpleX Chat - committer: SimpleX Chat - delete-branch: true + run: scripts/ci/package-tests.sh test diff --git a/docs/contributing/RELEASES.md b/docs/contributing/RELEASES.md new file mode 100644 index 0000000000..ea6123e4c8 --- /dev/null +++ b/docs/contributing/RELEASES.md @@ -0,0 +1,153 @@ +# Library and package releases + +## Releases + +| Release | Location | Version | Published on | +|---|---|---|---| +| libsimplex | simplex-chat-libs release `core-` | `version` of `simplex-chat.cabal` | push to master or stable with a new core version | +| Node.js add-on | simplex-chat-libs release `nodejs-addon-<7 characters of the commit>` | | push to master or stable with unreleased add-on sources | +| `@simplex-chat/types` | npm | `version` of `packages/simplex-chat-client/types/typescript/package.json` | library release with an unpublished types version | +| `simplex-chat` | npm | `version` of `packages/simplex-chat-nodejs/package.json` | push to master or stable with an unpublished version | +| `simplex-chat` | PyPI | `__version__` of `packages/simplex-chat-python/src/simplex_chat/_version.py` | push to master or stable with an unpublished version | + +Add-on sources: +- `packages/simplex-chat-nodejs/binding.gyp` +- `packages/simplex-chat-nodejs/cpp/` +- `node-addon-api` version in `devDependencies` of `packages/simplex-chat-nodejs/package.json` + +Release states in simplex-chat-libs: + +``` +draft created by maybe-release; assets uploaded by the build jobs +published every asset present; published by the releases job +``` + +npm tag: `beta` for a version with a prerelease part, `latest` otherwise. + +Releases and publications are independent of the Haskell test results. + +`releases.toml` of simplex-chat-libs, written by `build.yml`: + +``` +core.. commit of the library release; branch: master or stable +npm.. commit of the publication +pypi.. commit of the publication +``` + +## Library release + +1. On master or stable, after `git pull`: + + ``` + python3 scripts/ci/packages_release.py core-release + ``` + + Checked: version higher than the highest version recorded for the branch. + + Written: + - `version` of `simplex-chat.cabal` + - with types changed since the previous library release of the branch: types `version` (bumped), `simplexChatCore`, line `SimpleX Chat core:` of the types README + + `--types-version` sets the types version, for the first release of an app version, e.g. `--types-version 7.1.0-beta.7.1`. + +2. Commit, push to the same branch. + +3. Run of `build.yml`: + - `maybe-release`: version claimed for the commit in `releases.toml`, draft `core-` created + - build jobs: 6 library zips uploaded + - `releases`: release published + - `publish`: `@simplex-chat/types` published, when its version is unpublished + - `record`: types version recorded + +Failed claim, with every build job skipped: +- version lower than the highest version recorded for the branch +- types changed, types version published +- types changed, `simplexChatCore` different from the core version + +After a failed claim, a fix commit is pushed; the version is claimed by the next push. + +Incomplete draft: completed by a re-run of the failed jobs in the run of the claim. The version is skipped by later pushes, as it is recorded for another commit. + +## Add-on release + +Run of `build.yml` on a push to master or stable with unreleased add-on sources: +- `maybe-release`: draft `nodejs-addon-<7 characters of the commit>` created, notes `sources: ` +- build jobs, 5 platforms: add-on built, tested with the latest library release of the branch, uploaded +- `releases`: release published + +Incomplete draft: completed by a re-run or by the next push with the same add-on sources. + +## Package release + +1. On master or stable, after `git pull`: + + ``` + python3 scripts/ci/packages_release.py prepare [nodejs] [python] + ``` + + Both packages when none is listed. + + Checked: + - published library release recorded for the branch + - add-on release published with every asset (Node.js) + - types version published on npm (Node.js) + - Python types unchanged since the commit of the pinned library release (Python) + - pinned releases published with the pinned SHA-256 + - every pinned asset downloaded with a matching SHA-256 + + Written: + - pins in `packages/simplex-chat-nodejs/src/release.ts` and `_version.py`: latest library release of the branch, add-on release of the current sources (Node.js), SHA-256 of every asset + - `@simplex-chat/types` dependency: `^` (Node.js) + - package versions: a published version bumped when the pins or the package files changed + + `--nodejs-version` and `--python-version` set the versions, for the first release of an app version, e.g. `--nodejs-version 7.1.0-beta.7.1 --python-version 7.1.0b7`. + +2. Commit, push to the same branch. + +3. Run of `build.yml`: + - build jobs, 5 platforms: packages tested, Node.js with the pinned add-on + - `publish`: package tests of 5 platforms and pins checked, unpublished versions published with trusted publishing + - `record`: published versions recorded + +Versions unpublished after a failure: published by the next push to the branch. + +## Local publication + +1. Step 1 of the package release. + +2. Package tests in a Python virtual environment, on the local platform (`linux-x86_64`, `linux-aarch64`, `macos-aarch64`, `macos-x86_64` or `windows-x86_64`): + + ``` + scripts/ci/package-tests.sh publish nodejs python + ``` + +3. In `packages/simplex-chat-nodejs`, with the npm tag of the version: + + ``` + npm publish --access public --tag beta + ``` + +4. In `packages/simplex-chat-python`: + + ``` + python -m pip install build twine + python -m build + twine upload dist/simplex_chat-* + ``` + +5. Commit, push to the same branch. Locally published versions are skipped by `build.yml` and are not recorded in `releases.toml`. + +## Settings + +- simplex-chat-libs: release immutability enabled +- secret `NODEJS_REPO_TOKEN`: contents of simplex-chat-libs, read and write +- trusted publisher of `simplex-chat` and `@simplex-chat/types` on npm and of `simplex-chat` on PyPI: workflow `build.yml` +- secrets `NPM_TOKEN` and `PYPI_API_TOKEN`: used instead of trusted publishing when set +- variables `NPM_SCOPE`, `PYPI_PROJECT`, `PYPI_URL` and `PYPI_UPLOAD_URL`: publication under other names, for test repositories + +## Pull requests + +- `packages.yml`, for package changes: + - core unchanged: add-on built and packages tested on 5 platforms + - core changed: add-on built on 5 platforms when its sources changed +- `build.yml`, for core changes: package tests on Linux x86_64 with the library built from the pull request diff --git a/packages/simplex-chat-client/types/typescript/README.md b/packages/simplex-chat-client/types/typescript/README.md index e30cf0d0c3..8d5f88657d 100644 --- a/packages/simplex-chat-client/types/typescript/README.md +++ b/packages/simplex-chat-client/types/typescript/README.md @@ -4,6 +4,8 @@ This TypeScript library provides auto-generated types for bots API: commands and It is used in [simplex-chat](https://www.npmjs.com/package/simplex-chat) Node.js library. +SimpleX Chat core: 7.1.0.12 + [API reference](https://github.com/simplex-chat/simplex-chat/tree/stable/bots). ## License diff --git a/packages/simplex-chat-client/types/typescript/package.json b/packages/simplex-chat-client/types/typescript/package.json index 7bf43e0840..983292abd1 100644 --- a/packages/simplex-chat-client/types/typescript/package.json +++ b/packages/simplex-chat-client/types/typescript/package.json @@ -1,6 +1,7 @@ { "name": "@simplex-chat/types", - "version": "0.12.1", + "version": "7.1.0-beta.6.1", + "simplexChatCore": "7.1.0.12", "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 67eefdee4a..eaf9de68fb 100644 --- a/packages/simplex-chat-nodejs/package.json +++ b/packages/simplex-chat-nodejs/package.json @@ -1,6 +1,6 @@ { "name": "simplex-chat", - "version": "7.1.0-beta.6.3", + "version": "7.1.0-beta.6.4", "main": "dist/index.js", "types": "dist/index.d.ts", "gypfile": false, @@ -24,7 +24,7 @@ "docs": "typedoc" }, "dependencies": { - "@simplex-chat/types": "^0.12.1", + "@simplex-chat/types": "^7.1.0-beta.6.1", "extract-zip": "^2.0.1", "fast-deep-equal": "^3.1.3" }, diff --git a/packages/simplex-chat-nodejs/src/release.ts b/packages/simplex-chat-nodejs/src/release.ts index b56435e210..ad9dfa4c78 100644 --- a/packages/simplex-chat-nodejs/src/release.ts +++ b/packages/simplex-chat-nodejs/src/release.ts @@ -1,16 +1,16 @@ // Written by scripts/ci/packages_release.py from the simplex-chat-libs releases. -export const LIBS_RELEASE = "core-7.1.0.11" +export const LIBS_RELEASE = "core-7.1.0.12" export const ADDON_RELEASE: string | undefined = "nodejs-addon-308c0960c707" export const SHA256: {readonly [asset: string]: string} = { - "simplex-chat-libs-linux-aarch64.zip": "d66aa1b30b9fa20225dad8ef75a9c605961e1c0adde2ba0283e2fcac4e15d260", - "simplex-chat-libs-linux-x86_64-postgres.zip": "925b3166f969b12082ff8ee659e5f2a9933fe40120deb80c9aa3002a5ba6c803", - "simplex-chat-libs-linux-x86_64.zip": "dabad0824e8813ea4a084fbf37e1ba4cf6dcea9cd8ccadc521fec200f246208a", - "simplex-chat-libs-macos-aarch64.zip": "aa74608b149fff7508a47eaac4284dcc6d189c6ed8b7c18428df9158bc176a61", - "simplex-chat-libs-macos-x86_64.zip": "a0e21457e1077be7f71b46c1c27452945b2cee2b6c50efb0004412ce5b7042ec", - "simplex-chat-libs-windows-x86_64.zip": "c01dad574daa101a1cbfd31ddbf2b3d0d986a4a42f97d789d2f878b2050b2b4b", + "simplex-chat-libs-linux-aarch64.zip": "924b8e14eaba5e309fa584c4bfe598421cf64983d63790c1c45247f592f60055", + "simplex-chat-libs-linux-x86_64-postgres.zip": "323f908b45dde751d6082f8885d07d81fba8a5ab8b2df7d3b8ab3a87e56299e5", + "simplex-chat-libs-linux-x86_64.zip": "995409e963db408bcf896361b669944484f845b6744fefe479aebb0ca2e7c5cd", + "simplex-chat-libs-macos-aarch64.zip": "64464aa48b996e5a96b4aa651bc57bc69e412d3c3804b589c67bd9dc52faddbf", + "simplex-chat-libs-macos-x86_64.zip": "94664f96ff7a2cd5a419096e7e843928faa31cb3addb8869fa1e4b5a0d0b3629", + "simplex-chat-libs-windows-x86_64.zip": "2e10844e5c119921139c373ee2971341aceef7dc4198429d3be6b1570a009d15", "simplex-chat-nodejs-linux-aarch64.node": "3636a82c56c48229e0326fa03d6ceb5c16dd3afbcc1e86337783dc53a7328d67", "simplex-chat-nodejs-linux-x86_64.node": "55f3479b03fdb57591179ed730da744f1aa362e7529df55221b60028415746e9", "simplex-chat-nodejs-macos-aarch64.node": "a3472872fb0816f435f52ee5471088e6545ebffb95e89fbf8e1ab82275f2d56e", diff --git a/packages/simplex-chat-python/README.md b/packages/simplex-chat-python/README.md index b86886d591..aa892d32ab 100644 --- a/packages/simplex-chat-python/README.md +++ b/packages/simplex-chat-python/README.md @@ -56,15 +56,6 @@ ruff check && pyright && pytest tests/ Wire types under `src/simplex_chat/types/_*.py` are generated. Regenerate with `cabal test simplex-chat-test --test-options='--match Python'`. -## Release - -Manual for now. Bump `_version.py:__version__`, build a wheel, upload to PyPI: - -```bash -uv build --wheel -uv publish --token "$PYPI_TOKEN" -``` - ## License [AGPL-3.0](./LICENSE) diff --git a/packages/simplex-chat-python/src/simplex_chat/_version.py b/packages/simplex-chat-python/src/simplex_chat/_version.py index 0786a81795..e5075096d4 100644 --- a/packages/simplex-chat-python/src/simplex_chat/_version.py +++ b/packages/simplex-chat-python/src/simplex_chat/_version.py @@ -1,16 +1,16 @@ """Package version and the simplex-chat-libs release it loads. -Written by scripts/ci/packages_release.py: the release PR bumps the version +Written by scripts/ci/packages_release.py prepare: it bumps the version and pins new library releases with the SHA-256 of their assets. """ -__version__ = "7.1.0b6" # PEP 440 — read by hatchling for wheel metadata -LIBS_RELEASE = "core-7.1.0.11" # simplex-chat-libs release tag +__version__ = "7.1.0b6.post1" # PEP 440 — read by hatchling for wheel metadata +LIBS_RELEASE = "core-7.1.0.12" # simplex-chat-libs release tag LIBS_SHA256 = { - "simplex-chat-libs-linux-aarch64.zip": "d66aa1b30b9fa20225dad8ef75a9c605961e1c0adde2ba0283e2fcac4e15d260", - "simplex-chat-libs-linux-x86_64-postgres.zip": "925b3166f969b12082ff8ee659e5f2a9933fe40120deb80c9aa3002a5ba6c803", - "simplex-chat-libs-linux-x86_64.zip": "dabad0824e8813ea4a084fbf37e1ba4cf6dcea9cd8ccadc521fec200f246208a", - "simplex-chat-libs-macos-aarch64.zip": "aa74608b149fff7508a47eaac4284dcc6d189c6ed8b7c18428df9158bc176a61", - "simplex-chat-libs-macos-x86_64.zip": "a0e21457e1077be7f71b46c1c27452945b2cee2b6c50efb0004412ce5b7042ec", - "simplex-chat-libs-windows-x86_64.zip": "c01dad574daa101a1cbfd31ddbf2b3d0d986a4a42f97d789d2f878b2050b2b4b", + "simplex-chat-libs-linux-aarch64.zip": "924b8e14eaba5e309fa584c4bfe598421cf64983d63790c1c45247f592f60055", + "simplex-chat-libs-linux-x86_64-postgres.zip": "323f908b45dde751d6082f8885d07d81fba8a5ab8b2df7d3b8ab3a87e56299e5", + "simplex-chat-libs-linux-x86_64.zip": "995409e963db408bcf896361b669944484f845b6744fefe479aebb0ca2e7c5cd", + "simplex-chat-libs-macos-aarch64.zip": "64464aa48b996e5a96b4aa651bc57bc69e412d3c3804b589c67bd9dc52faddbf", + "simplex-chat-libs-macos-x86_64.zip": "94664f96ff7a2cd5a419096e7e843928faa31cb3addb8869fa1e4b5a0d0b3629", + "simplex-chat-libs-windows-x86_64.zip": "2e10844e5c119921139c373ee2971341aceef7dc4198429d3be6b1570a009d15", } diff --git a/plans/2026-10-18-package-releases.md b/plans/2026-10-18-package-releases.md new file mode 100644 index 0000000000..286ab2cf26 --- /dev/null +++ b/plans/2026-10-18-package-releases.md @@ -0,0 +1,364 @@ +# Package releases + +Repository: simplex-chat/simplex-chat. +Prerequisite: PR #7750 merged; master runs are not cancelled. +Scope: releases in simplex-chat-libs, npm and PyPI. +App releases (`v*` tags in simplex-chat): unchanged. + +## Releases + +| Release | Repository | Name | Released when | Assets | +|---|---|---|---|---| +| libsimplex | simplex-chat-libs | `core-` | push to master or stable claims the version for its branch | `LIBS_ASSETS` | +| `@simplex-chat/types` | npm | version of its `package.json` | library release push, version absent from npm | | +| add-on | simplex-chat-libs | `nodejs-addon-<7 characters of the pushed commit sha>` | push to master or stable, published release of the add-on hash absent | `ADDON_ASSETS` | +| `simplex-chat` (npm) | npm | version of its `package.json` | push to master or stable, version absent from npm | | +| `simplex-chat` (PyPI) | PyPI | `__version__` of `_version.py` | push to master or stable, version absent from PyPI | | + +Release states in simplex-chat-libs: + +``` +absent +draft created before the build jobs; assets uploaded by the build jobs +published every asset present; immutable +``` + +Add-on hash: output of the current `addon_tag` function without its prefix. +Add-on release notes: `sources: `. + +Types source: `packages/simplex-chat-client/types/typescript/src`. +Python types source: `packages/simplex-chat-python/src/simplex_chat/types`. + +## Library claim + +Claim: entry `core..` = simplex-chat commit in `releases.toml` of simplex-chat-libs. +Branches: master, stable. +Written through the contents API with the blob sha; a concurrent writer re-reads after 409. + +Rows in order of evaluation: + +| `core..` | `claim-core` result | +|---|---| +| version lower than the highest version recorded for the branch | exit 1 | +| version recorded on any branch for this commit | `resume` | +| version recorded on any branch for another commit | `skip` | +| absent, types check failed | exit 1 | +| absent, types check passed | `new`: entry written | + +Previous core release: commit of the highest version recorded for the branch. + +Types check: + +``` +types changed types source differs from the previous core release +passed types unchanged + or types version unrecorded, absent from npm, simplexChatCore = +``` + +## Types versions + +``` +scheme ., as simplex-chat (npm) +first 7.1.0-beta.6.1, from core-release --types-version +next bump_npm_version of the types version at the previous core release +npm tag dist_tag: beta for a prerelease version, latest otherwise +``` + +## Manual settings + +1. simplex-chat-libs: immutable releases enabled. +2. npm trusted publisher of `simplex-chat`: workflow `build.yml`. +3. npm trusted publisher of `@simplex-chat/types`: workflow `build.yml`. +4. PyPI trusted publisher of `simplex-chat`: workflow `build.yml`. +5. Secret `RELEASE_PR_TOKEN`: deleted. +6. PR #7732: closed; branch `packages-release/master`: deleted. +7. `releases.toml` entry added: `core.master."7.1.0.11"` = `9711dcfc9c39ba125c1fb1674a3e039b1b9c0e81`. +8. Notes of release `nodejs-addon-308c0960c707`: `sources: 308c0960c707`. + +## Files + +- `scripts/ci/packages_release.py` +- `scripts/ci/test_packages_release.py` +- `scripts/ci/package-tests.sh`: new. +- `.github/workflows/build.yml` +- `.github/workflows/packages.yml` +- `packages/simplex-chat-client/types/typescript/package.json`: field `simplexChatCore`, `7.1.0.11`. +- `packages/simplex-chat-client/types/typescript/README.md`: line `SimpleX Chat core: 7.1.0.11`. + +## packages_release.py + +Asset set by tag prefix: + +``` +core- LIBS_ASSETS +nodejs-addon- ADDON_ASSETS +``` + +Commands: + +``` +core-release [--branch ] [--types-version ] + run locally + branch: the checked out branch by default + version not higher than the highest version recorded for the branch: exit 1 + version: of simplex-chat.cabal written + types changed or --types-version: types version written, as in Types versions + types version written: simplexChatCore and the README line written +claim-core + run in maybe-release + Library claim; prints claim=new, claim=resume or claim=skip + new: also prints commit= +release-status + prints status=published, status=draft or status=absent + drafts: from the authenticated release list +addon-release + releases nodejs-addon-* with notes "sources: " + published one: prints tag= and status=published + draft: prints tag= and status=draft + neither: prints status=absent +latest-libs [--branch ] + prints tag= + order: numeric components of the version +publish-release + draft with every asset of its set: published + missing assets: listed, exit 1 +check-pins + pins of release.ts and _version.py compared with the digests of published releases + mismatch, missing asset or unpublished release: listed, exit 1 +download + asset saved to / + SHA-256 compared with the API digest; mismatch: exit 1 +download-pinned + tag and SHA-256 from release.ts + asset saved to /; mismatch: exit 1 +prepare [nodejs] [python] [--branch ] + run locally + branch: the checked out branch by default + nodejs: simplex-chat (npm); python: simplex-chat (PyPI) + packages: the listed ones; all when none is listed + other package: pins and version unchanged + library pin: latest-libs of the branch + warning: core- newer than the pinned release + nodejs add-on pin: addon-release; exit 1 unless published with every asset + nodejs types dependency: ^; exit 1 unless published on npm + python: exit 1 when the Python types source differs from the commit recorded for the pinned core version + every pinned asset: download, SHA-256 compared with the API digest + changed package: directory without tests differs from the commit that set its version + versions: bumped as now; --nodejs-version, --python-version override + written: release.ts, _version.py, package.json of simplex-chat (npm) + summary: printed; --summary and --types-version removed +build-plan + pull requests only + --event and --release-missing removed +``` + +Commands removed: `addon-tag`. +`record`: option `--branch` of `--core`, default master; replaced by `claim-core` in maybe-release. +Unchanged: `changes`, `publish-plan`, `test-names`. + +`build_plan(core_changed, addon_changed)`: + +``` +core changed, add-on changed platforms: all; test: false +core changed, add-on unchanged platforms: none; test: false +core unchanged platforms: all; test: true +``` + +## package-tests.sh + +Commands from the build steps of `packages.yml` (lines 147-166). + +``` +build + types, npm package and add-on built + add-on copied to packages/simplex-chat-nodejs/build/simplex-chat-nodejs-.node +test + jest, as in packages.yml; SIMPLEX_LIBS_DIR and SIMPLEX_ADDON_PATH from the environment + pytest +addon + build + simplex-chat-libs-.zip of $LIBS_RELEASE: packages_release.py download, unpack to $RUNNER_TEMP/libs + jest with SIMPLEX_LIBS_DIR=$RUNNER_TEMP/libs/libs + add-on uploaded to draft $ADDON_TAG of $LIBS_REPOSITORY: gh release upload --clobber, token $GH_TOKEN +publish [nodejs] [python] + nodejs: types and npm package built + download-pinned simplex-chat-nodejs-.node to $RUNNER_TEMP/addon + add-on copied to packages/simplex-chat-nodejs/build/Release/simplex.node, the path of the tests + jest + python: pytest + run in CI and locally +``` + +## build.yml + +### maybe-release + +Outputs: + +``` +libs_tag core-, when the claim is new or resume +addon_tag tag of the add-on draft +addon_draft true when the add-on release is a draft after this job +libs_latest output tag of latest-libs +publish_types libs_tag set, types version printed by publish-plan +publish_nodejs nodejs version printed by publish-plan +publish_python python version printed by publish-plan +publish publish_nodejs or publish_python +``` + +Steps after "Create release": + +| Step | Condition | Action | +|---|---|---| +| Library release | push to master or stable | `claim-core `; new → `gh release create core- --draft --target `; resume → existing draft, `libs_tag` empty when published; skip → notice annotation | +| Add-on release | push to master or stable | `addon-release`: absent → `gh release create nodejs-addon-<7 characters of github.sha> --draft --notes "sources: "`; draft → reused; output `addon_draft` true when absent or draft | +| Latest library | `addon_draft` | `latest-libs --branch ` | +| Versions to publish | push to master or stable | `publish-plan`; environment `NPM_SCOPE`, `PYPI_PROJECT`, `PYPI_URL` as in `packages.yml` | + +Steps removed: "Check version change", "Record libs release", "Create libs release". +"Clone project": `fetch-depth: 2`, unchanged; the types check fetches the commit of the recorded types version. +Token of the release steps: `NODEJS_REPO_TOKEN`. +Library release jobs: concurrency groups with `libs_tag`, unchanged. + +### Library uploads + +Steps "Upload library" (build-linux, build-macos, build-windows) and "Upload postgres library": + +``` +action gh release upload --clobber --repo -libs +token NODEJS_REPO_TOKEN +conditions unchanged +``` + +### Package steps in build jobs + +Platform jobs: + +``` +build-linux matrix.libs entry linux-x86_64 +build-linux-push matrix.libs entry linux-aarch64 +build-macos both entries macos- +build-windows windows-latest windows-x86_64 +``` + +Steps, after "Run tests" (build-linux, build-macos) and after "Upload library" (build-windows): + +| Step | Condition | +|---|---| +| Setup Node.js 22 | `!cancelled()`; core pull request condition, `addon_draft` or `publish` | +| Setup Python 3.12 | `!cancelled()`, Setup Node.js succeeded | +| Package tests (pull request) | build-linux only, as now; `package-tests.sh build linux-x86_64`, then `test` with SIMPLEX_LIBS_DIR | +| Add-on | `!cancelled()`, `addon_draft`, Setup Python succeeded; `package-tests.sh addon ` | +| Package tests | `!cancelled()`, `publish`, Setup Python succeeded; `package-tests.sh publish `, with `nodejs` when `publish_nodejs`, with `python` when `publish_python` | +| Package tests result | Package tests succeeded; artifact `package-tests-` | + +Linux steps: on the host. + +Environment of "Add-on": + +``` +LIBS_RELEASE libs_latest +ADDON_TAG addon_tag +LIBS_REPOSITORY -libs +GH_TOKEN NODEJS_REPO_TOKEN +``` + +### Final jobs + +``` +releases + needs maybe-release, build-linux, build-linux-push, build-linux-postgres, build-macos, build-windows + if !cancelled(), libs_tag or addon_draft + step 1 publish-release ; libs_tag + step 2 publish-release ; !cancelled(), addon_draft + token NODEJS_REPO_TOKEN +publish + needs maybe-release, build-linux, build-linux-push, build-macos, build-windows, releases + if !cancelled(), publish or publish_types + permissions contents: read; id-token: write + env as in packages.yml: NODE_AUTH_TOKEN, NPM_SCOPE, PYPI_PROJECT, PYPI_URL + step 1 when publish: artifacts package-tests-*, 5 required + step 2 when publish: check-pins + steps "publish" job of packages.yml, from "Versions to publish" + types step publish_types and needs.releases.result == 'success' +record + as in packages.yml; needs publish +``` + +Comments of the `publish` job of `packages.yml`: moved with it. +Comment of the `record` job: removed. +Trusted publishing comment (`packages.yml` lines 12-15): moved above `publish`. + +Job removed: `packages`, with its header comment. + +## packages.yml + +``` +triggers pull_request, paths unchanged + push, workflow_dispatch, workflow_call: removed +addon job steps "Add-on release tag", "Release branch", "Check the add-on release": removed + outputs tag, release_branch, missing: removed + Build plan: --event and --release-missing removed +build job Checkout, Setup Node.js, Setup Python + package-tests.sh build + package-tests.sh test, when planned + "Upload add-on artifact": removed +jobs removed publish, record, release +header lines 3-10 and 17-28: removed +``` + +## Release procedure + +Library and types: + +1. `python3 scripts/ci/packages_release.py core-release ` on master or stable, with `--types-version` for a new app version. +2. Commit of the written files; push to the same branch. +3. `build.yml`: claim, draft, uploads by the build jobs, `releases`, types publication. +4. Failed claim: fix commit; the next push claims the version. +5. Incomplete draft: re-run of the failed build jobs, then of `releases` and `publish`. + +Add-on: + +1. Push to master or stable changes the add-on sources. +2. Incomplete draft: completed by a re-run or by the next push. + +Packages, published by GitHub: + +1. `git pull` on master or stable. +2. `python3 scripts/ci/packages_release.py prepare`, with package names and version overrides when needed. +3. Commit of the written files; push to the same branch. +4. Versions absent from the registries: published by `build.yml`. + +Packages, published locally: + +1. Steps 1 and 2 above. +2. `scripts/ci/package-tests.sh publish [nodejs] [python]`. +3. `npm publish` of `simplex-chat`; `python -m build` and `twine upload` of the Python package. +4. Step 3 above; `build.yml` skips the published versions. + +Published npm package: `files` of `package.json` and the pins; add-on and libsimplex downloaded at run time. + +## Verification + +1. Unit tests: + - `claim-core`: every row of the Library claim table + - types check: changed and unchanged types, recorded and unrecorded types version + - `core-release` with and without types changes + - `release-status` + - `addon-release` notes lookup + - `prepare` with one package listed + - `prepare python` with changed Python types + - `latest-libs` order + - `publish-release` with missing assets + - `check-pins` mismatch + - `download` digest mismatch + - `build_plan` cases +2. `gh release upload` to a draft by tag: `shared.FetchRelease` of gh looks up the draft by tag (`fetchDraftRelease`). +3. First library release after the merge: assets uploaded to the draft, release published by `publish-release`. +4. First master push after merge: claim `skip`, as `core-7.1.0.11` is recorded for `9711dcfc`; `addon-release` finds `nodejs-addon-308c0960c707`; package steps skipped. +5. Local `prepare`: pins `core-7.1.0.11` and `nodejs-addon-308c0960c707`; every download matches. +6. `core-release 7.1.0.12 --types-version 7.1.0-beta.6.1`, commit, push: claim `new`, draft, 6 assets uploaded, release published, types 7.1.0-beta.6.1 published with tag beta. +7. Master push with an add-on source change: draft created, 5 assets uploaded, release published. +8. Master push with versions from `prepare`: 5 artifacts `package-tests-*`, pins checked, versions published, versions recorded. +9. Second push during the run of step 6: claim `skip`. +10. Stable push after master is merged into stable, with a stable core version: claim `new` for `core.stable`. diff --git a/scripts/ci/package-tests.sh b/scripts/ci/package-tests.sh new file mode 100755 index 0000000000..33d83c4986 --- /dev/null +++ b/scripts/ci/package-tests.sh @@ -0,0 +1,81 @@ +#!/bin/bash + +set -euo pipefail + +cd "$(dirname "$0")/../.." +nodejs=packages/simplex-chat-nodejs +python_package=packages/simplex-chat-python +python=$(command -v python3 || command -v python) +temp=${RUNNER_TEMP:-$(mktemp -d)} + +usage() { + echo "usage: $0 build | test | addon | publish [nodejs] [python]" >&2 + exit 1 +} + +build_package() { + (cd packages/simplex-chat-client/types/typescript && npm install --no-audit --no-fund && npx tsc) + (cd "$nodejs" && npm install --ignore-scripts --no-save --no-audit --no-fund ../simplex-chat-client/types/typescript) + if [ -n "${1:-}" ]; then + (cd "$nodejs" && npx --yes node-gyp rebuild) + fi + (cd "$nodejs" && npx tsc && cp src/simplex.* dist) + if [ -n "${1:-}" ]; then + cp "$nodejs/build/Release/simplex.node" "$nodejs/build/simplex-chat-nodejs-$1.node" + fi +} + +test_nodejs() { + (cd "$nodejs" && npx jest --forceExit --testNamePattern '^(?!.*use preset servers)') +} + +test_python() { + (unset SIMPLEX_LIBS_DIR && cd "$python_package" && "$python" -m pip install -e ".[test]" && "$python" -m pytest -q) +} + +case "${1:-}" in + build) + [ $# -eq 2 ] || usage + build_package "$2" + ;; + test) + [ $# -eq 1 ] || usage + test_nodejs + test_python + ;; + addon) + [ $# -eq 2 ] || usage + build_package "$2" + zip=simplex-chat-libs-$2.zip + "$python" scripts/ci/packages_release.py download "$LIBS_RELEASE" "$zip" "$temp/libs" + "$python" -m zipfile -e "$temp/libs/$zip" "$temp/libs" + SIMPLEX_LIBS_DIR=$temp/libs/libs test_nodejs + gh release upload "$ADDON_TAG" "$nodejs/build/simplex-chat-nodejs-$2.node" --clobber --repo "$LIBS_REPOSITORY" + ;; + publish) + [ $# -ge 2 ] || usage + platform=$2 + shift 2 + for suite in "$@"; do + case "$suite" in + nodejs) + build_package + addon=simplex-chat-nodejs-$platform.node + "$python" scripts/ci/packages_release.py download-pinned "$addon" "$temp/addon" + mkdir -p "$nodejs/build/Release" + cp "$temp/addon/$addon" "$nodejs/build/Release/simplex.node" + test_nodejs + ;; + python) + test_python + ;; + *) + usage + ;; + esac + done + ;; + *) + usage + ;; +esac diff --git a/scripts/ci/packages_release.py b/scripts/ci/packages_release.py index b13b9cc00d..fdfd7c3b68 100644 --- a/scripts/ci/packages_release.py +++ b/scripts/ci/packages_release.py @@ -1,13 +1,21 @@ #!/usr/bin/env python3 """Release automation for simplex-chat and @simplex-chat/types on npm and simplex-chat on PyPI. -addon-tag print the add-on release tag for the current sources -changes print whether the changes since a commit touch the chat core and the add-on -build-plan print the platforms to build the add-on on and whether to run the package tests -prepare update pins and versions for a release PR -publish-plan print the package versions that are not published yet -record record releases and published versions in releases.toml of simplex-chat-libs -test-names rename the packages to NPM_SCOPE and PYPI_PROJECT (test repositories) +core-release set the core version and the types version for a library release +claim-core claim the library release of the core version for a commit +release-status print whether a release of simplex-chat-libs is published, a draft or absent +addon-release print the add-on release of the current add-on sources +latest-libs print the newest published library release +publish-release publish a draft release that has every asset +check-pins check the pins of the packages against the published releases +download download a release asset and check its digest +download-pinned download a pinned asset and check its pin +changes print whether the changes since a commit touch the chat core and the add-on +build-plan print the platforms to build the add-on on and whether to run the package tests +prepare update pins and versions of the packages +publish-plan print the package versions that are not published yet +record record releases and published versions in releases.toml of simplex-chat-libs +test-names rename the packages to NPM_SCOPE and PYPI_PROJECT (test repositories) """ from __future__ import annotations @@ -19,12 +27,14 @@ import hashlib import json import os import re +import shlex import subprocess import sys +import tempfile import tomllib import urllib.error import urllib.request -from collections.abc import Callable +from collections.abc import Callable, Collection from dataclasses import dataclass, field, replace from typing import Any from pathlib import Path @@ -37,7 +47,15 @@ RELEASE_FILE = f"{NODEJS_DIR}/src/release.ts" PYTHON_VERSION_FILE = f"{PYTHON_DIR}/src/simplex_chat/_version.py" ADDON_SOURCES = [f"{NODEJS_DIR}/binding.gyp", f"{NODEJS_DIR}/cpp"] CORE_SOURCES = ["src", "simplex-chat.cabal", "cabal.project"] -ADDON_TAG_HASH_LENGTH = 12 +CABAL_FILE = "simplex-chat.cabal" +CABAL_VERSION = re.compile(r"^(version:\s*)(\S+)", re.M) +TYPES_SOURCES = [f"{TYPES_DIR}/src"] +TYPES_README = f"{TYPES_DIR}/README.md" +TYPES_README_CORE = re.compile(r"^SimpleX Chat core: .*$", re.M) +PYTHON_TYPES_SOURCES = [f"{PYTHON_DIR}/src/simplex_chat/types"] +ADDON_HASH_LENGTH = 12 +CORE_TAG = re.compile(r"^core-(\d+(?:\.\d+)*)$") +ADDON_TAG_PREFIX = "nodejs-addon-" LIBS_ASSETS = [ "simplex-chat-libs-linux-aarch64.zip", "simplex-chat-libs-linux-x86_64-postgres.zip", @@ -58,18 +76,27 @@ ADDON_ASSETS = [f"simplex-chat-nodejs-{platform}.node" for platform in ADDON_PLA SHA256_HEX = re.compile(r"^[0-9a-f]{64}$") RELEASES_FILE = "releases.toml" RECORD_ATTEMPTS = 5 +DOWNLOAD_CHUNK = 1 << 20 TOML_BARE_KEY = re.compile(r"^[A-Za-z0-9_-]+$") # Pure functions -def addon_tag(files: dict[str, bytes], node_addon_api: str) -> str: +def addon_hash(files: dict[str, bytes], node_addon_api: str) -> str: digest = hashlib.sha256() for name in sorted(files): digest.update(name.encode() + b"\0" + files[name] + b"\0") digest.update(f"node-addon-api@{node_addon_api}".encode()) - return f"nodejs-addon-{digest.hexdigest()[:ADDON_TAG_HASH_LENGTH]}" + return digest.hexdigest()[:ADDON_HASH_LENGTH] + + +def addon_notes(sources_hash: str) -> str: + return f"sources: {sources_hash}" + + +def version_key(version: str) -> tuple[int, ...]: + return tuple(int(part) for part in version.split(".")) def bump_npm_version(version: str) -> str: @@ -141,7 +168,7 @@ def render_python_version(version: str, pins: Pins) -> str: return ( '"""Package version and the simplex-chat-libs release it loads.\n' "\n" - "Written by scripts/ci/packages_release.py: the release PR bumps the version\n" + "Written by scripts/ci/packages_release.py prepare: it bumps the version\n" "and pins new library releases with the SHA-256 of their assets.\n" '"""\n' "\n" @@ -152,16 +179,84 @@ def render_python_version(version: str, pins: Pins) -> str: ) +def asset_digests(release: dict) -> dict[str, str]: + return {a["name"]: (a.get("digest") or "").removeprefix("sha256:") for a in release["assets"]} + + def release_digests(release: dict | None, assets: list[str]) -> dict[str, str] | None: """Asset digests of a GitHub release, or None when the release is missing or incomplete.""" if release is None: return None - digests = {a["name"]: (a.get("digest") or "").removeprefix("sha256:") for a in release["assets"]} + digests = asset_digests(release) if not all(SHA256_HEX.match(digests.get(asset, "")) for asset in assets): return None return {asset: digests[asset] for asset in assets} +def release_assets(tag: str) -> list[str]: + if CORE_TAG.match(tag): + return LIBS_ASSETS + if tag.startswith(ADDON_TAG_PREFIX): + return ADDON_ASSETS + raise ValueError(f"no asset set for release {tag}") + + +def preferred_release(releases: list[dict]) -> dict | None: + """The published release among them, otherwise the first draft.""" + return next((r for r in releases if not r["draft"]), next(iter(releases), None)) + + +def release_status(release: dict | None) -> str: + if release is None: + return "absent" + return "draft" if release["draft"] else "published" + + +def tag_release(releases: list[dict], tag: str) -> dict | None: + return preferred_release([r for r in releases if r["tag_name"] == tag]) + + +def addon_release(releases: list[dict], sources_hash: str) -> dict | None: + notes = addon_notes(sources_hash) + return preferred_release( + [r for r in releases if r["tag_name"].startswith(ADDON_TAG_PREFIX) and r.get("body") == notes] + ) + + +def core_version(tag: str) -> str | None: + match = CORE_TAG.match(tag) + return match[1] if match else None + + +def latest_libs(releases: list[dict], versions: Collection[str]) -> str | None: + """The newest published release of the core versions that has every library asset.""" + tags = [ + r["tag_name"] + for r in releases + if not r["draft"] and core_version(r["tag_name"]) in versions and release_digests(r, LIBS_ASSETS) + ] + return max(tags, key=lambda tag: version_key(core_version(tag)), default=None) + + +def missing_assets(release: dict) -> list[str]: + present = {a["name"] for a in release["assets"]} + return [asset for asset in release_assets(release["tag_name"]) if asset not in present] + + +def pin_problems(pins: Pins, published: dict[str, dict | None]) -> list[str]: + """Differences between the pins and the published releases of the pinned tags.""" + problems = [] + for tag, release in published.items(): + if release is None: + problems.append(f"{tag} is not published") + continue + digests = asset_digests(release) + for asset in release_assets(tag): + if pins.sha256.get(asset) != digests.get(asset): + problems.append(f"{asset} of {tag}: pinned {pins.sha256.get(asset)}, published {digests.get(asset)}") + return problems + + @dataclass(frozen=True) class Package: version: str @@ -177,10 +272,11 @@ class State: addon_digests: dict[str, str] | None nodejs_pins: Pins python_pins: Pins - types: Package + types_dependency: str + nodejs_types_dependency: str nodejs: Package python: Package - types_version: str | None = None + packages: frozenset[str] = frozenset({"nodejs", "python"}) nodejs_version: str | None = None python_version: str | None = None @@ -189,23 +285,16 @@ class State: class Plan: nodejs_pins: Pins python_pins: Pins - types_version: str - types_bumped: bool + types_dependency: str | None nodejs_version: str nodejs_changed: bool python_version: str python_changed: bool notes: list[str] = field(default_factory=list) - @property - def types_dependency(self) -> str | None: - return f"^{self.types_version}" if self.types_bumped else None - @property def title(self) -> str: parts = [] - if self.types_bumped: - parts.append(f"types {self.types_version}") if self.nodejs_changed: parts.append(f"nodejs {self.nodejs_version}") if self.python_changed: @@ -214,7 +303,7 @@ class Plan: def plan(state: State) -> Plan: - """What a release PR changes, from the state of the branch and the releases.""" + """What a package release changes, from the state of the branch and the releases.""" notes: list[str] = [] def pin(pins: Pins, field_name: str, tag: str, digests: dict[str, str] | None, what: str) -> tuple[Pins, bool]: @@ -227,27 +316,30 @@ def plan(state: State) -> Plan: notes.append(f"{what}: {tag}") return replace(pins, **{field_name: tag, "sha256": {**pins.sha256, **digests}}), True - nodejs_pins, nodejs_libs = pin( - state.nodejs_pins, "libs_release", state.libs_tag, state.libs_digests, "Node.js libsimplex" - ) - nodejs_pins, addon = pin(nodejs_pins, "addon_release", state.addon_tag, state.addon_digests, "Node.js add-on") - python_pins, python_libs = pin( - state.python_pins, "libs_release", state.libs_tag, state.libs_digests, "Python libsimplex" - ) - def next_version(pkg: Package, changed: bool, override: str | None, bump) -> str: if override: return override return bump(pkg.version) if changed and pkg.published else pkg.version - types_version = next_version(state.types, state.types.changed, state.types_version, bump_npm_version) - types_bumped = types_version != state.types.version - nodejs_changed = nodejs_libs or addon or types_bumped or state.nodejs.changed - nodejs_version = next_version(state.nodejs, nodejs_changed, state.nodejs_version, bump_npm_version) - python_changed = python_libs or state.python.changed - python_version = next_version(state.python, python_changed, state.python_version, bump_python_version) + nodejs_pins, nodejs_version, nodejs_changed, types_dependency = state.nodejs_pins, state.nodejs.version, False, None + if "nodejs" in state.packages: + nodejs_pins, nodejs_libs = pin( + state.nodejs_pins, "libs_release", state.libs_tag, state.libs_digests, "Node.js libsimplex" + ) + nodejs_pins, addon = pin(nodejs_pins, "addon_release", state.addon_tag, state.addon_digests, "Node.js add-on") + if state.types_dependency != state.nodejs_types_dependency: + types_dependency = state.types_dependency + notes.append(f"@simplex-chat/types: {types_dependency}") + nodejs_changed = nodejs_libs or addon or types_dependency is not None or state.nodejs.changed + nodejs_version = next_version(state.nodejs, nodejs_changed, state.nodejs_version, bump_npm_version) + python_pins, python_version, python_changed = state.python_pins, state.python.version, False + if "python" in state.packages: + python_pins, python_libs = pin( + state.python_pins, "libs_release", state.libs_tag, state.libs_digests, "Python libsimplex" + ) + python_changed = python_libs or state.python.changed + python_version = next_version(state.python, python_changed, state.python_version, bump_python_version) for name, old, new in [ - ("@simplex-chat/types", state.types.version, types_version), ("simplex-chat (npm)", state.nodejs.version, nodejs_version), ("simplex-chat (PyPI)", state.python.version, python_version), ]: @@ -256,8 +348,7 @@ def plan(state: State) -> Plan: return Plan( nodejs_pins=nodejs_pins, python_pins=python_pins, - types_version=types_version, - types_bumped=types_bumped, + types_dependency=types_dependency, nodejs_version=nodejs_version, nodejs_changed=nodejs_changed or nodejs_version != state.nodejs.version, python_version=python_version, @@ -272,31 +363,49 @@ class BuildPlan: test: bool -def build_plan( - event: str, release_missing: bool, libs_release: str, core_changed: bool, addon_changed: bool -) -> BuildPlan: - """Each package test runs once, against the libsimplex of the change: a pull request that changes the core - is tested by build.yml with its own libsimplex, a new library release with that release.""" - if libs_release: - return BuildPlan(ADDON_PLATFORMS, test=True) - if event == "pull_request": - if core_changed: - return BuildPlan(ADDON_PLATFORMS if addon_changed else [], test=False) - return BuildPlan(ADDON_PLATFORMS, test=True) - return BuildPlan(ADDON_PLATFORMS if release_missing else [], test=True) +def build_plan(core_changed: bool, addon_changed: bool) -> BuildPlan: + """The package tests of a pull request that changes the core run in build.yml, with its own libsimplex.""" + if core_changed: + return BuildPlan(ADDON_PLATFORMS if addon_changed else [], test=False) + return BuildPlan(ADDON_PLATFORMS, test=True) def build_matrix(platforms: list[str]) -> str: return json.dumps([{"platform": platform, "os": ADDON_RUNNERS[platform]} for platform in platforms]) -def recorded_commit(releases: dict, key: str, version: str) -> str | None: - """The simplex-chat commit recorded for a version; key is "core", "npm/" or "pypi/".""" +def core_key(branch: str) -> str: + return f"core/{branch}" + + +def recorded_versions(releases: dict, key: str) -> dict[str, str]: + """The recorded versions and their simplex-chat commits; key is "core/", "npm/" or "pypi/".""" table = releases for part in key.split("/", 1): table = table.get(part, {}) - commit = table.get(version) - return commit if isinstance(commit, str) else None + return {version: commit for version, commit in table.items() if isinstance(commit, str)} + + +def recorded_commit(releases: dict, key: str, version: str) -> str | None: + return recorded_versions(releases, key).get(version) + + +def highest_recorded(releases: dict, key: str) -> str | None: + return max(recorded_versions(releases, key), key=version_key, default=None) + + +def recorded_core_commit(releases: dict, version: str) -> str | None: + """The commit recorded for the core version on any branch: a release tag is shared by the branches.""" + for branch in releases.get("core", {}): + if commit := recorded_commit(releases, core_key(branch), version): + return commit + return None + + +def previous_core_commit(releases: dict, branch: str) -> str | None: + """The commit of the highest core version recorded for the branch.""" + previous = highest_recorded(releases, core_key(branch)) + return recorded_commit(releases, core_key(branch), previous) if previous else None def add_releases(releases: dict, entries: list[tuple[str, str]], commit: str) -> dict: @@ -357,14 +466,18 @@ def write_json(file: str, value: dict) -> None: (ROOT / file).write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n") -def current_addon_tag() -> str: +def current_addon_hash() -> str: files = { path.relative_to(ROOT).as_posix(): path.read_bytes() for source in ADDON_SOURCES for path in ([ROOT / source] if (ROOT / source).is_file() else sorted((ROOT / source).rglob("*"))) if path.is_file() } - return addon_tag(files, read_json(f"{NODEJS_DIR}/package.json")["devDependencies"]["node-addon-api"]) + return addon_hash(files, read_json(f"{NODEJS_DIR}/package.json")["devDependencies"]["node-addon-api"]) + + +def cabal_version() -> str: + return CABAL_VERSION.search(read_text(CABAL_FILE))[2] def changed_since(base: str, sources: list[str]) -> bool: @@ -378,13 +491,9 @@ def changes(base: str) -> None: def print_build_plan(args: argparse.Namespace) -> None: - pull_request = args.event == "pull_request" result = build_plan( - args.event, - release_missing=args.release_missing, - libs_release=args.libs_release, - core_changed=pull_request and changed_since(args.base, CORE_SOURCES), - addon_changed=pull_request and changed_since(args.base, ADDON_SOURCES), + core_changed=changed_since(args.base, CORE_SOURCES), + addon_changed=changed_since(args.base, ADDON_SOURCES), ) print(f"platforms={build_matrix(result.platforms)}") print(f"test={'true' if result.test else ''}") @@ -429,10 +538,13 @@ def http_json(url: str, headers: dict[str, str] | None = None, method: str = "GE # Workflows pass unset repository variables as empty strings. -def npm_published(name: str, version: str) -> bool: +def npm_package_url(name: str) -> str: registry = os.environ.get("NPM_REGISTRY") or "https://registry.npmjs.org" - package = registry_name(name, os.environ.get("NPM_SCOPE")).replace("/", "%2F") - return http_json(f"{registry}/{package}/{version}") is not None + return f"{registry}/{registry_name(name, os.environ.get('NPM_SCOPE')).replace('/', '%2F')}" + + +def npm_published(name: str, version: str) -> bool: + return http_json(f"{npm_package_url(name)}/{version}") is not None def python_project() -> str: @@ -445,19 +557,50 @@ def pypi_published(version: str) -> bool: return http_json(f"{index}/pypi/{project}/{version}/json") is not None +def libs_repository() -> str: + return os.environ.get("LIBS_REPOSITORY") or "simplex-chat/simplex-chat-libs" + + def libs_api(path: str, method: str = "GET", body: dict | None = None) -> Any: """GitHub API of the simplex-chat-libs repository.""" - repo = os.environ.get("LIBS_REPOSITORY") or "simplex-chat/simplex-chat-libs" headers = {"Accept": "application/vnd.github+json"} if token := os.environ.get("GH_TOKEN"): headers["Authorization"] = f"Bearer {token}" - return http_json(f"https://api.github.com/repos/{repo}/{path}", headers, method, body) + return http_json(f"https://api.github.com/repos/{libs_repository()}/{path}", headers, method, body) def github_release(tag: str) -> dict | None: + """The published release of the tag.""" return libs_api(f"releases/tags/{tag}") +def libs_releases() -> list[dict]: + """The releases of simplex-chat-libs; drafts are listed only with a token that can push to it.""" + releases: list[dict] = [] + page = 1 + while batch := libs_api(f"releases?per_page=100&page={page}"): + releases += batch + page += 1 + return releases + + +def download(tag: str, asset: str, directory: str, sha256: str) -> Path: + """Saves the asset of the release to the directory; exits when its SHA-256 differs.""" + url = f"https://github.com/{libs_repository()}/releases/download/{tag}/{asset}" + target = Path(directory) / asset + target.parent.mkdir(parents=True, exist_ok=True) + digest = hashlib.sha256() + request = urllib.request.Request(url, headers={"User-Agent": "simplex-chat-release"}) + with urllib.request.urlopen(request, timeout=600) as response, target.open("wb") as file: + while chunk := response.read(DOWNLOAD_CHUNK): + file.write(chunk) + digest.update(chunk) + if digest.hexdigest() != sha256: + target.unlink() + sys.exit(f"SHA-256 of {asset} of {tag} is {digest.hexdigest()}, expected {sha256}") + return target + + def read_releases() -> tuple[dict, str | None]: """The releases and the blob of the file, which a change must name.""" file = libs_api(f"contents/{RELEASES_FILE}") @@ -495,6 +638,13 @@ def commit_exists(commit: str) -> bool: return found.returncode == 0 +def fetch_commit(commit: str) -> str: + """The commit, fetched from origin when the checkout lacks it.""" + if not commit_exists(commit): + git("fetch", "-q", "--depth=1", "origin", commit) + return commit + + def npm_key(directory: str) -> str: return f"npm/{read_json(f'{directory}/package.json')['name']}" @@ -517,21 +667,113 @@ def npm_package(directory: str, releases: dict) -> Package: ) -def prepare(args: argparse.Namespace) -> None: - cabal_version = read_field("simplex-chat.cabal", r"^version:\s*(\S+)") - libs_tag = f"core-{cabal_version}" - addon = current_addon_tag() - python_version, python_pins = parse_python_version(read_text(PYTHON_VERSION_FILE)) +def types_changed(releases: dict, branch: str) -> bool: + """Whether the types sources differ from those of the previous core release on the branch.""" + previous = previous_core_commit(releases, branch) + return previous is None or changed_since(fetch_commit(previous), TYPES_SOURCES) + + +def types_problem(version: str, releases: dict, branch: str) -> str | None: + """Why the types can't be released with core , or None when they can.""" + if not types_changed(releases, branch): + return None + types = read_json(f"{TYPES_DIR}/package.json") + if recorded_commit(releases, npm_key(TYPES_DIR), types["version"]) or npm_published(types["name"], types["version"]): + return f"{types['name']} changed, and its version {types['version']} is published" + if types.get("simplexChatCore") != version: + return f"simplexChatCore of {types['name']} is {types.get('simplexChatCore')}, expected {version}" + return None + + +def claim_core(version: str, commit: str, branch: str) -> None: releases, _ = read_releases() + highest = highest_recorded(releases, core_key(branch)) + if highest and version_key(version) < version_key(highest): + sys.exit(f"core {version} is lower than core {highest} recorded for {branch}") + if recorded := recorded_core_commit(releases, version): + print(f"claim={'resume' if recorded == commit else 'skip'}") + return + if problem := types_problem(version, releases, branch): + sys.exit(problem) + libs_commit = record_releases([(core_key(branch), version)], commit) + releases, _ = read_releases() + if recorded_core_commit(releases, version) == commit: + print("claim=new") + print(f"commit={libs_commit}") + else: + print("claim=skip") + + +def current_branch() -> str: + return git("rev-parse", "--abbrev-ref", "HEAD") + + +def core_release(version: str, branch: str, types_version: str | None) -> None: + releases, _ = read_releases() + highest = highest_recorded(releases, core_key(branch)) + if highest and version_key(version) <= version_key(highest): + sys.exit(f"core {version} is not higher than core {highest} recorded for {branch}") + (ROOT / CABAL_FILE).write_text(CABAL_VERSION.sub(rf"\g<1>{version}", read_text(CABAL_FILE), count=1)) + print(f"{CABAL_FILE}: {version}") + if not types_version and not types_changed(releases, branch): + return + types = read_json(f"{TYPES_DIR}/package.json") + if not types_version: + previous = previous_core_commit(releases, branch) + released = json.loads(git("show", f"{previous}:{TYPES_DIR}/package.json")) if previous else types + types_version = bump_npm_version(released["version"]) + readme, found = TYPES_README_CORE.subn(f"SimpleX Chat core: {version}", read_text(TYPES_README), count=1) + if not found: + sys.exit(f"{TYPES_README} has no line 'SimpleX Chat core: '") + write_json(f"{TYPES_DIR}/package.json", {**types, "version": types_version, "simplexChatCore": version}) + (ROOT / TYPES_README).write_text(readme) + print(f"{types['name']}: {types_version}") + + +def pinned_tags(pins: Pins) -> list[str]: + return [tag for tag in (pins.libs_release, pins.addon_release) if tag] + + +def published_pin_problems(pins: Pins) -> list[str]: + return pin_problems(pins, {tag: github_release(tag) for tag in pinned_tags(pins)}) + + +def prepare(args: argparse.Namespace) -> None: + packages = frozenset(args.packages or ["nodejs", "python"]) + branch = args.branch or current_branch() + releases, _ = read_releases() + libs = libs_releases() + libs_tag = latest_libs(libs, recorded_versions(releases, core_key(branch))) + if libs_tag is None: + sys.exit(f"no published library release recorded for {branch}") + core = core_version(libs_tag) + if version_key(cabal_version()) > version_key(core): + print(f"warning: core-{cabal_version()} is not published, {libs_tag} is pinned", file=sys.stderr) + sources_hash = current_addon_hash() + addon = addon_release(libs, sources_hash) + addon_digests = release_digests(addon, ADDON_ASSETS) if release_status(addon) == "published" else None + if "nodejs" in packages and addon_digests is None: + sys.exit(f"the add-on release of sources {sources_hash} is not published with every asset") + types = read_json(f"{TYPES_DIR}/package.json") + types_name = types["name"] + if "nodejs" in packages and not npm_published(types_name, types["version"]): + sys.exit(f"{types_name} {types['version']} is not published") + if "python" in packages: + core_commit = recorded_commit(releases, core_key(branch), core) + if changed_since(core_commit, PYTHON_TYPES_SOURCES): + sys.exit(f"the Python types differ from those of {libs_tag}") + nodejs = read_json(f"{NODEJS_DIR}/package.json") + python_version, python_pins = parse_python_version(read_text(PYTHON_VERSION_FILE)) result = plan( State( libs_tag=libs_tag, - libs_digests=release_digests(github_release(libs_tag), LIBS_ASSETS), - addon_tag=addon, - addon_digests=release_digests(github_release(addon), ADDON_ASSETS), + libs_digests=release_digests(tag_release(libs, libs_tag), LIBS_ASSETS), + addon_tag=addon["tag_name"] if addon else "", + addon_digests=addon_digests, nodejs_pins=parse_release(read_text(RELEASE_FILE)), python_pins=python_pins, - types=npm_package(TYPES_DIR, releases), + types_dependency=f"^{types['version']}", + nodejs_types_dependency=nodejs["dependencies"][types_name], nodejs=npm_package(NODEJS_DIR, releases), python=package_state( pypi_key(), @@ -542,24 +784,35 @@ def prepare(args: argparse.Namespace) -> None: releases, lambda: pypi_published(python_version), ), - types_version=args.types_version, + packages=packages, nodejs_version=args.nodejs_version, python_version=args.python_version, ) ) - (ROOT / RELEASE_FILE).write_text(render_release(result.nodejs_pins)) - (ROOT / PYTHON_VERSION_FILE).write_text(render_python_version(result.python_version, result.python_pins)) - types = read_json(f"{TYPES_DIR}/package.json") - write_json(f"{TYPES_DIR}/package.json", {**types, "version": result.types_version}) - nodejs = read_json(f"{NODEJS_DIR}/package.json") - dependencies = nodejs["dependencies"] - if result.types_dependency: - dependencies = {**dependencies, "@simplex-chat/types": result.types_dependency} - write_json(f"{NODEJS_DIR}/package.json", {**nodejs, "version": result.nodejs_version, "dependencies": dependencies}) - summary = result.title + "\n\n" + "".join(f"- {note}\n" for note in result.notes) - if args.summary: - Path(args.summary).write_text(summary) - print(summary, end="") + selected_pins = [ + pins for name, pins in [("nodejs", result.nodejs_pins), ("python", result.python_pins)] if name in packages + ] + if problems := [problem for pins in selected_pins for problem in published_pin_problems(pins)]: + sys.exit("\n".join(problems)) + assets = { + (tag, asset, pins.sha256[asset]) + for pins in selected_pins + for tag in pinned_tags(pins) + for asset in release_assets(tag) + } + with tempfile.TemporaryDirectory() as directory: + for tag, asset, sha256 in sorted(assets): + download(tag, asset, directory, sha256) + print(f"checked {asset} of {tag}", file=sys.stderr) + if "nodejs" in packages: + (ROOT / RELEASE_FILE).write_text(render_release(result.nodejs_pins)) + dependencies = nodejs["dependencies"] + if result.types_dependency: + dependencies = {**dependencies, types_name: result.types_dependency} + write_json(f"{NODEJS_DIR}/package.json", {**nodejs, "version": result.nodejs_version, "dependencies": dependencies}) + if "python" in packages: + (ROOT / PYTHON_VERSION_FILE).write_text(render_python_version(result.python_version, result.python_pins)) + print(result.title + "\n\n" + "".join(f"- {note}\n" for note in result.notes), end="") def publish_plan() -> None: @@ -579,7 +832,7 @@ def publish_plan() -> None: def record(args: argparse.Namespace) -> None: versions = [ - ("core", args.core), + (core_key(args.branch), args.core), (npm_key(TYPES_DIR), args.types), (npm_key(NODEJS_DIR), args.nodejs), (pypi_key(), args.python), @@ -617,35 +870,118 @@ def test_names() -> None: ) -def github_bool(value: str) -> bool: - """GitHub expressions pass booleans to the shell as "true", "false" or "".""" - return value == "true" +def print_release_status(tag: str) -> None: + print(f"status={release_status(tag_release(libs_releases(), tag))}") + + +def print_addon_release() -> None: + sources_hash = current_addon_hash() + release = addon_release(libs_releases(), sources_hash) + print(f"notes={shlex.quote(addon_notes(sources_hash))}") + print(f"status={release_status(release)}") + print(f"tag={release['tag_name'] if release else ''}") + + +def publish_release(tag: str) -> None: + release = tag_release(libs_releases(), tag) + if release is None: + sys.exit(f"{tag}: no release") + if not release["draft"]: + print(f"{tag} is published") + return + if missing := missing_assets(release): + sys.exit(f"{tag}: missing {', '.join(missing)}") + libs_api(f"releases/{release['id']}", "PATCH", {"draft": False}) + print(f"{tag} published") + + +def check_pins() -> None: + _, python_pins = parse_python_version(read_text(PYTHON_VERSION_FILE)) + problems = published_pin_problems(parse_release(read_text(RELEASE_FILE))) + published_pin_problems(python_pins) + if problems: + sys.exit("\n".join(problems)) + print("the pins match the published releases") + + +def download_asset(tag: str, asset: str, directory: str) -> None: + digests = release_digests(github_release(tag), [asset]) + if digests is None: + sys.exit(f"{asset} of {tag} is not published with a digest") + download(tag, asset, directory, digests[asset]) + + +def download_pinned(asset: str, directory: str) -> None: + pins = parse_release(read_text(RELEASE_FILE)) + tag = next((tag for tag in pinned_tags(pins) if asset in release_assets(tag)), None) + if tag is None or asset not in pins.sha256: + sys.exit(f"{asset} is not pinned in {RELEASE_FILE}") + download(tag, asset, directory, pins.sha256[asset]) def main(argv: list[str]) -> None: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) commands = parser.add_subparsers(dest="command", required=True) - commands.add_parser("addon-tag") + branch_help = "the branch of the core versions, the checked out branch by default" + core_release_parser = commands.add_parser("core-release") + core_release_parser.add_argument("version", help="the new core version") + core_release_parser.add_argument("--branch", help=branch_help) + core_release_parser.add_argument("--types-version", help="this types version instead of a bump when needed") + claim_parser = commands.add_parser("claim-core") + claim_parser.add_argument("version", help="the core version of simplex-chat.cabal") + claim_parser.add_argument("commit", help="the simplex-chat commit to release it from") + claim_parser.add_argument("branch", help="the branch of the commit") + status_parser = commands.add_parser("release-status") + status_parser.add_argument("tag") + commands.add_parser("addon-release") + latest_libs_parser = commands.add_parser("latest-libs") + latest_libs_parser.add_argument("--branch", help=branch_help) + publish_release_parser = commands.add_parser("publish-release") + publish_release_parser.add_argument("tag") + commands.add_parser("check-pins") + download_parser = commands.add_parser("download") + download_parser.add_argument("tag") + download_parser.add_argument("asset") + download_parser.add_argument("directory") + download_pinned_parser = commands.add_parser("download-pinned") + download_pinned_parser.add_argument("asset") + download_pinned_parser.add_argument("directory") changes_parser = commands.add_parser("changes") changes_parser.add_argument("base", help="the commit to compare with") build_parser = commands.add_parser("build-plan") - build_parser.add_argument("--event", required=True, help="github.event_name") - build_parser.add_argument("--base", default="", help="the base commit of a pull request") - build_parser.add_argument("--release-missing", type=github_bool, default=False) - build_parser.add_argument("--libs-release", default="") + build_parser.add_argument("--base", required=True, help="the base commit of the pull request") prepare_parser = commands.add_parser("prepare") - for package in ["nodejs", "types", "python"]: + prepare_parser.add_argument("packages", nargs="*", choices=["nodejs", "python"], help="all when none is listed") + prepare_parser.add_argument("--branch", help=branch_help) + for package in ["nodejs", "python"]: prepare_parser.add_argument(f"--{package}-version", help="this version instead of a bump when needed") - prepare_parser.add_argument("--summary", help="file for the PR title and body") commands.add_parser("publish-plan") record_parser = commands.add_parser("record") record_parser.add_argument("--commit", required=True, help="the simplex-chat commit released or published from") + record_parser.add_argument("--branch", default="master", help="the branch of the core version") for name in ["core", "types", "nodejs", "python"]: record_parser.add_argument(f"--{name}", default="", help=f"{name} version, recorded when not empty") commands.add_parser("test-names") args = parser.parse_args(argv) - if args.command == "addon-tag": - print(current_addon_tag()) + if args.command == "core-release": + core_release(args.version, args.branch or current_branch(), args.types_version) + elif args.command == "claim-core": + claim_core(args.version, args.commit, args.branch) + elif args.command == "release-status": + print_release_status(args.tag) + elif args.command == "addon-release": + print_addon_release() + elif args.command == "latest-libs": + releases, _ = read_releases() + versions = recorded_versions(releases, core_key(args.branch or current_branch())) + print(f"tag={latest_libs(libs_releases(), versions) or ''}") + elif args.command == "publish-release": + publish_release(args.tag) + elif args.command == "check-pins": + check_pins() + elif args.command == "download": + download_asset(args.tag, args.asset, args.directory) + elif args.command == "download-pinned": + download_pinned(args.asset, args.directory) elif args.command == "changes": changes(args.base) elif args.command == "build-plan": diff --git a/scripts/ci/test_packages_release.py b/scripts/ci/test_packages_release.py index 1838525c74..994e678690 100644 --- a/scripts/ci/test_packages_release.py +++ b/scripts/ci/test_packages_release.py @@ -1,7 +1,10 @@ +import argparse import base64 import contextlib import email.message +import hashlib import io +import json import subprocess import tomllib import urllib.error @@ -14,26 +17,41 @@ from unittest import mock import packages_release from packages_release import ( ADDON_ASSETS, + CABAL_FILE, NODEJS_DIR, + PYTHON_DIR, PYTHON_VERSION_FILE, + RELEASE_FILE, TYPES_DIR, + TYPES_README, ADDON_PLATFORMS, BuildPlan, LIBS_ASSETS, Package, Pins, State, - addon_tag, + addon_hash, + addon_notes, + addon_release, build_matrix, build_plan, bump_npm_version, bump_python_version, changed_since, add_releases, + claim_core, + core_release, + download, + highest_recorded, + latest_libs, + missing_assets, package_state, + pin_problems, + prepare, publish_plan, record_releases, recorded_commit, + release_status, render_releases, dist_tag, npm_published, @@ -45,6 +63,8 @@ from packages_release import ( release_digests, render_python_version, render_release, + tag_release, + types_problem, ) @@ -60,6 +80,10 @@ def github_release(assets: list[str], n: int) -> dict: return {"assets": [{"name": name, "digest": f"sha256:{sha(n)}"} for name in assets]} +def listed_release(tag: str, assets: list[str], n: int, draft: bool = False, body: str | None = None) -> dict: + return {"id": n, "tag_name": tag, "draft": draft, "body": body, **github_release(assets, n)} + + def state(**overrides) -> State: current = State( libs_tag="core-1.0.0.1", @@ -68,7 +92,8 @@ def state(**overrides) -> State: addon_digests=digests(ADDON_ASSETS, 2), nodejs_pins=Pins("core-1.0.0.1", {**digests(LIBS_ASSETS, 1), **digests(ADDON_ASSETS, 2)}, "nodejs-addon-aaa"), python_pins=Pins("core-1.0.0.1", digests(LIBS_ASSETS, 1)), - types=Package("0.12.1", changed=False, published=True), + types_dependency="^0.12.1", + nodejs_types_dependency="^0.12.1", nodejs=Package("7.1.0-beta.6.1", changed=False, published=True), python=Package("7.1.0b6", changed=False, published=True), ) @@ -101,15 +126,15 @@ class VersionTest(unittest.TestCase): self.assertEqual(registry_name("simplex-chat", None), "simplex-chat") -class AddonTagTest(unittest.TestCase): +class AddonHashTest(unittest.TestCase): def test_depends_on_sources_and_node_addon_api(self): files = {"a.cc": b"1", "b.h": b"2"} - tag = addon_tag(files, "^8.5.0") - self.assertRegex(tag, r"^nodejs-addon-[0-9a-f]{12}$") - self.assertEqual(addon_tag(dict(reversed(files.items())), "^8.5.0"), tag) - self.assertNotEqual(addon_tag({**files, "b.h": b"3"}, "^8.5.0"), tag) - self.assertNotEqual(addon_tag({"a.cc": b"1", "c.h": b"2"}, "^8.5.0"), tag) - self.assertNotEqual(addon_tag(files, "^8.6.0"), tag) + sources_hash = addon_hash(files, "^8.5.0") + self.assertRegex(sources_hash, r"^[0-9a-f]{12}$") + self.assertEqual(addon_hash(dict(reversed(files.items())), "^8.5.0"), sources_hash) + self.assertNotEqual(addon_hash({**files, "b.h": b"3"}, "^8.5.0"), sources_hash) + self.assertNotEqual(addon_hash({"a.cc": b"1", "c.h": b"2"}, "^8.5.0"), sources_hash) + self.assertNotEqual(addon_hash(files, "^8.6.0"), sources_hash) class PinFilesTest(unittest.TestCase): @@ -135,7 +160,7 @@ class ReleaseDigestsTest(unittest.TestCase): class PlanTest(unittest.TestCase): def test_nothing_changes_when_current(self): result = plan(state()) - self.assertEqual((result.nodejs_changed, result.python_changed, result.types_bumped), (False, False, False)) + self.assertEqual((result.nodejs_changed, result.python_changed, result.types_dependency), (False, False, None)) self.assertEqual(result.notes, []) def test_new_libs_release_updates_both_libraries(self): @@ -170,17 +195,17 @@ class PlanTest(unittest.TestCase): plan(state(python=Package("7.1.0b6", True, True))).title, "packages: release python 7.1.0b6.post1" ) - def test_types_bump_updates_the_nodejs_dependency(self): - result = plan(state(types=Package("0.12.1", True, True))) + def test_new_types_version_updates_the_nodejs_dependency(self): + result = plan(state(types_dependency="^0.12.2")) self.assertEqual(result.types_dependency, "^0.12.2") - self.assertEqual(result.title, "packages: release types 0.12.2, nodejs 7.1.0-beta.6.2") + self.assertEqual(result.title, "packages: release nodejs 7.1.0-beta.6.2") def test_all_changes_combine_into_one_release(self): result = plan( state( **NEW_LIBS, **NEW_ADDON, - types=Package("0.12.1", True, True), + types_dependency="^0.12.2", nodejs=Package("7.1.0-beta.6.1", True, True), python=Package("7.1.0b6", True, True), ) @@ -188,7 +213,16 @@ class PlanTest(unittest.TestCase): self.assertEqual( (result.nodejs_pins.libs_release, result.nodejs_pins.addon_release), ("core-1.0.0.2", "nodejs-addon-bbb") ) - self.assertEqual(result.title, "packages: release types 0.12.2, nodejs 7.1.0-beta.6.2, python 7.1.0b6.post1") + self.assertEqual(result.title, "packages: release nodejs 7.1.0-beta.6.2, python 7.1.0b6.post1") + + def test_unlisted_package_keeps_its_pins_and_version(self): + result = plan(state(**NEW_LIBS, types_dependency="^0.12.2", packages=frozenset({"python"}))) + self.assertEqual(result.nodejs_pins, state().nodejs_pins) + self.assertEqual( + (result.nodejs_version, result.nodejs_changed, result.types_dependency), ("7.1.0-beta.6.1", False, None) + ) + self.assertEqual(result.python_pins.libs_release, "core-1.0.0.2") + self.assertEqual(result.title, "packages: release python 7.1.0b6.post1") def test_unpublished_versions_are_not_bumped_again(self): result = plan( @@ -199,29 +233,18 @@ class PlanTest(unittest.TestCase): self.assertEqual(result.title, "packages: release nodejs 7.1.0-beta.6.2, python 7.1.0b6.post1") def test_requested_versions_win(self): - result = plan(state(nodejs_version="7.2.0", types_version="0.13.0", python_version="7.2.0")) - self.assertEqual(result.types_dependency, "^0.13.0") - self.assertEqual(result.title, "packages: release types 0.13.0, nodejs 7.2.0, python 7.2.0") + result = plan(state(nodejs_version="7.2.0", python_version="7.2.0")) + self.assertEqual(result.title, "packages: release nodejs 7.2.0, python 7.2.0") class BuildPlanTest(unittest.TestCase): - def plan(self, event="push", release_missing=False, libs_release="", core=False, addon=False): - return build_plan(event, release_missing, libs_release, core, addon) - - def test_push_builds_a_missing_addon_release_and_tests_it(self): - self.assertEqual(self.plan(release_missing=True), BuildPlan(ADDON_PLATFORMS, test=True)) - self.assertEqual(self.plan(), BuildPlan([], test=True)) - def test_pull_request_tests_with_the_pinned_libs(self): - self.assertEqual(self.plan("pull_request"), BuildPlan(ADDON_PLATFORMS, test=True)) - self.assertEqual(self.plan("pull_request", addon=True), BuildPlan(ADDON_PLATFORMS, test=True)) + self.assertEqual(build_plan(core_changed=False, addon_changed=False), BuildPlan(ADDON_PLATFORMS, test=True)) + self.assertEqual(build_plan(core_changed=False, addon_changed=True), BuildPlan(ADDON_PLATFORMS, test=True)) def test_core_change_leaves_tests_to_build_yml(self): - self.assertEqual(self.plan("pull_request", core=True), BuildPlan([], test=False)) - self.assertEqual(self.plan("pull_request", core=True, addon=True), BuildPlan(ADDON_PLATFORMS, test=False)) - - def test_new_library_release_is_tested_on_every_platform(self): - self.assertEqual(self.plan(libs_release="core-1.0.0.2"), BuildPlan(ADDON_PLATFORMS, test=True)) + self.assertEqual(build_plan(core_changed=True, addon_changed=False), BuildPlan([], test=False)) + self.assertEqual(build_plan(core_changed=True, addon_changed=True), BuildPlan(ADDON_PLATFORMS, test=False)) def test_matrix_names_the_runner_of_each_platform(self): self.assertEqual( @@ -297,6 +320,12 @@ class ReleasesFileTest(unittest.TestCase): self.assertIsNone(recorded_commit(self.RELEASES, "npm/simplex-chat", "7.1.0-beta.6.4")) self.assertIsNone(recorded_commit({}, "pypi/simplex-chat", "7.1.0b6")) + def test_highest_recorded_version_is_compared_numerically(self): + releases = {"core": {"7.1.0.9": "a", "7.1.0.11": "b"}, "npm": {"@simplex-chat/types": {"0.9.0": "c"}}} + self.assertEqual(highest_recorded(releases, "core"), "7.1.0.11") + self.assertEqual(highest_recorded(releases, "npm/@simplex-chat/types"), "0.9.0") + self.assertIsNone(highest_recorded(releases, "npm/simplex-chat")) + def test_adds_entries_and_keeps_recorded_commits(self): updated = add_releases(self.RELEASES, [("npm/simplex-chat", "7.1.0-beta.6.4"), ("core", "7.1.0.11")], "x") self.assertEqual(updated["npm"]["simplex-chat"], {"7.1.0-beta.6.3": "n1", "7.1.0-beta.6.4": "x"}) @@ -355,13 +384,10 @@ class RecordReleasesTest(unittest.TestCase): self.assertEqual(record_releases([("core", "1")], "other"), "libs0") -class PackageStateTest(unittest.TestCase): +class GitRepoTest(unittest.TestCase): def setUp(self): self.repo = Path(tempfile.mkdtemp()) self.git("init", "-q") - self.write("pkg/version.txt", 'version = "1"\n') - self.write("pkg/src.txt", "a") - self.commit("set version") def git(self, *args): subprocess.run(["git", "-c", "user.name=t", "-c", "user.email=t@t", *args], cwd=self.repo, check=True) @@ -374,6 +400,19 @@ class PackageStateTest(unittest.TestCase): self.git("add", "-A") self.git("commit", "-q", "-m", message) + def head(self): + return subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=self.repo, capture_output=True, text=True + ).stdout.strip() + + +class PackageStateTest(GitRepoTest): + def setUp(self): + super().setUp() + self.write("pkg/version.txt", 'version = "1"\n') + self.write("pkg/src.txt", "a") + self.commit("set version") + def state(self, releases=None, registry_published=False): with mock.patch.object(packages_release, "ROOT", self.repo): return package_state( @@ -383,11 +422,6 @@ class PackageStateTest(unittest.TestCase): def changed(self): return self.state().changed - def head(self): - return subprocess.run( - ["git", "rev-parse", "HEAD"], cwd=self.repo, capture_output=True, text=True - ).stdout.strip() - def test_recorded_version_is_published_and_compared_with_its_commit(self): self.write("pkg/src.txt", "b") self.commit("change published with version 1") @@ -467,5 +501,303 @@ class PackageStateTest(unittest.TestCase): self.assertFalse(self.changed()) +class ReleasesTest(unittest.TestCase): + def test_latest_libs_is_the_highest_complete_published_version(self): + releases = [ + listed_release("core-7.1.0.9", LIBS_ASSETS, 1), + listed_release("core-7.1.0.11", LIBS_ASSETS, 2), + listed_release("core-7.1.0.12", LIBS_ASSETS[1:], 3), + listed_release("core-7.1.0.13", LIBS_ASSETS, 4, draft=True), + listed_release("core-test-7.1.0.14", LIBS_ASSETS, 5), + listed_release("nodejs-addon-1111111", ADDON_ASSETS, 6), + ] + versions = {"7.1.0.9", "7.1.0.11", "7.1.0.12", "7.1.0.13", "test-7.1.0.14"} + self.assertEqual(latest_libs(releases, versions), "core-7.1.0.11") + self.assertEqual(latest_libs(releases, {"7.1.0.9"}), "core-7.1.0.9") + self.assertIsNone(latest_libs(releases, {"7.0.0.12"})) + + def test_published_release_is_preferred_to_a_draft(self): + draft = listed_release("core-7.1.0.12", LIBS_ASSETS, 1, draft=True) + published = listed_release("core-7.1.0.12", LIBS_ASSETS, 2) + self.assertEqual(tag_release([draft, published], "core-7.1.0.12"), published) + self.assertEqual(tag_release([draft], "core-7.1.0.12"), draft) + statuses = [ + release_status(tag_release(releases, "core-7.1.0.12")) for releases in ([draft, published], [draft], []) + ] + self.assertEqual(statuses, ["published", "draft", "absent"]) + + def test_addon_release_is_found_by_its_notes(self): + other = listed_release("nodejs-addon-1111111", ADDON_ASSETS, 1, body=addon_notes("bbb")) + draft = listed_release("nodejs-addon-2222222", ADDON_ASSETS, 2, draft=True, body=addon_notes("aaa")) + published = listed_release("nodejs-addon-3333333", ADDON_ASSETS, 3, body=addon_notes("aaa")) + self.assertEqual(addon_release([other, draft, published], "aaa"), published) + self.assertEqual(addon_release([other, draft], "aaa"), draft) + self.assertIsNone(addon_release([other], "aaa")) + + def test_missing_assets_of_a_draft(self): + self.assertEqual(missing_assets(listed_release("core-7.1.0.12", LIBS_ASSETS[1:], 1)), LIBS_ASSETS[:1]) + self.assertEqual(missing_assets(listed_release("nodejs-addon-1111111", ADDON_ASSETS, 1)), []) + + def test_pin_problems_list_mismatches_and_unpublished_releases(self): + pins = Pins("core-7.1.0.11", {**digests(LIBS_ASSETS, 1), **digests(ADDON_ASSETS, 2)}, "nodejs-addon-1111111") + published = { + "core-7.1.0.11": github_release(LIBS_ASSETS, 1), + "nodejs-addon-1111111": github_release(ADDON_ASSETS, 2), + } + self.assertEqual(pin_problems(pins, published), []) + self.assertEqual( + len(pin_problems(pins, {**published, "core-7.1.0.11": github_release(LIBS_ASSETS, 3)})), len(LIBS_ASSETS) + ) + self.assertEqual( + pin_problems(pins, {**published, "nodejs-addon-1111111": None}), ["nodejs-addon-1111111 is not published"] + ) + + +class PublishReleaseTest(unittest.TestCase): + def publish(self, releases): + api = mock.Mock() + with ( + mock.patch.object(packages_release, "libs_releases", return_value=releases), + mock.patch.object(packages_release, "libs_api", api), + contextlib.redirect_stdout(io.StringIO()), + ): + packages_release.publish_release("core-7.1.0.12") + return api + + def test_complete_draft_is_published(self): + api = self.publish([listed_release("core-7.1.0.12", LIBS_ASSETS, 7, draft=True)]) + api.assert_called_once_with("releases/7", "PATCH", {"draft": False}) + + def test_draft_with_missing_assets_stays_a_draft(self): + with self.assertRaises(SystemExit): + self.publish([listed_release("core-7.1.0.12", LIBS_ASSETS[1:], 7, draft=True)]) + + def test_published_release_is_left_unchanged(self): + self.publish([listed_release("core-7.1.0.12", LIBS_ASSETS, 7)]).assert_not_called() + + +class ClaimCoreTest(unittest.TestCase): + def claim(self, recorded, after_record=None, problem=None, version="7.1.0.12", branch="master"): + reads = iter([(recorded, "blob"), (after_record or recorded, "blob")]) + self.record = mock.Mock(return_value="libs1") + output = io.StringIO() + with ( + mock.patch.object(packages_release, "read_releases", lambda: next(reads)), + mock.patch.object(packages_release, "record_releases", self.record), + mock.patch.object(packages_release, "types_problem", return_value=problem), + contextlib.redirect_stdout(output), + ): + claim_core(version, "sx1", branch) + return output.getvalue().splitlines() + + def test_lower_version_fails(self): + with self.assertRaises(SystemExit): + self.claim({"core": {"master": {"7.1.0.13": "sx0"}}}) + + def test_version_recorded_for_this_commit_resumes(self): + self.assertEqual(self.claim({"core": {"master": {"7.1.0.12": "sx1"}}}), ["claim=resume"]) + + def test_version_recorded_for_another_commit_is_skipped(self): + self.assertEqual(self.claim({"core": {"master": {"7.1.0.11": "sx0", "7.1.0.12": "sx0"}}}), ["claim=skip"]) + + def test_version_recorded_on_another_branch_is_skipped(self): + recorded = {"core": {"master": {"7.1.0.11": "sx0"}, "stable": {"7.1.0.12": "sx2"}}} + self.assertEqual(self.claim(recorded), ["claim=skip"]) + + def test_failed_types_check_fails_before_recording(self): + with self.assertRaises(SystemExit): + self.claim({"core": {"master": {"7.1.0.11": "sx0"}}}, problem="types changed") + self.record.assert_not_called() + + def test_unrecorded_version_is_claimed(self): + claimed = {"core": {"master": {"7.1.0.11": "sx0", "7.1.0.12": "sx1"}}} + self.assertEqual(self.claim({"core": {"master": {"7.1.0.11": "sx0"}}}, claimed), ["claim=new", "commit=libs1"]) + self.record.assert_called_once_with([("core/master", "7.1.0.12")], "sx1") + + def test_stable_versions_are_compared_with_stable_only(self): + recorded = {"core": {"master": {"7.1.0.11": "sx0"}, "stable": {"7.0.0.12": "sx3"}}} + claimed = {"core": {**recorded["core"], "stable": {"7.0.0.12": "sx3", "7.0.0.13": "sx1"}}} + self.assertEqual( + self.claim(recorded, claimed, version="7.0.0.13", branch="stable"), ["claim=new", "commit=libs1"] + ) + self.record.assert_called_once_with([("core/stable", "7.0.0.13")], "sx1") + with self.assertRaises(SystemExit): + self.claim(recorded, version="7.0.0.11", branch="stable") + + def test_concurrent_claim_of_another_commit_is_skipped(self): + claimed = {"core": {"master": {"7.1.0.11": "sx0", "7.1.0.12": "sx2"}}} + self.assertEqual(self.claim({"core": {"master": {"7.1.0.11": "sx0"}}}, claimed), ["claim=skip"]) + + +class TypesProblemTest(unittest.TestCase): + def problem(self, types, changed=True, published=False, releases=None): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = Path(directory.name) + (root / TYPES_DIR).mkdir(parents=True) + (root / TYPES_DIR / "package.json").write_text(json.dumps({"name": "@simplex-chat/types", **types})) + with ( + mock.patch.object(packages_release, "ROOT", root), + mock.patch.object(packages_release, "types_changed", return_value=changed), + mock.patch.object(packages_release, "npm_published", return_value=published), + ): + return types_problem("7.1.0.12", releases or {}, "master") + + def test_unchanged_types_pass(self): + self.assertIsNone(self.problem({"version": "0.12.1", "simplexChatCore": "7.1.0.11"}, changed=False)) + + def test_changed_types_with_a_new_version_for_the_core_pass(self): + self.assertIsNone(self.problem({"version": "0.12.2", "simplexChatCore": "7.1.0.12"})) + + def test_changed_types_with_a_published_version_fail(self): + self.assertIsNotNone(self.problem({"version": "0.12.1", "simplexChatCore": "7.1.0.12"}, published=True)) + recorded = {"npm": {"@simplex-chat/types": {"0.12.1": "c"}}} + self.assertIsNotNone(self.problem({"version": "0.12.1", "simplexChatCore": "7.1.0.12"}, releases=recorded)) + + def test_changed_types_for_another_core_fail(self): + self.assertIsNotNone(self.problem({"version": "0.12.2", "simplexChatCore": "7.1.0.11"})) + + +class CoreReleaseTest(GitRepoTest): + def setUp(self): + super().setUp() + self.write(CABAL_FILE, "name: simplex-chat\nversion: 7.1.0.11\n") + types = {"name": "@simplex-chat/types", "version": "0.12.1", "simplexChatCore": "7.1.0.11"} + self.write(f"{TYPES_DIR}/package.json", json.dumps(types, indent=2) + "\n") + self.write(TYPES_README, "# Types\n\nSimpleX Chat core: 7.1.0.11\n") + self.write(f"{TYPES_DIR}/src/types.ts", "a") + self.commit("types 0.12.1") + self.releases = {"core": {"master": {"7.1.0.11": self.head()}}} + + def release(self, version, types_version=None): + with ( + mock.patch.object(packages_release, "ROOT", self.repo), + mock.patch.object(packages_release, "read_releases", return_value=(self.releases, "blob")), + contextlib.redirect_stdout(io.StringIO()), + ): + core_release(version, "master", types_version) + + def read(self, file): + return (self.repo / file).read_text() + + def types(self): + return json.loads(self.read(f"{TYPES_DIR}/package.json")) + + def test_changed_types_get_a_new_version_for_the_core(self): + self.write(f"{TYPES_DIR}/src/types.ts", "b") + self.commit("types change") + self.release("7.1.0.12") + self.assertIn("version: 7.1.0.12\n", self.read(CABAL_FILE)) + self.assertEqual((self.types()["version"], self.types()["simplexChatCore"]), ("0.12.2", "7.1.0.12")) + self.assertIn("SimpleX Chat core: 7.1.0.12\n", self.read(TYPES_README)) + + def test_unchanged_types_keep_their_version(self): + self.release("7.1.0.12") + self.assertIn("version: 7.1.0.12\n", self.read(CABAL_FILE)) + self.assertEqual((self.types()["version"], self.types()["simplexChatCore"]), ("0.12.1", "7.1.0.11")) + + def test_requested_types_version_wins(self): + self.release("7.1.0.12", "7.1.0-beta.6.1") + self.assertEqual((self.types()["version"], self.types()["simplexChatCore"]), ("7.1.0-beta.6.1", "7.1.0.12")) + + def test_types_version_is_bumped_from_the_previous_core_release(self): + self.write(f"{TYPES_DIR}/package.json", json.dumps({**self.types(), "version": "0.12.5"})) + self.write(f"{TYPES_DIR}/src/types.ts", "b") + self.commit("types change and a manual version") + self.release("7.1.0.12") + self.assertEqual(self.types()["version"], "0.12.2") + + def test_version_must_be_higher_than_the_recorded_one(self): + with self.assertRaises(SystemExit): + self.release("7.1.0.11") + self.assertIn("version: 7.1.0.11\n", self.read(CABAL_FILE)) + + +class PrepareTest(GitRepoTest): + def setUp(self): + super().setUp() + nodejs = { + "name": "simplex-chat", + "version": "7.1.0-beta.6.1", + "dependencies": {"@simplex-chat/types": "^0.12.1"}, + "devDependencies": {"node-addon-api": "^8.5.0"}, + } + self.write(CABAL_FILE, "version: 1.0.0.2\n") + self.write(f"{NODEJS_DIR}/binding.gyp", "{}") + self.write(f"{NODEJS_DIR}/cpp/simplex.cc", "add-on") + self.write(f"{NODEJS_DIR}/package.json", json.dumps(nodejs)) + self.write(RELEASE_FILE, render_release(state().nodejs_pins)) + self.write(f"{TYPES_DIR}/package.json", json.dumps({"name": "@simplex-chat/types", "version": "0.12.1"})) + self.write(PYTHON_VERSION_FILE, render_python_version("7.1.0b6", state().python_pins)) + self.write(f"{PYTHON_DIR}/src/simplex_chat/types/_types.py", "a") + self.write(f"{PYTHON_DIR}/pyproject.toml", 'name = "simplex-chat"\n') + self.commit("packages") + with mock.patch.object(packages_release, "ROOT", self.repo): + sources_hash = packages_release.current_addon_hash() + self.libs = [ + listed_release("core-1.0.0.2", LIBS_ASSETS, 3), + listed_release("nodejs-addon-1111111", ADDON_ASSETS, 4, body=addon_notes(sources_hash)), + ] + self.releases = { + "core": {"master": {"1.0.0.2": self.head()}}, + "npm": {"simplex-chat": {"7.1.0-beta.6.1": self.head()}}, + "pypi": {"simplex-chat": {"7.1.0b6": self.head()}}, + } + + def prepare(self, *packages): + published = {release["tag_name"]: release for release in self.libs} + download = mock.Mock() + with ( + mock.patch.object(packages_release, "ROOT", self.repo), + mock.patch.object(packages_release, "read_releases", return_value=(self.releases, "blob")), + mock.patch.object(packages_release, "libs_releases", return_value=self.libs), + mock.patch.object(packages_release, "github_release", published.get), + mock.patch.object(packages_release, "npm_published", return_value=True), + mock.patch.object(packages_release, "pypi_published", return_value=True), + mock.patch.object(packages_release, "download", download), + contextlib.redirect_stdout(io.StringIO()), + contextlib.redirect_stderr(io.StringIO()), + ): + prepare( + argparse.Namespace(packages=list(packages), branch="master", nodejs_version=None, python_version=None) + ) + return download + + def test_listed_package_is_released_alone(self): + download = self.prepare("nodejs") + pins = parse_release((self.repo / RELEASE_FILE).read_text()) + self.assertEqual((pins.libs_release, pins.addon_release), ("core-1.0.0.2", "nodejs-addon-1111111")) + self.assertEqual(json.loads((self.repo / NODEJS_DIR / "package.json").read_text())["version"], "7.1.0-beta.6.2") + python = parse_python_version((self.repo / PYTHON_VERSION_FILE).read_text()) + self.assertEqual(python, ("7.1.0b6", state().python_pins)) + self.assertEqual(download.call_count, len(LIBS_ASSETS) + len(ADDON_ASSETS)) + + def test_python_types_newer_than_the_pinned_core_stop_the_release(self): + self.write(f"{PYTHON_DIR}/src/simplex_chat/types/_types.py", "b") + self.commit("python types change") + with self.assertRaises(SystemExit): + self.prepare("python") + self.assertEqual(parse_python_version((self.repo / PYTHON_VERSION_FILE).read_text())[0], "7.1.0b6") + + +class DownloadTest(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.directory = directory.name + + def download(self, sha256): + with mock.patch.object(packages_release.urllib.request, "urlopen", return_value=io.BytesIO(b"data")): + return download("core-1.0.0.1", "a.zip", self.directory, sha256) + + def test_matching_digest_keeps_the_file(self): + self.assertEqual(self.download(hashlib.sha256(b"data").hexdigest()).read_bytes(), b"data") + + def test_mismatching_digest_removes_the_file(self): + with self.assertRaises(SystemExit): + self.download(sha(1)) + self.assertFalse((Path(self.directory) / "a.zip").exists()) + + if __name__ == "__main__": unittest.main() diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 16ab1fcb59..747e32db2e 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: 7.1.0.11 +version: 7.1.0.12 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat From 924f2b96449b2fc68532c4e95dc8ab550dfbb302 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 19 Oct 2026 08:32:09 +0100 Subject: [PATCH 05/15] api: fix python code generation (#7764) * api: fix python code generation * fix python types --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- bots/src/API/Docs/Generate/Python.hs | 45 +++++++++---------- .../src/simplex_chat/types/_types.py | 7 +-- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/bots/src/API/Docs/Generate/Python.hs b/bots/src/API/Docs/Generate/Python.hs index 64aa1d1062..8d60bced77 100644 --- a/bots/src/API/Docs/Generate/Python.hs +++ b/bots/src/API/Docs/Generate/Python.hs @@ -60,10 +60,7 @@ commandsCodeText = <> respAliasLine where constrName = T.pack $ fstToUpper tag - classDef = - ("class " <> constrName <> "(TypedDict):\n") - <> bodyOrPass (fieldsCodePy " " "T." params) - <> "\n" + classDef = typedDictCode constrName (fieldsCodePy "T." params) <> "\n" cmdStringFunc = ("\ndef " <> constrName <> "_cmd_string(self: " <> constrName <> ") -> str:\n") <> " return " <> pySelfSyntaxText "T." (fstToUpper tag, params) syntax <> "\n" @@ -143,9 +140,7 @@ typesCodeText = "FileProtocol" -> map toUpper m _ -> m typeDefCode = case typeDef of - ATDRecord fields -> - ("\nclass " <> name' <> "(TypedDict):\n") - <> bodyOrPass (fieldsCodePy " " "" fields) + ATDRecord fields -> "\n" <> typedDictCode name' (fieldsCodePy "" fields) ATDEnum cs -> "\n" <> name' <> " = Literal[" <> T.intercalate ", " (map (\m -> "\"" <> T.pack (enumValue m) <> "\"") $ L.toList cs) @@ -231,9 +226,7 @@ unionTypeCodePy memberName typesNamespace name cs = constrTypeRef (ATUnionMember tag _) = memberName name tag tagLiterals = T.intercalate ", " $ map (\(ATUnionMember tag _) -> "\"" <> T.pack tag <> "\"") $ L.toList cs memberClass (ATUnionMember tag fields) = - ("\nclass " <> memberName name tag <> "(TypedDict):\n") - <> (" type: Literal[\"" <> T.pack tag <> "\"]\n") - <> fieldsCodePy " " typesNamespace fields + "\n" <> typedDictCode (memberName name tag) (("type", "Literal[\"" <> T.pack tag <> "\"]", "") : fieldsCodePy typesNamespace fields) -- | Render the right-hand side of a union alias: either inline (one line) or -- multi-line wrapped in parentheses with `|` separators between alternatives. @@ -248,21 +241,27 @@ unionAliasRhs lhs constr cs (h : t) -> h : map ("| " <>) t cs' = map constr cs --- | Emit a body of `pass` if there are no fields, otherwise the rendered --- fields as-is. -bodyOrPass :: Text -> Text -bodyOrPass body - | T.null body = " pass\n" - | otherwise = body +typedDictCode :: Text -> [(Text, Text, Text)] -> Text +typedDictCode name fields + | any (\(field, _, _) -> field `elem` pyKeywords) fields = + (name <> " = TypedDict(\"" <> name <> "\", {\n") + <> foldMap (\(field, t, comment) -> " \"" <> field <> "\": " <> t <> "," <> comment <> "\n") fields + <> "})\n" + | null fields = "class " <> name <> "(TypedDict):\n pass\n" + | otherwise = + ("class " <> name <> "(TypedDict):\n") + <> foldMap (\(field, t, comment) -> " " <> field <> ": " <> t <> comment <> "\n") fields --- | Render record fields for a TypedDict body. Each field becomes --- `: [ # ]`. Optional fields wrap the type in --- `NotRequired[...]`. -fieldsCodePy :: Text -> Text -> [APIRecordField] -> Text -fieldsCodePy indent namespace = foldMap render +pyKeywords :: [Text] +pyKeywords = + ["False", "None", "True", "and", "as", "assert", "async", "await", "break", "class", "continue", "def", "del", "elif", "else", "except", "finally", "for", "from", "global", "if", "import", "in", "is", "lambda", "nonlocal", "not", "or", "pass", "raise", "return", "try", "while", "with", "yield"] + +-- | Render record fields for a TypedDict as name, type and comment. +-- Optional fields wrap the type in `NotRequired[...]`. +fieldsCodePy :: Text -> [APIRecordField] -> [(Text, Text, Text)] +fieldsCodePy namespace = map render where - render (APIRecordField name t) = - indent <> T.pack name <> ": " <> wrapOptional t (typeText t) <> typeComment t <> "\n" + render (APIRecordField name t) = (T.pack name, wrapOptional t (typeText t), typeComment t) wrapOptional t inner = case t of ATOptional _ -> "NotRequired[" <> inner <> "]" _ -> inner 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 373cf58d43..5457f9c154 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -3071,9 +3071,10 @@ class SecurityCode(TypedDict): securityCode: str verifiedAt: str # ISO-8601 timestamp -class SegmentRange(TypedDict): - from: int # int - to: int # int +SegmentRange = TypedDict("SegmentRange", { + "from": int, # int + "to": int, # int +}) class SimplePreference(TypedDict): allow: "FeatureAllowed" From bdc4edc524de9fea751e6b19e459804347ec3fde Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:07:52 +0400 Subject: [PATCH 06/15] nix: build armv7a from master with ghc 8.10.7 (#7763) --- docs/CONTRIBUTING.md | 17 +- flake.lock | 918 +++++++++++++++++- flake.nix | 134 +-- scripts/android/build-android.sh | 11 +- scripts/desktop/build-lib-linux.sh | 2 +- scripts/desktop/build-lib-mac.sh | 2 +- scripts/desktop/build-lib-windows.sh | 2 +- .../simplex-chat-reproduce-builds-android.sh | 7 +- 8 files changed, 974 insertions(+), 119 deletions(-) diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 3ecfa17409..7dbf4510e9 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -57,11 +57,7 @@ You will have to add `/opt/homebrew/opt/openssl@3.0/bin` to your PATH in order t - `stable` - stable release of the apps, can be used for updates to the previous stable release (GHC 9.6.3). -- `stable-android` - used to build stable Android core library with Nix (GHC 8.10.7) - only for Android armv7a. - -- `master` - branch for beta version releases (compatible with both GHC 9.6.3 and 8.10.7). - -- `master-android` - used to build beta Android core library with Nix (GHC 8.10.7) - only for Android armv7a. +- `master` - branch for beta version releases (compatible with both GHC 9.6.3 and 8.10.7). Nix builds Android armv7a core library with GHC 8.10.7 and all other core libraries with GHC 9.6.3. **In simplexmq repo** @@ -71,18 +67,13 @@ You will have to add `/opt/homebrew/opt/openssl@3.0/bin` to your PATH in order t 1. Make PRs to `master` branch _only_ for both simplex-chat and simplexmq repos. -2. To build core libraries for Android, iOS and Windows: -- merge `master` branch to `master-android` branch. -- push to GitHub. +2. To build core libraries for Android, iOS and Windows, push `master` branch to GitHub. -3. All libraries should be built from `master` branch, Android armv7a - from `master-android` branch. +3. All libraries should be built from `master` branch. 4. To build Desktop and CLI apps, make tag in `master` branch, APK files should be attached to the release. -5. After the public release to App Store and Play Store, merge: -- `master` to `stable` -- `master` to `master-android` (and compile/update code) -- `master-android` to `stable-android` +5. After the public release to App Store and Play Store, merge `master` to `stable`. 6. Independently, `master` branch of simplexmq repo should be merged to `stable` branch on stable releases. diff --git a/flake.lock b/flake.lock index eac7357cd8..2b1d187945 100644 --- a/flake.lock +++ b/flake.lock @@ -16,6 +16,37 @@ "type": "github" } }, + "HTTP_2": { + "flake": false, + "locked": { + "lastModified": 1451647621, + "narHash": "sha256-oHIyw3x0iKBexEo49YeUDV1k74ZtyYKGR2gNJXXRxts=", + "owner": "phadej", + "repo": "HTTP", + "rev": "9bc0996d412fef1787449d841277ef663ad9a915", + "type": "github" + }, + "original": { + "owner": "phadej", + "repo": "HTTP", + "type": "github" + } + }, + "blank": { + "locked": { + "lastModified": 1625557891, + "narHash": "sha256-O8/MWsPBGhhyPoPLHZAuoZiiHo9q6FLlEeIDEXuj6T4=", + "owner": "divnix", + "repo": "blank", + "rev": "5a5d2684073d9f563072ed07c871d577a6c614a8", + "type": "github" + }, + "original": { + "owner": "divnix", + "repo": "blank", + "type": "github" + } + }, "cabal-32": { "flake": false, "locked": { @@ -33,6 +64,23 @@ "type": "github" } }, + "cabal-32_2": { + "flake": false, + "locked": { + "lastModified": 1603716527, + "narHash": "sha256-X0TFfdD4KZpwl0Zr6x+PLxUt/VyKQfX7ylXHdmZIL+w=", + "owner": "haskell", + "repo": "cabal", + "rev": "48bf10787e27364730dd37a42b603cee8d6af7ee", + "type": "github" + }, + "original": { + "owner": "haskell", + "ref": "3.2", + "repo": "cabal", + "type": "github" + } + }, "cabal-34": { "flake": false, "locked": { @@ -50,6 +98,23 @@ "type": "github" } }, + "cabal-34_2": { + "flake": false, + "locked": { + "lastModified": 1645834128, + "narHash": "sha256-wG3d+dOt14z8+ydz4SL7pwGfe7SiimxcD/LOuPCV6xM=", + "owner": "haskell", + "repo": "cabal", + "rev": "5ff598c67f53f7c4f48e31d722ba37172230c462", + "type": "github" + }, + "original": { + "owner": "haskell", + "ref": "3.4", + "repo": "cabal", + "type": "github" + } + }, "cabal-36": { "flake": false, "locked": { @@ -67,6 +132,23 @@ "type": "github" } }, + "cabal-36_2": { + "flake": false, + "locked": { + "lastModified": 1669081697, + "narHash": "sha256-I5or+V7LZvMxfbYgZATU4awzkicBwwok4mVoje+sGmU=", + "owner": "haskell", + "repo": "cabal", + "rev": "8fd619e33d34924a94e691c5fea2c42f0fc7f144", + "type": "github" + }, + "original": { + "owner": "haskell", + "ref": "3.6", + "repo": "cabal", + "type": "github" + } + }, "cardano-shell": { "flake": false, "locked": { @@ -83,6 +165,80 @@ "type": "github" } }, + "cardano-shell_2": { + "flake": false, + "locked": { + "lastModified": 1608537748, + "narHash": "sha256-PulY1GfiMgKVnBci3ex4ptk2UNYMXqGjJOxcPy2KYT4=", + "owner": "input-output-hk", + "repo": "cardano-shell", + "rev": "9392c75087cb9a3d453998f4230930dea3a95725", + "type": "github" + }, + "original": { + "owner": "input-output-hk", + "repo": "cardano-shell", + "type": "github" + } + }, + "devshell": { + "inputs": { + "flake-utils": [ + "haskellNix-ghc8107", + "tullia", + "std", + "flake-utils" + ], + "nixpkgs": [ + "haskellNix-ghc8107", + "tullia", + "std", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1663445644, + "narHash": "sha256-+xVlcK60x7VY1vRJbNUEAHi17ZuoQxAIH4S4iUFUGBA=", + "owner": "numtide", + "repo": "devshell", + "rev": "e3dc3e21594fe07bdb24bdf1c8657acaa4cb8f66", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "devshell", + "type": "github" + } + }, + "dmerge": { + "inputs": { + "nixlib": [ + "haskellNix-ghc8107", + "tullia", + "std", + "nixpkgs" + ], + "yants": [ + "haskellNix-ghc8107", + "tullia", + "std", + "yants" + ] + }, + "locked": { + "lastModified": 1659548052, + "narHash": "sha256-fzI2gp1skGA8mQo/FBFrUAtY0GQkAIAaV/V127TJPyY=", + "owner": "divnix", + "repo": "data-merge", + "rev": "d160d18ce7b1a45b88344aa3f13ed1163954b497", + "type": "github" + }, + "original": { + "owner": "divnix", + "repo": "data-merge", + "type": "github" + } + }, "flake-compat": { "flake": false, "locked": { @@ -100,6 +256,39 @@ "type": "github" } }, + "flake-compat_2": { + "flake": false, + "locked": { + "lastModified": 1672831974, + "narHash": "sha256-z9k3MfslLjWQfnjBtEtJZdq3H7kyi2kQtUThfTgdRk0=", + "owner": "input-output-hk", + "repo": "flake-compat", + "rev": "45f2638735f8cdc40fe302742b79f248d23eb368", + "type": "github" + }, + "original": { + "owner": "input-output-hk", + "ref": "hkm/gitlab-fix", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-compat_3": { + "flake": false, + "locked": { + "lastModified": 1650374568, + "narHash": "sha256-Z+s0J8/r907g149rllvwhb4pKi8Wam5ij0st8PwAh+E=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "b4a34015c698c7793d592d66adbab377907a2be8", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, "flake-parts": { "inputs": { "nixpkgs-lib": "nixpkgs-lib" @@ -136,6 +325,51 @@ "type": "github" } }, + "flake-utils_2": { + "locked": { + "lastModified": 1667395993, + "narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "5aed5285a952e0b949eb3ba02c12fa4fcfef535f", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "flake-utils_3": { + "locked": { + "lastModified": 1653893745, + "narHash": "sha256-0jntwV3Z8//YwuOjzhV2sgJJPt+HY6KhU7VZUL0fKZQ=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "1ed9fb1935d260de5fe1c2f7ee0ebaae17ed2fa1", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "flake-utils_4": { + "locked": { + "lastModified": 1659877975, + "narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, "ghc-8.6.5-iohk": { "flake": false, "locked": { @@ -153,6 +387,23 @@ "type": "github" } }, + "ghc-8.6.5-iohk_2": { + "flake": false, + "locked": { + "lastModified": 1600920045, + "narHash": "sha256-DO6kxJz248djebZLpSzTGD6s8WRpNI9BTwUeOf5RwY8=", + "owner": "input-output-hk", + "repo": "ghc", + "rev": "95713a6ecce4551240da7c96b6176f980af75cae", + "type": "github" + }, + "original": { + "owner": "input-output-hk", + "ref": "release/8.6.5-iohk", + "repo": "ghc", + "type": "github" + } + }, "ghc98X": { "flake": false, "locked": { @@ -190,6 +441,25 @@ "url": "https://gitlab.haskell.org/ghc/ghc" } }, + "gomod2nix": { + "inputs": { + "nixpkgs": "nixpkgs_3", + "utils": "utils" + }, + "locked": { + "lastModified": 1655245309, + "narHash": "sha256-d/YPoQ/vFn1+GTmSdvbSBSTOai61FONxB4+Lt6w/IVI=", + "owner": "tweag", + "repo": "gomod2nix", + "rev": "40d32f82fc60d66402eb0972e6e368aeab3faf58", + "type": "github" + }, + "original": { + "owner": "tweag", + "repo": "gomod2nix", + "type": "github" + } + }, "hackage": { "flake": false, "locked": { @@ -206,6 +476,23 @@ "type": "github" } }, + "hackage-ghc8107": { + "flake": false, + "locked": { + "lastModified": 1702340598, + "narHash": "sha256-CC0HI+6iKPtH+8r/ZfcpW5v/OYvL7zMwpr0xfkXV1zU=", + "owner": "input-output-hk", + "repo": "hackage.nix", + "rev": "24617c569995e38bf3b83b48eec6628a50fdb4fb", + "type": "github" + }, + "original": { + "owner": "input-output-hk", + "repo": "hackage.nix", + "rev": "24617c569995e38bf3b83b48eec6628a50fdb4fb", + "type": "github" + } + }, "haskellNix": { "inputs": { "HTTP": "HTTP", @@ -260,6 +547,50 @@ "type": "github" } }, + "haskellNix-ghc8107": { + "inputs": { + "HTTP": "HTTP_2", + "cabal-32": "cabal-32_2", + "cabal-34": "cabal-34_2", + "cabal-36": "cabal-36_2", + "cardano-shell": "cardano-shell_2", + "flake-compat": "flake-compat_2", + "flake-utils": "flake-utils_2", + "ghc-8.6.5-iohk": "ghc-8.6.5-iohk_2", + "hackage": [ + "hackage-ghc8107" + ], + "hpc-coveralls": "hpc-coveralls_2", + "hydra": "hydra_2", + "iserv-proxy": "iserv-proxy_2", + "nixpkgs": [ + "nixpkgs-ghc8107" + ], + "nixpkgs-2003": "nixpkgs-2003_2", + "nixpkgs-2105": "nixpkgs-2105_2", + "nixpkgs-2111": "nixpkgs-2111_2", + "nixpkgs-2205": "nixpkgs-2205_2", + "nixpkgs-2211": "nixpkgs-2211_2", + "nixpkgs-unstable": "nixpkgs-unstable_2", + "old-ghc-nix": "old-ghc-nix_2", + "stackage": "stackage_2", + "tullia": "tullia" + }, + "locked": { + "lastModified": 1677975916, + "narHash": "sha256-dbe8lEEPyfzjdRwpePClv7J9p9lQg7BwbBqAMCw4RLw=", + "owner": "input-output-hk", + "repo": "haskell.nix", + "rev": "ab5efd87ce3fd8ade38a01d97693d29a4f1ae7e4", + "type": "github" + }, + "original": { + "owner": "input-output-hk", + "repo": "haskell.nix", + "rev": "ab5efd87ce3fd8ade38a01d97693d29a4f1ae7e4", + "type": "github" + } + }, "hls-1.10": { "flake": false, "locked": { @@ -395,6 +726,22 @@ "type": "github" } }, + "hpc-coveralls_2": { + "flake": false, + "locked": { + "lastModified": 1607498076, + "narHash": "sha256-8uqsEtivphgZWYeUo5RDUhp6bO9j2vaaProQxHBltQk=", + "owner": "sevanspowell", + "repo": "hpc-coveralls", + "rev": "14df0f7d229f4cd2e79f8eabb1a740097fdfa430", + "type": "github" + }, + "original": { + "owner": "sevanspowell", + "repo": "hpc-coveralls", + "type": "github" + } + }, "hydra": { "inputs": { "nix": "nix", @@ -418,6 +765,52 @@ "type": "indirect" } }, + "hydra_2": { + "inputs": { + "nix": "nix_2", + "nixpkgs": [ + "haskellNix-ghc8107", + "hydra", + "nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1671755331, + "narHash": "sha256-hXsgJj0Cy0ZiCiYdW2OdBz5WmFyOMKuw4zyxKpgUKm4=", + "owner": "NixOS", + "repo": "hydra", + "rev": "f48f00ee6d5727ae3e488cbf9ce157460853fea8", + "type": "github" + }, + "original": { + "id": "hydra", + "type": "indirect" + } + }, + "incl": { + "inputs": { + "nixlib": [ + "haskellNix-ghc8107", + "tullia", + "std", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1669263024, + "narHash": "sha256-E/+23NKtxAqYG/0ydYgxlgarKnxmDbg6rCMWnOBqn9Q=", + "owner": "divnix", + "repo": "incl", + "rev": "ce7bebaee048e4cd7ebdb4cee7885e00c4e2abca", + "type": "github" + }, + "original": { + "owner": "divnix", + "repo": "incl", + "type": "github" + } + }, "iserv-proxy": { "flake": false, "locked": { @@ -435,6 +828,23 @@ "url": "https://gitlab.haskell.org/hamishmack/iserv-proxy.git" } }, + "iserv-proxy_2": { + "flake": false, + "locked": { + "lastModified": 1670983692, + "narHash": "sha256-avLo34JnI9HNyOuauK5R69usJm+GfW3MlyGlYxZhTgY=", + "ref": "hkm/remote-iserv", + "rev": "50d0abb3317ac439a4e7495b185a64af9b7b9300", + "revCount": 10, + "type": "git", + "url": "https://gitlab.haskell.org/hamishmack/iserv-proxy.git" + }, + "original": { + "ref": "hkm/remote-iserv", + "type": "git", + "url": "https://gitlab.haskell.org/hamishmack/iserv-proxy.git" + } + }, "lowdown-src": { "flake": false, "locked": { @@ -451,10 +861,26 @@ "type": "github" } }, + "lowdown-src_2": { + "flake": false, + "locked": { + "lastModified": 1633514407, + "narHash": "sha256-Dw32tiMjdK9t3ETl5fzGrutQTzh2rufgZV4A/BbxuD4=", + "owner": "kristapsdz", + "repo": "lowdown", + "rev": "d2c2b44ff6c27b936ec27358a2653caaef8f73b8", + "type": "github" + }, + "original": { + "owner": "kristapsdz", + "repo": "lowdown", + "type": "github" + } + }, "mac2ios": { "inputs": { "flake-parts": "flake-parts", - "nixpkgs": "nixpkgs_2" + "nixpkgs": "nixpkgs_6" }, "locked": { "lastModified": 1699767871, @@ -470,6 +896,35 @@ "type": "github" } }, + "n2c": { + "inputs": { + "flake-utils": [ + "haskellNix-ghc8107", + "tullia", + "std", + "flake-utils" + ], + "nixpkgs": [ + "haskellNix-ghc8107", + "tullia", + "std", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1665039323, + "narHash": "sha256-SAh3ZjFGsaCI8FRzXQyp56qcGdAqgKEfJWPCQ0Sr7tQ=", + "owner": "nlewo", + "repo": "nix2container", + "rev": "b008fe329ffb59b67bf9e7b08ede6ee792f2741a", + "type": "github" + }, + "original": { + "owner": "nlewo", + "repo": "nix2container", + "type": "github" + } + }, "nix": { "inputs": { "lowdown-src": "lowdown-src", @@ -491,6 +946,116 @@ "type": "github" } }, + "nix-nomad": { + "inputs": { + "flake-compat": "flake-compat_3", + "flake-utils": [ + "haskellNix-ghc8107", + "tullia", + "nix2container", + "flake-utils" + ], + "gomod2nix": "gomod2nix", + "nixpkgs": [ + "haskellNix-ghc8107", + "tullia", + "nixpkgs" + ], + "nixpkgs-lib": [ + "haskellNix-ghc8107", + "tullia", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1658277770, + "narHash": "sha256-T/PgG3wUn8Z2rnzfxf2VqlR1CBjInPE0l1yVzXxPnt0=", + "owner": "tristanpemble", + "repo": "nix-nomad", + "rev": "054adcbdd0a836ae1c20951b67ed549131fd2d70", + "type": "github" + }, + "original": { + "owner": "tristanpemble", + "repo": "nix-nomad", + "type": "github" + } + }, + "nix2container": { + "inputs": { + "flake-utils": "flake-utils_3", + "nixpkgs": "nixpkgs_4" + }, + "locked": { + "lastModified": 1658567952, + "narHash": "sha256-XZ4ETYAMU7XcpEeAFP3NOl9yDXNuZAen/aIJ84G+VgA=", + "owner": "nlewo", + "repo": "nix2container", + "rev": "60bb43d405991c1378baf15a40b5811a53e32ffa", + "type": "github" + }, + "original": { + "owner": "nlewo", + "repo": "nix2container", + "type": "github" + } + }, + "nix_2": { + "inputs": { + "lowdown-src": "lowdown-src_2", + "nixpkgs": "nixpkgs_2", + "nixpkgs-regression": "nixpkgs-regression_2" + }, + "locked": { + "lastModified": 1661606874, + "narHash": "sha256-9+rpYzI+SmxJn+EbYxjGv68Ucp22bdFUSy/4LkHkkDQ=", + "owner": "NixOS", + "repo": "nix", + "rev": "11e45768b34fdafdcf019ddbd337afa16127ff0f", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "2.11.0", + "repo": "nix", + "type": "github" + } + }, + "nixago": { + "inputs": { + "flake-utils": [ + "haskellNix-ghc8107", + "tullia", + "std", + "flake-utils" + ], + "nixago-exts": [ + "haskellNix-ghc8107", + "tullia", + "std", + "blank" + ], + "nixpkgs": [ + "haskellNix-ghc8107", + "tullia", + "std", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1661824785, + "narHash": "sha256-/PnwdWoO/JugJZHtDUioQp3uRiWeXHUdgvoyNbXesz8=", + "owner": "nix-community", + "repo": "nixago", + "rev": "8c1f9e5f1578d4b2ea989f618588d62a335083c3", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixago", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 1657693803, @@ -523,6 +1088,22 @@ "type": "github" } }, + "nixpkgs-2003_2": { + "locked": { + "lastModified": 1620055814, + "narHash": "sha256-8LEHoYSJiL901bTMVatq+rf8y7QtWuZhwwpKE2fyaRY=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "1db42b7fe3878f3f5f7a4f2dc210772fd080e205", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-20.03-darwin", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs-2105": { "locked": { "lastModified": 1659914493, @@ -539,6 +1120,22 @@ "type": "github" } }, + "nixpkgs-2105_2": { + "locked": { + "lastModified": 1659914493, + "narHash": "sha256-lkA5X3VNMKirvA+SUzvEhfA7XquWLci+CGi505YFAIs=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "022caabb5f2265ad4006c1fa5b1ebe69fb0c3faf", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-21.05-darwin", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs-2111": { "locked": { "lastModified": 1659446231, @@ -555,6 +1152,22 @@ "type": "github" } }, + "nixpkgs-2111_2": { + "locked": { + "lastModified": 1659446231, + "narHash": "sha256-hekabNdTdgR/iLsgce5TGWmfIDZ86qjPhxDg/8TlzhE=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "eabc38219184cc3e04a974fe31857d8e0eac098d", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-21.11-darwin", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs-2205": { "locked": { "lastModified": 1685573264, @@ -571,6 +1184,22 @@ "type": "github" } }, + "nixpkgs-2205_2": { + "locked": { + "lastModified": 1672580127, + "narHash": "sha256-3lW3xZslREhJogoOkjeZtlBtvFMyxHku7I/9IVehhT8=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "0874168639713f547c05947c76124f78441ea46c", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-22.05-darwin", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs-2211": { "locked": { "lastModified": 1688392541, @@ -587,6 +1216,22 @@ "type": "github" } }, + "nixpkgs-2211_2": { + "locked": { + "lastModified": 1675730325, + "narHash": "sha256-uNvD7fzO5hNlltNQUAFBPlcEjNG5Gkbhl/ROiX+GZU4=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b7ce17b1ebf600a72178f6302c77b6382d09323f", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-22.11-darwin", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs-2305": { "locked": { "lastModified": 1705033721, @@ -619,6 +1264,22 @@ "type": "github" } }, + "nixpkgs-ghc8107": { + "locked": { + "lastModified": 1676726892, + "narHash": "sha256-M7OYVR6dKmzmlebIjybFf3l18S2uur8lMyWWnHQooLY=", + "owner": "angerman", + "repo": "nixpkgs", + "rev": "729469087592bdea58b360de59dadf6d58714c42", + "type": "github" + }, + "original": { + "owner": "angerman", + "repo": "nixpkgs", + "rev": "729469087592bdea58b360de59dadf6d58714c42", + "type": "github" + } + }, "nixpkgs-lib": { "locked": { "dir": "lib", @@ -653,6 +1314,22 @@ "type": "github" } }, + "nixpkgs-regression_2": { + "locked": { + "lastModified": 1643052045, + "narHash": "sha256-uGJ0VXIhWKGXxkeNnq4TvV3CIOkUJ3PAoLZ3HMzNVMw=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2", + "type": "github" + } + }, "nixpkgs-unstable": { "locked": { "lastModified": 1694822471, @@ -669,7 +1346,86 @@ "type": "github" } }, + "nixpkgs-unstable_2": { + "locked": { + "lastModified": 1675758091, + "narHash": "sha256-7gFSQbSVAFUHtGCNHPF7mPc5CcqDk9M2+inlVPZSneg=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "747927516efcb5e31ba03b7ff32f61f6d47e7d87", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs_2": { + "locked": { + "lastModified": 1657693803, + "narHash": "sha256-G++2CJ9u0E7NNTAi9n5G8TdDmGJXcIjkJ3NF8cetQB8=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "365e1b3a859281cf11b94f87231adeabbdd878a2", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-22.05-small", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_3": { + "locked": { + "lastModified": 1653581809, + "narHash": "sha256-Uvka0V5MTGbeOfWte25+tfRL3moECDh1VwokWSZUdoY=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "83658b28fe638a170a19b8933aa008b30640fbd1", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_4": { + "locked": { + "lastModified": 1654807842, + "narHash": "sha256-ADymZpr6LuTEBXcy6RtFHcUZdjKTBRTMYwu19WOx17E=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fc909087cc3386955f21b4665731dbdaceefb1d8", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_5": { + "locked": { + "lastModified": 1665087388, + "narHash": "sha256-FZFPuW9NWHJteATOf79rZfwfRn5fE0wi9kRzvGfDHPA=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "95fda953f6db2e9496d2682c4fc7b82f959878f7", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_6": { "locked": { "lastModified": 1698434055, "narHash": "sha256-Phxi5mUKSoL7A0IYUiYtkI9e8NcGaaV5PJEaJApU1Ko=", @@ -685,6 +1441,21 @@ "type": "github" } }, + "nosys": { + "locked": { + "lastModified": 1667881534, + "narHash": "sha256-FhwJ15uPLRsvaxtt/bNuqE/ykMpNAPF0upozFKhTtXM=", + "owner": "divnix", + "repo": "nosys", + "rev": "2d0d5207f6a230e9d0f660903f8db9807b54814f", + "type": "github" + }, + "original": { + "owner": "divnix", + "repo": "nosys", + "type": "github" + } + }, "old-ghc-nix": { "flake": false, "locked": { @@ -702,16 +1473,36 @@ "type": "github" } }, + "old-ghc-nix_2": { + "flake": false, + "locked": { + "lastModified": 1631092763, + "narHash": "sha256-sIKgO+z7tj4lw3u6oBZxqIhDrzSkvpHtv0Kki+lh9Fg=", + "owner": "angerman", + "repo": "old-ghc-nix", + "rev": "af48a7a7353e418119b6dfe3cd1463a657f342b8", + "type": "github" + }, + "original": { + "owner": "angerman", + "ref": "master", + "repo": "old-ghc-nix", + "type": "github" + } + }, "root": { "inputs": { "flake-utils": "flake-utils", "hackage": "hackage", + "hackage-ghc8107": "hackage-ghc8107", "haskellNix": "haskellNix", + "haskellNix-ghc8107": "haskellNix-ghc8107", "mac2ios": "mac2ios", "nixpkgs": [ "haskellNix", "nixpkgs-2305" - ] + ], + "nixpkgs-ghc8107": "nixpkgs-ghc8107" } }, "stackage": { @@ -730,6 +1521,67 @@ "type": "github" } }, + "stackage_2": { + "flake": false, + "locked": { + "lastModified": 1677888571, + "narHash": "sha256-YkhRNOaN6QVagZo1cfykYV8KqkI8/q6r2F5+jypOma4=", + "owner": "input-output-hk", + "repo": "stackage.nix", + "rev": "cb50e6fabdfb2d7e655059039012ad0623f06a27", + "type": "github" + }, + "original": { + "owner": "input-output-hk", + "repo": "stackage.nix", + "type": "github" + } + }, + "std": { + "inputs": { + "arion": [ + "haskellNix-ghc8107", + "tullia", + "std", + "blank" + ], + "blank": "blank", + "devshell": "devshell", + "dmerge": "dmerge", + "flake-utils": "flake-utils_4", + "incl": "incl", + "makes": [ + "haskellNix-ghc8107", + "tullia", + "std", + "blank" + ], + "microvm": [ + "haskellNix-ghc8107", + "tullia", + "std", + "blank" + ], + "n2c": "n2c", + "nixago": "nixago", + "nixpkgs": "nixpkgs_5", + "nosys": "nosys", + "yants": "yants" + }, + "locked": { + "lastModified": 1674526466, + "narHash": "sha256-tMTaS0bqLx6VJ+K+ZT6xqsXNpzvSXJTmogkraBGzymg=", + "owner": "divnix", + "repo": "std", + "rev": "516387e3d8d059b50e742a2ff1909ed3c8f82826", + "type": "github" + }, + "original": { + "owner": "divnix", + "repo": "std", + "type": "github" + } + }, "systems": { "locked": { "lastModified": 1681028828, @@ -744,6 +1596,68 @@ "repo": "default", "type": "github" } + }, + "tullia": { + "inputs": { + "nix-nomad": "nix-nomad", + "nix2container": "nix2container", + "nixpkgs": [ + "haskellNix-ghc8107", + "nixpkgs" + ], + "std": "std" + }, + "locked": { + "lastModified": 1675695930, + "narHash": "sha256-B7rEZ/DBUMlK1AcJ9ajnAPPxqXY6zW2SBX+51bZV0Ac=", + "owner": "input-output-hk", + "repo": "tullia", + "rev": "621365f2c725608f381b3ad5b57afef389fd4c31", + "type": "github" + }, + "original": { + "owner": "input-output-hk", + "repo": "tullia", + "type": "github" + } + }, + "utils": { + "locked": { + "lastModified": 1653893745, + "narHash": "sha256-0jntwV3Z8//YwuOjzhV2sgJJPt+HY6KhU7VZUL0fKZQ=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "1ed9fb1935d260de5fe1c2f7ee0ebaae17ed2fa1", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "yants": { + "inputs": { + "nixpkgs": [ + "haskellNix-ghc8107", + "tullia", + "std", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1667096281, + "narHash": "sha256-wRRec6ze0gJHmGn6m57/zhz/Kdvp9HS4Nl5fkQ+uIuA=", + "owner": "divnix", + "repo": "yants", + "rev": "d18f356ec25cb94dc9c275870c3a7927a10f8c3c", + "type": "github" + }, + "original": { + "owner": "divnix", + "repo": "yants", + "type": "github" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index 84f84c34ec..26ece60cbf 100644 --- a/flake.nix +++ b/flake.nix @@ -8,8 +8,17 @@ flake = false; }; inputs.haskellNix.inputs.hackage.follows = "hackage"; + # armv7a android is built with GHC 8.10.7; its toolchain is pinned by revision so updates of the inputs above don't move it. + inputs.haskellNix-ghc8107.url = "github:input-output-hk/haskell.nix/ab5efd87ce3fd8ade38a01d97693d29a4f1ae7e4"; + inputs.nixpkgs-ghc8107.url = "github:angerman/nixpkgs/729469087592bdea58b360de59dadf6d58714c42"; + inputs.hackage-ghc8107 = { + url = "github:input-output-hk/hackage.nix/24617c569995e38bf3b83b48eec6628a50fdb4fb"; + flake = false; + }; + inputs.haskellNix-ghc8107.inputs.nixpkgs.follows = "nixpkgs-ghc8107"; + inputs.haskellNix-ghc8107.inputs.hackage.follows = "hackage-ghc8107"; inputs.flake-utils.url = "github:numtide/flake-utils"; - outputs = { self, haskellNix, nixpkgs, flake-utils, mac2ios, ... }: + outputs = { self, haskellNix, haskellNix-ghc8107, nixpkgs, flake-utils, mac2ios, ... }: let systems = [ "x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin" ]; in flake-utils.lib.eachSystem systems (system: # this android26 overlay makes the pkgsCross.{aarch64-android,armv7a-android-prebuilt} to set stdVer to 26 (Android 8). @@ -29,8 +38,9 @@ }; in # `appendOverlays` with a singleton is identical to `extend`. let pkgs = haskellNix.legacyPackages.${system}.appendOverlays [android26]; in - let drv' = { extra-modules, pkgs', ... }: pkgs'.haskell-nix.project { - compiler-nix-name = "ghc963"; + let pkgs8107 = haskellNix-ghc8107.legacyPackages.${system}.appendOverlays [android26]; in + let drv' = { extra-modules, pkgs', compiler-nix-name ? "ghc963", ... }: pkgs'.haskell-nix.project { + inherit compiler-nix-name; index-state = "2023-12-12T00:00:00Z"; # We need this, to specify we want the cabal project. # If the stack.yaml was dropped, this would not be necessary. @@ -126,16 +136,20 @@ "x86_64-linux" = let androidPkgs = pkgs.pkgsCross.aarch64-android; - android32Pkgs = pkgs.pkgsCross.armv7a-android-prebuilt; + android32Pkgs = pkgs8107.pkgsCross.armv7a-android-prebuilt; # For some reason building libiconv with nixpgks android setup produces # LANGINFO_CODESET to be found, which is not compatible with android sdk 23; # so we'll patch up iconv to not include that. - androidIconv = (androidPkgs.libiconv.override { enableStatic = true; }).overrideAttrs (old: { + mkAndroidIconv = androidPkgs: (androidPkgs.libiconv.override { enableStatic = true; }).overrideAttrs (old: { postConfigure = '' echo "#undef HAVE_LANGINFO_CODESET" >> libcharset/config.h echo "#undef HAVE_LANGINFO_CODESET" >> lib/config.h ''; }); + androidIconv = mkAndroidIconv androidPkgs; + # Only interpolated in a commented-out line of armv7a postInstall; it must come from the + # GHC 8.10.7 nixpkgs to keep that derivation identical to the former master-android build. + androidIconv8107 = mkAndroidIconv pkgs8107.pkgsCross.aarch64-android; # Similarly to icovn, for reasons beyond my current knowledge, nixpkgs andorid # toolchain makes configure believe we have MEMFD_CREATE, which we don't in # sdk 23. @@ -275,37 +289,22 @@ # STATIC aarch64-linux "${pkgs.pkgsCross.aarch64-multiplatform-musl.hostPlatform.system}-static:exe:simplex-chat" = (drv pkgs.pkgsCross.aarch64-multiplatform-musl).simplex-chat.components.exes.simplex-chat; - "armv7a-android:lib:support" = (drv android32Pkgs).android-support.components.library.override (p: { - smallAddressSpace = true; - # we won't want -dyamic (see aarch64-android:lib:simplex-chat) - enableShared = false; - # we also do not want to have any dependencies listed (especially no rts!) - enableStatic = false; - - # This used to work with 8.10.7... - # setupBuildFlags = p.component.setupBuildFlags ++ map (x: "--ghc-option=${x}") [ "-shared" "-o" "libsupport.so" ]; - # ... but now with 9.6+ - # we have to do the -shared thing by hand. - postBuild = '' - armv7a-unknown-linux-androideabi-ghc -shared -o libsupport.so \ - -optl-Wl,-u,setLineBuffering \ - -optl-Wl,-u,pipe_std_to_socket \ - dist/build/*.a - ''; - + "armv7a-android:lib:support" = (drv' { pkgs' = android32Pkgs; extra-modules = []; compiler-nix-name = "ghc8107"; }).android-support.components.library.override { + smallAddressSpace = true; enableShared = false; + setupBuildFlags = map (x: "--ghc-option=${x}") [ "-shared" "-o" "libsupport.so" ]; postInstall = '' mkdir -p $out/_pkg cp libsupport.so $out/_pkg - ${pkgs.patchelf}/bin/patchelf --remove-needed libunwind.so.1 $out/_pkg/libsupport.so - (cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 $out/pkg-armv7a-android-libsupport.zip *) + ${pkgs8107.patchelf}/bin/patchelf --remove-needed libunwind.so.1 $out/_pkg/libsupport.so + (cd $out/_pkg; ${pkgs8107.zip}/bin/zip -r -9 $out/pkg-armv7a-android-libsupport.zip *) rm -fR $out/_pkg mkdir -p $out/nix-support echo "file binary-dist \"$(echo $out/*.zip)\"" \ > $out/nix-support/hydra-build-products ''; - }); + }; # The android-support package is at # https://github.com/simplex-chat/android-support "aarch64-android:lib:support" = (drv androidPkgs).android-support.components.library.override (p: { @@ -340,10 +339,10 @@ }); "armv7a-android:lib:simplex-chat" = (drv' { pkgs' = android32Pkgs; + compiler-nix-name = "ghc8107"; extra-modules = [{ - packages.text.flags.simdutf = false; packages.direct-sqlcipher.flags.openssl = true; - packages.direct-sqlcipher.components.library.libs = pkgs.lib.mkForce [ + packages.direct-sqlcipher.components.library.libs = pkgs8107.lib.mkForce [ (android32Pkgs.openssl.override { static = true; enableKTLS = false; }) ]; packages.direct-sqlcipher.patches = [ @@ -351,70 +350,21 @@ ]; packages.simplex-chat.flags.client_library = true; packages.simplexmq.flags.client_library = true; - packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ + packages.simplexmq.components.library.libs = pkgs8107.lib.mkForce [ (android32Pkgs.openssl.override { static = true; enableKTLS = false; }) ]; - # 32 bit patches - packages.basement.patches = [ - ./scripts/nix/basement-pr-573.patch - ]; - packages.memory.patches = [ - ./scripts/nix/memory-pr-99.patch - ]; }]; - }).simplex-chat.components.library.override (p: { - smallAddressSpace = true; - # we want -shared, but not -dyanmic, hence `enableShared = false`. - enableShared = false; - # we _do_ want rts, and other libs. Hence `enableStatic = true`. - enableStatic = true; + }).simplex-chat.components.library.override { + smallAddressSpace = true; enableShared = false; # for android we build a shared library, passing these arguments is a bit tricky, as # we want only the threaded rts (HSrts_thr) and ffi to be linked, but not fed into iserv for # template haskell cross compilation. Thus we just pass them as linker options (-optl). - setupBuildFlags = p.component.setupBuildFlags - # flags to tell GHC we want to produce a -shared object, and we want to also link - # - the ffi library (ffi) - ++ map (x: "--ghc-option=${x}") [ - "-shared" "-o" "libsimplex.so" - "-threaded" - # "-debug" - "-optl-lffi" - "-j1" - ] - # This is fairly idiotic. LLD will strip out foreign exported - # symbols (a GHC bug? Codegen bug?). So we need to pass `-u ` - # to ensure they stay in the produced library. Having them - # _undefined_ and _lazy_ (lld will tell with -y that the - # symbol is lazy), makes them _defined_. m( - ++ map (sym: "--ghc-option=-optl-Wl,-u,${sym}") [ - "chat_close_store" - "chat_decrypt_file" - "chat_decrypt_media" - "chat_encrypt_file" - "chat_encrypt_media" - "chat_migrate_init" - "chat_migrate_init_queue" - "chat_parse_markdown" - "chat_parse_server" - "chat_parse_uri" - "chat_password_hash" - "chat_read_file" - "chat_recv_msg" - "chat_recv_msg_wait" - "chat_send_cmd" - "chat_send_cmd_retry" - "chat_send_remote_cmd" - "chat_send_remote_cmd_retry" - "chat_valid_name" - "chat_parse_badge_code" - "chat_json_length" - "chat_badge_keygen" - "chat_badge_issue" - "chat_write_file" - ]; + # 32-bit ARM only: processAgentMessageConn is too large for ARM's PC-relative constant-pool + # range at -O2; -O1 is appended after the cabal -O2 here, so it wins (aarch64 stays -O2). + setupBuildFlags = map (x: "--ghc-option=${x}") [ "-shared" "-o" "libsimplex.so" "-optl-lHSrts_thr" "-optl-lffi" "-j1" "-O1"]; postInstall = '' set -x - ${pkgs.tree}/bin/tree $out + ${pkgs8107.tree}/bin/tree $out mkdir -p $out/_pkg # copy over includes, we might want those, but maybe not. # cp -r $out/lib/*/*/include $out/_pkg/ @@ -425,7 +375,7 @@ # find ./dist -name "libHS*-ghc*.a" -exec cp {} $out/_pkg \; # find ${android32FFI}/lib -name "*.a" -exec cp {} $out/_pkg \; # find ${android32Pkgs.gmp6.override { withStatic = true; }}/lib -name "*.a" -exec cp {} $out/_pkg \; - # find ${androidIconv}/lib -name "*.a" -exec cp {} $out/_pkg \; + # find ${androidIconv8107}/lib -name "*.a" -exec cp {} $out/_pkg \; # find ${android32Pkgs.stdenv.cc.libc}/lib -name "*.a" -exec cp {} $out/_pkg \; echo ${android32Pkgs.openssl.override { enableKTLS = false; }} find ${(android32Pkgs.openssl.override { enableKTLS = false; }).out}/lib -name "*.so" -exec cp {} $out/_pkg \; @@ -433,22 +383,22 @@ # remove the .1 and other version suffixes from .so's. Androids linker # doesn't play nice with them. for lib in $out/_pkg/*.so; do - for dep in $(${pkgs.patchelf}/bin/patchelf --print-needed "$lib"); do + for dep in $(${pkgs8107.patchelf}/bin/patchelf --print-needed "$lib"); do if [[ "''${dep##*.so}" ]]; then echo "$lib : $dep -> ''${dep%%.so*}.so" chmod +w "$lib" - ${pkgs.patchelf}/bin/patchelf --replace-needed "$dep" "''${dep%%.so*}.so" "$lib" + ${pkgs8107.patchelf}/bin/patchelf --replace-needed "$dep" "''${dep%%.so*}.so" "$lib" fi done done for lib in $out/_pkg/*.so; do chmod +w "$lib" - ${pkgs.patchelf}/bin/patchelf --remove-needed libunwind.so "$lib" - [[ "$lib" != *libsimplex.so ]] && ${pkgs.patchelf}/bin/patchelf --set-soname "$(basename -a $lib)" "$lib" + ${pkgs8107.patchelf}/bin/patchelf --remove-needed libunwind.so "$lib" + [[ "$lib" != *libsimplex.so ]] && ${pkgs8107.patchelf}/bin/patchelf --set-soname "$(basename -a $lib)" "$lib" done - ${pkgs.tree}/bin/tree $out/_pkg + ${pkgs8107.tree}/bin/tree $out/_pkg # Strip from debug symbols find "$out/_pkg" -type f -name "*.so" -exec ${android32Pkgs.stdenv.cc.targetPrefix}strip --strip-unneeded {} + @@ -458,13 +408,13 @@ find "$out/_pkg" -type d -exec chmod 755 {} + find "$out/_pkg" -exec touch -h -d '@1764547200' {} + - (cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 -X $out/pkg-armv7a-android-libsimplex.zip *) + (cd $out/_pkg; ${pkgs8107.zip}/bin/zip -r -9 -X $out/pkg-armv7a-android-libsimplex.zip *) rm -fR $out/_pkg mkdir -p $out/nix-support echo "file binary-dist \"$(echo $out/*.zip)\"" \ > $out/nix-support/hydra-build-products ''; - }); + }; "aarch64-android:lib:simplex-chat" = (drv' { pkgs' = androidPkgs; extra-modules = [{ diff --git a/scripts/android/build-android.sh b/scripts/android/build-android.sh index 267db9f243..7542ed3205 100755 --- a/scripts/android/build-android.sh +++ b/scripts/android/build-android.sh @@ -105,14 +105,9 @@ build() { sed -i.bak "s/android.version_code=.*/android.version_code=${vercode}/" "$folder/apps/multiplatform/gradle.properties" for arch in $arches; do - if [ "$arch" = "armv7a" ]; then - android_simplex_lib="${folder}#hydraJobs.${arch}-android:lib:simplex-chat.x86_64-linux" - android_support_lib="${folder}#hydraJobs.${arch}-android:lib:support.x86_64-linux" - else - android_simplex_lib="${folder}#hydraJobs.x86_64-linux.${arch}-android:lib:simplex-chat" - android_support_lib="${folder}#hydraJobs.x86_64-linux.${arch}-android:lib:support" - fi - + android_simplex_lib="${folder}#hydraJobs.x86_64-linux.${arch}-android:lib:simplex-chat" + android_support_lib="${folder}#hydraJobs.x86_64-linux.${arch}-android:lib:support" + android_simplex_lib_output="${PWD}/result/pkg-${arch}-android-libsimplex.zip" android_support_lib_output="${PWD}/result/pkg-${arch}-android-libsupport.zip" diff --git a/scripts/desktop/build-lib-linux.sh b/scripts/desktop/build-lib-linux.sh index ea5fc9b760..3006f5aad0 100755 --- a/scripts/desktop/build-lib-linux.sh +++ b/scripts/desktop/build-lib-linux.sh @@ -24,7 +24,7 @@ BUILD_DIR=$DIST_DIR/build/$ARCH-$OS/ghc-${GHC_VERSION}/simplex-chat-* exports=( $(sed 's/foreign export ccall "chat_migrate_init_key"//' src/Simplex/Chat/Mobile.hs | sed 's/foreign export ccall "chat_reopen_store"//' |grep "foreign export ccall" | cut -d '"' -f2) ) for elem in "${exports[@]}"; do count=$(grep -R "$elem$" libsimplex.dll.def | wc -l); if [ $count -ne 1 ]; then echo Wrong exports in libsimplex.dll.def. Add \"$elem\" to that file; exit 1; fi ; done -for elem in "${exports[@]}"; do count=$(grep -R "\"$elem\"" flake.nix | wc -l); if [ $count -ne 2 ]; then echo Wrong exports in flake.nix. Add \"$elem\" in two places of the file; exit 1; fi ; done +for elem in "${exports[@]}"; do count=$(grep -R "\"$elem\"" flake.nix | wc -l); if [ $count -ne 1 ]; then echo Wrong exports in flake.nix. Add \"$elem\" to aarch64-android:lib:simplex-chat; exit 1; fi ; done #rm -rf $BUILD_DIR if [[ "$DATABASE_BACKEND" == "postgres" ]]; then diff --git a/scripts/desktop/build-lib-mac.sh b/scripts/desktop/build-lib-mac.sh index 5563086c29..e69aacd422 100755 --- a/scripts/desktop/build-lib-mac.sh +++ b/scripts/desktop/build-lib-mac.sh @@ -22,7 +22,7 @@ BUILD_DIR=dist-newstyle/build/$ARCH-*/ghc-*/simplex-chat-* exports=( $(sed 's/foreign export ccall "chat_migrate_init_key"//' src/Simplex/Chat/Mobile.hs | sed 's/foreign export ccall "chat_reopen_store"//' |grep "foreign export ccall" | cut -d '"' -f2) ) for elem in "${exports[@]}"; do count=$(grep -R "$elem$" libsimplex.dll.def | wc -l); if [ $count -ne 1 ]; then echo Wrong exports in libsimplex.dll.def. Add \"$elem\" to that file; exit 1; fi ; done -for elem in "${exports[@]}"; do count=$(grep -R "\"$elem\"" flake.nix | wc -l); if [ $count -ne 2 ]; then echo Wrong exports in flake.nix. Add \"$elem\" in two places of the file; exit 1; fi ; done +for elem in "${exports[@]}"; do count=$(grep -R "\"$elem\"" flake.nix | wc -l); if [ $count -ne 1 ]; then echo Wrong exports in flake.nix. Add \"$elem\" to aarch64-android:lib:simplex-chat; exit 1; fi ; done rm -rf $BUILD_DIR diff --git a/scripts/desktop/build-lib-windows.sh b/scripts/desktop/build-lib-windows.sh index 15e021adf7..ad62b2d209 100755 --- a/scripts/desktop/build-lib-windows.sh +++ b/scripts/desktop/build-lib-windows.sh @@ -19,7 +19,7 @@ BUILD_DIR=dist-newstyle/build/$ARCH-$OS/ghc-*/simplex-chat-* exports=( $(sed 's/foreign export ccall "chat_migrate_init_key"//' src/Simplex/Chat/Mobile.hs | sed 's/foreign export ccall "chat_reopen_store"//' |grep "foreign export ccall" | cut -d '"' -f2) ) for elem in "${exports[@]}"; do count=$(grep -R "$elem$" libsimplex.dll.def | wc -l); if [ $count -ne 1 ]; then echo Wrong exports in libsimplex.dll.def. Add \"$elem\" to that file; exit 1; fi ; done -for elem in "${exports[@]}"; do count=$(grep -R "\"$elem\"" flake.nix | wc -l); if [ $count -ne 2 ]; then echo Wrong exports in flake.nix. Add \"$elem\" in two places of the file; exit 1; fi ; done +for elem in "${exports[@]}"; do count=$(grep -R "\"$elem\"" flake.nix | wc -l); if [ $count -ne 1 ]; then echo Wrong exports in flake.nix. Add \"$elem\" to aarch64-android:lib:simplex-chat; exit 1; fi ; done # IMPORTANT: in order to get a working build you should use x86_64 MinGW with make, cmake, gcc. # 100% working MinGW is https://github.com/brechtsanders/winlibs_mingw/releases/download/13.1.0-16.0.5-11.0.0-ucrt-r5/winlibs-x86_64-posix-seh-gcc-13.1.0-mingw-w64ucrt-11.0.0-r5.zip diff --git a/scripts/simplex-chat-reproduce-builds-android.sh b/scripts/simplex-chat-reproduce-builds-android.sh index 4bd7262d17..45bf2a6936 100755 --- a/scripts/simplex-chat-reproduce-builds-android.sh +++ b/scripts/simplex-chat-reproduce-builds-android.sh @@ -246,7 +246,12 @@ Continue?' for arch in $ARCHES; do case "$arch" in armv7a) - build_tag="${tag}-armv7a" + # Releases made before the master-android branch was merged have armv7a in a separate tag. + if git -C "$build_directory" rev-parse -q --verify "refs/tags/${tag}-armv7a" > /dev/null; then + build_tag="${tag}-armv7a" + else + build_tag="${tag}" + fi ;; aarch64) build_tag="${tag}" From 0eeac1bb920f460820acea4c43f4b909da5b7643 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:00:31 +0400 Subject: [PATCH 07/15] docs: normalize badge in-app checkout wireframe --- ...026-09-23-badge-in-app-checkout.excalidraw | 9588 +---------------- 1 file changed, 243 insertions(+), 9345 deletions(-) diff --git a/plans/sketches/2026-09-23-badge-in-app-checkout.excalidraw b/plans/sketches/2026-09-23-badge-in-app-checkout.excalidraw index 25caf53711..3e48751677 100644 --- a/plans/sketches/2026-09-23-badge-in-app-checkout.excalidraw +++ b/plans/sketches/2026-09-23-badge-in-app-checkout.excalidraw @@ -3,9350 +3,248 @@ "version": 2, "source": "https://excalidraw-jetbrains-plugin", "elements": [ - { - "id": "hdr-bg", - "type": "rectangle", - "x": 0, - "y": 0, - "width": 2500, - "height": 96, - "strokeColor": "transparent", - "backgroundColor": "#1c1e26", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": null, - "groupIds": [ - "header" - ], - "seed": 1001, - "index": "a000", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "hdr-title", - "type": "text", - "x": 40, - "y": 24, - "width": 620, - "height": 28, - "strokeColor": "#ffffff", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "header" - ], - "seed": 1002, - "text": "Buying a badge - hidden codes", - "fontSize": 22, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a001", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Buying a badge - hidden codes", - "lineHeight": 1.2727272727272727 - }, - { - "id": "hdr-sub", - "type": "text", - "x": 40, - "y": 58, - "width": 1500, - "height": 15, - "strokeColor": "#a0a6b0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "header" - ], - "seed": 1003, - "text": "the shipped code purchase, reused whole: the page still mints a code, the deep link carries it, and the buyer never sees it. solid = exists, dashed = does not", - "fontSize": 12, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a002", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "the shipped code purchase, reused whole: the page still mints a code, the deep link carries it, and the buyer never sees it. solid = exists, dashed = does not", - "lineHeight": 1.25 - }, - { - "id": "hdr-r1", - "type": "text", - "x": 2260, - "y": 26, - "width": 200, - "height": 15, - "strokeColor": "#a0a6b0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "header" - ], - "seed": 1004, - "text": "in-app checkout", - "fontSize": 12, - "fontFamily": 2, - "textAlign": "right", - "verticalAlign": "top", - "autoResize": false, - "index": "a003", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "in-app checkout", - "lineHeight": 1.25 - }, - { - "id": "hdr-r2", - "type": "text", - "x": 2260, - "y": 48, - "width": 200, - "height": 14, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "header" - ], - "seed": 1005, - "text": "wireframes", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "right", - "verticalAlign": "top", - "autoResize": false, - "index": "a004", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "wireframes", - "lineHeight": 1.2727272727272727 - }, - { - "id": "l1-band", - "type": "rectangle", - "x": 20, - "y": 150, - "width": 2460, - "height": 850, - "strokeColor": "transparent", - "backgroundColor": "#eef5ff", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "lane1" - ], - "seed": 1010, - "index": "a010", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "l1-head", - "type": "text", - "x": 60, - "y": 178, - "width": 900, - "height": 22, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "lane1" - ], - "seed": 1011, - "text": "Non-store builds - the browser does all of it", - "fontSize": 17, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a011", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Non-store builds - the browser does all of it", - "lineHeight": 1.2941176470588236 - }, - { - "id": "l1-sub", - "type": "text", - "x": 60, - "y": 206, - "width": 1300, - "height": 14, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "lane1" - ], - "seed": 1012, - "text": "no catalog API, no invoice API, no in-app checkout: the app opens the page, the page sells a code, the deep link brings it home", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a012", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "no catalog API, no invoice API, no in-app checkout: the app opens the page, the page sells a code, the deep link brings it home", - "lineHeight": 1.2727272727272727 - }, - { - "id": "a1-panel", - "type": "rectangle", - "x": 60, - "y": 260, - "width": 300, - "height": 620, - "strokeColor": "#8f97a3", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A1", - "lane1" - ], - "seed": 1101, - "boundElements": [ - { - "id": "ar-a1a2", - "type": "arrow" - } - ], - "index": "a020", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a1-title", - "type": "text", - "x": 80, - "y": 306, - "width": 260, - "height": 24, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A1", - "lane1" - ], - "seed": 1102, - "text": "Support SimpleX", - "fontSize": 19, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a021", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Support SimpleX", - "lineHeight": 1.263157894736842 - }, - { - "id": "a1-body", - "type": "text", - "x": 76, - "y": 348, - "width": 268, - "height": 39, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A1", - "lane1" - ], - "seed": 1103, - "text": "Get a badge to send larger files (2-5GB)\nthat stay available longer (7-21 days),\nand to show it on your profile.", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a022", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Get a badge to send larger files (2-5GB)\nthat stay available longer (7-21 days),\nand to show it on your profile.", - "lineHeight": 1.3 - }, - { - "id": "a1-info", - "type": "text", - "x": 80, - "y": 404, - "width": 260, - "height": 13, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A1", - "lane1" - ], - "seed": 1104, - "text": "(i) How badges protect your privacy", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a023", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "(i) How badges protect your privacy", - "lineHeight": 1.3 - }, - { - "id": "a1-hero", - "type": "rectangle", - "x": 132, - "y": 448, - "width": 156, - "height": 156, - "strokeColor": "transparent", - "backgroundColor": "#e8eef7", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A1", - "lane1" - ], - "seed": 1105, - "index": "a024", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a1-herot", - "type": "text", - "x": 132, - "y": 519, - "width": 156, - "height": 12, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A1", - "lane1" - ], - "seed": 1106, - "text": "phone-supporter", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a025", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "phone-supporter", - "lineHeight": 1.3333333333333333 - }, - { - "id": "a1-btn", - "type": "rectangle", - "x": 90, - "y": 730, - "width": 240, - "height": 38, - "strokeColor": "transparent", - "backgroundColor": "#0053d0", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A1", - "lane1" - ], - "seed": 1107, - "boundElements": [ - { - "id": "a1-btnt", - "type": "text" - } - ], - "index": "a026", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a1-btnt", - "type": "text", - "x": 100, - "y": 742, - "width": 220, - "height": 14, - "strokeColor": "#ffffff", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A1", - "lane1" - ], - "seed": 1108, - "text": "Buy in browser", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "a1-btn", - "index": "a027", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "originalText": "Buy in browser", - "autoResize": true, - "lineHeight": 1.2727272727272727 - }, - { - "id": "a1-sec", - "type": "text", - "x": 80, - "y": 786, - "width": 260, - "height": 13, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A1", - "lane1" - ], - "seed": 1109, - "text": "Redeem code", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a028", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Redeem code", - "lineHeight": 1.3 - }, - { - "id": "a1-secn", - "type": "text", - "x": 80, - "y": 806, - "width": 260, - "height": 12, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A1", - "lane1" - ], - "seed": 1112, - "text": "the manual path, for a code from elsewhere", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a029", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "the manual path, for a code from elsewhere", - "lineHeight": 1.3333333333333333 - }, - { - "id": "a1-capt", - "type": "text", - "x": 60, - "y": 900, - "width": 300, - "height": 14, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A1", - "lane1" - ], - "seed": 1110, - "text": "A1. BadgesSupportSimplexView", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a02a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "A1. BadgesSupportSimplexView", - "lineHeight": 1.2727272727272727 - }, - { - "id": "a1-capb", - "type": "text", - "x": 60, - "y": 920, - "width": 340, - "height": 66, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A1", - "lane1" - ], - "seed": 1111, - "text": "Get your code becomes the primary button and loses the\nconfirm-in-browser alert - the tap opens the browser. Redeem\nbadge code drops to the text button. The info button stays as\nit is - the file's TODO would swap it for Why SimpleX is built\nonce purchase lands, and it should not.\nchooseLevelButton() is never called in this build.", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a02b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Get your code becomes the primary button and loses the\nconfirm-in-browser alert - the tap opens the browser. Redeem\nbadge code drops to the text button. The info button stays as\nit is - the file's TODO would swap it for Why SimpleX is built\nonce purchase lands, and it should not.\nchooseLevelButton() is never called in this build.", - "lineHeight": 1.2222222222222223 - }, - { - "id": "a2-panel", - "type": "rectangle", - "x": 480, - "y": 260, - "width": 300, - "height": 620, - "strokeColor": "#8f97a3", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A2", - "lane1" - ], - "seed": 1201, - "boundElements": [ - { - "id": "ar-a1a2", - "type": "arrow" - }, - { - "id": "ar-a2a3", - "type": "arrow" - } - ], - "index": "a030", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a2-url", - "type": "rectangle", - "x": 480, - "y": 260, - "width": 300, - "height": 26, - "strokeColor": "transparent", - "backgroundColor": "#eceef1", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A2", - "lane1" - ], - "seed": 1202, - "index": "a031", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a2-urlt", - "type": "text", - "x": 496, - "y": 268, - "width": 268, - "height": 12, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A2", - "lane1" - ], - "seed": 1203, - "text": "badges.simplex.chat/#/tier?app=true", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a032", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "badges.simplex.chat/#/tier?app=true", - "lineHeight": 1.3333333333333333 - }, - { - "id": "a2-title", - "type": "text", - "x": 496, - "y": 312, - "width": 268, - "height": 22, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A2", - "lane1" - ], - "seed": 1204, - "text": "Choose your badge", - "fontSize": 17, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a033", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Choose your badge", - "lineHeight": 1.2941176470588236 - }, - { - "id": "a2-lede", - "type": "text", - "x": 496, - "y": 342, - "width": 268, - "height": 13, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A2", - "lane1" - ], - "seed": 1205, - "text": "Larger files that stay available longer.", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a034", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Larger files that stay available longer.", - "lineHeight": 1.368421052631579 - }, - { - "id": "a2-c1", - "type": "rectangle", - "x": 496, - "y": 378, - "width": 130, - "height": 150, - "strokeColor": "#d6dae0", - "backgroundColor": "#f7f8fa", - "fillStyle": "solid", - "strokeWidth": 2, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A2", - "lane1" - ], - "seed": 1206, - "index": "a035", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a2-c1i", - "type": "rectangle", - "x": 545, - "y": 398, - "width": 32, - "height": 32, - "strokeColor": "transparent", - "backgroundColor": "#dfe4ea", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A2", - "lane1" - ], - "seed": 1207, - "index": "a036", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a2-c1t", - "type": "text", - "x": 504, - "y": 442, - "width": 114, - "height": 52, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A2", - "lane1" - ], - "seed": 1208, - "text": "Supporter\n2GB files\n\n$7/month", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a037", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Supporter\n2GB files\n\n$7/month", - "lineHeight": 1.3 - }, - { - "id": "a2-c2", - "type": "rectangle", - "x": 634, - "y": 378, - "width": 130, - "height": 150, - "strokeColor": "#0053d0", - "backgroundColor": "#f7f8fa", - "fillStyle": "solid", - "strokeWidth": 2, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A2", - "lane1" - ], - "seed": 1209, - "index": "a038", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a2-c2i", - "type": "rectangle", - "x": 683, - "y": 398, - "width": 32, - "height": 32, - "strokeColor": "transparent", - "backgroundColor": "#dfe4ea", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A2", - "lane1" - ], - "seed": 1210, - "index": "a039", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a2-c2t", - "type": "text", - "x": 642, - "y": 442, - "width": 114, - "height": 52, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A2", - "lane1" - ], - "seed": 1211, - "text": "Legend\n5GB files\n\n$70/month", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a03a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Legend\n5GB files\n\n$70/month", - "lineHeight": 1.3 - }, - { - "id": "a2-btn", - "type": "rectangle", - "x": 496, - "y": 560, - "width": 268, - "height": 36, - "strokeColor": "transparent", - "backgroundColor": "#0053d0", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A2", - "lane1" - ], - "seed": 1212, - "boundElements": [ - { - "id": "a2-btnt", - "type": "text" - } - ], - "index": "a03b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a2-btnt", - "type": "text", - "x": 506, - "y": 571, - "width": 248, - "height": 14, - "strokeColor": "#ffffff", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A2", - "lane1" - ], - "seed": 1213, - "text": "Continue", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "a2-btn", - "index": "a03c", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "originalText": "Continue", - "autoResize": true, - "lineHeight": 1.2727272727272727 - }, - { - "id": "a2-capt", - "type": "text", - "x": 480, - "y": 900, - "width": 300, - "height": 14, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A2", - "lane1" - ], - "seed": 1215, - "text": "A2. Where the link lands", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a03d", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "A2. Where the link lands", - "lineHeight": 1.2727272727272727 - }, - { - "id": "a2-capb", - "type": "text", - "x": 480, - "y": 920, - "width": 340, - "height": 66, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A2", - "lane1" - ], - "seed": 1216, - "text": "The page's own second screen, quoted as it is. The flag\nin #/tier?app=true skips its Support SimpleX, which the\napp has just shown. NEW on the page: read that flag, start\nhere, and carry it: the session to checkout, then the order.\nThe app builds the URL, so it can also say which kind of\napp it is - better than guessing from the user agent.", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a03e", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "The page's own second screen, quoted as it is. The flag\nin #/tier?app=true skips its Support SimpleX, which the\napp has just shown. NEW on the page: read that flag, start\nhere, and carry it: the session to checkout, then the order.\nThe app builds the URL, so it can also say which kind of\napp it is - better than guessing from the user agent.", - "lineHeight": 1.2222222222222223 - }, - { - "id": "a3-panel", - "type": "rectangle", - "x": 900, - "y": 260, - "width": 300, - "height": 620, - "strokeColor": "#8f97a3", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1301, - "boundElements": [ - { - "id": "ar-a2a3", - "type": "arrow" - }, - { - "id": "ar-a3a4", - "type": "arrow" - } - ], - "index": "a040", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a3-url", - "type": "rectangle", - "x": 900, - "y": 260, - "width": 300, - "height": 26, - "strokeColor": "transparent", - "backgroundColor": "#eceef1", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1302, - "index": "a041", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a3-urlt", - "type": "text", - "x": 916, - "y": 268, - "width": 268, - "height": 12, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1303, - "text": "badges.simplex.chat/#/tier?app=true", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a042", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "badges.simplex.chat/#/tier?app=true", - "lineHeight": 1.3333333333333333 - }, - { - "id": "a3-title", - "type": "text", - "x": 916, - "y": 312, - "width": 268, - "height": 22, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1304, - "text": "How long?", - "fontSize": 17, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a043", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "How long?", - "lineHeight": 1.2941176470588236 - }, - { - "id": "a3-lede", - "type": "text", - "x": 916, - "y": 342, - "width": 268, - "height": 13, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1305, - "text": "Legend - 5GB files, 21 days", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a044", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Legend - 5GB files, 21 days", - "lineHeight": 1.368421052631579 - }, - { - "id": "a3-c1", - "type": "rectangle", - "x": 916, - "y": 378, - "width": 84, - "height": 124, - "strokeColor": "#d6dae0", - "backgroundColor": "#f7f8fa", - "fillStyle": "solid", - "strokeWidth": 2, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1306, - "index": "a045", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a3-c1t", - "type": "text", - "x": 920, - "y": 418, - "width": 76, - "height": 39, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1307, - "text": "1 month\n\n$70", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a046", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "1 month\n\n$70", - "lineHeight": 1.3 - }, - { - "id": "a3-c2", - "type": "rectangle", - "x": 1008, - "y": 378, - "width": 84, - "height": 124, - "strokeColor": "#d6dae0", - "backgroundColor": "#f7f8fa", - "fillStyle": "solid", - "strokeWidth": 2, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1308, - "index": "a047", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a3-c2t", - "type": "text", - "x": 1012, - "y": 412, - "width": 76, - "height": 52, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1309, - "text": "3 months\n\n$140\none month free", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a048", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "3 months\n\n$140\none month free", - "lineHeight": 1.3 - }, - { - "id": "a3-c3", - "type": "rectangle", - "x": 1100, - "y": 378, - "width": 84, - "height": 124, - "strokeColor": "#0053d0", - "backgroundColor": "#f7f8fa", - "fillStyle": "solid", - "strokeWidth": 2, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1310, - "index": "a049", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a3-c3t", - "type": "text", - "x": 1104, - "y": 412, - "width": 76, - "height": 52, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1311, - "text": "12 months\n\n$420\nsave 50%", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a04a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "12 months\n\n$420\nsave 50%", - "lineHeight": 1.3 - }, - { - "id": "a3-note", - "type": "text", - "x": 916, - "y": 520, - "width": 268, - "height": 24, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1312, - "text": "Paid once, for the months you choose.\nNo subscription.", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a04b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Paid once, for the months you choose.\nNo subscription.", - "lineHeight": 1.3333333333333333 - }, - { - "id": "a3-btn", - "type": "rectangle", - "x": 916, - "y": 560, - "width": 268, - "height": 36, - "strokeColor": "transparent", - "backgroundColor": "#0053d0", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1313, - "boundElements": [ - { - "id": "a3-btnt", - "type": "text" - } - ], - "index": "a04c", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a3-btnt", - "type": "text", - "x": 926, - "y": 571, - "width": 248, - "height": 14, - "strokeColor": "#ffffff", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1314, - "text": "Continue", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "a3-btn", - "index": "a04d", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "originalText": "Continue", - "autoResize": true, - "lineHeight": 1.2727272727272727 - }, - { - "id": "a3-capt", - "type": "text", - "x": 900, - "y": 900, - "width": 300, - "height": 14, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1315, - "text": "A3. Unchanged", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a04e", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "A3. Unchanged", - "lineHeight": 1.2727272727272727 - }, - { - "id": "a3-capb", - "type": "text", - "x": 900, - "y": 920, - "width": 340, - "height": 66, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1316, - "text": "The page's own screen, and the source of the names this\nplan adopts in the app: Choose your badge, How long?,\nCheck your order. Prices and offers are the compiled catalog\nthe page already has, so getBadgeCatalog is not needed by\nanyone in this lane.", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a04f", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "The page's own screen, and the source of the names this\nplan adopts in the app: Choose your badge, How long?,\nCheck your order. Prices and offers are the compiled catalog\nthe page already has, so getBadgeCatalog is not needed by\nanyone in this lane.", - "lineHeight": 1.4666666666666666 - }, - { - "id": "a4-panel", - "type": "rectangle", - "x": 1320, - "y": 260, - "width": 300, - "height": 620, - "strokeColor": "#8f97a3", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1401, - "boundElements": [ - { - "id": "ar-a3a4", - "type": "arrow" - }, - { - "id": "ar-a4a5", - "type": "arrow" - } - ], - "index": "a050", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a4-url", - "type": "rectangle", - "x": 1320, - "y": 260, - "width": 300, - "height": 26, - "strokeColor": "transparent", - "backgroundColor": "#eceef1", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1402, - "index": "a051", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a4-urlt", - "type": "text", - "x": 1336, - "y": 268, - "width": 268, - "height": 12, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1403, - "text": "badges.simplex.chat/?order=8f3a...", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a052", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "badges.simplex.chat/?order=8f3a...", - "lineHeight": 1.3333333333333333 - }, - { - "id": "a4-title", - "type": "text", - "x": 1336, - "y": 312, - "width": 268, - "height": 22, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1404, - "text": "Check your order", - "fontSize": 17, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a053", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Check your order", - "lineHeight": 1.2941176470588236 - }, - { - "id": "a4-rowsL", - "type": "text", - "x": 1336, - "y": 352, - "width": 160, - "height": 48, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1405, - "text": "Your badge\nDuration\nLaunch discount: 50% off\nTotal", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a054", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Your badge\nDuration\nLaunch discount: 50% off\nTotal", - "lineHeight": 1.263157894736842 - }, - { - "id": "a4-rowsR", - "type": "text", - "x": 1508, - "y": 352, - "width": 96, - "height": 48, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1406, - "text": "Legend\n12 months\n-$420.00\n$420.00", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "right", - "verticalAlign": "top", - "autoResize": false, - "index": "a055", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Legend\n12 months\n-$420.00\n$420.00", - "lineHeight": 1.263157894736842 - }, - { - "id": "a4-label", - "type": "text", - "x": 1336, - "y": 420, - "width": 100, - "height": 12, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1407, - "text": "PAY WITH", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a056", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "PAY WITH", - "lineHeight": 1.3333333333333333 - }, - { - "id": "a4-m1", - "type": "rectangle", - "x": 1336, - "y": 438, - "width": 84, - "height": 60, - "strokeColor": "#d6dae0", - "backgroundColor": "#f7f8fa", - "fillStyle": "solid", - "strokeWidth": 2, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1408, - "index": "a057", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a4-m1t", - "type": "text", - "x": 1340, - "y": 460, - "width": 76, - "height": 13, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1409, - "text": "Bitcoin", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a058", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Bitcoin", - "lineHeight": 1.3 - }, - { - "id": "a4-m2", - "type": "rectangle", - "x": 1428, - "y": 438, - "width": 84, - "height": 60, - "strokeColor": "#0053d0", - "backgroundColor": "#f7f8fa", - "fillStyle": "solid", - "strokeWidth": 2, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1410, - "index": "a059", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a4-m2t", - "type": "text", - "x": 1432, - "y": 460, - "width": 76, - "height": 13, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1411, - "text": "Monero", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a05a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Monero", - "lineHeight": 1.3 - }, - { - "id": "a4-m3", - "type": "rectangle", - "x": 1520, - "y": 438, - "width": 84, - "height": 60, - "strokeColor": "#d6dae0", - "backgroundColor": "#f7f8fa", - "fillStyle": "solid", - "strokeWidth": 2, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1412, - "index": "a05b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a4-m3t", - "type": "text", - "x": 1524, - "y": 460, - "width": 76, - "height": 13, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1413, - "text": "Card", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a05c", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Card", - "lineHeight": 1.3 - }, - { - "id": "a4-btn", - "type": "rectangle", - "x": 1336, - "y": 560, - "width": 268, - "height": 36, - "strokeColor": "transparent", - "backgroundColor": "#0053d0", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1414, - "boundElements": [ - { - "id": "a4-btnt", - "type": "text" - } - ], - "index": "a05d", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a4-btnt", - "type": "text", - "x": 1346, - "y": 571, - "width": 248, - "height": 14, - "strokeColor": "#ffffff", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1415, - "text": "Pay $420.00 with Monero", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "a4-btn", - "index": "a05e", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "originalText": "Pay $420.00 with Monero", - "autoResize": true, - "lineHeight": 1.2727272727272727 - }, - { - "id": "a4-capt", - "type": "text", - "x": 1320, - "y": 900, - "width": 300, - "height": 14, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1416, - "text": "A4. Unchanged, and the pay screens after it", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a05f", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "A4. Unchanged, and the pay screens after it", - "lineHeight": 1.2727272727272727 - }, - { - "id": "a4-capb", - "type": "text", - "x": 1320, - "y": 920, - "width": 340, - "height": 66, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1417, - "text": "The method is picked here and the code is minted here, as\ntoday: the browser generates it and sends only its SHA-256.\nSend 1.482 XMR, the countdown and Payment received follow\nand are unchanged, so they are not drawn. The service needs\nno new API for this lane at all.", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a05g", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "The method is picked here and the code is minted here, as\ntoday: the browser generates it and sends only its SHA-256.\nSend 1.482 XMR, the countdown and Payment received follow\nand are unchanged, so they are not drawn. The service needs\nno new API for this lane at all.", - "lineHeight": 1.4666666666666666 - }, - { - "id": "a5-panel", - "type": "rectangle", - "x": 1740, - "y": 260, - "width": 300, - "height": 620, - "strokeColor": "#0053d0", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 1, - "strokeStyle": "dashed", - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A5", - "lane1" - ], - "seed": 1501, - "boundElements": [ - { - "id": "ar-a4a5", - "type": "arrow" - }, - { - "id": "ar-a5a6", - "type": "arrow" - } - ], - "index": "a060", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a5-url", - "type": "rectangle", - "x": 1740, - "y": 260, - "width": 300, - "height": 26, - "strokeColor": "transparent", - "backgroundColor": "#eceef1", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A5", - "lane1" - ], - "seed": 1502, - "index": "a061", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a5-urlt", - "type": "text", - "x": 1756, - "y": 268, - "width": 268, - "height": 12, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A5", - "lane1" - ], - "seed": 1503, - "text": "badges.simplex.chat/?order=8f3a...", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a062", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "badges.simplex.chat/?order=8f3a...", - "lineHeight": 1.3333333333333333 - }, - { - "id": "a5-title", - "type": "text", - "x": 1756, - "y": 312, - "width": 268, - "height": 22, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A5", - "lane1" - ], - "seed": 1504, - "text": "Paid", - "fontSize": 17, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a063", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Paid", - "lineHeight": 1.2941176470588236 - }, - { - "id": "a5-lede", - "type": "text", - "x": 1756, - "y": 344, - "width": 268, - "height": 26, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A5", - "lane1" - ], - "seed": 1505, - "text": "Opening SimpleX to add your badge.\nIf nothing happens, use the button.", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a064", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Opening SimpleX to add your badge.\nIf nothing happens, use the button.", - "lineHeight": 1.368421052631579 - }, - { - "id": "a5-btn", - "type": "rectangle", - "x": 1756, - "y": 396, - "width": 268, - "height": 38, - "strokeColor": "transparent", - "backgroundColor": "#0053d0", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A5", - "lane1" - ], - "seed": 1506, - "boundElements": [ - { - "id": "a5-btnt", - "type": "text" - } - ], - "index": "a065", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a5-btnt", - "type": "text", - "x": 1766, - "y": 408, - "width": 248, - "height": 14, - "strokeColor": "#ffffff", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A5", - "lane1" - ], - "seed": 1507, - "text": "Return to SimpleX", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "a5-btn", - "index": "a066", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "originalText": "Return to SimpleX", - "autoResize": true, - "lineHeight": 1.2727272727272727 - }, - { - "id": "a5-sec", - "type": "text", - "x": 1756, - "y": 452, - "width": 268, - "height": 13, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A5", - "lane1" - ], - "seed": 1508, - "text": "Show code", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a067", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Show code", - "lineHeight": 1.3 - }, - { - "id": "a5-link", - "type": "rectangle", - "x": 1756, - "y": 496, - "width": 268, - "height": 34, - "strokeColor": "#d6dae0", - "backgroundColor": "#f7f8fa", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A5", - "lane1" - ], - "seed": 1509, - "index": "a068", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a5-linkt", - "type": "text", - "x": 1762, - "y": 507, - "width": 256, - "height": 13, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A5", - "lane1" - ], - "seed": 1510, - "text": "simplexchat:/badge/code/SB-4K2M9-...", - "fontSize": 9, - "fontFamily": 3, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a069", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "simplexchat:/badge/code/SB-4K2M9-...", - "lineHeight": 1.4444444444444444 - }, - { - "id": "a5-note", - "type": "text", - "x": 1756, - "y": 544, - "width": 268, - "height": 33, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A5", - "lane1" - ], - "seed": 1511, - "text": "the page tries this on load; the button is\nthe backup, and Show code is the\nbackup to the backup", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a06a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "the page tries this on load; the button is\nthe backup, and Show code is the\nbackup to the backup", - "lineHeight": 1.2222222222222223 - }, - { - "id": "a5-capt", - "type": "text", - "x": 1740, - "y": 900, - "width": 300, - "height": 14, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A5", - "lane1" - ], - "seed": 1512, - "text": "A5. New: the code leaves by link, not by eye", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a06b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "A5. New: the code leaves by link, not by eye", - "lineHeight": 1.2727272727272727 - }, - { - "id": "a5-capb", - "type": "text", - "x": 1740, - "y": 920, - "width": 340, - "height": 66, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A5", - "lane1" - ], - "seed": 1513, - "text": "Replaces codeIssued for a mobile app purchase. The flag is\nnot in the URL here - it rode the session to checkout and is\nstamped on the order. The code is still minted and saved, so\nShow code recovers it if the link fails; a code that could\nnot be saved gets the plain screen instead. simplexchat: is a\nscheme of its own, so a badge link can never be read as a\nconnection link. The attempt is silent where it is blocked.", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a06c", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Replaces codeIssued for a mobile app purchase. The flag is\nnot in the URL here - it rode the session to checkout and is\nstamped on the order. The code is still minted and saved, so\nShow code recovers it if the link fails; a code that could\nnot be saved gets the plain screen instead. simplexchat: is a\nscheme of its own, so a badge link can never be read as a\nconnection link. The attempt is silent where it is blocked.", - "lineHeight": 1.2222222222222223 - }, - { - "id": "a6-panel", - "type": "rectangle", - "x": 2160, - "y": 260, - "width": 300, - "height": 620, - "strokeColor": "#0053d0", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 1, - "strokeStyle": "dashed", - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A6", - "lane1" - ], - "seed": 1601, - "boundElements": [ - { - "id": "ar-a5a6", - "type": "arrow" - } - ], - "index": "a070", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a6-title", - "type": "text", - "x": 2180, - "y": 306, - "width": 260, - "height": 24, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A6", - "lane1" - ], - "seed": 1602, - "text": "Badge is being issued", - "fontSize": 18, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a071", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Badge is being issued", - "lineHeight": 1.3333333333333333 - }, - { - "id": "a6-spin", - "type": "ellipse", - "x": 2288, - "y": 380, - "width": 44, - "height": 44, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "fillStyle": "solid", - "strokeWidth": 2, - "roughness": 0, - "groupIds": [ - "A6", - "lane1" - ], - "seed": 1603, - "index": "a072", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a6-spint", - "type": "text", - "x": 2180, - "y": 440, - "width": 260, - "height": 12, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A6", - "lane1" - ], - "seed": 1604, - "text": "progress", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a073", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "progress", - "lineHeight": 1.3333333333333333 - }, - { - "id": "a6-body", - "type": "text", - "x": 2186, - "y": 478, - "width": 248, - "height": 39, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A6", - "lane1" - ], - "seed": 1605, - "text": "This takes a moment. You do not\nneed to do anything, and you can\nclose the app.", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a074", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "This takes a moment. You do not\nneed to do anything, and you can\nclose the app.", - "lineHeight": 1.368421052631579 - }, - { - "id": "a6-then", - "type": "rectangle", - "x": 2176, - "y": 600, - "width": 268, - "height": 96, - "strokeColor": "#8f97a3", - "backgroundColor": "#f2f3f5", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A6", - "lane1" - ], - "seed": 1606, - "index": "a075", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a6-theni", - "type": "rectangle", - "x": 2190, - "y": 612, - "width": 240, - "height": 72, - "strokeColor": "transparent", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A6", - "lane1" - ], - "seed": 1607, - "index": "a076", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a6-theng", - "type": "rectangle", - "x": 2298, - "y": 620, - "width": 24, - "height": 24, - "strokeColor": "transparent", - "backgroundColor": "#dfe4ea", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A6", - "lane1" - ], - "seed": 1608, - "index": "a077", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a6-thent", - "type": "text", - "x": 2180, - "y": 650, - "width": 260, - "height": 13, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A6", - "lane1" - ], - "seed": 1609, - "text": "Legend", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a078", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Legend", - "lineHeight": 1.3 - }, - { - "id": "a6-thens", - "type": "text", - "x": 2180, - "y": 666, - "width": 260, - "height": 11, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A6", - "lane1" - ], - "seed": 1610, - "text": "shown on your profile", - "fontSize": 8.5, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a079", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "shown on your profile", - "lineHeight": 1.2941176470588236 - }, - { - "id": "a6-thenn", - "type": "text", - "x": 2160, - "y": 708, - "width": 300, - "height": 11, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A6", - "lane1" - ], - "seed": 1611, - "text": "then BadgesYourBadgeView, unchanged", - "fontSize": 8.5, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a07a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "then BadgesYourBadgeView, unchanged", - "lineHeight": 1.2941176470588236 - }, - { - "id": "a6-capt", - "type": "text", - "x": 2160, - "y": 900, - "width": 300, - "height": 14, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A6", - "lane1" - ], - "seed": 1612, - "text": "A6. New screen, and the only new app state", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a07b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "A6. New screen, and the only new app state", - "lineHeight": 1.2727272727272727 - }, - { - "id": "a6-capb", - "type": "text", - "x": 2160, - "y": 920, - "width": 340, - "height": 66, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A6", - "lane1" - ], - "seed": 1613, - "text": "The deep link lands here, not on Redeem code: a pasted code\nand a QR scanner make no sense when the code arrived by\nlink. It calls the existing apiRedeemBadgeCode and shows its\nfailures as that screen does. Naming: Redeeming code names\nthe mechanism, Badge is being issued names the outcome -\nand the outcome is what the buyer cares about.", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a07c", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "The deep link lands here, not on Redeem code: a pasted code\nand a QR scanner make no sense when the code arrived by\nlink. It calls the existing apiRedeemBadgeCode and shows its\nfailures as that screen does. Naming: Redeeming code names\nthe mechanism, Badge is being issued names the outcome -\nand the outcome is what the buyer cares about.", - "lineHeight": 1.2222222222222223 - }, - { - "id": "ar-a1a2", - "type": "arrow", - "x": 368, - "y": 570, - "width": 104, - "height": 0, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "strokeWidth": 2, - "roughness": 0, - "roundness": null, - "elbowed": true, - "groupIds": [], - "seed": 1701, - "points": [ - [ - 0, - 0 - ], - [ - 104, - 0 - ] - ], - "startArrowhead": null, - "endArrowhead": "arrow", - "startBinding": null, - "endBinding": null, - "boundElements": [ - { - "id": "ar-a1a2-t", - "type": "text" - } - ], - "index": "a080", - "version": 2, - "versionNonce": 1538965901, - "isDeleted": false, - "fillStyle": "solid", - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251194517, - "link": null, - "locked": false, - "lastCommittedPoint": null - }, - { - "id": "ar-a1a2-t", - "type": "text", - "x": 365.810546875, - "y": 563.5, - "width": 108.37890625, - "height": 13, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [], - "seed": 1702, - "text": "Buy in browser - no alert", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "ar-a1a2", - "index": "a081", - "version": 3, - "versionNonce": 709045891, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251191288, - "link": null, - "locked": false, - "originalText": "Buy in browser - no alert", - "autoResize": true, - "lineHeight": 1.3 - }, - { - "id": "ar-a2a3", - "type": "arrow", - "x": 788, - "y": 570, - "width": 104, - "height": 0, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "strokeWidth": 2, - "roughness": 0, - "roundness": null, - "elbowed": true, - "groupIds": [], - "seed": 1703, - "points": [ - [ - 0, - 0 - ], - [ - 104, - 0 - ] - ], - "startArrowhead": null, - "endArrowhead": "arrow", - "startBinding": null, - "endBinding": null, - "boundElements": [ - { - "id": "ar-a2a3-t", - "type": "text" - } - ], - "index": "a082", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false, - "lastCommittedPoint": null - }, - { - "id": "ar-a2a3-t", - "type": "text", - "x": 800, - "y": 556, - "width": 80, - "height": 13, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [], - "seed": 1704, - "text": "Continue", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "ar-a2a3", - "index": "a083", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "originalText": "Continue", - "autoResize": true, - "lineHeight": 1.3 - }, - { - "id": "ar-a3a4", - "type": "arrow", - "x": 1208, - "y": 570, - "width": 104, - "height": 0, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "strokeWidth": 2, - "roughness": 0, - "roundness": null, - "elbowed": true, - "groupIds": [], - "seed": 1705, - "points": [ - [ - 0, - 0 - ], - [ - 104, - 0 - ] - ], - "startArrowhead": null, - "endArrowhead": "arrow", - "startBinding": null, - "endBinding": null, - "boundElements": [ - { - "id": "ar-a3a4-t", - "type": "text" - } - ], - "index": "a084", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false, - "lastCommittedPoint": null - }, - { - "id": "ar-a3a4-t", - "type": "text", - "x": 1220, - "y": 556, - "width": 80, - "height": 13, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [], - "seed": 1706, - "text": "Continue", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "ar-a3a4", - "index": "a085", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "originalText": "Continue", - "autoResize": true, - "lineHeight": 1.3 - }, - { - "id": "ar-a4a5", - "type": "arrow", - "x": 1628, - "y": 570, - "width": 104, - "height": 0, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "strokeWidth": 2, - "roughness": 0, - "roundness": null, - "elbowed": true, - "groupIds": [], - "seed": 1707, - "points": [ - [ - 0, - 0 - ], - [ - 104, - 0 - ] - ], - "startArrowhead": null, - "endArrowhead": "arrow", - "startBinding": null, - "endBinding": null, - "boundElements": [ - { - "id": "ar-a4a5-t", - "type": "text" - } - ], - "index": "a086", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false, - "lastCommittedPoint": null - }, - { - "id": "ar-a4a5-t", - "type": "text", - "x": 1640, - "y": 556, - "width": 80, - "height": 13, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [], - "seed": 1708, - "text": "it settles", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "ar-a4a5", - "index": "a087", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "originalText": "it settles", - "autoResize": true, - "lineHeight": 1.3 - }, - { - "id": "ar-a5a6", - "type": "arrow", - "x": 2048, - "y": 570, - "width": 104, - "height": 0, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "strokeWidth": 2, - "roughness": 0, - "roundness": null, - "elbowed": true, - "groupIds": [], - "seed": 1709, - "points": [ - [ - 0, - 0 - ], - [ - 104, - 0 - ] - ], - "startArrowhead": null, - "endArrowhead": "arrow", - "startBinding": null, - "endBinding": null, - "boundElements": [ - { - "id": "ar-a5a6-t", - "type": "text" - } - ], - "index": "a088", - "version": 2, - "versionNonce": 737650275, - "isDeleted": false, - "fillStyle": "solid", - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251382068, - "link": null, - "locked": false, - "lastCommittedPoint": null - }, - { - "id": "ar-a5a6-t", - "type": "text", - "x": 2056.93115234375, - "y": 557, - "width": 86.1376953125, - "height": 26, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [], - "seed": 1710, - "text": "simplexchat: carries\nthe code", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "ar-a5a6", - "index": "a089", - "version": 4, - "versionNonce": 1743168653, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251380246, - "link": null, - "locked": false, - "originalText": "simplexchat: carries the code", - "autoResize": true, - "lineHeight": 1.3 - }, - { - "id": "l2-band", - "type": "rectangle", - "x": 20, - "y": 1040, - "width": 2460, - "height": 850, - "strokeColor": "transparent", - "backgroundColor": "#f5f1ff", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "lane2" - ], - "seed": 2010, - "index": "a100", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "l2-head", - "type": "text", - "x": 60, - "y": 1068, - "width": 900, - "height": 22, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "lane2" - ], - "seed": 2011, - "text": "Store builds - the store path in the app, the browser alongside it", - "fontSize": 17, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a101", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Store builds - the store path in the app, the browser alongside it", - "lineHeight": 1.2941176470588236 - }, - { - "id": "l2-sub", - "type": "text", - "x": 60, - "y": 1096, - "width": 1300, - "height": 14, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "lane2" - ], - "seed": 2012, - "text": "Buy in browser runs lane A unchanged; only these four screens and the outcomes after them are in-app, and only here", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a102", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Buy in browser runs lane A unchanged; only these four screens and the outcomes after them are in-app, and only here", - "lineHeight": 1.2727272727272727 - }, - { - "id": "b1-panel", - "type": "rectangle", - "x": 60, - "y": 1150, - "width": 300, - "height": 620, - "strokeColor": "#8f97a3", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "B1", - "lane2" - ], - "seed": 2101, - "boundElements": [ - { - "id": "ar-b1b2", - "type": "arrow" - } - ], - "index": "a110", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "b1-title", - "type": "text", - "x": 80, - "y": 1196, - "width": 260, - "height": 24, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B1", - "lane2" - ], - "seed": 2102, - "text": "Support SimpleX", - "fontSize": 19, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a111", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Support SimpleX", - "lineHeight": 1.263157894736842 - }, - { - "id": "b1-body", - "type": "text", - "x": 76, - "y": 1238, - "width": 268, - "height": 39, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B1", - "lane2" - ], - "seed": 2103, - "text": "Get a badge to send larger files (2-5GB)\nthat stay available longer (7-21 days),\nand to show it on your profile.", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a112", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Get a badge to send larger files (2-5GB)\nthat stay available longer (7-21 days),\nand to show it on your profile.", - "lineHeight": 1.3 - }, - { - "id": "b1-info", - "type": "text", - "x": 80, - "y": 1294, - "width": 260, - "height": 13, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B1", - "lane2" - ], - "seed": 2104, - "text": "(i) How badges protect your privacy", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a113", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "(i) How badges protect your privacy", - "lineHeight": 1.3 - }, - { - "id": "b1-hero", - "type": "rectangle", - "x": 132, - "y": 1338, - "width": 156, - "height": 156, - "strokeColor": "transparent", - "backgroundColor": "#efeaf9", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "B1", - "lane2" - ], - "seed": 2105, - "index": "a114", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "b1-herot", - "type": "text", - "x": 132, - "y": 1409, - "width": 156, - "height": 12, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B1", - "lane2" - ], - "seed": 2106, - "text": "phone-supporter", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a115", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "phone-supporter", - "lineHeight": 1.3333333333333333 - }, - { - "id": "b1-btn", - "type": "rectangle", - "x": 90, - "y": 1620, - "width": 240, - "height": 38, - "strokeColor": "transparent", - "backgroundColor": "#5b21b6", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "B1", - "lane2" - ], - "seed": 2107, - "boundElements": [ - { - "id": "b1-btnt", - "type": "text" - } - ], - "index": "a116", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "b1-btnt", - "type": "text", - "x": 100, - "y": 1632, - "width": 220, - "height": 14, - "strokeColor": "#ffffff", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B1", - "lane2" - ], - "seed": 2108, - "text": "Choose your badge", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "b1-btn", - "index": "a117", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "originalText": "Choose your badge", - "autoResize": true, - "lineHeight": 1.2727272727272727 - }, - { - "id": "b1-sec", - "type": "text", - "x": 80, - "y": 1676, - "width": 260, - "height": 13, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B1", - "lane2" - ], - "seed": 2109, - "text": "Buy in browser", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a118", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Buy in browser", - "lineHeight": 1.3 - }, - { - "id": "b1-secn", - "type": "text", - "x": 80, - "y": 1696, - "width": 260, - "height": 24, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B1", - "lane2" - ], - "seed": 2110, - "text": "where the browser is not allowed,\nthis slot reads Redeem code instead", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a119", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "where the browser is not allowed,\nthis slot reads Redeem code instead", - "lineHeight": 1.3333333333333333 - }, - { - "id": "b1-capt", - "type": "text", - "x": 60, - "y": 1790, - "width": 300, - "height": 14, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B1", - "lane2" - ], - "seed": 2111, - "text": "B1. Two buttons, one of them gated", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a11a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "B1. Two buttons, one of them gated", - "lineHeight": 1.2727272727272727 - }, - { - "id": "b1-capb", - "type": "text", - "x": 60, - "y": 1810, - "width": 340, - "height": 66, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B1", - "lane2" - ], - "seed": 2112, - "text": "Buy in browser is the same tap as A1's and runs the same\nlane, but only where the store rules allow it - a wider set of\nmarkets than the crowdfunding banner covers, and not pinned\ndown yet. Where it is not allowed the slot reads Redeem code\ninstead, so this screen always offers one of the two and codes\nare never out of reach.", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a11b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Buy in browser is the same tap as A1's and runs the same\nlane, but only where the store rules allow it - a wider set of\nmarkets than the crowdfunding banner covers, and not pinned\ndown yet. Where it is not allowed the slot reads Redeem code\ninstead, so this screen always offers one of the two and codes\nare never out of reach.", - "lineHeight": 1.2222222222222223 - }, - { - "id": "b2-panel", - "type": "rectangle", - "x": 480, - "y": 1150, - "width": 300, - "height": 620, - "strokeColor": "#8f97a3", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2201, - "boundElements": [ - { - "id": "ar-b1b2", - "type": "arrow" - }, - { - "id": "ar-b2b3", - "type": "arrow" - } - ], - "index": "a120", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "b2-back", - "type": "text", - "x": 496, - "y": 1168, - "width": 20, - "height": 14, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2202, - "text": "<", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a121", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "<", - "lineHeight": 1.2727272727272727 - }, - { - "id": "b2-title", - "type": "text", - "x": 500, - "y": 1196, - "width": 260, - "height": 24, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2203, - "text": "Choose your badge", - "fontSize": 19, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a122", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Choose your badge", - "lineHeight": 1.263157894736842 - }, - { - "id": "b2-lede", - "type": "text", - "x": 500, - "y": 1236, - "width": 260, - "height": 13, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2204, - "text": "Larger files that stay available longer.", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a123", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Larger files that stay available longer.", - "lineHeight": 1.368421052631579 - }, - { - "id": "b2-avatar", - "type": "ellipse", - "x": 598, - "y": 1268, - "width": 56, - "height": 56, - "strokeColor": "transparent", - "backgroundColor": "#e3e6ea", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2205, - "index": "a124", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "b2-name", - "type": "text", - "x": 500, - "y": 1334, - "width": 260, - "height": 15, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2206, - "text": "My nickname [badge] v", - "fontSize": 12, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a125", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "My nickname [badge] v", - "lineHeight": 1.25 - }, - { - "id": "b2-c1", - "type": "rectangle", - "x": 492, - "y": 1378, - "width": 132, - "height": 150, - "strokeColor": "#5b21b6", - "backgroundColor": "#f7f8fa", - "fillStyle": "solid", - "strokeWidth": 2, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2207, - "index": "a126", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "b2-c1i", - "type": "rectangle", - "x": 542, - "y": 1398, - "width": 32, - "height": 32, - "strokeColor": "transparent", - "backgroundColor": "#dfe4ea", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2208, - "index": "a127", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "b2-c1t", - "type": "text", - "x": 500, - "y": 1442, - "width": 116, - "height": 52, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2209, - "text": "Supporter\nSend 2GB files\n\n$6.99/month", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a128", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Supporter\nSend 2GB files\n\n$6.99/month", - "lineHeight": 1.3 - }, - { - "id": "b2-c2", - "type": "rectangle", - "x": 636, - "y": 1378, - "width": 132, - "height": 150, - "strokeColor": "#d6dae0", - "backgroundColor": "#f7f8fa", - "fillStyle": "solid", - "strokeWidth": 2, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2210, - "index": "a129", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "b2-c2i", - "type": "rectangle", - "x": 686, - "y": 1398, - "width": 32, - "height": 32, - "strokeColor": "transparent", - "backgroundColor": "#dfe4ea", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2211, - "index": "a12a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "b2-c2t", - "type": "text", - "x": 644, - "y": 1442, - "width": 116, - "height": 52, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2212, - "text": "Legend\nSend 5GB files\n\n$69.99/month", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a12b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Legend\nSend 5GB files\n\n$69.99/month", - "lineHeight": 1.3 - }, - { - "id": "b2-btn", - "type": "rectangle", - "x": 510, - "y": 1596, - "width": 240, - "height": 38, - "strokeColor": "transparent", - "backgroundColor": "#5b21b6", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2213, - "boundElements": [ - { - "id": "b2-btnt", - "type": "text" - } - ], - "index": "a12c", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "b2-btnt", - "type": "text", - "x": 520, - "y": 1608, - "width": 220, - "height": 14, - "strokeColor": "#ffffff", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2214, - "text": "Continue", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "b2-btn", - "index": "a12d", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "originalText": "Continue", - "autoResize": true, - "lineHeight": 1.2727272727272727 - }, - { - "id": "b2-sec", - "type": "text", - "x": 500, - "y": 1652, - "width": 260, - "height": 13, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2215, - "text": "Redeem code", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a12e", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Redeem code", - "lineHeight": 1.3 - }, - { - "id": "b2-infon", - "type": "text", - "x": 500, - "y": 1676, - "width": 260, - "height": 12, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2216, - "text": "here only when B1 offers the browser;\nthe privacy link always stays on B1", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a12f", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "here only when B1 offers the browser;\nthe privacy link always stays on B1", - "lineHeight": 1.3333333333333333 - }, - { - "id": "b2-capt", - "type": "text", - "x": 480, - "y": 1790, - "width": 300, - "height": 14, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2217, - "text": "B2. BadgesChooseBadgeView", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a130", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "B2. BadgesChooseBadgeView", - "lineHeight": 1.2727272727272727 - }, - { - "id": "b2-capb", - "type": "text", - "x": 480, - "y": 1810, - "width": 340, - "height": 66, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2218, - "text": "Your level becomes Choose your badge, with the page's lede.\nRedeem code sits here only when B1's slot is taken by Buy in\nbrowser; where the browser is not allowed it stays on B1 and\nthis screen has Continue alone. The privacy link never moves -\nit stays on Support SimpleX in both builds.", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a131", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Your level becomes Choose your badge, with the page's lede.\nRedeem code sits here only when B1's slot is taken by Buy in\nbrowser; where the browser is not allowed it stays on B1 and\nthis screen has Continue alone. The privacy link never moves -\nit stays on Support SimpleX in both builds.", - "lineHeight": 1.4666666666666666 - }, - { - "id": "b3-panel", - "type": "rectangle", - "x": 900, - "y": 1150, - "width": 300, - "height": 620, - "strokeColor": "#8f97a3", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2301, - "boundElements": [ - { - "id": "ar-b2b3", - "type": "arrow" - }, - { - "id": "ar-b3b4", - "type": "arrow" - } - ], - "index": "a132", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "b3-back", - "type": "text", - "x": 916, - "y": 1168, - "width": 20, - "height": 14, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2302, - "text": "<", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a133", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "<", - "lineHeight": 1.2727272727272727 - }, - { - "id": "b3-title", - "type": "text", - "x": 920, - "y": 1196, - "width": 260, - "height": 24, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2303, - "text": "How long?", - "fontSize": 19, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a134", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "How long?", - "lineHeight": 1.263157894736842 - }, - { - "id": "b3-lede", - "type": "text", - "x": 920, - "y": 1236, - "width": 260, - "height": 13, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2304, - "text": "Legend - 5GB files, 21 days", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a135", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Legend - 5GB files, 21 days", - "lineHeight": 1.368421052631579 - }, - { - "id": "b3-avatar", - "type": "ellipse", - "x": 1022, - "y": 1268, - "width": 56, - "height": 56, - "strokeColor": "transparent", - "backgroundColor": "#e3e6ea", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2305, - "index": "a136", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "b3-name", - "type": "text", - "x": 920, - "y": 1334, - "width": 260, - "height": 15, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2306, - "text": "My nickname [badge]", - "fontSize": 12, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a137", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "My nickname [badge]", - "lineHeight": 1.25 - }, - { - "id": "b3-c1", - "type": "rectangle", - "x": 912, - "y": 1386, - "width": 85, - "height": 124, - "strokeColor": "#d6dae0", - "backgroundColor": "#f7f8fa", - "fillStyle": "solid", - "strokeWidth": 2, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2307, - "index": "a138", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "b3-c1t", - "type": "text", - "x": 917, - "y": 1426, - "width": 75, - "height": 39, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2308, - "text": "1 month\n\n$69.99", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a139", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "1 month\n\n$69.99", - "lineHeight": 1.3 - }, - { - "id": "b3-c2", - "type": "rectangle", - "x": 1007, - "y": 1386, - "width": 85, - "height": 124, - "strokeColor": "#d6dae0", - "backgroundColor": "#f7f8fa", - "fillStyle": "solid", - "strokeWidth": 2, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2309, - "index": "a13a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "b3-c2t", - "type": "text", - "x": 1010, - "y": 1426, - "width": 79, - "height": 39, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2310, - "text": "Monthly\n\n$69.99/month", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a13b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Monthly\n\n$69.99/month", - "lineHeight": 1.3 - }, - { - "id": "b3-c3", - "type": "rectangle", - "x": 1102, - "y": 1386, - "width": 85, - "height": 124, - "strokeColor": "#5b21b6", - "backgroundColor": "#f7f8fa", - "fillStyle": "solid", - "strokeWidth": 2, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2311, - "index": "a13c", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "b3-c3t", - "type": "text", - "x": 1105, - "y": 1420, - "width": 79, - "height": 52, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2312, - "text": "Annual\n\n$699.99/year\nSave 17%", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a13d", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Annual\n\n$699.99/year\nSave 17%", - "lineHeight": 1.3 - }, - { - "id": "b3-btn", - "type": "rectangle", - "x": 930, - "y": 1620, - "width": 240, - "height": 38, - "strokeColor": "transparent", - "backgroundColor": "#5b21b6", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2313, - "boundElements": [ - { - "id": "b3-btnt", - "type": "text" - } - ], - "index": "a13e", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "b3-btnt", - "type": "text", - "x": 940, - "y": 1632, - "width": 220, - "height": 14, - "strokeColor": "#ffffff", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2314, - "text": "Continue", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "b3-btn", - "index": "a13f", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "originalText": "Continue", - "autoResize": true, - "lineHeight": 1.2727272727272727 - }, - { - "id": "b3-foot", - "type": "text", - "x": 920, - "y": 1676, - "width": 260, - "height": 13, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2315, - "text": "Renews on 24 August 2027. Cancel anytime.", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a140", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Renews on 24 August 2027. Cancel anytime.", - "lineHeight": 1.368421052631579 - }, - { - "id": "b3-capt", - "type": "text", - "x": 900, - "y": 1790, - "width": 300, - "height": 14, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2316, - "text": "B3. BadgesHowLongView, disarmed", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a141", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "B3. BadgesHowLongView, disarmed", - "lineHeight": 1.2727272727272727 - }, - { - "id": "b3-capb", - "type": "text", - "x": 900, - "y": 1810, - "width": 340, - "height": 66, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2317, - "text": "The level name was the title; now How long? is, with the\nchosen badge on the line under it, as the page does it.\nPay $69.99 becomes Continue and purchase() moves to B4.\nBadgePeriod as written today: one-time, monthly, annual,\nprices from StoreKit and Play, which need not match the web\npage's and generally will not.", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a142", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "The level name was the title; now How long? is, with the\nchosen badge on the line under it, as the page does it.\nPay $69.99 becomes Continue and purchase() moves to B4.\nBadgePeriod as written today: one-time, monthly, annual,\nprices from StoreKit and Play, which need not match the web\npage's and generally will not.", - "lineHeight": 1.2222222222222223 - }, - { - "id": "b4-panel", - "type": "rectangle", - "x": 1320, - "y": 1150, - "width": 300, - "height": 620, - "strokeColor": "#5b21b6", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 1, - "strokeStyle": "dashed", - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "B4", - "lane2" - ], - "seed": 2401, - "boundElements": [ - { - "id": "ar-b3b4", - "type": "arrow" - }, - { - "id": "ar-b4o1", - "type": "arrow" - }, - { - "id": "ar-b4o2", - "type": "arrow" - }, - { - "id": "ar-b4o3", - "type": "arrow" - } - ], - "index": "a143", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "b4-back", - "type": "text", - "x": 1336, - "y": 1168, - "width": 20, - "height": 14, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B4", - "lane2" - ], - "seed": 2402, - "text": "<", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a144", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "<", - "lineHeight": 1.2727272727272727 - }, - { - "id": "b4-title", - "type": "text", - "x": 1340, - "y": 1196, - "width": 260, - "height": 24, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B4", - "lane2" - ], - "seed": 2403, - "text": "Check your order", - "fontSize": 19, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a145", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Check your order", - "lineHeight": 1.263157894736842 - }, - { - "id": "b4-rowsL", - "type": "text", - "x": 1344, - "y": 1270, - "width": 160, - "height": 36, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B4", - "lane2" - ], - "seed": 2404, - "text": "Your badge\nDuration\nTotal", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a146", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Your badge\nDuration\nTotal", - "lineHeight": 1.263157894736842 - }, - { - "id": "b4-rowsR", - "type": "text", - "x": 1500, - "y": 1270, - "width": 96, - "height": 36, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B4", - "lane2" - ], - "seed": 2405, - "text": "Legend\nAnnual\n$699.99/year", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "right", - "verticalAlign": "top", - "autoResize": false, - "index": "a147", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Legend\nAnnual\n$699.99/year", - "lineHeight": 1.263157894736842 - }, - { - "id": "b4-div", - "type": "rectangle", - "x": 1344, - "y": 1292, - "width": 252, - "height": 1, - "strokeColor": "#dfe4ea", - "backgroundColor": "#dfe4ea", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": null, - "groupIds": [ - "B4", - "lane2" - ], - "seed": 2406, - "index": "a148", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "b4-note", - "type": "text", - "x": 1344, - "y": 1336, - "width": 252, - "height": 26, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B4", - "lane2" - ], - "seed": 2407, - "text": "Charged by the App Store to the\naccount on this device.", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a149", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Charged by the App Store to the\naccount on this device.", - "lineHeight": 1.368421052631579 - }, - { - "id": "b4-btn", - "type": "rectangle", - "x": 1350, - "y": 1620, - "width": 240, - "height": 38, - "strokeColor": "transparent", - "backgroundColor": "#5b21b6", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "B4", - "lane2" - ], - "seed": 2408, - "boundElements": [ - { - "id": "b4-btnt", - "type": "text" - } - ], - "index": "a14a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "b4-btnt", - "type": "text", - "x": 1360, - "y": 1632, - "width": 220, - "height": 14, - "strokeColor": "#ffffff", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B4", - "lane2" - ], - "seed": 2409, - "text": "Pay $699.99/year", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "b4-btn", - "index": "a14b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "originalText": "Pay $699.99/year", - "autoResize": true, - "lineHeight": 1.2727272727272727 - }, - { - "id": "b4-foot", - "type": "text", - "x": 1340, - "y": 1676, - "width": 260, - "height": 13, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B4", - "lane2" - ], - "seed": 2410, - "text": "Renews on 24 August 2027. Cancel anytime.", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a14c", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Renews on 24 August 2027. Cancel anytime.", - "lineHeight": 1.368421052631579 - }, - { - "id": "b4-capt", - "type": "text", - "x": 1320, - "y": 1790, - "width": 300, - "height": 14, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B4", - "lane2" - ], - "seed": 2411, - "text": "B4. New screen, one for every market", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a14d", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "B4. New screen, one for every market", - "lineHeight": 1.2727272727272727 - }, - { - "id": "b4-capb", - "type": "text", - "x": 1320, - "y": 1810, - "width": 340, - "height": 66, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B4", - "lane2" - ], - "seed": 2412, - "text": "No second button and nothing about paying elsewhere - that\nchoice was made back on B1. So this screen is identical in\nevery storefront and nothing on it is gated, which also means\nthe subscription never has to be reconciled against a web\nprice.", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a14e", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "No second button and nothing about paying elsewhere - that\nchoice was made back on B1. So this screen is identical in\nevery storefront and nothing on it is gated, which also means\nthe subscription never has to be reconciled against a web\nprice.", - "lineHeight": 1.4666666666666666 - }, - { - "id": "o1-card", - "type": "rectangle", - "x": 1740, - "y": 1160, - "width": 300, - "height": 120, - "strokeColor": "#5b21b6", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 1, - "strokeStyle": "dashed", - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "O1", - "lane2" - ], - "seed": 2601, - "boundElements": [ - { - "id": "ar-b4o1", - "type": "arrow" - } - ], - "index": "a150", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "o1-t", - "type": "text", - "x": 1760, - "y": 1174, - "width": 260, - "height": 17, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O1", - "lane2" - ], - "seed": 2602, - "text": "Paid", - "fontSize": 13, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a151", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Paid", - "lineHeight": 1.3076923076923077 - }, - { - "id": "o1-s", - "type": "text", - "x": 1760, - "y": 1198, - "width": 260, - "height": 13, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O1", - "lane2" - ], - "seed": 2603, - "text": "o Issuing your badge", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a152", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "o Issuing your badge", - "lineHeight": 1.3 - }, - { - "id": "o1-b", - "type": "text", - "x": 1766, - "y": 1222, - "width": 248, - "height": 26, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O1", - "lane2" - ], - "seed": 2604, - "text": "The store has taken the payment.\nKeeps trying if you close the app.", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a153", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "The store has taken the payment.\nKeeps trying if you close the app.", - "lineHeight": 1.4444444444444444 - }, - { - "id": "o2-card", - "type": "rectangle", - "x": 1740, - "y": 1300, - "width": 300, - "height": 96, - "strokeColor": "#8f97a3", - "backgroundColor": "#f2f3f5", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "O2", - "lane2" - ], - "seed": 2605, - "index": "a154", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "o2-i", - "type": "rectangle", - "x": 1756, - "y": 1312, - "width": 268, - "height": 72, - "strokeColor": "transparent", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "O2", - "lane2" - ], - "seed": 2606, - "index": "a155", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "o2-g", - "type": "rectangle", - "x": 1878, - "y": 1320, - "width": 24, - "height": 24, - "strokeColor": "transparent", - "backgroundColor": "#dfe4ea", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "O2", - "lane2" - ], - "seed": 2607, - "index": "a156", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "o2-t", - "type": "text", - "x": 1760, - "y": 1350, - "width": 260, - "height": 13, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O2", - "lane2" - ], - "seed": 2608, - "text": "Legend", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a157", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Legend", - "lineHeight": 1.3 - }, - { - "id": "o2-s", - "type": "text", - "x": 1760, - "y": 1366, - "width": 260, - "height": 11, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O2", - "lane2" - ], - "seed": 2609, - "text": "shown on your profile", - "fontSize": 8.5, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a158", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "shown on your profile", - "lineHeight": 1.2941176470588236 - }, - { - "id": "o2-n", - "type": "text", - "x": 1740, - "y": 1402, - "width": 300, - "height": 11, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O2", - "lane2" - ], - "seed": 2610, - "text": "BadgesYourBadgeView, unchanged", - "fontSize": 8.5, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a159", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "BadgesYourBadgeView, unchanged", - "lineHeight": 1.2941176470588236 - }, - { - "id": "o3-card", - "type": "rectangle", - "x": 1740, - "y": 1424, - "width": 300, - "height": 130, - "strokeColor": "#5b21b6", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 1, - "strokeStyle": "dashed", - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "O3", - "lane2" - ], - "seed": 2611, - "boundElements": [ - { - "id": "ar-b4o2", - "type": "arrow" - } - ], - "index": "a15a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "o3-t", - "type": "text", - "x": 1760, - "y": 1438, - "width": 260, - "height": 15, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O3", - "lane2" - ], - "seed": 2612, - "text": "Waiting for approval", - "fontSize": 12, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a15b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Waiting for approval", - "lineHeight": 1.25 - }, - { - "id": "o3-s", - "type": "text", - "x": 1760, - "y": 1460, - "width": 260, - "height": 13, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O3", - "lane2" - ], - "seed": 2613, - "text": "o Nothing has been charged", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a15c", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "o Nothing has been charged", - "lineHeight": 1.368421052631579 - }, - { - "id": "o3-b", - "type": "text", - "x": 1746, - "y": 1484, - "width": 288, - "height": 39, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O3", - "lane2" - ], - "seed": 2614, - "text": "Ask to Buy on iOS, a slow payment on\nPlay. Holds the slot so nothing can be\nbought twice, and becomes Paid above.", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a15d", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Ask to Buy on iOS, a slow payment on\nPlay. Holds the slot so nothing can be\nbought twice, and becomes Paid above.", - "lineHeight": 1.4444444444444444 - }, - { - "id": "o4-card", - "type": "rectangle", - "x": 1760, - "y": 1584, - "width": 260, - "height": 120, - "strokeColor": "#d6dae0", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "O4", - "lane2" - ], - "seed": 2615, - "boundElements": [ - { - "id": "ar-b4o3", - "type": "arrow" - } - ], - "index": "a15e", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "o4-t", - "type": "text", - "x": 1776, - "y": 1598, - "width": 228, - "height": 14, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O4", - "lane2" - ], - "seed": 2616, - "text": "Cannot verify this purchase", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a15f", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Cannot verify this purchase", - "lineHeight": 1.2727272727272727 - }, - { - "id": "o4-b", - "type": "text", - "x": 1776, - "y": 1620, - "width": 228, - "height": 39, - "strokeColor": "#4b5563", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O4", - "lane2" - ], - "seed": 2617, - "text": "The store returned a transaction\nthat Apple has not signed. Nothing\nhas been bought.", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a15g", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "The store returned a transaction\nthat Apple has not signed. Nothing\nhas been bought.", - "lineHeight": 1.4444444444444444 - }, - { - "id": "o4-d", - "type": "rectangle", - "x": 1760, - "y": 1668, - "width": 260, - "height": 1, - "strokeColor": "#dfe4ea", - "backgroundColor": "#dfe4ea", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": null, - "groupIds": [ - "O4", - "lane2" - ], - "seed": 2618, - "index": "a15h", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "o4-o", - "type": "text", - "x": 1760, - "y": 1678, - "width": 260, - "height": 14, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O4", - "lane2" - ], - "seed": 2619, - "text": "Ok", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a15i", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Ok", - "lineHeight": 1.2727272727272727 - }, - { - "id": "o-note", - "type": "text", - "x": 1740, - "y": 1712, - "width": 300, - "height": 44, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O", - "lane2" - ], - "seed": 2620, - "text": "iOS only - Play does no local verification, so a bad\ntoken is refused by the service instead. cancelled:\nnothing. other errors: today's Purchase error alert.", - "fontSize": 8.5, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a15j", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "iOS only - Play does no local verification, so a bad\ntoken is refused by the service instead. cancelled:\nnothing. other errors: today's Purchase error alert.", - "lineHeight": 1.7254901960784312 - }, - { - "id": "o-capt", - "type": "text", - "x": 1740, - "y": 1790, - "width": 300, - "height": 14, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O", - "lane2" - ], - "seed": 2621, - "text": "B5. What the sheet returns", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a15k", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "B5. What the sheet returns", - "lineHeight": 1.2727272727272727 - }, - { - "id": "o-capb", - "type": "text", - "x": 1740, - "y": 1810, - "width": 340, - "height": 66, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O", - "lane2" - ], - "seed": 2622, - "text": "Two of these are states on BadgeState, not alerts: Paid, which\nthe worker retries, and Waiting, which holds nothing but the\nslot - Support SimpleX must not offer a second purchase of a\nconsumable. Both resolve through the store, not the service:\nTransaction.updates on iOS, queryPurchasesAsync on Play,\nwhich is also how each is re-found after a restart.", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a15l", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Two of these are states on BadgeState, not alerts: Paid, which\nthe worker retries, and Waiting, which holds nothing but the\nslot - Support SimpleX must not offer a second purchase of a\nconsumable. Both resolve through the store, not the service:\nTransaction.updates on iOS, queryPurchasesAsync on Play,\nwhich is also how each is re-found after a restart.", - "lineHeight": 1.2222222222222223 - }, - { - "id": "ar-b1b2", - "type": "arrow", - "x": 368, - "y": 1460, - "width": 104, - "height": 0, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "strokeWidth": 2, - "roughness": 0, - "roundness": null, - "elbowed": true, - "groupIds": [], - "seed": 2701, - "points": [ - [ - 0, - 0 - ], - [ - 104, - 0 - ] - ], - "startArrowhead": null, - "endArrowhead": "arrow", - "startBinding": null, - "endBinding": null, - "boundElements": [ - { - "id": "ar-b1b2-t", - "type": "text" - } - ], - "index": "a180", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false, - "lastCommittedPoint": null - }, - { - "id": "ar-b1b2-t", - "type": "text", - "x": 380, - "y": 1446, - "width": 80, - "height": 13, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [], - "seed": 2702, - "text": "Choose your badge", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "ar-b1b2", - "index": "a181", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "originalText": "Choose your badge", - "autoResize": true, - "lineHeight": 1.3 - }, - { - "id": "ar-b2b3", - "type": "arrow", - "x": 788, - "y": 1460, - "width": 104, - "height": 0, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "strokeWidth": 2, - "roughness": 0, - "roundness": null, - "elbowed": true, - "groupIds": [], - "seed": 2703, - "points": [ - [ - 0, - 0 - ], - [ - 104, - 0 - ] - ], - "startArrowhead": null, - "endArrowhead": "arrow", - "startBinding": null, - "endBinding": null, - "boundElements": [ - { - "id": "ar-b2b3-t", - "type": "text" - } - ], - "index": "a182", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false, - "lastCommittedPoint": null - }, - { - "id": "ar-b2b3-t", - "type": "text", - "x": 800, - "y": 1446, - "width": 80, - "height": 13, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [], - "seed": 2704, - "text": "Continue", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "ar-b2b3", - "index": "a183", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "originalText": "Continue", - "autoResize": true, - "lineHeight": 1.3 - }, - { - "id": "ar-b3b4", - "type": "arrow", - "x": 1208, - "y": 1460, - "width": 104, - "height": 0, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "strokeWidth": 2, - "roughness": 0, - "roundness": null, - "elbowed": true, - "groupIds": [], - "seed": 2705, - "points": [ - [ - 0, - 0 - ], - [ - 104, - 0 - ] - ], - "startArrowhead": null, - "endArrowhead": "arrow", - "startBinding": null, - "endBinding": null, - "boundElements": [ - { - "id": "ar-b3b4-t", - "type": "text" - } - ], - "index": "a184", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false, - "lastCommittedPoint": null - }, - { - "id": "ar-b3b4-t", - "type": "text", - "x": 1220, - "y": 1446, - "width": 80, - "height": 13, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [], - "seed": 2706, - "text": "Continue", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "ar-b3b4", - "index": "a185", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "originalText": "Continue", - "autoResize": true, - "lineHeight": 1.3 - }, - { - "id": "ar-b4o1", - "type": "arrow", - "x": 1628, - "y": 1220, - "width": 104, - "height": 0, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "strokeWidth": 2, - "roughness": 0, - "roundness": null, - "elbowed": true, - "groupIds": [], - "seed": 2709, - "points": [ - [ - 0, - 0 - ], - [ - 104, - 0 - ] - ], - "startArrowhead": null, - "endArrowhead": "arrow", - "startBinding": null, - "endBinding": null, - "boundElements": [ - { - "id": "ar-b4o1-t", - "type": "text" - } - ], - "index": "a186", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false, - "lastCommittedPoint": null - }, - { - "id": "ar-b4o1-t", - "type": "text", - "x": 1640, - "y": 1206, - "width": 80, - "height": 13, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [], - "seed": 2710, - "text": "verified", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "ar-b4o1", - "index": "a187", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "originalText": "verified", - "autoResize": true, - "lineHeight": 1.3 - }, - { - "id": "ar-b4o2", - "type": "arrow", - "x": 1628, - "y": 1489, - "width": 104, - "height": 0, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "strokeWidth": 2, - "roughness": 0, - "roundness": null, - "elbowed": true, - "groupIds": [], - "seed": 2711, - "points": [ - [ - 0, - 0 - ], - [ - 104, - 0 - ] - ], - "startArrowhead": null, - "endArrowhead": "arrow", - "startBinding": null, - "endBinding": null, - "boundElements": [ - { - "id": "ar-b4o2-t", - "type": "text" - } - ], - "index": "a188", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false, - "lastCommittedPoint": null - }, - { - "id": "ar-b4o2-t", - "type": "text", - "x": 1640, - "y": 1475, - "width": 80, - "height": 13, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [], - "seed": 2712, - "text": "pending", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "ar-b4o2", - "index": "a189", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "originalText": "pending", - "autoResize": true, - "lineHeight": 1.3 - }, - { - "id": "ar-b4o3", - "type": "arrow", - "x": 1628, - "y": 1644, - "width": 124, - "height": 0, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "strokeWidth": 2, - "strokeStyle": "dashed", - "roughness": 0, - "roundness": null, - "elbowed": true, - "groupIds": [], - "seed": 2713, - "points": [ - [ - 0, - 0 - ], - [ - 124, - 0 - ] - ], - "startArrowhead": null, - "endArrowhead": "arrow", - "startBinding": null, - "endBinding": null, - "boundElements": [ - { - "id": "ar-b4o3-t", - "type": "text" - } - ], - "index": "a18a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false, - "lastCommittedPoint": null - }, - { - "id": "ar-b4o3-t", - "type": "text", - "x": 1640, - "y": 1630, - "width": 100, - "height": 13, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [], - "seed": 2714, - "text": "unverified", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "ar-b4o3", - "index": "a18b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "originalText": "unverified", - "autoResize": true, - "lineHeight": 1.3 - }, - { - "id": "l3-band", - "type": "rectangle", - "x": 20, - "y": 1930, - "width": 2460, - "height": 850, - "strokeColor": "transparent", - "backgroundColor": "#fff6ef", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "lane3" - ], - "seed": 3010, - "index": "a200", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "l3-head", - "type": "text", - "x": 60, - "y": 1958, - "width": 900, - "height": 22, - "strokeColor": "#a15c1e", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "lane3" - ], - "seed": 3011, - "text": "Desktop - where the deep link may not exist", - "fontSize": 17, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a201", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Desktop - where the deep link may not exist", - "lineHeight": 1.2941176470588236 - }, - { - "id": "l3-sub", - "type": "text", - "x": 60, - "y": 1986, - "width": 1300, - "height": 14, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "lane3" - ], - "seed": 3012, - "text": "the fallback is the shipped code flow with both halves opened at once, so nothing depends on a scheme handler that may not be there", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a202", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "the fallback is the shipped code flow with both halves opened at once, so nothing depends on a scheme handler that may not be there", - "lineHeight": 1.2727272727272727 - }, - { - "id": "d1-panel", - "type": "rectangle", - "x": 60, - "y": 2040, - "width": 300, - "height": 620, - "strokeColor": "#8f97a3", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "D1", - "lane3" - ], - "seed": 3101, - "boundElements": [ - { - "id": "ar-d1d2", - "type": "arrow" - } - ], - "index": "a210", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "d1-back", - "type": "text", - "x": 76, - "y": 2058, - "width": 20, - "height": 14, - "strokeColor": "#a15c1e", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D1", - "lane3" - ], - "seed": 3102, - "text": "<", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a211", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "<", - "lineHeight": 1.2727272727272727 - }, - { - "id": "d1-title", - "type": "text", - "x": 80, - "y": 2086, - "width": 260, - "height": 24, - "strokeColor": "#a15c1e", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D1", - "lane3" - ], - "seed": 3103, - "text": "Redeem code", - "fontSize": 19, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a212", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Redeem code", - "lineHeight": 1.263157894736842 - }, - { - "id": "d1-body", - "type": "text", - "x": 80, - "y": 2128, - "width": 260, - "height": 13, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D1", - "lane3" - ], - "seed": 3104, - "text": "Paste the code you received.", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a213", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Paste the code you received.", - "lineHeight": 1.3 - }, - { - "id": "d1-field", - "type": "rectangle", - "x": 76, - "y": 2160, - "width": 268, - "height": 40, - "strokeColor": "transparent", - "backgroundColor": "#eceef1", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "D1", - "lane3" - ], - "seed": 3105, - "index": "a214", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "d1-fieldt", - "type": "text", - "x": 80, - "y": 2174, - "width": 260, - "height": 13, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D1", - "lane3" - ], - "seed": 3106, - "text": "SB-XXXXX-XXXXX-XXXXX-XXXXX", - "fontSize": 10, - "fontFamily": 3, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a215", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "SB-XXXXX-XXXXX-XXXXX-XXXXX", - "lineHeight": 1.3 - }, - { - "id": "d1-paste", - "type": "text", - "x": 80, - "y": 2216, - "width": 260, - "height": 13, - "strokeColor": "#a15c1e", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D1", - "lane3" - ], - "seed": 3107, - "text": "Paste", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a216", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Paste", - "lineHeight": 1.3 - }, - { - "id": "d1-scan", - "type": "rectangle", - "x": 126, - "y": 2254, - "width": 168, - "height": 168, - "strokeColor": "#d6dae0", - "backgroundColor": "#f2f3f5", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "D1", - "lane3" - ], - "seed": 3108, - "index": "a217", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "d1-scant", - "type": "text", - "x": 126, - "y": 2332, - "width": 168, - "height": 12, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D1", - "lane3" - ], - "seed": 3109, - "text": "QR scanner", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a218", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "QR scanner", - "lineHeight": 1.3333333333333333 - }, - { - "id": "d1-btn", - "type": "rectangle", - "x": 90, - "y": 2510, - "width": 240, - "height": 38, - "strokeColor": "transparent", - "backgroundColor": "#dcc9b4", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "D1", - "lane3" - ], - "seed": 3110, - "boundElements": [ - { - "id": "d1-btnt", - "type": "text" - } - ], - "index": "a219", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "d1-btnt", - "type": "text", - "x": 100, - "y": 2522, - "width": 220, - "height": 14, - "strokeColor": "#ffffff", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D1", - "lane3" - ], - "seed": 3111, - "text": "Redeem", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "d1-btn", - "index": "a21a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "originalText": "Redeem", - "autoResize": true, - "lineHeight": 1.2727272727272727 - }, - { - "id": "d1-note", - "type": "text", - "x": 80, - "y": 2566, - "width": 260, - "height": 24, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D1", - "lane3" - ], - "seed": 3112, - "text": "opened by Buy in browser at the same\nmoment as the browser, and waiting", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a21b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "opened by Buy in browser at the same\nmoment as the browser, and waiting", - "lineHeight": 1.3333333333333333 - }, - { - "id": "d1-capt", - "type": "text", - "x": 60, - "y": 2680, - "width": 300, - "height": 14, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D1", - "lane3" - ], - "seed": 3113, - "text": "D1. Both halves at once", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a21c", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "D1. Both halves at once", - "lineHeight": 1.2727272727272727 - }, - { - "id": "d1-capb", - "type": "text", - "x": 60, - "y": 2700, - "width": 340, - "height": 66, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D1", - "lane3" - ], - "seed": 3114, - "text": "On desktop, Buy in browser opens the page and this screen\ntogether, so the code has somewhere to go the moment it\nappears. No new screen, no deep link, no A6 - the shipped\nflow, with one extra window. It is also the honest fallback for\nany platform where the scheme turns out not to work.", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a21d", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "On desktop, Buy in browser opens the page and this screen\ntogether, so the code has somewhere to go the moment it\nappears. No new screen, no deep link, no A6 - the shipped\nflow, with one extra window. It is also the honest fallback for\nany platform where the scheme turns out not to work.", - "lineHeight": 1.4666666666666666 - }, - { - "id": "d2-panel", - "type": "rectangle", - "x": 480, - "y": 2040, - "width": 300, - "height": 620, - "strokeColor": "#8f97a3", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "D2", - "lane3" - ], - "seed": 3201, - "boundElements": [ - { - "id": "ar-d1d2", - "type": "arrow" - } - ], - "index": "a220", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "d2-url", - "type": "rectangle", - "x": 480, - "y": 2040, - "width": 300, - "height": 26, - "strokeColor": "transparent", - "backgroundColor": "#eceef1", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "D2", - "lane3" - ], - "seed": 3202, - "index": "a221", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "d2-urlt", - "type": "text", - "x": 496, - "y": 2048, - "width": 268, - "height": 12, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D2", - "lane3" - ], - "seed": 3203, - "text": "badges.simplex.chat/#/tier?app=desktop", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a222", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "badges.simplex.chat/#/tier?app=desktop", - "lineHeight": 1.3333333333333333 - }, - { - "id": "d2-title", - "type": "text", - "x": 496, - "y": 2090, - "width": 268, - "height": 22, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D2", - "lane3" - ], - "seed": 3204, - "text": "Paid. Here is your code.", - "fontSize": 16, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a223", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Paid. Here is your code.", - "lineHeight": 1.375 - }, - { - "id": "d2-code", - "type": "rectangle", - "x": 496, - "y": 2128, - "width": 268, - "height": 36, - "strokeColor": "#d6dae0", - "backgroundColor": "#f7f8fa", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "D2", - "lane3" - ], - "seed": 3205, - "index": "a224", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "d2-codet", - "type": "text", - "x": 500, - "y": 2140, - "width": 260, - "height": 14, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D2", - "lane3" - ], - "seed": 3206, - "text": "SB-4K2M9-XQ7VT-H3BNP-R8WLC", - "fontSize": 10, - "fontFamily": 3, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a225", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "SB-4K2M9-XQ7VT-H3BNP-R8WLC", - "lineHeight": 1.4 - }, - { - "id": "d2-btn", - "type": "rectangle", - "x": 496, - "y": 2180, - "width": 268, - "height": 34, - "strokeColor": "#0053d0", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "D2", - "lane3" - ], - "seed": 3207, - "boundElements": [ - { - "id": "d2-btnt", - "type": "text" - } - ], - "index": "a226", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "d2-btnt", - "type": "text", - "x": 506, - "y": 2190, - "width": 248, - "height": 14, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D2", - "lane3" - ], - "seed": 3208, - "text": "Copy code", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "d2-btn", - "index": "a227", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "originalText": "Copy code", - "autoResize": true, - "lineHeight": 1.2727272727272727 - }, - { - "id": "d2-qr", - "type": "rectangle", - "x": 496, - "y": 2236, - "width": 110, - "height": 110, - "strokeColor": "#d6dae0", - "backgroundColor": "#f7f8fa", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": null, - "groupIds": [ - "D2", - "lane3" - ], - "seed": 3209, - "index": "a228", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "d2-qrt", - "type": "text", - "x": 496, - "y": 2352, - "width": 110, - "height": 24, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D2", - "lane3" - ], - "seed": 3210, - "text": "scan to carry it\nto your phone", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a229", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "scan to carry it\nto your phone", - "lineHeight": 1.3333333333333333 - }, - { - "id": "d2-redeem", - "type": "text", - "x": 622, - "y": 2236, - "width": 142, - "height": 36, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D2", - "lane3" - ], - "seed": 3211, - "text": "REDEEM IT IN THE APP\nThe Redeem code screen\nis already open.", - "fontSize": 8.5, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a22a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "REDEEM IT IN THE APP\nThe Redeem code screen\nis already open.", - "lineHeight": 1.411764705882353 - }, - { - "id": "d2-warn", - "type": "rectangle", - "x": 622, - "y": 2288, - "width": 142, - "height": 58, - "strokeColor": "transparent", - "backgroundColor": "#fdf3e7", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "D2", - "lane3" - ], - "seed": 3212, - "index": "a22b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "d2-warnt", - "type": "text", - "x": 632, - "y": 2298, - "width": 122, - "height": 38, - "strokeColor": "#8a5a1e", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D2", - "lane3" - ], - "seed": 3213, - "text": "This is the only copy.\nSaved in this browser\nand nowhere else.", - "fontSize": 8.5, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a22c", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "This is the only copy.\nSaved in this browser\nand nowhere else.", - "lineHeight": 1.4901960784313726 - }, - { - "id": "d2-capt", - "type": "text", - "x": 480, - "y": 2680, - "width": 300, - "height": 14, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D2", - "lane3" - ], - "seed": 3214, - "text": "D2. The page, told which app it is", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a22d", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "D2. The page, told which app it is", - "lineHeight": 1.2727272727272727 - }, - { - "id": "d2-capb", - "type": "text", - "x": 480, - "y": 2700, - "width": 340, - "height": 66, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D2", - "lane3" - ], - "seed": 3215, - "text": "codeIssued as it ships, with one line changed. The app builds\nthe URL, so ?app=desktop is a fact rather than a guess -\nbetter than sniffing the user agent, which cannot tell a\ndesktop browser opened by SimpleX from any other. Mobile\ngets ?app=true and the A5 screen instead.", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a22e", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "codeIssued as it ships, with one line changed. The app builds\nthe URL, so ?app=desktop is a fact rather than a guess -\nbetter than sniffing the user agent, which cannot tell a\ndesktop browser opened by SimpleX from any other. Mobile\ngets ?app=true and the A5 screen instead.", - "lineHeight": 1.4666666666666666 - }, - { - "id": "q-box", - "type": "rectangle", - "x": 900, - "y": 2040, - "width": 1540, - "height": 260, - "strokeColor": "#e0a672", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "dashed", - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "Q", - "lane3" - ], - "seed": 3301, - "index": "a230", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "q-head", - "type": "text", - "x": 924, - "y": 2064, - "width": 900, - "height": 20, - "strokeColor": "#a15c1e", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "Q", - "lane3" - ], - "seed": 3302, - "text": "QUESTIONS - deep links, and whether the desktop can have them at all", - "fontSize": 15, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a231", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "QUESTIONS - deep links, and whether the desktop can have them at all", - "lineHeight": 1.3333333333333333 - }, - { - "id": "q-body", - "type": "text", - "x": 924, - "y": 2098, - "width": 1490, - "height": 130, - "strokeColor": "#4b5563", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "Q", - "lane3" - ], - "seed": 3303, - "text": "1. Desktop has no scheme handler today. Nothing in the desktop module registers one and nothing handles it: appOpenUrl is wired in commonMain but has no desktop plumbing, and\n desktop/build.gradle.kts declares only Deb, Dmg, Msi and Exe with no protocol entry. Adding one is per-OS work - CFBundleURLTypes plus setOpenURIHandler on macOS, registry keys\n the MSI would have to write on Windows, a .desktop file with x-scheme-handler/simplexchat on Linux - and it would still not cover an unpackaged run. Hence D1: desktop does not wait for it.\n\n2. Will a browser follow it without a tap? The attempt is made from a hidden frame, so a refusal stays inside the frame rather than replacing the screen with an error page that would\n put the code into history. Expect nothing to happen on iOS Safari, which has ignored custom schemes in frames for years, nor on Chrome, which blocks a gestureless external-protocol\n launch - and a payment settling minutes after the last tap has no gesture. So Return to SimpleX is the real path. What is left is to try it on devices once the app registers the scheme.", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a232", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "1. Desktop has no scheme handler today. Nothing in the desktop module registers one and nothing handles it: appOpenUrl is wired in commonMain but has no desktop plumbing, and\n desktop/build.gradle.kts declares only Deb, Dmg, Msi and Exe with no protocol entry. Adding one is per-OS work - CFBundleURLTypes plus setOpenURIHandler on macOS, registry keys\n the MSI would have to write on Windows, a .desktop file with x-scheme-handler/simplexchat on Linux - and it would still not cover an unpackaged run. Hence D1: desktop does not wait for it.\n\n2. Will a browser follow it without a tap? The attempt is made from a hidden frame, so a refusal stays inside the frame rather than replacing the screen with an error page that would\n put the code into history. Expect nothing to happen on iOS Safari, which has ignored custom schemes in frames for years, nor on Chrome, which blocks a gestureless external-protocol\n launch - and a payment settling minutes after the last tap has no gesture. So Return to SimpleX is the real path. What is left is to try it on devices once the app registers the scheme.", - "lineHeight": 1.4444444444444444 - }, - { - "id": "n-box", - "type": "rectangle", - "x": 900, - "y": 2340, - "width": 1540, - "height": 300, - "strokeColor": "#c9ced6", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "N", - "lane3" - ], - "seed": 3401, - "index": "a233", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "n-head", - "type": "text", - "x": 924, - "y": 2364, - "width": 900, - "height": 20, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "N", - "lane3" - ], - "seed": 3402, - "text": "What has to be built, and what it costs", - "fontSize": 15, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a234", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "What has to be built, and what it costs", - "lineHeight": 1.3333333333333333 - }, - { - "id": "n-body", - "type": "text", - "x": 924, - "y": 2398, - "width": 1490, - "height": 220, - "strokeColor": "#4b5563", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "N", - "lane3" - ], - "seed": 3403, - "text": "The browser lane needs nothing from the service: the code purchase runs exactly as it ships, the browser mints the code and sends only its SHA-256, and the only reason the buyer\nnever sees one is that the link carries it instead of the screen. The store lane is the part that does need service work - purchaseBadge has to accept a store receipt and verify it,\nand no provider adapter for that exists today.\n\nThe page needs three things: read the app flag from the fragment and start at Choose your badge; at the end, try the deep link and offer Return to SimpleX with Show code under it;\nand for ?app=desktop, keep showing codeIssued as it does now.\n\nThe app needs: Buy in browser without its confirm alert; a handler for the new link; A6, a spinner around the existing apiRedeemBadgeCode; on desktop, opening Redeem code alongside\nthe browser; and for the store lane, B4's checkout and the two BadgeState outcomes behind B5.\n\nWhat it costs: the app cannot show a price before sending the buyer out, because it has no catalog - Buy in browser is a leap into a page. And the code still exists, so it can be\nintercepted, screenshotted from the backup button, or pasted somewhere else. Hidden is not the same as absent.", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a235", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "The browser lane needs nothing from the service: the code purchase runs exactly as it ships, the browser mints the code and sends only its SHA-256, and the only reason the buyer\nnever sees one is that the link carries it instead of the screen. The store lane is the part that does need service work - purchaseBadge has to accept a store receipt and verify it,\nand no provider adapter for that exists today.\n\nThe page needs three things: read the app flag from the fragment and start at Choose your badge; at the end, try the deep link and offer Return to SimpleX with Show code under it;\nand for ?app=desktop, keep showing codeIssued as it does now.\n\nThe app needs: Buy in browser without its confirm alert; a handler for the new link; A6, a spinner around the existing apiRedeemBadgeCode; on desktop, opening Redeem code alongside\nthe browser; and for the store lane, B4's checkout and the two BadgeState outcomes behind B5.\n\nWhat it costs: the app cannot show a price before sending the buyer out, because it has no catalog - Buy in browser is a leap into a page. And the code still exists, so it can be\nintercepted, screenshotted from the backup button, or pasted somewhere else. Hidden is not the same as absent.", - "lineHeight": 2 - }, - { - "id": "ar-d1d2", - "type": "arrow", - "x": 368, - "y": 2350, - "width": 104, - "height": 0, - "strokeColor": "#a15c1e", - "backgroundColor": "transparent", - "strokeWidth": 2, - "strokeStyle": "dashed", - "roughness": 0, - "roundness": null, - "elbowed": true, - "groupIds": [], - "seed": 3501, - "points": [ - [ - 0, - 0 - ], - [ - 104, - 0 - ] - ], - "startArrowhead": "arrow", - "endArrowhead": "arrow", - "startBinding": null, - "endBinding": null, - "boundElements": [ - { - "id": "ar-d1d2-t", - "type": "text" - } - ], - "index": "a250", - "version": 2, - "versionNonce": 1589412771, - "isDeleted": false, - "fillStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251896821, - "link": null, - "locked": false, - "lastCommittedPoint": null - }, - { - "id": "ar-d1d2-t", - "type": "text", - "x": 370.23193359375, - "y": 2337, - "width": 99.5361328125, - "height": 26, - "strokeColor": "#a15c1e", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [], - "seed": 3502, - "text": "opened together, code\ncarried by hand", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "ar-d1d2", - "index": "a251", - "version": 4, - "versionNonce": 1533799245, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251895408, - "link": null, - "locked": false, - "originalText": "opened together, code carried by hand", - "autoResize": true, - "lineHeight": 1.3 - }, - { - "id": "ft-note", - "type": "text", - "x": 60, - "y": 2830, - "width": 2400, - "height": 26, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "footer" - ], - "seed": 4005, - "text": "Copy is quoted from the app and the page. The app's screens take the page's names: Your level becomes Choose your badge, the level name becomes How long?, and Check your order is shared already.\nStore prices are illustrative and come from the storefront; the page's prices are the shipped catalog. Not drawn: the page's refusals - no active price, a provider unavailable, too many attempts - which are unchanged and already built.", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a300", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Copy is quoted from the app and the page. The app's screens take the page's names: Your level becomes Choose your badge, the level name becomes How long?, and Check your order is shared already.\nStore prices are illustrative and come from the storefront; the page's prices are the shipped catalog. Not drawn: the page's refusals - no active price, a provider unavailable, too many attempts - which are unchanged and already built.", - "lineHeight": 1.3 - } + {"id":"hdr-bg","type":"rectangle","x":0,"y":0,"width":2500,"height":96,"strokeColor":"transparent","backgroundColor":"#1c1e26","fillStyle":"solid","strokeWidth":1,"roughness":0,"groupIds":["header"],"index":"a000"}, + {"id":"hdr-title","type":"text","x":40,"y":24,"width":620,"height":28,"strokeColor":"#ffffff","roughness":0,"groupIds":["header"],"text":"Buying a badge - hidden codes","fontSize":22,"fontFamily":2,"index":"a001","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"hdr-sub","type":"text","x":40,"y":58,"width":1500,"height":15,"strokeColor":"#a0a6b0","roughness":0,"groupIds":["header"],"text":"the shipped code purchase, reused whole: the page still mints a code, the deep link carries it, and the buyer never sees it. solid = exists, dashed = does not","fontSize":12,"fontFamily":2,"index":"a002","fillStyle":"solid","strokeWidth":2,"lineHeight":1.25}, + {"id":"hdr-r1","type":"text","x":2260,"y":26,"width":200,"height":15,"strokeColor":"#a0a6b0","roughness":0,"groupIds":["header"],"text":"in-app checkout","fontSize":12,"fontFamily":2,"textAlign":"right","autoResize":false,"index":"a003","fillStyle":"solid","strokeWidth":2,"lineHeight":1.25}, + {"id":"hdr-r2","type":"text","x":2260,"y":48,"width":200,"height":14,"strokeColor":"#6b7280","roughness":0,"groupIds":["header"],"text":"wireframes","fontSize":11,"fontFamily":2,"textAlign":"right","autoResize":false,"index":"a004","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"l1-band","type":"rectangle","x":20,"y":150,"width":2460,"height":850,"strokeColor":"transparent","backgroundColor":"#eef5ff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["lane1"],"index":"a010"}, + {"id":"l1-head","type":"text","x":60,"y":178,"width":900,"height":22,"strokeColor":"#0053d0","roughness":0,"groupIds":["lane1"],"text":"Non-store builds - the browser does all of it","fontSize":17,"fontFamily":2,"index":"a011","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2941176470588236}, + {"id":"l1-sub","type":"text","x":60,"y":206,"width":1300,"height":14,"strokeColor":"#6b7280","roughness":0,"groupIds":["lane1"],"text":"no catalog API, no invoice API, no in-app checkout: the app opens the page, the page sells a code, the deep link brings it home","fontSize":11,"fontFamily":2,"index":"a012","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"a1-panel","type":"rectangle","x":60,"y":260,"width":300,"height":620,"strokeColor":"#8f97a3","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A1","lane1"],"boundElements":[{"id":"ar-a1a2","type":"arrow"}],"index":"a020"}, + {"id":"a1-title","type":"text","x":80,"y":306,"width":260,"height":24,"strokeColor":"#0053d0","roughness":0,"groupIds":["A1","lane1"],"text":"Support SimpleX","fontSize":19,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a021","fillStyle":"solid","strokeWidth":2,"lineHeight":1.263157894736842}, + {"id":"a1-body","type":"text","x":76,"y":348,"width":268,"height":39,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A1","lane1"],"text":"Get a badge to send larger files (2-5GB)\nthat stay available longer (7-21 days),\nand to show it on your profile.","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a022","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"a1-info","type":"text","x":80,"y":404,"width":260,"height":13,"strokeColor":"#0053d0","roughness":0,"groupIds":["A1","lane1"],"text":"(i) How badges protect your privacy","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a023","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"a1-hero","type":"rectangle","x":132,"y":448,"width":156,"height":156,"strokeColor":"transparent","backgroundColor":"#e8eef7","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A1","lane1"],"index":"a024"}, + {"id":"a1-herot","type":"text","x":132,"y":519,"width":156,"height":12,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["A1","lane1"],"text":"phone-supporter","fontSize":9,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a025","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"a1-btn","type":"rectangle","x":90,"y":730,"width":240,"height":38,"strokeColor":"transparent","backgroundColor":"#0053d0","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A1","lane1"],"boundElements":[{"id":"a1-btnt","type":"text"}],"index":"a026"}, + {"id":"a1-btnt","type":"text","x":100,"y":742,"width":220,"height":14,"strokeColor":"#ffffff","roughness":0,"groupIds":["A1","lane1"],"text":"Buy in browser","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"a1-btn","index":"a027","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"a1-sec","type":"text","x":80,"y":786,"width":260,"height":13,"strokeColor":"#0053d0","roughness":0,"groupIds":["A1","lane1"],"text":"Redeem code","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a028","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"a1-secn","type":"text","x":80,"y":806,"width":260,"height":12,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["A1","lane1"],"text":"the manual path, for a code from elsewhere","fontSize":9,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a029","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"a1-capt","type":"text","x":60,"y":900,"width":300,"height":14,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A1","lane1"],"text":"A1. BadgesSupportSimplexView","fontSize":11,"fontFamily":2,"index":"a02a","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"a1-capb","type":"text","x":60,"y":920,"width":340,"height":66,"strokeColor":"#6b7280","roughness":0,"groupIds":["A1","lane1"],"text":"Get your code becomes the primary button and loses the\nconfirm-in-browser alert - the tap opens the browser. Redeem\nbadge code drops to the text button. The info button stays as\nit is - the file's TODO would swap it for Why SimpleX is built\nonce purchase lands, and it should not.\nchooseLevelButton() is never called in this build.","fontSize":9,"fontFamily":2,"index":"a02b","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2222222222222223}, + {"id":"a2-panel","type":"rectangle","x":480,"y":260,"width":300,"height":620,"strokeColor":"#8f97a3","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A2","lane1"],"boundElements":[{"id":"ar-a1a2","type":"arrow"},{"id":"ar-a2a3","type":"arrow"}],"index":"a030"}, + {"id":"a2-url","type":"rectangle","x":480,"y":260,"width":300,"height":26,"strokeColor":"transparent","backgroundColor":"#eceef1","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A2","lane1"],"index":"a031"}, + {"id":"a2-urlt","type":"text","x":496,"y":268,"width":268,"height":12,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["A2","lane1"],"text":"badges.simplex.chat/#/tier?app=true","fontSize":9,"fontFamily":2,"autoResize":false,"index":"a032","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"a2-title","type":"text","x":496,"y":312,"width":268,"height":22,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A2","lane1"],"text":"Choose your badge","fontSize":17,"fontFamily":2,"autoResize":false,"index":"a033","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2941176470588236}, + {"id":"a2-lede","type":"text","x":496,"y":342,"width":268,"height":13,"strokeColor":"#6b7280","roughness":0,"groupIds":["A2","lane1"],"text":"Larger files that stay available longer.","fontSize":9.5,"fontFamily":2,"autoResize":false,"index":"a034","fillStyle":"solid","strokeWidth":2,"lineHeight":1.368421052631579}, + {"id":"a2-c1","type":"rectangle","x":496,"y":378,"width":130,"height":150,"strokeColor":"#d6dae0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":2,"roughness":0,"roundness":{"type":3},"groupIds":["A2","lane1"],"index":"a035"}, + {"id":"a2-c1i","type":"rectangle","x":545,"y":398,"width":32,"height":32,"strokeColor":"transparent","backgroundColor":"#dfe4ea","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A2","lane1"],"index":"a036"}, + {"id":"a2-c1t","type":"text","x":504,"y":442,"width":114,"height":52,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A2","lane1"],"text":"Supporter\n2GB files\n\n$7/month","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a037","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"a2-c2","type":"rectangle","x":634,"y":378,"width":130,"height":150,"strokeColor":"#0053d0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":2,"roughness":0,"roundness":{"type":3},"groupIds":["A2","lane1"],"index":"a038"}, + {"id":"a2-c2i","type":"rectangle","x":683,"y":398,"width":32,"height":32,"strokeColor":"transparent","backgroundColor":"#dfe4ea","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A2","lane1"],"index":"a039"}, + {"id":"a2-c2t","type":"text","x":642,"y":442,"width":114,"height":52,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A2","lane1"],"text":"Legend\n5GB files\n\n$70/month","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a03a","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"a2-btn","type":"rectangle","x":496,"y":560,"width":268,"height":36,"strokeColor":"transparent","backgroundColor":"#0053d0","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A2","lane1"],"boundElements":[{"id":"a2-btnt","type":"text"}],"index":"a03b"}, + {"id":"a2-btnt","type":"text","x":506,"y":571,"width":248,"height":14,"strokeColor":"#ffffff","roughness":0,"groupIds":["A2","lane1"],"text":"Continue","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"a2-btn","index":"a03c","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"a2-capt","type":"text","x":480,"y":900,"width":300,"height":14,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A2","lane1"],"text":"A2. Where the link lands","fontSize":11,"fontFamily":2,"index":"a03d","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"a2-capb","type":"text","x":480,"y":920,"width":340,"height":66,"strokeColor":"#6b7280","roughness":0,"groupIds":["A2","lane1"],"text":"The page's own second screen, quoted as it is. The flag\nin #/tier?app=true skips its Support SimpleX, which the\napp has just shown. NEW on the page: read that flag, start\nhere, and carry it: the session to checkout, then the order.\nThe app builds the URL, so it can also say which kind of\napp it is - better than guessing from the user agent.","fontSize":9,"fontFamily":2,"index":"a03e","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2222222222222223}, + {"id":"a3-panel","type":"rectangle","x":900,"y":260,"width":300,"height":620,"strokeColor":"#8f97a3","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A3","lane1"],"boundElements":[{"id":"ar-a2a3","type":"arrow"},{"id":"ar-a3a4","type":"arrow"}],"index":"a040"}, + {"id":"a3-url","type":"rectangle","x":900,"y":260,"width":300,"height":26,"strokeColor":"transparent","backgroundColor":"#eceef1","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A3","lane1"],"index":"a041"}, + {"id":"a3-urlt","type":"text","x":916,"y":268,"width":268,"height":12,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["A3","lane1"],"text":"badges.simplex.chat/#/tier?app=true","fontSize":9,"fontFamily":2,"autoResize":false,"index":"a042","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"a3-title","type":"text","x":916,"y":312,"width":268,"height":22,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A3","lane1"],"text":"How long?","fontSize":17,"fontFamily":2,"autoResize":false,"index":"a043","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2941176470588236}, + {"id":"a3-lede","type":"text","x":916,"y":342,"width":268,"height":13,"strokeColor":"#6b7280","roughness":0,"groupIds":["A3","lane1"],"text":"Legend - 5GB files, 21 days","fontSize":9.5,"fontFamily":2,"autoResize":false,"index":"a044","fillStyle":"solid","strokeWidth":2,"lineHeight":1.368421052631579}, + {"id":"a3-c1","type":"rectangle","x":916,"y":378,"width":84,"height":124,"strokeColor":"#d6dae0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":2,"roughness":0,"roundness":{"type":3},"groupIds":["A3","lane1"],"index":"a045"}, + {"id":"a3-c1t","type":"text","x":920,"y":418,"width":76,"height":39,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A3","lane1"],"text":"1 month\n\n$70","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a046","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"a3-c2","type":"rectangle","x":1008,"y":378,"width":84,"height":124,"strokeColor":"#d6dae0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":2,"roughness":0,"roundness":{"type":3},"groupIds":["A3","lane1"],"index":"a047"}, + {"id":"a3-c2t","type":"text","x":1012,"y":412,"width":76,"height":52,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A3","lane1"],"text":"3 months\n\n$140\none month free","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a048","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"a3-c3","type":"rectangle","x":1100,"y":378,"width":84,"height":124,"strokeColor":"#0053d0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":2,"roughness":0,"roundness":{"type":3},"groupIds":["A3","lane1"],"index":"a049"}, + {"id":"a3-c3t","type":"text","x":1104,"y":412,"width":76,"height":52,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A3","lane1"],"text":"12 months\n\n$420\nsave 50%","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a04a","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"a3-note","type":"text","x":916,"y":520,"width":268,"height":24,"strokeColor":"#6b7280","roughness":0,"groupIds":["A3","lane1"],"text":"Paid once, for the months you choose.\nNo subscription.","fontSize":9,"fontFamily":2,"autoResize":false,"index":"a04b","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"a3-btn","type":"rectangle","x":916,"y":560,"width":268,"height":36,"strokeColor":"transparent","backgroundColor":"#0053d0","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A3","lane1"],"boundElements":[{"id":"a3-btnt","type":"text"}],"index":"a04c"}, + {"id":"a3-btnt","type":"text","x":926,"y":571,"width":248,"height":14,"strokeColor":"#ffffff","roughness":0,"groupIds":["A3","lane1"],"text":"Continue","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"a3-btn","index":"a04d","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"a3-capt","type":"text","x":900,"y":900,"width":300,"height":14,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A3","lane1"],"text":"A3. Unchanged","fontSize":11,"fontFamily":2,"index":"a04e","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"a3-capb","type":"text","x":900,"y":920,"width":340,"height":66,"strokeColor":"#6b7280","roughness":0,"groupIds":["A3","lane1"],"text":"The page's own screen, and the source of the names this\nplan adopts in the app: Choose your badge, How long?,\nCheck your order. Prices and offers are the compiled catalog\nthe page already has, so getBadgeCatalog is not needed by\nanyone in this lane.","fontSize":9,"fontFamily":2,"index":"a04f","fillStyle":"solid","strokeWidth":2,"lineHeight":1.4666666666666666}, + {"id":"a4-panel","type":"rectangle","x":1320,"y":260,"width":300,"height":620,"strokeColor":"#8f97a3","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A4","lane1"],"boundElements":[{"id":"ar-a3a4","type":"arrow"},{"id":"ar-a4a5","type":"arrow"}],"index":"a050"}, + {"id":"a4-url","type":"rectangle","x":1320,"y":260,"width":300,"height":26,"strokeColor":"transparent","backgroundColor":"#eceef1","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A4","lane1"],"index":"a051"}, + {"id":"a4-urlt","type":"text","x":1336,"y":268,"width":268,"height":12,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["A4","lane1"],"text":"badges.simplex.chat/?order=8f3a...","fontSize":9,"fontFamily":2,"autoResize":false,"index":"a052","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"a4-title","type":"text","x":1336,"y":312,"width":268,"height":22,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A4","lane1"],"text":"Check your order","fontSize":17,"fontFamily":2,"autoResize":false,"index":"a053","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2941176470588236}, + {"id":"a4-rowsL","type":"text","x":1336,"y":352,"width":160,"height":48,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A4","lane1"],"text":"Your badge\nDuration\nLaunch discount: 50% off\nTotal","fontSize":9.5,"fontFamily":2,"autoResize":false,"index":"a054","fillStyle":"solid","strokeWidth":2,"lineHeight":1.263157894736842}, + {"id":"a4-rowsR","type":"text","x":1508,"y":352,"width":96,"height":48,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A4","lane1"],"text":"Legend\n12 months\n-$420.00\n$420.00","fontSize":9.5,"fontFamily":2,"textAlign":"right","autoResize":false,"index":"a055","fillStyle":"solid","strokeWidth":2,"lineHeight":1.263157894736842}, + {"id":"a4-label","type":"text","x":1336,"y":420,"width":100,"height":12,"strokeColor":"#6b7280","roughness":0,"groupIds":["A4","lane1"],"text":"PAY WITH","fontSize":9,"fontFamily":2,"autoResize":false,"index":"a056","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"a4-m1","type":"rectangle","x":1336,"y":438,"width":84,"height":60,"strokeColor":"#d6dae0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":2,"roughness":0,"roundness":{"type":3},"groupIds":["A4","lane1"],"index":"a057"}, + {"id":"a4-m1t","type":"text","x":1340,"y":460,"width":76,"height":13,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A4","lane1"],"text":"Bitcoin","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a058","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"a4-m2","type":"rectangle","x":1428,"y":438,"width":84,"height":60,"strokeColor":"#0053d0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":2,"roughness":0,"roundness":{"type":3},"groupIds":["A4","lane1"],"index":"a059"}, + {"id":"a4-m2t","type":"text","x":1432,"y":460,"width":76,"height":13,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A4","lane1"],"text":"Monero","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a05a","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"a4-m3","type":"rectangle","x":1520,"y":438,"width":84,"height":60,"strokeColor":"#d6dae0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":2,"roughness":0,"roundness":{"type":3},"groupIds":["A4","lane1"],"index":"a05b"}, + {"id":"a4-m3t","type":"text","x":1524,"y":460,"width":76,"height":13,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A4","lane1"],"text":"Card","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a05c","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"a4-btn","type":"rectangle","x":1336,"y":560,"width":268,"height":36,"strokeColor":"transparent","backgroundColor":"#0053d0","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A4","lane1"],"boundElements":[{"id":"a4-btnt","type":"text"}],"index":"a05d"}, + {"id":"a4-btnt","type":"text","x":1346,"y":571,"width":248,"height":14,"strokeColor":"#ffffff","roughness":0,"groupIds":["A4","lane1"],"text":"Pay $420.00 with Monero","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"a4-btn","index":"a05e","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"a4-capt","type":"text","x":1320,"y":900,"width":300,"height":14,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A4","lane1"],"text":"A4. Unchanged, and the pay screens after it","fontSize":11,"fontFamily":2,"index":"a05f","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"a4-capb","type":"text","x":1320,"y":920,"width":340,"height":66,"strokeColor":"#6b7280","roughness":0,"groupIds":["A4","lane1"],"text":"The method is picked here and the code is minted here, as\ntoday: the browser generates it and sends only its SHA-256.\nSend 1.482 XMR, the countdown and Payment received follow\nand are unchanged, so they are not drawn. The service needs\nno new API for this lane at all.","fontSize":9,"fontFamily":2,"index":"a05g","fillStyle":"solid","strokeWidth":2,"lineHeight":1.4666666666666666}, + {"id":"a5-panel","type":"rectangle","x":1740,"y":260,"width":300,"height":620,"strokeColor":"#0053d0","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"strokeStyle":"dashed","roughness":0,"roundness":{"type":3},"groupIds":["A5","lane1"],"boundElements":[{"id":"ar-a4a5","type":"arrow"},{"id":"ar-a5a6","type":"arrow"}],"index":"a060"}, + {"id":"a5-url","type":"rectangle","x":1740,"y":260,"width":300,"height":26,"strokeColor":"transparent","backgroundColor":"#eceef1","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A5","lane1"],"index":"a061"}, + {"id":"a5-urlt","type":"text","x":1756,"y":268,"width":268,"height":12,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["A5","lane1"],"text":"badges.simplex.chat/?order=8f3a...","fontSize":9,"fontFamily":2,"autoResize":false,"index":"a062","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"a5-title","type":"text","x":1756,"y":312,"width":268,"height":22,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A5","lane1"],"text":"Paid","fontSize":17,"fontFamily":2,"autoResize":false,"index":"a063","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2941176470588236}, + {"id":"a5-lede","type":"text","x":1756,"y":344,"width":268,"height":26,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A5","lane1"],"text":"Opening SimpleX to add your badge.\nIf nothing happens, use the button.","fontSize":9.5,"fontFamily":2,"autoResize":false,"index":"a064","fillStyle":"solid","strokeWidth":2,"lineHeight":1.368421052631579}, + {"id":"a5-btn","type":"rectangle","x":1756,"y":396,"width":268,"height":38,"strokeColor":"transparent","backgroundColor":"#0053d0","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A5","lane1"],"boundElements":[{"id":"a5-btnt","type":"text"}],"index":"a065"}, + {"id":"a5-btnt","type":"text","x":1766,"y":408,"width":248,"height":14,"strokeColor":"#ffffff","roughness":0,"groupIds":["A5","lane1"],"text":"Return to SimpleX","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"a5-btn","index":"a066","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"a5-sec","type":"text","x":1756,"y":452,"width":268,"height":13,"strokeColor":"#0053d0","roughness":0,"groupIds":["A5","lane1"],"text":"Show code","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a067","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"a5-link","type":"rectangle","x":1756,"y":496,"width":268,"height":34,"strokeColor":"#d6dae0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A5","lane1"],"index":"a068"}, + {"id":"a5-linkt","type":"text","x":1762,"y":507,"width":256,"height":13,"strokeColor":"#6b7280","roughness":0,"groupIds":["A5","lane1"],"text":"simplexchat:/badge/code/SB-4K2M9-...","fontSize":9,"fontFamily":3,"textAlign":"center","autoResize":false,"index":"a069","fillStyle":"solid","strokeWidth":2,"lineHeight":1.4444444444444444}, + {"id":"a5-note","type":"text","x":1756,"y":544,"width":268,"height":33,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["A5","lane1"],"text":"the page tries this on load; the button is\nthe backup, and Show code is the\nbackup to the backup","fontSize":9,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a06a","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2222222222222223}, + {"id":"a5-capt","type":"text","x":1740,"y":900,"width":300,"height":14,"strokeColor":"#0053d0","roughness":0,"groupIds":["A5","lane1"],"text":"A5. New: the code leaves by link, not by eye","fontSize":11,"fontFamily":2,"index":"a06b","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"a5-capb","type":"text","x":1740,"y":920,"width":340,"height":66,"strokeColor":"#6b7280","roughness":0,"groupIds":["A5","lane1"],"text":"Replaces codeIssued for a mobile app purchase. The flag is\nnot in the URL here - it rode the session to checkout and is\nstamped on the order. The code is still minted and saved, so\nShow code recovers it if the link fails; a code that could\nnot be saved gets the plain screen instead. simplexchat: is a\nscheme of its own, so a badge link can never be read as a\nconnection link. The attempt is silent where it is blocked.","fontSize":9,"fontFamily":2,"index":"a06c","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2222222222222223}, + {"id":"a6-panel","type":"rectangle","x":2160,"y":260,"width":300,"height":620,"strokeColor":"#0053d0","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"strokeStyle":"dashed","roughness":0,"roundness":{"type":3},"groupIds":["A6","lane1"],"boundElements":[{"id":"ar-a5a6","type":"arrow"}],"index":"a070"}, + {"id":"a6-title","type":"text","x":2180,"y":306,"width":260,"height":24,"strokeColor":"#0053d0","roughness":0,"groupIds":["A6","lane1"],"text":"Badge is being issued","fontSize":18,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a071","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"a6-spin","type":"ellipse","x":2288,"y":380,"width":44,"height":44,"strokeColor":"#0053d0","fillStyle":"solid","strokeWidth":2,"roughness":0,"groupIds":["A6","lane1"],"index":"a072"}, + {"id":"a6-spint","type":"text","x":2180,"y":440,"width":260,"height":12,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["A6","lane1"],"text":"progress","fontSize":9,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a073","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"a6-body","type":"text","x":2186,"y":478,"width":248,"height":39,"strokeColor":"#6b7280","roughness":0,"groupIds":["A6","lane1"],"text":"This takes a moment. You do not\nneed to do anything, and you can\nclose the app.","fontSize":9.5,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a074","fillStyle":"solid","strokeWidth":2,"lineHeight":1.368421052631579}, + {"id":"a6-then","type":"rectangle","x":2176,"y":600,"width":268,"height":96,"strokeColor":"#8f97a3","backgroundColor":"#f2f3f5","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A6","lane1"],"index":"a075"}, + {"id":"a6-theni","type":"rectangle","x":2190,"y":612,"width":240,"height":72,"strokeColor":"transparent","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A6","lane1"],"index":"a076"}, + {"id":"a6-theng","type":"rectangle","x":2298,"y":620,"width":24,"height":24,"strokeColor":"transparent","backgroundColor":"#dfe4ea","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A6","lane1"],"index":"a077"}, + {"id":"a6-thent","type":"text","x":2180,"y":650,"width":260,"height":13,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A6","lane1"],"text":"Legend","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a078","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"a6-thens","type":"text","x":2180,"y":666,"width":260,"height":11,"strokeColor":"#6b7280","roughness":0,"groupIds":["A6","lane1"],"text":"shown on your profile","fontSize":8.5,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a079","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2941176470588236}, + {"id":"a6-thenn","type":"text","x":2160,"y":708,"width":300,"height":11,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["A6","lane1"],"text":"then BadgesYourBadgeView, unchanged","fontSize":8.5,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a07a","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2941176470588236}, + {"id":"a6-capt","type":"text","x":2160,"y":900,"width":300,"height":14,"strokeColor":"#0053d0","roughness":0,"groupIds":["A6","lane1"],"text":"A6. New screen, and the only new app state","fontSize":11,"fontFamily":2,"index":"a07b","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"a6-capb","type":"text","x":2160,"y":920,"width":340,"height":66,"strokeColor":"#6b7280","roughness":0,"groupIds":["A6","lane1"],"text":"The deep link lands here, not on Redeem code: a pasted code\nand a QR scanner make no sense when the code arrived by\nlink. It calls the existing apiRedeemBadgeCode and shows its\nfailures as that screen does. Naming: Redeeming code names\nthe mechanism, Badge is being issued names the outcome -\nand the outcome is what the buyer cares about.","fontSize":9,"fontFamily":2,"index":"a07c","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2222222222222223}, + {"id":"ar-a1a2","type":"arrow","x":368,"y":570,"width":104,"height":0,"strokeColor":"#0053d0","strokeWidth":2,"roughness":0,"elbowed":true,"points":[[0,0],[104,0]],"endArrowhead":"arrow","boundElements":[{"id":"ar-a1a2-t","type":"text"}],"index":"a080","fillStyle":"solid"}, + {"id":"ar-a1a2-t","type":"text","x":365.810546875,"y":563.5,"width":108.37890625,"height":13,"strokeColor":"#0053d0","roughness":0,"text":"Buy in browser - no alert","fontSize":10,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar-a1a2","index":"a081","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"ar-a2a3","type":"arrow","x":788,"y":570,"width":104,"height":0,"strokeColor":"#0053d0","strokeWidth":2,"roughness":0,"elbowed":true,"points":[[0,0],[104,0]],"endArrowhead":"arrow","boundElements":[{"id":"ar-a2a3-t","type":"text"}],"index":"a082","fillStyle":"solid"}, + {"id":"ar-a2a3-t","type":"text","x":800,"y":556,"width":80,"height":13,"strokeColor":"#0053d0","roughness":0,"text":"Continue","fontSize":10,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar-a2a3","index":"a083","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"ar-a3a4","type":"arrow","x":1208,"y":570,"width":104,"height":0,"strokeColor":"#0053d0","strokeWidth":2,"roughness":0,"elbowed":true,"points":[[0,0],[104,0]],"endArrowhead":"arrow","boundElements":[{"id":"ar-a3a4-t","type":"text"}],"index":"a084","fillStyle":"solid"}, + {"id":"ar-a3a4-t","type":"text","x":1220,"y":556,"width":80,"height":13,"strokeColor":"#0053d0","roughness":0,"text":"Continue","fontSize":10,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar-a3a4","index":"a085","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"ar-a4a5","type":"arrow","x":1628,"y":570,"width":104,"height":0,"strokeColor":"#0053d0","strokeWidth":2,"roughness":0,"elbowed":true,"points":[[0,0],[104,0]],"endArrowhead":"arrow","boundElements":[{"id":"ar-a4a5-t","type":"text"}],"index":"a086","fillStyle":"solid"}, + {"id":"ar-a4a5-t","type":"text","x":1640,"y":556,"width":80,"height":13,"strokeColor":"#0053d0","roughness":0,"text":"it settles","fontSize":10,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar-a4a5","index":"a087","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"ar-a5a6","type":"arrow","x":2048,"y":570,"width":104,"height":0,"strokeColor":"#0053d0","strokeWidth":2,"roughness":0,"elbowed":true,"points":[[0,0],[104,0]],"endArrowhead":"arrow","boundElements":[{"id":"ar-a5a6-t","type":"text"}],"index":"a088","fillStyle":"solid"}, + {"id":"ar-a5a6-t","type":"text","x":2056.93115234375,"y":557,"width":86.1376953125,"height":26,"strokeColor":"#0053d0","roughness":0,"text":"simplexchat: carries\nthe code","fontSize":10,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar-a5a6","index":"a089","fillStyle":"solid","strokeWidth":2,"originalText":"simplexchat: carries the code","lineHeight":1.3}, + {"id":"l2-band","type":"rectangle","x":20,"y":1040,"width":2460,"height":850,"strokeColor":"transparent","backgroundColor":"#f5f1ff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["lane2"],"index":"a100"}, + {"id":"l2-head","type":"text","x":60,"y":1068,"width":900,"height":22,"strokeColor":"#5b21b6","roughness":0,"groupIds":["lane2"],"text":"Store builds - the store path in the app, the browser alongside it","fontSize":17,"fontFamily":2,"index":"a101","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2941176470588236}, + {"id":"l2-sub","type":"text","x":60,"y":1096,"width":1300,"height":14,"strokeColor":"#6b7280","roughness":0,"groupIds":["lane2"],"text":"Buy in browser runs lane A unchanged; only these four screens and the outcomes after them are in-app, and only here","fontSize":11,"fontFamily":2,"index":"a102","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"b1-panel","type":"rectangle","x":60,"y":1150,"width":300,"height":620,"strokeColor":"#8f97a3","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["B1","lane2"],"boundElements":[{"id":"ar-b1b2","type":"arrow"}],"index":"a110"}, + {"id":"b1-title","type":"text","x":80,"y":1196,"width":260,"height":24,"strokeColor":"#5b21b6","roughness":0,"groupIds":["B1","lane2"],"text":"Support SimpleX","fontSize":19,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a111","fillStyle":"solid","strokeWidth":2,"lineHeight":1.263157894736842}, + {"id":"b1-body","type":"text","x":76,"y":1238,"width":268,"height":39,"strokeColor":"#1c1e26","roughness":0,"groupIds":["B1","lane2"],"text":"Get a badge to send larger files (2-5GB)\nthat stay available longer (7-21 days),\nand to show it on your profile.","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a112","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"b1-info","type":"text","x":80,"y":1294,"width":260,"height":13,"strokeColor":"#5b21b6","roughness":0,"groupIds":["B1","lane2"],"text":"(i) How badges protect your privacy","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a113","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"b1-hero","type":"rectangle","x":132,"y":1338,"width":156,"height":156,"strokeColor":"transparent","backgroundColor":"#efeaf9","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["B1","lane2"],"index":"a114"}, + {"id":"b1-herot","type":"text","x":132,"y":1409,"width":156,"height":12,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["B1","lane2"],"text":"phone-supporter","fontSize":9,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a115","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"b1-btn","type":"rectangle","x":90,"y":1620,"width":240,"height":38,"strokeColor":"transparent","backgroundColor":"#5b21b6","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["B1","lane2"],"boundElements":[{"id":"b1-btnt","type":"text"}],"index":"a116"}, + {"id":"b1-btnt","type":"text","x":100,"y":1632,"width":220,"height":14,"strokeColor":"#ffffff","roughness":0,"groupIds":["B1","lane2"],"text":"Choose your badge","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"b1-btn","index":"a117","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"b1-sec","type":"text","x":80,"y":1676,"width":260,"height":13,"strokeColor":"#5b21b6","roughness":0,"groupIds":["B1","lane2"],"text":"Buy in browser","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a118","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"b1-secn","type":"text","x":80,"y":1696,"width":260,"height":24,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["B1","lane2"],"text":"where the browser is not allowed,\nthis slot reads Redeem code instead","fontSize":9,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a119","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"b1-capt","type":"text","x":60,"y":1790,"width":300,"height":14,"strokeColor":"#1c1e26","roughness":0,"groupIds":["B1","lane2"],"text":"B1. Two buttons, one of them gated","fontSize":11,"fontFamily":2,"index":"a11a","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"b1-capb","type":"text","x":60,"y":1810,"width":340,"height":66,"strokeColor":"#6b7280","roughness":0,"groupIds":["B1","lane2"],"text":"Buy in browser is the same tap as A1's and runs the same\nlane, but only where the store rules allow it - a wider set of\nmarkets than the crowdfunding banner covers, and not pinned\ndown yet. Where it is not allowed the slot reads Redeem code\ninstead, so this screen always offers one of the two and codes\nare never out of reach.","fontSize":9,"fontFamily":2,"index":"a11b","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2222222222222223}, + {"id":"b2-panel","type":"rectangle","x":480,"y":1150,"width":300,"height":620,"strokeColor":"#8f97a3","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["B2","lane2"],"boundElements":[{"id":"ar-b1b2","type":"arrow"},{"id":"ar-b2b3","type":"arrow"}],"index":"a120"}, + {"id":"b2-back","type":"text","x":496,"y":1168,"width":20,"height":14,"strokeColor":"#5b21b6","roughness":0,"groupIds":["B2","lane2"],"text":"<","fontSize":11,"fontFamily":2,"autoResize":false,"index":"a121","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"b2-title","type":"text","x":500,"y":1196,"width":260,"height":24,"strokeColor":"#5b21b6","roughness":0,"groupIds":["B2","lane2"],"text":"Choose your badge","fontSize":19,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a122","fillStyle":"solid","strokeWidth":2,"lineHeight":1.263157894736842}, + {"id":"b2-lede","type":"text","x":500,"y":1236,"width":260,"height":13,"strokeColor":"#6b7280","roughness":0,"groupIds":["B2","lane2"],"text":"Larger files that stay available longer.","fontSize":9.5,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a123","fillStyle":"solid","strokeWidth":2,"lineHeight":1.368421052631579}, + {"id":"b2-avatar","type":"ellipse","x":598,"y":1268,"width":56,"height":56,"strokeColor":"transparent","backgroundColor":"#e3e6ea","fillStyle":"solid","strokeWidth":1,"roughness":0,"groupIds":["B2","lane2"],"index":"a124"}, + {"id":"b2-name","type":"text","x":500,"y":1334,"width":260,"height":15,"strokeColor":"#1c1e26","roughness":0,"groupIds":["B2","lane2"],"text":"My nickname [badge] v","fontSize":12,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a125","fillStyle":"solid","strokeWidth":2,"lineHeight":1.25}, + {"id":"b2-c1","type":"rectangle","x":492,"y":1378,"width":132,"height":150,"strokeColor":"#5b21b6","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":2,"roughness":0,"roundness":{"type":3},"groupIds":["B2","lane2"],"index":"a126"}, + {"id":"b2-c1i","type":"rectangle","x":542,"y":1398,"width":32,"height":32,"strokeColor":"transparent","backgroundColor":"#dfe4ea","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["B2","lane2"],"index":"a127"}, + {"id":"b2-c1t","type":"text","x":500,"y":1442,"width":116,"height":52,"strokeColor":"#1c1e26","roughness":0,"groupIds":["B2","lane2"],"text":"Supporter\nSend 2GB files\n\n$6.99/month","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a128","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"b2-c2","type":"rectangle","x":636,"y":1378,"width":132,"height":150,"strokeColor":"#d6dae0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":2,"roughness":0,"roundness":{"type":3},"groupIds":["B2","lane2"],"index":"a129"}, + {"id":"b2-c2i","type":"rectangle","x":686,"y":1398,"width":32,"height":32,"strokeColor":"transparent","backgroundColor":"#dfe4ea","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["B2","lane2"],"index":"a12a"}, + {"id":"b2-c2t","type":"text","x":644,"y":1442,"width":116,"height":52,"strokeColor":"#1c1e26","roughness":0,"groupIds":["B2","lane2"],"text":"Legend\nSend 5GB files\n\n$69.99/month","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a12b","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"b2-btn","type":"rectangle","x":510,"y":1596,"width":240,"height":38,"strokeColor":"transparent","backgroundColor":"#5b21b6","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["B2","lane2"],"boundElements":[{"id":"b2-btnt","type":"text"}],"index":"a12c"}, + {"id":"b2-btnt","type":"text","x":520,"y":1608,"width":220,"height":14,"strokeColor":"#ffffff","roughness":0,"groupIds":["B2","lane2"],"text":"Continue","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"b2-btn","index":"a12d","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"b2-sec","type":"text","x":500,"y":1652,"width":260,"height":13,"strokeColor":"#5b21b6","roughness":0,"groupIds":["B2","lane2"],"text":"Redeem code","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a12e","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"b2-infon","type":"text","x":500,"y":1676,"width":260,"height":12,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["B2","lane2"],"text":"here only when B1 offers the browser;\nthe privacy link always stays on B1","fontSize":9,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a12f","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"b2-capt","type":"text","x":480,"y":1790,"width":300,"height":14,"strokeColor":"#1c1e26","roughness":0,"groupIds":["B2","lane2"],"text":"B2. BadgesChooseBadgeView","fontSize":11,"fontFamily":2,"index":"a130","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"b2-capb","type":"text","x":480,"y":1810,"width":340,"height":66,"strokeColor":"#6b7280","roughness":0,"groupIds":["B2","lane2"],"text":"Your level becomes Choose your badge, with the page's lede.\nRedeem code sits here only when B1's slot is taken by Buy in\nbrowser; where the browser is not allowed it stays on B1 and\nthis screen has Continue alone. The privacy link never moves -\nit stays on Support SimpleX in both builds.","fontSize":9,"fontFamily":2,"index":"a131","fillStyle":"solid","strokeWidth":2,"lineHeight":1.4666666666666666}, + {"id":"b3-panel","type":"rectangle","x":900,"y":1150,"width":300,"height":620,"strokeColor":"#8f97a3","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["B3","lane2"],"boundElements":[{"id":"ar-b2b3","type":"arrow"},{"id":"ar-b3b4","type":"arrow"}],"index":"a132"}, + {"id":"b3-back","type":"text","x":916,"y":1168,"width":20,"height":14,"strokeColor":"#5b21b6","roughness":0,"groupIds":["B3","lane2"],"text":"<","fontSize":11,"fontFamily":2,"autoResize":false,"index":"a133","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"b3-title","type":"text","x":920,"y":1196,"width":260,"height":24,"strokeColor":"#5b21b6","roughness":0,"groupIds":["B3","lane2"],"text":"How long?","fontSize":19,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a134","fillStyle":"solid","strokeWidth":2,"lineHeight":1.263157894736842}, + {"id":"b3-lede","type":"text","x":920,"y":1236,"width":260,"height":13,"strokeColor":"#6b7280","roughness":0,"groupIds":["B3","lane2"],"text":"Legend - 5GB files, 21 days","fontSize":9.5,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a135","fillStyle":"solid","strokeWidth":2,"lineHeight":1.368421052631579}, + {"id":"b3-avatar","type":"ellipse","x":1022,"y":1268,"width":56,"height":56,"strokeColor":"transparent","backgroundColor":"#e3e6ea","fillStyle":"solid","strokeWidth":1,"roughness":0,"groupIds":["B3","lane2"],"index":"a136"}, + {"id":"b3-name","type":"text","x":920,"y":1334,"width":260,"height":15,"strokeColor":"#1c1e26","roughness":0,"groupIds":["B3","lane2"],"text":"My nickname [badge]","fontSize":12,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a137","fillStyle":"solid","strokeWidth":2,"lineHeight":1.25}, + {"id":"b3-c1","type":"rectangle","x":912,"y":1386,"width":85,"height":124,"strokeColor":"#d6dae0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":2,"roughness":0,"roundness":{"type":3},"groupIds":["B3","lane2"],"index":"a138"}, + {"id":"b3-c1t","type":"text","x":917,"y":1426,"width":75,"height":39,"strokeColor":"#1c1e26","roughness":0,"groupIds":["B3","lane2"],"text":"1 month\n\n$69.99","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a139","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"b3-c2","type":"rectangle","x":1007,"y":1386,"width":85,"height":124,"strokeColor":"#d6dae0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":2,"roughness":0,"roundness":{"type":3},"groupIds":["B3","lane2"],"index":"a13a"}, + {"id":"b3-c2t","type":"text","x":1010,"y":1426,"width":79,"height":39,"strokeColor":"#1c1e26","roughness":0,"groupIds":["B3","lane2"],"text":"Monthly\n\n$69.99/month","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a13b","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"b3-c3","type":"rectangle","x":1102,"y":1386,"width":85,"height":124,"strokeColor":"#5b21b6","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":2,"roughness":0,"roundness":{"type":3},"groupIds":["B3","lane2"],"index":"a13c"}, + {"id":"b3-c3t","type":"text","x":1105,"y":1420,"width":79,"height":52,"strokeColor":"#1c1e26","roughness":0,"groupIds":["B3","lane2"],"text":"Annual\n\n$699.99/year\nSave 17%","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a13d","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"b3-btn","type":"rectangle","x":930,"y":1620,"width":240,"height":38,"strokeColor":"transparent","backgroundColor":"#5b21b6","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["B3","lane2"],"boundElements":[{"id":"b3-btnt","type":"text"}],"index":"a13e"}, + {"id":"b3-btnt","type":"text","x":940,"y":1632,"width":220,"height":14,"strokeColor":"#ffffff","roughness":0,"groupIds":["B3","lane2"],"text":"Continue","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"b3-btn","index":"a13f","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"b3-foot","type":"text","x":920,"y":1676,"width":260,"height":13,"strokeColor":"#6b7280","roughness":0,"groupIds":["B3","lane2"],"text":"Renews on 24 August 2027. Cancel anytime.","fontSize":9.5,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a140","fillStyle":"solid","strokeWidth":2,"lineHeight":1.368421052631579}, + {"id":"b3-capt","type":"text","x":900,"y":1790,"width":300,"height":14,"strokeColor":"#1c1e26","roughness":0,"groupIds":["B3","lane2"],"text":"B3. BadgesHowLongView, disarmed","fontSize":11,"fontFamily":2,"index":"a141","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"b3-capb","type":"text","x":900,"y":1810,"width":340,"height":66,"strokeColor":"#6b7280","roughness":0,"groupIds":["B3","lane2"],"text":"The level name was the title; now How long? is, with the\nchosen badge on the line under it, as the page does it.\nPay $69.99 becomes Continue and purchase() moves to B4.\nBadgePeriod as written today: one-time, monthly, annual,\nprices from StoreKit and Play, which need not match the web\npage's and generally will not.","fontSize":9,"fontFamily":2,"index":"a142","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2222222222222223}, + {"id":"b4-panel","type":"rectangle","x":1320,"y":1150,"width":300,"height":620,"strokeColor":"#5b21b6","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"strokeStyle":"dashed","roughness":0,"roundness":{"type":3},"groupIds":["B4","lane2"],"boundElements":[{"id":"ar-b3b4","type":"arrow"},{"id":"ar-b4o1","type":"arrow"},{"id":"ar-b4o2","type":"arrow"},{"id":"ar-b4o3","type":"arrow"}],"index":"a143"}, + {"id":"b4-back","type":"text","x":1336,"y":1168,"width":20,"height":14,"strokeColor":"#5b21b6","roughness":0,"groupIds":["B4","lane2"],"text":"<","fontSize":11,"fontFamily":2,"autoResize":false,"index":"a144","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"b4-title","type":"text","x":1340,"y":1196,"width":260,"height":24,"strokeColor":"#5b21b6","roughness":0,"groupIds":["B4","lane2"],"text":"Check your order","fontSize":19,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a145","fillStyle":"solid","strokeWidth":2,"lineHeight":1.263157894736842}, + {"id":"b4-rowsL","type":"text","x":1344,"y":1270,"width":160,"height":36,"strokeColor":"#1c1e26","roughness":0,"groupIds":["B4","lane2"],"text":"Your badge\nDuration\nTotal","fontSize":9.5,"fontFamily":2,"autoResize":false,"index":"a146","fillStyle":"solid","strokeWidth":2,"lineHeight":1.263157894736842}, + {"id":"b4-rowsR","type":"text","x":1500,"y":1270,"width":96,"height":36,"strokeColor":"#1c1e26","roughness":0,"groupIds":["B4","lane2"],"text":"Legend\nAnnual\n$699.99/year","fontSize":9.5,"fontFamily":2,"textAlign":"right","autoResize":false,"index":"a147","fillStyle":"solid","strokeWidth":2,"lineHeight":1.263157894736842}, + {"id":"b4-div","type":"rectangle","x":1344,"y":1292,"width":252,"height":1,"strokeColor":"#dfe4ea","backgroundColor":"#dfe4ea","fillStyle":"solid","strokeWidth":1,"roughness":0,"groupIds":["B4","lane2"],"index":"a148"}, + {"id":"b4-note","type":"text","x":1344,"y":1336,"width":252,"height":26,"strokeColor":"#6b7280","roughness":0,"groupIds":["B4","lane2"],"text":"Charged by the App Store to the\naccount on this device.","fontSize":9.5,"fontFamily":2,"autoResize":false,"index":"a149","fillStyle":"solid","strokeWidth":2,"lineHeight":1.368421052631579}, + {"id":"b4-btn","type":"rectangle","x":1350,"y":1620,"width":240,"height":38,"strokeColor":"transparent","backgroundColor":"#5b21b6","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["B4","lane2"],"boundElements":[{"id":"b4-btnt","type":"text"}],"index":"a14a"}, + {"id":"b4-btnt","type":"text","x":1360,"y":1632,"width":220,"height":14,"strokeColor":"#ffffff","roughness":0,"groupIds":["B4","lane2"],"text":"Pay $699.99/year","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"b4-btn","index":"a14b","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"b4-foot","type":"text","x":1340,"y":1676,"width":260,"height":13,"strokeColor":"#6b7280","roughness":0,"groupIds":["B4","lane2"],"text":"Renews on 24 August 2027. Cancel anytime.","fontSize":9.5,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a14c","fillStyle":"solid","strokeWidth":2,"lineHeight":1.368421052631579}, + {"id":"b4-capt","type":"text","x":1320,"y":1790,"width":300,"height":14,"strokeColor":"#5b21b6","roughness":0,"groupIds":["B4","lane2"],"text":"B4. New screen, one for every market","fontSize":11,"fontFamily":2,"index":"a14d","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"b4-capb","type":"text","x":1320,"y":1810,"width":340,"height":66,"strokeColor":"#6b7280","roughness":0,"groupIds":["B4","lane2"],"text":"No second button and nothing about paying elsewhere - that\nchoice was made back on B1. So this screen is identical in\nevery storefront and nothing on it is gated, which also means\nthe subscription never has to be reconciled against a web\nprice.","fontSize":9,"fontFamily":2,"index":"a14e","fillStyle":"solid","strokeWidth":2,"lineHeight":1.4666666666666666}, + {"id":"o1-card","type":"rectangle","x":1740,"y":1160,"width":300,"height":120,"strokeColor":"#5b21b6","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"strokeStyle":"dashed","roughness":0,"roundness":{"type":3},"groupIds":["O1","lane2"],"boundElements":[{"id":"ar-b4o1","type":"arrow"}],"index":"a150"}, + {"id":"o1-t","type":"text","x":1760,"y":1174,"width":260,"height":17,"strokeColor":"#1c1e26","roughness":0,"groupIds":["O1","lane2"],"text":"Paid","fontSize":13,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a151","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3076923076923077}, + {"id":"o1-s","type":"text","x":1760,"y":1198,"width":260,"height":13,"strokeColor":"#5b21b6","roughness":0,"groupIds":["O1","lane2"],"text":"o Issuing your badge","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a152","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"o1-b","type":"text","x":1766,"y":1222,"width":248,"height":26,"strokeColor":"#6b7280","roughness":0,"groupIds":["O1","lane2"],"text":"The store has taken the payment.\nKeeps trying if you close the app.","fontSize":9,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a153","fillStyle":"solid","strokeWidth":2,"lineHeight":1.4444444444444444}, + {"id":"o2-card","type":"rectangle","x":1740,"y":1300,"width":300,"height":96,"strokeColor":"#8f97a3","backgroundColor":"#f2f3f5","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["O2","lane2"],"index":"a154"}, + {"id":"o2-i","type":"rectangle","x":1756,"y":1312,"width":268,"height":72,"strokeColor":"transparent","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["O2","lane2"],"index":"a155"}, + {"id":"o2-g","type":"rectangle","x":1878,"y":1320,"width":24,"height":24,"strokeColor":"transparent","backgroundColor":"#dfe4ea","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["O2","lane2"],"index":"a156"}, + {"id":"o2-t","type":"text","x":1760,"y":1350,"width":260,"height":13,"strokeColor":"#1c1e26","roughness":0,"groupIds":["O2","lane2"],"text":"Legend","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a157","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"o2-s","type":"text","x":1760,"y":1366,"width":260,"height":11,"strokeColor":"#6b7280","roughness":0,"groupIds":["O2","lane2"],"text":"shown on your profile","fontSize":8.5,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a158","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2941176470588236}, + {"id":"o2-n","type":"text","x":1740,"y":1402,"width":300,"height":11,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["O2","lane2"],"text":"BadgesYourBadgeView, unchanged","fontSize":8.5,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a159","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2941176470588236}, + {"id":"o3-card","type":"rectangle","x":1740,"y":1424,"width":300,"height":130,"strokeColor":"#5b21b6","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"strokeStyle":"dashed","roughness":0,"roundness":{"type":3},"groupIds":["O3","lane2"],"boundElements":[{"id":"ar-b4o2","type":"arrow"}],"index":"a15a"}, + {"id":"o3-t","type":"text","x":1760,"y":1438,"width":260,"height":15,"strokeColor":"#1c1e26","roughness":0,"groupIds":["O3","lane2"],"text":"Waiting for approval","fontSize":12,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a15b","fillStyle":"solid","strokeWidth":2,"lineHeight":1.25}, + {"id":"o3-s","type":"text","x":1760,"y":1460,"width":260,"height":13,"strokeColor":"#5b21b6","roughness":0,"groupIds":["O3","lane2"],"text":"o Nothing has been charged","fontSize":9.5,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a15c","fillStyle":"solid","strokeWidth":2,"lineHeight":1.368421052631579}, + {"id":"o3-b","type":"text","x":1746,"y":1484,"width":288,"height":39,"strokeColor":"#6b7280","roughness":0,"groupIds":["O3","lane2"],"text":"Ask to Buy on iOS, a slow payment on\nPlay. Holds the slot so nothing can be\nbought twice, and becomes Paid above.","fontSize":9,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a15d","fillStyle":"solid","strokeWidth":2,"lineHeight":1.4444444444444444}, + {"id":"o4-card","type":"rectangle","x":1760,"y":1584,"width":260,"height":120,"strokeColor":"#d6dae0","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["O4","lane2"],"boundElements":[{"id":"ar-b4o3","type":"arrow"}],"index":"a15e"}, + {"id":"o4-t","type":"text","x":1776,"y":1598,"width":228,"height":14,"strokeColor":"#1c1e26","roughness":0,"groupIds":["O4","lane2"],"text":"Cannot verify this purchase","fontSize":11,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a15f","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"o4-b","type":"text","x":1776,"y":1620,"width":228,"height":39,"strokeColor":"#4b5563","roughness":0,"groupIds":["O4","lane2"],"text":"The store returned a transaction\nthat Apple has not signed. Nothing\nhas been bought.","fontSize":9,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a15g","fillStyle":"solid","strokeWidth":2,"lineHeight":1.4444444444444444}, + {"id":"o4-d","type":"rectangle","x":1760,"y":1668,"width":260,"height":1,"strokeColor":"#dfe4ea","backgroundColor":"#dfe4ea","fillStyle":"solid","strokeWidth":1,"roughness":0,"groupIds":["O4","lane2"],"index":"a15h"}, + {"id":"o4-o","type":"text","x":1760,"y":1678,"width":260,"height":14,"strokeColor":"#0053d0","roughness":0,"groupIds":["O4","lane2"],"text":"Ok","fontSize":11,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a15i","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"o-note","type":"text","x":1740,"y":1712,"width":300,"height":44,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["O","lane2"],"text":"iOS only - Play does no local verification, so a bad\ntoken is refused by the service instead. cancelled:\nnothing. other errors: today's Purchase error alert.","fontSize":8.5,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a15j","fillStyle":"solid","strokeWidth":2,"lineHeight":1.7254901960784312}, + {"id":"o-capt","type":"text","x":1740,"y":1790,"width":300,"height":14,"strokeColor":"#5b21b6","roughness":0,"groupIds":["O","lane2"],"text":"B5. What the sheet returns","fontSize":11,"fontFamily":2,"index":"a15k","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"o-capb","type":"text","x":1740,"y":1810,"width":340,"height":66,"strokeColor":"#6b7280","roughness":0,"groupIds":["O","lane2"],"text":"Two of these are states on BadgeState, not alerts: Paid, which\nthe worker retries, and Waiting, which holds nothing but the\nslot - Support SimpleX must not offer a second purchase of a\nconsumable. Both resolve through the store, not the service:\nTransaction.updates on iOS, queryPurchasesAsync on Play,\nwhich is also how each is re-found after a restart.","fontSize":9,"fontFamily":2,"index":"a15l","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2222222222222223}, + {"id":"ar-b1b2","type":"arrow","x":368,"y":1460,"width":104,"height":0,"strokeColor":"#5b21b6","strokeWidth":2,"roughness":0,"elbowed":true,"points":[[0,0],[104,0]],"endArrowhead":"arrow","boundElements":[{"id":"ar-b1b2-t","type":"text"}],"index":"a180","fillStyle":"solid"}, + {"id":"ar-b1b2-t","type":"text","x":380,"y":1446,"width":80,"height":13,"strokeColor":"#5b21b6","roughness":0,"text":"Choose your badge","fontSize":10,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar-b1b2","index":"a181","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"ar-b2b3","type":"arrow","x":788,"y":1460,"width":104,"height":0,"strokeColor":"#5b21b6","strokeWidth":2,"roughness":0,"elbowed":true,"points":[[0,0],[104,0]],"endArrowhead":"arrow","boundElements":[{"id":"ar-b2b3-t","type":"text"}],"index":"a182","fillStyle":"solid"}, + {"id":"ar-b2b3-t","type":"text","x":800,"y":1446,"width":80,"height":13,"strokeColor":"#5b21b6","roughness":0,"text":"Continue","fontSize":10,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar-b2b3","index":"a183","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"ar-b3b4","type":"arrow","x":1208,"y":1460,"width":104,"height":0,"strokeColor":"#5b21b6","strokeWidth":2,"roughness":0,"elbowed":true,"points":[[0,0],[104,0]],"endArrowhead":"arrow","boundElements":[{"id":"ar-b3b4-t","type":"text"}],"index":"a184","fillStyle":"solid"}, + {"id":"ar-b3b4-t","type":"text","x":1220,"y":1446,"width":80,"height":13,"strokeColor":"#5b21b6","roughness":0,"text":"Continue","fontSize":10,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar-b3b4","index":"a185","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"ar-b4o1","type":"arrow","x":1628,"y":1220,"width":104,"height":0,"strokeColor":"#5b21b6","strokeWidth":2,"roughness":0,"elbowed":true,"points":[[0,0],[104,0]],"endArrowhead":"arrow","boundElements":[{"id":"ar-b4o1-t","type":"text"}],"index":"a186","fillStyle":"solid"}, + {"id":"ar-b4o1-t","type":"text","x":1640,"y":1206,"width":80,"height":13,"strokeColor":"#5b21b6","roughness":0,"text":"verified","fontSize":10,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar-b4o1","index":"a187","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"ar-b4o2","type":"arrow","x":1628,"y":1489,"width":104,"height":0,"strokeColor":"#5b21b6","strokeWidth":2,"roughness":0,"elbowed":true,"points":[[0,0],[104,0]],"endArrowhead":"arrow","boundElements":[{"id":"ar-b4o2-t","type":"text"}],"index":"a188","fillStyle":"solid"}, + {"id":"ar-b4o2-t","type":"text","x":1640,"y":1475,"width":80,"height":13,"strokeColor":"#5b21b6","roughness":0,"text":"pending","fontSize":10,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar-b4o2","index":"a189","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"ar-b4o3","type":"arrow","x":1628,"y":1644,"width":124,"height":0,"strokeColor":"#9aa1ab","strokeWidth":2,"strokeStyle":"dashed","roughness":0,"elbowed":true,"points":[[0,0],[124,0]],"endArrowhead":"arrow","boundElements":[{"id":"ar-b4o3-t","type":"text"}],"index":"a18a","fillStyle":"solid"}, + {"id":"ar-b4o3-t","type":"text","x":1640,"y":1630,"width":100,"height":13,"strokeColor":"#6b7280","roughness":0,"text":"unverified","fontSize":10,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar-b4o3","index":"a18b","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"l3-band","type":"rectangle","x":20,"y":1930,"width":2460,"height":850,"strokeColor":"transparent","backgroundColor":"#fff6ef","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["lane3"],"index":"a200"}, + {"id":"l3-head","type":"text","x":60,"y":1958,"width":900,"height":22,"strokeColor":"#a15c1e","roughness":0,"groupIds":["lane3"],"text":"Desktop - where the deep link may not exist","fontSize":17,"fontFamily":2,"index":"a201","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2941176470588236}, + {"id":"l3-sub","type":"text","x":60,"y":1986,"width":1300,"height":14,"strokeColor":"#6b7280","roughness":0,"groupIds":["lane3"],"text":"the fallback is the shipped code flow with both halves opened at once, so nothing depends on a scheme handler that may not be there","fontSize":11,"fontFamily":2,"index":"a202","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"d1-panel","type":"rectangle","x":60,"y":2040,"width":300,"height":620,"strokeColor":"#8f97a3","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["D1","lane3"],"boundElements":[{"id":"ar-d1d2","type":"arrow"}],"index":"a210"}, + {"id":"d1-back","type":"text","x":76,"y":2058,"width":20,"height":14,"strokeColor":"#a15c1e","roughness":0,"groupIds":["D1","lane3"],"text":"<","fontSize":11,"fontFamily":2,"autoResize":false,"index":"a211","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"d1-title","type":"text","x":80,"y":2086,"width":260,"height":24,"strokeColor":"#a15c1e","roughness":0,"groupIds":["D1","lane3"],"text":"Redeem code","fontSize":19,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a212","fillStyle":"solid","strokeWidth":2,"lineHeight":1.263157894736842}, + {"id":"d1-body","type":"text","x":80,"y":2128,"width":260,"height":13,"strokeColor":"#1c1e26","roughness":0,"groupIds":["D1","lane3"],"text":"Paste the code you received.","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a213","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"d1-field","type":"rectangle","x":76,"y":2160,"width":268,"height":40,"strokeColor":"transparent","backgroundColor":"#eceef1","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["D1","lane3"],"index":"a214"}, + {"id":"d1-fieldt","type":"text","x":80,"y":2174,"width":260,"height":13,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["D1","lane3"],"text":"SB-XXXXX-XXXXX-XXXXX-XXXXX","fontSize":10,"fontFamily":3,"textAlign":"center","autoResize":false,"index":"a215","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"d1-paste","type":"text","x":80,"y":2216,"width":260,"height":13,"strokeColor":"#a15c1e","roughness":0,"groupIds":["D1","lane3"],"text":"Paste","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a216","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"d1-scan","type":"rectangle","x":126,"y":2254,"width":168,"height":168,"strokeColor":"#d6dae0","backgroundColor":"#f2f3f5","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["D1","lane3"],"index":"a217"}, + {"id":"d1-scant","type":"text","x":126,"y":2332,"width":168,"height":12,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["D1","lane3"],"text":"QR scanner","fontSize":9,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a218","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"d1-btn","type":"rectangle","x":90,"y":2510,"width":240,"height":38,"strokeColor":"transparent","backgroundColor":"#dcc9b4","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["D1","lane3"],"boundElements":[{"id":"d1-btnt","type":"text"}],"index":"a219"}, + {"id":"d1-btnt","type":"text","x":100,"y":2522,"width":220,"height":14,"strokeColor":"#ffffff","roughness":0,"groupIds":["D1","lane3"],"text":"Redeem","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"d1-btn","index":"a21a","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"d1-note","type":"text","x":80,"y":2566,"width":260,"height":24,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["D1","lane3"],"text":"opened by Buy in browser at the same\nmoment as the browser, and waiting","fontSize":9,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a21b","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"d1-capt","type":"text","x":60,"y":2680,"width":300,"height":14,"strokeColor":"#1c1e26","roughness":0,"groupIds":["D1","lane3"],"text":"D1. Both halves at once","fontSize":11,"fontFamily":2,"index":"a21c","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"d1-capb","type":"text","x":60,"y":2700,"width":340,"height":66,"strokeColor":"#6b7280","roughness":0,"groupIds":["D1","lane3"],"text":"On desktop, Buy in browser opens the page and this screen\ntogether, so the code has somewhere to go the moment it\nappears. No new screen, no deep link, no A6 - the shipped\nflow, with one extra window. It is also the honest fallback for\nany platform where the scheme turns out not to work.","fontSize":9,"fontFamily":2,"index":"a21d","fillStyle":"solid","strokeWidth":2,"lineHeight":1.4666666666666666}, + {"id":"d2-panel","type":"rectangle","x":480,"y":2040,"width":300,"height":620,"strokeColor":"#8f97a3","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["D2","lane3"],"boundElements":[{"id":"ar-d1d2","type":"arrow"}],"index":"a220"}, + {"id":"d2-url","type":"rectangle","x":480,"y":2040,"width":300,"height":26,"strokeColor":"transparent","backgroundColor":"#eceef1","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["D2","lane3"],"index":"a221"}, + {"id":"d2-urlt","type":"text","x":496,"y":2048,"width":268,"height":12,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["D2","lane3"],"text":"badges.simplex.chat/#/tier?app=desktop","fontSize":9,"fontFamily":2,"autoResize":false,"index":"a222","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"d2-title","type":"text","x":496,"y":2090,"width":268,"height":22,"strokeColor":"#1c1e26","roughness":0,"groupIds":["D2","lane3"],"text":"Paid. Here is your code.","fontSize":16,"fontFamily":2,"autoResize":false,"index":"a223","fillStyle":"solid","strokeWidth":2,"lineHeight":1.375}, + {"id":"d2-code","type":"rectangle","x":496,"y":2128,"width":268,"height":36,"strokeColor":"#d6dae0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["D2","lane3"],"index":"a224"}, + {"id":"d2-codet","type":"text","x":500,"y":2140,"width":260,"height":14,"strokeColor":"#1c1e26","roughness":0,"groupIds":["D2","lane3"],"text":"SB-4K2M9-XQ7VT-H3BNP-R8WLC","fontSize":10,"fontFamily":3,"textAlign":"center","autoResize":false,"index":"a225","fillStyle":"solid","strokeWidth":2,"lineHeight":1.4}, + {"id":"d2-btn","type":"rectangle","x":496,"y":2180,"width":268,"height":34,"strokeColor":"#0053d0","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["D2","lane3"],"boundElements":[{"id":"d2-btnt","type":"text"}],"index":"a226"}, + {"id":"d2-btnt","type":"text","x":506,"y":2190,"width":248,"height":14,"strokeColor":"#0053d0","roughness":0,"groupIds":["D2","lane3"],"text":"Copy code","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"d2-btn","index":"a227","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"d2-qr","type":"rectangle","x":496,"y":2236,"width":110,"height":110,"strokeColor":"#d6dae0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":1,"roughness":0,"groupIds":["D2","lane3"],"index":"a228"}, + {"id":"d2-qrt","type":"text","x":496,"y":2352,"width":110,"height":24,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["D2","lane3"],"text":"scan to carry it\nto your phone","fontSize":9,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a229","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"d2-redeem","type":"text","x":622,"y":2236,"width":142,"height":36,"strokeColor":"#1c1e26","roughness":0,"groupIds":["D2","lane3"],"text":"REDEEM IT IN THE APP\nThe Redeem code screen\nis already open.","fontSize":8.5,"fontFamily":2,"autoResize":false,"index":"a22a","fillStyle":"solid","strokeWidth":2,"lineHeight":1.411764705882353}, + {"id":"d2-warn","type":"rectangle","x":622,"y":2288,"width":142,"height":58,"strokeColor":"transparent","backgroundColor":"#fdf3e7","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["D2","lane3"],"index":"a22b"}, + {"id":"d2-warnt","type":"text","x":632,"y":2298,"width":122,"height":38,"strokeColor":"#8a5a1e","roughness":0,"groupIds":["D2","lane3"],"text":"This is the only copy.\nSaved in this browser\nand nowhere else.","fontSize":8.5,"fontFamily":2,"autoResize":false,"index":"a22c","fillStyle":"solid","strokeWidth":2,"lineHeight":1.4901960784313726}, + {"id":"d2-capt","type":"text","x":480,"y":2680,"width":300,"height":14,"strokeColor":"#1c1e26","roughness":0,"groupIds":["D2","lane3"],"text":"D2. The page, told which app it is","fontSize":11,"fontFamily":2,"index":"a22d","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"d2-capb","type":"text","x":480,"y":2700,"width":340,"height":66,"strokeColor":"#6b7280","roughness":0,"groupIds":["D2","lane3"],"text":"codeIssued as it ships, with one line changed. The app builds\nthe URL, so ?app=desktop is a fact rather than a guess -\nbetter than sniffing the user agent, which cannot tell a\ndesktop browser opened by SimpleX from any other. Mobile\ngets ?app=true and the A5 screen instead.","fontSize":9,"fontFamily":2,"index":"a22e","fillStyle":"solid","strokeWidth":2,"lineHeight":1.4666666666666666}, + {"id":"q-box","type":"rectangle","x":900,"y":2040,"width":1540,"height":260,"strokeColor":"#e0a672","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":2,"strokeStyle":"dashed","roughness":0,"roundness":{"type":3},"groupIds":["Q","lane3"],"index":"a230"}, + {"id":"q-head","type":"text","x":924,"y":2064,"width":900,"height":20,"strokeColor":"#a15c1e","roughness":0,"groupIds":["Q","lane3"],"text":"QUESTIONS - deep links, and whether the desktop can have them at all","fontSize":15,"fontFamily":2,"autoResize":false,"index":"a231","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"q-body","type":"text","x":924,"y":2098,"width":1490,"height":130,"strokeColor":"#4b5563","roughness":0,"groupIds":["Q","lane3"],"text":"1. Desktop has no scheme handler today. Nothing in the desktop module registers one and nothing handles it: appOpenUrl is wired in commonMain but has no desktop plumbing, and\n desktop/build.gradle.kts declares only Deb, Dmg, Msi and Exe with no protocol entry. Adding one is per-OS work - CFBundleURLTypes plus setOpenURIHandler on macOS, registry keys\n the MSI would have to write on Windows, a .desktop file with x-scheme-handler/simplexchat on Linux - and it would still not cover an unpackaged run. Hence D1: desktop does not wait for it.\n\n2. Will a browser follow it without a tap? The attempt is made from a hidden frame, so a refusal stays inside the frame rather than replacing the screen with an error page that would\n put the code into history. Expect nothing to happen on iOS Safari, which has ignored custom schemes in frames for years, nor on Chrome, which blocks a gestureless external-protocol\n launch - and a payment settling minutes after the last tap has no gesture. So Return to SimpleX is the real path. What is left is to try it on devices once the app registers the scheme.","fontSize":10,"fontFamily":2,"autoResize":false,"index":"a232","fillStyle":"solid","strokeWidth":2,"lineHeight":1.4444444444444444}, + {"id":"n-box","type":"rectangle","x":900,"y":2340,"width":1540,"height":300,"strokeColor":"#c9ced6","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["N","lane3"],"index":"a233"}, + {"id":"n-head","type":"text","x":924,"y":2364,"width":900,"height":20,"strokeColor":"#1c1e26","roughness":0,"groupIds":["N","lane3"],"text":"What has to be built, and what it costs","fontSize":15,"fontFamily":2,"autoResize":false,"index":"a234","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"n-body","type":"text","x":924,"y":2398,"width":1490,"height":220,"strokeColor":"#4b5563","roughness":0,"groupIds":["N","lane3"],"text":"The browser lane needs nothing from the service: the code purchase runs exactly as it ships, the browser mints the code and sends only its SHA-256, and the only reason the buyer\nnever sees one is that the link carries it instead of the screen. The store lane is the part that does need service work - purchaseBadge has to accept a store receipt and verify it,\nand no provider adapter for that exists today.\n\nThe page needs three things: read the app flag from the fragment and start at Choose your badge; at the end, try the deep link and offer Return to SimpleX with Show code under it;\nand for ?app=desktop, keep showing codeIssued as it does now.\n\nThe app needs: Buy in browser without its confirm alert; a handler for the new link; A6, a spinner around the existing apiRedeemBadgeCode; on desktop, opening Redeem code alongside\nthe browser; and for the store lane, B4's checkout and the two BadgeState outcomes behind B5.\n\nWhat it costs: the app cannot show a price before sending the buyer out, because it has no catalog - Buy in browser is a leap into a page. And the code still exists, so it can be\nintercepted, screenshotted from the backup button, or pasted somewhere else. Hidden is not the same as absent.","fontSize":10,"fontFamily":2,"autoResize":false,"index":"a235","fillStyle":"solid","strokeWidth":2,"lineHeight":2}, + {"id":"ar-d1d2","type":"arrow","x":368,"y":2350,"width":104,"height":0,"strokeColor":"#a15c1e","strokeWidth":2,"strokeStyle":"dashed","roughness":0,"elbowed":true,"points":[[0,0],[104,0]],"startArrowhead":"arrow","endArrowhead":"arrow","boundElements":[{"id":"ar-d1d2-t","type":"text"}],"index":"a250","fillStyle":"solid"}, + {"id":"ar-d1d2-t","type":"text","x":370.23193359375,"y":2337,"width":99.5361328125,"height":26,"strokeColor":"#a15c1e","roughness":0,"text":"opened together, code\ncarried by hand","fontSize":10,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar-d1d2","index":"a251","fillStyle":"solid","strokeWidth":2,"originalText":"opened together, code carried by hand","lineHeight":1.3}, + {"id":"ft-note","type":"text","x":60,"y":2830,"width":2400,"height":26,"strokeColor":"#6b7280","roughness":0,"groupIds":["footer"],"text":"Copy is quoted from the app and the page. The app's screens take the page's names: Your level becomes Choose your badge, the level name becomes How long?, and Check your order is shared already.\nStore prices are illustrative and come from the storefront; the page's prices are the shipped catalog. Not drawn: the page's refusals - no active price, a provider unavailable, too many attempts - which are unchanged and already built.","fontSize":10,"fontFamily":2,"index":"a300","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3} ], - "appState": { - "gridSize": 20, - "gridStep": 5, - "gridModeEnabled": false, - "viewBackgroundColor": "#ffffff" - }, + "appState": {"gridSize": 20, "gridStep": 5, "gridModeEnabled": false, "viewBackgroundColor": "#ffffff"}, "files": {} -} \ No newline at end of file +} From 35bc0b66d9ea10ce6838ce5b766ba68b838f551f Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:31:38 +0000 Subject: [PATCH 08/15] ci: app test workflows save no caches, reuse their builds as artifacts, and run Claude in its own job (#7767) --- .github/workflows/desktop-linux.yml | 220 +++++++++--------- .github/workflows/ios-sim.yml | 261 ++++++++++++++-------- scripts/ci/README.md | 37 +++ scripts/ci/desktop-linux/README.md | 35 +-- scripts/ci/desktop-linux/backend.sh | 37 +-- scripts/ci/desktop-linux/build-backend.sh | 1 - scripts/ci/desktop-linux/build-core.sh | 6 - scripts/ci/desktop-linux/claude-scope | 3 + scripts/ci/desktop-linux/claude-test.sh | 30 +-- scripts/ci/ios-sim/README.md | 36 +-- scripts/ci/ios-sim/backend.sh | 54 ++--- scripts/ci/ios-sim/build-backend.sh | 1 - scripts/ci/ios-sim/build-core.sh | 1 - scripts/ci/ios-sim/claude-prompt.md | 2 +- scripts/ci/ios-sim/claude-scope | 2 + scripts/ci/ios-sim/claude-test.sh | 28 ++- scripts/ci/ios-sim/claude-usage.sh | 20 +- scripts/ci/ios-sim/earlier-run.sh | 6 +- 18 files changed, 445 insertions(+), 335 deletions(-) create mode 100644 scripts/ci/README.md create mode 100644 scripts/ci/desktop-linux/claude-scope diff --git a/.github/workflows/desktop-linux.yml b/.github/workflows/desktop-linux.yml index f826ccbbfc..90a50f132c 100644 --- a/.github/workflows/desktop-linux.yml +++ b/.github/workflows/desktop-linux.yml @@ -15,7 +15,11 @@ on: type: boolean default: false backend: - description: Also start the test backend and test the released app + description: Also start the test backend + type: boolean + default: false + release: + description: Also test the latest released app, to tell a branch's failure from the runner's type: boolean default: false @@ -24,8 +28,8 @@ permissions: actions: read concurrency: - # a push that asks for no run doesn't cancel one that does - group: desktop-linux-${{ github.ref }}-${{ github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[app-ci]') || contains(github.event.head_commit.message, '[app-ci:desktop]') || contains(github.event.head_commit.message, '[claude-test]') || contains(github.event.head_commit.message, '[claude-test:desktop]') }} + # a push that asks for no run must not cancel one that does + group: desktop-linux-${{ github.ref }}-${{ (github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[app-ci]') || contains(github.event.head_commit.message, '[app-ci:desktop]') || contains(github.event.head_commit.message, '[claude-test]') || contains(github.event.head_commit.message, '[claude-test:desktop]')) && 'requested' || github.run_id }} cancel-in-progress: true env: @@ -38,6 +42,7 @@ env: jobs: plan: name: choose jobs, find earlier builds + # the concurrency group repeats this test, as it can't read job outputs if: >- github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[app-ci]') || @@ -49,6 +54,10 @@ jobs: outputs: run-claude: ${{ steps.jobs.outputs.claude }} run-backend: ${{ steps.jobs.outputs.backend }} + run-release: ${{ steps.jobs.outputs.release }} + claude-backend: ${{ steps.scope.outputs.backend }} + core: ${{ steps.names.outputs.core }} + core-run: ${{ steps.core.outputs.run-id }} backend: ${{ steps.names.outputs.backend }} backend-run: ${{ steps.backend.outputs.run-id }} steps: @@ -57,19 +66,46 @@ jobs: env: CLAUDE: ${{ inputs.claude || contains(github.event.head_commit.message, '[claude-test]') || contains(github.event.head_commit.message, '[claude-test:desktop]') }} BACKEND: ${{ inputs.backend || contains(github.event.head_commit.message, '[ci-backend]') }} + RELEASE: ${{ inputs.release || contains(github.event.head_commit.message, '[ci-release]') }} run: | echo "claude=$CLAUDE" | tee -a "$GITHUB_OUTPUT" echo "backend=$BACKEND" | tee -a "$GITHUB_OUTPUT" + echo "release=$RELEASE" | tee -a "$GITHUB_OUTPUT" - name: Checkout code - if: steps.jobs.outputs.backend == 'true' - uses: actions/checkout@v5 + uses: actions/checkout@v6 + + - name: Claude scope + id: scope + if: steps.jobs.outputs.claude == 'true' + env: + BACKEND: ${{ steps.jobs.outputs.backend }} + run: | + backend=$(sed -n 's/^backend: *//p' scripts/ci/desktop-linux/claude-scope) + case "$backend" in + yes | no) echo "backend=$backend" | tee -a "$GITHUB_OUTPUT" ;; + *) + echo "::error::claude-scope must say backend: yes or backend: no" + exit 1 + ;; + esac + if [ "$backend" = yes ] && [ "$BACKEND" != true ]; then + echo "::error::claude-scope says the Claude session uses the test backend: ask for it too" + exit 1 + fi # artifacts are named after the sources they are built from, so a run uses one that an earlier run uploaded - name: Artifact names id: names - if: steps.jobs.outputs.backend == 'true' - run: echo "backend=desktop-linux-backend-${{ hashFiles('src/**', 'apps/simplex-chat/**', 'apps/simplex-directory-service/Main.hs', 'apps/simplex-directory-service/src/**', 'cabal.project', 'simplex-chat.cabal', 'scripts/ci/desktop-linux/build-backend.sh') }}" >> "$GITHUB_OUTPUT" + run: | + echo "core=desktop-linux-core-${{ hashFiles('src/**', 'cabal.project', 'simplex-chat.cabal', 'PRIVACY.md', 'scripts/desktop/build-lib-linux.sh', 'scripts/ci/desktop-linux/build-core.sh') }}" >> "$GITHUB_OUTPUT" + echo "backend=desktop-linux-backend-${{ hashFiles('src/**', 'apps/simplex-chat/**', 'apps/simplex-directory-service/Main.hs', 'apps/simplex-directory-service/src/**', 'cabal.project', 'simplex-chat.cabal', 'scripts/ci/desktop-linux/build-backend.sh') }}" >> "$GITHUB_OUTPUT" + + - name: Find core library + id: core + env: + GH_TOKEN: ${{ github.token }} + run: scripts/ci/desktop-linux/earlier-run.sh "${{ steps.names.outputs.core }}" - name: Find backend id: backend @@ -86,7 +122,7 @@ jobs: timeout-minutes: 150 steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Show runner run: | @@ -96,26 +132,11 @@ jobs: df -h / - name: Setup Haskell - id: haskell uses: simplex-chat/setup-haskell-action@v2 with: ghc-version: ${{ env.GHC_VER }} cabal-version: ${{ env.CABAL_VER }} - # before the first backend store exists, the core library's has most of the same Hackage dependencies - - name: Restore cabal store - id: cabal-cache - uses: actions/cache/restore@v5 - with: - path: | - ${{ steps.haskell.outputs.cabal-store }} - dist-newstyle - key: desktop-linux-backend-store-ghc${{ env.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}-${{ github.run_id }} - restore-keys: | - desktop-linux-backend-store-ghc${{ env.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}- - desktop-linux-backend-store-ghc${{ env.GHC_VER }}- - desktop-linux-core-store-ghc${{ env.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}- - - name: Install libraries run: | sudo apt-get update @@ -124,17 +145,8 @@ jobs: - name: Build backend run: scripts/ci/desktop-linux/build-backend.sh ~/backend/${{ env.BACKEND_PKG }} - - name: Save cabal store - if: ${{ !cancelled() }} - uses: actions/cache/save@v5 - with: - path: | - ${{ steps.haskell.outputs.cabal-store }} - dist-newstyle - key: ${{ steps.cabal-cache.outputs.cache-primary-key }} - - name: Upload backend - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: ${{ needs.plan.outputs.backend }} path: ~/backend/${{ env.BACKEND_PKG }} @@ -145,11 +157,12 @@ jobs: core: name: core library for Linux needs: plan + if: needs.plan.outputs.core-run == '' runs-on: ubuntu-24.04 timeout-minutes: 150 steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Show runner run: | @@ -158,79 +171,39 @@ jobs: free -h df -h / - - name: Restore core library - id: core-cache - uses: actions/cache/restore@v5 - with: - path: ~/core/${{ env.CORE_PKG }} - key: desktop-linux-core-${{ hashFiles('src/**', 'cabal.project', 'simplex-chat.cabal', 'PRIVACY.md', 'scripts/desktop/build-lib-linux.sh', 'scripts/ci/desktop-linux/build-core.sh') }} - - name: Setup Haskell - id: haskell - if: steps.core-cache.outputs.cache-hit != 'true' uses: simplex-chat/setup-haskell-action@v2 with: ghc-version: ${{ env.GHC_VER }} cabal-version: ${{ env.CABAL_VER }} - - name: Restore cabal store - id: cabal-cache - if: steps.core-cache.outputs.cache-hit != 'true' - uses: actions/cache/restore@v5 - with: - path: | - ${{ steps.haskell.outputs.cabal-store }} - dist-newstyle - key: desktop-linux-core-store-ghc${{ env.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}-${{ github.run_id }} - restore-keys: | - desktop-linux-core-store-ghc${{ env.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}- - desktop-linux-core-store-ghc${{ env.GHC_VER }}- - - name: Install libraries - if: steps.core-cache.outputs.cache-hit != 'true' run: | sudo apt-get update sudo apt-get install -y libffi-dev libgmp-dev libnuma-dev libssl-dev zlib1g-dev patchelf - name: Build core library - if: steps.core-cache.outputs.cache-hit != 'true' run: scripts/ci/desktop-linux/build-core.sh ~/core/${{ env.CORE_PKG }} - - name: Save cabal store - if: ${{ !cancelled() && steps.core-cache.outputs.cache-hit != 'true' }} - uses: actions/cache/save@v5 - with: - path: | - ${{ steps.haskell.outputs.cabal-store }} - dist-newstyle - key: ${{ steps.cabal-cache.outputs.cache-primary-key }} - - - name: Save core library - if: steps.core-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v5 - with: - path: ~/core/${{ env.CORE_PKG }} - key: ${{ steps.core-cache.outputs.cache-primary-key }} - - name: Upload core library - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: - name: desktop-core-linux-x86_64 + name: ${{ needs.plan.outputs.core }} path: ~/core/${{ env.CORE_PKG }} compression-level: 0 - retention-days: 3 + retention-days: 30 if-no-files-found: error app: name: desktop app under Xvfb needs: [plan, core, backend] - # backend is skipped when it isn't asked for or an earlier run built it + # core and backend are skipped when an earlier run built them, and backend also when the run doesn't start it if: ${{ !cancelled() && !failure() && needs.plan.result == 'success' }} runs-on: ubuntu-24.04 timeout-minutes: 60 steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: submodules: recursive @@ -250,10 +223,12 @@ jobs: dpkg-query -W xvfb xdotool openbox imagemagick tesseract-ocr sqlite3 fonts-dejavu-core - name: Download core library - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: - name: desktop-core-linux-x86_64 + name: ${{ needs.plan.outputs.core }} path: ~/core + run-id: ${{ needs.plan.outputs.core-run || github.run_id }} + github-token: ${{ github.token }} - name: Install core library id: core @@ -261,7 +236,7 @@ jobs: - name: Download backend if: needs.plan.outputs.run-backend == 'true' - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: ${{ needs.plan.outputs.backend }} path: ~/backend @@ -281,7 +256,6 @@ jobs: with: distribution: corretto java-version: ${{ env.JAVA_VER }} - cache: gradle - name: Build app id: build @@ -294,6 +268,21 @@ jobs: du -sh "$app" LD_LIBRARY_PATH="$app/lib/app/resources" ldd "$app/lib/app/resources/libapp-lib.so" + # for the Claude job; a tarball keeps the app's file modes, which an artifact drops + - name: Pack app + if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.plan.outputs.run-claude == 'true' }} + run: tar -czf "$RUNNER_TEMP/desktop-app.tar.gz" -C apps/multiplatform/release/main/app simplex + + - name: Upload app + if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.plan.outputs.run-claude == 'true' }} + uses: actions/upload-artifact@v7 + with: + name: desktop-app + path: ${{ runner.temp }}/desktop-app.tar.gz + compression-level: 0 + retention-days: 1 + if-no-files-found: error + - name: Run desktop tests if: ${{ !cancelled() && steps.build.outcome == 'success' }} working-directory: apps/multiplatform @@ -320,7 +309,7 @@ jobs: - name: Upload smoke test results if: ${{ !cancelled() }} - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: desktop-smoke-test path: ${{ runner.temp }}/smoke-test @@ -329,7 +318,7 @@ jobs: - name: Upload desktop test report if: ${{ !cancelled() }} - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: desktop-test-report path: | @@ -340,7 +329,7 @@ jobs: - name: Upload backend logs if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }} - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: desktop-backend-logs path: ${{ runner.temp }}/backend/*.log @@ -352,8 +341,8 @@ jobs: # may use the token. claude: name: Claude tests the desktop app - needs: [plan, core, backend] - if: ${{ !cancelled() && !failure() && needs.plan.outputs.run-claude == 'true' }} + needs: [plan, app] + if: ${{ !cancelled() && needs.plan.outputs.run-claude == 'true' && needs.app.result == 'success' }} runs-on: ubuntu-24.04 timeout-minutes: 60 environment: @@ -363,14 +352,13 @@ jobs: group: desktop-linux-claude-${{ github.ref }} cancel-in-progress: true env: - # the first release with Haiku 5.5's token counts right (2.1.293 added the model, 2.1.296 fixed its counts) - CLAUDE_CODE_VERSION: 2.1.296 + # the same in both app workflows + CLAUDE_CODE_VERSION: 2.1.300 CLAUDE_MODEL: claude-haiku-5-5 steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: - submodules: recursive persist-credentials: false - name: Install tools @@ -379,18 +367,20 @@ jobs: sudo apt-get install -y xvfb xdotool openbox imagemagick tesseract-ocr sqlite3 fonts-dejavu-core dpkg-query -W xvfb xdotool openbox imagemagick tesseract-ocr sqlite3 fonts-dejavu-core - - name: Download core library - uses: actions/download-artifact@v7 + - name: Download app + uses: actions/download-artifact@v8 with: - name: desktop-core-linux-x86_64 - path: ~/core + name: desktop-app + path: ${{ runner.temp }} - - name: Install core library - run: scripts/ci/desktop-linux/install-core.sh ~/core/${{ env.CORE_PKG }} + - name: Unpack app + run: | + mkdir -p "$RUNNER_TEMP/app" + tar -xzf "$RUNNER_TEMP/desktop-app.tar.gz" -C "$RUNNER_TEMP/app" - name: Download backend - if: needs.plan.outputs.run-backend == 'true' - uses: actions/download-artifact@v7 + if: needs.plan.outputs.claude-backend == 'yes' + uses: actions/download-artifact@v8 with: name: ${{ needs.plan.outputs.backend }} path: ~/backend @@ -398,21 +388,11 @@ jobs: github-token: ${{ github.token }} - name: Start test backend - if: needs.plan.outputs.run-backend == 'true' + id: backend + if: needs.plan.outputs.claude-backend == 'yes' timeout-minutes: 30 run: scripts/ci/desktop-linux/backend.sh start ~/backend/${{ env.BACKEND_PKG }} "$RUNNER_TEMP/backend" - - name: Setup Java - uses: actions/setup-java@v5 - with: - distribution: corretto - java-version: ${{ env.JAVA_VER }} - cache: gradle - - - name: Build app - working-directory: apps/multiplatform - run: ./gradlew --no-daemon --console=plain -Psimplex.assets.dir=../../assets :desktop:createDistributable - # ui goes on every shell's PATH, where Claude's Bash tool finds it whatever environment it builds - name: Install Claude Code and the ui command run: | @@ -422,7 +402,7 @@ jobs: sudo chmod +x /usr/local/bin/ui - name: Start the app - run: scripts/ci/desktop-linux/smoke-test.sh apps/multiplatform/release/main/app/simplex "$RUNNER_TEMP/claude-test/screens" start + run: scripts/ci/desktop-linux/smoke-test.sh "$RUNNER_TEMP/app/simplex" "$RUNNER_TEMP/claude-test/screens" start # Claude's Bash is limited to single ui commands by the guard hook in claude-test.sh. Claude Code's credential # scrub stays off: on Linux it forces its whole Bash sandbox (bubblewrap, socat), which ui isn't set up for. @@ -438,12 +418,12 @@ jobs: run: scripts/ci/desktop-linux/claude-usage.py "$RUNNER_TEMP/claude-transcript.jsonl" "$RUNNER_TEMP/claude-usage.json" - name: Stop test backend - if: ${{ !cancelled() && needs.plan.outputs.run-backend == 'true' }} + if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }} run: scripts/ci/desktop-linux/backend.sh stop "$RUNNER_TEMP/backend" - name: Upload Claude test results if: ${{ !cancelled() }} - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: desktop-claude-test path: | @@ -456,8 +436,8 @@ jobs: if-no-files-found: warn - name: Upload backend logs - if: ${{ !cancelled() && needs.plan.outputs.run-backend == 'true' }} - uses: actions/upload-artifact@v6 + if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }} + uses: actions/upload-artifact@v7 with: name: desktop-claude-backend-logs path: ${{ runner.temp }}/backend/*.log @@ -468,12 +448,12 @@ jobs: release: name: released app under Xvfb needs: plan - if: needs.plan.outputs.run-backend == 'true' + if: needs.plan.outputs.run-release == 'true' runs-on: ubuntu-24.04 timeout-minutes: 30 steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 filter: tree:0 @@ -492,7 +472,7 @@ jobs: - name: Upload smoke test results if: ${{ !cancelled() }} - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: desktop-release-smoke-test path: ${{ runner.temp }}/smoke-test diff --git a/.github/workflows/ios-sim.yml b/.github/workflows/ios-sim.yml index 5d44162369..210a939fbc 100644 --- a/.github/workflows/ios-sim.yml +++ b/.github/workflows/ios-sim.yml @@ -39,25 +39,55 @@ env: jobs: plan: - name: find earlier builds + name: choose jobs, find earlier builds + # the concurrency group repeats this test, as it can't read job outputs if: >- github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[app-ci]') || contains(github.event.head_commit.message, '[app-ci:ios]') || contains(github.event.head_commit.message, '[claude-test]') || contains(github.event.head_commit.message, '[claude-test:ios]') - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 outputs: + run-claude: ${{ steps.jobs.outputs.claude }} + run-backend: ${{ steps.jobs.outputs.backend }} + claude-backend: ${{ steps.scope.outputs.backend }} core: ${{ steps.names.outputs.core }} - backend: ${{ steps.names.outputs.backend }} core-run: ${{ steps.core.outputs.run-id }} + backend: ${{ steps.names.outputs.backend }} backend-run: ${{ steps.backend.outputs.run-id }} - claude: ${{ inputs.claude || contains(github.event.head_commit.message, '[claude-test]') || contains(github.event.head_commit.message, '[claude-test:ios]') }} - start-backend: ${{ inputs.backend || contains(github.event.head_commit.message, '[ci-backend]') }} steps: + - name: Choose jobs + id: jobs + env: + CLAUDE: ${{ inputs.claude || contains(github.event.head_commit.message, '[claude-test]') || contains(github.event.head_commit.message, '[claude-test:ios]') }} + BACKEND: ${{ inputs.backend || contains(github.event.head_commit.message, '[ci-backend]') }} + run: | + echo "claude=$CLAUDE" | tee -a "$GITHUB_OUTPUT" + echo "backend=$BACKEND" | tee -a "$GITHUB_OUTPUT" + - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 + + - name: Claude scope + id: scope + if: steps.jobs.outputs.claude == 'true' + env: + BACKEND: ${{ steps.jobs.outputs.backend }} + run: | + backend=$(sed -n 's/^backend: *//p' scripts/ci/ios-sim/claude-scope) + case "$backend" in + yes | no) echo "backend=$backend" | tee -a "$GITHUB_OUTPUT" ;; + *) + echo "::error::claude-scope must say backend: yes or backend: no" + exit 1 + ;; + esac + if [ "$backend" = yes ] && [ "$BACKEND" != true ]; then + echo "::error::claude-scope says the Claude sessions use the test backend: ask for it too" + exit 1 + fi # artifacts are named after the sources they are built from, so a run uses one that an earlier run uploaded - name: Artifact names @@ -74,6 +104,7 @@ jobs: - name: Find backend id: backend + if: steps.jobs.outputs.backend == 'true' env: GH_TOKEN: ${{ github.token }} run: scripts/ci/ios-sim/earlier-run.sh "${{ steps.names.outputs.backend }}" @@ -82,11 +113,11 @@ jobs: name: core library for the iOS simulator needs: plan if: needs.plan.outputs.core-run == '' - runs-on: macos-latest + runs-on: macos-26 timeout-minutes: 240 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Show runner run: | @@ -100,29 +131,12 @@ jobs: - name: Use the macOS 26 SDK run: scripts/ci/ios-sim/use-macos26-sdk.sh - # without ~/.cabal, cabal 3.10 keeps its store in XDG directories, not where setup-haskell reports it - - name: Use ~/.cabal for cabal - run: echo "CABAL_DIR=$HOME/.cabal" >> "$GITHUB_ENV" - - name: Setup Haskell - id: haskell uses: simplex-chat/setup-haskell-action@v2 with: ghc-version: ${{ env.GHC_VER }} cabal-version: ${{ env.CABAL_VER }} - - name: Restore cabal store - id: cabal-cache - uses: actions/cache/restore@v4 - with: - path: | - ${{ steps.haskell.outputs.cabal-store }} - dist-newstyle - key: ios-sim-core-store-ghc${{ env.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}-${{ github.run_id }} - restore-keys: | - ios-sim-core-store-ghc${{ env.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}- - ios-sim-core-store-ghc${{ env.GHC_VER }}- - - name: Install libraries run: brew install openssl@3 libffi gmp @@ -130,20 +144,11 @@ jobs: id: build run: scripts/ci/ios-sim/build-core.sh ~/Downloads/${{ env.CORE_PKG }} - - name: Save cabal store - if: ${{ !cancelled() }} - uses: actions/cache/save@v4 - with: - path: | - ${{ steps.haskell.outputs.cabal-store }} - dist-newstyle - key: ${{ steps.cabal-cache.outputs.cache-primary-key }} - - name: Patch core library for the simulator run: scripts/ci/ios-sim/prepare-libs.sh - name: Upload core library - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ needs.plan.outputs.core }} path: ~/Downloads/${{ env.CORE_PKG }} @@ -154,12 +159,12 @@ jobs: backend: name: test backend for the app needs: plan - if: needs.plan.outputs.start-backend == 'true' && needs.plan.outputs.backend-run == '' - runs-on: macos-latest + if: needs.plan.outputs.run-backend == 'true' && needs.plan.outputs.backend-run == '' + runs-on: macos-26 timeout-minutes: 240 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Show runner run: | @@ -171,42 +176,17 @@ jobs: - name: Use the macOS 26 SDK run: scripts/ci/ios-sim/use-macos26-sdk.sh - - name: Use ~/.cabal for cabal - run: echo "CABAL_DIR=$HOME/.cabal" >> "$GITHUB_ENV" - - name: Setup Haskell - id: haskell uses: simplex-chat/setup-haskell-action@v2 with: ghc-version: ${{ env.GHC_VER }} cabal-version: ${{ env.CABAL_VER }} - - name: Restore cabal store - id: cabal-cache - uses: actions/cache/restore@v4 - with: - path: | - ${{ steps.haskell.outputs.cabal-store }} - dist-newstyle - key: ios-sim-backend-store-ghc${{ env.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}-${{ github.run_id }} - restore-keys: | - ios-sim-backend-store-ghc${{ env.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}- - ios-sim-backend-store-ghc${{ env.GHC_VER }}- - - name: Build backend run: scripts/ci/ios-sim/build-backend.sh ~/Downloads/${{ env.BACKEND_PKG }} - - name: Save cabal store - if: ${{ !cancelled() }} - uses: actions/cache/save@v4 - with: - path: | - ${{ steps.haskell.outputs.cabal-store }} - dist-newstyle - key: ${{ steps.cabal-cache.outputs.cache-primary-key }} - - name: Upload backend - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ needs.plan.outputs.backend }} path: ~/Downloads/${{ env.BACKEND_PKG }} @@ -219,20 +199,13 @@ jobs: needs: [plan, core, backend] # core and backend are skipped when an earlier run built them, and backend also when the run doesn't start it if: ${{ !cancelled() && !failure() && needs.plan.result == 'success' }} - runs-on: macos-latest - environment: - name: ${{ needs.plan.outputs.claude == 'true' && 'app-sim-claude' || '' }} - deployment: false - timeout-minutes: 330 + runs-on: macos-26 + timeout-minutes: 120 env: - CLAUDE_RUN: ${{ needs.plan.outputs.claude }} - START_BACKEND: ${{ needs.plan.outputs.start-backend }} + START_BACKEND: ${{ needs.plan.outputs.run-backend }} steps: - # the job later holds the Claude token, so the checkout's GitHub token is not left in .git/config - name: Checkout code - uses: actions/checkout@v4 - with: - persist-credentials: false + uses: actions/checkout@v6 - name: Show runner run: | @@ -247,7 +220,7 @@ jobs: run: scripts/ci/ios-sim/use-macos26-sdk.sh - name: Download core library - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: ${{ needs.plan.outputs.core }} path: ~/Downloads @@ -261,7 +234,7 @@ jobs: - name: Download backend if: env.START_BACKEND == 'true' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: ${{ needs.plan.outputs.backend }} path: ~/Downloads @@ -290,37 +263,34 @@ jobs: DEVELOPMENT_TEAM= \ PROVISIONING_PROFILE_SPECIFIER= + # for the Claude job; a tarball keeps the app's file modes, which an artifact drops + - name: Pack app + if: needs.plan.outputs.run-claude == 'true' + run: tar -czf "$RUNNER_TEMP/SimpleX.app.tar.gz" -C "$RUNNER_TEMP/DerivedData/Build/Products/Debug-iphonesimulator" SimpleX.app + + - name: Upload app + if: needs.plan.outputs.run-claude == 'true' + uses: actions/upload-artifact@v7 + with: + name: ios-sim-app + path: ${{ runner.temp }}/SimpleX.app.tar.gz + compression-level: 0 + retention-days: 1 + if-no-files-found: error + - name: Install AXe run: scripts/ci/ios-sim/install-axe.sh - name: Run smoke test run: scripts/ci/ios-sim/smoke-test.sh "$RUNNER_TEMP/DerivedData/Build/Products/Debug-iphonesimulator/SimpleX.app" "$RUNNER_TEMP/smoke-test" - - name: Install Claude Code - if: env.CLAUDE_RUN == 'true' - run: | - npm install -g @anthropic-ai/claude-code@2.1.300 - claude --version - - - name: Run the device test plan with Claude - if: env.CLAUDE_RUN == 'true' - timeout-minutes: 240 - env: - CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - BACKEND_DIR: ${{ env.START_BACKEND == 'true' && format('{0}/backend', runner.temp) || '' }} - run: scripts/ci/ios-sim/claude-test.sh "$RUNNER_TEMP/claude-test" - - - name: Report Claude usage - if: ${{ always() && env.CLAUDE_RUN == 'true' }} - run: scripts/ci/ios-sim/claude-usage.sh "$RUNNER_TEMP/claude-test" - - name: Stop test backend if: ${{ !cancelled() && env.START_BACKEND == 'true' }} run: scripts/ci/ios-sim/backend.sh stop "$RUNNER_TEMP/backend" - name: Upload smoke test results if: ${{ !cancelled() }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ios-sim-smoke-test path: ${{ runner.temp }}/smoke-test @@ -329,7 +299,7 @@ jobs: - name: Upload backend logs if: ${{ !cancelled() && env.START_BACKEND == 'true' }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ios-sim-backend-logs path: | @@ -338,11 +308,104 @@ jobs: retention-days: 3 if-no-files-found: warn + # The app-sim-claude environment's branch rule decides which branches may use the token. + claude: + name: Claude tests the iOS app + needs: [plan, app] + if: ${{ !cancelled() && needs.plan.outputs.run-claude == 'true' && needs.app.result == 'success' }} + runs-on: macos-26 + timeout-minutes: 330 + environment: + name: app-sim-claude + deployment: false + concurrency: + group: ios-sim-claude-${{ github.ref }} + cancel-in-progress: true + env: + # the same in both app workflows + CLAUDE_CODE_VERSION: 2.1.300 + steps: + # the job holds the Claude token, so the checkout's GitHub token is not left in .git/config + - name: Checkout code + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Show runner + run: | + sw_vers + xcodebuild -version + xcrun simctl list runtimes + + - name: Use the macOS 26 SDK + run: scripts/ci/ios-sim/use-macos26-sdk.sh + + - name: Download app + uses: actions/download-artifact@v8 + with: + name: ios-sim-app + path: ${{ runner.temp }} + + - name: Unpack app + run: tar -xzf "$RUNNER_TEMP/SimpleX.app.tar.gz" -C "$RUNNER_TEMP" + + - name: Download backend + if: needs.plan.outputs.claude-backend == 'yes' + uses: actions/download-artifact@v8 + with: + name: ${{ needs.plan.outputs.backend }} + path: ~/Downloads + run-id: ${{ needs.plan.outputs.backend-run || github.run_id }} + github-token: ${{ github.token }} + + - name: Start test backend + id: backend + if: needs.plan.outputs.claude-backend == 'yes' + timeout-minutes: 30 + run: scripts/ci/ios-sim/backend.sh start ~/Downloads/${{ env.BACKEND_PKG }} "$RUNNER_TEMP/backend" + + - name: Install AXe + run: scripts/ci/ios-sim/install-axe.sh + + - name: Run smoke test + run: scripts/ci/ios-sim/smoke-test.sh "$RUNNER_TEMP/SimpleX.app" "$RUNNER_TEMP/claude-test/smoke-test" + + - name: Install Claude Code + run: | + npm install -g "@anthropic-ai/claude-code@$CLAUDE_CODE_VERSION" + claude --version + + - name: Run the device test plan with Claude + timeout-minutes: 240 + env: + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + BACKEND_DIR: ${{ steps.backend.outcome == 'success' && format('{0}/backend', runner.temp) || '' }} + run: scripts/ci/ios-sim/claude-test.sh "$RUNNER_TEMP/claude-test" + + - name: Report Claude usage + if: ${{ always() }} + run: scripts/ci/ios-sim/claude-usage.sh "$RUNNER_TEMP/claude-test" + + - name: Stop test backend + if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }} + run: scripts/ci/ios-sim/backend.sh stop "$RUNNER_TEMP/backend" + - name: Upload Claude test results - if: ${{ always() && env.CLAUDE_RUN == 'true' }} - uses: actions/upload-artifact@v4 + if: ${{ always() }} + uses: actions/upload-artifact@v7 with: name: ios-sim-claude-test path: ${{ runner.temp }}/claude-test retention-days: 14 if-no-files-found: warn + + - name: Upload backend logs + if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }} + uses: actions/upload-artifact@v7 + with: + name: ios-sim-claude-backend-logs + path: | + ${{ runner.temp }}/backend/*.log + ${{ runner.temp }}/backend/seed.md + retention-days: 3 + if-no-files-found: warn diff --git a/scripts/ci/README.md b/scripts/ci/README.md new file mode 100644 index 0000000000..edc3569c38 --- /dev/null +++ b/scripts/ci/README.md @@ -0,0 +1,37 @@ +# App tests on GitHub's runners + +Two workflows build the apps from a branch's own code and test them, only when asked: `ios-sim` (the iOS app in a simulator on a macOS runner) and `desktop-linux` (the desktop app on a Linux runner). Each runs a scripted smoke test and, on request, a Claude session that follows a test plan, and a test backend (relay, directory, names server, peers). Details: [iOS](ios-sim/README.md), [desktop](desktop-linux/README.md). + +## Run tags + +Put a tag in the message of the commit you push. Tags combine: each workflow runs everything its tags ask for. + +| Tag | iOS | Desktop | +|---|---|---| +| `[app-ci]` | scripted test | scripted test | +| `[app-ci:ios]` | scripted test | — | +| `[app-ci:desktop]` | — | scripted test | +| `[claude-test]` | scripted test, then Claude | scripted test, then Claude | +| `[claude-test:ios]` | scripted test, then Claude | — | +| `[claude-test:desktop]` | — | scripted test, then Claude | +| `[ci-backend]`, with one of the above | also the test backend | also the test backend | +| `[ci-release]`, with one of the above | — | also the latest release's onboarding, to tell a branch's failure from the runner's | + +Only the pushed head commit counts, and a tag counts anywhere in its message, body included: never write one in a message unless that push should run it. A push that asks for nothing starts no job and doesn't cancel a running test; a newer requested run on the same branch cancels the older one. Pushes to master and stable never start a run, because a squash merge's message lists the merged commits' subjects with their tags; run it by hand there. + +## Running a workflow by hand + +Actions → `ios-sim` or `desktop-linux` → Run workflow, on any branch, with checkboxes that are off by default: `claude` (the Claude session), `backend` (the test backend) and, on desktop, `release` (the latest release's onboarding). + +## How long a run takes + +| Run | iOS | Desktop | +|---|---|---| +| Scripted test, builds reused | about 25 min, plus the wait for a macOS runner, which the team's builds share (minutes to an hour) | about 10 min | +| The core rebuilt, after Haskell changes | 30–45 min more | about 25 min more | +| With the test backend | 20–45 min more when it is rebuilt, then about 10 min to start | about 30 min more when it is rebuilt, then a few minutes to start | +| With Claude on the smoke plan | 10–15 min more | about 4 min more | + +## The Claude job + +It needs the GitHub environment `app-sim-claude` with the secret `CLAUDE_CODE_OAUTH_TOKEN`, a Claude subscription token. The environment's branch rule alone decides which branches may use it: on a branch it doesn't allow, a run that asks for Claude fails at the Claude job's environment check, and the scripted test runs as usual. Each run spends the subscription's usage limits; the job summary gives its tokens and cost estimated at API prices. diff --git a/scripts/ci/desktop-linux/README.md b/scripts/ci/desktop-linux/README.md index a9080e1c87..f84c42b8c7 100644 --- a/scripts/ci/desktop-linux/README.md +++ b/scripts/ci/desktop-linux/README.md @@ -4,42 +4,29 @@ ## Running it -On any branch, in either of two ways: +The workflow runs only when asked, on any branch, by a run tag in the pushed commit's message or by hand from the Actions tab: [App tests on GitHub's runners](../README.md) lists the tags and inputs. -- **Dispatch:** Actions → desktop-linux → Run workflow, with the `claude` and `backend` checkboxes (both off by default). -- **Push** with a tag in the head commit's message, which is how agents in push sessions ask for a run: - -| Tag | Runs | -|---|---| -| `[app-ci]` | the scripted test, here and in the iOS app's workflow where the branch has it | -| `[app-ci:desktop]` | the scripted test, here only | -| `[claude-test]` | the scripted test and the Claude job, here and in the iOS app's workflow where the branch has it | -| `[claude-test:desktop]` | the scripted test and the Claude job, here only | -| `[ci-backend]` | with one of the above: also the test backend and the release job | - -A push without these tags (or with only iOS tags) starts a run whose jobs are all skipped, and it doesn't cancel a run in progress. A newer run that was asked for cancels an older one on the same branch. Pushes to master and stable never start it, because a squash merge's message lists the merged commits' subjects with their tags; dispatch it there instead. - -Every run builds the core library, and the backend when it's asked for, from the branch's own Haskell code. It reuses an earlier build only when the sources are unchanged: the core by a cache keyed on `src/**`, `cabal.project`, `simplex-chat.cabal`, `PRIVACY.md` and the build scripts; the backend by an artifact of the same branch named after a hash of its sources (30 days). So a branch that changes core and app code together tests both. +Every run builds the core library, and the backend when it's asked for, from the branch's own Haskell code. It reuses an earlier build only when the sources are unchanged, as an artifact of the same branch named after a hash of its sources (30 days): for the core `src/**`, `cabal.project`, `simplex-chat.cabal`, `PRIVACY.md` and the build scripts, for the backend `src/**`, the CLI's and the directory's sources, `cabal.project`, `simplex-chat.cabal` and its build script. So a branch that changes core and app code together tests both. The workflow saves no Actions caches, which this repository evicts within hours and shares with `build.yml`. ## Jobs | Job | Does | Time | |---|---|---| -| `plan` | chooses the jobs, finds an earlier backend build | 10 s | -| `core` | `scripts/desktop/build-lib-linux.sh` via `build-core.sh` | 15 s cached, about 23 min not | -| `app` | the app (`:desktop:createDistributable`), desktopTest, `smoke-test.sh` | about 10 min: build 4.5, desktopTest 0.5, smoke test 3.5 | -| `claude` (opt-in) | the app at its first screen, then Claude Code drives it | about 7 min, Claude 2.5 | -| `backend` (opt-in) | `build-backend.sh`: `simplex-chat`, `simplex-directory-service`, and `smp-server` from the simplexmq commit `cabal.project` pins | about 27 min, skipped when reused | +| `plan` | chooses the jobs, finds earlier core and backend builds | 10 s | +| `core` | `scripts/desktop/build-lib-linux.sh` via `build-core.sh` | skipped when reused, about 24 min not | +| `app` | the app (`:desktop:createDistributable`), desktopTest, `smoke-test.sh` | about 12 min: build 4.5, desktopTest 0.5, smoke test 3.5 | +| `claude` (opt-in) | after `app` passed: its distributable at the first screen, then Claude Code drives it | about 4 min, Claude 3 | +| `backend` (opt-in) | `build-backend.sh`: `simplex-chat`, `simplex-directory-service`, and `smp-server` from the simplexmq commit `cabal.project` pins | about 30 min, skipped when reused | | `release` (opt-in) | the latest release's .deb for this Ubuntu, onboarding only, to tell a branch's failure from the runner's | 1.5 min | -Queueing for a runner adds from under a minute to 20 minutes. Results are artifacts: `desktop-smoke-test` (numbered screenshots, the text OCR read from each, `steps.log`, `checks.txt`, `app.log`), `desktop-test-report`, `desktop-claude-test`, and the backend's logs. The job summaries list the checks, the desktopTest results and the Claude usage. +Queueing for a runner adds from under a minute to 20 minutes. Results are artifacts: `desktop-smoke-test` (numbered screenshots, the text OCR read from each, `steps.log`, `checks.txt`, `app.log`), `desktop-test-report`, `desktop-claude-test`, and the backend's logs; `desktop-app`, the distributable for the Claude job, is kept a day. The job summaries list the checks, the desktopTest results and the Claude usage. ## Scripts - `ui`: the UI driver. Compose for Desktop has no accessibility tree on Linux, so it finds text on screenshots by OCR (tesseract) and acts with xdotool on an Xvfb display. `ui` alone prints its commands: start (with app settings), shot, text, find, wait, click, click-text, scroll, type, key, avatar (a profile image's colour), ink (a text's height), do (several in a row), stop. `screen-text.py`, `screen-colour.py` and `screen-ink.py` are its helpers. - `smoke-test.sh [start|onboarding|setup|backend]`: the scripted cases; the default is onboarding and both layouts. `setup` also creates a local group and makes the test SMP server the app's only names server; `backend` does that, connects to the seeded name, and checks both layouts. - `backend.sh start ` / `stop `: the test backend, below. -- `claude-test.sh`, `claude-test.md` (the prompt), `claude-guard.py`, `claude-usage.py`: the Claude job. +- `claude-test.sh`, `claude-test.md` (the prompt), `claude-scope` (whether the prompt uses the test backend), `claude-guard.py`, `claude-usage.py`: the Claude job. - `build-core.sh`, `install-core.sh`, `build-backend.sh`, `earlier-run.sh`, `release-app.sh`, `test-summary.py`: builds, reuse and reports. ## The test backend @@ -54,8 +41,8 @@ It writes the directory's address to `/directory-link` and the SMP server's ## The Claude job -It needs the GitHub environment `app-sim-claude` with the secret `CLAUDE_CODE_OAUTH_TOKEN`, a Claude subscription token. Which branches may use it is decided only by that environment's branch rule in the repository settings: on a branch the rule doesn't allow, a requested Claude run fails at the environment check. The token goes only to the step that runs `claude`, and every `ui` call unsets it. With `[ci-backend]` the job also starts the test backend for prompts that use it, and waits for its build; this smoke path's prompt doesn't use it. The job installs Claude Code 2.1.296 and runs `claude-haiku-5-5` with `--max-turns 60` and `--max-budget-usd 3`. Its Bash runs only single `ui` commands (`claude-guard.py`, a PreToolUse hook: Claude Code runs read-only commands without asking in every permission mode). Before anything the session wrote is printed or uploaded, `claude-test.sh` checks it for the token; if it's there, the output, transcript, Claude Code's stderr and the guard log are deleted and the job fails. It passes when Claude's final message ends with `CLAUDE-TEST: PASS`; `report.md`, the transcript, the guard's log and `claude-usage.json` are in `desktop-claude-test`. +It needs the GitHub environment `app-sim-claude` with the secret `CLAUDE_CODE_OAUTH_TOKEN`, a Claude subscription token. Which branches may use it is decided only by that environment's branch rule in the repository settings: on a branch the rule doesn't allow, a requested Claude run fails at the environment check. It runs once the app job has passed, on that job's distributable, one per branch at a time. The token goes only to the step that runs `claude`; `claude-test.sh` passes it to the `claude` process alone, and every `ui` call unsets it too. The job starts the test backend when `claude-scope` says `backend: yes`; a run that asks for Claude must then ask for the backend too, or the plan job fails. On master it says `no`, since this prompt doesn't use the backend. The job installs Claude Code at the version the iOS workflow pins too, and runs `claude-haiku-5-5` with `--max-turns 60`, `--max-budget-usd 3` and the iOS sessions' other flags (`--restricted`, `--permission-prompts none`, `--effort high`). Its Bash runs only single `ui` commands (`claude-guard.py`, a PreToolUse hook: Claude Code runs read-only commands without asking in every permission mode). Before anything the session wrote is printed or uploaded, `claude-test.sh` checks it for the token; if it's there, the output, transcript, Claude Code's stderr and the guard log are deleted and the job fails. It passes when Claude's final message ends with `CLAUDE-TEST: PASS`; `report.md`, the transcript, the guard's log and `claude-usage.json` are in `desktop-claude-test`. ## Cost -The runners are free for this public repository. A Claude run spends the subscription's usage limits, which interactive sessions share: Haiku 5.5 does this smoke path in 15 to 17 turns, about 0.008 USD estimated at API prices (Claude Code's estimate, not a bill on a subscription). +The runners are free for this public repository. A Claude run spends the subscription's usage limits, which interactive sessions share: Haiku 5.5 does this smoke path in 15 to 30 turns, 0.01 to 0.02 USD estimated at API prices (Claude Code's estimate, not a bill on a subscription). diff --git a/scripts/ci/desktop-linux/backend.sh b/scripts/ci/desktop-linux/backend.sh index 836f09d275..842b3c890e 100755 --- a/scripts/ci/desktop-linux/backend.sh +++ b/scripts/ci/desktop-linux/backend.sh @@ -120,21 +120,27 @@ create_directory() { } start_directory() { + local pid deadline=$((SECONDS + 60)) # -y: the service adds its own tables to the database the CLI created, and would otherwise ask on stdin nohup "$bin/simplex-directory-service" -d "$work/directory" -y --super-users "$1" < /dev/null >> "$work/directory.log" 2>&1 & - echo $! > "$work/directory.pid" - sleep 5 - if ! kill -0 "$(cat "$work/directory.pid")" 2> /dev/null; then - echo "error: the directory service exited" - cat "$work/directory.log" - return 1 - fi + pid=$! + echo "$pid" > "$work/directory.pid" + # its output is block-buffered, so it shows that its chat has started by connecting to its servers + until grep -q "pid=$pid," <<< "$(ss -Htnp state established)"; do + if ! kill -0 "$pid" 2> /dev/null || [ "$SECONDS" -ge "$deadline" ]; then + echo "error: the directory service did not start" + cat "$work/directory.log" + return 1 + fi + sleep 1 + done } -stop_directory() { +# Waits for the process to exit, which completes its output, and kills it after 30 s. +stop_process() { local pid deadline - pid=$(cat "$work/directory.pid") - kill -INT "$pid" 2> /dev/null || true + pid=$(cat "$work/$1.pid") + kill "-$2" "$pid" 2> /dev/null || true deadline=$((SECONDS + 30)) while kill -0 "$pid" 2> /dev/null; do if [ "$SECONDS" -ge "$deadline" ]; then @@ -152,7 +158,7 @@ connect_admin() { admin_until "Welcome to $directory_name" 180 "/tail @'$directory_name' 10" local id id=$(db_query directory "SELECT contact_id FROM contacts WHERE local_display_name = '$admin'") - stop_directory + stop_process directory INT start_directory "$id:$admin" echo "directory restarted with super user $id:$admin" } @@ -311,12 +317,15 @@ start() { stop() { local p - for p in directory relay smp-server resolver; do + for p in directory relay smp-server; do if [ -f "$work/$p.pid" ]; then - kill -INT "$(cat "$work/$p.pid")" 2> /dev/null || true + stop_process "$p" INT fi done - sleep 3 + # Python, started in the background by a script, ignores SIGINT + if [ -f "$work/resolver.pid" ]; then + stop_process resolver TERM + fi for p in directory relay smp-server resolver; do echo "::group::$p log" cat "$work/$p.log" 2> /dev/null || true diff --git a/scripts/ci/desktop-linux/build-backend.sh b/scripts/ci/desktop-linux/build-backend.sh index adda90dcfe..fbe20bad8e 100755 --- a/scripts/ci/desktop-linux/build-backend.sh +++ b/scripts/ci/desktop-linux/build-backend.sh @@ -13,7 +13,6 @@ out="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" ghc --version cabal --version -du -sh ~/.cabal/store dist-newstyle 2> /dev/null || true cat > cabal.project.local << EOF ignore-project: False diff --git a/scripts/ci/desktop-linux/build-core.sh b/scripts/ci/desktop-linux/build-core.sh index 13ab52eb30..cda413e9f1 100755 --- a/scripts/ci/desktop-linux/build-core.sh +++ b/scripts/ci/desktop-linux/build-core.sh @@ -8,13 +8,10 @@ set -euo pipefail mkdir -p "$(dirname "$1")" out="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" arch=$(uname -m) -ghc_ver=$(ghc --numeric-version) -version=$(sed -n 's/^version: *//p' simplex-chat.cabal) libs_dir=apps/multiplatform/common/src/commonMain/cpp/desktop/libs/linux-$arch ghc --version cabal --version -du -sh ~/.local/state/cabal/store ~/.cabal/store dist-newstyle 2> /dev/null || true cat > cabal.project.local << EOF ignore-project: False @@ -24,9 +21,6 @@ package direct-sqlcipher flags: +openssl EOF -# a restored dist-newstyle can hold an older version's build directory, which breaks the script's simplex-chat-* glob -find "dist-newstyle/build/$arch-linux/ghc-$ghc_ver" -maxdepth 1 -name 'simplex-chat-*' ! -name "simplex-chat-$version" -exec rm -rf {} + 2> /dev/null || true - SKIP_VLC=1 scripts/desktop/build-lib-linux.sh ls -la "$libs_dir" diff --git a/scripts/ci/desktop-linux/claude-scope b/scripts/ci/desktop-linux/claude-scope new file mode 100644 index 0000000000..ca4c2da0f9 --- /dev/null +++ b/scripts/ci/desktop-linux/claude-scope @@ -0,0 +1,3 @@ +# What the Claude job needs for the session claude-test.md describes. +# backend: yes when the prompt uses the test backend; a run that asks for Claude must then start it too. +backend: no diff --git a/scripts/ci/desktop-linux/claude-test.sh b/scripts/ci/desktop-linux/claude-test.sh index d254226b75..12fde73a05 100755 --- a/scripts/ci/desktop-linux/claude-test.sh +++ b/scripts/ci/desktop-linux/claude-test.sh @@ -11,7 +11,9 @@ set -euo pipefail -: "${CLAUDE_CODE_OAUTH_TOKEN:?CLAUDE_CODE_OAUTH_TOKEN must be set}" +token=${CLAUDE_CODE_OAUTH_TOKEN:?CLAUDE_CODE_OAUTH_TOKEN must be set} +# only the claude process gets the token, nothing else this script starts +unset CLAUDE_CODE_OAUTH_TOKEN here=$(cd "$(dirname "$0")" && pwd) out=$(cd "$1" && pwd) transcript=$2 @@ -35,26 +37,28 @@ EOF claude --version cd "$out" status=0 -claude -p "$(cat prompt.md)" \ +CLAUDE_CODE_OAUTH_TOKEN=$token claude -p \ --model "$CLAUDE_MODEL" \ + --effort high \ + --restricted \ + --tools Bash,Read,Write,Edit \ + --allowedTools "Bash(ui)" "Bash(ui *)" "Read(/$out/**)" "Edit(/$out/**)" \ + --permission-mode dontAsk \ + --permission-prompts none \ + --strict-mcp-config \ --max-turns 60 \ --max-budget-usd 3 \ - --output-format stream-json \ - --verbose \ - --tools Bash,Read,Write \ - --permission-mode dontAsk \ - --allowedTools "Bash(ui *)" "Read(/$out/**)" "Edit(/$out/**)" \ - --disallowedTools "mcp__*" \ - --strict-mcp-config \ - --disable-slash-commands \ + --autocompact 200k \ --no-session-persistence \ --settings "$settings" \ - < /dev/null > "$transcript" 2> "$stderr" || status=$? + --output-format stream-json \ + --verbose \ + < prompt.md > "$transcript" 2> "$stderr" || status=$? # A second safeguard to the file tools' limits: what the session wrote is printed and uploaded only when it is known # not to hold the token. grep reads the token from a pipe, so it is in no process's arguments. leak=0 -printf '%s\n' "$CLAUDE_CODE_OAUTH_TOKEN" | grep -rqF -f - -- "$out" "$transcript" "$stderr" "$CLAUDE_GUARD_LOG" || leak=$? +printf '%s\n' "$token" | grep -rqF -f - -- "$out" "$transcript" "$stderr" "$CLAUDE_GUARD_LOG" || leak=$? if [ "$leak" -ne 1 ]; then rm -rf -- "$out" "$transcript" "$stderr" "$CLAUDE_GUARD_LOG" echo "error: the token is in the Claude session's output, or checking for it failed (grep exited $leak); the output, transcript, stderr and guard log are deleted" @@ -78,4 +82,4 @@ if [ "$status" -ne 0 ]; then exit 1 fi last=$(grep -v '^[[:space:]]*$' <<< "$result" | tail -n 1 || true) -grep -qxE '[*`]*CLAUDE-TEST: PASS[*`]*' <<< "$last" +grep -qxE '[*`]*CLAUDE-TEST: PASS[*`.]*[[:space:]]*' <<< "$last" diff --git a/scripts/ci/ios-sim/README.md b/scripts/ci/ios-sim/README.md index dd2c37c030..d84fd7d9e0 100644 --- a/scripts/ci/ios-sim/README.md +++ b/scripts/ci/ios-sim/README.md @@ -4,33 +4,33 @@ ## Running it -The workflow runs only when asked, on any branch: +The workflow runs only when asked, on any branch, by a run tag in the pushed commit's message or by hand from the Actions tab: [App tests on GitHub's runners](../README.md) lists the tags and inputs. -- **Dispatch** it from the Actions tab (workflow "ios-sim", "Run workflow"), with the inputs `claude` (also run the Claude session) and `backend` (also start the test backend), both off by default. -- **Push** a commit whose message carries a tag: `[app-ci]` runs the scripted tests of both apps (iOS and desktop), `[app-ci:ios]` only this one; `[claude-test]` (both apps) or `[claude-test:ios]` runs the scripted test and then the Claude session; add `[ci-backend]` to build and start the test backend. Only the pushed head commit counts, and a tag matches anywhere in its message, body included, so don't quote a tag in a message that shouldn't run it. - -A push without a tag starts nothing and doesn't cancel a running test; a newer requested run on the same branch cancels the older one. Pushes to master and stable never start it, because a squash merge's message lists the merged commits' subjects with their tags; dispatch it there instead. Which branches may run the Claude session is decided only by the `app-sim-claude` environment's branch rule in the repository settings: on a branch it doesn't allow, a run that asks for Claude fails at the environment check, and because the app job holds the environment, its scripted test fails with it. Ask for Claude only on branches the rule allows. - -Results: the job log, the job summary (Claude's usage) and the artifacts `ios-sim-smoke-test` (a screenshot, a 1000-px view and an accessibility tree per step, the app log), `ios-sim-backend-logs` and `ios-sim-claude-test` (per session: `prompt.md`, `report.md`, `shots/`, `transcript.jsonl`, `usage.json`). Read them with `gh run view --log` and `gh run download -n `. +Results: the job logs, the Claude job's summary (Claude's usage) and the artifacts `ios-sim-smoke-test` (a screenshot, a 1000-px view and an accessibility tree per step, the app log), `ios-sim-backend-logs` and `ios-sim-claude-test` (the Claude job's smoke test in `smoke-test/`, and per session: `prompt.md`, `report.md`, `shots/`, `transcript.jsonl`, `usage.json`). Read them with `gh run view --log` and `gh run download -n `. ## How long it takes, what it costs -- `plan` (ubuntu, 15 s) names the core library and the backend after their sources, `hashFiles` over the Haskell sources, `cabal.project`, `simplex-chat.cabal` and the build script, and reuses an artifact of the same name that an earlier run of the same branch uploaded (30 days). So every run builds the core library, and the backend when asked, from the branch's own Haskell code, and rebuilds only when those sources change. -- `core` (macOS, 35–45 min when not reused): `build-core.sh` builds `lib:simplex-chat` with cabal and the iOS flags, as the Nix iOS build does, into the package `scripts/ios/prepare.sh` expects, and `prepare-libs.sh` marks it for the simulator (`mac2ios`). -- `backend` (macOS, 20–30 min when not reused, only when asked): `build-backend.sh` builds `simplex-chat`, `simplex-directory-service` and `smp-server`. -- `app` (macOS): the Xcode build 8–22 min, the smoke test about 10 min, the backend start about 10 min when asked, Claude as long as its plan needs. +- `plan` (ubuntu-24.04, 10 s) reads what the run asks for, names the core library and the backend after their sources, `hashFiles` over the Haskell sources, `cabal.project`, `simplex-chat.cabal` and the build script, and reuses an artifact of the same name that an earlier run of the same branch uploaded (30 days). So every run builds the core library, and the backend when asked, from the branch's own Haskell code, and rebuilds only when those sources change. +- `core` (macOS, 30–45 min when not reused): `build-core.sh` builds `lib:simplex-chat` with cabal and the iOS flags, as the Nix iOS build does, into the package `scripts/ios/prepare.sh` expects, and `prepare-libs.sh` marks it for the simulator (`mac2ios`). +- `backend` (macOS, 20–45 min when not reused, only when asked): `build-backend.sh` builds `simplex-chat`, `simplex-directory-service` and `smp-server`. +- `app` (macOS): the Xcode build 8–22 min, the smoke test about 10 min, the backend start about 10 min when asked. +- `claude` (macOS, only when asked, after `app` passed): a new simulator with the app job's build and the smoke test, about 7 min, then the sessions (the smoke plan's took 2.6 min). -A run with reused builds and no extras takes about 26 minutes plus the wait for a macOS runner. Hosted minutes are free for this public repository; the limit is the repository's five concurrent macOS jobs, shared with PR builds (each `build.yml` run takes two), so queue waits can be long. Caches are evicted within hours (the repository is over its cache limit); the artifacts are what make runs fast. +A run with reused builds and no extras takes about 25 minutes plus the wait for a macOS runner. Hosted minutes are free for this public repository; the limit is the repository's five concurrent macOS jobs, shared with PR builds (each `build.yml` run takes two), so queue waits can be long. The workflow saves no Actions caches, which this repository evicts within hours and shares with `build.yml`: builds are reused only as the artifacts above, and a core or backend rebuild compiles its dependencies too. + +The macOS jobs run on `macos-26`, the image with Xcode 26 and the iPhone 17 simulator; `macos-latest` moves to each new macOS and its Xcode. ## The Claude session -It runs after the smoke test, in the same job, on the simulator the smoke test leaves running, once per layout listed in `claude-scope`, with Claude Code installed from npm at a pinned version. The job then references the environment `app-sim-claude`, which holds the secret `CLAUDE_CODE_OAUTH_TOKEN` (a Claude subscription token) and admits the branches its rule allows; jobs without Claude reference no environment. Only the Claude step gets the token, and `claude-test.sh` passes it to the `claude` processes alone. The job checks out without persisting its GitHub token and installs a pinned AXe (`install-axe.sh`), since it later holds the Claude token. +It runs in its own job, `claude`, once the app job has passed: the job installs that job's build (the artifact `ios-sim-app`, kept a day) in a new simulator, runs the smoke test to reach the sessions' starting state, then one session per layout listed in `claude-scope`, with Claude Code installed from npm at the version the desktop workflow pins too. Only this job references the environment `app-sim-claude`, which holds the secret `CLAUDE_CODE_OAUTH_TOKEN` (a Claude subscription token) and admits the branches its rule allows; one runs per branch at a time. Only the Claude step gets the token, and `claude-test.sh` passes it to the `claude` processes alone. The job checks out without persisting its GitHub token and installs a pinned AXe (`install-axe.sh`), since it holds the Claude token. It starts the test backend when `claude-scope` says `backend: yes`; a run that asks for Claude must then ask for the backend too, or the plan job fails. On master it says `no`, since the smoke plan doesn't use the backend. -- `claude-scope`: the model, the layouts, the cases per layout, optional entries per layout, `max-turns` and `max-budget-usd` per session. +Each session's final message ends with `CLAUDE-TEST: PASS`, or `CLAUDE-TEST: FAIL` and the failed cases; the step fails unless every session passes. + +- `claude-scope`: whether the cases use the test backend, the model, the layouts, the cases per layout, optional entries per layout, `max-turns` and `max-budget-usd` per session. - `device-test-plan.md`: the plan (`| ID | Do | Expect |` tables). On master it is a three-case smoke plan; a feature branch replaces it with its own and names its cases in `claude-scope`. - `claude-prompt.md`: what any session needs to know about the app, the `ui` helper and the test backend; `claude-test.sh` adds the `ui` help, the session's cases, layout, start state and limits, the seeded backend and the plan. -- `claude-guard.py`: a PreToolUse hook that lets through only one plain `ui` command per Bash call, since read-only commands run without asking in every permission mode and Bash inherits the token. Read, Write and Edit are allowed only in the session's own directory, so a session can't change `ui` or the scripts it runs. After each session `claude-test.sh` deletes the session's files if the token appears in them. -- `claude-usage.sh`: each session's turns, duration, tokens and cost "estimated at API prices" (Claude Code's own estimate, not a bill) to the job summary and `claude-usage.json`. +- `claude-guard.py`: a PreToolUse hook that lets through only one plain `ui` command per Bash call, since read-only commands run without asking in every permission mode and Bash inherits the token. Read, Write and Edit are allowed only in the session's own directory, so a session can't change `ui` or the scripts it runs. After each session `claude-test.sh` deletes the session's files, and fails, if the token appears in them or the check for it fails. +- `claude-usage.sh`: each session's turns, duration, tokens and cost "estimated at API prices" (Claude Code's own estimate, not a bill) to the job summary and `claude-usage.json`, or why they are unknown (a session cut short before its token check is not uploaded). Haiku 5.5 drives the app well: the smoke plan takes a few dozen turns and cents; a 50-case feature plan took 300–450 turns, 30–60 minutes and $1.1–1.7 per session, estimated at API prices. Every run spends the subscription's usage limits, which interactive sessions share. @@ -47,11 +47,11 @@ Haiku 5.5 drives the app well: the smoke plan takes a few dozen turns and cents; - a local SMP server with a `[NAMES]` section and `names-resolver.py`, the names `cinews.testing`, `ciboth.testing`, `cialice.testing` and `cibobby.testing` claimed by their owners, and the contacts cialice and Robert, online and accepting connections; its address goes to `/names-server`; - checks before the app starts: a search sent to the directory as a message must list `ci_group` and `ci_channel`, and `/_connect plan` must resolve each name from a fresh peer. -Not yet verified on master: the backend ran on `f/directory-search`'s code, and its checks were rewritten for master's (the directory answers searches sent as messages there; service requests need the feature branch). +On master's code it starts and passes these checks (the directory answers searches sent as messages); service requests to the directory, which only `f/directory-search` answers, are not checked here. It writes what it seeded to `/seed.md` (the Claude sessions get it) and logs to `/*.log`; `stop`, `directory-stop`, `directory-start` and `listings` are its other commands. The app reaches it through the simulator, which shares the runner's network; pointing the app at the names server is a few steps in Settings, described in `claude-prompt.md`. ## Other files -- `use-macos26-sdk.sh`: runner images from 20261013 add Command Line Tools with a macOS 27 SDK that Xcode 26.6's linker can't read; every macOS job points the default SDK back at macOS 26.5. +- `use-macos26-sdk.sh`: runner images from 20261013 add Command Line Tools with a macOS 27 SDK that Xcode 26.6's linker can't read; every macOS job points the default SDK back at macOS 26.5. The pinned `macos-26` label runs such an image. - `earlier-run.sh `: finds the newest run of this branch in this repository with that artifact; artifacts from forks and other branches are never used. diff --git a/scripts/ci/ios-sim/backend.sh b/scripts/ci/ios-sim/backend.sh index 02b293a9f9..3c4c5c4ca6 100755 --- a/scripts/ci/ios-sim/backend.sh +++ b/scripts/ci/ios-sim/backend.sh @@ -57,13 +57,6 @@ expect_out() { fi } -expect_no_error() { - if grep -qiE "error" "$work/out"; then - echo "error: the command failed" - return 1 - fi -} - # Each run keeps the client online for a few seconds, which also lets the handshakes it waits for progress. admin_until() { local pattern=$1 secs=$2 cmd=$3 @@ -114,22 +107,28 @@ create_directory() { } start_directory() { + local pid deadline=$((SECONDS + 60)) # -y: the service adds its own tables to the database the CLI created, and would otherwise ask on stdin nohup "$bin/simplex-directory-service" -d "$work/directory" -y --super-users "$1" < /dev/null >> "$work/directory.log" 2>&1 & - echo $! > "$work/directory.pid" + pid=$! + echo "$pid" > "$work/directory.pid" echo "$1" > "$work/super-user" - sleep 5 - if ! kill -0 "$(cat "$work/directory.pid")" 2> /dev/null; then - echo "error: the directory service exited" - cat "$work/directory.log" - return 1 - fi + # its output is block-buffered, so it shows that its chat has started by connecting to its servers + until lsof -a -p "$pid" -iTCP -sTCP:ESTABLISHED > /dev/null 2>&1; do + if ! kill -0 "$pid" 2> /dev/null || [ "$SECONDS" -ge "$deadline" ]; then + echo "error: the directory service did not start" + cat "$work/directory.log" + return 1 + fi + sleep 1 + done } -stop_directory() { +# Waits for the process to exit, which completes its output, and kills it after 30 s. +stop_process() { local pid deadline - pid=$(cat "$work/directory.pid") - kill -INT "$pid" 2> /dev/null || true + pid=$(cat "$work/$1.pid") + kill "-$2" "$pid" 2> /dev/null || true deadline=$((SECONDS + 30)) while kill -0 "$pid" 2> /dev/null; do if [ "$SECONDS" -ge "$deadline" ]; then @@ -147,7 +146,7 @@ connect_admin() { admin_until "Welcome to $directory_name" 180 "/tail @'$directory_name' 10" local id id=$(db_query directory "SELECT contact_id FROM contacts WHERE local_display_name = '$admin'") - stop_directory + stop_process directory INT start_directory "$id:$admin" echo "directory restarted with super user $id:$admin" } @@ -299,7 +298,7 @@ start_contact() { expect_out "^auto_accept on" register_name "$name" "$address" "" named "$db" "$display_name" 5 "/_set domain 1 $name" - expect_no_error + expect_out "^new contact address set$" # --headless is for relays only; a command with a long -t keeps the client online, accepting contacts, for 6 hours nohup "$bin/simplex-chat" -d "$work/$db" -e "/auto_accept on" -t 21600 --user-display-name "$display_name" < /dev/null > "$work/$db.log" 2>&1 & echo $! > "$work/$db.pid" @@ -323,7 +322,7 @@ seed_names() { link=$(channel_link ciboth) register_name ciboth.testing "$address" "$link" named "$admin" "$admin" 5 "/_set domain 1 ciboth.testing" - expect_no_error + expect_out "^new contact address set$" named "$admin" "$admin" 5 "/public group access #ciboth domain=ciboth.testing" expect_out "updated public group access: domain=ciboth.testing" @@ -334,6 +333,8 @@ seed_names() { # What the app will resolve, from a client that knows none of the names' owners. probe_names() { local target pattern + # -s gives its server the names role only for users that exist when the client starts + chat probe 1 "/user" --user-display-name ci_probe for target in "#cinews.testing|ok to connect via relays" "@cialice.testing|contact address: ok to connect" "@cibobby.testing|contact address: ok to connect" "ciboth.testing|You can also connect to @ciboth"; do pattern=${target#*|} target=${target%%|*} @@ -433,14 +434,17 @@ start() { } stop() { - local p processes="directory relay ci_alice ci_robert smp-server names-resolver" + local p processes="directory relay ci_alice ci_robert smp-server" for p in $processes; do if [ -f "$work/$p.pid" ]; then - kill -INT "$(cat "$work/$p.pid")" 2> /dev/null || true + stop_process "$p" INT fi done - sleep 3 - for p in $processes; do + # Python, started in the background by a script, ignores SIGINT + if [ -f "$work/names-resolver.pid" ]; then + stop_process names-resolver TERM + fi + for p in $processes names-resolver; do echo "::group::$p log" cat "$work/$p.log" 2> /dev/null || true echo "::endgroup::" @@ -459,7 +463,7 @@ case "${1:-}" in ;; directory-stop) work=$2 - stop_directory + stop_process directory INT ;; directory-start) work=$2 diff --git a/scripts/ci/ios-sim/build-backend.sh b/scripts/ci/ios-sim/build-backend.sh index 44b6c4c0c0..cf319a95dd 100755 --- a/scripts/ci/ios-sim/build-backend.sh +++ b/scripts/ci/ios-sim/build-backend.sh @@ -12,7 +12,6 @@ openssl_dir=$(brew --prefix openssl@3) ghc --version cabal --version -du -sh ~/.cabal/store dist-newstyle || true cat > cabal.project.local < cabal.project.local < (optional) tells that session which directory entries and names its cases use, so none meets an entry already known. +# backend: yes when the cases use the test backend; a run that asks for Claude must then start it too. +backend: no model: claude-haiku-5-5 layouts: reachable cases-reachable: S1-S3 diff --git a/scripts/ci/ios-sim/claude-test.sh b/scripts/ci/ios-sim/claude-test.sh index 5d72d03a9b..e069f33ef3 100755 --- a/scripts/ci/ios-sim/claude-test.sh +++ b/scripts/ci/ios-sim/claude-test.sh @@ -2,7 +2,8 @@ # Runs the device test plan with Claude Code on this runner after the smoke test, one session per layout, # as claude-scope says. A session drives the app only through the ui helper, and reads and writes only its -# own directory /: prompt.md, report.md, shots/, transcript.jsonl (stream-json). +# own directory, which ends up in /: prompt.md, report.md, shots/, transcript.jsonl (stream-json). +# It passes when every session's final message ends with "CLAUDE-TEST: PASS". # Usage: scripts/ci/ios-sim/claude-test.sh # Needs CLAUDE_CODE_OAUTH_TOKEN, SIM_UDID (the booted simulator with the app) and, when the test backend runs, BACKEND_DIR. @@ -14,6 +15,9 @@ unset CLAUDE_CODE_OAUTH_TOKEN mkdir -p "$1" out=$(cd "$1" && pwd) +# sessions run here and move to the output dir, which is uploaded, only after their files are checked for the token +staging=$out.sessions +mkdir -p "$staging" dir=$(cd "$(dirname "$0")" && pwd) : "${SIM_UDID:?SIM_UDID must be the simulator UDID}" BACKEND_DIR=${BACKEND_DIR:-} @@ -89,14 +93,15 @@ write_prompt() { status=0 previous="" for layout in $layouts; do - session=$out/$layout + session=$staging/$layout mkdir -p "$session/shots" + echo "$layout" >> "$out/sessions" ui layout "$layout" write_prompt "$layout" "$previous" > "$session/prompt.md" echo "::group::Claude session: $layout, $model, $max_turns turns, \$$max_budget" session_status=0 # outside the session's directory, so the session cannot change it - export CLAUDE_GUARD_LOG=$out/$layout-guard.log + export CLAUDE_GUARD_LOG=$staging/$layout-guard.log : > "$CLAUDE_GUARD_LOG" ( cd "$session" @@ -120,11 +125,16 @@ for layout in $layouts; do ) || session_status=$? echo "claude exited with $session_status" # the token must never be printed or uploaded, whatever the session did; grep reads it from a pipe, not from its arguments - if grep -rqF -f <(printf '%s\n' "$token") "$session" "$CLAUDE_GUARD_LOG"; then + leak=0 + grep -rqF -f <(printf '%s\n' "$token") "$session" "$CLAUDE_GUARD_LOG" || leak=$? + if [ "$leak" -ne 1 ]; then rm -rf "$session" "$CLAUDE_GUARD_LOG" - echo "::error::the $layout session's files contained the token, so they were deleted before the upload" + echo "::error::the $layout session's files contain the token, or checking for it failed (grep exited $leak), so they were deleted before the upload" session_status=1 else + mv "$session" "$CLAUDE_GUARD_LOG" "$out/" + session=$out/$layout + CLAUDE_GUARD_LOG=$out/$layout-guard.log tail -n 20 "$session/stderr.log" if [ -f "$session/report.md" ]; then cat "$session/report.md" @@ -133,6 +143,14 @@ for layout in $layouts; do fi echo "guard hook: $(grep -c '^allow' "$CLAUDE_GUARD_LOG" || true) allowed, $(grep -c '^refuse' "$CLAUDE_GUARD_LOG" || true) refused" grep '^refuse' "$CLAUDE_GUARD_LOG" || true + result=$(jq -r -R 'fromjson? | select(.type == "result") | .result // empty' "$session/transcript.jsonl" | tail -n 20 || true) + echo "final message, last lines:" + echo "$result" + last=$(grep -v '^[[:space:]]*$' <<< "$result" | tail -n 1 || true) + if ! grep -qxE '[*`]*CLAUDE-TEST: PASS[*`.]*[[:space:]]*' <<< "$last"; then + echo "::error::the $layout session's final message does not end with CLAUDE-TEST: PASS" + session_status=1 + fi fi echo "::endgroup::" if [ "$session_status" -ne 0 ]; then diff --git a/scripts/ci/ios-sim/claude-usage.sh b/scripts/ci/ios-sim/claude-usage.sh index 79132707cb..ee63e32ef4 100755 --- a/scripts/ci/ios-sim/claude-usage.sh +++ b/scripts/ci/ios-sim/claude-usage.sh @@ -19,10 +19,22 @@ mkdir -p "$out" echo "|---|---|---|---|---|---|---|---|---|---|---|" } >> "$summary" -for transcript in "$out"/*/transcript.jsonl; do - [ -f "$transcript" ] || continue - session=$(basename "$(dirname "$transcript")") +# claude-test.sh lists each session it starts in /sessions; a session's files reach only once +# they are checked for the token +while read -r session; do sessions=$((sessions + 1)) + transcript=$out/$session/transcript.jsonl + if [ ! -f "$transcript" ]; then + if [ -d "$out.sessions/$session" ]; then + reason="cut short before its files were checked for the token, so they are not uploaded" + else + reason="its files held the token or could not be checked, so they were deleted" + fi + echo "| $session | $reason: usage unknown | | | | | | | | | |" >> "$summary" + mkdir -p "$out/$session" + jq -n --arg s "$session" '{session: $s, result: "missing"}' > "$out/$session/usage.json" + continue + fi result=$(jq -c -R 'fromjson? | select(.type == "result")' "$transcript" | tail -n 1) if [ -z "$result" ]; then echo "| $session | no result event: the session timed out or crashed | | | | | | | | | |" >> "$summary" @@ -31,7 +43,7 @@ for transcript in "$out"/*/transcript.jsonl; do fi jq --arg s "$session" '{session: $s, subtype, is_error, num_turns, duration_ms, usage: (.usage | {input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens}), modelUsage, total_cost_usd, permission_denials: (.permission_denials // [] | length)}' <<< "$result" > "$out/$session/usage.json" jq -r --arg s "$session" '"| \($s) | \(.subtype)\(if .is_error then " (error)" else "" end) | \(.num_turns) | \(.duration_ms / 60000 | floor) min | \(.usage.input_tokens) | \(.usage.output_tokens) | \(.usage.cache_read_input_tokens) | \(.usage.cache_creation_input_tokens) | \([.modelUsage // {} | to_entries[] | "\(.key) $\(.value.costUSD // 0 | . * 100 | round / 100)"] | join(", ")) | $\(.total_cost_usd // 0 | . * 100 | round / 100) | \(.permission_denials // [] | length) |"' <<< "$result" >> "$summary" -done +done < <(cat "$out/sessions" 2> /dev/null) if [ "$sessions" -eq 0 ]; then echo "No Claude session ran." >> "$summary" diff --git a/scripts/ci/ios-sim/earlier-run.sh b/scripts/ci/ios-sim/earlier-run.sh index 752bea6292..7081bd08b1 100755 --- a/scripts/ci/ios-sim/earlier-run.sh +++ b/scripts/ci/ios-sim/earlier-run.sh @@ -8,10 +8,10 @@ set -euo pipefail name=$1 -run_id=$(gh api "repos/$GITHUB_REPOSITORY/actions/artifacts?name=$name&per_page=100" --jq " +run_id=$(gh api "repos/$GITHUB_REPOSITORY/actions/artifacts?name=$name&per_page=100" | jq -r --arg branch "$GITHUB_REF_NAME" ' [.artifacts[] - | select((.expired | not) and .workflow_run.head_branch == \"$GITHUB_REF_NAME\" and .workflow_run.head_repository_id == .workflow_run.repository_id)] - | sort_by(.created_at) | last | .workflow_run.id // empty") + | select((.expired | not) and .workflow_run.head_branch == $branch and .workflow_run.head_repository_id == .workflow_run.repository_id)] + | sort_by(.created_at) | last | .workflow_run.id // empty') if [ -n "$run_id" ]; then echo "$name: from run $run_id" echo "run-id=$run_id" >> "$GITHUB_OUTPUT" From 7ac7d000252cc973d2c7fb2e9b844a750d07491f Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:25:26 +0000 Subject: [PATCH 09/15] ci: start the app tests from run tags only on f/ branches (#7768) --- .github/workflows/desktop-linux.yml | 3 ++- .github/workflows/ios-sim.yml | 3 ++- scripts/ci/README.md | 4 ++-- scripts/ci/desktop-linux/README.md | 2 +- scripts/ci/ios-sim/README.md | 2 +- 5 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/desktop-linux.yml b/.github/workflows/desktop-linux.yml index 90a50f132c..d1186018aa 100644 --- a/.github/workflows/desktop-linux.yml +++ b/.github/workflows/desktop-linux.yml @@ -4,7 +4,8 @@ name: desktop-linux on: push: branches: - - "**" + # only f/ branches, so other branches' pull requests don't list skipped runs; elsewhere, run it by hand + - "f/*" # a squash merge's message lists the merged commits' subjects, with their tags - "!master" - "!stable" diff --git a/.github/workflows/ios-sim.yml b/.github/workflows/ios-sim.yml index 210a939fbc..a275bb82d6 100644 --- a/.github/workflows/ios-sim.yml +++ b/.github/workflows/ios-sim.yml @@ -3,7 +3,8 @@ name: ios-sim on: push: branches: - - '**' + # only f/ branches, so other branches' pull requests don't list skipped runs; elsewhere, run it by hand + - 'f/*' # a squash merge's message lists the merged commits' subjects, with their tags - '!master' - '!stable' diff --git a/scripts/ci/README.md b/scripts/ci/README.md index edc3569c38..2f5660dfc1 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -4,7 +4,7 @@ Two workflows build the apps from a branch's own code and test them, only when a ## Run tags -Put a tag in the message of the commit you push. Tags combine: each workflow runs everything its tags ask for. +Put a tag in the message of the commit you push to a branch named `f/…`. Tags combine: each workflow runs everything its tags ask for. | Tag | iOS | Desktop | |---|---|---| @@ -17,7 +17,7 @@ Put a tag in the message of the commit you push. Tags combine: each workflow run | `[ci-backend]`, with one of the above | also the test backend | also the test backend | | `[ci-release]`, with one of the above | — | also the latest release's onboarding, to tell a branch's failure from the runner's | -Only the pushed head commit counts, and a tag counts anywhere in its message, body included: never write one in a message unless that push should run it. A push that asks for nothing starts no job and doesn't cancel a running test; a newer requested run on the same branch cancels the older one. Pushes to master and stable never start a run, because a squash merge's message lists the merged commits' subjects with their tags; run it by hand there. +Only the pushed head commit counts, and a tag counts anywhere in its message, body included: never write one in a message unless that push should run it. A push that asks for nothing starts no job and doesn't cancel a running test; a newer requested run on the same branch cancels the older one. Tags start runs only on `f/…` branches, so pull requests from other branches don't list skipped runs; on any other branch, master and stable included, run a workflow by hand. ## Running a workflow by hand diff --git a/scripts/ci/desktop-linux/README.md b/scripts/ci/desktop-linux/README.md index f84c42b8c7..be0586558a 100644 --- a/scripts/ci/desktop-linux/README.md +++ b/scripts/ci/desktop-linux/README.md @@ -4,7 +4,7 @@ ## Running it -The workflow runs only when asked, on any branch, by a run tag in the pushed commit's message or by hand from the Actions tab: [App tests on GitHub's runners](../README.md) lists the tags and inputs. +The workflow runs only when asked: by a run tag in a commit pushed to an `f/…` branch, or by hand from the Actions tab on any branch. [App tests on GitHub's runners](../README.md) lists the tags and inputs. Every run builds the core library, and the backend when it's asked for, from the branch's own Haskell code. It reuses an earlier build only when the sources are unchanged, as an artifact of the same branch named after a hash of its sources (30 days): for the core `src/**`, `cabal.project`, `simplex-chat.cabal`, `PRIVACY.md` and the build scripts, for the backend `src/**`, the CLI's and the directory's sources, `cabal.project`, `simplex-chat.cabal` and its build script. So a branch that changes core and app code together tests both. The workflow saves no Actions caches, which this repository evicts within hours and shares with `build.yml`. diff --git a/scripts/ci/ios-sim/README.md b/scripts/ci/ios-sim/README.md index d84fd7d9e0..2e98b029a3 100644 --- a/scripts/ci/ios-sim/README.md +++ b/scripts/ci/ios-sim/README.md @@ -4,7 +4,7 @@ ## Running it -The workflow runs only when asked, on any branch, by a run tag in the pushed commit's message or by hand from the Actions tab: [App tests on GitHub's runners](../README.md) lists the tags and inputs. +The workflow runs only when asked: by a run tag in a commit pushed to an `f/…` branch, or by hand from the Actions tab on any branch. [App tests on GitHub's runners](../README.md) lists the tags and inputs. Results: the job logs, the Claude job's summary (Claude's usage) and the artifacts `ios-sim-smoke-test` (a screenshot, a 1000-px view and an accessibility tree per step, the app log), `ios-sim-backend-logs` and `ios-sim-claude-test` (the Claude job's smoke test in `smoke-test/`, and per session: `prompt.md`, `report.md`, `shots/`, `transcript.jsonl`, `usage.json`). Read them with `gh run view --log` and `gh run download -n `. From 1cbbe825176931bb4bd4b196969dd2f07f63d913 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 19 Oct 2026 11:59:43 +0100 Subject: [PATCH 10/15] ci: fix mac library build to sign and notarize library (#7765) * ci: fix mac library build to sign and notarize library * script * sign and notarize to test * remove test build --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- .github/workflows/build.yml | 5 +++++ docs/contributing/RELEASES.md | 3 ++- scripts/ci/sign-libs-mac.sh | 31 +++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100755 scripts/ci/sign-libs-mac.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 074a6feb48..68737fdb3f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1119,11 +1119,16 @@ jobs: id: mac_lib_build if: ${{ !cancelled() && needs.maybe-release.outputs.libs_tag && steps.mac_cli_build.outcome == 'success' }} shell: bash + env: + APPLE_SIMPLEX_SIGNING_KEYCHAIN: ${{ secrets.APPLE_SIMPLEX_SIGNING_KEYCHAIN }} + APPLE_SIMPLEX_NOTARIZATION_APPLE_ID: ${{ secrets.APPLE_SIMPLEX_NOTARIZATION_APPLE_ID }} + APPLE_SIMPLEX_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_SIMPLEX_NOTARIZATION_PASSWORD }} run: | SKIP_VLC=1 scripts/desktop/build-lib-mac.sh name=simplex-chat-libs-macos-${{ matrix.arch }} mkdir -p release-libs/$name/libs find apps/multiplatform/common/src/commonMain/cpp/desktop/libs/mac-${{ matrix.arch }} -maxdepth 1 -type f -exec cp {} release-libs/$name/libs/ \; + scripts/ci/sign-libs-mac.sh release-libs/$name/libs (cd release-libs/$name && zip -r ../$name.zip libs) - name: Upload library diff --git a/docs/contributing/RELEASES.md b/docs/contributing/RELEASES.md index ea6123e4c8..e0b80fda5b 100644 --- a/docs/contributing/RELEASES.md +++ b/docs/contributing/RELEASES.md @@ -54,7 +54,7 @@ pypi.. commit of the publication 3. Run of `build.yml`: - `maybe-release`: version claimed for the commit in `releases.toml`, draft `core-` created - - build jobs: 6 library zips uploaded + - build jobs: 6 library zips uploaded; macOS libraries signed with the Developer ID and notarized - `releases`: release published - `publish`: `@simplex-chat/types` published, when its version is unpublished - `record`: types version recorded @@ -141,6 +141,7 @@ Versions unpublished after a failure: published by the next push to the branch. - simplex-chat-libs: release immutability enabled - secret `NODEJS_REPO_TOKEN`: contents of simplex-chat-libs, read and write +- secrets `APPLE_SIMPLEX_SIGNING_KEYCHAIN`, `APPLE_SIMPLEX_NOTARIZATION_APPLE_ID` and `APPLE_SIMPLEX_NOTARIZATION_PASSWORD`: signing and notarization of the macOS libraries, shared with the desktop app build - trusted publisher of `simplex-chat` and `@simplex-chat/types` on npm and of `simplex-chat` on PyPI: workflow `build.yml` - secrets `NPM_TOKEN` and `PYPI_API_TOKEN`: used instead of trusted publishing when set - variables `NPM_SCOPE`, `PYPI_PROJECT`, `PYPI_URL` and `PYPI_UPLOAD_URL`: publication under other names, for test repositories diff --git a/scripts/ci/sign-libs-mac.sh b/scripts/ci/sign-libs-mac.sh new file mode 100755 index 0000000000..e455404503 --- /dev/null +++ b/scripts/ci/sign-libs-mac.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +set -euo pipefail + +libs=$1 +team=5NN7GUYB6T +keychain=/tmp/simplex.keychain +temp=$(mktemp -d) +trap 'rm -rf "$keychain" "$temp"' EXIT + +notarytool() { + xcrun notarytool "$@" --apple-id "$APPLE_SIMPLEX_NOTARIZATION_APPLE_ID" --password "$APPLE_SIMPLEX_NOTARIZATION_PASSWORD" --team-id "$team" +} + +echo "$APPLE_SIMPLEX_SIGNING_KEYCHAIN" | base64 --decode -o "$keychain" +security unlock-keychain -p "" "$keychain" +security list-keychains -s $(security list-keychains | xargs) "$keychain" + +for lib in "$libs"/*.dylib; do + codesign --remove-signature "$lib" + codesign --timestamp --options runtime --force --sign "Developer ID Application: SimpleX Chat Ltd ($team)" --keychain "$keychain" "$lib" +done + +ditto -c -k --keepParent "$libs" "$temp/libs.zip" +notarytool submit "$temp/libs.zip" --wait --timeout 1h --output-format json > "$temp/submission.json" || true +cat "$temp/submission.json" +read -r id status < <(python3 -c 'import json, sys; r = json.load(open(sys.argv[1])); print(r["id"], r["status"])' "$temp/submission.json") +notarytool log "$id" +if [ "$status" != Accepted ]; then + exit 1 +fi From 634fa5ecff1b3a101278960410e56b3b00aabd51 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 19 Oct 2026 12:14:19 +0100 Subject: [PATCH 11/15] core: autoincrement database IDs (#7766) * core: autoincrement database IDs * migration * update simplexmq --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 1 + src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../Migrations/M20261018_autoincrement_ids.hs | 110 ++++++++++++++++++ .../SQLite/Migrations/agent_query_plans.txt | 13 +++ .../Store/SQLite/Migrations/chat_schema.sql | 18 +-- tests/ChatClient.hs | 6 +- tests/ChatTests/Groups.hs | 32 +---- tests/ChatTests/Profiles.hs | 27 ----- tests/SchemaDump.hs | 3 +- 11 files changed, 143 insertions(+), 75 deletions(-) create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20261018_autoincrement_ids.hs diff --git a/cabal.project b/cabal.project index fea595d685..d8868c31c1 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: cba13823567d7cd564d8a1f354046174fba375d8 + tag: aabf4cdab98f9fb4c92dc6d9ec661a5f08f0e80f source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 49ae96bb8f..28b7b78368 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."cba13823567d7cd564d8a1f354046174fba375d8" = "0nhd6zsmqs55b7shh945b0v9pn0qqkpx9djq9dldair82hsmw1if"; + "https://github.com/simplex-chat/simplexmq.git"."aabf4cdab98f9fb4c92dc6d9ec661a5f08f0e80f" = "07w7rlc29gyd5p5yblgscgza1cv7sz01jygvxd6zp59xsygcd6zq"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."2330df3dc8c4b660674d3c02d76bb86f66c2abee" = "11pfj1vqsjdl26nplkr2l6z1xj7cn153qijj5gknmlzq3krn7x1i"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 747e32db2e..c61978990d 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -353,6 +353,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20261010_invite_member_via_group Simplex.Chat.Store.SQLite.Migrations.M20261012_delivery_job_notification Simplex.Chat.Store.SQLite.Migrations.M20261016_badge_store_receipts + Simplex.Chat.Store.SQLite.Migrations.M20261018_autoincrement_ids other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 535e5d3864..dd2238adf5 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -180,6 +180,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260925_badge_bindings import Simplex.Chat.Store.SQLite.Migrations.M20261010_invite_member_via_group import Simplex.Chat.Store.SQLite.Migrations.M20261012_delivery_job_notification import Simplex.Chat.Store.SQLite.Migrations.M20261016_badge_store_receipts +import Simplex.Chat.Store.SQLite.Migrations.M20261018_autoincrement_ids import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -359,7 +360,8 @@ schemaMigrations = ("20260925_badge_bindings", m20260925_badge_bindings, Just down_m20260925_badge_bindings), ("20261010_invite_member_via_group", m20261010_invite_member_via_group, Just down_m20261010_invite_member_via_group), ("20261012_delivery_job_notification", m20261012_delivery_job_notification, Just down_m20261012_delivery_job_notification), - ("20261016_badge_store_receipts", m20261016_badge_store_receipts, Just down_m20261016_badge_store_receipts) + ("20261016_badge_store_receipts", m20261016_badge_store_receipts, Just down_m20261016_badge_store_receipts), + ("20261018_autoincrement_ids", m20261018_autoincrement_ids, Just down_m20261018_autoincrement_ids) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20261018_autoincrement_ids.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20261018_autoincrement_ids.hs new file mode 100644 index 0000000000..a390e1326f --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20261018_autoincrement_ids.hs @@ -0,0 +1,110 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20261018_autoincrement_ids where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20261018_autoincrement_ids :: Query +m20261018_autoincrement_ids = + [sql| +INSERT INTO sqlite_sequence (name, seq) +SELECT 'users', MAX(ROWID) FROM users; + +INSERT INTO sqlite_sequence (name, seq) +SELECT 'contacts', MAX(ROWID) FROM contacts; + +INSERT INTO sqlite_sequence (name, seq) +SELECT 'groups', MAX(ROWID) FROM groups; + +INSERT INTO sqlite_sequence (name, seq) +SELECT 'group_members', MAX(ROWID) FROM group_members; + +INSERT INTO sqlite_sequence (name, seq) +SELECT 'files', MAX(ROWID) FROM files; + +INSERT INTO sqlite_sequence (name, seq) +SELECT 'connections', MAX(ROWID) FROM connections; + +INSERT INTO sqlite_sequence (name, seq) +SELECT 'contact_requests', MAX(ROWID) FROM contact_requests; + +INSERT INTO sqlite_sequence (name, seq) +SELECT 'rcv_roster_transfers', MAX(ROWID) FROM rcv_roster_transfers; + +PRAGMA writable_schema=1; + +UPDATE sqlite_master SET sql = replace(sql, 'user_id INTEGER PRIMARY KEY,', 'user_id INTEGER PRIMARY KEY AUTOINCREMENT,') +WHERE name = 'users' AND type = 'table'; + +UPDATE sqlite_master SET sql = replace(sql, 'contact_id INTEGER PRIMARY KEY,', 'contact_id INTEGER PRIMARY KEY AUTOINCREMENT,') +WHERE name = 'contacts' AND type = 'table'; + +UPDATE sqlite_master SET sql = replace(sql, 'group_id INTEGER PRIMARY KEY,', 'group_id INTEGER PRIMARY KEY AUTOINCREMENT,') +WHERE name = 'groups' AND type = 'table'; + +UPDATE sqlite_master SET sql = replace(sql, 'group_member_id INTEGER PRIMARY KEY,', 'group_member_id INTEGER PRIMARY KEY AUTOINCREMENT,') +WHERE name = 'group_members' AND type = 'table'; + +UPDATE sqlite_master SET sql = replace(sql, 'file_id INTEGER PRIMARY KEY,', 'file_id INTEGER PRIMARY KEY AUTOINCREMENT,') +WHERE name = 'files' AND type = 'table'; + +UPDATE sqlite_master SET sql = replace(sql, 'connection_id INTEGER PRIMARY KEY,', 'connection_id INTEGER PRIMARY KEY AUTOINCREMENT,') +WHERE name = 'connections' AND type = 'table'; + +UPDATE sqlite_master SET sql = replace(sql, 'contact_request_id INTEGER PRIMARY KEY,', 'contact_request_id INTEGER PRIMARY KEY AUTOINCREMENT,') +WHERE name = 'contact_requests' AND type = 'table'; + +UPDATE sqlite_master SET sql = replace(sql, 'roster_transfer_id INTEGER PRIMARY KEY,', 'roster_transfer_id INTEGER PRIMARY KEY AUTOINCREMENT,') +WHERE name = 'rcv_roster_transfers' AND type = 'table'; + +PRAGMA writable_schema=RESET; +|] + +down_m20261018_autoincrement_ids :: Query +down_m20261018_autoincrement_ids = + [sql| +DELETE FROM sqlite_sequence WHERE name = 'users'; + +DELETE FROM sqlite_sequence WHERE name = 'contacts'; + +DELETE FROM sqlite_sequence WHERE name = 'groups'; + +DELETE FROM sqlite_sequence WHERE name = 'group_members'; + +DELETE FROM sqlite_sequence WHERE name = 'files'; + +DELETE FROM sqlite_sequence WHERE name = 'connections'; + +DELETE FROM sqlite_sequence WHERE name = 'contact_requests'; + +DELETE FROM sqlite_sequence WHERE name = 'rcv_roster_transfers'; + +PRAGMA writable_schema=1; + +UPDATE sqlite_master SET sql = replace(sql, 'user_id INTEGER PRIMARY KEY AUTOINCREMENT,', 'user_id INTEGER PRIMARY KEY,') +WHERE name = 'users' AND type = 'table'; + +UPDATE sqlite_master SET sql = replace(sql, 'contact_id INTEGER PRIMARY KEY AUTOINCREMENT,', 'contact_id INTEGER PRIMARY KEY,') +WHERE name = 'contacts' AND type = 'table'; + +UPDATE sqlite_master SET sql = replace(sql, 'group_id INTEGER PRIMARY KEY AUTOINCREMENT,', 'group_id INTEGER PRIMARY KEY,') +WHERE name = 'groups' AND type = 'table'; + +UPDATE sqlite_master SET sql = replace(sql, 'group_member_id INTEGER PRIMARY KEY AUTOINCREMENT,', 'group_member_id INTEGER PRIMARY KEY,') +WHERE name = 'group_members' AND type = 'table'; + +UPDATE sqlite_master SET sql = replace(sql, 'file_id INTEGER PRIMARY KEY AUTOINCREMENT,', 'file_id INTEGER PRIMARY KEY,') +WHERE name = 'files' AND type = 'table'; + +UPDATE sqlite_master SET sql = replace(sql, 'connection_id INTEGER PRIMARY KEY AUTOINCREMENT,', 'connection_id INTEGER PRIMARY KEY,') +WHERE name = 'connections' AND type = 'table'; + +UPDATE sqlite_master SET sql = replace(sql, 'contact_request_id INTEGER PRIMARY KEY AUTOINCREMENT,', 'contact_request_id INTEGER PRIMARY KEY,') +WHERE name = 'contact_requests' AND type = 'table'; + +UPDATE sqlite_master SET sql = replace(sql, 'roster_transfer_id INTEGER PRIMARY KEY AUTOINCREMENT,', 'roster_transfer_id INTEGER PRIMARY KEY,') +WHERE name = 'rcv_roster_transfers' AND type = 'table'; + +PRAGMA writable_schema=RESET; +|] 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 d7debec504..a02d379f10 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -614,6 +614,15 @@ Query: Plan: SEARCH address_ratchet_keys USING INDEX idx_address_ratchet_keys (conn_id=? AND ratchet_key_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 address_ratchet_keys WHERE conn_id = ? @@ -1526,6 +1535,10 @@ Query: UPDATE ratchets SET ratchet_state = ? WHERE conn_id = ? Plan: SEARCH ratchets USING PRIMARY KEY (conn_id=?) +Query: UPDATE rcv_file_chunk_replicas SET delay = ?, retries = retries + 1, updated_at = ? WHERE rcv_file_chunk_replica_id = ? +Plan: +SEARCH rcv_file_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE rcv_file_chunk_replicas SET received = 1, updated_at = ? WHERE rcv_file_chunk_replica_id = ? Plan: SEARCH rcv_file_chunk_replicas 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 d3b3a095d4..91183ede3a 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -36,7 +36,7 @@ CREATE TABLE contact_profiles( preferences_json TEXT ) STRICT; CREATE TABLE users( - user_id INTEGER PRIMARY KEY, + user_id INTEGER PRIMARY KEY AUTOINCREMENT, contact_id INTEGER NOT NULL UNIQUE REFERENCES contacts ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED, local_display_name TEXT NOT NULL UNIQUE, @@ -63,6 +63,7 @@ CREATE TABLE users( ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED ) STRICT; +CREATE TABLE sqlite_sequence(name,seq); CREATE TABLE display_names( user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, local_display_name TEXT NOT NULL, @@ -74,7 +75,7 @@ CREATE TABLE display_names( UNIQUE(user_id, ldn_base, ldn_suffix) ON CONFLICT FAIL ) WITHOUT ROWID, STRICT; CREATE TABLE contacts( - contact_id INTEGER PRIMARY KEY, + contact_id INTEGER PRIMARY KEY AUTOINCREMENT, contact_profile_id INTEGER REFERENCES contact_profiles ON DELETE SET NULL, user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, local_display_name TEXT NOT NULL, @@ -151,7 +152,7 @@ CREATE TABLE group_profiles( preferences_json TEXT ) STRICT; CREATE TABLE groups( - group_id INTEGER PRIMARY KEY, -- local group ID + group_id INTEGER PRIMARY KEY AUTOINCREMENT, -- local group ID user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, local_display_name TEXT NOT NULL, -- local group name without spaces group_profile_id INTEGER REFERENCES group_profiles ON DELETE SET NULL, -- shared group profile @@ -222,7 +223,7 @@ CREATE TABLE groups( ) STRICT; CREATE TABLE group_members( -- group members, excluding the local user - group_member_id INTEGER PRIMARY KEY, + group_member_id INTEGER PRIMARY KEY AUTOINCREMENT, group_id INTEGER NOT NULL REFERENCES groups ON DELETE CASCADE, member_id BLOB NOT NULL, -- shared member ID, unique per group member_role TEXT NOT NULL, -- owner, admin, member @@ -280,7 +281,7 @@ CREATE TABLE group_member_intros( UNIQUE(re_group_member_id, to_group_member_id) ) STRICT; CREATE TABLE files( - file_id INTEGER PRIMARY KEY, + file_id INTEGER PRIMARY KEY AUTOINCREMENT, contact_id INTEGER REFERENCES contacts ON DELETE CASCADE, group_id INTEGER REFERENCES groups ON DELETE CASCADE, file_name TEXT NOT NULL, @@ -352,7 +353,7 @@ CREATE TABLE rcv_file_chunks( ) WITHOUT ROWID, STRICT; CREATE TABLE connections( -- all SMP agent connections - connection_id INTEGER PRIMARY KEY, + connection_id INTEGER PRIMARY KEY AUTOINCREMENT, agent_conn_id BLOB NOT NULL UNIQUE, conn_level INTEGER NOT NULL DEFAULT 0, via_contact INTEGER REFERENCES contacts(contact_id) ON DELETE SET NULL, @@ -419,7 +420,7 @@ CREATE TABLE user_contact_links( UNIQUE(user_id, local_display_name) ) STRICT; CREATE TABLE contact_requests( - contact_request_id INTEGER PRIMARY KEY, + contact_request_id INTEGER PRIMARY KEY AUTOINCREMENT, user_contact_link_id INTEGER REFERENCES user_contact_links ON UPDATE CASCADE ON DELETE SET NULL, agent_invitation_id BLOB NOT NULL, @@ -530,7 +531,6 @@ CREATE TABLE chat_items( fwd_from_member_id BLOB, fwd_from_shared_msg_id BLOB ) STRICT; -CREATE TABLE sqlite_sequence(name,seq); CREATE TABLE chat_item_messages( chat_item_id INTEGER NOT NULL REFERENCES chat_items ON DELETE CASCADE, message_id INTEGER NOT NULL UNIQUE REFERENCES messages ON DELETE CASCADE, @@ -848,7 +848,7 @@ CREATE TABLE group_relays( base_web_url TEXT ) STRICT; CREATE TABLE rcv_roster_transfers( - roster_transfer_id INTEGER PRIMARY KEY, + roster_transfer_id INTEGER PRIMARY KEY AUTOINCREMENT, group_id INTEGER NOT NULL REFERENCES groups ON DELETE CASCADE, from_member_id INTEGER NOT NULL REFERENCES group_members ON DELETE CASCADE, roster_version INTEGER NOT NULL, diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 0593dc3554..086abd8e48 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -60,7 +60,7 @@ import Simplex.Messaging.Client (ProtocolClientConfig (..)) import Simplex.Messaging.Client.Agent (defaultSMPClientAgentConfig) import Simplex.Messaging.Protocol (ProtoServerWithAuth (..), ProtocolServer (..), ProtocolType (..)) import Simplex.Messaging.Server (runSMPServerBlocking) -import Simplex.Messaging.Server.Env.STM (ServerConfig (..), ServerStoreCfg (..), StartOptions (..), StorePaths (..), defaultMessageExpiration, defaultIdleQueueInterval, defaultNtfExpiration, defaultInactiveClientExpiration) +import Simplex.Messaging.Server.Env.STM (ServerConfig (..), ServerStoreCfg (..), StartOptions (..), StorePaths (..), defaultMaxClientCmdWait, defaultMessageExpiration, defaultNtfExpiration, defaultInactiveClientExpiration) import NameResolver (NameRegistry, resolverNamesConfig, withNameResolver) import Simplex.Messaging.Server.MsgStore.STM (STMMsgStore) import Simplex.Messaging.Transport @@ -638,8 +638,6 @@ smpServerCfg p = { transports = [(smpTestPort p, transport @TLS, False)], tbqSize = 4, msgQueueQuota = 16, - maxJournalMsgCount = 24, - maxJournalStateLines = 4, queueIdBytes = 24, msgIdBytes = 24, serverStoreCfg = SSCMemory Nothing, -- $ Just StorePaths {storeLogFile = "tmp/smp-server-store.log", storeMsgsFile = Just "tmp/smp-server-messages.log"}, @@ -653,7 +651,6 @@ smpServerCfg p = messageExpiration = Just defaultMessageExpiration, expireMessagesOnStart = False, expireMessagesOnSend = False, - idleQueueInterval = defaultIdleQueueInterval, notificationExpiration = defaultNtfExpiration, inactiveClientExpiration = Just defaultInactiveClientExpiration, smpCredentials = @@ -680,6 +677,7 @@ smpServerCfg p = allowSMPProxy = True, serverClientConcurrency = 16, serverResolverConcurrency = 1000, + maxClientCmdWait = defaultMaxClientCmdWait, namesConfig = Nothing, information = Nothing, startOptions = StartOptions {maintenance = False, compactLog = False, logLevel = LogError, skipWarnings = False, confirmMigrations = MCYesUp} diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index dcb3321702..63f954454a 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -12644,13 +12644,7 @@ testChannelMessageFile ps = withNewTestChat ps "dan" danProfile $ \dan -> withNewTestChat ps "eve" eveProfile $ \eve -> withXFTPServer ps $ do createChannel1Relay "team" alice bob cath dan eve - -- the roster arrives as a file before this one; Postgres assigns it a new id and does not - -- reuse it on delete (SQLite does), so the received message file is id 2 here, 1 on SQLite. -#if defined(dbPostgres) let rcvFileId = 2 :: Int -#else - let rcvFileId = 1 :: Int -#endif -- owner sends file as channel message alice #> "/f #team ./tests/fixtures/test.jpg" alice <## "use /fc 1 to cancel sending" @@ -12696,11 +12690,7 @@ testChannelMessageFileCancel ps = withNewTestChat ps "dan" danProfile $ \dan -> withNewTestChat ps "eve" eveProfile $ \eve -> withXFTPServer ps $ do createChannel1Relay "team" alice bob cath dan eve -#if defined(dbPostgres) let rcvFileId = 2 :: Int -#else - let rcvFileId = 1 :: Int -#endif -- owner sends file as channel message alice #> "/f #team ./tests/fixtures/test.jpg" alice <## "use /fc 1 to cancel sending" @@ -12876,11 +12866,7 @@ testChannelOwnerFileTransferAsMember ps = withNewTestChat ps "dan" danProfile $ \dan -> withNewTestChat ps "eve" eveProfile $ \eve -> withXFTPServer ps $ do createChannel1Relay "team" alice bob cath dan eve -#if defined(dbPostgres) let rcvFileId = 2 :: Int -#else - let rcvFileId = 1 :: Int -#endif -- owner sends file as member (not as channel) alice ##> "/_send #1(as_group=off) json [{\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"type\": \"file\", \"text\": \"\"}}]" alice <# "/f #team ./tests/fixtures/test.jpg" @@ -13028,11 +13014,7 @@ testChannelFileBadgeProof ps = do withNewTestChatCfg ps cfg "eve" eveProfile $ \eve -> withXFTPServer ps $ do createChannel1Relay "team" alice bob cath dan eve addTestBadge alice =<< issueTestBadge sk futureDate -#if defined(dbPostgres) let rcvFileId = 2 :: Int -#else - let rcvFileId = 1 :: Int -#endif alice ##> "/_send #1(as_group=off) json [{\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"type\": \"file\", \"text\": \"\"}}]" alice <# "/f #team ./tests/fixtures/test.jpg" alice <## "use /fc 1 to cancel sending" @@ -13095,11 +13077,7 @@ testChannelOwnerFileCancelAsMember ps = withNewTestChat ps "dan" danProfile $ \dan -> withNewTestChat ps "eve" eveProfile $ \eve -> withXFTPServer ps $ do createChannel1Relay "team" alice bob cath dan eve -#if defined(dbPostgres) let rcvFileId = 2 :: Int -#else - let rcvFileId = 1 :: Int -#endif -- owner sends file as member (not as channel) alice ##> "/_send #1(as_group=off) json [{\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"type\": \"file\", \"text\": \"\"}}]" alice <# "/f #team ./tests/fixtures/test.jpg" @@ -13475,17 +13453,9 @@ testChannelSignedFile ps = xftpCLI ["rand", testfile, "1mb"] `shouldReturn` ["File created: " <> testfile] createChannel1Relay "team" alice bob cath dan eve promoteChannelMember "team" alice bob cath [dan, eve] - -- roster serves arrive as files that Postgres deletes without reusing the id (SQLite reuses - -- it), so ids run higher here. cath's send and the subscribers' (dan, eve) receive both - -- follow only their join roster (id 2). The relay (bob) also received the roster re-served - -- on cath's promotion, so its file is id 3. The owner (alice) has no roster file, so id 1. -#if defined(dbPostgres) + -- roster files are deleted after the transfer, so ids run higher: cath, dan and eve use id 2, the relay (bob) id 3, the owner (alice) id 1. let fileId = 2 :: Int relayFileId = 3 :: Int -#else - let fileId = 1 :: Int - relayFileId = 1 :: Int -#endif -- cath's first (signed) message introduces her to dan/eve cath ##> "/_send #1 sign=on text hi" diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 91ec9a54da..db16226989 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -2379,11 +2379,7 @@ testPlanAddressContactViaAddress = bob @@@ [("@alice", "")] -- GUI api -#if defined(dbPostgres) bob ##> "/_connect contact 1 4" -#else - bob ##> "/_connect contact 1 2" -#endif connecting alice bob where connecting alice bob = do @@ -2441,11 +2437,7 @@ testPlanAddressContactViaShortAddress = bob @@@ [("@alice", "")] -- GUI api -#if defined(dbPostgres) bob ##> "/_connect contact 1 4" -#else - bob ##> "/_connect contact 1 2" -#endif connecting alice bob where connecting alice bob = do @@ -3077,14 +3069,8 @@ testChangePCCUser = testChat2 aliceProfile bobProfile $ alice ##> "/user alisa" showActiveUser alice "alisa" -- Change connection back to other user -#if defined(dbPostgres) alice ##> "/_set conn user :2 3" alice <## "connection 2 changed from user alisa to user alisa2, new link:" -#else - -- connection ID does not change in SQLite because table has no auto-increment - alice ##> "/_set conn user :1 3" - alice <## "connection 1 changed from user alisa to user alisa2, new link:" -#endif alice <## "" _shortInv <- getTermLine alice alice <## "" @@ -3126,14 +3112,8 @@ testChangePCCUserFromIncognito = testChat2 aliceProfile bobProfile $ alice ##> "/user alisa" showActiveUser alice "alisa" -- Change connection back to initial user -#if defined(dbPostgres) alice ##> "/_set conn user :2 1" alice <## "connection 2 changed from user alisa to user alice, new link:" -#else - -- connection ID does not change in SQLite because table has no auto-increment - alice ##> "/_set conn user :1 1" - alice <## "connection 1 changed from user alisa to user alice, new link:" -#endif alice <## "" _shortInv <- getTermLine alice alice <## "" @@ -3171,16 +3151,9 @@ testChangePCCUserAndThenIncognito = testChat2 aliceProfile bobProfile $ alice ##> "/user alisa" showActiveUser alice "alisa" -- Change connection to incognito and make sure it's attached to the newly created user profile -#if defined(dbPostgres) alice ##> "/_set incognito :2 on" _ <- getTermLine alice alice <## "connection 2 changed to incognito" -#else - -- connection ID does not change in SQLite because table has no auto-increment - alice ##> "/_set incognito :1 on" - _ <- getTermLine alice - alice <## "connection 1 changed to incognito" -#endif bob ##> ("/connect " <> inv) bob <## "confirmation sent!" alisaIncognito <- getTermLine alice diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index 0f2cddcc1c..fecebfc8b2 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -146,7 +146,8 @@ skipComparisonForDownMigrations = -- appends; CREATE INDEX appends). "20260529_delivery_job_senders", -- group_domain is removed - "20260603_simplex_name" + "20260603_simplex_name", + "20261018_autoincrement_ids" ] getSchema :: FilePath -> FilePath -> IO String From 2ea1ec0d50eb6ead8188348ca773ee7ad2c76b8f Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:17:16 +0400 Subject: [PATCH 12/15] ui: reconnect call (#7711) * Update Call.hs * Update Internal.hs * add reconnecting string * Update WebRTC.kt * desktop: show reconnecting state, keep call duration * android keep connection time correct wiith adding reconnecting * Update CallTypes.swift add reconnecting status on IOS * Update WebRTC.swift add reconnecting * add reconnecting and make sure call duration is correct * Update call.ts * core: regenerate call.js from call.ts * Update Call.hs revert call status change minimization * Update Internal.hs rever to minimize changes * Update CallTypes.swift minimize changes * Update WebRTC.kt minimize changes * Update CallView.desktop.kt desktop shows reconnecting without sending to core * Update CallView.android.kt show reconnecting without sending it to the core * Update ActiveCallView.swift show reconnecting to ui without sending to core * Update CallView.desktop.kt add reconnecting sound on reconnect * Update CallView.android.kt add reconnecting sound * Update ActiveCallView.swift play reconnecting sound when reconnecting * Update RecAndPlay.desktop.kt * rebuild * refactor * build * android, desktop: restart ICE to reconnect calls * ios: restart ICE to reconnect calls * webrtc: add call reconnection e2e tests * android, desktop: fix lost call commands --------- Co-authored-by: GuavaGoat <320862384+GuavaGoat@users.noreply.github.com> Co-authored-by: Evgeny Poberezkin Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- apps/ios/Shared/Model/SimpleXAPI.swift | 6 +- .../Shared/Views/Call/ActiveCallView.swift | 38 ++- apps/ios/Shared/Views/Call/WebRTC.swift | 20 +- apps/ios/Shared/Views/Call/WebRTCClient.swift | 163 +++++++-- apps/ios/SimpleXChat/CallTypes.swift | 4 +- apps/ios/product/flows/calling.md | 12 +- apps/ios/spec/api.md | 2 +- apps/ios/spec/services/calls.md | 16 +- .../common/views/call/CallView.android.kt | 39 ++- .../chat/simplex/common/model/SimpleXAPI.kt | 6 +- .../chat/simplex/common/views/call/WebRTC.kt | 8 +- .../moko-resources/base/strings.xml | 1 + .../commonMain/resources/assets/www/call.js | 117 ++++++- .../resources/assets/www/desktop/ui.js | 4 +- .../common/platform/RecAndPlay.desktop.kt | 4 +- .../common/views/call/CallView.desktop.kt | 43 ++- apps/multiplatform/product/flows/calling.md | 12 +- apps/multiplatform/product/views/call.md | 9 +- apps/multiplatform/spec/api.md | 2 +- apps/multiplatform/spec/services/calls.md | 5 +- packages/simplex-chat-webrtc/README.md | 17 + packages/simplex-chat-webrtc/package.json | 3 +- packages/simplex-chat-webrtc/src/call.ts | 105 +++++- packages/simplex-chat-webrtc/test/chat.js | 147 ++++++++ .../test/fullstack.test.js | 188 +++++++++++ packages/simplex-chat-webrtc/test/harness.js | 317 ++++++++++++++++++ .../test/reconnect.test.js | 95 ++++++ 27 files changed, 1247 insertions(+), 136 deletions(-) create mode 100644 packages/simplex-chat-webrtc/test/chat.js create mode 100644 packages/simplex-chat-webrtc/test/fullstack.test.js create mode 100644 packages/simplex-chat-webrtc/test/harness.js create mode 100644 packages/simplex-chat-webrtc/test/reconnect.test.js diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 60ecfea09c..fb1d78f650 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1850,8 +1850,8 @@ func apiSendCallAnswer(_ contact: Contact, _ rtcSession: String, _ rtcIceCandida try await sendCommandOkResp(.apiSendCallAnswer(contact: contact, answer: answer)) } -func apiSendCallExtraInfo(_ contact: Contact, _ rtcIceCandidates: String) async throws { - let extraInfo = WebRTCExtraInfo(rtcIceCandidates: rtcIceCandidates) +func apiSendCallExtraInfo(_ contact: Contact, _ rtcSession: String?, _ rtcIceCandidates: String) async throws { + let extraInfo = WebRTCExtraInfo(rtcSession: rtcSession, rtcIceCandidates: rtcIceCandidates) try await sendCommandOkResp(.apiSendCallExtraInfo(contact: contact, extraInfo: extraInfo)) } @@ -2987,7 +2987,7 @@ func processReceivedMsg(_ res: ChatEvent) async { } case let .callExtraInfo(_, contact, extraInfo): await withCall(contact) { _ in - await m.callCommand.processCommand(.ice(iceCandidates: extraInfo.rtcIceCandidates)) + await m.callCommand.processCommand(.ice(iceCandidates: extraInfo.rtcIceCandidates, session: extraInfo.rtcSession)) } case let .callEnded(_, contact): if let invitation = await MainActor.run(body: { m.callInvitations.removeValue(forKey: contact.id) }) { diff --git a/apps/ios/Shared/Views/Call/ActiveCallView.swift b/apps/ios/Shared/Views/Call/ActiveCallView.swift index 754bcb2715..a2e65dcf71 100644 --- a/apps/ios/Shared/Views/Call/ActiveCallView.swift +++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift @@ -161,10 +161,10 @@ struct ActiveCallView: View { CallSoundsPlayer.shared.stop() } } - case let .ice(iceCandidates): + case let .ice(iceCandidates, session): Task { do { - try await apiSendCallExtraInfo(call.contact, iceCandidates) + try await apiSendCallExtraInfo(call.contact, session, iceCandidates) } catch { logger.error("apiSendCallExtraInfo \(responseError(error))") } @@ -172,15 +172,24 @@ struct ActiveCallView: View { case let .connection(state): if let callStatus = WebRTCCallStatus.init(rawValue: state.connectionState), case .connected = callStatus { - call.direction == .outgoing - ? CallController.shared.reportOutgoingCall(call: call, connectedAt: nil) - : CallController.shared.reportIncomingCall(call: call, connectedAt: nil) + // connectedAt is kept on reconnection, so that call duration is not reset + if call.connectedAt == nil { + call.direction == .outgoing + ? CallController.shared.reportOutgoingCall(call: call, connectedAt: nil) + : CallController.shared.reportIncomingCall(call: call, connectedAt: nil) + call.connectedAt = .now + } call.callState = .connected - call.connectedAt = .now + CallSoundsPlayer.shared.stop() if !wasConnected { CallSoundsPlayer.shared.vibrate(long: false) wasConnected = true } + } else if state.connectionState == "reconnecting" { + if call.callState != .reconnecting { + CallSoundsPlayer.shared.startConnectingCallSound() + } + call.callState = .reconnecting } if state.connectionState == "closed" { closeCallView(client) @@ -190,17 +199,22 @@ struct ActiveCallView: View { m.activeCall = nil m.activeCallViewIsCollapsed = false } - Task { - do { - try await apiCallStatus(call.contact, state.connectionState) - } catch { - logger.error("apiCallStatus \(responseError(error))") + // "reconnecting" is not a core call status + if state.connectionState != "reconnecting" { + Task { + do { + try await apiCallStatus(call.contact, state.connectionState) + } catch { + logger.error("apiCallStatus \(responseError(error))") + } } } case let .connected(connectionInfo): call.callState = .connected call.connectionInfo = connectionInfo - call.connectedAt = .now + if call.connectedAt == nil { + call.connectedAt = .now + } if !wasConnected { CallSoundsPlayer.shared.vibrate(long: false) wasConnected = true diff --git a/apps/ios/Shared/Views/Call/WebRTC.swift b/apps/ios/Shared/Views/Call/WebRTC.swift index ef9135761c..6f7a0efa38 100644 --- a/apps/ios/Shared/Views/Call/WebRTC.swift +++ b/apps/ios/Shared/Views/Call/WebRTC.swift @@ -77,6 +77,7 @@ enum CallState { case answerReceived // incoming - webrtc answer received via API case negotiated // outgoing - webrtc offer processed and answer sent, incoming - webrtc answer processed case connected + case reconnecting case ended var text: LocalizedStringKey { @@ -89,6 +90,7 @@ enum CallState { case .answerReceived: return "received confirmation…" case .negotiated: return "connecting…" case .connected: return "connected" + case .reconnecting: return "reconnecting…" case .ended: return "ended" } } @@ -119,7 +121,7 @@ enum WCallCommand: Equatable, Encodable, Decodable { case start(media: CallMediaType, aesKey: String? = nil, iceServers: [RTCIceServer]? = nil, relay: Bool? = nil) case offer(offer: String, iceCandidates: String, media: CallMediaType, aesKey: String? = nil, iceServers: [RTCIceServer]? = nil, relay: Bool? = nil) case answer(answer: String, iceCandidates: String) - case ice(iceCandidates: String) + case ice(iceCandidates: String, session: String? = nil) case media(source: CallMediaSource, enable: Bool) case end @@ -131,6 +133,7 @@ enum WCallCommand: Equatable, Encodable, Decodable { case offer case answer case iceCandidates + case session case enable case iceServers case relay @@ -174,9 +177,10 @@ enum WCallCommand: Equatable, Encodable, Decodable { try container.encode("answer", forKey: .type) try container.encode(answer, forKey: .answer) try container.encode(iceCandidates, forKey: .iceCandidates) - case let .ice(iceCandidates): + case let .ice(iceCandidates, session): try container.encode("ice", forKey: .type) try container.encode(iceCandidates, forKey: .iceCandidates) + try container.encodeIfPresent(session, forKey: .session) case let .media(source, enable): try container.encode("media", forKey: .type) try container.encode(source, forKey: .media) @@ -213,7 +217,8 @@ enum WCallCommand: Equatable, Encodable, Decodable { self = .answer(answer: answer, iceCandidates: iceCandidates) case "ice": let iceCandidates = try container.decode(String.self, forKey: CodingKeys.iceCandidates) - self = .ice(iceCandidates: iceCandidates) + let session = try container.decodeIfPresent(String.self, forKey: CodingKeys.session) + self = .ice(iceCandidates: iceCandidates, session: session) case "media": let source = try container.decode(CallMediaSource.self, forKey: CodingKeys.source) let enable = try container.decode(Bool.self, forKey: CodingKeys.enable) @@ -231,7 +236,7 @@ enum WCallResponse: Equatable, Decodable { case capabilities(capabilities: CallCapabilities) case offer(offer: String, iceCandidates: String, capabilities: CallCapabilities) case answer(answer: String, iceCandidates: String) - case ice(iceCandidates: String) + case ice(iceCandidates: String, session: String? = nil) case connection(state: ConnectionState) case connected(connectionInfo: ConnectionInfo) case peerMedia(source: CallMediaSource, enabled: Bool) @@ -246,6 +251,7 @@ enum WCallResponse: Equatable, Decodable { case offer case answer case iceCandidates + case session case state case connectionInfo case message @@ -290,7 +296,8 @@ enum WCallResponse: Equatable, Decodable { self = .answer(answer: answer, iceCandidates: iceCandidates) case "ice": let iceCandidates = try container.decode(String.self, forKey: CodingKeys.iceCandidates) - self = .ice(iceCandidates: iceCandidates) + let session = try container.decodeIfPresent(String.self, forKey: CodingKeys.session) + self = .ice(iceCandidates: iceCandidates, session: session) case "connection": let state = try container.decode(ConnectionState.self, forKey: CodingKeys.state) self = .connection(state: state) @@ -333,9 +340,10 @@ extension WCallResponse: Encodable { try container.encode("answer", forKey: .type) try container.encode(answer, forKey: .answer) try container.encode(iceCandidates, forKey: .iceCandidates) - case let .ice(iceCandidates): + case let .ice(iceCandidates, session): try container.encode("ice", forKey: .type) try container.encode(iceCandidates, forKey: .iceCandidates) + try container.encodeIfPresent(session, forKey: .session) case let .connection(state): try container.encode("connection", forKey: .type) try container.encode(state, forKey: .state) diff --git a/apps/ios/Shared/Views/Call/WebRTCClient.swift b/apps/ios/Shared/Views/Call/WebRTCClient.swift index c7d53d3c69..15d36f7931 100644 --- a/apps/ios/Shared/Views/Call/WebRTCClient.swift +++ b/apps/ios/Shared/Views/Call/WebRTCClient.swift @@ -20,6 +20,8 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg }() private static let ivTagBytes: Int = 28 private static let enableEncryption: Bool = true + private static let reconnectTimeout: UInt64 = 30_000_000_000 + private static let iceRestartInterval: UInt64 = 10_000_000_000 private var chat_ctrl = getChatCtrl() struct Call { @@ -37,6 +39,9 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg var frameEncryptor: RTCFrameEncryptor? var frameDecryptor: RTCFrameDecryptor? var peerHasOldVersion: Bool + var wasConnected = false + var reconnection: Task? = nil + var iceRestarts: Task? = nil } struct NotConnectedCall { @@ -257,11 +262,15 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg resp = .error(message: "answer: remote description is not set") } } - case let .ice(iceCandidates): + case let .ice(iceCandidates, session): if let pc = pc, let remoteIceCandidates: [RTCIceCandidate] = decodeJSON(decompressFromBase64(input: iceCandidates)) { - addIceCandidates(pc, remoteIceCandidates) - resp = .ok + if let session { + resp = await receiveRestartSession(pc, session, remoteIceCandidates) + } else { + addIceCandidates(pc, remoteIceCandidates) + resp = .ok + } } else { resp = .error(message: "ice: call not started") } @@ -301,6 +310,101 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg } } + private func startReconnecting(_ connection: RTCPeerConnection) async { + activeCall?.reconnection = Task { + _ = try? await Task.sleep(nanoseconds: WebRTCClient.reconnectTimeout) + if Task.isCancelled { return } + await sendConnectionState(connection, "disconnected") + endCall() + } + // Only the party that sent the initial offer restarts ICE, so that restart offers never collide + if connection.localDescription?.type == .offer { + activeCall?.iceRestarts = Task { + while !Task.isCancelled { + await restartIce(connection) + _ = try? await Task.sleep(nanoseconds: WebRTCClient.iceRestartInterval) + } + } + } + await sendConnectionState(connection, "reconnecting") + } + + private func stopReconnecting() { + activeCall?.reconnection?.cancel() + activeCall?.reconnection = nil + activeCall?.iceRestarts?.cancel() + activeCall?.iceRestarts = nil + } + + private func restartIce(_ connection: RTCPeerConnection) async { + // the previous restart offer is not answered yet + if connection.signalingState != .stable { return } + let (offer, error) = await connection.offer(iceRestart: true) + if let offer { + await sendRestartSession(offer) + } else { + logger.error("WebRTCClient: ICE restart error: \(error?.localizedDescription ?? "unknown error")") + } + } + + private func receiveRestartSession(_ pc: RTCPeerConnection, _ session: String, _ remoteIceCandidates: [RTCIceCandidate]) async -> WCallResponse { + guard let desc: CustomRTCSessionDescription = decodeJSON(decompressFromBase64(input: session)), + let type = desc.type, let sdp = desc.sdp else { + return .error(message: "ice: invalid session") + } + let remoteDesc = RTCSessionDescription(type: type.toWebRTCSdpType(), sdp: sdp) + if type == .offer && pc.localDescription?.type != .offer { + if (try? await pc.setRemoteDescription(remoteDesc)) == nil { + return .error(message: "ice: remote offer is not set") + } + let (answer, error) = await pc.answer() + guard let answer else { + return .error(message: "ice: answer error: \(error?.localizedDescription ?? "unknown error")") + } + addIceCandidates(pc, remoteIceCandidates) + await sendRestartSession(answer) + } else if type == .answer && pc.signalingState == .haveLocalOffer { + if (try? await pc.setRemoteDescription(remoteDesc)) == nil { + return .error(message: "ice: remote answer is not set") + } + addIceCandidates(pc, remoteIceCandidates) + } else { + return .error(message: "ice: unexpected ICE restart \(type.rawValue)") + } + return .ok + } + + private func sendRestartSession(_ desc: RTCSessionDescription) async { + await sendCallResponse(.init( + corrId: nil, + resp: .ice( + iceCandidates: compressToBase64(input: encodeJSON(await getInitialIceCandidates())), + session: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: desc.type.toSdpType(), sdp: desc.sdp))) + ), + command: nil) + ) + waitForMoreIceCandidates() + } + + private func sendConnectionState(_ connection: RTCPeerConnection, _ connectionState: String) async { + guard let iceConnectionState = connection.iceConnectionState.toString(), + let iceGatheringState = connection.iceGatheringState.toString(), + let signalingState = connection.signalingState.toString() + else { + return + } + await sendCallResponse(.init( + corrId: nil, + resp: .connection(state: ConnectionState( + connectionState: connectionState, + iceConnectionState: iceConnectionState, + iceGatheringState: iceGatheringState, + signalingState: signalingState) + ), + command: nil) + ) + } + // Spec: spec/services/calls.md#sendIceCandidates func sendIceCandidates(_ candidates: [RTCIceCandidate]) async { await self.sendCallResponse(.init( @@ -657,6 +761,8 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg (notConnectedCall?.localCameraAndTrack?.0 as? RTCCameraVideoCapturer)?.stopCapture() guard let call = activeCall else { return } logger.debug("WebRTCClient: ending the call") + call.reconnection?.cancel() + call.iceRestarts?.cancel() call.connection.close() call.connection.delegate = nil call.frameEncryptor?.delegate = nil @@ -678,16 +784,18 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg extension WebRTC.RTCPeerConnection { - func mediaConstraints() -> RTCMediaConstraints { - RTCMediaConstraints( - mandatoryConstraints: [kRTCMediaConstraintsOfferToReceiveAudio: kRTCMediaConstraintsValueTrue, - kRTCMediaConstraintsOfferToReceiveVideo: kRTCMediaConstraintsValueTrue], - optionalConstraints: nil) + func mediaConstraints(iceRestart: Bool = false) -> RTCMediaConstraints { + var constraints = [kRTCMediaConstraintsOfferToReceiveAudio: kRTCMediaConstraintsValueTrue, + kRTCMediaConstraintsOfferToReceiveVideo: kRTCMediaConstraintsValueTrue] + if iceRestart { + constraints[kRTCMediaConstraintsIceRestart] = kRTCMediaConstraintsValueTrue + } + return RTCMediaConstraints(mandatoryConstraints: constraints, optionalConstraints: nil) } - func offer() async -> (RTCSessionDescription?, Error?) { + func offer(iceRestart: Bool = false) async -> (RTCSessionDescription?, Error?) { await withCheckedContinuation { cont in - offer(for: mediaConstraints()) { (sdp, error) in + offer(for: mediaConstraints(iceRestart: iceRestart)) { (sdp, error) in self.processSDP(cont, sdp, error) } } @@ -766,24 +874,20 @@ extension WebRTCClient: RTCPeerConnectionDelegate { func peerConnection(_ connection: RTCPeerConnection, didChange newState: RTCIceConnectionState) { debugPrint("Connection new connection state: \(newState.toString() ?? "" + newState.rawValue.description) \(connection.receivers)") - guard let connectionStateString = newState.toString(), - let iceConnectionStateString = connection.iceConnectionState.toString(), - let iceGatheringStateString = connection.iceGatheringState.toString(), - let signalingStateString = connection.signalingState.toString() - else { - return - } + guard let connectionStateString = newState.toString() else { return } Task { - await sendCallResponse(.init( - corrId: nil, - resp: .connection(state: ConnectionState( - connectionState: connectionStateString, - iceConnectionState: iceConnectionStateString, - iceGatheringState: iceGatheringStateString, - signalingState: signalingStateString) - ), - command: nil) - ) + if activeCall?.wasConnected == true { + switch newState { + case .disconnected, .failed: + if activeCall?.reconnection == nil { + await startReconnecting(connection) + } + return + case .new, .checking: return + default: () + } + } + await sendConnectionState(connection, connectionStateString) switch newState { case .checking: @@ -792,7 +896,10 @@ extension WebRTCClient: RTCPeerConnectionDelegate { } let enableSpeaker: Bool = ChatModel.shared.activeCall?.localMediaSources.hasVideo == true setSpeakerEnabledAndConfigureSession(enableSpeaker) - case .connected: sendConnectedEvent(connection) + case .connected: + stopReconnecting() + activeCall?.wasConnected = true + sendConnectedEvent(connection) case .disconnected, .failed: endCall() default: () } diff --git a/apps/ios/SimpleXChat/CallTypes.swift b/apps/ios/SimpleXChat/CallTypes.swift index ece65130e6..53830bc68f 100644 --- a/apps/ios/SimpleXChat/CallTypes.swift +++ b/apps/ios/SimpleXChat/CallTypes.swift @@ -34,10 +34,12 @@ public struct WebRTCSession: Codable { // Spec: spec/services/calls.md#WebRTCExtraInfo public struct WebRTCExtraInfo: Codable { - public init(rtcIceCandidates: String) { + public init(rtcSession: String?, rtcIceCandidates: String) { + self.rtcSession = rtcSession self.rtcIceCandidates = rtcIceCandidates } + public var rtcSession: String? public var rtcIceCandidates: String } diff --git a/apps/ios/product/flows/calling.md b/apps/ios/product/flows/calling.md index 86cb026625..e656a2932a 100644 --- a/apps/ios/product/flows/calling.md +++ b/apps/ios/product/flows/calling.md @@ -103,6 +103,14 @@ All signaling messages are exchanged via E2E encrypted SMP messages (no central 5. `CallViewRenderers` manages WebRTC video rendering surfaces. 6. Call status updates are sent via `apiCallStatus(contact:status:)`. +### 6.1 Reconnection + +1. A connected call loses its connection, e.g. when the device switches between Wi-Fi and mobile network. +2. The call shows "reconnecting…" and plays the connecting sound. Call duration continues, as this state is not reported to the core. +3. The callee (the party that sent the initial offer) restarts ICE every 10 seconds, sending the new offer via `apiSendCallExtraInfo` with `rtcSession`; the caller answers the same way. +4. When the connection is restored, the call returns to connected state. +5. If the connection is not restored in 30 seconds, the call ends. Clients without ICE restart support ignore the restart offer, so the call ends in this case. + ### 7. Audio Routing 1. `CallAudioDeviceManager` handles audio device selection. @@ -141,7 +149,7 @@ All signaling messages are exchanged via E2E encrypted SMP messages (no central | `RcvCallInvitation` | `SimpleXChat/CallTypes.swift` | Incoming call: user, contact, callType, sharedKey, callUUID, callTs | | `WebRTCCallOffer` | `SimpleXChat/CallTypes.swift` | SDP offer with call type and WebRTC session data | | `WebRTCSession` | `SimpleXChat/CallTypes.swift` | `rtcSession` (SDP) and `rtcIceCandidates` (serialized) | -| `WebRTCExtraInfo` | `SimpleXChat/CallTypes.swift` | Additional ICE candidates sent after initial offer/answer | +| `WebRTCExtraInfo` | `SimpleXChat/CallTypes.swift` | Additional ICE candidates sent after initial offer/answer, and ICE restart offer/answer | | `WebRTCCallStatus` | `SimpleXChat/CallTypes.swift` | Call lifecycle states for status reporting | | `CallMediaSource` | `SimpleXChat/CallTypes.swift` | `.mic`, `.camera`, `.screenAudio`, `.screenVideo`, `.unknown` | | `VideoCamera` | `SimpleXChat/CallTypes.swift` | `.user` (front) or `.environment` (rear) camera | @@ -152,7 +160,7 @@ All signaling messages are exchanged via E2E encrypted SMP messages (no central |-------|-------|----------| | Chat not ready on CallKit answer | App suspended, slow startup | `waitUntilChatStarted` with 30s timeout; `action.fail()` on timeout | | Call invitation not found | Race condition between notification and event processing | `justRefreshCallInvitations()` retry | -| WebRTC peer connection failure | NAT traversal, network issues | Call ends with error status | +| WebRTC peer connection failure | NAT traversal, network issues | Before connection: call ends with error status; after connection: reconnection with ICE restart, call ends if not reconnected in 30 seconds | | CallKit action fail | Internal state mismatch | `action.fail()` called, call cleaned up | | No camera/mic permission | User denied permissions | Permission request dialog shown | diff --git a/apps/ios/spec/api.md b/apps/ios/spec/api.md index 56c3c34cb6..cefc76dd19 100644 --- a/apps/ios/spec/api.md +++ b/apps/ios/spec/api.md @@ -179,7 +179,7 @@ The `ChatCommand` enum ([`AppAPITypes.swift` L15](../Shared/Model/AppAPITypes.sw | `apiRejectCall` | `contact` | Reject incoming call | [L158](../Shared/Model/AppAPITypes.swift#L158) | | `apiSendCallOffer` | `contact, callOffer: WebRTCCallOffer` | Send SDP offer | [L159](../Shared/Model/AppAPITypes.swift#L159) | | `apiSendCallAnswer` | `contact, answer: WebRTCSession` | Send SDP answer | [L160](../Shared/Model/AppAPITypes.swift#L160) | -| `apiSendCallExtraInfo` | `contact, extraInfo: WebRTCExtraInfo` | Send ICE candidates | [L161](../Shared/Model/AppAPITypes.swift#L161) | +| `apiSendCallExtraInfo` | `contact, extraInfo: WebRTCExtraInfo` | Send ICE candidates or ICE restart offer/answer | [L171](../Shared/Model/AppAPITypes.swift#L171) | | `apiEndCall` | `contact` | End active call | [L162](../Shared/Model/AppAPITypes.swift#L162) | | `apiGetCallInvitations` | -- | Get pending call invitations | [L163](../Shared/Model/AppAPITypes.swift#L163) | | `apiCallStatus` | `contact, callStatus` | Report call status change | [L164](../Shared/Model/AppAPITypes.swift#L164) | diff --git a/apps/ios/spec/services/calls.md b/apps/ios/spec/services/calls.md index 6a1d89f6a3..b3fb83d151 100644 --- a/apps/ios/spec/services/calls.md +++ b/apps/ios/spec/services/calls.md @@ -116,7 +116,10 @@ Manages the WebRTC peer connection, media streams, and data channels. | [`sendCallCommand`](../../Shared/Views/Call/WebRTCClient.swift#L176) | Dispatches WCallCommand (offer/answer/ICE) | L176 | | [`addIceCandidates`](../../Shared/Views/Call/WebRTCClient.swift#L165) | `peerConnection.add(RTCIceCandidate)` | L165 | | [`getInitialIceCandidates`](../../Shared/Views/Call/WebRTCClient.swift#L285) | Collects initial ICE candidates | L285 | -| [`sendIceCandidates`](../../Shared/Views/Call/WebRTCClient.swift#L305) | Sends gathered ICE candidates | L305 | +| [`sendIceCandidates`](../../Shared/Views/Call/WebRTCClient.swift#L409) | Sends gathered ICE candidates | L409 | +| [`startReconnecting`](../../Shared/Views/Call/WebRTCClient.swift#L313) | Reports "reconnecting", starts reconnection timeout and, for the party that sent the initial offer, periodic ICE restarts | L313 | +| [`restartIce`](../../Shared/Views/Call/WebRTCClient.swift#L339) | Creates ICE restart offer and sends it with candidates in `.ice(iceCandidates:session:)` | L339 | +| [`receiveRestartSession`](../../Shared/Views/Call/WebRTCClient.swift#L350) | Applies ICE restart offer (and sends the answer) or answer received in `.ice` command | L350 | | [`enableMedia`](../../Shared/Views/Call/WebRTCClient.swift#L365) | Enable/disable audio or video track | L365 | | [`setupLocalTracks`](../../Shared/Views/Call/WebRTCClient.swift#L423) | Creates audio/video tracks and adds to connection | L423 | | [`startCaptureLocalVideo`](../../Shared/Views/Call/WebRTCClient.swift#L581) | Front/back camera toggle and capture start | L581 | @@ -179,6 +182,10 @@ All call signaling travels through the same encrypted SMP message channels used 5. P2P connection established ``` +### Reconnection + +When a connected call loses ICE connection (`.disconnected` or `.failed`), `WebRTCClient` reports `"reconnecting"` connection state instead of ending the call. `ActiveCallView` sets `.reconnecting` call state and plays the connecting sound; this state is not sent to the core via `apiCallStatus`, and `connectedAt` is kept. The party that sent the initial offer (callee) restarts ICE every 10 seconds: the restart offer and answer are sent via `apiSendCallExtraInfo` with `rtcSession` (`x.call.extra`). When ICE connects again, the call returns to `.connected`. If it does not reconnect in 30 seconds, `WebRTCClient` reports `"disconnected"` and ends the call. + ### API Commands | Command | Direction | Purpose | @@ -187,7 +194,7 @@ All call signaling travels through the same encrypted SMP message channels used | `apiRejectCall(contact:)` | Callee -> Caller | Reject call | | `apiSendCallOffer(contact:, callOffer:)` | Callee -> Caller | Send SDP offer | | `apiSendCallAnswer(contact:, answer:)` | Caller -> Callee | Send SDP answer | -| `apiSendCallExtraInfo(contact:, extraInfo:)` | Both | Send ICE candidates | +| `apiSendCallExtraInfo(contact:, extraInfo:)` | Both | Send ICE candidates, or ICE restart offer/answer in `rtcSession` | | `apiEndCall(contact:)` | Either | End call | | `apiGetCallInvitations` | -- | Get pending invitations | | `apiCallStatus(contact:, callStatus:)` | -- | Report status change | @@ -317,10 +324,11 @@ struct WebRTCSession { } ``` -### [WebRTCExtraInfo](../../SimpleXChat/CallTypes.swift#L36-L42) +### [WebRTCExtraInfo](../../SimpleXChat/CallTypes.swift#L36-L45) ```swift struct WebRTCExtraInfo { + var rtcSession: String? // ICE restart offer or answer var rtcIceCandidates: String // Additional ICE candidates } ``` @@ -330,7 +338,7 @@ struct WebRTCExtraInfo { Stored in `ChatModel.activeCall`: - Contact reference - Call UUID -- Call state (enum: `.waitCapabilities`, `.invitationAccepted`, `.offerSent`, `.answerReceived`, `.connected`, etc.) +- Call state (enum: `.waitCapabilities`, `.invitationAccepted`, `.offerSent`, `.answerReceived`, `.connected`, `.reconnecting`, etc.) - Media type - WebRTCClient reference diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt index dfe38f197a..d4c533fd4e 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt @@ -52,8 +52,7 @@ import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.datetime.Clock import kotlinx.serialization.encodeToString import java.io.Closeable @@ -168,13 +167,24 @@ actual fun ActiveCallView() { CallSoundsPlayer.stop() } is WCallResponse.Ice -> withBGApi { - chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.iceCandidates) + chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.session, r.iceCandidates) } is WCallResponse.Connection -> - try { + // the call is restored and NOT sent to the core + // Makes sure the core DOES NOT record the duration wrong if a reconnect happens + if (r.state.connectionState == "reconnecting") { + if (call.callState != CallState.Reconnecting) { + CallSoundsPlayer.startConnectingCallSound(scope) + } + updateActiveCall(call) { it.copy(callState = CallState.Reconnecting) } + } else try { val callStatus = json.decodeFromString("\"${r.state.connectionState}\"") if (callStatus == WebRTCCallStatus.Connected) { - updateActiveCall(call) { it.copy(callState = CallState.Connected, connectedAt = Clock.System.now()) } + if (call.callState == CallState.Reconnecting) { + CallSoundsPlayer.stop() + } + // connectedAt is only set ONCE, this way call duration is NOT reset when the call is restored from reconnecting + updateActiveCall(call) { it.copy(callState = CallState.Connected, connectedAt = it.connectedAt ?: Clock.System.now()) } } withBGApi { chatModel.controller.apiCallStatus(callRh, call.contact, callStatus) } } catch (e: Throwable) { @@ -705,18 +715,17 @@ fun WebRTCView(callCommand: SnapshotStateList, onResponse: (WVAPIM val wv = webView.value if (wv != null) { LaunchedEffect(Unit) { - snapshotFlow { callCommand.firstOrNull() } - .distinctUntilChanged() - .filterNotNull() - .collect { - while (callCommand.isNotEmpty()) { - val cmd = callCommand.removeFirstOrNull() - Log.d(TAG, "WebRTCView LaunchedEffect executing ${cmd?.javaClass?.simpleName}") - if (cmd != null) { - processCommand(wv, cmd) - } + while (isActive) { + // waiting for the first command would miss a command equal to the previous one + snapshotFlow { callCommand.isNotEmpty() }.first { it } + while (callCommand.isNotEmpty()) { + val cmd = callCommand.removeFirstOrNull() + Log.d(TAG, "WebRTCView LaunchedEffect executing ${cmd?.javaClass?.simpleName}") + if (cmd != null) { + processCommand(wv, cmd) } } + } } } val assetLoader = WebViewAssetLoader.Builder() 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 54d684160e..3508221858 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 @@ -2185,8 +2185,8 @@ object ChatController { return r.result is CR.CmdOk } - suspend fun apiSendCallExtraInfo(rh: Long?, contact: Contact, rtcIceCandidates: String): Boolean { - val extraInfo = WebRTCExtraInfo(rtcIceCandidates) + suspend fun apiSendCallExtraInfo(rh: Long?, contact: Contact, rtcSession: String?, rtcIceCandidates: String): Boolean { + val extraInfo = WebRTCExtraInfo(rtcSession, rtcIceCandidates) val r = sendCmd(rh, CC.ApiSendCallExtraInfo(contact, extraInfo)) return r.result is CR.CmdOk } @@ -3436,7 +3436,7 @@ object ChatController { } is CR.CallExtraInfo -> { withCall(r, r.contact) { _ -> - chatModel.callCommand.add(WCallCommand.Ice(iceCandidates = r.extraInfo.rtcIceCandidates)) + chatModel.callCommand.add(WCallCommand.Ice(iceCandidates = r.extraInfo.rtcIceCandidates, session = r.extraInfo.rtcSession)) } } is CR.CallEnded -> { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt index b761218fdc..86e826e946 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt @@ -56,6 +56,7 @@ enum class CallState { AnswerReceived, Negotiated, Connected, + Reconnecting, Ended; val text: String get() = when(this) { @@ -67,6 +68,7 @@ enum class CallState { AnswerReceived -> generalGetString(MR.strings.callstate_received_confirmation) Negotiated -> generalGetString(MR.strings.callstate_connecting) Connected -> generalGetString(MR.strings.callstate_connected) + Reconnecting -> generalGetString(MR.strings.callstate_reconnecting) Ended -> generalGetString(MR.strings.callstate_ended) } } @@ -91,7 +93,7 @@ sealed class WCallCommand { @Serializable @SerialName("start") data class Start(val media: CallMediaType, val aesKey: String? = null, val iceServers: List? = null, val relay: Boolean? = null): WCallCommand() @Serializable @SerialName("offer") data class Offer(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null, val iceServers: List? = null, val relay: Boolean? = null): WCallCommand() @Serializable @SerialName("answer") data class Answer (val answer: String, val iceCandidates: String): WCallCommand() - @Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallCommand() + @Serializable @SerialName("ice") data class Ice(val iceCandidates: String, val session: String? = null): WCallCommand() @Serializable @SerialName("media") data class Media(val source: CallMediaSource, val enable: Boolean): WCallCommand() @Serializable @SerialName("camera") data class Camera(val camera: VideoCamera): WCallCommand() @Serializable @SerialName("description") data class Description(val state: String, val description: String): WCallCommand() @@ -104,7 +106,7 @@ sealed class WCallResponse { @Serializable @SerialName("capabilities") data class Capabilities(val capabilities: CallCapabilities): WCallResponse() @Serializable @SerialName("offer") data class Offer(val offer: String, val iceCandidates: String, val capabilities: CallCapabilities): WCallResponse() @Serializable @SerialName("answer") data class Answer(val answer: String, val iceCandidates: String): WCallResponse() - @Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallResponse() + @Serializable @SerialName("ice") data class Ice(val iceCandidates: String, val session: String? = null): WCallResponse() @Serializable @SerialName("connection") data class Connection(val state: ConnectionState): WCallResponse() @Serializable @SerialName("connected") data class Connected(val connectionInfo: ConnectionInfo): WCallResponse() @Serializable @SerialName("peerMedia") data class PeerMedia(val source: CallMediaSource, val enabled: Boolean): WCallResponse() @@ -116,7 +118,7 @@ sealed class WCallResponse { @Serializable data class WebRTCCallOffer(val callType: CallType, val rtcSession: WebRTCSession) @Serializable data class WebRTCSession(val rtcSession: String, val rtcIceCandidates: String) -@Serializable data class WebRTCExtraInfo(val rtcIceCandidates: String) +@Serializable data class WebRTCExtraInfo(val rtcSession: String? = null, val rtcIceCandidates: String) @Serializable data class CallType(val media: CallMediaType, val capabilities: CallCapabilities) @Serializable data class RcvCallInvitation( val remoteHostId: Long?, diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml index 48c844acce..cd80492607 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml @@ -1301,6 +1301,7 @@ received answer… received confirmation… connecting… + reconnecting… connected ended diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js b/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js index 7ab8d6fdd6..6fb3677a2e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js @@ -54,6 +54,8 @@ let inactiveCallMediaSources = { let activeCall; let notConnectedCall; let answerTimeout = 30000; +let reconnectTimeout = 30000; +let iceRestartInterval = 10000; var useWorker = false; var isDesktop = false; var localizedState = ""; @@ -75,7 +77,8 @@ const processCommand = (function () { return { peerConnectionConfig: { iceServers: iceServers !== null && iceServers !== void 0 ? iceServers : defaultIceServers, - iceCandidatePoolSize: 10, + // Chrome gathers no candidates on ICE restart when candidates pool is used + iceCandidatePoolSize: 0, encodedInsertableStreams, iceTransportPolicy: relay ? "relay" : "all", // needed for Android WebView >= 69 && <= 72 where default was "plan-b" which is incompatible with transceivers @@ -202,10 +205,14 @@ const processCommand = (function () { layout: (_c = notConnectedCall === null || notConnectedCall === void 0 ? void 0 : notConnectedCall.layout) !== null && _c !== void 0 ? _c : LayoutType.Default, cameraTrackWasSetBefore: localStream.getVideoTracks().length > 0, peerHasOldVersion: false, + receiveRestartSession, }; localOrPeerMediaSourcesChanged(call); await setupMediaStreams(call); let connectionTimeout = setTimeout(connectionHandler, answerTimeout); + let reconnectingTimeout; + let iceRestartTimeout; + let wasConnected = false; if (pc.connectionState) { pc.addEventListener("connectionstatechange", connectionStateChange); } @@ -216,30 +223,49 @@ const processCommand = (function () { async function connectionStateChange() { // "failed" means the second party did not answer in time (15 sec timeout in Chrome WebView) // See https://source.chromium.org/chromium/chromium/src/+/main:third_party/webrtc/p2p/base/p2p_constants.cc;l=70) - if (pc.connectionState !== "failed") + if (pc.connectionState !== "failed" || wasConnected) connectionHandler(); } - async function connectionHandler() { + async function connectionHandler(reconnectTimedOut = false) { var _a; + let state = (_a = pc.connectionState) !== null && _a !== void 0 ? _a : (pc.iceConnectionState != "completed" && pc.iceConnectionState != "checking" + ? pc.iceConnectionState + : pc.iceConnectionState == "completed" + ? "connected" + : "connecting"); /* webView 69-70 doesn't have connectionState yet */ + if (wasConnected) { + if (reconnectTimedOut) { + reconnectingTimeout = undefined; + if (activeCall !== call || state == "connected") + return; + state = "disconnected"; + } + else if (state == "disconnected" || state == "failed") { + if (reconnectingTimeout !== undefined) + return; + reconnectingTimeout = setTimeout(() => connectionHandler(true), reconnectTimeout); + if (isIceRestarter()) + restartIce(); + state = "reconnecting"; + } + else if (state == "connecting" || state == "new") { + return; + } + } sendMessageToNative({ resp: { type: "connection", state: { - connectionState: (_a = pc.connectionState) !== null && _a !== void 0 ? _a : (pc.iceConnectionState != "completed" && pc.iceConnectionState != "checking" - ? pc.iceConnectionState - : pc.iceConnectionState == "completed" - ? "connected" - : "connecting") /* webView 69-70 doesn't have connectionState yet */, + connectionState: state, iceConnectionState: pc.iceConnectionState, iceGatheringState: pc.iceGatheringState, signalingState: pc.signalingState, }, }, }); - if (pc.connectionState == "disconnected" || - pc.connectionState == "failed" || - (!pc.connectionState && (pc.iceConnectionState == "disconnected" || pc.iceConnectionState == "failed"))) { + if (state == "disconnected" || state == "failed") { clearConnectionTimeout(); + clearReconnecting(); if (pc.connectionState) { pc.removeEventListener("connectionstatechange", connectionStateChange); } @@ -251,8 +277,10 @@ const processCommand = (function () { } endCall(); } - else if (pc.connectionState == "connected" || (!pc.connectionState && pc.iceConnectionState == "connected")) { + else if (state == "connected") { clearConnectionTimeout(); + clearReconnecting(); + wasConnected = true; const stats = (await pc.getStats()); for (const stat of stats.values()) { const { type, state } = stat; @@ -278,6 +306,53 @@ const processCommand = (function () { connectionTimeout = undefined; } } + function clearReconnecting() { + clearTimeout(reconnectingTimeout); + reconnectingTimeout = undefined; + clearTimeout(iceRestartTimeout); + iceRestartTimeout = undefined; + } + // Only the party that sent the initial offer restarts ICE, so that restart offers never collide + function isIceRestarter() { + var _a; + return ((_a = pc.localDescription) === null || _a === void 0 ? void 0 : _a.type) == "offer"; + } + async function restartIce() { + if (activeCall !== call || reconnectingTimeout === undefined) + return; + iceRestartTimeout = setTimeout(restartIce, iceRestartInterval); + // the previous restart offer is not answered yet + if (pc.signalingState != "stable") + return; + try { + const iceCandidates = getIceCandidates(pc, config); + const offer = await pc.createOffer({ iceRestart: true }); + await pc.setLocalDescription(offer); + sendMessageToNative({ resp: { type: "ice", session: serialize(offer), iceCandidates: await iceCandidates } }); + } + catch (e) { + console.log("ICE restart error", e); + } + } + async function receiveRestartSession(session, iceCandidates) { + const desc = parse(session); + const remoteDesc = new RTCSessionDescription(!webView69Or70() ? desc : adaptSdpToOldWebView(desc)); + if (desc.type == "offer" && !isIceRestarter()) { + await pc.setRemoteDescription(remoteDesc); + const localIceCandidates = getIceCandidates(pc, config); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + addIceCandidates(pc, iceCandidates); + sendMessageToNative({ resp: { type: "ice", session: serialize(answer), iceCandidates: await localIceCandidates } }); + } + else if (desc.type == "answer" && pc.signalingState == "have-local-offer") { + await pc.setRemoteDescription(remoteDesc); + addIceCandidates(pc, iceCandidates); + } + else { + throw new Error(`unexpected ICE restart ${desc.type} in ${pc.signalingState} state`); + } + } } function serialize(x) { return LZString.compressToBase64(JSON.stringify(x)); @@ -296,6 +371,8 @@ const processCommand = (function () { 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 = null; try { localStream = await getLocalMediaStream(true, command.media == CallMediaType.Video && (await browserHasCamera()), VideoCamera.User); @@ -332,7 +409,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); @@ -436,7 +514,11 @@ const processCommand = (function () { break; case "ice": const remoteIceCandidates = parse(command.iceCandidates); - if (pc) { + if (activeCall && command.session) { + await activeCall.receiveRestartSession(command.session, remoteIceCandidates); + resp = { type: "ok" }; + } + else if (pc) { addIceCandidates(pc, remoteIceCandidates); resp = { type: "ok" }; } @@ -1131,6 +1213,13 @@ const processCommand = (function () { activeCall.localScreenStream.getTracks().forEach((track) => track.stop()); } } + // 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) 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 e6828817ee..be1c820b4e 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 @@ -2,7 +2,7 @@ // Override defaults to enable worker on Chrome and Safari useWorker = typeof window.Worker !== "undefined"; isDesktop = true; -// Create WebSocket connection. +// 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"); @@ -192,4 +192,4 @@ function updateCallInfoView(state, description) { document.getElementById("state").innerText = state; document.getElementById("description").innerText = description; } -//# sourceMappingURL=ui.js.map +//# sourceMappingURL=ui.js.map \ No newline at end of file diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt index f22ed52357..e5542d5da1 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt @@ -313,7 +313,9 @@ actual object CallSoundsPlayer: CallSoundsPlayerInterface { override fun startConnectingCallSound(scope: CoroutineScope) { // Taken from https://github.com/TelegramOrg/Telegram-Android // https://github.com/TelegramOrg/Telegram-Android/blob/master/LICENSE - start("/media/connecting_call.mp3", 3000, scope) + // the delay is the length of the track exactly, so it repeats with no delay, which already happens on ios and android + // Android (isLooping) and iOS (which sleeps for the track duration) + start("/media/connecting_call.mp3", 3536, scope) } override fun startInCallSound(scope: CoroutineScope) { 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 881b9ee168..616f46dab6 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 @@ -8,6 +8,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.* import chat.simplex.res.* import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.flow.* import kotlinx.datetime.Clock import kotlinx.serialization.encodeToString @@ -55,13 +56,24 @@ actual fun ActiveCallView() { CallSoundsPlayer.stop() } is WCallResponse.Ice -> withBGApi { - chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.iceCandidates) + chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.session, r.iceCandidates) } is WCallResponse.Connection -> - try { + // the call is restored and NOT sent to the core + // Makes sure the core DOES NOT record the duration wrong if a reconnect happens + if (r.state.connectionState == "reconnecting") { + if (call.callState != CallState.Reconnecting) { + CallSoundsPlayer.startConnectingCallSound(scope) + } + chatModel.activeCall.value = call.copy(callState = CallState.Reconnecting) + } else try { val callStatus = json.decodeFromString("\"${r.state.connectionState}\"") if (callStatus == WebRTCCallStatus.Connected) { - chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectedAt = Clock.System.now()) + if (call.callState == CallState.Reconnecting) { + CallSoundsPlayer.stop() + } + // connectedAt is only set ONCE, this way call duration is NOT reset when the call is restored from reconnecting + chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectedAt = call.connectedAt ?: Clock.System.now()) } withBGApi { chatModel.controller.apiCallStatus(callRh, call.contact, callStatus) } } catch (e: Throwable) { @@ -194,21 +206,20 @@ fun WebRTCController(callCommand: SnapshotStateList, onResponse: ( } } LaunchedEffect(Unit) { - snapshotFlow { callCommand.firstOrNull() } - .distinctUntilChanged() - .filterNotNull() - .collect { - while (connections.isEmpty()) { - delay(100) - } - while (callCommand.isNotEmpty()) { - val cmd = callCommand.removeFirstOrNull() - Log.d(TAG, "WebRTCController LaunchedEffect executing ${cmd?.javaClass?.simpleName}") - if (cmd != null) { - processCommand(cmd) - } + while (isActive) { + // waiting for the first command would miss a command equal to the previous one + snapshotFlow { callCommand.isNotEmpty() }.first { it } + while (connections.isEmpty()) { + delay(100) + } + while (callCommand.isNotEmpty()) { + val cmd = callCommand.removeFirstOrNull() + Log.d(TAG, "WebRTCController LaunchedEffect executing ${cmd?.javaClass?.simpleName}") + if (cmd != null) { + processCommand(cmd) } } + } } } diff --git a/apps/multiplatform/product/flows/calling.md b/apps/multiplatform/product/flows/calling.md index fae7f42031..d29c1d4478 100644 --- a/apps/multiplatform/product/flows/calling.md +++ b/apps/multiplatform/product/flows/calling.md @@ -99,6 +99,7 @@ Both platforms share a common signaling flow through the Haskell core API. ``` Outgoing: WaitCapabilities -> InvitationSent -> OfferSent -> AnswerReceived -> Negotiated -> Connected -> Ended Incoming: InvitationAccepted -> OfferReceived -> Negotiated -> Connected -> Ended +Reconnection: Connected -> Reconnecting -> Connected | Ended ``` | State | Description | @@ -111,8 +112,17 @@ Incoming: InvitationAccepted -> OfferReceived -> Negotiated -> Connected -> Ende | `AnswerReceived` | Caller received SDP answer | | `Negotiated` | ICE negotiation complete | | `Connected` | Media flowing | +| `Reconnecting` | Connection lost, waiting for ICE restart | | `Ended` | Call terminated | +### 3.1 Reconnection + +1. A connected call loses its WebRTC connection, e.g. when the device switches between Wi-Fi and mobile network. +2. The WebRTC engine reports `"reconnecting"` connection state; the app shows "reconnecting…" and plays the connecting sound. This state is not reported to the core, and `connectedAt` is kept, so call duration continues. +3. The callee's WebRTC engine (the party that sent the initial offer) restarts ICE every 10 seconds, sending the new offer in `WCallResponse.Ice` with `session` via `apiSendCallExtraInfo`. The caller answers the same way. +4. When the connection is restored, the call returns to `Connected` and the sound stops. +5. If the connection is not restored in 30 seconds, the call ends as disconnected. Clients without ICE restart support ignore the restart offer, so the call ends in this case. + --- ## 4. Ending a Call @@ -184,7 +194,7 @@ Incoming: InvitationAccepted -> OfferReceived -> Negotiated -> Connected -> Ende | `apiRejectCall(rh, contact)` | Reject incoming call | | `apiSendCallOffer(rh, contact, rtcSession, rtcIceCandidates, media, capabilities)` | Send SDP offer | | `apiSendCallAnswer(rh, contact, rtcSession, rtcIceCandidates)` | Send SDP answer | -| `apiSendCallExtraInfo(rh, contact, rtcIceCandidates)` | Send additional ICE candidates | +| `apiSendCallExtraInfo(rh, contact, rtcSession, rtcIceCandidates)` | Send additional ICE candidates, or ICE restart offer/answer | | `apiEndCall(rh, contact)` | End active call | | `apiCallStatus(rh, contact, status)` | Report WebRTC connection status | diff --git a/apps/multiplatform/product/views/call.md b/apps/multiplatform/product/views/call.md index 51d323874c..1d4f3b881b 100644 --- a/apps/multiplatform/product/views/call.md +++ b/apps/multiplatform/product/views/call.md @@ -71,8 +71,8 @@ Sound: `SoundPlayer.start()` plays ringtone while banner is visible (unless call | 1. Capabilities | `WCallResponse.Capabilities` | Local capabilities reported; `apiSendCallInvitation()` called | | 2. Offer | `WCallResponse.Offer` | SDP offer + ICE candidates sent via `apiSendCallOffer()` | | 3. Answer | `WCallResponse.Answer` | SDP answer + ICE candidates sent via `apiSendCallAnswer()` | -| 4. ICE | `WCallResponse.Ice` | Additional ICE candidates exchanged via `apiSendCallExtraInfo()` | -| 5. Connection | `WCallResponse.Connection` | WebRTC connection state changes; `CallState.Connected` set on success | +| 4. ICE | `WCallResponse.Ice` | Additional ICE candidates, and ICE restart offer/answer in `session`, exchanged via `apiSendCallExtraInfo()` | +| 5. Connection | `WCallResponse.Connection` | WebRTC connection state changes; `CallState.Connected` set on success, `CallState.Reconnecting` when a connected call loses connection | | 6. Connected | `WCallResponse.Connected` | Connection info (relay/direct) stored in `call.connectionInfo` | | 7. PeerMedia | `WCallResponse.PeerMedia` | Remote party media source changes (mic, camera, screen) | | 8. Media control | `WCallCommand.Media` | Toggle local media sources (mic, camera, screen audio/video) | @@ -90,14 +90,15 @@ Sound: `SoundPlayer.start()` plays ringtone while banner is visible (unless call | `OfferReceived` | Callee received SDP offer | | `AnswerReceived` | Caller received SDP answer | | `Negotiated` | ICE negotiation complete | -| `Connected` | WebRTC media flowing; `connectedAt` timestamp set | +| `Connected` | WebRTC media flowing; `connectedAt` timestamp set on the first connection | +| `Reconnecting` | Connection lost; "reconnecting…" shown while ICE restarts, call ends after 30 seconds without connection | | `Ended` | Call terminated | ### Call Sounds | Sound | Trigger | |---|---| -| Connecting sound | `CallSoundsPlayer.startConnectingCallSound()` after invitation sent | +| Connecting sound | `CallSoundsPlayer.startConnectingCallSound()` after invitation sent, and while reconnecting | | In-call sound | `CallSoundsPlayer.startInCallSound()` when delivery receipt received | | Ringtone | `SoundPlayer.start()` for incoming calls | | End vibration | `CallSoundsPlayer.vibrate()` on call end (if was connected) | diff --git a/apps/multiplatform/spec/api.md b/apps/multiplatform/spec/api.md index 4114e9de4f..ec7cedb715 100644 --- a/apps/multiplatform/spec/api.md +++ b/apps/multiplatform/spec/api.md @@ -225,7 +225,7 @@ All functions below are `suspend fun` members of `ChatController` ([SimpleXAPI.k | `apiRejectCall` | `rh: Long?, contact: Contact` | Reject an incoming call | [L1854](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1854) | | `apiSendCallOffer` | `rh, contact, rtcSession, rtcIceCandidates, media, capabilities` | Send a WebRTC call offer | [L1859](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1859) | | `apiSendCallAnswer` | `rh: Long?, contact: Contact, rtcSession: String, rtcIceCandidates: String` | Send a WebRTC call answer | [L1866](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1866) | -| `apiSendCallExtraInfo` | `rh: Long?, contact: Contact, rtcIceCandidates: String` | Send additional ICE candidates during a call | [L1872](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1872) | +| `apiSendCallExtraInfo` | `rh: Long?, contact: Contact, rtcSession: String?, rtcIceCandidates: String` | Send additional ICE candidates or ICE restart offer/answer during a call | [L2156](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2156) | | `apiEndCall` | `rh: Long?, contact: Contact` | End an active call | [L1878](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1878) | | `apiCallStatus` | `rh: Long?, contact: Contact, status: WebRTCCallStatus` | Report call status updates to the core | [L1883](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1883) | diff --git a/apps/multiplatform/spec/services/calls.md b/apps/multiplatform/spec/services/calls.md index 85303e1dad..a46de4f45c 100644 --- a/apps/multiplatform/spec/services/calls.md +++ b/apps/multiplatform/spec/services/calls.md @@ -43,6 +43,7 @@ enum class CallState { AnswerReceived, // SDP answer received from peer Negotiated, // ICE negotiation in progress Connected, // Media flowing + Reconnecting, // Connection lost, waiting for ICE to recover Ended; // Call terminated } ``` @@ -53,6 +54,8 @@ enum class CallState { State transitions are driven by `WCallResponse` messages from the WebRTC layer. Each transition typically triggers a corresponding API command (e.g., `apiSendCallInvitation`, `apiSendCallOffer`). +**Reconnection**: `Connected` -> `Reconnecting` -> `Connected` or `Ended`. When a connected call loses its WebRTC connection, `call.js` reports `"reconnecting"` connection state instead of ending the call. The app sets `CallState.Reconnecting` and plays the connecting sound; this state is not sent to the core with `apiCallStatus`, so the call item duration is not affected, and `connectedAt` is kept. The party that sent the initial offer (callee) restarts ICE every 10 seconds until reconnected: the restart offer and the answer are sent as `WCallResponse.Ice` / `WCallCommand.Ice` with `session`, via `apiSendCallExtraInfo` (`x.call.extra` with `rtcSession`). If the connection is not restored in 30 seconds, `call.js` reports `"disconnected"` and ends the call. + --- @@ -140,7 +143,7 @@ Defined in [`SimpleXAPI.kt`](../../common/src/commonMain/kotlin/chat/simplex/com | `apiRejectCall` | [L1854](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1854) | Reject an incoming call | | `apiSendCallOffer` | [L1859](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1859) | Send SDP offer with ICE candidates and capabilities | | `apiSendCallAnswer` | [L1866](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1866) | Send SDP answer with ICE candidates | -| `apiSendCallExtraInfo` | [L1872](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1872) | Send additional ICE candidates discovered after initial exchange | +| `apiSendCallExtraInfo` | [L2156](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2156) | Send additional ICE candidates discovered after initial exchange, or ICE restart offer/answer in `rtcSession` | | `apiEndCall` | [L1878](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1878) | Terminate a call | | `apiCallStatus` | [L1883](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1883) | Report WebRTC connection status to the backend | diff --git a/packages/simplex-chat-webrtc/README.md b/packages/simplex-chat-webrtc/README.md index b8db78dbb1..087a7e6fef 100644 --- a/packages/simplex-chat-webrtc/README.md +++ b/packages/simplex-chat-webrtc/README.md @@ -4,3 +4,20 @@ npm i npm run build ``` + +## Tests + +End-to-end tests run two headless Chromium peers with `call.js` and simulate network outages by relaying media via UDP sockets controlled by the test: + +``` +npx playwright-core install chromium +npm test +``` + +Set `CHROME` to use another Chromium executable, and `VERBOSE=1` to log signaling and connection states. + +Full-stack tests in `test/fullstack.test.js` also pass call signaling via two chat cores and a local SMP server. They run when `SIMPLEX_CHAT` and `SMP_SERVER` are set to `simplex-chat` and `smp-server` executables: + +``` +SIMPLEX_CHAT=path/to/simplex-chat SMP_SERVER=path/to/smp-server npm test +``` diff --git a/packages/simplex-chat-webrtc/package.json b/packages/simplex-chat-webrtc/package.json index a6598b6ce7..e49a1484c2 100644 --- a/packages/simplex-chat-webrtc/package.json +++ b/packages/simplex-chat-webrtc/package.json @@ -8,7 +8,7 @@ "dist" ], "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "test": "tsc && node --test test/*.test.js", "build": "prettier --write --ignore-unknown . && tsc && ./copy" }, "repository": { @@ -31,6 +31,7 @@ "husky": "^7.0.4", "isomorphic-webcrypto": "^2.3.8", "lint-staged": "^12.4.1", + "playwright-core": "^1.64.0", "prettier": "^2.6.2", "typescript": "^4.6.4" }, diff --git a/packages/simplex-chat-webrtc/src/call.ts b/packages/simplex-chat-webrtc/src/call.ts index 8441560013..aa207939e8 100644 --- a/packages/simplex-chat-webrtc/src/call.ts +++ b/packages/simplex-chat-webrtc/src/call.ts @@ -142,6 +142,7 @@ interface WCallAnswer extends IWCallCommand, IWCallResponse { interface WCallIceCandidates extends IWCallCommand, IWCallResponse { type: "ice" iceCandidates: string // JSON strings for RTCIceCandidateInit[] + session?: string // JSON string for RTCSessionDescriptionInit of ICE restart offer or answer } interface WCEnableMedia extends IWCallCommand { @@ -281,6 +282,7 @@ interface Call { // controls whether transceiver's track was replaced already or set initially (in video call) cameraTrackWasSetBefore: boolean peerHasOldVersion: boolean + receiveRestartSession: (session: string, iceCandidates: RTCIceCandidateInit[]) => Promise } interface NotConnectedCall { @@ -306,6 +308,8 @@ let inactiveCallMediaSources: CallMediaSources = { let activeCall: Call | undefined let notConnectedCall: NotConnectedCall | undefined let answerTimeout = 30_000 +let reconnectTimeout = 30_000 +let iceRestartInterval = 10_000 var useWorker = false var isDesktop = false var localizedState = "" @@ -357,7 +361,8 @@ const processCommand = (function () { return { peerConnectionConfig: { iceServers: iceServers ?? defaultIceServers, - iceCandidatePoolSize: 10, + // Chrome gathers no candidates on ICE restart when candidates pool is used + iceCandidatePoolSize: 0, encodedInsertableStreams, iceTransportPolicy: relay ? "relay" : "all", // needed for Android WebView >= 69 && <= 72 where default was "plan-b" which is incompatible with transceivers @@ -486,10 +491,14 @@ const processCommand = (function () { layout: notConnectedCall?.layout ?? LayoutType.Default, cameraTrackWasSetBefore: localStream.getVideoTracks().length > 0, peerHasOldVersion: false, + receiveRestartSession, } localOrPeerMediaSourcesChanged(call) await setupMediaStreams(call) let connectionTimeout: number | undefined = setTimeout(connectionHandler, answerTimeout) + let reconnectingTimeout: number | undefined + let iceRestartTimeout: number | undefined + let wasConnected = false if (pc.connectionState) { pc.addEventListener("connectionstatechange", connectionStateChange) } else { @@ -500,33 +509,45 @@ const processCommand = (function () { async function connectionStateChange() { // "failed" means the second party did not answer in time (15 sec timeout in Chrome WebView) // See https://source.chromium.org/chromium/chromium/src/+/main:third_party/webrtc/p2p/base/p2p_constants.cc;l=70) - if (pc.connectionState !== "failed") connectionHandler() + if (pc.connectionState !== "failed" || wasConnected) connectionHandler() } - async function connectionHandler() { + async function connectionHandler(reconnectTimedOut = false) { + let state: string = + pc.connectionState ?? + (pc.iceConnectionState != "completed" && pc.iceConnectionState != "checking" + ? pc.iceConnectionState + : pc.iceConnectionState == "completed" + ? "connected" + : "connecting") /* webView 69-70 doesn't have connectionState yet */ + if (wasConnected) { + if (reconnectTimedOut) { + reconnectingTimeout = undefined + if (activeCall !== call || state == "connected") return + state = "disconnected" + } else if (state == "disconnected" || state == "failed") { + if (reconnectingTimeout !== undefined) return + reconnectingTimeout = setTimeout(() => connectionHandler(true), reconnectTimeout) + if (isIceRestarter()) restartIce() + state = "reconnecting" + } else if (state == "connecting" || state == "new") { + return + } + } sendMessageToNative({ resp: { type: "connection", state: { - connectionState: - pc.connectionState ?? - (pc.iceConnectionState != "completed" && pc.iceConnectionState != "checking" - ? pc.iceConnectionState - : pc.iceConnectionState == "completed" - ? "connected" - : "connecting") /* webView 69-70 doesn't have connectionState yet */, + connectionState: state, iceConnectionState: pc.iceConnectionState, iceGatheringState: pc.iceGatheringState, signalingState: pc.signalingState, }, }, }) - if ( - pc.connectionState == "disconnected" || - pc.connectionState == "failed" || - (!pc.connectionState && (pc.iceConnectionState == "disconnected" || pc.iceConnectionState == "failed")) - ) { + if (state == "disconnected" || state == "failed") { clearConnectionTimeout() + clearReconnecting() if (pc.connectionState) { pc.removeEventListener("connectionstatechange", connectionStateChange) } else { @@ -536,8 +557,10 @@ const processCommand = (function () { setTimeout(() => sendMessageToNative({resp: {type: "ended"}}), 0) } endCall() - } else if (pc.connectionState == "connected" || (!pc.connectionState && pc.iceConnectionState == "connected")) { + } else if (state == "connected") { clearConnectionTimeout() + clearReconnecting() + wasConnected = true const stats = (await pc.getStats()) as Map for (const stat of stats.values()) { const {type, state} = stat @@ -564,6 +587,51 @@ const processCommand = (function () { connectionTimeout = undefined } } + + function clearReconnecting() { + clearTimeout(reconnectingTimeout) + reconnectingTimeout = undefined + clearTimeout(iceRestartTimeout) + iceRestartTimeout = undefined + } + + // Only the party that sent the initial offer restarts ICE, so that restart offers never collide + function isIceRestarter(): boolean { + return pc.localDescription?.type == "offer" + } + + async function restartIce() { + if (activeCall !== call || reconnectingTimeout === undefined) return + iceRestartTimeout = setTimeout(restartIce, iceRestartInterval) + // the previous restart offer is not answered yet + if (pc.signalingState != "stable") return + try { + const iceCandidates = getIceCandidates(pc, config) + const offer = await pc.createOffer({iceRestart: true}) + await pc.setLocalDescription(offer) + sendMessageToNative({resp: {type: "ice", session: serialize(offer), iceCandidates: await iceCandidates}}) + } catch (e) { + console.log("ICE restart error", e) + } + } + + async function receiveRestartSession(session: string, iceCandidates: RTCIceCandidateInit[]) { + const desc: RTCSessionDescriptionInit = parse(session) + const remoteDesc = new RTCSessionDescription(!webView69Or70() ? desc : adaptSdpToOldWebView(desc)) + if (desc.type == "offer" && !isIceRestarter()) { + await pc.setRemoteDescription(remoteDesc) + const localIceCandidates = getIceCandidates(pc, config) + const answer = await pc.createAnswer() + await pc.setLocalDescription(answer) + addIceCandidates(pc, iceCandidates) + sendMessageToNative({resp: {type: "ice", session: serialize(answer), iceCandidates: await localIceCandidates}}) + } else if (desc.type == "answer" && pc.signalingState == "have-local-offer") { + await pc.setRemoteDescription(remoteDesc) + addIceCandidates(pc, iceCandidates) + } else { + throw new Error(`unexpected ICE restart ${desc.type} in ${pc.signalingState} state`) + } + } } function serialize(x: T): string { @@ -729,7 +797,10 @@ const processCommand = (function () { break case "ice": const remoteIceCandidates: RTCIceCandidateInit[] = parse(command.iceCandidates) - if (pc) { + if (activeCall && command.session) { + await activeCall.receiveRestartSession(command.session, remoteIceCandidates) + resp = {type: "ok"} + } else if (pc) { addIceCandidates(pc, remoteIceCandidates) resp = {type: "ok"} } else { diff --git a/packages/simplex-chat-webrtc/test/chat.js b/packages/simplex-chat-webrtc/test/chat.js new file mode 100644 index 0000000000..bf35f775d7 --- /dev/null +++ b/packages/simplex-chat-webrtc/test/chat.js @@ -0,0 +1,147 @@ +// Runs a local SMP server and chat cores with WebSocket API, so that call signaling goes via the chat protocol. +const {spawn, execFileSync} = require("child_process") +const fs = require("fs") +const net = require("net") +const path = require("path") +const {sleep, log} = require("./harness") + +const START_TIMEOUT = 30000 + +function freePort() { + return new Promise((resolve, reject) => { + const server = net.createServer() + server.on("error", reject) + server.listen(0, "127.0.0.1", () => { + const {port} = server.address() + server.close(() => resolve(port)) + }) + }) +} + +async function waitUntil(what, check) { + const deadline = Date.now() + START_TIMEOUT + while (!(await check())) { + if (Date.now() > deadline) throw new Error(`${what} did not start in ${START_TIMEOUT}ms`) + await sleep(200) + } +} + +function canConnect(port) { + return new Promise((resolve) => { + const socket = net.connect(port, "127.0.0.1", () => { + socket.destroy() + resolve(true) + }) + socket.on("error", () => resolve(false)) + }) +} + +function spawnLogged(name, bin, args, env) { + const proc = spawn(bin, args, {env, stdio: ["ignore", "pipe", "pipe"]}) + const output = (data) => log(`${name}:`, data.toString().trimEnd()) + proc.stdout.on("data", output) + proc.stderr.on("data", output) + return proc +} + +async function startSmpServer(bin, dir) { + const env = {...process.env, SMP_SERVER_CFG_PATH: path.join(dir, "cfg"), SMP_SERVER_LOG_PATH: path.join(dir, "log")} + const init = ["init", "--ip", "127.0.0.1", "--no-password", "--disable-web", "--disable-store-log", "-y"] + const fingerprint = execFileSync(bin, init, {env, stdio: ["ignore", "pipe", "pipe"]}) + .toString() + .match(/Fingerprint: (\S+)/)[1] + const port = await freePort() + const iniFile = path.join(dir, "cfg", "smp-server.ini") + const ini = fs + .readFileSync(iniFile, "utf8") + .replace(/^port = .*$/m, `port = ${port}`) + .replace(/^host = .*$/m, "host = localhost") + fs.writeFileSync(iniFile, ini) + const proc = spawnLogged("smp-server", bin, ["start"], env) + await waitUntil("SMP server", () => canConnect(port)) + return {address: `smp://${fingerprint}@localhost:${port}`, stop: () => proc.kill()} +} + +class ChatClient { + constructor(name, proc, ws) { + this.name = name + this.proc = proc + this.ws = ws + this.corrId = 0 + this.pending = new Map() + this.events = [] + this.waiters = [] + this.onEvent = async () => {} + ws.addEventListener("message", (e) => this.receive(JSON.parse(e.data))) + } + + static async start(bin, dir, name, smpServer) { + const port = await freePort() + const args = ["-d", path.join(dir, name), "-s", smpServer, "-p", String(port), "--user-display-name", name] + const proc = spawnLogged(`chat ${name}`, bin, args, process.env) + await waitUntil(`chat ${name}`, () => canConnect(port)) + const ws = new WebSocket(`ws://127.0.0.1:${port}`) + await new Promise((resolve, reject) => { + ws.addEventListener("open", resolve, {once: true}) + ws.addEventListener("error", reject, {once: true}) + }) + return new ChatClient(name, proc, ws) + } + + receive({corrId, resp}) { + if (corrId) { + this.pending.get(corrId)?.(resp) + this.pending.delete(corrId) + return + } + log(`${this.name} event:`, resp.type) + this.events.push(resp) + this.onEvent(resp).catch((e) => log(`${this.name} event error`, e)) + this.waiters = this.waiters.filter((w) => !(w.pred(resp) && (w.resolve(resp), true))) + } + + async cmd(cmd) { + const corrId = String(++this.corrId) + log(`${this.name} cmd:`, cmd.slice(0, 100)) + const resp = await new Promise((resolve) => { + this.pending.set(corrId, resolve) + this.ws.send(JSON.stringify({corrId, cmd})) + }) + if (resp.type === "chatCmdError") throw new Error(`${this.name} ${cmd.split(" ", 2).join(" ")}: ${JSON.stringify(resp)}`) + return resp + } + + waitEvent(pred, timeout, what) { + const found = this.events.find(pred) + if (found) return Promise.resolve(found) + return new Promise((resolve, reject) => { + const waiter = { + pred, + resolve: (resp) => { + clearTimeout(timer) + resolve(resp) + }, + } + this.waiters.push(waiter) + const timer = setTimeout(() => { + this.waiters = this.waiters.filter((w) => w !== waiter) + reject(new Error(`${this.name}: no ${what} event in ${timeout}ms`)) + }, timeout) + }) + } + + stop() { + this.ws.close() + this.proc.kill() + } +} + +async function connectContacts(alice, bob) { + const inv = await alice.cmd("/c") + await bob.cmd(`/c ${inv.connLinkInvitation.connFullLink}`) + const connected = (c) => c.waitEvent((e) => e.type === "contactConnected", START_TIMEOUT, "contactConnected") + const [a, b] = await Promise.all([connected(alice), connected(bob)]) + return [a.contact.contactId, b.contact.contactId] +} + +module.exports = {startSmpServer, ChatClient, connectContacts} diff --git a/packages/simplex-chat-webrtc/test/fullstack.test.js b/packages/simplex-chat-webrtc/test/fullstack.test.js new file mode 100644 index 0000000000..06711845a3 --- /dev/null +++ b/packages/simplex-chat-webrtc/test/fullstack.test.js @@ -0,0 +1,188 @@ +// Calls between two chat cores connected via a local SMP server, with the test playing the native app: +// it passes messages between call.js and the core API as the Android and desktop apps do. +const {test} = require("node:test") +const assert = require("node:assert/strict") +const fs = require("fs") +const os = require("os") +const path = require("path") +const {openPeers, sleep} = require("./harness") +const {startSmpServer, ChatClient, connectContacts} = require("./chat") + +const {SIMPLEX_CHAT, SMP_SERVER} = process.env +const MEDIA = "audio" +const DISCONNECT_DETECTION = 10000 +const RECONNECT = 20000 +const SIGNALING = 15000 +const CORE_CALL_STATUSES = ["connecting", "connected", "disconnected", "failed"] + +async function withChatCall({useWorker = false}, run) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "simplex-call-test-")) + const stops = [] + try { + const smp = await startSmpServer(SMP_SERVER, path.join(dir, "smp")) + stops.push(smp.stop) + const alice = await ChatClient.start(SIMPLEX_CHAT, dir, "alice", smp.address) + stops.push(() => alice.stop()) + const bob = await ChatClient.start(SIMPLEX_CHAT, dir, "bob", smp.address) + stops.push(() => bob.stop()) + const [aliceContact, bobContact] = await connectContacts(alice, bob) + const peers = await openPeers({useWorker}) + stops.push(peers.close) + // alice calls bob: a is alice's call.js, b is bob's call.js + const call = {...peers, alice, bob, aliceContact, bobContact} + connectApp(call.a, alice, aliceContact, peers) + connectApp(call.b, bob, bobContact, peers) + await startCall(call) + await run(call) + } finally { + for (const stop of stops.reverse()) await stop() + fs.rmSync(dir, {recursive: true, force: true}) + } +} + +function connectApp(peer, chat, contactId, {network, signaling}) { + peer.onResponse = async (resp) => { + switch (resp.type) { + case "ice": { + const {session, iceCandidates} = await network.routeSignal(peer.name, resp.session, resp.iceCandidates) + const extraInfo = JSON.stringify({rtcSession: session, rtcIceCandidates: iceCandidates}) + signaling.send(() => chat.cmd(`/_call extra @${contactId} ${extraInfo}`)) + break + } + case "connection": + if (CORE_CALL_STATUSES.includes(resp.state.connectionState)) { + await chat.cmd(`/_call status @${contactId} ${resp.state.connectionState}`) + } + break + case "ended": + await chat.cmd(`/_call end @${contactId}`).catch(() => {}) + break + } + } + chat.onEvent = async (event) => { + switch (event.type) { + case "callExtraInfo": + await peer.command({type: "ice", iceCandidates: event.extraInfo.rtcIceCandidates, session: event.extraInfo.rtcSession}) + break + case "callEnded": + await peer.command({type: "end"}) + break + } + } +} + +async function startCall({a, b, network, alice, bob, aliceContact, bobContact}) { + const capabilities = await a.command({type: "capabilities", media: MEDIA}) + assert.equal(capabilities.resp.type, "capabilities") + const callType = {media: MEDIA, capabilities: capabilities.resp.capabilities} + await alice.cmd(`/_call invite @${aliceContact} ${JSON.stringify(callType)}`) + + const {callInvitation} = await bob.waitEvent((e) => e.type === "callInvitation", SIGNALING, "callInvitation") + const offer = await b.command({type: "start", media: MEDIA, aesKey: callInvitation.sharedKey, iceServers: []}) + assert.equal(offer.resp.type, "offer", JSON.stringify(offer.resp)) + const offerSignal = await network.routeSignal("B", offer.resp.offer, offer.resp.iceCandidates) + const callOffer = { + callType: {media: MEDIA, capabilities: offer.resp.capabilities}, + rtcSession: {rtcSession: offerSignal.session, rtcIceCandidates: offerSignal.iceCandidates}, + } + await bob.cmd(`/_call offer @${bobContact} ${JSON.stringify(callOffer)}`) + + const offered = await alice.waitEvent((e) => e.type === "callOffer", SIGNALING, "callOffer") + const answer = await a.command({ + type: "offer", + offer: offered.offer.rtcSession, + iceCandidates: offered.offer.rtcIceCandidates, + media: MEDIA, + aesKey: offered.sharedKey, + iceServers: [], + }) + assert.equal(answer.resp.type, "answer", JSON.stringify(answer.resp)) + const answerSignal = await network.routeSignal("A", answer.resp.answer, answer.resp.iceCandidates) + const callAnswer = {rtcSession: answerSignal.session, rtcIceCandidates: answerSignal.iceCandidates} + await alice.cmd(`/_call answer @${aliceContact} ${JSON.stringify(callAnswer)}`) + + const answered = await bob.waitEvent((e) => e.type === "callAnswer", SIGNALING, "callAnswer") + const ok = await b.command({type: "answer", answer: answered.answer.rtcSession, iceCandidates: answered.answer.rtcIceCandidates}) + assert.equal(ok.resp.type, "ok", JSON.stringify(ok.resp)) + await Promise.all([a.waitForState("connected", RECONNECT), b.waitForState("connected", RECONNECT)]) +} + +async function assertAudioDecoded(a, b) { + const before = [await a.receivedAudioEnergy(), await b.receivedAudioEnergy()] + await sleep(2000) + const after = [await a.receivedAudioEnergy(), await b.receivedAudioEnergy()] + assert.ok(after[0] > before[0], `A decoded no audio: ${before[0]} -> ${after[0]}`) + assert.ok(after[1] > before[1], `B decoded no audio: ${before[1]} -> ${after[1]}`) +} + +async function callItem(chat, contactId) { + const r = await chat.cmd(`/_get chat @${contactId} count=1`) + return r.chat.chatItems[0].content +} + +function restartSessionsReceived(chat) { + return chat.events.filter((e) => e.type === "callExtraInfo" && e.extraInfo.rtcSession).length +} + +const skip = !(SIMPLEX_CHAT && SMP_SERVER) && "set SIMPLEX_CHAT and SMP_SERVER to chat CLI and SMP server executables" + +test("calls via chat core", {skip}, async (t) => { + for (const [app, useWorker] of [ + ["android", false], + ["desktop", true], + ]) { + await t.test(`${app}: encrypted call reconnects with ICE restart after network path change`, () => + withChatCall({useWorker}, async ({a, b, network, alice, bob, aliceContact, bobContact}) => { + assert.ok(a.messages.find((m) => m.resp.type === "capabilities").resp.capabilities.encryption, "call is not encrypted") + assert.equal(await a.page.evaluate(() => !!activeCall.worker), useWorker, "encryption worker") + await assertAudioDecoded(a, b) + assert.deepEqual(await callItem(alice, aliceContact), {type: "sndCall", status: "progress", duration: 0}) + const connectedAt = Date.now() + + const from = [a.messages.length, b.messages.length] + network.down = true + network.changePath() + await Promise.all([ + a.waitForState("reconnecting", DISCONNECT_DETECTION, from[0]), + b.waitForState("reconnecting", DISCONNECT_DETECTION, from[1]), + ]) + await sleep(3000) + network.down = false + await Promise.all([a.waitForState("connected", RECONNECT, from[0]), b.waitForState("connected", RECONNECT, from[1])]) + await assertAudioDecoded(a, b) + assert.ok(restartSessionsReceived(alice) > 0, "alice received no ICE restart offer") + assert.ok(restartSessionsReceived(bob) > 0, "bob received no ICE restart answer") + assert.equal((await callItem(alice, aliceContact)).status, "progress") + assert.equal((await callItem(bob, bobContact)).status, "progress") + + await alice.cmd(`/_call end @${aliceContact}`) + await a.command({type: "end"}) + await bob.waitEvent((e) => e.type === "callEnded", SIGNALING, "callEnded") + const minDuration = Math.floor((Date.now() - connectedAt) / 1000) - 1 + const aliceItem = await callItem(alice, aliceContact) + assert.equal(aliceItem.status, "ended") + assert.ok(aliceItem.duration >= minDuration, `call duration ${aliceItem.duration} < ${minDuration}`) + }) + ) + } + + await t.test("call ends in both chats when reconnection times out", () => + withChatCall({}, async ({a, b, network, alice, bob, aliceContact, bobContact}) => { + await Promise.all([a.setTimeouts({reconnectTimeout: 5000}), b.setTimeouts({reconnectTimeout: 5000})]) + network.down = true + // the first party to time out ends the call, and the other party ends it on "callEnded" event + const endedBy = (peer, other) => peer.waitForEnded(DISCONNECT_DETECTION + 10000).then(() => other) + const other = await Promise.any([endedBy(a, bob), endedBy(b, alice)]) + await other.waitEvent((e) => e.type === "callEnded", SIGNALING, "callEnded") + network.down = false + for (const [chat, contactId] of [ + [alice, aliceContact], + [bob, bobContact], + ]) { + const item = await callItem(chat, contactId) + assert.equal(item.status, "ended", `${chat.name} call item: ${JSON.stringify(item)}`) + assert.ok(item.duration >= 5, `${chat.name} call duration ${item.duration}`) + } + }) + ) +}) diff --git a/packages/simplex-chat-webrtc/test/harness.js b/packages/simplex-chat-webrtc/test/harness.js new file mode 100644 index 0000000000..d8e1668ce1 --- /dev/null +++ b/packages/simplex-chat-webrtc/test/harness.js @@ -0,0 +1,317 @@ +// Runs two headless Chromium peers with call.js; the test plays the native app and the chat core. +// Media can only flow through UDP relays controlled by the test, so outages and path changes are simulated +// by dropping packets in the relays. +const http = require("http") +const fs = require("fs") +const path = require("path") +const dgram = require("dgram") +const {chromium} = require("playwright-core") +const LZString = require("lz-string") + +const ROOT = path.join(__dirname, "..") +const PAGE_FILES = { + "/android/call.html": path.join(ROOT, "src/android/call.html"), + "/android/style.css": path.join(ROOT, "src/android/style.css"), + "/call.js": path.join(ROOT, "dist/call.js"), + "/lz-string.min.js": path.join(ROOT, "node_modules/lz-string/libs/lz-string.min.js"), +} +const CONTENT_TYPES = {".html": "text/html", ".js": "text/javascript", ".css": "text/css"} + +const startTime = Date.now() +function log(...args) { + if (process.env.VERBOSE) console.log(((Date.now() - startTime) / 1000).toFixed(1).padStart(6), ...args) +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) +const decode = (s) => JSON.parse(LZString.decompressFromBase64(s)) +const encode = (x) => LZString.compressToBase64(JSON.stringify(x)) + +function servePage() { + const server = http.createServer((req, res) => { + const file = PAGE_FILES[new URL(req.url, "http://localhost").pathname] + if (!file) return res.writeHead(404).end() + res.writeHead(200, {"Content-Type": CONTENT_TYPES[path.extname(file)]}).end(fs.readFileSync(file)) + }) + return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve(server))) +} + +function bindUdp(ip) { + return new Promise((resolve) => { + const socket = dgram.createSocket("udp4") + socket.bind(0, ip, () => resolve(socket)) + }) +} + +// Relays one ICE generation: each peer is told that the other peer is at the relay socket. +class Relay { + constructor(network, ip) { + this.network = network + this.ip = ip + this.ufrag = {A: undefined, B: undefined} + this.real = {A: undefined, B: undefined} + this.dead = false + this.forwarded = 0 + } + + async open() { + this.sockets = {A: await bindUdp(this.ip), B: await bindUdp(this.ip)} + this.sockets.A.on("message", (msg, from) => this.forward(msg, from, "B", "A")) + this.sockets.B.on("message", (msg, from) => this.forward(msg, from, "A", "B")) + } + + // socket A is advertised to B as A's address, so packets on it come from B and go to A via socket B + forward(msg, from, sender, receiver) { + if (this.dead || this.network.down) return + this.real[sender] = from + const to = this.real[receiver] + if (!to) return + this.forwarded++ + this.sockets[sender].send(msg, to.port, to.address) + } + + port(side) { + return this.sockets[side].address().port + } + + close() { + this.sockets.A.close() + this.sockets.B.close() + } +} + +class Network { + constructor() { + this.down = false + this.relays = [] + } + + // Returns the rewritten candidate, or undefined if the candidate cannot be relayed. + async route(side, candidate) { + const [, component, protocol, , ip, port, , type] = candidate.candidate.split(" ") + if (protocol.toLowerCase() !== "udp" || type !== "host" || ip.includes(":") || component !== "1") return + const ufrag = candidate.usernameFragment + if (this.relays.some((r) => r.ufrag[side] === ufrag)) return + let relay = this.relays.find((r) => !r.dead && r.ufrag[side] === undefined) + if (!relay) { + relay = new Relay(this, ip) + await relay.open() + this.relays.push(relay) + } + relay.ufrag[side] = ufrag + relay.real[side] = {address: ip, port: +port} + log(`relay ${this.relays.indexOf(relay)}: ${side} ${ip}:${port} -> ${relay.ip}:${relay.port(side)}`) + const parts = candidate.candidate.split(" ") + parts[4] = relay.ip + parts[5] = String(relay.port(side)) + return {...candidate, candidate: parts.join(" ")} + } + + // Takes and returns compressed session description and candidates, as in signaling messages + async routeSignal(side, session, iceCandidates) { + // candidates in SDP would connect the peers directly, bypassing the relays + if (session && decode(session).sdp.includes("a=candidate:")) throw new Error(`${side}: SDP has ICE candidates`) + const routed = [] + for (const c of decode(iceCandidates)) { + const r = c?.candidate && (await this.route(side, c)) + if (r) routed.push(r) + } + return {session, iceCandidates: encode(routed)} + } + + // Addresses of all current ICE generations become unreachable, as when a device switches networks. + changePath() { + for (const relay of this.relays) relay.dead = true + } + + close() { + for (const relay of this.relays) relay.close() + } +} + +class Peer { + constructor(name, page, {ignoresIceRestart = false} = {}) { + this.name = name + this.page = page + this.ignoresIceRestart = ignoresIceRestart + this.messages = [] + this.waiters = [] + this.onResponse = async () => {} + } + + async open(url) { + await this.page.exposeFunction("sendToTest", (json) => this.receive(JSON.parse(json))) + this.page.on("console", (m) => log(`${this.name} console:`, m.text().slice(0, 200))) + this.page.on("pageerror", (e) => log(`${this.name} page error:`, e.message)) + await this.page.goto(url) + await this.page.evaluate(() => { + sendMessageToNative = (msg) => window.sendToTest(JSON.stringify(msg)) + }) + } + + receive(msg) { + const resp = msg.resp + log(`${this.name} <-`, resp.type, resp.state?.connectionState ?? "", resp.session ? "with session" : "") + this.messages.push(msg) + if (!msg.command) this.onResponse(resp).catch((e) => log(`${this.name} response error`, e)) + this.waiters = this.waiters.filter((w) => !(w.pred(msg) && (w.resolve(msg), true))) + } + + connectionStates() { + return this.messages.filter((m) => m.resp.type === "connection").map((m) => m.resp.state.connectionState) + } + + sessionsSent() { + return this.messages.filter((m) => m.resp.type === "ice" && !m.command && m.resp.session).length + } + + ended() { + return this.messages.some((m) => m.resp.type === "ended") + } + + waitFor(pred, timeout, what) { + const found = this.messages.find(pred) + if (found) return Promise.resolve(found) + return new Promise((resolve, reject) => { + const waiter = { + pred, + resolve: (msg) => { + clearTimeout(timer) + resolve(msg) + }, + } + this.waiters.push(waiter) + const timer = setTimeout(() => { + this.waiters = this.waiters.filter((w) => w !== waiter) + reject(new Error(`${this.name}: no ${what} in ${timeout}ms, connection states: ${this.connectionStates()}`)) + }, timeout) + }) + } + + waitForState(state, timeout, after = 0) { + const pred = (m) => m.resp.type === "connection" && m.resp.state.connectionState === state && this.messages.indexOf(m) >= after + return this.waitFor(pred, timeout, `"${state}" state`) + } + + waitForEnded(timeout) { + return this.waitFor((m) => m.resp.type === "ended", timeout, "ended") + } + + command(command) { + log(`${this.name} ->`, command.type, command.session ? "with session" : "") + return this.page.evaluate((c) => processCommand({corrId: 0, command: c}), command) + } + + async setTimeouts(timeouts) { + await this.page.evaluate((t) => { + if (t.reconnectTimeout) reconnectTimeout = t.reconnectTimeout + if (t.iceRestartInterval) iceRestartInterval = t.iceRestartInterval + }, timeouts) + } + + async receivedBytes() { + return (await this.inboundAudio()).bytesReceived + } + + // energy grows only when received audio is decoded, i.e. it is decrypted with the right key + async receivedAudioEnergy() { + return (await this.inboundAudio()).totalAudioEnergy + } + + inboundAudio() { + return this.page.evaluate(async () => { + let audio + ;(await activeCall.connection.getStats()).forEach((s) => { + if (s.type === "inbound-rtp" && s.kind === "audio" && (!audio || s.bytesReceived > audio.bytesReceived)) audio = s + }) + return {bytesReceived: audio?.bytesReceived ?? 0, totalAudioEnergy: audio?.totalAudioEnergy ?? 0} + }) + } +} + +// Signaling stops while the network is down and resumes in order, as messages queued in SMP servers. +class Signaling { + constructor(network) { + this.network = network + this.queue = Promise.resolve() + this.closed = false + } + + send(deliver) { + this.queue = this.queue.then(async () => { + while (this.network.down && !this.closed) await sleep(100) + if (!this.closed) await deliver().catch((e) => log("signaling error", e.message)) + }) + } +} + +// useWorker encrypts media in a worker with RTCRtpScriptTransform, as the desktop app does +async function openPeers({answerer = {}, useWorker = false} = {}) { + const server = await servePage() + const url = `http://localhost:${server.address().port}/android/call.html` + const browser = await chromium.launch({ + executablePath: process.env.CHROME, + args: [ + "--use-fake-ui-for-media-stream", + "--use-fake-device-for-media-stream", + "--disable-features=WebRtcHideLocalIpsWithMdns", + "--autoplay-policy=no-user-gesture-required", + ], + }) + const newPeer = async (name, opts) => { + const context = await browser.newContext({permissions: ["camera", "microphone"]}) + const peer = new Peer(name, await context.newPage(), opts) + await peer.open(url) + if (useWorker) await peer.page.evaluate(() => (useWorker = true)) + return peer + } + // A sends the call invitation and B accepts it, so B sends the WebRTC offer, as "start" command in the apps + const a = await newPeer("A", answerer) + const b = await newPeer("B") + const network = new Network() + const signaling = new Signaling(network) + const close = async () => { + signaling.closed = true + await browser.close() + network.close() + server.close() + } + return {a, b, network, signaling, close} +} + +// The test relays signaling between the peers directly, as the apps and the chat core would do. +async function startCall({media = "audio", answerer = {}} = {}) { + const peers = await openPeers({answerer}) + const {a, b, network, signaling, close} = peers + const relayIce = (from, to) => async (resp) => { + if (resp.type !== "ice") return + const {session, iceCandidates} = await network.routeSignal(from.name, resp.session, resp.iceCandidates) + signaling.send(() => to.command({type: "ice", iceCandidates, session: to.ignoresIceRestart ? undefined : session})) + } + a.onResponse = relayIce(a, b) + b.onResponse = relayIce(b, a) + + try { + await a.command({type: "capabilities", media}) + const offer = await b.command({type: "start", media, iceServers: []}) + if (offer.resp.type !== "offer") throw new Error(`B start: ${JSON.stringify(offer.resp)}`) + const offerSignal = await network.routeSignal("B", offer.resp.offer, offer.resp.iceCandidates) + const answer = await a.command({ + type: "offer", + offer: offerSignal.session, + iceCandidates: offerSignal.iceCandidates, + media, + iceServers: [], + }) + if (answer.resp.type !== "answer") throw new Error(`A offer: ${JSON.stringify(answer.resp)}`) + const answerSignal = await network.routeSignal("A", answer.resp.answer, answer.resp.iceCandidates) + const ok = await b.command({type: "answer", answer: answerSignal.session, iceCandidates: answerSignal.iceCandidates}) + if (ok.resp.type !== "ok") throw new Error(`B answer: ${JSON.stringify(ok.resp)}`) + await Promise.all([a.waitForState("connected", 20000), b.waitForState("connected", 20000)]) + } catch (e) { + await close() + throw e + } + return peers +} + +module.exports = {openPeers, startCall, sleep, log} diff --git a/packages/simplex-chat-webrtc/test/reconnect.test.js b/packages/simplex-chat-webrtc/test/reconnect.test.js new file mode 100644 index 0000000000..d7eb9f861e --- /dev/null +++ b/packages/simplex-chat-webrtc/test/reconnect.test.js @@ -0,0 +1,95 @@ +const {test} = require("node:test") +const assert = require("node:assert/strict") +const {startCall, sleep} = require("./harness") + +// Chrome reports "disconnected" about 6 seconds after packets stop +const DISCONNECT_DETECTION = 10000 +const RECONNECT = 20000 + +async function assertMediaFlows(a, b) { + const before = [await a.receivedBytes(), await b.receivedBytes()] + await sleep(2000) + const after = [await a.receivedBytes(), await b.receivedBytes()] + assert.ok(after[0] > before[0], `A received no media: ${before[0]} -> ${after[0]}`) + assert.ok(after[1] > before[1], `B received no media: ${before[1]} -> ${after[1]}`) +} + +async function assertReconnects(a, b, outage) { + const from = [a.messages.length, b.messages.length] + await Promise.all([ + a.waitForState("reconnecting", DISCONNECT_DETECTION, from[0]), + b.waitForState("reconnecting", DISCONNECT_DETECTION, from[1]), + ]) + await outage() + await Promise.all([a.waitForState("connected", RECONNECT, from[0]), b.waitForState("connected", RECONNECT, from[1])]) + await assertMediaFlows(a, b) + assert.ok(!a.ended() && !b.ended(), "call ended") + assert.ok(!a.connectionStates().includes("disconnected"), `A states: ${a.connectionStates()}`) + assert.ok(!b.connectionStates().includes("disconnected"), `B states: ${b.connectionStates()}`) +} + +async function withCall(opts, run) { + const call = await startCall(opts) + try { + await run(call) + } finally { + await call.close() + } +} + +test("call reconnection", {concurrency: true}, async (t) => { + await t.test("recovers after a brief outage", () => + withCall({}, async ({a, b, network}) => { + await assertMediaFlows(a, b) + network.down = true + await assertReconnects(a, b, async () => { + network.down = false + }) + }) + ) + + await t.test("recovers after an outage longer than ICE consent timeout", () => + withCall({}, async ({a, b, network}) => { + network.down = true + await assertReconnects(a, b, async () => { + await sleep(15000) + network.down = false + }) + assert.ok(b.sessionsSent() > 0, "offerer did not restart ICE") + assert.ok(a.sessionsSent() > 0, "answerer did not answer ICE restart") + }) + ) + + await t.test("recovers when the network path changes", () => + withCall({}, async ({a, b, network}) => { + network.down = true + network.changePath() + await assertReconnects(a, b, async () => { + network.down = false + }) + const relay = network.relays[network.relays.length - 1] + assert.ok(!relay.dead && relay.forwarded > 0, "media does not use the new path") + }) + ) + + await t.test("ends the call when reconnection times out", () => + withCall({}, async ({a, b, network}) => { + await Promise.all([a.setTimeouts({reconnectTimeout: 5000}), b.setTimeouts({reconnectTimeout: 5000})]) + network.down = true + await Promise.all([a.waitForState("reconnecting", DISCONNECT_DETECTION), b.waitForState("reconnecting", DISCONNECT_DETECTION)]) + await Promise.all([a.waitForEnded(10000), b.waitForEnded(10000)]) + assert.deepEqual(a.connectionStates().slice(-2), ["reconnecting", "disconnected"]) + assert.deepEqual(b.connectionStates().slice(-2), ["reconnecting", "disconnected"]) + }) + ) + + await t.test("ends the call when the peer does not support ICE restart", () => + withCall({answerer: {ignoresIceRestart: true}}, async ({a, b, network}) => { + await Promise.all([a.setTimeouts({reconnectTimeout: 10000}), b.setTimeouts({reconnectTimeout: 10000})]) + network.changePath() + await Promise.all([a.waitForEnded(DISCONNECT_DETECTION + 15000), b.waitForEnded(DISCONNECT_DETECTION + 15000)]) + assert.ok(b.sessionsSent() > 0, "offerer did not restart ICE") + assert.equal(a.sessionsSent(), 0) + }) + ) +}) From 58bc94bc7497f20b93ec302fc8f4cfd99ee8b135 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:22:10 +0400 Subject: [PATCH 13/15] core: carry ICE restart session in call extra info (#7718) --- docs/protocol/simplex-chat.md | 4 +++- docs/protocol/simplex-chat.schema.json | 3 +++ src/Simplex/Chat/Call.hs | 3 ++- tests/ChatTests/Direct.hs | 7 +++++++ tests/ProtocolTests.hs | 6 ++++++ 5 files changed, 21 insertions(+), 2 deletions(-) diff --git a/docs/protocol/simplex-chat.md b/docs/protocol/simplex-chat.md index 8cd655d0c0..a95e7ee75f 100644 --- a/docs/protocol/simplex-chat.md +++ b/docs/protocol/simplex-chat.md @@ -286,7 +286,9 @@ These message are used for WebRTC calls: 3. `x.call.answer`: to continue with call connection the initiating clients must reply with `x.call.answer` message. This message contains WebRTC answer and collected ICE candidates. Additional ICE candidates can be sent in `x.call.extra` message. -4. `x.call.end` message is sent to notify the other party that the call is terminated. +4. `x.call.extra` with `rtcSession` property: when an established connection is lost, the client that sent `x.call.offer` restarts ICE and sends the new offer in `rtcSession` of `x.call.extra`. The other client replies with the answer in `rtcSession` of `x.call.extra`. Clients that do not support it ignore this property, and the call ends when reconnection times out. + +5. `x.call.end` message is sent to notify the other party that the call is terminated. Call messages have their own version, independent of the chat protocol version. The initiating client sends the range of call versions it supports in `callVRange` property of `x.call.inv`. The receiving client chooses the highest version supported by both clients and sends it in `callVersion` property of `x.call.offer`. The initiating client rejects the offer if this version is not in the range it sent. A missing range or version means version 1. diff --git a/docs/protocol/simplex-chat.schema.json b/docs/protocol/simplex-chat.schema.json index 5fb4794094..a9bb2d8735 100644 --- a/docs/protocol/simplex-chat.schema.json +++ b/docs/protocol/simplex-chat.schema.json @@ -364,6 +364,9 @@ "rtcExtraInfo": { "properties": { "rtcIceCandidates": {"type": "string"} + }, + "optionalProperties": { + "rtcSession": {"type": "string"} } } } diff --git a/src/Simplex/Chat/Call.hs b/src/Simplex/Chat/Call.hs index f64cd8e736..fec5473996 100644 --- a/src/Simplex/Chat/Call.hs +++ b/src/Simplex/Chat/Call.hs @@ -230,7 +230,8 @@ data WebRTCSession = WebRTCSession deriving (Eq, Show) data WebRTCExtraInfo = WebRTCExtraInfo - { rtcIceCandidates :: Text -- LZW compressed JSON encoding of array of ICE candidates + { rtcSession :: Maybe Text, -- LZW compressed JSON encoding of ICE restart offer or answer + rtcIceCandidates :: Text -- LZW compressed JSON encoding of array of ICE candidates } deriving (Eq, Show) diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 7300fcff22..3ce8f97130 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -1362,6 +1362,13 @@ testNegotiateCall = bob <## "ok" threadDelay 100000 bob #$> ("/_get chat @2 count=100", callChat, chatFeatures <> [(0, "incoming call: in progress")]) + -- either party can restart ICE of a connected call + bob ##> "/_call extra @2 {\"rtcSession\":\"{}\",\"rtcIceCandidates\":\"[]\"}" + bob <## "ok" + alice <## "call extra info from bob" + alice ##> "/_call extra @2 {\"rtcSession\":\"{}\",\"rtcIceCandidates\":\"[]\"}" + alice <## "ok" + bob <## "call extra info from alice" -- either party can end the call bob ##> "/_call end @2" bob <## "ok" diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index fb9e8317c3..79415071f7 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -433,5 +433,11 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do it "x.call.offer with call version" $ "{\"v\":\"9\",\"event\":\"x.call.offer\",\"params\":{\"callId\":\"AQIDBA==\",\"offer\":{\"callType\":{\"media\":\"video\",\"capabilities\":{\"encryption\":true}},\"rtcSession\":{\"rtcSession\":\"{}\",\"rtcIceCandidates\":\"[]\"},\"callVersion\":2}}}" #==# XCallOffer (CallId "\1\2\3\4") CallOffer {callType = defaultCallType, rtcSession = testRTCSession, callDhPubKey = Nothing, callVersion = Just $ VersionCall 2} + it "x.call.extra without session" $ + "{\"v\":\"9\",\"event\":\"x.call.extra\",\"params\":{\"callId\":\"AQIDBA==\",\"extra\":{\"rtcExtraInfo\":{\"rtcIceCandidates\":\"[]\"}}}}" + #==# XCallExtra (CallId "\1\2\3\4") CallExtraInfo {rtcExtraInfo = WebRTCExtraInfo {rtcSession = Nothing, rtcIceCandidates = "[]"}} + it "x.call.extra with ICE restart session" $ + "{\"v\":\"9\",\"event\":\"x.call.extra\",\"params\":{\"callId\":\"AQIDBA==\",\"extra\":{\"rtcExtraInfo\":{\"rtcSession\":\"{}\",\"rtcIceCandidates\":\"[]\"}}}}" + #==# XCallExtra (CallId "\1\2\3\4") CallExtraInfo {rtcExtraInfo = WebRTCExtraInfo {rtcSession = Just "{}", rtcIceCandidates = "[]"}} where testRTCSession = WebRTCSession {rtcSession = "{}", rtcIceCandidates = "[]"} From d04510f66f410f77679569040ab84b61cf67921f Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:26:22 +0000 Subject: [PATCH 14/15] ci: add a placeholder for the Android app test workflow, so it can be started by hand on its branch (#7772) --- .github/workflows/android-emu.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/workflows/android-emu.yml diff --git a/.github/workflows/android-emu.yml b/.github/workflows/android-emu.yml new file mode 100644 index 0000000000..1d195d653b --- /dev/null +++ b/.github/workflows/android-emu.yml @@ -0,0 +1,30 @@ +name: android-emu + +# A placeholder, so that the Android app test workflow can be started by hand on a branch that has its full +# version: GitHub dispatches only workflows whose file is on the default branch. On master it does nothing. +on: + workflow_dispatch: + inputs: + claude: + description: Also run the Claude job + type: boolean + default: false + backend: + description: Also start the test backend + type: boolean + default: false + release: + description: Also test the latest release's onboarding + type: boolean + default: false + +permissions: + contents: read + +jobs: + placeholder: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Nothing to run here + run: echo "This branch has only the placeholder of android-emu; start it on a branch with the full workflow." From 3e6fd3836b6973093f1c2854f1673c64b2ccc028 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 19 Oct 2026 12:47:25 +0100 Subject: [PATCH 15/15] core: round down time of sent messages to prevent replies showing above if they are within one second (#7695) * core: round down time of sent messages to prevent replies showing above if they are within one second * fix item timestamps * query plans --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- src/Simplex/Chat/Library/Commands.hs | 2 +- src/Simplex/Chat/Library/Internal.hs | 17 +++++++++++------ src/Simplex/Chat/Store/Messages.hs | 6 +++--- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 788b29237d..b218305adb 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -2686,7 +2686,7 @@ processChatCommand cxt nm = \case combineResults _ _ (Left e) = Left e createCI :: DB.Connection -> User -> Bool -> UTCTime -> (Contact, SndMessage) -> IO () createCI db user hasLink createdAt (ct, sndMsg) = - void $ createNewSndChatItem db user (CDDirectSnd ct) False sndMsg (CISndMsgContent mc) Nothing Nothing Nothing False hasLink createdAt + void $ createNewSndChatItem db user (CDDirectSnd ct) False sndMsg (CISndMsgContent mc) Nothing Nothing Nothing False hasLink (roundDownToSecond createdAt) createdAt SendMessageQuote cName (AMsgDirection msgDir) quotedMsg msg -> withUser $ \user@User {userId} -> do contactId <- withFastStore $ \db -> getContactIdByName db user cName quotedItemId <- withFastStore $ \db -> getDirectChatItemIdByText db userId contactId msgDir quotedMsg diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index d4e9893bd9..cd89881e4f 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -40,7 +40,7 @@ import Data.Foldable (foldr') import Data.Functor (($>)) import Data.Functor.Identity import Data.Int (Int64) -import Data.List (find, foldl', mapAccumL, partition) +import Data.List (foldl', mapAccumL, partition) import Data.List.NonEmpty (NonEmpty (..), (<|)) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) @@ -3020,13 +3020,17 @@ saveSndChatItems user cd showGroupAsSender itemsData itemTimed live = do createItem :: DB.Connection -> UTCTime -> NewSndChatItemData c -> IO (Either ChatError (ChatItem c 'MDSnd)) createItem db createdAt NewSndChatItemData {msg = msg@SndMessage {sharedMsgId, signedMsg_}, content, itemTexts = itemTexts@(_, ParsedMarkdown ft_ _), itemMentions, ciFile, quotedItem, itemForwarded} = do let hasLink_ = ciContentHasLink content ft_ - ciId <- createNewSndChatItem db user cd showGroupAsSender msg content quotedItem itemForwarded itemTimed live hasLink_ createdAt + itemTs = roundDownToSecond createdAt + ciId <- createNewSndChatItem db user cd showGroupAsSender msg content quotedItem itemForwarded itemTimed live hasLink_ itemTs createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt - let ci = mkChatItem_ cd showGroupAsSender ciId content itemTexts ciFile quotedItem (Just sharedMsgId) itemForwarded itemTimed live False hasLink_ createdAt Nothing (toMsgVerified (signMessagesRequired cd) (MSSVerified <$ signedMsg_)) createdAt + let ci = mkChatItem_ cd showGroupAsSender ciId content itemTexts ciFile quotedItem (Just sharedMsgId) itemForwarded itemTimed live False hasLink_ itemTs Nothing (toMsgVerified (signMessagesRequired cd) (MSSVerified <$ signedMsg_)) createdAt Right <$> case cd of CDGroupSnd g _scope | not (null itemMentions) -> createGroupCIMentions db g ci itemMentions _ -> pure ci +roundDownToSecond :: UTCTime -> UTCTime +roundDownToSecond t@UTCTime {utctDayTime} = t {utctDayTime = fromInteger (floor utctDayTime)} + saveRcvChatItemNoParse :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> UTCTime -> CIContent 'MDRcv -> CM (ChatItem c 'MDRcv, ChatInfo c) saveRcvChatItemNoParse user cd msg brokerTs = saveRcvChatItem user cd msg brokerTs . ciContentNoParse @@ -3363,7 +3367,7 @@ createChatItems :: CM' [Either ChatError AChatItem] createChatItems user itemTs_ dirsCIContents = do createdAt <- liftIO getCurrentTime - let itemTs = fromMaybe createdAt itemTs_ + let itemTs = fromMaybe (roundDownToSecond createdAt) itemTs_ cxt <- asks storeCxt void . withStoreBatch' $ \db -> map (updateChat db cxt createdAt) dirsCIContents withStoreBatch' $ \db -> concatMap (createACIs db itemTs createdAt) dirsCIContents @@ -3419,9 +3423,10 @@ createLocalChatItems user cd itemsData createdAt = do createItem :: DB.Connection -> (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, ParsedMarkdown)) -> IO (ChatItem 'CTLocal 'MDSnd) createItem db (content, ciFile, itemForwarded, ts@(_, ParsedMarkdown ft_ _)) = do let hasLink_ = ciContentHasLink content ft_ - ciId <- createNewChatItem_ db user cd False Nothing Nothing content (Nothing, Nothing, Nothing, Nothing, Nothing) itemForwarded Nothing False False hasLink_ createdAt Nothing Nothing Nothing Nothing createdAt + itemTs = roundDownToSecond createdAt + ciId <- createNewChatItem_ db user cd False Nothing Nothing content (Nothing, Nothing, Nothing, Nothing, Nothing) itemForwarded Nothing False False hasLink_ itemTs Nothing Nothing Nothing Nothing createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt - pure $ mkChatItem_ cd False ciId content ts ciFile Nothing Nothing itemForwarded Nothing False False hasLink_ createdAt Nothing Nothing createdAt + pure $ mkChatItem_ cd False ciId content ts ciFile Nothing Nothing itemForwarded Nothing False False hasLink_ itemTs Nothing Nothing createdAt withUser' :: (User -> CM ChatResponse) -> CM ChatResponse withUser' action = diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 5ad9107e6f..ee5a7f1659 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -546,9 +546,9 @@ setSupportChatMemberAttention db cxt user g m memberAttention = do 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 -createNewSndChatItem db user chatDirection showGroupAsSender SndMessage {msgId, sharedMsgId, signedMsg_} ciContent quotedItem itemForwarded timed live hasLink createdAt = - createNewChatItem_ db user chatDirection showGroupAsSender createdByMsgId (Just sharedMsgId) ciContent quoteRow itemForwarded timed live False hasLink createdAt Nothing (toMsgVerified (signMessagesRequired chatDirection) (MSSVerified <$ signedMsg_)) signedMsg_ Nothing createdAt +createNewSndChatItem :: DB.Connection -> User -> ChatDirection c 'MDSnd -> ShowGroupAsSender -> SndMessage -> CIContent 'MDSnd -> Maybe (CIQuote c) -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> UTCTime -> UTCTime -> IO ChatItemId +createNewSndChatItem db user chatDirection showGroupAsSender SndMessage {msgId, sharedMsgId, signedMsg_} ciContent quotedItem itemForwarded timed live hasLink itemTs createdAt = + createNewChatItem_ db user chatDirection showGroupAsSender createdByMsgId (Just sharedMsgId) ciContent quoteRow itemForwarded timed live False hasLink itemTs Nothing (toMsgVerified (signMessagesRequired chatDirection) (MSSVerified <$ signedMsg_)) signedMsg_ Nothing createdAt where createdByMsgId = if msgId == 0 then Nothing else Just msgId quoteRow :: NewQuoteRow