diff --git a/README.md b/README.md index 6ed444c7b5..16defdf24a 100644 --- a/README.md +++ b/README.md @@ -54,24 +54,10 @@ If you are interested in helping us to integrate open-source language models, an ## Join user groups -You can join the groups created by other users via the new [directory service](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion). We are not responsible for the content shared in these groups. +You can find the groups created by users in [SimpleX Directory](https://simplex.chat/directory/). It is also available as [SimpleX bot](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) that allows to add your own groups and communities to the directory. We are not responsible for the content shared in these groups. **Please note**: The groups below are created for the users to be able to ask questions, make suggestions and ask questions about SimpleX Chat only. -You also can: -- criticize the app, and make comparisons with other messengers. -- share new messengers you think could be interesting for privacy, as long as you don't spam. -- share some privacy related publications, infrequently. -- having preliminary approved with the admin in direct message, share the link to a group you created, but only once. Once the group has more than 10 members it can be submitted to [SimpleX Directory Service](./docs/DIRECTORY.md) where the new users will be able to discover it. - -You must: -- be polite to other users -- avoid spam (too frequent messages, even if they are relevant) -- avoid any personal attacks or hostility. -- avoid sharing any content that is not relevant to the above (that includes, but is not limited to, discussing politics or any aspects of society other than privacy, security, technology and communications, sharing any content that may be found offensive by other users, etc.). - -Messages not following these rules will be deleted, the right to send messages may be revoked, and the access to the new members to the group may be temporarily restricted, to prevent re-joining under a different name - our imperfect group moderation does not have a better solution at the moment. - You can join an English-speaking users group if you want to ask any questions: [#SimpleX users group](https://smp4.simplex.im/g#hr4lvFeBmndWMKTwqiodPz3VBo_6UmdGWocXd1SupsM) There is also a group [#simplex-devs](https://smp6.simplex.im/g#Drx3efC-n418AuSpzTspw9SER0iJwrQTmKBafQHwkKM) for developers who build on SimpleX platform: @@ -81,11 +67,7 @@ There is also a group [#simplex-devs](https://smp6.simplex.im/g#Drx3efC-n418AuSp - social apps and services - etc. -There are groups in other languages, that we have the apps interface translated into. These groups are for testing, and asking questions to other SimpleX Chat users: - -[\#SimpleX-DE](https://smp6.simplex.im/g#V6tQ-lJqsdgJJdJiLPtP326oQFKHvwinIbgruZ9K2oU) (German-speaking), [\#SimpleX-ES](https://smp5.simplex.im/g#xJ5kwDLq2305O5FmpUzvgRIXXAcAJ9S5BItCd2Wmloc) (Spanish-speaking), [\#SimpleX-FR](https://smp6.simplex.im/g#cVOpB0CKd6hEf2aWQ6sJ22E2DVgQLtdHoiSdKxXeKqk) (French-speaking), [\#SimpleX-RU](https://smp5.simplex.im/g#vwXRdfG5SgtaG6aVcITiUGd--Ux0rY1IuH4QXYxlq3U) (Russian-speaking), [\#SimpleX-IT](https://smp5.simplex.im/g#BtRcjsl29ULFNBSE2OPhp1UwZfW7PW9gUYFQTKHdjqU) (Italian-speaking). - -You can join either by opening these links in the app or by opening them in a desktop browser and scanning the QR code. +You can join these and other groups by opening these links in the app or by opening them in a desktop browser and scanning the QR code. ## Follow our updates diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 8b4dd0e4d7..2227766cfa 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -113,7 +113,7 @@ kotlin { } // For jSystemThemeDetector only implementation("net.java.dev.jna:jna-platform:5.14.0") - implementation("com.sshtools:two-slices:0.9.0-SNAPSHOT") + implementation("com.sshtools:two-slices:0.9.1") implementation("org.slf4j:slf4j-simple:2.0.12") implementation("uk.co.caprica:vlcj:4.8.3") implementation("net.java.dev.jna:jna:5.14.0") diff --git a/apps/simplex-directory-service/Main.hs b/apps/simplex-directory-service/Main.hs index e2b96f5677..88e7739aa0 100644 --- a/apps/simplex-directory-service/Main.hs +++ b/apps/simplex-directory-service/Main.hs @@ -5,8 +5,6 @@ module Main where import Directory.Options import Directory.Service import Directory.Store -import Simplex.Chat.Controller (ChatConfig (..), ChatHooks (..), defaultChatHooks) -import Simplex.Chat.Core import Simplex.Chat.Terminal (terminalChatConfig) main :: IO () @@ -15,11 +13,4 @@ main = do st <- restoreDirectoryStore directoryLog if runCLI then directoryServiceCLI st opts - else do - env <- newServiceState opts - let chatHooks = - defaultChatHooks - { postStartHook = Just $ directoryStartHook st opts, - acceptMember = Just $ acceptMemberHook opts env - } - simplexChatCore (terminalChatConfig {chatHooks}) (mkChatOpts opts) $ directoryService st opts env + else directoryService st opts terminalChatConfig diff --git a/apps/simplex-directory-service/src/Directory/Listing.hs b/apps/simplex-directory-service/src/Directory/Listing.hs index a05e82285d..cef478c273 100644 --- a/apps/simplex-directory-service/src/Directory/Listing.hs +++ b/apps/simplex-directory-service/src/Directory/Listing.hs @@ -1,9 +1,11 @@ +{-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TupleSections #-} +{-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module Directory.Listing where @@ -11,23 +13,38 @@ module Directory.Listing where import Control.Applicative ((<|>)) import Control.Concurrent.STM import Control.Monad +import Crypto.Hash (Digest, MD5) +import qualified Crypto.Hash as CH import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ +import qualified Data.ByteArray as BA import Data.ByteString (ByteString) -import Data.ByteString.Base64 as B64 +import qualified Data.ByteString.Base64 as B64 +import qualified Data.ByteString.Base64.URL as B64URL import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy as LB -import Data.Maybe (fromMaybe) +import Data.Int (Int64) +import Data.List (isPrefixOf) +import Data.Maybe (catMaybes, fromMaybe) import qualified Data.Set as S import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) +import Data.Time.Clock +import Data.Time.Clock.System +import Data.Time.Format.ISO8601 (iso8601Show) import Directory.Store +import Simplex.Chat.Markdown import Simplex.Chat.Types +import Simplex.Messaging.Agent.Protocol +import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, taggedObjectJSON) import System.Directory import System.FilePath +directoryDataPath :: String +directoryDataPath = "data" + listingFileName :: String listingFileName = "listing.json" @@ -47,9 +64,12 @@ $(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "DET") ''DirectoryEntryType) data DirectoryEntry = DirectoryEntry { entryType :: DirectoryEntryType, displayName :: Text, - shortDescr :: Maybe Text, - welcomeMessage :: Maybe Text, - imageFile :: Maybe String + groupLink :: CreatedLinkContact, + shortDescr :: Maybe MarkdownList, + welcomeMessage :: Maybe MarkdownList, + imageFile :: Maybe String, + activeAt :: Maybe UTCTime, + createdAt :: Maybe UTCTime } $(JQ.deriveJSON defaultJSON ''DirectoryEntry) @@ -60,38 +80,77 @@ $(JQ.deriveJSON defaultJSON ''DirectoryListing) type ImageFileData = ByteString -groupDirectoryEntry :: GroupInfoSummary -> (DirectoryEntry, Maybe (FilePath, ImageFileData)) -groupDirectoryEntry (GIS GroupInfo {groupId, groupProfile} summary) = +newOrActive :: NominalDiffTime +newOrActive = 30 * nominalDay + +recentRoundedTime :: Int64 -> UTCTime -> UTCTime -> Maybe UTCTime +recentRoundedTime roundTo now t + | diffUTCTime now t > newOrActive = Nothing + | otherwise = + let secs = (systemSeconds (utcToSystemTime t) `div` roundTo) * roundTo + in Just $ systemToUTCTime $ MkSystemTime secs 0 + +groupDirectoryEntry :: UTCTime -> GroupInfoSummary -> Maybe (DirectoryEntry, Maybe (FilePath, ImageFileData)) +groupDirectoryEntry now (GIS GroupInfo {groupProfile, chatTs, createdAt} summary gLink_) = let GroupProfile {displayName, shortDescr, description, image, memberAdmission} = groupProfile entryType = DETGroup memberAdmission summary - imgData = imgFileData =<< image - in (DirectoryEntry {entryType, displayName, shortDescr, welcomeMessage = description, imageFile = fst <$> imgData}, imgData) + entry groupLink = + let de = + DirectoryEntry + { entryType, + displayName, + groupLink, + shortDescr = toFormattedText <$> shortDescr, + welcomeMessage = toFormattedText <$> description, + imageFile = fst <$> imgData, + activeAt = recentRoundedTime 900 now $ fromMaybe createdAt chatTs, + createdAt = recentRoundedTime 86400 now createdAt + } + imgData = imgFileData groupLink =<< image + in (de, imgData) + in (entry . connLinkContact) <$> gLink_ where - imgFileData (ImageData img) = + imgFileData :: CreatedConnLink 'CMContact -> ImageData -> Maybe (FilePath, ByteString) + imgFileData groupLink (ImageData img) = let (img', imgExt) = fromMaybe (img, ".jpg") $ (,".jpg") <$> T.stripPrefix "data:image/jpg;base64," img <|> (,".png") <$> T.stripPrefix "data:image/png;base64," img - imgFile = listingImageFolder show groupId <> imgExt + imgName = B.unpack $ B64URL.encodeUnpadded $ BA.convert $ (CH.hash :: ByteString -> Digest MD5) $ strEncode (connFullLink groupLink) + imgFile = listingImageFolder imgName <> imgExt in case B64.decode $ encodeUtf8 img' of Right img'' -> Just (imgFile, img'') Left _ -> Nothing generateListing :: DirectoryStore -> FilePath -> [GroupInfoSummary] -> IO () generateListing st dir gs = do + createDirectoryIfMissing True dir + oldDirs <- filter ((directoryDataPath <> ".") `isPrefixOf`) <$> listDirectory dir + ts <- getCurrentTime + let newDirPath = directoryDataPath <> "." <> iso8601Show ts <> "/" + newDir = dir newDirPath gs' <- filterListedGroups st gs - removePathForcibly (dir listingImageFolder) - createDirectoryIfMissing True (dir listingImageFolder) - gs'' <- forM gs' $ \g@(GIS GroupInfo {groupId} _) -> do - let (g', img) = groupDirectoryEntry g - forM_ img $ \(imgFile, imgData) -> B.writeFile (dir imgFile) imgData - pure (groupId, g') - saveListing listingFileName gs'' - saveListing promotedFileName =<< filterPromotedGroups st gs'' + createDirectoryIfMissing True (newDir listingImageFolder) + gs'' <- + fmap catMaybes $ forM gs' $ \g@(GIS GroupInfo {groupId} _ _) -> + forM (groupDirectoryEntry ts g) $ \(g', img) -> do + forM_ img $ \(imgFile, imgData) -> B.writeFile (newDir imgFile) imgData + pure (groupId, g') + saveListing newDir listingFileName gs'' + saveListing newDir promotedFileName =<< filterPromotedGroups st gs'' + -- atomically update the link + let newSymLink = newDir <> ".link" + symLink = dir directoryDataPath + createDirectoryLink newDirPath newSymLink + renamePath newSymLink symLink + mapM_ (removePathForcibly . (dir )) oldDirs where - saveListing f = LB.writeFile (dir f) . J.encode . DirectoryListing . map snd + saveListing newDir f = LB.writeFile (newDir f) . J.encode . DirectoryListing . map snd filterPromotedGroups :: DirectoryStore -> [(GroupId, DirectoryEntry)] -> IO [(GroupId, DirectoryEntry)] filterPromotedGroups st gs = do pgs <- readTVarIO $ promotedGroups st pure $ filter (\g -> fst g `S.member` pgs) gs + +toFormattedText :: Text -> MarkdownList +toFormattedText t = fromMaybe [FormattedText Nothing t] $ parseMaybeMarkdownList t diff --git a/apps/simplex-directory-service/src/Directory/Search.hs b/apps/simplex-directory-service/src/Directory/Search.hs index 5b0d650444..2d4cbf9c7b 100644 --- a/apps/simplex-directory-service/src/Directory/Search.hs +++ b/apps/simplex-directory-service/src/Directory/Search.hs @@ -20,13 +20,13 @@ data SearchRequest = SearchRequest data SearchType = STAll | STRecent | STSearch Text takeTop :: Int -> [GroupInfoSummary] -> [GroupInfoSummary] -takeTop n = take n . sortOn (\(GIS _ GroupSummary {currentMembers}) -> Down currentMembers) +takeTop n = take n . sortOn (\(GIS _ GroupSummary {currentMembers} _) -> Down currentMembers) takeRecent :: Int -> [GroupInfoSummary] -> [GroupInfoSummary] -takeRecent n = take n . sortOn (\(GIS GroupInfo {createdAt} _) -> Down createdAt) +takeRecent n = take n . sortOn (\(GIS GroupInfo {createdAt} _ _) -> Down createdAt) groupIds :: [GroupInfoSummary] -> Set GroupId -groupIds = S.fromList . map (\(GIS GroupInfo {groupId} _) -> groupId) +groupIds = S.fromList . map (\(GIS GroupInfo {groupId} _ _) -> groupId) filterNotSent :: Set GroupId -> [GroupInfoSummary] -> [GroupInfoSummary] -filterNotSent sentGroups = filter (\(GIS GroupInfo {groupId} _) -> groupId `S.notMember` sentGroups) +filterNotSent sentGroups = filter (\(GIS GroupInfo {groupId} _ _) -> groupId `S.notMember` sentGroups) diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 7bbe4e43a4..b7f1bb3629 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -21,7 +21,6 @@ module Directory.Service where import Control.Concurrent (forkIO) -import Control.Concurrent.Async import Control.Concurrent.STM import Control.Logger.Simple import Control.Monad @@ -54,7 +53,6 @@ import Simplex.Chat.Markdown (Format (..), FormattedText (..), parseMaybeMarkdow import Simplex.Chat.Messages import Simplex.Chat.Options import Simplex.Chat.Protocol (MsgContent (..)) -import Simplex.Chat.Store (GroupLink (..)) import Simplex.Chat.Store.Direct (getContact) import Simplex.Chat.Store.Groups (getGroupInfo, getGroupLink, getGroupSummary, getUserGroupsWithSummary, setGroupCustomData) import Simplex.Chat.Store.Profiles (GroupLinkInfo (..), getGroupLinkInfo) @@ -69,7 +67,7 @@ import Simplex.Messaging.Agent.Protocol (AConnectionLink (..), ConnectionLink (. import Simplex.Messaging.Encoding.String import Simplex.Messaging.TMap (TMap) import qualified Simplex.Messaging.TMap as TM -import Simplex.Messaging.Util (safeDecodeUtf8, tshow, unlessM, whenM, ($>>=), (<$$>)) +import Simplex.Messaging.Util (raceAny_, safeDecodeUtf8, tshow, unlessM, whenM, ($>>=), (<$$>)) import System.Directory (getAppUserDataDirectory) import System.Exit (exitFailure) import System.Process (readProcess) @@ -96,7 +94,8 @@ data GroupRolesStatus data ServiceState = ServiceState { searchRequests :: TMap ContactId SearchRequest, blockedWordsCfg :: BlockedWordsConfig, - pendingCaptchas :: TMap GroupMemberId PendingCaptcha + pendingCaptchas :: TMap GroupMemberId PendingCaptcha, + updateListingsJob :: TMVar ChatController } data PendingCaptcha = PendingCaptcha @@ -119,7 +118,8 @@ newServiceState opts = do searchRequests <- TM.emptyIO blockedWordsCfg <- readBlockedWordsConfig opts pendingCaptchas <- TM.emptyIO - pure ServiceState {searchRequests, blockedWordsCfg, pendingCaptchas} + updateListingsJob <- newEmptyTMVarIO + pure ServiceState {searchRequests, blockedWordsCfg, pendingCaptchas, updateListingsJob} welcomeGetOpts :: IO DirectoryOpts welcomeGetOpts = do @@ -146,22 +146,41 @@ directoryServiceCLI st opts = do env <- newServiceState opts eventQ <- newTQueueIO let eventHook cc resp = atomically $ resp <$ writeTQueue eventQ (cc, resp) - chatHooks = defaultChatHooks {postStartHook = Just $ directoryStartHook st opts, eventHook = Just eventHook, acceptMember = Just $ acceptMemberHook opts env} - race_ - (simplexChatCLI' terminalChatConfig {chatHooks} (mkChatOpts opts) Nothing) - (processEvents eventQ env) + chatHooks = defaultChatHooks {postStartHook = Just $ directoryStartHook opts env, eventHook = Just eventHook, acceptMember = Just $ acceptMemberHook opts env} + raceAny_ $ + [ simplexChatCLI' terminalChatConfig {chatHooks} (mkChatOpts opts) Nothing, + processEvents eventQ env + ] + <> updateListingsThread_ st opts env where processEvents eventQ env = forever $ do (cc, resp) <- atomically $ readTQueue eventQ u_ <- readTVarIO (currentUser cc) forM_ u_ $ \user -> directoryServiceEvent st opts env user cc resp -directoryStartHook :: DirectoryStore -> DirectoryOpts -> ChatController -> IO () -directoryStartHook st opts cc = +updateListingDelay :: Int +updateListingDelay = 15 * 60 * 1000000 -- update every 15 minutes + +updateListingsThread_ :: DirectoryStore -> DirectoryOpts -> ServiceState -> [IO ()] +updateListingsThread_ st opts env = maybe [] (\f -> [updateListingsThread f]) $ webFolder opts + where + updateListingsThread f = do + cc <- atomically $ takeTMVar $ updateListingsJob env + forever $ do + u <- readTVarIO $ currentUser cc + forM_ u $ \user -> updateGroupListingFiles cc st user f + delay <- registerDelay updateListingDelay + atomically $ void (takeTMVar $ updateListingsJob env) `orElse` unlessM (readTVar delay) retry + +listingsUpdated :: ServiceState -> ChatController -> IO () +listingsUpdated env = void . atomically . tryPutTMVar (updateListingsJob env) + +directoryStartHook :: DirectoryOpts -> ServiceState -> ChatController -> IO () +directoryStartHook opts env cc = readTVarIO (currentUser cc) >>= \case Nothing -> putStrLn "No current user" >> exitFailure - Just user@User {userId, profile = p@LocalProfile {preferences}} -> do - forM_ (webFolder opts) $ updateGroupListingFiles cc st user + Just User {userId, profile = p@LocalProfile {preferences}} -> do + listingsUpdated env cc let cmds = fromMaybe [] $ preferences >>= commands_ unless (cmds == directoryCommands) $ do let prefs = (fromMaybe emptyChatPrefs preferences) {files = Just FilesPreference {allow = FANo}, commands = Just directoryCommands} :: Preferences @@ -188,12 +207,23 @@ directoryCommands = where idParam = Just "" -directoryService :: DirectoryStore -> DirectoryOpts -> ServiceState -> User -> ChatController -> IO () -directoryService st opts@DirectoryOpts {testing} env user cc = do - initializeBotAddress' (not testing) cc - race_ (forever $ void getLine) . forever $ do - (_, resp) <- atomically . readTBQueue $ outputQ cc - directoryServiceEvent st opts env user cc resp +directoryService :: DirectoryStore -> DirectoryOpts -> ChatConfig -> IO () +directoryService st opts@DirectoryOpts {testing} cfg = do + env <- newServiceState opts + let chatHooks = + defaultChatHooks + { postStartHook = Just $ directoryStartHook opts env, + acceptMember = Just $ acceptMemberHook opts env + } + simplexChatCore cfg {chatHooks} (mkChatOpts opts) $ \user cc -> do + initializeBotAddress' (not testing) cc + raceAny_ $ + [ forever $ void getLine, + forever $ do + (_, resp) <- atomically . readTBQueue $ outputQ cc + directoryServiceEvent st opts env user cc resp + ] + <> updateListingsThread_ st opts env acceptMemberHook :: DirectoryOpts -> ServiceState -> GroupInfo -> GroupLinkInfo -> Profile -> IO (Either GroupRejectionReason (GroupAcceptance, GroupMemberRole)) acceptMemberHook @@ -301,7 +331,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName getDuplicateGroup GroupInfo {groupId, groupProfile = GroupProfile {displayName, fullName}} = getGroups fullName >>= mapM duplicateGroup where - sameGroupNotRemoved (GIS g@GroupInfo {groupId = gId, groupProfile = GroupProfile {displayName = n, fullName = fn}} _) = + sameGroupNotRemoved (GIS g@GroupInfo {groupId = gId, groupProfile = GroupProfile {displayName = n, fullName = fn}} _ _) = gId /= groupId && n == displayName && fn == fullName && not (memberRemoved $ membership g) duplicateGroup [] = pure DGUnique duplicateGroup groups = do @@ -310,13 +340,13 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName then pure DGUnique else do (lgs, rgs) <- atomically $ (,) <$> readTVar (listedGroups st) <*> readTVar (reservedGroups st) - let reserved = any (\(GIS GroupInfo {groupId = gId} _) -> gId `S.member` lgs || gId `S.member` rgs) gs + let reserved = any (\(GIS GroupInfo {groupId = gId} _ _) -> gId `S.member` lgs || gId `S.member` rgs) gs if reserved then pure DGReserved else do removed <- foldM (\r -> fmap (r &&) . isGroupRemoved) True gs pure $ if removed then DGUnique else DGRegistered - isGroupRemoved (GIS GroupInfo {groupId = gId} _) = + isGroupRemoved (GIS GroupInfo {groupId = gId} _ _) = getGroupReg st gId >>= \case Just GroupReg {groupRegStatus} -> groupRemoved <$> readTVarIO groupRegStatus Nothing -> pure True @@ -395,7 +425,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName notifyOwner gr $ "Joined the group " <> displayName <> ", creating the link…" sendChatCmd cc (APICreateGroupLink groupId GRMember) >>= \case Right CRGroupLinkCreated {groupLink = GroupLink {connLinkContact = gLink}} -> do - setGroupStatus st opts cc user gr GRSPendingUpdate + setGroupStatus st env cc gr GRSPendingUpdate notifyOwner gr "Created the public link to join the group via this directory service that is always online.\n\n\ @@ -456,7 +486,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName Just DGReserved -> notifyOwner gr $ groupAlreadyListed toGroup _ -> do let gaId = 1 - setGroupStatus st opts cc user gr $ GRSPendingApproval gaId + setGroupStatus st env cc gr $ GRSPendingApproval gaId notifyOwner gr $ ("Thank you! The group link for " <> userGroupReference gr toGroup <> " is added to the welcome message" <> byMember) <> ".\nYou will be notified once the group is added to the directory - it may take up to 48 hours." @@ -466,18 +496,18 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName groupRef = groupReference toGroup groupProfileUpdate >>= \case GPNoServiceLink -> do - setGroupStatus st opts cc user gr GRSPendingUpdate + setGroupStatus st env cc gr GRSPendingUpdate notifyOwner gr $ ("The group profile is updated for " <> userGroupRef <> byMember <> ", but no link is added to the welcome message.\n\n") <> "The group will remain hidden from the directory until the group link is added and the group is re-approved." GPServiceLinkRemoved -> do - setGroupStatus st opts cc user gr GRSPendingUpdate + setGroupStatus st env cc gr GRSPendingUpdate notifyOwner gr $ ("The group link for " <> userGroupRef <> " is removed from the welcome message" <> byMember) <> ".\n\nThe group is hidden from the directory until the group link is added and the group is re-approved." notifyAdminUsers $ "The group link is removed from " <> groupRef <> ", de-listed." GPServiceLinkAdded _ -> do - setGroupStatus st opts cc user gr $ GRSPendingApproval n' + setGroupStatus st env cc gr $ GRSPendingApproval n' notifyOwner gr $ ("The group link is added to " <> userGroupRef <> byMember) <> "!\nIt is hidden from the directory until approved." @@ -490,7 +520,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName <> "!\nThe group is listed in directory." notifyAdminUsers $ "The group " <> groupRef <> " is updated" <> byMember <> " - only link or whitespace changes.\nThe group remained listed in directory." | otherwise -> do - setGroupStatus st opts cc user gr $ GRSPendingApproval n' + setGroupStatus st env cc gr $ GRSPendingApproval n' notifyOwner gr $ ("The group " <> userGroupRef <> " is updated" <> byMember) <> "!\nIt is hidden from the directory until approved." @@ -628,14 +658,14 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName when (ctId `isOwner` gr) $ do readTVarIO (groupRegStatus gr) >>= \case GRSSuspendedBadRoles -> when (rStatus == GRSOk) $ do - setGroupStatus st opts cc user gr GRSActive + setGroupStatus st env cc gr GRSActive notifyOwner gr $ uCtRole <> ".\n\nThe group is listed in the directory again." notifyAdminUsers $ "The group " <> groupRef <> " is listed " <> suCtRole GRSPendingApproval gaId -> when (rStatus == GRSOk) $ do sendToApprove g gr gaId notifyOwner gr $ uCtRole <> ".\n\nThe group is submitted for approval." GRSActive -> when (rStatus /= GRSOk) $ do - setGroupStatus st opts cc user gr GRSSuspendedBadRoles + setGroupStatus st env cc gr GRSSuspendedBadRoles notifyOwner gr $ uCtRole <> ".\n\nThe group is no longer listed in the directory." notifyAdminUsers $ "The group " <> groupRef <> " is de-listed " <> suCtRole _ -> pure () @@ -654,7 +684,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName readTVarIO (groupRegStatus gr) >>= \case GRSSuspendedBadRoles -> when (serviceRole == GRAdmin) $ whenContactIsOwner gr $ do - setGroupStatus st opts cc user gr GRSActive + setGroupStatus st env cc gr GRSActive notifyOwner gr $ uSrvRole <> ".\n\nThe group is listed in the directory again." notifyAdminUsers $ "The group " <> groupRef <> " is listed " <> suSrvRole GRSPendingApproval gaId -> when (serviceRole == GRAdmin) $ @@ -662,7 +692,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName sendToApprove g gr gaId notifyOwner gr $ uSrvRole <> ".\n\nThe group is submitted for approval." GRSActive -> when (serviceRole /= GRAdmin) $ do - setGroupStatus st opts cc user gr GRSSuspendedBadRoles + setGroupStatus st env cc gr GRSSuspendedBadRoles notifyOwner gr $ uSrvRole <> ".\n\nThe group is no longer listed in the directory." notifyAdminUsers $ "The group " <> groupRef <> " is de-listed " <> suSrvRole _ -> pure () @@ -679,7 +709,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName logInfo $ "contact ID " <> tshow ctId <> " removed from group " <> viewGroupName g withGroupReg g "contact removed" $ \gr -> do when (ctId `isOwner` gr) $ do - setGroupStatus st opts cc user gr GRSRemoved + setGroupStatus st env cc gr GRSRemoved notifyOwner gr $ "You are removed from the group " <> userGroupReference gr g <> ".\n\nThe group is no longer listed in the directory." notifyAdminUsers $ "The group " <> groupReference g <> " is de-listed (group owner is removed)." @@ -688,7 +718,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName logInfo $ "contact ID " <> tshow ctId <> " left group " <> viewGroupName g withGroupReg g "contact left" $ \gr -> do when (ctId `isOwner` gr) $ do - setGroupStatus st opts cc user gr GRSRemoved + setGroupStatus st env cc gr GRSRemoved notifyOwner gr $ "You left the group " <> userGroupReference gr g <> ".\n\nThe group is no longer listed in the directory." notifyAdminUsers $ "The group " <> groupReference g <> " is de-listed (group owner left)." @@ -696,7 +726,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName deServiceRemovedFromGroup g = do logInfo $ "service removed from group " <> viewGroupName g withGroupReg g "service removed" $ \gr -> do - setGroupStatus st opts cc user gr GRSRemoved + setGroupStatus st env cc gr GRSRemoved notifyOwner gr $ serviceName <> " is removed from the group " <> userGroupReference gr g <> ".\n\nThe group is no longer listed in the directory." notifyAdminUsers $ "The group " <> groupReference g <> " is de-listed (directory service is removed)." @@ -704,7 +734,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName deGroupDeleted g = do logInfo $ "group removed " <> viewGroupName g withGroupReg g "group removed" $ \gr -> do - setGroupStatus st opts cc user gr GRSRemoved + setGroupStatus st env cc gr GRSRemoved notifyOwner gr $ "The group " <> userGroupReference gr g <> " is deleted.\n\nThe group is no longer listed in the directory." notifyAdminUsers $ "The group " <> groupReference g <> " is de-listed (group is deleted)." @@ -925,7 +955,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName where msgs = replyMsg :| map foundGroup gs <> [moreMsg | moreGroups > 0] replyMsg = (Just ciId, MCText reply) - foundGroup (GIS GroupInfo {groupId, groupProfile = p@GroupProfile {image = image_}} GroupSummary {currentMembers}) = + foundGroup (GIS GroupInfo {groupId, groupProfile = p@GroupProfile {image = image_}} GroupSummary {currentMembers} _) = let membersStr = "_" <> tshow currentMembers <> " members_" showId = if isAdmin then tshow groupId <> ". " else "" text = showId <> groupInfoText p <> "\n" <> membersStr @@ -946,16 +976,16 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName _ -> do getGroupRolesStatus g gr >>= \case Just GRSOk -> do - setGroupStatus st opts cc user gr GRSActive + setGroupStatus st env cc gr GRSActive forM_ promote $ \promo -> if promo -- admins can unpromote, only super-user can promote when approving then unlessM (readTVarIO promoted) $ if knownCt `elem` superUsers - then setGroupPromoted st opts cc user gr True + then setGroupPromoted st env cc gr True else sendReply "You cannot promote groups" else do - whenM (readTVarIO promoted) $ setGroupPromoted st opts cc user gr False + whenM (readTVarIO promoted) $ setGroupPromoted st env cc gr False notifyOtherSuperUsers $ "Group promotion is disabled for " <> groupRef let approved = "The group " <> userGroupReference' gr n <> " is approved" notifyOwner gr $ @@ -991,7 +1021,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName withGroupAndReg sendReply groupId gName $ \_ gr -> readTVarIO (groupRegStatus gr) >>= \case GRSActive -> do - setGroupStatus st opts cc user gr GRSSuspended + setGroupStatus st env cc gr GRSSuspended let suspended = "The group " <> userGroupReference' gr gName <> " is suspended" notifyOwner gr $ suspended <> " and hidden from directory. Please contact the administrators." sendReply "Group suspended!" @@ -1002,7 +1032,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName withGroupAndReg sendReply groupId gName $ \_ gr -> readTVarIO (groupRegStatus gr) >>= \case GRSSuspended -> do - setGroupStatus st opts cc user gr GRSActive + setGroupStatus st env cc gr GRSActive let groupStr = "The group " <> userGroupReference' gr gName notifyOwner gr $ groupStr <> " is listed in the directory again!" sendReply "Group listing resumed!" @@ -1078,7 +1108,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName withGroupAndReg sendReply groupId gName $ \_ gr@GroupReg {groupRegStatus, promoted} -> do status <- readTVarIO groupRegStatus promote <- readTVarIO promoted - when (promote' /= promote) $ setGroupPromoted st opts cc user gr promote' + when (promote' /= promote) $ setGroupPromoted st env cc gr promote' let msg = "Group promotion " <> (if promote' then "enabled" <> (if status == GRSActive then "." else ", but the group is not listed.") else "disabled.") @@ -1132,18 +1162,16 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName let text = T.unlines $ [tshow useGroupId <> ". Error: getGroup. Please notify the developers."] <> maybeToList ownerStr_ <> [statusStr] sendComposedMessage cc ct Nothing $ MCText text -setGroupStatus :: DirectoryStore -> DirectoryOpts -> ChatController -> User -> GroupReg -> GroupRegStatus -> IO () -setGroupStatus st opts cc u gr grStatus' = do +setGroupStatus :: DirectoryStore -> ServiceState -> ChatController -> GroupReg -> GroupRegStatus -> IO () +setGroupStatus st env cc gr grStatus' = do let status' = grDirectoryStatus grStatus' status <- setGroupStatusStore st gr grStatus' - forM_ (webFolder opts) $ \dir -> - when ((status == DSListed || status' == DSListed) && status /= status') $ updateGroupListingFiles cc st u dir + when ((status == DSListed || status' == DSListed) && status /= status') $ listingsUpdated env cc -setGroupPromoted :: DirectoryStore -> DirectoryOpts -> ChatController -> User -> GroupReg -> Bool -> IO () -setGroupPromoted st opts cc u gr grPromoted' = do +setGroupPromoted :: DirectoryStore -> ServiceState -> ChatController -> GroupReg -> Bool -> IO () +setGroupPromoted st env cc gr grPromoted' = do (status, grPromoted) <- setGroupPromotedStore st gr grPromoted' - forM_ (webFolder opts) $ \dir -> - when (status == DSListed && grPromoted' /= grPromoted) $ updateGroupListingFiles cc st u dir + when (status == DSListed && grPromoted' /= grPromoted) $ listingsUpdated env cc updateGroupListingFiles :: ChatController -> DirectoryStore -> User -> FilePath -> IO () updateGroupListingFiles cc st u dir = diff --git a/apps/simplex-directory-service/src/Directory/Store.hs b/apps/simplex-directory-service/src/Directory/Store.hs index 16f047202f..1c8e1a24b5 100644 --- a/apps/simplex-directory-service/src/Directory/Store.hs +++ b/apps/simplex-directory-service/src/Directory/Store.hs @@ -273,7 +273,7 @@ getUserGroupRegs st ctId = filter ((ctId ==) . dbContactId) <$> readTVarIO (grou filterListedGroups :: DirectoryStore -> [GroupInfoSummary] -> IO [GroupInfoSummary] filterListedGroups st gs = do lgs <- readTVarIO $ listedGroups st - pure $ filter (\(GIS GroupInfo {groupId} _) -> groupId `S.member` lgs) gs + pure $ filter (\(GIS GroupInfo {groupId} _ _) -> groupId `S.member` lgs) gs listGroup :: DirectoryStore -> GroupReg -> STM () listGroup st gr = do diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index f20c7c4913..6207b1bf64 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -2159,6 +2159,7 @@ MemberSupport: **Record type**: - groupInfo: [GroupInfo](#groupinfo) - groupSummary: [GroupSummary](#groupsummary) +- groupLink: [GroupLink](#grouplink)? --- diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index 83675798af..d74f6d37dd 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -29,7 +29,6 @@ import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent import Simplex.Chat.Messages.CIContent.Events import Simplex.Chat.Protocol -import Simplex.Chat.Store.Groups import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared import Simplex.Chat.Types @@ -69,7 +68,7 @@ chatTypesDocs = sortOn docTypeName $! snd $! mapAccumL toCTDoc (S.empty, M.empty let (tds', td_) = toTypeDef tds sumTypeInfo in case td_ of Just typeDef -> (tds', CTDoc {typeDef, typeSyntax, typeDescr}) - Nothing -> error $ "Recursive type: " <> typeName + Nothing -> error $ "Recursive type: " <> typeName toTypeDef :: (S.Set String, M.Map String APITypeDef) -> (SumTypeInfo, SumTypeJsonEncoding, String, [ConsName], Expr, Text) -> ((S.Set String, M.Map String APITypeDef), Maybe APITypeDef) toTypeDef acc@(!visited, !typeDefs) (STI typeName allConstrs, jsonEncoding, consPrefix, hideConstrs, _, _) = @@ -84,7 +83,7 @@ toTypeDef acc@(!visited, !typeDefs) (STI typeName allConstrs, jsonEncoding, cons let fields = fromMaybe (error $ "Record type without fields: " <> typeName) $ L.nonEmpty fieldInfos ((visited', typeDefs'), fields') = mapAccumL (toAPIField_ typeName) (S.insert typeName visited, typeDefs) fields td = APITypeDef typeName $ ATDRecord $ L.toList fields' - in ((S.insert typeName visited', M.insert typeName td typeDefs'), Just td) + in ((S.insert typeName visited', M.insert typeName td typeDefs'), Just td) _ -> error $ "Record type with " <> show (length constrs) <> " constructors: " <> typeName STUnion -> if length constrs > 1 then toUnionType constrs else unionError constrs STUnion1 -> if length constrs == 1 then toUnionType constrs else unionError constrs @@ -98,16 +97,16 @@ toTypeDef acc@(!visited, !typeDefs) (STI typeName allConstrs, jsonEncoding, cons toUnionType constrs = let ((visited', typeDefs'), members) = mapAccumL toUnionMember (S.insert typeName visited, typeDefs) $ fromMaybe (unionError constrs) $ L.nonEmpty constrs td = APITypeDef typeName $ ATDUnion members - in ((S.insert typeName visited', M.insert typeName td typeDefs'), Just td) + in ((S.insert typeName visited', M.insert typeName td typeDefs'), Just td) toUnionMember tds RecordTypeInfo {consName, fieldInfos} = let memberTag = normalizeConsName consPrefix consName - in second (ATUnionMember memberTag) $ mapAccumL (toAPIField_ typeName) tds fieldInfos + in second (ATUnionMember memberTag) $ mapAccumL (toAPIField_ typeName) tds fieldInfos unionError constrs = error $ "Union type with " <> show (length constrs) <> " constructor(s): " <> typeName toEnumType = toEnumType_ $ normalizeConsName consPrefix toEnumType_ f constrs = let members = L.map toEnumMember $ fromMaybe (enumError constrs) $ L.nonEmpty constrs td = APITypeDef typeName $ ATDEnum members - in ((S.insert typeName visited, M.insert typeName td typeDefs), Just td) + in ((S.insert typeName visited, M.insert typeName td typeDefs), Just td) where toEnumMember RecordTypeInfo {consName, fieldInfos} = case fieldInfos of [] -> f consName @@ -121,7 +120,7 @@ toAPIField_ typeName tds (FieldInfo fieldName typeInfo) = second (APIRecordField toAPIType = \case TIType (ST name _) -> apiTypeForName name TIOptional tInfo -> second ATOptional $ toAPIType tInfo - TIArray {elemType, nonEmpty} -> second (`ATArray`nonEmpty) $ toAPIType elemType + TIArray {elemType, nonEmpty} -> second (`ATArray` nonEmpty) $ toAPIType elemType TIMap {keyType = ST name _, valueType} | name `elem` primitiveTypes -> second (ATMap (PT name)) $ toAPIType valueType | otherwise -> error $ "Non-primitive key type in " <> typeName <> ", " <> fieldName @@ -133,7 +132,7 @@ toAPIField_ typeName tds (FieldInfo fieldName typeInfo) = second (APIRecordField Nothing -> case find (\(STI name' _, _, _, _, _, _) -> name == name') chatTypesDocsData of Just sumTypeInfo -> let (tds', td_) = toTypeDef tds sumTypeInfo -- recursion to outer function, loops are resolved via type defs map lookup - in case td_ of + in case td_ of Just td -> (tds', ATDef td) Nothing -> (tds', ATRef name) Nothing -> error $ "Undefined type: " <> name @@ -352,7 +351,6 @@ chatTypesDocsData = (sti @XFTPErrorType, STUnion, "", [], "", ""), (sti @XFTPRcvFile, STRecord, "", [], "", ""), (sti @XFTPSndFile, STRecord, "", [], "", "") - -- (sti @DatabaseError, STUnion, "DB", [], "", ""), -- (sti @ChatItemInfo, STRecord, "", [], "", ""), -- (sti @ChatItemVersion, STRecord, "", [], "", ""), @@ -371,7 +369,7 @@ chatTypesDocsData = -- (sti @SendRef, STRecord, "", [], "", ""), -- (sti @SndQueueInfo, STRecord, "", [], "", ""), -- (sti @SndSwitchStatus, STEnum, "", [], "", ""), -- incorrect - ] + ] data SimplePreference = SimplePreference {allow :: FeatureAllowed} deriving (Generic) diff --git a/docs/ABOUT.md b/docs/ABOUT.md new file mode 100644 index 0000000000..44809b9c0c --- /dev/null +++ b/docs/ABOUT.md @@ -0,0 +1,18 @@ +--- +layout: layouts/jobs.html +permalink: /about/index.html +--- + +# About us + +SimpleX Chat Ltd is a company founded to develop SimpleX network and software. + +Our mission is to create a fully decentralized network, based on the same principles as open web, but the one that gives users full control and ownership of their identity, contacts and communities. + +## Contact us + +SimpleX: "Ask SimpleX team" contact in the app or [this address](https://simplex.chat/contact#/?v=2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion). + +Email: [chat@simplex.chat](mailto:chat@simplex.chat). You can use PGP to encrypt email messages using our key from [keys.openpgp.org](https://keys.openpgp.org/search?q=chat%40simplex.chat) (its fingerprint is `FB44 AF81 A45B DE32 7319 797C 8510 7E35 7D4A 17FC`) and making your key available for a secure reply. + +You can follow our updates on social media: [X/Twitter](https://x.com/simplexchat), [Reddit](https://www.reddit.com/r/SimpleXChat/), [Mastodon](https://mastodon.social/@simplex) and [Nostr](https://primal.net/p/npub1exv22uulqnmlluszc4yk92jhs2e5ajcs6mu3t00a6avzjcalj9csm7d828). diff --git a/docs/DIRECTORY.md b/docs/DIRECTORY.md index 9ffeeef2d2..8659222280 100644 --- a/docs/DIRECTORY.md +++ b/docs/DIRECTORY.md @@ -1,15 +1,17 @@ --- -title: SimpleX Directory Service +title: SimpleX Directory revision: 18.08.2023 --- -# SimpleX Directory Service +# SimpleX Directory -You can use an experimental directory service to discover the groups created and registered by other users. +You can use SimpleX Directory to discover the groups created and registered by other users. ## Searching for groups -Connect to the directory service via [this address](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) and send the message containing the words you want to find in the group name or welcome message. You will receive up to 10 groups with the largest number of members in the response, together with the links to join these groups. +SimpleX Directory is available at [simplex.chat/directory](https://simplex.chat/directory/) or via this [onion link](http://isdb4l77sjqoy2qq7ipum6x3at6hyn3jmxfx4zdhc72ufbmuq4ilwkqd.onion/directory/). + +You can also connect to SimpleX Directory via [this address](https://smp4.simplex.im/a#lXUjJW5vHYQzoLYgmi8GbxkGP41_kjefFvBrdwg-0Ok) and send the message containing the words you want to find in the group name or welcome message. You will receive up to 10 groups with the largest number of members in the response, together with the links to join these groups. Please note that your search queries can be kept by the bot as the conversation history, but you can use incognito mode when connecting to the bot, to avoid correlation with any other communications. See [Privacy policy](../PRIVACY.md) for more details. diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index bdc99ee750..97bc45f3ce 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -2451,6 +2451,7 @@ export interface GroupInfo { export interface GroupInfoSummary { groupInfo: GroupInfo groupSummary: GroupSummary + groupLink?: GroupLink } export interface GroupLink { diff --git a/scripts/desktop/make-deb-linux.sh b/scripts/desktop/make-deb-linux.sh index c9c4d5a81c..3226c22709 100755 --- a/scripts/desktop/make-deb-linux.sh +++ b/scripts/desktop/make-deb-linux.sh @@ -37,8 +37,12 @@ export SOURCE_DATE_EPOCH=1704067200 dpkg-deb -R ./release/main/deb/simplex*.deb ./extracted +# Source the distribution variables (VERSION_CODENAME) +. /etc/os-release + rm -f ./extracted/opt/*imple*/lib/app/*skiko-awt-runtime-linux* sed -i -e '/skiko-awt-runtime-linux/d' ./extracted/opt/*imple*/lib/app/simplex.cfg +sed -i "/Version/ s/\$/~$VERSION_CODENAME/" ./extracted/DEBIAN/control find ./extracted/ -exec touch -d "@$SOURCE_DATE_EPOCH" {} + dpkg-deb --build --root-owner-group --uniform-compression ./extracted ./release/main/deb/simplex_${ARCH}.deb diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 7a9dd3a92d..529873b7d3 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -473,8 +473,10 @@ executable simplex-directory-service , base64-bytestring >=1.0 && <1.3 , composition ==1.0.* , containers ==0.6.* + , crypton ==0.34.* , directory ==1.3.* , filepath ==1.4.* + , memory ==0.18.* , mtl >=2.3.1 && <3.0 , optparse-applicative >=0.15 && <0.17 , process >=1.6 && <1.6.18 diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 0b2f16ad1d..45fd264afc 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -62,7 +62,7 @@ import Simplex.Chat.Protocol import Simplex.Chat.Remote.AppVersion import Simplex.Chat.Remote.Types import Simplex.Chat.Stats (PresentedServersSummary) -import Simplex.Chat.Store (AddressSettings, ChatLockEntity, GroupLink, GroupLinkInfo, StoreError (..), UserContactLink, UserMsgReceiptSettings) +import Simplex.Chat.Store (AddressSettings, ChatLockEntity, GroupLinkInfo, StoreError (..), UserContactLink, UserMsgReceiptSettings) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index e80037be74..e81ec0cc3a 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -6,7 +6,6 @@ module Simplex.Chat.Store ChatLockEntity (..), UserMsgReceiptSettings (..), UserContactLink (..), - GroupLink (..), GroupLinkInfo (..), AddressSettings (..), AutoAccept (..), @@ -16,7 +15,6 @@ module Simplex.Chat.Store ) where -import Simplex.Chat.Store.Groups (GroupLink (..)) import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared import Simplex.Messaging.Agent.Store.Common (DBStore (..), withTransaction) diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 3d8bdead72..7e9d7b4ae2 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -18,7 +18,6 @@ module Simplex.Chat.Store.Groups GroupInfoRow, GroupMemberRow, MaybeGroupMemberRow, - GroupLink (..), toGroupInfo, toGroupMember, toMaybeGroupMember, @@ -162,7 +161,6 @@ import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class import Crypto.Random (ChaChaDRG) -import qualified Data.Aeson.TH as J import Data.Bifunctor (second) import Data.Bitraversable (bitraverse) import Data.Char (toLower) @@ -188,7 +186,6 @@ import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.Ratchet (pattern PQEncOff, pattern PQSupportOff) -import Simplex.Messaging.Parsers (defaultJSON) import Simplex.Messaging.Protocol (SubscriptionMode (..)) import Simplex.Messaging.Util (eitherToMaybe, firstRow', safeDecodeUtf8, ($>>), ($>>=), (<$$>)) import Simplex.Messaging.Version @@ -280,16 +277,6 @@ deleteGroupLink db User {userId} GroupInfo {groupId} = do (userId, groupId) DB.execute db "DELETE FROM user_contact_links WHERE user_id = ? AND group_id = ?" (userId, groupId) -data GroupLink = GroupLink - { userContactLinkId :: Int64, - connLinkContact :: CreatedLinkContact, - shortLinkDataSet :: Bool, - shortLinkLargeDataSet :: BoolDef, - groupLinkId :: GroupLinkId, - acceptMemberRole :: GroupMemberRole - } - deriving (Show) - getGroupLink :: DB.Connection -> User -> GroupInfo -> ExceptT StoreError IO GroupLink getGroupLink db User {userId} gInfo@GroupInfo {groupId} = ExceptT . firstRow toGroupLink (SEGroupLinkNotFound gInfo) $ @@ -982,9 +969,12 @@ getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ = do search = maybe "" (map toLower) search_ getUserGroupsWithSummary :: DB.Connection -> VersionRangeChat -> User -> Maybe ContactId -> Maybe String -> IO [GroupInfoSummary] -getUserGroupsWithSummary db vr user _contactId_ search_ = - getUserGroupDetails db vr user _contactId_ search_ - >>= mapM (\g@GroupInfo {groupId} -> GIS g <$> getGroupSummary db user groupId) +getUserGroupsWithSummary db vr user _contactId_ search_ = do + gs <- getUserGroupDetails db vr user _contactId_ search_ + forM gs $ \g@GroupInfo {groupId} -> do + s <- getGroupSummary db user groupId + link_ <- eitherToMaybe <$> runExceptT (getGroupLink db user g) + pure $ GIS g s link_ -- the statuses on non-current members should match memberCurrent' function getGroupSummary :: DB.Connection -> User -> GroupId -> IO GroupSummary @@ -2905,5 +2895,3 @@ updateGroupAlias db userId g@GroupInfo {groupId} localAlias = do updatedAt <- getCurrentTime DB.execute db "UPDATE groups SET local_alias = ?, updated_at = ? WHERE user_id = ? AND group_id = ?" (localAlias, updatedAt, userId, groupId) pure (g :: GroupInfo) {localAlias = localAlias} - -$(J.deriveJSON defaultJSON ''GroupLink) diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index f6d0e7da72..7eba39360f 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -2204,8 +2204,6 @@ updateGroupScopeUnreadStats db vr user g@GroupInfo {membership} scopeInfo (unrea m_ <- runExceptT $ getGroupMemberById db vr user groupMemberId pure $ either (const m) id m_ -- Left shouldn't happen, but types require it -deriving instance Show BoolInt - setGroupChatItemsDeleteAt :: DB.Connection -> User -> GroupId -> [(ChatItemId, Int)] -> UTCTime -> IO [(ChatItemId, UTCTime)] setGroupChatItemsDeleteAt db User {userId} groupId itemIds currentTs = forM itemIds $ \(chatItemId, ttl) -> do let deleteAt = addUTCTime (realToFrac ttl) currentTs diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index c4253942d8..a90d457920 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -527,7 +527,17 @@ data GroupSummary = GroupSummary } deriving (Show) -data GroupInfoSummary = GIS {groupInfo :: GroupInfo, groupSummary :: GroupSummary} +data GroupInfoSummary = GIS {groupInfo :: GroupInfo, groupSummary :: GroupSummary, groupLink :: Maybe GroupLink} + deriving (Show) + +data GroupLink = GroupLink + { userContactLinkId :: Int64, + connLinkContact :: CreatedLinkContact, + shortLinkDataSet :: Bool, + shortLinkLargeDataSet :: BoolDef, + groupLinkId :: GroupLinkId, + acceptMemberRole :: GroupMemberRole + } deriving (Show) data ContactOrGroup = CGContact Contact | CGGroup GroupInfo [GroupMember] @@ -2075,6 +2085,8 @@ $(JQ.deriveJSON defaultJSON ''Group) $(JQ.deriveJSON defaultJSON ''GroupSummary) +$(JQ.deriveJSON defaultJSON ''GroupLink) + $(JQ.deriveJSON defaultJSON ''GroupInfoSummary) instance FromField MsgFilter where fromField = fromIntField_ msgFilterIntP diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index dcaabe433e..015d4e6645 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -50,7 +50,7 @@ import Simplex.Chat.Operators import Simplex.Chat.Protocol import Simplex.Chat.Remote.AppVersion (AppVersion (..), pattern AppVersionRange) import Simplex.Chat.Remote.Types -import Simplex.Chat.Store (AddressSettings (..), AutoAccept (..), GroupLink (..), StoreError (..), UserContactLink (..)) +import Simplex.Chat.Store (AddressSettings (..), AutoAccept (..), StoreError (..), UserContactLink (..)) import Simplex.Chat.Styled import Simplex.Chat.Types import Simplex.Chat.Types.Preferences @@ -1365,8 +1365,8 @@ viewGroupsList [] = ["you have no groups!", "to create: " <> highlight' "/g Text - ldn_ (GIS GroupInfo {localDisplayName} _) = T.toLower localDisplayName - groupSS (GIS g@GroupInfo {membership, chatSettings = ChatSettings {enableNtfs}} GroupSummary {currentMembers}) = + ldn_ (GIS GroupInfo {localDisplayName} _ _) = T.toLower localDisplayName + groupSS (GIS g@GroupInfo {membership, chatSettings = ChatSettings {enableNtfs}} GroupSummary {currentMembers} _) = case memberStatus membership of GSMemInvited -> groupInvitation' g s -> membershipIncognito g <> ttyFullGroup g <> viewMemberStatus s <> alias g diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 168fd7bfca..1550a7bd9b 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -21,8 +21,7 @@ import Directory.Service import Directory.Store import GHC.IO.Handle (hClose) import Simplex.Chat.Bot.KnownContacts -import Simplex.Chat.Controller (ChatConfig (..), ChatHooks (..), defaultChatHooks) -import Simplex.Chat.Core +import Simplex.Chat.Controller (ChatConfig (..)) import qualified Simplex.Chat.Markdown as MD import Simplex.Chat.Options (CoreChatOpts (..)) import Simplex.Chat.Options.DB @@ -1129,11 +1128,12 @@ testListUserGroups promote ps = checkListings :: [T.Text] -> [T.Text] -> IO () checkListings listed promoted = do + threadDelay 100000 checkListing listingFileName listed checkListing promotedFileName promoted where checkListing f expected = do - Just (DirectoryListing gs) <- J.decodeFileStrict $ "./tests/tmp/web" f + Just (DirectoryListing gs) <- J.decodeFileStrict $ "./tests/tmp/web/data" f map groupName gs `shouldBe` expected groupName DirectoryEntry {displayName} = displayName @@ -1396,14 +1396,9 @@ withDirectoryOwnersGroup ps cfg dsLink createOwnersGroup webFolder test = do runDirectory :: ChatConfig -> DirectoryOpts -> IO () -> IO () runDirectory cfg opts@DirectoryOpts {directoryLog} action = do st <- restoreDirectoryStore directoryLog - t <- forkIO $ bot st + t <- forkIO $ directoryService st opts cfg threadDelay 500000 action `finally` (mapM_ hClose (directoryLogFile st) >> killThread t) - where - bot st = do - env <- newServiceState opts - let cfg' = cfg {chatHooks = defaultChatHooks {acceptMember = Just $ acceptMemberHook opts env}} - simplexChatCore cfg' (mkChatOpts opts) $ directoryService st opts env registerGroup :: TestCC -> TestCC -> String -> String -> IO () registerGroup su u n fn = registerGroupId su u n fn 1 1 diff --git a/website/langs/cs.json b/website/langs/cs.json index 146626551f..d90d392dab 100644 --- a/website/langs/cs.json +++ b/website/langs/cs.json @@ -238,7 +238,7 @@ "f-droid-page-simplex-chat-repo-section-text": "Chcete-li jej přidat do vašeho F-Droid clienta, naskenujte QR kód nebo použijte tuto adresu URL:", "f-droid-page-f-droid-org-repo-section-text": "SimpleX Chat a F-Droid.org repozitáře jsou podepsané různými klíči. Chcete-li přepnout, prosím exportujte chat databázi a přeinstalujte aplikaci.", "comparison-section-list-point-4a": "SimpleX relé nemůže ohrozit šifrování e2e. Ověřte bezpečnostní kód, který zmírňuje mimo pásmový útok na kanál", - "docs-dropdown-8": "Služba SimpleX Directory", + "docs-dropdown-8": "SimpleX Directory", "please-enable-javascript": "Prosím, povolte JavaScript k zobrazení QR kódu.", "please-use-link-in-mobile-app": "Prosím použijte odkaz v mobilní aplikaci", "simplex-chat-via-f-droid": "SimpleX Chat přes F-Droid", diff --git a/website/langs/en.json b/website/langs/en.json index f9691e2594..3df67f6c18 100644 --- a/website/langs/en.json +++ b/website/langs/en.json @@ -1,5 +1,6 @@ { "home": "Home", + "directory": "Directory", "developers": "Developers", "reference": "Reference", "blog": "Blog", @@ -24,6 +25,7 @@ "copyright-label": "© 2020-2025 SimpleX | Open-Source Project", "simplex-chat-protocol": "SimpleX Chat protocol", "terminal-cli": "Terminal CLI", + "about-and-contact-us": "About & Contact us", "terms-and-privacy-policy": "Privacy Policy", "hero-header": "Privacy redefined", "hero-subheader": "The first messenger
without user IDs", @@ -232,7 +234,7 @@ "docs-dropdown-5": "Host XFTP Server", "docs-dropdown-6": "WebRTC servers", "docs-dropdown-7": "Translate SimpleX Chat", - "docs-dropdown-8": "SimpleX Directory Service", + "docs-dropdown-8": "SimpleX Directory", "docs-dropdown-9": "Downloads", "docs-dropdown-10": "Transparency", "docs-dropdown-11": "FAQ", diff --git a/website/langs/nl.json b/website/langs/nl.json index a6a0a740ae..f0b313cc14 100644 --- a/website/langs/nl.json +++ b/website/langs/nl.json @@ -241,7 +241,7 @@ "stable-versions-built-by-f-droid-org": "Stabiele versies gebouwd door F-Droid.org", "releases-to-this-repo-are-done-1-2-days-later": "De releases voor deze repository vinden enkele dagen later plaats", "f-droid-page-f-droid-org-repo-section-text": "SimpleX Chat- en F-Droid.org-repository's ondertekenen builds met de verschillende sleutels. Om over te stappen, alstublieft exporteer de chatdatabase en installeer de app opnieuw.", - "docs-dropdown-8": "SimpleX Directory Service", + "docs-dropdown-8": "SimpleX Directory", "comparison-section-list-point-4a": "SimpleX relais kunnen de e2e-versleuteling niet in gevaar brengen. Controleer de beveiligingscode om aanvallen op out-of-band kanalen te beperken", "hero-overlay-3-title": "Beveiligings beoordeling", "hero-overlay-card-3-p-2": "Trail of Bits heeft in november 2022 de cryptografie en netwerkcomponenten van het SimpleX-platform beoordeeld. Lees meer in de aankondiging.", diff --git a/website/langs/ru.json b/website/langs/ru.json index ea7766db1b..ccf480f1d7 100644 --- a/website/langs/ru.json +++ b/website/langs/ru.json @@ -172,6 +172,7 @@ "simplex-private-card-10-point-2": "Это позволяет доставлять сообщения без идентификаторов профиля пользователя, и обеспечивает лучшую конфиденциальность метаданных, чем альтернативы.", "privacy-matters-2-title": "Манипулирование выборами", "home": "Главная", + "directory": "Каталог", "chat-protocol": "Протокол чата", "simplex-private-card-5-point-2": "Это делает сообщениям разного размера одинаковыми для серверов и сети.", "hero-overlay-card-1-p-1": "Многие спрашивают: Если у SimpleX нет никаких идентификаторов пользователя, то как приложение знает, куда доставлять сообщения?", @@ -217,7 +218,7 @@ "hero-2-header-desc": "В видео показано, как подключиться к Вашему другу через одноразовый QR-код, при встрече или во время видеосвязи. Вы также можете соединится, поделившись ссылкой-приглашением.", "simplex-network-overlay-card-1-li-6": "Сети P2P могут быть уязвимы для DRDoS атаки, когда клиенты могут ретранслировать и увеличивать трафик, что приводит к отказу всей сети. Клиенты SimpleX ретранслируют трафик только из известного соединения и не могут быть использованы злоумышленником для создания трафика во всей сети.", "if-you-already-installed-simplex-chat-for-the-terminal": "Если Вы уже установили SimpleX Chat для терминала", - "docs-dropdown-8": "Служба Каталогов SimpleX", + "docs-dropdown-8": "Каталог SimpleX", "simplex-private-card-1-point-1": "Протокол двойного обновления ключей —
\"отрицаемые\" сообщения с идеальной прямой секретностью и восстановлением после взлома.", "simplex-private-card-8-point-1": "Серверы SimpleX действуют как узлы-миксеры с низкой задержкой — входящие и исходящие сообщения имеют разный порядок.", "simplex-unique-overlay-card-2-p-1": "Поскольку у Вас нет идентификатора в сети SimpleX, никто не сможет связаться с Вами, если Вы сами не предоставите одноразовый или временный адрес в виде QR-кода или ссылки.", @@ -235,6 +236,7 @@ "hero-overlay-card-1-p-6": "Подробнее читайте в техническом описании SimpleX.", "simplex-network-overlay-card-1-p-1": "Протоколы и приложения для обмена сообщениями P2P имеют различные проблемы, которые делают их менее надежными, чем SimpleX, более сложными для анализа и уязвимыми для нескольких типов атак.", "terms-and-privacy-policy": "Политика Конфиденциальности", + "about-and-contact-us": "O нас и наши контакты", "simplex-network-overlay-card-1-li-1": "Сети P2P используют DHT (распределенные хэш-таблицы) для маршрутизации сообщений. DHT должны обеспечивать баланс между гарантией доставки и задержкой. SimpleX имеет как лучшую гарантию доставки, так и меньшую задержку, чем P2P. В сетях P2P сообщение передается через нескольких узлов, последовательно, кол-во узлов-посредников будет расти параллельно размеру сети — O(log N).", "privacy-matters-section-label": "Убедитесь, что Ваш мессенджер не может получить доступ к Вашим данным!", "simplex-unique-overlay-card-3-p-1": "SimpleX Chat хранит все пользовательские данные на клиентских устройствах в портативном формате зашифрованной базы данных которую можно перенести на другое устройство.", diff --git a/website/run.sh b/website/run.sh new file mode 100755 index 0000000000..a0a8dd0e50 --- /dev/null +++ b/website/run.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +set -e + +cd .. +./website/web.sh +cd website +npm run start diff --git a/website/src/_includes/footer.html b/website/src/_includes/footer.html index 340473ab3e..7f86200fb0 100644 --- a/website/src/_includes/footer.html +++ b/website/src/_includes/footer.html @@ -7,6 +7,26 @@
+ - @@ -56,7 +64,7 @@ - + diff --git a/website/src/_includes/navbar.html b/website/src/_includes/navbar.html index fc7a80458c..762686d5cf 100644 --- a/website/src/_includes/navbar.html +++ b/website/src/_includes/navbar.html @@ -12,15 +12,23 @@ - + - {% if ('blog' not in page.url) and ('jobs' not in page.url) %} + {% if ('blog' not in page.url) and ('jobs' not in page.url) and ('about' not in page.url) and ('privacy' not in page.url) and ('directory' not in page.url) %}

