5 // Created by Evan Schoenberg on 6/10/05.
8 #import "AIChatController.h"
10 #import <Adium/AIContentControllerProtocol.h>
11 #import <Adium/AIContactControllerProtocol.h>
12 #import <Adium/AIInterfaceControllerProtocol.h>
13 #import <Adium/AIMenuControllerProtocol.h>
14 #import "AdiumChatEvents.h"
15 #import <Adium/AIAccount.h>
16 #import <Adium/AIChat.h>
17 #import <Adium/AIContentObject.h>
18 #import <Adium/AIContentMessage.h>
19 #import <Adium/AIListContact.h>
20 #import <Adium/AIMetaContact.h>
21 #import <Adium/AIService.h>
22 #import <AIUtilities/AIArrayAdditions.h>
23 #import <AIUtilities/AIMenuAdditions.h>
25 @interface AIChatController (PRIVATE)
26 - (NSSet *)_informObserversOfChatStatusChange:(AIChat *)inChat withKeys:(NSSet *)modifiedKeys silent:(BOOL)silent;
27 - (void)chatAttributesChanged:(AIChat *)inChat modifiedKeys:(NSSet *)inModifiedKeys;
31 * @class AIChatController
32 * @brief Core controller for chats
34 * This is the only class which should vend AIChat objects (via openChat... or chatWith:...).
35 * AIChat objects should never be created directly.
37 @implementation AIChatController
40 * @brief Initialize the controller
44 if ((self = [super init])) {
46 chatObserverArray = [[NSMutableArray alloc] init];
47 adiumChatEvents = [[AdiumChatEvents alloc] init];
50 openChats = [[NSMutableSet alloc] init];
57 * @brief Controller loaded
59 - (void)controllerDidLoad
61 //Observe content so we can update the most recent chat
62 [[adium notificationCenter] addObserver:self
63 selector:@selector(didExchangeContent:)
64 name:CONTENT_MESSAGE_RECEIVED
67 [[adium notificationCenter] addObserver:self
68 selector:@selector(didExchangeContent:)
69 name:CONTENT_MESSAGE_RECEIVED_GROUP
72 [[adium notificationCenter] addObserver:self
73 selector:@selector(didExchangeContent:)
74 name:CONTENT_MESSAGE_SENT
77 [[adium notificationCenter] addObserver:self
78 selector:@selector(adiumWillTerminate:)
79 name:AIAppWillTerminateNotification
82 //Ignore menu item for contacts in group chats
83 menuItem_ignore = [[NSMenuItem allocWithZone:[NSMenu menuZone]] initWithTitle:@""
85 action:@selector(toggleIgnoreOfContact:)
87 [[adium menuController] addContextualMenuItem:menuItem_ignore toLocation:Context_Contact_GroupChatAction];
89 [adiumChatEvents controllerDidLoad];
94 * @brief Controller will close
96 - (void)controllerWillClose
102 * @brief Adium will terminate
104 * Post the Chat_WillClose for each open chat so any closing behavior can be performed
106 - (void)adiumWillTerminate:(NSNotification *)inNotification
108 NSEnumerator *enumerator = [openChats objectEnumerator];
111 //Every open chat is about to close. We perform the internal closing here rather than calling on the interface controller since the UI need not change.
112 while ((chat = [enumerator nextObject])) {
113 [self closeChat:chat];
122 [openChats release]; openChats = nil;
123 [chatObserverArray release]; chatObserverArray = nil;
124 [[adium notificationCenter] removeObserver:self];
130 * @brief Register a chat observer
132 * Chat observers are notified when status objects are changed on chats
134 * @param inObserver An observer, which must conform to AIChatObserver
136 - (void)registerChatObserver:(id <AIChatObserver>)inObserver
139 [chatObserverArray addObject:[NSValue valueWithNonretainedObject:inObserver]];
141 //Let the new observer process all existing chats
142 [self updateAllChatsForObserver:inObserver];
146 * @brief Unregister a chat observer
148 - (void)unregisterChatObserver:(id <AIChatObserver>)inObserver
150 [chatObserverArray removeObject:[NSValue valueWithNonretainedObject:inObserver]];
154 * @brief Chat status changed
156 * Called by AIChat after it changes one or more status keys.
158 - (void)chatStatusChanged:(AIChat *)inChat modifiedStatusKeys:(NSSet *)inModifiedKeys silent:(BOOL)silent
160 NSSet *modifiedAttributeKeys;
162 //Let all observers know the chat's status has changed before performing any further notifications
163 modifiedAttributeKeys = [self _informObserversOfChatStatusChange:inChat withKeys:inModifiedKeys silent:silent];
165 //Post an attributes changed message (if necessary)
166 if ([modifiedAttributeKeys count]) {
167 [self chatAttributesChanged:inChat modifiedKeys:modifiedAttributeKeys];
172 * @brief Chat attributes changed
174 * Called by -[AIChatController chatStatusChanged:modifiedStatusKeys:silent:] if any observers changed attributes
176 - (void)chatAttributesChanged:(AIChat *)inChat modifiedKeys:(NSSet *)inModifiedKeys
178 //Post an attributes changed message
179 [[adium notificationCenter] postNotificationName:Chat_AttributesChanged
181 userInfo:(inModifiedKeys ? [NSDictionary dictionaryWithObject:inModifiedKeys
182 forKey:@"Keys"] : nil)];
186 * @brief Send each chat in turn to an observer with a nil modifiedStatusKeys argument
188 * This lets an observer use its normal update mechanism to update every chat in some manner
190 - (void)updateAllChatsForObserver:(id <AIChatObserver>)observer
192 NSEnumerator *enumerator = [openChats objectEnumerator];
195 while ((chat = [enumerator nextObject])) {
196 [self chatStatusChanged:chat modifiedStatusKeys:nil silent:NO];
201 * @brief Notify observers of a status change. Returns the modified attribute keys
203 - (NSSet *)_informObserversOfChatStatusChange:(AIChat *)inChat withKeys:(NSSet *)modifiedKeys silent:(BOOL)silent
205 NSMutableSet *attrChange = nil;
206 NSEnumerator *enumerator;
207 NSValue *observerValue;
209 //Let our observers know
210 enumerator = [chatObserverArray objectEnumerator];
211 while ((observerValue = [enumerator nextObject])) {
212 id <AIChatObserver> observer;
215 observer = [observerValue nonretainedObjectValue];
216 if ((newKeys = [observer updateChat:inChat keys:modifiedKeys silent:silent])) {
217 if (!attrChange) attrChange = [NSMutableSet set];
218 [attrChange unionSet:newKeys];
222 //Send out the notification for other observers
223 [[adium notificationCenter] postNotificationName:Chat_StatusChanged
225 userInfo:(modifiedKeys ? [NSDictionary dictionaryWithObject:modifiedKeys
226 forKey:@"Keys"] : nil)];
231 //Chats -------------------------------------------------------------------------------------------------
234 * @brief Opens a chat for communication with the contact, creating if necessary.
236 * The interface controller will then be asked to open the UI for the new chat.
238 * @param inContact The AIListContact on which to open a chat. If an AIMetaContact, an appropriate contained contact will be selected.
239 * @param onPreferredAccount If YES, Adium will determine the account on which the chat should be opened. If NO, [inContact account] will be used. Value is treated as YES for AIMetaContacts by the action of -[AIChatController chatWithContact:].
241 - (AIChat *)openChatWithContact:(AIListContact *)inContact onPreferredAccount:(BOOL)onPreferredAccount
245 if (onPreferredAccount) {
246 inContact = [[adium contactController] preferredContactForContentType:CONTENT_MESSAGE_TYPE
247 forListContact:inContact];
250 chat = [self chatWithContact:inContact];
251 if (chat) [[adium interfaceController] openChat:chat];
257 * @brief Creates a chat for communication with the contact, but does not make the chat active
259 * No window or tab is opened for the chat.
260 * If a chat with this contact already exists, it is returned.
261 * If a chat with a contact within the same metaContact at this contact exists, it is switched to this contact
264 * The passed contact, if an AIListContact, will be used exactly -- that is, [inContact account] is the account on which the chat will be opened.
265 * If the passed contact is an AIMetaContact, an appropriate contact/account pair will be automatically selected by this method.
267 * @param inContact The contact with which to open a chat. See description above.
269 - (AIChat *)chatWithContact:(AIListContact *)inContact
271 NSEnumerator *enumerator;
273 AIListContact *targetContact = inContact;
276 If we're dealing with a meta contact, open a chat with the preferred contact for this meta contact
277 It's a good idea for the caller to pick the preferred contact for us, since they know the content type
278 being sent and more information - but we'll do it here as well just to be safe.
280 if ([inContact containsMultipleContacts]) {
281 targetContact = [[adium contactController] preferredContactForContentType:CONTENT_MESSAGE_TYPE
282 forListContact:inContact];
285 If we have no accounts online, preferredContactForContentType:forListContact will return nil.
286 We'd rather open up the chat window on a useless contact than do nothing, so just pick the
287 preferredContact from the metaContact.
289 if (!targetContact) {
290 targetContact = [(AIMetaContact *)inContact preferredContact];
294 //If we can't get a contact, we're not going to be able to get a chat... return nil
295 if (!targetContact) {
296 AILog(@"Warning: -[AIChatController chatWithContact:%@] got a nil targetContact.",inContact);
297 NSLog(@"Warning: -[AIChatController chatWithContact:%@] got a nil targetContact.",inContact);
301 //XXX Temporary.. make sure that the fixes early in adium-1.1svn are right.
302 NSAssert1(![targetContact isMemberOfClass:[AIMetaContact class]], @"Should not get this far in chatWithContact: with an AIMetaContact (%@)!",targetContact);
304 //Search for an existing chat we can switch instead of replacing
305 enumerator = [openChats objectEnumerator];
306 while ((chat = [enumerator nextObject])) {
307 //If a chat for this object already exists
308 if ([[chat uniqueChatID] isEqualToString:[targetContact internalObjectID]]) {
309 if (!([chat listObject] == targetContact)) {
310 [self switchChat:chat toAccount:[targetContact account]];
316 //If this object is within a meta contact, and a chat for an object in that meta contact already exists
317 if ([[targetContact containingObject] isKindOfClass:[AIMetaContact class]] &&
318 [[chat listObject] containingObject] == [targetContact containingObject]) {
320 //Switch the chat to be on this contact (and its account) now
321 [self switchChat:chat toListContact:targetContact usingContactAccount:YES];
328 AIAccount *account = [targetContact account];
331 chat = [AIChat chatForAccount:account];
332 [chat addObject:targetContact];
333 [openChats addObject:chat];
334 AILog(@"chatWithContact: Added <<%@>> [%@]",chat,openChats);
336 //Inform the account of its creation
337 if (![[targetContact account] openChat:chat]) {
338 [openChats removeObject:chat];
339 AILog(@"chatWithContact: Immediately removed <<%@>> [%@]",chat,openChats);
348 * @brief Return a pre-existing chat with a contact.
350 * @result The chat, or nil if no chat with the contact exists
352 - (AIChat *)existingChatWithContact:(AIListContact *)inContact
354 NSEnumerator *enumerator;
357 if ([inContact isKindOfClass:[AIMetaContact class]]) {
358 //Search for a chat with any contact within this AIMetaContact
359 enumerator = [openChats objectEnumerator];
360 while ((chat = [enumerator nextObject])) {
361 if ([[(AIMetaContact *)inContact containedObjects] containsObjectIdenticalTo:[chat listObject]]) break;
365 //Search for a chat with this AIListContact
366 enumerator = [openChats objectEnumerator];
367 while ((chat = [enumerator nextObject])) {
368 if ([chat listObject] == inContact) break;
376 * @brief Open a group chat
378 * @param inName The name of the chat; in general, the chat room name
379 * @param account The account on which to create the group chat
380 * @param chatCreationInfo A dictionary of information which may be used by the account when joining the chat serverside
381 * @brief opens a chat with the above parameters. Assigns chatroom info to the created AIChat object.
383 - (AIChat *)chatWithName:(NSString *)name identifier:(id)identifier onAccount:(AIAccount *)account chatCreationInfo:(NSDictionary *)chatCreationInfo
387 name = [[account service] normalizeChatName:name];
390 chat = [self existingChatWithIdentifier:identifier onAccount:account];
392 //See if a chat was made with this name but which doesn't yet have an identifier. If so, take ownership!
393 chat = [self existingChatWithName:name onAccount:account];
394 if (chat && ![chat identifier]) [chat setIdentifier:identifier];
398 //If the caller doesn't care about the identifier, do a search based on name to avoid creating a new chat incorrectly
399 chat = [self existingChatWithName:name onAccount:account];
402 AILog(@"chatWithName %@ identifier %@ existing --> %@", name, identifier, chat);
405 chat = [AIChat chatForAccount:account];
407 [chat setIdentifier:identifier];
408 [chat setIsGroupChat:YES];
409 [chat setChatCreationDictionary:chatCreationInfo];
411 [openChats addObject:chat];
412 AILog(@"chatWithName:%@ identifier:%@ onAccount:%@ added <<%@>> [%@]",name,identifier,account,chat,openChats);
415 //Inform the account of its creation
416 if (![account openChat:chat]) {
417 [openChats removeObject:chat];
418 AILog(@"chatWithName: Immediately removed <<%@>> [%@]",chat,openChats);
423 AILog(@"chatWithName %@ created --> %@",name,chat);
428 * @brief Find an existing group chat
430 * @result The group AIChat, or nil if no such chat exists
432 - (AIChat *)existingChatWithName:(NSString *)name onAccount:(AIAccount *)account
434 NSEnumerator *enumerator;
437 name = [[account service] normalizeChatName:name];
439 enumerator = [openChats objectEnumerator];
441 while ((chat = [enumerator nextObject])) {
442 if (([chat account] == account) &&
443 ([[chat name] isEqualToString:name])) {
452 * @brief Find an existing group chat
454 * @result The group AIChat, or nil if no such chat exists
456 - (AIChat *)existingChatWithIdentifier:(id)identifier onAccount:(AIAccount *)account
458 NSEnumerator *enumerator;
461 enumerator = [openChats objectEnumerator];
463 while ((chat = [enumerator nextObject])) {
464 if (([chat account] == account) &&
465 ([[chat identifier] isEqual:identifier])) {
474 * @brief Find an existing chat by unique chat ID
476 * @result The AIChat, or nil if no such chat exists
478 - (AIChat *)existingChatWithUniqueChatID:(NSString *)uniqueChatID
480 NSEnumerator *enumerator;
483 enumerator = [openChats objectEnumerator];
485 while ((chat = [enumerator nextObject])) {
486 if ([[chat uniqueChatID] isEqualToString:uniqueChatID]) {
495 * @brief Close a chat
497 * This should be called only by the interface controller. To close a chat programatically, use the interface controller's closeChat:.
499 * @result YES the chat was removed succesfully; NO if it was not
501 - (BOOL)closeChat:(AIChat *)inChat
505 /* If we are currently passing a content object for this chat through our content filters, don't remove it from
506 * our openChats set as it will become needed soon. If we were to remove it, and a second message came in which was
507 * also before the first message is done filtering, we would otherwise mistakenly think we needed to create a new
508 * chat, generating a duplicate.
510 shouldRemove = ![[adium contentController] chatIsReceivingContent:inChat];
514 if (mostRecentChat == inChat) {
515 [mostRecentChat release];
516 mostRecentChat = nil;
519 //Send out the Chat_WillClose notification
520 [[adium notificationCenter] postNotificationName:Chat_WillClose object:inChat userInfo:nil];
524 /* If we didn't remove the chat because we're waiting for it to reopen, don't cause the account
525 * to close down the chat.
527 [[inChat account] closeChat:inChat];
528 [openChats removeObject:inChat];
529 AILog(@"closeChat: Removed <<%@>> [%@]",inChat, openChats);
531 AILog(@"closeChat: Did not remove <<%@>> [%@]",inChat, openChats);
534 [inChat setIsOpen:NO];
541 * @brief Switch a chat from one account to another
543 * The target list contact for the chat is changed to be an 'identical' one on the target account; that is, a contact
544 * with the same UID but an account and service appropriate for newAccount.
546 - (void)switchChat:(AIChat *)chat toAccount:(AIAccount *)newAccount
548 AIAccount *oldAccount = [chat account];
549 if (newAccount != oldAccount) {
550 //Hang onto stuff until we're done
553 //Close down the chat on account A
554 [oldAccount closeChat:chat];
556 //Set the account and the listObject
558 [chat setAccount:newAccount];
560 //We want to keep the same destination for the chat but switch it to a listContact on the desired account.
561 AIListContact *newContact = [[adium contactController] contactWithService:[newAccount service]
563 UID:[[chat listObject] UID]];
564 [chat setListObject:newContact];
567 //Open the chat on account B
568 [newAccount openChat:chat];
576 * @brief Switch the list contact of a chat
578 * @param chat The chat
579 * @param inContact The contact with which the chat will now take place
580 * @param useContactAccount If YES, the chat is also set to [inContact account] as its account. If NO, the account and service of chat are unchanged.
582 - (void)switchChat:(AIChat *)chat toListContact:(AIListContact *)inContact usingContactAccount:(BOOL)useContactAccount
584 AIAccount *newAccount = (useContactAccount ? [inContact account] : [chat account]);
586 //Switch the inContact over to a contact on the new account so we send messages to the right place.
587 AIListContact *newContact = [[adium contactController] contactWithService:[newAccount service]
589 UID:[inContact UID]];
590 if (newContact != [chat listObject]) {
591 //Hang onto stuff until we're done
594 //Close down the chat on the account, as the account may need to perform actions such as closing a connection
595 [[chat account] closeChat:chat];
597 //Set to the new listContact and account as needed
598 [chat setListObject:newContact];
599 if (useContactAccount) [chat setAccount:newAccount];
601 //Reopen the chat on the account
602 [[chat account] openChat:chat];
610 * @brief Find all open chats with a contact
612 * @param inContact The contact. If inContact is an AIMetaContact, all chats with all contacts within the metaContact will be returned.
613 * @result An NSSet with all chats with the contact. In general, will contain 0 or 1 AIChat objects, though it may contain more.
615 - (NSSet *)allChatsWithContact:(AIListContact *)inContact
617 NSMutableSet *foundChats = nil;
619 //Scan the objects participating in each chat, looking for the requested object
620 if ([inContact isKindOfClass:[AIMetaContact class]]) {
621 if ([openChats count]) {
622 NSEnumerator *enumerator;
623 AIListContact *listContact;
625 enumerator = [[(AIMetaContact *)inContact listContacts] objectEnumerator];
626 while ((listContact = [enumerator nextObject])) {
627 NSSet *listContactChats;
628 if ((listContactChats = [self allChatsWithContact:listContact])) {
629 if (!foundChats) foundChats = [NSMutableSet set];
630 [foundChats unionSet:listContactChats];
636 NSEnumerator *enumerator;
639 enumerator = [openChats objectEnumerator];
640 while ((chat = [enumerator nextObject])) {
641 if (![chat isGroupChat] &&
642 [[[chat listObject] internalObjectID] isEqualToString:[inContact internalObjectID]] &&
644 if (!foundChats) foundChats = [NSMutableSet set];
645 [foundChats addObject:chat];
654 * @brief All open chats
656 * Open chats from the chatController may include chats which are not currently displayed by the interface.
664 * @brief Find the chat which most recently received content which has not yet been seen
666 * @result An AIChat with unviewed content, or nil if no chats current have unviewed content
668 - (AIChat *)mostRecentUnviewedChat
670 AIChat *mostRecentUnviewedChat = nil;
672 if (mostRecentChat && [mostRecentChat unviewedContentCount]) {
673 //First choice: switch to the chat which received chat most recently if it has unviewed content
674 mostRecentUnviewedChat = mostRecentChat;
677 //Second choice: switch to the first chat we can find which has unviewed content
678 NSEnumerator *enumerator = [openChats objectEnumerator];
680 while ((chat = [enumerator nextObject]) && ![chat unviewedContentCount]);
682 if (chat) mostRecentUnviewedChat = chat;
685 return mostRecentUnviewedChat;
689 * @brief Gets the total number of unviewed messages
691 * @result The number of unviewed messages
693 - (int)unviewedContentCount
697 NSEnumerator *enumerator;
699 enumerator = [[self openChats] objectEnumerator];
700 while ((chat = [enumerator nextObject])) {
701 count += [chat unviewedContentCount];
707 * @brief Is the passed contact in a group chat?
709 * @result YES if the contact is in an open group chat; NO if not.
711 - (BOOL)contactIsInGroupChat:(AIListContact *)listContact
713 NSEnumerator *chatEnumerator = [openChats objectEnumerator];
715 BOOL contactIsInGroupChat = NO;
717 while ((chat = [chatEnumerator nextObject])) {
718 if ([chat isGroupChat] &&
719 [chat containsObject:listContact]) {
721 contactIsInGroupChat = YES;
726 return contactIsInGroupChat;
730 * @brief Called when content is sent or received
732 * Update the most recent chat
734 - (void)didExchangeContent:(NSNotification *)notification
736 AIContentObject *contentObject = [[notification userInfo] objectForKey:@"AIContentObject"];
738 //Update our most recent chat
739 if ([contentObject trackContent]) {
740 AIChat *chat = [contentObject chat];
742 if (chat != mostRecentChat) {
743 [mostRecentChat release];
744 mostRecentChat = [chat retain];
751 * @brief Toggle ignoring of a contact
753 * Must be called from the contextual menu for the contact within a chat
755 - (void)toggleIgnoreOfContact:(id)sender
757 AIListObject *listObject = [[adium menuController] currentContextMenuObject];
758 AIChat *chat = [[adium menuController] currentContextMenuChat];
760 if ([listObject isKindOfClass:[AIListContact class]]) {
761 BOOL isIgnored = [chat isListContactIgnored:(AIListContact *)listObject];
762 [chat setListContact:(AIListContact *)listObject isIgnored:!isIgnored];
767 * @brief Menu item validation
769 * When asked to validate our ignore menu item, set its title to ignore/un-ignore as appropriate for the contact
771 - (BOOL)validateMenuItem:(NSMenuItem *)menuItem
773 if (menuItem == menuItem_ignore) {
774 AIListObject *listObject = [[adium menuController] currentContextMenuObject];
775 AIChat *chat = [[adium menuController] currentContextMenuChat];
777 if ([listObject isKindOfClass:[AIListContact class]]) {
778 if ([chat isListContactIgnored:(AIListContact *)listObject]) {
779 [menuItem setTitle:AILocalizedString(@"Un-ignore","Un-ignore means begin receiving messages from this contact again in a chat")];
782 [menuItem setTitle:AILocalizedString(@"Ignore","Ignore means no longer receive messages from this contact in a chat")];
785 [menuItem setTitle:AILocalizedString(@"Ignore","Ignore means no longer receive messages from this contact in a chat")];
793 #pragma mark Chat contact addition and removal
796 * @brief A chat added a listContact to its participatants list
798 * @param chat The chat
799 * @param inContact The contact
800 * @param notify If YES, trigger the contact joined event if this is a group chat. Ignored if this is not a group chat.
802 - (void)chat:(AIChat *)chat addedListContact:(AIListContact *)inContact notify:(BOOL)notify
804 if (notify && [chat isGroupChat]) {
805 /* Prevent triggering of the event when we are informed that the chat's own account entered the chat
806 * If the UID of a contact in a chat differs from a normal UID, such as is the case with Jabber where a chat
807 * contact has the form "roomname@conferenceserver/handle" this will fail, but it's better than nothing.
809 if (![[[inContact account] UID] isEqualToString:[inContact UID]]) {
810 [adiumChatEvents chat:chat addedListContact:inContact];
814 //Always notify Adium that the list changed so it can be updated, caches can be modified, etc.
815 [[adium notificationCenter] postNotificationName:Chat_ParticipatingListObjectsChanged
820 * @brief A chat removed a listContact from its participants list
822 * @param chat The chat
823 * @param inContact The contact
825 - (void)chat:(AIChat *)chat removedListContact:(AIListContact *)inContact
827 if ([chat isGroupChat]) {
828 [adiumChatEvents chat:chat removedListContact:inContact];
831 [[adium notificationCenter] postNotificationName:Chat_ParticipatingListObjectsChanged
835 - (NSString *)defaultInvitationMessageForRoom:(NSString *)room account:(AIAccount *)inAccount
837 return [NSString stringWithFormat:AILocalizedString(@"%@ invites you to join the chat \"%@\"", nil), [inAccount formattedUID], room];