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