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