Frameworks/Adium Framework/Source/AIStatusMenu.m
author Zachary West <zacw@adium.im>
Mon Nov 23 17:02:25 2009 -0500 (2009-11-23)
changeset 2805 9c32a9b78f76
parent 1946 866f1f27b315
permissions -rw-r--r--
Revert the "never use last saved status" behavior. Re-fixes #6227.

The behavior was #ifdef'd out to appease MSN users, who wanted their status messages to propagate. Find some middle ground here: if a status message is currently set, a custom new-status window will retain it. Otherwise, it'll use the saved one. And we'll always use the last-saved status settings otherwise, so "autoreply" saving always works.
     1 //
     2 //  AIStatusMenu.m
     3 //  Adium
     4 //
     5 //  Created by Evan Schoenberg on 11/23/05.
     6 //
     7 
     8 #import <Adium/AIStatusMenu.h>
     9 #import <Adium/AIStatus.h>
    10 #import <Adium/AIStatusGroup.h>
    11 #import <Adium/AIAccount.h>
    12 #import <Adium/AIStatusControllerProtocol.h>
    13 #import <Adium/AIEditStateWindowController.h>
    14 #import <Adium/AIStatusIcons.h>
    15 #import <Adium/AISocialNetworkingStatusMenu.h>
    16 #import <Adium/AIAccountControllerProtocol.h>
    17 #import <Adium/AIMenuControllerProtocol.h>
    18 #import <AIUtilities/AIArrayAdditions.h>
    19 #import <AIUtilities/AIEventAdditions.h>
    20 #import <AIUtilities/AIMenuAdditions.h>
    21 #import <AIUtilities/AIStringAdditions.h>
    22 
    23 #define STATUS_TITLE_CUSTOM			[AILocalizedString(@"Custom", nil) stringByAppendingEllipsis]
    24 #define STATE_TITLE_MENU_LENGTH		30
    25 
    26 @interface AIStatusMenu ()
    27 - (id)initWithDelegate:(id<AIStatusMenuDelegate>)inDelegate;
    28 @end
    29 
    30 @implementation AIStatusMenu
    31 
    32 + (id)statusMenuWithDelegate:(id<AIStatusMenuDelegate>)inDelegate
    33 {
    34 	return [[[self alloc] initWithDelegate:inDelegate] autorelease];
    35 }
    36 
    37 - (id)initWithDelegate:(id<AIStatusMenuDelegate>)inDelegate
    38 {
    39 	if ((self = [super init])) {
    40 		self.delegate = inDelegate;
    41 		
    42 		NSParameterAssert([delegate respondsToSelector:@selector(statusMenu:didRebuildStatusMenuItems:)]);
    43 
    44 		menuItemArray = [[NSMutableArray alloc] init];
    45 		stateMenuItemsAlreadyValidated = [[NSMutableSet alloc] init];
    46 
    47 		[self rebuildMenu];
    48 
    49 		[[NSNotificationCenter defaultCenter] addObserver:self
    50 									   selector:@selector(stateArrayChanged:)
    51 										   name:AIStatusStateArrayChangedNotification
    52 										 object:nil];
    53 		[[NSNotificationCenter defaultCenter] addObserver:self
    54 									   selector:@selector(activeStatusStateChanged:)
    55 										   name:AIStatusActiveStateChangedNotification
    56 										 object:nil];
    57 		
    58 		//Update our state menus when the state array or status icon set changes
    59 		[[NSNotificationCenter defaultCenter] addObserver:self
    60 									   selector:@selector(statusIconSetChanged:)
    61 										   name:AIStatusIconSetDidChangeNotification
    62 										 object:nil];
    63 	}
    64 	
    65 	return self;
    66 }
    67 
    68 - (void)dealloc
    69 {
    70 	[[NSNotificationCenter defaultCenter] removeObserver:self];
    71 	[stateMenuItemsAlreadyValidated release];
    72 	[menuItemArray release];
    73 
    74 	self.delegate = nil;
    75 
    76 	[super dealloc];
    77 }
    78 
    79 @synthesize delegate;
    80 
    81 /*!
    82  * @brief The delegate is just too good for the menu items we've created; it will create all of the ones it wants on its own
    83  */
    84 - (void)delegateWillReplaceAllMenuItems
    85 {
    86 	//Remove the menu items from needing update
    87 	[stateMenuItemsAlreadyValidated removeAllObjects];
    88 
    89 	//Clear the array itself
    90 	[menuItemArray removeAllObjects];	
    91 }
    92 
    93 /*!
    94  * @brief The delegate created its own menu items it wants us to track and update
    95  */
    96 - (void)delegateCreatedMenuItems:(NSArray *)addedMenuItems
    97 {
    98 	//Now add the items we were given
    99 	[menuItemArray addObjectsFromArray:addedMenuItems];
   100 }
   101 
   102 - (void)stateArrayChanged:(NSNotification *)notification
   103 {	
   104 	[self rebuildMenu];
   105 }
   106 
   107 - (void)activeStatusStateChanged:(NSNotification *)notification
   108 {
   109 	[stateMenuItemsAlreadyValidated removeAllObjects];
   110 }
   111 
   112 - (void)statusIconSetChanged:(NSNotification *)notification
   113 {
   114 	[self rebuildMenu];	
   115 }
   116 
   117 /*!
   118  * @brief Generate the custom menu item for a status type
   119  */
   120 - (NSMenuItem *)customMenuItemForStatusType:(AIStatusType)statusType
   121 {
   122 	NSMenuItem *menuItem;
   123 	
   124 	menuItem = [[NSMenuItem alloc] initWithTitle:STATUS_TITLE_CUSTOM
   125 										  target:self
   126 										  action:@selector(selectCustomState:)
   127 								   keyEquivalent:@""];
   128 	
   129 	[menuItem setImage:[AIStatusIcons statusIconForStatusName:nil
   130 												   statusType:statusType
   131 													 iconType:AIStatusIconMenu
   132 													direction:AIIconNormal]];
   133 	[menuItem setTag:statusType];
   134 	
   135 	return [menuItem autorelease];
   136 }
   137 
   138 /*!
   139  * @brief Rebuild the menu
   140  */
   141 - (void)rebuildMenu
   142 {
   143 	NSEnumerator			*enumerator;
   144 	NSMenuItem				*menuItem;
   145 	AIStatus				*statusState;
   146 	AIStatusType			currentStatusType = AIAvailableStatusType;
   147 	AIStatusMutabilityType	currentStatusMutabilityType = AILockedStatusState;
   148 
   149 	[adium.menuController delayMenuItemPostProcessing];
   150 	
   151 	if ([delegate respondsToSelector:@selector(statusMenu:willRemoveStatusMenuItems:)]) {
   152 		[delegate statusMenu:self willRemoveStatusMenuItems:menuItemArray];
   153 	}
   154 
   155 	[menuItemArray removeAllObjects];
   156 	[stateMenuItemsAlreadyValidated removeAllObjects];
   157 
   158 	/* Create a menu item for each state.  States must first be sorted such that states of the same AIStatusType
   159 		* are grouped together.
   160 		*/
   161 	enumerator = [[adium.statusController sortedFullStateArray] objectEnumerator];
   162 	while ((statusState = [enumerator nextObject])) {
   163 		NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
   164 		AIStatusType thisStatusType = statusState.statusType;
   165 		AIStatusType thisStatusMutabilityType = [statusState mutabilityType];
   166 		
   167 		if ((currentStatusMutabilityType != AISecondaryLockedStatusState) &&
   168 			(thisStatusMutabilityType == AISecondaryLockedStatusState)) {
   169 			//Add the custom item, as we are ending this group
   170 			[menuItemArray addObject:[self customMenuItemForStatusType:currentStatusType]];
   171 			
   172 			//Add a divider when we switch to a secondary locked group
   173 			[menuItemArray addObject:[NSMenuItem separatorItem]];
   174 		}
   175 		
   176 		//We treat Invisible statuses as being the same as Away for purposes of the menu
   177 		if (thisStatusType == AIInvisibleStatusType) thisStatusType = AIAwayStatusType;
   178 		
   179 		/* Add the "Custom..." state option and a separatorItem before beginning to add items for a new statusType
   180 			* Sorting the menu items before enumerating means that we know our statuses are sorted first by statusType
   181 			*/
   182 		if ((currentStatusType != thisStatusType) &&
   183 			(currentStatusType != AIOfflineStatusType)) {
   184 			
   185 			//Don't include a Custom item after the secondary locked group, as it was already included
   186 			if ((currentStatusMutabilityType != AISecondaryLockedStatusState)) {
   187 				[menuItemArray addObject:[self customMenuItemForStatusType:currentStatusType]];
   188 			}
   189 			
   190 			//Add a divider
   191 			[menuItemArray addObject:[NSMenuItem separatorItem]];
   192 			
   193 			currentStatusType = thisStatusType;
   194 		}
   195 
   196 		menuItem = [[NSMenuItem alloc] initWithTitle:[AIStatusMenu titleForMenuDisplayOfState:statusState]
   197 											  target:self
   198 											  action:@selector(selectState:)
   199 									   keyEquivalent:@""];
   200 		
   201 		if ([statusState isKindOfClass:[AIStatus class]]) {
   202 			[menuItem setToolTip:[statusState statusMessageTooltipString]];
   203 			
   204 		} else {
   205 			/* AIStatusGroup */
   206 			[menuItem setSubmenu:[(AIStatusGroup *)statusState statusSubmenuNotifyingTarget:self
   207 																					 action:@selector(selectState:)]];
   208 		}
   209 		[menuItem setRepresentedObject:[NSDictionary dictionaryWithObject:statusState
   210 																   forKey:@"AIStatus"]];
   211 		[menuItem setTag:currentStatusType];
   212 		[menuItem setImage:[statusState menuIcon]];
   213 		[menuItemArray addObject:menuItem];
   214 		[menuItem release];
   215 		
   216 		currentStatusMutabilityType = thisStatusMutabilityType;
   217 		[pool release];
   218 	}
   219 	
   220 	if (currentStatusType != AIOfflineStatusType) {
   221 		/* Add the last "Custom..." state option for the last statusType we handled,
   222 		 * which didn't get a "Custom..." item yet.  At present, our last status type should always be
   223 		 * our AIOfflineStatusType, so this will never be executed and just exists for completeness.
   224 		 */
   225 		[menuItemArray addObject:[self customMenuItemForStatusType:currentStatusType]];
   226 	}
   227 
   228 	//Now that we are done creating the menu items, tell the plugin about them
   229 	[delegate statusMenu:self didRebuildStatusMenuItems:menuItemArray];
   230 	
   231 	[adium.menuController endDelayMenuItemPostProcessing];
   232 }
   233 
   234 /*!
   235 * @brief Menu validation
   236  *
   237  * Our state menu items should always be active, so always return YES for validation.
   238  *
   239  * Here we lazily set the state of our menu items if our stateMenuItemsAlreadyValidated set indicates it is needed.
   240  *
   241  * Random note: stateMenuItemsAlreadyValidated will almost never have a count of 0 because separatorItems
   242  * get included but never get validated.
   243  */
   244 - (BOOL)validateMenuItem:(NSMenuItem *)menuItem
   245 {
   246 	if (![stateMenuItemsAlreadyValidated containsObject:menuItem]) {
   247 		NSDictionary	*dict = [menuItem representedObject];
   248 		AIAccount		*account = [dict objectForKey:@"AIAccount"];
   249 		AIStatus		*menuItemStatusState = [dict objectForKey:@"AIStatus"];
   250 		
   251 		if (account) {
   252 			/* Account-specific menu items */
   253 			AIStatus *appropriateActiveStatusState = account.statusState;
   254 			
   255 			/* Our "Custom..." menu choice has a nil represented object.  If the appropriate active search state is
   256 				* in our array of states from which we made menu items, we'll be searching to match it.  If it isn't,
   257 				* we have a custom state and will be searching for the custom item of the right type, switching all other
   258 				* menu items to NSOffState.
   259 				*/
   260 			if ([adium.statusController.flatStatusSet containsObject:appropriateActiveStatusState]) {
   261 				//If the search state is in the array so is a saved state, search for the match
   262 				if ((menuItemStatusState == appropriateActiveStatusState) ||
   263 					([menuItemStatusState isKindOfClass:[AIStatusGroup class]] &&
   264 					 [(AIStatusGroup *)menuItemStatusState enclosesStatusState:appropriateActiveStatusState])) {
   265 					if ([menuItem state] != NSOnState) [menuItem setState:NSOnState];
   266 				} else {
   267 					if ([menuItem state] != NSOffState) [menuItem setState:NSOffState];
   268 				}
   269 			} else {
   270 				//If there is not a status state, we are in a Custom state. Search for the correct Custom item.
   271 				if (menuItemStatusState) {
   272 					//If the menu item has an associated state, it's always off.
   273 					if ([menuItem state] != NSOffState) [menuItem setState:NSOffState];
   274 				} else {
   275 					//If it doesn't, check the tag to see if it should be on or off.
   276 					if ([menuItem tag] == appropriateActiveStatusState.statusType) {
   277 						if ([menuItem state] != NSOnState) [menuItem setState:NSOnState];
   278 					} else {
   279 						if ([menuItem state] != NSOffState) [menuItem setState:NSOffState];
   280 					}
   281 				}
   282 			}
   283 		} else {
   284 			/* General menu items */
   285 			NSSet	*allActiveStatusStates = [adium.statusController allActiveStatusStates];
   286 			int		onState = (([allActiveStatusStates count] == 1) ? NSOnState : NSMixedState);
   287 			
   288 			if (menuItemStatusState) {
   289 				//If this menu item has a status state, set it to the right on state if that state is active
   290 				if ([allActiveStatusStates containsObject:menuItemStatusState] ||
   291 					([menuItemStatusState isKindOfClass:[AIStatusGroup class]] &&
   292 					 [(AIStatusGroup *)menuItemStatusState enclosesStatusStateInSet:allActiveStatusStates])) {
   293 					if ([menuItem state] != onState) [menuItem setState:onState];
   294 				} else {
   295 					if ([menuItem state] != NSOffState) [menuItem setState:NSOffState];
   296 				}
   297 			} else {
   298 				//If it doesn't, check the tag to see if it should be on or off by looking for a matching custom state
   299 				NSEnumerator	*activeStatusStatesEnumerator = [allActiveStatusStates objectEnumerator];
   300 				NSSet			*flatStatusSet = adium.statusController.flatStatusSet;
   301 				AIStatus		*statusState;
   302 				BOOL			foundCorrectStatusState = NO;
   303 				
   304 				while (!foundCorrectStatusState && (statusState = [activeStatusStatesEnumerator nextObject])) {
   305 					/* We found a custom match if our array of menu item states doesn't contain this state and
   306 					* its statusType matches the menuItem's tag.
   307 					*/
   308 					foundCorrectStatusState = (![flatStatusSet containsObject:statusState] &&
   309 											   ([menuItem tag] == statusState.statusType));
   310 				}
   311 				
   312 				if (foundCorrectStatusState) {
   313 					if ([menuItem state] != NSOnState) [menuItem setState:onState];
   314 				} else {
   315 					if ([menuItem state] != NSOffState) [menuItem setState:NSOffState];
   316 				}
   317 			}
   318 		}
   319 		
   320 		[stateMenuItemsAlreadyValidated addObject:menuItem];
   321 	}
   322 	
   323 	return YES;
   324 }
   325 
   326 /*!
   327  * @brief Select a state menu item
   328  *
   329  * Invoked by a state menu item, sets the state corresponding to the menu item as the active state.
   330  *
   331  * If the representedObject NSDictionary has an @"AIAccount" object, set the state just for the appropriate AIAccount.
   332  * Otherwise, set the state globally.
   333  */
   334 - (void)selectState:(id)sender
   335 {
   336 	NSDictionary	*dict = [sender representedObject];
   337 	AIStatusItem	*statusItem = [dict objectForKey:@"AIStatus"];
   338 	AIAccount		*account = [dict objectForKey:@"AIAccount"];
   339 	
   340 	if ([statusItem isKindOfClass:[AIStatusGroup class]]) {
   341 		statusItem = [(AIStatusGroup *)statusItem anyContainedStatus];
   342 	}
   343 	
   344 	/* Random undocumented feature of the moment... hold option and select a state to bring up the custom status window
   345 	 * for modifying and then setting it. Alternately, select an active status (one in the on state) to do the same.
   346 	 * Selecting a mixed state item should still select it to switch to full-on (all accounts).
   347 	 */	
   348 	NSEventType eventType = [[NSApp currentEvent] type];
   349 	BOOL		keyEvent = (eventType == NSKeyDown || eventType == NSKeyUp);
   350 	BOOL		isOptionClick = [NSEvent optionKey] && !keyEvent;
   351 	if (isOptionClick ||
   352 		(([sender state] == NSOnState) && (statusItem.statusType != AIOfflineStatusType))) {
   353 		[AIEditStateWindowController editCustomState:(AIStatus *)statusItem
   354 											 forType:statusItem.statusType
   355 										  andAccount:account
   356 									  withSaveOption:YES
   357 											onWindow:nil
   358 									 notifyingTarget:adium.statusController];
   359 		
   360 	} else {
   361 		if (account) {
   362 			BOOL shouldRebuild;
   363 			
   364 			shouldRebuild = [adium.statusController removeIfNecessaryTemporaryStatusState:account.statusState];
   365 			[account setStatusState:(AIStatus *)statusItem];
   366 			
   367 			//Enable the account if it isn't currently enabled
   368 			if (!account.enabled && statusItem.statusType != AIOfflineStatusType) {
   369 				[account setEnabled:YES];
   370 			}
   371 			
   372 			if (shouldRebuild) {
   373 				//Rebuild our menus if there was a change
   374 				[[NSNotificationCenter defaultCenter] postNotificationName:AIStatusStateArrayChangedNotification object:nil];
   375 			}
   376 			
   377 		} else {
   378 			[adium.statusController setActiveStatusState:(AIStatus *)statusItem];
   379 		}
   380 	}
   381 }
   382 
   383 /*!
   384  * @brief Select the custom state menu item
   385  *
   386  * Invoked by the custom state menu item, opens a custom state window.
   387  * If the representedObject NSDictionary has an @"AIAccount" object, configure just for the appropriate AIAccount.
   388  * Otherwise, configure globally.
   389  */
   390 - (IBAction)selectCustomState:(id)sender
   391 {
   392 	NSDictionary	*dict = [sender representedObject];
   393 	AIAccount		*account = [dict objectForKey:@"AIAccount"];
   394 	AIStatusType	statusType = [sender tag];
   395 	AIStatus		*baseStatusState;
   396 	
   397 	if (account) {
   398 		baseStatusState = account.statusState;
   399 	} else {
   400 		baseStatusState = adium.statusController.activeStatusState;
   401 	}
   402 	
   403 	/* If we are going to a custom state of a different type, we don't want to prefill with baseStatusState as it stands.
   404 	 * Instead, we load the last used status of that type.
   405 	 */
   406 	if ((baseStatusState.statusType != statusType)) {
   407 		NSDictionary *lastStatusStates = [adium.preferenceController preferenceForKey:@"LastStatusStates"
   408 																				  group:PREF_GROUP_STATUS_PREFERENCES];
   409 		NSData		*lastStatusStateData = [lastStatusStates objectForKey:[[NSNumber numberWithInt:statusType] stringValue]];
   410 		AIStatus	*lastStatusStateOfThisType = (lastStatusStateData ?
   411 												  [NSKeyedUnarchiver unarchiveObjectWithData:lastStatusStateData] :
   412 												  nil);
   413 		if (lastStatusStateOfThisType) {
   414 			// Restore the current status message into this last-saved variety, since users tend want to keep them.
   415 			// If it doesn't exist, use the last-saved status message.
   416 			if (baseStatusState.statusMessage.length) {
   417 				lastStatusStateOfThisType.statusMessage = baseStatusState.statusMessage;
   418 			}
   419 			
   420 			baseStatusState = [[lastStatusStateOfThisType retain] autorelease];
   421 		}
   422 	}
   423 
   424 	[AIEditStateWindowController editCustomState:baseStatusState
   425 										 forType:statusType
   426 									  andAccount:account
   427 								  withSaveOption:YES
   428 										onWindow:nil
   429 								 notifyingTarget:adium.statusController];
   430 }
   431 
   432 #pragma mark -
   433 #pragma mark Class methods
   434 + (NSMenu *)staticStatusStatesMenuNotifyingTarget:(id)target selector:(SEL)selector
   435 {
   436 	NSMenu			*statusStatesMenu = [[NSMenu allocWithZone:[NSMenu menuZone]] init];
   437 	NSEnumerator	*enumerator;
   438 	AIStatus		*statusState;
   439 	AIStatusType	currentStatusType = AIAvailableStatusType;
   440 	NSMenuItem		*menuItem;
   441 	
   442 	[statusStatesMenu setMenuChangedMessagesEnabled:NO];
   443 	[statusStatesMenu setAutoenablesItems:NO];
   444 	
   445 	if (!target && !selector) {
   446 		//Need to set a target and action for items with submenus (AIStatusGroups) to be selectable... so if we're not given one, set one.
   447 		target = self;
   448 		selector = @selector(dummyAction:);
   449 	}
   450 	
   451 	/* Create a menu item for each state.  States must first be sorted such that states of the same AIStatusType
   452 		* are grouped together.
   453 		*/
   454 	enumerator = [[adium.statusController sortedFullStateArray] objectEnumerator];
   455 	while ((statusState = [enumerator nextObject])) {
   456 		AIStatusType thisStatusType = statusState.statusType;
   457 
   458 		//We treat Invisible statuses as being the same as Away for purposes of the menu
   459 		if (thisStatusType == AIInvisibleStatusType) thisStatusType = AIAwayStatusType;
   460 
   461 		if (currentStatusType != thisStatusType) {
   462 			//Add a divider between each type of status
   463 			[statusStatesMenu addItem:[NSMenuItem separatorItem]];
   464 			currentStatusType = thisStatusType;
   465 		}
   466 	
   467 		menuItem = [[NSMenuItem alloc] initWithTitle:[AIStatusMenu titleForMenuDisplayOfState:statusState]
   468 											  target:target
   469 											  action:selector
   470 									   keyEquivalent:@""];
   471 	
   472 		[menuItem setImage:[statusState menuIcon]];
   473 		[menuItem setTag:statusState.statusType];
   474 		[menuItem setRepresentedObject:[NSDictionary dictionaryWithObject:statusState
   475 																   forKey:@"AIStatus"]];
   476 		if ([statusState isKindOfClass:[AIStatus class]]) {
   477 			[menuItem setToolTip:[statusState statusMessageTooltipString]];
   478 			
   479 		} else {
   480 			/* AIStatusGroup */
   481 			[menuItem setSubmenu:[(AIStatusGroup *)statusState statusSubmenuNotifyingTarget:target
   482 																					 action:selector]];
   483 		}
   484 		
   485 		[statusStatesMenu addItem:menuItem];
   486 		[menuItem release];
   487 	}
   488 	
   489 	[statusStatesMenu setMenuChangedMessagesEnabled:YES];
   490 	
   491 	return [statusStatesMenu autorelease];
   492 }
   493 
   494 /*!
   495 * @brief Determine a string to use as a menu title
   496  *
   497  * This method truncates a state title string for display as a menu item.
   498  * Wide menus aren't pretty and may cause crashing in certain versions of OS X, so all state
   499  * titles should be run through this method before being used as menu item titles.
   500  *
   501  * @param statusState The state for which we want a title
   502  *
   503  * @result An appropriate NSString title
   504  */
   505 + (NSString *)titleForMenuDisplayOfState:(AIStatusItem *)statusState
   506 {
   507 	NSString	*title = [statusState title];
   508 	
   509 	/* Why plus 3? Say STATE_TITLE_MENU_LENGTH was 7, and the title is @"ABCDEFGHIJ".
   510 	* The shortened title will be @"ABCDEFG..." which looks to be just as long - even
   511 	* if the ellipsis is an ellipsis character and therefore technically two characters
   512 	* shorter. Better to just use the full string, which appears as being the same length.
   513 	*/
   514 	if ([title length] > (STATE_TITLE_MENU_LENGTH + 3)) {
   515 		title = [title stringWithEllipsisByTruncatingToLength:STATE_TITLE_MENU_LENGTH];
   516 	}
   517 	
   518 	return title;
   519 }
   520 
   521 + (void)dummyAction:(id)sender {};
   522 
   523 @end