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