diff --git a/frontend/controller/actions/chatroom.js b/frontend/controller/actions/chatroom.js index b1830ea28..6a42024cb 100644 --- a/frontend/controller/actions/chatroom.js +++ b/frontend/controller/actions/chatroom.js @@ -137,6 +137,13 @@ export default (sbp('sbp/selectors/register', { name: `${userID}/${userCSKid}` }) ], + data: { + ...params.data, + attributes: { + ...params.data?.attributes, + creatorID: userID + } + }, contractName: 'gi.contracts/chatroom' }) @@ -159,8 +166,7 @@ export default (sbp('sbp/selectors/register', { const originatingContractID = state.attributes.groupContractID ? state.attributes.groupContractID : contractID // $FlowFixMe - return Promise.all(Object.keys(state.users).map(async (username) => { - const pContractID = await sbp('namespace/lookup', username) + return Promise.all(Object.keys(state.members).map((pContractID) => { const CEKid = findKeyIdByName(rootState[pContractID], 'cek') if (!CEKid) { console.warn(`Unable to share rotated keys for ${originatingContractID} with ${pContractID}: Missing CEK`) @@ -188,9 +194,8 @@ export default (sbp('sbp/selectors/register', { ...encryptedAction('gi.actions/chatroom/deleteMessage', L('Failed to delete message.')), ...encryptedAction('gi.actions/chatroom/makeEmotion', L('Failed to make emotion.')), ...encryptedAction('gi.actions/chatroom/join', L('Failed to join chat channel.'), async (sendMessage, params, signingKeyId) => { - const rootGetters = sbp('state/vuex/getters') const rootState = sbp('state/vuex/state') - const userID = rootGetters.ourContactProfiles[params.data.username]?.contractID + const userID = params.data.memberID || rootState.loggedIn.identityContractID // We need to read values from both the chatroom and the identity contracts' // state, so we call wait to run the rest of this function after all @@ -244,7 +249,7 @@ export default (sbp('sbp/selectors/register', { ...encryptedAction('gi.actions/chatroom/changeDescription', L('Failed to change chat channel description.')), ...encryptedAction('gi.actions/chatroom/leave', L('Failed to leave chat channel.'), async (sendMessage, params, signingKeyId) => { const rootGetters = sbp('state/vuex/getters') - const userID = rootGetters.ourContactProfiles[params.data.member]?.contractID + const userID = rootGetters.ourContactProfiles[params.data.memberID]?.contractID const keyIds = userID && sbp('chelonia/contract/foreignKeysByContractID', params.contractID, userID) diff --git a/frontend/controller/actions/group.js b/frontend/controller/actions/group.js index 36d2f9f80..10e180cc5 100644 --- a/frontend/controller/actions/group.js +++ b/frontend/controller/actions/group.js @@ -174,13 +174,14 @@ export default (sbp('sbp/selectors/register', { data: { invites: { [inviteKeyId]: { - creator: INVITE_INITIAL_CREATOR, + creatorID: INVITE_INITIAL_CREATOR, inviteKeyId } }, settings: { // authorizations: [contracts.CanModifyAuths.dummyAuth()], // TODO: this groupName: name, + groupCreatorID: userID, groupPicture: finalPicture, sharedValues, mincomeAmount: +mincomeAmount, @@ -225,7 +226,7 @@ export default (sbp('sbp/selectors/register', { data: { groupContractID: contractID, inviteSecret: serializeKey(CSK, true), - creator: true + creatorID: true } }]) @@ -262,15 +263,27 @@ export default (sbp('sbp/selectors/register', { // JOINING_GROUP, so that we process leave actions and don't interfere // with the leaving process (otherwise, the side-effects will prevent // us from fully leaving). - sbp('chelonia/contract/wait', [params.originatingContractID, params.contractID]) + await sbp('chelonia/contract/wait', [params.originatingContractID, params.contractID]) + // TODO: See if we can remove the need for okTurtles.data/set sbp('okTurtles.data/set', 'JOINING_GROUP-' + params.contractID, true) try { const { loggedIn } = sbp('state/vuex/state') if (!loggedIn) throw new Error('[gi.actions/group/join] Not logged in') - const { username, identityContractID: userID } = loggedIn - - await sbp('chelonia/contract/sync', params.contractID) + const { identityContractID: userID } = loggedIn + + // When syncing the group contract, the contract might call /remove on + // itself if we had previously joined and left the group. By using + // deferredRemove we ensure that it's not deleted until we've finished + // trying to join. + // When we are re-joinining, this function will call /cancelRemove to + // ensure that the contract isn't removed, and if we're not re-joining, + // the call at the end to /remove with removeIfPending will remove the + // group contract. + // Part of this same functionality is what `sbp('okTurtles.data/set', 'JOINING_GROUP-'` + // does. In a later improvement, we could remove that and handle re-joinining + // within the contract state. + await sbp('chelonia/contract/sync', params.contractID, { deferredRemove: true }) const rootState = sbp('state/vuex/state') if (!rootState.contracts[params.contractID]) { console.warn('[gi.actions/group/join] The group contract was removed after sync. If this happened during logging in, this likely means that we left the group on a different session.', { contractID: params.contractID }) @@ -340,6 +353,8 @@ export default (sbp('sbp/selectors/register', { sbp('okTurtles.events/on', CONTRACT_HAS_RECEIVED_KEYS, eventHandler) } + // !sendKeyRequest && !(hasSecretKeys && !pendingKeyShares) && !(!hasSecretKeys && !pendingKeyShares) && !pendingKeyShares + // After syncing the group contract, we send a key request if (sendKeyRequest) { // Send the key request @@ -372,6 +387,9 @@ export default (sbp('sbp/selectors/register', { console.error(`[gi.actions/group/join] Error while sending key request for ${params.contractID}:`, e?.message || e, e) throw e }) + // While we're waiting for keys, we should not remove the group contract + // (if had previously left) because we're re-joining + sbp('chelonia/contract/cancelRemove', params.contractID) // Nothing left to do until the keys are received @@ -385,7 +403,7 @@ export default (sbp('sbp/selectors/register', { // We're joining for the first time // In this case, we share our profile key with the group, call the // inviteAccept action and join the General chatroom - if (state.profiles?.[username]?.status !== PROFILE_STATUS.ACTIVE) { + if (state.profiles?.[userID]?.status !== PROFILE_STATUS.ACTIVE) { // All reads are done here at the top to ensure that they happen // synchronously, before any await calls. // If reading after an asynchronous operation, we might get inconsistent @@ -432,10 +450,6 @@ export default (sbp('sbp/selectors/register', { postpublish: null } }) - - if (rootState.currentGroupId === params.contractID) { - await sbp('gi.actions/group/updateLastLoggedIn', { contractID: params.contractID }) - } } catch (e) { console.error(`[gi.actions/group/join] Error while sending key request for ${params.contractID}:`, e) throw e @@ -452,59 +466,26 @@ export default (sbp('sbp/selectors/register', { // This could happen, for example, after logging in if we still haven't // received a response to the key request. sbp('okTurtles.data/set', 'JOINING_GROUP-' + params.contractID, false) - console.warn('Requested to join group but we\'ve been removed. contractID=' + params.contractID) - - // Now, we check to make sure that we were removed (could have - // happened while we were offline or on a different device) - sbp('chelonia/queueInvocation', params.contractID, () => { - const rootState = sbp('state/vuex/state') - const state = rootState[params.contractID] - - // The contract has already been removed - if (!state) return - - // We have indeed been removed - if (state.profiles?.[username]?.status === PROFILE_STATUS.REMOVED) { - // We can't await as remove is using the same queue - console.info('[gi.actions/group/join] We appear to have left the group. Removing the contract.', { - contractID: params.contractID, - username, - userID, - hasSecretKeys, - pendingKeyShares, - status: state.profiles?.[username]?.status - }) - sbp('chelonia/contract/remove', params.contractID).catch((e) => { - console.error('[gi.actions/group/join] An error occurred while trying to remove the group contract', e) - }) - } else { - console.warn('[gi.actions/group/join] Invalid or inconsistent state. We would appear to have been removed but aren\'nt', { - contractID: params.contractID, - username, - userID, - hasSecretKeys, - pendingKeyShares, - status: state.profiles?.[username]?.status - }) - } - }).catch((e) => { - console.error('[gi.actions/group/join] Error while completing the group removal process', { - contractID: params.contractID, - username, - userID, - hasSecretKeys, - pendingKeyShares, - status: state.profiles?.[username]?.status - }, e) - }) } else if (pendingKeyShares) { console.info('Requested to join group but already waiting for OP_KEY_SHARE. contractID=' + params.contractID) + // If we're waiting on keys to be shared with us, we need to cancel any + // pending removal while we're waiting + sbp('chelonia/contract/cancelRemove', params.contractID) } else { - console.warn('Requested to join group but the state appears invalid. contractID=' + params.contractID, { sendKeyRequest, hasSecretKeys, pendingKeyShares }) + console.error('Requested to join group but the state appears invalid. This should be unreachable. contractID=' + params.contractID, { sendKeyRequest, hasSecretKeys, pendingKeyShares }) } } catch (e) { console.error('gi.actions/group/join failed!', e) + // If an error occurred, the join process is incomplete and we should not + // remove the group contract. + sbp('chelonia/contract/cancelRemove', params.contractID) throw new GIErrorUIRuntimeError(L('Failed to join the group: {codeError}', { codeError: e.message })) + } finally { + // If we called join but it didn't result in any actions being sent, we + // may have left the group. In this case, we execute any pending /remove + // actions on the contract. This will have no side-effects if /remove on + // the group contract hasn't been called. + await sbp('chelonia/contract/remove', params.contractID, { removeIfPending: true }) } }, 'gi.actions/group/joinAndSwitch': async function (params: $Exact) { @@ -537,9 +518,8 @@ export default (sbp('sbp/selectors/register', { // $FlowFixMe return Promise.all( Object.entries(state.profiles) - .filter(([_, p]) => (p: any).departedDate == null) - .map(async ([username]) => { - const pContractID = await sbp('namespace/lookup', username) + .filter(([_, p]) => (p: any).status === PROFILE_STATUS.ACTIVE) + .map(([pContractID]) => { const CEKid = sbp('chelonia/contract/currentKeyIdByName', rootState[pContractID], 'cek') if (!CEKid) { console.warn(`Unable to share rotated keys for ${contractID} with ${pContractID}: Missing CEK`) @@ -644,18 +624,18 @@ export default (sbp('sbp/selectors/register', { ...encryptedAction('gi.actions/group/joinChatRoom', L('Failed to join chat channel.'), async function (sendMessage, params) { const rootState = sbp('state/vuex/state') const rootGetters = sbp('state/vuex/getters') - const me = rootState.loggedIn.username - const username = params.data.username || me + const me = rootState.loggedIn.identityContractID + const memberID = params.data.memberID || me - if (!rootGetters.isJoinedChatRoom(params.data.chatRoomID) && username !== me) { + if (!rootGetters.isJoinedChatRoom(params.data.chatRoomID) && memberID !== me) { throw new GIErrorUIRuntimeError(L('Only channel members can invite others to join.')) } // If we are inviting someone else to join, we need to share the chatroom's keys // with them so that they are able to read messages and participate - if (username !== me && rootState[params.data.chatRoomID].attributes.privacyLevel === CHATROOM_PRIVACY_LEVEL.PRIVATE) { + if (memberID !== me && rootState[params.data.chatRoomID].attributes.privacyLevel === CHATROOM_PRIVACY_LEVEL.PRIVATE) { await sbp('gi.actions/out/shareVolatileKeys', { - contractID: rootGetters.ourContactProfiles[username].contractID, + contractID: memberID, contractName: 'gi.contracts/identity', subjectContractID: params.data.chatRoomID, keyIds: '*' @@ -713,20 +693,19 @@ export default (sbp('sbp/selectors/register', { } }) }), + 'gi.actions/group/removeOurselves': (params: GIActionParams) => { + return sbp('gi.actions/group/removeMember', { + ...omit(params, ['options', 'action']), + data: {} + }) + }, ...encryptedAction('gi.actions/group/removeMember', - (params, e) => L('Failed to remove {member}: {reportError}', { member: params.data.member, ...LError(e) }), + (params, e) => params.data.memberID ? L('Failed to remove {memberID}: {reportError}', { memberID: params.data.memberID, ...LError(e) }) : L('Failed to leave group. {codeError}', { codeError: e.message }), async function (sendMessage, params, signingKeyId) { await sendMessage({ ...omit(params, ['options', 'action']) }) }), - ...encryptedAction('gi.actions/group/removeOurselves', - (e) => L('Failed to leave group. {codeError}', { codeError: e.message }), - async function (sendMessage, params) { - await sendMessage({ - ...omit(params, ['options', 'action']) - }) - }), ...encryptedAction('gi.actions/group/changeChatRoomDescription', L('Failed to update description of chat channel.'), async function (sendMessage, params: GIActionParams) { @@ -752,13 +731,13 @@ export default (sbp('sbp/selectors/register', { } }) }), - 'gi.actions/group/autobanUser': async function (message: GIMessage, error: Object, attempt = 1) { + 'gi.actions/group/autobanUser': async function (message: GIMessage, error: Object, msgMeta: { signingKeyId: string, signingContractID: string, innerSigningKeyId: string, innerSigningContractID: string }, attempt = 1) { try { if (attempt === 1) { // to decrease likelihood of multiple proposals being created at the same time, wait // a random amount of time on the first call setTimeout(() => { - sbp('gi.actions/group/autobanUser', message, error, attempt + 1) + sbp('gi.actions/group/autobanUser', message, error, msgMeta, attempt + 1) .catch((e) => { console.error('[gi.actions/group/autobanUser] Error from setTimeout callback (1st attempt)', e) }) @@ -771,23 +750,24 @@ export default (sbp('sbp/selectors/register', { // // NOTE: we cast to 'any' to work around flow errors // see: https://stackoverflow.com/a/41329247/1781435 - const { meta } = message.decryptedValue() - const username = meta && meta.username + const memberID = msgMeta && msgMeta.innerSigningContractID const groupID = message.contractID() - const contractState = sbp('state/vuex/state')[groupID] - const getters = sbp('state/vuex/getters') - if (username && getters.groupProfile(username)) { - console.warn(`autoBanSenderOfMessage: autobanning ${username} from ${groupID}`) + const rootState = sbp('state/vuex/state') + const contractState = rootState[groupID] + if (memberID && rootState.contracts[groupID]?.type === 'gi.contracts/group' && contractState?.profiles?.[memberID]?.status === PROFILE_STATUS.ACTIVE) { + const rootGetters = sbp('state/vuex/getters') + const username = rootGetters.usernameFromID(memberID) + console.warn(`autoBanSenderOfMessage: autobanning ${memberID} (username ${username}) from ${groupID}`) // find existing proposal if it exists let [proposalHash, proposal]: [string, ?Object] = Object.entries(contractState.proposals) .find(([hash, prop]: [string, Object]) => ( prop.status === STATUS_OPEN && prop.data.proposalType === PROPOSAL_REMOVE_MEMBER && - prop.data.proposalData.member === username + prop.data.proposalData.memberName === memberID )) ?? ['', undefined] if (proposal) { // cast our vote if we haven't already cast it - if (!proposal.votes[getters.ourUsername]) { + if (!proposal.votes[rootState.loggedIn.identityContractID]) { await sbp('gi.actions/group/proposalVote', { contractID: groupID, data: { proposalHash, vote: VOTE_FOR, passPayload: { secret: '' } }, @@ -802,7 +782,7 @@ export default (sbp('sbp/selectors/register', { data: { proposalType: PROPOSAL_REMOVE_MEMBER, proposalData: { - member: username, + memberID, reason: L("Automated ban because they're sending malformed messages resulting in: {error}", { error: error.message }), automated: true }, @@ -813,12 +793,12 @@ export default (sbp('sbp/selectors/register', { }) } catch (e) { if (attempt > 3) { - console.error(`autoBanSenderOfMessage: max attempts reached. Error ${e.message} attempting to ban ${username}`, message, e) + console.error(`autoBanSenderOfMessage: max attempts reached. Error ${e.message} attempting to ban ${memberID}`, message, e) } else { const randDelay = randomIntFromRange(0, 1500) - console.warn(`autoBanSenderOfMessage: ${e.message} attempting to ban ${username}, retrying in ${randDelay} ms...`, e) + console.warn(`autoBanSenderOfMessage: ${e.message} attempting to ban ${memberID}, retrying in ${randDelay} ms...`, e) setTimeout(() => { - sbp('gi.actions/group/autobanUser', message, error, attempt + 1) + sbp('gi.actions/group/autobanUser', message, error, msgMeta, attempt + 1) .catch((e) => { console.error('[gi.actions/group/autobanUser] Error from setTimeout callback (> 3rd attempt)', e) }) @@ -893,7 +873,7 @@ export default (sbp('sbp/selectors/register', { const enforceDunbar = true // Context for this hard-coded boolean variable: https://github.com/okTurtles/group-income/pull/1648#discussion_r1230389924 const { groupMembersCount, currentGroupState } = sbp('state/vuex/getters') - const memberInvitesCount = Object.values(currentGroupState.invites || {}).filter((invite: any) => invite.creator !== INVITE_INITIAL_CREATOR).length + const memberInvitesCount = Object.values(currentGroupState.invites || {}).filter((invite: any) => invite.creatorID !== INVITE_INITIAL_CREATOR).length const isGroupSizeLarge = (groupMembersCount + memberInvitesCount) >= MAX_GROUP_MEMBER_COUNT if (isGroupSizeLarge) { diff --git a/frontend/controller/actions/identity.js b/frontend/controller/actions/identity.js index 7b7ed9a62..4891d10d1 100644 --- a/frontend/controller/actions/identity.js +++ b/frontend/controller/actions/identity.js @@ -389,7 +389,10 @@ export default (sbp('sbp/selectors/register', { // (2) Check whether the join process is still incomplete // This needs to be re-checked because it may have changed after // sync - state[groupId]?.profiles?.[username]?.status !== PROFILE_STATUS.ACTIVE && + // // We only check for groups where we don't have a profile, as + // // re-joining is handled by the group contract itself. + // !state[groupId]?.profiles?.[identityContractID] && // ?.status !== PROFILE_STATUS. + state[groupId]?.profiles?.[identityContractID]?.status !== PROFILE_STATUS.ACTIVE && // (3) Call join sbp('gi.actions/group/join', { originatingContractID: identityContractID, @@ -413,7 +416,7 @@ export default (sbp('sbp/selectors/register', { .forEach(cId => { // We send this action only for groups we have fully joined (i.e., // accepted an invite add added our profile) - if (state[cId]?.profiles?.[username]?.status === PROFILE_STATUS.ACTIVE) { + if (state[cId]?.profiles?.[identityContractID]?.status === PROFILE_STATUS.ACTIVE) { sbp('gi.actions/group/updateLastLoggedIn', { contractID: cId }).catch((e) => console.error('Error sending updateLastLoggedIn', e)) } }) @@ -530,7 +533,7 @@ export default (sbp('sbp/selectors/register', { 'gi.actions/identity/shareNewPEK': async (contractID: string, newKeys) => { const rootState = sbp('state/vuex/state') const state = rootState[contractID] - const username = state.attributes.username + const identityContractID = state.attributes.identityContractID // TODO: Also share PEK with DMs await Promise.all((state.loginState?.groupIds || []).filter(groupID => !!rootState.contracts[groupID]).map(groupID => { @@ -562,7 +565,7 @@ export default (sbp('sbp/selectors/register', { hooks: { preSendCheck: (_, state) => { // Don't send this message if we're no longer a group member - return state?.profiles?.[username]?.status === PROFILE_STATUS.ACTIVE + return state?.profiles?.[identityContractID]?.status === PROFILE_STATUS.ACTIVE } } }).catch(e => { @@ -585,7 +588,7 @@ export default (sbp('sbp/selectors/register', { ...encryptedAction('gi.actions/identity/createDirectMessage', L('Failed to create a new direct message channel.'), async function (sendMessage, params) { const rootState = sbp('state/vuex/state') const rootGetters = sbp('state/vuex/getters') - const partnerProfiles = params.data.usernames.map(username => rootGetters.ourContactProfiles[username]) + const partnerIDs = params.data.memberIDs.map(memberID => rootGetters.ourContactProfilesById[memberID].contractID) // NOTE: 'rootState.currentGroupId' could be changed while waiting for the sbp functions to be proceeded // So should save it as a constant variable 'currentGroupId', and use it which can't be changed const currentGroupId = rootState.currentGroupId @@ -616,14 +619,14 @@ export default (sbp('sbp/selectors/register', { await sbp('gi.actions/chatroom/join', { ...omit(params, ['options', 'contractID', 'data', 'hooks']), contractID: message.contractID(), - data: { username: rootState.loggedIn.username } + data: {} }) - for (const profile of partnerProfiles) { + for (const partnerID of partnerIDs) { await sbp('gi.actions/chatroom/join', { ...omit(params, ['options', 'contractID', 'data', 'hooks']), contractID: message.contractID(), - data: { username: profile.username } + data: { memberID: partnerID } }) } @@ -638,14 +641,14 @@ export default (sbp('sbp/selectors/register', { } }) - for (const [index, profile] of partnerProfiles.entries()) { - const hooks = index < partnerProfiles.length - 1 ? undefined : { prepublish: null, postpublish: params.hooks?.postpublish } + for (let index = 0; index < partnerIDs.length; index++) { + const hooks = index < partnerIDs.length - 1 ? undefined : { prepublish: null, postpublish: params.hooks?.postpublish } // Share the keys to the newly created chatroom with partners // TODO: We need to handle multiple groups and the possibility of not // having any groups in common await sbp('gi.actions/out/shareVolatileKeys', { - contractID: profile.contractID, + contractID: partnerIDs[index], contractName: 'gi.contracts/identity', subjectContractID: message.contractID(), keyIds: '*' @@ -653,7 +656,7 @@ export default (sbp('sbp/selectors/register', { await sbp('gi.actions/identity/joinDirectMessage', { ...omit(params, ['options', 'contractID', 'data', 'hooks']), - contractID: profile.contractID, + contractID: partnerIDs[index], data: { groupContractID: currentGroupId, // TODO: We need to handle multiple groups and the possibility of not @@ -662,7 +665,7 @@ export default (sbp('sbp/selectors/register', { }, // For now, we assume that we're messaging someone which whom we // share a group - signingKeyId: sbp('chelonia/contract/suitableSigningKey', profile.contractID, [GIMessage.OP_ACTION_ENCRYPTED], ['sig'], undefined, ['gi.contracts/identity/joinDirectMessage']), + signingKeyId: sbp('chelonia/contract/suitableSigningKey', partnerIDs[index], [GIMessage.OP_ACTION_ENCRYPTED], ['sig'], undefined, ['gi.contracts/identity/joinDirectMessage']), innerSigningContractID: rootState.currentGroupId, hooks }) diff --git a/frontend/controller/actions/utils.js b/frontend/controller/actions/utils.js index 5ad5a3aa0..9741e2c92 100644 --- a/frontend/controller/actions/utils.js +++ b/frontend/controller/actions/utils.js @@ -251,9 +251,9 @@ export const encryptedNotification = ( } } -export async function createInvite ({ quantity = 1, creator, expires, invitee }: { - quantity: number, creator: string, expires: number, invitee?: string -}): Promise<{inviteKeyId: string; creator: string; invitee?: string; }> { +export async function createInvite ({ quantity = 1, creatorID, expires, invitee }: { + quantity: number, creatorID: string, expires: number, invitee?: string +}): Promise<{inviteKeyId: string; creatorID: string; invitee?: string; }> { const rootState = sbp('state/vuex/state') if (!rootState.currentGroupId) { @@ -308,7 +308,7 @@ export async function createInvite ({ quantity = 1, creator, expires, invitee }: return { inviteKeyId, - creator, + creatorID, invitee } } diff --git a/frontend/controller/router.js b/frontend/controller/router.js index 53791cdb1..4828ee60a 100644 --- a/frontend/controller/router.js +++ b/frontend/controller/router.js @@ -41,8 +41,8 @@ const loginGuard = { const inviteGuard = { guard: (to, from) => { - // ex: http://localhost:8000/app/join?groupId=21XWnNRE7vggw4ngGqmQz5D4vAwPYqcREhEkGop2mYZTKVkx8H&secret=5157 - return !(to.query.groupId && to.query.secret) + // ex: http://localhost:8000/app/join#groupId=21XWnNRE7vggw4ngGqmQz5D4vAwPYqcREhEkGop2mYZTKVkx8H&secret=5157 + return !(to.hash.includes('groupId=') && to.hash.includes('secret=')) }, redirect: (to, from) => ({ path: '/' }) } diff --git a/frontend/main.js b/frontend/main.js index 4842542d0..53c61a796 100644 --- a/frontend/main.js +++ b/frontend/main.js @@ -113,14 +113,14 @@ async function startApp () { allowedSelectors: [ 'namespace/lookup', 'state/vuex/state', 'state/vuex/settings', 'state/vuex/commit', 'state/vuex/getters', - 'chelonia/contract/sync', 'chelonia/contract/isSyncing', 'chelonia/contract/remove', 'controller/router', + 'chelonia/contract/sync', 'chelonia/contract/isSyncing', 'chelonia/contract/remove', 'chelonia/contract/cancelRemove', 'controller/router', 'chelonia/contract/suitableSigningKey', 'chelonia/contract/currentKeyIdByName', 'chelonia/storeSecretKeys', 'chelonia/crypto/keyId', 'chelonia/queueInvocation', 'chelonia/contract/waitingForKeyShareTo', + 'chelonia/contract/successfulKeySharesByContractID', 'gi.actions/chatroom/leave', - 'gi.actions/group/removeOurselves', - 'gi.actions/group/groupProfileUpdate', 'gi.actions/group/displayMincomeChangedPrompt', 'gi.actions/group/addChatRoom', + 'gi.actions/group/removeOurselves', 'gi.actions/group/groupProfileUpdate', 'gi.actions/group/displayMincomeChangedPrompt', 'gi.actions/group/addChatRoom', 'gi.actions/group/join', 'gi.actions/group/joinChatRoom', 'gi.actions/identity/addJoinDirectMessageKey', 'gi.actions/identity/leaveGroup', 'gi.notifications/emit', @@ -149,10 +149,10 @@ async function startApp () { errorNotification('handleEvent', e, message) } }, - processError: (e: Error, message: GIMessage) => { + processError: (e: Error, message: GIMessage, msgMeta: { signingKeyId: string, signingContractID: string, innerSigningKeyId: string, innerSigningContractID: string }) => { if (e.name === 'GIErrorIgnoreAndBan') { sbp('okTurtles.eventQueue/queueEvent', message.contractID(), [ - 'gi.actions/group/autobanUser', message, e + 'gi.actions/group/autobanUser', message, e, msgMeta ]) } // For now, we ignore all missing keys errors diff --git a/frontend/model/contracts/chatroom.js b/frontend/model/contracts/chatroom.js index 2aa7d9769..e229ccb1e 100644 --- a/frontend/model/contracts/chatroom.js +++ b/frontend/model/contracts/chatroom.js @@ -4,7 +4,7 @@ import { L, Vue } from '@common/common.js' import sbp from '@sbp/sbp' -import { objectOf, optional, string, arrayOf } from '~/frontend/model/contracts/misc/flowTyper.js' +import { objectOf, optional, string, arrayOf, actionRequireInnerSignature } from '~/frontend/model/contracts/misc/flowTyper.js' import { findForeignKeysByContractID, findKeyIdByName } from '~/shared/domains/chelonia/utils.js' import { CHATROOM_ACTIONS_PER_PAGE, @@ -18,14 +18,13 @@ import { MESSAGE_TYPES, POLL_STATUS } from './shared/constants.js' -import { createMessage, findMessageIdx, leaveChatRoom, makeMentionFromUsername } from './shared/functions.js' +import { createMessage, findMessageIdx, leaveChatRoom, makeMentionFromUserID } from './shared/functions.js' import { cloneDeep, merge } from './shared/giLodash.js' import { makeNotification } from './shared/nativeNotification.js' import { chatRoomAttributesType, messageType } from './shared/types.js' function createNotificationData ( notificationType: string, - moreParams: Object = {} ): Object { return { @@ -53,7 +52,7 @@ function setReadUntilWhileJoining ({ contractID, hash, createdDate }: { function messageReceivePostEffect ({ contractID, messageHash, datetime, text, - isDMOrMention, messageType, username, chatRoomName + isDMOrMention, messageType, memberID, chatRoomName }: { contractID: string, messageHash: string, @@ -61,7 +60,7 @@ function messageReceivePostEffect ({ text: string, messageType: string, isDMOrMention: boolean, - username: string, + memberID: string, chatRoomName: string }): void { if (sbp('chelonia/contract/isSyncing', contractID)) { @@ -108,16 +107,11 @@ sbp('chelonia/defineContract', { name: 'gi.contracts/chatroom', metadata: { validate: objectOf({ - createdDate: string, // action created date - username: string, // action creator - identityContractID: string // action creator identityContractID + createdDate: string // action created date }), async create () { - const { username, identityContractID } = sbp('state/vuex/state').loggedIn return { - createdDate: await fetchServerTime(), - username, - identityContractID + createdDate: await fetchServerTime() } } }, @@ -131,8 +125,8 @@ sbp('chelonia/defineContract', { chatRoomAttributes (state, getters) { return getters.currentChatRoomState.attributes || {} }, - chatRoomUsers (state, getters) { - return getters.currentChatRoomState.users || {} + chatRoomMembers (state, getters) { + return getters.currentChatRoomState.members || {} }, chatRoomLatestMessages (state, getters) { return getters.currentChatRoomState.messages || [] @@ -152,10 +146,9 @@ sbp('chelonia/defineContract', { maxDescriptionLength: CHATROOM_DESCRIPTION_LIMITS_IN_CHARS }, attributes: { - creator: meta.username, deletedDate: null }, - users: {}, + members: {}, messages: [] }, data) for (const key in initialState) { @@ -170,17 +163,20 @@ sbp('chelonia/defineContract', { } }, 'gi.contracts/chatroom/join': { - validate: objectOf({ - username: string // username of joining member - }), - process ({ data, meta, hash, height }, { state }) { - const { username } = data + validate: actionRequireInnerSignature(objectOf({ + memberID: optional(string) // user id of joining memberID + })), + process ({ data, meta, hash, height, contractID, innerSigningContractID }, { state }) { + const memberID = data.memberID || innerSigningContractID + if (!memberID) { + throw new Error('The new member must be given either explicitly or implcitly with an inner signature') + } if (!state.onlyRenderMessage) { - if (state.users[username]) { - throw new Error(`Can not join the chatroom which ${username} is already part of`) + if (state.members[memberID]) { + throw new Error(`Can not join the chatroom which ${memberID} is already part of`) } - Vue.set(state.users, username, { joinedDate: meta.createdDate }) + Vue.set(state.members, memberID, { joinedDate: meta.createdDate }) return } @@ -188,37 +184,37 @@ sbp('chelonia/defineContract', { return } - const notificationType = username === meta.username ? MESSAGE_NOTIFICATIONS.JOIN_MEMBER : MESSAGE_NOTIFICATIONS.ADD_MEMBER + const notificationType = memberID === innerSigningContractID ? MESSAGE_NOTIFICATIONS.JOIN_MEMBER : MESSAGE_NOTIFICATIONS.ADD_MEMBER const notificationData = createNotificationData( notificationType, - notificationType === MESSAGE_NOTIFICATIONS.ADD_MEMBER ? { username } : {} + notificationType === MESSAGE_NOTIFICATIONS.ADD_MEMBER ? { memberID, actorID: innerSigningContractID } : { memberID } ) - const newMessage = createMessage({ meta, hash, height, data: notificationData, state }) + const newMessage = createMessage({ meta, hash, height, data: notificationData, state, innerSigningContractID }) state.messages.push(newMessage) }, - sideEffect ({ data, contractID, hash, meta }, { state }) { + sideEffect ({ data, contractID, hash, meta, innerSigningContractID }, { state }) { if (state.onlyRenderMessage) return - sbp('chelonia/queueInvocation', contractID, async () => { + sbp('chelonia/queueInvocation', contractID, () => { const rootState = sbp('state/vuex/state') const state = rootState[contractID] + const memberID = data.memberID || innerSigningContractID - if (!state?.users?.[data.username]) { + if (!state?.members?.[memberID]) { return } const rootGetters = sbp('state/vuex/getters') - const { username } = data const loggedIn = sbp('state/vuex/state').loggedIn setReadUntilWhileJoining({ contractID, hash, createdDate: meta.createdDate }) - if (username === loggedIn.username) { + if (memberID === loggedIn.identityContractID) { // The join process is now complete, so we can remove this key if // it was set. This key is used to prevent us from calling `/remove` // when the join process is incomplete. See the comment on group.js // (joinChatRoom sideEffect) for a more detailed explanation of // what this does. - sbp('okTurtles.data/delete', `JOINING_CHATROOM-${contractID}-${username}`) + sbp('okTurtles.data/delete', `JOINING_CHATROOM-${contractID}-${memberID}`) if (state.attributes.type === CHATROOM_TYPES.DIRECT_MESSAGE) { // NOTE: To ignore scroll to the message of this hash @@ -228,35 +224,20 @@ sbp('chelonia/defineContract', { deletedDate: meta.createdDate }) } - const lookupResult = await Promise.allSettled( - Object.keys(state.users) - .filter((name) => - !rootGetters.ourContactProfiles[name] && name !== loggedIn.username) - .map(async (name) => await sbp('namespace/lookup', name).then((r) => { - if (!r) throw new Error('Cannot lookup username: ' + name) - return r - })) + + // subscribe to founder's IdentityContract & everyone else's + const profileIds = Object.keys(state.members).filter((id) => + id !== loggedIn.identityContractID && + !rootGetters.ourContactProfilesById[id] ) - const errors = lookupResult - .filter(({ status }) => status === 'rejected') - .map((r) => (r: any).reason) - await sbp('chelonia/contract/sync', - lookupResult - .filter(({ status }) => status === 'fulfilled') - .map((r) => (r: any).value) - ).catch(e => errors.push(e)) - if (errors.length) { - const msg = `Encountered ${errors.length} errors while joining a chatroom` - console.error(msg, errors) - throw new Error(msg) - } + sbp('chelonia/contract/sync', profileIds).catch((e) => { + console.error('Error while syncing other members\' contracts at chatroom join', e) + }) } else { - if (!rootGetters.ourContactProfiles[username]) { - const contractID = await sbp('namespace/lookup', username) - if (!contractID) { - throw new Error('Cannot lookup username: ' + username) - } - await sbp('chelonia/contract/sync', contractID) + if (!rootGetters.ourContactProfiles[memberID]) { + sbp('chelonia/contract/sync', memberID).catch((e) => { + console.error(`Error while syncing new memberID's contract ${memberID}`, e) + }) } } }).catch((e) => { @@ -265,10 +246,10 @@ sbp('chelonia/defineContract', { } }, 'gi.contracts/chatroom/rename': { - validate: objectOf({ + validate: actionRequireInnerSignature(objectOf({ name: string - }), - process ({ data, meta, hash, height }, { state }) { + })), + process ({ data, meta, hash, height, innerSigningContractID }, { state }) { Vue.set(state.attributes, 'name', data.name) if (!state.onlyRenderMessage) { @@ -276,7 +257,7 @@ sbp('chelonia/defineContract', { } const notificationData = createNotificationData(MESSAGE_NOTIFICATIONS.UPDATE_NAME, {}) - const newMessage = createMessage({ meta, hash, height, data: notificationData, state }) + const newMessage = createMessage({ meta, hash, height, data: notificationData, state, innerSigningContractID }) state.messages.push(newMessage) }, sideEffect ({ contractID, hash, meta }) { @@ -284,10 +265,10 @@ sbp('chelonia/defineContract', { } }, 'gi.contracts/chatroom/changeDescription': { - validate: objectOf({ + validate: actionRequireInnerSignature(objectOf({ description: string - }), - process ({ data, meta, hash, height }, { state }) { + })), + process ({ data, meta, hash, height, innerSigningContractID }, { state }) { Vue.set(state.attributes, 'description', data.description) if (!state.onlyRenderMessage) { @@ -297,7 +278,7 @@ sbp('chelonia/defineContract', { const notificationData = createNotificationData( MESSAGE_NOTIFICATIONS.UPDATE_DESCRIPTION, {} ) - const newMessage = createMessage({ meta, hash, height, data: notificationData, state }) + const newMessage = createMessage({ meta, hash, height, data: notificationData, state, innerSigningContractID }) state.messages.push(newMessage) }, sideEffect ({ contractID, hash, meta }) { @@ -306,21 +287,25 @@ sbp('chelonia/defineContract', { }, 'gi.contracts/chatroom/leave': { validate: objectOf({ - username: optional(string), // coming from the gi.contracts/group/leaveChatRoom - member: string // username to be removed + memberID: optional(string) // member to be removed }), - process ({ data, meta, hash, height, contractID }, { state }) { - const { member } = data - const isKicked = data.username && member !== data.username + process ({ data, meta, hash, height, contractID, innerSigningContractID }, { state }) { + const memberID = data.memberID || innerSigningContractID + if (!memberID) { + throw new Error('The removed member must be given either explicitly or implcitly with an inner signature') + } + // innerSigningContractID !== contractID is the special case of a member + // being removed using the group's CSK (usually when a member is removed) + const isKicked = innerSigningContractID && memberID !== innerSigningContractID if (!state.onlyRenderMessage) { - if (!state.users) { - console.error('Missing state.users: ' + JSON.stringify(state)) + if (!state.members) { + console.error('Missing state.members: ' + JSON.stringify(state)) } - if (!state.users[member]) { - throw new Error(`Can not leave the chatroom ${contractID} which ${member} is not part of`) + if (!state.members[memberID]) { + throw new Error(`Can not leave the chatroom ${contractID} which ${memberID} is not part of`) } - Vue.delete(state.users, member) + Vue.delete(state.members, memberID) return } @@ -329,32 +314,37 @@ sbp('chelonia/defineContract', { } const notificationType = !isKicked ? MESSAGE_NOTIFICATIONS.LEAVE_MEMBER : MESSAGE_NOTIFICATIONS.KICK_MEMBER - const notificationData = createNotificationData(notificationType, isKicked ? { username: member } : {}) + const notificationData = createNotificationData(notificationType, { memberID }) const newMessage = createMessage({ - meta: isKicked ? meta : { ...meta, username: member }, + meta, hash, height, data: notificationData, - state + state, + // Special case for a memberID being removed using the group's CSK + // This way, we show the 'Member left' notification instead of the + // 'kicked' notification + innerSigningContractID: !isKicked ? memberID : innerSigningContractID }) state.messages.push(newMessage) }, - sideEffect ({ data, hash, contractID, meta }, { state }) { + sideEffect ({ data, hash, contractID, meta, innerSigningContractID }, { state }) { if (state.onlyRenderMessage) return sbp('chelonia/queueInvocation', contractID, () => { const rootState = sbp('state/vuex/state') const state = rootState[contractID] + const memberID = data.memberID || innerSigningContractID - if (!state || !!state.users?.[data.member]) { + if (!state || !!state.members?.[data.memberID]) { return } - if (data.member === rootState.loggedIn.username) { + if (memberID === rootState.loggedIn.identityContractID) { // If we're in the process of joining this chatroom, don't call // /remove, as we're still waiting to be added to the chatroom. // See group.js (joinChatRoom sideEffect) for a more detailed // explanation of why we need this - if (!sbp('okTurtles.data/get', `JOINING_CHATROOM-${contractID}-${data.member}`)) { + if (!sbp('okTurtles.data/get', `JOINING_CHATROOM-${contractID}-${memberID}`)) { // NOTE: make sure *not* to await on this, since that can cause // a potential deadlock. See same warning in sideEffect for // 'gi.contracts/group/removeMember' @@ -370,26 +360,22 @@ sbp('chelonia/defineContract', { } } - const rootGetters = sbp('state/vuex/getters') - const userID = rootGetters.ourContactProfiles[data.member]?.contractID - if (userID) { - sbp('gi.contracts/chatroom/removeForeignKeys', contractID, userID, data.member, state) - } + sbp('gi.contracts/chatroom/removeForeignKeys', contractID, memberID, state) }).catch((e) => { console.error('[gi.contracts/chatroom/leave/sideEffect] Error at sideEffect', e?.message || e) }) } }, 'gi.contracts/chatroom/delete': { - validate: (data, { state, meta }) => { - if (state.attributes.creator !== meta.username) { + validate: actionRequireInnerSignature((_, { state, meta, message: { innerSigningContractID } }) => { + if (state.attributes.creatorID !== innerSigningContractID) { throw new TypeError(L('Only the channel creator can delete channel.')) } - }, - process ({ data, meta }, { state }) { + }), + process ({ meta }, { state }) { Vue.set(state.attributes, 'deletedDate', meta.createdDate) - for (const username in state.users) { - Vue.delete(state.users, username) + for (const memberID in state.members) { + Vue.delete(state.members, memberID) } }, sideEffect ({ meta, contractID }, { state }) { @@ -405,12 +391,12 @@ sbp('chelonia/defineContract', { } }, 'gi.contracts/chatroom/addMessage': { - validate: messageType, + validate: actionRequireInnerSignature(messageType), // NOTE: This function is 'reentrant' and may be called multiple times // for the same message and state. The `direction` attributes handles // these situations especially, and it's meant to mark sent-by-the-user // but not-yet-received-over-the-network messages. - process ({ direction, data, meta, hash, height }, { state }) { + process ({ direction, data, meta, hash, height, innerSigningContractID }, { state }) { // Exit early if we're only supposed to render messages. if (!state.onlyRenderMessage) { return @@ -421,22 +407,22 @@ sbp('chelonia/defineContract', { if (!existingMsg) { // If no existing message, simply add it to the messages array. const pending = direction === 'outgoing' - state.messages.push(createMessage({ meta, data, hash, height, state, pending })) + state.messages.push(createMessage({ meta, data, hash, height, state, pending, innerSigningContractID })) } else if (direction !== 'outgoing') { // If an existing message is found, it's no longer pending. delete existingMsg.pending } }, - sideEffect ({ contractID, hash, height, meta, data }, { state, getters }) { + sideEffect ({ contractID, hash, height, meta, data, innerSigningContractID }, { state, getters }) { setReadUntilWhileJoining({ contractID, hash, createdDate: meta.createdDate }) - const me = sbp('state/vuex/state').loggedIn.username + const me = sbp('state/vuex/state').loggedIn.identityContractID - if (me === meta.username && data.type !== MESSAGE_TYPES.INTERACTIVE) { + if (me === innerSigningContractID && data.type !== MESSAGE_TYPES.INTERACTIVE) { return } - const newMessage = createMessage({ meta, data, hash, height, state }) - const mentions = makeMentionFromUsername(me) + const newMessage = createMessage({ meta, data, hash, height, state, innerSigningContractID }) + const mentions = makeMentionFromUserID(me) const isMentionedMe = data.type === MESSAGE_TYPES.TEXT && (newMessage.text.includes(mentions.me) || newMessage.text.includes(mentions.all)) @@ -447,24 +433,24 @@ sbp('chelonia/defineContract', { text: newMessage.text, isDMOrMention: isMentionedMe || getters.chatRoomAttributes.type === CHATROOM_TYPES.DIRECT_MESSAGE, messageType: data.type, - username: meta.username, + memberID: innerSigningContractID, chatRoomName: getters.chatRoomAttributes.name }) } }, 'gi.contracts/chatroom/editMessage': { - validate: objectOf({ + validate: actionRequireInnerSignature(objectOf({ hash: string, createdDate: string, text: string - }), - process ({ data, meta }, { state }) { + })), + process ({ data, meta, innerSigningContractID }, { state }) { // NOTE: edit message whose type is MESSAGE_TYPES.TEXT if (!state.onlyRenderMessage) { return } const msgIndex = findMessageIdx(data.hash, state.messages) - if (msgIndex >= 0 && meta.username === state.messages[msgIndex].from) { + if (msgIndex >= 0 && innerSigningContractID === state.messages[msgIndex].from) { state.messages[msgIndex].text = data.text state.messages[msgIndex].updatedDate = meta.createdDate if (state.onlyRenderMessage && state.messages[msgIndex].pending) { @@ -472,16 +458,16 @@ sbp('chelonia/defineContract', { } } }, - sideEffect ({ contractID, hash, meta, data }, { getters }) { + sideEffect ({ contractID, hash, meta, data, innerSigningContractID }, { getters }) { const rootState = sbp('state/vuex/state') - const me = rootState.loggedIn.username - if (me === meta.username || getters.chatRoomAttributes.type === CHATROOM_TYPES.DIRECT_MESSAGE) { + const me = rootState.loggedIn.identityContractID + if (me === innerSigningContractID || getters.chatRoomAttributes.type === CHATROOM_TYPES.DIRECT_MESSAGE) { return } const isAlreadyAdded = !!sbp('state/vuex/getters') .chatRoomUnreadMessages(contractID).find(m => m.messageHash === data.hash) - const mentions = makeMentionFromUsername(me) + const mentions = makeMentionFromUserID(me) const isMentionedMe = data.text.includes(mentions.me) || data.text.includes(mentions.all) if (!isAlreadyAdded) { @@ -498,7 +484,7 @@ sbp('chelonia/defineContract', { text: data.text, isDMOrMention: isMentionedMe, messageType: MESSAGE_TYPES.TEXT, - username: meta.username, + memberID: innerSigningContractID, chatRoomName: getters.chatRoomAttributes.name }) } else if (!isMentionedMe) { @@ -510,8 +496,8 @@ sbp('chelonia/defineContract', { } }, 'gi.contracts/chatroom/deleteMessage': { - validate: objectOf({ hash: string }), - process ({ data, meta }, { state }) { + validate: actionRequireInnerSignature(objectOf({ hash: string })), + process ({ data, meta, innerSigningContractID }, { state }) { if (!state.onlyRenderMessage) { return } @@ -523,15 +509,15 @@ sbp('chelonia/defineContract', { for (const message of state.messages) { if (message.replyingMessage?.hash === data.hash) { message.replyingMessage.hash = null - message.replyingMessage.text = L('Original message was removed by {username}', { - username: makeMentionFromUsername(meta.username).me + message.replyingMessage.text = L('Original message was removed by {user}', { + user: makeMentionFromUserID(innerSigningContractID).me }) } } }, - sideEffect ({ data, contractID, hash, meta }) { + sideEffect ({ data, contractID, hash, meta, innerSigningContractID }) { const rootState = sbp('state/vuex/state') - const me = rootState.loggedIn.username + const me = rootState.loggedIn.identityContractID if (rootState.chatRoomScrollPosition[contractID] === data.hash) { sbp('state/vuex/commit', 'setChatRoomScrollPosition', { @@ -548,7 +534,7 @@ sbp('chelonia/defineContract', { }) } - if (me === meta.username) { + if (me === innerSigningContractID) { return } @@ -561,11 +547,11 @@ sbp('chelonia/defineContract', { } }, 'gi.contracts/chatroom/makeEmotion': { - validate: objectOf({ + validate: actionRequireInnerSignature(objectOf({ hash: string, emoticon: string - }), - process ({ data, meta, contractID }, { state }) { + })), + process ({ data, meta, contractID, innerSigningContractID }, { state }) { if (!state.onlyRenderMessage) { return } @@ -574,7 +560,7 @@ sbp('chelonia/defineContract', { if (msgIndex >= 0) { let emoticons = cloneDeep(state.messages[msgIndex].emoticons || {}) if (emoticons[emoticon]) { - const alreadyAdded = emoticons[emoticon].indexOf(meta.username) + const alreadyAdded = emoticons[emoticon].indexOf(innerSigningContractID) if (alreadyAdded >= 0) { emoticons[emoticon].splice(alreadyAdded, 1) if (!emoticons[emoticon].length) { @@ -584,10 +570,10 @@ sbp('chelonia/defineContract', { } } } else { - emoticons[emoticon].push(meta.username) + emoticons[emoticon].push(innerSigningContractID) } } else { - emoticons[emoticon] = [meta.username] + emoticons[emoticon] = [innerSigningContractID] } if (emoticons) { Vue.set(state.messages[msgIndex], 'emoticons', emoticons) @@ -598,12 +584,12 @@ sbp('chelonia/defineContract', { } }, 'gi.contracts/chatroom/voteOnPoll': { - validate: objectOf({ + validate: actionRequireInnerSignature(objectOf({ hash: string, votes: arrayOf(string), votesAsString: string - }), - process ({ data, meta, hash, height }, { state }) { + })), + process ({ data, meta, hash, height, innerSigningContractID }, { state }) { if (!state.onlyRenderMessage) { return } @@ -620,7 +606,7 @@ sbp('chelonia/defineContract', { const foundOpt = optsCopy.find(x => x.id === optId) if (foundOpt) { - foundOpt.voted.push(meta.username) + foundOpt.voted.push(innerSigningContractID) votedOptNames.push(`"${foundOpt.value}"`) } }) @@ -636,7 +622,7 @@ sbp('chelonia/defineContract', { pollMessageHash: data.hash } ) - const newMessage = createMessage({ meta, hash, height, data: notificationData, state }) + const newMessage = createMessage({ meta, hash, height, data: notificationData, state, innerSigningContractID }) state.messages.push(newMessage) }, sideEffect ({ contractID, hash, meta }) { @@ -644,12 +630,12 @@ sbp('chelonia/defineContract', { } }, 'gi.contracts/chatroom/changeVoteOnPoll': { - validate: objectOf({ + validate: actionRequireInnerSignature(objectOf({ hash: string, votes: arrayOf(string), votesAsString: string - }), - process ({ data, meta, hash, height }, { state }) { + })), + process ({ data, meta, hash, height, innerSigningContractID }, { state }) { if (!state.onlyRenderMessage) { return } @@ -657,7 +643,7 @@ sbp('chelonia/defineContract', { const msgIndex = findMessageIdx(data.hash, state.messages) if (msgIndex >= 0) { - const me = meta.username + const me = innerSigningContractID const myUpdatedVotes = data.votes const pollData = state.messages[msgIndex].pollData const optsCopy = cloneDeep(pollData.options) @@ -688,7 +674,7 @@ sbp('chelonia/defineContract', { pollMessageHash: data.hash } ) - const newMessage = createMessage({ meta, hash, height, data: notificationData, state }) + const newMessage = createMessage({ meta, hash, height, data: notificationData, state, innerSigningContractID }) state.messages.push(newMessage) }, sideEffect ({ contractID, hash, meta }) { @@ -696,9 +682,9 @@ sbp('chelonia/defineContract', { } }, 'gi.contracts/chatroom/closePoll': { - validate: objectOf({ + validate: actionRequireInnerSignature(objectOf({ hash: string - }), + })), process ({ data }, { state }) { if (!state.onlyRenderMessage) { return @@ -738,8 +724,8 @@ sbp('chelonia/defineContract', { console.warn(`rotateKeys: ${e.name} thrown during queueEvent to ${contractID}:`, e) }) }, - 'gi.contracts/chatroom/removeForeignKeys': (contractID, userID, member, state) => { - const keyIds = findForeignKeysByContractID(state, userID) + 'gi.contracts/chatroom/removeForeignKeys': (contractID, memberID, state) => { + const keyIds = findForeignKeysByContractID(state, memberID) if (!keyIds?.length) return @@ -756,7 +742,7 @@ sbp('chelonia/defineContract', { hooks: { preSendCheck: (_, state) => { // Only issue OP_KEY_DEL for non-members - return !state.users?.[member] + return !state.members?.[memberID] } } }).catch(e => { diff --git a/frontend/model/contracts/group.js b/frontend/model/contracts/group.js index 407f822dd..4d94d0bc9 100644 --- a/frontend/model/contracts/group.js +++ b/frontend/model/contracts/group.js @@ -16,11 +16,11 @@ import { import { paymentStatusType, paymentType, PAYMENT_COMPLETED } from './shared/payments/index.js' import { createPaymentInfo, paymentHashesFromPaymentPeriod } from './shared/functions.js' import { cloneDeep, deepEqualJSONType, omit, merge } from './shared/giLodash.js' -import { addTimeToDate, dateToPeriodStamp, compareISOTimestamps, dateFromPeriodStamp, isPeriodStamp, comparePeriodStamps, dateIsWithinPeriod, DAYS_MILLIS, periodStampsForDate, plusOnePeriodLength } from './shared/time.js' +import { addTimeToDate, dateToPeriodStamp, dateFromPeriodStamp, isPeriodStamp, comparePeriodStamps, dateIsWithinPeriod, DAYS_MILLIS, periodStampsForDate, plusOnePeriodLength } from './shared/time.js' import { unadjustedDistribution, adjustedDistribution } from './shared/distribution/distribution.js' import currencies from './shared/currencies.js' -import { inviteType, chatRoomAttributesType } from './shared/types.js' -import { arrayOf, objectOf, objectMaybeOf, optional, string, number, boolean, object, unionOf, tupleOf } from '~/frontend/model/contracts/misc/flowTyper.js' +import { inviteType, groupChatRoomAttributesType } from './shared/types.js' +import { arrayOf, objectOf, objectMaybeOf, optional, string, number, boolean, object, unionOf, tupleOf, actionRequireInnerSignature } from '~/frontend/model/contracts/misc/flowTyper.js' import { findKeyIdByName, findForeignKeysByContractID } from '~/shared/domains/chelonia/utils.js' import { REMOVE_NOTIFICATION } from '~/frontend/model/notifications/mutationKeys.js' @@ -33,10 +33,11 @@ function vueFetchInitKV (obj: Object, key: string, initialValue: any): any { return value } -function initGroupProfile (joinedDate: string) { +function initGroupProfile (joinedDate: string, joinedHeight: number) { return { globalUsername: '', // TODO: this? e.g. groupincome:greg / namecoin:bob / ens:alice joinedDate, + joinedHeight, lastLoggedIn: joinedDate, nonMonetaryContributions: [], status: PROFILE_STATUS.ACTIVE, @@ -58,7 +59,7 @@ function initPaymentPeriod ({ meta, getters }) { // TODO: for the currency change proposal, have it update the mincomeExchangeRate // using .mincomeExchangeRate *= proposal.exchangeRate mincomeExchangeRate: 1, // modified by proposals to change mincome currency - paymentsFrom: {}, // fromUser => toUser => Array + paymentsFrom: {}, // fromMemberID => toMemberID => Array // snapshot of adjusted distribution after each completed payment // yes, it is possible a payment began in one period and completed in another // in which case lastAdjustedDistribution for the previous period will be updated @@ -106,9 +107,9 @@ function initGroupStreaks () { lastStreakPeriod: null, fullMonthlyPledges: 0, // group streaks for 100% monthly payments - every pledging members have completed their payments fullMonthlySupport: 0, // group streaks for 100% monthly supports - total amount of pledges done is equal to the group's monthly contribution goal - onTimePayments: {}, // { username: number, ... } - missedPayments: {}, // { username: number, ... } - noVotes: {} // { username: number, ... } + onTimePayments: {}, // { memberID: number, ... } + missedPayments: {}, // { memberID: number, ... } + noVotes: {} // { memberID: number, ... } } } @@ -146,23 +147,24 @@ function updateAdjustedDistribution ({ period, getters }) { dueOn: getters.dueDateForPeriod(period) }).filter(todo => { // only return todos for active members - return getters.groupProfile(todo.to).status === PROFILE_STATUS.ACTIVE + return getters.groupProfile(todo.toMemberID).status === PROFILE_STATUS.ACTIVE }) } } -function memberLeaves ({ username, dateLeft }, { contractID, meta, state, getters }) { - if (!state.profiles[meta.username] || state.profiles[meta.username].status !== PROFILE_STATUS.ACTIVE) { - throw new Error(`[gi.contracts/group memberLeaves] Can't remove non-exisiting member ${meta.username}`) +function memberLeaves ({ memberID, dateLeft, heightLeft }, { contractID, meta, state, getters }) { + if (!state.profiles[memberID] || state.profiles[memberID].status !== PROFILE_STATUS.ACTIVE) { + throw new Error(`[gi.contracts/group memberLeaves] Can't remove non-exisiting member ${memberID}`) } - state.profiles[username].status = PROFILE_STATUS.REMOVED - state.profiles[username].departedDate = dateLeft + state.profiles[memberID].status = PROFILE_STATUS.REMOVED + state.profiles[memberID].departedDate = dateLeft + state.profiles[memberID].departedHeight = heightLeft // remove any todos for this member from the adjusted distribution updateCurrentDistribution({ contractID, meta, state, getters }) Object.keys(state.chatRooms).forEach((chatroomID) => { - removeGroupChatroomProfile(state, chatroomID, username) + removeGroupChatroomProfile(state, chatroomID, memberID) }) // When a member is leaving, we need to mark the CSK and the CEK as needing @@ -182,7 +184,7 @@ function memberLeaves ({ username, dateLeft }, { contractID, meta, state, getter Vue.set(state._volatile.pendingKeyRevocations, CEKid, true) } -function isActionYoungerThanUser (actionMeta: Object, userProfile: ?Object): boolean { +function isActionYoungerThanUser (height: number, userProfile: ?Object): boolean { // A util function that checks if an action (or event) in a group occurred after a particular user joined a group. // This is used mostly for checking if a notification should be sent for that user or not. // e.g.) user-2 who joined a group later than user-1 (who is the creator of the group) doesn't need to receive @@ -192,7 +194,7 @@ function isActionYoungerThanUser (actionMeta: Object, userProfile: ?Object): boo if (!userProfile) { return false } - return compareISOTimestamps(actionMeta.createdDate, userProfile.joinedDate) > 0 + return userProfile.joinedHeight < height } function updateGroupStreaks ({ state, getters }) { @@ -216,7 +218,7 @@ function updateGroupStreaks ({ state, getters }) { payments: getters.paymentsForPeriod(cPeriod), dueOn: getters.dueDateForPeriod(cPeriod) }).filter(todo => { - return getters.groupProfile(todo.to).status === PROFILE_STATUS.ACTIVE + return getters.groupProfile(todo.toMemberID).status === PROFILE_STATUS.ACTIVE }) // --- update 'fullMonthlyPledgesCount' streak --- @@ -234,8 +236,8 @@ function updateGroupStreaks ({ state, getters }) { const thisPeriodPaymentDetails = getters.paymentsForPeriod(cPeriod) const thisPeriodHaveNeeds = thisPeriodPayments?.haveNeedsSnapshot || getters.haveNeedsForThisPeriod(cPeriod) - const filterMyItems = (array, username) => array.filter(item => item.from === username) - const isPledgingMember = username => thisPeriodHaveNeeds.some(entry => entry.name === username && entry.haveNeed > 0) + const filterMyItems = (array, member) => array.filter(item => item.fromMemberID === member) + const isPledgingMember = member => thisPeriodHaveNeeds.some(entry => entry.memberID === member && entry.haveNeed > 0) // --- update 'fullMonthlySupport' streak. --- const totalContributionGoal = thisPeriodHaveNeeds.reduce( @@ -253,19 +255,19 @@ function updateGroupStreaks ({ state, getters }) { ) // --- update 'onTimePayments' & 'missedPayments' streaks for 'pledging' members of the group --- - for (const username in getters.groupProfiles) { - if (!isPledgingMember(username)) continue + for (const memberID in getters.groupProfiles) { + if (!isPledgingMember(memberID)) continue // 1) update 'onTimePayments' - const myMissedPaymentsInThisPeriod = filterMyItems(thisPeriodDistribution, username) - const userCurrentStreak = vueFetchInitKV(streaks.onTimePayments, username, 0) + const myMissedPaymentsInThisPeriod = filterMyItems(thisPeriodDistribution, memberID) + const userCurrentStreak = vueFetchInitKV(streaks.onTimePayments, memberID, 0) Vue.set( streaks.onTimePayments, - username, + memberID, noPaymentsAtAll ? 0 : myMissedPaymentsInThisPeriod.length === 0 && - filterMyItems(thisPeriodPaymentDetails, username).every(p => p.isLate === false) + filterMyItems(thisPeriodPaymentDetails, memberID).every(p => p.isLate === false) // check-1. if the user made all the pledgeds assigned to them in this period. // check-2. all those payments by the user were done on time. ? userCurrentStreak + 1 @@ -273,10 +275,10 @@ function updateGroupStreaks ({ state, getters }) { ) // 2) update 'missedPayments' - const myMissedPaymentsStreak = vueFetchInitKV(streaks.missedPayments, username, 0) + const myMissedPaymentsStreak = vueFetchInitKV(streaks.missedPayments, memberID, 0) Vue.set( streaks.missedPayments, - username, + memberID, noPaymentsAtAll ? myMissedPaymentsStreak + 1 : myMissedPaymentsInThisPeriod.length >= 1 @@ -287,9 +289,9 @@ function updateGroupStreaks ({ state, getters }) { } const removeGroupChatroomProfile = (state, chatRoomID, member) => { - Vue.set(state.chatRooms[chatRoomID], 'users', + Vue.set(state.chatRooms[chatRoomID], 'members', Object.fromEntries( - Object.entries(state.chatRooms[chatRoomID].users) + Object.entries(state.chatRooms[chatRoomID].members) .map(([memberKey, profile]) => { if (memberKey === member && (profile: any)?.status === PROFILE_STATUS.ACTIVE) { return [memberKey, { ...profile, status: PROFILE_STATUS.REMOVED }] @@ -300,12 +302,12 @@ const removeGroupChatroomProfile = (state, chatRoomID, member) => { ) } -const leaveChatRoomAction = (state, { chatRoomID, member }, meta, leavingGroup) => { - const sendingData = leavingGroup - ? { member: member } - : { member: member, username: meta.username } +const leaveChatRoomAction = (state, chatRoomID, memberID, actorID, leavingGroup) => { + const sendingData = leavingGroup || actorID !== memberID + ? { memberID } + : {} - if (state?.chatRooms?.[chatRoomID]?.users?.[member]?.status !== PROFILE_STATUS.REMOVED) { + if (state?.chatRooms?.[chatRoomID]?.members?.[memberID]?.status !== PROFILE_STATUS.REMOVED) { return } @@ -337,7 +339,7 @@ const leaveChatRoomAction = (state, { chatRoomID, member }, meta, leavingGroup) extraParams.innerSigningContractID = null } - return sbp('gi.actions/chatroom/leave', { + sbp('gi.actions/chatroom/leave', { contractID: chatRoomID, data: sendingData, ...extraParams @@ -353,35 +355,43 @@ const leaveChatRoomAction = (state, { chatRoomID, member }, meta, leavingGroup) return } throw e + }).catch((e) => { + console.error('[gi.contracts/group] Error sending chatroom leave action', e) }) } -const leaveAllChatRoomsUponLeaving = (state, member, meta) => { +const leaveAllChatRoomsUponLeaving = (state, memberID, actorID) => { const chatRooms = state.chatRooms return Promise.all(Object.keys(chatRooms) - .filter(cID => chatRooms[cID].users?.[member]?.status === PROFILE_STATUS.REMOVED) - .map((chatRoomID) => leaveChatRoomAction(state, { + .filter(cID => chatRooms[cID].members?.[memberID]?.status === PROFILE_STATUS.REMOVED) + .map((chatRoomID) => leaveChatRoomAction( + state, chatRoomID, - member - }, meta, true)) + memberID, + actorID, + true + )) ) } +export const actionRequireActiveMember = (next: Function): Function => (data, props) => { + const innerSigningContractID = props.message.innerSigningContractID + if (!innerSigningContractID || innerSigningContractID === props.contractID) { + throw new Error('Missing inner signature') + } + return next(data, props) +} + sbp('chelonia/defineContract', { name: 'gi.contracts/group', metadata: { validate: objectOf({ - createdDate: string, - username: string, - identityContractID: string + createdDate: string }), async create () { - const { username, identityContractID } = sbp('state/vuex/state').loggedIn return { - createdDate: await fetchServerTime(), - username, - identityContractID + createdDate: await fetchServerTime() } } }, @@ -404,35 +414,35 @@ sbp('chelonia/defineContract', { return getters.currentGroupState.settings || {} }, profileActive (state, getters) { - return username => { + return member => { const profiles = getters.currentGroupState.profiles - return profiles?.[username]?.status === PROFILE_STATUS.ACTIVE + return profiles?.[member]?.status === PROFILE_STATUS.ACTIVE } }, pendingAccept (state, getters) { - return username => { + return member => { const profiles = getters.currentGroupState.profiles - return profiles?.[username]?.status === PROFILE_STATUS.PENDING + return profiles?.[member]?.status === PROFILE_STATUS.PENDING } }, groupProfile (state, getters) { - return username => { + return member => { const profiles = getters.currentGroupState.profiles - return profiles && profiles[username] + return profiles && profiles[member] } }, groupProfiles (state, getters) { const profiles = {} - for (const username in (getters.currentGroupState.profiles || {})) { - const profile = getters.groupProfile(username) + for (const member in (getters.currentGroupState.profiles || {})) { + const profile = getters.groupProfile(member) if (profile.status === PROFILE_STATUS.ACTIVE) { - profiles[username] = profile + profiles[member] = profile } } return profiles }, groupCreatedDate (state, getters) { - const creator = getters.groupSettings.groupCreator + const creator = getters.groupSettings.groupCreatorID return getters.groupProfile(creator).joinedDate }, groupMincomeAmount (state, getters) { @@ -458,7 +468,7 @@ sbp('chelonia/defineContract', { } return keys }, - // paymentTotalFromUserToUser (state, getters) { + // paymentTotalfromMembertoMemberID (state, getters) { // // this code was removed in https://github.com/okTurtles/group-income/pull/1691 // // because it was unused. feel free to bring it back if needed. // }, @@ -506,11 +516,11 @@ sbp('chelonia/defineContract', { } } }, - groupMembersByUsername (state, getters) { + groupMembersByContractID (state, getters) { return Object.keys(getters.groupProfiles) }, groupMembersCount (state, getters) { - return getters.groupMembersByUsername.length + return getters.groupMembersByContractID.length }, groupMembersPending (state, getters) { const invites = getters.currentGroupState.invites @@ -519,10 +529,11 @@ sbp('chelonia/defineContract', { for (const inviteKeyId in invites) { if ( vmInvites[inviteKeyId].status === INVITE_STATUS.VALID && - invites[inviteKeyId].creator !== INVITE_INITIAL_CREATOR + invites[inviteKeyId].creatorID !== INVITE_INITIAL_CREATOR ) { - pendingMembers[invites[inviteKeyId].invitee] = { - invitedBy: invites[inviteKeyId].creator, + pendingMembers[inviteKeyId] = { + displayName: invites[inviteKeyId].invitee, + invitedBy: invites[inviteKeyId].creatorID, expires: vmInvites[inviteKeyId].expires } } @@ -585,10 +596,10 @@ sbp('chelonia/defineContract', { // make sure that this function also handles userExitsGroupEvent const groupProfiles = getters.groupProfiles // TODO: these should use the haveNeeds for the specific period's distribution period const haveNeeds = [] - for (const username in groupProfiles) { - const { incomeDetailsType, joinedDate } = groupProfiles[username] + for (const memberID in groupProfiles) { + const { incomeDetailsType, joinedDate } = groupProfiles[memberID] if (incomeDetailsType) { - const amount = groupProfiles[username][incomeDetailsType] + const amount = groupProfiles[memberID][incomeDetailsType] const haveNeed = incomeDetailsType === 'incomeAmount' ? amount - getters.groupMincomeAmount : amount // construct 'when' this way in case we ever use a pro-rated algorithm let when = dateFromPeriodStamp(currentPeriod).toISOString() @@ -599,7 +610,7 @@ sbp('chelonia/defineContract', { })) { when = joinedDate } - haveNeeds.push({ name: username, haveNeed, when }) + haveNeeds.push({ memberID, haveNeed, when }) } } return haveNeeds @@ -662,11 +673,10 @@ sbp('chelonia/defineContract', { const initialState = merge({ payments: {}, paymentsByPeriod: {}, - thankYousFrom: {}, // { fromUser1: { toUser1: msg1, toUser2: msg2, ... }, fromUser2: {}, ... } + thankYousFrom: {}, // { fromMember1: { toMember1: msg1, toMember2: msg2, ... }, fromMember2: {}, ... } invites: {}, proposals: {}, // hashes => {} TODO: this, see related TODOs in GroupProposal settings: { - groupCreator: meta.username, distributionPeriodLength: 30 * DAYS_MILLIS, inviteExpiryOnboarding: INVITE_EXPIRES_IN_DAYS.ON_BOARDING, inviteExpiryProposal: INVITE_EXPIRES_IN_DAYS.PROPOSAL, @@ -716,10 +726,10 @@ sbp('chelonia/defineContract', { } }, 'gi.contracts/group/payment': { - validate: objectMaybeOf({ + validate: actionRequireActiveMember(objectMaybeOf({ // TODO: how to handle donations to okTurtles? // TODO: how to handle payments to groups or users outside of this group? - toUser: string, + toMemberID: string, amount: number, currencyFromTo: tupleOf(string, string), // must be one of the keys in currencies.js (e.g. USD, EUR, etc.) TODO: handle old clients not having one of these keys, see OP_PROTOCOL_UPGRADE https://github.com/okTurtles/group-income/issues/603 // multiply 'amount' by 'exchangeRate', which must always be @@ -732,8 +742,8 @@ sbp('chelonia/defineContract', { paymentType: paymentType, details: optional(object), memo: optional(string) - }), - process ({ data, meta, hash, contractID }, { state, getters }) { + })), + process ({ data, meta, hash, contractID, height, innerSigningContractID }, { state, getters }) { if (data.status === PAYMENT_COMPLETED) { console.error(`payment: payment ${hash} cannot have status = 'completed'!`, { data, meta, hash }) throw new Errors.GIErrorIgnoreAndBan('payments cannot be instantly completed!') @@ -741,28 +751,30 @@ sbp('chelonia/defineContract', { Vue.set(state.payments, hash, { data: { ...data, + fromMemberID: innerSigningContractID, groupMincome: getters.groupMincomeAmount }, + height, meta, history: [[meta.createdDate, hash]] }) const { paymentsFrom } = initFetchPeriodPayments({ contractID, meta, state, getters }) - const fromUser = vueFetchInitKV(paymentsFrom, meta.username, {}) - const toUser = vueFetchInitKV(fromUser, data.toUser, []) - toUser.push(hash) + const fromMemberID = vueFetchInitKV(paymentsFrom, innerSigningContractID, {}) + const toMemberID = vueFetchInitKV(fromMemberID, data.toMemberID, []) + toMemberID.push(hash) // TODO: handle completed payments here too! (for manual payment support) } }, 'gi.contracts/group/paymentUpdate': { - validate: objectMaybeOf({ + validate: actionRequireActiveMember(objectMaybeOf({ paymentHash: string, updatedProperties: objectMaybeOf({ status: paymentStatusType, details: object, memo: string }) - }), - process ({ data, meta, hash, contractID }, { state, getters }) { + })), + process ({ data, meta, hash, contractID, innerSigningContractID }, { state, getters }) { // TODO: we don't want to keep a history of all payments in memory all the time // https://github.com/okTurtles/group-income/issues/426 const payment = state.payments[data.paymentHash] @@ -773,8 +785,8 @@ sbp('chelonia/defineContract', { throw new Errors.GIErrorIgnoreAndBan('paymentUpdate without existing payment') } // if the payment is being modified by someone other than the person who sent or received it, throw an exception - if (meta.username !== payment.meta.username && meta.username !== payment.data.toUser) { - console.error(`paymentUpdate: bad username ${meta.username} != ${payment.meta.username} != ${payment.data.username}`, { data, meta, hash }) + if (innerSigningContractID !== payment.data.fromMemberID && innerSigningContractID !== payment.data.toMemberID) { + console.error(`paymentUpdate: bad member ${innerSigningContractID} != ${payment.data.fromMemberID} != ${payment.data.toMemberID}`, { data, meta, hash }) throw new Errors.GIErrorIgnoreAndBan('paymentUpdate from bad user!') } payment.history.push([meta.createdDate, hash]) @@ -797,16 +809,16 @@ sbp('chelonia/defineContract', { state.totalPledgeAmount = currentTotalPledgeAmount + payment.data.amount } }, - sideEffect ({ meta, contractID, data }, { state, getters }) { + sideEffect ({ meta, contractID, data, innerSigningContractID }, { state, getters }) { if (data.updatedProperties.status === PAYMENT_COMPLETED) { const { loggedIn } = sbp('state/vuex/state') const payment = state.payments[data.paymentHash] - if (loggedIn.username === payment.data.toUser) { + if (loggedIn.identityContractID === payment.data.toMemberID) { sbp('gi.notifications/emit', 'PAYMENT_RECEIVED', { createdDate: meta.createdDate, groupID: contractID, - creator: meta.username, + creatorID: innerSigningContractID, paymentHash: data.paymentHash, amount: getters.withGroupCurrency(payment.data.amount) }) @@ -815,31 +827,29 @@ sbp('chelonia/defineContract', { } }, 'gi.contracts/group/sendPaymentThankYou': { - validate: objectOf({ - fromUser: string, - toUser: string, + validate: actionRequireActiveMember(objectOf({ + toMemberID: string, memo: string - }), - process ({ data }, { state }) { - const fromUser = vueFetchInitKV(state.thankYousFrom, data.fromUser, {}) - Vue.set(fromUser, data.toUser, data.memo) + })), + process ({ data, innerSigningContractID }, { state }) { + const fromMemberID = vueFetchInitKV(state.thankYousFrom, innerSigningContractID, {}) + Vue.set(fromMemberID, data.toMemberID, data.memo) }, - sideEffect ({ contractID, meta, data }) { + sideEffect ({ contractID, meta, data, innerSigningContractID }) { const { loggedIn } = sbp('state/vuex/state') - if (data.toUser === loggedIn.username) { + if (data.toMemberID === loggedIn.identityContractID) { sbp('gi.notifications/emit', 'PAYMENT_THANKYOU_SENT', { createdDate: meta.createdDate, groupID: contractID, - creator: meta.username, // username of the from user. to be used with sbp('namespace/lookup') in 'AvatarUser.vue' - fromUser: data.fromUser, // display name of the from user - toUser: data.toUser + fromMemberID: innerSigningContractID, + toMemberID: data.toMemberID }) } } }, 'gi.contracts/group/proposal': { - validate: (data, { state, meta }) => { + validate: actionRequireActiveMember((data, { state, meta }) => { objectOf({ proposalType: proposalType, proposalData: object, // data for Vue widgets @@ -862,12 +872,14 @@ sbp('chelonia/defineContract', { // TODO - verify if type of proposal already exists (SETTING_CHANGE). } - }, - process ({ data, meta, hash }, { state }) { + }), + process ({ data, meta, hash, height, innerSigningContractID }, { state }) { Vue.set(state.proposals, hash, { data, meta, - votes: { [meta.username]: VOTE_FOR }, + height, + creatorID: innerSigningContractID, + votes: { [innerSigningContractID]: VOTE_FOR }, status: STATUS_OPEN, notifiedBeforeExpire: false, payload: null // set later by group/proposalVote @@ -876,7 +888,7 @@ sbp('chelonia/defineContract', { // TODO: create a global timer to auto-pass/archive expired votes // make sure to set that proposal's status as STATUS_EXPIRED if it's expired }, - sideEffect ({ contractID, meta, data }, { getters }) { + sideEffect ({ contractID, meta, data, height, innerSigningContractID }, { getters }) { const { loggedIn } = sbp('state/vuex/state') const typeToSubTypeMap = { [PROPOSAL_INVITE_MEMBER]: 'ADD_MEMBER', @@ -889,33 +901,33 @@ sbp('chelonia/defineContract', { [PROPOSAL_GENERIC]: 'GENERIC' } - const myProfile = getters.groupProfile(loggedIn.username) + const myProfile = getters.groupProfile(loggedIn.identityContractID) - if (isActionYoungerThanUser(meta, myProfile)) { + if (isActionYoungerThanUser(height, myProfile)) { sbp('gi.notifications/emit', 'NEW_PROPOSAL', { createdDate: meta.createdDate, groupID: contractID, - creator: meta.username, + creatorID: innerSigningContractID, subtype: typeToSubTypeMap[data.proposalType] }) } } }, 'gi.contracts/group/proposalVote': { - validate: objectOf({ + validate: actionRequireActiveMember(objectOf({ proposalHash: string, vote: string, passPayload: optional(unionOf(object, string)) // TODO: this, somehow we need to send an OP_KEY_ADD GIMessage to add a generated once-only writeonly message public key to the contract, and (encrypted) include the corresponding invite link, also, we need all clients to verify that this message/operation was valid to prevent a hacked client from adding arbitrary OP_KEY_ADD messages, and automatically ban anyone generating such messages - }), + })), process (message, { state, getters }) { - const { data, hash, meta } = message + const { data, hash, meta, innerSigningContractID } = message const proposal = state.proposals[data.proposalHash] if (!proposal) { // https://github.com/okTurtles/group-income/issues/602 console.error(`proposalVote: no proposal for ${data.proposalHash}!`, { data, meta, hash }) throw new Errors.GIErrorIgnoreAndBan('proposalVote without existing proposal') } - Vue.set(proposal.votes, meta.username, data.vote) + Vue.set(proposal.votes, innerSigningContractID, data.vote) // TODO: handle vote pass/fail // check if proposal is expired, if so, ignore (but log vote) if (new Date(meta.createdDate).getTime() > proposal.data.expires_date_ms) { @@ -931,43 +943,43 @@ sbp('chelonia/defineContract', { Vue.set(proposal, 'dateClosed', meta.createdDate) // update 'streaks.noVotes' which records the number of proposals that each member did NOT vote for - const votedMembers = Object.keys(proposal.votes) - for (const member of getters.groupMembersByUsername) { - const memberCurrentStreak = vueFetchInitKV(getters.groupStreaks.noVotes, member, 0) - const memberHasVoted = votedMembers.includes(member) + const votedMemberIDs = Object.keys(proposal.votes) + for (const memberID of getters.groupMembersByContractID) { + const memberCurrentStreak = vueFetchInitKV(getters.groupStreaks.noVotes, memberID, 0) + const memberHasVoted = votedMemberIDs.includes(memberID) - Vue.set(getters.groupStreaks.noVotes, member, memberHasVoted ? 0 : memberCurrentStreak + 1) + Vue.set(getters.groupStreaks.noVotes, memberID, memberHasVoted ? 0 : memberCurrentStreak + 1) } } }, - sideEffect ({ contractID, data, meta }, { state, getters }) { + sideEffect ({ contractID, data, meta, height, innerSigningContractID }, { state, getters }) { const proposal = state.proposals[data.proposalHash] const { loggedIn } = sbp('state/vuex/state') - const myProfile = getters.groupProfile(loggedIn.username) + const myProfile = getters.groupProfile(loggedIn.identityContractID) if (proposal?.dateClosed && - isActionYoungerThanUser(meta, myProfile)) { + isActionYoungerThanUser(height, myProfile)) { sbp('gi.notifications/emit', 'PROPOSAL_CLOSED', { createdDate: meta.createdDate, groupID: contractID, - creator: meta.username, + creatorID: innerSigningContractID, proposalStatus: proposal.status }) } } }, 'gi.contracts/group/proposalCancel': { - validate: objectOf({ + validate: actionRequireActiveMember(objectOf({ proposalHash: string - }), - process ({ data, meta, contractID }, { state }) { + })), + process ({ data, meta, contractID, innerSigningContractID }, { state }) { const proposal = state.proposals[data.proposalHash] if (!proposal) { // https://github.com/okTurtles/group-income/issues/602 console.error(`proposalCancel: no proposal for ${data.proposalHash}!`, { data, meta }) throw new Errors.GIErrorIgnoreAndBan('proposalVote without existing proposal') - } else if (proposal.meta.username !== meta.username) { - console.error(`proposalCancel: proposal ${data.proposalHash} belongs to ${proposal.meta.username} not ${meta.username}!`, { data, meta }) + } else if (proposal.creatorID !== innerSigningContractID) { + console.error(`proposalCancel: proposal ${data.proposalHash} belongs to ${proposal.creatorID} not ${innerSigningContractID}!`, { data, meta }) throw new Errors.GIErrorIgnoreAndBan('proposalWithdraw for wrong user!') } Vue.set(proposal, 'status', STATUS_CANCELLED) @@ -976,9 +988,9 @@ sbp('chelonia/defineContract', { } }, 'gi.contracts/group/markProposalsExpired': { - validate: objectOf({ + validate: actionRequireActiveMember(objectOf({ proposalIds: arrayOf(string) - }), + })), process ({ data, meta, contractID }, { state }) { if (data.proposalIds.length) { for (const proposalId of data.proposalIds) { @@ -994,7 +1006,7 @@ sbp('chelonia/defineContract', { } }, 'gi.contracts/group/notifyExpiringProposals': { - validate: arrayOf(string), + validate: actionRequireActiveMember(arrayOf(string)), process ({ data, meta, contractID }, { state }) { for (const proposalId of data) { Vue.set(state.proposals[proposalId], 'notifiedBeforeExpire', true) @@ -1002,100 +1014,73 @@ sbp('chelonia/defineContract', { } }, 'gi.contracts/group/removeMember': { - validate: (data, { state, getters, meta }) => { + validate: actionRequireActiveMember((data, { state, getters, meta, message: { innerSigningContractID, proposalHash } }) => { objectOf({ - member: string, // username to remove + memberID: optional(string), // member to remove reason: optional(string), - automated: optional(boolean), - // In case it happens in a big group (by proposal) - // we need to validate the associated proposal. - proposalHash: optional(string), - // TODO: Figure out error happening here - proposalPayload: optional(objectOf({ - secret: string // NOTE: simulate the OP_KEY_* stuff for now - })) + automated: optional(boolean) })(data) - const memberToRemove = data.member + const memberToRemove = data.memberID || innerSigningContractID const membersCount = getters.groupMembersCount - const isGroupCreator = meta.username === state.settings.groupCreator + const isGroupCreator = innerSigningContractID === state.settings.groupCreatorID if (!state.profiles[memberToRemove]) { throw new TypeError(L('Not part of the group.')) } - if (membersCount === 1 || memberToRemove === meta.username) { - throw new TypeError(L('Cannot remove yourself.')) + if (membersCount === 1) { + throw new TypeError(L('Cannot remove the last member.')) + } + + if (memberToRemove === innerSigningContractID) { + return true } if (isGroupCreator) { return true } else if (membersCount < 3) { // In a small group only the creator can remove someone - // TODO: check whether meta.username has required admin permissions + // TODO: check whetherinnerSigningContractID has required admin permissions throw new TypeError(L('Only the group creator can remove members.')) } else { // In a big group a removal can only happen through a proposal - const proposal = state.proposals[data.proposalHash] + // We don't need to do much validation as this attribute is only + // provided through a secure context. It's presence indicates that + // a proposal passed. + const proposal = state.proposals[proposalHash] if (!proposal) { // TODO this throw new TypeError(L('Admin credentials needed and not implemented yet.')) } - - if (!proposal.payload || proposal.payload.secret !== data.proposalPayload.secret) { - throw new TypeError(L('Invalid associated proposal.')) - } } - }, - process ({ data, meta, contractID }, { state, getters }) { + }), + process ({ data, meta, contractID, height, innerSigningContractID }, { state, getters }) { memberLeaves( - { username: data.member, dateLeft: meta.createdDate }, + { memberID: data.memberID || innerSigningContractID, dateLeft: meta.createdDate, heightLeft: height }, { contractID, meta, state, getters } ) }, - sideEffect ({ data, meta, contractID }, { state, getters }) { + sideEffect ({ data, meta, contractID, height, innerSigningContractID }, { state, getters }) { // Put this invocation at the end of a sync to ensure that leaving and // re-joining works - sbp('chelonia/queueInvocation', contractID, () => sbp('gi.contracts/group/leaveGroup', { data, meta, contractID, getters })).catch(e => { + sbp('chelonia/queueInvocation', contractID, () => sbp('gi.contracts/group/leaveGroup', { data, meta, contractID, getters, height, innerSigningContractID })).catch(e => { console.error(`[gi.contracts/group/removeMember/sideEffect] Error ${e.name} during queueInvocation for ${contractID}`, e) }) } }, - 'gi.contracts/group/removeOurselves': { - validate: objectMaybeOf({ - reason: string - }), - process ({ data, meta, contractID }, { state, getters }) { - memberLeaves( - { username: meta.username, dateLeft: meta.createdDate }, - { contractID, meta, state, getters } - ) - - sbp('gi.contracts/group/pushSideEffect', contractID, - ['gi.contracts/group/removeMember/sideEffect', { - meta, - data: { member: meta.username, reason: data.reason || '' }, - contractID - }] - ) - } - }, 'gi.contracts/group/invite': { - validate: inviteType, + validate: actionRequireActiveMember(inviteType), process ({ data, meta }, { state }) { Vue.set(state.invites, data.inviteKeyId, data) } }, 'gi.contracts/group/inviteAccept': { - validate: Boolean, - process ({ data, meta }, { state }) { - // TODO: ensure `meta.username` is unique for the lifetime of the username - // since we are making it possible for the same username to leave and - // rejoin the group. All of their past posts will be re-associated with - // them upon re-joining. - if (state.profiles[meta.username]?.status === PROFILE_STATUS.ACTIVE) { - throw new Error(`[gi.contracts/group/inviteAccept] Existing members can't accept invites: ${meta.username}`) + validate: actionRequireInnerSignature(() => {}), + process ({ data, meta, height, innerSigningContractID }, { state }) { + if (state.profiles[innerSigningContractID]?.status === PROFILE_STATUS.ACTIVE) { + throw new Error(`[gi.contracts/group/inviteAccept] Existing members can't accept invites: ${innerSigningContractID}`) } - Vue.set(state.profiles, meta.username, initGroupProfile(meta.createdDate)) + Vue.set(state.profiles, innerSigningContractID, initGroupProfile(meta.createdDate, height)) // If we're triggered by handleEvent in state.js (and not latestContractState) // then the asynchronous sideEffect function will get called next // and we will subscribe to this new user's identity contract @@ -1105,7 +1090,7 @@ sbp('chelonia/defineContract', { // They MUST NOT call 'commit'! // They should only coordinate the actions of outside contracts. // Otherwise `latestContractState` and `handleEvent` will not produce same state! - sideEffect ({ meta, contractID, innerSigningContractID }, { state }) { + sideEffect ({ meta, contractID, height, innerSigningContractID }, { state }) { const { loggedIn } = sbp('state/vuex/state') sbp('chelonia/queueInvocation', contractID, async () => { @@ -1120,7 +1105,7 @@ sbp('chelonia/defineContract', { const { profiles = {} } = state - if (profiles[meta.username].status !== PROFILE_STATUS.ACTIVE) { + if (profiles[innerSigningContractID].status !== PROFILE_STATUS.ACTIVE) { return } @@ -1130,6 +1115,10 @@ sbp('chelonia/defineContract', { // however per #610 that might be handled in handleEvent (?), or per #356 might not be needed if (innerSigningContractID === userID) { // we're the person who just accepted the group invite + // Our status is active, so we cancel any pending removal (from + // having left in the past) + sbp('chelonia/contract/cancelRemove', contractID) + // Add the group's CSK to our identity contract so that we can receive // DMs. await sbp('gi.actions/identity/addJoinDirectMessageKey', userID, contractID, 'csk') @@ -1137,7 +1126,7 @@ sbp('chelonia/defineContract', { const generalChatRoomId = state.generalChatRoomId if (generalChatRoomId) { // Join the general chatroom - if (state.chatRooms[generalChatRoomId]?.users?.[loggedIn.username]?.status !== PROFILE_STATUS.ACTIVE) { + if (state.chatRooms[generalChatRoomId]?.members?.[loggedIn.identityContractID]?.status !== PROFILE_STATUS.ACTIVE) { sbp('gi.actions/group/joinChatRoom', { contractID, data: { @@ -1169,32 +1158,13 @@ sbp('chelonia/defineContract', { } // subscribe to founder's IdentityContract & everyone else's - Promise.allSettled( - Object.keys(profiles) - .filter((name) => - !rootGetters.ourContactProfiles[name] && name !== loggedIn.username) - .map(async (name) => await sbp('namespace/lookup', name).then((r) => { - if (!r) throw new Error('Cannot lookup username: ' + name) - return r - })) - ).then(lookupResult => { - const errors = lookupResult - .filter(({ status }) => status === 'rejected') - .map((r) => (r: any).reason) - - return sbp('chelonia/contract/sync', - lookupResult - .filter(({ status }) => status === 'fulfilled') - .map((r) => (r: any).value) + const profileIds = Object.keys(profiles) + .filter((id) => + id !== loggedIn.identityContractID && + !rootGetters.ourContactProfilesById[id] ) - .catch(e => { - errors.push(e) - }).then(() => errors) - }).then((errors) => { - if (errors.length) { - const msg = `Encountered ${errors.length} errors while accepting invites` - console.error(msg, errors) - } + sbp('chelonia/contract/sync', profileIds).catch((e) => { + console.error('Error while syncing other members\' contracts at inviteAccept', e) }) // If we don't have a current group ID, select the group we've just @@ -1216,22 +1186,22 @@ sbp('chelonia/defineContract', { console.error('[gi.contracts/group/inviteAccept/sideEffect]: An error occurred', e) }) - if (meta.username !== loggedIn.username) { + if (innerSigningContractID !== loggedIn.identityContractID) { const { profiles = {} } = state - const myProfile = profiles[loggedIn.username] + const myProfile = profiles[loggedIn.identityContractID] - if (isActionYoungerThanUser(meta, myProfile)) { + if (isActionYoungerThanUser(height, myProfile)) { sbp('gi.notifications/emit', 'MEMBER_ADDED', { // emit a notification for a member addition. createdDate: meta.createdDate, groupID: contractID, - username: meta.username + memberID: innerSigningContractID }) } } } }, 'gi.contracts/group/inviteRevoke': { - validate: (data, { state, meta }) => { + validate: actionRequireActiveMember((data, { state, meta }) => { objectOf({ inviteKeyId: string })(data) @@ -1239,7 +1209,7 @@ sbp('chelonia/defineContract', { if (!state._vm.invites[data.inviteKeyId]) { throw new TypeError(L('The link does not exist.')) } - }, + }), process () { // Handled by Chelonia } @@ -1247,7 +1217,7 @@ sbp('chelonia/defineContract', { 'gi.contracts/group/updateSettings': { // OPTIMIZE: Make this custom validation function // reusable accross other future validators - validate: (data, { getters, meta }) => { + validate: actionRequireActiveMember((data, { getters, meta, message: { innerSigningContractID } }) => { objectMaybeOf({ groupName: x => typeof x === 'string', groupPicture: x => typeof x === 'string', @@ -1258,7 +1228,7 @@ sbp('chelonia/defineContract', { allowPublicChannels: x => typeof x === 'boolean' })(data) - const isGroupCreator = meta.username === getters.groupSettings.groupCreator + const isGroupCreator = innerSigningContractID === getters.groupSettings.groupCreatorID if ('allowPublicChannels' in data && !isGroupCreator) { throw new TypeError(L('Only group creator can allow public channels.')) } else if ('distributionDate' in data && !isGroupCreator) { @@ -1267,8 +1237,8 @@ sbp('chelonia/defineContract', { (getters.groupDistributionStarted(meta.createdDate) || Object.keys(getters.groupPeriodPayments).length > 1)) { throw new TypeError(L('Can\'t change distribution date because distribution period has already started.')) } - }, - process ({ contractID, meta, data }, { state, getters }) { + }), + process ({ contractID, meta, data, height, innerSigningContractID }, { state, getters }) { // If mincome has been updated, cache the old value and use it later to determine if the user should get a 'MINCOME_CHANGED' notification. const mincomeCache = 'mincomeAmount' in data ? state.settings.mincomeAmount : null @@ -1289,14 +1259,16 @@ sbp('chelonia/defineContract', { { toAmount: data.mincomeAmount, fromAmount: mincomeCache - } + }, + height, + innerSigningContractID ] ) } } }, 'gi.contracts/group/groupProfileUpdate': { - validate: objectMaybeOf({ + validate: actionRequireActiveMember(objectMaybeOf({ incomeDetailsType: x => ['incomeAmount', 'pledgeAmount'].includes(x), incomeAmount: x => typeof x === 'number' && x >= 0, pledgeAmount: x => typeof x === 'number' && x >= 0, @@ -1312,9 +1284,9 @@ sbp('chelonia/defineContract', { value: string }) ) - }), - process ({ data, meta, contractID }, { state, getters }) { - const groupProfile = state.profiles[meta.username] + })), + process ({ data, meta, contractID, innerSigningContractID }, { state, getters }) { + const groupProfile = state.profiles[innerSigningContractID] const nonMonetary = groupProfile.nonMonetaryContributions for (const key in data) { const value = data[key] @@ -1340,11 +1312,11 @@ sbp('chelonia/defineContract', { } }, 'gi.contracts/group/updateAllVotingRules': { - validate: objectMaybeOf({ + validate: actionRequireActiveMember(objectMaybeOf({ ruleName: x => [RULE_PERCENTAGE, RULE_DISAGREEMENT].includes(x), ruleThreshold: number, expires_ms: number - }), + })), process ({ data, meta }, { state }) { // Update all types of proposal settings for simplicity if (data.ruleName && data.ruleThreshold) { @@ -1363,20 +1335,25 @@ sbp('chelonia/defineContract', { } }, 'gi.contracts/group/addChatRoom': { + // The #General chatroom is added without an inner signature validate: objectOf({ chatRoomID: string, - attributes: chatRoomAttributesType + attributes: groupChatRoomAttributesType }), - process ({ data, meta }, { state }) { + process ({ data, meta, contractID, innerSigningContractID }, { state }) { const { name, type, privacyLevel, description } = data.attributes + // XOR: has(innerSigningContractID) XOR #General + if (!!innerSigningContractID === (data.attributes.name === CHATROOM_GENERAL_NAME)) { + throw new Error('All chatrooms other than #General must have an inner signature and the #General chatroom must have no inner signature') + } Vue.set(state.chatRooms, data.chatRoomID, { - creator: meta.username, + creatorID: innerSigningContractID || contractID, name, description, type, privacyLevel, deletedDate: null, - users: {} + members: {} }) if (!state.generalChatRoomId) { Vue.set(state, 'generalChatRoomId', data.chatRoomID) @@ -1393,46 +1370,48 @@ sbp('chelonia/defineContract', { } }, 'gi.contracts/group/deleteChatRoom': { - validate: (data, { getters, meta }) => { + validate: actionRequireActiveMember((data, { getters, meta, message: { innerSigningContractID } }) => { objectOf({ chatRoomID: string })(data) - if (getters.getGroupChatRooms[data.chatRoomID].creator !== meta.username) { + if (getters.getGroupChatRooms[data.chatRoomID].creatorID !== innerSigningContractID) { throw new TypeError(L('Only the channel creator can delete channel.')) } - }, + }), process ({ data, meta }, { state }) { Vue.delete(state.chatRooms, data.chatRoomID) } }, 'gi.contracts/group/leaveChatRoom': { - validate: objectOf({ + validate: actionRequireActiveMember(objectOf({ chatRoomID: string, - member: string - }), - process ({ data, meta }, { state }) { + memberID: optional(string) + })), + process ({ data, innerSigningContractID }, { state }) { if (!state.chatRooms[data.chatRoomID]) { throw new Error('Cannot leave a chatroom which isn\'t part of the group') } - if (state.chatRooms[data.chatRoomID].users[data.member]?.status !== PROFILE_STATUS.ACTIVE) { + const memberID = data.memberID || innerSigningContractID + if (state.chatRooms[data.chatRoomID].members[memberID]?.status !== PROFILE_STATUS.ACTIVE) { throw new Error('Cannot leave a chatroom that you\'re not part of') } - removeGroupChatroomProfile(state, data.chatRoomID, data.member) + removeGroupChatroomProfile(state, data.chatRoomID, memberID) }, sideEffect ({ meta, data, contractID, innerSigningContractID }, { state }) { const rootState = sbp('state/vuex/state') + const memberID = data.memberID || innerSigningContractID if (innerSigningContractID === rootState.loggedIn.identityContractID && !sbp('okTurtles.data/get', 'JOINING_GROUP-' + contractID)) { sbp('chelonia/queueInvocation', contractID, () => { const rootState = sbp('state/vuex/state') - if (rootState[contractID]?.profiles?.[meta.username]?.status === PROFILE_STATUS.ACTIVE) { - return leaveChatRoomAction(state, data, meta) + if (rootState[contractID]?.profiles?.[innerSigningContractID]?.status === PROFILE_STATUS.ACTIVE) { + return leaveChatRoomAction(state, data.chatRoomID, memberID, innerSigningContractID) } }).catch((e) => { console.error(`[gi.contracts/group/leaveChatRoom/sideEffect] Error for ${contractID}`, { contractID, data, error: e }) }) - } else if (data.member === rootState.loggedIn.username) { + } else if (memberID === rootState.loggedIn.identityContractID) { // Abort the joining action if it's been initiated. This way, calling /remove on the leave action will work - if (sbp('okTurtles.data/get', `JOINING_CHATROOM-${data.chatRoomID}-${data.member}`)) { - sbp('okTurtles.data/delete', `JOINING_CHATROOM-${data.chatRoomID}-${data.member}`) + if (sbp('okTurtles.data/get', `JOINING_CHATROOM-${data.chatRoomID}-${memberID}`)) { + sbp('okTurtles.data/delete', `JOINING_CHATROOM-${data.chatRoomID}-${memberID}`) sbp('chelonia/contract/remove', data.chatRoomID).then(() => { const rootState = sbp('state/vuex/state') if (rootState.currentChatRoomIDs[contractID] === data.chatRoomID) { @@ -1448,19 +1427,19 @@ sbp('chelonia/defineContract', { } }, 'gi.contracts/group/joinChatRoom': { - validate: objectMaybeOf({ - username: string, + validate: actionRequireActiveMember(objectMaybeOf({ + memberID: optional(string), chatRoomID: string - }), - process ({ data, meta }, { state }) { - const username = data.username || meta.username - if (state.profiles[username]?.status !== PROFILE_STATUS.ACTIVE) { + })), + process ({ data, meta, innerSigningContractID }, { state }) { + const memberID = data.memberID || innerSigningContractID + if (state.profiles[memberID]?.status !== PROFILE_STATUS.ACTIVE) { throw new Error('Cannot join a chatroom for a group you\'re not a member of') } if (!state.chatRooms[data.chatRoomID]) { throw new Error('Cannot join a chatroom which isn\'t part of the group') } - if (state.chatRooms[data.chatRoomID].users[username]?.status === PROFILE_STATUS.ACTIVE) { + if (state.chatRooms[data.chatRoomID].members[memberID]?.status === PROFILE_STATUS.ACTIVE) { throw new Error('Cannot join a chatroom that you\'re already part of') } // Here, we could use a list of active members or we could use a @@ -1474,26 +1453,26 @@ sbp('chelonia/defineContract', { // removed members, we would need to possibly fetch every chatroom // contract to account for chatrooms for which the removed member is // a part of. - Vue.set(state.chatRooms[data.chatRoomID].users, username, { status: PROFILE_STATUS.ACTIVE }) + Vue.set(state.chatRooms[data.chatRoomID].members, memberID, { status: PROFILE_STATUS.ACTIVE }) }, sideEffect ({ meta, data, contractID, innerSigningContractID }, { state }) { const rootState = sbp('state/vuex/state') - const username = data.username || meta.username + const memberID = data.memberID || innerSigningContractID // If we added someone to the chatroom (including ourselves), we issue // the relevant action to the chatroom contract if (innerSigningContractID === rootState.loggedIn.identityContractID) { - sbp('chelonia/queueInvocation', contractID, () => sbp('gi.contracts/group/joinGroupChatrooms', contractID, data.chatRoomID, username)).catch((e) => { + sbp('chelonia/queueInvocation', contractID, () => sbp('gi.contracts/group/joinGroupChatrooms', contractID, data.chatRoomID, memberID)).catch((e) => { console.error(`[gi.contracts/group/joinChatRoom/sideEffect] Error adding member to group chatroom for ${contractID}`, { e, data }) }) - } else if (username === rootState.loggedIn.username) { + } else if (memberID === rootState.loggedIn.identityContractID) { // If we were the ones added to the chatroom, we sync the chatroom. // This is an `else` block because joinGroupChatrooms already calls // sync sbp('chelonia/queueInvocation', contractID, () => { const rootState = sbp('state/vuex/state') - if (rootState[contractID]?.chatRooms[data.chatRoomID]?.users[username]?.status === PROFILE_STATUS.ACTIVE) { + if (rootState[contractID]?.chatRooms[data.chatRoomID]?.members[memberID]?.status === PROFILE_STATUS.ACTIVE) { // If we were added by someone else, we might sync the chatroom // contract before the corresponding `/join` action is issued. // If we were previously a member of the chatroom, we would have @@ -1506,7 +1485,7 @@ sbp('chelonia/defineContract', { // implemented in Chelonia. With reference counting, we'd keep // track of the 'reason' we're subscribing to a contract, and // we won't need this special key. - sbp('okTurtles.data/set', `JOINING_CHATROOM-${data.chatRoomID}-${username}`, true) + sbp('okTurtles.data/set', `JOINING_CHATROOM-${data.chatRoomID}-${memberID}`, true) sbp('chelonia/contract/sync', data.chatRoomID).catch((e) => { console.error(`[gi.contracts/group/joinChatRoom/sideEffect] Error syncing chatroom contract for ${contractID}`, { e, data }) }) @@ -1516,27 +1495,27 @@ sbp('chelonia/defineContract', { } }, 'gi.contracts/group/renameChatRoom': { - validate: objectOf({ + validate: actionRequireActiveMember(objectOf({ chatRoomID: string, name: string - }), + })), process ({ data, meta }, { state, getters }) { Vue.set(state.chatRooms[data.chatRoomID], 'name', data.name) } }, 'gi.contracts/group/changeChatRoomDescription': { - validate: objectOf({ + validate: actionRequireActiveMember(objectOf({ chatRoomID: string, description: string - }), + })), process ({ data, meta }, { state, getters }) { Vue.set(state.chatRooms[data.chatRoomID], 'description', data.description) } }, 'gi.contracts/group/updateLastLoggedIn': { - validate () {}, - process ({ meta }, { getters }) { - const profile = getters.groupProfiles[meta.username] + validate: actionRequireActiveMember(() => {}), + process ({ meta, innerSigningContractID }, { getters }) { + const profile = getters.groupProfiles[innerSigningContractID] if (profile) { Vue.set(profile, 'lastLoggedIn', meta.createdDate) @@ -1544,7 +1523,7 @@ sbp('chelonia/defineContract', { } }, 'gi.contracts/group/updateDistributionDate': { - validate: optional, + validate: actionRequireActiveMember(optional), process ({ meta }, { state, getters }) { const period = getters.periodStampGivenDate(meta.createdDate) const current = getters.groupSettings?.distributionDate @@ -1599,7 +1578,7 @@ sbp('chelonia/defineContract', { const rootState = sbp('state/vuex/state') const rootGetters = sbp('state/vuex/getters') const contracts = rootState.contracts || {} - const { username, identityContractID } = rootState.loggedIn + const { identityContractID } = rootState.loggedIn // NOTE: should remove archived data from IndexedStorage // regarding the current group (proposals, payments) @@ -1623,7 +1602,7 @@ sbp('chelonia/defineContract', { cID !== contractID ).sort(cID => // prefer successfully joined groups - rootState[cID]?.profiles?.[username] ? -1 : 1 + rootState[cID]?.profiles?.[identityContractID] ? -1 : 1 )[0] || null sbp('state/vuex/commit', 'setCurrentChatRoomId', {}) sbp('state/vuex/commit', 'setCurrentGroupId', groupIdToSwitch) @@ -1683,7 +1662,7 @@ sbp('chelonia/defineContract', { }, 'gi.contracts/group/archivePayments': async function (contractID, archivingPayments) { const { paymentsByPeriod, payments } = archivingPayments - const { identityContractID, username } = sbp('state/vuex/state').loggedIn + const { identityContractID } = sbp('state/vuex/state').loggedIn // NOTE: we save payments by period and also in types of 'Sent' and 'Received' as well // because it's not efficient to find all sent/received payments from the payments list @@ -1693,7 +1672,7 @@ sbp('chelonia/defineContract', { const archSentOrReceivedPayments = await sbp('gi.db/archive/load', archSentOrReceivedPaymentsKey) || { sent: [], received: [] } // sort payments in order to keep the same sorting format as the recent data in vuex - const sortPayments = payments => payments.sort((f, l) => compareISOTimestamps(l.meta.createdDate, f.meta.createdDate)) + const sortPayments = payments => payments.sort((f, l) => l.height - f.height) // prepare to archive by the period for (const period of Object.keys(paymentsByPeriod).sort()) { @@ -1702,13 +1681,13 @@ sbp('chelonia/defineContract', { // filter sent/received payments from the current period const newSentOrReceivedPayments = { sent: [], received: [] } const { paymentsFrom } = paymentsByPeriod[period] - for (const fromUser of Object.keys(paymentsFrom)) { - for (const toUser of Object.keys(paymentsFrom[fromUser])) { - if (toUser === username || fromUser === username) { - const receivedOrSent = toUser === username ? 'received' : 'sent' - for (const hash of paymentsFrom[fromUser][toUser]) { - const { data, meta } = payments[hash] - newSentOrReceivedPayments[receivedOrSent].push({ hash, period, data, meta, amount: data.amount }) + for (const fromMemberID of Object.keys(paymentsFrom)) { + for (const toMemberID of Object.keys(paymentsFrom[fromMemberID])) { + if (toMemberID === identityContractID || fromMemberID === identityContractID) { + const receivedOrSent = toMemberID === identityContractID ? 'received' : 'sent' + for (const hash of paymentsFrom[fromMemberID][toMemberID]) { + const { data, meta, height } = payments[hash] + newSentOrReceivedPayments[receivedOrSent].push({ hash, period, height, data, meta, amount: data.amount }) } } } @@ -1759,7 +1738,7 @@ sbp('chelonia/defineContract', { await sbp('gi.db/archive/delete', archPaymentsByPeriodKey) await sbp('gi.db/archive/delete', archSentOrReceivedPaymentsKey) }, - 'gi.contracts/group/sendMincomeChangedNotification': async function (contractID, meta, data) { + 'gi.contracts/group/sendMincomeChangedNotification': async function (contractID, meta, data, height, innerSigningContractID) { // NOTE: When group's mincome has changed, below actions should be taken. // - When mincome has increased, send 'MINCOME_CHANGED' notification to both receiving/pledging members. // - When mincome has decreased, and the changed mincome is below the monthly income of a receiving member, then @@ -1768,7 +1747,7 @@ sbp('chelonia/defineContract', { // 3) and send 'MINCOME_CHANGED' notification. const myProfile = sbp('state/vuex/getters').ourGroupProfile - if (isActionYoungerThanUser(meta, myProfile) && myProfile.incomeDetailsType) { + if (isActionYoungerThanUser(height, myProfile) && myProfile.incomeDetailsType) { const memberType = myProfile.incomeDetailsType === 'pledgeAmount' ? 'pledging' : 'receiving' const mincomeIncreased = data.toAmount > data.fromAmount const actionNeeded = mincomeIncreased || @@ -1800,19 +1779,19 @@ sbp('chelonia/defineContract', { await sbp('gi.notifications/emit', 'MINCOME_CHANGED', { groupID: contractID, - creator: meta.username, + creatorID: innerSigningContractID, to: data.toAmount, memberType, increased: mincomeIncreased }) } }, - 'gi.contracts/group/joinGroupChatrooms': async function (contractID, chatRoomId, member) { + 'gi.contracts/group/joinGroupChatrooms': async function (contractID, chatRoomId, memberID) { const rootState = sbp('state/vuex/state') const state = rootState[contractID] - const username = rootState.loggedIn.username + const actorID = rootState.loggedIn.identityContractID - if (state?.profiles?.[username]?.status !== PROFILE_STATUS.ACTIVE || state.chatRooms?.[chatRoomId]?.users[member]?.status !== PROFILE_STATUS.ACTIVE) { + if (state?.profiles?.[actorID]?.status !== PROFILE_STATUS.ACTIVE || state.chatRooms?.[chatRoomId]?.members[memberID]?.status !== PROFILE_STATUS.ACTIVE) { return } @@ -1830,9 +1809,9 @@ sbp('chelonia/defineContract', { await sbp('gi.actions/chatroom/join', { contractID: chatRoomId, - data: { username: member }, + data: actorID === memberID ? {} : { memberID }, encryptionKeyId, - ...username === member && { + ...actorID === memberID && { hooks: { onprocessed: () => { sbp('state/vuex/commit', 'setCurrentChatRoomId', { groupId: contractID, chatRoomId }) @@ -1840,55 +1819,94 @@ sbp('chelonia/defineContract', { } } }).catch(e => { - console.error(`Unable to join ${member} to chatroom ${chatRoomId} for group ${contractID}`, e) + console.error(`Unable to join ${memberID} to chatroom ${chatRoomId} for group ${contractID}`, e) }) } finally { await sbp('chelonia/contract/remove', chatRoomId, { removeIfPending: true }) } }, // eslint-disable-next-line require-await - 'gi.contracts/group/leaveGroup': async ({ data, meta, contractID, getters }) => { + 'gi.contracts/group/leaveGroup': async ({ data, meta, contractID, height, getters, innerSigningContractID }) => { const rootState = sbp('state/vuex/state') - const rootGetters = sbp('state/vuex/getters') const state = rootState[contractID] - const { username } = rootState.loggedIn + const { identityContractID } = rootState.loggedIn + const memberID = data.memberID || innerSigningContractID if (!state) { console.info(`[gi.contracts/group/leaveGroup] for ${contractID}: contract has been removed`) return } - if (state.profiles?.[data.member]?.status !== PROFILE_STATUS.REMOVED) { - console.info(`[gi.contracts/group/leaveGroup] for ${contractID}: member has not left`, { contractID, member: data.member, status: state.profiles?.[data.member]?.status }) + if (state.profiles?.[memberID]?.status !== PROFILE_STATUS.REMOVED) { + console.info(`[gi.contracts/group/leaveGroup] for ${contractID}: member has not left`, { contractID, memberID, status: state.profiles?.[memberID]?.status }) return } - if (data.member === username && sbp('okTurtles.data/get', 'JOINING_GROUP-' + contractID)) { - console.info(`[gi.contracts/group/leaveGroup] for ${contractID}: member is currently joining`, { contractID, member: data.member, status: state.profiles?.[data.member]?.status }) + if (memberID === identityContractID && sbp('okTurtles.data/get', 'JOINING_GROUP-' + contractID)) { + console.info(`[gi.contracts/group/leaveGroup] for ${contractID}: member is currently joining`, { contractID, memberID, status: state.profiles?.[memberID]?.status }) return } - leaveAllChatRoomsUponLeaving(state, data.member, meta).catch((e) => { + // TODO: Use this later. This is an attempt at removing the need for JOINING_GROUP. + // The following detects whether we're in the process of joining, and if we + // are, it doesn't remove the contract and calls /join to complete the + // joining process. It'll likely be useful later for removing JOINING_GROUP + // and handling re-joininig within the group contract itself. + /* + if (isNaN(0) && member === identityContractID) { + // It looks like we were removed. Now, before removing the contract we + // need to see if we're not re-joining. + // First, we check if there are no pending key requests for us + const shouldWeJoin = () => { + const pendingKeyShares = sbp('chelonia/contract/waitingForKeyShareTo', state, identityContractID) + if (pendingKeyShares) { + console.info('[gi.contracts/group/leaveGroup] Not removing group contract because it has a pending key share for ourselves', contractID) + return true + } + // Now, let's see if we had a key request that's been answered + const sentKeyShares = sbp('chelonia/contract/successfulKeySharesByContractID', state, identityContractID) + // We received a key share after the last time we left + if (sentKeyShares?.[identityContractID]?.[0].height > state.profiles[member].departedHeight) { + console.info('[gi.contracts/group/leaveGroup] Not removing group contract because it has shared keys with ourselves after we left', contractID) + return true + } + return false + } + + if (shouldWeJoin()) { + console.info('[gi.contracts/group/leaveGroup] calling join to finish the join process instead', contractID) + sbp('gi.actions/group/join', { + contractID: contractID, + contractName: 'gi.contracts/group' + }).catch((e) => { + alert(L('Join group error: {msg}', { msg: e?.message || 'unknown error' })) + console.error(`Error during gi.actions/group/join for ${contractID} at gi.contracts/group/leaveGroup`, e) + }) + return + } + } */ + + leaveAllChatRoomsUponLeaving(state, memberID, innerSigningContractID).catch((e) => { console.error('[gi.contracts/group/leaveGroup]: Error while leaving all chatrooms', e) }) - if (data.member === username) { + if (memberID === identityContractID) { // we can't await on this in here, because it will cause a deadlock, since Chelonia processes // this method on the eventqueue for this contractID, and /remove uses that same eventqueue sbp('chelonia/contract/remove', contractID).catch(e => { console.error(`sideEffect(removeMember): ${e.name} thrown by /remove ${contractID}:`, e) }) } else { - const myProfile = getters.groupProfile(username) + const myProfile = getters.groupProfile(identityContractID) - if (isActionYoungerThanUser(meta, myProfile)) { - const memberRemovedThemselves = data.member === meta.username + if (isActionYoungerThanUser(height, myProfile)) { + const memberRemovedThemselves = memberID === innerSigningContractID sbp('gi.notifications/emit', // emit a notification for a member removal. memberRemovedThemselves ? 'MEMBER_LEFT' : 'MEMBER_REMOVED', { createdDate: meta.createdDate, groupID: contractID, - username: memberRemovedThemselves ? meta.username : data.member + memberID }) Promise.resolve() @@ -1899,15 +1917,12 @@ sbp('chelonia/defineContract', { console.error(`[gi.contracts/group/leaveGroup] for ${contractID}: Error rotating group keys or our PEK`, e) }) - const userID = rootGetters.ourContactProfiles[data.member]?.contractID - if (userID) { - sbp('gi.contracts/group/removeForeignKeys', contractID, userID, state) - } + sbp('gi.contracts/group/removeForeignKeys', contractID, memberID, state) } // TODO - #828 remove the member contract if applicable. // problem is, if they're in another group we're also a part of, or if we // have a DM with them, we don't want to do this. may need to use manual reference counting - // sbp('chelonia/contract/release', getters.groupProfile(data.member).contractID) + // sbp('chelonia/contract/release', getters.groupProfile(member).contractID) } // TODO - #850 verify open proposals and see if they need some re-adjustment. diff --git a/frontend/model/contracts/identity.js b/frontend/model/contracts/identity.js index 99b705d56..0c44a5a83 100644 --- a/frontend/model/contracts/identity.js +++ b/frontend/model/contracts/identity.js @@ -140,7 +140,7 @@ sbp('chelonia/defineContract', { validate: objectMaybeOf({ groupContractID: string, inviteSecret: string, - creator: optional(boolean) + creatorID: optional(boolean) }), process ({ hash, data, meta }, { state }) { const { groupContractID, inviteSecret } = data @@ -242,10 +242,9 @@ sbp('chelonia/defineContract', { if (has(rootState.contracts, groupContractID)) { sbp('gi.actions/group/removeOurselves', { - contractID: groupContractID, - data: {} + contractID: groupContractID }).catch(e => { - console.error(`[gi.contracts/identity/leaveGroup/sideEffect] Error sending /removeOurselves action to group ${data.groupContractID}`, e) + console.error(`[gi.contracts/identity/leaveGroup/sideEffect] Error removing ourselves from group contract ${data.groupContractID}`, e) }) } diff --git a/frontend/model/contracts/manifests.json b/frontend/model/contracts/manifests.json index 165036afd..8fd47c2cf 100644 --- a/frontend/model/contracts/manifests.json +++ b/frontend/model/contracts/manifests.json @@ -1,7 +1,7 @@ { "manifests": { - "gi.contracts/chatroom": "z9brRu3VGUJuML5qnvRfPu8GvuH8rX21FoTfkXTZDYXiVfqhUQXh", - "gi.contracts/group": "z9brRu3VNbanb5DR2YTUrFDUaZ7LwHfQS3V6mwExwYEXSMmVKDyp", - "gi.contracts/identity": "z9brRu3VSV4mxub2x1bcJ2Jx8n5c4moy9LUG9FWnpVisEmNhMJ7N" + "gi.contracts/chatroom": "z9brRu3VRwA9WQ6nhRbJz8KvZ3W6a43vhXrx1iES6yRsExsXzTpG", + "gi.contracts/group": "z9brRu3VNeHviRAc7DLPBQqyagTrpUHoXF9YdMFr1gtrgYGMzfSB", + "gi.contracts/identity": "z9brRu3VSVRddCxKg3YRMP8niKH7gwNq5TexdjvBEDfoLRv97DBh" } } diff --git a/frontend/model/contracts/misc/flowTyper.js b/frontend/model/contracts/misc/flowTyper.js index 2897e1413..a848cd049 100644 --- a/frontend/model/contracts/misc/flowTyper.js +++ b/frontend/model/contracts/misc/flowTyper.js @@ -401,4 +401,12 @@ function unionOf_ (...typeFuncs) { } // $FlowFixMe // const unionOf: UnionT = (unionOf_) -export const unionOf = unionOf_ \ No newline at end of file +export const unionOf = unionOf_ + +export const actionRequireInnerSignature = (next: Function): Function => (data, props) => { + const innerSigningContractID = props.message.innerSigningContractID + if (!innerSigningContractID || innerSigningContractID === props.contractID) { + throw new Error('Missing inner signature') + } + return next(data, props) +} \ No newline at end of file diff --git a/frontend/model/contracts/shared/distribution/distribution.js b/frontend/model/contracts/shared/distribution/distribution.js index 3aaeb30dc..845ba4eb8 100644 --- a/frontend/model/contracts/shared/distribution/distribution.js +++ b/frontend/model/contracts/shared/distribution/distribution.js @@ -50,12 +50,12 @@ function reduceDistribution (payments: Distribution): Distribution { const paymentB = payments[j] // Were paymentA and paymentB between the same two users? - if ((paymentA.from === paymentB.from && paymentA.to === paymentB.to) || - (paymentA.to === paymentB.from && paymentA.from === paymentB.to)) { + if ((paymentA.fromMemberID === paymentB.fromMemberID && paymentA.toMemberID === paymentB.toMemberID) || + (paymentA.toMemberID === paymentB.fromMemberID && paymentA.fromMemberID === paymentB.toMemberID)) { // Add or subtract paymentB's amount to paymentA's amount, depending on the relative // direction of the two payments: - paymentA.amount += (paymentA.from === paymentB.from ? 1 : -1) * paymentB.amount - paymentA.total += (paymentA.from === paymentB.from ? 1 : -1) * paymentB.total + paymentA.amount += (paymentA.fromMemberID === paymentB.fromMemberID ? 1 : -1) * paymentB.amount + paymentA.total += (paymentA.fromMemberID === paymentB.fromMemberID ? 1 : -1) * paymentB.total // Remove paymentB from payments, and decrement the inner sentinal loop variable: payments.splice(j, 1) j-- diff --git a/frontend/model/contracts/shared/distribution/distribution.test.js b/frontend/model/contracts/shared/distribution/distribution.test.js index 68d6945a5..5972a621c 100644 --- a/frontend/model/contracts/shared/distribution/distribution.test.js +++ b/frontend/model/contracts/shared/distribution/distribution.test.js @@ -25,33 +25,33 @@ describe('Test group-income-distribution.js', function () { }) it('EVENTS: [u1, u2, u3 and u4] join the group and set haveNeeds of [100, 100, -50, and -50], respectively. Test unadjusted.', function () { setup.splice(setup.length, 0, - { type: 'haveNeedEvent', data: { name: 'u1', haveNeed: 100 } }, - { type: 'haveNeedEvent', data: { name: 'u2', haveNeed: 100 } }, - { type: 'haveNeedEvent', data: { name: 'u3', haveNeed: -50 } }, - { type: 'haveNeedEvent', data: { name: 'u4', haveNeed: -50 } } + { type: 'haveNeedEvent', data: { memberID: 'u1', haveNeed: 100 } }, + { type: 'haveNeedEvent', data: { memberID: 'u2', haveNeed: 100 } }, + { type: 'haveNeedEvent', data: { memberID: 'u3', haveNeed: -50 } }, + { type: 'haveNeedEvent', data: { memberID: 'u4', haveNeed: -50 } } ) should(distributionWrapper(setup)).eql([ - { amount: 50, from: 'u2', to: 'u4' }, - { amount: 50, from: 'u1', to: 'u3' } + { amount: 50, fromMemberID: 'u2', toMemberID: 'u4' }, + { amount: 50, fromMemberID: 'u1', toMemberID: 'u3' } ]) }) it('Test the adjusted version of the previous event-list. Should be unchanged.', function () { should(distributionWrapper(setup, { adjusted: true })).eql([ - { amount: 50, from: 'u2', to: 'u4', total: 50, partial: false, isLate: false, dueOn: '2021-01' }, - { amount: 50, from: 'u1', to: 'u3', total: 50, partial: false, isLate: false, dueOn: '2021-01' } + { amount: 50, fromMemberID: 'u2', toMemberID: 'u4', total: 50, partial: false, isLate: false, dueOn: '2021-01' }, + { amount: 50, fromMemberID: 'u1', toMemberID: 'u3', total: 50, partial: false, isLate: false, dueOn: '2021-01' } ]) }) it('EVENT: a payment of $10 is made from u1 to u3.', function () { - setup.push({ type: 'paymentEvent', data: { from: 'u1', to: 'u3', amount: 10 } }) + setup.push({ type: 'paymentEvent', data: { fromMemberID: 'u1', toMemberID: 'u3', amount: 10 } }) should(distributionWrapper(setup, { adjusted: true })).eql([ - { amount: 50, from: 'u2', to: 'u4', total: 50, partial: false, isLate: false, dueOn: '2021-01' }, - { amount: 40, from: 'u1', to: 'u3', total: 50, partial: true, isLate: false, dueOn: '2021-01' } + { amount: 50, fromMemberID: 'u2', toMemberID: 'u4', total: 50, partial: false, isLate: false, dueOn: '2021-01' }, + { amount: 40, fromMemberID: 'u1', toMemberID: 'u3', total: 50, partial: true, isLate: false, dueOn: '2021-01' } ]) }) it('EVENT: a payment of $40 is made from u1 to u3.', function () { - setup.push({ type: 'paymentEvent', data: { from: 'u1', to: 'u3', amount: 40 } }) + setup.push({ type: 'paymentEvent', data: { fromMemberID: 'u1', toMemberID: 'u3', amount: 40 } }) should(distributionWrapper(setup, { adjusted: true })).eql([ - { amount: 50, from: 'u2', to: 'u4', total: 50, partial: false, isLate: false, dueOn: '2021-01' } + { amount: 50, fromMemberID: 'u2', toMemberID: 'u4', total: 50, partial: false, isLate: false, dueOn: '2021-01' } ]) }) }) diff --git a/frontend/model/contracts/shared/distribution/mincome-proportional.js b/frontend/model/contracts/shared/distribution/mincome-proportional.js index b6db8a141..09db2a8d6 100644 --- a/frontend/model/contracts/shared/distribution/mincome-proportional.js +++ b/frontend/model/contracts/shared/distribution/mincome-proportional.js @@ -1,7 +1,7 @@ 'use strict' export type HaveNeedObject = { - name: string; + memberID: string; haveNeed: number } @@ -27,8 +27,8 @@ export default function mincomeProportional (haveNeeds: Array): const belowPercentage = Math.abs(needer.haveNeed) / totalNeed payments.push({ amount: distributionAmount * belowPercentage, - from: haver.name, - to: needer.name + fromMemberID: haver.memberID, + toMemberID: needer.memberID }) } } diff --git a/frontend/model/contracts/shared/distribution/mincome-proportional.test.js b/frontend/model/contracts/shared/distribution/mincome-proportional.test.js index ab7050cf5..6a65097b0 100644 --- a/frontend/model/contracts/shared/distribution/mincome-proportional.test.js +++ b/frontend/model/contracts/shared/distribution/mincome-proportional.test.js @@ -6,20 +6,20 @@ import mincomeProportional from './mincome-proportional.js' describe('proportionalMincomeDistributionTest', function () { it('distribute income above mincome proportionally', function () { const members = [ - { name: 'a', haveNeed: -30 }, - { name: 'b', haveNeed: -20 }, - { name: 'c', haveNeed: 0 }, - { name: 'd', haveNeed: 10 }, - { name: 'e', haveNeed: 30 }, - { name: 'f', haveNeed: 60 } + { memberID: 'a', haveNeed: -30 }, + { memberID: 'b', haveNeed: -20 }, + { memberID: 'c', haveNeed: 0 }, + { memberID: 'd', haveNeed: 10 }, + { memberID: 'e', haveNeed: 30 }, + { memberID: 'f', haveNeed: 60 } ] const expected = [ - { amount: 3, from: 'd', to: 'a' }, - { amount: 2, from: 'd', to: 'b' }, - { amount: 9, from: 'e', to: 'a' }, - { amount: 6, from: 'e', to: 'b' }, - { amount: 18, from: 'f', to: 'a' }, - { amount: 12, from: 'f', to: 'b' } + { amount: 3, fromMemberID: 'd', toMemberID: 'a' }, + { amount: 2, fromMemberID: 'd', toMemberID: 'b' }, + { amount: 9, fromMemberID: 'e', toMemberID: 'a' }, + { amount: 6, fromMemberID: 'e', toMemberID: 'b' }, + { amount: 18, fromMemberID: 'f', toMemberID: 'a' }, + { amount: 12, fromMemberID: 'f', toMemberID: 'b' } ] should(mincomeProportional(members)).eql(expected) @@ -27,32 +27,32 @@ describe('proportionalMincomeDistributionTest', function () { it('distribute income above mincome proportionally when extra won\'t cover need', function () { const members = [ - { name: 'a', haveNeed: -30 }, - { name: 'b', haveNeed: -20 }, - { name: 'c', haveNeed: 0 }, - { name: 'd', haveNeed: 4 }, - { name: 'e', haveNeed: 4 }, - { name: 'f', haveNeed: 10 } + { memberID: 'a', haveNeed: -30 }, + { memberID: 'b', haveNeed: -20 }, + { memberID: 'c', haveNeed: 0 }, + { memberID: 'd', haveNeed: 4 }, + { memberID: 'e', haveNeed: 4 }, + { memberID: 'f', haveNeed: 10 } ] const expected = [ - { amount: 2.4, from: 'd', to: 'a' }, - { amount: 1.6, from: 'd', to: 'b' }, - { amount: 2.4, from: 'e', to: 'a' }, - { amount: 1.6, from: 'e', to: 'b' }, - { amount: 6, from: 'f', to: 'a' }, - { amount: 4, from: 'f', to: 'b' } + { amount: 2.4, fromMemberID: 'd', toMemberID: 'a' }, + { amount: 1.6, fromMemberID: 'd', toMemberID: 'b' }, + { amount: 2.4, fromMemberID: 'e', toMemberID: 'a' }, + { amount: 1.6, fromMemberID: 'e', toMemberID: 'b' }, + { amount: 6, fromMemberID: 'f', toMemberID: 'a' }, + { amount: 4, fromMemberID: 'f', toMemberID: 'b' } ] should(mincomeProportional(members)).eql(expected) }) it('don\'t distribute anything if no one is above mincome', function () { const members = [ - { name: 'a', haveNeed: -30 }, - { name: 'b', haveNeed: -20 }, - { name: 'c', haveNeed: 0 }, - { name: 'd', haveNeed: -5 }, - { name: 'e', haveNeed: -20 }, - { name: 'f', haveNeed: -30 } + { memberID: 'a', haveNeed: -30 }, + { memberID: 'b', haveNeed: -20 }, + { memberID: 'c', haveNeed: 0 }, + { memberID: 'd', haveNeed: -5 }, + { memberID: 'e', haveNeed: -20 }, + { memberID: 'f', haveNeed: -30 } ] const expected = [] should(mincomeProportional(members)).eql(expected) @@ -60,12 +60,12 @@ describe('proportionalMincomeDistributionTest', function () { it('don\'t distribute anything if everyone is above mincome', function () { const members = [ - { name: 'a', haveNeed: 0 }, - { name: 'b', haveNeed: 5 }, - { name: 'c', haveNeed: 0 }, - { name: 'd', haveNeed: 10 }, - { name: 'e', haveNeed: 60 }, - { name: 'f', haveNeed: 12 } + { memberID: 'a', haveNeed: 0 }, + { memberID: 'b', haveNeed: 5 }, + { memberID: 'c', haveNeed: 0 }, + { memberID: 'd', haveNeed: 10 }, + { memberID: 'e', haveNeed: 60 }, + { memberID: 'f', haveNeed: 12 } ] const expected = [] should(mincomeProportional(members)).eql(expected) diff --git a/frontend/model/contracts/shared/distribution/payments-minimizer.js b/frontend/model/contracts/shared/distribution/payments-minimizer.js index 5e401ef89..334bb73a6 100644 --- a/frontend/model/contracts/shared/distribution/payments-minimizer.js +++ b/frontend/model/contracts/shared/distribution/payments-minimizer.js @@ -4,21 +4,21 @@ // such that the least number of payments are made. export default function minimizeTotalPaymentsCount ( distribution: Array -): Array { +): Array { const neederTotalReceived = {} const haverTotalHave = {} const haversSorted = [] const needersSorted = [] const minimizedDistribution = [] for (const todo of distribution) { - neederTotalReceived[todo.to] = (neederTotalReceived[todo.to] || 0) + todo.amount - haverTotalHave[todo.from] = (haverTotalHave[todo.from] || 0) + todo.amount + neederTotalReceived[todo.toMemberID] = (neederTotalReceived[todo.toMemberID] || 0) + todo.amount + haverTotalHave[todo.fromMemberID] = (haverTotalHave[todo.fromMemberID] || 0) + todo.amount } - for (const name in haverTotalHave) { - haversSorted.push({ name, amount: haverTotalHave[name] }) + for (const memberID in haverTotalHave) { + haversSorted.push({ memberID, amount: haverTotalHave[memberID] }) } - for (const name in neederTotalReceived) { - needersSorted.push({ name, amount: neederTotalReceived[name] }) + for (const memberID in neederTotalReceived) { + needersSorted.push({ memberID, amount: neederTotalReceived[memberID] }) } // sort haves and needs: greatest to least haversSorted.sort((a, b) => b.amount - a.amount) @@ -29,17 +29,17 @@ export default function minimizeTotalPaymentsCount ( const diff = mostHaver.amount - mostNeeder.amount if (diff < 0) { // we used up everything the haver had - minimizedDistribution.push({ amount: mostHaver.amount, from: mostHaver.name, to: mostNeeder.name }) + minimizedDistribution.push({ amount: mostHaver.amount, fromMemberID: mostHaver.memberID, toMemberID: mostNeeder.memberID }) mostNeeder.amount -= mostHaver.amount needersSorted.push(mostNeeder) } else if (diff > 0) { // we completely filled up the needer's need and still have some left over - minimizedDistribution.push({ amount: mostNeeder.amount, from: mostHaver.name, to: mostNeeder.name }) + minimizedDistribution.push({ amount: mostNeeder.amount, fromMemberID: mostHaver.memberID, toMemberID: mostNeeder.memberID }) mostHaver.amount -= mostNeeder.amount haversSorted.push(mostHaver) } else { // a perfect match - minimizedDistribution.push({ amount: mostNeeder.amount, from: mostHaver.name, to: mostNeeder.name }) + minimizedDistribution.push({ amount: mostNeeder.amount, fromMemberID: mostHaver.memberID, toMemberID: mostNeeder.memberID }) } } return minimizedDistribution diff --git a/frontend/model/contracts/shared/functions.js b/frontend/model/contracts/shared/functions.js index e84e93603..2665c5449 100644 --- a/frontend/model/contracts/shared/functions.js +++ b/frontend/model/contracts/shared/functions.js @@ -25,9 +25,9 @@ export function paymentHashesFromPaymentPeriod (periodPayments: Object): string[ let hashes = [] if (periodPayments) { const { paymentsFrom } = periodPayments - for (const fromUser in paymentsFrom) { - for (const toUser in paymentsFrom[fromUser]) { - hashes = hashes.concat(paymentsFrom[fromUser][toUser]) + for (const fromMemberID in paymentsFrom) { + for (const toMemberID in paymentsFrom[fromMemberID]) { + hashes = hashes.concat(paymentsFrom[fromMemberID][toMemberID]) } } } @@ -36,11 +36,11 @@ export function paymentHashesFromPaymentPeriod (periodPayments: Object): string[ } export function createPaymentInfo (paymentHash: string, payment: Object): { - from: string, to: string, hash: string, amount: number, isLate: boolean, when: string + fromMemberID: string, toMemberID: string, hash: string, amount: number, isLate: boolean, when: string } { return { - from: payment.meta.username, - to: payment.data.toUser, + fromMemberID: payment.data.fromMemberID, + toMemberID: payment.data.toMemberID, hash: paymentHash, amount: payment.data.amount, isLate: !!payment.data.isLate, @@ -50,8 +50,8 @@ export function createPaymentInfo (paymentHash: string, payment: Object): { // chatroom.js related -export function createMessage ({ meta, data, hash, height, state, pending }: { - meta: Object, data: Object, hash: string, height: number, state?: Object, pending?: boolean +export function createMessage ({ meta, data, hash, height, state, pending, innerSigningContractID }: { + meta: Object, data: Object, hash: string, height: number, state?: Object, pending?: boolean, innerSigningContractID?: String }): Object { const { type, text, replyingMessage } = data const { createdDate } = meta @@ -60,7 +60,7 @@ export function createMessage ({ meta, data, hash, height, state, pending }: { type, hash, height, - from: meta.username, + from: innerSigningContractID, datetime: new Date(createdDate).toISOString(), pending } @@ -74,7 +74,7 @@ export function createMessage ({ meta, data, hash, height, state, pending }: { ...newMessage, pollData: { ...pollData, - creator: meta.username, + creatorID: innerSigningContractID, status: POLL_STATUS.ACTIVE, // 'voted' field below will contain the user names of the users who has voted for this option options: pollData.options.map(opt => ({ ...opt, voted: [] })) @@ -130,11 +130,28 @@ export function findMessageIdx (hash: string, messages: Array): number { return -1 } -export function makeMentionFromUsername (username: string): { +// This function serves two purposes, depending on the forceUsername parameter +// If forceUsername is true, mentions will be like @username, @all, for display +// purposes. +// If forceUsername is false (default), mentions like @username will be converted +// to @, for internal representation purposes. +// forceUsername is used for display purposes in the UI, so that we can show +// a mention like @username instead of @userID in SendArea +export function makeMentionFromUsername (username: string, forceUsername: ?boolean): { + me: string, all: string +} { + const rootGetters = sbp('state/vuex/getters') + // Even if forceUsername is true, we want to look up the contract ID to ensure + // that it exists, so that we know it'll later succeed. + const userID = rootGetters.ourContactProfiles[username]?.contractID + return makeMentionFromUserID(forceUsername && userID ? username : userID) +} + +export function makeMentionFromUserID (userID: string): { me: string, all: string } { return { - me: `@${username}`, + me: userID ? `@${userID}` : '', all: '@all' } } diff --git a/frontend/model/contracts/shared/types.js b/frontend/model/contracts/shared/types.js index 632daf6d8..d38cc0886 100644 --- a/frontend/model/contracts/shared/types.js +++ b/frontend/model/contracts/shared/types.js @@ -13,7 +13,7 @@ import { export const inviteType: any = objectOf({ inviteKeyId: string, - creator: string, + creatorID: string, invitee: optional(string) }) @@ -22,11 +22,20 @@ export const inviteType: any = objectOf({ export const chatRoomAttributesType: any = objectOf({ name: string, description: string, + creatorID: string, type: unionOf(...Object.values(CHATROOM_TYPES).map(v => literalOf(v))), privacyLevel: unionOf(...Object.values(CHATROOM_PRIVACY_LEVEL).map(v => literalOf(v))), groupContractID: optional(string) }) +export const groupChatRoomAttributesType: any = objectOf({ + name: string, + description: string, + type: literalOf(CHATROOM_TYPES.GROUP), + privacyLevel: unionOf(...Object.values(CHATROOM_PRIVACY_LEVEL).map(v => literalOf(v))), + groupContractID: optional(string) +}) + export const messageType: any = objectMaybeOf({ type: unionOf(...Object.values(MESSAGE_TYPES).map(v => literalOf(v))), text: string, // message text | notificationType when type if NOTIFICATION @@ -35,7 +44,7 @@ export const messageType: any = objectMaybeOf({ proposalType: string, expires_date_ms: number, createdDate: string, - creator: string, + creatorID: string, variant: unionOf(...Object.values(PROPOSAL_VARIANTS).map(v => literalOf(v))) }), notification: objectMaybeOf({ diff --git a/frontend/model/contracts/shared/voting/proposals.js b/frontend/model/contracts/shared/voting/proposals.js index 2565d1136..1c6b93a55 100644 --- a/frontend/model/contracts/shared/voting/proposals.js +++ b/frontend/model/contracts/shared/voting/proposals.js @@ -29,8 +29,20 @@ export function archiveProposal ( ) } -export function buildInvitationUrl (groupId: string, groupName: string, inviteSecret: string, creator?: string): string { - return `${location.origin}/app/join?${(new URLSearchParams({ groupId: groupId, groupName: groupName, secret: inviteSecret, creator: creator || '' })).toString()}` +export function buildInvitationUrl (groupId: string, groupName: string, inviteSecret: string, creatorID?: string): string { + const rootGetters = sbp('state/vuex/getters') + const creatorUsername = creatorID && rootGetters.usernameFromID(creatorID) + return `${location.origin}/app/join#?${(new URLSearchParams({ + groupId: groupId, + groupName: groupName, + secret: inviteSecret, + ...(creatorID && { + creatorID, + ...(creatorUsername && { + creatorUsername + }) + }) + })).toString()}` } export const proposalSettingsType: any = objectOf({ @@ -81,15 +93,16 @@ export const proposalDefaults = { const proposals: Object = { [PROPOSAL_INVITE_MEMBER]: { defaults: proposalDefaults, - [VOTE_FOR]: function (state, { meta, data, contractID }) { + [VOTE_FOR]: function (state, message) { + const { data, contractID } = message const { proposalHash } = data const proposal = state.proposals[proposalHash] proposal.payload = data.passPayload proposal.status = STATUS_PASSED // NOTE: if invite/process requires more than just data+meta // this code will need to be updated... - const message = { meta, data: data.passPayload, contractID } - sbp('gi.contracts/group/invite/process', message, state) + const forMessage = { ...message, data: data.passPayload } + sbp('gi.contracts/group/invite/process', forMessage, state) sbp('okTurtles.events/emit', PROPOSAL_RESULT, state, VOTE_FOR, data) archiveProposal({ state, proposalHash, proposal, contractID }) // TODO: for now, generate the link and send it to the user's inbox @@ -127,20 +140,17 @@ const proposals: Object = { }, [PROPOSAL_REMOVE_MEMBER]: { defaults: proposalDefaults, - [VOTE_FOR]: function (state, { meta, data, contractID }) { + [VOTE_FOR]: function (state, message) { + const { data, contractID } = message const { proposalHash, passPayload } = data const proposal = state.proposals[proposalHash] proposal.status = STATUS_PASSED proposal.payload = passPayload - const messageData = { - ...proposal.data.proposalData, - proposalHash, - proposalPayload: passPayload - } - const message = { data: messageData, meta, contractID } - sbp('gi.contracts/group/removeMember/process', message, state) + const messageData = proposal.data.proposalData + const forMessage = { ...message, data: messageData, proposalHash } + sbp('gi.contracts/group/removeMember/process', forMessage, state) sbp('gi.contracts/group/pushSideEffect', contractID, - ['gi.contracts/group/removeMember/sideEffect', message] + ['gi.contracts/group/removeMember/sideEffect', forMessage] ) archiveProposal({ state, proposalHash, proposal, contractID }) }, @@ -148,44 +158,46 @@ const proposals: Object = { }, [PROPOSAL_GROUP_SETTING_CHANGE]: { defaults: proposalDefaults, - [VOTE_FOR]: function (state, { meta, data, contractID }) { + [VOTE_FOR]: function (state, message) { + const { data, contractID } = message const { proposalHash } = data const proposal = state.proposals[proposalHash] proposal.status = STATUS_PASSED const { setting, proposedValue } = proposal.data.proposalData // NOTE: if updateSettings ever needs more ethana just meta+data // this code will need to be updated - const message = { - meta, + const forMessage = { + ...message, data: { [setting]: proposedValue }, - contractID + proposalHash } - sbp('gi.contracts/group/updateSettings/process', message, state) + sbp('gi.contracts/group/updateSettings/process', forMessage, state) sbp('gi.contracts/group/pushSideEffect', contractID, - ['gi.contracts/group/updateSettings/sideEffect', { ...message, meta: { ...message.meta, username: proposal.meta.username } }]) + ['gi.contracts/group/updateSettings/sideEffect', forMessage]) archiveProposal({ state, proposalHash, proposal, contractID }) }, [VOTE_AGAINST]: voteAgainst }, [PROPOSAL_PROPOSAL_SETTING_CHANGE]: { defaults: proposalDefaults, - [VOTE_FOR]: function (state, { meta, data, contractID }) { + [VOTE_FOR]: function (state, message) { + const { data, contractID } = message const { proposalHash } = data const proposal = state.proposals[proposalHash] proposal.status = STATUS_PASSED - const message = { - meta, + const forMessage = { + ...message, data: proposal.data.proposalData, - contractID + proposalHash } - sbp('gi.contracts/group/updateAllVotingRules/process', message, state) + sbp('gi.contracts/group/updateAllVotingRules/process', forMessage, state) archiveProposal({ state, proposalHash, proposal, contractID }) }, [VOTE_AGAINST]: voteAgainst }, [PROPOSAL_GENERIC]: { defaults: proposalDefaults, - [VOTE_FOR]: function (state, { meta, data, contractID }) { + [VOTE_FOR]: function (state, { data, contractID }) { const { proposalHash } = data const proposal = state.proposals[proposalHash] proposal.status = STATUS_PASSED diff --git a/frontend/model/notifications/mainNotificationsMixin.js b/frontend/model/notifications/mainNotificationsMixin.js index d43c9486d..61b354183 100644 --- a/frontend/model/notifications/mainNotificationsMixin.js +++ b/frontend/model/notifications/mainNotificationsMixin.js @@ -116,7 +116,7 @@ const periodicNotificationEntries = [ sbp('gi.notifications/emit', 'NEW_DISTRIBUTION_PERIOD', { createdDate: new Date().toISOString(), groupID: rootState.currentGroupId, - creator: rootGetters.ourUsername, + creatorID: rootGetters.ourIdentityContractId, memberType: rootGetters.ourGroupProfile.incomeDetailsType === 'pledgeAmount' ? 'pledger' : 'receiver' }) }, @@ -151,16 +151,16 @@ const periodicNotificationEntries = [ proposalData: proposal.data.proposalData, expires_date_ms: proposal.data.expires_date_ms, createdDate: proposal.meta.createdDate, - creator: proposal.meta.username + creatorID: proposal.creatorID }) } - if (!Object.keys(proposal.votes).includes(rootGetters.ourUsername) && // check if the user hasn't voted for this proposal. + if (!Object.keys(proposal.votes).includes(rootGetters.ourIdentityContractId) && // check if the user hasn't voted for this proposal. !myNotificationHas(item => item.type === 'PROPOSAL_EXPIRING' && item.data.proposalId === proposalId, contractID) // the user hasn't received the pop-up notification. ) { groupNotificationItems.push({ proposalId, - creator: proposal.meta.username, + creatorID: proposal.creatorID, proposalType: proposal.data.proposalType, proposalData: proposal.data.proposalData }) @@ -187,7 +187,7 @@ const periodicNotificationEntries = [ sbp('gi.notifications/emit', 'PROPOSAL_EXPIRING', { createdDate: new Date().toISOString(), groupID: contractID, - creator: proposal.creator, + creatorID: proposal.creatorID, proposalId: proposal.proposalId, proposalType: proposal.proposalType, proposalData: proposal.proposalData, diff --git a/frontend/model/notifications/selectors.js b/frontend/model/notifications/selectors.js index b9061d640..82a46f1a6 100644 --- a/frontend/model/notifications/selectors.js +++ b/frontend/model/notifications/selectors.js @@ -21,7 +21,7 @@ sbp('sbp/selectors/register', { // Creates the notification object in a single step. const notification = { - avatarUsername: template.avatarUsername || sbp('state/vuex/getters').ourUsername, + avatarUserID: template.avatarUserID || sbp('state/vuex/getters').ourIdentityContractId, ...template, // Sets 'groupID' if this notification only pertains to a certain group. ...(template.scope === 'group' ? { groupID: data.groupID } : {}), diff --git a/frontend/model/notifications/templates.js b/frontend/model/notifications/templates.js index ee11b8cab..45e692d9a 100644 --- a/frontend/model/notifications/templates.js +++ b/frontend/model/notifications/templates.js @@ -12,6 +12,7 @@ import { } from '@model/contracts/shared/constants.js' const contractName = (contractID) => sbp('state/vuex/state').contracts[contractID]?.type ?? contractID +const usernameFromID = (userID) => sbp('state/vuex/getters').usernameFromID(userID) // Note: this escaping is not intended as a protection against XSS. // It is only done to enable correct rendering of special characters in usernames. // To guard against XSS when rendering usernames, use the `v-safe-html` directive. @@ -97,25 +98,25 @@ export default ({ data: { lastUpdatedDate: data.lastUpdatedDate } } }, - MEMBER_ADDED (data: { groupID: string, username: string }) { + MEMBER_ADDED (data: { groupID: string, memberID: string }) { const rootState = sbp('state/vuex/state') + const name = strong(usernameFromID(data.memberID)) return { - avatarUsername: data.username, - body: L('The group has a new member. Say hi to {name}!', { - name: strong(data.username) - }), + avatarUserID: data.memberID, + body: L('The group has a new member. Say hi to {name}!', { name }), icon: 'user-plus', level: 'info', linkTo: `/group-chat/${rootState[data.groupID]?.generalChatRoomId}`, scope: 'group' } }, - MEMBER_LEFT (data: { groupID: string, username: string }) { + MEMBER_LEFT (data: { groupID: string, memberID: string }) { + const name = strong(usernameFromID(data.memberID)) return { - avatarUsername: data.username, + avatarUserID: data.memberID, body: L('{name} has left your group. Contributions were updated accordingly.', { - name: strong(data.username) + name }), icon: 'user-minus', level: 'danger', @@ -123,12 +124,13 @@ export default ({ scope: 'group' } }, - MEMBER_REMOVED (data: { groupID: string, username: string }) { + MEMBER_REMOVED (data: { groupID: string, memberID: string }) { + const name = strong(usernameFromID(data.memberID)) return { - avatarUsername: data.username, + avatarUserID: data.memberID, // REVIEW @mmbotelho - Not only contributions, but also proposals. body: L('{name} was kicked out of the group. Contributions were updated accordingly.', { - name: strong(data.username) + name }), icon: 'user-minus', level: 'danger', @@ -136,14 +138,15 @@ export default ({ scope: 'group' } }, - NEW_PROPOSAL (data: { groupID: string, creator: string, subtype: NewProposalType }) { + NEW_PROPOSAL (data: { groupID: string, creatorID: string, subtype: NewProposalType }) { + const name = strong(usernameFromID(data.creatorID)) const bodyTemplateMap = { - ADD_MEMBER: (creator: string) => L('{member} proposed to add a member to the group. Vote now!', { member: strong(creator) }), - CHANGE_MINCOME: (creator: string) => L('{member} proposed to change the group mincome. Vote now!', { member: strong(creator) }), - CHANGE_DISTRIBUTION_DATE: (creator: string) => L('{member} proposed to change the group distribution date. Vote now!', { member: strong(creator) }), - CHANGE_VOTING_RULE: (creator: string) => L('{member} proposed to change the group voting system. Vote now!', { member: strong(creator) }), - REMOVE_MEMBER: (creator: string) => L('{member} proposed to remove a member from the group. Vote now!', { member: strong(creator) }), - GENERIC: (creator: string) => L('{member} created a proposal. Vote now!', { member: strong(creator) }) + ADD_MEMBER: () => L('{name} proposed to add a member to the group. Vote now!', { name }), + CHANGE_MINCOME: () => L('{name} proposed to change the group mincome. Vote now!', { name }), + CHANGE_DISTRIBUTION_DATE: () => L('{name} proposed to change the group distribution date. Vote now!', { name }), + CHANGE_VOTING_RULE: () => L('{name} proposed to change the group voting system. Vote now!', { name }), + REMOVE_MEMBER: () => L('{name} proposed to remove a member from the group. Vote now!', { name }), + GENERIC: () => L('{name} created a proposal. Vote now!', { name }) } const iconMap = { @@ -156,9 +159,9 @@ export default ({ } return { - avatarUsername: data.creator, - body: bodyTemplateMap[data.subtype](data.creator), - creator: data.creator, + avatarUserID: data.creatorID, + body: bodyTemplateMap[data.subtype](), + creatorID: data.creatorID, icon: iconMap[data.subtype], level: 'info', linkTo: '/dashboard#proposals', @@ -166,7 +169,7 @@ export default ({ scope: 'group' } }, - PROPOSAL_EXPIRING (data: { creator: string, proposalType: string, proposalData: any, title?: string, proposalId: string }) { + PROPOSAL_EXPIRING (data: { creatorID: string, proposalType: string, proposalData: any, title?: string, proposalId: string }) { const typeToTitleMap = { [PROPOSAL_INVITE_MEMBER]: L('Member addition'), [PROPOSAL_REMOVE_MEMBER]: L('Member removal'), @@ -179,7 +182,7 @@ export default ({ } return { - avatarUsername: data.creator, + avatarUserID: data.creatorID, body: L('Proposal about to expire: {i_}"{proposalTitle}"{_i}. please vote!', { ...LTags('i'), proposalTitle: typeToTitleMap[data.proposalType] @@ -191,59 +194,60 @@ export default ({ data: { proposalId: data.proposalId } } }, - PROPOSAL_CLOSED (data: { groupID: string, creator: string, proposalStatus: string }) { + PROPOSAL_CLOSED (data: { groupID: string, creatorID: string, proposalStatus: string }) { + const name = strong(usernameFromID(data.creatorID)) const bodyTemplateMap = { // TODO: needs various messages depending on the proposal type? TBD by team. - [STATUS_PASSED]: (creator: string) => L("{member}'s proposal has passed.", { member: strong(creator) }), - [STATUS_FAILED]: (creator: string) => L("{member}'s proposal has failed.", { member: strong(creator) }) + [STATUS_PASSED]: () => L("{name}'s proposal has passed.", { name }), + [STATUS_FAILED]: () => L("{name}'s proposal has failed.", { name }) } return { - avatarUsername: data.creator, - body: bodyTemplateMap[data.proposalStatus](data.creator), + avatarUserID: data.creatorID, + body: bodyTemplateMap[data.proposalStatus](), icon: 'cog', // TODO : to be decided. level: 'info', linkTo: '/dashboard#proposals', scope: 'group' } }, - PAYMENT_RECEIVED (data: { creator: string, amount: string, paymentHash: string }) { - const { userDisplayName } = sbp('state/vuex/getters') + PAYMENT_RECEIVED (data: { creatorID: string, amount: string, paymentHash: string }) { + const { userDisplayNameFromID } = sbp('state/vuex/getters') return { - avatarUsername: data.creator, - body: L('{fromUser} sent you a {amount} mincome contribution. {strong_}Review and send a thank you note.{_strong}', { - fromUser: userDisplayName(data.creator), // displayName of the sender + avatarUserID: data.creatorID, + body: L('{name} sent you a {amount} mincome contribution. {strong_}Review and send a thank you note.{_strong}', { + name: userDisplayNameFromID(data.creatorID), // displayName of the sender amount: data.amount, ...LTags('strong') }), - creator: data.creator, + creatorID: data.creatorID, icon: '', level: 'info', linkTo: `/payments?modal=PaymentDetail&id=${data.paymentHash}`, scope: 'group' } }, - PAYMENT_THANKYOU_SENT (data: { creator: string, fromUser: string, toUser: string }) { + PAYMENT_THANKYOU_SENT (data: { creatorID: string, fromMemberID: string, toMemberID: string }) { return { - avatarUsername: data.creator, + avatarUserID: data.creatorID, body: L('{name} sent you a {strong_}thank you note{_strong} for your contribution.', { - name: strong(data.fromUser), + name: strong(usernameFromID(data.fromMemberID)), ...LTags('strong') }), - creator: data.creator, + creatorID: data.creatorID, icon: '', level: 'info', - linkTo: `/payments?modal=ThankYouNoteModal&from=${data.fromUser}&to=${data.toUser}`, + linkTo: `/payments?modal=ThankYouNoteModal&from=${data.fromMemberID}&to=${data.toMemberID}`, scope: 'group' } }, - MINCOME_CHANGED (data: { creator: string, to: number, memberType: string, increased: boolean }) { + MINCOME_CHANGED (data: { creatorID: string, to: number, memberType: string, increased: boolean }) { const { withGroupCurrency } = sbp('state/vuex/getters') return { - avatarUsername: data.creator, + avatarUserID: data.creatorID, body: L('The mincome has changed to {amount}.', { amount: withGroupCurrency(data.to) }), - creator: data.creator, + creatorID: data.creatorID, icon: '', level: 'info', scope: 'group', @@ -257,14 +261,14 @@ export default ({ }] } }, - NEW_DISTRIBUTION_PERIOD (data: { creator: string, memberType: string }) { + NEW_DISTRIBUTION_PERIOD (data: { creatorID: string, memberType: string }) { const bodyTemplate = { 'pledger': L('A new distribution period has started. Please check Payment TODOs.'), 'receiver': L('A new distribution period has started. Please update your income details if they have changed.') } return { - avatarUsername: data.creator, + avatarUserID: data.creatorID, body: bodyTemplate[data.memberType], level: 'info', icon: 'coins', diff --git a/frontend/model/notifications/types.flow.js b/frontend/model/notifications/types.flow.js index c69b989bf..b6d4fb1ff 100644 --- a/frontend/model/notifications/types.flow.js +++ b/frontend/model/notifications/types.flow.js @@ -9,7 +9,7 @@ export type NewProposalType = export type Notification = { // Indicates which user avatar icon to display alongside the notification. - +avatarUsername: string; + +avatarUserID: string; +body: string; // If present, indicates in which group's notification list to display the notification. +groupID?: string; @@ -34,7 +34,7 @@ export type NotificationLevel = 'danger' | 'info'; export type NotificationScope = 'group' | 'user' | 'app'; export type NotificationTemplate = { - +avatarUsername?: string; + +avatarUserID?: string; +body: string; +icon: string; +level: NotificationLevel; diff --git a/frontend/model/state.js b/frontend/model/state.js index d04f14da5..ede5017e9 100644 --- a/frontend/model/state.js +++ b/frontend/model/state.js @@ -9,7 +9,6 @@ import { EVENT_HANDLED, CONTRACT_REGISTERED } from '~/shared/domains/chelonia/ev import { LOGOUT } from '~/frontend/utils/events.js' import Vuex from 'vuex' import { MESSAGE_NOTIFY_SETTINGS, MESSAGE_TYPES, INVITE_INITIAL_CREATOR } from '@model/contracts/shared/constants.js' -import { compareISOTimestamps } from '@model/contracts/shared/time.js' import { PAYMENT_NOT_RECEIVED } from '@model/contracts/shared/payments/index.js' import { omit, merge, cloneDeep, debounce, union } from '@model/contracts/shared/giLodash.js' import { unadjustedDistribution, adjustedDistribution } from '@model/contracts/shared/distribution/distribution.js' @@ -220,18 +219,18 @@ const getters = { return state.loggedIn && state.loggedIn.username }, ourProfileActive (state, getters) { - return getters.profileActive(getters.ourUsername) + return getters.profileActive(getters.ourIdentityContractId) }, ourPendingAccept (state, getters) { - return getters.pendingAccept(getters.ourUsername) + return getters.pendingAccept(getters.ourIdentityContractId) }, ourGroupProfile (state, getters) { - return getters.groupProfile(getters.ourUsername) + return getters.groupProfile(getters.ourIdentityContractId) }, ourUserDisplayName (state, getters) { // TODO - refactor Profile and Welcome and any other component that needs this const userContract = getters.currentIdentityState || {} - return (userContract.attributes && userContract.attributes.displayName) || getters.ourUsername + return (userContract.attributes && userContract.attributes.displayName) || getters.ourUsername || getters.ourIdentityContractId }, ourIdentityContractId (state) { return state.loggedIn && state.loggedIn.identityContractID @@ -241,7 +240,7 @@ const getters = { // into group.js ourContributionSummary (state, getters) { const groupProfiles = getters.groupProfiles - const ourUsername = getters.ourUsername + const ourIdentityContractId = getters.ourIdentityContractId const ourGroupProfile = getters.ourGroupProfile if (!ourGroupProfile || !ourGroupProfile.incomeDetailsType) { @@ -251,17 +250,16 @@ const getters = { const doWeNeedIncome = ourGroupProfile.incomeDetailsType === 'incomeAmount' const distribution = getters.groupIncomeDistribution - const nonMonetaryContributionsOf = (username) => groupProfiles[username].nonMonetaryContributions || [] - const getDisplayName = (username) => getters.globalProfile(username)?.displayName || username + const nonMonetaryContributionsOf = (memberID) => groupProfiles[memberID].nonMonetaryContributions || [] return { givingMonetary: (() => { if (doWeNeedIncome) { return null } const who = [] const total = distribution - .filter(p => p.from === ourUsername) + .filter(p => p.fromMemberID === ourIdentityContractId) .reduce((acc, payment) => { - who.push(getDisplayName(payment.to)) + who.push(getters.userDisplayNameFromID(payment.toMemberID)) return acc + payment.amount }, 0) @@ -272,9 +270,9 @@ const getters = { const needed = getters.groupSettings.mincomeAmount - ourGroupProfile.incomeAmount const who = [] const total = distribution - .filter(p => p.to === ourUsername) + .filter(p => p.toMemberID === ourIdentityContractId) .reduce((acc, payment) => { - who.push(getDisplayName(payment.from)) + who.push(getters.userDisplayNameFromID(payment.fromMemberID)) return acc + payment.amount }, 0) @@ -282,10 +280,10 @@ const getters = { })(), receivingNonMonetary: (() => { const listWho = Object.keys(groupProfiles) - .filter(username => username !== ourUsername && nonMonetaryContributionsOf(username).length > 0) - const listWhat = listWho.reduce((contr, username) => { - const displayName = getDisplayName(username) - const userContributions = nonMonetaryContributionsOf(username) + .filter(memberID => memberID !== ourIdentityContractId && nonMonetaryContributionsOf(memberID).length > 0) + const listWhat = listWho.reduce((contr, memberID) => { + const displayName = getters.userDisplayNameFromID(memberID) + const userContributions = nonMonetaryContributionsOf(memberID) userContributions.forEach((what) => { const contributionIndex = contr.findIndex(c => c.what === what) @@ -305,13 +303,19 @@ const getters = { })() } }, - userDisplayName (state, getters) { - return (username) => { - if (username === getters.ourUsername) { + usernameFromID (state, getters) { + return (userID) => { + const profile = getters.ourContactProfilesById[userID] + return profile?.username || userID + } + }, + userDisplayNameFromID (state, getters) { + return (user) => { + if (user === getters.ourIdentityContractId) { return getters.ourUserDisplayName } - const profile = getters.ourContactProfiles[username] || {} - return profile.displayName || username + const profile = getters.ourContactProfilesById[user] || {} + return profile.displayName || profile.username || user } }, // this getter gets recomputed automatically according to the setInterval on reactiveDate @@ -324,11 +328,11 @@ const getters = { latePayments (state, getters) { const periodPayments = getters.groupPeriodPayments if (Object.keys(periodPayments).length === 0) return - const ourUsername = getters.ourUsername + const ourIdentityContractId = getters.ourIdentityContractId const pPeriod = getters.periodBeforePeriod(getters.currentPaymentPeriod) const pPayments = periodPayments[pPeriod] if (pPayments) { - return pPayments.lastAdjustedDistribution.filter(todo => todo.from === ourUsername) + return pPayments.lastAdjustedDistribution.filter(todo => todo.fromMemberID === ourIdentityContractId) } }, // used with graphs like those in the dashboard and in the income details modal @@ -363,17 +367,17 @@ const getters = { const thisPeriodPayments = periodPayments[period] const paymentsFrom = thisPeriodPayments && thisPeriodPayments.paymentsFrom if (paymentsFrom) { - const ourUsername = getters.ourUsername + const ourIdentityContractId = getters.ourIdentityContractId const allPayments = getters.currentGroupState.payments - for (const toUser in paymentsFrom[ourUsername]) { - for (const paymentHash of paymentsFrom[ourUsername][toUser]) { - const { data, meta } = allPayments[paymentHash] + for (const toMemberID in paymentsFrom[ourIdentityContractId]) { + for (const paymentHash of paymentsFrom[ourIdentityContractId][toMemberID]) { + const { data, meta, height } = allPayments[paymentHash] - payments.push({ hash: paymentHash, data, meta, amount: data.amount, period }) + payments.push({ hash: paymentHash, height, data, meta, amount: data.amount, period }) } } } - return payments.sort((paymentA, paymentB) => compareISOTimestamps(paymentB.meta.createdDate, paymentA.meta.createdDate)) + return payments.sort((paymentA, paymentB) => paymentB.height - paymentA.height) } }, ourPaymentsReceivedInPeriod (state, getters) { @@ -384,27 +388,27 @@ const getters = { const thisPeriodPayments = periodPayments[period] const paymentsFrom = thisPeriodPayments && thisPeriodPayments.paymentsFrom if (paymentsFrom) { - const ourUsername = getters.ourUsername + const ourIdentityContractId = getters.ourIdentityContractId const allPayments = getters.currentGroupState.payments - for (const fromUser in paymentsFrom) { - for (const toUser in paymentsFrom[fromUser]) { - if (toUser === ourUsername) { - for (const paymentHash of paymentsFrom[fromUser][toUser]) { - const { data, meta } = allPayments[paymentHash] + for (const fromMemberID in paymentsFrom) { + for (const toMemberID in paymentsFrom[fromMemberID]) { + if (toMemberID === ourIdentityContractId) { + for (const paymentHash of paymentsFrom[fromMemberID][toMemberID]) { + const { data, meta, height } = allPayments[paymentHash] - payments.push({ hash: paymentHash, data, meta, amount: data.amount }) + payments.push({ hash: paymentHash, height, data, meta, amount: data.amount }) } } } } } - return payments.sort((paymentA, paymentB) => compareISOTimestamps(paymentB.meta.createdDate, paymentA.meta.createdDate)) + return payments.sort((paymentA, paymentB) => paymentB.height - paymentA.height) } }, ourPayments (state, getters) { const periodPayments = getters.groupPeriodPayments if (Object.keys(periodPayments).length === 0) return - const ourUsername = getters.ourUsername + const ourIdentityContractId = getters.ourIdentityContractId const cPeriod = getters.currentPaymentPeriod const pPeriod = getters.periodBeforePeriod(cPeriod) const currentSent = getters.ourPaymentsSentInPeriod(cPeriod) @@ -414,7 +418,7 @@ const getters = { // TODO: take into account pending payments that have been sent but not yet completed const todo = () => { - return getters.groupIncomeAdjustedDistribution.filter(p => p.from === ourUsername) + return getters.groupIncomeAdjustedDistribution.filter(p => p.fromMemberID === ourIdentityContractId) } return { @@ -425,9 +429,9 @@ const getters = { }, ourPaymentsSummary (state, getters) { const isNeeder = getters.ourGroupProfile.incomeDetailsType === 'incomeAmount' - const ourUsername = getters.ourUsername + const ourIdentityContractId = getters.ourIdentityContractId const isOurPayment = (payment) => { - return isNeeder ? payment.to === ourUsername : payment.from === ourUsername + return isNeeder ? payment.toMemberID === ourIdentityContractId : payment.fromMemberID === ourIdentityContractId } const sumUpAmountReducer = (acc, payment) => acc + payment.amount const cPeriod = getters.currentPaymentPeriod @@ -456,7 +460,7 @@ const getters = { }, currentWelcomeInvite (state, getters) { const invites = getters.currentGroupState.invites - const inviteId = Object.keys(invites).find(invite => invites[invite].creator === INVITE_INITIAL_CREATOR) + const inviteId = Object.keys(invites).find(invite => invites[invite].creatorID === INVITE_INITIAL_CREATOR) const expires = getters.currentGroupState._vm.authorizedKeys[inviteId].meta.expires return { inviteId, expires } }, @@ -473,32 +477,38 @@ const getters = { }, groupMembersSorted (state, getters) { const profiles = getters.currentGroupState.profiles - if (!profiles || !profiles[getters.ourUsername]) return [] - const weJoinedMs = new Date(profiles[getters.ourUsername].joinedDate).getTime() - const isNewMember = (username) => { - if (username === getters.ourUsername) { return false } - const memberProfile = profiles[username] + if (!profiles || !profiles[getters.ourIdentityContractId]) return [] + const weJoinedHeight = profiles[getters.ourIdentityContractId].joinedHeight + const isNewMember = (memberID) => { + if (memberID === getters.ourIdentityContractId) { return false } + const memberProfile = profiles[memberID] if (!memberProfile) return false + const memberJoinedHeight = memberProfile.joinedHeight const memberJoinedMs = new Date(memberProfile.joinedDate).getTime() - const joinedAfterUs = weJoinedMs <= memberJoinedMs + const joinedAfterUs = weJoinedHeight < memberJoinedHeight return joinedAfterUs && Date.now() - memberJoinedMs < 604800000 // joined less than 1w (168h) ago. } - return Object.keys({ ...getters.groupMembersPending, ...getters.groupProfiles }) - .filter(username => getters.groupProfiles[username] || - getters.groupMembersPending[username].expires >= Date.now()) - .map(username => { - const { displayName } = getters.globalProfile(username) || {} + const groupMembersPending = getters.groupMembersPending + + // $FlowFixMe[method-unbinding] + return [groupMembersPending, getters.groupProfiles].flatMap(Object.keys) + .filter(memberID => getters.groupProfiles[memberID] || + getters.groupMembersPending[memberID].expires >= Date.now()) + .map(memberID => { + const { contractID, displayName, username } = getters.globalProfile(memberID) || groupMembersPending[memberID] || {} return { + id: memberID, // common unique ID: it can be either the contract ID or the invite key + contractID, username, - displayName: displayName || username, - invitedBy: getters.groupMembersPending[username], - isNew: isNewMember(username) + displayName: displayName || username || memberID, + invitedBy: getters.groupMembersPending[memberID], + isNew: isNewMember(memberID) } }) .sort((userA, userB) => { - const nameA = userA.displayName.toUpperCase() - const nameB = userB.displayName.toUpperCase() + const nameA = userA.displayName.normalize().toUpperCase() + const nameB = userB.displayName.normalize().toUpperCase() // Show pending members first if (userA.invitedBy && !userB.invitedBy) { return -1 } if (!userA.invitedBy && userB.invitedBy) { return 1 } @@ -514,8 +524,8 @@ const getters = { }, globalProfile (state, getters) { // get profile from username who is part of current group - return username => { - return getters.ourContactProfiles[username] + return memberID => { + return getters.ourContactProfilesById[memberID] } }, ourContactProfiles (state, getters) { @@ -537,7 +547,7 @@ const getters = { .forEach(contractID => { const attributes = state[contractID].attributes if (attributes) { // NOTE: this is for fixing the error while syncing the identity contracts - profiles[contractID] = attributes + profiles[contractID] = { ...attributes, contractID } } }) return profiles @@ -545,17 +555,17 @@ const getters = { ourContactsById (state, getters) { return Object.keys(getters.ourContactProfilesById) .sort((userIdA, userIdB) => { - const nameA = ((getters.ourContactProfilesById[userIdA].displayName?.toUpperCase())) || getters.ourContactProfilesById[userIdA].username || userIdA - const nameB = ((getters.ourContactProfilesById[userIdB].displayName?.toUpperCase())) || getters.ourContactProfilesById[userIdB].username || userIdB - return nameA > nameB ? 1 : -1 + const nameA = ((getters.ourContactProfilesById[userIdA].displayName)) || getters.ourContactProfilesById[userIdA].username || userIdA + const nameB = ((getters.ourContactProfilesById[userIdB].displayName)) || getters.ourContactProfilesById[userIdB].username || userIdB + return nameA.normalize().toUpperCase() > nameB.normalize().toUpperCase() ? 1 : -1 }) }, ourContacts (state, getters) { return Object.keys(getters.ourContactProfiles) .sort((usernameA, usernameB) => { - const nameA = getters.ourContactProfiles[usernameA].displayName?.toUpperCase() || usernameA - const nameB = getters.ourContactProfiles[usernameB].displayName?.toUpperCase() || usernameB - return nameA > nameB ? 1 : -1 + const nameA = getters.ourContactProfiles[usernameA].displayName || usernameA + const nameB = getters.ourContactProfiles[usernameB].displayName || usernameB + return nameA.normalize().toUpperCase() > nameB.normalize().toUpperCase() ? 1 : -1 }) }, ourGroupDirectMessages (state, getters) { @@ -565,17 +575,17 @@ const getters = { const directMessageSettings = getters.ourDirectMessages[chatRoomId] // NOTE: skip DMs whose chatroom contracts are not synced yet - if (!chatRoomState || !chatRoomState.users?.[getters.ourUsername]) { + if (!chatRoomState || !chatRoomState.members?.[getters.ourIdentityContractId]) { continue } // NOTE: get only visible DMs for the current group if (directMessageSettings.groupContractID === state.currentGroupId && directMessageSettings.visible) { - const users = Object.keys(chatRoomState.users) - const partners = users - .filter(username => username !== getters.ourUsername) + const members = Object.keys(chatRoomState.members) + const partners = members + .filter(memberID => memberID !== getters.ourIdentityContractId) .sort((p1, p2) => { - const p1JoinedDate = new Date(chatRoomState.users[p1].joinedDate).getTime() - const p2JoinedDate = new Date(chatRoomState.users[p2].joinedDate).getTime() + const p1JoinedDate = new Date(chatRoomState.members[p1].joinedDate).getTime() + const p2JoinedDate = new Date(chatRoomState.members[p2].joinedDate).getTime() return p1JoinedDate - p2JoinedDate }) // NOTE: lastJoinedParter is chatroom member who has joined the chatroom for the last time. @@ -584,14 +594,14 @@ const getters = { const lastJoinedPartner = partners[partners.length - 1] currentGroupDirectMessages[chatRoomId] = { ...directMessageSettings, - users, + members, partners, lastJoinedPartner, // TODO: The UI should display display names, usernames and (in the future) // identity contract IDs differently in some way (e.g., font, font size, // prefix (@), etc.) to make it impossible (or at least obvious) to impersonate // users (e.g., 'user1' changing their display name to 'user2') - title: partners.map(un => getters.ourContactProfiles[un]?.displayName || un).join(', '), + title: partners.map(cID => getters.userDisplayNameFromID(cID)).join(', '), picture: getters.ourContactProfiles[lastJoinedPartner]?.picture } } @@ -601,7 +611,7 @@ const getters = { // NOTE: this getter is used to find the ID of the direct message in the current group // with the name[s] of partner[s]. Normally it's more useful to find direct message // by the partners instead of contractID - ourGroupDirectMessageFromUsernames (state, getters) { + ourGroupDirectMessageFromUserIds (state, getters) { return (partners) => { // NOTE: string | string[] if (typeof partners === 'string') { partners = [partners] @@ -618,7 +628,7 @@ const getters = { return chatRoomId => !!getters.ourGroupDirectMessages[chatRoomId || getters.currentChatRoomId] }, isJoinedChatRoom (state, getters) { - return (chatRoomId: string, username?: string) => !!state[chatRoomId]?.users?.[username || getters.ourUsername] + return (chatRoomId: string, memberID?: string) => !!state[chatRoomId]?.members?.[memberID || getters.ourIdentityContractId] }, currentChatRoomId (state, getters) { return state.currentChatRoomIDs[state.currentGroupId] || null @@ -664,7 +674,7 @@ const getters = { for (const contractID in chatRoomsInDetail) { const chatRoom = state[contractID] if (chatRoom && chatRoom.attributes && - chatRoom.users[state.loggedIn.username]) { + chatRoom.members[state.loggedIn.identityContractID]) { chatRoomsInDetail[contractID] = { ...chatRoom.attributes, id: contractID, @@ -678,10 +688,10 @@ const getters = { } return chatRoomsInDetail }, - chatRoomUsersInSort (state, getters) { + chatRoomMembersInSort (state, getters) { return getters.groupMembersSorted - .map(member => ({ username: member.username, displayName: member.displayName })) - .filter(member => !!getters.chatRoomUsers[member.username]) || [] + .map(member => ({ contractID: member.contractID, username: member.username, displayName: member.displayName })) + .filter(member => !!getters.chatRoomMembers[member.contractID]) || [] } } diff --git a/frontend/views/components/AvatarUser.vue b/frontend/views/components/AvatarUser.vue index 66c6d9a31..5a1c5af81 100644 --- a/frontend/views/components/AvatarUser.vue +++ b/frontend/views/components/AvatarUser.vue @@ -18,7 +18,7 @@ export default ({ picture: { type: String }, - username: String, + contractID: String, alt: { type: String, default: '' @@ -38,13 +38,8 @@ export default ({ }, async mounted () { if (!this.profilePicture) { - console.debug(`Looking for ${this.username} profile picture`) - const userContractId = await sbp('namespace/lookup', this.username) - if (!userContractId) { - console.warn(`AvatarUser: ${this.username} doesn't exist!`) - return - } - const state = await sbp('chelonia/latestContractState', userContractId) || {} + console.debug(`Looking for ${this.contractID} profile picture`) + const state = await sbp('chelonia/latestContractState', this.contractID) || {} this.ephemeral.url = state.attributes && state.attributes.picture } }, @@ -53,7 +48,7 @@ export default ({ 'ourGroupProfile' ]), profilePicture () { - const profile = this.$store.getters.globalProfile(this.username) + const profile = this.$store.getters.globalProfile(this.contractID) return this.picture || (profile && profile.picture) }, pictureURL () { diff --git a/frontend/views/components/ProfileCard.vue b/frontend/views/components/ProfileCard.vue index 1255271d3..c0336d1b3 100644 --- a/frontend/views/components/ProfileCard.vue +++ b/frontend/views/components/ProfileCard.vue @@ -17,8 +17,8 @@ tooltip( :aria-label='L("{username} profile", { username })' ) .c-identity(:class='{notGroupMember: !isActiveGroupMember}') - avatar-user(:username='username' size='lg') - user-name(:username='username') + avatar-user(:contractID='contractID' size='lg') + user-name(:contractID='contractID') i18n.has-text-1( tag='p' @@ -70,9 +70,9 @@ tooltip( ) Send message i18n.button.is-outlined.is-small( - v-if='groupShouldPropose || ourUsername === groupSettings.groupCreator' + v-if='groupShouldPropose || ourIdentityContractId === groupSettings.groupCreatorID' tag='button' - @click='openModal("RemoveMember", { username })' + @click='openModal("RemoveMember", { memberID: contractID })' data-test='buttonRemoveMember' ) Remove member @@ -96,7 +96,7 @@ import { PROFILE_STATUS } from '~/frontend/model/contracts/shared/constants.js' export default ({ name: 'ProfileCard', props: { - username: String, + contractID: String, direction: { type: String, validator: (value) => ['left', 'top-left'].includes(value), @@ -118,23 +118,25 @@ export default ({ }, computed: { ...mapGetters([ - 'ourUsername', 'groupProfiles', 'groupSettings', 'globalProfile', 'groupShouldPropose', 'ourContributionSummary', - 'ourGroupDirectMessageFromUsernames', + 'ourGroupDirectMessageFromUserIds', 'ourIdentityContractId' ]), isSelf () { - return this.username === this.ourUsername + return this.contractID === this.ourIdentityContractId }, profile () { - return this.globalProfile(this.username) + return this.globalProfile(this.contractID) + }, + username () { + return this.profile?.username || this.contractID }, userGroupProfile () { - return this.groupProfiles[this.username] + return this.groupProfiles[this.contractID] }, isActiveGroupMember () { return this.userGroupProfile?.status === PROFILE_STATUS.ACTIVE @@ -161,9 +163,9 @@ export default ({ this.$refs.tooltip.toggle() }, sendMessage () { - const chatRoomId = this.ourGroupDirectMessageFromUsernames(this.username) + const chatRoomId = this.ourGroupDirectMessageFromUserIds(this.contractID) if (!chatRoomId) { - this.createDirectMessage(this.username) + this.createDirectMessage(this.contractID) } else { if (!this.ourGroupDirectMessages[chatRoomId].visible) { this.setDMVisibility(chatRoomId, true) diff --git a/frontend/views/components/UserName.vue b/frontend/views/components/UserName.vue index f4d7857d9..81ad3b555 100644 --- a/frontend/views/components/UserName.vue +++ b/frontend/views/components/UserName.vue @@ -13,14 +13,18 @@ import { mapGetters } from 'vuex' export default ({ name: 'UserName', props: { - username: String + contractID: String }, computed: { ...mapGetters([ - 'globalProfile' + 'globalProfile', + 'usernameFromID' ]), + username () { + return this.usernameFromID(this.contractID) + }, displayName () { - return this.globalProfile(this.username).displayName + return this.globalProfile(this.contractID).displayName } } }: Object) diff --git a/frontend/views/components/UsersSelector.vue b/frontend/views/components/UsersSelector.vue index 204243ec7..dafe34c3a 100644 --- a/frontend/views/components/UsersSelector.vue +++ b/frontend/views/components/UsersSelector.vue @@ -12,14 +12,14 @@ form.c-search-form(@submit.prevent='') name='search' ) .profile-wrapper( - v-for='(username, index) in usernames' + v-for='(contractID, index) in userIDs' :key='index' ) .profile - avatar-user(:username='username' size='xs') - .c-name.has-text-bold {{ displayName(username) }} + avatar-user(:contractID='contractID' size='xs') + .c-name.has-text-bold {{ userDisplayNameFromID(contractID) }} .button.is-icon-small( - @click.prevent.stop='remove(username)' + @click.prevent.stop='remove(contractID)' :aria-label='L("Clear search")' ) i.icon-times @@ -31,7 +31,7 @@ form.c-search-form(@submit.prevent='') @keyup='onHandleKeyUp' ) - .buttons.is-end.c-button-container(v-if='usernames.length') + .buttons.is-end.c-button-container(v-if='userIDs.length') button-submit.is-success.c-create-btn(@click='submitHandler') i18n Create @@ -52,7 +52,7 @@ export default ({ type: String, required: true }, - usernames: { + userIDs: { type: Array, default: [] }, @@ -74,7 +74,7 @@ export default ({ } }, computed: { - ...mapGetters(['ourContactProfiles']) + ...mapGetters(['userDisplayNameFromID']) }, mounted () { this.$refs.input.innerHTML = this.defaultValue @@ -84,11 +84,8 @@ export default ({ } }, methods: { - remove (username) { - this.$emit('remove', username) - }, - displayName (username) { - return this.ourContactProfiles[username].displayName || username + remove (contractID) { + this.$emit('remove', contractID) }, clear () { this.$refs.input.focus() diff --git a/frontend/views/components/graphs/Overview.vue b/frontend/views/components/graphs/Overview.vue index 891bc3a57..812769233 100644 --- a/frontend/views/components/graphs/Overview.vue +++ b/frontend/views/components/graphs/Overview.vue @@ -65,10 +65,10 @@ export default ({ let list = {} // TODO: cleanup/improve this code if (this.distribution.length === 0) { - Object.keys(this.groupProfiles).forEach(username => { - const profile = this.groupProfiles[username] + Object.keys(this.groupProfiles).forEach(memberID => { + const profile = this.groupProfiles[memberID] if (profile.incomeDetailsType) { - list[username] = { + list[memberID] = { amount: 0, total: profile.incomeDetailsType === 'incomeAmount' ? profile.incomeAmount - this.mincome : profile.pledgeAmount } @@ -76,8 +76,8 @@ export default ({ }) } else { this.distribution.forEach(distribution => { - list = this.addToList(list, distribution.from, distribution.amount) - list = this.addToList(list, distribution.to, -distribution.amount) + list = this.addToList(list, distribution.fromMemberID, distribution.amount) + list = this.addToList(list, distribution.toMemberID, -distribution.amount) }) } // Sort object by need / pledge diff --git a/frontend/views/containers/chatroom/ChatMain.vue b/frontend/views/containers/chatroom/ChatMain.vue index 12e9a8dc4..e09369439 100644 --- a/frontend/views/containers/chatroom/ChatMain.vue +++ b/frontend/views/containers/chatroom/ChatMain.vue @@ -29,8 +29,8 @@ ) div(slot='no-more') conversation-greetings( - :members='summary.numberOfUsers' - :creator='summary.attributes.creator' + :members='summary.numberOfMembers' + :creatorID='summary.attributes.creatorID' :type='summary.attributes.type' :joined='summary.isJoined' :name='summary.title' @@ -38,8 +38,8 @@ ) div(slot='no-results') conversation-greetings( - :members='summary.numberOfUsers' - :creator='summary.attributes.creator' + :members='summary.numberOfMembers' + :creatorID='summary.attributes.creatorID' :type='summary.attributes.type' :joined='summary.isJoined' :name='summary.title' @@ -73,7 +73,7 @@ :edited='!!message.updatedDate' :emoticonsList='message.emoticons' :who='who(message)' - :currentUsername='currentUserAttr.username' + :currentUserID='currentUserAttr.id' :avatar='avatar(message.from)' :variant='variant(message)' :isSameSender='isSameSender(index)' @@ -298,9 +298,8 @@ export default ({ 'currentChatRoomId', 'chatRoomSettings', 'chatRoomAttributes', - 'chatRoomUsers', + 'chatRoomMembers', 'ourIdentityContractId', - 'ourUsername', 'currentIdentityState', 'isJoinedChatRoom', 'setChatRoomScrollPosition', @@ -346,7 +345,7 @@ export default ({ }[message.type] }, isCurrentUser (from) { - return this.currentUserAttr.username === from + return this.currentUserAttr.id === from }, who (message) { const user = this.isCurrentUser(message.from) ? this.currentUserAttr : this.summary.participants[message.from] @@ -573,7 +572,7 @@ export default ({ Vue.set(this.messageState, 'contract', { settings: cloneDeep(this.chatRoomSettings), attributes: cloneDeep(this.chatRoomAttributes), - users: cloneDeep(this.chatRoomUsers), + users: cloneDeep(this.chatRoomMembers), _vm: cloneDeep(this.currentChatVm), messages: [], onlyRenderMessage: true // NOTE: DO NOT RENAME THIS OR CHATROOM WOULD BREAK @@ -778,8 +777,7 @@ export default ({ const isMessageAddedOrDeleted = (message: GIMessage) => { if (![GIMessage.OP_ACTION_ENCRYPTED, GIMessage.OP_ACTION_UNENCRYPTED].includes(message.opType())) return {} - const { action, meta } = value - const rootState = sbp('state/vuex/state') + const { action } = value let addedOrDeleted = 'NONE' if (/(addMessage|join|rename|changeDescription|leave)$/.test(action)) { @@ -789,11 +787,12 @@ export default ({ addedOrDeleted = 'DELETED' } - return { addedOrDeleted, self: rootState.loggedIn.username === meta.username } + // TODO: Use innerSigningContractID + return { addedOrDeleted } } // NOTE: while syncing the chatroom contract, we should ignore all the events - const { addedOrDeleted, self } = isMessageAddedOrDeleted(message) + const { addedOrDeleted } = isMessageAddedOrDeleted(message) // This ensures that `this.latestEvents.push(message.serialize())` below // happens in order @@ -839,10 +838,11 @@ export default ({ this.$forceUpdate() if (this.ephemeral.scrolledDistance < 50) { - if (addedOrDeleted === 'ADDED') { + if (addedOrDeleted === 'ADDED' && this.messages.length) { const isScrollable = this.$refs.conversation && this.$refs.conversation.scrollHeight !== this.$refs.conversation.clientHeight - if (!self && isScrollable) { + const fromOurselves = this.isCurrentUser(this.messages[this.messages.length - 1].from) + if (!fromOurselves && isScrollable) { this.updateScroll() } else if (!isScrollable && this.messages.length) { const msg = this.messages[this.messages.length - 1] diff --git a/frontend/views/containers/chatroom/ChatMembers.vue b/frontend/views/containers/chatroom/ChatMembers.vue index 764c93c2d..bcec5adaa 100644 --- a/frontend/views/containers/chatroom/ChatMembers.vue +++ b/frontend/views/containers/chatroom/ChatMembers.vue @@ -19,9 +19,9 @@ :key='chatRoomId' ) .profile-wrapper(v-if='partners.length === 1') - profile-card(:username='partners[0]' deactivated) - avatar-user(:username='partners[0]' :picture='picture' size='sm' data-test='openMemberProfileCard') - span.is-unstyled.c-name.has-ellipsis(:data-test='partners[0]') {{ title }} + profile-card(:contractID='partners[0]' deactivated) + avatar-user(:contractID='partners[0]' :picture='picture' size='sm' data-test='openMemberProfileCard') + span.is-unstyled.c-name.has-ellipsis(:data-test='usernameFromID(partners[0])') {{ title }} .group-wrapper(v-else) .picture-wrapper @@ -71,7 +71,8 @@ export default ({ 'ourContactProfiles', 'groupShouldPropose', 'ourGroupDirectMessages', - 'chatRoomUnreadMentions' + 'chatRoomUnreadMentions', + 'usernameFromID' ]) }, methods: { diff --git a/frontend/views/containers/chatroom/ChatMembersAllModal.vue b/frontend/views/containers/chatroom/ChatMembersAllModal.vue index 50ab94e09..f14b8b7e3 100644 --- a/frontend/views/containers/chatroom/ChatMembersAllModal.vue +++ b/frontend/views/containers/chatroom/ChatMembersAllModal.vue @@ -46,29 +46,29 @@ modal-base-template.has-background( data-test='joinedChannelMembersList' ) li.c-search-member( - v-for='{username, displayName, departedDate} in filteredRecents' - :key='username' + v-for='{contractID, username, displayName, departedDate} in filteredRecents' + :key='contractID' ) - profile-card(:username='username' direction='top-left') + profile-card(:contractID='contractID' direction='top-left') .c-identity - avatar-user(:username='username' size='sm') + avatar-user(:contractID='contractID' size='sm') .c-name(data-test='username') span - strong {{ localizedName(username, displayName) }} + strong {{ localizedName(contractID, username, displayName) }} .c-display-name(v-if='displayName' data-test='profileName') @{{ username }} - .c-actions(v-if='!isDirectMessage() && isJoined && removable(username)') + .c-actions(v-if='!isDirectMessage() && isJoined && removable(contractID)') button.is-icon( v-if='!departedDate' :data-test='"removeMember-" + username' - @click.stop='removeMember(username)' + @click.stop='removeMember(contractID)' ) i.icon-times .has-text-success(v-else) i.icon-check i18n Removed. button.is-unstyled.c-action-undo( - @click.stop='addToChannel(username, true)' + @click.stop='addToChannel(contractID, true)' ) {{L("Undo")}} .is-subtitle.c-second-section @@ -83,22 +83,22 @@ modal-base-template.has-background( data-test='unjoinedChannelMembersList' ) li.c-search-member( - v-for='{username, displayName, joinedDate} in filteredOthers' - :key='username' + v-for='{contractID, username, displayName, joinedDate} in filteredOthers' + :key='contractID' ) - profile-card(:username='username' direction='top-left') + profile-card(:contractID='contractID' direction='top-left') .c-identity - avatar-user(:username='username' size='sm') + avatar-user(:contractID='contractID' size='sm') .c-name(data-test='username') span - strong {{ localizedName(username, displayName) }} + strong {{ localizedName(contractID, username, displayName) }} .c-display-name(v-if='displayName' data-test='profileName') @{{ username }} .c-actions(v-if='isJoined') button-submit.button.is-outlined.is-small( v-if='!joinedDate' type='button' - @click.stop='addToChannel(username)' + @click.stop='addToChannel(contractID)' :data-test='"addToChannel-" + username' ) i18n(:args='LTags("span")') Add {span_}to channel{_span} @@ -108,7 +108,7 @@ modal-base-template.has-background( i18n Added. button-submit.is-unstyled.c-action-undo( v-if='!isDirectMessage()' - @click.stop='removeMember(username, true)' + @click.stop='removeMember(contractID, true)' ) i18n Undo @@ -154,10 +154,13 @@ export default ({ 'currentGroupState', 'groupMembersSorted', 'getGroupChatRooms', - 'chatRoomUsers', - 'chatRoomUsersInSort', + 'chatRoomMembers', + 'chatRoomMembersInSort', 'globalProfile', - 'isJoinedChatRoom' + 'isJoinedChatRoom', + 'ourIdentityContractId', + 'ourContactsById', + 'ourContactProfilesById' ]), ...mapState([ 'currentGroupId' @@ -203,12 +206,12 @@ export default ({ // TODO: remove 'users', 'deletedDate' to keep consistency when this.isJoined === false return this.isJoined ? this.currentChatRoomState.attributes : this.getGroupChatRooms[this.currentChatRoomId] }, - chatRoomUsersInOrder () { + chatRoomMembersInOrder () { return this.isJoined - ? this.chatRoomUsersInSort + ? this.chatRoomMembersInSort : this.groupMembersSorted - .filter(member => this.getGroupChatRooms[this.currentChatRoomId].users[member.username]?.status === PROFILE_STATUS.ACTIVE) - .map(member => ({ username: member.username, displayName: member.displayName })) + .filter(member => this.getGroupChatRooms[this.currentChatRoomId].members[member.username]?.status === PROFILE_STATUS.ACTIVE) + .map(member => ({ contractID: member.contractID, username: member.username, displayName: member.displayName })) } }, mounted () { @@ -217,59 +220,65 @@ export default ({ methods: { initializeMembers () { if (this.isDirectMessage()) { - this.addedMembers = Object.keys(this.chatRoomUsers) - .map(username => { - const profile = username === this.ourUsername ? this.globalProfile(username) : this.ourContactProfiles[username] + this.addedMembers = Object.keys(this.chatRoomMembers) + .map(contractID => { + const profile = contractID === this.ourIdentityContractId ? this.globalProfile(contractID) : this.ourContactProfilesById[contractID] return { displayName: profile.displayName, - username, + username: profile.username, + contractID, departedDate: null } }) // TODO: every user needs to sync his contacts and also users from group messages // https://okturtles.slack.com/archives/C0EH7P20Y/p1669109352107659 - this.canAddMembers = this.ourContacts - .filter(username => !this.addedMembers.find(mb => mb.username === username)) - .map(username => ({ - username, - displayName: this.ourContactProfiles[username].displayName, - joinedDate: null - })) + this.canAddMembers = this.ourContactsById + .filter(contractID => !this.addedMembers.find(mb => mb.contractID === contractID)) + .map(contractID => { + const profile = this.ourContactProfilesById[contractID] || {} + return { + contractID, + username: profile.username, + displayName: profile.displayName, + joinedDate: null + } + }) } else { - this.addedMembers = this.chatRoomUsersInOrder.map(member => ({ ...member, departedDate: null })) + this.addedMembers = this.chatRoomMembersInOrder.map(member => ({ ...member, departedDate: null })) this.canAddMembers = this.groupMembersSorted - .filter(member => !this.addedMembers.find(mb => mb.username === member.username) && !member.invitedBy) + .filter(member => !this.addedMembers.find(mb => mb.contractID === member.contractID) && !member.invitedBy) .map(member => ({ username: member.username, displayName: member.displayName, + contractID: member.contractID, joinedDate: null })) } }, - localizedName (username, displayName) { - const name = displayName || `@${username}` - return username === this.ourUsername ? L('{name} (you)', { name }) : name + localizedName (contractID, username, displayName) { + const name = displayName || `@${username || contractID}` + return contractID === this.ourIdentityContractId ? L('{name} (you)', { name }) : name }, closeModal () { this.$refs.modal.close() }, - removable (username: string) { + removable (memberID: string) { if (!this.isJoined) { return false } - const { creator } = this.chatRoomAttribute + const { creatorID } = this.chatRoomAttribute if (this.currentGroupState.generalChatRoomId === this.currentChatRoomId) { return false - } else if (this.ourUsername === creator) { + } else if (this.ourIdentityContractId === creatorID) { return true - } else if (this.ourUsername === username) { + } else if (this.ourIdentityContractId === memberID) { return true } return false }, - async removeMember (username: string, undoing = false) { - if (!this.isJoinedChatRoom(this.currentChatRoomId, username)) { - console.log(`${username} is not part of this chatroom`) + async removeMember (contractID: string, undoing = false) { + if (!this.isJoinedChatRoom(this.currentChatRoomId, contractID)) { + console.log(`${contractID} is not part of this chatroom`) return } try { @@ -277,24 +286,24 @@ export default ({ contractID: this.currentGroupId, data: { chatRoomID: this.currentChatRoomId, - member: username + memberID: contractID } }) if (undoing) { this.canAddMembers = this.canAddMembers.map(member => - member.username === username ? { ...member, joinedDate: null } : member) + member.contractID === contractID ? { ...member, joinedDate: null } : member) } else { this.addedMembers = this.addedMembers.map(member => - member.username === username ? { ...member, departedDate: new Date().toISOString() } : member) + member.contractID === contractID ? { ...member, departedDate: new Date().toISOString() } : member) } } catch (e) { console.error('ChatMembersAllModal.vue removeMember() error:', e) } }, - async addToChannel (username: string, undoing = false) { + async addToChannel (contractID: string, undoing = false) { if (this.isDirectMessage()) { - const usernames = uniq(this.ourGroupDirectMessages[this.currentChatRoomId].partners.concat(username)) - const chatRoomId = this.ourGroupDirectMessageFromUsernames(usernames) + const usernames = uniq(this.ourGroupDirectMessages[this.currentChatRoomId].partners.concat(contractID)) + const chatRoomId = this.ourGroupDirectMessageFromUserIds(usernames) if (chatRoomId) { this.redirect(chatRoomId) } else { @@ -305,22 +314,22 @@ export default ({ return } - if (this.isJoinedChatRoom(this.currentChatRoomId, username)) { - console.log(`${username} is already joined this chatroom`) + if (this.isJoinedChatRoom(this.currentChatRoomId, contractID)) { + console.log(`${contractID} is already joined this chatroom`) return } try { await sbp('gi.actions/group/joinChatRoom', { contractID: this.currentGroupId, - data: { username, chatRoomID: this.currentChatRoomId } + data: { memberID: contractID, chatRoomID: this.currentChatRoomId } }) if (undoing) { this.addedMembers = this.addedMembers.map(member => - member.username === username ? { ...member, departedDate: null } : member) + member.contractID === contractID ? { ...member, departedDate: null } : member) } else { this.canAddMembers = this.canAddMembers.map(member => - member.username === username ? { ...member, joinedDate: new Date().toISOString() } : member) + member.contractID === contractID ? { ...member, joinedDate: new Date().toISOString() } : member) } } catch (e) { console.error('ChatMembersAllModal.vue addToChannel() error:', e) diff --git a/frontend/views/containers/chatroom/ChatMixin.js b/frontend/views/containers/chatroom/ChatMixin.js index c38aa94a4..e79f60ce7 100644 --- a/frontend/views/containers/chatroom/ChatMixin.js +++ b/frontend/views/containers/chatroom/ChatMixin.js @@ -11,8 +11,8 @@ const initSummary = { isPrivate: false, isGeneral: false, isJoined: false, - users: {}, - numberOfUsers: 0, + members: {}, + numberOfMembers: 0, participants: [] } @@ -38,12 +38,12 @@ const ChatMixin: Object = { 'currentGroupState', 'groupIdFromChatRoomId', 'ourGroupDirectMessages', - 'chatRoomUsers', + 'chatRoomMembers', 'generalChatRoomId', 'getGroupChatRooms', 'globalProfile', 'isJoinedChatRoom', - 'ourContactProfiles', + 'ourContactProfilesById', 'isDirectMessage' ]), ...mapState(['currentGroupId']), @@ -67,12 +67,12 @@ const ChatMixin: Object = { isPrivate: this.currentChatRoomState.attributes.privacyLevel === CHATROOM_PRIVACY_LEVEL.PRIVATE, isGeneral: this.generalChatRoomId === this.currentChatRoomId, isJoined: true, - users: Object.fromEntries(Object.keys(this.currentChatRoomState.users).map(username => { - const { displayName, picture, email } = this.globalProfile(username) || {} - return [username, { ...this.currentChatRoomState.users[username], displayName, picture, email }] + members: Object.fromEntries(Object.keys(this.currentChatRoomState.members).map(memberID => { + const { displayName, picture, email } = this.globalProfile(memberID) || {} + return [memberID, { ...this.currentChatRoomState.members[memberID], displayName, picture, email }] })), - numberOfUsers: Object.keys(this.currentChatRoomState.users).length, - participants: this.ourContactProfiles // TODO: return only historical contributors + numberOfMembers: Object.keys(this.currentChatRoomState.members).length, + participants: this.ourContactProfilesById // TODO: return only historical contributors } } }, @@ -95,9 +95,9 @@ const ChatMixin: Object = { loadLatestState (chatRoomId: string): void { const summarizedAttr = this.getGroupChatRooms[chatRoomId] if (summarizedAttr) { - const { creator, name, description, type, privacyLevel, users } = summarizedAttr - const activeUsers = Object - .entries(users) + const { creator, name, description, type, privacyLevel, members } = summarizedAttr + const activeMembers = Object + .entries(members) .filter(([, profile]) => (profile: any)?.status === PROFILE_STATUS.ACTIVE) .map(([username]) => { const { displayName, picture, email } = this.globalProfile(username) || {} @@ -108,9 +108,9 @@ const ChatMixin: Object = { chatRoomId, title: name, attributes: { creator, name, description, type, privacyLevel }, - users: activeUsers, - numberOfUsers: activeUsers.length, - participants: this.ourContactProfiles // TODO: return only historical contributors + members: activeMembers, + numberOfMembers: activeMembers.length, + participants: this.ourContactProfilesById // TODO: return only historical contributors } this.refreshTitle(name) } else { diff --git a/frontend/views/containers/chatroom/ConversationGreetings.vue b/frontend/views/containers/chatroom/ConversationGreetings.vue index 52e13ded9..2a8589780 100644 --- a/frontend/views/containers/chatroom/ConversationGreetings.vue +++ b/frontend/views/containers/chatroom/ConversationGreetings.vue @@ -12,7 +12,7 @@ i18n.button.is-outlined.is-small( tag='button' - v-if='!isDirectMessage() && joined && !description && creator === ourUsername' + v-if='!isDirectMessage() && joined && !description && creatorID === ourIdentityContractId' @click.prevent='openModal("EditChannelDescriptionModal")' data-test='addDescription' ) Add a description @@ -41,7 +41,7 @@ export default ({ joined: { type: Boolean }, - creator: { + creatorID: { type: String }, type: { @@ -55,7 +55,7 @@ export default ({ } }, computed: { - ...mapGetters(['ourUsername', 'isDirectMessage']), + ...mapGetters(['ourIdentityContractId', 'isDirectMessage']), text () { return { GIBot: L('I’m here to keep you update while you are away.'), diff --git a/frontend/views/containers/chatroom/DMMixin.js b/frontend/views/containers/chatroom/DMMixin.js index f4a9e9ec1..0d4ad4eeb 100644 --- a/frontend/views/containers/chatroom/DMMixin.js +++ b/frontend/views/containers/chatroom/DMMixin.js @@ -6,24 +6,23 @@ const DMMixin: Object = { computed: { ...mapGetters([ 'currentChatRoomId', - 'ourUsername', 'ourContacts', 'ourContactProfiles', 'isDirectMessage', 'ourGroupDirectMessages', 'ourIdentityContractId', - 'ourGroupDirectMessageFromUsernames' + 'ourGroupDirectMessageFromUserIds' ]) }, methods: { - async createDirectMessage (usernames: string | string[]) { - if (typeof usernames === 'string') { - usernames = [usernames] + async createDirectMessage (memberIDs: string | string[]) { + if (typeof memberIDs === 'string') { + memberIDs = [memberIDs] } try { await sbp('gi.actions/identity/createDirectMessage', { contractID: this.ourIdentityContractId, - data: { usernames }, + data: { memberIDs }, hooks: { onprocessed: (message) => { this.redirect(message.decryptedValue().data.contractID) diff --git a/frontend/views/containers/chatroom/LeaveChannelModal.vue b/frontend/views/containers/chatroom/LeaveChannelModal.vue index 3be93fd2e..470a96556 100644 --- a/frontend/views/containers/chatroom/LeaveChannelModal.vue +++ b/frontend/views/containers/chatroom/LeaveChannelModal.vue @@ -61,8 +61,7 @@ export default ({ await sbp('gi.actions/group/leaveChatRoom', { contractID: this.currentGroupId, data: { - chatRoomID: this.currentChatRoomId, - member: this.loggedIn.username + chatRoomID: this.currentChatRoomId } }) } catch (e) { diff --git a/frontend/views/containers/chatroom/Message.vue b/frontend/views/containers/chatroom/Message.vue index 600f84dfc..5c7341d44 100644 --- a/frontend/views/containers/chatroom/Message.vue +++ b/frontend/views/containers/chatroom/Message.vue @@ -26,7 +26,7 @@ export default ({ type: String, text: String, who: String, - currentUsername: String, + currentUserID: String, avatar: String, datetime: { type: Date, diff --git a/frontend/views/containers/chatroom/MessageBase.vue b/frontend/views/containers/chatroom/MessageBase.vue index 226962a1d..eeff17da5 100644 --- a/frontend/views/containers/chatroom/MessageBase.vue +++ b/frontend/views/containers/chatroom/MessageBase.vue @@ -59,7 +59,7 @@ v-if='!isEditing' :emoticonsList='emoticonsList' :messageType='type' - :currentUsername='currentUsername' + :currentUserID='currentUserID' @selectEmoticon='selectEmoticon($event)' @openEmoticon='openEmoticon($event)' ) @@ -87,7 +87,7 @@ import MessageActions from './MessageActions.vue' import MessageReactions from './MessageReactions.vue' import SendArea from './SendArea.vue' import { humanDate } from '@model/contracts/shared/time.js' -import { makeMentionFromUsername } from '@model/contracts/shared/functions.js' +import { makeMentionFromUserID } from '@model/contracts/shared/functions.js' import { MESSAGE_TYPES } from '@model/contracts/shared/constants.js' import { convertToMarkdown } from '@view-utils/convert-to-markdown.js' @@ -112,7 +112,7 @@ export default ({ messageHash: String, replyingMessage: String, who: String, - currentUsername: String, + currentUserID: String, avatar: String, datetime: { type: Date, @@ -131,7 +131,7 @@ export default ({ convertTextToMarkdown: Boolean }, computed: { - ...mapGetters(['chatRoomUsers', 'ourUsername']), + ...mapGetters(['chatRoomMembers', 'usernameFromID']), textObjects () { return this.generateTextObjectsFromText(this.text) }, @@ -200,20 +200,27 @@ export default ({ } ] } - const possibleMentions = [ - ...Object.keys(this.chatRoomUsers).map(u => makeMentionFromUsername(u).me), - makeMentionFromUsername('').all - ] + const allMention = makeMentionFromUserID('').all + const possibleMentions = Object.keys(this.chatRoomMembers).map(u => makeMentionFromUserID(u).me).filter(v => !!v) return text - .split(new RegExp(`(${possibleMentions.join('|')})`)) - .map(t => possibleMentions.includes(t) - ? { type: TextObjectType.Mention, text: t } - : { - type: TextObjectType.Text, - text: this.convertTextToMarkdown ? convertToMarkdown(t) : t - } - ) + // We try to find all the mentions and render them as mentions instead + // of regular text. The `(?<=\\s|^)` part ensures that a mention is + // preceded by a space or is the start of a line and the `(?=[^\\w\\d]|$)` + // ensures that it's followed by an end-of-line or a character that's not + // a letter or a number (so `Hi @user!` works). + .split(new RegExp(`(?<=\\s|^)(${allMention}|${possibleMentions.join('|')})(?=[^\\w\\d]|$)`)) + .map(t => { + if (t === allMention) { + return { type: TextObjectType.Mention, text: t } + } + return possibleMentions.includes(t) + ? { type: TextObjectType.Mention, text: t[0] + this.usernameFromID(t.slice(1)) } + : { + type: TextObjectType.Text, + text: this.convertTextToMarkdown ? convertToMarkdown(t) : t + } + }) } } }: Object) diff --git a/frontend/views/containers/chatroom/MessageNotification.vue b/frontend/views/containers/chatroom/MessageNotification.vue index 91ba16de7..6b4e9625b 100644 --- a/frontend/views/containers/chatroom/MessageNotification.vue +++ b/frontend/views/containers/chatroom/MessageNotification.vue @@ -27,7 +27,7 @@ export default ({ text: String, notification: Object, // { type, params } who: String, - currentUsername: String, + currentUserID: String, avatar: String, datetime: { type: Date, @@ -47,15 +47,15 @@ export default ({ isCurrentUser: Boolean }, computed: { - ...mapGetters(['userDisplayName', 'isDirectMessage', 'currentChatRoomId']), + ...mapGetters(['userDisplayNameFromID', 'isDirectMessage', 'currentChatRoomId']), message () { const { - username, + memberID, channelName, channelDescription, votedOptions } = this.notification.params - const displayName = this.userDisplayName(username) + const displayName = this.userDisplayNameFromID(memberID) const notificationTemplates = { // NOTE: 'onDirectMessage' is not being used at the moment diff --git a/frontend/views/containers/chatroom/MessagePoll.vue b/frontend/views/containers/chatroom/MessagePoll.vue index d5b4a7130..cc076377c 100644 --- a/frontend/views/containers/chatroom/MessagePoll.vue +++ b/frontend/views/containers/chatroom/MessagePoll.vue @@ -43,7 +43,7 @@ export default ({ pollData: Object, // { creator, status, question, options ... } messageId: String, messageHash: String, - currentUsername: String, + currentUserID: String, who: String, type: String, avatar: String, @@ -72,14 +72,14 @@ export default ({ }, computed: { ...mapGetters([ - 'ourUsername', + 'ourIdentityContractId', 'currentChatRoomId' ]), votesFlattened () { return this.pollData.options.reduce((accu, opt) => [...accu, ...opt.voted], []) }, hasVoted () { // checks if the current user has voted on this poll or not - return this.votesFlattened.includes(this.ourUsername) + return this.votesFlattened.includes(this.ourIdentityContractId) }, isPollEditable () { // If the current user is the creator of the poll and no one has voted yet, poll can be editted. return this.isCurrentUser && this.votesFlattened.length === 0 diff --git a/frontend/views/containers/chatroom/MessageReactions.vue b/frontend/views/containers/chatroom/MessageReactions.vue index 8fca92ce8..ce74385a1 100644 --- a/frontend/views/containers/chatroom/MessageReactions.vue +++ b/frontend/views/containers/chatroom/MessageReactions.vue @@ -3,7 +3,7 @@ .c-emoticon-wrapper( v-for='(list, emoticon, index) in emoticonsList' :key='index' - :class='{"is-user-emoticon": list.includes(currentUsername)}' + :class='{"is-user-emoticon": list.includes(currentUserID)}' @click='$emit("selectEmoticon", emoticon)' v-if='list.length' ) @@ -35,7 +35,7 @@ export default ({ Tooltip }, props: { - currentUsername: String, + currentUserID: String, emoticonsList: { type: Object, default: null @@ -51,10 +51,10 @@ export default ({ methods: { emoticonUserList (emoticon, list) { const you = L('You') - const nameList = list.map(username => { - const userProp = this.globalProfile(username) - if (username === this.currentUsername) return you - if (userProp) return userProp.displayName || userProp.username + const nameList = list.map(contractID => { + const userProp = this.globalProfile(contractID) + if (contractID === this.currentUserID) return you + if (userProp) return userProp.displayName || userProp.username || contractID return null }) const alreadyMade = nameList.indexOf(you) diff --git a/frontend/views/containers/chatroom/NewDirectMessageModal.vue b/frontend/views/containers/chatroom/NewDirectMessageModal.vue index e645daff5..1ed261b46 100644 --- a/frontend/views/containers/chatroom/NewDirectMessageModal.vue +++ b/frontend/views/containers/chatroom/NewDirectMessageModal.vue @@ -11,7 +11,7 @@ modal-base-template.has-background( .card.c-card users-selector( :label='L("Search")' - :usernames='selections' + :userIDs='selections' :autofocus='true' @change='onChangeKeyword' @remove='onRemoveSelection' @@ -46,18 +46,18 @@ modal-base-template.has-background( :key='chatRoomId' ) profile-card( - :username='lastJoinedPartner' + :contractID='lastJoinedPartner' deactivated direction='top-left' ) .c-identity .picture-wrapper - avatar-user(:username='lastJoinedPartner' size='sm') + avatar-user(:contractID='lastJoinedPartner' size='sm') .c-badge(v-if='partners.length > 1') {{ partners.length }} .c-name(data-test='lastJoinedPartner') span strong {{ title }} - .c-display-name(v-if='title !== lastJoinedPartner' data-test='profileName') @{{ partners.join(', @') }} + .c-display-name(v-if='title !== lastJoinedPartner' data-test='profileName') @{{ partners.map((p => usernameFromID(p))).join(', @') }} .is-subtitle i18n( @@ -70,13 +70,13 @@ modal-base-template.has-background( tag='ul' ) li.c-search-member( - v-for='{username, displayName} in filteredOthers' - @click='onAddSelection(username)' + v-for='{contractID, username, displayName} in filteredOthers' + @click='onAddSelection(contractID)' :key='username' ) - profile-card(:username='username' deactivated direction='top-left') + profile-card(:contractID='contractID' deactivated direction='top-left') .c-identity - avatar-user(:username='username' size='sm') + avatar-user(:contractID='contractID' size='sm') .c-name(data-test='username') span strong {{ localizedName(username, displayName) }} @@ -115,19 +115,23 @@ export default ({ }, computed: { ...mapGetters([ - 'userDisplayName', - 'ourUnreadMessages' + 'userDisplayNameFromID', + 'usernameFromID', + 'ourContactProfilesById', + 'ourIdentityContractId', + 'ourUnreadMessages', + 'ourContactsById' ]), ourNewDMContacts () { - return this.ourContacts - .filter(username => { - if (username === this.ourUsername) { + return this.ourContactsById + .filter(userID => { + if (userID === this.ourIdentityContractId) { return false } - const chatRoomId = this.ourGroupDirectMessageFromUsernames(username) + const chatRoomId = this.ourGroupDirectMessageFromUserIds(userID) return !chatRoomId || !this.ourGroupDirectMessages[chatRoomId].visible }) - .map(username => this.ourContactProfiles[username]) + .map(userID => this.ourContactProfilesById[userID]) }, ourRecentConversations () { return Object.keys(this.ourGroupDirectMessages) @@ -165,7 +169,7 @@ export default ({ }, filteredOthers () { return filterByKeyword(this.ourNewDMContacts, this.searchText, ['username', 'displayName']) - .filter(profile => !this.selections.includes(profile.username)) + .filter(profile => !this.selections.includes(profile.contractID)) }, searchCount () { return Object.keys(this.filteredOthers).length + Object.keys(this.filteredRecents).length @@ -183,22 +187,22 @@ export default ({ onChangeKeyword (keyword) { this.searchText = keyword }, - onAddSelection (usernames) { - if (typeof usernames === 'string') { - usernames = [usernames] + onAddSelection (contractIDs) { + if (typeof contractIDs === 'string') { + contractIDs = [contractIDs] } - for (const username of usernames) { - if (!this.selections.includes(username)) { - this.selections.push(username) + for (const contractID of contractIDs) { + if (!this.selections.includes(contractID)) { + this.selections.push(contractID) } } }, - onRemoveSelection (username) { - this.selections = this.selections.filter(un => un !== username) + onRemoveSelection (contractID) { + this.selections = this.selections.filter(cID => cID !== contractID) }, async onSubmit () { if (this.selections.length) { - const chatRoomId = this.ourGroupDirectMessageFromUsernames(this.selections) + const chatRoomId = this.ourGroupDirectMessageFromUserIds(this.selections) if (chatRoomId) { this.redirect(chatRoomId) } else { @@ -208,7 +212,7 @@ export default ({ if (this.filteredRecents.length) { this.redirect(this.filteredRecents[0].chatRoomId) } else if (this.filteredOthers.length) { - await this.createDirectMessage(this.filteredOthers[0].username) + await this.createDirectMessage(this.filteredOthers[0].contractID) } } diff --git a/frontend/views/containers/chatroom/SendArea.vue b/frontend/views/containers/chatroom/SendArea.vue index e2a85a57f..e01ba8c33 100644 --- a/frontend/views/containers/chatroom/SendArea.vue +++ b/frontend/views/containers/chatroom/SendArea.vue @@ -350,21 +350,21 @@ export default ({ }, computed: { ...mapGetters([ - 'chatRoomUsers', + 'chatRoomMembers', 'currentChatRoomId', 'chatRoomAttributes', - 'ourContactProfiles', 'ourContactProfilesById', 'globalProfile', - 'ourUsername' + 'ourIdentityContractId' ]), - users () { - return Object.keys(this.chatRoomUsers) - .map(username => { - const { displayName, picture } = this.ourContactProfiles[username] + members () { + return Object.keys(this.chatRoomMembers) + .map(memberID => { + const { username, displayName, picture } = this.ourContactProfilesById[memberID] return { + memberID, username, - displayName: displayName || username, + displayName: displayName || username || memberID, picture } }) @@ -387,7 +387,7 @@ export default ({ const userArr = this.ephemeral.typingUsers if (userArr.length) { - const getDisplayName = (username) => (this.globalProfile(username).displayName || username) + const getDisplayName = (memberID) => (this.globalProfile(memberID).displayName || this.globalProfile(memberID).username || memberID) const isMultiple = userArr.length > 1 const usernameCombined = userArr.map(u => getDisplayName(u)).join(', ') @@ -488,8 +488,10 @@ export default ({ addSelectedMention (index) { const curValue = this.$refs.textarea.value const curPosition = this.$refs.textarea.selectionStart + const selection = this.ephemeral.mention.options[index] - const mention = makeMentionFromUsername(this.ephemeral.mention.options[index].username).me + const mentionObj = makeMentionFromUsername(selection.username || selection.memberID, true) + const mention = selection.memberID === mentionObj.all ? mentionObj.all : mentionObj.me const value = curValue.slice(0, this.ephemeral.mention.position) + mention + ' ' + curValue.slice(curPosition) this.$refs.textarea.value = value @@ -560,6 +562,18 @@ export default ({ this.clearAllAttachments() } + /* Process mentions in the form @username => @userID */ + const mentionStart = makeMentionFromUsername('').all[0] + const availableMentions = this.members.map(memberID => memberID.username) + msgToSend = msgToSend.replace( + // This regular expression matches all @username mentions that are + // standing alone between spaces + new RegExp(`(?<=\\s|^)${mentionStart}(${availableMentions.join('|')})(?=[^\\w\\d]|$)`, 'g'), + (_, username) => { + return makeMentionFromUsername(username).me + } + ) + this.$emit('send', msgToSend) // TODO remove first / last empty lines this.$refs.textarea.value = '' this.updateTextArea() @@ -636,22 +650,20 @@ export default ({ this.updateTextWithLines() }, startMention (keyword, position) { - const all = makeMentionFromUsername('').all.slice(1) - let availableMentions = this.users + const all = makeMentionFromUsername('').all + const availableMentions = Array.from(this.members) // NOTE: '@all' mention should only be needed when the members are more than 3 - if (this.users.length > 2) { - availableMentions = [ - ...availableMentions, - { - username: all, - displayName: all, - picture: '/assets/images/horn.png' - } - ] + if (availableMentions.length > 2) { + availableMentions.push({ + memberID: all, + displayName: all.slice(1), + picture: '/assets/images/horn.png' + }) } + const normalKeyword = keyword.normalize().toUpperCase() this.ephemeral.mention.options = availableMentions.filter(user => - user.username.toUpperCase().includes(keyword.toUpperCase()) || - user.displayName.toUpperCase().includes(keyword.toUpperCase())) + user.username?.normalize().toUpperCase().includes(normalKeyword) || + user.displayName?.normalize().toUpperCase().includes(normalKeyword)) this.ephemeral.mention.position = position this.ephemeral.mention.index = 0 }, @@ -701,9 +713,9 @@ export default ({ }, onUserTyping (data) { if (data.contractID !== this.currentChatRoomId) return - const typingUser = this.ourContactProfilesById[data.innerSigningContractID]?.username + const typingUser = data.innerSigningContractID - if (typingUser && typingUser !== this.ourUsername) { + if (typingUser && typingUser !== this.ourIdentityContractId) { const addToList = username => { this.ephemeral.typingUsers = uniq([...this.ephemeral.typingUsers, username]) } @@ -715,18 +727,18 @@ export default ({ }, onUserStopTyping (data) { if (data.contractID !== this.currentChatRoomId) return - const typingUser = this.ourContactProfilesById[data.innerSigningContractID]?.username + const typingUser = data.innerSigningContractID - if (typingUser && typingUser !== this.ourUsername) { + if (typingUser && typingUser !== this.ourIdentityContractId) { this.removeFromTypingUsersArray(typingUser) } }, - removeFromTypingUsersArray (username) { - this.ephemeral.typingUsers = this.ephemeral.typingUsers.filter(u => u !== username) + removeFromTypingUsersArray (memberID) { + this.ephemeral.typingUsers = this.ephemeral.typingUsers.filter(u => u !== memberID) - if (this.typingUserTimeoutIds[username]) { - clearTimeout(this.typingUserTimeoutIds[username]) - delete this.typingUserTimeoutIds[username] + if (this.typingUserTimeoutIds[memberID]) { + clearTimeout(this.typingUserTimeoutIds[memberID]) + delete this.typingUserTimeoutIds[memberID] } }, emitUserTypingEvent () { diff --git a/frontend/views/containers/chatroom/poll-message-content/PollToVote.vue b/frontend/views/containers/chatroom/poll-message-content/PollToVote.vue index 990f52668..71be585c3 100644 --- a/frontend/views/containers/chatroom/poll-message-content/PollToVote.vue +++ b/frontend/views/containers/chatroom/poll-message-content/PollToVote.vue @@ -69,7 +69,7 @@ export default ({ }, computed: { ...mapGetters([ - 'ourUsername', + 'ourIdentityContractId', 'currentChatRoomId' ]), allowMultipleChoices () { @@ -143,7 +143,7 @@ export default ({ }, mounted () { if (this.isChangeMode) { - const extractedIds = this.pollData.options.filter(opt => opt.voted.includes(this.ourUsername)) + const extractedIds = this.pollData.options.filter(opt => opt.voted.includes(this.ourIdentityContractId)) .map(opt => opt.id) if (extractedIds.length) { diff --git a/frontend/views/containers/chatroom/poll-message-content/PollVoteResult.vue b/frontend/views/containers/chatroom/poll-message-content/PollVoteResult.vue index 1e6de16bd..d588d0795 100644 --- a/frontend/views/containers/chatroom/poll-message-content/PollVoteResult.vue +++ b/frontend/views/containers/chatroom/poll-message-content/PollVoteResult.vue @@ -32,7 +32,7 @@ .c-voters .c-voter-avatars-item(v-for='entry in list.voters' :key='entry.id') - voter-avatars(:voters='entry.users' :optionName='entry.optionName') + voter-avatars(:voters='entry.members' :optionName='entry.optionName') diff --git a/frontend/views/containers/payments/payment-row/PaymentRow.vue b/frontend/views/containers/payments/payment-row/PaymentRow.vue index 0a4a9dd42..941f3111d 100644 --- a/frontend/views/containers/payments/payment-row/PaymentRow.vue +++ b/frontend/views/containers/payments/payment-row/PaymentRow.vue @@ -7,10 +7,10 @@ template(v-if='!$slots["cellUser"]') .c-user profile-card( - :username='payment.username' + :contractID='payment.toMemberID' direction='top-left' ) - avatar-user.c-avatar(:username='payment.username' size='xs') + avatar-user.c-avatar(:contractID='payment.toMemberID' size='xs') strong.c-name {{payment.displayName}} span.c-user-date(:class='payment.isLate ? "pill is-danger" : "has-text-1"') {{ humanDate(payment.date) }} diff --git a/frontend/views/containers/proposals/AddMembers.vue b/frontend/views/containers/proposals/AddMembers.vue index 448433060..46924bac1 100644 --- a/frontend/views/containers/proposals/AddMembers.vue +++ b/frontend/views/containers/proposals/AddMembers.vue @@ -121,7 +121,7 @@ export default ({ data: { proposalType: PROPOSAL_INVITE_MEMBER, proposalData: { - member: invitee, + memberName: invitee, reason: form.reason }, votingRule: this.groupSettings.proposals[PROPOSAL_INVITE_MEMBER].rule, diff --git a/frontend/views/containers/proposals/DistributionDate.vue b/frontend/views/containers/proposals/DistributionDate.vue index f7062f883..1b0db6cf8 100644 --- a/frontend/views/containers/proposals/DistributionDate.vue +++ b/frontend/views/containers/proposals/DistributionDate.vue @@ -101,7 +101,7 @@ export default ({ ]), ...mapGetters([ 'groupDistributionStarted', - 'ourUsername', + 'ourIdentityContractId', 'groupShouldPropose', 'groupSettings', 'groupMembersCount' @@ -113,7 +113,7 @@ export default ({ return this.groupDistributionStarted(new Date().toISOString()) }, shouldImmediateChangeDistributionDate () { - return !this.distributionStarted && this.ourUsername === this.groupSettings.groupCreator + return !this.distributionStarted && this.ourIdentityContractId === this.groupSettings.groupCreatorID } }, beforeMount () { diff --git a/frontend/views/containers/proposals/MemberRequest.vue b/frontend/views/containers/proposals/MemberRequest.vue index bd0b46ca4..0f0bad848 100644 --- a/frontend/views/containers/proposals/MemberRequest.vue +++ b/frontend/views/containers/proposals/MemberRequest.vue @@ -32,12 +32,12 @@ ul.c-group-list li.c-group-member( - v-for='{username, displayName, status, date} in requestsSorted' + v-for='{contractID, username, displayName, status, date} in requestsSorted' :data-test='`request-${username}`' - :key='username' + :key='contractID' ) - profile-card(:username='username') - avatar-user(:username='username' size='sm') + profile-card(:contractID='contractID') + avatar-user(:contractID='contractID' size='sm') .c-name.has-text-bold {{username}} .c-date.has-text-1 {{ humanDate(date, { month: 'long', day: 'numeric', year: 'numeric' }) }} .c-action-container(v-if='status === "requested"') @@ -94,18 +94,21 @@ export default { SvgConversation, requestsSorted: [ { + contractID: '1', username: 'Pierre', date: new Date().toISOString(), displayName: 'Pierre', status: 'requested' }, { + contractID: '2', username: 'Pierre', date: new Date().toISOString(), displayName: 'Pierre', status: 'rejected' }, { + contractID: '3', username: 'Greg', date: new Date().toISOString(), displayName: 'Greg', diff --git a/frontend/views/containers/proposals/ProposalItem.vue b/frontend/views/containers/proposals/ProposalItem.vue index dd81b0770..d00d47b51 100644 --- a/frontend/views/containers/proposals/ProposalItem.vue +++ b/frontend/views/containers/proposals/ProposalItem.vue @@ -10,7 +10,7 @@ li.c-item-wrapper(data-test='proposalItem') ) avatar-user.c-avatar( v-else - :username='proposal.meta.username' size='xs' + :contractID='proposal.creatorID' size='xs' ) .c-main-content @@ -43,7 +43,7 @@ li.c-item-wrapper(data-test='proposalItem') banner-scoped(ref='voteMsg' data-test='voteMsg') p.c-sendLink(v-if='invitationLink' data-test='sendLink') i18n( - :args='{ user: proposal.data.proposalData.member}' + :args='{ user: proposal.data.proposalData.memberName}' ) Please send the following link to {user} so they can join the group: link-to-copy.c-invite-link( @@ -115,8 +115,9 @@ export default ({ ...mapGetters([ 'currentGroupState', 'groupMembersCount', - 'userDisplayName', - 'ourUsername', + 'userDisplayNameFromID', + 'usernameFromID', + 'ourIdentityContractId', 'ourUserDisplayName' ]), ...mapState(['currentGroupId']), @@ -127,8 +128,9 @@ export default ({ return this.proposalObject || this.currentGroupState.proposals[this.proposalHash] }, subtitle () { - const username = this.proposal.meta.username - const isOwnProposal = username === this.ourUsername + const creatorID = this.proposal.creatorID + const username = this.usernameFromID(creatorID) + const isOwnProposal = creatorID === this.ourIdentityContractId if (this.proposal.data.proposalData.automated) { return L('Group Income system is proposing') @@ -149,18 +151,18 @@ export default ({ return this.proposal.data.proposalType }, isOurProposal () { - return this.proposal.meta.username === this.ourUsername + return this.proposal.creatorID === this.ourIdentityContractId }, isToRemoveMe () { - return this.proposalType === PROPOSAL_REMOVE_MEMBER && this.proposal.data.proposalData.member === this.ourUsername + return this.proposalType === PROPOSAL_REMOVE_MEMBER && this.proposal.data.proposalData.memberID === this.ourIdentityContractId }, typeDescription () { return { [PROPOSAL_INVITE_MEMBER]: () => L('Add {user} to group.', { - user: this.proposal.data.proposalData.member + user: this.usernameFromID(this.proposal.data.proposalData.memberName) }), [PROPOSAL_REMOVE_MEMBER]: () => { - const user = this.userDisplayName(this.proposal.data.proposalData.member) + const user = this.userDisplayNameFromID(this.proposal.data.proposalData.memberID) const automated = this.proposal.data.proposalData.automated ? `[${L('Automated')}] ` : '' return this.isToRemoveMe ? L('{automated}Remove {user} (you) from the group.', { user, automated }) @@ -310,7 +312,7 @@ export default ({ this.currentGroupState._vm.authorizedKeys[inviteKeyId]._notAfterHeight === undefined && this.currentGroupState._vm.invites?.[inviteKeyId]?.inviteSecret ) { - return buildInvitationUrl(this.currentGroupId, this.currentGroupState.settings?.groupName, this.currentGroupState._vm.invites[inviteKeyId].inviteSecret, this.ourUserDisplayName) + return buildInvitationUrl(this.currentGroupId, this.currentGroupState.settings?.groupName, this.currentGroupState._vm.invites[inviteKeyId].inviteSecret, this.ourIdentityContractId) } } return false diff --git a/frontend/views/containers/proposals/ProposalTemplate.vue b/frontend/views/containers/proposals/ProposalTemplate.vue index 56fcfdf2a..caa72554e 100644 --- a/frontend/views/containers/proposals/ProposalTemplate.vue +++ b/frontend/views/containers/proposals/ProposalTemplate.vue @@ -126,7 +126,7 @@ export default ({ computed: { ...mapGetters([ 'groupSettings', - 'ourUsername', + 'ourIdentityContractId', 'groupMembersCount', 'groupShouldPropose', 'groupProposalSettings' @@ -138,7 +138,7 @@ export default ({ return this.groupProposalSettings() }, isGroupCreator () { - return this.ourUsername === this.groupSettings.groupCreator + return this.ourIdentityContractId === this.groupSettings.groupCreatorID }, isNextStep () { return this.currentStep <= this.maxSteps - 1 diff --git a/frontend/views/containers/proposals/ProposalVoteOptions.vue b/frontend/views/containers/proposals/ProposalVoteOptions.vue index 88c1d2f03..432eb3f4e 100644 --- a/frontend/views/containers/proposals/ProposalVoteOptions.vue +++ b/frontend/views/containers/proposals/ProposalVoteOptions.vue @@ -52,7 +52,7 @@ export default ({ 'currentGroupId' ]), ...mapGetters([ - 'ourUsername', + 'ourIdentityContractId', 'currentGroupState', 'groupSettings', 'currentIdentityState' @@ -65,7 +65,7 @@ export default ({ [VOTE_FOR]: L('yes'), [VOTE_AGAINST]: L('no') } - return humanStatus[this.proposal.votes[this.ourUsername]] + return humanStatus[this.proposal.votes[this.ourIdentityContractId]] }, meta () { return this.proposal.meta @@ -77,13 +77,13 @@ export default ({ return this.proposal.data.proposalData }, isToRemoveMe () { - return this.type === PROPOSAL_REMOVE_MEMBER && this.data.member === this.ourUsername + return this.type === PROPOSAL_REMOVE_MEMBER && this.data.memberID === this.ourIdentityContractId }, hadVoted () { - return this.proposal.votes[this.ourUsername] + return this.proposal.votes[this.ourIdentityContractId] }, ownProposal () { - return this.ourUsername === this.proposal.meta.username + return this.ourIdentityContractId === this.proposal.creatorID }, refVoteMsg () { return this.$parent.$refs.voteMsg @@ -95,7 +95,7 @@ export default ({ }, async voteFor () { // Avoid redundant vote from "Change vote" if already voted FOR before - if (!confirm(L('Are you sure you want to vote yes?')) || this.proposal.votes[this.ourUsername] === VOTE_FOR) { + if (!confirm(L('Are you sure you want to vote yes?')) || this.proposal.votes[this.ourIdentityContractId] === VOTE_FOR) { return null } this.ephemeral.changingVote = false @@ -107,14 +107,12 @@ export default ({ if (oneVoteToPass(proposalHash)) { if (this.type === PROPOSAL_INVITE_MEMBER) { passPayload = await createInvite({ - invitee: this.proposal.data.proposalData.member, - creator: this.proposal.meta.username, + invitee: this.proposal.data.proposalData.memberName, + creatorID: this.proposal.creatorID, expires: this.currentGroupState.settings.inviteExpiryProposal }) } else if (this.type === PROPOSAL_REMOVE_MEMBER) { - passPayload = { - secret: `${parseInt(Math.random() * 10000)}` // TODO: this - } + passPayload = {} } } await sbp('gi.actions/group/proposalVote', { @@ -128,7 +126,7 @@ export default ({ }, async voteAgainst () { // Avoid redundant vote from "Change vote" if already voted AGAINST before - if (!confirm(L('Are you sure you want to vote no?')) || this.proposal.votes[this.ourUsername] === VOTE_AGAINST) { + if (!confirm(L('Are you sure you want to vote no?')) || this.proposal.votes[this.ourIdentityContractId] === VOTE_AGAINST) { return null } this.ephemeral.changingVote = false diff --git a/frontend/views/containers/proposals/ProposalsWidget.vue b/frontend/views/containers/proposals/ProposalsWidget.vue index c4f1b5b31..561de8048 100644 --- a/frontend/views/containers/proposals/ProposalsWidget.vue +++ b/frontend/views/containers/proposals/ProposalsWidget.vue @@ -80,7 +80,6 @@ export default ({ 'groupDistributionStarted', 'groupShouldPropose', 'groupSettings', - 'ourUsername', 'ourIdentityContractId', 'groupMembersCount' ]), @@ -111,7 +110,7 @@ export default ({ } }, proposalOptions () { - const isUserGroupCreator = this.ourUsername === this.groupSettings.groupCreator + const isUserGroupCreator = this.ourIdentityContractId === this.groupSettings.groupCreatorID const defaultDisableConfig = !this.groupShouldPropose && !isUserGroupCreator return [ diff --git a/frontend/views/containers/proposals/PropositionsAllModal.vue b/frontend/views/containers/proposals/PropositionsAllModal.vue index 6d5960319..8f2d2d1db 100644 --- a/frontend/views/containers/proposals/PropositionsAllModal.vue +++ b/frontend/views/containers/proposals/PropositionsAllModal.vue @@ -63,7 +63,7 @@ export default ({ }, computed: { ...mapState(['currentGroupId']), - ...mapGetters(['currentGroupState', 'ourUsername', 'ourIdentityContractId']), + ...mapGetters(['currentGroupState', 'ourIdentityContractId']), proposals () { const p = this.ephemeral.proposals return this.ephemeral.selectbox.selectedOption === 'Newest' ? p : [...p].reverse() diff --git a/frontend/views/containers/proposals/RemoveMember.vue b/frontend/views/containers/proposals/RemoveMember.vue index f3ca43239..5daba8679 100644 --- a/frontend/views/containers/proposals/RemoveMember.vue +++ b/frontend/views/containers/proposals/RemoveMember.vue @@ -12,8 +12,8 @@ proposal-template( avatar.c-avatar(:src='memberGlobalProfile.picture' size='lg') p.is-title-4.c-descr(data-test='description') - i18n(:args='{ name: userDisplayName(username) }' v-if='groupShouldPropose') Remove {name} from the group - i18n(:args='{ name: userDisplayName(username) }' v-else) Are you sure you want to remove {name} from the group? + i18n(:args='{ name: userDisplayNameFromID(memberID) }' v-if='groupShouldPropose') Remove {name} from the group + i18n(:args='{ name: userDisplayNameFromID(memberID) }' v-else) Are you sure you want to remove {name} from the group? label.checkbox.c-use-admin-permissions(v-if='groupShouldPropose && isGroupCreator') input.input(type='checkbox' v-model='form.useAdminPermission') @@ -40,7 +40,7 @@ export default ({ }, data () { return { - username: null, + memberID: null, ephemeral: { currentStep: 0 }, @@ -55,16 +55,16 @@ export default ({ } }, created () { - const username = this.$route.query.username - const isPartOfGroup = this.groupProfiles[username] + const memberID = this.$route.query.memberID + const isPartOfGroup = this.groupProfiles[memberID] - if (username) { - sbp('okTurtles.events/emit', SET_MODAL_QUERIES, 'RemoveMember', { username }) + if (memberID) { + sbp('okTurtles.events/emit', SET_MODAL_QUERIES, 'RemoveMember', { memberID }) } if (isPartOfGroup) { - this.username = username + this.memberID = memberID } else { - console.warn('RemoveMember: Missing valid query "username".') + console.warn('RemoveMember: Missing valid query "memberID".') sbp('okTurtles.events/emit', CLOSE_MODAL) } }, @@ -79,20 +79,20 @@ export default ({ 'groupSettings', 'groupShouldPropose', 'groupMembersCount', - 'ourUsername', - 'userDisplayName' + 'ourIdentityContractId', + 'userDisplayNameFromID' ]), memberGlobalProfile () { - return this.globalProfile(this.username) || {} + return this.globalProfile(this.memberID) || {} }, isGroupCreator () { - return this.ourUsername === this.groupSettings.groupCreator + return this.ourIdentityContractId === this.groupSettings.groupCreatorID } }, methods: { async submit (form) { this.$refs.formMsg.clean() - const member = this.username + const memberID = this.memberID if (this.groupShouldPropose && !this.form.useAdminPermission) { try { @@ -101,7 +101,7 @@ export default ({ data: { proposalType: PROPOSAL_REMOVE_MEMBER, proposalData: { - member, + memberID, reason: form.reason }, votingRule: this.groupSettings.proposals[PROPOSAL_REMOVE_MEMBER].rule, @@ -110,7 +110,7 @@ export default ({ }) this.ephemeral.currentStep += 1 } catch (e) { - console.error('RemoveMember submit() error:', member, e) + console.error('RemoveMember submit() error:', memberID, e) this.$refs.formMsg.danger(e.message) this.ephemeral.currentStep = 0 @@ -120,11 +120,11 @@ export default ({ try { await sbp('gi.actions/group/removeMember', { - contractID: this.currentGroupId, data: { member } + contractID: this.currentGroupId, data: { memberID } }) this.$refs.proposal.close() } catch (e) { - console.error('Failed to remove member %s:', member, e.message) + console.error('Failed to remove member %s:', memberID, e.message) this.$refs.formMsg.danger(e.message) } } diff --git a/frontend/views/pages/Contributions.vue b/frontend/views/pages/Contributions.vue index af8dae632..8dcf666db 100644 --- a/frontend/views/pages/Contributions.vue +++ b/frontend/views/pages/Contributions.vue @@ -167,7 +167,6 @@ export default ({ }, computed: { ...mapGetters([ - 'ourUsername', 'ourGroupProfile', 'groupSettings', 'groupMembersCount', diff --git a/frontend/views/pages/DesignSystem.vue b/frontend/views/pages/DesignSystem.vue index 29296ccf5..26e3d2b23 100644 --- a/frontend/views/pages/DesignSystem.vue +++ b/frontend/views/pages/DesignSystem.vue @@ -1050,8 +1050,8 @@ page( pre | users-selector( | label='Search for users' - | :usernames='["alexjin", "greg", "andrea"]' - | defaultValue='alexjin' + | :userIDs='["contractID 1", "contractID 2", "contractID 3"]' + | defaultValue='contractID 1' | :autofocus='true' | @change='onChange' | @remove='onRemove' @@ -1061,8 +1061,8 @@ page( td users-selector( label='Search for users' - :usernames='form.searchUsers' - defaultValue='alexjin' + :userIDs='form.searchUserIDs' + defaultValue='contractID 1' ) tr td @@ -1534,7 +1534,7 @@ export default ({ }, form: { searchValue: '', - searchUsers: [], + searchUserIDs: [], selectPayment: 'choose', copyableInput: '', sliderValue: 25 diff --git a/frontend/views/pages/GroupChat.vue b/frontend/views/pages/GroupChat.vue index c387715b5..3bb886a3a 100644 --- a/frontend/views/pages/GroupChat.vue +++ b/frontend/views/pages/GroupChat.vue @@ -20,13 +20,13 @@ page(pageTestName='groupChat' :miniHeader='isDirectMessage()') ul menu-item( - v-if='!summary.isGeneral && ourUsername === summary.attributes.creator && !isDirectMessage()' + v-if='!summary.isGeneral && ourIdentityContractId === summary.attributes.creatorID && !isDirectMessage()' @click='openModal("EditChannelNameModal")' data-test='renameChannel' ) i18n Rename menu-item( - v-if='ourUsername === summary.attributes.creator && !isDirectMessage()' + v-if='ourIdentityContractId === summary.attributes.creatorID && !isDirectMessage()' @click='editDescription' data-test='updateDescription' ) @@ -49,7 +49,7 @@ page(pageTestName='groupChat' :miniHeader='isDirectMessage()') ) i18n(:args='{ channelName: summary.title }') Leave {channelName} menu-item.has-text-danger( - v-if='!summary.isGeneral && ourUsername === summary.attributes.creator && !isDirectMessage()' + v-if='!summary.isGeneral && ourIdentityContractId === summary.attributes.creatorID && !isDirectMessage()' @click='openModal("DeleteChannelModal")' data-test='deleteChannel' ) @@ -60,16 +60,16 @@ page(pageTestName='groupChat' :miniHeader='isDirectMessage()') i18n.is-unstyled.c-link( tag='button' @click='openModal("ChatMembersAllModal")' - :args='{ numMembers: summary.numberOfUsers }' + :args='{ numMembers: summary.numberOfMembers }' data-test='channelMembers' ) {numMembers} members template( - v-if='summary.attributes.description || ourUsername === summary.attributes.creator' + v-if='summary.attributes.description || ourIdentityContractId === summary.attributes.creatorID' ) | ∙ .is-unstyled( v-if='summary.attributes.description' - :class='{"c-link": ourUsername === summary.attributes.creator}' + :class='{"c-link": ourIdentityContractId === summary.attributes.creatorID}' data-test='updateDescription' @click='editDescription' ) @@ -136,7 +136,7 @@ export default ({ 'groupProfiles', 'isJoinedChatRoom', 'getGroupChatRooms', - 'ourUsername' + 'ourIdentityContractId' ]), getChatRoomIDsInSort () { return Object.keys(this.getGroupChatRooms || {}).map(chatRoomID => ({ diff --git a/frontend/views/pages/GroupDashboard.vue b/frontend/views/pages/GroupDashboard.vue index 5ef88782f..dd75dbfd5 100644 --- a/frontend/views/pages/GroupDashboard.vue +++ b/frontend/views/pages/GroupDashboard.vue @@ -81,7 +81,6 @@ export default ({ 'currentGroupId' ]), ...mapGetters([ - 'ourUsername', 'currentGroupState', // TODO normalize getters names 'groupSettings', 'groupsByName', diff --git a/frontend/views/pages/Join.vue b/frontend/views/pages/Join.vue index 008050a4b..ca8d484a5 100644 --- a/frontend/views/pages/Join.vue +++ b/frontend/views/pages/Join.vue @@ -80,7 +80,7 @@ export default ({ } }, computed: { - ...mapGetters(['ourUsername']), + ...mapGetters(['ourIdentityContractId']), ...mapState(['currentGroupId']), pageStatus: { get () { return this.ephemeral.pageStatus }, @@ -95,8 +95,8 @@ export default ({ }, mounted () { // For some reason in some Cypress tests it loses the route query when initialized is called - this.ephemeral.query = this.$route.query - if (syncFinished || !this.ourUsername) { + this.ephemeral.hash = new URLSearchParams(this.$route.hash.slice(1)) + if (syncFinished || !this.ourIdentityContractId) { this.initialize() } else { sbp('okTurtles.events/once', LOGIN, () => this.initialize()) @@ -105,10 +105,18 @@ export default ({ methods: { async initialize () { try { - const state = await sbp('chelonia/latestContractState', this.ephemeral.query.groupId) - const publicKeyId = keyId(this.ephemeral.query.secret) - const invite = state._vm.invites[publicKeyId] const messageToAskAnother = L('You should ask for a new one. Sorry about that!') + const groupId = this.ephemeral.hash.get('groupId') + const secret = this.ephemeral.hash.get('secret') + if (!groupId || !secret) { + console.error('Invalid invite link: missing group ID or secret') + this.ephemeral.errorMsg = messageToAskAnother + this.pageStatus = 'INVALID' + return + } + const state = await sbp('chelonia/latestContractState', groupId) + const publicKeyId = keyId(secret) + const invite = state._vm.invites[publicKeyId] if (invite?.expires < Date.now()) { console.log('Join.vue error: Link is already expired.') this.ephemeral.errorMsg = messageToAskAnother @@ -125,22 +133,25 @@ export default ({ this.pageStatus = 'INVALID' return } - if (this.ourUsername) { - if (this.currentGroupId && [PROFILE_STATUS.ACTIVE, PROFILE_STATUS.PENDING].includes(this.$store.state.contracts[this.ephemeral.query.groupId]?.profiles?.[this.ourUsername])) { + if (this.ourIdentityContractId) { + if (this.currentGroupId && [PROFILE_STATUS.ACTIVE, PROFILE_STATUS.PENDING].includes(this.$store.state.contracts[this.ephemeral.hash.groupId]?.profiles?.[this.ourIdentityContractId])) { this.$router.push({ path: '/dashboard' }) } else { await this.accept() } return } - const creator = this.ephemeral.query.creator - const message = creator - ? L('{who} invited you to join their group!', { who: creator }) + const creatorID = this.ephemeral.hash.get('creatorID') + const creatorUsername = this.ephemeral.hash.get('creatorUsername') + const who = creatorUsername || creatorID + const message = who + ? L('{who} invited you to join their group!', { who }) : L('You were invited to join') this.ephemeral.invitation = { - groupName: this.ephemeral.query.groupName ?? L('(group name unavailable)'), - creator, + groupName: this.ephemeral.hash.get('groupName') ?? L('(group name unavailable)'), + creatorID, + creatorUsername, message } this.pageStatus = 'SIGNING' @@ -158,8 +169,10 @@ export default ({ }, async accept () { this.ephemeral.errorMsg = null - const { groupId, secret } = this.ephemeral.query - const profileStatus = this.$store.state.contracts[groupId]?.profiles?.[this.ourUsername]?.status + const groupId = this.ephemeral.hash.get('groupId') + const secret = this.ephemeral.hash.get('secret') + + const profileStatus = this.$store.state.contracts[groupId]?.profiles?.[this.ourIdentityContractId]?.status if ([PROFILE_STATUS.ACTIVE, PROFILE_STATUS.PENDING].includes(profileStatus)) { return this.$router.push({ path: '/dashboard' }) } diff --git a/frontend/views/pages/Payments.vue b/frontend/views/pages/Payments.vue index 5cd0a661b..82c1f4dfd 100644 --- a/frontend/views/pages/Payments.vue +++ b/frontend/views/pages/Payments.vue @@ -261,7 +261,7 @@ export default ({ 'currentPaymentPeriod', 'ourGroupProfile', 'groupSettings', - 'userDisplayName', + 'userDisplayNameFromID', 'withGroupCurrency' ]), needsIncome () { @@ -343,8 +343,8 @@ export default ({ for (const payment of this.historicalPayments.todo) { payments.push({ hash: payment.hash || randomHexString(15), - username: payment.to, - displayName: this.userDisplayName(payment.to), + toMemberID: payment.toMemberID, + displayName: this.userDisplayNameFromID(payment.toMemberID), amount: payment.amount, total: payment.total, partial: payment.partial, @@ -359,8 +359,8 @@ export default ({ paymentsSent () { return this.historicalPayments.sent.map(payment => ({ ...payment, - username: payment.data.toUser, - displayName: this.userDisplayName(payment.data.toUser), + toMemberID: payment.data.toMemberID, + displayName: this.userDisplayNameFromID(payment.data.toMemberID), monthstamp: dateToMonthstamp(payment.meta.createdDate), date: payment.meta.createdDate })) @@ -368,8 +368,8 @@ export default ({ paymentsReceived () { return this.historicalPayments.received.map(payment => ({ ...payment, - username: payment.meta.username, // fromUser - displayName: this.userDisplayName(payment.meta.username), + fromMemberID: payment.data.fromMemberID, + displayName: this.userDisplayNameFromID(payment.data.fromMemberID), date: payment.meta.createdDate })) }, diff --git a/frontend/views/pages/PendingApproval.vue b/frontend/views/pages/PendingApproval.vue index 6a6ad50f0..7d4fb3bf2 100644 --- a/frontend/views/pages/PendingApproval.vue +++ b/frontend/views/pages/PendingApproval.vue @@ -39,7 +39,7 @@ export default ({ } }, computed: { - ...mapGetters(['ourUsername']), + ...mapGetters(['ourIdentityContractId']), ...mapState(['currentGroupId']), groupState () { if (!this.ephemeral.groupIdWhenMounted) return @@ -52,7 +52,7 @@ export default ({ const state = this.groupState return ( // We want the group state to be active - state?.profiles?.[this.ourUsername]?.status === PROFILE_STATUS.ACTIVE && + state?.profiles?.[this.ourIdentityContractId]?.status === PROFILE_STATUS.ACTIVE && // And we don't want to be in the process of re-syncing (i.e., re-building // the state after receiving new private keys) !sbp('chelonia/contract/isResyncing', state) && diff --git a/frontend/views/utils/lightning-dummy-data.js b/frontend/views/utils/lightning-dummy-data.js index 184080548..23eb97479 100644 --- a/frontend/views/utils/lightning-dummy-data.js +++ b/frontend/views/utils/lightning-dummy-data.js @@ -42,7 +42,7 @@ export const dummyLightningPaymentDetails = { data: { // $FlowFixMe transactionId: randomHexString(50), - toUser: 'fake-user-2', + toMemberID: 'fake-user-2', amount: 98.57142857, groupMincome: 1000, memo: 'Love you so much! Thank you for the Portuguese class last week. P.S.: sent to the Paypal email on your profile.', diff --git a/shared/domains/chelonia/GIMessage.js b/shared/domains/chelonia/GIMessage.js index d1d793871..5b98708fd 100644 --- a/shared/domains/chelonia/GIMessage.js +++ b/shared/domains/chelonia/GIMessage.js @@ -38,7 +38,7 @@ export type GIOpActionEncrypted = EncryptedData // encryp export type GIOpKeyAdd = (GIKey | EncryptedData)[] export type GIOpKeyDel = (string | EncryptedData)[] export type GIOpPropSet = { key: string; value: JSONType } -export type ProtoGIOpKeyShare = { contractID: string; keys: GIKey[]; foreignContractID?: string; keyRequestHash?: string } +export type ProtoGIOpKeyShare = { contractID: string; keys: GIKey[]; foreignContractID?: string; keyRequestHash?: string, keyRequestHeight?: number } export type GIOpKeyShare = ProtoGIOpKeyShare | EncryptedData // TODO encrypted GIOpKeyRequest export type ProtoGIOpKeyRequest = { diff --git a/shared/domains/chelonia/chelonia.js b/shared/domains/chelonia/chelonia.js index afdd70bf5..bcc10dd0a 100644 --- a/shared/domains/chelonia/chelonia.js +++ b/shared/domains/chelonia/chelonia.js @@ -410,6 +410,28 @@ export default (sbp('sbp/selectors/register', { if (!result?.length) return null return result }, + 'chelonia/contract/successfulKeySharesByContractID': function (contractIDOrState: string | Object, requestingContractID?: string) { + if (typeof contractIDOrState === 'string') { + const rootState = sbp(this.config.stateSelector) + contractIDOrState = rootState[contractIDOrState] + } + const keyShares = Object.values(contractIDOrState._vm.keyshares || {}) + if (!keyShares?.length) return + const result = Object.create(null) + // $FlowFixMe[incompatible-call] + keyShares.forEach((kS: { success: boolean, contractID: string, height: number, hash: string }) => { + if (!kS.success) return + if (requestingContractID && kS.contractID !== requestingContractID) return + if (!result[kS.contractID]) result[kS.contractID] = [] + result[kS.contractID].push({ height: kS.height, hash: kS.hash }) + }) + Object.keys(result).forEach(cID => { + result[cID].sort((a, b) => { + return b.height - a.height + }) + }) + return result + }, 'chelonia/contract/hasKeysToPerformOperation': function (contractIDOrState: string | Object, operation: string) { if (typeof contractIDOrState === 'string') { const rootState = sbp(this.config.stateSelector) @@ -431,7 +453,7 @@ export default (sbp('sbp/selectors/register', { const op = (operation !== '*') ? [operation] : operation const keyId = findSuitableSecretKeyId(contractIDOrState, op, ['sig']) - return sourceContractIDOrState?._vm?.sharedKeyIds?.includes(keyId) + return sourceContractIDOrState?._vm?.sharedKeyIds?.some((sK) => sK.id === keyId) }, 'chelonia/contract/currentKeyIdByName': function (contractIDOrState: string | Object, name: string, requireSecretKey?: boolean) { if (typeof contractIDOrState === 'string') { @@ -580,7 +602,7 @@ export default (sbp('sbp/selectors/register', { const gProxy = gettersProxy(state, contract.getters) state = state || contract.state(contractID) contract.metadata.validate(meta, { state, ...gProxy, contractID }) - contract.actions[action].validate(data, { state, ...gProxy, meta, contractID }) + contract.actions[action].validate(data, { state, ...gProxy, meta, message, contractID }) contract.actions[action].process(message, { state, ...gProxy }) }, // 'mutation' is an object that's similar to 'message', but not identical @@ -744,17 +766,19 @@ export default (sbp('sbp/selectors/register', { } if (params?.removeIfPending) { - if (!rootState.contracts[contractID].pendingRemove) { - if (has(this.removeCount, contractID)) { - if (this.removeCount[contractID] > 1) { - this.removeCount[contractID] -= 1 - } else { - delete this.removeCount[contractID] - } + if (has(this.removeCount, contractID)) { + if (this.removeCount[contractID] > 1) { + this.removeCount[contractID] -= 1 + } else { + delete this.removeCount[contractID] } + } + if (!rootState.contracts[contractID].pendingRemove) { return undefined } - } else if (this.removeCount[contractID] >= 1) { + } + + if (this.removeCount[contractID] >= 1) { rootState.contracts[contractID].pendingRemove = true return undefined } diff --git a/shared/domains/chelonia/internals.js b/shared/domains/chelonia/internals.js index 703835383..5934632dd 100644 --- a/shared/domains/chelonia/internals.js +++ b/shared/domains/chelonia/internals.js @@ -17,6 +17,35 @@ import { findKeyIdByName, findSuitablePublicKeyIds, findSuitableSecretKeyId, get import { isSignedData, signedIncomingData } from './signedData.js' // import 'ses' +const getMsgMeta = (message: GIMessage, contractID: string, state: Object) => { + const signingKeyId = message.signingKeyId() + let innerSigningKeyId: ?string = null + + const result = { + signingKeyId, + get signingContractID () { + return getContractIDfromKeyId(contractID, signingKeyId, state) + }, + get innerSigningKeyId () { + if (innerSigningKeyId === null) { + const value = message.message() + const data = unwrapMaybeEncryptedData(value) + if (data?.data && isSignedData(data.data)) { + innerSigningKeyId = (data.data: any).signingKeyId + } else { + innerSigningKeyId = undefined + } + return innerSigningKeyId + } + }, + get innerSigningContractID () { + return getContractIDfromKeyId(contractID, result.innerSigningKeyId, state) + } + } + + return result +} + const keysToMap = (keys: (GIKey | EncryptedData)[], height: number, authorizedKeys?: Object): Object => { // Using cloneDeep to ensure that the returned object is serializable // Keys in a GIMessage may not be serializable (i.e., supported by the @@ -35,7 +64,7 @@ const keysToMap = (keys: (GIKey | EncryptedData)[], height: number, autho key._notBeforeHeight = height if (authorizedKeys?.[key.id]) { if (authorizedKeys[key.id]._notAfterHeight == null) { - throw new Error('Cannot set existing unrevoked key') + throw new Error(`Cannot set existing unrevoked key: ${key.id}`) } // If the key was get previously, preserve its _notBeforeHeight // NOTE: (SECURITY) This may allow keys for periods for which it wasn't @@ -210,9 +239,9 @@ export default (sbp('sbp/selectors/register', { getRandomValues: (v) => globalThis.crypto.getRandomValues(v) }, ...(typeof window === 'object' && window && { - alert: window.alert, - confirm: window.confirm, - prompt: window.prompt + alert: window.alert.bind(window), + confirm: window.confirm.bind(window), + prompt: window.prompt.bind(window) }), isNaN, console, @@ -527,8 +556,6 @@ export default (sbp('sbp/selectors/register', { v = (v: any).valueOf() } - // TODO: Verify signing permissions - const { data, meta, action } = (v: any) if (!config.whitelisted(action)) { @@ -567,7 +594,7 @@ export default (sbp('sbp/selectors/register', { for (const key of v.keys) { if (key.id && key.meta?.private?.content) { if (!has(state._vm, 'sharedKeyIds')) self.config.reactiveSet(state._vm, 'sharedKeyIds', []) - if (!state._vm.sharedKeyIds.includes(key.id)) state._vm.sharedKeyIds.push(key.id) + if (!state._vm.sharedKeyIds.some((sK) => sK.id === key.id)) state._vm.sharedKeyIds.push({ id: key.id, contractID: v.contractID, height, keyRequestHash: v.keyRequestHash, keyRequestHeight: v.keyRequestHeight }) } } @@ -776,6 +803,7 @@ export default (sbp('sbp/selectors/register', { self.config.reactiveSet(state._vm.keyshares, hash, { contractID: originatingContractID, + height, success, ...(success && { hash: v.keyShareHash @@ -1303,7 +1331,7 @@ export default (sbp('sbp/selectors/register', { ]) const keys = pick( - state['secretKeys'], + state.secretKeys, Object.entries(contractState._vm.authorizedKeys) .filter(([, key]) => !!(key: any).meta?.private?.shareable) .map(([kId]) => kId) @@ -1325,7 +1353,8 @@ export default (sbp('sbp/selectors/register', { } } })), - keyRequestHash: hash + keyRequestHash: hash, + keyRequestHeight: height } // 3. Send OP_KEY_SHARE to identity contract @@ -1516,7 +1545,7 @@ export default (sbp('sbp/selectors/register', { // we revert any changes to the contract state that occurred, ignoring this mutation console.warn(`[chelonia] Error processing ${message.description()}: ${message.serialize()}. Any side effects will be skipped!`) processingErrored = e?.name !== 'ChelErrorWarning' - this.config.hooks.processError?.(e, message) + this.config.hooks.processError?.(e, message, getMsgMeta(message, contractID, state)) // special error that prevents the head from being updated, effectively killing the contract if (e.name === 'ChelErrorUnrecoverable') throw e } diff --git a/shared/domains/chelonia/signedData.js b/shared/domains/chelonia/signedData.js index defc16890..889490701 100644 --- a/shared/domains/chelonia/signedData.js +++ b/shared/domains/chelonia/signedData.js @@ -236,7 +236,10 @@ export const signedIncomingData = (contractID: string, state: ?Object, data: any if (mapperFn) verifySignedValue[1] = mapperFn(verifySignedValue[1]) return verifySignedValue[1] } - : () => JSON.parse(data._signedData[0]) + : () => { + const signedValue = JSON.parse(data._signedData[0]) + return mapperFn ? mapperFn(signedValue) : signedValue + } return wrapper({ get signingKeyId () { diff --git a/shared/domains/chelonia/utils.js b/shared/domains/chelonia/utils.js index 0d1a751e4..b65cfc95e 100644 --- a/shared/domains/chelonia/utils.js +++ b/shared/domains/chelonia/utils.js @@ -524,7 +524,8 @@ export const recreateEvent = (entry: GIMessage, state: Object, contractsState: O return entry } -export const getContractIDfromKeyId = (contractID: string, signingKeyId?: string, state: Object): string => { +export const getContractIDfromKeyId = (contractID: string, signingKeyId: ?string, state: Object): ?string => { + if (!signingKeyId) return return signingKeyId && state._vm.authorizedKeys[signingKeyId].foreignKey ? new URL(state._vm.authorizedKeys[signingKeyId].foreignKey).pathname : contractID diff --git a/test/backend.test.js b/test/backend.test.js index 48bc3590e..d5bb3a3fa 100644 --- a/test/backend.test.js +++ b/test/backend.test.js @@ -9,7 +9,7 @@ import { createCID } from '~/shared/functions.js' import * as Common from '@common/common.js' import proposals from '~/frontend/model/contracts/shared/voting/proposals.js' import { PAYMENT_PENDING, PAYMENT_TYPE_MANUAL } from '~/frontend/model/contracts/shared/payments/index.js' -import { PROPOSAL_INVITE_MEMBER, PROPOSAL_REMOVE_MEMBER, PROPOSAL_GROUP_SETTING_CHANGE, PROPOSAL_PROPOSAL_SETTING_CHANGE, PROPOSAL_GENERIC } from '~/frontend/model/contracts/shared/constants.js' +import { PROPOSAL_INVITE_MEMBER, PROPOSAL_REMOVE_MEMBER, PROPOSAL_GROUP_SETTING_CHANGE, PROPOSAL_PROPOSAL_SETTING_CHANGE, PROPOSAL_GENERIC, PROFILE_STATUS } from '~/frontend/model/contracts/shared/constants.js' import '~/frontend/controller/namespace.js' import chalk from 'chalk' import { THEME_LIGHT } from '~/frontend/model/settings/themes.js' @@ -157,7 +157,7 @@ describe('Full walkthrough', function () { }) return msg } - function createGroup (name: string, hooks: Object = {}): Promise { + function createGroup (name: string, creator: any, hooks: Object = {}): Promise { const CSK = keygen(SNULL) const CSKid = keyId(CSK) const CSKp = serializeKey(CSK, false) @@ -168,12 +168,13 @@ describe('Full walkthrough', function () { /* const initialInvite = createInvite({ quantity: 60, - creator: INVITE_INITIAL_CREATOR, + creatorID: INVITE_INITIAL_CREATOR, expires: INVITE_EXPIRES_IN_DAYS.ON_BOARDING }) */ return sbp('chelonia/out/registerContract', { contractName: 'gi.contracts/group', keys: [ + // This is the group's CSK and is used for outer signatures. { id: CSKid, name: 'csk', @@ -182,6 +183,19 @@ describe('Full walkthrough', function () { permissions: '*', allowedActions: '*', data: CSKp + }, + // We need to add the creator's CSK to the group in order to validate + // inner signatures, which are part of the permissions system in the + // group contract. + { + id: creator.signingKeyId(), + name: 'creator', + purpose: ['sig'], + ringLevel: 1, + permissions: '*', + allowedActions: '*', + data: sbp('chelonia/rootState')[creator.contractID()]._vm.authorizedKeys[creator.signingKeyId()].data, + foreignKey: `sp:${encodeURIComponent(creator.contractID())}?keyName=${encodeURIComponent('csk')}` } ], data: { @@ -201,17 +215,22 @@ describe('Full walkthrough', function () { [PROPOSAL_PROPOSAL_SETTING_CHANGE]: proposals[PROPOSAL_PROPOSAL_SETTING_CHANGE].defaults, [PROPOSAL_GENERIC]: proposals[PROPOSAL_GENERIC].defaults } + }, + profiles: { + [creator.contractID()]: { + status: PROFILE_STATUS.ACTIVE + } } }, signingKeyId: CSKid, hooks }) } - function createPaymentTo (to, amount, contractID, signingKeyId, currency = 'USD'): Promise { + function createPaymentTo (from, to, amount, contractID, signingKeyId, currency = 'USD'): Promise { return sbp('chelonia/out/actionUnencrypted', { action: 'gi.contracts/group/payment', data: { - toUser: to.decryptedValue().data.attributes.username, + toMemberID: to.contractID(), amount: amount, currency: currency, txid: String(parseInt(Math.random() * 10000000)), @@ -219,7 +238,8 @@ describe('Full walkthrough', function () { paymentType: PAYMENT_TYPE_MANUAL }, contractID, - signingKeyId + signingKeyId, + innerSigningKeyId: from.signingKeyId() }) } @@ -261,12 +281,12 @@ describe('Full walkthrough', function () { it('Should create a group & subscribe Alice', async function () { // set user Alice as being logged in so that metadata on messages is properly set login(users.alice) - groups.group1 = await createGroup('group1') + groups.group1 = await createGroup('group1', users.alice) await sbp('chelonia/contract/sync', groups.group1.contractID()) }) it('Should post an event', function () { - return createPaymentTo(users.bob, 100, groups.group1.contractID(), groups.group1.signingKeyId()) + return createPaymentTo(users.alice, users.bob, 100, groups.group1.contractID(), groups.group1.signingKeyId()) }) it('Should sync group and verify payments in state', async function () { @@ -276,7 +296,7 @@ describe('Full walkthrough', function () { it('Should fail with wrong contractID', async function () { try { - await createPaymentTo(users.bob, 100, '') + await createPaymentTo(users.alice, users.bob, 100, '') return Promise.reject(new Error("shouldn't get here!")) } catch (e) { return Promise.resolve() diff --git a/test/cypress/integration/group-chat.spec.js b/test/cypress/integration/group-chat.spec.js index 9af21d388..1e0c18f25 100644 --- a/test/cypress/integration/group-chat.spec.js +++ b/test/cypress/integration/group-chat.spec.js @@ -13,19 +13,19 @@ let me // since we are differentiate channels by their names in test mode // of course, we can create same name in production const chatRooms = [ - { name: 'Channel12', description: 'Description for Channel12', isPrivate: false, users: [user1, user2] }, - { name: 'Channel14', description: 'Description for Channel14', isPrivate: true, users: [user1] }, - { name: 'Channel13', description: '', isPrivate: true, users: [user1, user2] }, - { name: 'Channel11', description: '', isPrivate: false, users: [user1] }, - { name: 'Channel15', description: '', isPrivate: false, users: [user1, user2] }, - { name: 'Channel23', description: 'Description for Channel23', isPrivate: false, users: [user2] }, - { name: 'Channel22', description: 'Description for Channel22', isPrivate: true, users: [user2, user1, user3] }, - { name: 'Channel24', description: '', isPrivate: true, users: [user2, user3] }, - { name: 'Channel21', description: '', isPrivate: false, users: [user2, user1] } + { name: 'Channel12', description: 'Description for Channel12', isPrivate: false, members: [user1, user2] }, + { name: 'Channel14', description: 'Description for Channel14', isPrivate: true, members: [user1] }, + { name: 'Channel13', description: '', isPrivate: true, members: [user1, user2] }, + { name: 'Channel11', description: '', isPrivate: false, members: [user1] }, + { name: 'Channel15', description: '', isPrivate: false, members: [user1, user2] }, + { name: 'Channel23', description: 'Description for Channel23', isPrivate: false, members: [user2] }, + { name: 'Channel22', description: 'Description for Channel22', isPrivate: true, members: [user2, user1, user3] }, + { name: 'Channel24', description: '', isPrivate: true, members: [user2, user3] }, + { name: 'Channel21', description: '', isPrivate: false, members: [user2, user1] } ] -const channelsOf1For2 = chatRooms.filter(c => c.name.startsWith('Channel1') && c.users.includes(user2)).map(c => c.name) -const channelsOf2For1 = chatRooms.filter(c => c.name.startsWith('Channel2') && c.users.includes(user1)).map(c => c.name) -const channelsOf2For3 = chatRooms.filter(c => c.name.startsWith('Channel2') && c.users.includes(user3)).map(c => c.name) +const channelsOf1For2 = chatRooms.filter(c => c.name.startsWith('Channel1') && c.members.includes(user2)).map(c => c.name) +const channelsOf2For1 = chatRooms.filter(c => c.name.startsWith('Channel2') && c.members.includes(user1)).map(c => c.name) +const channelsOf2For3 = chatRooms.filter(c => c.name.startsWith('Channel2') && c.members.includes(user3)).map(c => c.name) function getProposalItems () { return cy.getByDT('proposalsWidget').children() @@ -251,11 +251,11 @@ describe('Group Chat Basic Features (Create & Join & Leave & Close)', () => { it('user1 checks the visibilities, sort order and permissions', () => { switchUser(user1) cy.giRedirectToGroupChat() - cy.log('ssers can update details(name, description) of the channels they created.') + cy.log('Users can update details(name, description) of the channels they created.') const undetailedChannel = chatRooms.filter(c => c.name.startsWith('Channel1') && !c.description)[0] const detailedChannel = chatRooms.filter(c => c.name.startsWith('Channel1') && c.description)[0] - const notUpdatableChannel = chatRooms.filter(c => !c.name.startsWith('Channel1') && !c.description && c.users.includes(me))[0] - const notJoinedChannel = chatRooms.filter(c => !c.name.startsWith('Channel1') && !c.users.includes(me))[0] + const notUpdatableChannel = chatRooms.filter(c => !c.name.startsWith('Channel1') && !c.description && c.members.includes(me))[0] + const notJoinedChannel = chatRooms.filter(c => !c.name.startsWith('Channel1') && !c.members.includes(me))[0] cy.log(`user1 can add description of ${undetailedChannel.name} chatroom because he is the creator`) cy.log('"Add Description" button is visible because no description is added') @@ -308,9 +308,9 @@ describe('Group Chat Basic Features (Create & Join & Leave & Close)', () => { cy.log('users can not see the private channels they are not part of.') cy.log('joined-channels are always in front of unjoined-channels. It means the channels order are different for each user.') - const joinedChannels = chatRooms.filter(c => c.users.includes(me)).map(c => c.name) + const joinedChannels = chatRooms.filter(c => c.members.includes(me)).map(c => c.name) .concat([CHATROOM_GENERAL_NAME]).sort() - const unjoinedChannels = chatRooms.filter(c => !c.users.includes(me) && !c.isPrivate).map(c => c.name).sort() + const unjoinedChannels = chatRooms.filter(c => !c.members.includes(me) && !c.isPrivate).map(c => c.name).sort() const visibleChatRooms = joinedChannels.concat(unjoinedChannels) cy.getByDT('channelsList').within(() => { @@ -351,7 +351,7 @@ describe('Group Chat Basic Features (Create & Join & Leave & Close)', () => { it('user1 kicks user2 from a channel and user2 leaves a channel by himself', () => { const leavingChannels = chatRooms - .filter(c => c.name.includes('Channel1') && c.users.includes(user2) && !c.isPrivate).map(c => c.name) + .filter(c => c.name.includes('Channel1') && c.members.includes(user2) && !c.isPrivate).map(c => c.name) // User1 kicks user2 kickMemberFromChannel(leavingChannels[0], user2) diff --git a/test/cypress/integration/group-proposals.spec.js b/test/cypress/integration/group-proposals.spec.js index 1bf6b8524..471d0a8e2 100644 --- a/test/cypress/integration/group-proposals.spec.js +++ b/test/cypress/integration/group-proposals.spec.js @@ -388,7 +388,7 @@ describe('Proposals - Add members', () => { }) it('an invalid invitation link cannot be used', () => { - cy.visit('/app/join?groupId=321&secret=123') + cy.visit('/app/join#?groupId=321&secret=123') cy.getByDT('pageTitle') .invoke('text') .should('contain', 'Oh no! This invite is not valid') diff --git a/test/cypress/integration/notifications.spec.js b/test/cypress/integration/notifications.spec.js index 872b8b74a..3c45d81ee 100644 --- a/test/cypress/integration/notifications.spec.js +++ b/test/cypress/integration/notifications.spec.js @@ -12,19 +12,19 @@ const fakeNotificationsForDreamers = [ { type: 'MEMBER_ADDED', data: { - username: 'greg' + memberID: 'greg' } }, { type: 'MEMBER_ADDED', data: { - username: 'kate' + memberID: 'kate' } }, { type: 'NEW_PROPOSAL', data: { - creator: 'greg', + creatorID: 'greg', subtype: 'CHANGE_MINCOME', value: '$1000' } @@ -32,13 +32,13 @@ const fakeNotificationsForDreamers = [ { type: 'MEMBER_ADDED', data: { - username: 'bot' + memberID: 'bot' } }, { type: 'NEW_PROPOSAL', data: { - creator: 'kate', + creatorID: 'kate', subtype: 'REMOVE_MEMBER' } } @@ -48,13 +48,13 @@ const otherFakeNotificationsForDreamers = [ { type: 'MEMBER_REMOVED', data: { - username: 'bot' + memberID: 'bot' } }, { type: 'MEMBER_ADDED', data: { - username: 'john' + memberID: 'john' } } ] @@ -63,19 +63,19 @@ const fakeNotificationsForTurtles = [ { type: 'MEMBER_ADDED', data: { - username: 'alice' + memberID: 'alice' } }, { type: 'MEMBER_ADDED', data: { - username: 'bob' + memberID: 'bob' } }, { type: 'MEMBER_ADDED', data: { - username: 'john' + memberID: 'john' } } ] diff --git a/test/cypress/support/commands.js b/test/cypress/support/commands.js index d6bc7f7bc..d0bc85ce8 100644 --- a/test/cypress/support/commands.js +++ b/test/cypress/support/commands.js @@ -15,7 +15,7 @@ const API_URL = Cypress.config('baseUrl') // util funcs const randomFromArray = arr => arr[Math.floor(Math.random() * arr.length)] // importing giLodash.js fails for some reason. const getParamsFromInvitationLink = invitationLink => { - const params = new URLSearchParams(new URL(invitationLink).search) + const params = new URLSearchParams(new URL(invitationLink).hash.slice(1)) return { groupId: params.get('groupId'), inviteSecret: params.get('secret')