]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - classes/Notice.php
remove inboxes option
[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  * @license  GNU Affero General Public License http://www.gnu.org/licenses/
33  */
34
35 if (!defined('STATUSNET') && !defined('LACONICA')) {
36     exit(1);
37 }
38
39 /**
40  * Table Definition for notice
41  */
42 require_once INSTALLDIR.'/classes/Memcached_DataObject.php';
43
44 /* We keep the first three 20-notice pages, plus one for pagination check,
45  * in the memcached cache. */
46
47 define('NOTICE_CACHE_WINDOW', 61);
48
49 define('MAX_BOXCARS', 128);
50
51 class Notice extends Memcached_DataObject
52 {
53     ###START_AUTOCODE
54     /* the code below is auto generated do not remove the above tag */
55
56     public $__table = 'notice';                          // table name
57     public $id;                              // int(4)  primary_key not_null
58     public $profile_id;                      // int(4)   not_null
59     public $uri;                             // varchar(255)  unique_key
60     public $content;                         // text()
61     public $rendered;                        // text()
62     public $url;                             // varchar(255)
63     public $created;                         // datetime()   not_null
64     public $modified;                        // timestamp()   not_null default_CURRENT_TIMESTAMP
65     public $reply_to;                        // int(4)
66     public $is_local;                        // tinyint(1)
67     public $source;                          // varchar(32)
68     public $conversation;                    // int(4)
69
70     /* Static get */
71     function staticGet($k,$v=NULL) { return Memcached_DataObject::staticGet('Notice',$k,$v); }
72
73     /* the code above is auto generated do not remove the tag below */
74     ###END_AUTOCODE
75
76     /* Notice types */
77     const LOCAL_PUBLIC    =  1;
78     const REMOTE_OMB      =  0;
79     const LOCAL_NONPUBLIC = -1;
80     const GATEWAY         = -2;
81
82     function getProfile()
83     {
84         return Profile::staticGet('id', $this->profile_id);
85     }
86
87     function delete()
88     {
89         $this->blowCaches(true);
90         $this->blowFavesCache(true);
91         $this->blowSubsCache(true);
92
93         // For auditing purposes, save a record that the notice
94         // was deleted.
95
96         $deleted = new Deleted_notice();
97
98         $deleted->id         = $this->id;
99         $deleted->profile_id = $this->profile_id;
100         $deleted->uri        = $this->uri;
101         $deleted->created    = $this->created;
102         $deleted->deleted    = common_sql_now();
103
104         $this->query('BEGIN');
105
106         $deleted->insert();
107
108         //Null any notices that are replies to this notice
109         $this->query(sprintf("UPDATE notice set reply_to = null WHERE reply_to = %d", $this->id));
110         $related = array('Reply',
111                          'Fave',
112                          'Notice_tag',
113                          'Group_inbox',
114                          'Queue_item',
115                          'Notice_inbox');
116
117         foreach ($related as $cls) {
118             $inst = new $cls();
119             $inst->notice_id = $this->id;
120             $inst->delete();
121         }
122         $result = parent::delete();
123         $this->query('COMMIT');
124     }
125
126     function saveTags()
127     {
128         /* extract all #hastags */
129         $count = preg_match_all('/(?:^|\s)#([\pL\pN_\-\.]{1,64})/', strtolower($this->content), $match);
130         if (!$count) {
131             return true;
132         }
133
134         //turn each into their canonical tag
135         //this is needed to remove dupes before saving e.g. #hash.tag = #hashtag
136         $hashtags = array();
137         for($i=0; $i<count($match[1]); $i++) {
138             $hashtags[] = common_canonical_tag($match[1][$i]);
139         }
140
141         /* Add them to the database */
142         foreach(array_unique($hashtags) as $hashtag) {
143             /* elide characters we don't want in the tag */
144             $this->saveTag($hashtag);
145         }
146         return true;
147     }
148
149     function saveTag($hashtag)
150     {
151         $tag = new Notice_tag();
152         $tag->notice_id = $this->id;
153         $tag->tag = $hashtag;
154         $tag->created = $this->created;
155         $id = $tag->insert();
156
157         if (!$id) {
158             throw new ServerException(sprintf(_('DB error inserting hashtag: %s'),
159                                               $last_error->message));
160             return;
161         }
162     }
163
164     static function saveNew($profile_id, $content, $source=null,
165                             $is_local=Notice::LOCAL_PUBLIC, $reply_to=null, $uri=null, $created=null) {
166
167         $profile = Profile::staticGet($profile_id);
168
169         $final = common_shorten_links($content);
170
171         if (Notice::contentTooLong($final)) {
172             throw new ClientException(_('Problem saving notice. Too long.'));
173         }
174
175         if (!$profile) {
176             throw new ClientException(_('Problem saving notice. Unknown user.'));
177         }
178
179         if (common_config('throttle', 'enabled') && !Notice::checkEditThrottle($profile_id)) {
180             common_log(LOG_WARNING, 'Excessive posting by profile #' . $profile_id . '; throttled.');
181             throw new ClientException(_('Too many notices too fast; take a breather '.
182                                         'and post again in a few minutes.'));
183         }
184
185         if (common_config('site', 'dupelimit') > 0 && !Notice::checkDupes($profile_id, $final)) {
186             common_log(LOG_WARNING, 'Dupe posting by profile #' . $profile_id . '; throttled.');
187             throw new ClientException(_('Too many duplicate messages too quickly;'.
188                                         ' take a breather and post again in a few minutes.'));
189         }
190
191         $banned = common_config('profile', 'banned');
192
193         if ( in_array($profile_id, $banned) || in_array($profile->nickname, $banned)) {
194             common_log(LOG_WARNING, "Attempted post from banned user: $profile->nickname (user id = $profile_id).");
195             throw new ClientException(_('You are banned from posting notices on this site.'));
196         }
197
198         $notice = new Notice();
199         $notice->profile_id = $profile_id;
200
201         $blacklist = common_config('public', 'blacklist');
202         $autosource = common_config('public', 'autosource');
203
204         # Blacklisted are non-false, but not 1, either
205
206         if (($blacklist && in_array($profile_id, $blacklist)) ||
207             ($source && $autosource && in_array($source, $autosource))) {
208             $notice->is_local = Notice::LOCAL_NONPUBLIC;
209         } else {
210             $notice->is_local = $is_local;
211         }
212
213         if (!empty($created)) {
214             $notice->created = $created;
215         } else {
216             $notice->created = common_sql_now();
217         }
218
219         $notice->content = $final;
220         $notice->rendered = common_render_content($final, $notice);
221         $notice->source = $source;
222         $notice->uri = $uri;
223
224         $notice->reply_to = self::getReplyTo($reply_to, $profile_id, $source, $final);
225
226         if (!empty($notice->reply_to)) {
227             $reply = Notice::staticGet('id', $notice->reply_to);
228             $notice->conversation = $reply->conversation;
229         }
230
231         if (Event::handle('StartNoticeSave', array(&$notice))) {
232
233             // XXX: some of these functions write to the DB
234
235             $notice->query('BEGIN');
236
237             $id = $notice->insert();
238
239             if (!$id) {
240                 common_log_db_error($notice, 'INSERT', __FILE__);
241                 throw new ServerException(_('Problem saving notice.'));
242             }
243
244             // Update ID-dependent columns: URI, conversation
245
246             $orig = clone($notice);
247
248             $changed = false;
249
250             if (empty($uri)) {
251                 $notice->uri = common_notice_uri($notice);
252                 $changed = true;
253             }
254
255             // If it's not part of a conversation, it's
256             // the beginning of a new conversation.
257
258             if (empty($notice->conversation)) {
259                 $notice->conversation = $notice->id;
260                 $changed = true;
261             }
262
263             if ($changed) {
264                 if (!$notice->update($orig)) {
265                     common_log_db_error($notice, 'UPDATE', __FILE__);
266                     throw new ServerException(_('Problem saving notice.'));
267                 }
268             }
269
270             // XXX: do we need to change this for remote users?
271
272             $notice->saveReplies();
273             $notice->saveTags();
274
275             $notice->addToInboxes();
276
277             $notice->saveUrls();
278
279             $notice->query('COMMIT');
280
281             Event::handle('EndNoticeSave', array($notice));
282         }
283
284         # Clear the cache for subscribed users, so they'll update at next request
285         # XXX: someone clever could prepend instead of clearing the cache
286
287         $notice->blowCaches();
288
289         return $notice;
290     }
291
292     /** save all urls in the notice to the db
293      *
294      * follow redirects and save all available file information
295      * (mimetype, date, size, oembed, etc.)
296      *
297      * @return void
298      */
299     function saveUrls() {
300         common_replace_urls_callback($this->content, array($this, 'saveUrl'), $this->id);
301     }
302
303     function saveUrl($data) {
304         list($url, $notice_id) = $data;
305         File::processNew($url, $notice_id);
306     }
307
308     static function checkDupes($profile_id, $content) {
309         $profile = Profile::staticGet($profile_id);
310         if (!$profile) {
311             return false;
312         }
313         $notice = $profile->getNotices(0, NOTICE_CACHE_WINDOW);
314         if ($notice) {
315             $last = 0;
316             while ($notice->fetch()) {
317                 if (time() - strtotime($notice->created) >= common_config('site', 'dupelimit')) {
318                     return true;
319                 } else if ($notice->content == $content) {
320                     return false;
321                 }
322             }
323         }
324         # If we get here, oldest item in cache window is not
325         # old enough for dupe limit; do direct check against DB
326         $notice = new Notice();
327         $notice->profile_id = $profile_id;
328         $notice->content = $content;
329         if (common_config('db','type') == 'pgsql')
330           $notice->whereAdd('extract(epoch from now() - created) < ' . common_config('site', 'dupelimit'));
331         else
332           $notice->whereAdd('now() - created < ' . common_config('site', 'dupelimit'));
333
334         $cnt = $notice->count();
335         return ($cnt == 0);
336     }
337
338     static function checkEditThrottle($profile_id) {
339         $profile = Profile::staticGet($profile_id);
340         if (!$profile) {
341             return false;
342         }
343         # Get the Nth notice
344         $notice = $profile->getNotices(common_config('throttle', 'count') - 1, 1);
345         if ($notice && $notice->fetch()) {
346             # If the Nth notice was posted less than timespan seconds ago
347             if (time() - strtotime($notice->created) <= common_config('throttle', 'timespan')) {
348                 # Then we throttle
349                 return false;
350             }
351         }
352         # Either not N notices in the stream, OR the Nth was not posted within timespan seconds
353         return true;
354     }
355
356     function getUploadedAttachment() {
357         $post = clone $this;
358         $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"';
359         $post->query($query);
360         $post->fetch();
361         if (empty($post->up) || empty($post->i)) {
362             $ret = false;
363         } else {
364             $ret = array($post->up, $post->i);
365         }
366         $post->free();
367         return $ret;
368     }
369
370     function hasAttachments() {
371         $post = clone $this;
372         $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);
373         $post->query($query);
374         $post->fetch();
375         $n_attachments = intval($post->n_attachments);
376         $post->free();
377         return $n_attachments;
378     }
379
380     function attachments() {
381         // XXX: cache this
382         $att = array();
383         $f2p = new File_to_post;
384         $f2p->post_id = $this->id;
385         if ($f2p->find()) {
386             while ($f2p->fetch()) {
387                 $f = File::staticGet($f2p->file_id);
388                 $att[] = clone($f);
389             }
390         }
391         return $att;
392     }
393
394     function blowCaches($blowLast=false)
395     {
396         $this->blowSubsCache($blowLast);
397         $this->blowNoticeCache($blowLast);
398         $this->blowRepliesCache($blowLast);
399         $this->blowPublicCache($blowLast);
400         $this->blowTagCache($blowLast);
401         $this->blowGroupCache($blowLast);
402         $this->blowConversationCache($blowLast);
403         $profile = Profile::staticGet($this->profile_id);
404         $profile->blowNoticeCount();
405     }
406
407     function blowConversationCache($blowLast=false)
408     {
409         $cache = common_memcache();
410         if ($cache) {
411             $ck = common_cache_key('notice:conversation_ids:'.$this->conversation);
412             $cache->delete($ck);
413             if ($blowLast) {
414                 $cache->delete($ck.';last');
415             }
416         }
417     }
418
419     function blowGroupCache($blowLast=false)
420     {
421         $cache = common_memcache();
422         if ($cache) {
423             $group_inbox = new Group_inbox();
424             $group_inbox->notice_id = $this->id;
425             if ($group_inbox->find()) {
426                 while ($group_inbox->fetch()) {
427                     $cache->delete(common_cache_key('user_group:notice_ids:' . $group_inbox->group_id));
428                     if ($blowLast) {
429                         $cache->delete(common_cache_key('user_group:notice_ids:' . $group_inbox->group_id.';last'));
430                     }
431                     $member = new Group_member();
432                     $member->group_id = $group_inbox->group_id;
433                     if ($member->find()) {
434                         while ($member->fetch()) {
435                             $cache->delete(common_cache_key('notice_inbox:by_user:' . $member->profile_id));
436                             if ($blowLast) {
437                                 $cache->delete(common_cache_key('notice_inbox:by_user:' . $member->profile_id . ';last'));
438                             }
439                         }
440                     }
441                 }
442             }
443             $group_inbox->free();
444             unset($group_inbox);
445         }
446     }
447
448     function blowTagCache($blowLast=false)
449     {
450         $cache = common_memcache();
451         if ($cache) {
452             $tag = new Notice_tag();
453             $tag->notice_id = $this->id;
454             if ($tag->find()) {
455                 while ($tag->fetch()) {
456                     $tag->blowCache($blowLast);
457                     $ck = 'profile:notice_ids_tagged:' . $this->profile_id . ':' . $tag->tag;
458
459                     $cache->delete($ck);
460                     if ($blowLast) {
461                         $cache->delete($ck . ';last');
462                     }
463                 }
464             }
465             $tag->free();
466             unset($tag);
467         }
468     }
469
470     function blowSubsCache($blowLast=false)
471     {
472         $cache = common_memcache();
473         if ($cache) {
474             $user = new User();
475
476             $UT = common_config('db','type')=='pgsql'?'"user"':'user';
477             $user->query('SELECT id ' .
478
479                          "FROM $UT JOIN subscription ON $UT.id = subscription.subscriber " .
480                          'WHERE subscription.subscribed = ' . $this->profile_id);
481
482             while ($user->fetch()) {
483                 $cache->delete(common_cache_key('notice_inbox:by_user:'.$user->id));
484                 $cache->delete(common_cache_key('notice_inbox:by_user_own:'.$user->id));
485                 if ($blowLast) {
486                     $cache->delete(common_cache_key('notice_inbox:by_user:'.$user->id.';last'));
487                     $cache->delete(common_cache_key('notice_inbox:by_user_own:'.$user->id.';last'));
488                 }
489             }
490             $user->free();
491             unset($user);
492         }
493     }
494
495     function blowNoticeCache($blowLast=false)
496     {
497         if ($this->is_local) {
498             $cache = common_memcache();
499             if (!empty($cache)) {
500                 $cache->delete(common_cache_key('profile:notice_ids:'.$this->profile_id));
501                 if ($blowLast) {
502                     $cache->delete(common_cache_key('profile:notice_ids:'.$this->profile_id.';last'));
503                 }
504             }
505         }
506     }
507
508     function blowRepliesCache($blowLast=false)
509     {
510         $cache = common_memcache();
511         if ($cache) {
512             $reply = new Reply();
513             $reply->notice_id = $this->id;
514             if ($reply->find()) {
515                 while ($reply->fetch()) {
516                     $cache->delete(common_cache_key('reply:stream:'.$reply->profile_id));
517                     if ($blowLast) {
518                         $cache->delete(common_cache_key('reply:stream:'.$reply->profile_id.';last'));
519                     }
520                 }
521             }
522             $reply->free();
523             unset($reply);
524         }
525     }
526
527     function blowPublicCache($blowLast=false)
528     {
529         if ($this->is_local == Notice::LOCAL_PUBLIC) {
530             $cache = common_memcache();
531             if ($cache) {
532                 $cache->delete(common_cache_key('public'));
533                 if ($blowLast) {
534                     $cache->delete(common_cache_key('public').';last');
535                 }
536             }
537         }
538     }
539
540     function blowFavesCache($blowLast=false)
541     {
542         $cache = common_memcache();
543         if ($cache) {
544             $fave = new Fave();
545             $fave->notice_id = $this->id;
546             if ($fave->find()) {
547                 while ($fave->fetch()) {
548                     $cache->delete(common_cache_key('fave:ids_by_user:'.$fave->user_id));
549                     $cache->delete(common_cache_key('fave:by_user_own:'.$fave->user_id));
550                     if ($blowLast) {
551                         $cache->delete(common_cache_key('fave:ids_by_user:'.$fave->user_id.';last'));
552                         $cache->delete(common_cache_key('fave:by_user_own:'.$fave->user_id.';last'));
553                     }
554                 }
555             }
556             $fave->free();
557             unset($fave);
558         }
559     }
560
561     # XXX: too many args; we need to move to named params or even a separate
562     # class for notice streams
563
564     static function getStream($qry, $cachekey, $offset=0, $limit=20, $since_id=0, $max_id=0, $order=null, $since=null) {
565
566         if (common_config('memcached', 'enabled')) {
567
568             # Skip the cache if this is a since, since_id or max_id qry
569             if ($since_id > 0 || $max_id > 0 || $since) {
570                 return Notice::getStreamDirect($qry, $offset, $limit, $since_id, $max_id, $order, $since);
571             } else {
572                 return Notice::getCachedStream($qry, $cachekey, $offset, $limit, $order);
573             }
574         }
575
576         return Notice::getStreamDirect($qry, $offset, $limit, $since_id, $max_id, $order, $since);
577     }
578
579     static function getStreamDirect($qry, $offset, $limit, $since_id, $max_id, $order, $since) {
580
581         $needAnd = false;
582         $needWhere = true;
583
584         if (preg_match('/\bWHERE\b/i', $qry)) {
585             $needWhere = false;
586             $needAnd = true;
587         }
588
589         if ($since_id > 0) {
590
591             if ($needWhere) {
592                 $qry .= ' WHERE ';
593                 $needWhere = false;
594             } else {
595                 $qry .= ' AND ';
596             }
597
598             $qry .= ' notice.id > ' . $since_id;
599         }
600
601         if ($max_id > 0) {
602
603             if ($needWhere) {
604                 $qry .= ' WHERE ';
605                 $needWhere = false;
606             } else {
607                 $qry .= ' AND ';
608             }
609
610             $qry .= ' notice.id <= ' . $max_id;
611         }
612
613         if ($since) {
614
615             if ($needWhere) {
616                 $qry .= ' WHERE ';
617                 $needWhere = false;
618             } else {
619                 $qry .= ' AND ';
620             }
621
622             $qry .= ' notice.created > \'' . date('Y-m-d H:i:s', $since) . '\'';
623         }
624
625         # Allow ORDER override
626
627         if ($order) {
628             $qry .= $order;
629         } else {
630             $qry .= ' ORDER BY notice.created DESC, notice.id DESC ';
631         }
632
633         if (common_config('db','type') == 'pgsql') {
634             $qry .= ' LIMIT ' . $limit . ' OFFSET ' . $offset;
635         } else {
636             $qry .= ' LIMIT ' . $offset . ', ' . $limit;
637         }
638
639         $notice = new Notice();
640
641         $notice->query($qry);
642
643         return $notice;
644     }
645
646     # XXX: this is pretty long and should probably be broken up into
647     # some helper functions
648
649     static function getCachedStream($qry, $cachekey, $offset, $limit, $order) {
650
651         # If outside our cache window, just go to the DB
652
653         if ($offset + $limit > NOTICE_CACHE_WINDOW) {
654             return Notice::getStreamDirect($qry, $offset, $limit, null, null, $order, null);
655         }
656
657         # Get the cache; if we can't, just go to the DB
658
659         $cache = common_memcache();
660
661         if (!$cache) {
662             return Notice::getStreamDirect($qry, $offset, $limit, null, null, $order, null);
663         }
664
665         # Get the notices out of the cache
666
667         $notices = $cache->get(common_cache_key($cachekey));
668
669         # On a cache hit, return a DB-object-like wrapper
670
671         if ($notices !== false) {
672             $wrapper = new ArrayWrapper(array_slice($notices, $offset, $limit));
673             return $wrapper;
674         }
675
676         # If the cache was invalidated because of new data being
677         # added, we can try and just get the new stuff. We keep an additional
678         # copy of the data at the key + ';last'
679
680         # No cache hit. Try to get the *last* cached version
681
682         $last_notices = $cache->get(common_cache_key($cachekey) . ';last');
683
684         if ($last_notices) {
685
686             # Reverse-chron order, so last ID is last.
687
688             $last_id = $last_notices[0]->id;
689
690             # XXX: this assumes monotonically increasing IDs; a fair
691             # bet with our DB.
692
693             $new_notice = Notice::getStreamDirect($qry, 0, NOTICE_CACHE_WINDOW,
694                                                   $last_id, null, $order, null);
695
696             if ($new_notice) {
697                 $new_notices = array();
698                 while ($new_notice->fetch()) {
699                     $new_notices[] = clone($new_notice);
700                 }
701                 $new_notice->free();
702                 $notices = array_slice(array_merge($new_notices, $last_notices),
703                                        0, NOTICE_CACHE_WINDOW);
704
705                 # Store the array in the cache for next time
706
707                 $result = $cache->set(common_cache_key($cachekey), $notices);
708                 $result = $cache->set(common_cache_key($cachekey) . ';last', $notices);
709
710                 # return a wrapper of the array for use now
711
712                 return new ArrayWrapper(array_slice($notices, $offset, $limit));
713             }
714         }
715
716         # Otherwise, get the full cache window out of the DB
717
718         $notice = Notice::getStreamDirect($qry, 0, NOTICE_CACHE_WINDOW, null, null, $order, null);
719
720         # If there are no hits, just return the value
721
722         if (!$notice) {
723             return $notice;
724         }
725
726         # Pack results into an array
727
728         $notices = array();
729
730         while ($notice->fetch()) {
731             $notices[] = clone($notice);
732         }
733
734         $notice->free();
735
736         # Store the array in the cache for next time
737
738         $result = $cache->set(common_cache_key($cachekey), $notices);
739         $result = $cache->set(common_cache_key($cachekey) . ';last', $notices);
740
741         # return a wrapper of the array for use now
742
743         $wrapper = new ArrayWrapper(array_slice($notices, $offset, $limit));
744
745         return $wrapper;
746     }
747
748     function getStreamByIds($ids)
749     {
750         $cache = common_memcache();
751
752         if (!empty($cache)) {
753             $notices = array();
754             foreach ($ids as $id) {
755                 $n = Notice::staticGet('id', $id);
756                 if (!empty($n)) {
757                     $notices[] = $n;
758                 }
759             }
760             return new ArrayWrapper($notices);
761         } else {
762             $notice = new Notice();
763             if (empty($ids)) {
764                 //if no IDs requested, just return the notice object
765                 return $notice;
766             }
767             $notice->whereAdd('id in (' . implode(', ', $ids) . ')');
768             $notice->orderBy('id DESC');
769
770             $notice->find();
771             return $notice;
772         }
773     }
774
775     function publicStream($offset=0, $limit=20, $since_id=0, $max_id=0, $since=null)
776     {
777         $ids = Notice::stream(array('Notice', '_publicStreamDirect'),
778                               array(),
779                               'public',
780                               $offset, $limit, $since_id, $max_id, $since);
781
782         return Notice::getStreamByIds($ids);
783     }
784
785     function _publicStreamDirect($offset=0, $limit=20, $since_id=0, $max_id=0, $since=null)
786     {
787         $notice = new Notice();
788
789         $notice->selectAdd(); // clears it
790         $notice->selectAdd('id');
791
792         $notice->orderBy('id DESC');
793
794         if (!is_null($offset)) {
795             $notice->limit($offset, $limit);
796         }
797
798         if (common_config('public', 'localonly')) {
799             $notice->whereAdd('is_local = ' . Notice::LOCAL_PUBLIC);
800         } else {
801             # -1 == blacklisted, -2 == gateway (i.e. Twitter)
802             $notice->whereAdd('is_local !='. Notice::LOCAL_NONPUBLIC);
803             $notice->whereAdd('is_local !='. Notice::GATEWAY);
804         }
805
806         if ($since_id != 0) {
807             $notice->whereAdd('id > ' . $since_id);
808         }
809
810         if ($max_id != 0) {
811             $notice->whereAdd('id <= ' . $max_id);
812         }
813
814         if (!is_null($since)) {
815             $notice->whereAdd('created > \'' . date('Y-m-d H:i:s', $since) . '\'');
816         }
817
818         $ids = array();
819
820         if ($notice->find()) {
821             while ($notice->fetch()) {
822                 $ids[] = $notice->id;
823             }
824         }
825
826         $notice->free();
827         $notice = NULL;
828
829         return $ids;
830     }
831
832     function conversationStream($id, $offset=0, $limit=20, $since_id=0, $max_id=0, $since=null)
833     {
834         $ids = Notice::stream(array('Notice', '_conversationStreamDirect'),
835                               array($id),
836                               'notice:conversation_ids:'.$id,
837                               $offset, $limit, $since_id, $max_id, $since);
838
839         return Notice::getStreamByIds($ids);
840     }
841
842     function _conversationStreamDirect($id, $offset=0, $limit=20, $since_id=0, $max_id=0, $since=null)
843     {
844         $notice = new Notice();
845
846         $notice->selectAdd(); // clears it
847         $notice->selectAdd('id');
848
849         $notice->conversation = $id;
850
851         $notice->orderBy('id DESC');
852
853         if (!is_null($offset)) {
854             $notice->limit($offset, $limit);
855         }
856
857         if ($since_id != 0) {
858             $notice->whereAdd('id > ' . $since_id);
859         }
860
861         if ($max_id != 0) {
862             $notice->whereAdd('id <= ' . $max_id);
863         }
864
865         if (!is_null($since)) {
866             $notice->whereAdd('created > \'' . date('Y-m-d H:i:s', $since) . '\'');
867         }
868
869         $ids = array();
870
871         if ($notice->find()) {
872             while ($notice->fetch()) {
873                 $ids[] = $notice->id;
874             }
875         }
876
877         $notice->free();
878         $notice = NULL;
879
880         return $ids;
881     }
882
883     function addToInboxes()
884     {
885         // XXX: loads constants
886
887         $inbox = new Notice_inbox();
888
889         $users = $this->getSubscribedUsers();
890
891         // FIXME: kind of ignoring 'transitional'...
892         // we'll probably stop supporting inboxless mode
893         // in 0.9.x
894
895         $ni = array();
896
897         foreach ($users as $id) {
898             $ni[$id] = NOTICE_INBOX_SOURCE_SUB;
899         }
900
901         $groups = $this->saveGroups();
902
903         foreach ($groups as $group) {
904             $users = $group->getUserMembers();
905             foreach ($users as $id) {
906                 if (!array_key_exists($id, $ni)) {
907                     $ni[$id] = NOTICE_INBOX_SOURCE_GROUP;
908                 }
909             }
910         }
911
912         $cnt = 0;
913
914         $qryhdr = 'INSERT INTO notice_inbox (user_id, notice_id, source, created) VALUES ';
915         $qry = $qryhdr;
916
917         foreach ($ni as $id => $source) {
918             if ($cnt > 0) {
919                 $qry .= ', ';
920             }
921             $qry .= '('.$id.', '.$this->id.', '.$source.", '".$this->created. "') ";
922             $cnt++;
923             if (rand() % NOTICE_INBOX_SOFT_LIMIT == 0) {
924                 // FIXME: Causes lag in replicated servers
925                 // Notice_inbox::gc($id);
926             }
927             if ($cnt >= MAX_BOXCARS) {
928                 $inbox = new Notice_inbox();
929                 $inbox->query($qry);
930                 $qry = $qryhdr;
931                 $cnt = 0;
932             }
933         }
934
935         if ($cnt > 0) {
936             $inbox = new Notice_inbox();
937             $inbox->query($qry);
938         }
939
940         return;
941     }
942
943     function getSubscribedUsers()
944     {
945         $user = new User();
946
947         if(common_config('db','quote_identifiers'))
948           $user_table = '"user"';
949         else $user_table = 'user';
950
951         $qry =
952           'SELECT id ' .
953           'FROM '. $user_table .' JOIN subscription '.
954           'ON '. $user_table .'.id = subscription.subscriber ' .
955           'WHERE subscription.subscribed = %d ';
956
957         $user->query(sprintf($qry, $this->profile_id));
958
959         $ids = array();
960
961         while ($user->fetch()) {
962             $ids[] = $user->id;
963         }
964
965         $user->free();
966
967         return $ids;
968     }
969
970     function saveGroups()
971     {
972         $groups = array();
973
974         /* extract all !group */
975         $count = preg_match_all('/(?:^|\s)!([A-Za-z0-9]{1,64})/',
976                                 strtolower($this->content),
977                                 $match);
978         if (!$count) {
979             return $groups;
980         }
981
982         $profile = $this->getProfile();
983
984         /* Add them to the database */
985
986         foreach (array_unique($match[1]) as $nickname) {
987             /* XXX: remote groups. */
988             $group = User_group::getForNickname($nickname);
989
990             if (empty($group)) {
991                 continue;
992             }
993
994             // we automatically add a tag for every group name, too
995
996             $tag = Notice_tag::pkeyGet(array('tag' => common_canonical_tag($nickname),
997                                              'notice_id' => $this->id));
998
999             if (is_null($tag)) {
1000                 $this->saveTag($nickname);
1001             }
1002
1003             if ($profile->isMember($group)) {
1004
1005                 $result = $this->addToGroupInbox($group);
1006
1007                 if (!$result) {
1008                     common_log_db_error($gi, 'INSERT', __FILE__);
1009                 }
1010
1011                 $groups[] = clone($group);
1012             }
1013         }
1014
1015         return $groups;
1016     }
1017
1018     function addToGroupInbox($group)
1019     {
1020         $gi = Group_inbox::pkeyGet(array('group_id' => $group->id,
1021                                          'notice_id' => $this->id));
1022
1023         if (empty($gi)) {
1024
1025             $gi = new Group_inbox();
1026
1027             $gi->group_id  = $group->id;
1028             $gi->notice_id = $this->id;
1029             $gi->created   = $this->created;
1030
1031             return $gi->insert();
1032         }
1033
1034         return true;
1035     }
1036
1037     function saveReplies()
1038     {
1039         // Alternative reply format
1040         $tname = false;
1041         if (preg_match('/^T ([A-Z0-9]{1,64}) /', $this->content, $match)) {
1042             $tname = $match[1];
1043         }
1044         // extract all @messages
1045         $cnt = preg_match_all('/(?:^|\s)@([a-z0-9]{1,64})/', $this->content, $match);
1046
1047         $names = array();
1048
1049         if ($cnt || $tname) {
1050             // XXX: is there another way to make an array copy?
1051             $names = ($tname) ? array_unique(array_merge(array(strtolower($tname)), $match[1])) : array_unique($match[1]);
1052         }
1053
1054         $sender = Profile::staticGet($this->profile_id);
1055
1056         $replied = array();
1057
1058         // store replied only for first @ (what user/notice what the reply directed,
1059         // we assume first @ is it)
1060
1061         for ($i=0; $i<count($names); $i++) {
1062             $nickname = $names[$i];
1063             $recipient = common_relative_profile($sender, $nickname, $this->created);
1064             if (!$recipient) {
1065                 continue;
1066             }
1067             // Don't save replies from blocked profile to local user
1068             $recipient_user = User::staticGet('id', $recipient->id);
1069             if ($recipient_user && $recipient_user->hasBlocked($sender)) {
1070                 continue;
1071             }
1072             $reply = new Reply();
1073             $reply->notice_id = $this->id;
1074             $reply->profile_id = $recipient->id;
1075             $id = $reply->insert();
1076             if (!$id) {
1077                 $last_error = &PEAR::getStaticProperty('DB_DataObject','lastError');
1078                 common_log(LOG_ERR, 'DB error inserting reply: ' . $last_error->message);
1079                 common_server_error(sprintf(_('DB error inserting reply: %s'), $last_error->message));
1080                 return;
1081             } else {
1082                 $replied[$recipient->id] = 1;
1083             }
1084         }
1085
1086         // Hash format replies, too
1087         $cnt = preg_match_all('/(?:^|\s)@#([a-z0-9]{1,64})/', $this->content, $match);
1088         if ($cnt) {
1089             foreach ($match[1] as $tag) {
1090                 $tagged = Profile_tag::getTagged($sender->id, $tag);
1091                 foreach ($tagged as $t) {
1092                     if (!$replied[$t->id]) {
1093                         // Don't save replies from blocked profile to local user
1094                         $t_user = User::staticGet('id', $t->id);
1095                         if ($t_user && $t_user->hasBlocked($sender)) {
1096                             continue;
1097                         }
1098                         $reply = new Reply();
1099                         $reply->notice_id = $this->id;
1100                         $reply->profile_id = $t->id;
1101                         $id = $reply->insert();
1102                         if (!$id) {
1103                             common_log_db_error($reply, 'INSERT', __FILE__);
1104                             return;
1105                         } else {
1106                             $replied[$recipient->id] = 1;
1107                         }
1108                     }
1109                 }
1110             }
1111         }
1112
1113         foreach (array_keys($replied) as $recipient) {
1114             $user = User::staticGet('id', $recipient);
1115             if ($user) {
1116                 mail_notify_attn($user, $this);
1117             }
1118         }
1119     }
1120
1121     function asAtomEntry($namespace=false, $source=false)
1122     {
1123         $profile = $this->getProfile();
1124
1125         $xs = new XMLStringer(true);
1126
1127         if ($namespace) {
1128             $attrs = array('xmlns' => 'http://www.w3.org/2005/Atom',
1129                            'xmlns:thr' => 'http://purl.org/syndication/thread/1.0');
1130         } else {
1131             $attrs = array();
1132         }
1133
1134         $xs->elementStart('entry', $attrs);
1135
1136         if ($source) {
1137             $xs->elementStart('source');
1138             $xs->element('title', null, $profile->nickname . " - " . common_config('site', 'name'));
1139             $xs->element('link', array('href' => $profile->profileurl));
1140             $user = User::staticGet('id', $profile->id);
1141             if (!empty($user)) {
1142                 $atom_feed = common_local_url('api',
1143                                               array('apiaction' => 'statuses',
1144                                                     'method' => 'user_timeline',
1145                                                     'argument' => $profile->nickname.'.atom'));
1146                 $xs->element('link', array('rel' => 'self',
1147                                            'type' => 'application/atom+xml',
1148                                            'href' => $profile->profileurl));
1149                 $xs->element('link', array('rel' => 'license',
1150                                            'href' => common_config('license', 'url')));
1151             }
1152
1153             $xs->element('icon', null, $profile->avatarUrl(AVATAR_PROFILE_SIZE));
1154         }
1155
1156         $xs->elementStart('author');
1157         $xs->element('name', null, $profile->nickname);
1158         $xs->element('uri', null, $profile->profileurl);
1159         $xs->elementEnd('author');
1160
1161         if ($source) {
1162             $xs->elementEnd('source');
1163         }
1164
1165         $xs->element('title', null, $this->content);
1166         $xs->element('summary', null, $this->content);
1167
1168         $xs->element('link', array('rel' => 'alternate',
1169                                    'href' => $this->bestUrl()));
1170
1171         $xs->element('id', null, $this->uri);
1172
1173         $xs->element('published', null, common_date_w3dtf($this->created));
1174         $xs->element('updated', null, common_date_w3dtf($this->modified));
1175
1176         if ($this->reply_to) {
1177             $reply_notice = Notice::staticGet('id', $this->reply_to);
1178             if (!empty($reply_notice)) {
1179                 $xs->element('link', array('rel' => 'related',
1180                                            'href' => $reply_notice->bestUrl()));
1181                 $xs->element('thr:in-reply-to',
1182                              array('ref' => $reply_notice->uri,
1183                                    'href' => $reply_notice->bestUrl()));
1184             }
1185         }
1186
1187         $xs->element('content', array('type' => 'html'), $this->rendered);
1188
1189         $tag = new Notice_tag();
1190         $tag->notice_id = $this->id;
1191         if ($tag->find()) {
1192             while ($tag->fetch()) {
1193                 $xs->element('category', array('term' => $tag->tag));
1194             }
1195         }
1196         $tag->free();
1197
1198         # Enclosures
1199         $attachments = $this->attachments();
1200         if($attachments){
1201             foreach($attachments as $attachment){
1202                 $enclosure=$attachment->getEnclosure();
1203                 if ($enclosure) {
1204                     $attributes = array('rel'=>'enclosure','href'=>$enclosure->url,'type'=>$enclosure->mimetype,'length'=>$enclosure->size);
1205                     if($enclosure->title){
1206                         $attributes['title']=$enclosure->title;
1207                     }
1208                     $xs->element('link', $attributes, null);
1209                 }
1210             }
1211         }
1212
1213         $xs->elementEnd('entry');
1214
1215         return $xs->getString();
1216     }
1217
1218     function bestUrl()
1219     {
1220         if (!empty($this->url)) {
1221             return $this->url;
1222         } else if (!empty($this->uri) && preg_match('/^https?:/', $this->uri)) {
1223             return $this->uri;
1224         } else {
1225             return common_local_url('shownotice',
1226                                     array('notice' => $this->id));
1227         }
1228     }
1229
1230     function stream($fn, $args, $cachekey, $offset=0, $limit=20, $since_id=0, $max_id=0, $since=null)
1231     {
1232         $cache = common_memcache();
1233
1234         if (empty($cache) ||
1235             $since_id != 0 || $max_id != 0 || (!is_null($since) && $since > 0) ||
1236             is_null($limit) ||
1237             ($offset + $limit) > NOTICE_CACHE_WINDOW) {
1238             return call_user_func_array($fn, array_merge($args, array($offset, $limit, $since_id,
1239                                                                       $max_id, $since)));
1240         }
1241
1242         $idkey = common_cache_key($cachekey);
1243
1244         $idstr = $cache->get($idkey);
1245
1246         if (!empty($idstr)) {
1247             // Cache hit! Woohoo!
1248             $window = explode(',', $idstr);
1249             $ids = array_slice($window, $offset, $limit);
1250             return $ids;
1251         }
1252
1253         $laststr = $cache->get($idkey.';last');
1254
1255         if (!empty($laststr)) {
1256             $window = explode(',', $laststr);
1257             $last_id = $window[0];
1258             $new_ids = call_user_func_array($fn, array_merge($args, array(0, NOTICE_CACHE_WINDOW,
1259                                                                           $last_id, 0, null)));
1260
1261             $new_window = array_merge($new_ids, $window);
1262
1263             $new_windowstr = implode(',', $new_window);
1264
1265             $result = $cache->set($idkey, $new_windowstr);
1266             $result = $cache->set($idkey . ';last', $new_windowstr);
1267
1268             $ids = array_slice($new_window, $offset, $limit);
1269
1270             return $ids;
1271         }
1272
1273         $window = call_user_func_array($fn, array_merge($args, array(0, NOTICE_CACHE_WINDOW,
1274                                                                      0, 0, null)));
1275
1276         $windowstr = implode(',', $window);
1277
1278         $result = $cache->set($idkey, $windowstr);
1279         $result = $cache->set($idkey . ';last', $windowstr);
1280
1281         $ids = array_slice($window, $offset, $limit);
1282
1283         return $ids;
1284     }
1285
1286     /**
1287      * Determine which notice, if any, a new notice is in reply to.
1288      *
1289      * For conversation tracking, we try to see where this notice fits
1290      * in the tree. Rough algorithm is:
1291      *
1292      * if (reply_to is set and valid) {
1293      *     return reply_to;
1294      * } else if ((source not API or Web) and (content starts with "T NAME" or "@name ")) {
1295      *     return ID of last notice by initial @name in content;
1296      * }
1297      *
1298      * Note that all @nickname instances will still be used to save "reply" records,
1299      * so the notice shows up in the mentioned users' "replies" tab.
1300      *
1301      * @param integer $reply_to   ID passed in by Web or API
1302      * @param integer $profile_id ID of author
1303      * @param string  $source     Source tag, like 'web' or 'gwibber'
1304      * @param string  $content    Final notice content
1305      *
1306      * @return integer ID of replied-to notice, or null for not a reply.
1307      */
1308
1309     static function getReplyTo($reply_to, $profile_id, $source, $content)
1310     {
1311         static $lb = array('xmpp', 'mail', 'sms', 'omb');
1312
1313         // If $reply_to is specified, we check that it exists, and then
1314         // return it if it does
1315
1316         if (!empty($reply_to)) {
1317             $reply_notice = Notice::staticGet('id', $reply_to);
1318             if (!empty($reply_notice)) {
1319                 return $reply_to;
1320             }
1321         }
1322
1323         // If it's not a "low bandwidth" source (one where you can't set
1324         // a reply_to argument), we return. This is mostly web and API
1325         // clients.
1326
1327         if (!in_array($source, $lb)) {
1328             return null;
1329         }
1330
1331         // Is there an initial @ or T?
1332
1333         if (preg_match('/^T ([A-Z0-9]{1,64}) /', $content, $match) ||
1334             preg_match('/^@([a-z0-9]{1,64})\s+/', $content, $match)) {
1335             $nickname = common_canonical_nickname($match[1]);
1336         } else {
1337             return null;
1338         }
1339
1340         // Figure out who that is.
1341
1342         $sender = Profile::staticGet('id', $profile_id);
1343         $recipient = common_relative_profile($sender, $nickname, common_sql_now());
1344
1345         if (empty($recipient)) {
1346             return null;
1347         }
1348
1349         // Get their last notice
1350
1351         $last = $recipient->getCurrentNotice();
1352
1353         if (!empty($last)) {
1354             return $last->id;
1355         }
1356     }
1357
1358     static function maxContent()
1359     {
1360         $contentlimit = common_config('notice', 'contentlimit');
1361         // null => use global limit (distinct from 0!)
1362         if (is_null($contentlimit)) {
1363             $contentlimit = common_config('site', 'textlimit');
1364         }
1365         return $contentlimit;
1366     }
1367
1368     static function contentTooLong($content)
1369     {
1370         $contentlimit = self::maxContent();
1371         return ($contentlimit > 0 && !empty($content) && (mb_strlen($content) > $contentlimit));
1372     }
1373 }