diff --git a/ChangeLog b/ChangeLog index adc318c5b..b8a92d3ef 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,3 +1,43 @@ +2009-09-10 Francis Lachapelle + + * UI/Scheduler/UIxComponentEditor.m ([UIxComponentEditor + -_handleAttendeesEdition]): the attendees list is now submited as + a JSON string. + ([UIxComponentEditor -_loadAttendees]): constructs a dictionary + with the attendees' information that will be passed as a JSON + string to the template. + + * UI/MailPartViewers/UIxMailPartICalViewer.m + ([UIxMailPartICalViewer -currentAttendeeClass]): new method that + returns the attendee's partstat as the CSS class and adds + "attendeeUser" if the attendee is the active user. + + * UI/MailPartViewers/UIxMailPartICalActions.m (-): added delegateAction. + + * UI/Contacts/UIxContactFoldersView.m ([UIxContactFoldersView + -allContactSearchAction]): added the possibility to pass the form + parameter "excludeLists" in order to avoid returning groups. + + * SoObjects/SOGo/LDAPSource.m ([LDAPSource + _convertLDAPEntryToContact:]): added a key named "isList" to the + data representation of the LDAP entry if it matches specific objectClasses. + + * SoObjects/Appointments/SOGoAptMailICalReply.m + ([SOGoAptMailICalReply -getBody]): trim spaces before returning + the body. + + * SoObjects/Appointments/SOGoAppointmentObject.m + ([SOGoAppointmentObject + -_handleAttendee:withDelegate:ownerUser:statusChange:inEvent:]): + added support for invitation delegation. + ([SOGoAppointementObject + -_updateAttendee:withDelegate:ownerUser:forEventUID:withRecurrenceId:withSequence:forUID:shouldAddSentBy:]): + fixed the case of chained delegates. + ([SOGoAppointmentObject + -changeParticipationStatus:withDelegate:forRecurrenceId:]): when + delegating an invitation, verify that the delegated is not already + a participant nor a group of users. + 2009-09-10 Cyril Robert * SoObjects/Appointments/SOGoWebAppointmentFolder.m: New diff --git a/SOPE/NGCards/ChangeLog b/SOPE/NGCards/ChangeLog index d1163690b..4021b5f32 100644 --- a/SOPE/NGCards/ChangeLog +++ b/SOPE/NGCards/ChangeLog @@ -1,3 +1,11 @@ +2009-09-10 Francis Lachapelle + + * iCalPerson.m ([iCalPerson -_valueOfMailtoAttribute:]): fixed the + string range that was removing valid characters from a quoted string. + + * NSString+NGCards.m ([NSString -rfc822Email]): new method that + returns the string without the "mailto:" prefix. + 2009-09-09 Cyril Robert * NGVCard.m: Made use of NSDictionary+Utilities' userRecordAsLDIFEntry. diff --git a/SOPE/NGCards/NSString+NGCards.h b/SOPE/NGCards/NSString+NGCards.h index c11ea9b43..4b3fa0eb4 100644 --- a/SOPE/NGCards/NSString+NGCards.h +++ b/SOPE/NGCards/NSString+NGCards.h @@ -1,6 +1,6 @@ /* NSString+NGCards.h - this file is part of SOPE * - * Copyright (C) 2006 Inverse inc. + * Copyright (C) 2006-2009 Inverse inc. * * Author: Wolfgang Sourdeau * @@ -35,6 +35,7 @@ - (NSArray *) asCardAttributeValues; - (NSString *) escapedForCards; - (NSString *) unescapedFromCard; +- (NSString *) rfc822Email; - (NSTimeInterval) durationAsTimeInterval; - (NSCalendarDate *) asCalendarDate; diff --git a/SOPE/NGCards/NSString+NGCards.m b/SOPE/NGCards/NSString+NGCards.m index 7efc7b89b..2ce0ce664 100644 --- a/SOPE/NGCards/NSString+NGCards.m +++ b/SOPE/NGCards/NSString+NGCards.m @@ -329,4 +329,17 @@ static NSString *commaSeparator = nil; return components; } +- (NSString *) rfc822Email +{ + unsigned idx; + + idx = NSMaxRange([self rangeOfString:@":"]); + + if ((idx > 0) && ([self length] > idx)) + return [self substringFromIndex: idx]; + + return self; +} + + @end diff --git a/SOPE/NGCards/iCalPerson.m b/SOPE/NGCards/iCalPerson.m index 3fc8fd181..971a0d101 100644 --- a/SOPE/NGCards/iCalPerson.m +++ b/SOPE/NGCards/iCalPerson.m @@ -205,7 +205,7 @@ mailTo = [self value: 0 ofAttribute: name]; if ([mailTo hasPrefix: @"\""]) mailTo - = [mailTo substringWithRange: NSMakeRange (0, [mailTo length] - 2)]; + = [mailTo substringWithRange: NSMakeRange (1, [mailTo length] - 2)]; return mailTo; } diff --git a/SoObjects/Appointments/GNUmakefile b/SoObjects/Appointments/GNUmakefile index 76b2227b6..a2180b5e0 100644 --- a/SoObjects/Appointments/GNUmakefile +++ b/SoObjects/Appointments/GNUmakefile @@ -60,7 +60,6 @@ Appointments_COMPONENTS += \ SOGoAptMailDutchDeletion.wo \ SOGoAptMailDutchUpdate.wo \ SOGoAptMailEnglishInvitation.wo \ - SOGoAptMailEnglishICalReply.wo \ SOGoAptMailEnglishDeletion.wo \ SOGoAptMailEnglishUpdate.wo \ SOGoAptMailFrenchInvitation.wo \ diff --git a/SoObjects/Appointments/SOGoAppointmentObject.m b/SoObjects/Appointments/SOGoAppointmentObject.m index 247880946..b8f0f7965 100644 --- a/SoObjects/Appointments/SOGoAppointmentObject.m +++ b/SoObjects/Appointments/SOGoAppointmentObject.m @@ -35,6 +35,8 @@ #import #import +#import + #import #import #import @@ -400,8 +402,8 @@ currentUID = [currentAttendee uid]; if (currentUID) [self _addOrUpdateEvent: newEvent - forUID: currentUID - owner: owner]; + forUID: currentUID + owner: owner]; } [self sendEMailUsingTemplateNamed: @"Update" @@ -425,8 +427,8 @@ currentUID = [currentAttendee uid]; if (currentUID) [self _addOrUpdateEvent: newEvent - forUID: currentUID - owner: owner]; + forUID: currentUID + owner: owner]; } } @@ -483,8 +485,8 @@ currentUID = [currentAttendee uid]; if (currentUID) [self _addOrUpdateEvent: newEvent - forUID: currentUID - owner: owner]; + forUID: currentUID + owner: owner]; } [self sendReceiptEmailUsingTemplateNamed: @"Update" @@ -635,7 +637,7 @@ delegateEmail = [otherAttendee delegatedTo]; if ([delegateEmail length]) - delegateEmail = [delegateEmail substringFromIndex: 7]; + delegateEmail = [delegateEmail rfc822Email]; if ([delegateEmail length]) otherDelegate = [event findParticipantWithEmail: delegateEmail]; else @@ -664,7 +666,22 @@ } if (removeDelegate) - [event removeFromAttendees: otherDelegate]; + { + while (otherDelegate) + { + [event removeFromAttendees: otherDelegate]; + + // Verify if the delegate was already delegated + delegateEmail = [otherDelegate delegatedTo]; + if ([delegateEmail length]) + delegateEmail = [delegateEmail rfc822Email]; + + if ([delegateEmail length]) + otherDelegate = [event findParticipantWithEmail: delegateEmail]; + else + otherDelegate = NO; + } + } if (addDelegate) [event addToAttendees: delegate]; @@ -721,7 +738,50 @@ ex = nil; currentStatus = [attendee partStat]; - if ([currentStatus caseInsensitiveCompare: newStatus] + + iCalPerson *otherAttendee, *otherDelegate; + NSString *delegateEmail; + BOOL addDelegate, removeDelegate; + + otherAttendee = attendee; + + delegateEmail = [otherAttendee delegatedTo]; + if ([delegateEmail length]) + delegateEmail = [delegateEmail rfc822Email]; + + if ([delegateEmail length]) + otherDelegate = [event findParticipantWithEmail: delegateEmail]; + else + otherDelegate = NO; + + /* We handle the addition/deletion of delegated users */ + addDelegate = NO; + removeDelegate = NO; + if (delegate) + { + if (otherDelegate) + { + // There was already a delegated + if (![delegate hasSameEmailAddress: otherDelegate]) + { + // The delegated has changed + removeDelegate = YES; + addDelegate = YES; + } + } + else + // There was no previous delegated + addDelegate = YES; + } + else + { + if (otherDelegate) + // The user has removed the delegated + removeDelegate = YES; + } + + if (addDelegate || removeDelegate + || [currentStatus caseInsensitiveCompare: newStatus] != NSOrderedSame) { [attendee setPartStat: newStatus]; @@ -745,7 +805,70 @@ // we don't want to keep the previous SENT-BY attribute there. [(NSMutableDictionary *)[attendee attributes] removeObjectForKey: @"SENT-BY"]; } + + [attendee setDelegatedTo: [delegate email]]; + NSString *delegatedUID; + NSMutableArray *delegates; + + if (removeDelegate) + { + delegates = [NSMutableArray new]; + + while (otherDelegate) + { + [delegates addObject: otherDelegate]; + + delegatedUID = [otherDelegate uid]; + if (delegatedUID) + // Delegated attendee is a local user; remove event from his calendar + [self _removeEventFromUID: delegatedUID + owner: [theOwnerUser login] + withRecurrenceId: [event recurrenceId]]; + + [event removeFromAttendees: otherDelegate]; + + // Verify if the delegate was already delegated + delegateEmail = [otherDelegate delegatedTo]; + if ([delegateEmail length]) + delegateEmail = [delegateEmail rfc822Email]; + + if ([delegateEmail length]) + otherDelegate = [event findParticipantWithEmail: delegateEmail]; + else + otherDelegate = NO; + } + + [self sendEMailUsingTemplateNamed: @"Deletion" + forObject: [event itipEntryWithMethod: @"cancel"] + previousObject: nil + toAttendees: delegates]; + [self sendReceiptEmailUsingTemplateNamed: @"Deletion" + forObject: event + to: delegates]; + [delegates release]; + } + + if (addDelegate) + { + delegatedUID = [delegate uid]; + delegates = [NSArray arrayWithObject: delegate]; + [event addToAttendees: delegate]; + + if (delegatedUID) + // Delegated attendee is a local user; add event to his calendar + [self _addOrUpdateEvent: event + forUID: delegatedUID + owner: [theOwnerUser login]]; + + [self sendEMailUsingTemplateNamed: @"Invitation" + forObject: [event itipEntryWithMethod: @"request"] + previousObject: nil + toAttendees: delegates]; + [self sendReceiptEmailUsingTemplateNamed: @"Invitation" + forObject: event to: delegates]; + } + // We generate the updated iCalendar file and we save it // in the database. newContent = [[event parent] versitString]; @@ -794,7 +917,9 @@ { att = [attendees objectAtIndex: i]; uid = [att uid]; - if (uid && att != attendee) + if (uid + && att != attendee + && ![uid isEqualToString: delegatedUID]) [self _updateAttendee: attendee withDelegate: delegate ownerUser: theOwnerUser @@ -1097,7 +1222,6 @@ int i; /* We update the copy of the organizer, only if it's a local user. */ -#warning add a check for only local users uid = [[event organizer] uid]; if (uid) [self _updateAttendee: attendee @@ -1200,7 +1324,8 @@ - (NSException *) changeParticipationStatus: (NSString *) status withDelegate: (iCalPerson *) delegate { - return [self changeParticipationStatus: status withDelegate: delegate + return [self changeParticipationStatus: status + withDelegate: delegate forRecurrenceId: nil]; } @@ -1240,27 +1365,37 @@ if (event) { // ownerUser will actually be the owner of the calendar - // where the participation change on the event has - // actually occured. The particpation change will of - // course be on the attendee that is the owner of the - // calendar where the participation change has occured. + // where the participation change on the event occurs. The particpation + // change will be on the attendee corresponding to the ownerUser. ownerUser = [SOGoUser userWithLogin: owner]; attendee = [event findParticipant: ownerUser]; if (attendee) - ex = [self _handleAttendee: attendee - withDelegate: delegate - ownerUser: ownerUser - statusChange: _status - inEvent: event]; + { + if (delegate + && ![[delegate email] isEqualToString: [attendee delegatedTo]]) + { + if ([event isParticipant: [[delegate email] rfc822Email]]) + ex = [NSException exceptionWithHTTPStatus: 403 + reason: @"delegate is a participant"]; + else if ([SOGoGroup groupWithEmail: [[delegate email] rfc822Email]]) + ex = [NSException exceptionWithHTTPStatus: 403 + reason: @"delegate is a group"]; + } + if (ex == nil) + ex = [self _handleAttendee: attendee + withDelegate: delegate + ownerUser: ownerUser + statusChange: _status + inEvent: event]; + } else ex = [NSException exceptionWithHTTPStatus: 404 // Not Found - reason: @"user does not participate in this " - @"calendar event"]; + reason: @"user does not participate in this calendar event"]; } else ex = [NSException exceptionWithHTTPStatus: 500 // Server Error - reason: @"unable to parse event record"]; + reason: @"unable to parse event record"]; return ex; } diff --git a/SoObjects/Appointments/SOGoAptMailEnglishICalReply.wo/SOGoAptMailEnglishICalReply.html b/SoObjects/Appointments/SOGoAptMailEnglishICalReply.wo/SOGoAptMailEnglishICalReply.html deleted file mode 100644 index 59689fc15..000000000 --- a/SoObjects/Appointments/SOGoAptMailEnglishICalReply.wo/SOGoAptMailEnglishICalReply.html +++ /dev/null @@ -1,2 +0,0 @@ -<#IsSubject>Event Invitation Reply: <#summary/> -<#IsBody><#attendee/><#HasSentBy> (sent by <#sentBy/>) has <#HasAccepted>accepted<#HasDeclined>declined<#HasNotAcceptedNotDeclined>not yet decided upon your event invitation. diff --git a/SoObjects/Appointments/SOGoAptMailEnglishICalReply.wo/SOGoAptMailEnglishICalReply.wod b/SoObjects/Appointments/SOGoAptMailEnglishICalReply.wo/SOGoAptMailEnglishICalReply.wod deleted file mode 100644 index 5c54f3352..000000000 --- a/SoObjects/Appointments/SOGoAptMailEnglishICalReply.wo/SOGoAptMailEnglishICalReply.wod +++ /dev/null @@ -1,39 +0,0 @@ -IsSubject: WOConditional { - condition = isSubject; -} - -IsBody: WOConditional { - condition = isSubject; - negate = YES; -} - -summary: WOString { - value = summary; - escapeHTML = NO; -} - -attendee: WOString { - value = attendeeName; - escapeHTML = NO; -} - -HasAccepted: WOConditional { - condition = hasAccepted; -} - -HasDeclined: WOConditional { - condition = hasDeclined; -} - -HasNotAcceptedNotDeclined: WOConditional { - condition = hasNotAcceptedNotDeclined; -} - -HasSentBy: WOConditional { - condition = hasSentBy; -} - -sentBy: WOString { - value = sentBy; - escapeHTML = NO; -} \ No newline at end of file diff --git a/SoObjects/Appointments/SOGoAptMailICalReply.m b/SoObjects/Appointments/SOGoAptMailICalReply.m index 0839ccf87..d0f9e82cb 100644 --- a/SoObjects/Appointments/SOGoAptMailICalReply.m +++ b/SoObjects/Appointments/SOGoAptMailICalReply.m @@ -186,8 +186,13 @@ static NSCharacterSet *wsSet = nil; - (NSString *) getBody { + NSString *body; + isSubject = NO; - return [[self generateResponse] contentAsString]; + + body = [[self generateResponse] contentAsString]; + + return [body stringByTrimmingCharactersInSet: wsSet]; } @end diff --git a/SoObjects/Appointments/SOGoCalendarComponent.m b/SoObjects/Appointments/SOGoCalendarComponent.m index 8a327dcaa..57e77c0cf 100644 --- a/SoObjects/Appointments/SOGoCalendarComponent.m +++ b/SoObjects/Appointments/SOGoCalendarComponent.m @@ -761,7 +761,7 @@ static inline BOOL _occurenceHasID (iCalRepeatableEntityObject *occurence, NSStr ownerUser = from; language = [ownerUser language]; /* create page name */ - pageName + pageName = [NSString stringWithFormat: @"SOGoAptMail%@ICalReply", language]; /* construct message content */ p = [app pageWithName: pageName inContext: context]; @@ -778,13 +778,13 @@ static inline BOOL _occurenceHasID (iCalRepeatableEntityObject *occurence, NSStr * at all. Mail.app shows the rich content alternative _only_ * so we'll stick with multipart/mixed for the time being. */ - [headerMap setObject: @"multipart/mixed" forKey: @"content-type"]; - [headerMap setObject: @"1.0" forKey: @"MIME-Version"]; [headerMap setObject: [attendee mailAddress] forKey: @"from"]; [headerMap setObject: [recipient mailAddress] forKey: @"to"]; mailDate = [[NSCalendarDate date] rfc822DateString]; [headerMap setObject: mailDate forKey: @"date"]; [headerMap setObject: [p getSubject] forKey: @"subject"]; + [headerMap setObject: @"1.0" forKey: @"MIME-Version"]; + [headerMap setObject: @"multipart/mixed" forKey: @"content-type"]; msg = [NGMimeMessage messageWithHeader: headerMap]; NSLog (@"sending 'REPLY' from %@ to %@", diff --git a/SoObjects/Contacts/SOGoContactLDAPFolder.m b/SoObjects/Contacts/SOGoContactLDAPFolder.m index 4ff97084e..917519811 100644 --- a/SoObjects/Contacts/SOGoContactLDAPFolder.m +++ b/SoObjects/Contacts/SOGoContactLDAPFolder.m @@ -251,6 +251,10 @@ data = @""; [newRecord setObject: data forKey: @"c_telephonenumber"]; + data = [oldRecord objectForKey: @"isGroup"]; + if (data) + [newRecord setObject: data forKey: @"isGroup"]; + contactInfo = [ud stringForKey: @"SOGoLDAPContactInfoAttribute"]; if ([contactInfo length] > 0) { data = [oldRecord objectForKey: contactInfo]; diff --git a/SoObjects/SOGo/LDAPSource.m b/SoObjects/SOGo/LDAPSource.m index 47bf15e63..7855d2484 100644 --- a/SoObjects/SOGo/LDAPSource.m +++ b/SoObjects/SOGo/LDAPSource.m @@ -658,14 +658,27 @@ static NSLock *lock; NSMutableDictionary *contactEntry; NSEnumerator *attributes; NSString *currentAttribute, *value; + NSArray *classes; contactEntry = [NSMutableDictionary dictionary]; [contactEntry setObject: [ldapEntry dn] forKey: @"dn"]; + classes = [ldapEntry objectClasses]; - if ([ldapEntry objectClasses]) - [contactEntry setObject: [ldapEntry objectClasses] - forKey: @"objectClasses"]; - attributes = [[self _searchAttributes] objectEnumerator]; + if (classes) + { + [contactEntry setObject: classes + forKey: @"objectClasses"]; + attributes = [[self _searchAttributes] objectEnumerator]; + + if ([classes containsObject: @"group"] || + [classes containsObject: @"groupOfNames"] || + [classes containsObject: @"groupOfUniqueNames"] || + [classes containsObject: @"posixGroup"]) + { + [contactEntry setObject: [NSNumber numberWithInt: 1] + forKey: @"isGroup"]; + } + } while ((currentAttribute = [attributes nextObject])) { value = [[ldapEntry attributeWithName: currentAttribute] @@ -887,10 +900,12 @@ static NSLock *lock; - (NGLdapEntry *) lookupGroupEntryByEmail: (NSString *) theEmail { +#warning We should support MailFieldNames return [self lookupGroupEntryByAttribute: @"mail" andValue: theEmail]; } +// This method should accept multiple attributes - (NGLdapEntry *) lookupGroupEntryByAttribute: (NSString *) theAttribute andValue: (NSString *) theValue { @@ -911,9 +926,6 @@ static NSLock *lock; EOQualifier *qualifier; NSString *s; - // FIXME - - // we should support MailFieldNames? s = [NSString stringWithFormat: @"(%@='%@')", theAttribute, SafeLDAPCriteria (theValue)]; qualifier = [EOQualifier qualifierWithQualifierFormat: s]; diff --git a/SoObjects/SOGo/SOGoGroup.m b/SoObjects/SOGo/SOGoGroup.m index 8c09ed128..dcd231222 100644 --- a/SoObjects/SOGo/SOGoGroup.m +++ b/SoObjects/SOGo/SOGoGroup.m @@ -204,7 +204,7 @@ dn = [dns objectAtIndex: i]; login = [um getLoginForDN: [dn lowercaseString]]; //NSLog(@"member = %@", login); - user = [SOGoUser userWithLogin: login roles: nil]; + user = [SOGoUser userWithLogin: login roles: nil]; if (user) [array addObject: user]; } @@ -213,7 +213,7 @@ for (i = 0; i < [uids count]; i++) { login = [uids objectAtIndex: i]; - NSLog(@"member = %@", login); + //NSLog(@"member = %@", login); user = [SOGoUser userWithLogin: login roles: nil]; if (user) diff --git a/UI/Common/English.lproj/Localizable.strings b/UI/Common/English.lproj/Localizable.strings index 603fa45ad..f46fa08a6 100644 --- a/UI/Common/English.lproj/Localizable.strings +++ b/UI/Common/English.lproj/Localizable.strings @@ -50,6 +50,10 @@ "You don't have the required privileges to perform the operation." = "You don't have the required privileges to perform the operation."; +"noEmailForDelegation" = "You must specify the address to which you want to delegate your invitation."; +"delegate is a participant" = "The delegate is already a participant."; +"delegate is a group" = "The specified address corresponds to a group. You can only delegate to a unique person."; + /* alarms */ "Reminder:" = "Reminder:"; "Start:" = "Start:"; diff --git a/UI/Contacts/UIxContactFoldersView.m b/UI/Contacts/UIxContactFoldersView.m index fcb84be20..babee4671 100644 --- a/UI/Contacts/UIxContactFoldersView.m +++ b/UI/Contacts/UIxContactFoldersView.m @@ -195,11 +195,13 @@ NSMutableDictionary *uniqueContacts; unsigned int i, j; NSSortDescriptor *commonNameDescriptor; + BOOL excludeGroups; searchText = [self queryParameterForKey: @"search"]; if ([searchText length] > 0) { NSLog(@"Search all contacts: %@", searchText); + excludeGroups = [[self queryParameterForKey: @"excludeGroups"] boolValue]; NS_DURING folders = [[self clientObject] subFolders]; NS_HANDLER @@ -218,7 +220,7 @@ { folder = [folders objectAtIndex: i]; if ([folder isKindOfClass: [SOGoContactLDAPFolder class]]) - [sortedFolders insertObject: folder atIndex: 0]; + [sortedFolders insertObject: folder atIndex: 0]; else [sortedFolders addObject: folder]; } @@ -234,8 +236,10 @@ contact = [contacts objectAtIndex: j]; mail = [contact objectForKey: @"c_mail"]; //NSLog(@" found %@ (%@)", [contact objectForKey: @"displayName"], mail); - if ([mail isNotNull] && [uniqueContacts objectForKey: mail] == nil) - [uniqueContacts setObject: contact forKey: mail]; + if ([mail isNotNull] + && [uniqueContacts objectForKey: mail] == nil + && !(excludeGroups && [contact objectForKey: @"isGroup"])) + [uniqueContacts setObject: contact forKey: mail]; } } if ([uniqueContacts count] > 0) diff --git a/UI/MailPartViewers/UIxMailPartICalActions.m b/UI/MailPartViewers/UIxMailPartICalActions.m index fdf5a3444..d91193c81 100644 --- a/UI/MailPartViewers/UIxMailPartICalActions.m +++ b/UI/MailPartViewers/UIxMailPartICalActions.m @@ -23,7 +23,9 @@ #import #import +#import #import +#import #import #import @@ -199,183 +201,95 @@ return chosenEvent; } -#warning this is code copied from SOGoAppointmentObject... -- (void) _updateAttendee: (iCalPerson *) attendee - ownerUser: (SOGoUser *) theOwnerUser - forEventUID: (NSString *) eventUID - withRecurrenceId: (NSCalendarDate *) recurrenceId - withSequence: (NSNumber *) sequence - forUID: (NSString *) uid - shouldAddSentBy: (BOOL) b -{ - SOGoAppointmentObject *eventObject; - iCalCalendar *calendar; - iCalEvent *event; - iCalPerson *otherAttendee; - NSArray *events; - NSString *iCalString, *recurrenceTime; - - eventObject = [self _eventObjectWithUID: eventUID - forUser: [SOGoUser userWithLogin: uid roles: nil]]; - if (![eventObject isNew]) - { - if (recurrenceId == nil) - { - // We must update main event and all its occurences (if any). - calendar = [eventObject calendar: NO secure: NO]; - event = (iCalEvent *)[calendar firstChildWithTag: [eventObject componentTag]]; - events = [calendar allObjects]; - } - else - { - // If recurrenceId is defined, find the specified occurence - // within the repeating vEvent. - recurrenceTime = [NSString stringWithFormat: @"%f", [recurrenceId timeIntervalSince1970]]; - event = (iCalEvent *)[eventObject lookupOccurence: recurrenceTime]; - - if (event == nil) - // If no occurence found, create one - event = (iCalEvent *)[eventObject newOccurenceWithID: recurrenceTime]; - - events = [NSArray arrayWithObject: event]; - } - - if ([[event sequence] compare: sequence] - == NSOrderedSame) - { - SOGoUser *currentUser; - int i; - - currentUser = [context activeUser]; - - for (i = 0; i < [events count]; i++) - { - event = [events objectAtIndex: i]; - - otherAttendee = [event findParticipant: theOwnerUser]; - [otherAttendee setPartStat: [attendee partStat]]; - - // If one has accepted / declined an invitation on behalf of - // the attendee, we add the user to the SENT-BY attribute. - if (b && ![[currentUser login] isEqualToString: [theOwnerUser login]]) - { - NSString *currentEmail; - currentEmail = [[currentUser allEmails] objectAtIndex: 0]; - [otherAttendee addAttribute: @"SENT-BY" - value: [NSString stringWithFormat: @"\"MAILTO:%@\"", currentEmail]]; - } - else - { - // We must REMOVE any SENT-BY here. This is important since if A accepted - // the event for B and then, B changes by himself his participation status, - // we don't want to keep the previous SENT-BY attribute there. - [(NSMutableDictionary *)[otherAttendee attributes] removeObjectForKey: @"SENT-BY"]; - } - } - iCalString = [[event parent] versitString]; - [eventObject saveContentString: iCalString]; - } - } -} - - (WOResponse *) _changePartStatusAction: (NSString *) newStatus + withDelegate: (iCalPerson *) delegate { WOResponse *response; SOGoAppointmentObject *eventObject; iCalEvent *chosenEvent; - iCalPerson *user; - iCalCalendar *emailCalendar, *calendar; - NSString *rsvp, *method, *organizerUID; + //NSException *ex; chosenEvent = [self _setupChosenEventAndEventObject: &eventObject]; if (chosenEvent) { - user = [chosenEvent findParticipant: [context activeUser]]; - [user setPartStat: newStatus]; - calendar = [chosenEvent parent]; - - emailCalendar = [[self _emailEvent] parent]; - method = [[emailCalendar method] lowercaseString]; - if ([method isEqualToString: @"request"]) - { - [calendar setMethod: @""]; - rsvp = [[user rsvp] lowercaseString]; - } - else - rsvp = nil; - - // We generate the updated iCalendar file and we save it - // in the database. - [eventObject saveContentString: [calendar versitString]]; - - // Send a notification to the organizer if necessary - if ([rsvp isEqualToString: @"true"] && - [chosenEvent isStillRelevant]) - [eventObject sendResponseToOrganizer: chosenEvent - from: [context activeUser]]; - - organizerUID = [[chosenEvent organizer] uid]; - if (organizerUID) - // Update the event in the organizer's calendar - [self _updateAttendee: user - ownerUser: [context activeUser] - forEventUID: [chosenEvent uid] - withRecurrenceId: [chosenEvent recurrenceId] - withSequence: [chosenEvent sequence] - forUID: organizerUID - shouldAddSentBy: YES]; - - // We update the calendar of all participants that are - // local to the system. This is useful in case user A accepts - // invitation from organizer B and users C, D, E who are also - // attendees need to verify if A has accepted. - NSArray *attendees; - iCalPerson *att; - NSString *uid; - int i; - - attendees = [chosenEvent attendees]; - - for (i = 0; i < [attendees count]; i++) - { - att = [attendees objectAtIndex: i]; - - if (att == user) continue; - - uid = [[LDAPUserManager sharedUserManager] - getUIDForEmail: [att rfc822Email]]; - - if (uid) - { - [self _updateAttendee: user - ownerUser: [context activeUser] - forEventUID: [chosenEvent uid] - withRecurrenceId: [chosenEvent recurrenceId] - withSequence: [chosenEvent sequence] - forUID: uid - shouldAddSentBy: YES]; - } - } - - response = [self responseWith204]; + response = (WOResponse*)[eventObject changeParticipationStatus: newStatus + withDelegate: delegate + forRecurrenceId: [chosenEvent recurrenceId]]; +// if (ex) +// response = ex; //[self responseWithStatus: 500]; +// else + if (!response) + response = [self responseWith204]; } else { response = [context response]; [response setStatus: 409]; } - + return response; } +//- (BOOL) shouldTakeValuesFromRequest: (WORequest *) request +// inContext: (WOContext*) localContext +//{ +// return YES; +//} + - (WOResponse *) acceptAction { - return [self _changePartStatusAction: @"ACCEPTED"]; + return [self _changePartStatusAction: @"ACCEPTED" + withDelegate: nil]; } - (WOResponse *) declineAction { - return [self _changePartStatusAction: @"DECLINED"]; + return [self _changePartStatusAction: @"DECLINED" + withDelegate: nil]; +} + +- (WOResponse *) delegateAction +{ +// BOOL receiveUpdates; + NSString *delegatedEmail, *delegatedUid; + iCalPerson *delegatedAttendee; + SOGoUser *user; + WORequest *request; + WOResponse *response; + + request = [context request]; + delegatedEmail = [request formValueForKey: @"to"]; + if ([delegatedEmail length]) + { + user = [context activeUser]; + delegatedAttendee = [iCalPerson new]; + [delegatedAttendee setEmail: delegatedEmail]; + delegatedUid = [delegatedAttendee uid]; + if (delegatedUid) + { + SOGoUser *delegatedUser; + delegatedUser = [SOGoUser userWithLogin: delegatedUid]; + [delegatedAttendee setCn: [delegatedUser cn]]; + } + + [delegatedAttendee setRole: @"REQ-PARTICIPANT"]; + [delegatedAttendee setRsvp: @"TRUE"]; + [delegatedAttendee setParticipationStatus: iCalPersonPartStatNeedsAction]; + [delegatedAttendee setDelegatedFrom: + [NSString stringWithFormat: @"mailto:%@", [[user allEmails] objectAtIndex: 0]]]; + +// receiveUpdates = [[request formValueForKey: @"receiveUpdates"] boolValue]; +// if (receiveUpdates) +// [delegatedAttendee setRole: @"NON-PARTICIPANT"]; + + response = [self _changePartStatusAction: @"DELEGATED" + withDelegate: delegatedAttendee]; + } + else + response = [NSException exceptionWithHTTPStatus: 400 + reason: @"missing 'to' parameter"]; + + return response; } - (WOResponse *) addToCalendarAction diff --git a/UI/MailPartViewers/UIxMailPartICalViewer.m b/UI/MailPartViewers/UIxMailPartICalViewer.m index 42835f471..d38da1518 100644 --- a/UI/MailPartViewers/UIxMailPartICalViewer.m +++ b/UI/MailPartViewers/UIxMailPartICalViewer.m @@ -376,6 +376,18 @@ return [[self authorativeEvent] userIsParticipant: [context activeUser]]; } +- (NSString *) currentAttendeeClass +{ + NSString *cssClass; + + cssClass = [[attendee partStatWithDefault] lowercaseString]; + + if ([[attendee rfc822Email] isEqualToString: [self loggedInUserEMail]]) + cssClass = [cssClass stringByAppendingString: @" attendeeUser"]; + + return cssClass; +} + /* derived fields */ - (NSString *) organizerDisplayName diff --git a/UI/MailPartViewers/product.plist b/UI/MailPartViewers/product.plist index 3dbb48bc7..ec3679ed1 100644 --- a/UI/MailPartViewers/product.plist +++ b/UI/MailPartViewers/product.plist @@ -28,6 +28,11 @@ actionClass = "UIxMailPartICalActions"; actionName = "decline"; }; + delegate = { + protectedBy = "View"; + actionClass = "UIxMailPartICalActions"; + actionName = "delegate"; + }; updateUserStatus = { protectedBy = "View"; actionClass = "UIxMailPartICalActions"; diff --git a/UI/Scheduler/English.lproj/Localizable.strings b/UI/Scheduler/English.lproj/Localizable.strings index 7dd20e429..bff2e9230 100644 --- a/UI/Scheduler/English.lproj/Localizable.strings +++ b/UI/Scheduler/English.lproj/Localizable.strings @@ -246,7 +246,7 @@ "partStat_ACCEPTED" = "Accepted"; "partStat_DECLINED" = "Declined"; "partStat_TENTATIVE" = "Tentative"; -"partStat_DELEGATED" = "Delegated"; +"partStat_DELEGATED" = "Delegated to"; "partStat_OTHER" = "Other"; /* Appointments (error messages) */ diff --git a/UI/Scheduler/UIxAppointmentEditor.m b/UI/Scheduler/UIxAppointmentEditor.m index 3758dda5b..e59596105 100644 --- a/UI/Scheduler/UIxAppointmentEditor.m +++ b/UI/Scheduler/UIxAppointmentEditor.m @@ -46,6 +46,7 @@ #import #import #import +#import #import #import #import @@ -530,4 +531,52 @@ return self; } +- (id) delegateAction +{ +// BOOL receiveUpdates; + NSString *delegatedEmail, *delegatedUid; + iCalPerson *delegatedAttendee; + SOGoUser *user; + WORequest *request; + WOResponse *response; + + response = nil; + request = [context request]; + delegatedEmail = [request formValueForKey: @"to"]; + if ([delegatedEmail length]) + { + user = [context activeUser]; + delegatedAttendee = [iCalPerson new]; + [delegatedAttendee setEmail: delegatedEmail]; + delegatedUid = [delegatedAttendee uid]; + if (delegatedUid) + { + SOGoUser *delegatedUser; + delegatedUser = [SOGoUser userWithLogin: delegatedUid]; + [delegatedAttendee setCn: [delegatedUser cn]]; + } + + [delegatedAttendee setRole: @"REQ-PARTICIPANT"]; + [delegatedAttendee setRsvp: @"TRUE"]; + [delegatedAttendee setParticipationStatus: iCalPersonPartStatNeedsAction]; + [delegatedAttendee setDelegatedFrom: + [NSString stringWithFormat: @"mailto:%@", [[user allEmails] objectAtIndex: 0]]]; + +// receiveUpdates = [[request formValueForKey: @"receiveUpdates"] boolValue]; +// if (receiveUpdates) +// [delegatedAttendee setRole: @"NON-PARTICIPANT"]; + + response = (WOResponse*)[[self clientObject] changeParticipationStatus: @"DELEGATED" + withDelegate: delegatedAttendee]; + } + else + response = [NSException exceptionWithHTTPStatus: 400 + reason: @"missing 'to' parameter"]; + + if (!response) + response = [self responseWithStatus: 200]; + + return response; +} + @end diff --git a/UI/Scheduler/UIxComponentEditor.h b/UI/Scheduler/UIxComponentEditor.h index 4886ee918..2f366b2bd 100644 --- a/UI/Scheduler/UIxComponentEditor.h +++ b/UI/Scheduler/UIxComponentEditor.h @@ -38,6 +38,7 @@ { iCalRepeatableEntityObject *component; id item; + id attendee; NSString *saveURL; NSMutableArray *calendarList; @@ -59,13 +60,11 @@ NSDictionary *cycle; NSString *cycleEnd; iCalPerson *organizer; + iCalPerson *ownerAsAttendee; NSString *componentOwner; NSString *dateFormat; - NSString *attendeesNames; - NSString *attendeesUIDs; - NSString *attendeesEmails; - NSString *attendeesStates; + NSMutableDictionary *jsonAttendees; NSString *reminder; NSString *reminderQuantity; @@ -121,7 +120,6 @@ - (NSString *) privacy; - (NSString *) itemPrivacyText; -- (NSArray *) statusTypes; - (void) setStatus: (NSString *) _status; - (NSString *) status; - (NSString *) itemStatusText; @@ -142,14 +140,7 @@ - (BOOL) hasAttendees; -- (void) setAttendeesNames: (NSString *) newAttendeesNames; -- (NSString *) attendeesNames; - -- (void) setAttendeesUIDs: (NSString *) newAttendeesUIDs; -- (NSString *) attendeesUIDs; - -- (void) setAttendeesEmails: (NSString *) newAttendeesEmails; -- (NSString *) attendeesEmails; +- (NSString *) jsonAttendees; - (NSString *) repeat; - (void) setRepeat: (NSString *) newRepeat; diff --git a/UI/Scheduler/UIxComponentEditor.m b/UI/Scheduler/UIxComponentEditor.m index 94847d837..21afa9a1f 100644 --- a/UI/Scheduler/UIxComponentEditor.m +++ b/UI/Scheduler/UIxComponentEditor.m @@ -58,6 +58,7 @@ #import #import #import +#import #import #import #import @@ -168,10 +169,9 @@ iRANGE(2); componentOwner = @""; organizer = nil; //organizerIdentity = nil; - attendeesNames = nil; - attendeesUIDs = nil; - attendeesEmails = nil; - attendeesStates = nil; + ownerAsAttendee = nil; + attendee = nil; + jsonAttendees = nil; calendarList = nil; repeat = nil; reminder = nil; @@ -202,16 +202,15 @@ iRANGE(2); [location release]; [organizer release]; //[organizerIdentity release]; + [ownerAsAttendee release]; [comment release]; [priority release]; [categories release]; [cycle release]; [cycleEnd release]; [attachUrl release]; - [attendeesNames release]; - [attendeesUIDs release]; - [attendeesEmails release]; - [attendeesStates release]; + [attendee release]; + [jsonAttendees release]; [calendarList release]; [reminder release]; @@ -241,42 +240,44 @@ iRANGE(2); - (void) _loadAttendees { NSEnumerator *attendees; + NSMutableDictionary *currentAttendeeData; iCalPerson *currentAttendee; - NSMutableString *names, *uids, *emails, *states; NSString *uid; LDAPUserManager *um; - names = [NSMutableString string]; - uids = [NSMutableString string]; - emails = [NSMutableString string]; - states = [NSMutableString string]; + jsonAttendees = [NSMutableDictionary new]; um = [LDAPUserManager sharedUserManager]; attendees = [[component attendees] objectEnumerator]; while ((currentAttendee = [attendees nextObject])) { - if ([[currentAttendee cn] length]) - [names appendFormat: @"%@,", [currentAttendee cn]]; - else - [names appendFormat: @"%@,", [currentAttendee rfc822Email]]; + currentAttendeeData = [NSMutableDictionary dictionary]; - [emails appendFormat: @"%@,", [currentAttendee rfc822Email]]; + if ([[currentAttendee cn] length]) + [currentAttendeeData setObject: [currentAttendee cn] + forKey: @"name"]; + + [currentAttendeeData setObject: [currentAttendee rfc822Email] + forKey: @"email"]; + uid = [um getUIDForEmail: [currentAttendee rfc822Email]]; if (uid != nil) - [uids appendFormat: @"%@,", uid]; - else - [uids appendString: @","]; - [states appendFormat: @"%@,", - [[currentAttendee partStat] lowercaseString]]; - } + [currentAttendeeData setObject: uid + forKey: @"uid"]; - if ([names length] > 0) - { - ASSIGN (attendeesNames, [names substringToIndex: [names length] - 1]); - ASSIGN (attendeesUIDs, [uids substringToIndex: [uids length] - 1]); - ASSIGN (attendeesEmails, - [emails substringToIndex: [emails length] - 1]); - ASSIGN (attendeesStates, [states substringToIndex: [states length] - 1]); + [currentAttendeeData setObject: [[currentAttendee partStat] lowercaseString] + forKey: @"partstat"]; + + if ([[currentAttendee delegatedTo] length]) + [currentAttendeeData setObject: [[currentAttendee delegatedTo] rfc822Email] + forKey: @"delegated-to"]; + + if ([[currentAttendee delegatedFrom] length]) + [currentAttendeeData setObject: [[currentAttendee delegatedFrom] rfc822Email] + forKey: @"delegated-from"]; + + [jsonAttendees setObject: currentAttendeeData + forKey: [currentAttendee rfc822Email]]; } } @@ -526,6 +527,8 @@ iRANGE(2); { // iCalRecurrenceRule *rrule; SOGoObject *co; + LDAPUserManager *um; + NSString *owner, *ownerEmail; if (!component) { @@ -545,6 +548,7 @@ iRANGE(2); ASSIGN (categories, [[component categories] componentsWithSafeSeparator: ',']); ASSIGN (organizer, [component organizer]); + [self _loadCategories]; [self _loadAttendees]; [self _loadRRules]; @@ -555,6 +559,11 @@ iRANGE(2); if ([componentCalendar isKindOfClass: [SOGoCalendarComponent class]]) componentCalendar = [componentCalendar container]; [componentCalendar retain]; + + um = [LDAPUserManager sharedUserManager]; + owner = [componentCalendar ownerInContext: context]; + ownerEmail = [um getEmailForUID: owner]; + ASSIGN (ownerAsAttendee, [component findParticipantWithEmail: (id)ownerEmail]); } } // /* cycles */ @@ -750,44 +759,32 @@ iRANGE(2); return ([[component attendees] count] > 0); } -- (void) setAttendeesNames: (NSString *) newAttendeesNames +- (void) setAttendee: (id) _attendee { - ASSIGN (attendeesNames, newAttendeesNames); + ASSIGN (attendee, _attendee); } -- (NSString *) attendeesNames +- (id) attendee { - return attendeesNames; + return attendee; } -- (void) setAttendeesUIDs: (NSString *) newAttendeesUIDs +- (NSString *) attendeeForDisplay { - ASSIGN (attendeesUIDs, newAttendeesUIDs); + NSString *fn, *result; + + fn = [attendee cnWithoutQuotes]; + if ([fn length]) + result = fn; + else + result = [attendee rfc822Email]; + + return result; } -- (NSString *) attendeesUIDs +- (NSString *) jsonAttendees { - return attendeesUIDs; -} - -- (void) setAttendeesEmails: (NSString *) newAttendeesEmails -{ - ASSIGN (attendeesEmails, newAttendeesEmails); -} - -- (NSString *) attendeesEmails -{ - return attendeesEmails; -} - -- (void) setAttendeesStates: (NSString *) newAttendeesStates -{ - ASSIGN (attendeesStates, newAttendeesStates); -} - -- (NSString *) attendeesStates -{ - return attendeesStates; + return [jsonAttendees jsonRepresentation]; } - (void) setLocation: (NSString *) _value @@ -971,6 +968,10 @@ iRANGE(2); word = @"ACCEPTED"; else if ([item intValue] == iCalPersonPartStatDeclined) word = @"DECLINED"; + else if ([item intValue] == iCalPersonPartStatDelegated) + word = @"DELEGATED"; + else + word = @"UNKNOWN"; return [self labelForKey: [NSString stringWithFormat: @"partStat_%@", word]]; } @@ -978,21 +979,18 @@ iRANGE(2); - (NSArray *) replyList { return [NSArray arrayWithObjects: - [NSNumber numberWithInt: iCalPersonPartStatAccepted], - [NSNumber numberWithInt: iCalPersonPartStatDeclined], nil]; + [NSNumber numberWithInt: iCalPersonPartStatAccepted], + [NSNumber numberWithInt: iCalPersonPartStatDeclined], + [NSNumber numberWithInt: iCalPersonPartStatDelegated], + nil]; } - (NSNumber *) reply { iCalPersonPartStat participationStatus; - LDAPUserManager *um; - NSString *owner, *ownerEmail; - um = [LDAPUserManager sharedUserManager]; - owner = [componentCalendar ownerInContext: context]; - ownerEmail = [um getEmailForUID: owner]; - // We assume the owner is part of the participants - participationStatus = [[component findParticipantWithEmail: (id)ownerEmail] participationStatus]; + participationStatus = [ownerAsAttendee participationStatus]; + return [NSNumber numberWithInt: participationStatus]; } @@ -1143,19 +1141,6 @@ iRANGE(2); return privacy; } -- (NSArray *) statusTypes -{ - static NSArray *statusTypes = nil; - - if (!statusTypes) - { - statusTypes = [NSArray arrayWithObjects: @"", @"TENTATIVE", @"CONFIRMED", @"CANCELLED", nil]; - [statusTypes retain]; - } - - return statusTypes; -} - - (void) setStatus: (NSString *) _status { ASSIGN (status, _status); @@ -1499,26 +1484,35 @@ RANGE(2); - (void) _handleAttendeesEdition { - NSArray *names, *emails; NSMutableArray *newAttendees; unsigned int count, max; NSString *currentEmail; iCalPerson *currentAttendee; + NSString *json; + NSDictionary *attendeesData; + NSArray *attendees; + NSDictionary *currentData; + NSScanner *jsonScanner; + WORequest *request; - newAttendees = [NSMutableArray new]; - if ([attendeesNames length] > 0) + request = [context request]; + json = [request formValueForKey: @"attendees"]; + attendees = [NSArray array]; + jsonScanner = [NSScanner scannerWithString: json]; + if ([jsonScanner scanJSONObject: &attendeesData]) { - names = [attendeesNames componentsSeparatedByString: @","]; - emails = [attendeesEmails componentsSeparatedByString: @","]; - max = [emails count]; + newAttendees = [NSMutableArray new]; + attendees = [attendeesData allValues]; + max = [attendees count]; for (count = 0; count < max; count++) { - currentEmail = [emails objectAtIndex: count]; + currentData = [attendees objectAtIndex: count]; + currentEmail = [currentData objectForKey: @"email"]; currentAttendee = [component findParticipantWithEmail: currentEmail]; if (!currentAttendee) { currentAttendee = [iCalPerson elementWithTag: @"attendee"]; - [currentAttendee setCn: [names objectAtIndex: count]]; + [currentAttendee setCn: [currentData objectForKey: @"name"]]; [currentAttendee setEmail: currentEmail]; [currentAttendee setRole: @"REQ-PARTICIPANT"]; [currentAttendee setRsvp: @"TRUE"]; @@ -1527,10 +1521,11 @@ RANGE(2); } [newAttendees addObject: currentAttendee]; } + [component setAttendees: newAttendees]; + [newAttendees release]; } - - [component setAttendees: newAttendees]; - [newAttendees release]; + else + NSLog(@"Error scanning following JSON:\n%@", json); } - (void) _handleOrganizer diff --git a/UI/Scheduler/product.plist b/UI/Scheduler/product.plist index ec2cd2c7e..d6f3e99b0 100644 --- a/UI/Scheduler/product.plist +++ b/UI/Scheduler/product.plist @@ -230,6 +230,11 @@ pageName = "UIxAppointmentEditor"; actionName = "decline"; }; + delegate = { + protectedBy = "RespondToComponent"; + actionClass = "UIxAppointmentEditor"; + actionName = "delegate"; + }; }; }; diff --git a/UI/Templates/Appointments/SOGoAptMailEnglishICalReply.wox b/UI/Templates/Appointments/SOGoAptMailEnglishICalReply.wox new file mode 100644 index 000000000..b6f39b69e --- /dev/null +++ b/UI/Templates/Appointments/SOGoAptMailEnglishICalReply.wox @@ -0,0 +1,17 @@ + + + + + + [Event Invitation Reply] + + + + (sent by ) has delegated the invitation to .accepteddeclinednot yet decided upon your event invitation. + + \ No newline at end of file diff --git a/UI/Templates/MailPartViewers/UIxMailPartICalViewer.wox b/UI/Templates/MailPartViewers/UIxMailPartICalViewer.wox index cf76b98c8..636cb72de 100644 --- a/UI/Templates/MailPartViewers/UIxMailPartICalViewer.wox +++ b/UI/Templates/MailPartViewers/UIxMailPartICalViewer.wox @@ -10,7 +10,7 @@ + var:value="pathToAttachment"/>
@@ -23,10 +23,13 @@ +
+
    +
    : - + () @@ -51,6 +54,25 @@ + + + + + + + + + + + + @@ -197,11 +219,12 @@ : - + - - () -
    + + + ( , ) +
    diff --git a/UI/Templates/SchedulerUI/UIxAttendeesEditor.wox b/UI/Templates/SchedulerUI/UIxAttendeesEditor.wox index 633fd8b09..3d0b4f9e1 100644 --- a/UI/Templates/SchedulerUI/UIxAttendeesEditor.wox +++ b/UI/Templates/SchedulerUI/UIxAttendeesEditor.wox @@ -55,8 +55,8 @@
    + const:jsFiles="skycalendar.js,UIxComponentEditor.js,SOGoAutoCompletion.js"> @@ -106,14 +107,7 @@ - - - - + @@ -166,6 +160,9 @@ +
    +
      +
      @@ -215,6 +212,23 @@ string="itemReplyText" var:selection="reply" /> + + + + +