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