Skip to content

Commit

Permalink
feat: Ability to render groups as attendees
Browse files Browse the repository at this point in the history
Implement ability to render groups and group members as attendees of an
calendar event. This is implemented by reading and interpreting the
CUTYPE and MEMBER parameter of the iCal ATTENDEE attribute.

For example attendee "user1" belongs to a group identified with the uri
mygroup@localhost: "ATTENDEE;MEMBER="mailto:mygroup@localhost";CN=user1;[...]".
The group entity is also a ATTENDEE entry but with "CUTYPE=GROUP" and
with the corresponding uri mygroup@localhost:
"ATTENDEE;CN=MyGroup;CUTYPE=GROUP;[...]:mailto:mygroup@localhost".

In the frontend, groups will be displayed as a collapsible list whereas
members of the group will be displayed with the same functionality as
normal attendees.

In case the group entry gets deleted, all of its members will get
removed too.

Empty groups with no members might be present in the iCal file but won't
get rendered.

According to the iCal specifications, groups can have more than one uri and
attendees could have multiple membership addresses in one attribute.
This is neither covered yet in calendar-js nor in this PR.

Signed-off-by: Jonas Heinrich <[email protected]>
  • Loading branch information
Jonas Heinrich committed Oct 31, 2023
1 parent c2b9a11 commit b132fcc
Show file tree
Hide file tree
Showing 5 changed files with 296 additions and 104 deletions.
211 changes: 114 additions & 97 deletions src/components/Editor/AvatarParticipationStatus.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
<!--
- @copyright Copyright (c) 2019 Georg Ehrke <[email protected]>
- @copyright Copyright (c) 2023 Jonas Heinrich <[email protected]>
-
- @author Georg Ehrke <[email protected]>
- @author Jonas Heinrich <[email protected]>
-
- @license AGPL-3.0-or-later
-
Expand All @@ -22,110 +25,119 @@

