use shared namespace for usernames, contact names and group names (#76)

* test adding same contact, add display_names table and functions

* rename display_name -> full_name

* use shared namespace for usernames, contact names and group names
This commit is contained in:
Evgeny Poberezkin 2021-07-14 20:11:41 +01:00 committed by GitHub
parent e99c4bda1e
commit e9d931059b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 262 additions and 292 deletions

View File

@ -1,7 +1,7 @@
CREATE TABLE contact_profiles ( -- remote user profile CREATE TABLE contact_profiles ( -- remote user profile
contact_profile_id INTEGER PRIMARY KEY, 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, -- contact name set by remote user (not unique), this name must not contain spaces
display_name TEXT NOT NULL, full_name TEXT NOT NULL,
properties TEXT NOT NULL DEFAULT '{}' -- JSON with contact profile properties properties TEXT NOT NULL DEFAULT '{}' -- JSON with contact profile properties
); );
@ -9,20 +9,35 @@ CREATE TABLE users (
user_id INTEGER PRIMARY KEY, user_id INTEGER PRIMARY KEY,
contact_id INTEGER NOT NULL UNIQUE REFERENCES contacts ON DELETE CASCADE contact_id INTEGER NOT NULL UNIQUE REFERENCES contacts ON DELETE CASCADE
DEFERRABLE INITIALLY DEFERRED, 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 ( CREATE TABLE contacts (
contact_id INTEGER PRIMARY KEY, contact_id INTEGER PRIMARY KEY,
contact_profile_id INTEGER UNIQUE REFERENCES contact_profiles, -- NULL if it's an incognito profile contact_profile_id INTEGER 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,
user_id INTEGER NOT NULL REFERENCES users, 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 is_user INTEGER NOT NULL DEFAULT 0, -- 1 if this contact is a user
created_at TEXT NOT NULL DEFAULT (datetime('now')), created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (user_id, local_contact_ref) ON CONFLICT FAIL, FOREIGN KEY (user_id, local_display_name)
UNIQUE (user_id, lcr_base, lcr_suffix) ON CONFLICT FAIL 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( CREATE TABLE known_servers(
@ -36,20 +51,21 @@ CREATE TABLE known_servers(
CREATE TABLE group_profiles ( -- shared group profiles CREATE TABLE group_profiles ( -- shared group profiles
group_profile_id INTEGER PRIMARY KEY, group_profile_id INTEGER PRIMARY KEY,
group_ref TEXT NOT NULL, -- this name must not contain spaces display_name TEXT NOT NULL, -- this name must not contain spaces
display_name TEXT NOT NULL, full_name TEXT NOT NULL,
properties TEXT NOT NULL DEFAULT '{}' -- JSON with user or contact profile properties TEXT NOT NULL DEFAULT '{}' -- JSON with user or contact profile
); );
CREATE TABLE groups ( CREATE TABLE groups (
group_id INTEGER PRIMARY KEY, -- local group ID 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, user_id INTEGER NOT NULL REFERENCES users,
UNIQUE (user_id, local_group_ref) ON CONFLICT FAIL, local_display_name TEXT NOT NULL, -- local group name without spaces
UNIQUE (user_id, lgr_base, lgr_suffix) ON CONFLICT FAIL 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 CREATE TABLE group_members ( -- group members, excluding the local user

View File

@ -55,16 +55,16 @@ data ChatCommand
| MarkdownHelp | MarkdownHelp
| AddContact | AddContact
| Connect SMPQueueInfo | Connect SMPQueueInfo
| DeleteContact ContactRef | DeleteContact ContactName
| SendMessage ContactRef ByteString | SendMessage ContactName ByteString
| NewGroup GroupProfile | NewGroup GroupProfile
| AddMember GroupRef ContactRef GroupMemberRole | AddMember GroupName ContactName GroupMemberRole
| RemoveMember GroupRef ContactRef | RemoveMember GroupName ContactName
| MemberRole GroupRef ContactRef GroupMemberRole | MemberRole GroupName ContactName GroupMemberRole
| LeaveGroup GroupRef | LeaveGroup GroupName
| DeleteGroup GroupRef | DeleteGroup GroupName
| ListMembers GroupRef | ListMembers GroupName
| SendGroupMessage GroupRef ByteString | SendGroupMessage GroupName ByteString
deriving (Show) deriving (Show)
cfg :: AgentConfig cfg :: AgentConfig
@ -195,7 +195,7 @@ processAgentMessage :: forall m. ChatMonad m => User -> ConnId -> ACommand 'Agen
processAgentMessage User {userId, profile} agentConnId agentMessage = do processAgentMessage User {userId, profile} agentConnId agentMessage = do
chatDirection <- withStore $ \st -> getConnectionChatDirection st userId agentConnId chatDirection <- withStore $ \st -> getConnectionChatDirection st userId agentConnId
case chatDirection of case chatDirection of
ReceivedDirectMessage (CContact ct@Contact {localContactRef = c}) -> ReceivedDirectMessage (CContact ct@Contact {localDisplayName = c}) ->
case agentMessage of case agentMessage of
MSG meta msgBody -> do MSG meta msgBody -> do
ChatMessage {chatMsgEvent} <- liftEither $ parseChatMessage msgBody ChatMessage {chatMsgEvent} <- liftEither $ parseChatMessage msgBody
@ -225,7 +225,7 @@ processAgentMessage User {userId, profile} agentConnId agentMessage = do
_ -> pure () _ -> pure ()
_ -> pure () _ -> pure ()
where where
newTextMessage :: ContactRef -> MsgMeta -> Maybe MsgBodyContent -> m () newTextMessage :: ContactName -> MsgMeta -> Maybe MsgBodyContent -> m ()
newTextMessage c meta = \case newTextMessage c meta = \case
Just MsgBodyContent {contentData = bs} -> do Just MsgBodyContent {contentData = bs} -> do
let text = safeDecodeUtf8 bs let text = safeDecodeUtf8 bs
@ -268,17 +268,17 @@ getCreateActiveUser st = do
newUser = do newUser = do
putStrLn putStrLn
"No user profiles found, it will be created now.\n\ "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 will be sent to your contacts when you connect.\n\
\They are only stored on your device and you can change them later." \They are only stored on your device and you can change them later."
loop loop
where where
loop = do loop = do
contactRef <- getContactRef displayName <- getContactName
displayName <- T.pack <$> getWithPrompt "profile name (optional)" fullName <- T.pack <$> getWithPrompt "full name (optional)"
liftIO (runExceptT $ createUser st Profile {contactRef, displayName} True) >>= \case liftIO (runExceptT $ createUser st Profile {displayName, fullName} True) >>= \case
Left SEDuplicateContactRef -> do Left SEDuplicateName -> do
putStrLn "chosen alias already used by another profile on this device, choose another one" putStrLn "chosen display name is already used by another profile on this device, choose another one"
loop loop
Left e -> putStrLn ("database error " <> show e) >> exitFailure Left e -> putStrLn ("database error " <> show e) >> exitFailure
Right user -> pure user Right user -> pure user
@ -302,14 +302,14 @@ getCreateActiveUser st = do
liftIO $ setActiveUser st (userId user) liftIO $ setActiveUser st (userId user)
pure user pure user
userStr :: User -> String userStr :: User -> String
userStr User {localContactRef, profile = Profile {displayName}} = userStr User {localDisplayName, profile = Profile {fullName}} =
T.unpack $ localContactRef <> if T.null displayName then "" else " (" <> displayName <> ")" T.unpack $ localDisplayName <> if T.null fullName then "" else " (" <> fullName <> ")"
getContactRef :: IO ContactRef getContactName :: IO ContactName
getContactRef = do getContactName = do
contactRef <- getWithPrompt "alias (no spaces)" displayName <- getWithPrompt "display name (no spaces)"
if null contactRef || isJust (find (== ' ') contactRef) if null displayName || isJust (find (== ' ') displayName)
then putStrLn "alias has space(s), choose another one" >> getContactRef then putStrLn "display name has space(s), choose another one" >> getContactName
else pure $ T.pack contactRef else pure $ T.pack displayName
getWithPrompt :: String -> IO String getWithPrompt :: String -> IO String
getWithPrompt s = putStr (s <> ": ") >> hFlush stdout >> getLine getWithPrompt s = putStr (s <> ": ") >> hFlush stdout >> getLine
@ -340,24 +340,23 @@ chatCommandP :: Parser ChatCommand
chatCommandP = chatCommandP =
("/help" <|> "/h") $> ChatHelp ("/help" <|> "/h") $> ChatHelp
<|> ("/group #" <|> "/g #") *> (NewGroup <$> groupProfile) <|> ("/group #" <|> "/g #") *> (NewGroup <$> groupProfile)
<|> ("/add #" <|> "/a #") *> (AddMember <$> groupRef <* A.space <*> contactRef <*> memberRole) <|> ("/add #" <|> "/a #") *> (AddMember <$> displayName <* A.space <*> displayName <*> memberRole)
<|> ("/remove #" <|> "/rm #") *> (RemoveMember <$> groupRef <* A.space <*> contactRef) <|> ("/remove #" <|> "/rm #") *> (RemoveMember <$> displayName <* A.space <*> displayName)
<|> ("/delete #" <|> "/d #") *> (DeleteGroup <$> groupRef) <|> ("/delete #" <|> "/d #") *> (DeleteGroup <$> displayName)
<|> ("/members #" <|> "/ms #") *> (ListMembers <$> groupRef) <|> ("/members #" <|> "/ms #") *> (ListMembers <$> displayName)
<|> A.char '#' *> (SendGroupMessage <$> groupRef <* A.space <*> A.takeByteString) <|> A.char '#' *> (SendGroupMessage <$> displayName <* A.space <*> A.takeByteString)
<|> ("/add" <|> "/a") $> AddContact <|> ("/add" <|> "/a") $> AddContact
<|> ("/connect " <|> "/c ") *> (Connect <$> smpQueueInfoP) <|> ("/connect " <|> "/c ") *> (Connect <$> smpQueueInfoP)
<|> ("/delete @" <|> "/delete " <|> "/d @" <|> "/d ") *> (DeleteContact <$> contactRef) <|> ("/delete @" <|> "/delete " <|> "/d @" <|> "/d ") *> (DeleteContact <$> displayName)
<|> A.char '@' *> (SendMessage <$> contactRef <*> (A.space *> A.takeByteString)) <|> A.char '@' *> (SendMessage <$> displayName <*> (A.space *> A.takeByteString))
<|> ("/markdown" <|> "/m") $> MarkdownHelp <|> ("/markdown" <|> "/m") $> MarkdownHelp
where where
contactRef = safeDecodeUtf8 <$> (B.cons <$> A.satisfy refChar <*> A.takeTill (== ' ')) displayName = safeDecodeUtf8 <$> (B.cons <$> A.satisfy refChar <*> A.takeTill (== ' '))
refChar c = c > ' ' && c /= '#' && c /= '@' refChar c = c > ' ' && c /= '#' && c /= '@'
groupRef = contactRef
groupProfile = do groupProfile = do
gRef <- groupRef gRef <- displayName
gName <- safeDecodeUtf8 <$> (A.space *> A.takeByteString) <|> pure "" 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 = memberRole =
(" owner" $> GROwner) (" owner" $> GROwner)
<|> (" admin" $> GRAdmin) <|> (" admin" $> GRAdmin)

View File

@ -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]
}

View File

@ -8,6 +8,7 @@
{-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE TypeOperators #-} {-# LANGUAGE TypeOperators #-}
module Simplex.Chat.Store module Simplex.Chat.Store
@ -83,19 +84,17 @@ insertedRowId :: DB.Connection -> IO Int64
insertedRowId db = fromOnly . head <$> DB.query_ db "SELECT last_insert_rowid()" insertedRowId db = fromOnly . head <$> DB.query_ db "SELECT last_insert_rowid()"
createUser :: (MonadUnliftIO m, MonadError StoreError m) => SQLiteStore -> Profile -> Bool -> m User createUser :: (MonadUnliftIO m, MonadError StoreError m) => SQLiteStore -> Profile -> Bool -> m User
createUser st Profile {contactRef, displayName} activeUser = createUser st Profile {displayName, fullName} activeUser =
liftIOEither . checkConstraint SEDuplicateContactRef . withTransaction st $ \db -> do liftIOEither . checkConstraint SEDuplicateName . withTransaction st $ \db -> do
DB.execute db "INSERT INTO contact_profiles (contact_ref, display_name) VALUES (?, ?)" (contactRef, displayName) DB.execute db "INSERT INTO users (local_display_name, active_user, contact_id) VALUES (?, ?, 0)" (displayName, activeUser)
profileId <- insertedRowId db
DB.execute db "INSERT INTO users (contact_id, active_user) VALUES (0, ?)" (Only activeUser)
userId <- insertedRowId db userId <- insertedRowId db
DB.execute DB.execute db "INSERT INTO display_names (local_display_name, ldn_base, user_id) VALUES (?, ?, ?)" (displayName, displayName, userId)
db DB.execute db "INSERT INTO contact_profiles (display_name, full_name) VALUES (?, ?)" (displayName, fullName)
"INSERT INTO contacts (contact_profile_id, local_contact_ref, lcr_base, user_id, is_user) VALUES (?, ?, ?, ?, ?)" profileId <- insertedRowId db
(profileId, contactRef, contactRef, userId, True) DB.execute db "INSERT INTO contacts (contact_profile_id, local_display_name, user_id, is_user) VALUES (?, ?, ?, ?)" (profileId, displayName, userId, True)
contactId <- insertedRowId db contactId <- insertedRowId db
DB.execute db "UPDATE users SET contact_id = ? WHERE user_id = ?" (contactId, userId) 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 :: SQLiteStore -> IO [User]
getUsers st = getUsers st =
@ -104,16 +103,16 @@ getUsers st =
<$> DB.query_ <$> DB.query_
db db
[sql| [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 FROM users u
JOIN contacts c ON u.contact_id = c.contact_id JOIN contacts c ON u.contact_id = c.contact_id
JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id
|] |]
toUser :: (UserId, Int64, Bool, ContactRef, Text) -> User toUser :: (UserId, Int64, Bool, ContactName, Text) -> User
toUser (userId, userContactId, activeUser, contactRef, displayName) = toUser (userId, userContactId, activeUser, displayName, fullName) =
let profile = Profile {contactRef, displayName} let profile = Profile {displayName, fullName}
in User {userId, userContactId, localContactRef = contactRef, profile, activeUser} in User {userId, userContactId, localDisplayName = displayName, profile, activeUser}
setActiveUser :: MonadUnliftIO m => SQLiteStore -> UserId -> m () setActiveUser :: MonadUnliftIO m => SQLiteStore -> UserId -> m ()
setActiveUser st userId = do setActiveUser st userId = do
@ -134,51 +133,17 @@ createDirectConnection st userId agentConnId =
createDirectContact :: createDirectContact ::
(MonadUnliftIO m, MonadError StoreError m) => SQLiteStore -> UserId -> Connection -> Profile -> m () (MonadUnliftIO m, MonadError StoreError m) => SQLiteStore -> UserId -> Connection -> Profile -> m ()
createDirectContact st userId Connection {connId} Profile {contactRef, displayName} = createDirectContact st userId Connection {connId} Profile {displayName, fullName} =
liftIOEither . withTransaction st $ \db -> do liftIOEither . withTransaction st $ \db ->
DB.execute db "INSERT INTO contact_profiles (contact_ref, display_name) VALUES (?, ?)" (contactRef, displayName) withLocalDisplayName db userId displayName $ \localDisplayName' -> do
DB.execute db "INSERT INTO contact_profiles (display_name, full_name) VALUES (?, ?)" (displayName, fullName)
profileId <- insertedRowId db profileId <- insertedRowId db
lcrSuffix <- getLcrSuffix db DB.execute db "INSERT INTO contacts (contact_profile_id, local_display_name, user_id) VALUES (?, ?, ?)" (profileId, localDisplayName', userId)
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 contactId <- insertedRowId db
DB.execute db "UPDATE connections SET contact_id = ? WHERE connection_id = ?" (contactId, connId) 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)
deleteContact :: MonadUnliftIO m => SQLiteStore -> UserId -> ContactRef -> m () deleteContact :: MonadUnliftIO m => SQLiteStore -> UserId -> ContactName -> m ()
deleteContact st userId contactRef = deleteContact st userId displayName =
liftIO . withTransaction st $ \db -> do liftIO . withTransaction st $ \db -> do
DB.executeNamed DB.executeNamed
db db
@ -187,23 +152,30 @@ deleteContact st userId contactRef =
SELECT connection_id SELECT connection_id
FROM connections c FROM connections c
JOIN contacts cs ON c.contact_id = cs.contact_id 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.executeNamed
db db
[sql| [sql|
DELETE FROM contacts 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 -- TODO return the last connection that is ready, not any last connection
-- requires updating connection status -- requires updating connection status
getContact :: getContact ::
(MonadUnliftIO m, MonadError StoreError m) => SQLiteStore -> UserId -> ContactRef -> m Contact (MonadUnliftIO m, MonadError StoreError m) => SQLiteStore -> UserId -> ContactName -> m Contact
getContact st userId localContactRef = getContact st userId localDisplayName =
liftIOEither . withTransaction st $ \db -> runExceptT $ do liftIOEither . withTransaction st $ \db -> runExceptT $ do
c@Contact {contactId} <- getContact_ db c@Contact {contactId} <- getContact_ db
activeConn <- getConnection_ db contactId activeConn <- getConnection_ db contactId
@ -214,12 +186,12 @@ getContact st userId localContactRef =
<$> DB.queryNamed <$> DB.queryNamed
db db
[sql| [sql|
SELECT c.contact_id, p.contact_ref, p.display_name SELECT c.contact_id, p.display_name, p.full_name
FROM contacts c FROM contacts c
JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id 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 getConnection_ db contactId = ExceptT $ do
connection connection
<$> DB.queryNamed <$> DB.queryNamed
@ -233,15 +205,15 @@ getContact st userId localContactRef =
LIMIT 1 LIMIT 1
|] |]
[":user_id" := userId, ":contact_id" := contactId] [":user_id" := userId, ":contact_id" := contactId]
toContact [(contactId, contactRef, displayName)] = toContact [(contactId, displayName, fullName)] =
let profile = Profile {contactRef, displayName} let profile = Profile {displayName, fullName}
in Right Contact {contactId, localContactRef, profile, activeConn = undefined} in Right Contact {contactId, localDisplayName, profile, activeConn = undefined}
toContact _ = Left $ SEContactNotFound localContactRef toContact _ = Left $ SEContactNotFound localDisplayName
connection (connRow : _) = Right $ toConnection connRow 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 :: (MonadUnliftIO m, MonadError StoreError m) => SQLiteStore -> UserId -> ContactName -> m [Connection]
getContactConnections st userId contactRef = getContactConnections st userId displayName =
liftIOEither . withTransaction st $ \db -> liftIOEither . withTransaction st $ \db ->
connections connections
<$> DB.queryNamed <$> DB.queryNamed
@ -253,11 +225,11 @@ getContactConnections st userId contactRef =
JOIN contacts cs ON c.contact_id == cs.contact_id JOIN contacts cs ON c.contact_id == cs.contact_id
WHERE c.user_id = :user_id WHERE c.user_id = :user_id
AND cs.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 where
connections [] = Left $ SEContactNotFound contactRef connections [] = Left $ SEContactNotFound displayName
connections rows = Right $ map toConnection rows connections rows = Right $ map toConnection rows
type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, ConnStatus, ConnType, Maybe Int64, Maybe Int64, UTCTime) 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.query
db db
[sql| [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 FROM contacts c
JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id
WHERE c.user_id = ? AND c.contact_id = ? WHERE c.user_id = ? AND c.contact_id = ?
|] |]
(userId, contactId) (userId, contactId)
toContact contactId c [(localContactRef, contactRef, displayName)] = toContact contactId c [(localDisplayName, displayName, fullName)] =
let profile = Profile {contactRef, displayName} let profile = Profile {displayName, fullName}
in Right $ CContact Contact {contactId, localContactRef, profile, activeConn = c} in Right $ CContact Contact {contactId, localDisplayName, profile, activeConn = c}
toContact _ _ _ = Left $ SEInternal "referenced contact not found" toContact _ _ _ = Left $ SEInternal "referenced contact not found"
-- | creates completely new group with a single member - the current user -- | 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 :: (MonadUnliftIO m, MonadError StoreError m) => SQLiteStore -> TVar ChaChaDRG -> User -> GroupProfile -> m Group
createNewGroup st gVar User {userId, userContactId, profile} p@GroupProfile {groupRef, displayName} = createNewGroup st gVar User {userId, userContactId, profile} p@GroupProfile {displayName, fullName} =
liftIOEither . checkConstraint SEDuplicateGroupRef . withTransaction st $ \db -> do liftIOEither . checkConstraint SEDuplicateName . withTransaction st $ \db -> do
-- group inserted before profile to ensure its local_group_ref is unique DB.execute db "INSERT INTO display_names (local_display_name, ldn_base, user_id) VALUES (?, ?, ?)" (displayName, displayName, userId)
DB.execute db "INSERT INTO groups (local_group_ref, lgr_base, user_id) VALUES (?, ?, ?)" (groupRef, groupRef, userId) DB.execute db "INSERT INTO group_profiles (display_name, full_name) VALUES (?, ?)" (displayName, fullName)
groupId <- insertedRowId db
DB.execute db "INSERT INTO group_profiles (group_ref, display_name) VALUES (?, ?)" (groupRef, displayName)
profileId <- insertedRowId db 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 memberId <- randomId gVar 12
createMember_ db groupId userContactId GROwner GSMemReady (Just userContactId) memberId createMember_ db groupId userContactId GROwner GSMemReady (Just userContactId) memberId
groupMemberId <- insertedRowId db groupMemberId <- insertedRowId db
@ -336,18 +307,26 @@ createNewGroup st gVar User {userId, userContactId, profile} p@GroupProfile {gro
memberProfile = profile, memberProfile = profile,
memberContactId = Just userContactId 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 -- | 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 :: (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} = createGroup st gVar User {userId, userContactId, profile} contact p@GroupProfile {displayName, fullName} =
liftIOEither . withTransaction st $ \db -> do liftIOEither . withTransaction st $ \db ->
DB.execute db "INSERT INTO group_profiles (group_ref, display_name) VALUES (?, ?)" (groupRef, displayName) withLocalDisplayName db userId displayName $ \localDisplayName -> do
DB.execute db "INSERT INTO group_profiles (display_name, full_name) VALUES (?, ?)" (displayName, fullName)
profileId <- insertedRowId db profileId <- insertedRowId db
lgrSuffix <- getLgrSuffix db DB.execute
group <- create db profileId lgrSuffix 20 db
pure group [sql|
where 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}
-- where
-- createMember_ db groupId userContactId GROwner GSMemReady (Just userContactId) memberId -- createMember_ db groupId userContactId GROwner GSMemReady (Just userContactId) memberId
-- groupMemberId <- insertedRowId db -- groupMemberId <- insertedRowId db
-- let membership = -- let membership =
@ -360,47 +339,12 @@ createGroup st gVar User {userId, userContactId, profile} contact p@GroupProfile
-- memberProfile = profile, -- memberProfile = profile,
-- memberContactId = Just userContactId -- memberContactId = Just userContactId
-- } -- }
-- pure $ Right Group {groupId, localGroupRef = groupRef, groupProfile = p, members = [], membership} -- pure $ Right Group {groupId, localDisplayName = displayName, groupProfile = p, members = [], membership}
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)
-- TODO return the last connection that is ready, not any last connection -- TODO return the last connection that is ready, not any last connection
-- requires updating connection status -- requires updating connection status
getGroup :: (MonadUnliftIO m, MonadError StoreError m) => SQLiteStore -> User -> GroupRef -> m Group getGroup :: (MonadUnliftIO m, MonadError StoreError m) => SQLiteStore -> User -> GroupName -> m Group
getGroup st User {userId, userContactId} localGroupRef = getGroup st User {userId, userContactId} localDisplayName =
liftIOEither . withTransaction st $ \db -> runExceptT $ do liftIOEither . withTransaction st $ \db -> runExceptT $ do
g@Group {groupId} <- getGroup_ db g@Group {groupId} <- getGroup_ db
members <- getMembers_ db groupId members <- getMembers_ db groupId
@ -413,17 +357,17 @@ getGroup st User {userId, userContactId} localGroupRef =
<$> DB.query <$> DB.query
db db
[sql| [sql|
SELECT g.group_id, p.group_ref, p.display_name SELECT g.group_id, p.display_name, p.full_name
FROM groups g FROM groups g
JOIN group_profiles p ON p.group_profile_id = g.group_profile_id 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) (localDisplayName, userId)
toGroup :: [(Int64, GroupRef, Text)] -> Either StoreError Group toGroup :: [(Int64, GroupName, Text)] -> Either StoreError Group
toGroup [(groupId, groupRef, displayName)] = toGroup [(groupId, displayName, fullName)] =
let groupProfile = GroupProfile {groupRef, displayName} let groupProfile = GroupProfile {displayName, fullName}
in Right Group {groupId, localGroupRef, groupProfile, members = undefined, membership = undefined} in Right Group {groupId, localDisplayName, groupProfile, members = undefined, membership = undefined}
toGroup _ = Left $ SEGroupNotFound localGroupRef toGroup _ = Left $ SEGroupNotFound localDisplayName
getMembers_ :: DB.Connection -> Int64 -> ExceptT StoreError IO [(GroupMember, Connection)] getMembers_ :: DB.Connection -> Int64 -> ExceptT StoreError IO [(GroupMember, Connection)]
getMembers_ db groupId = ExceptT $ do getMembers_ db groupId = ExceptT $ do
Right . map toContactMember Right . map toContactMember
@ -432,7 +376,7 @@ getGroup st User {userId, userContactId} localGroupRef =
[sql| [sql|
SELECT SELECT
m.group_member_id, m.member_id, m.member_role, m.member_status, 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.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 c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.created_at
FROM group_members m FROM group_members m
@ -452,7 +396,7 @@ getGroup st User {userId, userContactId} localGroupRef =
[sql| [sql|
SELECT SELECT
m.group_member_id, m.member_id, m.member_role, m.member_status, 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 FROM group_members m
JOIN groups g ON g.group_id = m.group_id JOIN groups g ON g.group_id = m.group_id
JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_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 :: (GroupMemberRow :. ConnectionRow) -> (GroupMember, Connection)
toContactMember (memberRow :. connRow) = (toGroupMember memberRow, toConnection connRow) toContactMember (memberRow :. connRow) = (toGroupMember memberRow, toConnection connRow)
toGroupMember :: GroupMemberRow -> GroupMember toGroupMember :: GroupMemberRow -> GroupMember
toGroupMember (groupMemberId, memberId, memberRole, memberStatus, invitedById, memberContactId, contactRef, displayName) = toGroupMember (groupMemberId, memberId, memberRole, memberStatus, invitedById, memberContactId, displayName, fullName) =
let memberProfile = Profile {contactRef, displayName} let memberProfile = Profile {displayName, fullName}
invitedBy = toInvitedBy userContactId invitedById invitedBy = toInvitedBy userContactId invitedById
in GroupMember {groupMemberId, memberId, memberRole, memberStatus, invitedBy, memberProfile, memberContactId} in GroupMember {groupMemberId, memberId, memberRole, memberStatus, invitedBy, memberProfile, memberContactId}
userMember :: [GroupMemberRow] -> Either StoreError GroupMember userMember :: [GroupMemberRow] -> Either StoreError GroupMember
userMember [memberRow] = Right $ toGroupMember memberRow userMember [memberRow] = Right $ toGroupMember memberRow
userMember _ = Left SEGroupWithoutUser 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 :: (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 = 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 ":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 :: TVar ChaChaDRG -> (ByteString -> IO ()) -> IO (Either StoreError ByteString)
createWithRandomId gVar create = tryCreate 3 createWithRandomId gVar create = tryCreate 3
where where
@ -529,11 +509,10 @@ randomId :: TVar ChaChaDRG -> Int -> IO ByteString
randomId gVar n = B64.encode <$> (atomically . stateTVar gVar $ randomBytesGenerate n) randomId gVar n = B64.encode <$> (atomically . stateTVar gVar $ randomBytesGenerate n)
data StoreError data StoreError
= SEDuplicateContactRef = SEDuplicateName
| SEContactNotFound ContactRef | SEContactNotFound ContactName
| SEContactNotReady ContactRef | SEContactNotReady ContactName
| SEDuplicateGroupRef | SEGroupNotFound GroupName
| SEGroupNotFound GroupRef
| SEGroupWithoutUser | SEGroupWithoutUser
| SEDuplicateGroupMember | SEDuplicateGroupMember
| SEConnectionNotFound ConnId | SEConnectionNotFound ConnId

View File

@ -15,7 +15,7 @@ import System.Terminal
import System.Terminal.Internal (LocalTerminal, Terminal, VirtualTerminal) import System.Terminal.Internal (LocalTerminal, Terminal, VirtualTerminal)
import UnliftIO.STM import UnliftIO.STM
data ActiveTo = ActiveNone | ActiveC ContactRef data ActiveTo = ActiveNone | ActiveC ContactName
deriving (Eq) deriving (Eq)
data ChatTerminal = ChatTerminal data ChatTerminal = ChatTerminal

View File

@ -26,7 +26,7 @@ import Simplex.Messaging.Agent.Store.SQLite (fromTextField_)
data User = User data User = User
{ userId :: UserId, { userId :: UserId,
userContactId :: Int64, userContactId :: Int64,
localContactRef :: ContactRef, localDisplayName :: ContactName,
profile :: Profile, profile :: Profile,
activeUser :: Bool activeUser :: Bool
} }
@ -35,7 +35,7 @@ type UserId = Int64
data Contact = Contact data Contact = Contact
{ contactId :: Int64, { contactId :: Int64,
localContactRef :: ContactRef, localDisplayName :: ContactName,
profile :: Profile, profile :: Profile,
activeConn :: Connection activeConn :: Connection
} }
@ -44,13 +44,13 @@ data Contact = Contact
contactConnId :: Contact -> ConnId contactConnId :: Contact -> ConnId
contactConnId Contact {activeConn = Connection {agentConnId}} = agentConnId contactConnId Contact {activeConn = Connection {agentConnId}} = agentConnId
type ContactRef = Text type ContactName = Text
type GroupRef = Text type GroupName = Text
data Group = Group data Group = Group
{ groupId :: Int64, { groupId :: Int64,
localGroupRef :: Text, localDisplayName :: GroupName,
groupProfile :: GroupProfile, groupProfile :: GroupProfile,
members :: [(GroupMember, Connection)], members :: [(GroupMember, Connection)],
membership :: GroupMember membership :: GroupMember
@ -58,8 +58,8 @@ data Group = Group
deriving (Eq, Show) deriving (Eq, Show)
data Profile = Profile data Profile = Profile
{ contactRef :: ContactRef, { displayName :: ContactName,
displayName :: Text fullName :: Text
} }
deriving (Generic, Eq, Show) deriving (Generic, Eq, Show)
@ -68,8 +68,8 @@ instance ToJSON Profile where toEncoding = J.genericToEncoding J.defaultOptions
instance FromJSON Profile instance FromJSON Profile
data GroupProfile = GroupProfile data GroupProfile = GroupProfile
{ groupRef :: GroupRef, { displayName :: GroupName,
displayName :: Text fullName :: Text
} }
deriving (Generic, Eq, Show) deriving (Generic, Eq, Show)

View File

@ -45,19 +45,19 @@ showInvitation = printToView . invitation
showChatError :: ChatReader m => ChatError -> m () showChatError :: ChatReader m => ChatError -> m ()
showChatError = printToView . chatError showChatError = printToView . chatError
showContactDeleted :: ChatReader m => ContactRef -> m () showContactDeleted :: ChatReader m => ContactName -> m ()
showContactDeleted = printToView . contactDeleted showContactDeleted = printToView . contactDeleted
showContactConnected :: ChatReader m => ContactRef -> m () showContactConnected :: ChatReader m => ContactName -> m ()
showContactConnected = printToView . contactConnected showContactConnected = printToView . contactConnected
showContactDisconnected :: ChatReader m => ContactRef -> m () showContactDisconnected :: ChatReader m => ContactName -> m ()
showContactDisconnected = printToView . contactDisconnected 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) 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) showSentMessage c msg = printToView =<< liftIO (sentMessage c msg)
showGroupCreated :: ChatReader m => GroupProfile -> m () showGroupCreated :: ChatReader m => GroupProfile -> m ()
@ -72,19 +72,19 @@ invitation qInfo =
"and ask them to connect: /c <name_for_you> <invitation_above>" "and ask them to connect: /c <name_for_you> <invitation_above>"
] ]
contactDeleted :: ContactRef -> [StyledString] contactDeleted :: ContactName -> [StyledString]
contactDeleted c = [ttyContact c <> " is deleted"] contactDeleted c = [ttyContact c <> " is deleted"]
contactConnected :: ContactRef -> [StyledString] contactConnected :: ContactName -> [StyledString]
contactConnected c = [ttyContact c <> " is connected"] contactConnected c = [ttyContact c <> " is connected"]
contactDisconnected :: ContactRef -> [StyledString] contactDisconnected :: ContactName -> [StyledString]
contactDisconnected c = ["disconnected from " <> ttyContact c <> " - restart chat"] contactDisconnected c = ["disconnected from " <> ttyContact c <> " - restart chat"]
groupCreated :: GroupProfile -> [StyledString] 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 receivedMessage c utcTime msg mOk = do
t <- formatUTCTime <$> getCurrentTimeZone <*> getZonedTime t <- formatUTCTime <$> getCurrentTimeZone <*> getZonedTime
pure $ prependFirst (t <> " " <> ttyFromContact c) (msgPlain msg) ++ showIntegrity mOk pure $ prependFirst (t <> " " <> ttyFromContact c) (msgPlain msg) ++ showIntegrity mOk
@ -110,7 +110,7 @@ receivedMessage c utcTime msg mOk = do
msgError :: String -> [StyledString] msgError :: String -> [StyledString]
msgError s = [styled (Colored Red) s] msgError s = [styled (Colored Red) s]
sentMessage :: ContactRef -> ByteString -> IO [StyledString] sentMessage :: ContactName -> ByteString -> IO [StyledString]
sentMessage c msg = do sentMessage c msg = do
time <- formatTime defaultTimeLocale "%H:%M" <$> getZonedTime time <- formatTime defaultTimeLocale "%H:%M" <$> getZonedTime
pure $ prependFirst (styleTime time <> " " <> ttyToContact c) (msgPlain $ safeDecodeUtf8 msg) pure $ prependFirst (styleTime time <> " " <> ttyToContact c) (msgPlain $ safeDecodeUtf8 msg)
@ -125,9 +125,9 @@ msgPlain = map styleMarkdownText . T.lines
chatError :: ChatError -> [StyledString] chatError :: ChatError -> [StyledString]
chatError = \case chatError = \case
ChatErrorStore err -> case err of ChatErrorStore err -> case err of
SEDuplicateName -> ["this display name is already used by user, contact or group"]
SEContactNotFound c -> ["no contact " <> ttyContact c] SEContactNotFound c -> ["no contact " <> ttyContact c]
SEContactNotReady c -> ["contact " <> ttyContact c <> " is not active yet"] SEContactNotReady c -> ["contact " <> ttyContact c <> " is not active yet"]
SEDuplicateGroupRef -> ["group with this alias already exists"]
e -> ["chat db error: " <> plain (show e)] e -> ["chat db error: " <> plain (show e)]
ChatErrorAgent err -> case err of ChatErrorAgent err -> case err of
-- CONN e -> case e of -- CONN e -> case e of
@ -141,16 +141,16 @@ chatError = \case
printToView :: (MonadUnliftIO m, MonadReader ChatController m) => [StyledString] -> m () printToView :: (MonadUnliftIO m, MonadReader ChatController m) => [StyledString] -> m ()
printToView s = asks chatTerminal >>= liftIO . (`printToTerminal` s) printToView s = asks chatTerminal >>= liftIO . (`printToTerminal` s)
ttyContact :: ContactRef -> StyledString ttyContact :: ContactName -> StyledString
ttyContact = styled (Colored Green) ttyContact = styled (Colored Green)
ttyToContact :: ContactRef -> StyledString ttyToContact :: ContactName -> StyledString
ttyToContact c = styled (Colored Cyan) $ "@" <> c <> " " ttyToContact c = styled (Colored Cyan) $ "@" <> c <> " "
ttyFromContact :: ContactRef -> StyledString ttyFromContact :: ContactName -> StyledString
ttyFromContact c = styled (Colored Yellow) $ c <> "> " ttyFromContact c = styled (Colored Yellow) $ c <> "> "
ttyGroup :: GroupRef -> StyledString ttyGroup :: GroupName -> StyledString
ttyGroup g = styled (Colored Blue) $ "#" <> g ttyGroup g = styled (Colored Blue) $ "#" <> g
-- ttyFromGroup :: Group -> Contact -> StyledString -- ttyFromGroup :: Group -> Contact -> StyledString

View File

@ -6,17 +6,17 @@ import ChatClient
import Control.Concurrent.Async (concurrently_) import Control.Concurrent.Async (concurrently_)
import Control.Concurrent.STM import Control.Concurrent.STM
import Data.Char (isDigit) import Data.Char (isDigit)
import Data.List (dropWhileEnd, find, isPrefixOf) import Data.List (dropWhileEnd, isPrefixOf)
import Simplex.Chat.Controller import Simplex.Chat.Controller
import Simplex.Chat.Types (Profile (..)) import Simplex.Chat.Types (Profile (..))
import System.Terminal.Internal (VirtualTerminal (..)) import System.Terminal.Internal (VirtualTerminal (..))
import Test.Hspec import Test.Hspec
aliceProfile :: Profile aliceProfile :: Profile
aliceProfile = Profile {contactRef = "alice", displayName = "Alice"} aliceProfile = Profile {displayName = "alice", fullName = "Alice"}
bobProfile :: Profile bobProfile :: Profile
bobProfile = Profile {contactRef = "bob", displayName = "Bob"} bobProfile = Profile {displayName = "bob", fullName = "Bob"}
testAddContact :: Spec testAddContact :: Spec
testAddContact = describe "add chat contact" $ testAddContact = describe "add chat contact" $
@ -32,6 +32,22 @@ testAddContact = describe "add chat contact" $
bob <# "alice> hello" bob <# "alice> hello"
bob #> "@alice hi" bob #> "@alice hi"
alice <# "bob> 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 () (##>) :: TestCC -> String -> IO ()
(##>) cc cmd = do (##>) cc cmd = do
@ -67,7 +83,11 @@ getWindow (TestCC _ t _) = do
if win' /= win then pure win' else retry if win' /= win then pure win' else retry
invitation :: [String] -> Maybe String invitation :: [String] -> Maybe String
invitation win = dropWhileEnd (== ' ') <$> find ("smp::" `isPrefixOf`) win invitation win = lastMaybe $ map (dropWhileEnd (== ' ')) $ filter ("smp::" `isPrefixOf`) win
lastOutput :: [String] -> String lastOutput :: [String] -> String
lastOutput win = dropWhileEnd (== ' ') $ win !! (length win - 2) -- (- 2) to exclude prompt lastOutput win = dropWhileEnd (== ' ') $ win !! (length win - 2) -- (- 2) to exclude prompt
lastMaybe :: [a] -> Maybe a
lastMaybe [] = Nothing
lastMaybe xs = Just $ last xs