]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - classes/Notice.php
Improve ShownoticeAction remote redirect code
[quix0rs-gnu-social.git] / classes / Notice.php
1 <?php
2 /**
3  * StatusNet - the distributed open-source microblogging tool
4  * Copyright (C) 2008-2011 StatusNet, Inc.
5  *
6  * This program is free software: you can redistribute it and/or modify
7  * it under the terms of the GNU Affero General Public License as published by
8  * the Free Software Foundation, either version 3 of the License, or
9  * (at your option) any later version.
10  *
11  * This program is distributed in the hope that it will be useful,
12  * but WITHOUT ANY WARRANTY; without even the implied warranty of
13  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.     See the
14  * GNU Affero General Public License for more details.
15  *
16  * You should have received a copy of the GNU Affero General Public License
17  * along with this program.     If not, see <http://www.gnu.org/licenses/>.
18  *
19  * @category Notices
20  * @package  StatusNet
21  * @author   Brenda Wallace <shiny@cpan.org>
22  * @author   Christopher Vollick <psycotica0@gmail.com>
23  * @author   CiaranG <ciaran@ciarang.com>
24  * @author   Craig Andrews <candrews@integralblue.com>
25  * @author   Evan Prodromou <evan@controlezvous.ca>
26  * @author   Gina Haeussge <osd@foosel.net>
27  * @author   Jeffery To <jeffery.to@gmail.com>
28  * @author   Mike Cochrane <mikec@mikenz.geek.nz>
29  * @author   Robin Millette <millette@controlyourself.ca>
30  * @author   Sarven Capadisli <csarven@controlyourself.ca>
31  * @author   Tom Adams <tom@holizz.com>
32  * @author   Mikael Nordfeldth <mmn@hethane.se>
33  * @copyright 2009 Free Software Foundation, Inc http://www.fsf.org
34  * @license  GNU Affero General Public License http://www.gnu.org/licenses/
35  */
36
37 if (!defined('GNUSOCIAL')) { exit(1); }
38
39 /**
40  * Table Definition for notice
41  */
42
43 /* We keep 200 notices, the max number of notices available per API request,
44  * in the memcached cache. */
45
46 define('NOTICE_CACHE_WINDOW', CachingNoticeStream::CACHE_WINDOW);
47
48 define('MAX_BOXCARS', 128);
49
50 class Notice extends Managed_DataObject
51 {
52     ###START_AUTOCODE
53     /* the code below is auto generated do not remove the above tag */
54
55     public $__table = 'notice';                          // table name
56     public $id;                              // int(4)  primary_key not_null
57     public $profile_id;                      // int(4)  multiple_key not_null
58     public $uri;                             // varchar(255)  unique_key
59     public $content;                         // text
60     public $rendered;                        // text
61     public $url;                             // varchar(255)
62     public $created;                         // datetime  multiple_key not_null default_0000-00-00%2000%3A00%3A00
63     public $modified;                        // timestamp   not_null default_CURRENT_TIMESTAMP
64     public $reply_to;                        // int(4)
65     public $is_local;                        // int(4)
66     public $source;                          // varchar(32)
67     public $conversation;                    // int(4)
68     public $lat;                             // decimal(10,7)
69     public $lon;                             // decimal(10,7)
70     public $location_id;                     // int(4)
71     public $location_ns;                     // int(4)
72     public $repeat_of;                       // int(4)
73     public $verb;                            // varchar(255)
74     public $object_type;                     // varchar(255)
75     public $scope;                           // int(4)
76
77     /* the code above is auto generated do not remove the tag below */
78     ###END_AUTOCODE
79
80     public static function schemaDef()
81     {
82         $def = array(
83             'fields' => array(
84                 'id' => array('type' => 'serial', 'not null' => true, 'description' => 'unique identifier'),
85                 'profile_id' => array('type' => 'int', 'not null' => true, 'description' => 'who made the update'),
86                 'uri' => array('type' => 'varchar', 'length' => 255, 'description' => 'universally unique identifier, usually a tag URI'),
87                 'content' => array('type' => 'text', 'description' => 'update content', 'collate' => 'utf8_general_ci'),
88                 'rendered' => array('type' => 'text', 'description' => 'HTML version of the content'),
89                 'url' => array('type' => 'varchar', 'length' => 255, 'description' => 'URL of any attachment (image, video, bookmark, whatever)'),
90                 'created' => array('type' => 'datetime', 'not null' => true, 'description' => 'date this record was created'),
91                 'modified' => array('type' => 'timestamp', 'not null' => true, 'description' => 'date this record was modified'),
92                 'reply_to' => array('type' => 'int', 'description' => 'notice replied to (usually a guess)'),
93                 'is_local' => array('type' => 'int', 'size' => 'tiny', 'default' => 0, 'description' => 'notice was generated by a user'),
94                 'source' => array('type' => 'varchar', 'length' => 32, 'description' => 'source of comment, like "web", "im", or "clientname"'),
95                 'conversation' => array('type' => 'int', 'description' => 'id of root notice in this conversation'),
96                 'lat' => array('type' => 'numeric', 'precision' => 10, 'scale' => 7, 'description' => 'latitude'),
97                 'lon' => array('type' => 'numeric', 'precision' => 10, 'scale' => 7, 'description' => 'longitude'),
98                 'location_id' => array('type' => 'int', 'description' => 'location id if possible'),
99                 'location_ns' => array('type' => 'int', 'description' => 'namespace for location'),
100                 'repeat_of' => array('type' => 'int', 'description' => 'notice this is a repeat of'),
101                 'object_type' => array('type' => 'varchar', 'length' => 255, 'description' => 'URI representing activity streams object type', 'default' => 'http://activitystrea.ms/schema/1.0/note'),
102                 'verb' => array('type' => 'varchar', 'length' => 255, 'description' => 'URI representing activity streams verb', 'default' => 'http://activitystrea.ms/schema/1.0/post'),
103                 'scope' => array('type' => 'int',
104                                  'description' => 'bit map for distribution scope; 0 = everywhere; 1 = this server only; 2 = addressees; 4 = followers; null = default'),
105             ),
106             'primary key' => array('id'),
107             'unique keys' => array(
108                 'notice_uri_key' => array('uri'),
109             ),
110             'foreign keys' => array(
111                 'notice_profile_id_fkey' => array('profile', array('profile_id' => 'id')),
112                 'notice_reply_to_fkey' => array('notice', array('reply_to' => 'id')),
113                 'notice_conversation_fkey' => array('conversation', array('conversation' => 'id')), # note... used to refer to notice.id
114                 'notice_repeat_of_fkey' => array('notice', array('repeat_of' => 'id')), # @fixme: what about repeats of deleted notices?
115             ),
116             'indexes' => array(
117                 'notice_created_id_is_local_idx' => array('created', 'id', 'is_local'),
118                 'notice_profile_id_idx' => array('profile_id', 'created', 'id'),
119                 'notice_repeat_of_created_id_idx' => array('repeat_of', 'created', 'id'),
120                 'notice_conversation_created_id_idx' => array('conversation', 'created', 'id'),
121                 'notice_replyto_idx' => array('reply_to')
122             )
123         );
124
125         if (common_config('search', 'type') == 'fulltext') {
126             $def['fulltext indexes'] = array('content' => array('content'));
127         }
128
129         return $def;
130     }
131         
132     /* Notice types */
133     const LOCAL_PUBLIC    =  1;
134     const REMOTE          =  0;
135     const LOCAL_NONPUBLIC = -1;
136     const GATEWAY         = -2;
137
138     const PUBLIC_SCOPE    = 0; // Useful fake constant
139     const SITE_SCOPE      = 1;
140     const ADDRESSEE_SCOPE = 2;
141     const GROUP_SCOPE     = 4;
142     const FOLLOWER_SCOPE  = 8;
143
144     protected $_profile = -1;
145     
146     public function getProfile()
147     {
148         if ($this->_profile === -1) {
149             $this->_setProfile(Profile::getKV('id', $this->profile_id));
150         }
151         return $this->_profile;
152     }
153     
154     public function _setProfile(Profile $profile=null)
155     {
156         if (!$profile instanceof Profile) {
157             throw new NoProfileException($this->profile_id);
158         }
159         $this->_profile = $profile;
160     }
161
162     function delete($useWhere=false)
163     {
164         // For auditing purposes, save a record that the notice
165         // was deleted.
166
167         // @fixme we have some cases where things get re-run and so the
168         // insert fails.
169         $deleted = Deleted_notice::getKV('id', $this->id);
170
171         if (!$deleted instanceof Deleted_notice) {
172             $deleted = Deleted_notice::getKV('uri', $this->uri);
173         }
174
175         if (!$deleted instanceof Deleted_notice) {
176             $deleted = new Deleted_notice();
177
178             $deleted->id         = $this->id;
179             $deleted->profile_id = $this->profile_id;
180             $deleted->uri        = $this->uri;
181             $deleted->created    = $this->created;
182             $deleted->deleted    = common_sql_now();
183
184             $deleted->insert();
185         }
186
187         if (Event::handle('NoticeDeleteRelated', array($this))) {
188
189             // Clear related records
190
191             $this->clearReplies();
192             $this->clearRepeats();
193             $this->clearFaves();
194             $this->clearTags();
195             $this->clearGroupInboxes();
196             $this->clearFiles();
197             $this->clearAttentions();
198
199             // NOTE: we don't clear queue items
200         }
201
202         $result = parent::delete($useWhere);
203
204         $this->blowOnDelete();
205         return $result;
206     }
207
208     public function getUri()
209     {
210         return $this->uri;
211     }
212
213     public function getUrl()
214     {
215         // The risk is we start having empty urls and non-http uris...
216         // and we can't really handle any other protocol right now.
217         switch (true) {
218         case common_valid_http_url($this->url): // should we allow non-http/https URLs?
219             return $this->url;
220         case $this->isLocal():
221             // let's generate a valid link to our locally available notice on demand
222             return common_local_url('shownotice', array('notice' => $this->id), null, null, false);
223         case common_valid_http_url($this->uri):
224             return $this->uri;
225         default:
226             common_debug('No URL available for notice: id='.$this->id);
227             throw new InvalidUrlException($this->url);
228         }
229     }
230
231     public function get_object_type($canonical=false) {
232         return $canonical
233                 ? ActivityObject::canonicalType($this->object_type)
234                 : $this->object_type;
235     }
236
237     public static function getByUri($uri)
238     {
239         $notice = new Notice();
240         $notice->uri = $uri;
241         if (!$notice->find(true)) {
242             throw new NoResultException($notice);
243         }
244         return $notice;
245     }
246
247     /**
248      * Extract #hashtags from this notice's content and save them to the database.
249      */
250     function saveTags()
251     {
252         /* extract all #hastags */
253         $count = preg_match_all('/(?:^|\s)#([\pL\pN_\-\.]{1,64})/u', strtolower($this->content), $match);
254         if (!$count) {
255             return true;
256         }
257
258         /* Add them to the database */
259         return $this->saveKnownTags($match[1]);
260     }
261
262     /**
263      * Record the given set of hash tags in the db for this notice.
264      * Given tag strings will be normalized and checked for dupes.
265      */
266     function saveKnownTags($hashtags)
267     {
268         //turn each into their canonical tag
269         //this is needed to remove dupes before saving e.g. #hash.tag = #hashtag
270         for($i=0; $i<count($hashtags); $i++) {
271             /* elide characters we don't want in the tag */
272             $hashtags[$i] = common_canonical_tag($hashtags[$i]);
273         }
274
275         foreach(array_unique($hashtags) as $hashtag) {
276             $this->saveTag($hashtag);
277             self::blow('profile:notice_ids_tagged:%d:%s', $this->profile_id, $hashtag);
278         }
279         return true;
280     }
281
282     /**
283      * Record a single hash tag as associated with this notice.
284      * Tag format and uniqueness must be validated by caller.
285      */
286     function saveTag($hashtag)
287     {
288         $tag = new Notice_tag();
289         $tag->notice_id = $this->id;
290         $tag->tag = $hashtag;
291         $tag->created = $this->created;
292         $id = $tag->insert();
293
294         if (!$id) {
295             // TRANS: Server exception. %s are the error details.
296             throw new ServerException(sprintf(_('Database error inserting hashtag: %s.'),
297                                               $last_error->message));
298             return;
299         }
300
301         // if it's saved, blow its cache
302         $tag->blowCache(false);
303     }
304
305     /**
306      * Save a new notice and push it out to subscribers' inboxes.
307      * Poster's permissions are checked before sending.
308      *
309      * @param int $profile_id Profile ID of the poster
310      * @param string $content source message text; links may be shortened
311      *                        per current user's preference
312      * @param string $source source key ('web', 'api', etc)
313      * @param array $options Associative array of optional properties:
314      *              string 'created' timestamp of notice; defaults to now
315      *              int 'is_local' source/gateway ID, one of:
316      *                  Notice::LOCAL_PUBLIC    - Local, ok to appear in public timeline
317      *                  Notice::REMOTE          - Sent from a remote service;
318      *                                            hide from public timeline but show in
319      *                                            local "and friends" timelines
320      *                  Notice::LOCAL_NONPUBLIC - Local, but hide from public timeline
321      *                  Notice::GATEWAY         - From another non-OStatus service;
322      *                                            will not appear in public views
323      *              float 'lat' decimal latitude for geolocation
324      *              float 'lon' decimal longitude for geolocation
325      *              int 'location_id' geoname identifier
326      *              int 'location_ns' geoname namespace to interpret location_id
327      *              int 'reply_to'; notice ID this is a reply to
328      *              int 'repeat_of'; notice ID this is a repeat of
329      *              string 'uri' unique ID for notice; a unique tag uri (can be url or anything too)
330      *              string 'url' permalink to notice; defaults to local notice URL
331      *              string 'rendered' rendered HTML version of content
332      *              array 'replies' list of profile URIs for reply delivery in
333      *                              place of extracting @-replies from content.
334      *              array 'groups' list of group IDs to deliver to, in place of
335      *                              extracting ! tags from content
336      *              array 'tags' list of hashtag strings to save with the notice
337      *                           in place of extracting # tags from content
338      *              array 'urls' list of attached/referred URLs to save with the
339      *                           notice in place of extracting links from content
340      *              boolean 'distribute' whether to distribute the notice, default true
341      *              string 'object_type' URL of the associated object type (default ActivityObject::NOTE)
342      *              string 'verb' URL of the associated verb (default ActivityVerb::POST)
343      *              int 'scope' Scope bitmask; default to SITE_SCOPE on private sites, 0 otherwise
344      *
345      * @fixme tag override
346      *
347      * @return Notice
348      * @throws ClientException
349      */
350     static function saveNew($profile_id, $content, $source, array $options=null) {
351         $defaults = array('uri' => null,
352                           'url' => null,
353                           'reply_to' => null,
354                           'repeat_of' => null,
355                           'scope' => null,
356                           'distribute' => true,
357                           'object_type' => null,
358                           'verb' => null);
359
360         if (!empty($options) && is_array($options)) {
361             $options = array_merge($defaults, $options);
362             extract($options);
363         } else {
364             extract($defaults);
365         }
366
367         if (!isset($is_local)) {
368             $is_local = Notice::LOCAL_PUBLIC;
369         }
370
371         $profile = Profile::getKV('id', $profile_id);
372         if (!$profile instanceof Profile) {
373             // TRANS: Client exception thrown when trying to save a notice for an unknown user.
374             throw new ClientException(_('Problem saving notice. Unknown user.'));
375         }
376
377         $user = User::getKV('id', $profile_id);
378         if ($user instanceof User) {
379             // Use the local user's shortening preferences, if applicable.
380             $final = $user->shortenLinks($content);
381         } else {
382             $final = common_shorten_links($content);
383         }
384
385         if (Notice::contentTooLong($final)) {
386             // TRANS: Client exception thrown if a notice contains too many characters.
387             throw new ClientException(_('Problem saving notice. Too long.'));
388         }
389
390         if (common_config('throttle', 'enabled') && !Notice::checkEditThrottle($profile_id)) {
391             common_log(LOG_WARNING, 'Excessive posting by profile #' . $profile_id . '; throttled.');
392             // TRANS: Client exception thrown when a user tries to post too many notices in a given time frame.
393             throw new ClientException(_('Too many notices too fast; take a breather '.
394                                         'and post again in a few minutes.'));
395         }
396
397         if (common_config('site', 'dupelimit') > 0 && !Notice::checkDupes($profile_id, $final)) {
398             common_log(LOG_WARNING, 'Dupe posting by profile #' . $profile_id . '; throttled.');
399             // TRANS: Client exception thrown when a user tries to post too many duplicate notices in a given time frame.
400             throw new ClientException(_('Too many duplicate messages too quickly;'.
401                                         ' take a breather and post again in a few minutes.'));
402         }
403
404         if (!$profile->hasRight(Right::NEWNOTICE)) {
405             common_log(LOG_WARNING, "Attempted post from user disallowed to post: " . $profile->nickname);
406
407             // TRANS: Client exception thrown when a user tries to post while being banned.
408             throw new ClientException(_('You are banned from posting notices on this site.'), 403);
409         }
410
411         $notice = new Notice();
412         $notice->profile_id = $profile_id;
413
414         $autosource = common_config('public', 'autosource');
415
416         // Sandboxed are non-false, but not 1, either
417
418         if (!$profile->hasRight(Right::PUBLICNOTICE) ||
419             ($source && $autosource && in_array($source, $autosource))) {
420             $notice->is_local = Notice::LOCAL_NONPUBLIC;
421         } else {
422             $notice->is_local = $is_local;
423         }
424
425         if (!empty($created)) {
426             $notice->created = $created;
427         } else {
428             $notice->created = common_sql_now();
429         }
430
431         if (!$notice->isLocal()) {
432             // Only do these checks for non-local notices. Local notices will generate these values later.
433             if (!common_valid_http_url($url)) {
434                 common_debug('Bad notice URL: ['.$url.'], URI: ['.$uri.']. Cannot link back to original! This is normal for shared notices etc.');
435             }
436             if (empty($uri)) {
437                 throw new ServerException('No URI for remote notice. Cannot accept that.');
438             }
439         }
440
441         $notice->content = $final;
442
443         $notice->source = $source;
444         $notice->uri = $uri;
445         $notice->url = $url;
446
447         // Get the groups here so we can figure out replies and such
448         if (!isset($groups)) {
449             $groups = User_group::idsFromText($notice->content, $profile);
450         }
451
452         $reply = null;
453
454         // Handle repeat case
455
456         if (isset($repeat_of)) {
457
458             // Check for a private one
459
460             $repeat = Notice::getKV('id', $repeat_of);
461
462             if (!($repeat instanceof Notice)) {
463                 // TRANS: Client exception thrown in notice when trying to repeat a missing or deleted notice.
464                 throw new ClientException(_('Cannot repeat; original notice is missing or deleted.'));
465             }
466
467             if ($profile->id == $repeat->profile_id) {
468                 // TRANS: Client error displayed when trying to repeat an own notice.
469                 throw new ClientException(_('You cannot repeat your own notice.'));
470             }
471
472             if ($repeat->scope != Notice::SITE_SCOPE &&
473                 $repeat->scope != Notice::PUBLIC_SCOPE) {
474                 // TRANS: Client error displayed when trying to repeat a non-public notice.
475                 throw new ClientException(_('Cannot repeat a private notice.'), 403);
476             }
477
478             if (!$repeat->inScope($profile)) {
479                 // The generic checks above should cover this, but let's be sure!
480                 // TRANS: Client error displayed when trying to repeat a notice you cannot access.
481                 throw new ClientException(_('Cannot repeat a notice you cannot read.'), 403);
482             }
483
484             if ($profile->hasRepeated($repeat)) {
485                 // TRANS: Client error displayed when trying to repeat an already repeated notice.
486                 throw new ClientException(_('You already repeated that notice.'));
487             }
488
489             $notice->repeat_of = $repeat_of;
490         } else {
491             $reply = self::getReplyTo($reply_to, $profile_id, $source, $final);
492
493             if (!empty($reply)) {
494
495                 if (!$reply->inScope($profile)) {
496                     // TRANS: Client error displayed when trying to reply to a notice a the target has no access to.
497                     // TRANS: %1$s is a user nickname, %2$d is a notice ID (number).
498                     throw new ClientException(sprintf(_('%1$s has no access to notice %2$d.'),
499                                                       $profile->nickname, $reply->id), 403);
500                 }
501
502                 $notice->reply_to     = $reply->id;
503                 $notice->conversation = $reply->conversation;
504
505                 // If the original is private to a group, and notice has no group specified,
506                 // make it to the same group(s)
507
508                 if (empty($groups) && ($reply->scope & Notice::GROUP_SCOPE)) {
509                     $groups = array();
510                     $replyGroups = $reply->getGroups();
511                     foreach ($replyGroups as $group) {
512                         if ($profile->isMember($group)) {
513                             $groups[] = $group->id;
514                         }
515                     }
516                 }
517
518                 // Scope set below
519             }
520         }
521
522         if (!empty($lat) && !empty($lon)) {
523             $notice->lat = $lat;
524             $notice->lon = $lon;
525         }
526
527         if (!empty($location_ns) && !empty($location_id)) {
528             $notice->location_id = $location_id;
529             $notice->location_ns = $location_ns;
530         }
531
532         if (!empty($rendered)) {
533             $notice->rendered = $rendered;
534         } else {
535             $notice->rendered = common_render_content($final, $notice);
536         }
537
538         if (empty($verb)) {
539             if (!empty($notice->repeat_of)) {
540                 $notice->verb        = ActivityVerb::SHARE;
541                 $notice->object_type = ActivityObject::ACTIVITY;
542             } else {
543                 $notice->verb        = ActivityVerb::POST;
544             }
545         } else {
546             $notice->verb = $verb;
547         }
548
549         if (empty($object_type)) {
550             $notice->object_type = (empty($notice->reply_to)) ? ActivityObject::NOTE : ActivityObject::COMMENT;
551         } else {
552             $notice->object_type = $object_type;
553         }
554
555         if (is_null($scope)) { // 0 is a valid value
556             if (!empty($reply)) {
557                 $notice->scope = $reply->scope;
558             } else {
559                 $notice->scope = self::defaultScope();
560             }
561         } else {
562             $notice->scope = $scope;
563         }
564
565         // For private streams
566
567         try {
568             $user = $profile->getUser();
569
570             if ($user->private_stream &&
571                 ($notice->scope == Notice::PUBLIC_SCOPE ||
572                  $notice->scope == Notice::SITE_SCOPE)) {
573                 $notice->scope |= Notice::FOLLOWER_SCOPE;
574             }
575         } catch (NoSuchUserException $e) {
576             // Cannot handle private streams for remote profiles
577         }
578
579         // Force the scope for private groups
580
581         foreach ($groups as $groupId) {
582             $group = User_group::getKV('id', $groupId);
583             if ($group instanceof User_group) {
584                 if ($group->force_scope) {
585                     $notice->scope |= Notice::GROUP_SCOPE;
586                     break;
587                 }
588             }
589         }
590
591         if (Event::handle('StartNoticeSave', array(&$notice))) {
592
593             // XXX: some of these functions write to the DB
594
595             $id = $notice->insert();
596
597             if (!$id) {
598                 common_log_db_error($notice, 'INSERT', __FILE__);
599                 // TRANS: Server exception thrown when a notice cannot be saved.
600                 throw new ServerException(_('Problem saving notice.'));
601             }
602
603             // Update ID-dependent columns: URI, conversation
604
605             $orig = clone($notice);
606
607             $changed = false;
608
609             // We can only get here if it's a local notice, since remote notices
610             // should've bailed out earlier due to lacking a URI.
611             if (empty($notice->uri)) {
612                 $notice->uri = sprintf('%s%s=%d:%s=%s',
613                                     TagURI::mint(),
614                                     'noticeId', $notice->id,
615                                     'objectType', $notice->get_object_type(true));
616                 $changed = true;
617             }
618
619             // If it's not part of a conversation, it's
620             // the beginning of a new conversation.
621
622             if (empty($notice->conversation)) {
623                 $conv = Conversation::create($notice);
624                 $notice->conversation = $conv->id;
625                 $changed = true;
626             }
627
628             if ($changed) {
629                 if ($notice->update($orig) === false) {
630                     common_log_db_error($notice, 'UPDATE', __FILE__);
631                     // TRANS: Server exception thrown when a notice cannot be updated.
632                     throw new ServerException(_('Problem saving notice.'));
633                 }
634             }
635
636         }
637
638         // Clear the cache for subscribed users, so they'll update at next request
639         // XXX: someone clever could prepend instead of clearing the cache
640
641         $notice->blowOnInsert();
642
643         // Save per-notice metadata...
644
645         if (isset($replies)) {
646             $notice->saveKnownReplies($replies);
647         } else {
648             $notice->saveReplies();
649         }
650
651         if (isset($tags)) {
652             $notice->saveKnownTags($tags);
653         } else {
654             $notice->saveTags();
655         }
656
657         // Note: groups may save tags, so must be run after tags are saved
658         // to avoid errors on duplicates.
659         // Note: groups should always be set.
660
661         $notice->saveKnownGroups($groups);
662
663         if (isset($urls)) {
664             $notice->saveKnownUrls($urls);
665         } else {
666             $notice->saveUrls();
667         }
668
669         if ($distribute) {
670             // Prepare inbox delivery, may be queued to background.
671             $notice->distribute();
672         }
673
674         return $notice;
675     }
676
677     function blowOnInsert($conversation = false)
678     {
679         $this->blowStream('profile:notice_ids:%d', $this->profile_id);
680
681         if ($this->isPublic()) {
682             $this->blowStream('public');
683         }
684
685         self::blow('notice:list-ids:conversation:%s', $this->conversation);
686         self::blow('conversation:notice_count:%d', $this->conversation);
687
688         if (!empty($this->repeat_of)) {
689             // XXX: we should probably only use one of these
690             $this->blowStream('notice:repeats:%d', $this->repeat_of);
691             self::blow('notice:list-ids:repeat_of:%d', $this->repeat_of);
692         }
693
694         $original = Notice::getKV('id', $this->repeat_of);
695
696         if ($original instanceof Notice) {
697             $originalUser = User::getKV('id', $original->profile_id);
698             if ($originalUser instanceof User) {
699                 $this->blowStream('user:repeats_of_me:%d', $originalUser->id);
700             }
701         }
702
703         $profile = Profile::getKV($this->profile_id);
704
705         if ($profile instanceof Profile) {
706             $profile->blowNoticeCount();
707         }
708
709         $ptags = $this->getProfileTags();
710         foreach ($ptags as $ptag) {
711             $ptag->blowNoticeStreamCache();
712         }
713     }
714
715     /**
716      * Clear cache entries related to this notice at delete time.
717      * Necessary to avoid breaking paging on public, profile timelines.
718      */
719     function blowOnDelete()
720     {
721         $this->blowOnInsert();
722
723         self::blow('profile:notice_ids:%d;last', $this->profile_id);
724
725         if ($this->isPublic()) {
726             self::blow('public;last');
727         }
728
729         self::blow('fave:by_notice', $this->id);
730
731         if ($this->conversation) {
732             // In case we're the first, will need to calc a new root.
733             self::blow('notice:conversation_root:%d', $this->conversation);
734         }
735
736         $ptags = $this->getProfileTags();
737         foreach ($ptags as $ptag) {
738             $ptag->blowNoticeStreamCache(true);
739         }
740     }
741
742     function blowStream()
743     {
744         $c = self::memcache();
745
746         if (empty($c)) {
747             return false;
748         }
749
750         $args = func_get_args();
751
752         $format = array_shift($args);
753
754         $keyPart = vsprintf($format, $args);
755
756         $cacheKey = Cache::key($keyPart);
757         
758         $c->delete($cacheKey);
759
760         // delete the "last" stream, too, if this notice is
761         // older than the top of that stream
762
763         $lastKey = $cacheKey.';last';
764
765         $lastStr = $c->get($lastKey);
766
767         if ($lastStr !== false) {
768             $window     = explode(',', $lastStr);
769             $lastID     = $window[0];
770             $lastNotice = Notice::getKV('id', $lastID);
771             if (!$lastNotice instanceof Notice // just weird
772                 || strtotime($lastNotice->created) >= strtotime($this->created)) {
773                 $c->delete($lastKey);
774             }
775         }
776     }
777
778     /** save all urls in the notice to the db
779      *
780      * follow redirects and save all available file information
781      * (mimetype, date, size, oembed, etc.)
782      *
783      * @return void
784      */
785     function saveUrls() {
786         if (common_config('attachments', 'process_links')) {
787             common_replace_urls_callback($this->content, array($this, 'saveUrl'), $this->id);
788         }
789     }
790
791     /**
792      * Save the given URLs as related links/attachments to the db
793      *
794      * follow redirects and save all available file information
795      * (mimetype, date, size, oembed, etc.)
796      *
797      * @return void
798      */
799     function saveKnownUrls($urls)
800     {
801         if (common_config('attachments', 'process_links')) {
802             // @fixme validation?
803             foreach (array_unique($urls) as $url) {
804                 File::processNew($url, $this->id);
805             }
806         }
807     }
808
809     /**
810      * @private callback
811      */
812     function saveUrl($url, $notice_id) {
813         File::processNew($url, $notice_id);
814     }
815
816     static function checkDupes($profile_id, $content) {
817         $profile = Profile::getKV($profile_id);
818         if (!$profile instanceof Profile) {
819             return false;
820         }
821         $notice = $profile->getNotices(0, CachingNoticeStream::CACHE_WINDOW);
822         if (!empty($notice)) {
823             $last = 0;
824             while ($notice->fetch()) {
825                 if (time() - strtotime($notice->created) >= common_config('site', 'dupelimit')) {
826                     return true;
827                 } else if ($notice->content == $content) {
828                     return false;
829                 }
830             }
831         }
832         // If we get here, oldest item in cache window is not
833         // old enough for dupe limit; do direct check against DB
834         $notice = new Notice();
835         $notice->profile_id = $profile_id;
836         $notice->content = $content;
837         $threshold = common_sql_date(time() - common_config('site', 'dupelimit'));
838         $notice->whereAdd(sprintf("created > '%s'", $notice->escape($threshold)));
839
840         $cnt = $notice->count();
841         return ($cnt == 0);
842     }
843
844     static function checkEditThrottle($profile_id) {
845         $profile = Profile::getKV($profile_id);
846         if (!$profile instanceof Profile) {
847             return false;
848         }
849         // Get the Nth notice
850         $notice = $profile->getNotices(common_config('throttle', 'count') - 1, 1);
851         if ($notice && $notice->fetch()) {
852             // If the Nth notice was posted less than timespan seconds ago
853             if (time() - strtotime($notice->created) <= common_config('throttle', 'timespan')) {
854                 // Then we throttle
855                 return false;
856             }
857         }
858         // Either not N notices in the stream, OR the Nth was not posted within timespan seconds
859         return true;
860     }
861
862         protected $_attachments = -1;
863         
864     function attachments() {
865
866                 if ($this->_attachments != -1)  {
867             return $this->_attachments;
868         }
869                 
870         $f2ps = File_to_post::listGet('post_id', array($this->id));
871                 
872                 $ids = array();
873                 
874                 foreach ($f2ps[$this->id] as $f2p) {
875             $ids[] = $f2p->file_id;    
876         }
877                 
878                 $files = File::multiGet('id', $ids);
879
880                 $this->_attachments = $files->fetchAll();
881                 
882         return $this->_attachments;
883     }
884
885         function _setAttachments($attachments)
886         {
887             $this->_attachments = $attachments;
888         }
889
890     function publicStream($offset=0, $limit=20, $since_id=0, $max_id=0)
891     {
892         $stream = new PublicNoticeStream();
893         return $stream->getNotices($offset, $limit, $since_id, $max_id);
894     }
895
896
897     function conversationStream($id, $offset=0, $limit=20, $since_id=0, $max_id=0)
898     {
899         $stream = new ConversationNoticeStream($id);
900
901         return $stream->getNotices($offset, $limit, $since_id, $max_id);
902     }
903
904     /**
905      * Is this notice part of an active conversation?
906      *
907      * @return boolean true if other messages exist in the same
908      *                 conversation, false if this is the only one
909      */
910     function hasConversation()
911     {
912         if (!empty($this->conversation)) {
913             $conversation = Notice::conversationStream(
914                 $this->conversation,
915                 1,
916                 1
917             );
918
919             if ($conversation->N > 0) {
920                 return true;
921             }
922         }
923         return false;
924     }
925
926     /**
927      * Grab the earliest notice from this conversation.
928      *
929      * @return Notice or null
930      */
931     function conversationRoot($profile=-1)
932     {
933         // XXX: can this happen?
934
935         if (empty($this->conversation)) {
936             return null;
937         }
938
939         // Get the current profile if not specified
940
941         if (is_int($profile) && $profile == -1) {
942             $profile = Profile::current();
943         }
944
945         // If this notice is out of scope, no root for you!
946
947         if (!$this->inScope($profile)) {
948             return null;
949         }
950
951         // If this isn't a reply to anything, then it's its own
952         // root.
953
954         if (empty($this->reply_to)) {
955             return $this;
956         }
957         
958         if (is_null($profile)) {
959             $keypart = sprintf('notice:conversation_root:%d:null', $this->id);
960         } else {
961             $keypart = sprintf('notice:conversation_root:%d:%d',
962                                $this->id,
963                                $profile->id);
964         }
965             
966         $root = self::cacheGet($keypart);
967
968         if ($root !== false && $root->inScope($profile)) {
969             return $root;
970         }
971
972         $last = $this;
973         while (true) {
974             try {
975                 $parent = $last->getParent();
976                 if ($parent->inScope($profile)) {
977                     $last = $parent;
978                     continue;
979                 }
980             } catch (Exception $e) {
981                 // Latest notice has no parent
982             }
983             // No parent, or parent out of scope
984             $root = $last;
985             break;
986         }
987
988         self::cacheSet($keypart, $root);
989
990         return $root;
991     }
992
993     /**
994      * Pull up a full list of local recipients who will be getting
995      * this notice in their inbox. Results will be cached, so don't
996      * change the input data wily-nilly!
997      *
998      * @param array $groups optional list of Group objects;
999      *              if left empty, will be loaded from group_inbox records
1000      * @param array $recipient optional list of reply profile ids
1001      *              if left empty, will be loaded from reply records
1002      * @return array associating recipient user IDs with an inbox source constant
1003      */
1004     function whoGets(array $groups=null, array $recipients=null)
1005     {
1006         $c = self::memcache();
1007
1008         if (!empty($c)) {
1009             $ni = $c->get(Cache::key('notice:who_gets:'.$this->id));
1010             if ($ni !== false) {
1011                 return $ni;
1012             }
1013         }
1014
1015         if (is_null($recipients)) {
1016             $recipients = $this->getReplies();
1017         }
1018
1019         $ni = array();
1020
1021         // Give plugins a chance to add folks in at start...
1022         if (Event::handle('StartNoticeWhoGets', array($this, &$ni))) {
1023
1024             $users = $this->getSubscribedUsers();
1025             foreach ($users as $id) {
1026                 $ni[$id] = NOTICE_INBOX_SOURCE_SUB;
1027             }
1028
1029             if (is_null($groups)) {
1030                 $groups = $this->getGroups();
1031             }
1032             foreach ($groups as $group) {
1033                 $users = $group->getUserMembers();
1034                 foreach ($users as $id) {
1035                     if (!array_key_exists($id, $ni)) {
1036                         $ni[$id] = NOTICE_INBOX_SOURCE_GROUP;
1037                     }
1038                 }
1039             }
1040
1041             $ptAtts = $this->getAttentionsFromProfileTags();
1042             foreach ($ptAtts as $key=>$val) {
1043                 if (!array_key_exists($key, $ni)) {
1044                     $ni[$key] = $val;
1045                 }
1046             }
1047
1048             foreach ($recipients as $recipient) {
1049                 if (!array_key_exists($recipient, $ni)) {
1050                     $ni[$recipient] = NOTICE_INBOX_SOURCE_REPLY;
1051                 }
1052             }
1053
1054             // Exclude any deleted, non-local, or blocking recipients.
1055             $profile = $this->getProfile();
1056             $originalProfile = null;
1057             if ($this->repeat_of) {
1058                 // Check blocks against the original notice's poster as well.
1059                 $original = Notice::getKV('id', $this->repeat_of);
1060                 if ($original instanceof Notice) {
1061                     $originalProfile = $original->getProfile();
1062                 }
1063             }
1064
1065             foreach ($ni as $id => $source) {
1066                 try {
1067                     $user = User::getKV('id', $id);
1068                     if (!$user instanceof User ||
1069                         $user->hasBlocked($profile) ||
1070                         ($originalProfile && $user->hasBlocked($originalProfile))) {
1071                         unset($ni[$id]);
1072                     }
1073                 } catch (UserNoProfileException $e) {
1074                     // User doesn't have a profile; invalid; skip them.
1075                     unset($ni[$id]);
1076                 }
1077             }
1078
1079             // Give plugins a chance to filter out...
1080             Event::handle('EndNoticeWhoGets', array($this, &$ni));
1081         }
1082
1083         if (!empty($c)) {
1084             // XXX: pack this data better
1085             $c->set(Cache::key('notice:who_gets:'.$this->id), $ni);
1086         }
1087
1088         return $ni;
1089     }
1090
1091     function getSubscribedUsers()
1092     {
1093         $user = new User();
1094
1095         if(common_config('db','quote_identifiers'))
1096           $user_table = '"user"';
1097         else $user_table = 'user';
1098
1099         $qry =
1100           'SELECT id ' .
1101           'FROM '. $user_table .' JOIN subscription '.
1102           'ON '. $user_table .'.id = subscription.subscriber ' .
1103           'WHERE subscription.subscribed = %d ';
1104
1105         $user->query(sprintf($qry, $this->profile_id));
1106
1107         $ids = array();
1108
1109         while ($user->fetch()) {
1110             $ids[] = $user->id;
1111         }
1112
1113         $user->free();
1114
1115         return $ids;
1116     }
1117
1118     function getProfileTags()
1119     {
1120         $profile = $this->getProfile();
1121         $list    = $profile->getOtherTags($profile);
1122         $ptags   = array();
1123
1124         while($list->fetch()) {
1125             $ptags[] = clone($list);
1126         }
1127
1128         return $ptags;
1129     }
1130
1131     public function getAttentionsFromProfileTags()
1132     {
1133         $ni = array();
1134         $ptags = $this->getProfileTags();
1135         foreach ($ptags as $ptag) {
1136             $users = $ptag->getUserSubscribers();
1137             foreach ($users as $id) {
1138                 $ni[$id] = NOTICE_INBOX_SOURCE_PROFILE_TAG;
1139             }
1140         }
1141         return $ni;
1142     }
1143
1144     /**
1145      * Record this notice to the given group inboxes for delivery.
1146      * Overrides the regular parsing of !group markup.
1147      *
1148      * @param string $group_ids
1149      * @fixme might prefer URIs as identifiers, as for replies?
1150      *        best with generalizations on user_group to support
1151      *        remote groups better.
1152      */
1153     function saveKnownGroups($group_ids)
1154     {
1155         if (!is_array($group_ids)) {
1156             // TRANS: Server exception thrown when no array is provided to the method saveKnownGroups().
1157             throw new ServerException(_('Bad type provided to saveKnownGroups.'));
1158         }
1159
1160         $groups = array();
1161         foreach (array_unique($group_ids) as $id) {
1162             $group = User_group::getKV('id', $id);
1163             if ($group instanceof User_group) {
1164                 common_log(LOG_ERR, "Local delivery to group id $id, $group->nickname");
1165                 $result = $this->addToGroupInbox($group);
1166                 if (!$result) {
1167                     common_log_db_error($gi, 'INSERT', __FILE__);
1168                 }
1169
1170                 if (common_config('group', 'addtag')) {
1171                     // we automatically add a tag for every group name, too
1172
1173                     $tag = Notice_tag::pkeyGet(array('tag' => common_canonical_tag($group->nickname),
1174                                                      'notice_id' => $this->id));
1175
1176                     if (is_null($tag)) {
1177                         $this->saveTag($group->nickname);
1178                     }
1179                 }
1180
1181                 $groups[] = clone($group);
1182             } else {
1183                 common_log(LOG_ERR, "Local delivery to group id $id skipped, doesn't exist");
1184             }
1185         }
1186
1187         return $groups;
1188     }
1189
1190     function addToGroupInbox(User_group $group)
1191     {
1192         $gi = Group_inbox::pkeyGet(array('group_id' => $group->id,
1193                                          'notice_id' => $this->id));
1194
1195         if (!$gi instanceof Group_inbox) {
1196
1197             $gi = new Group_inbox();
1198
1199             $gi->group_id  = $group->id;
1200             $gi->notice_id = $this->id;
1201             $gi->created   = $this->created;
1202
1203             $result = $gi->insert();
1204
1205             if (!$result) {
1206                 common_log_db_error($gi, 'INSERT', __FILE__);
1207                 // TRANS: Server exception thrown when an update for a group inbox fails.
1208                 throw new ServerException(_('Problem saving group inbox.'));
1209             }
1210
1211             self::blow('user_group:notice_ids:%d', $gi->group_id);
1212         }
1213
1214         return true;
1215     }
1216
1217     /**
1218      * Save reply records indicating that this notice needs to be
1219      * delivered to the local users with the given URIs.
1220      *
1221      * Since this is expected to be used when saving foreign-sourced
1222      * messages, we won't deliver to any remote targets as that's the
1223      * source service's responsibility.
1224      *
1225      * Mail notifications etc will be handled later.
1226      *
1227      * @param array  $uris   Array of unique identifier URIs for recipients
1228      */
1229     function saveKnownReplies(array $uris)
1230     {
1231         if (empty($uris)) {
1232             return;
1233         }
1234
1235         $sender = Profile::getKV($this->profile_id);
1236
1237         foreach (array_unique($uris) as $uri) {
1238
1239             $profile = Profile::fromURI($uri);
1240
1241             if (!$profile instanceof Profile) {
1242                 common_log(LOG_WARNING, "Unable to determine profile for URI '$uri'");
1243                 continue;
1244             }
1245
1246             if ($profile->hasBlocked($sender)) {
1247                 common_log(LOG_INFO, "Not saving reply to profile {$profile->id} ($uri) from sender {$sender->id} because of a block.");
1248                 continue;
1249             }
1250
1251             $this->saveReply($profile->id);
1252             self::blow('reply:stream:%d', $profile->id);
1253         }
1254
1255         return;
1256     }
1257
1258     /**
1259      * Pull @-replies from this message's content in StatusNet markup format
1260      * and save reply records indicating that this message needs to be
1261      * delivered to those users.
1262      *
1263      * Mail notifications to local profiles will be sent later.
1264      *
1265      * @return array of integer profile IDs
1266      */
1267
1268     function saveReplies()
1269     {
1270         // Don't save reply data for repeats
1271
1272         if (!empty($this->repeat_of)) {
1273             return array();
1274         }
1275
1276         $sender = Profile::getKV($this->profile_id);
1277
1278         $replied = array();
1279
1280         // If it's a reply, save for the replied-to author
1281         try {
1282             $parent = $this->getParent();
1283             $author = $parent->getProfile();
1284             if ($author instanceof Profile) {
1285                 $this->saveReply($author->id);
1286                 $replied[$author->id] = 1;
1287                 self::blow('reply:stream:%d', $author->id);
1288             }
1289         } catch (Exception $e) {
1290             // Not a reply, since it has no parent!
1291         }
1292
1293         // @todo ideally this parser information would only
1294         // be calculated once.
1295
1296         $mentions = common_find_mentions($this->content, $this);
1297
1298         // store replied only for first @ (what user/notice what the reply directed,
1299         // we assume first @ is it)
1300
1301         foreach ($mentions as $mention) {
1302
1303             foreach ($mention['mentioned'] as $mentioned) {
1304
1305                 // skip if they're already covered
1306
1307                 if (!empty($replied[$mentioned->id])) {
1308                     continue;
1309                 }
1310
1311                 // Don't save replies from blocked profile to local user
1312
1313                 $mentioned_user = User::getKV('id', $mentioned->id);
1314                 if ($mentioned_user instanceof User && $mentioned_user->hasBlocked($sender)) {
1315                     continue;
1316                 }
1317
1318                 $this->saveReply($mentioned->id);
1319                 $replied[$mentioned->id] = 1;
1320                 self::blow('reply:stream:%d', $mentioned->id);
1321             }
1322         }
1323
1324         $recipientIds = array_keys($replied);
1325
1326         return $recipientIds;
1327     }
1328
1329     function saveReply($profileId)
1330     {
1331         $reply = new Reply();
1332
1333         $reply->notice_id  = $this->id;
1334         $reply->profile_id = $profileId;
1335         $reply->modified   = $this->created;
1336
1337         $reply->insert();
1338
1339         return $reply;
1340     }
1341
1342     protected $_replies = -1;
1343
1344     /**
1345      * Pull the complete list of @-reply targets for this notice.
1346      *
1347      * @return array of integer profile ids
1348      */
1349     function getReplies()
1350     {
1351         if ($this->_replies != -1) {
1352             return $this->_replies;
1353         }
1354
1355         $replyMap = Reply::listGet('notice_id', array($this->id));
1356
1357         $ids = array();
1358
1359         foreach ($replyMap[$this->id] as $reply) {
1360             $ids[] = $reply->profile_id;
1361         }
1362
1363         $this->_replies = $ids;
1364
1365         return $ids;
1366     }
1367
1368     function _setReplies($replies)
1369     {
1370         $this->_replies = $replies;
1371     }
1372
1373     /**
1374      * Pull the complete list of @-reply targets for this notice.
1375      *
1376      * @return array of Profiles
1377      */
1378     function getReplyProfiles()
1379     {
1380         $ids = $this->getReplies();
1381         
1382         $profiles = Profile::multiGet('id', $ids);
1383         
1384         return $profiles->fetchAll();
1385     }
1386
1387     /**
1388      * Send e-mail notifications to local @-reply targets.
1389      *
1390      * Replies must already have been saved; this is expected to be run
1391      * from the distrib queue handler.
1392      */
1393     function sendReplyNotifications()
1394     {
1395         // Don't send reply notifications for repeats
1396
1397         if (!empty($this->repeat_of)) {
1398             return array();
1399         }
1400
1401         $recipientIds = $this->getReplies();
1402
1403         foreach ($recipientIds as $recipientId) {
1404             $user = User::getKV('id', $recipientId);
1405             if ($user instanceof User) {
1406                 mail_notify_attn($user, $this);
1407             }
1408         }
1409     }
1410
1411     /**
1412      * Pull list of groups this notice needs to be delivered to,
1413      * as previously recorded by saveKnownGroups().
1414      *
1415      * @return array of Group objects
1416      */
1417     
1418     protected $_groups = -1;
1419     
1420     function getGroups()
1421     {
1422         // Don't save groups for repeats
1423
1424         if (!empty($this->repeat_of)) {
1425             return array();
1426         }
1427         
1428         if ($this->_groups != -1)
1429         {
1430             return $this->_groups;
1431         }
1432         
1433         $gis = Group_inbox::listGet('notice_id', array($this->id));
1434
1435         $ids = array();
1436
1437                 foreach ($gis[$this->id] as $gi)
1438                 {
1439                     $ids[] = $gi->group_id;
1440                 }
1441                 
1442                 $groups = User_group::multiGet('id', $ids);
1443                 
1444                 $this->_groups = $groups->fetchAll();
1445                 
1446                 return $this->_groups;
1447     }
1448     
1449     function _setGroups($groups)
1450     {
1451         $this->_groups = $groups;
1452     }
1453
1454     /**
1455      * Convert a notice into an activity for export.
1456      *
1457      * @param User $cur Current user
1458      *
1459      * @return Activity activity object representing this Notice.
1460      */
1461
1462     function asActivity($cur=null)
1463     {
1464         $act = self::cacheGet(Cache::codeKey('notice:as-activity:'.$this->id));
1465
1466         if ($act instanceof Activity) {
1467             return $act;
1468         }
1469         $act = new Activity();
1470
1471         if (Event::handle('StartNoticeAsActivity', array($this, &$act))) {
1472
1473             $act->id      = $this->uri;
1474             $act->time    = strtotime($this->created);
1475             $act->link    = $this->bestUrl();
1476             $act->content = common_xml_safe_str($this->rendered);
1477
1478             $profile = $this->getProfile();
1479
1480             $act->actor            = ActivityObject::fromProfile($profile);
1481             $act->actor->extra[]   = $profile->profileInfo($cur);
1482
1483             $act->verb = $this->verb;
1484
1485             if ($this->repeat_of) {
1486                 $repeated = Notice::getKV('id', $this->repeat_of);
1487                 if ($repeated instanceof Notice) {
1488                     $act->objects[] = $repeated->asActivity($cur);
1489                 }
1490             } else {
1491                 $act->objects[] = ActivityObject::fromNotice($this);
1492             }
1493
1494             // XXX: should this be handled by default processing for object entry?
1495
1496             // Categories
1497
1498             $tags = $this->getTags();
1499
1500             foreach ($tags as $tag) {
1501                 $cat       = new AtomCategory();
1502                 $cat->term = $tag;
1503
1504                 $act->categories[] = $cat;
1505             }
1506
1507             // Enclosures
1508             // XXX: use Atom Media and/or File activity objects instead
1509
1510             $attachments = $this->attachments();
1511
1512             foreach ($attachments as $attachment) {
1513                 // Save local attachments
1514                 if (!empty($attachment->filename)) {
1515                     $act->attachments[] = ActivityObject::fromFile($attachment);
1516                 }
1517             }
1518
1519             $ctx = new ActivityContext();
1520
1521             try {
1522                 $reply = $this->getParent();
1523                 $ctx->replyToID  = $reply->uri;
1524                 $ctx->replyToUrl = $reply->bestUrl();
1525             } catch (Exception $e) {
1526                 // This is not a reply to something
1527             }
1528
1529             $ctx->location = $this->getLocation();
1530
1531             $conv = null;
1532
1533             if (!empty($this->conversation)) {
1534                 $conv = Conversation::getKV('id', $this->conversation);
1535                 if ($conv instanceof Conversation) {
1536                     $ctx->conversation = $conv->uri;
1537                 }
1538             }
1539
1540             $reply_ids = $this->getReplies();
1541
1542             foreach ($reply_ids as $id) {
1543                 $rprofile = Profile::getKV('id', $id);
1544                 if ($rprofile instanceof Profile) {
1545                     $ctx->attention[$rprofile->getUri()] = ActivityObject::PERSON;
1546                 }
1547             }
1548
1549             $groups = $this->getGroups();
1550
1551             foreach ($groups as $group) {
1552                 $ctx->attention[$group->getUri()] = ActivityObject::GROUP;
1553             }
1554
1555             switch ($this->scope) {
1556             case Notice::PUBLIC_SCOPE:
1557                 $ctx->attention[ActivityContext::ATTN_PUBLIC] = ActivityObject::COLLECTION;
1558                 break;
1559             case Notice::FOLLOWER_SCOPE:
1560                 $surl = common_local_url("subscribers", array('nickname' => $profile->nickname));
1561                 $ctx->attention[$surl] = ActivityObject::COLLECTION;
1562                 break;
1563             }
1564
1565             $act->context = $ctx;
1566
1567             $source = $this->getSource();
1568
1569             if ($source instanceof Notice_source) {
1570                 $act->generator = ActivityObject::fromNoticeSource($source);
1571             }
1572
1573             // Source
1574
1575             $atom_feed = $profile->getAtomFeed();
1576
1577             if (!empty($atom_feed)) {
1578
1579                 $act->source = new ActivitySource();
1580
1581                 // XXX: we should store the actual feed ID
1582
1583                 $act->source->id = $atom_feed;
1584
1585                 // XXX: we should store the actual feed title
1586
1587                 $act->source->title = $profile->getBestName();
1588
1589                 $act->source->links['alternate'] = $profile->profileurl;
1590                 $act->source->links['self']      = $atom_feed;
1591
1592                 $act->source->icon = $profile->avatarUrl(AVATAR_PROFILE_SIZE);
1593
1594                 $notice = $profile->getCurrentNotice();
1595
1596                 if ($notice instanceof Notice) {
1597                     $act->source->updated = self::utcDate($notice->created);
1598                 }
1599
1600                 $user = User::getKV('id', $profile->id);
1601
1602                 if ($user instanceof User) {
1603                     $act->source->links['license'] = common_config('license', 'url');
1604                 }
1605             }
1606
1607             if ($this->isLocal()) {
1608                 $act->selfLink = common_local_url('ApiStatusesShow', array('id' => $this->id,
1609                                                                            'format' => 'atom'));
1610                 $act->editLink = $act->selfLink;
1611             }
1612
1613             Event::handle('EndNoticeAsActivity', array($this, &$act));
1614         }
1615
1616         self::cacheSet(Cache::codeKey('notice:as-activity:'.$this->id), $act);
1617
1618         return $act;
1619     }
1620
1621     // This has gotten way too long. Needs to be sliced up into functional bits
1622     // or ideally exported to a utility class.
1623
1624     function asAtomEntry($namespace=false,
1625                          $source=false,
1626                          $author=true,
1627                          $cur=null)
1628     {
1629         $act = $this->asActivity($cur);
1630         $act->extra[] = $this->noticeInfo($cur);
1631         return $act->asString($namespace, $author, $source);
1632     }
1633
1634     /**
1635      * Extra notice info for atom entries
1636      *
1637      * Clients use some extra notice info in the atom stream.
1638      * This gives it to them.
1639      *
1640      * @param User $cur Current user
1641      *
1642      * @return array representation of <statusnet:notice_info> element
1643      */
1644
1645     function noticeInfo($cur)
1646     {
1647         // local notice ID (useful to clients for ordering)
1648
1649         $noticeInfoAttr = array('local_id' => $this->id);
1650
1651         // notice source
1652
1653         $ns = $this->getSource();
1654
1655         if ($ns instanceof Notice_source) {
1656             $noticeInfoAttr['source'] =  $ns->code;
1657             if (!empty($ns->url)) {
1658                 $noticeInfoAttr['source_link'] = $ns->url;
1659                 if (!empty($ns->name)) {
1660                     $noticeInfoAttr['source'] =  '<a href="'
1661                         . htmlspecialchars($ns->url)
1662                         . '" rel="nofollow">'
1663                         . htmlspecialchars($ns->name)
1664                         . '</a>';
1665                 }
1666             }
1667         }
1668
1669         // favorite and repeated
1670
1671         if (!empty($cur)) {
1672             $cp = $cur->getProfile();
1673             $noticeInfoAttr['favorite'] = ($cp->hasFave($this)) ? "true" : "false";
1674             $noticeInfoAttr['repeated'] = ($cp->hasRepeated($this)) ? "true" : "false";
1675         }
1676
1677         if (!empty($this->repeat_of)) {
1678             $noticeInfoAttr['repeat_of'] = $this->repeat_of;
1679         }
1680
1681         return array('statusnet:notice_info', $noticeInfoAttr, null);
1682     }
1683
1684     /**
1685      * Returns an XML string fragment with a reference to a notice as an
1686      * Activity Streams noun object with the given element type.
1687      *
1688      * Assumes that 'activity' namespace has been previously defined.
1689      *
1690      * @param string $element one of 'subject', 'object', 'target'
1691      * @return string
1692      */
1693
1694     function asActivityNoun($element)
1695     {
1696         $noun = ActivityObject::fromNotice($this);
1697         return $noun->asString('activity:' . $element);
1698     }
1699
1700     function bestUrl()
1701     {
1702         if (!empty($this->url)) {
1703             return $this->url;
1704         } else if (!empty($this->uri) && preg_match('/^https?:/', $this->uri)) {
1705             return $this->uri;
1706         } else {
1707             return common_local_url('shownotice',
1708                                     array('notice' => $this->id));
1709         }
1710     }
1711
1712
1713     /**
1714      * Determine which notice, if any, a new notice is in reply to.
1715      *
1716      * For conversation tracking, we try to see where this notice fits
1717      * in the tree. Rough algorithm is:
1718      *
1719      * if (reply_to is set and valid) {
1720      *     return reply_to;
1721      * } else if ((source not API or Web) and (content starts with "T NAME" or "@name ")) {
1722      *     return ID of last notice by initial @name in content;
1723      * }
1724      *
1725      * Note that all @nickname instances will still be used to save "reply" records,
1726      * so the notice shows up in the mentioned users' "replies" tab.
1727      *
1728      * @param integer $reply_to   ID passed in by Web or API
1729      * @param integer $profile_id ID of author
1730      * @param string  $source     Source tag, like 'web' or 'gwibber'
1731      * @param string  $content    Final notice content
1732      *
1733      * @return integer ID of replied-to notice, or null for not a reply.
1734      */
1735
1736     static function getReplyTo($reply_to, $profile_id, $source, $content)
1737     {
1738         static $lb = array('xmpp', 'mail', 'sms', 'omb');
1739
1740         // If $reply_to is specified, we check that it exists, and then
1741         // return it if it does
1742
1743         if (!empty($reply_to)) {
1744             $reply_notice = Notice::getKV('id', $reply_to);
1745             if ($reply_notice instanceof Notice) {
1746                 return $reply_notice;
1747             }
1748         }
1749
1750         // If it's not a "low bandwidth" source (one where you can't set
1751         // a reply_to argument), we return. This is mostly web and API
1752         // clients.
1753
1754         if (!in_array($source, $lb)) {
1755             return null;
1756         }
1757
1758         // Is there an initial @ or T?
1759
1760         if (preg_match('/^T ([A-Z0-9]{1,64}) /', $content, $match) ||
1761             preg_match('/^@([a-z0-9]{1,64})\s+/', $content, $match)) {
1762             $nickname = common_canonical_nickname($match[1]);
1763         } else {
1764             return null;
1765         }
1766
1767         // Figure out who that is.
1768
1769         $sender = Profile::getKV('id', $profile_id);
1770         if (!$sender instanceof Profile) {
1771             return null;
1772         }
1773
1774         $recipient = common_relative_profile($sender, $nickname, common_sql_now());
1775
1776         if (!$recipient instanceof Profile) {
1777             return null;
1778         }
1779
1780         // Get their last notice
1781
1782         $last = $recipient->getCurrentNotice();
1783
1784         if ($last instanceof Notice) {
1785             return $last;
1786         }
1787
1788         return null;
1789     }
1790
1791     static function maxContent()
1792     {
1793         $contentlimit = common_config('notice', 'contentlimit');
1794         // null => use global limit (distinct from 0!)
1795         if (is_null($contentlimit)) {
1796             $contentlimit = common_config('site', 'textlimit');
1797         }
1798         return $contentlimit;
1799     }
1800
1801     static function contentTooLong($content)
1802     {
1803         $contentlimit = self::maxContent();
1804         return ($contentlimit > 0 && !empty($content) && (mb_strlen($content) > $contentlimit));
1805     }
1806
1807     function getLocation()
1808     {
1809         $location = null;
1810
1811         if (!empty($this->location_id) && !empty($this->location_ns)) {
1812             $location = Location::fromId($this->location_id, $this->location_ns);
1813         }
1814
1815         if (is_null($location)) { // no ID, or Location::fromId() failed
1816             if (!empty($this->lat) && !empty($this->lon)) {
1817                 $location = Location::fromLatLon($this->lat, $this->lon);
1818             }
1819         }
1820
1821         return $location;
1822     }
1823
1824     /**
1825      * Convenience function for posting a repeat of an existing message.
1826      *
1827      * @param int $repeater_id: profile ID of user doing the repeat
1828      * @param string $source: posting source key, eg 'web', 'api', etc
1829      * @return Notice
1830      *
1831      * @throws Exception on failure or permission problems
1832      */
1833     function repeat($repeater_id, $source)
1834     {
1835         $author = Profile::getKV('id', $this->profile_id);
1836
1837         // TRANS: Message used to repeat a notice. RT is the abbreviation of 'retweet'.
1838         // TRANS: %1$s is the repeated user's name, %2$s is the repeated notice.
1839         $content = sprintf(_('RT @%1$s %2$s'),
1840                            $author->nickname,
1841                            $this->content);
1842
1843         $maxlen = common_config('site', 'textlimit');
1844         if ($maxlen > 0 && mb_strlen($content) > $maxlen) {
1845             // Web interface and current Twitter API clients will
1846             // pull the original notice's text, but some older
1847             // clients and RSS/Atom feeds will see this trimmed text.
1848             //
1849             // Unfortunately this is likely to lose tags or URLs
1850             // at the end of long notices.
1851             $content = mb_substr($content, 0, $maxlen - 4) . ' ...';
1852         }
1853
1854         // Scope is same as this one's
1855
1856         return self::saveNew($repeater_id,
1857                              $content,
1858                              $source,
1859                              array('repeat_of' => $this->id,
1860                                    'scope' => $this->scope));
1861     }
1862
1863     // These are supposed to be in chron order!
1864
1865     function repeatStream($limit=100)
1866     {
1867         $cache = Cache::instance();
1868
1869         if (empty($cache)) {
1870             $ids = $this->_repeatStreamDirect($limit);
1871         } else {
1872             $idstr = $cache->get(Cache::key('notice:repeats:'.$this->id));
1873             if ($idstr !== false) {
1874                 if (empty($idstr)) {
1875                         $ids = array();
1876                 } else {
1877                         $ids = explode(',', $idstr);
1878                 }
1879             } else {
1880                 $ids = $this->_repeatStreamDirect(100);
1881                 $cache->set(Cache::key('notice:repeats:'.$this->id), implode(',', $ids));
1882             }
1883             if ($limit < 100) {
1884                 // We do a max of 100, so slice down to limit
1885                 $ids = array_slice($ids, 0, $limit);
1886             }
1887         }
1888
1889         return NoticeStream::getStreamByIds($ids);
1890     }
1891
1892     function _repeatStreamDirect($limit)
1893     {
1894         $notice = new Notice();
1895
1896         $notice->selectAdd(); // clears it
1897         $notice->selectAdd('id');
1898
1899         $notice->repeat_of = $this->id;
1900
1901         $notice->orderBy('created, id'); // NB: asc!
1902
1903         if (!is_null($limit)) {
1904             $notice->limit(0, $limit);
1905         }
1906
1907         return $notice->fetchAll('id');
1908     }
1909
1910     function locationOptions($lat, $lon, $location_id, $location_ns, $profile = null)
1911     {
1912         $options = array();
1913
1914         if (!empty($location_id) && !empty($location_ns)) {
1915             $options['location_id'] = $location_id;
1916             $options['location_ns'] = $location_ns;
1917
1918             $location = Location::fromId($location_id, $location_ns);
1919
1920             if ($location instanceof Location) {
1921                 $options['lat'] = $location->lat;
1922                 $options['lon'] = $location->lon;
1923             }
1924
1925         } else if (!empty($lat) && !empty($lon)) {
1926             $options['lat'] = $lat;
1927             $options['lon'] = $lon;
1928
1929             $location = Location::fromLatLon($lat, $lon);
1930
1931             if ($location instanceof Location) {
1932                 $options['location_id'] = $location->location_id;
1933                 $options['location_ns'] = $location->location_ns;
1934             }
1935         } else if (!empty($profile)) {
1936             if (isset($profile->lat) && isset($profile->lon)) {
1937                 $options['lat'] = $profile->lat;
1938                 $options['lon'] = $profile->lon;
1939             }
1940
1941             if (isset($profile->location_id) && isset($profile->location_ns)) {
1942                 $options['location_id'] = $profile->location_id;
1943                 $options['location_ns'] = $profile->location_ns;
1944             }
1945         }
1946
1947         return $options;
1948     }
1949
1950     function clearAttentions()
1951     {
1952         $att = new Attention();
1953         $att->notice_id = $this->getID();
1954
1955         if ($att->find()) {
1956             while ($att->fetch()) {
1957                 // Can't do delete() on the object directly since it won't remove all of it
1958                 $other = clone($att);
1959                 $other->delete();
1960             }
1961         }
1962     }
1963
1964     function clearReplies()
1965     {
1966         $replyNotice = new Notice();
1967         $replyNotice->reply_to = $this->id;
1968
1969         //Null any notices that are replies to this notice
1970
1971         if ($replyNotice->find()) {
1972             while ($replyNotice->fetch()) {
1973                 $orig = clone($replyNotice);
1974                 $replyNotice->reply_to = null;
1975                 $replyNotice->update($orig);
1976             }
1977         }
1978
1979         // Reply records
1980
1981         $reply = new Reply();
1982         $reply->notice_id = $this->id;
1983
1984         if ($reply->find()) {
1985             while($reply->fetch()) {
1986                 self::blow('reply:stream:%d', $reply->profile_id);
1987                 $reply->delete();
1988             }
1989         }
1990
1991         $reply->free();
1992     }
1993
1994     function clearFiles()
1995     {
1996         $f2p = new File_to_post();
1997
1998         $f2p->post_id = $this->id;
1999
2000         if ($f2p->find()) {
2001             while ($f2p->fetch()) {
2002                 $f2p->delete();
2003             }
2004         }
2005         // FIXME: decide whether to delete File objects
2006         // ...and related (actual) files
2007     }
2008
2009     function clearRepeats()
2010     {
2011         $repeatNotice = new Notice();
2012         $repeatNotice->repeat_of = $this->id;
2013
2014         //Null any notices that are repeats of this notice
2015
2016         if ($repeatNotice->find()) {
2017             while ($repeatNotice->fetch()) {
2018                 $orig = clone($repeatNotice);
2019                 $repeatNotice->repeat_of = null;
2020                 $repeatNotice->update($orig);
2021             }
2022         }
2023     }
2024
2025     function clearFaves()
2026     {
2027         $fave = new Fave();
2028         $fave->notice_id = $this->id;
2029
2030         if ($fave->find()) {
2031             while ($fave->fetch()) {
2032                 self::blow('fave:ids_by_user_own:%d', $fave->user_id);
2033                 self::blow('fave:ids_by_user_own:%d;last', $fave->user_id);
2034                 self::blow('fave:ids_by_user:%d', $fave->user_id);
2035                 self::blow('fave:ids_by_user:%d;last', $fave->user_id);
2036                 $fave->delete();
2037             }
2038         }
2039
2040         $fave->free();
2041     }
2042
2043     function clearTags()
2044     {
2045         $tag = new Notice_tag();
2046         $tag->notice_id = $this->id;
2047
2048         if ($tag->find()) {
2049             while ($tag->fetch()) {
2050                 self::blow('profile:notice_ids_tagged:%d:%s', $this->profile_id, Cache::keyize($tag->tag));
2051                 self::blow('profile:notice_ids_tagged:%d:%s;last', $this->profile_id, Cache::keyize($tag->tag));
2052                 self::blow('notice_tag:notice_ids:%s', Cache::keyize($tag->tag));
2053                 self::blow('notice_tag:notice_ids:%s;last', Cache::keyize($tag->tag));
2054                 $tag->delete();
2055             }
2056         }
2057
2058         $tag->free();
2059     }
2060
2061     function clearGroupInboxes()
2062     {
2063         $gi = new Group_inbox();
2064
2065         $gi->notice_id = $this->id;
2066
2067         if ($gi->find()) {
2068             while ($gi->fetch()) {
2069                 self::blow('user_group:notice_ids:%d', $gi->group_id);
2070                 $gi->delete();
2071             }
2072         }
2073
2074         $gi->free();
2075     }
2076
2077     function distribute()
2078     {
2079         // We always insert for the author so they don't
2080         // have to wait
2081         Event::handle('StartNoticeDistribute', array($this));
2082
2083         // If there's a failure, we want to _force_
2084         // distribution at this point.
2085         try {
2086             $qm = QueueManager::get();
2087             $qm->enqueue($this, 'distrib');
2088         } catch (Exception $e) {
2089             // If the exception isn't transient, this
2090             // may throw more exceptions as DQH does
2091             // its own enqueueing. So, we ignore them!
2092             try {
2093                 $handler = new DistribQueueHandler();
2094                 $handler->handle($this);
2095             } catch (Exception $e) {
2096                 common_log(LOG_ERR, "emergency redistribution resulted in " . $e->getMessage());
2097             }
2098             // Re-throw so somebody smarter can handle it.
2099             throw $e;
2100         }
2101     }
2102
2103     function insert()
2104     {
2105         $result = parent::insert();
2106
2107         if ($result !== false) {
2108             // Profile::hasRepeated() abuses pkeyGet(), so we
2109             // have to clear manually
2110             if (!empty($this->repeat_of)) {
2111                 $c = self::memcache();
2112                 if (!empty($c)) {
2113                     $ck = self::multicacheKey('Notice',
2114                                               array('profile_id' => $this->profile_id,
2115                                                     'repeat_of' => $this->repeat_of));
2116                     $c->delete($ck);
2117                 }
2118             }
2119         }
2120
2121         return $result;
2122     }
2123
2124     /**
2125      * Get the source of the notice
2126      *
2127      * @return Notice_source $ns A notice source object. 'code' is the only attribute
2128      *                           guaranteed to be populated.
2129      */
2130     function getSource()
2131     {
2132         $ns = new Notice_source();
2133         if (!empty($this->source)) {
2134             switch ($this->source) {
2135             case 'web':
2136             case 'xmpp':
2137             case 'mail':
2138             case 'omb':
2139             case 'system':
2140             case 'api':
2141                 $ns->code = $this->source;
2142                 break;
2143             default:
2144                 $ns = Notice_source::getKV($this->source);
2145                 if (!$ns) {
2146                     $ns = new Notice_source();
2147                     $ns->code = $this->source;
2148                     $app = Oauth_application::getKV('name', $this->source);
2149                     if ($app) {
2150                         $ns->name = $app->name;
2151                         $ns->url  = $app->source_url;
2152                     }
2153                 }
2154                 break;
2155             }
2156         }
2157         return $ns;
2158     }
2159
2160     /**
2161      * Determine whether the notice was locally created
2162      *
2163      * @return boolean locality
2164      */
2165
2166     public function isLocal()
2167     {
2168         return ($this->is_local == Notice::LOCAL_PUBLIC ||
2169                 $this->is_local == Notice::LOCAL_NONPUBLIC);
2170     }
2171
2172     /**
2173      * Get the list of hash tags saved with this notice.
2174      *
2175      * @return array of strings
2176      */
2177     public function getTags()
2178     {
2179         $tags = array();
2180
2181         $keypart = sprintf('notice:tags:%d', $this->id);
2182
2183         $tagstr = self::cacheGet($keypart);
2184
2185         if ($tagstr !== false) {
2186             $tags = explode(',', $tagstr);
2187         } else {
2188             $tag = new Notice_tag();
2189             $tag->notice_id = $this->id;
2190             if ($tag->find()) {
2191                 while ($tag->fetch()) {
2192                     $tags[] = $tag->tag;
2193                 }
2194             }
2195             self::cacheSet($keypart, implode(',', $tags));
2196         }
2197
2198         return $tags;
2199     }
2200
2201     static private function utcDate($dt)
2202     {
2203         $dateStr = date('d F Y H:i:s', strtotime($dt));
2204         $d = new DateTime($dateStr, new DateTimeZone('UTC'));
2205         return $d->format(DATE_W3C);
2206     }
2207
2208     /**
2209      * Look up the creation timestamp for a given notice ID, even
2210      * if it's been deleted.
2211      *
2212      * @param int $id
2213      * @return mixed string recorded creation timestamp, or false if can't be found
2214      */
2215     public static function getAsTimestamp($id)
2216     {
2217         if (!$id) {
2218             return false;
2219         }
2220
2221         $notice = Notice::getKV('id', $id);
2222         if ($notice) {
2223             return $notice->created;
2224         }
2225
2226         $deleted = Deleted_notice::getKV('id', $id);
2227         if ($deleted) {
2228             return $deleted->created;
2229         }
2230
2231         return false;
2232     }
2233
2234     /**
2235      * Build an SQL 'where' fragment for timestamp-based sorting from a since_id
2236      * parameter, matching notices posted after the given one (exclusive).
2237      *
2238      * If the referenced notice can't be found, will return false.
2239      *
2240      * @param int $id
2241      * @param string $idField
2242      * @param string $createdField
2243      * @return mixed string or false if no match
2244      */
2245     public static function whereSinceId($id, $idField='id', $createdField='created')
2246     {
2247         $since = Notice::getAsTimestamp($id);
2248         if ($since) {
2249             return sprintf("($createdField = '%s' and $idField > %d) or ($createdField > '%s')", $since, $id, $since);
2250         }
2251         return false;
2252     }
2253
2254     /**
2255      * Build an SQL 'where' fragment for timestamp-based sorting from a since_id
2256      * parameter, matching notices posted after the given one (exclusive), and
2257      * if necessary add it to the data object's query.
2258      *
2259      * @param DB_DataObject $obj
2260      * @param int $id
2261      * @param string $idField
2262      * @param string $createdField
2263      * @return mixed string or false if no match
2264      */
2265     public static function addWhereSinceId(DB_DataObject $obj, $id, $idField='id', $createdField='created')
2266     {
2267         $since = self::whereSinceId($id, $idField, $createdField);
2268         if ($since) {
2269             $obj->whereAdd($since);
2270         }
2271     }
2272
2273     /**
2274      * Build an SQL 'where' fragment for timestamp-based sorting from a max_id
2275      * parameter, matching notices posted before the given one (inclusive).
2276      *
2277      * If the referenced notice can't be found, will return false.
2278      *
2279      * @param int $id
2280      * @param string $idField
2281      * @param string $createdField
2282      * @return mixed string or false if no match
2283      */
2284     public static function whereMaxId($id, $idField='id', $createdField='created')
2285     {
2286         $max = Notice::getAsTimestamp($id);
2287         if ($max) {
2288             return sprintf("($createdField < '%s') or ($createdField = '%s' and $idField <= %d)", $max, $max, $id);
2289         }
2290         return false;
2291     }
2292
2293     /**
2294      * Build an SQL 'where' fragment for timestamp-based sorting from a max_id
2295      * parameter, matching notices posted before the given one (inclusive), and
2296      * if necessary add it to the data object's query.
2297      *
2298      * @param DB_DataObject $obj
2299      * @param int $id
2300      * @param string $idField
2301      * @param string $createdField
2302      * @return mixed string or false if no match
2303      */
2304     public static function addWhereMaxId(DB_DataObject $obj, $id, $idField='id', $createdField='created')
2305     {
2306         $max = self::whereMaxId($id, $idField, $createdField);
2307         if ($max) {
2308             $obj->whereAdd($max);
2309         }
2310     }
2311
2312     function isPublic()
2313     {
2314         if (common_config('public', 'localonly')) {
2315             return ($this->is_local == Notice::LOCAL_PUBLIC);
2316         } else {
2317             return (($this->is_local != Notice::LOCAL_NONPUBLIC) &&
2318                     ($this->is_local != Notice::GATEWAY));
2319         }
2320     }
2321
2322     /**
2323      * Check that the given profile is allowed to read, respond to, or otherwise
2324      * act on this notice.
2325      *
2326      * The $scope member is a bitmask of scopes, representing a logical AND of the
2327      * scope requirement. So, 0x03 (Notice::ADDRESSEE_SCOPE | Notice::SITE_SCOPE) means
2328      * "only visible to people who are mentioned in the notice AND are users on this site."
2329      * Users on the site who are not mentioned in the notice will not be able to see the
2330      * notice.
2331      *
2332      * @param Profile $profile The profile to check; pass null to check for public/unauthenticated users.
2333      *
2334      * @return boolean whether the profile is in the notice's scope
2335      */
2336     function inScope($profile)
2337     {
2338         if (is_null($profile)) {
2339             $keypart = sprintf('notice:in-scope-for:%d:null', $this->id);
2340         } else {
2341             $keypart = sprintf('notice:in-scope-for:%d:%d', $this->id, $profile->id);
2342         }
2343
2344         $result = self::cacheGet($keypart);
2345
2346         if ($result === false) {
2347             $bResult = false;
2348             if (Event::handle('StartNoticeInScope', array($this, $profile, &$bResult))) {
2349                 $bResult = $this->_inScope($profile);
2350                 Event::handle('EndNoticeInScope', array($this, $profile, &$bResult));
2351             }
2352             $result = ($bResult) ? 1 : 0;
2353             self::cacheSet($keypart, $result, 0, 300);
2354         }
2355
2356         return ($result == 1) ? true : false;
2357     }
2358
2359     protected function _inScope($profile)
2360     {
2361         if (!is_null($this->scope)) {
2362             $scope = $this->scope;
2363         } else {
2364             $scope = self::defaultScope();
2365         }
2366
2367         // If there's no scope, anyone (even anon) is in scope.
2368
2369         if ($scope == 0) { // Not private
2370
2371             return !$this->isHiddenSpam($profile);
2372
2373         } else { // Private, somehow
2374
2375             // If there's scope, anon cannot be in scope
2376
2377             if (empty($profile)) {
2378                 return false;
2379             }
2380
2381             // Author is always in scope
2382
2383             if ($this->profile_id == $profile->id) {
2384                 return true;
2385             }
2386
2387             // Only for users on this site
2388
2389             if (($scope & Notice::SITE_SCOPE) && !$profile->isLocal()) {
2390                 return false;
2391             }
2392
2393             // Only for users mentioned in the notice
2394
2395             if ($scope & Notice::ADDRESSEE_SCOPE) {
2396
2397                 $reply = Reply::pkeyGet(array('notice_id' => $this->id,
2398                                              'profile_id' => $profile->id));
2399                                                                                  
2400                 if (!$reply instanceof Reply) {
2401                     return false;
2402                 }
2403             }
2404
2405             // Only for members of the given group
2406
2407             if ($scope & Notice::GROUP_SCOPE) {
2408
2409                 // XXX: just query for the single membership
2410
2411                 $groups = $this->getGroups();
2412
2413                 $foundOne = false;
2414
2415                 foreach ($groups as $group) {
2416                     if ($profile->isMember($group)) {
2417                         $foundOne = true;
2418                         break;
2419                     }
2420                 }
2421
2422                 if (!$foundOne) {
2423                     return false;
2424                 }
2425             }
2426
2427             // Only for followers of the author
2428
2429             $author = null;
2430
2431             if ($scope & Notice::FOLLOWER_SCOPE) {
2432
2433                 try {
2434                     $author = $this->getProfile();
2435                 } catch (Exception $e) {
2436                     return false;
2437                 }
2438         
2439                 if (!Subscription::exists($profile, $author)) {
2440                     return false;
2441                 }
2442             }
2443
2444             return !$this->isHiddenSpam($profile);
2445         }
2446     }
2447
2448     function isHiddenSpam($profile) {
2449         
2450         // Hide posts by silenced users from everyone but moderators.
2451
2452         if (common_config('notice', 'hidespam')) {
2453
2454             try {
2455                 $author = $this->getProfile();
2456             } catch(Exception $e) {
2457                 // If we can't get an author, keep it hidden.
2458                 // XXX: technically not spam, but, whatever.
2459                 return true;
2460             }
2461
2462             if ($author->hasRole(Profile_role::SILENCED)) {
2463                 if (!$profile instanceof Profile || (($profile->id !== $author->id) && (!$profile->hasRight(Right::REVIEWSPAM)))) {
2464                     return true;
2465                 }
2466             }
2467         }
2468
2469         return false;
2470     }
2471
2472     public function getParent()
2473     {
2474         $parent = Notice::getKV('id', $this->reply_to);
2475
2476         if (!$parent instanceof Notice) {
2477             throw new ServerException('Notice has no parent');
2478         }
2479
2480         return $parent;
2481     }
2482
2483     /**
2484      * Magic function called at serialize() time.
2485      *
2486      * We use this to drop a couple process-specific references
2487      * from DB_DataObject which can cause trouble in future
2488      * processes.
2489      *
2490      * @return array of variable names to include in serialization.
2491      */
2492
2493     function __sleep()
2494     {
2495         $vars = parent::__sleep();
2496         $skip = array('_profile', '_groups', '_attachments', '_faves', '_replies', '_repeats');
2497         return array_diff($vars, $skip);
2498     }
2499     
2500     static function defaultScope()
2501     {
2502         $scope = common_config('notice', 'defaultscope');
2503         if (is_null($scope)) {
2504                 if (common_config('site', 'private')) {
2505                         $scope = 1;
2506                 } else {
2507                         $scope = 0;
2508                 }
2509         }
2510         return $scope;
2511     }
2512
2513         static function fillProfiles($notices)
2514         {
2515                 $map = self::getProfiles($notices);
2516                 
2517                 foreach ($notices as $entry=>$notice) {
2518             try {
2519                         if (array_key_exists($notice->profile_id, $map)) {
2520                                 $notice->_setProfile($map[$notice->profile_id]);
2521                         }
2522             } catch (NoProfileException $e) {
2523                 common_log(LOG_WARNING, "Failed to fill profile in Notice with non-existing entry for profile_id: {$e->id}");
2524                 unset($notices[$entry]);
2525             }
2526                 }
2527                 
2528                 return array_values($map);
2529         }
2530         
2531         static function getProfiles(&$notices)
2532         {
2533                 $ids = array();
2534                 foreach ($notices as $notice) {
2535                         $ids[] = $notice->profile_id;
2536                 }
2537                 
2538                 $ids = array_unique($ids);
2539                 
2540                 return Profile::pivotGet('id', $ids); 
2541         }
2542         
2543         static function fillGroups(&$notices)
2544         {
2545         $ids = self::_idsOf($notices);
2546                 
2547         $gis = Group_inbox::listGet('notice_id', $ids);
2548                 
2549         $gids = array();
2550
2551                 foreach ($gis as $id => $gi)
2552                 {
2553                     foreach ($gi as $g)
2554                     {
2555                         $gids[] = $g->group_id;
2556                     }
2557                 }
2558                 
2559                 $gids = array_unique($gids);
2560                 
2561                 $group = User_group::pivotGet('id', $gids);
2562                 
2563                 foreach ($notices as $notice)
2564                 {
2565                         $grps = array();
2566                         $gi = $gis[$notice->id];
2567                         foreach ($gi as $g) {
2568                             $grps[] = $group[$g->group_id];
2569                         }
2570                     $notice->_setGroups($grps);
2571                 }
2572         }
2573
2574     static function _idsOf(&$notices)
2575     {
2576                 $ids = array();
2577                 foreach ($notices as $notice) {
2578                         $ids[] = $notice->id;
2579                 }
2580                 $ids = array_unique($ids);
2581         return $ids;
2582     }
2583
2584     static function fillAttachments(&$notices)
2585     {
2586         $ids = self::_idsOf($notices);
2587
2588         $f2pMap = File_to_post::listGet('post_id', $ids);
2589                 
2590                 $fileIds = array();
2591                 
2592                 foreach ($f2pMap as $noticeId => $f2ps) {
2593             foreach ($f2ps as $f2p) {
2594                 $fileIds[] = $f2p->file_id;    
2595             }
2596         }
2597
2598         $fileIds = array_unique($fileIds);
2599
2600                 $fileMap = File::pivotGet('id', $fileIds);
2601
2602                 foreach ($notices as $notice)
2603                 {
2604                         $files = array();
2605                         $f2ps = $f2pMap[$notice->id];
2606                         foreach ($f2ps as $f2p) {
2607                             $files[] = $fileMap[$f2p->file_id];
2608                         }
2609                     $notice->_setAttachments($files);
2610                 }
2611     }
2612
2613     protected $_faves;
2614
2615     /**
2616      * All faves of this notice
2617      *
2618      * @return array Array of Fave objects
2619      */
2620
2621     function getFaves()
2622     {
2623         if (isset($this->_faves) && is_array($this->_faves)) {
2624             return $this->_faves;
2625         }
2626         $faveMap = Fave::listGet('notice_id', array($this->id));
2627         $this->_faves = $faveMap[$this->id];
2628         return $this->_faves;
2629     }
2630
2631     function _setFaves($faves)
2632     {
2633         $this->_faves = $faves;
2634     }
2635
2636     static function fillFaves(&$notices)
2637     {
2638         $ids = self::_idsOf($notices);
2639         $faveMap = Fave::listGet('notice_id', $ids);
2640         $cnt = 0;
2641         $faved = array();
2642         foreach ($faveMap as $id => $faves) {
2643             $cnt += count($faves);
2644             if (count($faves) > 0) {
2645                 $faved[] = $id;
2646             }
2647         }
2648         foreach ($notices as $notice) {
2649                 $faves = $faveMap[$notice->id];
2650             $notice->_setFaves($faves);
2651         }
2652     }
2653
2654     static function fillReplies(&$notices)
2655     {
2656         $ids = self::_idsOf($notices);
2657         $replyMap = Reply::listGet('notice_id', $ids);
2658         foreach ($notices as $notice) {
2659             $replies = $replyMap[$notice->id];
2660             $ids = array();
2661             foreach ($replies as $reply) {
2662                 $ids[] = $reply->profile_id;
2663             }
2664             $notice->_setReplies($ids);
2665         }
2666     }
2667
2668     protected $_repeats;
2669
2670     function getRepeats()
2671     {
2672         if (isset($this->_repeats) && is_array($this->_repeats)) {
2673             return $this->_repeats;
2674         }
2675         $repeatMap = Notice::listGet('repeat_of', array($this->id));
2676         $this->_repeats = $repeatMap[$this->id];
2677         return $this->_repeats;
2678     }
2679
2680     function _setRepeats($repeats)
2681     {
2682         $this->_repeats = $repeats;
2683     }
2684
2685     static function fillRepeats(&$notices)
2686     {
2687         $ids = self::_idsOf($notices);
2688         $repeatMap = Notice::listGet('repeat_of', $ids);
2689         foreach ($notices as $notice) {
2690                 $repeats = $repeatMap[$notice->id];
2691             $notice->_setRepeats($repeats);
2692         }
2693     }
2694 }