{{ language.name }}

- {% endif %} + {% endif %} {% endif %} {% endfor %} @@ -198,7 +198,7 @@

{{ language.name }}

- {% endif %} + {% endif %} {% endif %} {% endfor %} diff --git a/website/src/blog.html b/website/src/blog.html index 571b240451..4a0460511a 100644 --- a/website/src/blog.html +++ b/website/src/blog.html @@ -1,36 +1,42 @@ --- layout: layouts/main.html title: "SimpleX blog: the latest news" -description: "SimpleX Chat - a private and encrypted messenger without any user IDs (not even random ones)! Make a private connection via link / QR code to send messages and make calls." +description: "SimpleX Chat - a private and encrypted messenger without any user IDs (not even random ones)! Make a +private connection via link / QR code to send messages and make calls." path: /blog templateEngineOverride: njk active_blog: true --- {% block css_links %} - + {% endblock %}
@@ -39,42 +45,48 @@ active_blog: true {% for blog in collections.blogs %} {% if not(blog.data.draft) %} -
-
-
- {% if blog.data.image %} - {% if blog.data.imageBottom %} - - {% elif blog.data.imageWide %} - - {% else %} - - {% endif %} - {% else %} - - - {% endif %} -
+
+
+
+ {% if blog.data.image %} + {% if blog.data.imageBottom %} + + {% elif blog.data.imageWide %} + + {% else %} + + {% endif %} + {% else %} + + + {% endif %}
-
-
-

