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