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