fix: create a CR on contact sync with received CR state (#3495)
* fix: create a CR on contact sync with received CR state * fix: create a CR on contact sync with sent CR state * Review fixes * Fix: ignore own contact installation or syncing
This commit is contained in:
parent
8b05ba6d8b
commit
8c90353bc0
|
@ -158,9 +158,9 @@ func (s *MessengerContactRequestSuite) receiveContactRequest(messageText string,
|
|||
return contactRequest
|
||||
}
|
||||
|
||||
func (s *MessengerContactRequestSuite) acceptContactRequest(contactRequest *common.Message, theirMessenger *Messenger) {
|
||||
func (s *MessengerContactRequestSuite) acceptContactRequest(contactRequest *common.Message, sender *Messenger, receiver *Messenger) {
|
||||
// Accept contact request, receiver side
|
||||
resp, err := theirMessenger.AcceptContactRequest(context.Background(), &requests.AcceptContactRequest{ID: types.Hex2Bytes(contactRequest.ID)})
|
||||
resp, err := receiver.AcceptContactRequest(context.Background(), &requests.AcceptContactRequest{ID: types.Hex2Bytes(contactRequest.ID)})
|
||||
s.Require().NoError(err)
|
||||
|
||||
// Make sure the message is updated
|
||||
|
@ -187,16 +187,16 @@ func (s *MessengerContactRequestSuite) acceptContactRequest(contactRequest *comm
|
|||
s.Require().True(resp.Chats()[1].Active)
|
||||
|
||||
// Make sure the sender is added to our contacts
|
||||
contacts := theirMessenger.AddedContacts()
|
||||
contacts := receiver.AddedContacts()
|
||||
s.Require().Len(contacts, 1)
|
||||
|
||||
// Make sure we consider them a mutual contact, receiver side
|
||||
mutualContacts := theirMessenger.MutualContacts()
|
||||
mutualContacts := receiver.MutualContacts()
|
||||
s.Require().Len(mutualContacts, 1)
|
||||
|
||||
// Wait for the message to reach its destination
|
||||
resp, err = WaitOnMessengerResponse(
|
||||
s.m,
|
||||
sender,
|
||||
func(r *MessengerResponse) bool {
|
||||
return len(r.Contacts) == 1 && len(r.Messages()) == 1 && len(r.ActivityCenterNotifications()) == 1
|
||||
},
|
||||
|
@ -240,7 +240,7 @@ func (s *MessengerContactRequestSuite) acceptContactRequest(contactRequest *comm
|
|||
|
||||
// Receiver's side chat should be also active after the accepting the CR
|
||||
myID := types.EncodeHex(crypto.FromECDSAPub(&s.m.identity.PublicKey))
|
||||
chat, ok = theirMessenger.allChats.Load(myID)
|
||||
chat, ok = receiver.allChats.Load(myID)
|
||||
s.Require().True(ok)
|
||||
s.Require().NotNil(chat)
|
||||
s.Require().True(chat.Active)
|
||||
|
@ -314,6 +314,28 @@ func (s *MessengerContactRequestSuite) retractContactRequest(contactID string, t
|
|||
s.Require().Equal(ContactRequestStateNone, resp.Contacts[0].ContactRequestRemoteState)
|
||||
}
|
||||
|
||||
func (s *MessengerContactRequestSuite) syncInstallationContactV2FromContact(contact *Contact) protobuf.SyncInstallationContactV2 {
|
||||
return protobuf.SyncInstallationContactV2{
|
||||
LastUpdatedLocally: contact.LastUpdatedLocally,
|
||||
LastUpdated: contact.LastUpdated,
|
||||
Id: contact.ID,
|
||||
DisplayName: contact.DisplayName,
|
||||
EnsName: contact.EnsName,
|
||||
LocalNickname: contact.LocalNickname,
|
||||
Added: contact.added(),
|
||||
Blocked: contact.Blocked,
|
||||
Muted: false,
|
||||
HasAddedUs: contact.hasAddedUs(),
|
||||
Removed: contact.Removed,
|
||||
ContactRequestLocalState: int64(contact.ContactRequestLocalState),
|
||||
ContactRequestRemoteState: int64(contact.ContactRequestRemoteState),
|
||||
ContactRequestRemoteClock: int64(contact.ContactRequestRemoteClock),
|
||||
ContactRequestLocalClock: int64(contact.ContactRequestLocalClock),
|
||||
VerificationStatus: int64(contact.VerificationStatus),
|
||||
TrustStatus: int64(contact.TrustStatus),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MessengerContactRequestSuite) TestReceiveAndAcceptContactRequest() { //nolint: unused
|
||||
messageText := "hello!"
|
||||
|
||||
|
@ -328,7 +350,7 @@ func (s *MessengerContactRequestSuite) TestReceiveAndAcceptContactRequest() { //
|
|||
}
|
||||
s.sendContactRequest(request, s.m)
|
||||
contactRequest := s.receiveContactRequest(messageText, theirMessenger)
|
||||
s.acceptContactRequest(contactRequest, theirMessenger)
|
||||
s.acceptContactRequest(contactRequest, s.m, theirMessenger)
|
||||
}
|
||||
|
||||
func (s *MessengerContactRequestSuite) TestReceiveAndDismissContactRequest() {
|
||||
|
@ -364,7 +386,7 @@ func (s *MessengerContactRequestSuite) TestReceiveAcceptAndRetractContactRequest
|
|||
}
|
||||
s.sendContactRequest(request, s.m)
|
||||
contactRequest := s.receiveContactRequest(messageText, theirMessenger)
|
||||
s.acceptContactRequest(contactRequest, theirMessenger)
|
||||
s.acceptContactRequest(contactRequest, s.m, theirMessenger)
|
||||
s.retractContactRequest(contactID, theirMessenger)
|
||||
}
|
||||
|
||||
|
@ -382,7 +404,7 @@ func (s *MessengerContactRequestSuite) TestReceiveAndAcceptContactRequestTwice()
|
|||
}
|
||||
s.sendContactRequest(request, s.m)
|
||||
contactRequest := s.receiveContactRequest(messageText, theirMessenger)
|
||||
s.acceptContactRequest(contactRequest, theirMessenger)
|
||||
s.acceptContactRequest(contactRequest, s.m, theirMessenger)
|
||||
|
||||
// Resend contact request with higher clock value
|
||||
resp, err := s.m.SendContactRequest(context.Background(), request)
|
||||
|
@ -540,7 +562,7 @@ func (s *MessengerContactRequestSuite) TestPairedDevicesRemoveContact() {
|
|||
}
|
||||
s.sendContactRequest(request, alice1)
|
||||
contactRequest := s.receiveContactRequest(messageText, bob)
|
||||
s.acceptContactRequest(contactRequest, bob)
|
||||
s.acceptContactRequest(contactRequest, alice1, bob)
|
||||
|
||||
// Wait for the message to reach its destination
|
||||
resp, err := WaitOnMessengerResponse(
|
||||
|
@ -608,7 +630,7 @@ func (s *MessengerContactRequestSuite) TestAliceRecoverStateSendContactRequest()
|
|||
s.Require().NotNil(contactRequest)
|
||||
|
||||
// Bob accepts the contact request
|
||||
s.acceptContactRequest(contactRequest, bob)
|
||||
s.acceptContactRequest(contactRequest, alice1, bob)
|
||||
|
||||
// Alice resets her device
|
||||
alice2, err := newMessengerWithKey(s.shh, s.m.identity, s.logger, nil)
|
||||
|
@ -678,7 +700,7 @@ func (s *MessengerContactRequestSuite) TestAliceRecoverStateReceiveContactReques
|
|||
s.Require().NotNil(contactRequest)
|
||||
|
||||
// Bob accepts the contact request
|
||||
s.acceptContactRequest(contactRequest, bob)
|
||||
s.acceptContactRequest(contactRequest, alice1, bob)
|
||||
|
||||
// Alice resets her device
|
||||
alice2, err := newMessengerWithKey(s.shh, s.m.identity, s.logger, nil)
|
||||
|
@ -757,7 +779,7 @@ func (s *MessengerContactRequestSuite) TestAliceOfflineRetractsAndAddsCorrectOrd
|
|||
s.Require().NotNil(contactRequest)
|
||||
|
||||
// Bob accepts the contact request
|
||||
s.acceptContactRequest(contactRequest, bob)
|
||||
s.acceptContactRequest(contactRequest, alice1, bob)
|
||||
|
||||
// Alice removes Bob from contacts
|
||||
_, err = alice1.RetractContactRequest(&requests.RetractContactRequest{ID: types.Hex2Bytes(bob.myHexIdentity())})
|
||||
|
@ -806,7 +828,7 @@ func (s *MessengerContactRequestSuite) TestAliceOfflineRetractsAndAddsWrongOrder
|
|||
s.Require().NotNil(contactRequest)
|
||||
|
||||
// Bob accepts the contact request
|
||||
s.acceptContactRequest(contactRequest, bob)
|
||||
s.acceptContactRequest(contactRequest, alice1, bob)
|
||||
|
||||
// Alice removes Bob from contacts
|
||||
_, err = alice1.RetractContactRequest(&requests.RetractContactRequest{ID: types.Hex2Bytes(bob.myHexIdentity())})
|
||||
|
@ -856,7 +878,7 @@ func (s *MessengerContactRequestSuite) TestAliceResendsContactRequestAfterRemovi
|
|||
// Bob accepts the contact request
|
||||
contactRequest := s.receiveContactRequest(messageTextFirst, theirMessenger)
|
||||
s.Require().NotNil(contactRequest)
|
||||
s.acceptContactRequest(contactRequest, theirMessenger)
|
||||
s.acceptContactRequest(contactRequest, s.m, theirMessenger)
|
||||
|
||||
// Alice removes Bob from contacts
|
||||
s.retractContactRequest(contactID, theirMessenger)
|
||||
|
@ -878,7 +900,7 @@ func (s *MessengerContactRequestSuite) TestAliceResendsContactRequestAfterRemovi
|
|||
// Bob accepts new contact request
|
||||
contactRequest = s.receiveContactRequest(messageTextSecond, theirMessenger)
|
||||
s.Require().NotNil(contactRequest)
|
||||
s.acceptContactRequest(contactRequest, theirMessenger)
|
||||
s.acceptContactRequest(contactRequest, s.m, theirMessenger)
|
||||
|
||||
// Make sure bob and alice are not mutual after sending CR
|
||||
s.Require().Len(s.m.MutualContacts(), 1)
|
||||
|
@ -1052,3 +1074,151 @@ func (s *MessengerContactRequestSuite) TestReceiveAcceptAndRetractContactRequest
|
|||
s.Require().Len(contacts, 1)
|
||||
s.Require().Equal(ContactRequestStateReceived, contacts[0].ContactRequestRemoteState)
|
||||
}
|
||||
|
||||
// The scenario tested is as follow:
|
||||
// 1) Alice sends a contact request to Bob
|
||||
// 2) Bob receives CR from Alice
|
||||
// 3) Bob resets his device
|
||||
// 4) Bob restores Alice's contact from backup, CR is created
|
||||
// 5) Bob succesefully accepts restored contact request
|
||||
// 6) Alice get notified properly
|
||||
func (s *MessengerContactRequestSuite) TestBobRestoresIncomingContactRequestFromSyncInstallationContactV2() {
|
||||
messageText := "hello, Bobby!"
|
||||
|
||||
alice := s.m
|
||||
|
||||
bob1 := s.newMessenger(s.shh)
|
||||
_, err := bob1.Start()
|
||||
s.Require().NoError(err)
|
||||
|
||||
aliceID := types.EncodeHex(crypto.FromECDSAPub(&alice.identity.PublicKey))
|
||||
bobID := types.EncodeHex(crypto.FromECDSAPub(&bob1.identity.PublicKey))
|
||||
|
||||
// Alice sends a contact request to bob
|
||||
requestFromAlice := &requests.SendContactRequest{
|
||||
ID: bobID,
|
||||
Message: messageText,
|
||||
}
|
||||
s.sendContactRequest(requestFromAlice, alice)
|
||||
|
||||
// Bob receives CR from Alice
|
||||
contactRequest := s.receiveContactRequest(messageText, bob1)
|
||||
s.Require().NotNil(contactRequest)
|
||||
|
||||
// Bob resets his device
|
||||
bob2, err := newMessengerWithKey(s.shh, bob1.identity, s.logger, nil)
|
||||
s.Require().NoError(err)
|
||||
|
||||
_, err = bob2.Start()
|
||||
s.Require().NoError(err)
|
||||
|
||||
// Get bob perspective of alice for backup
|
||||
aliceFromBob := bob1.Contacts()[0]
|
||||
state := bob2.buildMessageState()
|
||||
|
||||
// Restore alice's contact from backup
|
||||
sync := s.syncInstallationContactV2FromContact(aliceFromBob)
|
||||
err = bob2.HandleSyncInstallationContact(state, sync)
|
||||
s.Require().NoError(err)
|
||||
|
||||
// Accept latest CR for a contact
|
||||
resp, err := bob2.AcceptLatestContactRequestForContact(context.Background(), &requests.AcceptLatestContactRequestForContact{ID: types.Hex2Bytes(aliceID)})
|
||||
s.Require().NoError(err)
|
||||
|
||||
// Make sure the message is updated
|
||||
s.Require().NotNil(resp)
|
||||
s.Require().Len(resp.Messages(), 1)
|
||||
// NOTE: We don't restore CR message
|
||||
// s.Require().Equal(resp.Messages()[0].ID, contactRequest.ID)
|
||||
s.Require().Equal(common.ContactRequestStateAccepted, resp.Messages()[0].ContactRequestState)
|
||||
|
||||
s.Require().Len(resp.ActivityCenterNotifications(), 1)
|
||||
s.Require().NotNil(resp.ActivityCenterNotifications()[0].Message)
|
||||
s.Require().Equal(common.ContactRequestStateAccepted, resp.ActivityCenterNotifications()[0].Message.ContactRequestState)
|
||||
|
||||
// Check the contact state is correctly set
|
||||
s.Require().Len(resp.Contacts, 1)
|
||||
s.Require().True(resp.Contacts[0].mutual())
|
||||
|
||||
// Make sure the sender is added to our contacts
|
||||
contacts := bob2.AddedContacts()
|
||||
s.Require().Len(contacts, 1)
|
||||
|
||||
// Make sure we consider them a mutual contact, receiver side
|
||||
mutualContacts := bob2.MutualContacts()
|
||||
s.Require().Len(mutualContacts, 1)
|
||||
}
|
||||
|
||||
// The scenario tested is as follow:
|
||||
// 1) Alice sends a contact request to Bob
|
||||
// 2) Bob receives CR from Alice
|
||||
// 3) Alice resets her device
|
||||
// 4) Alice restores Bob's contact from backup, CR is created
|
||||
// 5) Bob accepts contact request
|
||||
// 6) Alice get notified properly
|
||||
func (s *MessengerContactRequestSuite) TestAliceRestoresOutgoingContactRequestFromSyncInstallationContactV2() {
|
||||
messageText := "hello, Bobby!"
|
||||
|
||||
alice1 := s.m
|
||||
|
||||
bob := s.newMessenger(s.shh)
|
||||
_, err := bob.Start()
|
||||
s.Require().NoError(err)
|
||||
|
||||
aliceID := types.EncodeHex(crypto.FromECDSAPub(&alice1.identity.PublicKey))
|
||||
bobID := types.EncodeHex(crypto.FromECDSAPub(&bob.identity.PublicKey))
|
||||
|
||||
// Alice sends a contact request to bob
|
||||
requestFromAlice := &requests.SendContactRequest{
|
||||
ID: bobID,
|
||||
Message: messageText,
|
||||
}
|
||||
s.sendContactRequest(requestFromAlice, alice1)
|
||||
|
||||
// Bob receives CR from Alice
|
||||
contactRequest := s.receiveContactRequest(messageText, bob)
|
||||
s.Require().NotNil(contactRequest)
|
||||
|
||||
// Bob resets his device
|
||||
alice2, err := newMessengerWithKey(s.shh, alice1.identity, s.logger, nil)
|
||||
s.Require().NoError(err)
|
||||
|
||||
_, err = alice2.Start()
|
||||
s.Require().NoError(err)
|
||||
|
||||
// Get bob perspective of alice for backup
|
||||
bobFromAlice := alice1.Contacts()[0]
|
||||
state := alice2.buildMessageState()
|
||||
|
||||
// Restore alice's contact from backup
|
||||
sync := s.syncInstallationContactV2FromContact(bobFromAlice)
|
||||
err = alice2.HandleSyncInstallationContact(state, sync)
|
||||
s.Require().NoError(err)
|
||||
|
||||
// Accept latest CR for a contact
|
||||
resp, err := bob.AcceptLatestContactRequestForContact(context.Background(), &requests.AcceptLatestContactRequestForContact{ID: types.Hex2Bytes(aliceID)})
|
||||
s.Require().NoError(err)
|
||||
|
||||
// Make sure the message is updated
|
||||
s.Require().NotNil(resp)
|
||||
s.Require().Len(resp.Messages(), 1)
|
||||
// NOTE: We don't restore CR message
|
||||
// s.Require().Equal(resp.Messages()[0].ID, contactRequest.ID)
|
||||
s.Require().Equal(common.ContactRequestStateAccepted, resp.Messages()[0].ContactRequestState)
|
||||
|
||||
s.Require().Len(resp.ActivityCenterNotifications(), 1)
|
||||
s.Require().NotNil(resp.ActivityCenterNotifications()[0].Message)
|
||||
s.Require().Equal(common.ContactRequestStateAccepted, resp.ActivityCenterNotifications()[0].Message.ContactRequestState)
|
||||
|
||||
// Check the contact state is correctly set
|
||||
s.Require().Len(resp.Contacts, 1)
|
||||
s.Require().True(resp.Contacts[0].mutual())
|
||||
|
||||
// Make sure the sender is added to our contacts
|
||||
contacts := bob.AddedContacts()
|
||||
s.Require().Len(contacts, 1)
|
||||
|
||||
// Make sure we consider them a mutual contact, receiver side
|
||||
mutualContacts := bob.MutualContacts()
|
||||
s.Require().Len(mutualContacts, 1)
|
||||
}
|
||||
|
|
|
@ -475,7 +475,7 @@ func (m *Messenger) AddContact(ctx context.Context, request *requests.AddContact
|
|||
request.Nickname,
|
||||
request.DisplayName,
|
||||
"",
|
||||
"Please add me to your contacts",
|
||||
defaultContactRequestText(),
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
|
@ -962,6 +962,10 @@ func defaultContactRequestID(contactID string) string {
|
|||
return "0x" + types.Bytes2Hex(append(types.Hex2Bytes(contactID), 0x20))
|
||||
}
|
||||
|
||||
func defaultContactRequestText() string {
|
||||
return "Please add me to your contacts"
|
||||
}
|
||||
|
||||
func (m *Messenger) BuildContact(request *requests.BuildContact) (*Contact, error) {
|
||||
contact, ok := m.allContacts.Load(request.PublicKey)
|
||||
if !ok {
|
||||
|
|
|
@ -275,7 +275,7 @@ func (m *Messenger) createContactRequestForContactUpdate(contact *Contact, messa
|
|||
messageState.CurrentMessageState.Message.Clock,
|
||||
messageState.CurrentMessageState.WhisperTimestamp,
|
||||
contact,
|
||||
"Please add me to your contacts",
|
||||
defaultContactRequestText(),
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
|
@ -328,8 +328,8 @@ func (m *Messenger) createIncomingContactRequestNotification(contact *Contact, m
|
|||
Name: contact.PrimaryName(),
|
||||
Message: contactRequest,
|
||||
Type: ActivityCenterNotificationTypeContactRequest,
|
||||
Author: messageState.CurrentMessageState.Contact.ID,
|
||||
Timestamp: messageState.CurrentMessageState.WhisperTimestamp,
|
||||
Author: contactRequest.From,
|
||||
Timestamp: contactRequest.WhisperTimestamp,
|
||||
ChatID: contact.ID,
|
||||
Read: contactRequest.ContactRequestState == common.ContactRequestStateAccepted || contactRequest.ContactRequestState == common.ContactRequestStateDismissed,
|
||||
Accepted: contactRequest.ContactRequestState == common.ContactRequestStateAccepted,
|
||||
|
@ -410,10 +410,59 @@ func (m *Messenger) handleCommandMessage(state *ReceivedMessageState, message *c
|
|||
return nil
|
||||
}
|
||||
|
||||
func (m *Messenger) syncContactRequestForInstallationContact(contact *Contact, state *ReceivedMessageState, chat *Chat, outgoing bool) error {
|
||||
if chat == nil {
|
||||
return fmt.Errorf("no chat restored during the contact synchronisation, contact.ID = %s", contact.ID)
|
||||
}
|
||||
|
||||
contactRequestID, err := m.persistence.LatestPendingContactRequestIDForContact(contact.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if contactRequestID != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
clock, timestamp := chat.NextClockAndTimestamp(m.transport)
|
||||
contactRequest, err := m.generateContactRequest(clock, timestamp, contact, defaultContactRequestText(), outgoing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
contactRequest.ID = defaultContactRequestID(contact.ID)
|
||||
|
||||
state.Response.AddMessage(contactRequest)
|
||||
err = m.persistence.SaveMessages([]*common.Message{contactRequest})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if outgoing {
|
||||
notification := m.generateOutgoingContactRequestNotification(contact, contactRequest)
|
||||
err = m.addActivityCenterNotification(state.Response, notification)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
err = m.createIncomingContactRequestNotification(contact, state, contactRequest, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Messenger) HandleSyncInstallationContact(state *ReceivedMessageState, message protobuf.SyncInstallationContactV2) error {
|
||||
// Ignore own contact installation
|
||||
if message.Id == m.myHexIdentity() {
|
||||
return nil
|
||||
}
|
||||
|
||||
removedOrBlocked := message.Removed || message.Blocked
|
||||
chat, ok := state.AllChats.Load(message.Id)
|
||||
if !ok && (message.Added || message.Muted) && !removedOrBlocked {
|
||||
if !ok && (message.Added || message.HasAddedUs || message.Muted) && !removedOrBlocked {
|
||||
pubKey, err := common.HexToPubkey(message.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -448,16 +497,31 @@ func (m *Messenger) HandleSyncInstallationContact(state *ReceivedMessageState, m
|
|||
uint64(message.ContactRequestLocalClock))
|
||||
state.ModifiedContacts.Store(contact.ID, true)
|
||||
state.AllContacts.Store(contact.ID, contact)
|
||||
|
||||
err := m.syncContactRequestForInstallationContact(contact, state, chat, contact.ContactRequestLocalState == ContactRequestStateSent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if message.Added || message.HasAddedUs {
|
||||
// NOTE(cammellos): this is for handling backward compatibility, old clients
|
||||
// won't propagate ContactRequestRemoteClock or ContactRequestLocalClock
|
||||
|
||||
if message.Added && contact.LastUpdatedLocally < message.LastUpdatedLocally {
|
||||
contact.ContactRequestSent(message.LastUpdatedLocally)
|
||||
|
||||
err := m.syncContactRequestForInstallationContact(contact, state, chat, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if message.HasAddedUs && contact.LastUpdated < message.LastUpdated {
|
||||
contact.ContactRequestReceived(message.LastUpdated)
|
||||
|
||||
err := m.syncContactRequestForInstallationContact(contact, state, chat, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if message.Removed && contact.LastUpdatedLocally < message.LastUpdatedLocally {
|
||||
|
@ -466,7 +530,6 @@ func (m *Messenger) HandleSyncInstallationContact(state *ReceivedMessageState, m
|
|||
return err
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Sync last updated field
|
||||
|
|
Loading…
Reference in New Issue