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