Implement the Retweet API. This means checking home_timeline and sending proper retweet messages. Fixes #12556.
On an annoying note, home_timeline (despite saying "Returns the 20 most recent statuses, including retweets, posted by the authenticating user and that user's friends") does not include outgoing retweets. This will either be fixed by Twitter quickly, or not. I'm tired of Twitter's inconsistent and buggy API. So, as such, there's currently no way to remove a retweet done by yourself.
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/AIContentMessage.h>
35 #import <Adium/AIListBookmark.h>
36 #import <Adium/AIChat.h>
37 #import <Adium/AIUserIcons.h>
38 #import <Adium/AIService.h>
39 #import <Adium/AIStatus.h>
40 #import <Adium/AIHTMLDecoder.h>
41 #import <Adium/AIContentEvent.h>
43 @interface AITwitterAccount()
44 - (void)updateUserIcon:(NSString *)url forContact:(AIListContact *)listContact;
46 - (void)updateTimelineChat:(AIChat *)timelineChat;
48 - (NSAttributedString *)parseMessage:(NSString *)inMessage
49 tweetID:(NSString *)tweetID
50 userID:(NSString *)userID
51 inReplyToUser:(NSString *)replyUserID
52 inReplyToTweetID:(NSString *)replyTweetID;
53 - (NSAttributedString *)parseDirectMessage:(NSString *)inMessage
54 withID:(NSString *)dmID
55 fromUser:(NSString *)sourceUID;
56 - (NSAttributedString *)attributedStringWithLinkLabel:(NSString *)label
57 linkDestination:(NSString *)destination
58 linkClass:(NSString *)attributeName;
60 - (void)setRequestType:(AITwitterRequestType)type forRequestID:(NSString *)requestID withDictionary:(NSDictionary *)info;
61 - (AITwitterRequestType)requestTypeForRequestID:(NSString *)requestID;
62 - (NSDictionary *)dictionaryForRequestID:(NSString *)requestID;
63 - (void)clearRequestTypeForRequestID:(NSString *)requestID;
65 - (void)periodicUpdate;
66 - (void)displayQueuedUpdatesForRequestType:(AITwitterRequestType)requestType;
68 - (void)getRateLimitAmount;
71 @implementation AITwitterAccount
76 pendingRequests = [[NSMutableDictionary alloc] init];
77 queuedUpdates = [[NSMutableArray alloc] init];
78 queuedDM = [[NSMutableArray alloc] init];
79 queuedOutgoingDM = [[NSMutableArray alloc] init];
81 [[NSNotificationCenter defaultCenter] addObserver:self
82 selector:@selector(chatDidOpen:)
86 [adium.preferenceController registerDefaults:[NSDictionary dictionaryWithObjectsAndKeys:
87 [NSNumber numberWithInt:TWITTER_UPDATE_INTERVAL_MINUTES], TWITTER_PREFERENCE_UPDATE_INTERVAL,
88 [NSNumber numberWithBool:YES], TWITTER_PREFERENCE_UPDATE_AFTER_SEND,
89 [NSNumber numberWithBool:YES], TWITTER_PREFERENCE_LOAD_CONTACTS, nil]
90 forGroup:TWITTER_PREFERENCE_GROUP_UPDATES
93 // If we don't have a server set, set our default (if we have one)
94 if (!self.host && self.defaultServer) {
95 [self setPreference:self.defaultServer forKey:KEY_CONNECT_HOST group:GROUP_ACCOUNT_STATUS];
98 [adium.preferenceController registerPreferenceObserver:self forGroup:TWITTER_PREFERENCE_GROUP_UPDATES];
99 [adium.preferenceController informObserversOfChangedKey:nil inGroup:TWITTER_PREFERENCE_GROUP_UPDATES object:self];
104 [[NSNotificationCenter defaultCenter] removeObserver:self];
105 [adium.preferenceController unregisterPreferenceObserver:self];
107 [twitterEngine release];
108 [pendingRequests release];
109 [queuedUpdates release];
111 [queuedOutgoingDM release];
117 * @brief Our default server if none is provided.
119 - (NSString *)defaultServer
121 return @"twitter.com";
124 #pragma mark AIAccount methods
126 * @brief We've been asked to connect.
128 * Sets our username and password for MGTwitterEngine, and validates credentials.
130 * Our connection procedure:
131 * 1. Validate credentials
132 * 2. Retrieve friends
133 * 3. Trigger "periodic" update - DM, replies, timeline
139 [twitterEngine release];
141 twitterEngine = [[MGTwitterEngine alloc] initWithDelegate:self];
143 [twitterEngine setClientName:@"Adium"
144 version:[NSApp applicationVersion]
145 URL:@"http://www.adiumx.com"
146 token:self.sourceToken];
148 [twitterEngine setAPIDomain:[self.host stringByAppendingPathComponent:self.apiPath]];
150 [twitterEngine setUsesSecureConnection:self.useSSL];
153 if (!self.passwordWhileConnected.length) {
154 [self setLastDisconnectionError:TWITTER_OAUTH_NOT_AUTHORIZED];
156 [[NSNotificationCenter defaultCenter] postNotificationName:@"AIEditAccount"
159 [self didDisconnect];
161 // Don't try and connect.
165 twitterEngine.useOAuth = YES;
167 OAToken *token = [[[OAToken alloc] initWithHTTPResponseBody:self.passwordWhileConnected] autorelease];
168 OAConsumer *consumer = [[[OAConsumer alloc] initWithKey:self.consumerKey secret:self.secretKey] autorelease];
170 twitterEngine.accessToken = token;
171 twitterEngine.consumer = consumer;
174 [twitterEngine setUsername:self.UID password:self.passwordWhileConnected];
177 AILogWithSignature(@"%@ connecting to %@", self, twitterEngine.APIDomain);
179 NSString *requestID = [twitterEngine checkUserCredentials];
182 [self setRequestType:AITwitterValidateCredentials forRequestID:requestID withDictionary:nil];
184 [self setLastDisconnectionError:AILocalizedString(@"Unable to connect to server", nil)];
185 [self didDisconnect];
190 * @brief Connection successful
192 * Our credentials were validated correctly. Set up the timeline chat, and request our friends from the server.
198 //Clear any previous disconnection error
199 [self setLastDisconnectionError:nil];
201 // Creating the fake timeline account.
202 AIListBookmark *timelineBookmark = nil;
204 if(!(timelineBookmark = [adium.contactController existingBookmarkForChatName:self.timelineChatName
206 chatCreationInfo:nil])) {
207 AIChat *newTimelineChat = [adium.chatController chatWithName:self.timelineChatName
210 chatCreationInfo:nil];
212 [newTimelineChat setDisplayName:self.timelineChatName];
214 timelineBookmark = [adium.contactController bookmarkForChat:newTimelineChat inGroup:[adium.contactController groupWithUID:TWITTER_REMOTE_GROUP_NAME]];
218 NSTimeInterval updateInterval = [[self preferenceForKey:TWITTER_PREFERENCE_UPDATE_INTERVAL group:TWITTER_PREFERENCE_GROUP_UPDATES] integerValue] * 60;
220 if(updateInterval > 0) {
221 [updateTimer invalidate];
222 updateTimer = [NSTimer scheduledTimerWithTimeInterval:updateInterval
224 selector:@selector(periodicUpdate)
228 [self periodicUpdate];
233 * @brief We've been asked to disconnect.
241 [twitterEngine release]; twitterEngine = nil;
242 [updateTimer invalidate]; updateTimer = nil;
244 [self didDisconnect];
248 * @brief Account will be deleted
250 - (void)willBeDeleted
252 [updateTimer invalidate]; updateTimer = nil;
254 [super willBeDeleted];
258 * @brief Session ended
260 * Remove all state information.
262 - (void)didDisconnect
264 [updateTimer invalidate]; updateTimer = nil;
265 [pendingRequests removeAllObjects];
266 [queuedDM removeAllObjects];
267 [queuedOutgoingDM removeAllObjects];
268 [queuedUpdates removeAllObjects];
270 [super didDisconnect];
276 * The API path extension for the given host.
278 - (NSString *)apiPath
284 * @brief Our source token
286 * On Twitter, our given source token is "adiumofficial".
288 - (NSString *)sourceToken
290 return @"adiumofficial";
294 * @brief Returns whether or not to connect to Twitter API over HTTPS.
302 * @brief Returns whether or not this account is connected via an encrypted connection.
306 return (self.online && [twitterEngine usesSecureConnection]);
310 * @brief Affirm we can open chats.
312 - (BOOL)openChat:(AIChat *)chat
314 [chat setValue:[NSNumber numberWithBool:YES] forProperty:@"Account Joined" notify:NotifyNow];
320 * @brief Allow all chats to close.
322 - (BOOL)closeChat:(AIChat *)inChat
328 * @brief Rejoin the requested chat.
330 - (BOOL)rejoinChat:(AIChat *)inChat
332 [self displayYouHaveConnectedInChat:inChat];
338 * @brief We always want to autocomplete the UID.
340 - (BOOL)chatShouldAutocompleteUID:(AIChat *)inChat
346 * @brief A chat opened.
348 * If this is a group chat which belongs to us, aka a timeline chat, set it up how we want it.
350 - (void)chatDidOpen:(NSNotification *)notification
352 AIChat *chat = [notification object];
354 if(chat.isGroupChat && chat.account == self) {
355 [self updateTimelineChat:chat];
360 * @brief We support adding and removing follows.
362 - (BOOL)contactListEditable
368 * @brief Move contacts
370 * Move existing contacts to a specific group on this account. The passed contacts should already exist somewhere on
372 * @param objects NSArray of AIListContact objects to remove
373 * @param group AIListGroup destination for contacts
375 - (void)moveListObjects:(NSArray *)objects oldGroups:(NSSet *)oldGroups toGroups:(NSSet *)groups
377 // XXX do twitter grouping
381 * @brief Rename a group
383 * Rename a group on this account.
384 * @param group AIListGroup to rename
385 * @param newName NSString name for the group
387 - (void)renameGroup:(AIListGroup *)group to:(NSString *)newName
389 // XXX do twitter grouping
393 * @brief For an invalid password, fail but don't try and reconnect or report it. We do it ourself.
395 - (AIReconnectDelayType)shouldAttemptReconnectAfterDisconnectionError:(NSString **)disconnectionError
397 AIReconnectDelayType reconnectDelayType = [super shouldAttemptReconnectAfterDisconnectionError:disconnectionError];
399 if ([*disconnectionError isEqualToString:TWITTER_INCORRECT_PASSWORD_MESSAGE]) {
400 reconnectDelayType = AIReconnectImmediately;
401 } else if ([*disconnectionError isEqualToString:TWITTER_OAUTH_NOT_AUTHORIZED]) {
402 reconnectDelayType = AIReconnectNeverNoMessage;
405 return reconnectDelayType;
409 * @brief Don't allow OTR encryption.
411 - (BOOL)allowSecureMessagingTogglingForChat:(AIChat *)inChat
417 * @brief Update our status
419 - (void)setSocialNetworkingStatusMessage:(NSAttributedString *)statusMessage
421 NSString *requestID = [twitterEngine sendUpdate:[statusMessage string]];
424 [self setRequestType:AITwitterSendUpdate
425 forRequestID:requestID
430 - (NSString *)encodedAttributedString:(NSAttributedString *)inAttributedString forListObject:(AIListObject *)inListObject
432 return [[inAttributedString attributedStringByConvertingLinksToURLStrings] string];
436 * @brief Send a message
438 * Sends a direct message to the user requested.
439 * If it fails to send, i.e. the request fails, an unknown error will occur.
440 * This is usually caused by the target not following the user.
442 - (BOOL)sendMessageObject:(AIContentMessage *)inContentMessage
446 if(inContentMessage.chat.isGroupChat) {
447 requestID = [twitterEngine sendUpdate:inContentMessage.encodedMessage
448 inReplyTo:[inContentMessage.chat valueForProperty:@"TweetInReplyToStatusID"]];
451 [self setRequestType:AITwitterSendUpdate
452 forRequestID:requestID
453 withDictionary:[NSDictionary dictionaryWithObject:inContentMessage.chat
456 inContentMessage.displayContent = NO;
458 AILogWithSignature(@"%@ Sending update [in reply to %@]: %@", self, [inContentMessage.chat valueForProperty:@"TweetInReplyToStatusID"], inContentMessage.encodedMessage);
462 requestID = [twitterEngine sendDirectMessage:inContentMessage.encodedMessage
463 to:inContentMessage.destination.UID];
466 [self setRequestType:AITwitterDirectMessageSend
467 forRequestID:requestID
468 withDictionary:[NSDictionary dictionaryWithObject:inContentMessage.chat
471 inContentMessage.displayContent = NO;
473 AILogWithSignature(@"%@ Sending DM to %@: %@", self, inContentMessage.destination.UID, inContentMessage.encodedMessage);
478 AILogWithSignature(@"%@ Message immediate fail.", self);
481 return (requestID != nil);
485 * @brief Trigger an info update
487 * This is called when the info inspector wants more information on a contact.
488 * Grab the user's profile information, set everything up accordingly in the user info method.
490 - (void)delayedUpdateContactStatus:(AIListContact *)inContact
496 NSString *requestID = [twitterEngine getUserInformationFor:inContact.UID];
499 [self setRequestType:AITwitterProfileUserInfo
500 forRequestID:requestID
501 withDictionary:[NSDictionary dictionaryWithObject:inContact forKey:@"ListContact"]];
506 * @brief Should an autoreply be sent to this message?
508 - (BOOL)shouldSendAutoreplyToMessage:(AIContentMessage *)message
514 * @brief Update the Twitter profile
516 - (void)setProfileName:(NSString *)name
518 location:(NSString *)location
519 description:(NSString *)description
521 NSString *requestID = [twitterEngine updateProfileName:name
525 description:description];
528 [self setRequestType:AITwitterProfileSelf
529 forRequestID:requestID
536 * @brief Should we store our password based on internal object ID?
538 * We only need to if we're using OAuth.
540 - (BOOL)useInternalObjectIDForPasswordName
542 return self.useOAuth;
546 * @brief Should we connect using OAuth?
548 * If enabled, the account view will display the OAuth setup. Basic authentication will not be used.
556 * @brief OAuth consumer key
558 - (NSString *)consumerKey
560 return @"amjYVOrzKpKkkHAsdEaClA";
564 * @brief OAuth secret key
566 - (NSString *)secretKey
568 return @"kvqM2CQsUO3J6NHctJVhTOzlKZ0k7FsTaR5NwakYU";
572 * @brief Token request URL
574 - (NSString *)tokenRequestURL
576 return @"https://twitter.com/oauth/request_token";
580 * @brief Token access URL
582 - (NSString *)tokenAccessURL
584 return @"https://twitter.com/oauth/access_token";
588 * @brief Token authorize URL
590 - (NSString *)tokenAuthorizeURL
592 return @"https://twitter.com/oauth/authorize";
595 #pragma mark Menu Items
597 * @brief Menu items for contact
599 * Returns an array of menu items for a contact on this account. This is the best place to add protocol-specific
600 * actions that aren't otherwise supported by Adium.
601 * @param inContact AIListContact for menu items
602 * @return NSArray of NSMenuItem instances for the passed contact
604 - (NSArray *)menuItemsForContact:(AIListContact *)inContact
606 NSMutableArray *menuItemArray = [NSMutableArray array];
608 NSMenuItem *menuItem;
610 NSImage *serviceIcon = [AIServiceIcons serviceIconForService:self.service
611 type:AIServiceIconSmall
612 direction:AIIconNormal];
614 menuItem = [[[NSMenuItem allocWithZone:[NSMenu menuZone]] initWithTitle:[NSString stringWithFormat:AILocalizedString(@"Open %@'s user page",nil), inContact.UID]
616 action:@selector(openUserPage:)
617 keyEquivalent:@""] autorelease];
618 [menuItem setImage:serviceIcon];
619 [menuItem setRepresentedObject:inContact];
620 [menuItemArray addObject:menuItem];
622 menuItem = [[[NSMenuItem allocWithZone:[NSMenu menuZone]] initWithTitle:[NSString stringWithFormat:AILocalizedString(@"Enable device notifications for %@",nil), inContact.UID]
624 action:@selector(enableOrDisableNotifications:)
625 keyEquivalent:@""] autorelease];
626 [menuItem setTag:YES];
627 [menuItem setImage:serviceIcon];
628 [menuItem setRepresentedObject:inContact];
629 [menuItemArray addObject:menuItem];
631 menuItem = [[[NSMenuItem allocWithZone:[NSMenu menuZone]] initWithTitle:[NSString stringWithFormat:AILocalizedString(@"Disable device notifications for %@",nil), inContact.UID]
633 action:@selector(enableOrDisableNotifications:)
634 keyEquivalent:@""] autorelease];
635 [menuItem setTag:NO];
636 [menuItem setImage:serviceIcon];
637 [menuItem setRepresentedObject:inContact];
638 [menuItemArray addObject:menuItem];
640 return menuItemArray;
644 * @brief Open the represented objec'ts user page
646 - (void)openUserPage:(NSMenuItem *)menuItem
648 [[NSWorkspace sharedWorkspace] openURL:[NSURL URLWithString:[self addressForLinkType:AITwitterLinkUserPage
649 userID:((AIListContact *)menuItem.representedObject).UID
655 * @brief Enable or disable notifications for a contact.
657 * If the menuItem's tag is YES, we're adding. Otherwise we're removing.
659 - (void)enableOrDisableNotifications:(NSMenuItem *)menuItem
661 if(![menuItem.representedObject isKindOfClass:[AIListContact class]]) {
665 BOOL enableNotification = menuItem.tag;
666 AIListContact *contact = menuItem.representedObject;
668 NSString *requestID = nil;
670 BOOL initialFailure = NO;
672 if (enableNotification) {
673 requestID = [twitterEngine enableNotificationsFor:contact.UID];
676 [self setRequestType:AITwitterNotificationEnable
677 forRequestID:requestID
678 withDictionary:[NSDictionary dictionaryWithObjectsAndKeys:contact, @"ListContact", nil]];
680 initialFailure = YES;
684 requestID = [twitterEngine disableNotificationsFor:contact.UID];
687 [self setRequestType:AITwitterNotificationDisable
688 forRequestID:requestID
689 withDictionary:[NSDictionary dictionaryWithObjectsAndKeys:contact, @"ListContact", nil]];
691 initialFailure = YES;
695 if (initialFailure) {
696 [adium.interfaceController handleErrorMessage:(enableNotification ?
697 AILocalizedString(@"Unable to Enable Notifications", nil) :
698 AILocalizedString(@"Unable to Disable Notifications", nil))
699 withDescription:AILocalizedString(@"Unable to connect to the Twitter server.", nil)];
704 * @brief Menu items for chat
706 * Returns an array of menu items for a chat on this account. This is the best place to add protocol-specific
707 * actions that aren't otherwise supported by Adium.
708 * @param inChat AIChat for menu items
709 * @return NSArray of NSMenuItem instances for the passed contact
711 - (NSArray *)menuItemsForChat:(AIChat *)inChat
713 NSMutableArray *menuItemArray = [NSMutableArray array];
715 NSMenuItem *menuItem;
717 NSImage *serviceIcon = [AIServiceIcons serviceIconForService:self.service
718 type:AIServiceIconSmall
719 direction:AIIconNormal];
721 menuItem = [[[NSMenuItem allocWithZone:[NSMenu menuZone]] initWithTitle:AILocalizedString(@"Update Tweets",nil)
723 action:@selector(periodicUpdate)
724 keyEquivalent:@""] autorelease];
725 [menuItem setImage:serviceIcon];
726 [menuItemArray addObject:menuItem];
728 menuItem = [[[NSMenuItem allocWithZone:[NSMenu menuZone]] initWithTitle:AILocalizedString(@"Reply to a Tweet",nil)
730 action:@selector(replyToTweet)
731 keyEquivalent:@""] autorelease];
732 [menuItem setImage:serviceIcon];
733 [menuItemArray addObject:menuItem];
735 return menuItemArray;
739 * @brief Menu items for the account's actions
741 * Returns an array of menu items for account-specific actions. This is the best place to add protocol-specific
742 * actions that aren't otherwise supported by Adium. It will only be queried if the account is online.
743 * @return NSArray of NSMenuItem instances for this account
745 - (NSArray *)accountActionMenuItems
747 NSMutableArray *menuItemArray = [NSMutableArray array];
749 NSMenuItem *menuItem;
751 menuItem = [[[NSMenuItem allocWithZone:[NSMenu menuZone]] initWithTitle:AILocalizedString(@"Update Tweets",nil)
753 action:@selector(periodicUpdate)
754 keyEquivalent:@""] autorelease];
755 [menuItemArray addObject:menuItem];
757 menuItem = [[[NSMenuItem allocWithZone:[NSMenu menuZone]] initWithTitle:AILocalizedString(@"Reply to a Tweet",nil)
759 action:@selector(replyToTweet)
760 keyEquivalent:@""] autorelease];
761 [menuItemArray addObject:menuItem];
763 menuItem = [[[NSMenuItem allocWithZone:[NSMenu menuZone]] initWithTitle:AILocalizedString(@"Get Rate Limit Amount",nil)
765 action:@selector(getRateLimitAmount)
766 keyEquivalent:@""] autorelease];
767 [menuItemArray addObject:menuItem];
769 return menuItemArray;
773 * @brief Open the reply to tweet window
775 * Opens a window in which the user can create a reply featuring in_reply_to_status_id being set.
779 [AITwitterReplyWindowController showReplyWindowForAccount:self];
783 * @brief Gets the current rate limit amount.
785 - (void)getRateLimitAmount
787 NSString *requestID = [twitterEngine getRateLimitStatus];
790 [self setRequestType:AITwitterRateLimitStatus
791 forRequestID:requestID
797 #pragma mark Contact handling
799 * @brief The name of our timeline chat
801 - (NSString *)timelineChatName
803 return [NSString stringWithFormat:TWITTER_TIMELINE_NAME, self.UID];
807 * @brief Our timeline chat
809 * If the timeline chat is not already active, it is created.
811 - (AIChat *)timelineChat
813 AIChat *timelineChat = [adium.chatController existingChatWithName:self.timelineChatName
817 timelineChat = [adium.chatController chatWithName:self.timelineChatName
820 chatCreationInfo:nil];
827 * @brief Update the timeline chat
829 * Remove the userlist
831 - (void)updateTimelineChat:(AIChat *)timelineChat
833 // Disable the user list on the chat.
834 if (timelineChat.chatContainer.chatViewController.userListVisible) {
835 [timelineChat.chatContainer.chatViewController toggleUserList];
838 // Update the participant list.
839 [timelineChat addParticipatingListObjects:self.contacts notify:NotifyNow];
841 [timelineChat setValue:[NSNumber numberWithInt:140] forProperty:@"Character Counter Max" notify:NotifyNow];
845 * @brief Update serverside icon
847 * This is called by AIUserIcons when it needs an icon update for a contact.
848 * If we already have an icon set (even a cached icon), ignore it.
849 * Otherwise return the Twitter service icon.
851 * This is so that when an unknown contact appears, it has an actual image
852 * to replace in the WKMV when an actual icon update is returned.
854 * This service icon will not remain saved very long, I see no harm in using it.
855 * This only occurs for "strangers".
857 - (NSData *)serversideIconDataForContact:(AIListContact *)listContact
859 if (![AIUserIcons userIconSourceForObject:listContact] &&
860 ![AIUserIcons cachedUserIconDataForObject:listContact]) {
861 return [[self.service defaultServiceIconOfType:AIServiceIconLarge] TIFFRepresentation];
868 * @brief Update a user icon from a URL if necessary
870 - (void)updateUserIcon:(NSString *)url forContact:(AIListContact *)listContact;
872 // If we don't already have an icon for the user...
873 if(![[listContact valueForProperty:TWITTER_PROPERTY_REQUESTED_USER_ICON] boolValue]) {
874 NSString *fileName = [[url lastPathComponent] stringByReplacingOccurrencesOfString:@"_normal." withString:@"_bigger."];
876 url = [[url stringByDeletingLastPathComponent] stringByAppendingPathComponent:fileName];
878 // Grab the user icon and set it as their serverside icon.
879 NSString *requestID = [twitterEngine getImageAtURL:url];
882 [self setRequestType:AITwitterUserIconPull
883 forRequestID:requestID
884 withDictionary:[NSDictionary dictionaryWithObject:listContact forKey:@"ListContact"]];
887 [listContact setValue:[NSNumber numberWithBool:YES] forProperty:TWITTER_PROPERTY_REQUESTED_USER_ICON notify:NotifyNever];
892 * @brief Unfollow the requested contacts.
894 - (void)removeContacts:(NSArray *)objects fromGroups:(NSArray *)groups
896 for (AIListContact *object in objects) {
897 NSString *requestID = [twitterEngine disableUpdatesFor:object.UID];
899 AILogWithSignature(@"%@ Requesting unfollow for: %@", self, object.UID);
902 [self setRequestType:AITwitterRemoveFollow
903 forRequestID:requestID
904 withDictionary:[NSDictionary dictionaryWithObject:object forKey:@"ListContact"]];
910 * @brief Follow the requested contact, trigger an information pull for them.
912 - (void)addContact:(AIListContact *)contact toGroup:(AIListGroup *)group
914 NSString *requestID = [twitterEngine enableUpdatesFor:contact.UID];
916 AILogWithSignature(@"%@ Requesting follow for: %@", self, contact.UID);
919 NSString *updateRequestID = [twitterEngine getUserInformationFor:contact.UID];
921 if (updateRequestID) {
922 [self setRequestType:AITwitterAddFollow
923 forRequestID:updateRequestID
924 withDictionary:[NSDictionary dictionaryWithObjectsAndKeys:contact.UID, @"UID", nil]];
929 #pragma mark Request cataloguing
931 * @brief Set the type and optional dictionary for a request ID
933 * Sets the AITwitterRequestType for a particular request ID, so when the request finishes we can identify what it is for.
934 * Optionally sets a dictionary which can be retrieved in association with the request type.
936 - (void)setRequestType:(AITwitterRequestType)type forRequestID:(NSString *)requestID withDictionary:(NSDictionary *)info
938 [pendingRequests setObject:[NSDictionary dictionaryWithObjectsAndKeys:[NSNumber numberWithInt:type], @"Type",
944 * @brief Get the request type for a request ID
946 - (AITwitterRequestType)requestTypeForRequestID:(NSString *)requestID
948 return [(NSNumber *)[[pendingRequests objectForKey:requestID] objectForKey:@"Type"] intValue];
952 * @brief Get the dictionary associated with a request ID
954 - (NSDictionary *)dictionaryForRequestID:(NSString *)requestID
956 return (NSDictionary *)[[pendingRequests objectForKey:requestID] objectForKey:@"Info"];
960 * @brief Remove a request ID's saved information.
962 - (void)clearRequestTypeForRequestID:(NSString *)requestID
964 [pendingRequests removeObjectForKey:requestID];
967 #pragma mark Preference updating
968 - (void)preferencesChangedForGroup:(NSString *)group key:(NSString *)key object:(AIListObject *)object
969 preferenceDict:(NSDictionary *)prefDict firstTime:(BOOL)firstTime
971 [super preferencesChangedForGroup:group key:key object:object preferenceDict:prefDict firstTime:firstTime];
973 // We only care about our changes.
974 if (object != self) {
978 if([group isEqualToString:GROUP_ACCOUNT_STATUS]) {
979 if([key isEqualToString:KEY_USER_ICON]) {
980 // Avoid pushing an icon update which we just downloaded.
981 if(![self boolValueForProperty:TWITTER_PROPERTY_REQUESTED_USER_ICON]) {
982 NSString *requestID = [twitterEngine updateProfileImage:[prefDict objectForKey:KEY_USER_ICON]];
985 AILogWithSignature(@"%@ Pushing self icon update", self);
987 [self setRequestType:AITwitterProfileSelf
988 forRequestID:requestID
993 [self setValue:nil forProperty:TWITTER_PROPERTY_REQUESTED_USER_ICON notify:NotifyNever];
997 if([group isEqualToString:TWITTER_PREFERENCE_GROUP_UPDATES]) {
998 if(!firstTime && [key isEqualToString:TWITTER_PREFERENCE_UPDATE_INTERVAL]) {
999 NSTimeInterval timeInterval = [updateTimer timeInterval];
1000 NSTimeInterval newTimeInterval = [[prefDict objectForKey:TWITTER_PREFERENCE_UPDATE_INTERVAL] intValue] * 60;
1002 if (timeInterval != newTimeInterval && self.online) {
1003 [updateTimer invalidate]; updateTimer = nil;
1005 if(newTimeInterval > 0) {
1006 updateTimer = [NSTimer scheduledTimerWithTimeInterval:newTimeInterval
1008 selector:@selector(periodicUpdate)
1015 updateAfterSend = [[prefDict objectForKey:TWITTER_PREFERENCE_UPDATE_AFTER_SEND] boolValue];
1016 retweetLink = [[prefDict objectForKey:TWITTER_PREFERENCE_RETWEET_SPAM] boolValue];
1018 if ([key isEqualToString:TWITTER_PREFERENCE_LOAD_CONTACTS] && self.online) {
1019 if ([[prefDict objectForKey:TWITTER_PREFERENCE_LOAD_CONTACTS] boolValue]) {
1020 // Delay updates when loading our contacts list.
1021 [self silenceAllContactUpdatesForInterval:18.0];
1022 // Grab our user list.
1023 NSString *requestID = [twitterEngine getRecentlyUpdatedFriendsFor:self.UID startingAtPage:1];
1026 [self setRequestType:AITwitterInitialUserInfo
1027 forRequestID:requestID
1028 withDictionary:[NSDictionary dictionaryWithObject:[NSNumber numberWithInt:1] forKey:@"Page"]];
1031 [self removeAllContacts];
1037 #pragma mark Periodic update scheduler
1039 * @brief Trigger our periodic updates
1041 - (void)periodicUpdate
1043 if (pendingUpdateCount) {
1044 AILogWithSignature(@"%@ Update already in progress. Count = %d", self, pendingUpdateCount);
1048 NSString *requestID;
1051 // We haven't completed the timeline nor replies. This lets us know if we should display statuses.
1052 followedTimelineCompleted = repliesCompleted = NO;
1053 futureTimelineLastID = futureRepliesLastID = nil;
1055 // Prevent triggering this update routine multiple times.
1056 pendingUpdateCount = 3;
1058 // We haven't printed error messages for this set.
1059 timelineErrorMessagePrinted = NO;
1061 [queuedUpdates removeAllObjects];
1062 [queuedDM removeAllObjects];
1064 AILogWithSignature(@"%@ Periodic update fire", self);
1066 // Pull direct messages
1067 lastID = [self preferenceForKey:TWITTER_PREFERENCE_DM_LAST_ID
1068 group:TWITTER_PREFERENCE_GROUP_UPDATES];
1070 requestID = [twitterEngine getDirectMessagesSinceID:lastID startingAtPage:1];
1073 [self setRequestType:AITwitterUpdateDirectMessage
1074 forRequestID:requestID
1075 withDictionary:[NSDictionary dictionaryWithObjectsAndKeys:[NSNumber numberWithInt:1], @"Page", nil]];
1077 --pendingUpdateCount;
1080 // Pull followed timeline
1081 lastID = [self preferenceForKey:TWITTER_PREFERENCE_TIMELINE_LAST_ID
1082 group:TWITTER_PREFERENCE_GROUP_UPDATES];
1084 requestID = [twitterEngine getFollowedTimelineFor:nil
1087 count:(lastID ? TWITTER_UPDATE_TIMELINE_COUNT : TWITTER_UPDATE_TIMELINE_COUNT_FIRST_RUN)];
1090 [self setRequestType:AITwitterUpdateFollowedTimeline
1091 forRequestID:requestID
1092 withDictionary:[NSDictionary dictionaryWithObjectsAndKeys:[NSNumber numberWithInt:1], @"Page", nil]];
1094 --pendingUpdateCount;
1097 // Pull the replies feed
1098 lastID = [self preferenceForKey:TWITTER_PREFERENCE_REPLIES_LAST_ID
1099 group:TWITTER_PREFERENCE_GROUP_UPDATES];
1101 requestID = [twitterEngine getRepliesSinceID:lastID startingAtPage:1];
1104 [self setRequestType:AITwitterUpdateReplies
1105 forRequestID:requestID
1106 withDictionary:[NSDictionary dictionaryWithObjectsAndKeys:[NSNumber numberWithInt:1], @"Page", nil]];
1108 --pendingUpdateCount;
1112 #pragma mark Message Display
1114 * @brief Returns a user-readable message for an error code.
1116 - (NSString *)errorMessageForError:(NSError *)error
1118 switch (error.code) {
1120 // Bad Request: your request is invalid, and we'll return an error message that tells you why.
1121 // This is the status code returned if you've exceeded the rate limit.
1122 return AILocalizedString(@"You've exceeded the rate limit.", nil);
1126 // Not Authorized: either you need to provide authentication credentials, or the credentials provided aren't valid.
1127 return AILocalizedString(@"Your credentials do not allow you access.", nil);
1131 // Forbidden: we understand your request, but are refusing to fulfill it. An accompanying error message should explain why.
1132 return AILocalizedString(@"Request refused by the server.", nil);
1136 // Not Found: either you're requesting an invalid URI or the resource in question doesn't exist (ex: no such user).
1137 return AILocalizedString(@"Requested resource not found.", nil);
1141 // Internal Server Error: we did something wrong. Please post to the group about it and the Twitter team will investigate.
1142 return AILocalizedString(@"The server reported an internal error.", nil);
1146 // Bad Gateway: returned if Twitter is down or being upgraded.
1147 return AILocalizedString(@"The server is currently down.", nil);
1153 // Service Unavailable: the Twitter servers are up, but are overloaded with requests. Try again later.
1154 return AILocalizedString(@"The server is overloaded with requests.", nil);
1159 return [NSString stringWithFormat:AILocalizedString(@"Unknown error: code %d, %@", nil), error.code, error.localizedDescription];
1163 * @brief Returns the link URL for a specific type of link
1165 - (NSString *)addressForLinkType:(AITwitterLinkType)linkType
1166 userID:(NSString *)userID
1167 statusID:(NSString *)statusID
1168 context:(NSString *)context
1170 NSString *address = nil;
1172 if (linkType == AITwitterLinkStatus) {
1173 address = [NSString stringWithFormat:@"https://twitter.com/%@/status/%@", userID, statusID];
1174 } else if (linkType == AITwitterLinkFriends) {
1175 address = [NSString stringWithFormat:@"https://twitter.com/%@/friends", userID];
1176 } else if (linkType == AITwitterLinkFollowers) {
1177 address = [NSString stringWithFormat:@"https://twitter.com/%@/followers", userID];
1178 } else if (linkType == AITwitterLinkUserPage) {
1179 address = [NSString stringWithFormat:@"https://twitter.com/%@", userID];
1180 } else if (linkType == AITwitterLinkSearchHash) {
1181 address = [NSString stringWithFormat:@"http://search.twitter.com/search?q=%%23%@", context];
1182 } else if (linkType == AITwitterLinkReply) {
1183 address = [NSString stringWithFormat:@"twitterreply://%@@%@?action=reply&status=%@", self.internalObjectID, userID, statusID];
1184 } else if (linkType == AITwitterLinkRetweet) {
1185 address = [NSString stringWithFormat:@"twitterreply://%@@%@?action=retweet&status=%@&message=%@", self.internalObjectID, userID, statusID, context];
1186 } else if (linkType == AITwitterLinkFavorite) {
1187 address = [NSString stringWithFormat:@"twitterreply://%@@%@?action=favorite&status=%@", self.internalObjectID, userID, statusID];
1188 } else if (linkType == AITwitterLinkDestroyStatus) {
1189 address = [NSString stringWithFormat:@"twitterreply://%@@%@?action=destroy&status=%@&message=%@", self.internalObjectID, userID, statusID, context];
1190 } else if (linkType == AITwitterLinkDestroyDM) {
1191 address = [NSString stringWithFormat:@"twitterreply://%@@%@?action=destroy&dm=%@&message=%@", self.internalObjectID, userID, statusID, context];
1198 * @brief Retweet the selected tweet.
1200 * Attempts to retweet a tweet.
1201 * Prints a status message in the chat on success/failure, behaves identical to sending a new tweet.
1203 - (void)retweetTweet:(NSString *)tweetID
1205 NSString *requestID = [twitterEngine retweetUpdate:tweetID];
1208 [self setRequestType:AITwitterSendUpdate
1209 forRequestID:requestID
1210 withDictionary:[NSDictionary dictionaryWithObjectsAndKeys:tweetID, @"tweetID", nil]];
1212 [self.timelineChat receivedError:[NSNumber numberWithInt:AIChatMessageSendingConnectionError]];
1217 * @brief Toggle the favorite status for a tweet.
1219 * Attempts to favorite a tweet. If that fails, it removes favorite status.
1220 * Prints a status message in the chat on success/failure, since it's otherwise not obvious.
1222 - (void)toggleFavoriteTweet:(NSString *)tweetID
1224 NSString *requestID = [twitterEngine markUpdate:tweetID asFavorite:YES];
1227 [self setRequestType:AITwitterFavoriteYes
1228 forRequestID:requestID
1229 withDictionary:[NSDictionary dictionaryWithObjectsAndKeys:tweetID, @"tweetID", nil]];
1231 AIChat *timelineChat = self.timelineChat;
1233 [adium.contentController displayEvent:AILocalizedString(@"Attempt to favorite tweet failed to connect.", nil)
1235 inChat:timelineChat];
1240 * @brief Destroy the tweet.
1242 * The user has already confirmed they want to destroy it; send the message.
1244 - (void)destroyTweet:(NSString *)tweetID
1246 NSString *requestID = [twitterEngine deleteUpdate:tweetID];
1249 [self setRequestType:AITwitterDestroyStatus
1250 forRequestID:requestID
1251 withDictionary:nil];
1253 AIChat *timelineChat = self.timelineChat;
1255 [adium.contentController displayEvent:AILocalizedString(@"Attempt to delete tweet failed to connect.", nil)
1257 inChat:timelineChat];
1262 * @brief Destroy the DM.
1264 * The user has already confirmed they want to destroy it; send the message.
1266 - (void)destroyDirectMessage:(NSString *)messageID
1267 forUser:(NSString *)userID
1269 NSString *requestID = [twitterEngine deleteDirectMessage:messageID];
1270 AIListContact *contact = [self contactWithUID:userID];
1273 [self setRequestType:AITwitterDestroyDM
1274 forRequestID:requestID
1275 withDictionary:[NSDictionary dictionaryWithObject:contact forKey:@"ListContact"]];
1277 AIChat *chat = [adium.chatController chatWithContact:contact];
1279 [adium.contentController displayEvent:AILocalizedString(@"Attempt to delete tweet failed to connect.", nil)
1286 * @brief Convert a link URL and name into an attributed link
1288 * @param label The text to display for the link.
1289 * @param destination The destination address for the link.
1290 * @param attributeName The name of the twitter link attribute for HTML processing.
1292 - (NSAttributedString *)attributedStringWithLinkLabel:(NSString *)label
1293 linkDestination:(NSString *)destination
1294 linkClass:(NSString *)className
1296 NSURL *url = [NSURL URLWithString:destination];
1297 NSDictionary *attributes = [NSDictionary dictionaryWithObjectsAndKeys:
1298 url, NSLinkAttributeName,
1299 className, AIElementClassAttributeName, nil];
1301 return [[[NSAttributedString alloc] initWithString:label attributes:attributes] autorelease];
1305 * @brief Parse an attributed string into a linkified version.
1307 - (NSAttributedString *)linkifiedAttributedStringFromString:(NSAttributedString *)inString
1309 NSAttributedString *attributedString;
1311 static NSCharacterSet *usernameCharacters = nil;
1312 static NSCharacterSet *hashCharacters = nil;
1314 if (!usernameCharacters) {
1315 usernameCharacters = [[NSCharacterSet characterSetWithCharactersInString:@"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_"] retain];
1318 if (!hashCharacters) {
1319 NSMutableCharacterSet *disallowedCharacters = [[NSCharacterSet punctuationCharacterSet] mutableCopy];
1320 [disallowedCharacters formUnionWithCharacterSet:[NSCharacterSet whitespaceCharacterSet]];
1322 hashCharacters = [[disallowedCharacters invertedSet] retain];
1324 [disallowedCharacters release];
1327 attributedString = [AITwitterURLParser linkifiedStringFromAttributedString:inString
1328 forPrefixCharacter:@"@"
1329 forLinkType:AITwitterLinkUserPage
1331 validCharacterSet:usernameCharacters];
1333 attributedString = [AITwitterURLParser linkifiedStringFromAttributedString:attributedString
1334 forPrefixCharacter:@"#"
1335 forLinkType:AITwitterLinkSearchHash
1337 validCharacterSet:hashCharacters];
1339 return attributedString;
1343 * @brief Parses a Twitter message into an attributed string
1345 - (NSAttributedString *)parseMessage:(NSString *)inMessage
1346 tweetID:(NSString *)tweetID
1347 userID:(NSString *)userID
1348 inReplyToUser:(NSString *)replyUserID
1349 inReplyToTweetID:(NSString *)replyTweetID
1351 NSAttributedString *message;
1353 message = [NSAttributedString stringWithString:[inMessage stringByUnescapingFromXMLWithEntities:nil]];
1355 message = [self linkifiedAttributedStringFromString:message];
1357 BOOL replyTweet = (replyTweetID.length);
1358 BOOL tweetLink = (tweetID.length && userID.length);
1360 if (replyTweet || tweetLink) {
1361 NSMutableAttributedString *mutableMessage = [[message mutableCopy] autorelease];
1363 NSUInteger startIndex = message.length;
1365 [mutableMessage appendString:@" (" withAttributes:nil];
1367 BOOL commaNeeded = NO;
1369 // Append a link to the tweet this is in reply to
1371 NSString *linkAddress = [self addressForLinkType:AITwitterLinkStatus
1373 statusID:replyTweetID
1376 if([inMessage hasPrefix:@"@"] &&
1377 inMessage.length >= replyUserID.length + 1 &&
1378 [replyUserID isCaseInsensitivelyEqualToString:[inMessage substringWithRange:NSMakeRange(1, replyUserID.length)]]) {
1379 // If the message has a "@" prefix, it's a proper in_reply_to_status_id if the usernames match. Set a link appropriately.
1380 [mutableMessage setAttributes:[NSDictionary dictionaryWithObjectsAndKeys:linkAddress, NSLinkAttributeName, nil]
1381 range:NSMakeRange(0, replyUserID.length + 1)];
1383 // This happens for mentions which are in_reply_to_status_id but the @target isn't the first part of the message.
1385 [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")
1386 linkDestination:linkAddress
1387 linkClass:AITwitterInReplyToClassName]];
1393 // Append a link to reply to this tweet
1395 NSString *linkAddress;
1397 if(![self.UID isCaseInsensitivelyEqualToString:userID]) {
1398 // A message from someone other than ourselves. RT and @ is permissible.
1401 [mutableMessage appendString:@", " withAttributes:nil];
1404 linkAddress = [self addressForLinkType:AITwitterLinkRetweet
1407 context:[inMessage stringByAddingPercentEscapesForAllCharacters]];
1409 [mutableMessage appendAttributedString:[self attributedStringWithLinkLabel:@"RT"
1410 linkDestination:linkAddress
1411 linkClass:AITwitterRetweetClassName]];
1416 [mutableMessage appendString:@", " withAttributes:nil];
1419 linkAddress = [self addressForLinkType:AITwitterLinkReply
1424 [mutableMessage appendAttributedString:[self attributedStringWithLinkLabel:@"@"
1425 linkDestination:linkAddress
1426 linkClass:AITwitterReplyClassName]];
1429 [mutableMessage appendString:@", " withAttributes:nil];
1432 // Our own message. Display a destroy link.
1433 linkAddress = [self addressForLinkType:AITwitterLinkDestroyStatus
1436 context:[inMessage stringByAddingPercentEscapesForAllCharacters]];
1438 [mutableMessage appendAttributedString:[self attributedStringWithLinkLabel:@"\u232B"
1439 linkDestination:linkAddress
1440 linkClass:AITwitterDeleteClassName]];
1443 [mutableMessage appendString:@", " withAttributes:nil];
1445 linkAddress = [self addressForLinkType:AITwitterLinkFavorite
1450 [mutableMessage appendAttributedString:[self attributedStringWithLinkLabel:@"\u2606"
1451 linkDestination:linkAddress
1452 linkClass:AITwitterFavoriteClassName]];
1454 [mutableMessage appendString:@", " withAttributes:nil];
1456 linkAddress = [self addressForLinkType:AITwitterLinkStatus
1461 [mutableMessage appendAttributedString:[self attributedStringWithLinkLabel:@"#"
1462 linkDestination:linkAddress
1463 linkClass:AITwitterStatusLinkClassName]];
1467 [mutableMessage appendString:@")" withAttributes:nil];
1469 [mutableMessage addAttributes:[NSDictionary dictionaryWithObjectsAndKeys:
1470 [NSNumber numberWithBool:YES], AITwitterActionLinksAttributeName,
1471 [NSNumber numberWithBool:YES], AIHiddenMessagePartAttributeName, nil]
1472 range:NSMakeRange(startIndex, mutableMessage.length - startIndex)];
1474 return mutableMessage;
1481 * @brief Parse a direct message
1483 - (NSAttributedString *)parseDirectMessage:(NSString *)inMessage
1484 withID:(NSString *)dmID
1485 fromUser:(NSString *)sourceUID
1487 NSAttributedString *message;
1489 message = [NSAttributedString stringWithString:[inMessage stringByUnescapingFromXMLWithEntities:nil]];
1491 message = [self linkifiedAttributedStringFromString:message];
1493 NSMutableAttributedString *mutableMessage = [[message mutableCopy] autorelease];
1495 NSUInteger startIndex = message.length;
1497 [mutableMessage appendString:@" (" withAttributes:nil];
1499 NSString *linkAddress = [self addressForLinkType:AITwitterLinkDestroyDM
1502 context:[inMessage stringByAddingPercentEscapesForAllCharacters]];
1504 [mutableMessage appendAttributedString:[self attributedStringWithLinkLabel:@"\u232B"
1505 linkDestination:linkAddress
1506 linkClass:AITwitterDeleteClassName]];
1508 [mutableMessage appendString:@")" withAttributes:nil];
1510 [mutableMessage addAttributes:[NSDictionary dictionaryWithObjectsAndKeys:
1511 [NSNumber numberWithBool:YES], AITwitterActionLinksAttributeName,
1512 [NSNumber numberWithBool:YES], AIHiddenMessagePartAttributeName, nil]
1513 range:NSMakeRange(startIndex, mutableMessage.length - startIndex)];
1515 return mutableMessage;
1520 * @brief Sort status updates
1522 NSInteger queuedUpdatesSort(id update1, id update2, void *context)
1524 return [[update1 objectForKey:TWITTER_STATUS_CREATED] compare:[update2 objectForKey:TWITTER_STATUS_CREATED]];
1528 * @brief Sort direct messages
1530 NSInteger queuedDMSort(id dm1, id dm2, void *context)
1532 return [[dm1 objectForKey:TWITTER_DM_CREATED] compare:[dm2 objectForKey:TWITTER_DM_CREATED]];
1536 * @brief Remove duplicate status updates.
1538 * If we're following someone who replies to us, we'll receive a status update in both the
1539 * timeline and the reply feed.
1541 * @param inArray The sorted array of Tweets
1543 - (NSArray *)arrayWithDuplicateTweetsRemoved:(NSArray *)inArray
1545 NSMutableArray *mutableArray = [inArray mutableCopy];
1547 NSDictionary *status = nil, *previousStatus = nil;
1549 // Starting at index 1, checking backwards. We'll never exceed bounds this way.
1550 for(NSUInteger index = 1; index < inArray.count; index++)
1552 status = [inArray objectAtIndex:index];
1553 previousStatus = [inArray objectAtIndex:index-1];
1555 if([[status objectForKey:TWITTER_STATUS_ID] isEqualToString:[previousStatus objectForKey:TWITTER_STATUS_ID]]) {
1556 [mutableArray removeObject:status];
1560 return [mutableArray autorelease];
1564 * @brief Display queued updates or direct messages
1566 * This could potentially be simplified since both DMs and updates have the same format.
1568 - (void)displayQueuedUpdatesForRequestType:(AITwitterRequestType)requestType
1570 if(requestType == AITwitterUpdateReplies || requestType == AITwitterUpdateFollowedTimeline) {
1571 if(!queuedUpdates.count) {
1575 AILogWithSignature(@"%@ Displaying %d updates", self, queuedUpdates.count);
1577 // Sort the queued updates (since we're intermingling pages of data from different souces)
1578 NSArray *sortedQueuedUpdates = [queuedUpdates sortedArrayUsingFunction:queuedUpdatesSort context:nil];
1580 sortedQueuedUpdates = [self arrayWithDuplicateTweetsRemoved:sortedQueuedUpdates];
1582 BOOL trackContent = [[self preferenceForKey:TWITTER_PREFERENCE_EVER_LOADED_TIMELINE group:TWITTER_PREFERENCE_GROUP_UPDATES] boolValue];
1584 AIChat *timelineChat = self.timelineChat;
1586 [[AIContactObserverManager sharedManager] delayListObjectNotifications];
1588 for (NSDictionary *status in sortedQueuedUpdates) {
1589 NSDate *date = [status objectForKey:TWITTER_STATUS_CREATED];
1590 NSString *text = [status objectForKey:TWITTER_STATUS_TEXT];
1592 NSString *contactUID = [[status objectForKey:TWITTER_STATUS_USER] objectForKey:TWITTER_STATUS_UID];
1594 id fromObject = nil;
1596 if(![self.UID isCaseInsensitivelyEqualToString:contactUID]) {
1597 AIListContact *listContact = [self contactWithUID:contactUID];
1599 // Update the user's status message
1600 [listContact setStatusMessage:[NSAttributedString stringWithString:[text stringByUnescapingFromXMLWithEntities:nil]]
1603 [self updateUserIcon:[[status objectForKey:TWITTER_STATUS_USER] objectForKey:TWITTER_INFO_ICON] forContact:listContact];
1605 [timelineChat addParticipatingListObject:listContact notify:NotifyNow];
1607 fromObject = (id)listContact;
1609 fromObject = (id)self;
1612 NSAttributedString *message = [self parseMessage:text
1613 tweetID:[status objectForKey:TWITTER_STATUS_ID]
1615 inReplyToUser:[status objectForKey:TWITTER_STATUS_REPLY_UID]
1616 inReplyToTweetID:[status objectForKey:TWITTER_STATUS_REPLY_ID]];
1618 AIContentMessage *contentMessage = [AIContentMessage messageInChat:timelineChat
1619 withSource:fromObject
1625 contentMessage.trackContent = trackContent;
1627 [adium.contentController receiveContentObject:contentMessage];
1630 [[AIContactObserverManager sharedManager] endListObjectNotificationsDelay];
1632 [queuedUpdates removeAllObjects];
1633 } else if (requestType == AITwitterUpdateDirectMessage || requestType == AITwitterDirectMessageSend) {
1634 NSMutableArray **unsortedArray = (requestType == AITwitterUpdateDirectMessage) ? &queuedDM : &queuedOutgoingDM;
1636 if (!(*unsortedArray).count) {
1640 AILogWithSignature(@"%@ Displaying %d DMs", self, queuedDM.count);
1642 NSArray *sortedQueuedDM = [*unsortedArray sortedArrayUsingFunction:queuedDMSort context:nil];
1644 for (NSDictionary *message in sortedQueuedDM) {
1645 NSDate *date = [message objectForKey:TWITTER_DM_CREATED];
1646 NSString *text = [message objectForKey:TWITTER_DM_TEXT];
1647 NSString *fromUID = [message objectForKey:TWITTER_DM_SENDER_UID];
1648 NSString *toUID = [message objectForKey:TWITTER_DM_RECIPIENT_UID];
1650 AIListObject *source = nil, *destination = nil;
1653 if([self.UID isCaseInsensitivelyEqualToString:fromUID]) {
1654 // This is a message we sent; display as coming from us.
1656 destination = [self contactWithUID:toUID];
1657 chat = [adium.chatController chatWithContact:(AIListContact *)destination];
1659 source = [self contactWithUID:fromUID];
1661 chat = [adium.chatController chatWithContact:(AIListContact *)source];
1664 if(chat && source && destination) {
1665 AIContentMessage *contentMessage = [AIContentMessage messageInChat:chat
1667 destination:destination
1669 message:[self parseDirectMessage:text
1670 withID:[message objectForKey:TWITTER_DM_ID]
1671 fromUser:chat.listObject.UID]
1674 [adium.contentController receiveContentObject:contentMessage];
1678 [*unsortedArray removeAllObjects];
1682 #pragma mark MGTwitterEngine Delegate Methods
1684 * @brief A request was successful
1686 * We only care about requests succeeding if they aren't specifically handled in another location.
1688 - (void)requestSucceeded:(NSString *)identifier
1690 // If a request succeeds and we think we're offline, call ourselves online.
1691 if ([self requestTypeForRequestID:identifier] == AITwitterDisconnect) {
1692 [self didDisconnect];
1693 } else if ([self requestTypeForRequestID:identifier] == AITwitterRemoveFollow) {
1694 AIListContact *listContact = [[self dictionaryForRequestID:identifier] objectForKey:@"ListContact"];
1696 for (NSString *groupName in listContact.remoteGroupNames) {
1697 [listContact removeRemoteGroupName:groupName];
1699 } else if ([self requestTypeForRequestID:identifier] == AITwitterDestroyStatus) {
1700 AIChat *timelineChat = self.timelineChat;
1702 [adium.contentController displayEvent:AILocalizedString(@"Your tweet has been successfully deleted.", nil)
1704 inChat:timelineChat];
1705 } else if ([self requestTypeForRequestID:identifier] == AITwitterDestroyDM) {
1706 AIListContact *contact = [[self dictionaryForRequestID:identifier] objectForKey:@"ListContact"];
1707 AIChat *chat = [adium.chatController chatWithContact:contact];
1709 [adium.contentController displayEvent:AILocalizedString(@"The direct message has been successfully deleted.", nil)
1716 * @brief A request failed
1718 * If it's a fatal error, we need to kill the session and retry. Otherwise, twitter's reliability is
1719 * pretty terrible, so let's ignore errors for the most part.
1721 - (void)requestFailed:(NSString *)identifier withError:(NSError *)error
1723 switch ([self requestTypeForRequestID:identifier]) {
1724 case AITwitterDirectMessageSend:
1725 case AITwitterSendUpdate:
1727 AIChat *chat = [[self dictionaryForRequestID:identifier] objectForKey:@"Chat"];
1730 [chat receivedError:[NSNumber numberWithInt:AIChatMessageSendingConnectionError]];
1732 AILogWithSignature(@"%@ Chat send error on %@", self, chat);
1737 case AITwitterDisconnect:
1738 [self didDisconnect];
1741 case AITwitterInitialUserInfo:
1742 [self setLastDisconnectionError:AILocalizedString(@"Unable to retrieve user list [fail]", "Message when a (vital) twitter request to retrieve the follow list fails")];
1743 [self didDisconnect];
1746 case AITwitterUserIconPull:
1748 AIListContact *listContact = [[self dictionaryForRequestID:identifier] objectForKey:@"ListContact"];
1750 // Image pull failed, flag ourselves as needing to try again.
1751 [listContact setValue:nil forProperty:TWITTER_PROPERTY_REQUESTED_USER_ICON notify:NotifyNever];
1755 case AITwitterUpdateFollowedTimeline:
1756 case AITwitterUpdateReplies:
1758 AIChat *timelineChat = [adium.chatController existingChatWithName:self.timelineChatName
1761 // Only print an error if the user already has the timeline open. Beyond annoying if we pop it open just to say "lol error"
1762 if (timelineChat && !timelineErrorMessagePrinted) {
1763 AIContentEvent *content = [AIContentEvent eventInChat:timelineChat
1767 message:[NSAttributedString stringWithString:[NSString stringWithFormat:AILocalizedString(@"Unable to update timeline: %@", nil),
1768 [self errorMessageForError:error]]]
1771 content.postProcessContent = NO;
1772 content.coalescingKey = @"error";
1774 [adium.contentController receiveContentObject:content];
1776 // This gets reset to NO the next a periodic update fires.
1777 timelineErrorMessagePrinted = YES;
1780 --pendingUpdateCount;
1784 case AITwitterUpdateDirectMessage:
1785 --pendingUpdateCount;
1788 case AITwitterAddFollow:
1789 if(error.code == 404) {
1790 [adium.interfaceController handleErrorMessage:AILocalizedString(@"Unable to Add Contact", nil)
1791 withDescription:[NSString stringWithFormat:AILocalizedString(@"Unable to add %@ to account %@, the user does not exist.", nil),
1792 [[self dictionaryForRequestID:identifier] objectForKey:@"UID"],
1793 self.explicitFormattedUID]];
1795 [adium.interfaceController handleErrorMessage:AILocalizedString(@"Unable to Add Contact", nil)
1796 withDescription:[NSString stringWithFormat:AILocalizedString(@"Unable to add %@ to account %@. %@",nil),
1797 [[self dictionaryForRequestID:identifier] objectForKey:@"UID"],
1798 self.explicitFormattedUID,
1799 [self errorMessageForError:error]]];
1803 case AITwitterRemoveFollow:
1804 [adium.interfaceController handleErrorMessage:AILocalizedString(@"Unable to Remove Contact", nil)
1805 withDescription:[NSString stringWithFormat:AILocalizedString(@"Unable to remove %@ on account %@. %@", nil),
1806 ((AIListContact *)[[self dictionaryForRequestID:identifier] objectForKey:@"ListContact"]).UID,
1807 self.explicitFormattedUID,
1808 [self errorMessageForError:error]]];
1811 case AITwitterValidateCredentials:
1812 if(error.code == 401) {
1814 [self setPasswordTemporarily:nil];
1815 [self setLastDisconnectionError:TWITTER_OAUTH_NOT_AUTHORIZED];
1817 [[NSNotificationCenter defaultCenter] postNotificationName:@"AIEditAccount"
1821 [self setLastDisconnectionError:TWITTER_INCORRECT_PASSWORD_MESSAGE];
1822 [self serverReportedInvalidPassword];
1825 [self didDisconnect];
1827 [self setLastDisconnectionError:AILocalizedString(@"Unable to validate credentials", nil)];
1828 [self didDisconnect];
1832 case AITwitterFavoriteYes:
1833 case AITwitterFavoriteNo:
1835 AIChat *timelineChat = self.timelineChat;
1837 if (error.code == 403) {
1838 // We've attempted to add or remove when we already have it marked as such. Try the opposite.
1839 BOOL addAsFavorite = ([self requestTypeForRequestID:identifier] == AITwitterFavoriteNo);
1840 NSString *tweetID = [[self dictionaryForRequestID:identifier] objectForKey:@"tweetID"];
1842 NSString *requestID = [twitterEngine markUpdate:tweetID
1843 asFavorite:addAsFavorite];
1846 [self setRequestType:(addAsFavorite ? AITwitterFavoriteYes : AITwitterFavoriteNo)
1847 forRequestID:requestID
1848 withDictionary:[NSDictionary dictionaryWithObjectsAndKeys:tweetID, @"tweetID", nil]];
1850 [adium.contentController displayEvent:AILocalizedString(@"Attempt to favorite tweet failed to connect.", nil)
1852 inChat:timelineChat];
1855 [adium.contentController displayEvent:[NSString stringWithFormat:AILocalizedString(@"Attempt to favorite tweet failed. %@", nil), [self errorMessageForError:error]]
1857 inChat:timelineChat];
1863 case AITwitterNotificationEnable:
1864 case AITwitterNotificationDisable:
1866 BOOL enableNotification = ([self requestTypeForRequestID:identifier] == AITwitterNotificationEnable);
1867 AIListContact *listContact = [[self dictionaryForRequestID:identifier] objectForKey:@"ListContact"];
1869 [adium.interfaceController handleErrorMessage:(enableNotification ?
1870 AILocalizedString(@"Unable to Enable Notifications", nil) :
1871 AILocalizedString(@"Unable to Disable Notifications", nil))
1872 withDescription:[NSString stringWithFormat:AILocalizedString(@"Cannot change notification setting for %@. %@", nil), listContact.UID, [self errorMessageForError:error]]];
1876 case AITwitterDestroyStatus:
1878 AIChat *timelineChat = self.timelineChat;
1880 [adium.contentController displayEvent:[NSString stringWithFormat:AILocalizedString(@"Your tweet failed to delete. %@", nil), [self errorMessageForError:error]]
1882 inChat:timelineChat];
1886 case AITwitterDestroyDM:
1888 AIListContact *contact = [[self dictionaryForRequestID:identifier] objectForKey:@"ListContact"];
1889 AIChat *chat = [adium.chatController chatWithContact:contact];
1891 [adium.contentController displayEvent:[NSString stringWithFormat:AILocalizedString(@"The direct message failed to delete. %@", nil), [self errorMessageForError:error]]
1897 case AITwitterUnknownType:
1898 case AITwitterRateLimitStatus:
1899 case AITwitterProfileSelf:
1900 case AITwitterSelfUserIconPull:
1901 case AITwitterProfileUserInfo:
1902 case AITwitterProfileStatusUpdates:
1903 // While we don't handle the errors, it's a good idea to not have a "default" just to prevent accidentally letting something
1904 // we should really handle slip through.
1909 AILogWithSignature(@"%@ Request failed (%@ - %u) - %@", self, identifier, [self requestTypeForRequestID:identifier], error);
1911 [self clearRequestTypeForRequestID:identifier];
1915 * @brief Status updates received
1917 - (void)statusesReceived:(NSArray *)statuses forRequest:(NSString *)identifier
1919 if([self requestTypeForRequestID:identifier] == AITwitterUpdateFollowedTimeline ||
1920 [self requestTypeForRequestID:identifier] == AITwitterUpdateReplies) {
1923 BOOL nextPageNecessary = NO;
1925 if([self requestTypeForRequestID:identifier] == AITwitterUpdateFollowedTimeline) {
1926 lastID = [self preferenceForKey:TWITTER_PREFERENCE_TIMELINE_LAST_ID
1927 group:TWITTER_PREFERENCE_GROUP_UPDATES];
1929 nextPageNecessary = (lastID && statuses.count >= TWITTER_UPDATE_TIMELINE_COUNT - 5);
1931 lastID = [self preferenceForKey:TWITTER_PREFERENCE_REPLIES_LAST_ID
1932 group:TWITTER_PREFERENCE_GROUP_UPDATES];
1934 nextPageNecessary = (lastID && statuses.count >= TWITTER_UPDATE_REPLIES_COUNT - 5);
1937 // Store the largest tweet ID we find; this will be our "last ID" the next time we run.
1938 NSString *largestTweet = [[self dictionaryForRequestID:identifier] objectForKey:@"LargestTweet"];
1940 // The largest ID is first, compare.
1941 if (statuses.count) {
1942 NSString *tweetID = [[statuses objectAtIndex:0] objectForKey:TWITTER_STATUS_ID];
1943 if (!largestTweet || [largestTweet compare:tweetID options:NSNumericSearch] == NSOrderedAscending) {
1944 largestTweet = tweetID;
1948 [queuedUpdates addObjectsFromArray:statuses];
1950 AILogWithSignature(@"%@ Last ID: %@ Largest Tweet: %@ Next Page Necessary: %d", self, lastID, largestTweet, nextPageNecessary);
1952 // See if we need to pull more updates.
1953 if (nextPageNecessary) {
1954 NSInteger nextPage = [[[self dictionaryForRequestID:identifier] objectForKey:@"Page"] intValue] + 1;
1955 NSString *requestID;
1957 if ([self requestTypeForRequestID:identifier] == AITwitterUpdateFollowedTimeline) {
1958 requestID = [twitterEngine getFollowedTimelineFor:nil
1960 startingAtPage:nextPage
1961 count:TWITTER_UPDATE_TIMELINE_COUNT];
1963 AILogWithSignature(@"%@ Pulling additional timeline page %d", self, nextPage);
1966 [self setRequestType:AITwitterUpdateFollowedTimeline
1967 forRequestID:requestID
1968 withDictionary:[NSDictionary dictionaryWithObjectsAndKeys:[NSNumber numberWithInt:nextPage], @"Page",
1969 largestTweet, @"LargestTweet", nil]];
1971 // Gracefully fail: remove all stored objects.
1972 AILogWithSignature(@"%@ Immediate timeline fail", self);
1973 --pendingUpdateCount;
1974 [queuedUpdates removeAllObjects];
1977 } else if ([self requestTypeForRequestID:identifier] == AITwitterUpdateReplies) {
1978 requestID = [twitterEngine getRepliesSinceID:lastID startingAtPage:nextPage];
1980 AILogWithSignature(@"%@ Pulling additional replies page %d", self, nextPage);
1983 [self setRequestType:AITwitterUpdateReplies
1984 forRequestID:requestID
1985 withDictionary:[NSDictionary dictionaryWithObjectsAndKeys:[NSNumber numberWithInt:nextPage], @"Page",
1986 largestTweet, @"LargestTweet", nil]];
1988 // Gracefully fail: remove all stored objects.
1989 AILogWithSignature(@"%@ Immediate reply fail", self);
1990 --pendingUpdateCount;
1991 [queuedUpdates removeAllObjects];
1995 if([self requestTypeForRequestID:identifier] == AITwitterUpdateFollowedTimeline) {
1996 followedTimelineCompleted = YES;
1997 futureTimelineLastID = [largestTweet retain];
1998 } else if ([self requestTypeForRequestID:identifier] == AITwitterUpdateReplies) {
1999 repliesCompleted = YES;
2000 futureRepliesLastID = [largestTweet retain];
2003 --pendingUpdateCount;
2005 AILogWithSignature(@"%@ Followed completed: %d Replies completed: %d", self, followedTimelineCompleted, repliesCompleted);
2007 if (followedTimelineCompleted && repliesCompleted) {
2008 if (queuedUpdates.count) {
2009 // Set the "last pulled" for the timeline and replies, since we've completed both.
2010 if(futureRepliesLastID) {
2011 AILogWithSignature(@"%@ futureRepliesLastID = %@", self, futureRepliesLastID);
2013 [self setPreference:futureRepliesLastID
2014 forKey:TWITTER_PREFERENCE_REPLIES_LAST_ID
2015 group:TWITTER_PREFERENCE_GROUP_UPDATES];
2017 [futureRepliesLastID release]; futureRepliesLastID = nil;
2020 if(futureTimelineLastID) {
2021 AILogWithSignature(@"%@ futureTimelineLastID = %@", self, futureTimelineLastID);
2023 [self setPreference:futureTimelineLastID
2024 forKey:TWITTER_PREFERENCE_TIMELINE_LAST_ID
2025 group:TWITTER_PREFERENCE_GROUP_UPDATES];
2027 [futureTimelineLastID release]; futureTimelineLastID = nil;
2030 [self displayQueuedUpdatesForRequestType:[self requestTypeForRequestID:identifier]];
2033 if (![self preferenceForKey:TWITTER_PREFERENCE_EVER_LOADED_TIMELINE group:TWITTER_PREFERENCE_GROUP_UPDATES]) {
2034 [self setPreference:[NSNumber numberWithBool:YES]
2035 forKey:TWITTER_PREFERENCE_EVER_LOADED_TIMELINE
2036 group:TWITTER_PREFERENCE_GROUP_UPDATES];
2040 } else if ([self requestTypeForRequestID:identifier] == AITwitterProfileStatusUpdates) {
2041 AIListContact *listContact = [[self dictionaryForRequestID:identifier] objectForKey:@"ListContact"];
2043 NSMutableArray *profileArray = [[listContact profileArray] mutableCopy];
2045 AILogWithSignature(@"%@ Updating statuses for profile, user %@", self, listContact);
2047 for (NSDictionary *update in statuses) {
2048 NSAttributedString *message = [self parseMessage:[update objectForKey:TWITTER_STATUS_TEXT]
2049 tweetID:[update objectForKey:TWITTER_STATUS_ID]
2050 userID:listContact.UID
2051 inReplyToUser:[update objectForKey:TWITTER_STATUS_REPLY_UID]
2052 inReplyToTweetID:[update objectForKey:TWITTER_STATUS_REPLY_ID]];
2054 [profileArray addObject:[NSDictionary dictionaryWithObjectsAndKeys:message, KEY_VALUE, nil]];
2057 [listContact setProfileArray:profileArray notify:NotifyNow];
2058 } else if ([self requestTypeForRequestID:identifier] == AITwitterSendUpdate) {
2059 if (updateAfterSend) {
2060 [self periodicUpdate];
2063 if (statuses.count) {
2064 [adium.contentController displayEvent:AILocalizedString(@"Tweet successfully sent.", nil)
2066 inChat:self.timelineChat];
2069 for(NSDictionary *update in statuses) {
2070 [[NSNotificationCenter defaultCenter] postNotificationName:AITwitterNotificationPostedStatus
2072 userInfo:[NSDictionary dictionaryWithObjectsAndKeys:self.timelineChat, @"AIChat", nil]];
2074 NSString *text = [[update objectForKey:TWITTER_STATUS_TEXT] stringByUnescapingFromXMLWithEntities:nil];
2076 if([[self preferenceForKey:TWITTER_PREFERENCE_UPDATE_GLOBAL group:TWITTER_PREFERENCE_GROUP_UPDATES] boolValue] &&
2077 (![text hasPrefix:@"@"] || [[self preferenceForKey:TWITTER_PREFERENCE_UPDATE_GLOBAL_REPLIES group:TWITTER_PREFERENCE_GROUP_UPDATES] boolValue])) {
2078 AIStatus *availableStatus = [AIStatus statusOfType:AIAvailableStatusType];
2080 availableStatus.statusMessage = [NSAttributedString stringWithString:text];
2081 [adium.statusController setActiveStatusState:availableStatus];
2084 } else if ([self requestTypeForRequestID:identifier] == AITwitterFavoriteYes ||
2085 [self requestTypeForRequestID:identifier] == AITwitterFavoriteNo) {
2086 AIChat *timelineChat = self.timelineChat;
2088 for (NSDictionary *status in statuses) {
2091 // Use HTML for the status message since it's just easier to localize that way.
2093 if ([self requestTypeForRequestID:identifier] == AITwitterFavoriteYes) {
2094 message = AILocalizedString(@"The <a href=\"%@\">requested tweet</a> by <a href=\"%@\">%@</a> is now a favorite.", nil);
2096 message = AILocalizedString(@"The <a href=\"%@\">requested tweet</a> by <a href=\"%@\">%@</a> is no longer a favorite.", nil);
2099 NSString *userID = [[status objectForKey:TWITTER_STATUS_USER] objectForKey:TWITTER_STATUS_UID];
2102 message = [NSString stringWithFormat:message,
2103 [self addressForLinkType:AITwitterLinkStatus
2105 statusID:[status objectForKey:TWITTER_STATUS_ID]
2107 [self addressForLinkType:AITwitterLinkUserPage
2113 NSAttributedString *attributedMessage = [[AIHTMLDecoder decoder] decodeHTML:message withDefaultAttributes:nil];
2115 AIContentEvent *content = [AIContentEvent eventInChat:timelineChat
2119 message:attributedMessage
2120 withType:@"favorite"];
2122 content.postProcessContent = NO;
2123 content.coalescingKey = @"favorite";
2125 [adium.contentController receiveContentObject:content];
2129 [self clearRequestTypeForRequestID:identifier];
2133 * @brief Direct messages received
2135 - (void)directMessagesReceived:(NSArray *)messages forRequest:(NSString *)identifier
2137 if ([self requestTypeForRequestID:identifier] == AITwitterUpdateDirectMessage) {
2138 NSString *lastID = [self preferenceForKey:TWITTER_PREFERENCE_DM_LAST_ID
2139 group:TWITTER_PREFERENCE_GROUP_UPDATES];
2141 BOOL nextPageNecessary = (lastID && messages.count >= TWITTER_UPDATE_DM_COUNT);
2143 // Store the largest tweet ID we find; this will be our "last ID" the next time we run.
2144 NSString *largestTweet = [[self dictionaryForRequestID:identifier] objectForKey:@"LargestTweet"];
2146 // The largest ID is first, compare.
2147 if (messages.count) {
2148 NSString *tweetID = [[messages objectAtIndex:0] objectForKey:TWITTER_DM_ID];
2149 if (!largestTweet || [largestTweet compare:tweetID] == NSOrderedAscending) {
2150 largestTweet = tweetID;
2154 [queuedDM addObjectsFromArray:messages];
2156 AILogWithSignature(@"%@ Last ID: %@ Largest Tweet: %@ Next Page Necessary: %d", self, lastID, largestTweet, nextPageNecessary);
2158 if(nextPageNecessary) {
2159 NSInteger nextPage = [[[self dictionaryForRequestID:identifier] objectForKey:@"Page"] intValue] + 1;
2161 NSString *requestID = [twitterEngine getDirectMessagesSinceID:lastID
2162 startingAtPage:nextPage];
2164 AILogWithSignature(@"%@ Pulling additional DM page %d", self, nextPage);
2167 [self setRequestType:AITwitterUpdateDirectMessage
2168 forRequestID:requestID
2169 withDictionary:[NSDictionary dictionaryWithObjectsAndKeys:[NSNumber numberWithInt:nextPage], @"Page",
2170 largestTweet, @"LargestTweet", nil]];
2172 // Gracefully fail: remove all stored objects.
2173 AILogWithSignature(@"%@ Immediate DM pull fail", self);
2174 --pendingUpdateCount;
2175 [queuedDM removeAllObjects];
2178 --pendingUpdateCount;
2181 AILogWithSignature(@"%@ Largest DM pulled = %@", self, largestTweet);
2183 [self setPreference:largestTweet
2184 forKey:TWITTER_PREFERENCE_DM_LAST_ID
2185 group:TWITTER_PREFERENCE_GROUP_UPDATES];
2188 // On first load, don't display any direct messages. Just ge the largest ID.
2189 if (queuedDM.count && lastID) {
2190 [self displayQueuedUpdatesForRequestType:[self requestTypeForRequestID:identifier]];
2192 [queuedDM removeAllObjects];
2195 } else if ([self requestTypeForRequestID:identifier] == AITwitterDirectMessageSend) {
2196 [queuedOutgoingDM addObjectsFromArray:messages];
2197 [self displayQueuedUpdatesForRequestType:AITwitterDirectMessageSend];
2200 [self clearRequestTypeForRequestID:identifier];
2204 * @brief User information received
2206 - (void)userInfoReceived:(NSArray *)userInfo forRequest:(NSString *)identifier
2208 if (([self requestTypeForRequestID:identifier] == AITwitterInitialUserInfo ||
2209 [self requestTypeForRequestID:identifier] == AITwitterAddFollow) &&
2210 [[self preferenceForKey:TWITTER_PREFERENCE_LOAD_CONTACTS group:TWITTER_PREFERENCE_GROUP_UPDATES] boolValue]) {
2211 [[AIContactObserverManager sharedManager] delayListObjectNotifications];
2213 // The current amount of friends per page is 100. Use >= just in case this changes.
2214 BOOL nextPageNecessary = (userInfo.count >= 100);
2216 AILogWithSignature(@"%@ User info pull, Next page necessary: %d Count: %d", self, nextPageNecessary, userInfo.count);
2218 for (NSDictionary *info in userInfo) {
2219 AIListContact *listContact = [self contactWithUID:[info objectForKey:TWITTER_INFO_UID]];
2221 // If the user isn't in a group, set them in the Twitter group.
2222 if(listContact.countOfRemoteGroupNames == 0) {
2223 [listContact addRemoteGroupName:TWITTER_REMOTE_GROUP_NAME];
2226 // Grab the Twitter display name and set it as the remote alias.
2227 if (![[listContact valueForProperty:@"Server Display Name"] isEqualToString:[info objectForKey:TWITTER_INFO_DISPLAY_NAME]]) {
2228 [listContact setServersideAlias:[info objectForKey:TWITTER_INFO_DISPLAY_NAME]
2229 silently:silentAndDelayed];
2232 // Grab the user icon and set it as their serverside icon.
2233 [self updateUserIcon:[info objectForKey:TWITTER_INFO_ICON] forContact:listContact];
2235 // Set the user as available.
2236 [listContact setStatusWithName:nil
2237 statusType:AIAvailableStatusType
2238 notify:NotifyLater];
2240 // Set the user's status message to their current twitter status text
2241 NSString *statusText = [[info objectForKey:TWITTER_INFO_STATUS] objectForKey:TWITTER_INFO_STATUS_TEXT];
2242 if (!statusText) //nil if they've never tweeted
2244 [listContact setStatusMessage:[NSAttributedString stringWithString:[statusText stringByUnescapingFromXMLWithEntities:nil]] notify:NotifyLater];
2246 // Set the user as online.
2247 [listContact setOnline:YES notify:NotifyLater silently:silentAndDelayed];
2249 [listContact notifyOfChangedPropertiesSilently:silentAndDelayed];
2252 [[AIContactObserverManager sharedManager] endListObjectNotificationsDelay];
2254 if (nextPageNecessary) {
2255 NSInteger nextPage = [[[self dictionaryForRequestID:identifier] objectForKey:@"Page"] intValue] + 1;
2256 NSString *requestID = [twitterEngine getRecentlyUpdatedFriendsFor:self.UID startingAtPage:nextPage];
2258 AILogWithSignature(@"%@ Pulling additional user info page %d", self, nextPage);
2261 [self setRequestType:AITwitterInitialUserInfo
2262 forRequestID:requestID
2263 withDictionary:[NSDictionary dictionaryWithObject:[NSNumber numberWithInt:nextPage]
2266 [self setLastDisconnectionError:AILocalizedString(@"Unable to retrieve user list [additional fail]", "Message when a (vital) twitter request to retrieve the follow list fails")];
2267 [self didDisconnect];
2270 } else if ([self valueForProperty:@"Connecting"]) {
2271 // Trigger our normal update routine.
2274 } else if ([self requestTypeForRequestID:identifier] == AITwitterProfileUserInfo) {
2275 NSDictionary *thisUserInfo = [userInfo objectAtIndex:0];
2278 AIListContact *listContact = [[self dictionaryForRequestID:identifier] objectForKey:@"ListContact"];
2280 NSArray *keyNames = [NSArray arrayWithObjects:@"name", @"location", @"description", @"url", @"friends_count", @"followers_count", @"statuses_count", nil];
2281 NSArray *readableNames = [NSArray arrayWithObjects:AILocalizedString(@"Name", nil), AILocalizedString(@"Location", nil),
2282 AILocalizedString(@"Biography", nil), AILocalizedString(@"Website", nil), AILocalizedString(@"Following", nil),
2283 AILocalizedString(@"Followers", nil), AILocalizedString(@"Updates", nil), nil];
2285 NSMutableArray *profileArray = [NSMutableArray array];
2287 for (NSUInteger index = 0; index < keyNames.count; index++) {
2288 NSString *keyName = [keyNames objectAtIndex:index];
2289 NSString *unattributedValue = [thisUserInfo objectForKey:keyName];
2291 if(![unattributedValue isEqualToString:@""]) {
2292 NSString *readableName = [readableNames objectAtIndex:index];
2293 NSAttributedString *value;
2295 if([keyName isEqualToString:@"friends_count"]) {
2296 value = [NSAttributedString attributedStringWithLinkLabel:unattributedValue
2297 linkDestination:[self addressForLinkType:AITwitterLinkFriends userID:listContact.UID statusID:nil context:nil]];
2298 } else if ([keyName isEqualToString:@"followers_count"]) {
2299 value = [NSAttributedString attributedStringWithLinkLabel:unattributedValue
2300 linkDestination:[self addressForLinkType:AITwitterLinkFollowers userID:listContact.UID statusID:nil context:nil]];
2301 } else if ([keyName isEqualToString:@"statuses_count"]) {
2302 value = [NSAttributedString attributedStringWithLinkLabel:unattributedValue
2303 linkDestination:[self addressForLinkType:AITwitterLinkUserPage userID:listContact.UID statusID:nil context:nil]];
2305 value = [NSAttributedString stringWithString:unattributedValue];
2308 [profileArray addObject:[NSDictionary dictionaryWithObjectsAndKeys:readableName, KEY_KEY, value, KEY_VALUE, nil]];
2312 AILogWithSignature(@"%@ Updating profileArray for user %@", self, listContact);
2314 [listContact setProfileArray:profileArray notify:NotifyNow];
2316 // Grab their statuses.
2317 NSString *requestID = [twitterEngine getUserTimelineFor:listContact.UID since:nil startingAtPage:0 count:TWITTER_UPDATE_USER_INFO_COUNT];
2320 [self setRequestType:AITwitterProfileStatusUpdates
2321 forRequestID:requestID
2322 withDictionary:[NSDictionary dictionaryWithObject:listContact forKey:@"ListContact"]];
2325 } else if ([self requestTypeForRequestID:identifier] == AITwitterValidateCredentials ||
2326 [self requestTypeForRequestID:identifier] == AITwitterProfileSelf) {
2327 for (NSDictionary *info in userInfo) {
2328 NSString *requestID = [twitterEngine getImageAtURL:[info objectForKey:TWITTER_INFO_ICON]];
2331 [self setRequestType:AITwitterSelfUserIconPull
2332 forRequestID:requestID
2333 withDictionary:nil];
2336 [self filterAndSetUID:[info objectForKey:TWITTER_INFO_UID]];
2338 if ([info objectForKey:@"name"]) {
2339 [self setPreference:[[NSAttributedString stringWithString:[info objectForKey:@"name"]] dataRepresentation]
2340 forKey:KEY_ACCOUNT_DISPLAY_NAME
2341 group:GROUP_ACCOUNT_STATUS];
2344 [self setValue:[info objectForKey:@"name"] forProperty:@"Profile Name" notify:NotifyLater];
2345 [self setValue:[info objectForKey:@"url"] forProperty:@"Profile URL" notify:NotifyLater];
2346 [self setValue:[info objectForKey:@"location"] forProperty:@"Profile Location" notify:NotifyLater];
2347 [self setValue:[info objectForKey:@"description"] forProperty:@"Profile Description" notify:NotifyLater];
2348 [self notifyOfChangedPropertiesSilently:NO];
2352 if([self requestTypeForRequestID:identifier] == AITwitterValidateCredentials) {
2353 // Our UID is definitely set; grab our friends.
2355 if ([[self preferenceForKey:TWITTER_PREFERENCE_LOAD_CONTACTS group:TWITTER_PREFERENCE_GROUP_UPDATES] boolValue]) {
2356 // If we load our follows as contacts, do so now.
2358 // Delay updates on initial login.
2359 [self silenceAllContactUpdatesForInterval:18.0];
2360 // Grab our user list.
2361 NSString *requestID = [twitterEngine getRecentlyUpdatedFriendsFor:self.UID startingAtPage:1];
2364 [self setRequestType:AITwitterInitialUserInfo
2365 forRequestID:requestID
2366 withDictionary:[NSDictionary dictionaryWithObject:[NSNumber numberWithInt:1] forKey:@"Page"]];
2368 [self setLastDisconnectionError:AILocalizedString(@"Unable to retrieve user list", nil)];
2369 [self didDisconnect];
2372 // If we don't load follows as contacts, we've finished connecting (fast, wasn't it?)
2376 } else if ([self requestTypeForRequestID:identifier] == AITwitterNotificationEnable ||
2377 [self requestTypeForRequestID:identifier] == AITwitterNotificationDisable) {
2378 BOOL enableNotification = ([self requestTypeForRequestID:identifier] == AITwitterNotificationEnable);
2379 AIListContact *listContact = [[self dictionaryForRequestID:identifier] objectForKey:@"ListContact"];
2381 for (NSDictionary *info in userInfo) {
2382 [adium.interfaceController handleMessage:(enableNotification ?
2383 AILocalizedString(@"Notifications Enabled", nil) :
2384 AILocalizedString(@"Notifications Disabled", nil))
2385 withDescription:[NSString stringWithFormat:(enableNotification ?
2386 AILocalizedString(@"You will now receive device notifications for %@.", nil) :
2387 AILocalizedString(@"You will no longer receive device notifications for %@.", nil)),
2389 withWindowTitle:(enableNotification ?
2390 AILocalizedString(@"Notifications Enabled", nil) :
2391 AILocalizedString(@"Notifications Disabled", nil))];
2395 [self clearRequestTypeForRequestID:identifier];
2399 * @brief Miscellaneous information received
2401 - (void)miscInfoReceived:(NSArray *)miscInfo forRequest:(NSString *)identifier
2403 if([self requestTypeForRequestID:identifier] == AITwitterRateLimitStatus) {
2404 NSDictionary *rateLimit = [miscInfo objectAtIndex:0];
2405 NSDate *resetDate = [NSDate dateWithTimeIntervalSince1970:[[rateLimit objectForKey:TWITTER_RATE_LIMIT_RESET_SECONDS] intValue]];
2407 [adium.interfaceController handleMessage:AILocalizedString(@"Current Twitter rate limit", "Message in the rate limit status window")
2408 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."),
2409 [[rateLimit objectForKey:TWITTER_RATE_LIMIT_REMAINING] intValue],
2410 [[rateLimit objectForKey:TWITTER_RATE_LIMIT_HOURLY_LIMIT] intValue],
2411 [NSDateFormatter stringForTimeInterval:[resetDate timeIntervalSinceNow]
2415 withWindowTitle:AILocalizedString(@"Rate Limit Status", nil)];
2418 [self clearRequestTypeForRequestID:identifier];
2422 * @brief Requested image received
2424 - (void)imageReceived:(NSImage *)image forRequest:(NSString *)identifier
2426 if([self requestTypeForRequestID:identifier] == AITwitterUserIconPull) {
2427 AIListContact *listContact = [[self dictionaryForRequestID:identifier] objectForKey:@"ListContact"];
2429 AILogWithSignature(@"%@ Updated user icon for %@", self, listContact);
2431 [listContact setServersideIconData:[image TIFFRepresentation]
2432 notify:NotifyLater];
2434 [listContact setValue:nil forProperty:TWITTER_PROPERTY_REQUESTED_USER_ICON notify:NotifyNever];
2435 } else if([self requestTypeForRequestID:identifier] == AITwitterSelfUserIconPull) {
2436 AILogWithSignature(@"Updated self icon for %@", self);
2438 // Set a property so we don't re-send thie image we're just now downloading.
2439 [self setValue:[NSNumber numberWithBool:YES] forProperty:TWITTER_PROPERTY_REQUESTED_USER_ICON notify:NotifyNever];
2441 [self setPreference:[NSNumber numberWithBool:YES]
2442 forKey:KEY_USE_USER_ICON
2443 group:GROUP_ACCOUNT_STATUS];
2446 [self setPreference:[image TIFFRepresentation]
2447 forKey:KEY_USER_ICON
2448 group:GROUP_ACCOUNT_STATUS];
2451 [self clearRequestTypeForRequestID:identifier];