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