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 <AIUtilities/AIArrayAdditions.h>
22 #import <AIUtilities/AIMenuAdditions.h>
24 @interface AIChatController (PRIVATE)
25 - (NSSet *)_informObserversOfChatStatusChange:(AIChat *)inChat withKeys:(NSSet *)modifiedKeys silent:(BOOL)silent;
26 - (void)chatAttributesChanged:(AIChat *)inChat modifiedKeys:(NSSet *)inModifiedKeys;
30 * @class AIChatController
31 * @brief Core controller for chats
33 * This is the only class which should vend AIChat objects (via openChat... or chatWith:...).
34 * AIChat objects should never be created directly.
36 @implementation AIChatController
39 * @brief Initialize the controller
43 if ((self = [super init])) {
45 chatObserverArray = [[NSMutableArray alloc] init];
46 adiumChatEvents = [[AdiumChatEvents alloc] init];
49 openChats = [[NSMutableSet alloc] init];
56 * @brief Controller loaded
58 - (void)controllerDidLoad
60 //Observe content so we can update the most recent chat
61 [[adium notificationCenter] addObserver:self
62 selector:@selector(didExchangeContent:)
63 name:CONTENT_MESSAGE_RECEIVED
66 [[adium notificationCenter] addObserver:self
67 selector:@selector(didExchangeContent:)
68 name:CONTENT_MESSAGE_RECEIVED_GROUP
71 [[adium notificationCenter] addObserver:self
72 selector:@selector(didExchangeContent:)
73 name:CONTENT_MESSAGE_SENT
76 [[adium notificationCenter] addObserver:self
77 selector:@selector(adiumWillTerminate:)
78 name:AIAppWillTerminateNotification
81 //Ignore menu item for contacts in group chats
82 menuItem_ignore = [[NSMenuItem allocWithZone:[NSMenu menuZone]] initWithTitle:@""
84 action:@selector(toggleIgnoreOfContact:)
86 [[adium menuController] addContextualMenuItem:menuItem_ignore toLocation:Context_Contact_GroupChatAction];
88 [adiumChatEvents controllerDidLoad];
93 * @brief Controller will close
95 - (void)controllerWillClose
101 * @brief Adium will terminate
103 * Post the Chat_WillClose for each open chat so any closing behavior can be performed
105 - (void)adiumWillTerminate:(NSNotification *)inNotification
107 NSEnumerator *enumerator = [openChats objectEnumerator];
110 //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.
111 while ((chat = [enumerator nextObject])) {
112 [self closeChat:chat];
121 [openChats release]; openChats = nil;
122 [chatObserverArray release]; chatObserverArray = nil;
123 [[adium notificationCenter] removeObserver:self];
129 * @brief Register a chat observer
131 * Chat observers are notified when status objects are changed on chats
133 * @param inObserver An observer, which must conform to AIChatObserver
135 - (void)registerChatObserver:(id <AIChatObserver>)inObserver
138 [chatObserverArray addObject:[NSValue valueWithNonretainedObject:inObserver]];
140 //Let the new observer process all existing chats
141 [self updateAllChatsForObserver:inObserver];
145 * @brief Unregister a chat observer
147 - (void)unregisterChatObserver:(id <AIChatObserver>)inObserver
149 [chatObserverArray removeObject:[NSValue valueWithNonretainedObject:inObserver]];
153 * @brief Chat status changed
155 * Called by AIChat after it changes one or more status keys.
157 - (void)chatStatusChanged:(AIChat *)inChat modifiedStatusKeys:(NSSet *)inModifiedKeys silent:(BOOL)silent
159 NSSet *modifiedAttributeKeys;
161 //Let all observers know the chat's status has changed before performing any further notifications
162 modifiedAttributeKeys = [self _informObserversOfChatStatusChange:inChat withKeys:inModifiedKeys silent:silent];
164 //Post an attributes changed message (if necessary)
165 if ([modifiedAttributeKeys count]) {
166 [self chatAttributesChanged:inChat modifiedKeys:modifiedAttributeKeys];
171 * @brief Chat attributes changed
173 * Called by -[AIChatController chatStatusChanged:modifiedStatusKeys:silent:] if any observers changed attributes
175 - (void)chatAttributesChanged:(AIChat *)inChat modifiedKeys:(NSSet *)inModifiedKeys
177 //Post an attributes changed message
178 [[adium notificationCenter] postNotificationName:Chat_AttributesChanged
180 userInfo:(inModifiedKeys ? [NSDictionary dictionaryWithObject:inModifiedKeys
181 forKey:@"Keys"] : nil)];
185 * @brief Send each chat in turn to an observer with a nil modifiedStatusKeys argument
187 * This lets an observer use its normal update mechanism to update every chat in some manner
189 - (void)updateAllChatsForObserver:(id <AIChatObserver>)observer
191 NSEnumerator *enumerator = [openChats objectEnumerator];
194 while ((chat = [enumerator nextObject])) {
195 [self chatStatusChanged:chat modifiedStatusKeys:nil silent:NO];
200 * @brief Notify observers of a status change. Returns the modified attribute keys
202 - (NSSet *)_informObserversOfChatStatusChange:(AIChat *)inChat withKeys:(NSSet *)modifiedKeys silent:(BOOL)silent
204 NSMutableSet *attrChange = nil;
205 NSEnumerator *enumerator;
206 NSValue *observerValue;
208 //Let our observers know
209 enumerator = [chatObserverArray objectEnumerator];
210 while ((observerValue = [enumerator nextObject])) {
211 id <AIChatObserver> observer;
214 observer = [observerValue nonretainedObjectValue];
215 if ((newKeys = [observer updateChat:inChat keys:modifiedKeys silent:silent])) {
216 if (!attrChange) attrChange = [NSMutableSet set];
217 [attrChange unionSet:newKeys];
221 //Send out the notification for other observers
222 [[adium notificationCenter] postNotificationName:Chat_StatusChanged
224 userInfo:(modifiedKeys ? [NSDictionary dictionaryWithObject:modifiedKeys
225 forKey:@"Keys"] : nil)];
230 //Chats -------------------------------------------------------------------------------------------------
233 * @brief Opens a chat for communication with the contact, creating if necessary.
235 * The interface controller will then be asked to open the UI for the new chat.
237 * @param inContact The AIListContact on which to open a chat. If an AIMetaContact, an appropriate contained contact will be selected.
238 * @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:].
240 - (AIChat *)openChatWithContact:(AIListContact *)inContact onPreferredAccount:(BOOL)onPreferredAccount
244 if (onPreferredAccount) {
245 inContact = [[adium contactController] preferredContactForContentType:CONTENT_MESSAGE_TYPE
246 forListContact:inContact];
249 chat = [self chatWithContact:inContact];
250 if (chat) [[adium interfaceController] openChat:chat];
256 * @brief Creates a chat for communication with the contact, but does not make the chat active
258 * No window or tab is opened for the chat.
259 * If a chat with this contact already exists, it is returned.
260 * If a chat with a contact within the same metaContact at this contact exists, it is switched to this contact
263 * The passed contact, if an AIListContact, will be used exactly -- that is, [inContact account] is the account on which the chat will be opened.
264 * If the passed contact is an AIMetaContact, an appropriate contact/account pair will be automatically selected by this method.
266 * @param inContact The contact with which to open a chat. See description above.
268 - (AIChat *)chatWithContact:(AIListContact *)inContact
270 NSEnumerator *enumerator;
272 AIListContact *targetContact = inContact;
275 If we're dealing with a meta contact, open a chat with the preferred contact for this meta contact
276 It's a good idea for the caller to pick the preferred contact for us, since they know the content type
277 being sent and more information - but we'll do it here as well just to be safe.
279 if ([inContact containsMultipleContacts]) {
280 targetContact = [[adium contactController] preferredContactForContentType:CONTENT_MESSAGE_TYPE
281 forListContact:inContact];
284 If we have no accounts online, preferredContactForContentType:forListContact will return nil.
285 We'd rather open up the chat window on a useless contact than do nothing, so just pick the
286 preferredContact from the metaContact.
288 if (!targetContact) {
289 targetContact = [(AIMetaContact *)inContact preferredContact];
293 //If we can't get a contact, we're not going to be able to get a chat... return nil
294 if (!targetContact) {
295 AILog(@"Warning: -[AIChatController chatWithContact:%@] got a nil targetContact.",inContact);
296 NSLog(@"Warning: -[AIChatController chatWithContact:%@] got a nil targetContact.",inContact);
300 //XXX Temporary.. make sure that the fixes early in adium-1.1svn are right.
301 NSAssert1(![targetContact isMemberOfClass:[AIMetaContact class]], @"Should not get this far in chatWithContact: with an AIMetaContact (%@)!",targetContact);
303 //Search for an existing chat we can switch instead of replacing
304 enumerator = [openChats objectEnumerator];
305 while ((chat = [enumerator nextObject])) {
306 //If a chat for this object already exists
307 if ([[chat uniqueChatID] isEqualToString:[targetContact internalObjectID]]) {
308 if (!([chat listObject] == targetContact)) {
309 [self switchChat:chat toAccount:[targetContact account]];
315 //If this object is within a meta contact, and a chat for an object in that meta contact already exists
316 if ([[targetContact containingObject] isKindOfClass:[AIMetaContact class]] &&
317 [[chat listObject] containingObject] == [targetContact containingObject]) {
319 //Switch the chat to be on this contact (and its account) now
320 [self switchChat:chat toListContact:targetContact usingContactAccount:YES];
327 AIAccount *account = [targetContact account];
330 chat = [AIChat chatForAccount:account];
331 [chat addObject:targetContact];
332 [openChats addObject:chat];
333 AILog(@"chatWithContact: Added <<%@>> [%@]",chat,openChats);
335 //Inform the account of its creation
336 if (![[targetContact account] openChat:chat]) {
337 [openChats removeObject:chat];
338 AILog(@"chatWithContact: Immediately removed <<%@>> [%@]",chat,openChats);
347 * @brief Return a pre-existing chat with a contact.
349 * @result The chat, or nil if no chat with the contact exists
351 - (AIChat *)existingChatWithContact:(AIListContact *)inContact
353 NSEnumerator *enumerator;
356 if ([inContact isKindOfClass:[AIMetaContact class]]) {
357 //Search for a chat with any contact within this AIMetaContact
358 enumerator = [openChats objectEnumerator];
359 while ((chat = [enumerator nextObject])) {
360 if ([[(AIMetaContact *)inContact containedObjects] containsObjectIdenticalTo:[chat listObject]]) break;
364 //Search for a chat with this AIListContact
365 enumerator = [openChats objectEnumerator];
366 while ((chat = [enumerator nextObject])) {
367 if ([chat listObject] == inContact) break;
375 * @brief Open a group chat
377 * @param inName The name of the chat; in general, the chat room name
378 * @param account The account on which to create the group chat
379 * @param chatCreationInfo A dictionary of information which may be used by the account when joining the chat serverside
380 * @brief opens a chat with the above parameters. Assigns chatroom info to the created AIChat object.
382 - (AIChat *)chatWithName:(NSString *)name identifier:(id)identifier onAccount:(AIAccount *)account chatCreationInfo:(NSDictionary *)chatCreationInfo
387 chat = [self existingChatWithIdentifier:identifier onAccount:account];
389 //See if a chat was made with this name but which doesn't yet have an identifier. If so, take ownership!
390 chat = [self existingChatWithName:name onAccount:account];
391 if (chat && ![chat identifier]) [chat setIdentifier:identifier];
395 //If the caller doesn't care about the identifier, do a search based on name to avoid creating a new chat incorrectly
396 chat = [self existingChatWithName:name onAccount:account];
399 AILog(@"chatWithName %@ identifier %@ existing --> %@", name, identifier, chat);
402 chat = [AIChat chatForAccount:account];
404 [chat setIdentifier:identifier];
405 [chat setIsGroupChat:YES];
406 [chat setChatCreationDictionary:chatCreationInfo];
408 [openChats addObject:chat];
409 AILog(@"chatWithName:%@ identifier:%@ onAccount:%@ added <<%@>> [%@]",name,identifier,account,chat,openChats);
412 //Inform the account of its creation
413 if (![account openChat:chat]) {
414 [openChats removeObject:chat];
415 AILog(@"chatWithName: Immediately removed <<%@>> [%@]",chat,openChats);
420 AILog(@"chatWithName %@ created --> %@",name,chat);
425 * @brief Find an existing group chat
427 * @result The group AIChat, or nil if no such chat exists
429 - (AIChat *)existingChatWithName:(NSString *)name onAccount:(AIAccount *)account
431 NSEnumerator *enumerator;
434 enumerator = [openChats objectEnumerator];
436 while ((chat = [enumerator nextObject])) {
437 if (([chat account] == account) &&
438 ([[chat name] isEqualToString:name])) {
447 * @brief Find an existing group chat
449 * @result The group AIChat, or nil if no such chat exists
451 - (AIChat *)existingChatWithIdentifier:(id)identifier onAccount:(AIAccount *)account
453 NSEnumerator *enumerator;
456 enumerator = [openChats objectEnumerator];
458 while ((chat = [enumerator nextObject])) {
459 if (([chat account] == account) &&
460 ([[chat identifier] isEqual:identifier])) {
469 * @brief Find an existing chat by unique chat ID
471 * @result The AIChat, or nil if no such chat exists
473 - (AIChat *)existingChatWithUniqueChatID:(NSString *)uniqueChatID
475 NSEnumerator *enumerator;
478 enumerator = [openChats objectEnumerator];
480 while ((chat = [enumerator nextObject])) {
481 if ([[chat uniqueChatID] isEqualToString:uniqueChatID]) {
490 * @brief Close a chat
492 * This should be called only by the interface controller. To close a chat programatically, use the interface controller's closeChat:.
494 * @result YES the chat was removed succesfully; NO if it was not
496 - (BOOL)closeChat:(AIChat *)inChat
500 /* If we are currently passing a content object for this chat through our content filters, don't remove it from
501 * our openChats set as it will become needed soon. If we were to remove it, and a second message came in which was
502 * also before the first message is done filtering, we would otherwise mistakenly think we needed to create a new
503 * chat, generating a duplicate.
505 shouldRemove = ![[adium contentController] chatIsReceivingContent:inChat];
509 if (mostRecentChat == inChat) {
510 [mostRecentChat release];
511 mostRecentChat = nil;
514 //Send out the Chat_WillClose notification
515 [[adium notificationCenter] postNotificationName:Chat_WillClose object:inChat userInfo:nil];
519 /* If we didn't remove the chat because we're waiting for it to reopen, don't cause the account
520 * to close down the chat.
522 [[inChat account] closeChat:inChat];
523 [openChats removeObject:inChat];
524 AILog(@"closeChat: Removed <<%@>> [%@]",inChat, openChats);
526 AILog(@"closeChat: Did not remove <<%@>> [%@]",inChat, openChats);
529 [inChat setIsOpen:NO];
536 * @brief Switch a chat from one account to another
538 * The target list contact for the chat is changed to be an 'identical' one on the target account; that is, a contact
539 * with the same UID but an account and service appropriate for newAccount.
541 - (void)switchChat:(AIChat *)chat toAccount:(AIAccount *)newAccount
543 AIAccount *oldAccount = [chat account];
544 if (newAccount != oldAccount) {
545 //Hang onto stuff until we're done
548 //Close down the chat on account A
549 [oldAccount closeChat:chat];
551 //Set the account and the listObject
553 [chat setAccount:newAccount];
555 //We want to keep the same destination for the chat but switch it to a listContact on the desired account.
556 AIListContact *newContact = [[adium contactController] contactWithService:[newAccount service]
558 UID:[[chat listObject] UID]];
559 [chat setListObject:newContact];
562 //Open the chat on account B
563 [newAccount openChat:chat];
571 * @brief Switch the list contact of a chat
573 * @param chat The chat
574 * @param inContact The contact with which the chat will now take place
575 * @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.
577 - (void)switchChat:(AIChat *)chat toListContact:(AIListContact *)inContact usingContactAccount:(BOOL)useContactAccount
579 AIAccount *newAccount = (useContactAccount ? [inContact account] : [chat account]);
581 //Switch the inContact over to a contact on the new account so we send messages to the right place.
582 AIListContact *newContact = [[adium contactController] contactWithService:[newAccount service]
584 UID:[inContact UID]];
585 if (newContact != [chat listObject]) {
586 //Hang onto stuff until we're done
589 //Close down the chat on the account, as the account may need to perform actions such as closing a connection
590 [[chat account] closeChat:chat];
592 //Set to the new listContact and account as needed
593 [chat setListObject:newContact];
594 if (useContactAccount) [chat setAccount:newAccount];
596 //Reopen the chat on the account
597 [[chat account] openChat:chat];
605 * @brief Find all open chats with a contact
607 * @param inContact The contact. If inContact is an AIMetaContact, all chats with all contacts within the metaContact will be returned.
608 * @result An NSSet with all chats with the contact. In general, will contain 0 or 1 AIChat objects, though it may contain more.
610 - (NSSet *)allChatsWithContact:(AIListContact *)inContact
612 NSMutableSet *foundChats = nil;
614 //Scan the objects participating in each chat, looking for the requested object
615 if ([inContact isKindOfClass:[AIMetaContact class]]) {
616 if ([openChats count]) {
617 NSEnumerator *enumerator;
618 AIListContact *listContact;
620 enumerator = [[(AIMetaContact *)inContact listContacts] objectEnumerator];
621 while ((listContact = [enumerator nextObject])) {
622 NSSet *listContactChats;
623 if ((listContactChats = [self allChatsWithContact:listContact])) {
624 if (!foundChats) foundChats = [NSMutableSet set];
625 [foundChats unionSet:listContactChats];
631 NSEnumerator *enumerator;
634 enumerator = [openChats objectEnumerator];
635 while ((chat = [enumerator nextObject])) {
636 if (![chat isGroupChat] &&
637 [[[chat listObject] internalObjectID] isEqualToString:[inContact internalObjectID]] &&
639 if (!foundChats) foundChats = [NSMutableSet set];
640 [foundChats addObject:chat];
649 * @brief All open chats
651 * Open chats from the chatController may include chats which are not currently displayed by the interface.
659 * @brief Find the chat which most recently received content which has not yet been seen
661 * @result An AIChat with unviewed content, or nil if no chats current have unviewed content
663 - (AIChat *)mostRecentUnviewedChat
665 AIChat *mostRecentUnviewedChat = nil;
667 if (mostRecentChat && [mostRecentChat unviewedContentCount]) {
668 //First choice: switch to the chat which received chat most recently if it has unviewed content
669 mostRecentUnviewedChat = mostRecentChat;
672 //Second choice: switch to the first chat we can find which has unviewed content
673 NSEnumerator *enumerator = [openChats objectEnumerator];
675 while ((chat = [enumerator nextObject]) && ![chat unviewedContentCount]);
677 if (chat) mostRecentUnviewedChat = chat;
680 return mostRecentUnviewedChat;
684 * @brief Gets the total number of unviewed messages
686 * @result The number of unviewed messages
688 - (int)unviewedContentCount
692 NSEnumerator *enumerator;
694 enumerator = [[self openChats] objectEnumerator];
695 while ((chat = [enumerator nextObject])) {
696 count += [chat unviewedContentCount];
702 * @brief Is the passed contact in a group chat?
704 * @result YES if the contact is in an open group chat; NO if not.
706 - (BOOL)contactIsInGroupChat:(AIListContact *)listContact
708 NSEnumerator *chatEnumerator = [openChats objectEnumerator];
710 BOOL contactIsInGroupChat = NO;
712 while ((chat = [chatEnumerator nextObject])) {
713 if ([chat isGroupChat] &&
714 [chat containsObject:listContact]) {
716 contactIsInGroupChat = YES;
721 return contactIsInGroupChat;
725 * @brief Called when content is sent or received
727 * Update the most recent chat
729 - (void)didExchangeContent:(NSNotification *)notification
731 AIContentObject *contentObject = [[notification userInfo] objectForKey:@"AIContentObject"];
733 //Update our most recent chat
734 if ([contentObject trackContent]) {
735 AIChat *chat = [contentObject chat];
737 if (chat != mostRecentChat) {
738 [mostRecentChat release];
739 mostRecentChat = [chat retain];
746 * @brief Toggle ignoring of a contact
748 * Must be called from the contextual menu for the contact within a chat
750 - (void)toggleIgnoreOfContact:(id)sender
752 AIListObject *listObject = [[adium menuController] currentContextMenuObject];
753 AIChat *chat = [[adium menuController] currentContextMenuChat];
755 if ([listObject isKindOfClass:[AIListContact class]]) {
756 BOOL isIgnored = [chat isListContactIgnored:(AIListContact *)listObject];
757 [chat setListContact:(AIListContact *)listObject isIgnored:!isIgnored];
762 * @brief Menu item validation
764 * When asked to validate our ignore menu item, set its title to ignore/un-ignore as appropriate for the contact
766 - (BOOL)validateMenuItem:(NSMenuItem *)menuItem
768 if (menuItem == menuItem_ignore) {
769 AIListObject *listObject = [[adium menuController] currentContextMenuObject];
770 AIChat *chat = [[adium menuController] currentContextMenuChat];
772 if ([listObject isKindOfClass:[AIListContact class]]) {
773 if ([chat isListContactIgnored:(AIListContact *)listObject]) {
774 [menuItem setTitle:AILocalizedString(@"Un-ignore","Un-ignore means begin receiving messages from this contact again in a chat")];
777 [menuItem setTitle:AILocalizedString(@"Ignore","Ignore means no longer receive messages from this contact in a chat")];
780 [menuItem setTitle:AILocalizedString(@"Ignore","Ignore means no longer receive messages from this contact in a chat")];
788 #pragma mark Chat contact addition and removal
791 * @brief A chat added a listContact to its participatants list
793 * @param chat The chat
794 * @param inContact The contact
795 * @param notify If YES, trigger the contact joined event if this is a group chat. Ignored if this is not a group chat.
797 - (void)chat:(AIChat *)chat addedListContact:(AIListContact *)inContact notify:(BOOL)notify
799 if (notify && [chat isGroupChat]) {
800 /* Prevent triggering of the event when we are informed that the chat's own account entered the chat
801 * If the UID of a contact in a chat differs from a normal UID, such as is the case with Jabber where a chat
802 * contact has the form "roomname@conferenceserver/handle" this will fail, but it's better than nothing.
804 if (![[[inContact account] UID] isEqualToString:[inContact UID]]) {
805 [adiumChatEvents chat:chat addedListContact:inContact];
809 //Always notify Adium that the list changed so it can be updated, caches can be modified, etc.
810 [[adium notificationCenter] postNotificationName:Chat_ParticipatingListObjectsChanged
815 * @brief A chat removed a listContact from its participants list
817 * @param chat The chat
818 * @param inContact The contact
820 - (void)chat:(AIChat *)chat removedListContact:(AIListContact *)inContact
822 if ([chat isGroupChat]) {
823 [adiumChatEvents chat:chat removedListContact:inContact];
826 [[adium notificationCenter] postNotificationName:Chat_ParticipatingListObjectsChanged
830 - (NSString *)defaultInvitationMessageForRoom:(NSString *)room account:(AIAccount *)inAccount
832 return [NSString stringWithFormat:AILocalizedString(@"%@ invites you to join the chat \"%@\"", nil), [inAccount formattedUID], room];