- {{ blog.data.title | safe }} -

-

- {{ blog.data.date.toUTCString().split(' ').slice(1, 4).join(' ') }} -

- {% if blog.data.previewBody %} -
- {% include blog.data.previewBody %} -
- {% elif blog.data.preview %} -

{{ blog.data.preview | safe }}

- {% endif %} +
+
+
+

+ {{ blog.data.title | safe }} +

+

+ {{ blog.data.date.toUTCString().split(' ').slice(1, 4).join(' ') }} +

+ {% if blog.data.previewBody %} +
+ {% include blog.data.previewBody %}
- Read More + {% elif blog.data.preview %} +

{{ blog.data.preview | safe }}

+ {% endif %}
-
+ Read More +
+
{% endif %} {% endfor %} diff --git a/website/src/css/design3.css b/website/src/css/design3.css index ebf75bf08c..60d3ebc1a7 100644 --- a/website/src/css/design3.css +++ b/website/src/css/design3.css @@ -210,6 +210,11 @@ #3f5598 70%, #c3faff 90%, #fff6e0 100%); + --sa-top: env(safe-area-inset-top, 0px); + --sa-right: env(safe-area-inset-right, 0px); + --sa-bottom: env(safe-area-inset-bottom, 0px); + --sa-left: env(safe-area-inset-left, 0px); + --vh: 1svh; } * { @@ -221,7 +226,12 @@ html { scroll-behavior: smooth; font-family: GT-Walsheim, Gilroy, Helvetica, sans-serif; - letter-spacing: 0.003em; +} + +html, body { background:#000; } + +.hidden { + display: none !important; } img { @@ -231,74 +241,11 @@ img { -ms-user-select: none; } -a { +a, +p { word-wrap: break-word; } -/* NEW SITE */ -.container, -.container-fluid, -.container-xxl, -.container-xl, -.container-lg, -.container-md, -.container-sm { - width: 100%; - /* padding: 0 20px; */ - margin-right: auto; - margin-left: auto; -} - -@media (min-width: 576px) { - - .container-sm, - .container { - max-width: 540px; - } -} - -@media (min-width: 768px) { - - .container-md, - .container-sm, - .container { - max-width: 720px; - } -} - -@media (min-width: 992px) { - - .container-lg, - .container-md, - .container-sm, - .container { - max-width: 960px; - } -} - -@media (min-width: 1200px) { - - .container-xl, - .container-lg, - .container-md, - .container-sm, - .container { - max-width: 1140px; - } -} - -@media (min-width: 1400px) { - - .container-xxl, - .container-xl, - .container-lg, - .container-md, - .container-sm, - .container { - max-width: 1320px; - } -} - .gradient-text { background: linear-gradient(90deg, #019bfe 0%, @@ -311,17 +258,39 @@ a { } .screen { - height: 100vh; - overflow-y: scroll; + height: 100dvh; + /* visible height, updates as bars expand/collapse */ + height: calc(var(--vh) * 100); + /* fallback for older browsers */ + overflow-y: auto; scroll-snap-type: y mandatory; + /* page-by-page snap */ scroll-behavior: smooth; + overscroll-behavior: contain; + /* avoid rubber-banding pulling UI over content */ + -webkit-overflow-scrolling: touch; } section { - width: 100vw; - height: 100vh; - position: relative; + height: 100dvh; + height: calc(var(--vh) * 100); + /* same fallback */ scroll-snap-align: start; + scroll-snap-stop: always; + /* requires a full swipe/scroll to leave */ + position: relative; +} + +/* Foreground content sits inside the safe area without changing section height */ +section .content { + min-height: 100%; + display: grid; + place-items: center; + padding: + max(16px, var(--sa-top)) max(16px, var(--sa-right)) max(24px, var(--sa-bottom)) + /* lifts content above the iOS bottom bar */ + max(16px, var(--sa-left)); + box-sizing: border-box; } /* --- COVER --- */ @@ -369,6 +338,30 @@ section.cover div.content { color: #ffffff; } +.cover .socials { + position: absolute; + width: 100vw; + bottom: 32px; + padding: 0 4vw; + + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + flex-wrap: wrap; +} + +.cover .socials a img { + width: auto; + height: 40px; + border: #4f4f4f 1px solid; + border-radius: 8px; +} + +.cover .socials a img.no-border { + border: none; +} + /* --- MAIN SECTIONS --- */ main { position: relative; @@ -414,6 +407,10 @@ main section .image { background-image: url("/img/design_3/token-31-04_webp.webp"); } +.page-6 .image { + background-image: url("/img/design_3/section-6-desktop.webp"); +} + .text-container { position: relative; color: white; @@ -422,23 +419,26 @@ main section .image { flex-direction: column; justify-content: center; align-items: flex-start; - gap: 2.5rem; - margin-right: auto; - padding-left: 8rem; + gap: 2.3vh; + margin-right: 6.25vw; + margin-left: 6.25vw; width: fit-content; } .text-container h2 { font-family: "GT-Walsheim", "Manrope", sans-serif; font-weight: 400; - font-size: clamp(2.5rem, 8vw, 5.6rem); + font-size: 4.6vw; letter-spacing: -0.025em; + line-height: 1.05; + max-width: 24vw; } .text-container p { font-family: "Manrope", "GT-Walsheim", sans-serif; font-weight: 200; - font-size: clamp(1rem, 2.25vw, 1.84rem); + font-size: 1.45vw; + max-width: 20.5vw; } .text-container p span { @@ -448,15 +448,183 @@ main section .image { .text-container a { font-family: "Manrope", "GT-Walsheim", sans-serif; font-weight: 200; - font-size: clamp(1rem, 2.25vw, 1.84rem); + font-size: 1.45vw; text-decoration: underline; text-decoration-line: underline; text-decoration-thickness: 0.5px; text-underline-offset: 4px; + max-width: 13vw; +} + +.page-2 .text-container h2 { + max-width: 24vw !important; +} + +.page-2 .text-container p { + max-width: 20.5vw; +} + +.page-3 .text-container h2 { + max-width: 30vw !important; +} + +.page-3 .text-container p { + max-width: 28.5vw; } .page-3 .text-container, .page-4 .text-container { - margin-right: 8rem; margin-left: auto; +} + +.page-4 .text-container h2 { + max-width: 28vw !important; +} + +.page-4 .text-container p { + max-width: 26.5vw; +} + +.page-4 .text-container a { + max-width: 26.5vw; +} + +.roadmap>p:first-child { + font-weight: 500; + color: #64fdff; +} + +.roadmap>p:nth-child(2) { + font-weight: 500; +} + +.roadmap>p:nth-child(3) { + font-weight: 200; +} + +.roadmap p { + max-width: 30vw; +} + +@media (max-width: 767px) { + section { + min-height: 100svh; + } + + section.cover div.content { + gap: 35px; + } + + .cover .content h1 { + font-size: 30vw; + } + + .cover .content h2 { + font-size: 7vw; + } + + .cover .content p { + font-size: 4.3vw; + max-width: 62.5vw; + } + + .cover .socials { + justify-content: center; + } + + /* --- MAIN SECTIONS --- */ + .page-2 .image { + background-image: url("/img/design_3/section2-mobile-bg.webp"); + } + + .page-3 .image { + background-image: url("/img/design_3/section-3-mobile.webp"); + } + + .page-4 .image { + background-image: url("/img/design_3/section-4-mobile.webp"); + height: 55%; + } + + .page-6 .image { + background-image: url("/img/design_3/section-6-mobile.webp"); + height: 48%; + } + + .page-3 .text-container, + .page-4 .text-container { + margin-left: 0; + } + + .text-container { + justify-content: flex-end; + align-items: flex-start; + gap: 1.25rem; + margin-right: 8vw !important; + margin-left: 8vw !important; + padding-bottom: 8vw; + max-width: 75vw; + } + + .text-container h2 { + font-family: "GT-Walsheim", "Manrope", sans-serif; + font-weight: 400; + font-size: 9.25vw; + letter-spacing: -0.025em; + max-width: 75vw !important; + line-height: 1.05; + } + + .text-container p { + font-size: 4.2vw; + } + + .text-container a { + font-weight: 300; + font-size: 4.2vw; + } + + .text-container p, + .text-container a { + max-width: 74vw !important; + } + + .page-2 .text-container { + max-width: 75vw; + } + + .page-2 .text-container h2 { + max-width: 75vw !important; + } + + .page-3 .text-container { + max-width: 80vw !important; + } + + .page-3 .text-container p { + max-width: 80vw !important; + } + + .page-3 .text-container h2 { + max-width: 85vw !important; + font-size: 13vw; + } + + .page-4 .text-container { + max-width: 80vw !important; + + } + + .page-4 .text-container h2 { + max-width: 80vw !important; + font-size: 14vw; + } + + .page-4 .text-container p { + max-width: 80vw !important; + } + + .page-4 .text-container a { + max-width: 80vw !important; + } } \ No newline at end of file diff --git a/website/src/directory.html b/website/src/directory.html new file mode 100644 index 0000000000..30d0a7b871 --- /dev/null +++ b/website/src/directory.html @@ -0,0 +1,273 @@ +--- +layout: layouts/main.html +title: "SimpleX Directory" +description: "Find communities on SimpleX network and create your own" +templateEngineOverride: njk +active_directory: true +--- + +{% set lang = page.url | getlang %} +{% block js_scripts %} + + +{% endblock %} + + + +
+
+

