Merge branch 'short-links' into f/short-links-kotlin-user-picker

This commit is contained in:
spaced4ndy 2025-06-27 13:14:31 +04:00
commit 86953de2f4
10 changed files with 94 additions and 76 deletions

View file

@ -1533,9 +1533,10 @@ func acceptContactRequest(incognito: Bool, contactRequestId: Int64) async {
NetworkModel.shared.setContactNetworkStatus(contact, .connected)
}
if contact.sndReady {
let chatId = chat.id
DispatchQueue.main.async {
dismissAllSheets(animated: true) {
ItemsModel.shared.loadOpenChat(chat.id)
ItemsModel.shared.loadOpenChat(chatId)
}
}
}

View file

@ -707,6 +707,7 @@ struct ComposeView: View {
await MainActor.run {
self.chatModel.updateContact(contact)
clearState()
NetworkModel.shared.setContactNetworkStatus(contact, .connected)
}
} else {
AlertManager.shared.showAlertMsg(title: "Empty message!")
@ -744,6 +745,7 @@ struct ComposeView: View {
if let contact = await apiConnectPreparedContact(contactId: chat.chatInfo.apiId, incognito: incognitoGroupDefault.get(), msg: mc) {
await MainActor.run {
self.chatModel.updateContact(contact)
NetworkModel.shared.setContactNetworkStatus(contact, .connected)
clearState()
}
} else {

View file

@ -455,7 +455,7 @@ struct ChatPreviewView: View {
let size = dynamicSize(userFont).incognitoSize
switch chat.chatInfo {
case let .direct(contact):
if contact.active && contact.activeConn != nil {
if contact.active, let status = contact.activeConn?.connStatus, status == .ready || status == .sndReady {
NetworkStatusView(contact: contact, size: size)
} else {
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size)

View file

@ -1735,8 +1735,8 @@ public struct Contact: Identifiable, Decodable, NamedChat, Hashable {
public var sndReady: Bool { get { ready || activeConn?.connStatus == .sndReady } }
public var active: Bool { get { contactStatus == .active } }
public var nextSendGrpInv: Bool { get { contactGroupMemberId != nil && !contactGrpInvSent } }
public var nextConnectPrepared: Bool { preparedContact != nil && activeConn == nil }
public var nextAcceptContactRequest: Bool { contactRequestId != nil && activeConn == nil }
public var nextConnectPrepared: Bool { preparedContact != nil && (activeConn == nil || activeConn?.connStatus == .prepared) }
public var nextAcceptContactRequest: Bool { contactRequestId != nil && (activeConn == nil || activeConn?.connStatus == .new) }
public var sendMsgToConnect: Bool { nextSendGrpInv || nextConnectPrepared }
public var displayName: String { localAlias == "" ? profile.displayName : localAlias }
public var fullName: String { get { profile.fullName } }
@ -1833,7 +1833,7 @@ public struct Connection: Decodable, Hashable {
public var connId: Int64
public var agentConnId: String
public var peerChatVRange: VersionRange
var connStatus: ConnStatus
public var connStatus: ConnStatus
public var connLevel: Int
public var viaGroupLink: Bool
public var customUserProfileId: Int64?

View file

@ -456,7 +456,7 @@ data ChatCommand
| APIChangePreparedGroupUser GroupId UserId
| APIConnectPreparedContact {contactId :: ContactId, incognito :: IncognitoEnabled, msgContent_ :: Maybe MsgContent}
| APIConnectPreparedGroup GroupId IncognitoEnabled (Maybe MsgContent)
| APIConnect UserId IncognitoEnabled (Maybe ACreatedConnLink)
| APIConnect UserId IncognitoEnabled ACreatedConnLink
| Connect IncognitoEnabled (Maybe AConnectionLink)
| APIConnectContactViaAddress UserId IncognitoEnabled ContactId
| ConnectSimplex IncognitoEnabled -- UserId (not used in UI)

View file

@ -1157,7 +1157,6 @@ processChatCommand' vr = \case
when (shortLinkDataSet && incognito) $ throwCmdError "incognito not allowed for address with short link data"
withUserContactLock "acceptContact" uclId $ do
cReq <- withFastStore $ \db -> getContactRequest db user connReqId
-- TODO [short links] accept async, move to continuation on JOIN?
(ct, conn@Connection {connId}, sqSecured) <- acceptContactRequest user cReq incognito
let contactUsed = isNothing gLinkInfo_
ct' <- withStore' $ \db -> do
@ -1795,18 +1794,16 @@ processChatCommand' vr = \case
Contact {preparedContact} <- withFastStore $ \db -> getContact db vr user contactId
case preparedContact of
Nothing -> throwCmdError "contact doesn't have link to connect"
Just PreparedContact {connLinkToConnect = ACCL SCMInvitation ccLink} ->
connectViaInvitation user incognito ccLink (Just contactId) >>= \case
CRSentConfirmation {customUserProfile} -> do
-- get updated contact with connection
ct' <- withFastStore $ \db -> getContact db vr user contactId
forM_ msgContent_ $ \mc -> do
let evt = XMsgNew $ MCSimple (extMsgContent mc Nothing)
(msg, _) <- sendDirectContactMessage user ct' evt
ci <- saveSndChatItem user (CDDirectSnd ct') msg (CISndMsgContent mc)
toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct') ci]
pure $ CRStartedConnectionToContact user ct' customUserProfile
cr -> pure cr
Just PreparedContact {connLinkToConnect = ACCL SCMInvitation ccLink} -> do
(_, customUserProfile) <- connectViaInvitation user incognito ccLink (Just contactId)
-- get updated contact with connection
ct' <- withFastStore $ \db -> getContact db vr user contactId
forM_ msgContent_ $ \mc -> do
let evt = XMsgNew $ MCSimple (extMsgContent mc Nothing)
(msg, _) <- sendDirectContactMessage user ct' evt
ci <- saveSndChatItem user (CDDirectSnd ct') msg (CISndMsgContent mc)
toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct') ci]
pure $ CRStartedConnectionToContact user ct' customUserProfile
Just PreparedContact {connLinkToConnect = ACCL SCMContact ccLink, welcomeSharedMsgId} -> do
-- TODO [short links] reuse welcomeSharedMsgId
msg_ <- forM msgContent_ $ \mc -> (,mc) <$> getSharedMsgId
@ -1831,11 +1828,12 @@ processChatCommand' vr = \case
gInfo' <- withFastStore $ \db -> getGroupInfo db vr user groupId
pure $ CRStartedConnectionToGroup user gInfo' customUserProfile
cr -> pure cr
APIConnect userId incognito (Just (ACCL SCMInvitation ccLink)) -> withUserId userId $ \user ->
connectViaInvitation user incognito ccLink Nothing
APIConnect userId incognito (Just (ACCL SCMContact ccLink)) -> withUserId userId $ \user ->
connectViaContact user incognito ccLink Nothing Nothing Nothing
APIConnect _ _ Nothing -> throwChatError CEInvalidConnReq
APIConnect userId incognito acl -> withUserId userId $ \user -> case acl of
ACCL SCMInvitation ccLink -> do
(dbConnId, incognitoProfile) <- connectViaInvitation user incognito ccLink Nothing
pcc <- withFastStore $ \db -> getPendingContactConnection db userId dbConnId
pure $ CRSentConfirmation user pcc incognitoProfile
ACCL SCMContact ccLink -> connectViaContact user incognito ccLink Nothing Nothing Nothing
Connect incognito (Just cLink@(ACL m cLink')) -> withUser $ \user -> do
(ccLink, plan) <- connectPlan user cLink `catchChatError` \e -> case cLink' of CLFull cReq -> pure (ACCL m (CCLink cReq Nothing), CPInvitationLink (ILPOk Nothing)); _ -> throwError e
connectWithPlan user incognito ccLink plan
@ -2869,9 +2867,9 @@ processChatCommand' vr = \case
CTGroup -> withFastStore $ \db -> getGroupChatItemIdByText' db user cId msg
CTLocal -> withFastStore $ \db -> getLocalChatItemIdByText' db user cId msg
_ -> throwCmdError "not supported"
connectViaInvitation :: User -> IncognitoEnabled -> CreatedLinkInvitation -> Maybe ContactId -> CM ChatResponse
connectViaInvitation :: User -> IncognitoEnabled -> CreatedLinkInvitation -> Maybe ContactId -> CM (Int64, Maybe Profile)
connectViaInvitation user@User {userId} incognito (CCLink cReq@(CRInvitationUri crData e2e) sLnk_) contactId_ =
withInvitationLock "connect" (strEncode cReq) . procCmd $ do
withInvitationLock "connect" (strEncode cReq) $ do
subMode <- chatReadVar subscriptionMode
-- [incognito] generate profile to send
incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing
@ -2884,24 +2882,22 @@ processChatCommand' vr = \case
dm <- encodeConnInfoPQ pqSup' chatV $ XInfo profileToSend
withFastStore' (\db -> getConnectionEntityByConnReq db vr user cReqs) >>= \case
Nothing -> joinNewConn chatV dm
Just (RcvDirectMsgConnection conn@Connection {connId, connStatus, contactConnInitiated} _ct_)
Just (RcvDirectMsgConnection conn@Connection {connId = dbConnId, connStatus, contactConnInitiated} _ct_)
| connStatus == ConnNew && contactConnInitiated -> joinNewConn chatV dm -- own connection link
| connStatus == ConnPrepared -> do
-- retrying join after error
pcc <- withFastStore $ \db -> getPendingContactConnection db userId connId
joinPreparedConn (aConnId conn) pcc dm
| connStatus == ConnPrepared -> joinPreparedConn dbConnId (aConnId conn) dm -- retrying join after error
Just ent -> throwCmdError $ "connection is not RcvDirectMsgConnection: " <> show (connEntityInfo ent)
where
joinNewConn chatV dm = do
connId <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq pqSup'
let ccLink = CCLink cReq $ serverShortLink <$> sLnk_
pcc <- withFastStore' $ \db -> createDirectConnection db user connId ccLink contactId_ ConnPrepared (incognitoProfile $> profileToSend) subMode chatV pqSup'
joinPreparedConn connId pcc dm
joinPreparedConn connId pcc@PendingContactConnection {pccConnId} dm = do
createdAt <- liftIO getCurrentTime
(dbConnId, _) <- withFastStore' $ \db -> createDirectConnection_ db userId connId ccLink contactId_ ConnPrepared (incognitoProfile $> profileToSend) subMode chatV pqSup' createdAt
joinPreparedConn dbConnId connId dm
joinPreparedConn dbConnId connId dm = do
(sqSecured, _serviceId) <- withAgent $ \a -> joinConnection a (aUserId user) connId True cReq dm pqSup' subMode
let newStatus = if sqSecured then ConnSndReady else ConnJoined
withFastStore' $ \db -> updateConnectionStatusFromTo db pccConnId ConnPrepared newStatus
pure $ CRSentConfirmation user pcc {pccConnStatus = newStatus} incognitoProfile
withFastStore' $ \db -> updateConnectionStatusFromTo db dbConnId ConnPrepared newStatus
pure (dbConnId, incognitoProfile)
cReqs =
( CRInvitationUri crData {crScheme = SSSimplex} e2e,
CRInvitationUri crData {crScheme = simplexChat} e2e
@ -3363,7 +3359,7 @@ processChatCommand' vr = \case
case plan of
CPContactAddress (CAPContactViaAddress Contact {contactId}) ->
processChatCommand $ APIConnectContactViaAddress userId incognito contactId
_ -> processChatCommand $ APIConnect userId incognito (Just ccLink)
_ -> processChatCommand $ APIConnect userId incognito ccLink
| otherwise = pure $ CRConnectionPlan user ccLink plan
invitationRequestPlan :: User -> ConnReqInvitation -> Maybe ContactShortLinkData -> CM ConnectionPlan
invitationRequestPlan user cReq contactSLinkData_ = do
@ -4480,7 +4476,7 @@ chatCommandP =
"/_connect contact @" *> (APIConnectPreparedContact <$> A.decimal <*> incognitoOnOffP <*> optional (A.space *> msgContentP)),
"/_connect group #" *> (APIConnectPreparedGroup <$> A.decimal <*> incognitoOnOffP <*> optional (A.space *> msgContentP)),
"/_connect " *> (APIAddContact <$> A.decimal <*> incognitoOnOffP),
"/_connect " *> (APIConnect <$> A.decimal <*> incognitoOnOffP <* A.space <*> connLinkP_),
"/_connect " *> (APIConnect <$> A.decimal <*> incognitoOnOffP <* A.space <*> connLinkP),
"/_set incognito :" *> (APISetConnectionIncognito <$> A.decimal <* A.space <*> onOffP),
"/_set conn user :" *> (APIChangeConnectionUser <$> A.decimal <* A.space <*> A.decimal),
("/connect" <|> "/c") *> (AddContact <$> incognitoP),
@ -4598,8 +4594,6 @@ chatCommandP =
cReq <- strP
sLink_ <- optional (A.space *> strP)
pure $ CCLink cReq sLink_
connLinkP_ =
((Just <$> connLinkP) <|> A.takeTill (== ' ') $> Nothing)
incognitoP = (A.space *> ("incognito" <|> "i")) $> True <|> pure False
incognitoOnOffP = (A.space *> "incognito=" *> onOffP) <|> pure False
imagePrefix = (<>) <$> "data:" <*> ("image/png;base64," <|> "image/jpg;base64,")

View file

@ -869,6 +869,14 @@ getRcvFilePath fileId fPath_ fn keepHandle = case fPath_ of
liftIO $ B.hPut h "" >> hFlush h
| otherwise = liftIO $ B.writeFile fPath ""
-- TODO [short links]
-- Please note:
-- - the connection here is created as ConnNew, even though when joining it is created as ConnPrepared.
-- (changing it is risky, as there may be existing "prepared" connections that were not accepted in ConnNew status).
-- - after accepted, the status is changed by this func caller to ConnSndReady if it is sndSecure, and not changed otherwise - joined changed to ConnJoined in this case.
-- - xContactId is set on the contact at the first acceptance attempt, not after accept success, which prevents profile updates after such attempt.
-- It may be reasonable to set it when contact is first prepared, but then we can't use it to ignore requests after acceptance,
-- and it may lead to race conditions with XInfo events.
acceptContactRequest :: User -> UserContactRequest -> IncognitoEnabled -> CM (Contact, Connection, SndQueueSecured)
acceptContactRequest user@User {userId} UserContactRequest {agentInvitationId = AgentInvId invId, contactId_, cReqChatVRange, localDisplayName = cName, profileId, profile = cp, userContactLinkId, xContactId, pqSupport} incognito = do
subMode <- chatReadVar subscriptionMode

View file

@ -23,6 +23,7 @@ module Simplex.Chat.Store.Direct
-- * Contacts and connections functions
getPendingContactConnection,
deletePendingContactConnection,
createDirectConnection_,
createDirectConnection,
createIncognitoProfile,
createConnReqConnection,
@ -63,7 +64,6 @@ module Simplex.Chat.Store.Direct
deleteContactRequest,
createContactFromRequest,
createAcceptedContactConn,
createAcceptedContact,
updateContactAccepted,
getUserByContactRequestId,
getPendingContactConnections,
@ -235,8 +235,13 @@ getContactByConnReqHash db vr user@User {userId} cReqHash = do
mapM (addDirectChatTags db) ct_
createDirectConnection :: DB.Connection -> User -> ConnId -> CreatedLinkInvitation -> Maybe ContactId -> ConnStatus -> Maybe Profile -> SubscriptionMode -> VersionChat -> PQSupport -> IO PendingContactConnection
createDirectConnection db User {userId} acId ccLink@(CCLink cReq shortLinkInv) contactId_ pccConnStatus incognitoProfile subMode chatV pqSup = do
createDirectConnection db User {userId} acId ccLink contactId_ pccConnStatus incognitoProfile subMode chatV pqSup = do
createdAt <- getCurrentTime
(pccConnId, customUserProfileId) <- createDirectConnection_ db userId acId ccLink contactId_ pccConnStatus incognitoProfile subMode chatV pqSup createdAt
pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = False, viaUserContactLink = Nothing, groupLinkId = Nothing, customUserProfileId, connLinkInv = Just ccLink, localAlias = "", createdAt, updatedAt = createdAt}
createDirectConnection_ :: DB.Connection -> UserId -> ConnId -> CreatedLinkInvitation -> Maybe ContactId -> ConnStatus -> Maybe Profile -> SubscriptionMode -> VersionChat -> PQSupport -> UTCTime -> IO (Int64, Maybe Int64)
createDirectConnection_ db userId acId (CCLink cReq shortLinkInv) contactId_ pccConnStatus incognitoProfile subMode chatV pqSup createdAt = do
customUserProfileId <- mapM (createIncognitoProfile_ db userId createdAt) incognitoProfile
let contactConnInitiated = pccConnStatus == ConnNew
DB.execute
@ -250,8 +255,8 @@ createDirectConnection db User {userId} acId ccLink@(CCLink cReq shortLinkInv) c
( (userId, acId, cReq, shortLinkInv, pccConnStatus, ConnContact, contactId_, BI contactConnInitiated, customUserProfileId)
:. (createdAt, createdAt, BI (subMode == SMOnlyCreate), chatV, pqSup, pqSup)
)
pccConnId <- insertedRowId db
pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = False, viaUserContactLink = Nothing, groupLinkId = Nothing, customUserProfileId, connLinkInv = Just ccLink, localAlias = "", createdAt, updatedAt = createdAt}
dbConnId <- insertedRowId db
pure (dbConnId, customUserProfileId)
createIncognitoProfile :: DB.Connection -> User -> Profile -> IO Int64
createIncognitoProfile db User {userId} p = do
@ -777,37 +782,6 @@ createAcceptedContactConn db User {userId} uclId contactId agentConnId connChatV
ExistingIncognito LocalProfile {profileId = pId} -> pure pId
createConnection_ db userId ConnContact (Just contactId) agentConnId ConnNew connChatVersion cReqChatVRange Nothing (Just uclId) customUserProfileId 0 currentTs subMode pqSup
createAcceptedContact :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ConnId -> VersionChat -> VersionRangeChat -> Profile -> Maybe XContactId -> PQSupport -> Maybe IncognitoProfile -> SubscriptionMode -> ExceptT StoreError IO (Contact, Connection)
createAcceptedContact
db
vr
user@User {userId}
uclId
agentConnId
connChatVersion
cReqChatVRange
Profile {displayName, fullName, image, contactLink, preferences}
xContactId
pqSup
incognitoProfile
subMode = do
currentTs <- liftIO getCurrentTime
let userPreferences = fromMaybe emptyChatPrefs $ incognitoProfile >> preferences
contactId <- ExceptT . withLocalDisplayName db userId displayName $ \ldn -> do
DB.execute
db
"INSERT INTO contact_profiles (display_name, full_name, image, contact_link, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)"
(displayName, fullName, image, contactLink, userId, preferences, currentTs, currentTs)
profileId <- insertedRowId db
DB.execute
db
"INSERT INTO contacts (user_id, local_display_name, contact_profile_id, enable_ntfs, user_preferences, created_at, updated_at, chat_ts, xcontact_id, contact_used) VALUES (?,?,?,?,?,?,?,?,?,?)"
(userId, ldn, profileId, BI True, userPreferences, currentTs, currentTs, currentTs, xContactId, BI True)
Right <$> insertedRowId db
conn <- liftIO $ createAcceptedContactConn db user uclId contactId agentConnId connChatVersion cReqChatVRange pqSup incognitoProfile subMode currentTs
ct <- getContact db vr user contactId
pure (ct, conn)
updateContactAccepted :: DB.Connection -> User -> Contact -> Bool -> IO ()
updateContactAccepted db User {userId} Contact {contactId} contactUsed =
DB.execute

View file

@ -99,7 +99,6 @@ import Data.Type.Equality
import Data.Word (Word32)
import Simplex.Chat.Messages
import Simplex.Chat.Messages.CIContent
import Simplex.Chat.Protocol
import Simplex.Chat.Store.Direct
import Simplex.Chat.Store.Messages
import Simplex.Chat.Store.Profiles

View file

@ -118,6 +118,8 @@ chatProfileTests = do
it "prepare contact with image in profile" testShortLinkInvitationImage
it "prepare contact with a long name in profile" testShortLinkInvitationLongName
it "prepare contact using address short link data and connect" testShortLinkAddressPrepareContact
it "prepare contact via invitation and connect after it is deleted" testShortLinkDeletedInvitation
it "prepare contact via address and connect after it is deleted" testShortLinkDeletedAddress
it "prepare business chat using address short link data and connect" testShortLinkAddressPrepareBusiness
it "prepare group using group short link data and connect" testShortLinkPrepareGroup
it "prepare group using group short link data and connect, host rejects" testShortLinkPrepareGroupReject
@ -3008,6 +3010,44 @@ testShortLinkAddressPrepareContact =
(alice <## "bob (Bob): contact is connected")
alice <##> bob
testShortLinkDeletedInvitation :: HasCallStack => TestParams -> IO ()
testShortLinkDeletedInvitation =
testChat2 aliceProfile bobProfile $
\alice bob -> do
alice ##> "/_connect 1"
(shortLink, fullLink) <- getInvitations alice
bob ##> ("/_connect plan 1 " <> shortLink)
bob <## "invitation link: ok to connect"
contactSLinkData <- getTermLine bob
bob ##> ("/_prepare contact 1 " <> fullLink <> " " <> shortLink <> " " <> contactSLinkData)
bob <## "alice: contact is prepared"
alice @@@ [(":1","")]
alice ##> "/_delete :1"
alice <## "connection :1 deleted"
bob ##> "/_connect contact @2"
bob <##. "error: connection authorization failed"
bob ##> "/_connect contact @2"
bob <##. "error: connection authorization failed"
testShortLinkDeletedAddress :: HasCallStack => TestParams -> IO ()
testShortLinkDeletedAddress =
testChat2 aliceProfile bobProfile $
\alice bob -> do
alice ##> "/ad"
(shortLink, fullLink) <- getContactLinks alice True
bob ##> ("/_connect plan 1 " <> shortLink)
bob <## "contact address: ok to connect"
contactSLinkData <- getTermLine bob
bob ##> ("/_prepare contact 1 " <> fullLink <> " " <> shortLink <> " " <> contactSLinkData)
bob <## "alice: contact is prepared"
alice ##> "/da"
alice <## "Your chat address is deleted - accepted contacts will remain connected."
alice <## "To create a new chat address use /ad"
bob ##> "/_connect contact @2"
bob <##. "error: connection authorization failed"
bob ##> "/_connect contact @2"
bob <##. "error: connection authorization failed"
testShortLinkAddressPrepareBusiness :: HasCallStack => TestParams -> IO ()
testShortLinkAddressPrepareBusiness =
testChat3 businessProfile aliceProfile {fullName = "Alice @ Biz"} bobProfile $