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