<template>
<div class="avatar-participation-status">
<Avatar :disable-tooltip="true"
<Avatar v-if="isGroup">
<template #icon>
<AccountMultiple :size="28" />
</template>
</Avatar>
<Avatar v-else
:disable-tooltip="true"
:user="commonName"
:display-name="commonName"
:is-no-user="true" />
<template v-if="participationStatus === 'ACCEPTED' && isViewedByOrganizer">
<IconCheck class="avatar-participation-status__indicator"
fill-color="#32CD32"
:size="20" />
<div class="avatar-participation-status__text">
{{ t('calendar', 'Invitation accepted') }}
</div>
</template>
<template v-else-if="isResource && participationStatus === 'ACCEPTED'">
<IconCheck class="avatar-participation-status__indicator"
fill-color="#32CD32"
:size="20" />
<div class="avatar-participation-status__text">
{{ t('calendar', 'Available') }}
</div>
</template>
<template v-else-if="isSuggestion">
<IconCheck class="avatar-participation-status__indicator"
fill-color="#32CD32"
:size="20" />
<div class="avatar-participation-status__text">
{{ t('calendar', 'Suggested') }}
</div>
</template>
<template v-else-if="participationStatus === 'TENTATIVE'">
<IconCheck class="avatar-participation-status__indicator"
fill-color="#32CD32"
:size="20" />
<div class="avatar-participation-status__text">
{{ t('calendar', 'Participation marked as tentative') }}
</div>
</template>
<template v-else-if="participationStatus === 'ACCEPTED' && !isViewedByOrganizer">
<IconCheck class="avatar-participation-status__indicator"
fill-color="#32CD32"
:size="20" />
<div class="avatar-participation-status__text">
{{ t('calendar', 'Accepted {organizerName}\'s invitation', {
organizerName: organizerDisplayName,
}) }}
</div>
</template>
<template v-else-if="isResource && participationStatus === 'DECLINED'">
<IconClose class="avatar-participation-status__indicator"
:size="20" />
<div class="avatar-participation-status__text">
{{ t('calendar', 'Not available') }}
</div>
</template>
<template v-else-if="participationStatus === 'DECLINED' && isViewedByOrganizer">
<IconClose class="avatar-participation-status__indicator"
:size="20" />
<div class="avatar-participation-status__text">
{{ t('calendar', 'Invitation declined') }}
</div>
</template>
<template v-else-if="participationStatus === 'DECLINED' && !isViewedByOrganizer">
<IconClose class="avatar-participation-status__indicator"
:size="20" />
<div class="avatar-participation-status__text">
{{ t('calendar', 'Declined {organizerName}\'s invitation', {
organizerName: organizerDisplayName,
}) }}
</div>
</template>
<template v-else-if="participationStatus === 'DELEGATED'">
<IconDelegated class="avatar-participation-status__indicator"
:size="20" />
<div class="avatar-participation-status__text">
{{ t('calendar', 'Invitation is delegated') }}
</div>
</template>
<template v-else-if="isResource">
<IconNoResponse class="avatar-participation-status__indicator"
:size="20" />
<div class="avatar-participation-status__text">
{{ t('calendar', 'Checking availability') }}
</div>
</template>
<template v-else-if="isViewedByOrganizer">
<IconNoResponse class="avatar-participation-status__indicator"
:size="20" />
<div class="avatar-participation-status__text">
{{ t('calendar', 'Invitation sent') }}
</div>
</template>
<template v-else>
<IconNoResponse class="avatar-participation-status__indicator"
:size="20" />
<div class="avatar-participation-status__text">
{{ t('calendar', 'Has not responded to {organizerName}\'s invitation yet', {
organizerName: organizerDisplayName,
}) }}
</div>
<template v-if="!isGroup">
<template v-if="participationStatus === 'ACCEPTED' && isViewedByOrganizer">
<IconCheck class="avatar-participation-status__indicator"
fill-color="#32CD32"
:size="20" />
<div class="avatar-participation-status__text">
{{ t('calendar', 'Invitation accepted') }}
</div>
</template>
<template v-else-if="isResource && participationStatus === 'ACCEPTED'">
<IconCheck class="avatar-participation-status__indicator"
fill-color="#32CD32"
:size="20" />
<div class="avatar-participation-status__text">
{{ t('calendar', 'Available') }}
</div>
</template>
<template v-else-if="isSuggestion">
<IconCheck class="avatar-participation-status__indicator"
fill-color="#32CD32"
:size="20" />
<div class="avatar-participation-status__text">
{{ t('calendar', 'Suggested') }}
</div>
</template>
<template v-else-if="participationStatus === 'TENTATIVE'">
<IconCheck class="avatar-participation-status__indicator"
fill-color="#32CD32"
:size="20" />
<div class="avatar-participation-status__text">
{{ t('calendar', 'Participation marked as tentative') }}
</div>
</template>
<template v-else-if="participationStatus === 'ACCEPTED' && !isViewedByOrganizer">
<IconCheck class="avatar-participation-status__indicator"
fill-color="#32CD32"
:size="20" />
<div class="avatar-participation-status__text">
{{ t('calendar', 'Accepted {organizerName}\'s invitation', {
organizerName: organizerDisplayName,
}) }}
</div>
</template>
<template v-else-if="isResource && participationStatus === 'DECLINED'">
<IconClose class="avatar-participation-status__indicator"
:size="20" />
<div class="avatar-participation-status__text">
{{ t('calendar', 'Not available') }}
</div>
</template>
<template v-else-if="participationStatus === 'DECLINED' && isViewedByOrganizer">
<IconClose class="avatar-participation-status__indicator"
:size="20" />
<div class="avatar-participation-status__text">
{{ t('calendar', 'Invitation declined') }}
</div>
</template>
<template v-else-if="participationStatus === 'DECLINED' && !isViewedByOrganizer">
<IconClose class="avatar-participation-status__indicator"
:size="20" />
<div class="avatar-participation-status__text">
{{ t('calendar', 'Declined {organizerName}\'s invitation', {
organizerName: organizerDisplayName,
}) }}
</div>
</template>
<template v-else-if="participationStatus === 'DELEGATED'">
<IconDelegated class="avatar-participation-status__indicator"
:size="20" />
<div class="avatar-participation-status__text">
{{ t('calendar', 'Invitation is delegated') }}
</div>
</template>
<template v-else-if="isResource">
<IconNoResponse class="avatar-participation-status__indicator"
:size="20" />
<div class="avatar-participation-status__text">
{{ t('calendar', 'Checking availability') }}
</div>
</template>
<template v-else-if="isViewedByOrganizer">
<IconNoResponse class="avatar-participation-status__indicator"
:size="20" />
<div class="avatar-participation-status__text">
{{ t('calendar', 'Invitation sent') }}
</div>
</template>
<template v-else>
<IconNoResponse class="avatar-participation-status__indicator"
:size="20" />
<div class="avatar-participation-status__text">
{{ t('calendar', 'Has not responded to {organizerName}\'s invitation yet', {
organizerName: organizerDisplayName,
}) }}
</div>
</template>
</template>
</div>
</template>

