]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - classes/Notice.php
Merge branch '0.7.x' into 0.8.x
[quix0rs-gnu-social.git] / classes / Notice.php
1 <?php
2 /*
3  * Laconica - a distributed open-source microblogging tool
4  * Copyright (C) 2008, Controlez-Vous, 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 class Notice extends Memcached_DataObject
33 {
34     ###START_AUTOCODE
35     /* the code below is auto generated do not remove the above tag */
36
37     public $__table = 'notice';                          // table name
38     public $id;                              // int(4)  primary_key not_null
39     public $profile_id;                      // int(4)   not_null
40     public $uri;                             // varchar(255)  unique_key
41     public $content;                         // varchar(140)
42     public $rendered;                        // text()
43     public $url;                             // varchar(255)
44     public $created;                         // datetime()   not_null
45     public $modified;                        // timestamp()   not_null default_CURRENT_TIMESTAMP
46     public $reply_to;                        // int(4)
47     public $is_local;                        // tinyint(1)
48     public $source;                          // varchar(32)
49     public $conversation;                    // int(4)
50
51     /* Static get */
52     function staticGet($k,$v=NULL) {
53         return Memcached_DataObject::staticGet('Notice',$k,$v);
54     }
55
56     /* the code above is auto generated do not remove the tag below */
57     ###END_AUTOCODE
58
59     function getProfile()
60     {
61         return Profile::staticGet('id', $this->profile_id);
62     }
63
64     function delete()
65     {
66         $this->blowCaches(true);
67         $this->blowFavesCache(true);
68         $this->blowSubsCache(true);
69
70         $this->query('BEGIN');
71         $related = array('Reply',
72                          'Fave',
73                          'Notice_tag',
74                          'Group_inbox',
75                          'Queue_item');
76         if (common_config('inboxes', 'enabled')) {
77             $related[] = 'Notice_inbox';
78         }
79         foreach ($related as $cls) {
80             $inst = new $cls();
81             $inst->notice_id = $this->id;
82             $inst->delete();
83         }
84         $result = parent::delete();
85         $this->query('COMMIT');
86     }
87
88     function saveTags()
89     {
90         /* extract all #hastags */
91         $count = preg_match_all('/(?:^|\s)#([A-Za-z0-9_\-\.]{1,64})/', strtolower($this->content), $match);
92         if (!$count) {
93             return true;
94         }
95
96         /* Add them to the database */
97         foreach(array_unique($match[1]) as $hashtag) {
98             /* elide characters we don't want in the tag */
99             $this->saveTag($hashtag);
100         }
101         return true;
102     }
103
104     function saveTag($hashtag)
105     {
106         $hashtag = common_canonical_tag($hashtag);
107
108         $tag = new Notice_tag();
109         $tag->notice_id = $this->id;
110         $tag->tag = $hashtag;
111         $tag->created = $this->created;
112         $id = $tag->insert();
113
114         if (!$id) {
115             throw new ServerException(sprintf(_('DB error inserting hashtag: %s'),
116                                               $last_error->message));
117             return;
118         }
119     }
120
121     static function saveNew($profile_id, $content, $source=null, $is_local=1, $reply_to=null, $uri=null) {
122
123         $profile = Profile::staticGet($profile_id);
124
125         $final =  common_shorten_links($content);
126
127         if (!$profile) {
128             common_log(LOG_ERR, 'Problem saving notice. Unknown user.');
129             return _('Problem saving notice. Unknown user.');
130         }
131
132         if (common_config('throttle', 'enabled') && !Notice::checkEditThrottle($profile_id)) {
133             common_log(LOG_WARNING, 'Excessive posting by profile #' . $profile_id . '; throttled.');
134             return _('Too many notices too fast; take a breather and post again in a few minutes.');
135         }
136
137         if (common_config('site', 'dupelimit') > 0 && !Notice::checkDupes($profile_id, $final)) {
138             common_log(LOG_WARNING, 'Dupe posting by profile #' . $profile_id . '; throttled.');
139                         return _('Too many duplicate messages too quickly; take a breather and post again in a few minutes.');
140         }
141
142                 $banned = common_config('profile', 'banned');
143
144         if ( in_array($profile_id, $banned) || in_array($profile->nickname, $banned)) {
145             common_log(LOG_WARNING, "Attempted post from banned user: $profile->nickname (user id = $profile_id).");
146             return _('You are banned from posting notices on this site.');
147         }
148
149         $notice = new Notice();
150         $notice->profile_id = $profile_id;
151
152         $blacklist = common_config('public', 'blacklist');
153         $autosource = common_config('public', 'autosource');
154
155         # Blacklisted are non-false, but not 1, either
156
157         if (($blacklist && in_array($profile_id, $blacklist)) ||
158             ($source && $autosource && in_array($source, $autosource))) {
159             $notice->is_local = -1;
160         } else {
161             $notice->is_local = $is_local;
162         }
163
164                 $notice->query('BEGIN');
165
166                 $notice->reply_to = $reply_to;
167                 $notice->created = common_sql_now();
168                 $notice->content = $final;
169                 $notice->rendered = common_render_content($final, $notice);
170                 $notice->source = $source;
171                 $notice->uri = $uri;
172
173         if (!empty($reply_to)) {
174             $reply_notice = Notice::staticGet('id', $reply_to);
175             if (!empty($reply_notice)) {
176                 $notice->reply_to = $reply_to;
177                 $notice->conversation = $reply_notice->conversation;
178             }
179         }
180
181         if (Event::handle('StartNoticeSave', array(&$notice))) {
182
183             $id = $notice->insert();
184
185             if (!$id) {
186                 common_log_db_error($notice, 'INSERT', __FILE__);
187                 return _('Problem saving notice.');
188             }
189
190             # Update the URI after the notice is in the database
191             if (!$uri) {
192                 $orig = clone($notice);
193                 $notice->uri = common_notice_uri($notice);
194
195                 if (!$notice->update($orig)) {
196                     common_log_db_error($notice, 'UPDATE', __FILE__);
197                     return _('Problem saving notice.');
198                 }
199             }
200
201             # XXX: do we need to change this for remote users?
202
203             $notice->saveReplies();
204             $notice->saveTags();
205             $notice->saveGroups();
206
207             $notice->addToInboxes();
208             $notice->query('COMMIT');
209
210             Event::handle('EndNoticeSave', array($notice));
211         }
212
213         # Clear the cache for subscribed users, so they'll update at next request
214         # XXX: someone clever could prepend instead of clearing the cache
215
216         if (common_config('memcached', 'enabled')) {
217             $notice->blowCaches();
218         }
219
220         return $notice;
221     }
222
223     static function checkDupes($profile_id, $content) {
224         $profile = Profile::staticGet($profile_id);
225         if (!$profile) {
226             return false;
227         }
228         $notice = $profile->getNotices(0, NOTICE_CACHE_WINDOW);
229         if ($notice) {
230             $last = 0;
231             while ($notice->fetch()) {
232                 if (time() - strtotime($notice->created) >= common_config('site', 'dupelimit')) {
233                     return true;
234                 } else if ($notice->content == $content) {
235                     return false;
236                 }
237             }
238         }
239         # If we get here, oldest item in cache window is not
240         # old enough for dupe limit; do direct check against DB
241         $notice = new Notice();
242         $notice->profile_id = $profile_id;
243         $notice->content = $content;
244         if (common_config('db','type') == 'pgsql')
245             $notice->whereAdd('extract(epoch from now() - created) < ' . common_config('site', 'dupelimit'));
246         else
247             $notice->whereAdd('now() - created < ' . common_config('site', 'dupelimit'));
248
249         $cnt = $notice->count();
250         return ($cnt == 0);
251     }
252
253     static function checkEditThrottle($profile_id) {
254         $profile = Profile::staticGet($profile_id);
255         if (!$profile) {
256             return false;
257         }
258         # Get the Nth notice
259         $notice = $profile->getNotices(common_config('throttle', 'count') - 1, 1);
260         if ($notice && $notice->fetch()) {
261             # If the Nth notice was posted less than timespan seconds ago
262             if (time() - strtotime($notice->created) <= common_config('throttle', 'timespan')) {
263                 # Then we throttle
264                 return false;
265             }
266         }
267         # Either not N notices in the stream, OR the Nth was not posted within timespan seconds
268         return true;
269     }
270
271     function blowCaches($blowLast=false)
272     {
273         $this->blowSubsCache($blowLast);
274         $this->blowNoticeCache($blowLast);
275         $this->blowRepliesCache($blowLast);
276         $this->blowPublicCache($blowLast);
277         $this->blowTagCache($blowLast);
278         $this->blowGroupCache($blowLast);
279     }
280
281     function blowGroupCache($blowLast=false)
282     {
283         $cache = common_memcache();
284         if ($cache) {
285             $group_inbox = new Group_inbox();
286             $group_inbox->notice_id = $this->id;
287             if ($group_inbox->find()) {
288                 while ($group_inbox->fetch()) {
289                     $cache->delete(common_cache_key('group:notices:'.$group_inbox->group_id));
290                     if ($blowLast) {
291                         $cache->delete(common_cache_key('group:notices:'.$group_inbox->group_id.';last'));
292                     }
293                     $member = new Group_member();
294                     $member->group_id = $group_inbox->group_id;
295                     if ($member->find()) {
296                         while ($member->fetch()) {
297                             $cache->delete(common_cache_key('user:notices_with_friends:' . $member->profile_id));
298                             if ($blowLast) {
299                                 $cache->delete(common_cache_key('user:notices_with_friends:' . $member->profile_id . ';last'));
300                             }
301                         }
302                     }
303                 }
304             }
305             $group_inbox->free();
306             unset($group_inbox);
307         }
308     }
309
310     function blowTagCache($blowLast=false)
311     {
312         $cache = common_memcache();
313         if ($cache) {
314             $tag = new Notice_tag();
315             $tag->notice_id = $this->id;
316             if ($tag->find()) {
317                 while ($tag->fetch()) {
318                     $cache->delete(common_cache_key('notice_tag:notice_stream:' . $tag->tag));
319                     if ($blowLast) {
320                         $cache->delete(common_cache_key('notice_tag:notice_stream:' . $tag->tag . ';last'));
321                     }
322                 }
323             }
324             $tag->free();
325             unset($tag);
326         }
327     }
328
329     function blowSubsCache($blowLast=false)
330     {
331         $cache = common_memcache();
332         if ($cache) {
333             $user = new User();
334
335             $UT = common_config('db','type')=='pgsql'?'"user"':'user';
336             $user->query('SELECT id ' .
337
338                          "FROM $UT JOIN subscription ON $UT.id = subscription.subscriber " .
339                          'WHERE subscription.subscribed = ' . $this->profile_id);
340
341             while ($user->fetch()) {
342                 $cache->delete(common_cache_key('user:notices_with_friends:' . $user->id));
343                 if ($blowLast) {
344                     $cache->delete(common_cache_key('user:notices_with_friends:' . $user->id . ';last'));
345                 }
346             }
347             $user->free();
348             unset($user);
349         }
350     }
351
352     function blowNoticeCache($blowLast=false)
353     {
354         if ($this->is_local) {
355             $cache = common_memcache();
356             if ($cache) {
357                 $cache->delete(common_cache_key('profile:notices:'.$this->profile_id));
358                 if ($blowLast) {
359                     $cache->delete(common_cache_key('profile:notices:'.$this->profile_id.';last'));
360                 }
361             }
362         }
363     }
364
365     function blowRepliesCache($blowLast=false)
366     {
367         $cache = common_memcache();
368         if ($cache) {
369             $reply = new Reply();
370             $reply->notice_id = $this->id;
371             if ($reply->find()) {
372                 while ($reply->fetch()) {
373                     $cache->delete(common_cache_key('user:replies:'.$reply->profile_id));
374                     if ($blowLast) {
375                         $cache->delete(common_cache_key('user:replies:'.$reply->profile_id.';last'));
376                     }
377                 }
378             }
379             $reply->free();
380             unset($reply);
381         }
382     }
383
384     function blowPublicCache($blowLast=false)
385     {
386         if ($this->is_local == 1) {
387             $cache = common_memcache();
388             if ($cache) {
389                 $cache->delete(common_cache_key('public'));
390                 if ($blowLast) {
391                     $cache->delete(common_cache_key('public').';last');
392                 }
393             }
394         }
395     }
396
397     function blowFavesCache($blowLast=false)
398     {
399         $cache = common_memcache();
400         if ($cache) {
401             $fave = new Fave();
402             $fave->notice_id = $this->id;
403             if ($fave->find()) {
404                 while ($fave->fetch()) {
405                     $cache->delete(common_cache_key('user:faves:'.$fave->user_id));
406                     if ($blowLast) {
407                         $cache->delete(common_cache_key('user:faves:'.$fave->user_id.';last'));
408                     }
409                 }
410             }
411             $fave->free();
412             unset($fave);
413         }
414     }
415
416     # XXX: too many args; we need to move to named params or even a separate
417     # class for notice streams
418
419     static function getStream($qry, $cachekey, $offset=0, $limit=20, $since_id=0, $before_id=0, $order=null, $since=null) {
420
421         if (common_config('memcached', 'enabled')) {
422
423             # Skip the cache if this is a since, since_id or before_id qry
424             if ($since_id > 0 || $before_id > 0 || $since) {
425                 return Notice::getStreamDirect($qry, $offset, $limit, $since_id, $before_id, $order, $since);
426             } else {
427                 return Notice::getCachedStream($qry, $cachekey, $offset, $limit, $order);
428             }
429         }
430
431         return Notice::getStreamDirect($qry, $offset, $limit, $since_id, $before_id, $order, $since);
432     }
433
434     static function getStreamDirect($qry, $offset, $limit, $since_id, $before_id, $order, $since) {
435
436         $needAnd = false;
437         $needWhere = true;
438
439         if (preg_match('/\bWHERE\b/i', $qry)) {
440             $needWhere = false;
441             $needAnd = true;
442         }
443
444         if ($since_id > 0) {
445
446             if ($needWhere) {
447                 $qry .= ' WHERE ';
448                 $needWhere = false;
449             } else {
450                 $qry .= ' AND ';
451             }
452
453             $qry .= ' notice.id > ' . $since_id;
454         }
455
456         if ($before_id > 0) {
457
458             if ($needWhere) {
459                 $qry .= ' WHERE ';
460                 $needWhere = false;
461             } else {
462                 $qry .= ' AND ';
463             }
464
465             $qry .= ' notice.id < ' . $before_id;
466         }
467
468         if ($since) {
469
470             if ($needWhere) {
471                 $qry .= ' WHERE ';
472                 $needWhere = false;
473             } else {
474                 $qry .= ' AND ';
475             }
476
477             $qry .= ' notice.created > \'' . date('Y-m-d H:i:s', $since) . '\'';
478         }
479
480         # Allow ORDER override
481
482         if ($order) {
483             $qry .= $order;
484         } else {
485             $qry .= ' ORDER BY notice.created DESC, notice.id DESC ';
486         }
487
488         if (common_config('db','type') == 'pgsql') {
489             $qry .= ' LIMIT ' . $limit . ' OFFSET ' . $offset;
490         } else {
491             $qry .= ' LIMIT ' . $offset . ', ' . $limit;
492         }
493
494         $notice = new Notice();
495
496         $notice->query($qry);
497
498         return $notice;
499     }
500
501     # XXX: this is pretty long and should probably be broken up into
502     # some helper functions
503
504     static function getCachedStream($qry, $cachekey, $offset, $limit, $order) {
505
506         # If outside our cache window, just go to the DB
507
508         if ($offset + $limit > NOTICE_CACHE_WINDOW) {
509             return Notice::getStreamDirect($qry, $offset, $limit, null, null, $order, null);
510         }
511
512         # Get the cache; if we can't, just go to the DB
513
514         $cache = common_memcache();
515
516         if (!$cache) {
517             return Notice::getStreamDirect($qry, $offset, $limit, null, null, $order, null);
518         }
519
520         # Get the notices out of the cache
521
522         $notices = $cache->get(common_cache_key($cachekey));
523
524         # On a cache hit, return a DB-object-like wrapper
525
526         if ($notices !== false) {
527             $wrapper = new ArrayWrapper(array_slice($notices, $offset, $limit));
528             return $wrapper;
529         }
530
531         # If the cache was invalidated because of new data being
532         # added, we can try and just get the new stuff. We keep an additional
533         # copy of the data at the key + ';last'
534
535         # No cache hit. Try to get the *last* cached version
536
537         $last_notices = $cache->get(common_cache_key($cachekey) . ';last');
538
539         if ($last_notices) {
540
541             # Reverse-chron order, so last ID is last.
542
543             $last_id = $last_notices[0]->id;
544
545             # XXX: this assumes monotonically increasing IDs; a fair
546             # bet with our DB.
547
548             $new_notice = Notice::getStreamDirect($qry, 0, NOTICE_CACHE_WINDOW,
549                                                   $last_id, null, $order, null);
550
551             if ($new_notice) {
552                 $new_notices = array();
553                 while ($new_notice->fetch()) {
554                     $new_notices[] = clone($new_notice);
555                 }
556                 $new_notice->free();
557                 $notices = array_slice(array_merge($new_notices, $last_notices),
558                                        0, NOTICE_CACHE_WINDOW);
559
560                 # Store the array in the cache for next time
561
562                 $result = $cache->set(common_cache_key($cachekey), $notices);
563                 $result = $cache->set(common_cache_key($cachekey) . ';last', $notices);
564
565                 # return a wrapper of the array for use now
566
567                 return new ArrayWrapper(array_slice($notices, $offset, $limit));
568             }
569         }
570
571         # Otherwise, get the full cache window out of the DB
572
573         $notice = Notice::getStreamDirect($qry, 0, NOTICE_CACHE_WINDOW, null, null, $order, null);
574
575         # If there are no hits, just return the value
576
577         if (!$notice) {
578             return $notice;
579         }
580
581         # Pack results into an array
582
583         $notices = array();
584
585         while ($notice->fetch()) {
586             $notices[] = clone($notice);
587         }
588
589         $notice->free();
590
591         # Store the array in the cache for next time
592
593         $result = $cache->set(common_cache_key($cachekey), $notices);
594         $result = $cache->set(common_cache_key($cachekey) . ';last', $notices);
595
596         # return a wrapper of the array for use now
597
598         $wrapper = new ArrayWrapper(array_slice($notices, $offset, $limit));
599
600         return $wrapper;
601     }
602
603     function publicStream($offset=0, $limit=20, $since_id=0, $before_id=0, $since=null)
604     {
605
606         $parts = array();
607
608         $qry = 'SELECT * FROM notice ';
609
610         if (common_config('public', 'localonly')) {
611             $parts[] = 'is_local = 1';
612         } else {
613             # -1 == blacklisted
614             $parts[] = 'is_local != -1';
615         }
616
617         if ($parts) {
618             $qry .= ' WHERE ' . implode(' AND ', $parts);
619         }
620
621         return Notice::getStream($qry,
622                                  'public',
623                                  $offset, $limit, $since_id, $before_id, null, $since);
624     }
625
626     function addToInboxes()
627     {
628         $enabled = common_config('inboxes', 'enabled');
629
630         if ($enabled === true || $enabled === 'transitional') {
631             $inbox = new Notice_inbox();
632             $UT = common_config('db','type')=='pgsql'?'"user"':'user';
633             $qry = 'INSERT INTO notice_inbox (user_id, notice_id, created) ' .
634               "SELECT $UT.id, " . $this->id . ", '" . $this->created . "' " .
635               "FROM $UT JOIN subscription ON $UT.id = subscription.subscriber " .
636               'WHERE subscription.subscribed = ' . $this->profile_id . ' ' .
637               'AND NOT EXISTS (SELECT user_id, notice_id ' .
638               'FROM notice_inbox ' .
639               "WHERE user_id = $UT.id " .
640               'AND notice_id = ' . $this->id . ' )';
641             if ($enabled === 'transitional') {
642                 $qry .= " AND $UT.inboxed = 1";
643             }
644             $inbox->query($qry);
645         }
646         return;
647     }
648
649     function saveGroups()
650     {
651         $enabled = common_config('inboxes', 'enabled');
652         if ($enabled !== true && $enabled !== 'transitional') {
653             return;
654         }
655
656         /* extract all !group */
657         $count = preg_match_all('/(?:^|\s)!([A-Za-z0-9]{1,64})/',
658                                 strtolower($this->content),
659                                 $match);
660         if (!$count) {
661             return true;
662         }
663
664         $profile = $this->getProfile();
665
666         /* Add them to the database */
667
668         foreach (array_unique($match[1]) as $nickname) {
669             /* XXX: remote groups. */
670             $group = User_group::staticGet('nickname', $nickname);
671
672             if (!$group) {
673                 continue;
674             }
675
676             // we automatically add a tag for every group name, too
677
678             $tag = Notice_tag::pkeyGet(array('tag' => common_canonical_tag($nickname),
679                                            'notice_id' => $this->id));
680
681             if (is_null($tag)) {
682                 $this->saveTag($nickname);
683             }
684
685             if ($profile->isMember($group)) {
686
687                 $gi = new Group_inbox();
688
689                 $gi->group_id  = $group->id;
690                 $gi->notice_id = $this->id;
691                 $gi->created   = common_sql_now();
692
693                 $result = $gi->insert();
694
695                 if (!$result) {
696                     common_log_db_error($gi, 'INSERT', __FILE__);
697                 }
698
699                 // FIXME: do this in an offline daemon
700
701                 $inbox = new Notice_inbox();
702                 $UT = common_config('db','type')=='pgsql'?'"user"':'user';
703                 $qry = 'INSERT INTO notice_inbox (user_id, notice_id, created, source) ' .
704                   "SELECT $UT.id, " . $this->id . ", '" . $this->created . "', 2 " .
705                   "FROM $UT JOIN group_member ON $UT.id = group_member.profile_id " .
706                   'WHERE group_member.group_id = ' . $group->id . ' ' .
707                   'AND NOT EXISTS (SELECT user_id, notice_id ' .
708                   'FROM notice_inbox ' .
709                   "WHERE user_id = $UT.id " .
710                   'AND notice_id = ' . $this->id . ' )';
711                 if ($enabled === 'transitional') {
712                     $qry .= " AND $UT.inboxed = 1";
713                 }
714                 $result = $inbox->query($qry);
715             }
716         }
717     }
718
719     function saveReplies()
720     {
721         // Alternative reply format
722         $tname = false;
723         if (preg_match('/^T ([A-Z0-9]{1,64}) /', $this->content, $match)) {
724             $tname = $match[1];
725         }
726         // extract all @messages
727         $cnt = preg_match_all('/(?:^|\s)@([a-z0-9]{1,64})/', $this->content, $match);
728
729         $names = array();
730
731         if ($cnt || $tname) {
732             // XXX: is there another way to make an array copy?
733             $names = ($tname) ? array_unique(array_merge(array(strtolower($tname)), $match[1])) : array_unique($match[1]);
734         }
735
736         $sender = Profile::staticGet($this->profile_id);
737
738         $replied = array();
739
740         // store replied only for first @ (what user/notice what the reply directed,
741         // we assume first @ is it)
742
743         for ($i=0; $i<count($names); $i++) {
744             $nickname = $names[$i];
745             $recipient = common_relative_profile($sender, $nickname, $this->created);
746             if (!$recipient) {
747                 continue;
748             }
749             if ($i == 0 && ($recipient->id != $sender->id) && !$this->reply_to) { // Don't save reply to self
750                 $reply_for = $recipient;
751                 $recipient_notice = $reply_for->getCurrentNotice();
752                 if ($recipient_notice) {
753                     $orig = clone($this);
754                     $this->reply_to = $recipient_notice->id;
755                     $this->conversation = $recipient_notice->conversation;
756                     $this->update($orig);
757                 }
758             }
759             // Don't save replies from blocked profile to local user
760             $recipient_user = User::staticGet('id', $recipient->id);
761             if ($recipient_user && $recipient_user->hasBlocked($sender)) {
762                 continue;
763             }
764             $reply = new Reply();
765             $reply->notice_id = $this->id;
766             $reply->profile_id = $recipient->id;
767             $id = $reply->insert();
768             if (!$id) {
769                 $last_error = &PEAR::getStaticProperty('DB_DataObject','lastError');
770                 common_log(LOG_ERR, 'DB error inserting reply: ' . $last_error->message);
771                 common_server_error(sprintf(_('DB error inserting reply: %s'), $last_error->message));
772                 return;
773             } else {
774                 $replied[$recipient->id] = 1;
775             }
776         }
777
778         // Hash format replies, too
779         $cnt = preg_match_all('/(?:^|\s)@#([a-z0-9]{1,64})/', $this->content, $match);
780         if ($cnt) {
781             foreach ($match[1] as $tag) {
782                 $tagged = Profile_tag::getTagged($sender->id, $tag);
783                 foreach ($tagged as $t) {
784                     if (!$replied[$t->id]) {
785                         // Don't save replies from blocked profile to local user
786                         $t_user = User::staticGet('id', $t->id);
787                         if ($t_user && $t_user->hasBlocked($sender)) {
788                             continue;
789                         }
790                         $reply = new Reply();
791                         $reply->notice_id = $this->id;
792                         $reply->profile_id = $t->id;
793                         $id = $reply->insert();
794                         if (!$id) {
795                             common_log_db_error($reply, 'INSERT', __FILE__);
796                             return;
797                         } else {
798                             $replied[$recipient->id] = 1;
799                         }
800                     }
801                 }
802             }
803         }
804
805         // If it's not a reply, make it the root of a new conversation
806
807         if (empty($this->conversation)) {
808             $orig = clone($this);
809             $this->conversation = $this->id;
810             $this->update($orig);
811         }
812
813         foreach (array_keys($replied) as $recipient) {
814             $user = User::staticGet('id', $recipient);
815             if ($user) {
816                 mail_notify_attn($user, $this);
817             }
818         }
819     }
820
821     function asAtomEntry($namespace=false, $source=false)
822     {
823         $profile = $this->getProfile();
824
825         $xs = new XMLStringer(true);
826
827         if ($namespace) {
828             $attrs = array('xmlns' => 'http://www.w3.org/2005/Atom',
829                            'xmlns:thr' => 'http://purl.org/syndication/thread/1.0');
830         } else {
831             $attrs = array();
832         }
833
834         $xs->elementStart('entry', $attrs);
835
836         if ($source) {
837             $xs->elementStart('source');
838             $xs->element('title', null, $profile->nickname . " - " . common_config('site', 'name'));
839             $xs->element('link', array('href' => $profile->profileurl));
840             $user = User::staticGet('id', $profile->id);
841             if (!empty($user)) {
842                 $atom_feed = common_local_url('api',
843                                               array('apiaction' => 'statuses',
844                                                     'method' => 'user_timeline',
845                                                     'argument' => $profile->nickname.'.atom'));
846                 $xs->element('link', array('rel' => 'self',
847                                            'type' => 'application/atom+xml',
848                                            'href' => $profile->profileurl));
849                 $xs->element('link', array('rel' => 'license',
850                                            'href' => common_config('license', 'url')));
851             }
852
853             $xs->element('icon', null, $profile->avatarUrl(AVATAR_PROFILE_SIZE));
854         }
855
856         $xs->elementStart('author');
857         $xs->element('name', null, $profile->nickname);
858         $xs->element('uri', null, $profile->profileurl);
859         $xs->elementEnd('author');
860
861         if ($source) {
862             $xs->elementEnd('source');
863         }
864
865         $xs->element('title', null, $this->content);
866         $xs->element('summary', null, $this->content);
867
868         $xs->element('link', array('rel' => 'alternate',
869                                    'href' => $this->bestUrl()));
870
871         $xs->element('id', null, $this->uri);
872
873         $xs->element('published', null, common_date_w3dtf($this->created));
874         $xs->element('updated', null, common_date_w3dtf($this->modified));
875
876         if ($this->reply_to) {
877             $reply_notice = Notice::staticGet('id', $this->reply_to);
878             if (!empty($reply_notice)) {
879                 $xs->element('link', array('rel' => 'related',
880                                            'href' => $reply_notice->bestUrl()));
881                 $xs->element('thr:in-reply-to',
882                              array('ref' => $reply_notice->uri,
883                                    'href' => $reply_notice->bestUrl()));
884             }
885         }
886
887         $xs->element('content', array('type' => 'html'), $this->rendered);
888
889         $tag = new Notice_tag();
890         $tag->notice_id = $this->id;
891         if ($tag->find()) {
892             while ($tag->fetch()) {
893                 $xs->element('category', array('term' => $tag->tag));
894             }
895         }
896         $tag->free();
897
898         $xs->elementEnd('entry');
899
900         return $xs->getString();
901     }
902
903     function bestUrl()
904     {
905         if (!empty($this->url)) {
906             return $this->url;
907         } else if (!empty($this->uri) && preg_match('/^https?:/', $this->uri)) {
908             return $this->uri;
909         } else {
910             return common_local_url('shownotice',
911                                     array('notice' => $this->id));
912         }
913     }
914 }