diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index f29f118ecc..4525f3c5dc 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -17,7 +17,7 @@ jobs: strategy: matrix: - node-version: [12.x] + node-version: [16.x] steps: - uses: actions/checkout@v2 diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index f7bf411735..e68508ccc3 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -30,7 +30,7 @@ In a more narrow sense, particularly in media, blockchain is used to refer speci Centralized networks are provided or controlled by a single entity. The examples are Threema, Signal, WhatsApp and Telegram. The advantage of that design is that the provider can innovate faster, and has a centralized approach to security. But the disadvantage is that the provider can change or discontinue the service, and leak, sell or disclose in some other way all users' data, including who they are connected with. -## Content padding +## Content padding [Message padding](#message-padding). @@ -149,7 +149,7 @@ Generalizing [the definition](https://csrc.nist.gov/glossary/term/pairwise_pseud In the context of SimpleX network, these are the identifiers generated by SMP relays to access anonymous messaging queues, with a separate identifier (and access credential) for each accessing party: recipient, sender and and optional notifications subscriber. The same approach is used by XFTP relays to access file chunks, with separate identifiers (and access credentials) for sender and each recipient. -## Peer-to-peer +## Peer-to-peer Peer-to-peer (P2P) is the network architecture when participants have equal rights and communicate directly via a general purpose transport or overlay network. Unlike client-server architecture, all peers in a P2P network both provide and consume the resources. In the context of messaging, P2P architecture usually means that the messages are sent between peers, without user accounts or messages being stored on any servers. Examples are Tox, Briar, Cwtch and many others. diff --git a/docs/SERVER.md b/docs/SERVER.md index cee3320e4c..e440de944f 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -1,15 +1,15 @@ --- title: Hosting your own SMP Server -revision: 31.01.2023 +revision: 05.06.2023 --- -| Updated 31.01.2023 | Languages: EN, [FR](/docs/lang/fr/SERVER.md), [CZ](/docs/lang/cs/SERVER.md) | +| Updated 05.06.2023 | Languages: EN, [FR](/docs/lang/fr/SERVER.md), [CZ](/docs/lang/cs/SERVER.md) | # Hosting your own SMP Server ## Overview -SMP server is the relay server used to pass messages in SimpleX network. SimpleX Chat apps have preset servers (for mobile apps these are smp8, smp9 and smp10.simplex.im), but you can easily change app configuration to use other servers. +SMP server is the relay server used to pass messages in SimpleX network. SimpleX Chat apps have preset servers (for mobile apps these are smp11, smp12 and smp14.simplex.im), but you can easily change app configuration to use other servers. SimpleX clients only determine which server is used to receive the messages, separately for each contact (or group connection with a group member), and these servers are only temporary, as the delivery address can change. diff --git a/docs/lang/cs/SERVER.md b/docs/lang/cs/SERVER.md index 2030989150..f8258909bc 100644 --- a/docs/lang/cs/SERVER.md +++ b/docs/lang/cs/SERVER.md @@ -1,14 +1,14 @@ --- title: Hostování vlastního serveru SMP -revision: 31.01.2023 +revision: 05.06.2023 --- -| Aktualizováno 31.01.2023 | Jazyky: CZ, [EN](/docs/SERVER.md), [FR](/docs/lang/fr/SERVER.md) | +| Aktualizováno 05.06.2023 | Jazyky: CZ, [EN](/docs/SERVER.md), [FR](/docs/lang/fr/SERVER.md) | # Hostování vlastního serveru SMP ## Přehled -SMP server je relay server používaný k předávání zpráv v síti SimpleX. Aplikace SimpleX Chat mají přednastavené servery (pro mobilní aplikace jsou to smp8, smp9 a smp10.simplex.im), ale konfiguraci aplikace můžete snadno změnit a používat jiné servery. +SMP server je relay server používaný k předávání zpráv v síti SimpleX. Aplikace SimpleX Chat mají přednastavené servery (pro mobilní aplikace jsou to smp11, smp12 a smp14.simplex.im), ale konfiguraci aplikace můžete snadno změnit a používat jiné servery. Klienti SimpleX pouze určují, který server bude použit pro příjem zpráv, a to pro každý kontakt (nebo spojení skupiny s členem skupiny) zvlášť, přičemž tyto servery jsou pouze dočasné, protože adresa pro doručování se může změnit. diff --git a/docs/lang/fr/SERVER.md b/docs/lang/fr/SERVER.md index aebddac983..ac9cec7eb9 100644 --- a/docs/lang/fr/SERVER.md +++ b/docs/lang/fr/SERVER.md @@ -1,14 +1,14 @@ --- title: Héberger votre propre serveur SMP -revision: 31.01.2023 +revision: 05.06.2023 --- -| 31.01.2023 | FR, [EN](/docs/SERVER.md), [CZ](/docs/lang/cs/SERVER.md) | +| 05.06.2023 | FR, [EN](/docs/SERVER.md), [CZ](/docs/lang/cs/SERVER.md) | # Héberger votre propre serveur SMP ## Présentation générale -Un serveur SMP est un serveur relais utilisé pour transmettre les messages sur le réseau SimpleX. Les apps SimpleX Chat ont des serveurs prédéfinis (pour les apps mobiles, smp8, smp9 et smp10.simplex.im), mais vous pouvez facilement modifier la configuration de l'app pour utiliser d'autres serveurs. +Un serveur SMP est un serveur relais utilisé pour transmettre les messages sur le réseau SimpleX. Les apps SimpleX Chat ont des serveurs prédéfinis (pour les apps mobiles, smp11, smp12 et smp14.simplex.im), mais vous pouvez facilement modifier la configuration de l'app pour utiliser d'autres serveurs. Seuls les utilisateurs de SimpleX déterminent quel serveur est utilisé pour recevoir les messages, séparément pour chaque contact (ou pour chaque connexion à un membre d'un groupe), et ces serveurs ne sont que temporaires, car l'adresse de réception peut changer. diff --git a/package.yaml b/package.yaml index 1710487fbd..c7b5aca491 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 5.1.2.0 +version: 5.1.3.0 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/simplex-chat.cabal b/simplex-chat.cabal index c101f69fd4..6d9b3030d9 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: 5.1.2.0 +version: 5.1.3.0 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat @@ -100,6 +100,7 @@ library Simplex.Chat.Migrations.M20230519_item_deleted_ts Simplex.Chat.Migrations.M20230526_indexes Simplex.Chat.Migrations.M20230529_indexes + Simplex.Chat.Migrations.M20230608_deleted_contacts Simplex.Chat.Mobile Simplex.Chat.Mobile.WebRTC Simplex.Chat.Options diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 8dab2e8d38..b983c19266 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -121,15 +121,16 @@ defaultChatConfig = testView = False, initialCleanupManagerDelay = 30 * 1000000, -- 30 seconds cleanupManagerInterval = 30 * 60, -- 30 minutes + cleanupManagerStepDelay = 3 * 1000000, -- 3 seconds ciExpirationInterval = 30 * 60 * 1000000 -- 30 minutes } _defaultSMPServers :: NonEmpty SMPServerWithAuth _defaultSMPServers = L.fromList - [ "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im,beccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion", - "smp://SkIkI6EPd2D63F4xFKfHk7I1UGZVNn6k1QWZ5rcyr6w=@smp9.simplex.im,jssqzccmrcws6bhmn77vgmhfjmhwlyr3u7puw4erkyoosywgl67slqqd.onion", - "smp://6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE=@smp10.simplex.im,rb2pbttocvnbrngnwziclp2f4ckjq65kebafws6g4hy22cdaiv5dwjqd.onion" + [ "smp://1OwYGt-yqOfe2IyVHhxz3ohqo3aCCMjtB-8wn4X_aoY=@smp11.simplex.im,6ioorbm6i3yxmuoezrhjk6f6qgkc4syabh7m3so74xunb5nzr4pwgfqd.onion", + "smp://UkMFNAXLXeAAe0beCa4w6X_zp18PwxSaSjY17BKUGXQ=@smp12.simplex.im,ie42b5weq7zdkghocs3mgxdjeuycheeqqmksntj57rmejagmg4eor5yd.onion", + "smp://enEkec4hlR3UtKx2NMpOUK_K4ZuDxjWBO1d9Y4YXVaA=@smp14.simplex.im,aspkyu2sopsnizbyfabtsicikr2s4r3ti35jogbcekhm3fsoeyjvgrid.onion" ] _defaultNtfServers :: [NtfServer] @@ -867,7 +868,7 @@ processChatCommand = \case Just _ -> pure [] Nothing -> do conns <- withStore $ \db -> getContactConnections db userId ct - withStore' (\db -> deleteContactWithoutGroups db user ct) + withStore' (\db -> setContactDeleted db user ct) `catchError` (toView . CRChatError (Just user)) pure $ map aConnId conns CTContactRequest -> pure $ chatCmdError (Just user) "not supported" @@ -2358,15 +2359,16 @@ cleanupManager :: forall m. ChatMonad m => m () cleanupManager = do interval <- asks (cleanupManagerInterval . config) runWithoutInitialDelay interval - delay <- asks (initialCleanupManagerDelay . config) - liftIO $ threadDelay' delay + initialDelay <- asks (initialCleanupManagerDelay . config) + liftIO $ threadDelay' initialDelay + stepDelay <- asks (cleanupManagerStepDelay . config) forever $ do flip catchError (toView . CRChatError Nothing) $ do waitChatStarted users <- withStoreCtx' (Just "cleanupManager, getUsers 1") getUsers let (us, us') = partition activeUser users - forM_ us $ cleanupUser interval - forM_ us' $ cleanupUser interval + forM_ us $ cleanupUser interval stepDelay + forM_ us' $ cleanupUser interval stepDelay cleanupMessages `catchError` (toView . CRChatError Nothing) liftIO $ threadDelay' $ diffToMicroseconds interval where @@ -2376,13 +2378,21 @@ cleanupManager = do let (us, us') = partition activeUser users forM_ us $ \u -> cleanupTimedItems cleanupInterval u `catchError` (toView . CRChatError (Just u)) forM_ us' $ \u -> cleanupTimedItems cleanupInterval u `catchError` (toView . CRChatError (Just u)) - cleanupUser cleanupInterval user = + cleanupUser cleanupInterval stepDelay user = do cleanupTimedItems cleanupInterval user `catchError` (toView . CRChatError (Just user)) + liftIO $ threadDelay' stepDelay + cleanupDeletedContacts user `catchError` (toView . CRChatError (Just user)) + liftIO $ threadDelay' stepDelay cleanupTimedItems cleanupInterval user = do ts <- liftIO getCurrentTime let startTimedThreadCutoff = addUTCTime cleanupInterval ts timedItems <- withStoreCtx' (Just "cleanupManager, getTimedItems") $ \db -> getTimedItems db user startTimedThreadCutoff forM_ timedItems $ \(itemRef, deleteAt) -> startTimedItemThread user itemRef deleteAt `catchError` const (pure ()) + cleanupDeletedContacts user = do + contacts <- withStore' (`getDeletedContacts` user) + forM_ contacts $ \ct -> + withStore' (\db -> deleteContactWithoutGroups db user ct) + `catchError` (toView . CRChatError (Just user)) cleanupMessages = do ts <- liftIO getCurrentTime let cutoffTs = addUTCTime (- (30 * nominalDay)) ts @@ -4295,7 +4305,9 @@ throwChatError = throwError . ChatError deleteMembersConnections :: ChatMonad m => User -> [GroupMember] -> m () deleteMembersConnections user members = do - let memberConns = mapMaybe (\GroupMember {activeConn} -> activeConn) members + let memberConns = + filter (\Connection {connStatus} -> connStatus /= ConnDeleted) $ + mapMaybe (\GroupMember {activeConn} -> activeConn) members deleteAgentConnectionsAsync user $ map aConnId memberConns forM_ memberConns $ \conn -> withStore' $ \db -> updateConnectionStatus db conn ConnDeleted @@ -4719,7 +4731,6 @@ withStoreCtx ctx_ action = do ChatController {chatStore} <- ask liftEitherError ChatErrorStore $ case ctx_ of Nothing -> withTransaction chatStore (runExceptT . action) `E.catch` handleInternal "" - Just _ -> withTransaction chatStore (runExceptT . action) `E.catch` handleInternal "" -- uncomment to debug store performance -- Just ctx -> do -- t1 <- liftIO getCurrentTime @@ -4728,6 +4739,7 @@ withStoreCtx ctx_ action = do -- t2 <- liftIO getCurrentTime -- putStrLn $ "withStoreCtx end :: " <> show t2 <> " :: " <> ctx <> " :: duration=" <> show (diffToMilliseconds $ diffUTCTime t2 t1) -- pure r + Just _ -> withTransaction chatStore (runExceptT . action) `E.catch` handleInternal "" where handleInternal :: String -> E.SomeException -> IO (Either StoreError a) handleInternal ctxStr e = pure . Left . SEInternalError $ show e <> ctxStr diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index ce152fe0fe..951ddb7735 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -112,6 +112,7 @@ data ChatConfig = ChatConfig testView :: Bool, initialCleanupManagerDelay :: Int64, cleanupManagerInterval :: NominalDiffTime, + cleanupManagerStepDelay :: Int64, ciExpirationInterval :: Int64 -- microseconds } diff --git a/src/Simplex/Chat/Migrations/M20230608_deleted_contacts.hs b/src/Simplex/Chat/Migrations/M20230608_deleted_contacts.hs new file mode 100644 index 0000000000..b7193300df --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20230608_deleted_contacts.hs @@ -0,0 +1,22 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20230608_deleted_contacts where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20230608_deleted_contacts :: Query +m20230608_deleted_contacts = + [sql| +ALTER TABLE contacts ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0; + +CREATE INDEX msg_delivery_events_msg_delivery_id ON msg_delivery_events(msg_delivery_id); +|] + +down_m20230608_deleted_contacts :: Query +down_m20230608_deleted_contacts = + [sql| +DROP INDEX msg_delivery_events_msg_delivery_id; + +ALTER TABLE contacts DROP COLUMN deleted; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 04d4c4cd7b..95f22cef39 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -63,6 +63,7 @@ CREATE TABLE contacts( contact_used INTEGER DEFAULT 0 CHECK(contact_used NOT NULL), user_preferences TEXT DEFAULT '{}' CHECK(user_preferences NOT NULL), chat_ts TEXT, + deleted INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -652,3 +653,6 @@ CREATE INDEX idx_msg_deliveries_agent_ack_cmd_id ON msg_deliveries( connection_id, agent_ack_cmd_id ); +CREATE INDEX msg_delivery_events_msg_delivery_id ON msg_delivery_events( + msg_delivery_id +); diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index a1d605e715..dd5e05d513 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -52,6 +52,8 @@ module Simplex.Chat.Store deleteContactConnectionsAndFiles, deleteContact, deleteContactWithoutGroups, + setContactDeleted, + getDeletedContacts, getContactByName, getContact, getContactIdByName, @@ -395,6 +397,7 @@ import Simplex.Chat.Migrations.M20230511_reactions import Simplex.Chat.Migrations.M20230519_item_deleted_ts import Simplex.Chat.Migrations.M20230526_indexes import Simplex.Chat.Migrations.M20230529_indexes +import Simplex.Chat.Migrations.M20230608_deleted_contacts import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Util (week) @@ -476,7 +479,8 @@ schemaMigrations = ("20230511_reactions", m20230511_reactions, Just down_m20230511_reactions), ("20230519_item_deleted_ts", m20230519_item_deleted_ts, Just down_m20230519_item_deleted_ts), ("20230526_indexes", m20230526_indexes, Just down_m20230526_indexes), - ("20230529_indexes", m20230529_indexes, Just down_m20230529_indexes) + ("20230529_indexes", m20230529_indexes, Just down_m20230529_indexes), + ("20230608_deleted_contacts", m20230608_deleted_contacts, Just down_m20230608_deleted_contacts) ] -- | The list of migrations in ascending order by date @@ -547,7 +551,7 @@ getUsersInfo db = getUsers db >>= mapM getUserInfo SELECT COUNT(1) FROM chat_items i JOIN contacts ct USING (contact_id) - WHERE i.user_id = ? AND i.item_status = ? AND (ct.enable_ntfs = 1 OR ct.enable_ntfs IS NULL) + WHERE i.user_id = ? AND i.item_status = ? AND (ct.enable_ntfs = 1 OR ct.enable_ntfs IS NULL) AND ct.deleted = 0 |] (userId, CISRcvNew) gCount <- @@ -622,7 +626,7 @@ getUserByARcvFileId db aRcvFileId = getUserByContactId :: DB.Connection -> ContactId -> ExceptT StoreError IO User getUserByContactId db contactId = ExceptT . firstRow toUser (SEUserNotFoundByContactId contactId) $ - DB.query db (userQuery <> " JOIN contacts ct ON ct.user_id = u.user_id WHERE ct.contact_id = ?") (Only contactId) + DB.query db (userQuery <> " JOIN contacts ct ON ct.user_id = u.user_id WHERE ct.contact_id = ? AND ct.deleted = 0") (Only contactId) getUserByGroupId :: DB.Connection -> GroupId -> ExceptT StoreError IO User getUserByGroupId db groupId = @@ -711,7 +715,7 @@ getConnReqContactXContactId db user@User {userId} cReqHash = do FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id JOIN connections c ON c.contact_id = ct.contact_id - WHERE ct.user_id = ? AND c.via_contact_uri_hash = ? + WHERE ct.user_id = ? AND c.via_contact_uri_hash = ? AND ct.deleted = 0 ORDER BY c.connection_id DESC LIMIT 1 |] @@ -757,7 +761,6 @@ getProfileById db userId profileId = [sql| SELECT cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, cp.preferences -- , ct.user_preferences FROM contact_profiles cp - -- JOIN contacts ct ON cp.contact_profile_id = ct.contact_profile_id WHERE cp.user_id = ? AND cp.contact_profile_id = ? |] (userId, profileId) @@ -849,6 +852,19 @@ deleteContactWithoutGroups db user@User {userId} Contact {contactId, localDispla DB.execute db "DELETE FROM contacts WHERE user_id = ? AND contact_id = ?" (userId, contactId) forM_ customUserProfileId $ \profileId -> deleteUnusedIncognitoProfileById_ db user profileId +setContactDeleted :: DB.Connection -> User -> Contact -> IO () +setContactDeleted db User {userId} Contact {contactId} = do + currentTs <- getCurrentTime + DB.execute db "UPDATE contacts SET deleted = 1, updated_at = ? WHERE user_id = ? AND contact_id = ?" (currentTs, userId, contactId) + +getDeletedContacts :: DB.Connection -> User -> IO [Contact] +getDeletedContacts db user@User {userId} = do + contactIds <- map fromOnly <$> DB.query db "SELECT contact_id FROM contacts WHERE user_id = ? AND deleted = 1" (Only userId) + rights <$> mapM (runExceptT . getDeletedContact db user) contactIds + +getDeletedContact :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO Contact +getDeletedContact db user contactId = getContact_ db user contactId True + deleteUnusedIncognitoProfileById_ :: DB.Connection -> User -> ProfileId -> IO () deleteUnusedIncognitoProfileById_ db User {userId} profile_id = DB.executeNamed @@ -1061,7 +1077,7 @@ getContactByName db user localDisplayName = do getUserContacts :: DB.Connection -> User -> IO [Contact] getUserContacts db user@User {userId} = do - contactIds <- map fromOnly <$> DB.query db "SELECT contact_id FROM contacts WHERE user_id = ?" (Only userId) + contactIds <- map fromOnly <$> DB.query db "SELECT contact_id FROM contacts WHERE user_id = ? AND deleted = 0" (Only userId) rights <$> mapM (runExceptT . getContact db user) contactIds -- only used in tests @@ -1365,7 +1381,7 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId Profi FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id LEFT JOIN connections c ON c.contact_id = ct.contact_id - WHERE ct.user_id = ? AND ct.xcontact_id = ? + WHERE ct.user_id = ? AND ct.xcontact_id = ? AND ct.deleted = 0 ORDER BY c.connection_id DESC LIMIT 1 |] @@ -1615,6 +1631,7 @@ getMatchingContacts db user@User {userId} Contact {contactId, profile = LocalPro FROM contacts ct JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id WHERE ct.user_id = ? AND ct.contact_id != ? + AND ct.deleted = 0 AND p.display_name = ? AND p.full_name = ? AND ((p.image IS NULL AND ? IS NULL) OR p.image = ?) |] @@ -1657,7 +1674,7 @@ matchReceivedProbe db user@User {userId} _from@Contact {contactId} (Probe probe) SELECT c.contact_id FROM contacts c JOIN received_probes r ON r.contact_id = c.contact_id - WHERE c.user_id = ? AND r.probe_hash = ? AND r.probe IS NULL + WHERE c.user_id = ? AND c.deleted = 0 AND r.probe_hash = ? AND r.probe IS NULL |] (userId, probeHash) currentTs <- getCurrentTime @@ -1678,7 +1695,7 @@ matchReceivedProbeHash db user@User {userId} _from@Contact {contactId} (ProbeHas SELECT c.contact_id, r.probe FROM contacts c JOIN received_probes r ON r.contact_id = c.contact_id - WHERE c.user_id = ? AND r.probe_hash = ? AND r.probe IS NOT NULL + WHERE c.user_id = ? AND c.deleted = 0 AND r.probe_hash = ? AND r.probe IS NOT NULL |] (userId, probeHash) currentTs <- getCurrentTime @@ -1703,7 +1720,7 @@ matchSentProbe db user@User {userId} _from@Contact {contactId} (Probe probe) = d FROM contacts c JOIN sent_probes s ON s.contact_id = c.contact_id JOIN sent_probe_hashes h ON h.sent_probe_id = s.sent_probe_id - WHERE c.user_id = ? AND s.probe = ? AND h.contact_id = ? + WHERE c.user_id = ? AND c.deleted = 0 AND s.probe = ? AND h.contact_id = ? |] (userId, probe, contactId) case contactIds of @@ -1808,7 +1825,7 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do p.preferences, c.user_preferences, c.created_at, c.updated_at, c.chat_ts FROM contacts c JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id - WHERE c.user_id = ? AND c.contact_id = ? + WHERE c.user_id = ? AND c.contact_id = ? AND c.deleted = 0 |] (userId, contactId) toContact' :: Int64 -> Connection -> [(ProfileId, ContactName, Text, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Int64, Bool, Maybe Bool) :. (Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime)] -> Either StoreError Contact @@ -1913,6 +1930,7 @@ getConnectionsContacts db agentConnIds = do JOIN connections c ON c.contact_id = ct.contact_id WHERE c.agent_conn_id IN (SELECT conn_id FROM temp.conn_ids) AND c.conn_type = ? + AND ct.deleted = 0 |] (Only ConnContact) DB.execute_ db "DROP TABLE temp.conn_ids" @@ -2388,7 +2406,7 @@ getContactViaMember db user@User {userId} GroupMember {groupMemberId} = where cc.contact_id = ct.contact_id ) JOIN group_members m ON m.contact_id = ct.contact_id - WHERE ct.user_id = ? AND m.group_member_id = ? + WHERE ct.user_id = ? AND m.group_member_id = ? AND ct.deleted = 0 |] (userId, groupMemberId) @@ -2697,7 +2715,7 @@ getViaGroupMember db User {userId, userContactId} Contact {contactId} = FROM connections cc where cc.group_member_id = m.group_member_id ) - WHERE ct.user_id = ? AND ct.contact_id = ? AND mu.contact_id = ? + WHERE ct.user_id = ? AND ct.contact_id = ? AND mu.contact_id = ? AND ct.deleted = 0 |] (userId, contactId, userContactId) where @@ -2727,7 +2745,7 @@ getViaGroupContact db user@User {userId} GroupMember {groupMemberId} = ) JOIN groups g ON g.group_id = ct.via_group JOIN group_members m ON m.group_id = g.group_id AND m.contact_id = ct.contact_id - WHERE ct.user_id = ? AND m.group_member_id = ? + WHERE ct.user_id = ? AND m.group_member_id = ? AND ct.deleted = 0 |] (userId, groupMemberId) where @@ -3826,6 +3844,7 @@ getDirectChatPreviews_ db user@User {userId} = do LEFT JOIN chat_items ri ON ri.user_id = i.user_id AND ri.contact_id = i.contact_id AND ri.shared_msg_id = i.quoted_shared_msg_id WHERE ct.user_id = ? AND ((c.conn_level = 0 AND c.via_group_link = 0) OR ct.contact_used = 1) + AND ct.deleted = 0 AND c.connection_id = ( SELECT cc_connection_id FROM ( SELECT @@ -4107,10 +4126,13 @@ getDirectChatBefore_ db User {userId} ct@Contact {contactId} beforeChatItemId co getContactIdByName :: DB.Connection -> User -> ContactName -> ExceptT StoreError IO Int64 getContactIdByName db User {userId} cName = ExceptT . firstRow fromOnly (SEContactNotFoundByName cName) $ - DB.query db "SELECT contact_id FROM contacts WHERE user_id = ? AND local_display_name = ?" (userId, cName) + DB.query db "SELECT contact_id FROM contacts WHERE user_id = ? AND local_display_name = ? AND deleted = 0" (userId, cName) getContact :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO Contact -getContact db user@User {userId} contactId = +getContact db user contactId = getContact_ db user contactId False + +getContact_ :: DB.Connection -> User -> Int64 -> Bool -> ExceptT StoreError IO Contact +getContact_ db user@User {userId} contactId deleted = ExceptT . fmap join . firstRow (toContactOrError user) (SEContactNotFound contactId) $ DB.query db @@ -4126,6 +4148,7 @@ getContact db user@User {userId} contactId = JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id LEFT JOIN connections c ON c.contact_id = ct.contact_id WHERE ct.user_id = ? AND ct.contact_id = ? + AND ct.deleted = ? AND c.connection_id = ( SELECT cc_connection_id FROM ( SELECT @@ -4138,7 +4161,7 @@ getContact db user@User {userId} contactId = ) ) |] - (userId, contactId, ConnReady, ConnSndReady) + (userId, contactId, deleted, ConnReady, ConnSndReady) getGroupChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTGroup) getGroupChat db user groupId pagination search_ = do @@ -5377,7 +5400,7 @@ getXGrpMemIntroContDirect db User {userId} Contact {contactId} = do FROM connections cc where cc.group_member_id = mh.group_member_id ) - WHERE ct.user_id = ? AND ct.contact_id = ? AND mh.member_category = ? + WHERE ct.user_id = ? AND ct.contact_id = ? AND ct.deleted = 0 AND mh.member_category = ? |] (userId, contactId, GCHostMember) where @@ -5407,7 +5430,7 @@ getXGrpMemIntroContGroup db User {userId} GroupMember {groupMemberId} = do FROM connections cc where cc.group_member_id = mh.group_member_id ) - WHERE m.user_id = ? AND m.group_member_id = ? AND mh.member_category = ? + WHERE m.user_id = ? AND m.group_member_id = ? AND mh.member_category = ? AND ct.deleted = 0 |] (userId, groupMemberId, GCHostMember) where diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 63ad8ca592..3d03f54b31 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -1327,7 +1327,7 @@ testUsersDifferentCIExpirationTTL tmp = do alice #$> ("/_get chat @4 count=100", chat, []) where - cfg = testCfg {initialCleanupManagerDelay = 0, ciExpirationInterval = 500000} + cfg = testCfg {initialCleanupManagerDelay = 0, cleanupManagerStepDelay = 0, ciExpirationInterval = 500000} testUsersRestartCIExpiration :: HasCallStack => FilePath -> IO () testUsersRestartCIExpiration tmp = do @@ -1410,7 +1410,7 @@ testUsersRestartCIExpiration tmp = do alice #$> ("/_get chat @4 count=100", chat, []) where - cfg = testCfg {initialCleanupManagerDelay = 0, ciExpirationInterval = 500000} + cfg = testCfg {initialCleanupManagerDelay = 0, cleanupManagerStepDelay = 0, ciExpirationInterval = 500000} testEnableCIExpirationOnlyForOneUser :: HasCallStack => FilePath -> IO () testEnableCIExpirationOnlyForOneUser tmp = do @@ -1481,7 +1481,7 @@ testEnableCIExpirationOnlyForOneUser tmp = do -- new messages are not deleted for second user alice #$> ("/_get chat @4 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4"), (1, "alisa 5"), (0, "alisa 6")]) where - cfg = testCfg {initialCleanupManagerDelay = 0, ciExpirationInterval = 500000} + cfg = testCfg {initialCleanupManagerDelay = 0, cleanupManagerStepDelay = 0, ciExpirationInterval = 500000} testDisableCIExpirationOnlyForOneUser :: HasCallStack => FilePath -> IO () testDisableCIExpirationOnlyForOneUser tmp = do @@ -1539,7 +1539,7 @@ testDisableCIExpirationOnlyForOneUser tmp = do -- second user messages are deleted alice #$> ("/_get chat @4 count=100", chat, []) where - cfg = testCfg {initialCleanupManagerDelay = 0, ciExpirationInterval = 500000} + cfg = testCfg {initialCleanupManagerDelay = 0, cleanupManagerStepDelay = 0, ciExpirationInterval = 500000} testUsersTimedMessages :: HasCallStack => FilePath -> IO () testUsersTimedMessages tmp = do diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 305f1620c1..85b9c720b1 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -9,6 +9,7 @@ import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) import Control.Monad (when) import qualified Data.Text as T +import Simplex.Chat.Controller (ChatConfig (..)) import Simplex.Chat.Store (agentStoreFile, chatStoreFile) import Simplex.Chat.Types (GroupMemberRole (..)) import System.Directory (copyFile) @@ -420,7 +421,7 @@ testGroup2 = testGroupDelete :: HasCallStack => FilePath -> IO () testGroupDelete = - testChat3 aliceProfile bobProfile cathProfile $ + testChatCfg3 cfg aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath alice ##> "/d #team" @@ -444,12 +445,15 @@ testGroupDelete = alice <##> bob alice <##> cath -- unused group contacts are deleted + threadDelay 3000000 bob ##> "@cath hi" bob <## "no contact cath" (cath "@bob hi" cath <## "no contact bob" (bob FilePath -> IO () testGroupSameName = @@ -1151,7 +1155,7 @@ testUpdateMemberRole = testGroupDeleteUnusedContacts :: HasCallStack => FilePath -> IO () testGroupDeleteUnusedContacts = - testChat3 aliceProfile bobProfile cathProfile $ + testChatCfg3 cfg aliceProfile bobProfile cathProfile $ \alice bob cath -> do -- create group 1 createGroup3 "team" alice bob cath @@ -1210,6 +1214,7 @@ testGroupDeleteUnusedContacts = cath `hasContactProfiles` ["alice", "bob", "cath"] -- delete group 2, unused contacts and profiles are deleted deleteGroup alice bob cath "club" + threadDelay 3000000 bob ##> "/contacts" bob <## "alice (Alice)" bob `hasContactProfiles` ["alice", "bob"] @@ -1217,6 +1222,7 @@ testGroupDeleteUnusedContacts = cath <## "alice (Alice)" cath `hasContactProfiles` ["alice", "cath"] where + cfg = testCfg {initialCleanupManagerDelay = 0, cleanupManagerInterval = 1, cleanupManagerStepDelay = 0} deleteGroup :: HasCallStack => TestCC -> TestCC -> TestCC -> String -> IO () deleteGroup alice bob cath group = do alice ##> ("/d #" <> group) @@ -1827,7 +1833,7 @@ testGroupLinkIncognitoMembership = testGroupLinkUnusedHostContactDeleted :: HasCallStack => FilePath -> IO () testGroupLinkUnusedHostContactDeleted = - testChat2 aliceProfile bobProfile $ + testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do -- create group 1 alice ##> "/g team" @@ -1881,10 +1887,12 @@ testGroupLinkUnusedHostContactDeleted = bob `hasContactProfiles` ["alice", "bob"] -- delete group 2, unused host contact and profile are deleted bobLeaveDeleteGroup alice bob "club" + threadDelay 3000000 bob ##> "/contacts" (bob TestCC -> TestCC -> String -> IO () bobLeaveDeleteGroup alice bob group = do bob ##> ("/l " <> group) @@ -1899,7 +1907,7 @@ testGroupLinkUnusedHostContactDeleted = testGroupLinkIncognitoUnusedHostContactsDeleted :: HasCallStack => FilePath -> IO () testGroupLinkIncognitoUnusedHostContactsDeleted = - testChat2 aliceProfile bobProfile $ + testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do bob #$> ("/incognito on", id, "ok") bobIncognitoTeam <- createGroupBobIncognito alice bob "team" "alice" @@ -1912,15 +1920,18 @@ testGroupLinkIncognitoUnusedHostContactsDeleted = bob `hasContactProfiles` ["alice", "alice", "bob", T.pack bobIncognitoTeam, T.pack bobIncognitoClub] -- delete group 1, unused host contact and profile are deleted bobLeaveDeleteGroup alice bob "team" bobIncognitoTeam + threadDelay 3000000 bob ##> "/contacts" bob <## "i alice_1 (Alice)" bob `hasContactProfiles` ["alice", "bob", T.pack bobIncognitoClub] -- delete group 2, unused host contact and profile are deleted bobLeaveDeleteGroup alice bob "club" bobIncognitoClub + threadDelay 3000000 bob ##> "/contacts" (bob TestCC -> TestCC -> String -> String -> IO String createGroupBobIncognito alice bob group bobsAliceContact = do alice ##> ("/g " <> group) diff --git a/website/.eleventy.js b/website/.eleventy.js index bf0bf8131f..1592f5ab95 100644 --- a/website/.eleventy.js +++ b/website/.eleventy.js @@ -3,10 +3,47 @@ const markdownItAnchor = require("markdown-it-anchor") const markdownItReplaceLink = require('markdown-it-replace-link') const slugify = require("slugify") const uri = require('fast-uri') -const i18n = require('eleventy-plugin-i18n'); -const fs = require("fs"); -const path = require("path"); -const pluginRss = require('@11ty/eleventy-plugin-rss'); +const i18n = require('eleventy-plugin-i18n') +const fs = require("fs") +const path = require("path") +const pluginRss = require('@11ty/eleventy-plugin-rss') +const { JSDOM } = require('jsdom') + + +// The implementation of Glossary feature +const md = new markdownIt() +const glossaryMarkdownContent = fs.readFileSync(path.resolve(__dirname, '../docs/GLOSSARY.md'), 'utf8') +const glossaryHtmlContent = md.render(glossaryMarkdownContent) +const glossaryDOM = new JSDOM(glossaryHtmlContent) +const glossaryDocument = glossaryDOM.window.document +const glossary = require('./src/_data/glossary.json') + +glossary.forEach(item => { + const headers = Array.from(glossaryDocument.querySelectorAll("h2")) + const matchingHeader = headers.find(header => header.textContent.trim() === item.definition) + + if (matchingHeader) { + let sibling = matchingHeader.nextElementSibling + let definition = '' + let firstParagraph = '' + let paragraphCount = 0 + + while (sibling && sibling.tagName !== 'H2') { + if (sibling.tagName === 'P') { + paragraphCount += 1 + if (firstParagraph === '') { + firstParagraph = sibling.innerHTML + } + } + definition += sibling.outerHTML || sibling.textContent + sibling = sibling.nextElementSibling + } + + item.definition = definition + item.tooltip = firstParagraph + item.hasMultipleParagraphs = paragraphCount > 1 + } +}) const globalConfig = { @@ -55,6 +92,102 @@ module.exports = function (ty) { } }) + ty.addFilter('applyGlossary', function (content) { + const dom = new JSDOM(content) + const { document } = dom.window + const body = document.querySelector('body') + const allContentNodes = document.querySelectorAll('p, td, a, h1, h2, h3, h4') + const overlayIds = [] + + glossary.forEach((term, index) => { + let changeNoted = false + const id = term.term.toLowerCase().replace(/\s/g, '-') + + allContentNodes.forEach((node) => { + const regex = new RegExp(`(?${term.term}` + const beforeContent = node.innerHTML + node.innerHTML = node.innerHTML.replace(regex, replacement) + if (beforeContent !== node.innerHTML && !changeNoted) { + changeNoted = true + } + }) + + if (changeNoted) { + const definitionTooltipDiv = document.createElement('div') + definitionTooltipDiv.id = `tooltip-${id}` + definitionTooltipDiv.className = "glossary-tooltip" + const titleH4 = document.createElement('h4') + titleH4.innerHTML = term.term + titleH4.className = "tooltip-title" + const p = document.createElement('p') + p.innerHTML = term.tooltip + const innerDiv = document.createElement('div') + innerDiv.appendChild(titleH4) + innerDiv.appendChild(p) + if (term.hasMultipleParagraphs) { + const readMoreBtn = document.createElement('button') + readMoreBtn.innerHTML = "Read more" + readMoreBtn.className = "read-more-btn open-overlay-btn" + readMoreBtn.setAttribute('data-show-overlay', id) + innerDiv.appendChild(readMoreBtn) + } + innerDiv.className = "tooltip-content" + definitionTooltipDiv.appendChild(innerDiv) + body.appendChild(definitionTooltipDiv) + } + + let tooltipDom = new JSDOM(term.definition) + let tooltipDocument = tooltipDom.window.document + const hashList = [term.term.toLowerCase().replace(/\s/g, '-')] + tooltipDocument.querySelectorAll('a[href*="#"]').forEach(a => { + let hashIndex = a.href.indexOf("#") + if (hashIndex !== -1) { + let hash = a.href.substring(hashIndex + 1) + hashList.push(hash) + } + }) + + hashList.forEach(hash => { + if (!overlayIds.includes(hash)) { + let termFromHash = glossary.find(term => term.term.toLowerCase().replace(/\s/g, '-') === hash) + if (!termFromHash) return + + const overlayDiv = document.createElement('div') + overlayDiv.id = hash + overlayDiv.className = "overlay glossary-overlay hidden" + const overlayCardDiv = document.createElement('div') + overlayCardDiv.className = "overlay-card" + const overlayTitleH1 = document.createElement('h1') + overlayTitleH1.className = "overlay-title" + overlayTitleH1.innerHTML = termFromHash.term + const overlayContent = document.createElement('div') + overlayContent.className = "overlay-content" + overlayContent.innerHTML = termFromHash.definition + const crossSVG = document.createElementNS("http://www.w3.org/2000/svg", "svg") + crossSVG.setAttribute('class', 'close-overlay-btn') + crossSVG.setAttribute('id', 'cross') + crossSVG.setAttribute('width', '16') + crossSVG.setAttribute('height', '16') + crossSVG.setAttribute('viewBox', '0 0 13 13') + crossSVG.setAttribute('xmlns', 'http://www.w3.org/2000/svg') + const crossPath = document.createElementNS("http://www.w3.org/2000/svg", "path") + crossPath.setAttribute('d', 'M12.7973 11.5525L7.59762 6.49833L12.7947 1.44675C13.055 1.19371 13.0658 0.771991 12.8188 0.505331C12.5718 0.238674 12.1602 0.227644 11.8999 0.480681L6.65343 5.58028L1.09979 0.182228C0.805 0.002228 0.430001 0.002228 0.135211 0.182228C-0.159579 0.362228 -0.159579 0.697228 0.135211 0.877228L5.68885 6.27528L0.4918 11.3295C0.231501 11.5825 0.220703 12.0042 0.467664 12.2709C0.714625 12.5376 1.12625 12.5486 1.38655 12.2956L6.63302 7.196L12.1867 12.5941C12.4815 12.7741 12.8565 12.7741 13.1513 12.5941C13.4461 12.4141 13.4461 12.0791 13.1513 11.8991L12.7973 11.5525Z') + crossSVG.appendChild(crossPath) + + overlayCardDiv.appendChild(overlayTitleH1) + overlayCardDiv.appendChild(overlayContent) + overlayCardDiv.appendChild(crossSVG) + overlayDiv.appendChild(overlayCardDiv) + body.appendChild(overlayDiv) + overlayIds.push(hash) + } + }) + }) + + return dom.serialize() + }) + ty.addShortcode("completeRoute", (obj) => { const urlParts = obj.url.split("/") @@ -88,7 +221,7 @@ module.exports = function (ty) { } }) - ty.addPlugin(pluginRss); + ty.addPlugin(pluginRss) ty.addPlugin(i18n, { translations, @@ -139,7 +272,7 @@ module.exports = function (ty) { const url = doc.url.replace("/docs/", "") const urlParts = url.split("/") - if (doc.inputPath.includes(referenceSubmenu)) { + if (doc.inputPath.split('/').includes(referenceSubmenu)) { if (urlParts.length === 1 && urlParts[0] !== "") { const index = newDocs.findIndex((ele) => ele.lang === 'en' && ele.menu === referenceMenu.menu) if (index !== -1) { diff --git a/website/langs/en.json b/website/langs/en.json index 7b0f35b1c1..5db528114d 100644 --- a/website/langs/en.json +++ b/website/langs/en.json @@ -231,5 +231,6 @@ "click-to-see": "Click to see", "menu": "Menu", "on-this-page": "On this page", - "back-to-top": "Back to top" + "back-to-top": "Back to top", + "glossary": "Glossary" } \ No newline at end of file diff --git a/website/package.json b/website/package.json index c85dd3c00e..5eddf63031 100644 --- a/website/package.json +++ b/website/package.json @@ -29,6 +29,9 @@ }, "dependencies": { "eleventy-plugin-i18n": "^0.1.3", - "gray-matter": "^4.0.3" + "fs": "^0.0.1-security", + "gray-matter": "^4.0.3", + "jsdom": "^22.1.0", + "markdown-it": "^13.0.1" } } diff --git a/website/src/_data/glossary.json b/website/src/_data/glossary.json new file mode 100644 index 0000000000..fd420ccaa6 --- /dev/null +++ b/website/src/_data/glossary.json @@ -0,0 +1,110 @@ +[ + { + "term": "Address portability", + "definition": "Address portability" + }, + { + "term": "Anonymous credentials", + "definition": "Anonymous credentials" + }, + { + "term": "Blockchain", + "definition": "Blockchain" + }, + { + "term": "Break-in recovery", + "definition": "Post-compromise security" + }, + { + "term": "Centralized network", + "definition": "Centralized network" + }, + { + "term": "Content padding", + "definition": "Message padding" + }, + { + "term": "Decentralized network", + "definition": "Decentralized network" + }, + { + "term": "Defense in depth", + "definition": "Defense in depth" + }, + { + "term": "Double ratchet algorithm", + "definition": "Double ratchet algorithm" + }, + { + "term": "End-to-end encryption", + "definition": "End-to-end encryption" + }, + { + "term": "Federated network", + "definition": "Federated network" + }, + { + "term": "Forward secrecy", + "definition": "Forward secrecy" + }, + { + "term": "Key agreement protocol", + "definition": "Key agreement protocol" + }, + { + "term": "Key exchange", + "definition": "Key agreement protocol" + }, + { + "term": "Man-in-the-middle attack", + "definition": "Man-in-the-middle attack" + }, + { + "term": "Merkle directed acyclic graph", + "definition": "Merkle directed acyclic graph" + }, + { + "term": "Message padding", + "definition": "Message padding" + }, + { + "term": "Onion routing", + "definition": "Onion routing" + }, + { + "term": "Overlay network", + "definition": "Overlay network" + }, + { + "term": "Pairwise pseudonymous identifier", + "definition": "Pairwise pseudonymous identifier" + }, + { + "term": "Peer-to-peer", + "definition": "Peer-to-peer" + }, + { + "term": "Perfect forward secrecy", + "definition": "Forward secrecy" + }, + { + "term": "Post-compromise security", + "definition": "Post-compromise security" + }, + { + "term": "Post-quantum cryptography", + "definition": "Post-quantum cryptography" + }, + { + "term": "Proxied peer-to-peer", + "definition": "Proxied peer-to-peer" + }, + { + "term": "Recovery from compromise", + "definition": "Post-compromise security" + }, + { + "term": "User identity", + "definition": "User identity" + } +] \ No newline at end of file diff --git a/website/src/_includes/components/macro.njk b/website/src/_includes/components/macro.njk index bfcbb6a435..0f19b0f369 100644 --- a/website/src/_includes/components/macro.njk +++ b/website/src/_includes/components/macro.njk @@ -22,7 +22,7 @@ {% endif %} - + diff --git a/website/src/_includes/contact_page.html b/website/src/_includes/contact_page.html index eba6190ab9..e37dcddd0d 100644 --- a/website/src/_includes/contact_page.html +++ b/website/src/_includes/contact_page.html @@ -7,8 +7,8 @@
-