SimpleX Directory

+

Welcome to the selected users' communities that you can join via SimpleX Chat + app.

+

SimpleX Directory is also available as a SimpleX chat bot.

+

Read about how to add your community.

+
+ + +
+
+ +
+
\ No newline at end of file diff --git a/website/src/img/design_3/android-dark.png b/website/src/img/design_3/android-dark.png new file mode 100644 index 0000000000..d4feead139 Binary files /dev/null and b/website/src/img/design_3/android-dark.png differ diff --git a/website/src/img/design_3/section-6-desktop.webp b/website/src/img/design_3/section-6-desktop.webp new file mode 100644 index 0000000000..7622f95dfe Binary files /dev/null and b/website/src/img/design_3/section-6-desktop.webp differ diff --git a/website/src/img/design_3/section-6-mobile.webp b/website/src/img/design_3/section-6-mobile.webp new file mode 100644 index 0000000000..ec2c166215 Binary files /dev/null and b/website/src/img/design_3/section-6-mobile.webp differ diff --git a/website/src/img/design_3/section2-mobile-bg.webp b/website/src/img/design_3/section2-mobile-bg.webp new file mode 100644 index 0000000000..d54ff0e9ca Binary files /dev/null and b/website/src/img/design_3/section2-mobile-bg.webp differ diff --git a/website/src/img/design_3/testflight-dark.png b/website/src/img/design_3/testflight-dark.png new file mode 100644 index 0000000000..ddcdd58fd9 Binary files /dev/null and b/website/src/img/design_3/testflight-dark.png differ diff --git a/website/src/img/group.svg b/website/src/img/group.svg new file mode 100644 index 0000000000..2a262ef38a --- /dev/null +++ b/website/src/img/group.svg @@ -0,0 +1,12 @@ + + + + + + + + + \ No newline at end of file diff --git a/website/src/index.html b/website/src/index.html index bb80fb44a7..668644b9eb 100644 --- a/website/src/index.html +++ b/website/src/index.html @@ -3,7 +3,7 @@ - + Simplex Chat @@ -11,12 +11,17 @@
-
-
-

