diff --git a/migrations/20210612_initial.sql b/migrations/20210612_initial.sql index 261990893..1da08a17e 100644 --- a/migrations/20210612_initial.sql +++ b/migrations/20210612_initial.sql @@ -1,7 +1,7 @@ CREATE TABLE contact_profiles ( -- remote user profile contact_profile_id INTEGER PRIMARY KEY, - contact_ref TEXT NOT NULL, -- contact name set by remote user (not unique), this name must not contain spaces - display_name TEXT NOT NULL, + display_name TEXT NOT NULL, -- contact name set by remote user (not unique), this name must not contain spaces + full_name TEXT NOT NULL, properties TEXT NOT NULL DEFAULT '{}' -- JSON with contact profile properties ); @@ -9,20 +9,35 @@ CREATE TABLE users ( user_id INTEGER PRIMARY KEY, contact_id INTEGER NOT NULL UNIQUE REFERENCES contacts ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, - active_user INTEGER -- 1 for active user + local_display_name TEXT NOT NULL UNIQUE, + active_user INTEGER NOT NULL DEFAULT 0, -- 1 for active user + FOREIGN KEY (user_id, local_display_name) + REFERENCES display_names (user_id, local_display_name) + ON DELETE RESTRICT + DEFERRABLE INITIALLY DEFERRED ); +CREATE TABLE display_names ( + user_id INTEGER NOT NULL REFERENCES users, + local_display_name TEXT NOT NULL, + ldn_base TEXT NOT NULL, + ldn_suffix INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, local_display_name) ON CONFLICT FAIL, + UNIQUE (user_id, ldn_base, ldn_suffix) ON CONFLICT FAIL +) WITHOUT ROWID; + CREATE TABLE contacts ( contact_id INTEGER PRIMARY KEY, - contact_profile_id INTEGER UNIQUE REFERENCES contact_profiles, -- NULL if it's an incognito profile - local_contact_ref TEXT NOT NULL, - lcr_base TEXT NOT NULL, - lcr_suffix INTEGER NOT NULL DEFAULT 0, + contact_profile_id INTEGER REFERENCES contact_profiles, -- NULL if it's an incognito profile user_id INTEGER NOT NULL REFERENCES users, + local_display_name TEXT NOT NULL, is_user INTEGER NOT NULL DEFAULT 0, -- 1 if this contact is a user created_at TEXT NOT NULL DEFAULT (datetime('now')), - UNIQUE (user_id, local_contact_ref) ON CONFLICT FAIL, - UNIQUE (user_id, lcr_base, lcr_suffix) ON CONFLICT FAIL + FOREIGN KEY (user_id, local_display_name) + REFERENCES display_names (user_id, local_display_name) + ON DELETE RESTRICT, + UNIQUE (user_id, local_display_name), + UNIQUE (user_id, contact_profile_id) ); CREATE TABLE known_servers( @@ -36,20 +51,21 @@ CREATE TABLE known_servers( CREATE TABLE group_profiles ( -- shared group profiles group_profile_id INTEGER PRIMARY KEY, - group_ref TEXT NOT NULL, -- this name must not contain spaces - display_name TEXT NOT NULL, + display_name TEXT NOT NULL, -- this name must not contain spaces + full_name TEXT NOT NULL, properties TEXT NOT NULL DEFAULT '{}' -- JSON with user or contact profile ); CREATE TABLE groups ( group_id INTEGER PRIMARY KEY, -- local group ID - local_group_ref TEXT NOT NULL, -- local group name without spaces - lgr_base TEXT NOT NULL, - lgr_suffix INTEGER NOT NULL DEFAULT 0, - group_profile_id INTEGER REFERENCES group_profiles, -- shared group profile user_id INTEGER NOT NULL REFERENCES users, - UNIQUE (user_id, local_group_ref) ON CONFLICT FAIL, - UNIQUE (user_id, lgr_base, lgr_suffix) ON CONFLICT FAIL + local_display_name TEXT NOT NULL, -- local group name without spaces + group_profile_id INTEGER REFERENCES group_profiles, -- shared group profile + FOREIGN KEY (user_id, local_display_name) + REFERENCES display_names (user_id, local_display_name) + ON DELETE RESTRICT, + UNIQUE (user_id, local_display_name), + UNIQUE (user_id, group_profile_id) ); CREATE TABLE group_members ( -- group members, excluding the local user diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 65914a207..c77c7cbff 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -55,16 +55,16 @@ data ChatCommand | MarkdownHelp | AddContact | Connect SMPQueueInfo - | DeleteContact ContactRef - | SendMessage ContactRef ByteString + | DeleteContact ContactName + | SendMessage ContactName ByteString | NewGroup GroupProfile - | AddMember GroupRef ContactRef GroupMemberRole - | RemoveMember GroupRef ContactRef - | MemberRole GroupRef ContactRef GroupMemberRole - | LeaveGroup GroupRef - | DeleteGroup GroupRef - | ListMembers GroupRef - | SendGroupMessage GroupRef ByteString + | AddMember GroupName ContactName GroupMemberRole + | RemoveMember GroupName ContactName + | MemberRole GroupName ContactName GroupMemberRole + | LeaveGroup GroupName + | DeleteGroup GroupName + | ListMembers GroupName + | SendGroupMessage GroupName ByteString deriving (Show) cfg :: AgentConfig @@ -195,7 +195,7 @@ processAgentMessage :: forall m. ChatMonad m => User -> ConnId -> ACommand 'Agen processAgentMessage User {userId, profile} agentConnId agentMessage = do chatDirection <- withStore $ \st -> getConnectionChatDirection st userId agentConnId case chatDirection of - ReceivedDirectMessage (CContact ct@Contact {localContactRef = c}) -> + ReceivedDirectMessage (CContact ct@Contact {localDisplayName = c}) -> case agentMessage of MSG meta msgBody -> do ChatMessage {chatMsgEvent} <- liftEither $ parseChatMessage msgBody @@ -225,7 +225,7 @@ processAgentMessage User {userId, profile} agentConnId agentMessage = do _ -> pure () _ -> pure () where - newTextMessage :: ContactRef -> MsgMeta -> Maybe MsgBodyContent -> m () + newTextMessage :: ContactName -> MsgMeta -> Maybe MsgBodyContent -> m () newTextMessage c meta = \case Just MsgBodyContent {contentData = bs} -> do let text = safeDecodeUtf8 bs @@ -268,17 +268,17 @@ getCreateActiveUser st = do newUser = do putStrLn "No user profiles found, it will be created now.\n\ - \Please choose your alias and your profile name.\n\ + \Please choose your display name and your full name.\n\ \They will be sent to your contacts when you connect.\n\ \They are only stored on your device and you can change them later." loop where loop = do - contactRef <- getContactRef - displayName <- T.pack <$> getWithPrompt "profile name (optional)" - liftIO (runExceptT $ createUser st Profile {contactRef, displayName} True) >>= \case - Left SEDuplicateContactRef -> do - putStrLn "chosen alias already used by another profile on this device, choose another one" + displayName <- getContactName + fullName <- T.pack <$> getWithPrompt "full name (optional)" + liftIO (runExceptT $ createUser st Profile {displayName, fullName} True) >>= \case + Left SEDuplicateName -> do + putStrLn "chosen display name is already used by another profile on this device, choose another one" loop Left e -> putStrLn ("database error " <> show e) >> exitFailure Right user -> pure user @@ -302,14 +302,14 @@ getCreateActiveUser st = do liftIO $ setActiveUser st (userId user) pure user userStr :: User -> String - userStr User {localContactRef, profile = Profile {displayName}} = - T.unpack $ localContactRef <> if T.null displayName then "" else " (" <> displayName <> ")" - getContactRef :: IO ContactRef - getContactRef = do - contactRef <- getWithPrompt "alias (no spaces)" - if null contactRef || isJust (find (== ' ') contactRef) - then putStrLn "alias has space(s), choose another one" >> getContactRef - else pure $ T.pack contactRef + userStr User {localDisplayName, profile = Profile {fullName}} = + T.unpack $ localDisplayName <> if T.null fullName then "" else " (" <> fullName <> ")" + getContactName :: IO ContactName + getContactName = do + displayName <- getWithPrompt "display name (no spaces)" + if null displayName || isJust (find (== ' ') displayName) + then putStrLn "display name has space(s), choose another one" >> getContactName + else pure $ T.pack displayName getWithPrompt :: String -> IO String getWithPrompt s = putStr (s <> ": ") >> hFlush stdout >> getLine @@ -340,24 +340,23 @@ chatCommandP :: Parser ChatCommand chatCommandP = ("/help" <|> "/h") $> ChatHelp <|> ("/group #" <|> "/g #") *> (NewGroup <$> groupProfile) - <|> ("/add #" <|> "/a #") *> (AddMember <$> groupRef <* A.space <*> contactRef <*> memberRole) - <|> ("/remove #" <|> "/rm #") *> (RemoveMember <$> groupRef <* A.space <*> contactRef) - <|> ("/delete #" <|> "/d #") *> (DeleteGroup <$> groupRef) - <|> ("/members #" <|> "/ms #") *> (ListMembers <$> groupRef) - <|> A.char '#' *> (SendGroupMessage <$> groupRef <* A.space <*> A.takeByteString) + <|> ("/add #" <|> "/a #") *> (AddMember <$> displayName <* A.space <*> displayName <*> memberRole) + <|> ("/remove #" <|> "/rm #") *> (RemoveMember <$> displayName <* A.space <*> displayName) + <|> ("/delete #" <|> "/d #") *> (DeleteGroup <$> displayName) + <|> ("/members #" <|> "/ms #") *> (ListMembers <$> displayName) + <|> A.char '#' *> (SendGroupMessage <$> displayName <* A.space <*> A.takeByteString) <|> ("/add" <|> "/a") $> AddContact <|> ("/connect " <|> "/c ") *> (Connect <$> smpQueueInfoP) - <|> ("/delete @" <|> "/delete " <|> "/d @" <|> "/d ") *> (DeleteContact <$> contactRef) - <|> A.char '@' *> (SendMessage <$> contactRef <*> (A.space *> A.takeByteString)) + <|> ("/delete @" <|> "/delete " <|> "/d @" <|> "/d ") *> (DeleteContact <$> displayName) + <|> A.char '@' *> (SendMessage <$> displayName <*> (A.space *> A.takeByteString)) <|> ("/markdown" <|> "/m") $> MarkdownHelp where - contactRef = safeDecodeUtf8 <$> (B.cons <$> A.satisfy refChar <*> A.takeTill (== ' ')) + displayName = safeDecodeUtf8 <$> (B.cons <$> A.satisfy refChar <*> A.takeTill (== ' ')) refChar c = c > ' ' && c /= '#' && c /= '@' - groupRef = contactRef groupProfile = do - gRef <- groupRef + gRef <- displayName gName <- safeDecodeUtf8 <$> (A.space *> A.takeByteString) <|> pure "" - pure GroupProfile {groupRef = gRef, displayName = if T.null gName then gRef else gName} + pure GroupProfile {displayName = gRef, fullName = if T.null gName then gRef else gName} memberRole = (" owner" $> GROwner) <|> (" admin" $> GRAdmin) diff --git a/src/Simplex/Chat/Protocol_.hs b/src/Simplex/Chat/Protocol_.hs deleted file mode 100644 index b241ac42b..000000000 --- a/src/Simplex/Chat/Protocol_.hs +++ /dev/null @@ -1,44 +0,0 @@ -{-# LANGUAGE DuplicateRecordFields #-} - -module Simplex.Chat.Protocol_ where - -import Data.ByteString (ByteString) -import Data.Text (Text) -import Simplex.Messaging.Agent.Protocol (ConnId) - -data ChatEvent = GroupEvent | MessageEvent | InfoEvent - -data Profile = Profile - { profileId :: ByteString, - displayName :: Text - } - -data Contact = Contact - { contactId :: ByteString, - profile :: Profile, - connections :: [Connection] - } - -data Connection = Connection - { connId :: ConnId, - connLevel :: Int, - viaConn :: ConnId - } - -data GroupMember = GroupMember - { groupId :: ByteString, - sharedMemberId :: ByteString, - contact :: Contact, - memberRole :: GroupMemberRole, - memberStatus :: GroupMemberStatus - } - -data GroupMemberRole = GROwner | GRAdmin | GRStandard - -data GroupMemberStatus = GSInvited | GSConnected | GSConnectedAll - -data Group = Group - { groupId :: ByteString, - displayName :: Text, - members :: [GroupMember] - } diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index bc7d726d8..86e8ffb5f 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -8,6 +8,7 @@ {-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TupleSections #-} {-# LANGUAGE TypeOperators #-} module Simplex.Chat.Store @@ -83,19 +84,17 @@ insertedRowId :: DB.Connection -> IO Int64 insertedRowId db = fromOnly . head <$> DB.query_ db "SELECT last_insert_rowid()" createUser :: (MonadUnliftIO m, MonadError StoreError m) => SQLiteStore -> Profile -> Bool -> m User -createUser st Profile {contactRef, displayName} activeUser = - liftIOEither . checkConstraint SEDuplicateContactRef . withTransaction st $ \db -> do - DB.execute db "INSERT INTO contact_profiles (contact_ref, display_name) VALUES (?, ?)" (contactRef, displayName) - profileId <- insertedRowId db - DB.execute db "INSERT INTO users (contact_id, active_user) VALUES (0, ?)" (Only activeUser) +createUser st Profile {displayName, fullName} activeUser = + liftIOEither . checkConstraint SEDuplicateName . withTransaction st $ \db -> do + DB.execute db "INSERT INTO users (local_display_name, active_user, contact_id) VALUES (?, ?, 0)" (displayName, activeUser) userId <- insertedRowId db - DB.execute - db - "INSERT INTO contacts (contact_profile_id, local_contact_ref, lcr_base, user_id, is_user) VALUES (?, ?, ?, ?, ?)" - (profileId, contactRef, contactRef, userId, True) + DB.execute db "INSERT INTO display_names (local_display_name, ldn_base, user_id) VALUES (?, ?, ?)" (displayName, displayName, userId) + DB.execute db "INSERT INTO contact_profiles (display_name, full_name) VALUES (?, ?)" (displayName, fullName) + profileId <- insertedRowId db + DB.execute db "INSERT INTO contacts (contact_profile_id, local_display_name, user_id, is_user) VALUES (?, ?, ?, ?)" (profileId, displayName, userId, True) contactId <- insertedRowId db DB.execute db "UPDATE users SET contact_id = ? WHERE user_id = ?" (contactId, userId) - pure . Right $ toUser (userId, contactId, activeUser, contactRef, displayName) + pure . Right $ toUser (userId, contactId, activeUser, displayName, fullName) getUsers :: SQLiteStore -> IO [User] getUsers st = @@ -104,16 +103,16 @@ getUsers st = <$> DB.query_ db [sql| - SELECT u.user_id, u.contact_id, u.active_user, c.local_contact_ref, p.display_name + SELECT u.user_id, u.contact_id, u.active_user, u.local_display_name, p.full_name FROM users u JOIN contacts c ON u.contact_id = c.contact_id JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id |] -toUser :: (UserId, Int64, Bool, ContactRef, Text) -> User -toUser (userId, userContactId, activeUser, contactRef, displayName) = - let profile = Profile {contactRef, displayName} - in User {userId, userContactId, localContactRef = contactRef, profile, activeUser} +toUser :: (UserId, Int64, Bool, ContactName, Text) -> User +toUser (userId, userContactId, activeUser, displayName, fullName) = + let profile = Profile {displayName, fullName} + in User {userId, userContactId, localDisplayName = displayName, profile, activeUser} setActiveUser :: MonadUnliftIO m => SQLiteStore -> UserId -> m () setActiveUser st userId = do @@ -134,51 +133,17 @@ createDirectConnection st userId agentConnId = createDirectContact :: (MonadUnliftIO m, MonadError StoreError m) => SQLiteStore -> UserId -> Connection -> Profile -> m () -createDirectContact st userId Connection {connId} Profile {contactRef, displayName} = - liftIOEither . withTransaction st $ \db -> do - DB.execute db "INSERT INTO contact_profiles (contact_ref, display_name) VALUES (?, ?)" (contactRef, displayName) - profileId <- insertedRowId db - lcrSuffix <- getLcrSuffix db - create db profileId lcrSuffix 20 - where - getLcrSuffix :: DB.Connection -> IO Int - getLcrSuffix db = - maybe 0 ((+ 1) . fromOnly) . listToMaybe - <$> DB.queryNamed - db - [sql| - SELECT lcr_suffix FROM contacts - WHERE user_id = :user_id AND lcr_base = :contact_ref - ORDER BY lcr_suffix DESC - LIMIT 1 - |] - [":user_id" := userId, ":contact_ref" := contactRef] - create :: DB.Connection -> Int64 -> Int -> Int -> IO (Either StoreError ()) - create _ _ _ 0 = pure $ Left SEDuplicateContactRef - create db profileId lcrSuffix attempts = do - let lcr = localContactRef' lcrSuffix - E.try (insertUser lcr) >>= \case - Right () -> do - contactId <- insertedRowId db - DB.execute db "UPDATE connections SET contact_id = ? WHERE connection_id = ?" (contactId, connId) - pure $ Right () - Left e - | DB.sqlError e == DB.ErrorConstraint -> create db profileId (lcrSuffix + 1) (attempts - 1) - | otherwise -> E.throwIO e - where - localContactRef' 0 = contactRef - localContactRef' n = contactRef <> T.pack ('_' : show n) - insertUser lcr = - DB.execute - db - [sql| - INSERT INTO contacts - (contact_profile_id, local_contact_ref, lcr_base, lcr_suffix, user_id) VALUES (?, ?, ?, ?, ?) - |] - (profileId, lcr, contactRef, lcrSuffix, userId) +createDirectContact st userId Connection {connId} Profile {displayName, fullName} = + liftIOEither . withTransaction st $ \db -> + withLocalDisplayName db userId displayName $ \localDisplayName' -> do + DB.execute db "INSERT INTO contact_profiles (display_name, full_name) VALUES (?, ?)" (displayName, fullName) + profileId <- insertedRowId db + DB.execute db "INSERT INTO contacts (contact_profile_id, local_display_name, user_id) VALUES (?, ?, ?)" (profileId, localDisplayName', userId) + contactId <- insertedRowId db + DB.execute db "UPDATE connections SET contact_id = ? WHERE connection_id = ?" (contactId, connId) -deleteContact :: MonadUnliftIO m => SQLiteStore -> UserId -> ContactRef -> m () -deleteContact st userId contactRef = +deleteContact :: MonadUnliftIO m => SQLiteStore -> UserId -> ContactName -> m () +deleteContact st userId displayName = liftIO . withTransaction st $ \db -> do DB.executeNamed db @@ -187,23 +152,30 @@ deleteContact st userId contactRef = SELECT connection_id FROM connections c JOIN contacts cs ON c.contact_id = cs.contact_id - WHERE cs.user_id = :user_id AND cs.local_contact_ref = :contact_ref + WHERE cs.user_id = :user_id AND cs.local_display_name = :display_name ) |] - [":user_id" := userId, ":contact_ref" := contactRef] + [":user_id" := userId, ":display_name" := displayName] DB.executeNamed db [sql| DELETE FROM contacts - WHERE user_id = :user_id AND local_contact_ref = :contact_ref + WHERE user_id = :user_id AND local_display_name = :display_name |] - [":user_id" := userId, ":contact_ref" := contactRef] + [":user_id" := userId, ":display_name" := displayName] + DB.executeNamed + db + [sql| + DELETE FROM display_names + WHERE user_id = :user_id AND local_display_name = :display_name + |] + [":user_id" := userId, ":display_name" := displayName] -- TODO return the last connection that is ready, not any last connection -- requires updating connection status getContact :: - (MonadUnliftIO m, MonadError StoreError m) => SQLiteStore -> UserId -> ContactRef -> m Contact -getContact st userId localContactRef = + (MonadUnliftIO m, MonadError StoreError m) => SQLiteStore -> UserId -> ContactName -> m Contact +getContact st userId localDisplayName = liftIOEither . withTransaction st $ \db -> runExceptT $ do c@Contact {contactId} <- getContact_ db activeConn <- getConnection_ db contactId @@ -214,12 +186,12 @@ getContact st userId localContactRef = <$> DB.queryNamed db [sql| - SELECT c.contact_id, p.contact_ref, p.display_name + SELECT c.contact_id, p.display_name, p.full_name FROM contacts c JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id - WHERE c.user_id = :user_id AND c.local_contact_ref = :local_contact_ref AND c.is_user = :is_user + WHERE c.user_id = :user_id AND c.local_display_name = :local_display_name AND c.is_user = :is_user |] - [":user_id" := userId, ":local_contact_ref" := localContactRef, ":is_user" := False] + [":user_id" := userId, ":local_display_name" := localDisplayName, ":is_user" := False] getConnection_ db contactId = ExceptT $ do connection <$> DB.queryNamed @@ -233,15 +205,15 @@ getContact st userId localContactRef = LIMIT 1 |] [":user_id" := userId, ":contact_id" := contactId] - toContact [(contactId, contactRef, displayName)] = - let profile = Profile {contactRef, displayName} - in Right Contact {contactId, localContactRef, profile, activeConn = undefined} - toContact _ = Left $ SEContactNotFound localContactRef + toContact [(contactId, displayName, fullName)] = + let profile = Profile {displayName, fullName} + in Right Contact {contactId, localDisplayName, profile, activeConn = undefined} + toContact _ = Left $ SEContactNotFound localDisplayName connection (connRow : _) = Right $ toConnection connRow - connection _ = Left $ SEContactNotReady localContactRef + connection _ = Left $ SEContactNotReady localDisplayName -getContactConnections :: (MonadUnliftIO m, MonadError StoreError m) => SQLiteStore -> UserId -> ContactRef -> m [Connection] -getContactConnections st userId contactRef = +getContactConnections :: (MonadUnliftIO m, MonadError StoreError m) => SQLiteStore -> UserId -> ContactName -> m [Connection] +getContactConnections st userId displayName = liftIOEither . withTransaction st $ \db -> connections <$> DB.queryNamed @@ -253,11 +225,11 @@ getContactConnections st userId contactRef = JOIN contacts cs ON c.contact_id == cs.contact_id WHERE c.user_id = :user_id AND cs.user_id = :user_id - AND cs.local_contact_ref == :contact_ref + AND cs.local_display_name == :display_name |] - [":user_id" := userId, ":contact_ref" := contactRef] + [":user_id" := userId, ":display_name" := displayName] where - connections [] = Left $ SEContactNotFound contactRef + connections [] = Left $ SEContactNotFound displayName connections rows = Right $ map toConnection rows type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, ConnStatus, ConnType, Maybe Int64, Maybe Int64, UTCTime) @@ -302,27 +274,26 @@ getConnectionChatDirection st userId agentConnId = <$> DB.query db [sql| - SELECT c.local_contact_ref, p.contact_ref, p.display_name + SELECT c.local_display_name, p.display_name, p.full_name FROM contacts c JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id WHERE c.user_id = ? AND c.contact_id = ? |] (userId, contactId) - toContact contactId c [(localContactRef, contactRef, displayName)] = - let profile = Profile {contactRef, displayName} - in Right $ CContact Contact {contactId, localContactRef, profile, activeConn = c} + toContact contactId c [(localDisplayName, displayName, fullName)] = + let profile = Profile {displayName, fullName} + in Right $ CContact Contact {contactId, localDisplayName, profile, activeConn = c} toContact _ _ _ = Left $ SEInternal "referenced contact not found" -- | creates completely new group with a single member - the current user createNewGroup :: (MonadUnliftIO m, MonadError StoreError m) => SQLiteStore -> TVar ChaChaDRG -> User -> GroupProfile -> m Group -createNewGroup st gVar User {userId, userContactId, profile} p@GroupProfile {groupRef, displayName} = - liftIOEither . checkConstraint SEDuplicateGroupRef . withTransaction st $ \db -> do - -- group inserted before profile to ensure its local_group_ref is unique - DB.execute db "INSERT INTO groups (local_group_ref, lgr_base, user_id) VALUES (?, ?, ?)" (groupRef, groupRef, userId) - groupId <- insertedRowId db - DB.execute db "INSERT INTO group_profiles (group_ref, display_name) VALUES (?, ?)" (groupRef, displayName) +createNewGroup st gVar User {userId, userContactId, profile} p@GroupProfile {displayName, fullName} = + liftIOEither . checkConstraint SEDuplicateName . withTransaction st $ \db -> do + DB.execute db "INSERT INTO display_names (local_display_name, ldn_base, user_id) VALUES (?, ?, ?)" (displayName, displayName, userId) + DB.execute db "INSERT INTO group_profiles (display_name, full_name) VALUES (?, ?)" (displayName, fullName) profileId <- insertedRowId db - DB.execute db "UPDATE groups SET group_profile_id = ? WHERE group_id = ?" (profileId, groupId) + DB.execute db "INSERT INTO groups (local_display_name, user_id, group_profile_id) VALUES (?, ?, ?)" (displayName, userId, profileId) + groupId <- insertedRowId db memberId <- randomId gVar 12 createMember_ db groupId userContactId GROwner GSMemReady (Just userContactId) memberId groupMemberId <- insertedRowId db @@ -336,71 +307,44 @@ createNewGroup st gVar User {userId, userContactId, profile} p@GroupProfile {gro memberProfile = profile, memberContactId = Just userContactId } - pure $ Right Group {groupId, localGroupRef = groupRef, groupProfile = p, members = [], membership} + pure $ Right Group {groupId, localDisplayName = displayName, groupProfile = p, members = [], membership} -- | creates a new group record for the group the current user was invited to createGroup :: (MonadUnliftIO m, MonadError StoreError m) => SQLiteStore -> TVar ChaChaDRG -> User -> Contact -> GroupProfile -> m Group -createGroup st gVar User {userId, userContactId, profile} contact p@GroupProfile {groupRef, displayName} = - liftIOEither . withTransaction st $ \db -> do - DB.execute db "INSERT INTO group_profiles (group_ref, display_name) VALUES (?, ?)" (groupRef, displayName) - profileId <- insertedRowId db - lgrSuffix <- getLgrSuffix db - group <- create db profileId lgrSuffix 20 - pure group - where - -- createMember_ db groupId userContactId GROwner GSMemReady (Just userContactId) memberId - -- groupMemberId <- insertedRowId db - -- let membership = - -- GroupMember - -- { groupMemberId, - -- memberId, - -- memberRole = GROwner, - -- memberStatus = GSMemReady, - -- invitedBy = IBUser, - -- memberProfile = profile, - -- memberContactId = Just userContactId - -- } - -- pure $ Right Group {groupId, localGroupRef = groupRef, groupProfile = p, members = [], membership} +createGroup st gVar User {userId, userContactId, profile} contact p@GroupProfile {displayName, fullName} = + liftIOEither . withTransaction st $ \db -> + withLocalDisplayName db userId displayName $ \localDisplayName -> do + DB.execute db "INSERT INTO group_profiles (display_name, full_name) VALUES (?, ?)" (displayName, fullName) + profileId <- insertedRowId db + DB.execute + db + [sql| + INSERT INTO groups + (group_profile_id, local_display_name, user_id) VALUES (?, ?, ?) + |] + (profileId, localDisplayName, userId) + groupId <- insertedRowId db + pure Group {groupId, localDisplayName, groupProfile = p, members = undefined, membership = undefined} - getLgrSuffix :: DB.Connection -> IO Int - getLgrSuffix db = - maybe 0 ((+ 1) . fromOnly) . listToMaybe - <$> DB.queryNamed - db - [sql| - SELECT lgr_suffix FROM groups - WHERE user_id = :user_id AND lgr_base = :group_ref - ORDER BY lgr_suffix DESC - LIMIT 1 - |] - [":user_id" := userId, ":group_ref" := groupRef] - create :: DB.Connection -> Int64 -> Int -> Int -> IO (Either StoreError Group) - create _ _ _ 0 = pure $ Left SEDuplicateGroupRef - create db profileId lgrSuffix attempts = do - let lgr = localGroupRef' lgrSuffix - E.try (insertGroup lgr) >>= \case - Right () -> do - groupId <- insertedRowId db - pure $ Right Group {groupId, localGroupRef = lgr, groupProfile = p, members = undefined, membership = undefined} - Left e - | DB.sqlError e == DB.ErrorConstraint -> create db profileId (lgrSuffix + 1) (attempts - 1) - | otherwise -> E.throwIO e - where - localGroupRef' 0 = groupRef - localGroupRef' n = groupRef <> T.pack ('_' : show n) - insertGroup lgr = - DB.execute - db - [sql| - INSERT INTO groups - (group_profile_id, local_group_ref, lgr_base, lgr_suffix, user_id) VALUES (?, ?, ?, ?, ?) - |] - (profileId, lgr, groupRef, lgrSuffix, userId) +-- where +-- createMember_ db groupId userContactId GROwner GSMemReady (Just userContactId) memberId +-- groupMemberId <- insertedRowId db +-- let membership = +-- GroupMember +-- { groupMemberId, +-- memberId, +-- memberRole = GROwner, +-- memberStatus = GSMemReady, +-- invitedBy = IBUser, +-- memberProfile = profile, +-- memberContactId = Just userContactId +-- } +-- pure $ Right Group {groupId, localDisplayName = displayName, groupProfile = p, members = [], membership} -- TODO return the last connection that is ready, not any last connection -- requires updating connection status -getGroup :: (MonadUnliftIO m, MonadError StoreError m) => SQLiteStore -> User -> GroupRef -> m Group -getGroup st User {userId, userContactId} localGroupRef = +getGroup :: (MonadUnliftIO m, MonadError StoreError m) => SQLiteStore -> User -> GroupName -> m Group +getGroup st User {userId, userContactId} localDisplayName = liftIOEither . withTransaction st $ \db -> runExceptT $ do g@Group {groupId} <- getGroup_ db members <- getMembers_ db groupId @@ -413,17 +357,17 @@ getGroup st User {userId, userContactId} localGroupRef = <$> DB.query db [sql| - SELECT g.group_id, p.group_ref, p.display_name + SELECT g.group_id, p.display_name, p.full_name FROM groups g JOIN group_profiles p ON p.group_profile_id = g.group_profile_id - WHERE g.local_group_ref = ? AND g.user_id = ? + WHERE g.local_display_name = ? AND g.user_id = ? |] - (localGroupRef, userId) - toGroup :: [(Int64, GroupRef, Text)] -> Either StoreError Group - toGroup [(groupId, groupRef, displayName)] = - let groupProfile = GroupProfile {groupRef, displayName} - in Right Group {groupId, localGroupRef, groupProfile, members = undefined, membership = undefined} - toGroup _ = Left $ SEGroupNotFound localGroupRef + (localDisplayName, userId) + toGroup :: [(Int64, GroupName, Text)] -> Either StoreError Group + toGroup [(groupId, displayName, fullName)] = + let groupProfile = GroupProfile {displayName, fullName} + in Right Group {groupId, localDisplayName, groupProfile, members = undefined, membership = undefined} + toGroup _ = Left $ SEGroupNotFound localDisplayName getMembers_ :: DB.Connection -> Int64 -> ExceptT StoreError IO [(GroupMember, Connection)] getMembers_ db groupId = ExceptT $ do Right . map toContactMember @@ -432,7 +376,7 @@ getGroup st User {userId, userContactId} localGroupRef = [sql| SELECT m.group_member_id, m.member_id, m.member_role, m.member_status, - m.invited_by, m.contact_id, p.contact_ref, p.display_name, + m.invited_by, m.contact_id, p.display_name, p.full_name, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.created_at FROM group_members m @@ -452,7 +396,7 @@ getGroup st User {userId, userContactId} localGroupRef = [sql| SELECT m.group_member_id, m.member_id, m.member_role, m.member_status, - m.invited_by, m.contact_id, p.contact_ref, p.display_name + m.invited_by, m.contact_id, p.display_name, p.full_name FROM group_members m JOIN groups g ON g.group_id = m.group_id JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id @@ -462,15 +406,15 @@ getGroup st User {userId, userContactId} localGroupRef = toContactMember :: (GroupMemberRow :. ConnectionRow) -> (GroupMember, Connection) toContactMember (memberRow :. connRow) = (toGroupMember memberRow, toConnection connRow) toGroupMember :: GroupMemberRow -> GroupMember - toGroupMember (groupMemberId, memberId, memberRole, memberStatus, invitedById, memberContactId, contactRef, displayName) = - let memberProfile = Profile {contactRef, displayName} + toGroupMember (groupMemberId, memberId, memberRole, memberStatus, invitedById, memberContactId, displayName, fullName) = + let memberProfile = Profile {displayName, fullName} invitedBy = toInvitedBy userContactId invitedById in GroupMember {groupMemberId, memberId, memberRole, memberStatus, invitedBy, memberProfile, memberContactId} userMember :: [GroupMemberRow] -> Either StoreError GroupMember userMember [memberRow] = Right $ toGroupMember memberRow userMember _ = Left SEGroupWithoutUser -type GroupMemberRow = (Int64, ByteString, GroupMemberRole, GroupMemberStatus, Maybe Int64, Maybe Int64, ContactRef, Text) +type GroupMemberRow = (Int64, ByteString, GroupMemberRole, GroupMemberStatus, Maybe Int64, Maybe Int64, ContactName, Text) createGroupMember :: (MonadUnliftIO m, MonadError StoreError m) => SQLiteStore -> TVar ChaChaDRG -> User -> Int64 -> Int64 -> GroupMemberRole -> InvitedBy -> ConnId -> m MemberId createGroupMember st gVar User {userId, userContactId} groupId contactId memberRole invitedBy agentConnId = @@ -512,6 +456,42 @@ createMember_ db groupId contactId memberRole memberStatus invitedBy memberId = ":contact_id" := contactId ] +-- | Saves unique local display name based on passed displayName, suffixed with _N if required. +-- This function should be called inside transaction. +withLocalDisplayName :: forall a. DB.Connection -> UserId -> Text -> (Text -> IO a) -> IO (Either StoreError a) +withLocalDisplayName db userId displayName action = getLdnSuffix >>= (`tryCreateName` 20) + where + getLdnSuffix :: IO Int + getLdnSuffix = + maybe 0 ((+ 1) . fromOnly) . listToMaybe + <$> DB.queryNamed + db + [sql| + SELECT ldn_suffix FROM display_names + WHERE user_id = :user_id AND ldn_base = :display_name + ORDER BY ldn_suffix DESC + LIMIT 1 + |] + [":user_id" := userId, ":display_name" := displayName] + tryCreateName :: Int -> Int -> IO (Either StoreError a) + tryCreateName _ 0 = pure $ Left SEDuplicateName + tryCreateName ldnSuffix attempts = do + let ldn = displayName <> (if ldnSuffix == 0 then "" else T.pack $ '_' : show ldnSuffix) + E.try (insertName ldn) >>= \case + Right () -> Right <$> action ldn + Left e + | DB.sqlError e == DB.ErrorConstraint -> tryCreateName (ldnSuffix + 1) (attempts - 1) + | otherwise -> E.throwIO e + where + insertName ldn = + DB.execute + db + [sql| + INSERT INTO display_names + (local_display_name, ldn_base, ldn_suffix, user_id) VALUES (?, ?, ?, ?) + |] + (ldn, displayName, ldnSuffix, userId) + createWithRandomId :: TVar ChaChaDRG -> (ByteString -> IO ()) -> IO (Either StoreError ByteString) createWithRandomId gVar create = tryCreate 3 where @@ -529,11 +509,10 @@ randomId :: TVar ChaChaDRG -> Int -> IO ByteString randomId gVar n = B64.encode <$> (atomically . stateTVar gVar $ randomBytesGenerate n) data StoreError - = SEDuplicateContactRef - | SEContactNotFound ContactRef - | SEContactNotReady ContactRef - | SEDuplicateGroupRef - | SEGroupNotFound GroupRef + = SEDuplicateName + | SEContactNotFound ContactName + | SEContactNotReady ContactName + | SEGroupNotFound GroupName | SEGroupWithoutUser | SEDuplicateGroupMember | SEConnectionNotFound ConnId diff --git a/src/Simplex/Chat/Terminal.hs b/src/Simplex/Chat/Terminal.hs index d3d5cec6e..ea29e2b24 100644 --- a/src/Simplex/Chat/Terminal.hs +++ b/src/Simplex/Chat/Terminal.hs @@ -15,7 +15,7 @@ import System.Terminal import System.Terminal.Internal (LocalTerminal, Terminal, VirtualTerminal) import UnliftIO.STM -data ActiveTo = ActiveNone | ActiveC ContactRef +data ActiveTo = ActiveNone | ActiveC ContactName deriving (Eq) data ChatTerminal = ChatTerminal diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 81a7a5b67..7d7e61602 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -26,7 +26,7 @@ import Simplex.Messaging.Agent.Store.SQLite (fromTextField_) data User = User { userId :: UserId, userContactId :: Int64, - localContactRef :: ContactRef, + localDisplayName :: ContactName, profile :: Profile, activeUser :: Bool } @@ -35,7 +35,7 @@ type UserId = Int64 data Contact = Contact { contactId :: Int64, - localContactRef :: ContactRef, + localDisplayName :: ContactName, profile :: Profile, activeConn :: Connection } @@ -44,13 +44,13 @@ data Contact = Contact contactConnId :: Contact -> ConnId contactConnId Contact {activeConn = Connection {agentConnId}} = agentConnId -type ContactRef = Text +type ContactName = Text -type GroupRef = Text +type GroupName = Text data Group = Group { groupId :: Int64, - localGroupRef :: Text, + localDisplayName :: GroupName, groupProfile :: GroupProfile, members :: [(GroupMember, Connection)], membership :: GroupMember @@ -58,8 +58,8 @@ data Group = Group deriving (Eq, Show) data Profile = Profile - { contactRef :: ContactRef, - displayName :: Text + { displayName :: ContactName, + fullName :: Text } deriving (Generic, Eq, Show) @@ -68,8 +68,8 @@ instance ToJSON Profile where toEncoding = J.genericToEncoding J.defaultOptions instance FromJSON Profile data GroupProfile = GroupProfile - { groupRef :: GroupRef, - displayName :: Text + { displayName :: GroupName, + fullName :: Text } deriving (Generic, Eq, Show) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index bb7cc01ec..226f1888f 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -45,19 +45,19 @@ showInvitation = printToView . invitation showChatError :: ChatReader m => ChatError -> m () showChatError = printToView . chatError -showContactDeleted :: ChatReader m => ContactRef -> m () +showContactDeleted :: ChatReader m => ContactName -> m () showContactDeleted = printToView . contactDeleted -showContactConnected :: ChatReader m => ContactRef -> m () +showContactConnected :: ChatReader m => ContactName -> m () showContactConnected = printToView . contactConnected -showContactDisconnected :: ChatReader m => ContactRef -> m () +showContactDisconnected :: ChatReader m => ContactName -> m () showContactDisconnected = printToView . contactDisconnected -showReceivedMessage :: ChatReader m => ContactRef -> UTCTime -> Text -> MsgIntegrity -> m () +showReceivedMessage :: ChatReader m => ContactName -> UTCTime -> Text -> MsgIntegrity -> m () showReceivedMessage c utcTime msg mOk = printToView =<< liftIO (receivedMessage c utcTime msg mOk) -showSentMessage :: ChatReader m => ContactRef -> ByteString -> m () +showSentMessage :: ChatReader m => ContactName -> ByteString -> m () showSentMessage c msg = printToView =<< liftIO (sentMessage c msg) showGroupCreated :: ChatReader m => GroupProfile -> m () @@ -72,19 +72,19 @@ invitation qInfo = "and ask them to connect: /c " ] -contactDeleted :: ContactRef -> [StyledString] +contactDeleted :: ContactName -> [StyledString] contactDeleted c = [ttyContact c <> " is deleted"] -contactConnected :: ContactRef -> [StyledString] +contactConnected :: ContactName -> [StyledString] contactConnected c = [ttyContact c <> " is connected"] -contactDisconnected :: ContactRef -> [StyledString] +contactDisconnected :: ContactName -> [StyledString] contactDisconnected c = ["disconnected from " <> ttyContact c <> " - restart chat"] groupCreated :: GroupProfile -> [StyledString] -groupCreated GroupProfile {groupRef, displayName} = ["group " <> ttyGroup groupRef <> " (" <> plain displayName <> ") is created"] +groupCreated GroupProfile {displayName, fullName} = ["group " <> ttyGroup displayName <> " (" <> plain fullName <> ") is created"] -receivedMessage :: ContactRef -> UTCTime -> Text -> MsgIntegrity -> IO [StyledString] +receivedMessage :: ContactName -> UTCTime -> Text -> MsgIntegrity -> IO [StyledString] receivedMessage c utcTime msg mOk = do t <- formatUTCTime <$> getCurrentTimeZone <*> getZonedTime pure $ prependFirst (t <> " " <> ttyFromContact c) (msgPlain msg) ++ showIntegrity mOk @@ -110,7 +110,7 @@ receivedMessage c utcTime msg mOk = do msgError :: String -> [StyledString] msgError s = [styled (Colored Red) s] -sentMessage :: ContactRef -> ByteString -> IO [StyledString] +sentMessage :: ContactName -> ByteString -> IO [StyledString] sentMessage c msg = do time <- formatTime defaultTimeLocale "%H:%M" <$> getZonedTime pure $ prependFirst (styleTime time <> " " <> ttyToContact c) (msgPlain $ safeDecodeUtf8 msg) @@ -125,9 +125,9 @@ msgPlain = map styleMarkdownText . T.lines chatError :: ChatError -> [StyledString] chatError = \case ChatErrorStore err -> case err of + SEDuplicateName -> ["this display name is already used by user, contact or group"] SEContactNotFound c -> ["no contact " <> ttyContact c] SEContactNotReady c -> ["contact " <> ttyContact c <> " is not active yet"] - SEDuplicateGroupRef -> ["group with this alias already exists"] e -> ["chat db error: " <> plain (show e)] ChatErrorAgent err -> case err of -- CONN e -> case e of @@ -141,16 +141,16 @@ chatError = \case printToView :: (MonadUnliftIO m, MonadReader ChatController m) => [StyledString] -> m () printToView s = asks chatTerminal >>= liftIO . (`printToTerminal` s) -ttyContact :: ContactRef -> StyledString +ttyContact :: ContactName -> StyledString ttyContact = styled (Colored Green) -ttyToContact :: ContactRef -> StyledString +ttyToContact :: ContactName -> StyledString ttyToContact c = styled (Colored Cyan) $ "@" <> c <> " " -ttyFromContact :: ContactRef -> StyledString +ttyFromContact :: ContactName -> StyledString ttyFromContact c = styled (Colored Yellow) $ c <> "> " -ttyGroup :: GroupRef -> StyledString +ttyGroup :: GroupName -> StyledString ttyGroup g = styled (Colored Blue) $ "#" <> g -- ttyFromGroup :: Group -> Contact -> StyledString diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index 95947f1ce..a13c591d0 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -6,17 +6,17 @@ import ChatClient import Control.Concurrent.Async (concurrently_) import Control.Concurrent.STM import Data.Char (isDigit) -import Data.List (dropWhileEnd, find, isPrefixOf) +import Data.List (dropWhileEnd, isPrefixOf) import Simplex.Chat.Controller import Simplex.Chat.Types (Profile (..)) import System.Terminal.Internal (VirtualTerminal (..)) import Test.Hspec aliceProfile :: Profile -aliceProfile = Profile {contactRef = "alice", displayName = "Alice"} +aliceProfile = Profile {displayName = "alice", fullName = "Alice"} bobProfile :: Profile -bobProfile = Profile {contactRef = "bob", displayName = "Bob"} +bobProfile = Profile {displayName = "bob", fullName = "Bob"} testAddContact :: Spec testAddContact = describe "add chat contact" $ @@ -32,6 +32,22 @@ testAddContact = describe "add chat contact" $ bob <# "alice> hello" bob #> "@alice hi" alice <# "bob> hi" + -- test adding the same contact one more time - local name will be different + alice ##> "/a" + Just inv' <- invitation <$> getWindow alice + bob ##> ("/c " <> inv') + concurrently_ + (bob <## "alice_1 is connected") + (alice <## "bob_1 is connected") + alice #> "@bob_1 hello" + bob <# "alice_1> hello" + bob #> "@alice_1 hi" + alice <# "bob_1> hi" + -- test deleting contact + alice ##> "/d bob_1" + alice <## "bob_1 is deleted" + chatCommand alice "@bob_1 hey" + alice <## "no contact bob_1" (##>) :: TestCC -> String -> IO () (##>) cc cmd = do @@ -67,7 +83,11 @@ getWindow (TestCC _ t _) = do if win' /= win then pure win' else retry invitation :: [String] -> Maybe String -invitation win = dropWhileEnd (== ' ') <$> find ("smp::" `isPrefixOf`) win +invitation win = lastMaybe $ map (dropWhileEnd (== ' ')) $ filter ("smp::" `isPrefixOf`) win lastOutput :: [String] -> String lastOutput win = dropWhileEnd (== ' ') $ win !! (length win - 2) -- (- 2) to exclude prompt + +lastMaybe :: [a] -> Maybe a +lastMaybe [] = Nothing +lastMaybe xs = Just $ last xs