{{ header | i18n({}, lang ) | safe }}

-

{{ "contact-hero-subheader" | i18n({}, lang ) | safe }}

+

{{ header | i18n({}, lang ) | safe }}

+

{{ "contact-hero-subheader" | i18n({}, lang ) | safe }}

{{ "contact-hero-p-1" | i18n({}, lang ) | safe }}

@@ -43,7 +43,7 @@
-

{{ header | i18n({}, lang ) | safe }}

+

{{ header | i18n({}, lang ) | safe }}

{{ "to-make-a-connection" | i18n({}, lang ) | safe }}

@@ -88,7 +88,7 @@
- {{ content | safe }} + {{ content | applyGlossary | safe }} {% include "footer.html" %} diff --git a/website/src/_includes/navbar.html b/website/src/_includes/navbar.html index 50fca514b6..beb3139c2b 100644 --- a/website/src/_includes/navbar.html +++ b/website/src/_includes/navbar.html @@ -67,6 +67,8 @@
  • {{ "chat-protocol" | i18n({}, lang ) | safe }}
  • +
  • {{ "glossary" | i18n({}, lang ) | safe }}

  • diff --git a/website/src/_includes/sections/join_simplex.html b/website/src/_includes/sections/join_simplex.html index 5b00d171d5..edd7148067 100644 --- a/website/src/_includes/sections/join_simplex.html +++ b/website/src/_includes/sections/join_simplex.html @@ -1,7 +1,7 @@ {# join simplex #}
    -

    {{ "join" | i18n({}, lang ) | safe }} SimpleX

    +

    {{ "join" | i18n({}, lang ) | safe }} SimpleX

    {{ "we-invite-you-to-join-the-conversation" | i18n({}, lang ) | safe }}

    diff --git a/website/src/_includes/sections/simplex_unique.html b/website/src/_includes/sections/simplex_unique.html index aee8272379..6759e3a5cb 100644 --- a/website/src/_includes/sections/simplex_unique.html +++ b/website/src/_includes/sections/simplex_unique.html @@ -1,6 +1,6 @@
    -

    {{ "why-simplex-is" | i18n({}, lang ) | safe }} {{ "unique" | i18n({}, lang ) | safe }}

    +

    {{ "why-simplex-is" | i18n({}, lang ) | safe }} {{ "unique" | i18n({}, lang ) | safe }}

    @@ -15,8 +15,8 @@
    -

    #{{ section.id }}

    -

    {{ section.title | i18n({}, lang ) | safe }}

    +

    #{{ section.id }}

    +

    {{ section.title | i18n({}, lang ) | safe }}

    diff --git a/website/src/_includes/simplex_explained.html b/website/src/_includes/simplex_explained.html index f7f906a23d..15bd8623c5 100644 --- a/website/src/_includes/simplex_explained.html +++ b/website/src/_includes/simplex_explained.html @@ -25,7 +25,7 @@
    -

    {{ "simplex-explained" | i18n({}, lang ) | safe }}

    +

    {{ "simplex-explained" | i18n({}, lang ) | safe }}