Be
Free

-

Freedom & Security
of Your Communications

-

The first network where you own
your identity, contacts, and groups.

-
+
+

Be
Free

+

Freedom & Security
of Your Communications

+

The first network where you own
your identity, contacts, and groups.

+
+
+ + + + +
@@ -26,10 +31,10 @@
-

World's
Most Secure
Messaging

-

SimpleX messaging has
cutting-edge end-to-end
encryption.

-

For your security, servers can’t
see your messages or who you
talk to.

- Learn more about
SimpleX messaging
+

World's Most Secure Messaging

+

SimpleX messaging has cutting-edge end-to-end encryption.

+

For your security, servers can’t see your messages or who you talk to.

+ Learn more about SimpleX messaging
@@ -37,24 +42,55 @@

You Own
The Next Web

-

SimpleX is founded on the belief that you
must own your identity, contacts and
communities.

-

Open and decentralized network lets you
connect with people and share ideas: be
free and secure.

+

SimpleX is founded on the belief that you must own your identity, contacts and communities.

+

Open and decentralized network lets you connect with people and share ideas: be free and secure.

-

Communities
That Last

-

You will support your favorite groups with
future Community vouchers.

-

Vouchers will pay for servers, to let your
communities stay free and independent.

+

Communities That Last

+

You will support your favorite groups with future Community vouchers.

