refactor: make accepting member requests to join async

This is necessary because with community token permissions, when owners
manually accept a request, we a) don't want to block the UI when the
users funds are check on chain and b) in case of insufficient funds,
we'll react with a modal that tells the owner that the user can't be
accepted.

All of that is done in this commit.
This commit is contained in:
Pascal Precht 2023-03-21 12:21:23 +01:00 committed by Follow the white rabbit
parent f39dfa87f7
commit 5e965bcbb7
18 changed files with 271 additions and 32 deletions

View File

@ -264,6 +264,10 @@ proc init*(self: Controller) =
if (args.community.id == self.sectionId):
self.delegate.onJoinedCommunity()
self.events.on(SIGNAL_ACCEPT_REQUEST_TO_JOIN_FAILED_NO_PERMISSION) do(e: Args):
var args = CommunityMemberArgs(e)
self.delegate.onAcceptRequestToJoinFailedNoPermission(args.communityId, args.pubKey, args.requestId)
self.events.on(SIGNAL_CONTACT_NICKNAME_CHANGED) do(e: Args):
var args = ContactArgs(e)
self.delegate.onContactDetailsUpdated(args.contactId)
@ -453,7 +457,7 @@ proc joinGroupChatFromInvitation*(self: Controller, groupName: string, chatId: s
self.gifService, self.mailserversService)
proc acceptRequestToJoinCommunity*(self: Controller, requestId: string, communityId: string) =
self.communityService.acceptRequestToJoinCommunity(communityId, requestId)
self.communityService.asyncAcceptRequestToJoinCommunity(communityId, requestId)
proc declineRequestToJoinCommunity*(self: Controller, requestId: string, communityId: string) =
self.communityService.declineRequestToJoinCommunity(communityId, requestId)

View File

@ -365,3 +365,6 @@ method onKickedFromCommunity*(self: AccessInterface) =
method onJoinedCommunity*(self: AccessInterface) =
raise newException(ValueError, "No implementation available")
method onAcceptRequestToJoinFailedNoPermission*(self: AccessInterface, communityId: string, memberKey: string, requestId: string) {.base.} =
raise newException(ValueError, "No implementation available")

View File

