Add underscores to the list of permissible hash tag characters. Fixes #12673.
2 * Adium is the legal property of its developers, whose names are listed in the copyright file included
3 * with this source distribution.
5 * This program is free software; you can redistribute it and/or modify it under the terms of the GNU
6 * General Public License as published by the Free Software Foundation; either version 2 of the License,
7 * or (at your option) any later version.
9 * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
10 * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
11 * Public License for more details.
13 * You should have received a copy of the GNU General Public License along with this program; if not,
14 * write to the Free Software Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
17 #import "AITwitterAccount.h"
18 #import "AITwitterURLParser.h"
19 #import "AITwitterReplyWindowController.h"
20 #import "MGTwitterEngine/MGTwitterEngine.h"
21 #import <AIUtilities/AIAttributedStringAdditions.h>
22 #import <AIUtilities/AIStringAdditions.h>
23 #import <AIUtilities/AIMenuAdditions.h>
24 #import <AIUtilities/AIApplicationAdditions.h>
25 #import <AIUtilities/AIDateFormatterAdditions.h>
26 #import <Adium/AIChatControllerProtocol.h>
27 #import <Adium/AIContentControllerProtocol.h>
28 #import <Adium/AIContactControllerProtocol.h>
29 #import <Adium/AIStatusControllerProtocol.h>
30 #import <Adium/AIInterfaceControllerProtocol.h>
31 #import <Adium/AIAccountControllerProtocol.h>
32 #import <Adium/AIContactObserverManager.h>
33 #import <Adium/AIListContact.h>
34 #import <Adium/AIListGroup.h>
35 #import <Adium/AIContentMessage.h>
36 #import <Adium/AIListBookmark.h>
37 #import <Adium/AIChat.h>
38 #import <Adium/AIUserIcons.h>
39 #import <Adium/AIService.h>
40 #import <Adium/AIStatus.h>
41 #import <Adium/AIHTMLDecoder.h>
42 #import <Adium/AIContentEvent.h>
44 @interface AITwitterAccount()
45 - (void)updateUserIcon:(NSString *)url forContact:(AIListContact *)listContact;
47 - (void)updateTimelineChat:(AIChat *)timelineChat;
49 - (NSAttributedString *)parseMessage:(NSString *)inMessage
50 tweetID:(NSString *)tweetID
51 userID:(NSString *)userID
52 inReplyToUser:(NSString *)replyUserID
53 inReplyToTweetID:(NSString *)replyTweetID;
54 - (NSAttributedString *)parseDirectMessage:(NSString *)inMessage
55 withID:(NSString *)dmID
56 fromUser:(NSString *)sourceUID;
57 - (NSAttributedString *)attributedStringWithLinkLabel:(NSString *)label
58 linkDestination:(NSString *)destination
59 linkClass:(NSString *)attributeName;
61 - (void)setRequestType:(AITwitterRequestType)type forRequestID:(NSString *)requestID withDictionary:(NSDictionary *)info;
62 - (AITwitterRequestType)requestTypeForRequestID:(NSString *)requestID;
63 - (NSDictionary *)dictionaryForRequestID:(NSString *)requestID;
64 - (void)clearRequestTypeForRequestID:(NSString *)requestID;
66 - (void)periodicUpdate;
67 - (void)displayQueuedUpdatesForRequestType:(AITwitterRequestType)requestType;
69 - (void)getRateLimitAmount;
72 @implementation AITwitterAccount
77 pendingRequests = [[NSMutableDictionary alloc] init];
78 queuedUpdates = [[NSMutableArray alloc] init];
79 queuedDM = [[NSMutableArray alloc] init];
80 queuedOutgoingDM = [[NSMutableArray alloc] init];
82 [[NSNotificationCenter defaultCenter] addObserver:self
83 selector:@selector(chatDidOpen:)
87 [adium.preferenceController registerDefaults:[NSDictionary dictionaryWithObjectsAndKeys:
88 [NSNumber numberWithInt:TWITTER_UPDATE_INTERVAL_MINUTES], TWITTER_PREFERENCE_UPDATE_INTERVAL,
89 [NSNumber numberWithBool:YES], TWITTER_PREFERENCE_UPDATE_AFTER_SEND,
90 [NSNumber numberWithBool:YES], TWITTER_PREFERENCE_LOAD_CONTACTS, nil]
91 forGroup:TWITTER_PREFERENCE_GROUP_UPDATES
94 // If we don't have a server set, set our default (if we have one)
95 if (!self.host && self.defaultServer) {
96 [self setPreference:self.defaultServer forKey:KEY_CONNECT_HOST group:GROUP_ACCOUNT_STATUS];
99 [adium.preferenceController registerPreferenceObserver:self forGroup:TWITTER_PREFERENCE_GROUP_UPDATES];
100 [adium.preferenceController informObserversOfChangedKey:nil inGroup:TWITTER_PREFERENCE_GROUP_UPDATES object:self];
105 [[NSNotificationCenter defaultCenter] removeObserver:self];
106 [adium.preferenceController unregisterPreferenceObserver:self];
108 [twitterEngine release];
109 [pendingRequests release];
110 [queuedUpdates release];
112 [queuedOutgoingDM release];
118 * @brief Our default server if none is provided.
120 - (NSString *)defaultServer
122 return @"twitter.com";
125 #pragma mark AIAccount methods
127 * @brief We've been asked to connect.
129 * Sets our username and password for MGTwitterEngine, and validates credentials.
131 * Our connection procedure:
132 * 1. Validate credentials
133 * 2. Retrieve friends
134 * 3. Trigger "periodic" update - DM, replies, timeline
140 [twitterEngine release];
142 twitterEngine = [[MGTwitterEngine alloc] initWithDelegate:self];
144 [twitterEngine setClientName:@"Adium"
145 version:[NSApp applicationVersion]
146 URL:@"http://www.adiumx.com"
147 token:self.sourceToken];
149 [twitterEngine setAPIDomain:[self.host stringByAppendingPathComponent:self.apiPath]];
151 [twitterEngine setUsesSecureConnection:self.useSSL];
154 if (!self.passwordWhileConnected.length) {
155 [self setLastDisconnectionError:TWITTER_OAUTH_NOT_AUTHORIZED];
157 [[NSNotificationCenter defaultCenter] postNotificationName:@"AIEditAccount"
160 [self didDisconnect];
162 // Don't try and connect.
166 twitterEngine.useOAuth = YES;
168 OAToken *token = [[[OAToken alloc] initWithHTTPResponseBody:self.passwordWhileConnected] autorelease];
169 OAConsumer *consumer = [[[OAConsumer alloc] initWithKey:self.consumerKey secret:self.secretKey] autorelease];
171 twitterEngine.accessToken = token;
172 twitterEngine.consumer = consumer;
175 [twitterEngine setUsername:self.UID password:self.passwordWhileConnected];
178 AILogWithSignature(@"%@ connecting to %@", self, twitterEngine.APIDomain);
180 NSString *requestID = [twitterEngine checkUserCredentials];
183 [self setRequestType:AITwitterValidateCredentials forRequestID:requestID withDictionary:nil];
185 [self setLastDisconnectionError:AILocalizedString(@"Unable to connect to server", nil)];
186 [self didDisconnect];
191 * @brief Connection successful
193 * Our credentials were validated correctly. Set up the timeline chat, and request our friends from the server.
199 //Clear any previous disconnection error
200 [self setLastDisconnectionError:nil];
202 // Creating the fake timeline account.
203 AIListBookmark *timelineBookmark = nil;
205 if(!(timelineBookmark = [adium.contactController existingBookmarkForChatName:self.timelineChatName
207 chatCreationInfo:nil])) {
208 AIChat *newTimelineChat = [adium.chatController chatWithName:self.timelineChatName
211 chatCreationInfo:nil];
213 [newTimelineChat setDisplayName:self.timelineChatName];
215 timelineBookmark = [adium.contactController bookmarkForChat:newTimelineChat inGroup:[adium.contactController groupWithUID:TWITTER_REMOTE_GROUP_NAME]];
219 NSTimeInterval updateInterval = [[self preferenceForKey:TWITTER_PREFERENCE_UPDATE_INTERVAL group:TWITTER_PREFERENCE_GROUP_UPDATES] integerValue] * 60;
221 if(updateInterval > 0) {
222 [updateTimer invalidate];
223 updateTimer = [NSTimer scheduledTimerWithTimeInterval:updateInterval
225 selector:@selector(periodicUpdate)
229 [self periodicUpdate];
234 * @brief We've been asked to disconnect.
242 [twitterEngine release]; twitterEngine = nil;
243 [updateTimer invalidate]; updateTimer = nil;
245 [self didDisconnect];
249 * @brief Account will be deleted
251 - (void)willBeDeleted
253 [updateTimer invalidate]; updateTimer = nil;
255 [super willBeDeleted];
259 * @brief Session ended
261 * Remove all state information.
263 - (void)didDisconnect
265 [updateTimer invalidate]; updateTimer = nil;
266 [pendingRequests removeAllObjects];
267 [queuedDM removeAllObjects];
268 [queuedOutgoingDM removeAllObjects];
269 [queuedUpdates removeAllObjects];
271 [super didDisconnect];
277 * The API path extension for the given host.
279 - (NSString *)apiPath
285 * @brief Our source token
287 * On Twitter, our given source token is "adiumofficial".
289 - (NSString *)sourceToken
291 return @"adiumofficial";
295 * @brief Returns whether or not to connect to Twitter API over HTTPS.
303 * @brief Returns whether or not this account is connected via an encrypted connection.
307 return (self.online && [twitterEngine usesSecureConnection]);
311 * @brief Affirm we can open chats.
313 - (BOOL)openChat:(AIChat *)chat
315 [chat setValue:[NSNumber numberWithBool:YES] forProperty:@"Account Joined" notify:NotifyNow];
321 * @brief Allow all chats to close.
323 - (BOOL)closeChat:(AIChat *)inChat
329 * @brief Rejoin the requested chat.
331 - (BOOL)rejoinChat:(AIChat *)inChat
333 [self displayYouHaveConnectedInChat:inChat];
339 * @brief We always want to autocomplete the UID.
341 - (BOOL)chatShouldAutocompleteUID:(AIChat *)inChat
347 * @brief A chat opened.
349 * If this is a group chat which belongs to us, aka a timeline chat, set it up how we want it.
351 - (void)chatDidOpen:(NSNotification *)notification
353 AIChat *chat = [notification object];
355 if(chat.isGroupChat && chat.account == self) {
356 [self updateTimelineChat:chat];
361 * @brief We support adding and removing follows.
363 - (BOOL)contactListEditable
369 * @brief Move contacts
371 * Move existing contacts to a specific group on this account. The passed contacts should already exist somewhere on
373 * @param objects NSArray of AIListContact objects to remove
374 * @param group AIListGroup destination for contacts
376 - (void)moveListObjects:(NSArray *)objects oldGroups:(NSSet *)oldGroups toGroups:(NSSet *)groups
378 // XXX do twitter grouping
382 * @brief Rename a group
384 * Rename a group on this account.
385 * @param group AIListGroup to rename
386 * @param newName NSString name for the group
388 - (void)renameGroup:(AIListGroup *)group to:(NSString *)newName
390 // XXX do twitter grouping
394 * @brief For an invalid password, fail but don't try and reconnect or report it. We do it ourself.
396 - (AIReconnectDelayType)shouldAttemptReconnectAfterDisconnectionError:(NSString **)disconnectionError
398 AIReconnectDelayType reconnectDelayType = [super shouldAttemptReconnectAfterDisconnectionError:disconnectionError];
400 if ([*disconnectionError isEqualToString:TWITTER_INCORRECT_PASSWORD_MESSAGE]) {
401 reconnectDelayType = AIReconnectImmediately;
402 } else if ([*disconnectionError isEqualToString:TWITTER_OAUTH_NOT_AUTHORIZED]) {
403 reconnectDelayType = AIReconnectNeverNoMessage;
406 return reconnectDelayType;
410 * @brief Don't allow OTR encryption.
412 - (BOOL)allowSecureMessagingTogglingForChat:(AIChat *)inChat
418 * @brief Update our status
420 - (void)setSocialNetworkingStatusMessage:(NSAttributedString *)statusMessage
422 NSString *requestID = [twitterEngine sendUpdate:[statusMessage string]];
425 [self setRequestType:AITwitterSendUpdate
426 forRequestID:requestID
431 - (NSString *)encodedAttributedString:(NSAttributedString *)inAttributedString forListObject:(AIListObject *)inListObject
433 return [[inAttributedString attributedStringByConvertingLinksToURLStrings] string];
437 * @brief Send a message
439 * Sends a direct message to the user requested.
440 * If it fails to send, i.e. the request fails, an unknown error will occur.
441 * This is usually caused by the target not following the user.
443 - (BOOL)sendMessageObject:(AIContentMessage *)inContentMessage
447 if(inContentMessage.chat.isGroupChat) {
448 requestID = [twitterEngine sendUpdate:inContentMessage.encodedMessage
449 inReplyTo:[inContentMessage.chat valueForProperty:@"TweetInReplyToStatusID"]];
452 [self setRequestType:AITwitterSendUpdate
453 forRequestID:requestID
454 withDictionary:[NSDictionary dictionaryWithObject:inContentMessage.chat
457 inContentMessage.displayContent = NO;
459 AILogWithSignature(@"%@ Sending update [in reply to %@]: %@", self, [inContentMessage.chat valueForProperty:@"TweetInReplyToStatusID"], inContentMessage.encodedMessage);
463 requestID = [twitterEngine sendDirectMessage:inContentMessage.encodedMessage
464 to:inContentMessage.destination.UID];
467 [self setRequestType:AITwitterDirectMessageSend
468 forRequestID:requestID
469 withDictionary:[NSDictionary dictionaryWithObject:inContentMessage.chat
472 inContentMessage.displayContent = NO;
474 AILogWithSignature(@"%@ Sending DM to %@: %@", self, inContentMessage.destination.UID, inContentMessage.encodedMessage);
479 AILogWithSignature(@"%@ Message immediate fail.", self);
482 return (requestID != nil);
486 * @brief Trigger an info update
488 * This is called when the info inspector wants more information on a contact.
489 * Grab the user's profile information, set everything up accordingly in the user info method.
491 - (void)delayedUpdateContactStatus:(AIListContact *)inContact
497 NSString *requestID = [twitterEngine getUserInformationFor:inContact.UID];
500 [self setRequestType:AITwitterProfileUserInfo
501 forRequestID:requestID
502 withDictionary:[NSDictionary dictionaryWithObject:inContact forKey:@"ListContact"]];
507 * @brief Should an autoreply be sent to this message?
509 - (BOOL)shouldSendAutoreplyToMessage:(AIContentMessage *)message
515 * @brief Update the Twitter profile
517 - (void)setProfileName:(NSString *)name
519 location:(NSString *)location
520 description:(NSString *)description
522 NSString *requestID = [twitterEngine updateProfileName:name
526 description:description];
529 [self setRequestType:AITwitterProfileSelf
530 forRequestID:requestID
537 * @brief Should we store our password based on internal object ID?
539 * We only need to if we're using OAuth.
541 - (BOOL)useInternalObjectIDForPasswordName
543 return self.useOAuth;
547 * @brief Should we connect using OAuth?
549 * If enabled, the account view will display the OAuth setup. Basic authentication will not be used.
557 * @brief OAuth consumer key
559 - (NSString *)consumerKey
561 return @"amjYVOrzKpKkkHAsdEaClA";
565 * @brief OAuth secret key
567 - (NSString *)secretKey
569 return @"kvqM2CQsUO3J6NHctJVhTOzlKZ0k7FsTaR5NwakYU";
573 * @brief Token request URL
575 - (NSString *)tokenRequestURL
577 return @"https://twitter.com/oauth/request_token";
581 * @brief Token access URL
583 - (NSString *)tokenAccessURL
585 return @"https://twitter.com/oauth/access_token";
589 * @brief Token authorize URL
591 - (NSString *)tokenAuthorizeURL
593 return @"https://twitter.com/oauth/authorize";
596 #pragma mark Menu Items
598 * @brief Menu items for contact
600 * Returns an array of menu items for a contact on this account. This is the best place to add protocol-specific
601 * actions that aren't otherwise supported by Adium.
602 * @param inContact AIListContact for menu items
603 * @return NSArray of NSMenuItem instances for the passed contact
605 - (NSArray *)menuItemsForContact:(AIListContact *)inContact
607 NSMutableArray *menuItemArray = [NSMutableArray array];
609 NSMenuItem *menuItem;
611 NSImage *serviceIcon = [AIServiceIcons serviceIconForService:self.service
612 type:AIServiceIconSmall
613 direction:AIIconNormal];
615 menuItem = [[[NSMenuItem allocWithZone:[NSMenu menuZone]] initWithTitle:[NSString stringWithFormat:AILocalizedString(@"Open %@'s user page",nil), inContact.UID]
617 action:@selector(openUserPage:)
618 keyEquivalent:@""] autorelease];
619 [menuItem setImage:serviceIcon];
620 [menuItem setRepresentedObject:inContact];
621 [menuItemArray addObject:menuItem];
623 menuItem = [[[NSMenuItem allocWithZone:[NSMenu menuZone]] initWithTitle:[NSString stringWithFormat:AILocalizedString(@"Enable device notifications for %@", "Enable sending Twitter notifications to your phone (device)"), inContact.UID]
625 action:@selector(enableOrDisableNotifications:)
626 keyEquivalent:@""] autorelease];
627 [menuItem setTag:YES];
628 [menuItem setImage:serviceIcon];
629 [menuItem setRepresentedObject:inContact];
630 [menuItemArray addObject:menuItem];
632 menuItem = [[[NSMenuItem allocWithZone:[NSMenu menuZone]] initWithTitle:[NSString stringWithFormat:AILocalizedString(@"Disable device notifications for %@", "Disable sending Twitter notifications to your phone"), inContact.UID]
634 action:@selector(enableOrDisableNotifications:)
635 keyEquivalent:@""] autorelease];
636 [menuItem setTag:NO];
637 [menuItem setImage:serviceIcon];
638 [menuItem setRepresentedObject:inContact];
639 [menuItemArray addObject:menuItem];
641 return menuItemArray;
645 * @brief Open the represented objec'ts user page
647 - (void)openUserPage:(NSMenuItem *)menuItem
649 [[NSWorkspace sharedWorkspace] openURL:[NSURL URLWithString:[self addressForLinkType:AITwitterLinkUserPage
650 userID:((AIListContact *)menuItem.representedObject).UID
656 * @brief Enable or disable notifications for a contact.
658 * If the menuItem's tag is YES, we're adding. Otherwise we're removing.
660 - (void)enableOrDisableNotifications:(NSMenuItem *)menuItem
662 if(![menuItem.representedObject isKindOfClass:[AIListContact class]]) {
666 BOOL enableNotification = menuItem.tag;
667 AIListContact *contact = menuItem.representedObject;
669 NSString *requestID = nil;
671 BOOL initialFailure = NO;
673 if (enableNotification) {
674 requestID = [twitterEngine enableNotificationsFor:contact.UID];
677 [self setRequestType:AITwitterNotificationEnable
678 forRequestID:requestID
679 withDictionary:[NSDictionary dictionaryWithObjectsAndKeys:contact, @"ListContact", nil]];
681 initialFailure = YES;
685 requestID = [twitterEngine disableNotificationsFor:contact.UID];
688 [self setRequestType:AITwitterNotificationDisable
689 forRequestID:requestID
690 withDictionary:[NSDictionary dictionaryWithObjectsAndKeys:contact, @"ListContact", nil]];
692 initialFailure = YES;
696 if (initialFailure) {
697 [adium.interfaceController handleErrorMessage:(enableNotification ?
698 AILocalizedString(@"Unable to Enable Notifications", nil) :
699 AILocalizedString(@"Unable to Disable Notifications", nil))
700 withDescription:AILocalizedString(@"Unable to connect to the Twitter server.", nil)];
705 * @brief Menu items for chat
707 * Returns an array of menu items for a chat on this account. This is the best place to add protocol-specific
708 * actions that aren't otherwise supported by Adium.
709 * @param inChat AIChat for menu items
710 * @return NSArray of NSMenuItem instances for the passed contact
712 - (NSArray *)menuItemsForChat:(AIChat *)inChat
714 NSMutableArray *menuItemArray = [NSMutableArray array];
716 NSMenuItem *menuItem;
718 NSImage *serviceIcon = [AIServiceIcons serviceIconForService:self.service
719 type:AIServiceIconSmall
720 direction:AIIconNormal];
722 menuItem = [[[NSMenuItem allocWithZone:[NSMenu menuZone]] initWithTitle:AILocalizedString(@"Update Tweets",nil)
724 action:@selector(periodicUpdate)
725 keyEquivalent:@""] autorelease];
726 [menuItem setImage:serviceIcon];
727 [menuItemArray addObject:menuItem];
729 menuItem = [[[NSMenuItem allocWithZone:[NSMenu menuZone]] initWithTitle:AILocalizedString(@"Reply to a Tweet",nil)
731 action:@selector(replyToTweet)
732 keyEquivalent:@""] autorelease];
733 [menuItem setImage:serviceIcon];
734 [menuItemArray addObject:menuItem];
736 return menuItemArray;
740 * @brief Menu items for the account's actions
742 * Returns an array of menu items for account-specific actions. This is the best place to add protocol-specific
743 * actions that aren't otherwise supported by Adium. It will only be queried if the account is online.
744 * @return NSArray of NSMenuItem instances for this account
746 - (NSArray *)accountActionMenuItems
748 NSMutableArray *menuItemArray = [NSMutableArray array];
750 NSMenuItem *menuItem;
752 menuItem = [[[NSMenuItem allocWithZone:[NSMenu menuZone]] initWithTitle:AILocalizedString(@"Update Tweets",nil)
754 action:@selector(periodicUpdate)
755 keyEquivalent:@""] autorelease];
756 [menuItemArray addObject:menuItem];
758 menuItem = [[[NSMenuItem allocWithZone:[NSMenu menuZone]] initWithTitle:AILocalizedString(@"Reply to a Tweet",nil)
760 action:@selector(replyToTweet)
761 keyEquivalent:@""] autorelease];
762 [menuItemArray addObject:menuItem];
764 menuItem = [[[NSMenuItem allocWithZone:[NSMenu menuZone]] initWithTitle:AILocalizedString(@"Get Rate Limit Amount",nil)
766 action:@selector(getRateLimitAmount)
767 keyEquivalent:@""] autorelease];
768 [menuItemArray addObject:menuItem];
770 return menuItemArray;
774 * @brief Open the reply to tweet window
776 * Opens a window in which the user can create a reply featuring in_reply_to_status_id being set.
780 [AITwitterReplyWindowController showReplyWindowForAccount:self];
784 * @brief Gets the current rate limit amount.
786 - (void)getRateLimitAmount
788 NSString *requestID = [twitterEngine getRateLimitStatus];
791 [self setRequestType:AITwitterRateLimitStatus
792 forRequestID:requestID
798 #pragma mark Contact handling
800 * @brief The name of our timeline chat
802 - (NSString *)timelineChatName
804 return [NSString stringWithFormat:TWITTER_TIMELINE_NAME, self.UID];
808 * @brief Our timeline chat
810 * If the timeline chat is not already active, it is created.
812 - (AIChat *)timelineChat
814 AIChat *timelineChat = [adium.chatController existingChatWithName:self.timelineChatName
818 timelineChat = [adium.chatController chatWithName:self.timelineChatName
821 chatCreationInfo:nil];
828 * @brief Update the timeline chat
830 * Remove the userlist
832 - (void)updateTimelineChat:(AIChat *)timelineChat
834 // Disable the user list on the chat.
835 if (timelineChat.chatContainer.chatViewController.userListVisible) {
836 [timelineChat.chatContainer.chatViewController toggleUserList];
839 // Update the participant list.
840 [timelineChat addParticipatingListObjects:self.contacts notify:NotifyNow];
842 [timelineChat setValue:[NSNumber numberWithInt:140] forProperty:@"Character Counter Max" notify:NotifyNow];
846 * @brief Update serverside icon
848 * This is called by AIUserIcons when it needs an icon update for a contact.
849 * If we already have an icon set (even a cached icon), ignore it.
850 * Otherwise return the Twitter service icon.
852 * This is so that when an unknown contact appears, it has an actual image
853 * to replace in the WKMV when an actual icon update is returned.
855 * This service icon will not remain saved very long, I see no harm in using it.
856 * This only occurs for "strangers".
858 - (NSData *)serversideIconDataForContact:(AIListContact *)listContact
860 if (![AIUserIcons userIconSourceForObject:listContact] &&
861 ![AIUserIcons cachedUserIconDataForObject:listContact]) {
862 return [[self.service defaultServiceIconOfType:AIServiceIconLarge] TIFFRepresentation];
869 * @brief Update a user icon from a URL if necessary
871 - (void)updateUserIcon:(NSString *)url forContact:(AIListContact *)listContact;
873 // If we don't already have an icon for the user...
874 if(![[listContact valueForProperty:TWITTER_PROPERTY_REQUESTED_USER_ICON] boolValue]) {
875 NSString *fileName = [[url lastPathComponent] stringByReplacingOccurrencesOfString:@"_normal." withString:@"_bigger."];
877 url = [[url stringByDeletingLastPathComponent] stringByAppendingPathComponent:fileName];
879 // Grab the user icon and set it as their serverside icon.
880 NSString *requestID = [twitterEngine getImageAtURL:url];
883 [self setRequestType:AITwitterUserIconPull
884 forRequestID:requestID
885 withDictionary:[NSDictionary dictionaryWithObject:listContact forKey:@"ListContact"]];
888 [listContact setValue:[NSNumber numberWithBool:YES] forProperty:TWITTER_PROPERTY_REQUESTED_USER_ICON notify:NotifyNever];
893 * @brief Unfollow the requested contacts.
895 - (void)removeContacts:(NSArray *)objects fromGroups:(NSArray *)groups
897 for (AIListContact *object in objects) {
898 NSString *requestID = [twitterEngine disableUpdatesFor:object.UID];
900 AILogWithSignature(@"%@ Requesting unfollow for: %@", self, object.UID);
903 [self setRequestType:AITwitterRemoveFollow
904 forRequestID:requestID
905 withDictionary:[NSDictionary dictionaryWithObject:object forKey:@"ListContact"]];
911 * @brief Follow the requested contact, trigger an information pull for them.
913 - (void)addContact:(AIListContact *)contact toGroup:(AIListGroup *)group
915 if ([contact.UID isCaseInsensitivelyEqualToString:self.UID]) {
916 AILogWithSignature(@"Not adding contact %@ to group %@, it's me!", contact.UID, group.UID);
920 NSString *requestID = [twitterEngine enableUpdatesFor:contact.UID];
922 AILogWithSignature(@"%@ Requesting follow for: %@", self, contact.UID);
925 NSString *updateRequestID = [twitterEngine getUserInformationFor:contact.UID];
927 if (updateRequestID) {
928 [self setRequestType:AITwitterAddFollow
929 forRequestID:updateRequestID
930 withDictionary:[NSDictionary dictionaryWithObjectsAndKeys:contact.UID, @"UID", nil]];
935 #pragma mark Request cataloguing
937 * @brief Set the type and optional dictionary for a request ID
939 * Sets the AITwitterRequestType for a particular request ID, so when the request finishes we can identify what it is for.
940 * Optionally sets a dictionary which can be retrieved in association with the request type.
942 - (void)setRequestType:(AITwitterRequestType)type forRequestID:(NSString *)requestID withDictionary:(NSDictionary *)info
944 [pendingRequests setObject:[NSDictionary dictionaryWithObjectsAndKeys:[NSNumber numberWithInt:type], @"Type",
950 * @brief Get the request type for a request ID
952 - (AITwitterRequestType)requestTypeForRequestID:(NSString *)requestID
954 return [(NSNumber *)[[pendingRequests objectForKey:requestID] objectForKey:@"Type"] intValue];
958 * @brief Get the dictionary associated with a request ID
960 - (NSDictionary *)dictionaryForRequestID:(NSString *)requestID
962 return (NSDictionary *)[[pendingRequests objectForKey:requestID] objectForKey:@"Info"];
966 * @brief Remove a request ID's saved information.
968 - (void)clearRequestTypeForRequestID:(NSString *)requestID
970 [pendingRequests removeObjectForKey:requestID];
973 #pragma mark Preference updating
974 - (void)preferencesChangedForGroup:(NSString *)group key:(NSString *)key object:(AIListObject *)object
975 preferenceDict:(NSDictionary *)prefDict firstTime:(BOOL)firstTime
977 [super preferencesChangedForGroup:group key:key object:object preferenceDict:prefDict firstTime:firstTime];
979 // We only care about our changes.
980 if (object != self) {
984 if([group isEqualToString:GROUP_ACCOUNT_STATUS]) {
985 if([key isEqualToString:KEY_USER_ICON]) {
986 // Avoid pushing an icon update which we just downloaded.
987 if(![self boolValueForProperty:TWITTER_PROPERTY_REQUESTED_USER_ICON]) {
988 NSString *requestID = [twitterEngine updateProfileImage:[prefDict objectForKey:KEY_USER_ICON]];
991 AILogWithSignature(@"%@ Pushing self icon update", self);
993 [self setRequestType:AITwitterProfileSelf
994 forRequestID:requestID
999 [self setValue:nil forProperty:TWITTER_PROPERTY_REQUESTED_USER_ICON notify:NotifyNever];
1003 if([group isEqualToString:TWITTER_PREFERENCE_GROUP_UPDATES]) {
1004 if(!firstTime && [key isEqualToString:TWITTER_PREFERENCE_UPDATE_INTERVAL]) {
1005 NSTimeInterval timeInterval = [updateTimer timeInterval];
1006 NSTimeInterval newTimeInterval = [[prefDict objectForKey:TWITTER_PREFERENCE_UPDATE_INTERVAL] intValue] * 60;
1008 if (timeInterval != newTimeInterval && self.online) {
1009 [updateTimer invalidate]; updateTimer = nil;
1011 if(newTimeInterval > 0) {
1012 updateTimer = [NSTimer scheduledTimerWithTimeInterval:newTimeInterval
1014 selector:@selector(periodicUpdate)
1021 updateAfterSend = [[prefDict objectForKey:TWITTER_PREFERENCE_UPDATE_AFTER_SEND] boolValue];
1022 retweetLink = [[prefDict objectForKey:TWITTER_PREFERENCE_RETWEET_SPAM] boolValue];
1024 if ([key isEqualToString:TWITTER_PREFERENCE_LOAD_CONTACTS] && self.online) {
1025 if ([[prefDict objectForKey:TWITTER_PREFERENCE_LOAD_CONTACTS] boolValue]) {
1026 // Delay updates when loading our contacts list.
1027 [self silenceAllContactUpdatesForInterval:18.0];
1028 // Grab our user list.
1029 NSString *requestID = [twitterEngine getRecentlyUpdatedFriendsFor:self.UID startingAtPage:1];
1032 [self setRequestType:AITwitterInitialUserInfo
1033 forRequestID:requestID
1034 withDictionary:[NSDictionary dictionaryWithObject:[NSNumber numberWithInt:1] forKey:@"Page"]];
1037 [self removeAllContacts];
1043 #pragma mark Periodic update scheduler
1045 * @brief Trigger our periodic updates
1047 - (void)periodicUpdate
1049 if (pendingUpdateCount) {
1050 AILogWithSignature(@"%@ Update already in progress. Count = %d", self, pendingUpdateCount);
1054 NSString *requestID;
1057 // We haven't completed the timeline nor replies. This lets us know if we should display statuses.
1058 followedTimelineCompleted = repliesCompleted = NO;
1059 futureTimelineLastID = futureRepliesLastID = nil;
1061 // Prevent triggering this update routine multiple times.
1062 pendingUpdateCount = 3;
1064 // We haven't printed error messages for this set.
1065 timelineErrorMessagePrinted = NO;
1067 [queuedUpdates removeAllObjects];
1068 [queuedDM removeAllObjects];
1070 AILogWithSignature(@"%@ Periodic update fire", self);
1072 // Pull direct messages
1073 lastID = [self preferenceForKey:TWITTER_PREFERENCE_DM_LAST_ID
1074 group:TWITTER_PREFERENCE_GROUP_UPDATES];
1076 requestID = [twitterEngine getDirectMessagesSinceID:lastID startingAtPage:1];
1079 [self setRequestType:AITwitterUpdateDirectMessage
1080 forRequestID:requestID
1081 withDictionary:[NSDictionary dictionaryWithObjectsAndKeys:[NSNumber numberWithInt:1], @"Page", nil]];
1083 --pendingUpdateCount;
1086 // Pull followed timeline
1087 lastID = [self preferenceForKey:TWITTER_PREFERENCE_TIMELINE_LAST_ID
1088 group:TWITTER_PREFERENCE_GROUP_UPDATES];
1090 requestID = [twitterEngine getFollowedTimelineFor:nil
1093 count:(lastID ? TWITTER_UPDATE_TIMELINE_COUNT : TWITTER_UPDATE_TIMELINE_COUNT_FIRST_RUN)];
1096 [self setRequestType:AITwitterUpdateFollowedTimeline
1097 forRequestID:requestID
1098 withDictionary:[NSDictionary dictionaryWithObjectsAndKeys:[NSNumber numberWithInt:1], @"Page", nil]];
1100 --pendingUpdateCount;
1103 // Pull the replies feed
1104 lastID = [self preferenceForKey:TWITTER_PREFERENCE_REPLIES_LAST_ID
1105 group:TWITTER_PREFERENCE_GROUP_UPDATES];
1107 requestID = [twitterEngine getRepliesSinceID:lastID startingAtPage:1];
1110 [self setRequestType:AITwitterUpdateReplies
1111 forRequestID:requestID
1112 withDictionary:[NSDictionary dictionaryWithObjectsAndKeys:[NSNumber numberWithInt:1], @"Page", nil]];
1114 --pendingUpdateCount;
1118 #pragma mark Message Display
1120 * @brief Returns a user-readable message for an error code.
1122 - (NSString *)errorMessageForError:(NSError *)error
1124 switch (error.code) {
1126 // Bad Request: your request is invalid, and we'll return an error message that tells you why.
1127 // This is the status code returned if you've exceeded the rate limit.
1128 return AILocalizedString(@"You've exceeded the rate limit.", nil);
1132 // Not Authorized: either you need to provide authentication credentials, or the credentials provided aren't valid.
1133 return AILocalizedString(@"Your credentials do not allow you access.", nil);
1137 // Forbidden: we understand your request, but are refusing to fulfill it. An accompanying error message should explain why.
1138 return AILocalizedString(@"Request refused by the server.", nil);
1142 // Not Found: either you're requesting an invalid URI or the resource in question doesn't exist (ex: no such user).
1143 return AILocalizedString(@"Requested resource not found.", nil);
1147 // Internal Server Error: we did something wrong. Please post to the group about it and the Twitter team will investigate.
1148 return AILocalizedString(@"The server reported an internal error.", nil);
1152 // Bad Gateway: returned if Twitter is down or being upgraded.
1153 return AILocalizedString(@"The server is currently down.", nil);
1159 // Service Unavailable: the Twitter servers are up, but are overloaded with requests. Try again later.
1160 return AILocalizedString(@"The server is overloaded with requests.", nil);
1165 return [NSString stringWithFormat:AILocalizedString(@"Unknown error: code %d, %@", nil), error.code, error.localizedDescription];
1169 * @brief Returns the link URL for a specific type of link
1171 - (NSString *)addressForLinkType:(AITwitterLinkType)linkType
1172 userID:(NSString *)userID
1173 statusID:(NSString *)statusID
1174 context:(NSString *)context
1176 NSString *address = nil;
1178 if (linkType == AITwitterLinkStatus) {
1179 address = [NSString stringWithFormat:@"https://twitter.com/%@/status/%@", userID, statusID];
1180 } else if (linkType == AITwitterLinkFriends) {
1181 address = [NSString stringWithFormat:@"https://twitter.com/%@/friends", userID];
1182 } else if (linkType == AITwitterLinkFollowers) {
1183 address = [NSString stringWithFormat:@"https://twitter.com/%@/followers", userID];
1184 } else if (linkType == AITwitterLinkUserPage) {
1185 address = [NSString stringWithFormat:@"https://twitter.com/%@", userID];
1186 } else if (linkType == AITwitterLinkSearchHash) {
1187 address = [NSString stringWithFormat:@"http://search.twitter.com/search?q=%%23%@", context];
1188 } else if (linkType == AITwitterLinkReply) {
1189 address = [NSString stringWithFormat:@"twitterreply://%@@%@?action=reply&status=%@", self.internalObjectID, userID, statusID];
1190 } else if (linkType == AITwitterLinkRetweet) {
1191 address = [NSString stringWithFormat:@"twitterreply://%@@%@?action=retweet&status=%@&message=%@", self.internalObjectID, userID, statusID, context];
1192 } else if (linkType == AITwitterLinkFavorite) {
1193 address = [NSString stringWithFormat:@"twitterreply://%@@%@?action=favorite&status=%@", self.internalObjectID, userID, statusID];
1194 } else if (linkType == AITwitterLinkDestroyStatus) {
1195 address = [NSString stringWithFormat:@"twitterreply://%@@%@?action=destroy&status=%@&message=%@", self.internalObjectID, userID, statusID, context];
1196 } else if (linkType == AITwitterLinkDestroyDM) {
1197 address = [NSString stringWithFormat:@"twitterreply://%@@%@?action=destroy&dm=%@&message=%@", self.internalObjectID, userID, statusID, context];
1204 * @brief Retweet the selected tweet.
1206 * Attempts to retweet a tweet.
1207 * Prints a status message in the chat on success/failure, behaves identical to sending a new tweet.
1209 * @returns YES if the account could send a retweet message, NO if the account doesn't support it.
1211 - (BOOL)retweetTweet:(NSString *)tweetID
1213 NSString *requestID = [twitterEngine retweetUpdate:tweetID];
1216 [self setRequestType:AITwitterSendUpdate
1217 forRequestID:requestID
1218 withDictionary:[NSDictionary dictionaryWithObjectsAndKeys:tweetID, @"tweetID", nil]];
1220 [self.timelineChat receivedError:[NSNumber numberWithInt:AIChatMessageSendingConnectionError]];
1227 * @brief Toggle the favorite status for a tweet.
1229 * Attempts to favorite a tweet. If that fails, it removes favorite status.
1230 * Prints a status message in the chat on success/failure, since it's otherwise not obvious.
1232 - (void)toggleFavoriteTweet:(NSString *)tweetID
1234 NSString *requestID = [twitterEngine markUpdate:tweetID asFavorite:YES];
1237 [self setRequestType:AITwitterFavoriteYes
1238 forRequestID:requestID
1239 withDictionary:[NSDictionary dictionaryWithObjectsAndKeys:tweetID, @"tweetID", nil]];
1241 AIChat *timelineChat = self.timelineChat;
1243 [adium.contentController displayEvent:AILocalizedString(@"Attempt to favorite tweet failed to connect.", nil)
1245 inChat:timelineChat];
1250 * @brief Destroy the tweet.
1252 * The user has already confirmed they want to destroy it; send the message.
1254 - (void)destroyTweet:(NSString *)tweetID
1256 NSString *requestID = [twitterEngine deleteUpdate:tweetID];
1259 [self setRequestType:AITwitterDestroyStatus
1260 forRequestID:requestID
1261 withDictionary:nil];
1263 AIChat *timelineChat = self.timelineChat;
1265 [adium.contentController displayEvent:AILocalizedString(@"Attempt to delete tweet failed to connect.", nil)
1267 inChat:timelineChat];
1272 * @brief Destroy the DM.
1274 * The user has already confirmed they want to destroy it; send the message.
1276 - (void)destroyDirectMessage:(NSString *)messageID
1277 forUser:(NSString *)userID
1279 NSString *requestID = [twitterEngine deleteDirectMessage:messageID];
1280 AIListContact *contact = [self contactWithUID:userID];
1283 [self setRequestType:AITwitterDestroyDM
1284 forRequestID:requestID
1285 withDictionary:[NSDictionary dictionaryWithObject:contact forKey:@"ListContact"]];
1287 AIChat *chat = [adium.chatController chatWithContact:contact];
1289 [adium.contentController displayEvent:AILocalizedString(@"Attempt to delete tweet failed to connect.", nil)
1296 * @brief Convert a link URL and name into an attributed link
1298 * @param label The text to display for the link.
1299 * @param destination The destination address for the link.
1300 * @param attributeName The name of the twitter link attribute for HTML processing.
1302 - (NSAttributedString *)attributedStringWithLinkLabel:(NSString *)label
1303 linkDestination:(NSString *)destination
1304 linkClass:(NSString *)className
1306 NSURL *url = [NSURL URLWithString:destination];
1307 NSDictionary *attributes = [NSDictionary dictionaryWithObjectsAndKeys:
1308 url, NSLinkAttributeName,
1309 className, AIElementClassAttributeName, nil];
1311 return [[[NSAttributedString alloc] initWithString:label attributes:attributes] autorelease];
1315 * @brief Parse an attributed string into a linkified version.
1317 - (NSAttributedString *)linkifiedAttributedStringFromString:(NSAttributedString *)inString
1319 NSAttributedString *attributedString;
1321 static NSCharacterSet *usernameCharacters = nil;
1322 static NSCharacterSet *hashCharacters = nil;
1324 if (!usernameCharacters) {
1325 usernameCharacters = [[NSCharacterSet characterSetWithCharactersInString:@"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_"] retain];
1328 if (!hashCharacters) {
1329 NSMutableCharacterSet *disallowedCharacters = [[NSCharacterSet punctuationCharacterSet] mutableCopy];
1330 [disallowedCharacters formUnionWithCharacterSet:[NSCharacterSet whitespaceCharacterSet]];
1331 [disallowedCharacters removeCharactersInString:@"_"];
1333 hashCharacters = [[disallowedCharacters invertedSet] retain];
1335 [disallowedCharacters release];
1338 attributedString = [AITwitterURLParser linkifiedStringFromAttributedString:inString
1339 forPrefixCharacter:@"@"
1340 forLinkType:AITwitterLinkUserPage
1342 validCharacterSet:usernameCharacters];
1344 attributedString = [AITwitterURLParser linkifiedStringFromAttributedString:attributedString
1345 forPrefixCharacter:@"#"
1346 forLinkType:AITwitterLinkSearchHash
1348 validCharacterSet:hashCharacters];
1350 return attributedString;
1354 * @brief Parses a Twitter message into an attributed string
1356 - (NSAttributedString *)parseMessage:(NSString *)inMessage
1357 tweetID:(NSString *)tweetID
1358 userID:(NSString *)userID
1359 inReplyToUser:(NSString *)replyUserID
1360 inReplyToTweetID:(NSString *)replyTweetID
1362 NSAttributedString *message;
1364 message = [NSAttributedString stringWithString:[inMessage stringByUnescapingFromXMLWithEntities:nil]];
1366 message = [self linkifiedAttributedStringFromString:message];
1368 BOOL replyTweet = (replyTweetID.length);
1369 BOOL tweetLink = (tweetID.length && userID.length);
1371 if (replyTweet || tweetLink) {
1372 NSMutableAttributedString *mutableMessage = [[message mutableCopy] autorelease];
1374 NSUInteger startIndex = message.length;
1376 [mutableMessage appendString:@" (" withAttributes:nil];
1378 BOOL commaNeeded = NO;
1380 // Append a link to the tweet this is in reply to
1382 NSString *linkAddress = [self addressForLinkType:AITwitterLinkStatus
1384 statusID:replyTweetID
1387 if([inMessage hasPrefix:@"@"] &&
1388 inMessage.length >= replyUserID.length + 1 &&
1389 [replyUserID isCaseInsensitivelyEqualToString:[inMessage substringWithRange:NSMakeRange(1, replyUserID.length)]]) {
1390 // If the message has a "@" prefix, it's a proper in_reply_to_status_id if the usernames match. Set a link appropriately.
1391 [mutableMessage setAttributes:[NSDictionary dictionaryWithObjectsAndKeys:linkAddress, NSLinkAttributeName, nil]
1392 range:NSMakeRange(0, replyUserID.length + 1)];
1394 // This happens for mentions which are in_reply_to_status_id but the @target isn't the first part of the message.
1396 [mutableMessage appendAttributedString:[self attributedStringWithLinkLabel:AILocalizedString(@"IRT", "An abbreviation for 'in reply to' - placed at the beginning of the tweet tools for those which are directly in reply to another")
1397 linkDestination:linkAddress
1398 linkClass:AITwitterInReplyToClassName]];
1404 // Append a link to reply to this tweet
1406 NSString *linkAddress;
1408 if(![self.UID isCaseInsensitivelyEqualToString:userID]) {
1409 // A message from someone other than ourselves. RT and @ is permissible.
1412 [mutableMessage appendString:@", " withAttributes:nil];
1415 linkAddress = [self addressForLinkType:AITwitterLinkRetweet
1418 context:[inMessage stringByAddingPercentEscapesForAllCharacters]];
1420 [mutableMessage appendAttributedString:[self attributedStringWithLinkLabel:@"RT"
1421 linkDestination:linkAddress
1422 linkClass:AITwitterRetweetClassName]];
1427 [mutableMessage appendString:@", " withAttributes:nil];
1430 linkAddress = [self addressForLinkType:AITwitterLinkReply
1435 [mutableMessage appendAttributedString:[self attributedStringWithLinkLabel:@"@"
1436 linkDestination:linkAddress
1437 linkClass:AITwitterReplyClassName]];
1440 [mutableMessage appendString:@", " withAttributes:nil];
1443 // Our own message. Display a destroy link.
1444 linkAddress = [self addressForLinkType:AITwitterLinkDestroyStatus
1447 context:[inMessage stringByAddingPercentEscapesForAllCharacters]];
1449 [mutableMessage appendAttributedString:[self attributedStringWithLinkLabel:@"\u232B"
1450 linkDestination:linkAddress
1451 linkClass:AITwitterDeleteClassName]];
1454 [mutableMessage appendString:@", " withAttributes:nil];
1456 linkAddress = [self addressForLinkType:AITwitterLinkFavorite
1461 [mutableMessage appendAttributedString:[self attributedStringWithLinkLabel:@"\u2606"
1462 linkDestination:linkAddress
1463 linkClass:AITwitterFavoriteClassName]];
1465 [mutableMessage appendString:@", " withAttributes:nil];
1467 linkAddress = [self addressForLinkType:AITwitterLinkStatus
1472 [mutableMessage appendAttributedString:[self attributedStringWithLinkLabel:@"#"
1473 linkDestination:linkAddress
1474 linkClass:AITwitterStatusLinkClassName]];
1478 [mutableMessage appendString:@")" withAttributes:nil];
1480 [mutableMessage addAttributes:[NSDictionary dictionaryWithObjectsAndKeys:
1481 [NSNumber numberWithBool:YES], AITwitterActionLinksAttributeName,
1482 [NSNumber numberWithBool:YES], AIHiddenMessagePartAttributeName, nil]
1483 range:NSMakeRange(startIndex, mutableMessage.length - startIndex)];
1485 return mutableMessage;
1492 * @brief Parse a direct message
1494 - (NSAttributedString *)parseDirectMessage:(NSString *)inMessage
1495 withID:(NSString *)dmID
1496 fromUser:(NSString *)sourceUID
1498 NSAttributedString *message;
1500 message = [NSAttributedString stringWithString:[inMessage stringByUnescapingFromXMLWithEntities:nil]];
1502 message = [self linkifiedAttributedStringFromString:message];
1504 NSMutableAttributedString *mutableMessage = [[message mutableCopy] autorelease];
1506 NSUInteger startIndex = message.length;
1508 [mutableMessage appendString:@" (" withAttributes:nil];
1510 NSString *linkAddress = [self addressForLinkType:AITwitterLinkDestroyDM
1513 context:[inMessage stringByAddingPercentEscapesForAllCharacters]];
1515 [mutableMessage appendAttributedString:[self attributedStringWithLinkLabel:@"\u232B"
1516 linkDestination:linkAddress
1517 linkClass:AITwitterDeleteClassName]];
1519 [mutableMessage appendString:@")" withAttributes:nil];
1521 [mutableMessage addAttributes:[NSDictionary dictionaryWithObjectsAndKeys:
1522 [NSNumber numberWithBool:YES], AITwitterActionLinksAttributeName,
1523 [NSNumber numberWithBool:YES], AIHiddenMessagePartAttributeName, nil]
1524 range:NSMakeRange(startIndex, mutableMessage.length - startIndex)];
1526 return mutableMessage;
1531 * @brief Sort status updates
1533 NSInteger queuedUpdatesSort(id update1, id update2, void *context)
1535 return [[update1 objectForKey:TWITTER_STATUS_CREATED] compare:[update2 objectForKey:TWITTER_STATUS_CREATED]];
1539 * @brief Sort direct messages
1541 NSInteger queuedDMSort(id dm1, id dm2, void *context)
1543 return [[dm1 objectForKey:TWITTER_DM_CREATED] compare:[dm2 objectForKey:TWITTER_DM_CREATED]];
1547 * @brief Remove duplicate status updates.
1549 * If we're following someone who replies to us, we'll receive a status update in both the
1550 * timeline and the reply feed.
1552 * @param inArray The sorted array of Tweets
1554 - (NSArray *)arrayWithDuplicateTweetsRemoved:(NSArray *)inArray
1556 NSMutableArray *mutableArray = [inArray mutableCopy];
1558 NSDictionary *status = nil, *previousStatus = nil;
1560 // Starting at index 1, checking backwards. We'll never exceed bounds this way.
1561 for(NSUInteger index = 1; index < inArray.count; index++)
1563 status = [inArray objectAtIndex:index];
1564 previousStatus = [inArray objectAtIndex:index-1];
1566 if([[status objectForKey:TWITTER_STATUS_ID] isEqualToString:[previousStatus objectForKey:TWITTER_STATUS_ID]]) {
1567 [mutableArray removeObject:status];
1571 return [mutableArray autorelease];
1575 * @brief Display queued updates or direct messages
1577 * This could potentially be simplified since both DMs and updates have the same format.
1579 - (void)displayQueuedUpdatesForRequestType:(AITwitterRequestType)requestType
1581 if(requestType == AITwitterUpdateReplies || requestType == AITwitterUpdateFollowedTimeline) {
1582 if(!queuedUpdates.count) {
1586 AILogWithSignature(@"%@ Displaying %d updates", self, queuedUpdates.count);
1588 // Sort the queued updates (since we're intermingling pages of data from different souces)
1589 NSArray *sortedQueuedUpdates = [queuedUpdates sortedArrayUsingFunction:queuedUpdatesSort context:nil];
1591 sortedQueuedUpdates = [self arrayWithDuplicateTweetsRemoved:sortedQueuedUpdates];
1593 BOOL trackContent = [[self preferenceForKey:TWITTER_PREFERENCE_EVER_LOADED_TIMELINE group:TWITTER_PREFERENCE_GROUP_UPDATES] boolValue];
1595 AIChat *timelineChat = self.timelineChat;
1597 [[AIContactObserverManager sharedManager] delayListObjectNotifications];
1599 for (NSDictionary *status in sortedQueuedUpdates) {
1600 NSDate *date = [status objectForKey:TWITTER_STATUS_CREATED];
1601 NSString *text = [status objectForKey:TWITTER_STATUS_TEXT];
1603 NSString *contactUID = [[status objectForKey:TWITTER_STATUS_USER] objectForKey:TWITTER_STATUS_UID];
1605 id fromObject = nil;
1607 if(![self.UID isCaseInsensitivelyEqualToString:contactUID]) {
1608 AIListContact *listContact = [self contactWithUID:contactUID];
1610 // Update the user's status message
1611 [listContact setStatusMessage:[NSAttributedString stringWithString:[text stringByUnescapingFromXMLWithEntities:nil]]
1614 [self updateUserIcon:[[status objectForKey:TWITTER_STATUS_USER] objectForKey:TWITTER_INFO_ICON] forContact:listContact];
1616 [timelineChat addParticipatingListObject:listContact notify:NotifyNow];
1618 fromObject = (id)listContact;
1620 fromObject = (id)self;
1623 NSAttributedString *message = [self parseMessage:text
1624 tweetID:[status objectForKey:TWITTER_STATUS_ID]
1626 inReplyToUser:[status objectForKey:TWITTER_STATUS_REPLY_UID]
1627 inReplyToTweetID:[status objectForKey:TWITTER_STATUS_REPLY_ID]];
1629 AIContentMessage *contentMessage = [AIContentMessage messageInChat:timelineChat
1630 withSource:fromObject
1636 contentMessage.trackContent = trackContent;
1638 [adium.contentController receiveContentObject:contentMessage];
1641 [[AIContactObserverManager sharedManager] endListObjectNotificationsDelay];
1643 [queuedUpdates removeAllObjects];
1644 } else if (requestType == AITwitterUpdateDirectMessage || requestType == AITwitterDirectMessageSend) {
1645 NSMutableArray **unsortedArray = (requestType == AITwitterUpdateDirectMessage) ? &queuedDM : &queuedOutgoingDM;
1647 if (!(*unsortedArray).count) {
1651 AILogWithSignature(@"%@ Displaying %d DMs", self, queuedDM.count);
1653 NSArray *sortedQueuedDM = [*unsortedArray sortedArrayUsingFunction:queuedDMSort context:nil];
1655 for (NSDictionary *message in sortedQueuedDM) {
1656 NSDate *date = [message objectForKey:TWITTER_DM_CREATED];
1657 NSString *text = [message objectForKey:TWITTER_DM_TEXT];
1658 NSString *fromUID = [message objectForKey:TWITTER_DM_SENDER_UID];
1659 NSString *toUID = [message objectForKey:TWITTER_DM_RECIPIENT_UID];
1661 AIListObject *source = nil, *destination = nil;
1664 if([self.UID isCaseInsensitivelyEqualToString:fromUID]) {
1665 // This is a message we sent; display as coming from us.
1667 destination = [self contactWithUID:toUID];
1668 chat = [adium.chatController chatWithContact:(AIListContact *)destination];
1670 source = [self contactWithUID:fromUID];
1672 chat = [adium.chatController chatWithContact:(AIListContact *)source];
1675 if(chat && source && destination) {
1676 AIContentMessage *contentMessage = [AIContentMessage messageInChat:chat
1678 destination:destination
1680 message:[self parseDirectMessage:text
1681 withID:[message objectForKey:TWITTER_DM_ID]
1682 fromUser:chat.listObject.UID]
1685 [adium.contentController receiveContentObject:contentMessage];
1689 [*unsortedArray removeAllObjects];
1693 #pragma mark MGTwitterEngine Delegate Methods
1695 * @brief A request was successful
1697 * We only care about requests succeeding if they aren't specifically handled in another location.
1699 - (void)requestSucceeded:(NSString *)identifier
1701 // If a request succeeds and we think we're offline, call ourselves online.
1702 if ([self requestTypeForRequestID:identifier] == AITwitterDisconnect) {
1703 [self didDisconnect];
1704 } else if ([self requestTypeForRequestID:identifier] == AITwitterRemoveFollow) {
1705 AIListContact *listContact = [[self dictionaryForRequestID:identifier] objectForKey:@"ListContact"];
1707 for (NSString *groupName in listContact.remoteGroupNames) {
1708 [listContact removeRemoteGroupName:groupName];
1710 } else if ([self requestTypeForRequestID:identifier] == AITwitterDestroyStatus) {
1711 AIChat *timelineChat = self.timelineChat;
1713 [adium.contentController displayEvent:AILocalizedString(@"Your tweet has been successfully deleted.", nil)
1715 inChat:timelineChat];
1716 } else if ([self requestTypeForRequestID:identifier] == AITwitterDestroyDM) {
1717 AIListContact *contact = [[self dictionaryForRequestID:identifier] objectForKey:@"ListContact"];
1718 AIChat *chat = [adium.chatController chatWithContact:contact];
1720 [adium.contentController displayEvent:AILocalizedString(@"The direct message has been successfully deleted.", nil)
1727 * @brief A request failed
1729 * If it's a fatal error, we need to kill the session and retry. Otherwise, twitter's reliability is
1730 * pretty terrible, so let's ignore errors for the most part.
1732 - (void)requestFailed:(NSString *)identifier withError:(NSError *)error
1734 switch ([self requestTypeForRequestID:identifier]) {
1735 case AITwitterDirectMessageSend:
1736 case AITwitterSendUpdate:
1738 AIChat *chat = [[self dictionaryForRequestID:identifier] objectForKey:@"Chat"];
1741 [chat receivedError:[NSNumber numberWithInt:AIChatMessageSendingConnectionError]];
1743 AILogWithSignature(@"%@ Chat send error on %@", self, chat);
1748 case AITwitterDisconnect:
1749 [self didDisconnect];
1752 case AITwitterInitialUserInfo:
1753 [self setLastDisconnectionError:AILocalizedString(@"Unable to retrieve user list [fail]", "Message when a (vital) twitter request to retrieve the follow list fails")];
1754 [self didDisconnect];
1757 case AITwitterUserIconPull:
1759 AIListContact *listContact = [[self dictionaryForRequestID:identifier] objectForKey:@"ListContact"];
1761 // Image pull failed, flag ourselves as needing to try again.
1762 [listContact setValue:nil forProperty:TWITTER_PROPERTY_REQUESTED_USER_ICON notify:NotifyNever];
1766 case AITwitterUpdateFollowedTimeline:
1767 case AITwitterUpdateReplies:
1769 AIChat *timelineChat = [adium.chatController existingChatWithName:self.timelineChatName
1772 // Only print an error if the user already has the timeline open. Beyond annoying if we pop it open just to say "lol error"
1773 if (timelineChat && !timelineErrorMessagePrinted) {
1774 AIContentEvent *content = [AIContentEvent eventInChat:timelineChat
1778 message:[NSAttributedString stringWithString:[NSString stringWithFormat:AILocalizedString(@"Unable to update timeline: %@", nil),
1779 [self errorMessageForError:error]]]
1782 content.postProcessContent = NO;
1783 content.coalescingKey = @"error";
1785 [adium.contentController receiveContentObject:content];
1787 // This gets reset to NO the next a periodic update fires.
1788 timelineErrorMessagePrinted = YES;
1791 --pendingUpdateCount;
1795 case AITwitterUpdateDirectMessage:
1796 --pendingUpdateCount;
1799 case AITwitterAddFollow:
1800 if(error.code == 404) {
1801 [adium.interfaceController handleErrorMessage:AILocalizedString(@"Unable to Add Contact", nil)
1802 withDescription:[NSString stringWithFormat:AILocalizedString(@"Unable to add %@ to account %@, the user does not exist.", nil),
1803 [[self dictionaryForRequestID:identifier] objectForKey:@"UID"],
1804 self.explicitFormattedUID]];
1806 [adium.interfaceController handleErrorMessage:AILocalizedString(@"Unable to Add Contact", nil)
1807 withDescription:[NSString stringWithFormat:AILocalizedString(@"Unable to add %@ to account %@. %@",nil),
1808 [[self dictionaryForRequestID:identifier] objectForKey:@"UID"],
1809 self.explicitFormattedUID,
1810 [self errorMessageForError:error]]];
1814 case AITwitterRemoveFollow:
1815 [adium.interfaceController handleErrorMessage:AILocalizedString(@"Unable to Remove Contact", nil)
1816 withDescription:[NSString stringWithFormat:AILocalizedString(@"Unable to remove %@ on account %@. %@", nil),
1817 ((AIListContact *)[[self dictionaryForRequestID:identifier] objectForKey:@"ListContact"]).UID,
1818 self.explicitFormattedUID,
1819 [self errorMessageForError:error]]];
1822 case AITwitterValidateCredentials:
1823 if(error.code == 401) {
1825 [self setPasswordTemporarily:nil];
1826 [self setLastDisconnectionError:TWITTER_OAUTH_NOT_AUTHORIZED];
1828 [[NSNotificationCenter defaultCenter] postNotificationName:@"AIEditAccount"
1832 [self setLastDisconnectionError:TWITTER_INCORRECT_PASSWORD_MESSAGE];
1833 [self serverReportedInvalidPassword];
1836 [self didDisconnect];
1838 [self setLastDisconnectionError:AILocalizedString(@"Unable to validate credentials", nil)];
1839 [self didDisconnect];
1843 case AITwitterFavoriteYes:
1844 case AITwitterFavoriteNo:
1846 AIChat *timelineChat = self.timelineChat;
1848 if (error.code == 403) {
1849 // We've attempted to add or remove when we already have it marked as such. Try the opposite.
1850 BOOL addAsFavorite = ([self requestTypeForRequestID:identifier] == AITwitterFavoriteNo);
1851 NSString *tweetID = [[self dictionaryForRequestID:identifier] objectForKey:@"tweetID"];
1853 NSString *requestID = [twitterEngine markUpdate:tweetID
1854 asFavorite:addAsFavorite];
1857 [self setRequestType:(addAsFavorite ? AITwitterFavoriteYes : AITwitterFavoriteNo)
1858 forRequestID:requestID
1859 withDictionary:[NSDictionary dictionaryWithObjectsAndKeys:tweetID, @"tweetID", nil]];
1861 [adium.contentController displayEvent:AILocalizedString(@"Attempt to favorite tweet failed to connect.", nil)
1863 inChat:timelineChat];
1866 [adium.contentController displayEvent:[NSString stringWithFormat:AILocalizedString(@"Attempt to favorite tweet failed. %@", nil), [self errorMessageForError:error]]
1868 inChat:timelineChat];
1874 case AITwitterNotificationEnable:
1875 case AITwitterNotificationDisable:
1877 BOOL enableNotification = ([self requestTypeForRequestID:identifier] == AITwitterNotificationEnable);
1878 AIListContact *listContact = [[self dictionaryForRequestID:identifier] objectForKey:@"ListContact"];
1880 [adium.interfaceController handleErrorMessage:(enableNotification ?
1881 AILocalizedString(@"Unable to Enable Notifications", nil) :
1882 AILocalizedString(@"Unable to Disable Notifications", nil))
1883 withDescription:[NSString stringWithFormat:AILocalizedString(@"Cannot change notification setting for %@. %@", nil), listContact.UID, [self errorMessageForError:error]]];
1887 case AITwitterDestroyStatus:
1889 AIChat *timelineChat = self.timelineChat;
1891 [adium.contentController displayEvent:[NSString stringWithFormat:AILocalizedString(@"Your tweet failed to delete. %@", nil), [self errorMessageForError:error]]
1893 inChat:timelineChat];
1897 case AITwitterDestroyDM:
1899 AIListContact *contact = [[self dictionaryForRequestID:identifier] objectForKey:@"ListContact"];
1900 AIChat *chat = [adium.chatController chatWithContact:contact];
1902 [adium.contentController displayEvent:[NSString stringWithFormat:AILocalizedString(@"The direct message failed to delete. %@", nil), [self errorMessageForError:error]]
1908 case AITwitterUnknownType:
1909 case AITwitterRateLimitStatus:
1910 case AITwitterProfileSelf:
1911 case AITwitterSelfUserIconPull:
1912 case AITwitterProfileUserInfo:
1913 case AITwitterProfileStatusUpdates:
1914 // While we don't handle the errors, it's a good idea to not have a "default" just to prevent accidentally letting something
1915 // we should really handle slip through.
1920 AILogWithSignature(@"%@ Request failed (%@ - %u) - %@", self, identifier, [self requestTypeForRequestID:identifier], error);
1922 [self clearRequestTypeForRequestID:identifier];
1926 * @brief Status updates received
1928 - (void)statusesReceived:(NSArray *)statuses forRequest:(NSString *)identifier
1930 if([self requestTypeForRequestID:identifier] == AITwitterUpdateFollowedTimeline ||
1931 [self requestTypeForRequestID:identifier] == AITwitterUpdateReplies) {
1934 BOOL nextPageNecessary = NO;
1936 if([self requestTypeForRequestID:identifier] == AITwitterUpdateFollowedTimeline) {
1937 lastID = [self preferenceForKey:TWITTER_PREFERENCE_TIMELINE_LAST_ID
1938 group:TWITTER_PREFERENCE_GROUP_UPDATES];
1940 nextPageNecessary = (lastID && statuses.count >= TWITTER_UPDATE_TIMELINE_COUNT - 5);
1942 lastID = [self preferenceForKey:TWITTER_PREFERENCE_REPLIES_LAST_ID
1943 group:TWITTER_PREFERENCE_GROUP_UPDATES];
1945 nextPageNecessary = (lastID && statuses.count >= TWITTER_UPDATE_REPLIES_COUNT - 5);
1948 // Store the largest tweet ID we find; this will be our "last ID" the next time we run.
1949 NSString *largestTweet = [[self dictionaryForRequestID:identifier] objectForKey:@"LargestTweet"];
1951 // The largest ID is first, compare.
1952 if (statuses.count) {
1953 NSString *tweetID = [[statuses objectAtIndex:0] objectForKey:TWITTER_STATUS_ID];
1954 if (!largestTweet || [largestTweet compare:tweetID options:NSNumericSearch] == NSOrderedAscending) {
1955 largestTweet = tweetID;
1959 [queuedUpdates addObjectsFromArray:statuses];
1961 AILogWithSignature(@"%@ Last ID: %@ Largest Tweet: %@ Next Page Necessary: %d", self, lastID, largestTweet, nextPageNecessary);
1963 // See if we need to pull more updates.
1964 if (nextPageNecessary) {
1965 NSInteger nextPage = [[[self dictionaryForRequestID:identifier] objectForKey:@"Page"] intValue] + 1;
1966 NSString *requestID;
1968 if ([self requestTypeForRequestID:identifier] == AITwitterUpdateFollowedTimeline) {
1969 requestID = [twitterEngine getFollowedTimelineFor:nil
1971 startingAtPage:nextPage
1972 count:TWITTER_UPDATE_TIMELINE_COUNT];
1974 AILogWithSignature(@"%@ Pulling additional timeline page %d", self, nextPage);
1977 [self setRequestType:AITwitterUpdateFollowedTimeline
1978 forRequestID:requestID
1979 withDictionary:[NSDictionary dictionaryWithObjectsAndKeys:[NSNumber numberWithInt:nextPage], @"Page",
1980 largestTweet, @"LargestTweet", nil]];
1982 // Gracefully fail: remove all stored objects.
1983 AILogWithSignature(@"%@ Immediate timeline fail", self);
1984 --pendingUpdateCount;
1985 [queuedUpdates removeAllObjects];
1988 } else if ([self requestTypeForRequestID:identifier] == AITwitterUpdateReplies) {
1989 requestID = [twitterEngine getRepliesSinceID:lastID startingAtPage:nextPage];
1991 AILogWithSignature(@"%@ Pulling additional replies page %d", self, nextPage);
1994 [self setRequestType:AITwitterUpdateReplies
1995 forRequestID:requestID
1996 withDictionary:[NSDictionary dictionaryWithObjectsAndKeys:[NSNumber numberWithInt:nextPage], @"Page",
1997 largestTweet, @"LargestTweet", nil]];
1999 // Gracefully fail: remove all stored objects.
2000 AILogWithSignature(@"%@ Immediate reply fail", self);
2001 --pendingUpdateCount;
2002 [queuedUpdates removeAllObjects];
2006 if([self requestTypeForRequestID:identifier] == AITwitterUpdateFollowedTimeline) {
2007 followedTimelineCompleted = YES;
2008 futureTimelineLastID = [largestTweet retain];
2009 } else if ([self requestTypeForRequestID:identifier] == AITwitterUpdateReplies) {
2010 repliesCompleted = YES;
2011 futureRepliesLastID = [largestTweet retain];
2014 --pendingUpdateCount;
2016 AILogWithSignature(@"%@ Followed completed: %d Replies completed: %d", self, followedTimelineCompleted, repliesCompleted);
2018 if (followedTimelineCompleted && repliesCompleted) {
2019 if (queuedUpdates.count) {
2020 // Set the "last pulled" for the timeline and replies, since we've completed both.
2021 if(futureRepliesLastID) {
2022 AILogWithSignature(@"%@ futureRepliesLastID = %@", self, futureRepliesLastID);
2024 [self setPreference:futureRepliesLastID
2025 forKey:TWITTER_PREFERENCE_REPLIES_LAST_ID
2026 group:TWITTER_PREFERENCE_GROUP_UPDATES];
2028 [futureRepliesLastID release]; futureRepliesLastID = nil;
2031 if(futureTimelineLastID) {
2032 AILogWithSignature(@"%@ futureTimelineLastID = %@", self, futureTimelineLastID);
2034 [self setPreference:futureTimelineLastID
2035 forKey:TWITTER_PREFERENCE_TIMELINE_LAST_ID
2036 group:TWITTER_PREFERENCE_GROUP_UPDATES];
2038 [futureTimelineLastID release]; futureTimelineLastID = nil;
2041 [self displayQueuedUpdatesForRequestType:[self requestTypeForRequestID:identifier]];
2044 if (![self preferenceForKey:TWITTER_PREFERENCE_EVER_LOADED_TIMELINE group:TWITTER_PREFERENCE_GROUP_UPDATES]) {
2045 [self setPreference:[NSNumber numberWithBool:YES]
2046 forKey:TWITTER_PREFERENCE_EVER_LOADED_TIMELINE
2047 group:TWITTER_PREFERENCE_GROUP_UPDATES];
2051 } else if ([self requestTypeForRequestID:identifier] == AITwitterProfileStatusUpdates) {
2052 AIListContact *listContact = [[self dictionaryForRequestID:identifier] objectForKey:@"ListContact"];
2054 NSMutableArray *profileArray = [[listContact profileArray] mutableCopy];
2056 AILogWithSignature(@"%@ Updating statuses for profile, user %@", self, listContact);
2058 for (NSDictionary *update in statuses) {
2059 NSAttributedString *message = [self parseMessage:[update objectForKey:TWITTER_STATUS_TEXT]
2060 tweetID:[update objectForKey:TWITTER_STATUS_ID]
2061 userID:listContact.UID
2062 inReplyToUser:[update objectForKey:TWITTER_STATUS_REPLY_UID]
2063 inReplyToTweetID:[update objectForKey:TWITTER_STATUS_REPLY_ID]];
2065 [profileArray addObject:[NSDictionary dictionaryWithObjectsAndKeys:message, KEY_VALUE, nil]];
2068 [listContact setProfileArray:profileArray notify:NotifyNow];
2069 } else if ([self requestTypeForRequestID:identifier] == AITwitterSendUpdate) {
2070 if (updateAfterSend) {
2071 [self periodicUpdate];
2074 if (statuses.count) {
2075 [adium.contentController displayEvent:AILocalizedString(@"Tweet successfully sent.", nil)
2077 inChat:self.timelineChat];
2080 for(NSDictionary *update in statuses) {
2081 [[NSNotificationCenter defaultCenter] postNotificationName:AITwitterNotificationPostedStatus
2083 userInfo:[NSDictionary dictionaryWithObjectsAndKeys:self.timelineChat, @"AIChat", nil]];
2085 NSString *text = [[update objectForKey:TWITTER_STATUS_TEXT] stringByUnescapingFromXMLWithEntities:nil];
2087 if([[self preferenceForKey:TWITTER_PREFERENCE_UPDATE_GLOBAL group:TWITTER_PREFERENCE_GROUP_UPDATES] boolValue] &&
2088 (![text hasPrefix:@"@"] || [[self preferenceForKey:TWITTER_PREFERENCE_UPDATE_GLOBAL_REPLIES group:TWITTER_PREFERENCE_GROUP_UPDATES] boolValue])) {
2089 AIStatus *availableStatus = [AIStatus statusOfType:AIAvailableStatusType];
2091 availableStatus.statusMessage = [NSAttributedString stringWithString:text];
2092 [adium.statusController setActiveStatusState:availableStatus];
2095 } else if ([self requestTypeForRequestID:identifier] == AITwitterFavoriteYes ||
2096 [self requestTypeForRequestID:identifier] == AITwitterFavoriteNo) {
2097 AIChat *timelineChat = self.timelineChat;
2099 for (NSDictionary *status in statuses) {
2102 // Use HTML for the status message since it's just easier to localize that way.
2104 if ([self requestTypeForRequestID:identifier] == AITwitterFavoriteYes) {
2105 message = AILocalizedString(@"The <a href=\"%@\">requested tweet</a> by <a href=\"%@\">%@</a> is now a favorite.", nil);
2107 message = AILocalizedString(@"The <a href=\"%@\">requested tweet</a> by <a href=\"%@\">%@</a> is no longer a favorite.", nil);
2110 NSString *userID = [[status objectForKey:TWITTER_STATUS_USER] objectForKey:TWITTER_STATUS_UID];
2113 message = [NSString stringWithFormat:message,
2114 [self addressForLinkType:AITwitterLinkStatus
2116 statusID:[status objectForKey:TWITTER_STATUS_ID]
2118 [self addressForLinkType:AITwitterLinkUserPage
2124 NSAttributedString *attributedMessage = [[AIHTMLDecoder decoder] decodeHTML:message withDefaultAttributes:nil];
2126 AIContentEvent *content = [AIContentEvent eventInChat:timelineChat
2130 message:attributedMessage
2131 withType:@"favorite"];
2133 content.postProcessContent = NO;
2134 content.coalescingKey = @"favorite";
2136 [adium.contentController receiveContentObject:content];
2140 [self clearRequestTypeForRequestID:identifier];
2144 * @brief Direct messages received
2146 - (void)directMessagesReceived:(NSArray *)messages forRequest:(NSString *)identifier
2148 if ([self requestTypeForRequestID:identifier] == AITwitterUpdateDirectMessage) {
2149 NSString *lastID = [self preferenceForKey:TWITTER_PREFERENCE_DM_LAST_ID
2150 group:TWITTER_PREFERENCE_GROUP_UPDATES];
2152 BOOL nextPageNecessary = (lastID && messages.count >= TWITTER_UPDATE_DM_COUNT);
2154 // Store the largest tweet ID we find; this will be our "last ID" the next time we run.
2155 NSString *largestTweet = [[self dictionaryForRequestID:identifier] objectForKey:@"LargestTweet"];
2157 // The largest ID is first, compare.
2158 if (messages.count) {
2159 NSString *tweetID = [[messages objectAtIndex:0] objectForKey:TWITTER_DM_ID];
2160 if (!largestTweet || [largestTweet compare:tweetID] == NSOrderedAscending) {
2161 largestTweet = tweetID;
2165 [queuedDM addObjectsFromArray:messages];
2167 AILogWithSignature(@"%@ Last ID: %@ Largest Tweet: %@ Next Page Necessary: %d", self, lastID, largestTweet, nextPageNecessary);
2169 if(nextPageNecessary) {
2170 NSInteger nextPage = [[[self dictionaryForRequestID:identifier] objectForKey:@"Page"] intValue] + 1;
2172 NSString *requestID = [twitterEngine getDirectMessagesSinceID:lastID
2173 startingAtPage:nextPage];
2175 AILogWithSignature(@"%@ Pulling additional DM page %d", self, nextPage);
2178 [self setRequestType:AITwitterUpdateDirectMessage
2179 forRequestID:requestID
2180 withDictionary:[NSDictionary dictionaryWithObjectsAndKeys:[NSNumber numberWithInt:nextPage], @"Page",
2181 largestTweet, @"LargestTweet", nil]];
2183 // Gracefully fail: remove all stored objects.
2184 AILogWithSignature(@"%@ Immediate DM pull fail", self);
2185 --pendingUpdateCount;
2186 [queuedDM removeAllObjects];
2189 --pendingUpdateCount;
2192 AILogWithSignature(@"%@ Largest DM pulled = %@", self, largestTweet);
2194 [self setPreference:largestTweet
2195 forKey:TWITTER_PREFERENCE_DM_LAST_ID
2196 group:TWITTER_PREFERENCE_GROUP_UPDATES];
2199 // On first load, don't display any direct messages. Just ge the largest ID.
2200 if (queuedDM.count && lastID) {
2201 [self displayQueuedUpdatesForRequestType:[self requestTypeForRequestID:identifier]];
2203 [queuedDM removeAllObjects];
2206 } else if ([self requestTypeForRequestID:identifier] == AITwitterDirectMessageSend) {
2207 [queuedOutgoingDM addObjectsFromArray:messages];
2208 [self displayQueuedUpdatesForRequestType:AITwitterDirectMessageSend];
2211 [self clearRequestTypeForRequestID:identifier];
2215 * @brief User information received
2217 - (void)userInfoReceived:(NSArray *)userInfo forRequest:(NSString *)identifier
2219 if (([self requestTypeForRequestID:identifier] == AITwitterInitialUserInfo ||
2220 [self requestTypeForRequestID:identifier] == AITwitterAddFollow) &&
2221 [[self preferenceForKey:TWITTER_PREFERENCE_LOAD_CONTACTS group:TWITTER_PREFERENCE_GROUP_UPDATES] boolValue]) {
2222 [[AIContactObserverManager sharedManager] delayListObjectNotifications];
2224 // The current amount of friends per page is 100. Use >= just in case this changes.
2225 BOOL nextPageNecessary = (userInfo.count >= 100);
2227 AILogWithSignature(@"%@ User info pull, Next page necessary: %d Count: %d", self, nextPageNecessary, userInfo.count);
2229 for (NSDictionary *info in userInfo) {
2230 AIListContact *listContact = [self contactWithUID:[info objectForKey:TWITTER_INFO_UID]];
2232 // If the user isn't in a group, set them in the Twitter group.
2233 if(listContact.countOfRemoteGroupNames == 0) {
2234 [listContact addRemoteGroupName:TWITTER_REMOTE_GROUP_NAME];
2237 // Grab the Twitter display name and set it as the remote alias.
2238 if (![[listContact valueForProperty:@"Server Display Name"] isEqualToString:[info objectForKey:TWITTER_INFO_DISPLAY_NAME]]) {
2239 [listContact setServersideAlias:[info objectForKey:TWITTER_INFO_DISPLAY_NAME]
2240 silently:silentAndDelayed];
2243 // Grab the user icon and set it as their serverside icon.
2244 [self updateUserIcon:[info objectForKey:TWITTER_INFO_ICON] forContact:listContact];
2246 // Set the user as available.
2247 [listContact setStatusWithName:nil
2248 statusType:AIAvailableStatusType
2249 notify:NotifyLater];
2251 // Set the user's status message to their current twitter status text
2252 NSString *statusText = [[info objectForKey:TWITTER_INFO_STATUS] objectForKey:TWITTER_INFO_STATUS_TEXT];
2253 if (!statusText) //nil if they've never tweeted
2255 [listContact setStatusMessage:[NSAttributedString stringWithString:[statusText stringByUnescapingFromXMLWithEntities:nil]] notify:NotifyLater];
2257 // Set the user as online.
2258 [listContact setOnline:YES notify:NotifyLater silently:silentAndDelayed];
2260 [listContact notifyOfChangedPropertiesSilently:silentAndDelayed];
2263 [[AIContactObserverManager sharedManager] endListObjectNotificationsDelay];
2265 if (nextPageNecessary) {
2266 NSInteger nextPage = [[[self dictionaryForRequestID:identifier] objectForKey:@"Page"] intValue] + 1;
2267 NSString *requestID = [twitterEngine getRecentlyUpdatedFriendsFor:self.UID startingAtPage:nextPage];
2269 AILogWithSignature(@"%@ Pulling additional user info page %d", self, nextPage);
2272 [self setRequestType:AITwitterInitialUserInfo
2273 forRequestID:requestID
2274 withDictionary:[NSDictionary dictionaryWithObject:[NSNumber numberWithInt:nextPage]
2277 [self setLastDisconnectionError:AILocalizedString(@"Unable to retrieve user list [additional fail]", "Message when a (vital) twitter request to retrieve the follow list fails")];
2278 [self didDisconnect];
2281 } else if ([self valueForProperty:@"Connecting"]) {
2282 // Trigger our normal update routine.
2285 } else if ([self requestTypeForRequestID:identifier] == AITwitterProfileUserInfo) {
2286 NSDictionary *thisUserInfo = [userInfo objectAtIndex:0];
2289 AIListContact *listContact = [[self dictionaryForRequestID:identifier] objectForKey:@"ListContact"];
2291 NSArray *keyNames = [NSArray arrayWithObjects:@"name", @"location", @"description", @"url", @"friends_count", @"followers_count", @"statuses_count", nil];
2292 NSArray *readableNames = [NSArray arrayWithObjects:AILocalizedString(@"Name", nil), AILocalizedString(@"Location", nil),
2293 AILocalizedString(@"Biography", nil), AILocalizedString(@"Website", nil), AILocalizedString(@"Following", nil),
2294 AILocalizedString(@"Followers", nil), AILocalizedString(@"Updates", nil), nil];
2296 NSMutableArray *profileArray = [NSMutableArray array];
2298 for (NSUInteger index = 0; index < keyNames.count; index++) {
2299 NSString *keyName = [keyNames objectAtIndex:index];
2300 NSString *unattributedValue = [thisUserInfo objectForKey:keyName];
2302 if(![unattributedValue isEqualToString:@""]) {
2303 NSString *readableName = [readableNames objectAtIndex:index];
2304 NSAttributedString *value;
2306 if([keyName isEqualToString:@"friends_count"]) {
2307 value = [NSAttributedString attributedStringWithLinkLabel:unattributedValue
2308 linkDestination:[self addressForLinkType:AITwitterLinkFriends userID:listContact.UID statusID:nil context:nil]];
2309 } else if ([keyName isEqualToString:@"followers_count"]) {
2310 value = [NSAttributedString attributedStringWithLinkLabel:unattributedValue
2311 linkDestination:[self addressForLinkType:AITwitterLinkFollowers userID:listContact.UID statusID:nil context:nil]];
2312 } else if ([keyName isEqualToString:@"statuses_count"]) {
2313 value = [NSAttributedString attributedStringWithLinkLabel:unattributedValue
2314 linkDestination:[self addressForLinkType:AITwitterLinkUserPage userID:listContact.UID statusID:nil context:nil]];
2316 value = [NSAttributedString stringWithString:unattributedValue];
2319 [profileArray addObject:[NSDictionary dictionaryWithObjectsAndKeys:readableName, KEY_KEY, value, KEY_VALUE, nil]];
2323 AILogWithSignature(@"%@ Updating profileArray for user %@", self, listContact);
2325 [listContact setProfileArray:profileArray notify:NotifyNow];
2327 // Grab their statuses.
2328 NSString *requestID = [twitterEngine getUserTimelineFor:listContact.UID since:nil startingAtPage:0 count:TWITTER_UPDATE_USER_INFO_COUNT];
2331 [self setRequestType:AITwitterProfileStatusUpdates
2332 forRequestID:requestID
2333 withDictionary:[NSDictionary dictionaryWithObject:listContact forKey:@"ListContact"]];
2336 } else if ([self requestTypeForRequestID:identifier] == AITwitterValidateCredentials ||
2337 [self requestTypeForRequestID:identifier] == AITwitterProfileSelf) {
2338 for (NSDictionary *info in userInfo) {
2339 NSString *requestID = [twitterEngine getImageAtURL:[info objectForKey:TWITTER_INFO_ICON]];
2342 [self setRequestType:AITwitterSelfUserIconPull
2343 forRequestID:requestID
2344 withDictionary:nil];
2347 [self filterAndSetUID:[info objectForKey:TWITTER_INFO_UID]];
2349 if ([info objectForKey:@"name"]) {
2350 [self setPreference:[[NSAttributedString stringWithString:[info objectForKey:@"name"]] dataRepresentation]
2351 forKey:KEY_ACCOUNT_DISPLAY_NAME
2352 group:GROUP_ACCOUNT_STATUS];
2355 [self setValue:[info objectForKey:@"name"] forProperty:@"Profile Name" notify:NotifyLater];
2356 [self setValue:[info objectForKey:@"url"] forProperty:@"Profile URL" notify:NotifyLater];
2357 [self setValue:[info objectForKey:@"location"] forProperty:@"Profile Location" notify:NotifyLater];
2358 [self setValue:[info objectForKey:@"description"] forProperty:@"Profile Description" notify:NotifyLater];
2359 [self notifyOfChangedPropertiesSilently:NO];
2363 if([self requestTypeForRequestID:identifier] == AITwitterValidateCredentials) {
2364 // Our UID is definitely set; grab our friends.
2366 if ([[self preferenceForKey:TWITTER_PREFERENCE_LOAD_CONTACTS group:TWITTER_PREFERENCE_GROUP_UPDATES] boolValue]) {
2367 // If we load our follows as contacts, do so now.
2369 // Delay updates on initial login.
2370 [self silenceAllContactUpdatesForInterval:18.0];
2371 // Grab our user list.
2372 NSString *requestID = [twitterEngine getRecentlyUpdatedFriendsFor:self.UID startingAtPage:1];
2375 [self setRequestType:AITwitterInitialUserInfo
2376 forRequestID:requestID
2377 withDictionary:[NSDictionary dictionaryWithObject:[NSNumber numberWithInt:1] forKey:@"Page"]];
2379 [self setLastDisconnectionError:AILocalizedString(@"Unable to retrieve user list", nil)];
2380 [self didDisconnect];
2383 // If we don't load follows as contacts, we've finished connecting (fast, wasn't it?)
2387 } else if ([self requestTypeForRequestID:identifier] == AITwitterNotificationEnable ||
2388 [self requestTypeForRequestID:identifier] == AITwitterNotificationDisable) {
2389 BOOL enableNotification = ([self requestTypeForRequestID:identifier] == AITwitterNotificationEnable);
2390 AIListContact *listContact = [[self dictionaryForRequestID:identifier] objectForKey:@"ListContact"];
2392 for (NSDictionary *info in userInfo) {
2393 [adium.interfaceController handleMessage:(enableNotification ?
2394 AILocalizedString(@"Notifications Enabled", nil) :
2395 AILocalizedString(@"Notifications Disabled", nil))
2396 withDescription:[NSString stringWithFormat:(enableNotification ?
2397 AILocalizedString(@"You will now receive device notifications for %@.", nil) :
2398 AILocalizedString(@"You will no longer receive device notifications for %@.", nil)),
2400 withWindowTitle:(enableNotification ?
2401 AILocalizedString(@"Notifications Enabled", nil) :
2402 AILocalizedString(@"Notifications Disabled", nil))];
2406 [self clearRequestTypeForRequestID:identifier];
2410 * @brief Miscellaneous information received
2412 - (void)miscInfoReceived:(NSArray *)miscInfo forRequest:(NSString *)identifier
2414 if([self requestTypeForRequestID:identifier] == AITwitterRateLimitStatus) {
2415 NSDictionary *rateLimit = [miscInfo objectAtIndex:0];
2416 NSDate *resetDate = [NSDate dateWithTimeIntervalSince1970:[[rateLimit objectForKey:TWITTER_RATE_LIMIT_RESET_SECONDS] intValue]];
2418 [adium.interfaceController handleMessage:AILocalizedString(@"Current Twitter rate limit", "Message in the rate limit status window")
2419 withDescription:[NSString stringWithFormat:AILocalizedString(@"You have %d/%d more requests for %@.", "The first %d is the number of requests, the second is the total number of requests per hour. The %@ is the duration of time until the count resets."),
2420 [[rateLimit objectForKey:TWITTER_RATE_LIMIT_REMAINING] intValue],
2421 [[rateLimit objectForKey:TWITTER_RATE_LIMIT_HOURLY_LIMIT] intValue],
2422 [NSDateFormatter stringForTimeInterval:[resetDate timeIntervalSinceNow]
2426 withWindowTitle:AILocalizedString(@"Rate Limit Status", nil)];
2429 [self clearRequestTypeForRequestID:identifier];
2433 * @brief Requested image received
2435 - (void)imageReceived:(NSImage *)image forRequest:(NSString *)identifier
2437 if([self requestTypeForRequestID:identifier] == AITwitterUserIconPull) {
2438 AIListContact *listContact = [[self dictionaryForRequestID:identifier] objectForKey:@"ListContact"];
2440 AILogWithSignature(@"%@ Updated user icon for %@", self, listContact);
2442 [listContact setServersideIconData:[image TIFFRepresentation]
2443 notify:NotifyLater];
2445 [listContact setValue:nil forProperty:TWITTER_PROPERTY_REQUESTED_USER_ICON notify:NotifyNever];
2446 } else if([self requestTypeForRequestID:identifier] == AITwitterSelfUserIconPull) {
2447 AILogWithSignature(@"Updated self icon for %@", self);
2449 // Set a property so we don't re-send thie image we're just now downloading.
2450 [self setValue:[NSNumber numberWithBool:YES] forProperty:TWITTER_PROPERTY_REQUESTED_USER_ICON notify:NotifyNever];
2452 [self setPreference:[NSNumber numberWithBool:YES]
2453 forKey:KEY_USE_USER_ICON
2454 group:GROUP_ACCOUNT_STATUS];
2457 [self setPreference:[image TIFFRepresentation]
2458 forKey:KEY_USER_ICON
2459 group:GROUP_ACCOUNT_STATUS];
2462 [self clearRequestTypeForRequestID:identifier];