+

Vouchers will pay for servers, to let your communities stay free and independent.

Learn more about Community Vouchers
+ +
+
+
+

SimpleX Roadmap to Free Internet

+
+

2025

+

Scale to Large Communities

+

Escaping centralized platforms

+
+
+

2026

+

Sustainable Communities & Servers

+

Launching Community Vouchers

+
+
+

2027

+

Make Your Communities Grow

+

Tools to promote your communities

+
+
+
+ \ No newline at end of file diff --git a/website/src/js/design3.js b/website/src/js/design3.js index b082f220f6..e8cb7ec900 100644 --- a/website/src/js/design3.js +++ b/website/src/js/design3.js @@ -1,3 +1,32 @@ -document.addEventListener('DOMContentLoaded', () => { +const isMobile = { + Android: () => navigator.userAgent.match(/Android/i), + iOS: () => navigator.userAgent.match(/iPhone|iPad|iPod/i) +}; -}); +document.addEventListener('DOMContentLoaded', () => { + const googlePlayBtn = document.querySelector('.google-play-btn'); + const appleStoreBtn = document.querySelector('.apple-store-btn'); + const fDroidBtn = document.querySelector('.f-droid-btn'); + const testflightBtn = document.querySelector('.testflight-btn'); + const androidBtn = document.querySelector('.android-btn'); + + if (!googlePlayBtn || !appleStoreBtn || !fDroidBtn || !testflightBtn || !androidBtn) return; + + + if (isMobile.Android()) { + googlePlayBtn.classList.remove('hidden'); + fDroidBtn.classList.remove('hidden'); + androidBtn.classList.remove('hidden'); + } + else if (isMobile.iOS()) { + appleStoreBtn.classList.remove('hidden'); + testflightBtn.classList.remove('hidden'); + } + else { + appleStoreBtn.classList.remove('hidden'); + googlePlayBtn.classList.remove('hidden'); + fDroidBtn.classList.remove('hidden'); + testflightBtn.classList.remove('hidden'); + androidBtn.classList.remove('hidden'); + } +}); \ No newline at end of file diff --git a/website/src/js/directory.js b/website/src/js/directory.js new file mode 100644 index 0000000000..e198231c60 --- /dev/null +++ b/website/src/js/directory.js @@ -0,0 +1,511 @@ +(function() { +const directoryDataURL = 'https://directory.simplex.chat/data/'; + +// const directoryDataURL = 'http://localhost:8080/directory-data/'; + +let allEntries = []; + +let filteredEntries = []; + +let currentSortMode = ''; + +let currentSearch = ''; + +let currentPage = 1; + +async function initDirectory() { + const listing = await fetchJSON(directoryDataURL + 'listing.json') + const liveBtn = document.querySelector('#top-pagination .live'); + const newBtn = document.querySelector('#top-pagination .new'); + const topBtn = document.querySelector('#top-pagination .top'); + const searchInput = document.getElementById('search'); + allEntries = listing.entries + renderEntries('top', bySortPriority, topBtn) + searchInput.addEventListener('input', (e) => renderEntries('top', bySortPriority, topBtn, e.target.value.trim())); + liveBtn.addEventListener('click', () => renderEntries('live', byActiveAtDesc, liveBtn)); + newBtn.addEventListener('click', () => renderEntries('new', byCreatedAtDesc, newBtn)); + topBtn.addEventListener('click', () => renderEntries('top', bySortPriority, topBtn)); + + function renderEntries(mode, comparator, btn, search = '') { + if (currentSortMode === mode && search == currentSearch) return; + currentSortMode = mode; + if (location.hash) location.hash = ''; + liveBtn.classList.remove('active'); + newBtn.classList.remove('active'); + topBtn.classList.remove('active'); + if (search == '') { + currentSearch = ''; + currentPage = 1; + searchInput.value = ''; + btn.classList.add('active'); + } else { + currentSearch = search; + } + filteredEntries = filterEntries(mode, search ?? '').sort(comparator); + renderDirectoryPage(); + } +} + +function renderDirectoryPage() { + const currentEntries = addPagination(filteredEntries); + displayEntries(currentEntries); +} + +function filterEntries(mode, s) { + if (s === '' && mode == 'top') return allEntries.slice(); + const query = s.toLowerCase(); + return allEntries.filter(entry => + ( mode === 'top' + || (mode === 'new' && entry.createdAt) + || (mode === 'live' && entry.activeAt) + ) && + ( query === '' + || (entry.displayName || '').toLowerCase().includes(query) + || includesQuery(entry.shortDescr, query) + || includesQuery(entry.welcomeMessage, query) + ) + ); +} + +function includesQuery(field, query) { + return field + && Array.isArray(field) + && field.some(ft => { + switch (ft.format?.type) { + case 'uri': return uriIncludesQuery(ft.text, query); + case 'hyperLink': return textIncludesQuery(ft.format.showText, query) || uriIncludesQuery(ft.format.linkUri, query); + case 'simplexLink': return textIncludesQuery(ft.format.showText, query); + default: return textIncludesQuery(ft.text, query); + } + }); +} + +function textIncludesQuery(text, query) { + return text ? text.toLowerCase().includes(query) : false +} + +function uriIncludesQuery(uri, query) { + if (!uri) return false; + uri = uri.toLowerCase(); + return !uri.includes('simplex') && uri.includes(query); +} + +async function fetchJSON(url) { + try { + const response = await fetch(url) + if (!response.ok) throw new Error(`HTTP status: ${response.status}`) + return await response.json() + } catch (e) { + console.error(e) + } +} + +function bySortPriority(entry1, entry2) { + return entrySortPriority(entry2) - entrySortPriority(entry1); +} + +function byActiveAtDesc(entry1, entry2) { + return (roundedTs(entry2.activeAt) - roundedTs(entry1.activeAt)) * 10 + + Math.sign(bySortPriority(entry1, entry2)); +} + +function byCreatedAtDesc(entry1, entry2) { + return (roundedTs(entry2.createdAt) - roundedTs(entry1.createdAt)) * 10 + + Math.sign(bySortPriority(entry1, entry2)); +} + +function roundedTs(s) { + try { + return new Date(s).valueOf(); + } catch { + return 0; + } +} + +const simplexUsersGroup = 'SimpleX users group'; + +function entrySortPriority(entry) { + return entry.displayName === simplexUsersGroup + ? Number.MAX_VALUE + : entryMemberCount(entry) +} + +function entryMemberCount(entry) { + return entry.entryType.type == 'group' + ? (entry.entryType.summary?.currentMembers ?? 0) + : 0 +} + +const now = new Date(); +const nowVal = now.valueOf(); +const today = new Date(now); +today.setHours(0, 0, 0, 0); +const todayVal = today.valueOf(); +const todayYear = today.getFullYear(); + +const dateFormatter = Intl?.DateTimeFormat?.(undefined, {month: '2-digit', day: '2-digit'}); +const dateYearFormatter = Intl?.DateTimeFormat?.(undefined, {year: 'numeric', month: '2-digit', day: '2-digit'}); + +function showDate(d) { + return dateFormatter && d.getFullYear() == todayYear + ? dateFormatter.format(d) + : dateYearFormatter?.format(d) ?? d.toLocaleDateString(); +} + +function showCreatedOn(s) { + const d = new Date(s) + d.setHours(0, 0, 0, 0); + return 'Created' + (d.valueOf() === todayVal ? ' today' : ' on ' + showDate(d)); +} + +function showActiveOn(s) { + const d = new Date(s) + const ago = nowVal - d.valueOf(); + if (ago <= 1200000) return 'Active now'; // 20 minutes + if (ago <= 10800000) return 'Active recently'; // 3 hours + d.setHours(0, 0, 0, 0); + return 'Active' + (d.valueOf() === todayVal ? ' today' : ' on ' + showDate(d)); +} + +function displayEntries(entries) { + const directory = document.getElementById('directory'); + directory.innerHTML = ''; + + for (let entry of entries) { + try { + const { entryType, displayName, groupLink, shortDescr, welcomeMessage, 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-[#11182F] mb-8'; + + const textContainer = document.createElement('div'); + textContainer.className = 'text-container'; + + const nameElement = document.createElement('h2'); + nameElement.textContent = displayName; + nameElement.className = 'text-grey-black dark:text-white !text-lg md:!text-xl font-bold'; + textContainer.appendChild(nameElement); + + const welcomeMessageHTML = welcomeMessage ? renderMarkdown(welcomeMessage) : undefined; + const shortDescrHTML = shortDescr ? renderMarkdown(shortDescr) : undefined; + if (shortDescrHTML && welcomeMessageHTML?.includes(shortDescrHTML) !== true) { + const descrElement = document.createElement('p'); + descrElement.innerHTML = renderMarkdown(shortDescr); + textContainer.appendChild(descrElement); + } + + if (welcomeMessageHTML) { + const messageElement = document.createElement('p'); + messageElement.innerHTML = welcomeMessageHTML; + textContainer.appendChild(messageElement); + + const readMore = document.createElement('p'); + readMore.textContent = 'Read more'; + readMore.className = 'read-more'; + readMore.style.display = 'none'; + textContainer.appendChild(readMore); + + setTimeout(() => { + const computedStyle = window.getComputedStyle(messageElement); + const lineHeight = parseFloat(computedStyle.lineHeight); + const maxLines = 5; + const maxHeight = maxLines * lineHeight + const maxHeightPx = `${maxHeight}px`; + messageElement.style.maxHeight = maxHeightPx; + messageElement.style.overflow = 'hidden'; + + if (messageElement.scrollHeight > maxHeight + 4) { + readMore.style.display = 'block'; + readMore.addEventListener('click', () => { + if (messageElement.style.maxHeight === maxHeightPx) { + messageElement.style.maxHeight = 'none'; + readMore.className = 'read-less'; + readMore.innerHTML = '▲'; + } else { + messageElement.style.maxHeight = maxHeightPx; + readMore.className = 'read-more'; + readMore.textContent = 'Read more'; + } + }); + } + }, 0); + } + + const entryTimestamp = currentSortMode === 'new' && entry.createdAt + ? showCreatedOn(entry.createdAt) + : entry.activeAt + ? showActiveOn(entry.activeAt) + : ''; + if (entryTimestamp) { + timestampElement = document.createElement('p'); + timestampElement.textContent = entryTimestamp; + timestampElement.className = 'text-sm'; + textContainer.appendChild(timestampElement); + } + + const memberCount = entryMemberCount(entry); + if (typeof memberCount == 'number' && memberCount > 0) { + const memberCountElement = document.createElement('p'); + memberCountElement.textContent = `${memberCount} members`; + memberCountElement.className = 'text-sm'; + textContainer.appendChild(memberCountElement); + } + + const imgLinkElement = document.createElement('a'); + const groupLinkUri = groupLink.connShortLink ?? groupLink.connFullLink + try { + imgLinkElement.href = platformSimplexUri(groupLinkUri); + } catch(e) { + console.log(e); + imgLinkElement.href = groupLinkUri; + } + imgLinkElement.target = "_blank"; + imgLinkElement.title = `Join ${displayName}`; + + const imgElement = document.createElement('img'); + imgElement.src = imageFile ? directoryDataURL + imageFile : '/img/group.svg'; + imgElement.alt = displayName; + imgElement.addEventListener('error', () => imgElement.src = '/img/group.svg'); + imgLinkElement.appendChild(imgElement); + entryDiv.appendChild(imgLinkElement); + + entryDiv.appendChild(textContainer); + directory.appendChild(entryDiv); + } catch (e) { + console.log(e); + } + } + + for (let el of document.querySelectorAll('.secret')) { + el.addEventListener('click', () => el.classList.toggle('visible')); + } + + directory.style.height = ''; +} + +function goToPage(p) { + currentPage = p; + renderDirectoryPage(); +} + +function addPagination(entries) { + const entriesPerPage = 10; + const totalPages = Math.ceil(entries.length / entriesPerPage); + if (currentPage < 1) currentPage = 1; + if (currentPage > totalPages) currentPage = totalPages; + + const startIndex = (currentPage - 1) * entriesPerPage; + const endIndex = Math.min(startIndex + entriesPerPage, entries.length); + const currentEntries = entries.slice(startIndex, endIndex); + + // addPaginationElements('top-pagination') + addPaginationElements('bottom-pagination') + return currentEntries; + + function addPaginationElements(paginationId) { + const pagination = document.getElementById(paginationId); + if (!pagination) { + return currentEntries; + } + pagination.innerHTML = ''; + + try { + let startPage, endPage; + const pageButtonCount = 8 + if (totalPages <= pageButtonCount) { + startPage = 1; + endPage = totalPages; + } else { + startPage = Math.max(1, currentPage - 4); + endPage = Math.min(totalPages, startPage + pageButtonCount - 1); + if (endPage - startPage + 1 < pageButtonCount) { + startPage = Math.max(1, endPage - pageButtonCount + 1); + } + } + + // if (currentPage > 1 && startPage > 1) { + // const firstBtn = document.createElement('button'); + // firstBtn.textContent = 'First'; + // firstBtn.classList.add('text-btn'); + // firstBtn.addEventListener('click', () => goToPage(1)); + // pagination.appendChild(firstBtn); + // } + + if (currentPage > 1) { + const prevBtn = document.createElement('button'); + prevBtn.textContent = 'Prev'; + prevBtn.classList.add('text-btn'); + prevBtn.addEventListener('click', () => goToPage(currentPage - 1)); + pagination.appendChild(prevBtn); + } + + for (let p = startPage; p <= endPage; p++) { + const pageBtn = document.createElement('button'); + pageBtn.textContent = p.toString(); + if (p === currentPage) { + pageBtn.classList.add('active'); + } else if (p === currentPage - 1 || p === currentPage + 1) { + pageBtn.classList.add('neighbor'); + } + pageBtn.addEventListener('click', () => goToPage(p)); + pagination.appendChild(pageBtn); + } + + if (currentPage < totalPages) { + const nextBtn = document.createElement('button'); + nextBtn.textContent = 'Next'; + nextBtn.classList.add('text-btn'); + nextBtn.addEventListener('click', () => goToPage(currentPage + 1)); + pagination.appendChild(nextBtn); + } + + // if (endPage < totalPages) { + // const lastBtn = document.createElement('button'); + // lastBtn.textContent = 'Last'; + // lastBtn.classList.add('text-btn'); + // lastBtn.addEventListener('click', () => goToPage(totalPages)); + // pagination.appendChild(lastBtn); + // } + + } catch (e) { + console.log(e); + } + } +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initDirectory); +} else { + initDirectory(); +} + +function escapeHtml(text) { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(/\n/g, "
"); +} + +function getSimplexLinkDescr(linkType) { + switch (linkType) { + case 'contact': return 'SimpleX contact address'; + case 'invitation': return 'SimpleX one-time invitation'; + case 'group': return 'SimpleX group link'; + case 'channel': return 'SimpleX channel link'; + case 'relay': return 'SimpleX relay link'; + default: return 'SimpleX link'; + } +} + +function viaHost(smpHosts) { + const first = smpHosts[0] ?? '?'; + return `via ${first}`; +} + +function isCurrentSite(uri) { + return uri.startsWith("https://simplex.chat") || uri.startsWith("https://www.simplex.chat") +} + +function targetBlank(uri) { + return isCurrentSite(uri) ? '' : ' target="_blank"' +} + +const simplexAddressRegexp = /^simplex:\/([a-z]+)#(.+)/i; + +const simplexShortLinkTypes = ["a", "c", "g", "i", "r"]; + +function platformSimplexUri(uri) { + if (isMobile.any()) return uri; + const res = uri.match(simplexAddressRegexp); + if (!res || !Array.isArray(res) || res.length < 3) return uri; + const linkType = res[1]; + const fragment = res[2]; + if (simplexShortLinkTypes.includes(linkType)) { + const queryIndex = fragment.indexOf('?'); + if (queryIndex === -1) return uri; + const hashPart = fragment.substring(0, queryIndex); + const queryStr = fragment.substring(queryIndex + 1); + const params = new URLSearchParams(queryStr); + const host = params.get('h'); + if (!host) return uri; + params.delete('h'); + let newFragment = hashPart; + const remainingParams = params.toString(); + if (remainingParams) newFragment += '?' + remainingParams; + return `https://${host}:/${linkType}#${newFragment}`; + } else { + return `https://simplex.chat/${linkType}#${fragment}`; + } +} + +function renderMarkdown(fts) { + let html = ''; + for (const ft of fts) { + const { format, text } = ft; + if (!format) { + html += escapeHtml(text); + continue; + } + try { + switch (format.type) { + case 'bold': + html += `${escapeHtml(text)}`; + break; + case 'italic': + html += `${escapeHtml(text)}`; + break; + case 'strikeThrough': + html += `${escapeHtml(text)}`; + break; + case 'snippet': + html += `${escapeHtml(text)}`; + break; + case 'secret': + html += `${escapeHtml(text)}`; + break; + case 'colored': + html += `${escapeHtml(text)}`; + break; + case 'uri': + let href = text.startsWith('http://') || text.startsWith('https://') || text.startsWith('simplex:/') ? text : 'https://' + text; + html += `${escapeHtml(text)}`; + break; + case 'hyperLink': { + const { showText, linkUri } = format; + html += `${escapeHtml(showText ?? linkUri)}`; + break; + } + case 'simplexLink': { + const { showText, linkType, simplexUri, smpHosts } = format; + const linkText = showText ? escapeHtml(showText) : getSimplexLinkDescr(linkType); + html += `${linkText} (${viaHost(smpHosts)})`; + break; + } + case 'command': + html += `${escapeHtml(text)}`; + break; + case 'mention': + html += `${escapeHtml(text)}`; + break; + case 'email': + html += `${escapeHtml(text)}`; + break; + case 'phone': + html += `${escapeHtml(text)}`; + break; + case 'unknown': + html += escapeHtml(text); + break; + default: + html += escapeHtml(text); + } + } catch(e) { + console.log(e); + html += escapeHtml(text); + } + } + return html; +} +})(); diff --git a/website/src/js/script.js b/website/src/js/script.js index 5f863f48ee..cf240dd375 100644 --- a/website/src/js/script.js +++ b/website/src/js/script.js @@ -26,7 +26,8 @@ const uniqueSwiper = new Swiper('.unique-swiper', { const isMobile = { Android: () => navigator.userAgent.match(/Android/i), - iOS: () => navigator.userAgent.match(/iPhone|iPad|iPod/i) + iOS: () => navigator.userAgent.match(/iPhone|iPad|iPod/i), + any: () => navigator.userAgent.match(/Android|iPhone|iPad|iPod/i) }; const privateSwiper = new Swiper('.private-swiper', {