Unescape the HREF attribute's text before passing it to NSURL which does not expect...
[adiumx.git] / Source / DCMessageContextDisplayPlugin.m
blob90430a97ba9700aa1c6f6e7a17c854ab8a42406e
1 /* 
2  * Adium is the legal property of its developers, whose names are listed in the copyright file included
3  * with this source distribution.
4  * 
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.
8  * 
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.
12  * 
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.
15  */
17 #import <Adium/AIContentControllerProtocol.h>
18 #import <Adium/AIPreferenceControllerProtocol.h>
19 #import "DCMessageContextDisplayPlugin.h"
20 #import "DCMessageContextDisplayPreferences.h"
21 #import <AIUtilities/AIDictionaryAdditions.h>
22 #import <Adium/AIChat.h>
23 #import <Adium/AIContentContext.h>
24 //#import "SMSQLiteLoggerPlugin.h"
25 //#import "AICoreComponentLoader.h"
27 //Old school
28 #import <Adium/AIListContact.h>
29 #import <AIUtilities/AIAttributedStringAdditions.h>
30 #import <Adium/AIAccountControllerProtocol.h>
32 //omg crawsslinkz
33 #import "AILoggerPlugin.h"
35 //LMX
36 #import <LMX/LMXParser.h>
37 #import <Adium/AIXMLElement.h>
38 #import <AIUtilities/AIStringAdditions.h>
39 #import "unistd.h"
40 #import <AIUtilities/NSCalendarDate+ISO8601Parsing.h>
41 #import <Adium/AIContactControllerProtocol.h>
42 #import <Adium/AIHTMLDecoder.h>
44 /**
45  * @class DCMessageContextDisplayPlugin
46  * @brief Component to display in-window message history
47  *
48  * The amount of history, and criteria of when to display history, are determined in the Advanced->Message History preferences.
49  */
50 @interface DCMessageContextDisplayPlugin (PRIVATE)
51 - (void)preferencesChangedForGroup:(NSString *)group key:(NSString *)key
52                                                         object:(AIListObject *)object preferenceDict:(NSDictionary *)prefDict firstTime:(BOOL)firstTime;
53 - (void)old_preferencesChangedForGroup:(NSString *)group key:(NSString *)key
54                                                                 object:(AIListObject *)object preferenceDict:(NSDictionary *)prefDict firstTime:(BOOL)firstTime;
55 - (BOOL)contextShouldBeDisplayed:(NSCalendarDate *)inDate;
56 - (NSArray *)contextForChat:(AIChat *)chat;
57 @end
59 @implementation DCMessageContextDisplayPlugin
61 /**
62  * @brief Install
63  */
64 - (void)installPlugin
66         isObserving = NO;
67         
68         //Setup our preferences
69     [[adium preferenceController] registerDefaults:[NSDictionary dictionaryNamed:CONTEXT_DISPLAY_DEFAULTS
70                                                                                                                                                 forClass:[self class]] 
71                                                                                   forGroup:PREF_GROUP_CONTEXT_DISPLAY];
72                 
73         //Obtain the default preferences and use them - Adium 1.1 experiment to see if people use these prefs
74         [self old_preferencesChangedForGroup:PREF_GROUP_CONTEXT_DISPLAY
75                                                                  key:nil
76                                                           object:nil
77                                           preferenceDict:[NSDictionary dictionaryNamed:CONTEXT_DISPLAY_DEFAULTS
78                                                                                                                   forClass:[self class]]
79                                                    firstTime:YES];
80         
81         //Observe preference changes for whether or not to display message history
82         [[adium preferenceController] registerPreferenceObserver:self forGroup:PREF_GROUP_CONTEXT_DISPLAY];
85 /**
86  * @brief Uninstall
87  */
88 - (void)uninstallPlugin
90         [[adium preferenceController] unregisterPreferenceObserver:self];
91         [[adium notificationCenter] removeObserver:self];
94 - (void)preferencesChangedForGroup:(NSString *)group key:(NSString *)key
95                                                                 object:(AIListObject *)object preferenceDict:(NSDictionary *)prefDict firstTime:(BOOL)firstTime
97         if (!object) {          
98                 shouldDisplay = [[prefDict objectForKey:KEY_DISPLAY_CONTEXT] boolValue];
99                 linesToDisplay = [[prefDict objectForKey:KEY_DISPLAY_LINES] intValue];
101                 if (shouldDisplay && linesToDisplay > 0 && !isObserving) {
102                         //Observe new message windows only if we aren't already observing them
103                         isObserving = YES;
104                         [[adium notificationCenter] addObserver:self
105                                                                                    selector:@selector(addContextDisplayToWindow:)
106                                                                                            name:Chat_DidOpen 
107                                                                                          object:nil];
108                         
109                 } else if (isObserving && (!shouldDisplay || linesToDisplay <= 0)) {
110                         //Remove observer
111                         isObserving = NO;
112                         [[adium notificationCenter] removeObserver:self name:Chat_DidOpen object:nil];
113                         
114                 }
115         }
118  * @brief Preferences for when to display history changed
120  * Only change our preferences in response to global preference notifications; specific objects use this group as well.
121  */
122 - (void)old_preferencesChangedForGroup:(NSString *)group key:(NSString *)key
123                                                         object:(AIListObject *)object preferenceDict:(NSDictionary *)prefDict firstTime:(BOOL)firstTime
125         if (!object) {
126                 haveTalkedDays = [[prefDict objectForKey:KEY_HAVE_TALKED_DAYS] intValue];
127                 haveNotTalkedDays = [[prefDict objectForKey:KEY_HAVE_NOT_TALKED_DAYS] intValue];
128                 displayMode = [[prefDict objectForKey:KEY_DISPLAY_MODE] intValue];
129                 
130                 haveTalkedUnits = [[prefDict objectForKey:KEY_HAVE_TALKED_UNITS] intValue];
131                 haveNotTalkedUnits = [[prefDict objectForKey:KEY_HAVE_NOT_TALKED_UNITS] intValue];              
132         }
136  * @brief Retrieve and display in-window message history
138  * Called in response to the Chat_DidOpen notification
139  */
140 - (void)addContextDisplayToWindow:(NSNotification *)notification
142         AIChat  *chat = (AIChat *)[notification object];
143         
144         //Don't show context for group chats
145         if ([chat isGroupChat]) return;
146         
147         NSArray *context = [self contextForChat:chat];
149         if (context && [context count] > 0 && shouldDisplay) {
150                 //Check if the history fits the date restrictions
151                 
152                 //The most recent message is what determines whether we have "chatted in the last X days", "not chatted in the last X days", etc.
153                 NSCalendarDate *mostRecentMessage = [[(AIContentContext *)[context lastObject] date] dateWithCalendarFormat:nil timeZone:nil];
154                 if ([self contextShouldBeDisplayed:mostRecentMessage]) {
155                         NSEnumerator            *enumerator;
156                         AIContentContext        *contextMessage;
158                         enumerator = [context objectEnumerator];
159                         while((contextMessage = [enumerator nextObject])) {
160                                 /* Don't display immediately, so the message view can aggregate multiple message history items.
161                                  * As required, we post Content_ChatDidFinishAddingUntrackedContent when finished adding. */
162                                 [contextMessage setDisplayContentImmediately:NO];
163                                 
164                                 [[adium contentController] displayContentObject:contextMessage
165                                                                                         usingContentFilters:YES
166                                                                                                         immediately:YES];
167                         }
169                         //We finished adding untracked content
170                         [[adium notificationCenter] postNotificationName:Content_ChatDidFinishAddingUntrackedContent
171                                                                                                           object:chat];
173                 }
174         }
178  * @brief Does a specified date match our criteria for display?
180  * The date passed should be the date of the _most recent_ stored message history item
182  * @result YES if the mesage history should be displayed
183  */
184 - (BOOL)contextShouldBeDisplayed:(NSCalendarDate *)inDate
186         BOOL dateIsGood = YES;
187         int thresholdDays = 0;
188         int thresholdHours = 0;
189         
190         if (displayMode != MODE_ALWAYS) {
191                 
192                 if (displayMode == MODE_HAVE_TALKED) {
193                         if (haveTalkedUnits == UNIT_DAYS)
194                                 thresholdDays = haveTalkedDays;
195                         
196                         else if (haveTalkedUnits == UNIT_HOURS)
197                                 thresholdHours = haveTalkedDays;
198                         
199                 } else if (displayMode == MODE_HAVE_NOT_TALKED) {
200                         if ( haveTalkedUnits == UNIT_DAYS )
201                                 thresholdDays = haveNotTalkedDays;
202                         else if (haveTalkedUnits == UNIT_HOURS)
203                                 thresholdHours = haveNotTalkedDays;
204                 }
205                 
206                 // Take the most recent message's date, add our limits to it
207                 // See if the new date is earlier or later than today's date
208                 NSCalendarDate *newDate = [inDate dateByAddingYears:0 months:0 days:thresholdDays hours:thresholdHours minutes:0 seconds:0];
210                 NSComparisonResult comparison = [newDate compare:[NSDate date]];
211                 
212                 if (((displayMode == MODE_HAVE_TALKED) && (comparison == NSOrderedAscending)) ||
213                         ((displayMode == MODE_HAVE_NOT_TALKED) && (comparison == NSOrderedDescending)) ) {
214                         dateIsGood = NO;
215                 }
216         }
217         
218         return dateIsGood;
221 static int linesLeftToFind = 0;
223  * @brief Retrieve the message history for a particular chat
225  * Asks AILoggerPlugin for the path to the right file, and then uses LMX to parse that file backwards.
226  */
227 - (NSArray *)contextForChat:(AIChat *)chat
229         //If there's no log there, there's no message history. Bail out.
230         NSArray *logPaths = [AILoggerPlugin sortedArrayOfLogFilesForChat:chat];
231         if(!logPaths) return nil;
232                 
233         NSString *logObjectUID = [chat name];
234         if (!logObjectUID) logObjectUID = [[chat listObject] UID];
235         logObjectUID = [logObjectUID safeFilenameString];
237         NSString *baseLogPath = [[AILoggerPlugin logBasePath] stringByAppendingPathComponent:
238                 [AILoggerPlugin relativePathForLogWithObject:logObjectUID onAccount:[chat account]]];
239                         
240         //Initialize a place to store found messages
241         NSMutableArray *outerFoundContentContexts = [NSMutableArray arrayWithCapacity:linesToDisplay]; 
243         //Set up the counter variable
244         linesLeftToFind = linesToDisplay;
246         //Iterate over the elements of the log path array.
247         NSEnumerator *pathsEnumerator = [logPaths objectEnumerator];
248         NSString *logPath = nil;
249         while (linesLeftToFind > 0 && (logPath = [pathsEnumerator nextObject])) {
250                 //If it's not a .chatlog, ignore it.
251                 if (![logPath hasSuffix:@".chatlog"])
252                         continue;
253                                 
254                 //Stick the base path on to the beginning
255                 logPath = [baseLogPath stringByAppendingPathComponent:logPath];
256                 
257                 //Initialize the found messages array and element stack for us-as-delegate
258                 foundMessages = [NSMutableArray arrayWithCapacity:linesLeftToFind];
259                 elementStack = [NSMutableArray array];
261                 //Create the parser and set ourselves as the delegate
262                 LMXParser *parser = [LMXParser parser];
263                 [parser setDelegate:self];
265                 //Set up info needed by elementStarted to create content objects.
266                 NSMutableDictionary *contextInfo = nil;
267                 {
268                         //Get the service name from the path name
269                         NSString *serviceName = [[[[[logPath stringByDeletingLastPathComponent] stringByDeletingLastPathComponent] lastPathComponent] componentsSeparatedByString:@"."] objectAtIndex:0U];
271                         AIListObject *account = [chat account];
272                         NSString         *accountID = [NSString stringWithFormat:@"%@.%@", [account serviceID], [account UID]];
274                         contextInfo = [NSMutableDictionary dictionaryWithObjectsAndKeys:
275                                 serviceName, @"Service name",
276                                 account, @"Account",
277                                 accountID, @"Account ID",
278                                 chat, @"Chat",
279                                 nil];
280                         [parser setContextInfo:(void *)contextInfo];
281                 }
283                 //Open up the file we need to read from, and seek to the end (this is a *backwards* parser, after all :)
284                 NSFileHandle *file = [NSFileHandle fileHandleForReadingAtPath:logPath];
285                 [file seekToEndOfFile];
286                 
287                 //Set up some more doohickeys and then start the parse loop
288                 int readSize = 4 * getpagesize(); //Read 4 pages at a time.
289                 NSMutableData *chunk = [NSMutableData dataWithLength:readSize];
290                 int fd = [file fileDescriptor];
291                 char *buf = [chunk mutableBytes];
292                 off_t offset = [file offsetInFile];
293                 enum LMXParseResult result = LMXParsedIncomplete;
295                 //We use NSValue because autorelease pools cannot be retained.
296                 NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
297                 NSValue *value = [NSValue valueWithNonretainedObject:pool];
298                 [contextInfo setObject:value forKey:@"Autorelease pool"];
300                 do {
301                         //Calculate the new offset
302                         offset = (offset <= readSize) ? 0 : offset - readSize;
303                         
304                         //Seek to it and read greedily until we hit readSize or run out of file.
305                         int idx = 0;
306                         for (ssize_t amountRead = 0; idx < readSize; idx += amountRead) { 
307                                 amountRead = pread(fd, buf + idx, readSize, offset + idx); 
308                            if (amountRead <= 0) break;
309                         }
310                         offset -= idx;
311                         
312                         //Parse
313                         result = [parser parseChunk:chunk];
314                         
315                 //Continue to parse as long as we need more elements, we have data to read, and LMX doesn't think we're done.
316                 } while ([foundMessages count] < linesLeftToFind && offset > 0 && result != LMXParsedCompletely);
318                 //Pop our autorelease pool.
319                 //It may be a different one from the one we started with, so get it from the dictionary.
320                 value = [contextInfo objectForKey:@"Autorelease pool"];
321                 [[value nonretainedObjectValue] release];
323                 //Be a good citizen and close the file
324                 [file closeFile];
326                 //Add our locals to the outer array; we're probably looping again.
327                 [outerFoundContentContexts replaceObjectsInRange:NSMakeRange(0, 0) withObjectsFromArray:foundMessages];
328                 linesLeftToFind -= [outerFoundContentContexts count];
329         }
330         return outerFoundContentContexts;
333 #pragma mark LMX delegate
335 - (void)parser:(LMXParser *)parser elementEnded:(NSString *)elementName
337         if ([elementName isEqualToString:@"message"]) {
338                 [elementStack insertObject:[AIXMLElement elementWithName:elementName] atIndex:0U];
339         }
340         else if ([elementStack count]) {
341                 AIXMLElement *element = [AIXMLElement elementWithName:elementName];
342                 [(AIXMLElement *)[elementStack objectAtIndex:0U] insertObject:element atIndex:0U];
343                 [elementStack insertObject:element atIndex:0U];
344         }
347 - (void)parser:(LMXParser *)parser foundCharacters:(NSString *)string
349         if ([elementStack count])
350                 [(AIXMLElement *)[elementStack objectAtIndex:0U] insertObject:string atIndex:0U];
353 - (void)parser:(LMXParser *)parser elementStarted:(NSString *)elementName attributes:(NSDictionary *)attributes
355         if ([elementStack count]) {
356                 AIXMLElement *element = [elementStack objectAtIndex:0U];
357                 if (attributes) {
358                         [element setAttributeNames:[attributes allKeys] values:[attributes allValues]];
359                 }
360                 
361                 NSMutableDictionary *contextInfo = [parser contextInfo];
363                 if ([elementName isEqualToString:@"message"]) {
364                         //A message element has started!
365                         //This means that we have all of this message now, and therefore can create a single content object from the AIXMLElement tree and then throw away that tree.
366                         //This saves memory when a message element contains many elements (since each one is represented by an AIXMLElement sub-tree in the AIXMLElement tree, as opposed to a simple NSAttributeRun in the NSAttributedString of the content object).
368                         NSString     *serviceName = [contextInfo objectForKey:@"Service name"];
369                         AIListObject *account     = [contextInfo objectForKey:@"Account"];
370                         NSString     *accountID   = [contextInfo objectForKey:@"Account ID"];
371                         AIChat       *chat        = [contextInfo objectForKey:@"Chat"];
373                         //Set up some doohickers.
374                         NSDictionary    *attributes = [element attributes];
375                         NSString                *timeString = [attributes objectForKey:@"time"];
376                         //Create the context object
377                         //http://www.visualdistortion.org/crash/view.jsp?crash=211821
378                         if (timeString) {
379                                 NSCalendarDate *time = [NSCalendarDate calendarDateWithString:timeString];
381                                 NSString                *autoreplyAttribute = [attributes objectForKey:@"auto"];
382                                 NSString                *sender = [NSString stringWithFormat:@"%@.%@", serviceName, [attributes objectForKey:@"sender"]];
383                                 BOOL                    sentByMe = ([sender isEqualToString:accountID]);
384                                 
385                                 /*don't fade the messages if they're within the last 5 minutes
386                                  *since that will be resuming a conversation, not starting a new one.
387                                  *Why the class trickery? Less code duplication, clearer what is actually different between the two cases.
388                                  */
389                                 Class messageClass = (-[time timeIntervalSinceNow] > 300.0) ? [AIContentContext class] : [AIContentMessage class];
390                                 AIContentMessage *message = [messageClass messageInChat:chat 
391                                                                                                                          withSource:(sentByMe ? account : [chat listObject])
392                                                                                                                         destination:(sentByMe ? [chat listObject] : account)
393                                                                                                                                    date:time
394                                                                                                                                 message:[[AIHTMLDecoder decoder] decodeHTML:[element contentsAsXMLString]]
395                                                                                                                           autoreply:(autoreplyAttribute && [autoreplyAttribute caseInsensitiveCompare:@"true"] == NSOrderedSame)];
396                                 
397                                 //Don't log this object
398                                 [message setPostProcessContent:NO];
399                                 [message setTrackContent:NO];
400                                 
401                                 //Add it to the array (in front, since we're working backwards, and we want the array in forward order)
402                                 [foundMessages insertObject:message atIndex:0];
403                         } else {
404                                 NSLog(@"Null message context display time for %@",element);
405                         }
406                 }
408                 [elementStack removeObjectAtIndex:0U];
409                 if ([foundMessages count] == linesLeftToFind) {
410                         if ([elementStack count]) [elementStack removeAllObjects];
411                         [parser abortParsing];
412                 } else {
413                         //We're still looking for more messages in this file.
414                         //Pop the current autorelease pool and start a new one.
415                         //This frees the most recent tree of autoreleased AIXMLElements.
416                         //We use NSValue because autorelease pools cannot be retained.
417                         NSValue *value = [contextInfo objectForKey:@"Autorelease pool"];
418                         [[value nonretainedObjectValue] release];
419                         NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
420                         value = [NSValue valueWithNonretainedObject:pool];
421                         [contextInfo setObject:value forKey:@"Autorelease pool"];
422                 }
423         }
426 @end