<script>
import { NcAvatar as Avatar } from '@nextcloud/vue'
import AccountMultiple from 'vue-material-design-icons/AccountMultiple.vue'
import IconCheck from 'vue-material-design-icons/CheckCircle.vue'
import IconNoResponse from 'vue-material-design-icons/HelpCircle.vue'
import IconClose from 'vue-material-design-icons/CloseCircle.vue'
Expand All @@ -134,8 +146,9 @@ import IconDelegated from 'vue-material-design-icons/ArrowRightDropCircle.vue'
export default {
name: 'AvatarParticipationStatus',
components: {
Avatar,
IconCheck,
Avatar,
AccountMultiple,
IconCheck,
IconNoResponse,
IconClose,
IconDelegated,
Expand All @@ -162,6 +175,10 @@ export default {
type: Boolean,
required: true,
},
isGroup: {
type: Boolean,
required: false,
},
isSuggestion: {
type: Boolean,
default: false,
Expand Down
56 changes: 55 additions & 1 deletion src/components/Editor/Invitees/InviteesList.vue
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
<!--
- @copyright Copyright (c) 2019 Georg Ehrke <[email protected]>
- @copyright Copyright (c) 2023 Jonas Heinrich <[email protected]>
-
- @author Georg Ehrke <[email protected]>
- @author Richard Steinmetz <[email protected]>
- @author Jonas Heinrich <[email protected]>
-
- @license AGPL-3.0-or-later
-
Expand Down Expand Up @@ -30,10 +32,12 @@
:is-read-only="isReadOnly || isSharedWithMe"
:organizer="calendarObjectInstance.organizer" />
<InviteesListItem v-for="invitee in inviteesWithoutOrganizer"
v-if="noGroupOrNoneEmpty(invitee)"

Check failure on line 35 in src/components/Editor/Invitees/InviteesList.vue

View workflow job for this annotation

GitHub Actions / eslint

The 'inviteesWithoutOrganizer' variable inside 'v-for' directive should be replaced with a computed property that returns filtered array instead. You should not mix 'v-for' with 'v-if'
:key="invitee.email"
:attendee="invitee"
:is-read-only="isReadOnly || isSharedWithMe"
:organizer-display-name="organizerDisplayName"
:members="invitee.members"
@remove-attendee="removeAttendee" />
<NoAttendeesView v-if="isReadOnly && isListEmpty"
:message="noInviteesMessage" />
Expand Down Expand Up @@ -129,13 +133,45 @@ export default {
return !['RESOURCE', 'ROOM'].includes(attendee.attendeeProperty.userType)
})
},
groups() {
return this.calendarObjectInstance.attendees.filter(attendee => {
return attendee.attendeeProperty.userType === 'GROUP'
})
},
inviteesWithoutOrganizer() {
if (!this.calendarObjectInstance.organizer) {
return this.invitees
}
return this.invitees
.filter(attendee => attendee.uri !== this.calendarObjectInstance.organizer.uri)
.filter(attendee => {
// Filter attendees which are part of an invited group
let isMemberOfGroup = false
// TODO Expose member attribute in calendar-js
// See https://github.com/nextcloud/calendar-js/pull/627
const attendeeMemberUri = attendee.attendeeProperty._parameters.get('MEMBER')?._value ?? attendee.member
if (attendeeMemberUri) {
isMemberOfGroup = this.groups.some(function(group) {
return attendeeMemberUri.includes(group.uri)
&& attendee.attendeeProperty.userType === 'INDIVIDUAL'
})
}
// Add attendee to group member list
if (isMemberOfGroup) {
this.groups.forEach(group => {
if (attendee.member.includes(group.uri)) {
if (!group.members) {
group.members = []
}
group.members.push(attendee)
}
})
}
return attendee.uri !== this.calendarObjectInstance.organizer.uri
&& !isMemberOfGroup
})
},
isOrganizer() {
return this.calendarObjectInstance.organizer !== null
Expand Down Expand Up @@ -204,6 +240,19 @@ export default {
})
},
removeAttendee(attendee) {
// Remove attendee from participating group
if (attendee.member) {
this.groups.forEach(group => {
if (attendee.member.includes(group.uri)) {
group.members = group.members.filter(member => {
if (!attendee.member.includes(group.uri)) {
return true
}
return false
})
}
})
}
this.$store.commit('removeAttendee', {
calendarObjectInstance: this.calendarObjectInstance,
attendee,
Expand Down Expand Up @@ -251,6 +300,11 @@ export default {
this.creatingTalkRoom = false
}
},
noGroupOrNoneEmpty(attendee) {
return attendee.attendeeProperty.userType !== 'GROUP'
|| (attendee.attendeeProperty.userType === 'GROUP' && attendee.members
&& attendee.members.length !== 0)
}

Check warning on line 307 in src/components/Editor/Invitees/InviteesList.vue

View workflow job for this annotation

GitHub Actions / eslint

Missing trailing comma
},
}
</script>
Expand Down
Loading

0 comments on commit b132fcc

Please sign in to comment.