@ -998,6 +998,11 @@ method acceptRequestToJoinCommunity*(self: Module, requestId: string, communityI
method declineRequestToJoinCommunity*(self: Module, requestId: string, communityId: string) =
self.controller.declineRequestToJoinCommunity(requestId, communityId)
method onAcceptRequestToJoinFailedNoPermission*(self: Module, communityId: string, memberKey: string, requestId: string) =
let community = self.controller.getCommunityById(communityId)
let contact = self.controller.getContactById(memberKey)
self.view.emitOpenNoPermissionsToJoinPopupSignal(community.name, contact.displayName, community.id, requestId)
method createCommunityChannel*(self: Module, name, description, emoji, color, categoryId: string) =
self.controller.createCommunityChannel(name, description, emoji, color, categoryId)

View File

@ -251,6 +251,10 @@ QtObject:
proc declineRequestToJoinCommunity*(self: View, requestId: string, communityId: string) {.slot.} =
self.delegate.declineRequestToJoinCommunity(requestId, communityId)
proc openNoPermissionsToJoinPopup*(self:View, communityName: string, userName: string, communityId: string, requestId: string) {.signal.}
proc emitOpenNoPermissionsToJoinPopupSignal*(self: View, communityName: string, userName: string, communityId: string, requestId: string) =
self.openNoPermissionsToJoinPopup(communityName, userName, communityId, requestId)
proc createCommunityChannel*(
self: View,
name: string,

View File

@ -333,6 +333,22 @@ proc init*(self: Controller) =
let args = CommunityTokenDeployedStatusArgs(e)
self.delegate.onCommunityTokenDeployStateChanged(args.communityId, args.contractAddress, args.deployState)
self.events.on(SIGNAL_ACCEPT_REQUEST_TO_JOIN_LOADING) do(e: Args):
var args = CommunityMemberArgs(e)
self.delegate.onAcceptRequestToJoinLoading(args.communityId, args.pubKey)
self.events.on(SIGNAL_ACCEPT_REQUEST_TO_JOIN_FAILED) do(e: Args):
var args = CommunityMemberArgs(e)
self.delegate.onAcceptRequestToJoinFailed(args.communityId, args.pubKey, args.requestId)
self.events.on(SIGNAL_ACCEPT_REQUEST_TO_JOIN_FAILED_NO_PERMISSION) do(e: Args):
var args = CommunityMemberArgs(e)
self.delegate.onAcceptRequestToJoinFailedNoPermission(args.communityId, args.pubKey, args.requestId)
self.events.on(SIGNAL_COMMUNITY_MEMBER_APPROVED) do(e: Args):
var args = CommunityMemberArgs(e)
self.delegate.onAcceptRequestToJoinSuccess(args.communityId, args.pubKey, args.requestId)
self.events.on(SIGNAL_SHARED_KEYCARD_MODULE_FLOW_TERMINATED) do(e: Args):
let args = SharedKeycarModuleFlowTerminatedArgs(e)
if args.uniqueIdentifier == UNIQUE_MAIN_MODULE_KEYCARD_SYNC_IDENTIFIER:

View File

@ -283,6 +283,18 @@ method onCommunityTokenDeployed*(self: AccessInterface, communityToken: Communit
method onCommunityTokenDeployStateChanged*(self: AccessInterface, communityId: string, contractAddress: string, deployState: DeployState) {.base.} =
raise newException(ValueError, "No implementation available")
method onAcceptRequestToJoinFailed*(self: AccessInterface, communityId: string, memberKey: string, requestId: string) {.base.} =
raise newException(ValueError, "No implementation available")
method onAcceptRequestToJoinFailedNoPermission*(self: AccessInterface, communityId: string, memberKey: string, requestId: string) {.base.} =
raise newException(ValueError, "No implementation available")
method onAcceptRequestToJoinLoading*(self: AccessInterface, communityId: string, memberKey: string) {.base.} =
raise newException(ValueError, "No implementation available")
method onAcceptRequestToJoinSuccess*(self: AccessInterface, communityId: string, memberKey: string, requestId: string) {.base.} =
raise newException(ValueError, "No implementation available")
# This way (using concepts) is used only for the modules managed by AppController
type
DelegateInterface* = concept c

View File

@ -972,6 +972,26 @@ method onCommunityTokenDeployStateChanged*[T](self: Module[T], communityId: stri
if item.id != "":
item.updateCommunityTokenDeployState(contractAddress, deployState)
method onAcceptRequestToJoinLoading*[T](self: Module[T], communityId: string, memberKey: string) =
let item = self.view.model().getItemById(communityId)
if item.id != "":
item.updatePendingRequestLoadingState(memberKey, true)
method onAcceptRequestToJoinFailed*[T](self: Module[T], communityId: string, memberKey: string) =
let item = self.view.model().getItemById(communityId)
if item.id != "":
item.updatePendingRequestLoadingState(memberKey, false)
method onAcceptRequestToJoinFailedNoPermission*[T](self: Module[T], communityId: string, memberKey: string, requestId: string) =
let item = self.view.model().getItemById(communityId)
if item.id != "":
item.updatePendingRequestLoadingState(memberKey, false)
method onAcceptRequestToJoinSuccess*[T](self: Module[T], communityId: string, memberKey: string, requestId: string) =
let item = self.view.model().getItemById(communityId)
if item.id != "":
item.updatePendingRequestLoadingState(memberKey, false)
method contactUpdated*[T](self: Module[T], publicKey: string) =
let contactDetails = self.controller.getContactDetails(publicKey)
self.view.activeSection().updateMember(

View File

@ -10,6 +10,7 @@ type
isAdmin: bool
joined: bool
requestToJoinId: string
requestToJoinLoading*: bool
# FIXME: remove defaults
proc initMemberItem*(
@ -32,11 +33,13 @@ proc initMemberItem*(
isAdmin: bool = false,
joined: bool = false,
requestToJoinId: string = "",
requestToJoinLoading: bool = false
): MemberItem =
result = MemberItem()
result.isAdmin = isAdmin
result.joined = joined
result.requestToJoinId = requestToJoinId
result.requestToJoinLoading = requestToJoinLoading
result.UserItem.setup(
pubKey = pubKey,
displayName = displayName,
@ -95,4 +98,7 @@ proc requestToJoinId*(self: MemberItem): string {.inline.} =
self.requestToJoinId
proc `requestToJoinId=`*(self: MemberItem, value: string) {.inline.} =
self.requestToJoinId = value
self.requestToJoinId = value
proc requestToJoinLoading*(self: MemberItem): bool {.inline.} =
self.requestToJoinLoading

View File

@ -27,6 +27,7 @@ type
IsAdmin
Joined
RequestToJoinId
RequestToJoinLoading
QtObject:
type
@ -92,6 +93,7 @@ QtObject:
ModelRole.IsAdmin.int: "isAdmin",
ModelRole.Joined.int: "joined",
ModelRole.RequestToJoinId.int: "requestToJoinId",
ModelRole.RequestToJoinLoading.int: "requestToJoinLoading",
}.toTable
method data(self: Model, index: QModelIndex, role: int): QVariant =
@ -143,6 +145,8 @@ QtObject:
result = newQVariant(item.joined)
of ModelRole.RequestToJoinId:
result = newQVariant(item.requestToJoinId)
of ModelRole.RequestToJoinLoading:
result = newQVariant(item.requestToJoinLoading)
proc addItem*(self: Model, item: MemberItem) =
self.beginInsertRows(newQModelIndex(), self.items.len, self.items.len)
@ -301,3 +305,15 @@ QtObject:
# TODO: rename me to getItemsAsPubkeys
proc getItemIds*(self: Model): seq[string] =
return self.items.map(i => i.pubKey)
proc updateLoadingState*(self: Model, memberKey: string, requestToJoinLoading: bool) =
let idx = self.findIndexForMember(memberKey)
if(idx == -1):
return
self.items[idx].requestToJoinLoading = requestToJoinLoading
let index = self.createIndex(idx, 0, nil)
self.dataChanged(index, index, @[
ModelRole.RequestToJoinLoading.int
])

View File

@ -51,7 +51,7 @@ type
historyArchiveSupportEnabled: bool
pinMessageAllMembersEnabled: bool
bannedMembersModel: member_model.Model
pendingMemberRequestsModel: member_model.Model
pendingMemberRequestsModel*: member_model.Model
declinedMemberRequestsModel: member_model.Model
encrypted: bool
communityTokensModel: community_tokens_model.TokenModel
@ -318,4 +318,7 @@ proc updateCommunityTokenDeployState*(self: SectionItem, contractAddress: string
self.communityTokensModel.updateDeployState(contractAddress, deployState)
proc communityTokens*(self: SectionItem): community_tokens_model.TokenModel {.inline.} =
self.communityTokensModel
self.communityTokensModel
proc updatePendingRequestLoadingState*(self: SectionItem, memberKey: string, loading: bool) {.inline.} =
self.pendingMemberRequestsModel.updateLoadingState(memberKey, loading)

View File

@ -40,6 +40,7 @@ type
BannedMembersModel
Encrypted
CommunityTokensModel
PendingMemberRequestsModel
DeclinedMemberRequestsModel
AmIBanned
@ -111,6 +112,7 @@ QtObject:
ModelRole.BannedMembersModel.int:"bannedMembers",
ModelRole.Encrypted.int:"encrypted",
ModelRole.CommunityTokensModel.int:"communityTokens",
ModelRole.PendingMemberRequestsModel.int:"pendingMemberRequests",
ModelRole.DeclinedMemberRequestsModel.int:"declinedMemberRequests",
ModelRole.AmIBanned.int:"amIBanned"
}.toTable
@ -190,6 +192,8 @@ QtObject:
result = newQVariant(item.encrypted)
of ModelRole.CommunityTokensModel:
result = newQVariant(item.communityTokens)
of ModelRole.PendingMemberRequestsModel:
result = newQVariant(item.pendingMemberRequests)
of ModelRole.DeclinedMemberRequestsModel:
result = newQVariant(item.declinedMemberRequests)
of ModelRole.AmIBanned:
@ -287,6 +291,7 @@ QtObject:
ModelRole.BannedMembersModel.int,
ModelRole.Encrypted.int,
ModelRole.CommunityTokensModel.int,
ModelRole.PendingMemberRequestsModel.int,
ModelRole.DeclinedMemberRequestsModel.int,
ModelRole.AmIBanned.int
])

View File

@ -54,3 +54,20 @@ const asyncLoadCuratedCommunitiesTask: Task = proc(argEncoded: string) {.gcsafe,
"error": e.msg,
})
type
AsyncAcceptRequestToJoinCommunityTaskArg = ref object of QObjectTaskArg
communityId: string
requestId: string
const asyncAcceptRequestToJoinCommunityTask: Task = proc(argEncoded: string) {.gcsafe, nimcall.} =
let arg = decode[AsyncAcceptRequestToJoinCommunityTaskArg](argEncoded)
try:
let response = status_go.acceptRequestToJoinCommunity(arg.requestId)
let tpl: tuple[communityId: string, requestId: string, response: RpcResponse[JsonNode], error: string] = (arg.communityId, arg.requestId, response, "")
arg.finish(tpl)
except Exception as e:
arg.finish(%* {
"error": e.msg,
"communityId": arg.communityId,
"requestId": arg.requestId
})

View File

@ -64,6 +64,7 @@ type
CommunityMemberArgs* = ref object of Args
communityId*: string
pubKey*: string
requestId*: string
CommunityMembersArgs* = ref object of Args
communityId*: string
@ -160,6 +161,10 @@ const SIGNAL_CURATED_COMMUNITIES_LOADING* = "curatedCommunitiesLoading"
const SIGNAL_CURATED_COMMUNITIES_LOADED* = "curatedCommunitiesLoaded"
const SIGNAL_CURATED_COMMUNITIES_LOADING_FAILED* = "curatedCommunitiesLoadingFailed"
const SIGNAL_ACCEPT_REQUEST_TO_JOIN_LOADING* = "acceptRequestToJoinLoading"
const SIGNAL_ACCEPT_REQUEST_TO_JOIN_FAILED* = "acceptRequestToJoinFailed"
const SIGNAL_ACCEPT_REQUEST_TO_JOIN_FAILED_NO_PERMISSION* = "acceptRequestToJoinFailedNoPermission"
const TOKEN_PERMISSIONS_ADDED = "tokenPermissionsAdded"
const TOKEN_PERMISSIONS_MODIFIED = "tokenPermissionsModified"
@ -179,12 +184,15 @@ QtObject:
# Forward declaration
proc loadCommunityTags(self: Service): string
proc asyncLoadCuratedCommunities*(self: Service)
proc asyncAcceptRequestToJoinCommunity*(self: Service, communityId: string, requestId: string)
proc handleCommunityUpdates(self: Service, communities: seq[CommunityDto], updatedChats: seq[ChatDto], removedChats: seq[string])
proc handleCommunitiesSettingsUpdates(self: Service, communitiesSettings: seq[CommunitySettingsDto])
proc pendingRequestsToJoinForCommunity*(self: Service, communityId: string): seq[CommunityMembershipRequestDto]
proc declinedRequestsToJoinForCommunity*(self: Service, communityId: string): seq[CommunityMembershipRequestDto]
proc canceledRequestsToJoinForCommunity*(self: Service, communityId: string): seq[CommunityMembershipRequestDto]
proc getPendingRequestIndex(self: Service, communityId: string, requestId: string): int
proc removeMembershipRequestFromCommunityAndGetMemberPubkey*(self: Service, communityId: string, requestId: string): string
proc getUserPubKeyFromPendingRequest*(self: Service, communityId: string, requestId: string): string
proc delete*(self: Service) =
discard
@ -1318,6 +1326,48 @@ QtObject:
self.events.emit(SIGNAL_COMMUNITY_DATA_IMPORTED, CommunityArgs(community: community))
proc asyncAcceptRequestToJoinCommunity*(self: Service, communityId: string, requestId: string) =
try:
let userKey = self.getUserPubKeyFromPendingRequest(communityId, requestId)
self.events.emit(SIGNAL_ACCEPT_REQUEST_TO_JOIN_LOADING, CommunityMemberArgs(communityId: communityId, pubKey: userKey))
let arg = AsyncAcceptRequestToJoinCommunityTaskArg(
tptr: cast[ByteAddress](asyncAcceptRequestToJoinCommunityTask),
vptr: cast[ByteAddress](self.vptr),
slot: "onAsyncAcceptRequestToJoinCommunityDone",
communityId: communityId,
requestId: requestId
)
self.threadpool.start(arg)
except Exception as e:
error "Error accepting request to join community", msg = e.msg
proc onAsyncAcceptRequestToJoinCommunityDone*(self: Service, response: string) {.slot.} =
try:
let rpcResponseObj = response.parseJson
let communityId = rpcResponseObj{"communityId"}.getStr
let requestId = rpcResponseObj{"requestId"}.getStr
let userKey = self.getUserPubKeyFromPendingRequest(communityId, requestId)
if rpcResponseObj{"error"}.kind != JNull and rpcResponseObj{"error"}.getStr != "":
let errorMessage = rpcResponseObj{"error"}.getStr
if errorMessage.contains("has no permission to join"):
self.events.emit(SIGNAL_ACCEPT_REQUEST_TO_JOIN_FAILED_NO_PERMISSION, CommunityMemberArgs(communityId: communityId, pubKey: userKey, requestId: requestId))
else:
self.events.emit(SIGNAL_ACCEPT_REQUEST_TO_JOIN_FAILED, CommunityMemberArgs(communityId: communityId, pubKey: userKey, requestId: requestId))
return
discard self.removeMembershipRequestFromCommunityAndGetMemberPubkey(communityId, requestId)
if (userKey == ""):
error "Did not find pubkey in the pending request"
return
self.events.emit(SIGNAL_COMMUNITY_MEMBER_APPROVED, CommunityMemberArgs(communityId: communityId, pubKey: userKey, requestId: requestId))
except Exception as e:
let errMsg = e.msg
error "error accepting request to join: ", errMsg
self.events.emit(SIGNAL_ACCEPT_REQUEST_TO_JOIN_FAILED, Args())
proc asyncLoadCuratedCommunities*(self: Service) =
self.events.emit(SIGNAL_CURATED_COMMUNITIES_LOADING, Args())
@ -1492,21 +1542,6 @@ QtObject:
except Exception as e:
error "Error canceled request to join community", msg = e.msg
proc acceptRequestToJoinCommunity*(self: Service, communityId: string, requestId: string) =
try:
let response = status_go.acceptRequestToJoinCommunity(requestId)
self.activityCenterService.parseActivityCenterResponse(response)
let newMemberPubkey = self.removeMembershipRequestFromCommunityAndGetMemberPubkey(communityId, requestId)
if (newMemberPubkey == ""):
error "Did not find pubkey in the pending request"
return
self.events.emit(SIGNAL_COMMUNITY_MEMBER_APPROVED, CommunityMemberArgs(communityId: communityId, pubKey: newMemberPubkey))
except Exception as e:
error "Error accepting request to join community", msg = e.msg
proc declineRequestToJoinCommunity*(self: Service, communityId: string, requestId: string) =
try:
let response = status_go.declineRequestToJoinCommunity(requestId)
@ -1657,3 +1692,11 @@ QtObject:
CommunityTokenPermissionArgs(communityId: communityId, tokenPermission: tokenPermission))
except Exception as e:
error "Error deleting community token permission", msg = e.msg
proc getUserPubKeyFromPendingRequest*(self: Service, communityId: string, requestId: string): string =
let indexPending = self.getPendingRequestIndex(communityId, requestId)
if (indexPending == -1):
raise newException(RpcException, fmt"Community request not found: {requestId}")
let community = self.communities[communityId]
return community.pendingRequestsToJoin[indexPending].publicKey

View File

@ -101,6 +101,15 @@ Item {
onClicked: root.unbanUserClicked(model.pubKey)
},
StatusButton {
visible: (root.panelType === CommunityMembersTabPanel.TabType.PendingRequests) && isHovered
text: qsTr("Reject")
type: StatusBaseButton.Type.Danger
icon.name: "close-circle"
icon.color: Style.current.danger
onClicked: root.declineRequestToJoin(model.requestToJoinId)
},
StatusButton {
visible: (root.panelType === CommunityMembersTabPanel.TabType.PendingRequests ||
root.panelType === CommunityMembersTabPanel.TabType.DeclinedRequests) && isHovered
@ -110,17 +119,10 @@ Item {
normalColor: Theme.palette.successColor2
hoverColor: Theme.palette.successColor3
textColor: Theme.palette.successColor1
loading: model.requestToJoinLoading
onClicked: root.acceptRequestToJoin(model.requestToJoinId)
},
StatusButton {
visible: (root.panelType === CommunityMembersTabPanel.TabType.PendingRequests) && isHovered
text: qsTr("Reject")
type: StatusBaseButton.Type.Danger
icon.name: "close-circle"
icon.color: Style.current.danger
onClicked: root.declineRequestToJoin(model.requestToJoinId)
}
]
width: membersList.width

View File

@ -231,7 +231,7 @@ StatusSectionLayout {
CommunityMembersSettingsPanel {
membersModel: root.community.members
bannedMembersModel: root.community.bannedMembers
pendingMemberRequestsModel: root.community.pendingRequestsToJoin
pendingMemberRequestsModel: root.community.pendingMemberRequests
declinedMemberRequestsModel: root.community.declinedMemberRequests
editable: root.community.amISectionAdmin
communityName: root.community.name
@ -240,8 +240,8 @@ StatusSectionLayout {
onKickUserClicked: root.rootStore.removeUserFromCommunity(id)
onBanUserClicked: root.rootStore.banUserFromCommunity(id)
onUnbanUserClicked: root.rootStore.unbanUserFromCommunity(id)
onAcceptRequestToJoin: root.rootStore.acceptRequestToJoinCommunity(id, root.communityId)
onDeclineRequestToJoin: root.rootStore.declineRequestToJoinCommunity(id, root.communityId)
onAcceptRequestToJoin: root.rootStore.acceptRequestToJoinCommunity(id, root.community.id)
onDeclineRequestToJoin: root.rootStore.declineRequestToJoinCommunity(id, root.community.id)
}
CommunityPermissionsSettingsPanel {
@ -348,4 +348,28 @@ StatusSectionLayout {
store: root.rootStore
}
}
Component {
id: noPermissionsPopupCmp
NoPermissionsToJoinPopup {
onRejectButtonClicked: {
root.rootStore.declineRequestToJoinCommunity(requestId, communityId)
close()
}
onClosed: destroy()
}
}
Connections {
target: root.chatCommunitySectionModule
function onOpenNoPermissionsToJoinPopup(communityName: string, userName: string, communityId: string, requestId: string) {
Global.openPopup(noPermissionsPopupCmp, {
communityName: communityName,
userName: userName,
communityId: communityId,
requestId: requestId
})
}
}
}

View File

@ -0,0 +1,58 @@
import QtQuick 2.13
import QtQuick.Controls 2.13
import QtQuick.Layouts 1.13
import QtQml.Models 2.14
import utils 1.0
import StatusQ.Core 0.1
import StatusQ.Core.Theme 0.1
import StatusQ.Popups.Dialog 0.1
import StatusQ.Controls 0.1
StatusDialog {
id: root
width: 400
title: qsTr("Required assets not held")
property string userName: ""
property string communityName: ""
property string communityId: ""
property string requestId: ""
signal rejectButtonClicked(string requestId, string communityId)
footer: StatusDialogFooter {
rightButtons: ObjectModel {
StatusButton {
text: qsTr("Reject")
type: StatusBaseButton.Type.Danger
icon.name: "close-circle"
icon.color: Style.current.danger
onClicked: root.rejectButtonClicked(root.requestId, root.communityId)
}
}
}
ColumnLayout {
anchors.fill: parent
spacing: Style.current.padding
StatusBaseText {
text: qsTr("%1 no longer holds the tokens required to join %2 in their wallet, so their request to join %2 must be rejected.").arg(root.userName).arg(root.communityName)
font.pixelSize: 15
wrapMode: Text.WordWrap
color: Theme.palette.directColor1
Layout.fillWidth: true
}
StatusBaseText {
text: qsTr("%1 can request to join %2 again in the future, when they have the tokens required to join %2 in their wallet.").arg(root.userName).arg(root.communityName)
font.pixelSize: 15
wrapMode: Text.WordWrap
color: Theme.palette.directColor1
Layout.fillWidth: true
}
}
}

View File

@ -25,3 +25,4 @@ DisplayNamePopup 1.0 DisplayNamePopup.qml
SendContactRequestModal 1.0 SendContactRequestModal.qml
AccountsModalHeader 1.0 AccountsModalHeader.qml
GetSyncCodeInstructionsPopup 1.0 GetSyncCodeInstructionsPopup.qml
NoPermissionsToJoinPopup 1.0 NoPermissionsToJoinPopup.qml

2
vendor/status-go vendored

@ -1 +1 @@
Subproject commit 48eb7052848a17ab7f972112a3653f0b4cc8537a
Subproject commit 7bc03e22f724408cf8ac14117915f72ef7888d88