Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Corrideat/task/#2341 anyone can join invite issue #2366

Open
wants to merge 30 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
96e6592
increase the life of anyone-invite-link
SebinSong Sep 10, 2024
abe1666
update readableExpiryInfo method (incomplete)
SebinSong Sep 11, 2024
655bbfc
some refactoring in GroupMembers.vue and ProposalTemplate.vue
SebinSong Sep 11, 2024
c1f86a4
add generateInviteImmediately() method / make sure the invite works
SebinSong Sep 12, 2024
4c72344
Merge remote-tracking branch 'origin/master' into sebin/task/#2341-an…
SebinSong Sep 13, 2024
f638fb7
Merge remote-tracking branch 'origin/master' into sebin/task/#2341-an…
SebinSong Sep 15, 2024
2621b53
fix the enter issue / update submit-btn text
SebinSong Sep 15, 2024
f9ec6c5
revert the invite life
SebinSong Sep 15, 2024
95f1edc
update readable invite-life logic
SebinSong Sep 15, 2024
43e557e
update the period display expression again
SebinSong Sep 16, 2024
8a437e4
Merge remote-tracking branch 'origin/master' into sebin/task/#2341-an…
SebinSong Sep 18, 2024
c80ddeb
update comment
SebinSong Sep 18, 2024
709daac
Merge remote-tracking branch 'origin/master' into sebin/task/#2341-an…
SebinSong Sep 23, 2024
b31dd3e
implement gi.app/group/fixAnyoneCanJoinLink and use it
SebinSong Sep 24, 2024
eed98aa
Invite expires. TODO: Update exisiting invites
corrideat Sep 30, 2024
1459e8a
Recreate anyone can join
corrideat Oct 2, 2024
bd56e10
Fields for readable date
corrideat Oct 4, 2024
ba54c9b
Merge branch 'master' into corrideat/task/#2341-anyone-can-join-invit…
corrideat Oct 4, 2024
c93c63b
Fix types
corrideat Oct 4, 2024
f6b9f75
Merge branch 'master' into corrideat/task/#2341-anyone-can-join-invit…
corrideat Oct 5, 2024
feb3efd
Post upgrade verification for upgrading invites
corrideat Oct 5, 2024
81cc322
Use MAX_GROUP_MEMBER_COUNT instead of 150
corrideat Oct 6, 2024
713f8e3
Feedback
corrideat Oct 9, 2024
330624d
postUpgradeVerification improvements
corrideat Oct 10, 2024
1011aac
Filter for valid invites
corrideat Oct 10, 2024
74b69d1
Revert type changes
corrideat Oct 11, 2024
ca90a19
Feedback
corrideat Oct 12, 2024
d255eb4
Skip anyone expiration test if it doesnt expire
corrideat Oct 12, 2024
27ed1bd
Remove renundant check
corrideat Oct 13, 2024
d0ec792
Feedback
corrideat Oct 14, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 75 additions & 3 deletions frontend/controller/actions/group.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
import { GIErrorUIRuntimeError, L, LError, LTags } from '@common/common.js'
import {
CHATROOM_PRIVACY_LEVEL,
INVITE_EXPIRES_IN_DAYS,
INVITE_INITIAL_CREATOR,
INVITE_EXPIRES_IN_DAYS,
MAX_GROUP_MEMBER_COUNT,
MESSAGE_TYPES,
PROFILE_STATUS,
PROPOSAL_GENERIC,
Expand Down Expand Up @@ -38,6 +39,7 @@ import { Secret } from '~/shared/domains/chelonia/Secret.js'
import type { ChelKeyRequestParams } from '~/shared/domains/chelonia/chelonia.js'
import { encryptedOutgoingData, encryptedOutgoingDataWithRawKey } from '~/shared/domains/chelonia/encryptedData.js'
import { CONTRACT_HAS_RECEIVED_KEYS, EVENT_HANDLED } from '~/shared/domains/chelonia/events.js'
import { findKeyIdByName } from '~/shared/domains/chelonia/utils.js'
// Using relative path to crypto.js instead of ~-path to workaround some esbuild bug
import type { Key } from '../../../shared/domains/chelonia/crypto.js'
import { CURVE25519XSALSA20POLY1305, EDWARDS25519SHA512BATCH, keyId, keygen, serializeKey } from '../../../shared/domains/chelonia/crypto.js'
Expand Down Expand Up @@ -180,8 +182,11 @@ export default (sbp('sbp/selectors/register', {
ringLevel: Number.MAX_SAFE_INTEGER,
permissions: [GIMessage.OP_KEY_REQUEST],
meta: {
quantity: 60,
expires: Date.now() + DAYS_MILLIS * INVITE_EXPIRES_IN_DAYS.ON_BOARDING,
quantity: MAX_GROUP_MEMBER_COUNT,
...(INVITE_EXPIRES_IN_DAYS.ON_BOARDING && {
expires:
await sbp('chelonia/time') * 1000 + DAYS_MILLIS * INVITE_EXPIRES_IN_DAYS.ON_BOARDING
}),
private: {
content: inviteKeyS
}
Expand Down Expand Up @@ -907,6 +912,72 @@ export default (sbp('sbp/selectors/register', {
sbp('okTurtles.events/emit', REPLACE_MODAL, 'IncomeDetails')
}
},
'gi.actions/group/fixAnyoneCanJoinLink': async function ({ contractID }) {
const now = await sbp('chelonia/time') * 1000
const state = await sbp('chelonia/contract/state', contractID)

// Add up all all used 'anyone can join' invite links
// Then, we take the difference and, if the number is less than
// MAX_GROUP_MEMBER_COUNT, we create a new invite.
const usedInvites = Object.keys(state.invites)
.filter(invite => state.invites[invite].creatorID === INVITE_INITIAL_CREATOR)
.reduce((acc, cv) => acc +
((state._vm.invites[cv].initialQuantity - state._vm.invites[cv].quantity
) || 0), 0)

const quantity = MAX_GROUP_MEMBER_COUNT - usedInvites

if (quantity <= 0) {
console.warn('[gi.actions/group/fixAnyoneCanJoinLink] Already used MAX_GROUP_MEMBER_COUNT invites for group', contractID, MAX_GROUP_MEMBER_COUNT)
return
}

const CEKid = findKeyIdByName(state, 'cek')
const CSKid = findKeyIdByName(state, 'csk')

if (!CEKid || !CSKid) {
throw new Error('Contract is missing a CEK or CSK')
}

const inviteKey = keygen(EDWARDS25519SHA512BATCH)
const inviteKeyId = keyId(inviteKey)
const inviteKeyP = serializeKey(inviteKey, false)
const inviteKeyS = encryptedOutgoingData(state, CEKid, serializeKey(inviteKey, true))

const ik = {
id: inviteKeyId,
name: '#inviteKey-' + inviteKeyId,
purpose: ['sig'],
ringLevel: Number.MAX_SAFE_INTEGER,
permissions: [GIMessage.OP_KEY_REQUEST],
meta: {
quantity,
...(INVITE_EXPIRES_IN_DAYS.ON_BOARDING && {
expires:
await sbp('chelonia/time') * 1000 + DAYS_MILLIS * INVITE_EXPIRES_IN_DAYS.ON_BOARDING
}),
private: {
content: inviteKeyS
}
},
data: inviteKeyP
}

// Replace all existing anyone-can-join invite links with the new one
const activeInvites = Object.keys(state.invites)
.filter(invite => state.invites[invite].creatorID === INVITE_INITIAL_CREATOR && !(state._vm.invites[invite].expires >= now))

await Promise.all(activeInvites.map(inviteKeyId => sbp('gi.actions/group/inviteRevoke', { contractID, data: { inviteKeyId } })))

await sbp('chelonia/out/keyAdd', {
contractName: 'gi.contracts/group',
contractID,
data: [ik],
signingKeyId: CSKid
})

await sbp('gi.actions/group/invite', { contractID, data: { inviteKeyId, creatorID: 'invite-initial-creator' } })
corrideat marked this conversation as resolved.
Show resolved Hide resolved
},
...encryptedAction('gi.actions/group/leaveChatRoom', L('Failed to leave chat channel.'), async (sendMessage, params) => {
const state = await sbp('chelonia/contract/state', params.contractID)
const memberID = params.data.memberID || sbp('chelonia/rootState').loggedIn.identityContractID
Expand Down Expand Up @@ -1041,6 +1112,7 @@ export default (sbp('sbp/selectors/register', {
...encryptedAction('gi.actions/group/updateSettings', L('Failed to update group settings.')),
...encryptedAction('gi.actions/group/updateAllVotingRules', (params, e) => L('Failed to update voting rules. {codeError}', { codeError: e.message })),
...encryptedAction('gi.actions/group/updateDistributionDate', L('Failed to update group distribution date.')),
...encryptedAction('gi.actions/group/updateGroupInviteExpiry', L('Failed to update group invite expiry.')),
...encryptedAction('gi.actions/group/upgradeFrom1.0.7', L('Failed to upgrade from version 1.0.7')),
...((process.env.NODE_ENV === 'development' || process.env.CI) && {
...encryptedAction('gi.actions/group/forceDistributionDate', L('Failed to force distribution date.'))
Expand Down
2 changes: 1 addition & 1 deletion frontend/model/contracts/shared/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export const MESSAGE_TYPES = {
}

export const INVITE_EXPIRES_IN_DAYS = {
ON_BOARDING: 30,
ON_BOARDING: null, // No expiration
PROPOSAL: 7
}

Expand Down
12 changes: 9 additions & 3 deletions frontend/model/contracts/shared/time.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const MINS_MILLIS = 60000
export const HOURS_MILLIS = 60 * MINS_MILLIS
export const DAYS_MILLIS = 24 * HOURS_MILLIS
export const MONTHS_MILLIS = 30 * DAYS_MILLIS
export const YEARS_MILLIS = 365 * DAYS_MILLIS

export const plusOnePeriodLength = (timestamp: string, periodLength: number): string => (
dateToPeriodStamp(addTimeToDate(timestamp, periodLength))
Expand Down Expand Up @@ -184,7 +185,12 @@ export function humanDate (
: ((navigator.languages: any): string[]) ?? navigator.language ?? fallback
// NOTE: `.toLocaleDateString()` automatically takes local timezone differences into account.
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toLocaleDateString
return new Date(date).toLocaleDateString(locale, options)
const dateObj = new Date(date)

// Avoid returning 'Invalid Date'
if (!isNaN(dateObj.valueOf())) return dateObj.toLocaleDateString(locale, options)
corrideat marked this conversation as resolved.
Show resolved Hide resolved

return ''
}

export function isPeriodStamp (arg: string): boolean {
Expand All @@ -207,11 +213,11 @@ export function isShortMonthstamp (arg: string): boolean {
return /^(0[1-9]|1[0-2])$/.test(arg)
}

export function monthName (monthstamp: string): string {
export function monthName (monthstamp: string): ?string {
corrideat marked this conversation as resolved.
Show resolved Hide resolved
return humanDate(dateFromMonthstamp(monthstamp), { month: 'long' })
}

export function proximityDate (date: Date): string {
export function proximityDate (date: Date): ?string {
date = new Date(date)
const today = new Date()
const yesterday = (d => new Date(d.setDate(d.getDate() - 1)))(new Date())
Expand Down
31 changes: 27 additions & 4 deletions frontend/model/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { EVENT_HANDLED, CONTRACT_REGISTERED } from '~/shared/domains/chelonia/ev
import { LOGOUT } from '~/frontend/utils/events.js'
import Vue from 'vue'
import Vuex from 'vuex'
import { PROFILE_STATUS, INVITE_INITIAL_CREATOR } from '@model/contracts/shared/constants.js'
import { MAX_GROUP_MEMBER_COUNT, PROFILE_STATUS, INVITE_INITIAL_CREATOR } from '@model/contracts/shared/constants.js'
import { PAYMENT_NOT_RECEIVED } from '@model/contracts/shared/payments/index.js'
import { cloneDeep, debounce } from '@model/contracts/shared/giLodash.js'
import { unadjustedDistribution, adjustedDistribution } from '@model/contracts/shared/distribution/distribution.js'
Expand Down Expand Up @@ -157,6 +157,29 @@ sbp('sbp/selectors/register', {
upgradeAction(contractID)
})
}
})();
(() => {
// Update expired invites
// If fewer than MAX_GROUP_MEMBER_COUNT 'anyone can join' have been used,
// create a new 'anyone can join' link up to MAX_GROUP_MEMBER_COUNT invites
corrideat marked this conversation as resolved.
Show resolved Hide resolved
const ourIdentityContractId = state.loggedIn?.identityContractID
if (!ourIdentityContractId || !state[ourIdentityContractId]?.groups) return
Object.entries(state[ourIdentityContractId].groups).map(([groupID, { hasLeft }]: [string, Object]) => {
const groupState = state[groupID]
if (hasLeft || !groupState?.invites) return undefined
const hasBeenUpdated = Object.keys(groupState.invites).some(inviteId => {
return groupState.invites[inviteId].creatorID === INVITE_INITIAL_CREATOR &&
groupState._vm.invites[inviteId].expires == null
})
if (hasBeenUpdated) return undefined
const usedInvites = Object.keys(groupState.invites)
.filter(invite => groupState.invites[invite].creatorID === INVITE_INITIAL_CREATOR)
.reduce((acc, cv) => acc +
((groupState._vm.invites[cv].initialQuantity - groupState._vm.invites[cv].quantity) || 0), 0)
return (usedInvites < MAX_GROUP_MEMBER_COUNT) ? groupID : undefined
corrideat marked this conversation as resolved.
Show resolved Hide resolved
}).filter(Boolean).forEach((contractID) => {
sbp('gi.actions/group/fixAnyoneCanJoinLink', { contractID }).catch(e => console.error(`[state/vuex/postUpgradeVerification] Error during gi.actions/group/fixAnyoneCanJoinLink for ${contractID}:`, e))
})
})()
},
'state/vuex/save': (encrypted: ?boolean, state: ?Object) => {
Expand Down Expand Up @@ -499,8 +522,8 @@ const getters = {
},
currentWelcomeInvite (state, getters) {
const invites = getters.currentGroupState.invites
const inviteId = Object.keys(invites).find(invite => invites[invite].creatorID === INVITE_INITIAL_CREATOR)
const expires = getters.currentGroupState._vm.authorizedKeys[inviteId].meta.expires
const inviteId = Object.keys(invites).find(invite => invites[invite].creatorID === INVITE_INITIAL_CREATOR && !(getters.currentGroupState._vm.invites[invite].expires >= Date.now()) && !(getters.currentGroupState._vm.invites[invite].quantity <= 0))
const expires = getters.currentGroupState._vm.invites[inviteId].expires
return { inviteId, expires }
},
// list of group names and contractIDs
Expand Down Expand Up @@ -552,7 +575,7 @@ const getters = {
// $FlowFixMe[method-unbinding]
return [groupMembersPending, getters.groupProfiles].flatMap(Object.keys)
.filter(memberID => getters.groupProfiles[memberID] ||
getters.groupMembersPending[memberID].expires >= Date.now())
!(getters.groupMembersPending[memberID].expires < Date.now()))
.map(memberID => {
const { contractID, displayName, username } = getters.globalProfile(memberID) || groupMembersPending[memberID] || (getters.groupProfiles[memberID] ? { contractID: memberID } : {})
return {
Expand Down
25 changes: 8 additions & 17 deletions frontend/views/containers/dashboard/GroupMembers.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@
@click='headerButtonAction'
)
i.icon-plus.is-prefix
i18n(v-if='action === "addMember"') Add
i18n(v-else) New
corrideat marked this conversation as resolved.
Show resolved Hide resolved
i18n Add

ul.c-group-list
li.c-group-member(
Expand Down Expand Up @@ -59,11 +58,6 @@ export default ({
title: {
type: String,
default: L('Members')
},
action: {
type: String,
default: 'addMember',
validator: (value) => ['addMember'].includes(value)
}
},
computed: {
Expand All @@ -90,16 +84,13 @@ export default ({
return contractID === this.ourIdentityContractId ? L('{name} (you)', { name }) : name
},
headerButtonAction () {
if (this.action === 'addMember') {
const isWelcomeInviteExpired = this.currentWelcomeInvite.expires < Date.now()
if (!this.groupShouldPropose && !isWelcomeInviteExpired) {
this.openModal('InvitationLinkModal')
} else {
const contractID = this.$store.state.currentGroupId
sbp('gi.app/group/checkGroupSizeAndProposeMember', { contractID }).catch(e => {
console.error(`Error on action checkGroupSizeAndProposeMember (headerButtonAction) for ${contractID}`, e)
})
}
if (!this.groupShouldPropose) {
this.openModal('InvitationLinkModal')
} else {
const contractID = this.$store.state.currentGroupId
sbp('gi.app/group/checkGroupSizeAndProposeMember', { contractID }).catch(e => {
console.error(`Error on action checkGroupSizeAndProposeMember (headerButtonAction) for ${contractID}`, e)
})
}
}
}
Expand Down
6 changes: 6 additions & 0 deletions frontend/views/containers/global-dashboard/NewsAndUpdates.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ import { renderMarkdown } from '@view-utils/markdown-utils.js'
import Avatar from '@components/Avatar.vue'

const dummyPosts = [
{
createdAt: new Date('2024-10-29'),
title: '1.1.0: Anyone-can-join Invites Updated! Please read!',
content: "Version 1.1.0 introduces many new features and bug fixes, and among them is replacing the old anyone-can-join invite links with new ones that will let your group grow to Dunbar's Number.\n\n" +
'**The old links are expired, please use the new ones!**'
},
{
createdAt: new Date('2024-07-26'),
title: 'Group Income 1.0 released! 🥳',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ modal-template(ref='modal' :a11yTitle='L("Add new members")')
i18n.is-title-4(tag='h3') Share this link to grant access to your group.
i18n.has-text-1(tag='p') After the onboarding period has ended, everyone will be asked to vote on whether or not a new member should be added. But for now, enjoy 60 free passes!
link-to-copy.c-link(:link='link')
i18n.has-text-1(tag='p' :args='{ expireDate }') This invite link expires on {expireDate}.
i18n.has-text-1(v-if='expireDate' tag='p' :args='{ expireDate }') This invite link expires on {expireDate}.
i18n.has-text-1(v-else tag='p') This invite link doesn't expire
i18n.is-outlined.c-cta(tag='button' @click.prevent='close') Awesome
.c-broken(v-else)
svg-broken-link.c-svg
Expand Down
22 changes: 8 additions & 14 deletions frontend/views/containers/group-settings/InvitationsTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ import { OPEN_MODAL } from '@utils/events.js'
import { mapGetters, mapState } from 'vuex'
import { L } from '@common/common.js'
import { buildInvitationUrl } from '@view-utils/buildInvitationUrl.js'
import { timeLeft } from '@view-utils/time.js'

export default ({
name: 'InvitationsTable',
Expand Down Expand Up @@ -237,22 +238,16 @@ export default ({
else return isInviteExpired || isInviteRevoked ? L('Not used') : L('Not used yet')
},
readableExpiryInfo (expiryTime) {
const timeLeft = expiryTime - Date.now()
const MIL = 1000
const MIL_MIN = 60 * MIL
const MIL_HR = MIL_MIN * 60
const MIL_DAY = 24 * MIL_HR
let remainder

const days = Math.floor(timeLeft / MIL_DAY)
remainder = timeLeft % MIL_DAY
const hours = Math.floor(remainder / MIL_HR)
remainder = remainder % MIL_HR
const minutes = Math.ceil(remainder / MIL_MIN)
if (expiryTime == null) return
const { years, months, days, hours, minutes } = timeLeft(expiryTime)
// In the cases when displaying years/months, count the remainer hours/mins as +1 day eg) 3days 15hrs 25mins -> 4days.
if (years) return L('{years}y {months}mo {days}d left', { years, months, days })
if (months) return L('{months}mo {days}d left', { months, days })

if (days) return L('{days}d {hours}h {minutes}m left', { days, hours, minutes })
if (hours) return L('{hours}h {minutes}m left', { hours, minutes })
if (minutes) return L('{minutes}m left', { minutes })

return L('Expired')
},
mapInvite ([id, {
Expand Down Expand Up @@ -299,8 +294,7 @@ export default ({
},
handleInviteClick (e) {
if (e.target.classList.contains('js-btnInvite')) {
const isWelcomeInviteExpired = this.currentWelcomeInvite.expires < Date.now()
if (this.groupShouldPropose || isWelcomeInviteExpired) {
if (this.groupShouldPropose) {
const contractID = this.currentGroupId
sbp('gi.app/group/checkGroupSizeAndProposeMember', { contractID }).catch(e => {
console.error(`Error on action checkGroupSizeAndProposeMember (handleInviteClock) for ${contractID}`, e)
Expand Down
Loading