]> git.mxchange.org Git - quix0rs-gnu-social.git/blobdiff - classes/Notice.php
Added saveActivity method to Notice class
[quix0rs-gnu-social.git] / classes / Notice.php
index 25422aa332d5b8ef6b421075f8e505aeaeff3899..f11c670603dc202f8deb87c00a3ca2dc23c20a82 100644 (file)
@@ -141,14 +141,18 @@ class Notice extends Managed_DataObject
     const GROUP_SCOPE     = 4;
     const FOLLOWER_SCOPE  = 8;
 
-    protected $_profile = -1;
+    protected $_profile = array();
     
+    /**
+     * Will always return a profile, if anything fails it will
+     * (through _setProfile) throw a NoProfileException.
+     */
     public function getProfile()
     {
-        if ($this->_profile === -1) {
+        if (!isset($this->_profile[$this->profile_id])) {
             $this->_setProfile(Profile::getKV('id', $this->profile_id));
         }
-        return $this->_profile;
+        return $this->_profile[$this->profile_id];
     }
     
     public function _setProfile(Profile $profile=null)
@@ -156,7 +160,7 @@ class Notice extends Managed_DataObject
         if (!$profile instanceof Profile) {
             throw new NoProfileException($this->profile_id);
         }
-        $this->_profile = $profile;
+        $this->_profile[$this->profile_id] = $profile;
     }
 
     function delete($useWhere=false)
@@ -190,7 +194,6 @@ class Notice extends Managed_DataObject
 
             $this->clearReplies();
             $this->clearRepeats();
-            $this->clearFaves();
             $this->clearTags();
             $this->clearGroupInboxes();
             $this->clearFiles();
@@ -210,6 +213,28 @@ class Notice extends Managed_DataObject
         return $this->uri;
     }
 
+    /*
+     * Get a Notice object by URI. Will call external plugins for help
+     * using the event StartGetNoticeFromURI.
+     *
+     * @param string $uri A unique identifier for a resource (notice in this case)
+     */
+    static function fromUri($uri)
+    {
+        $notice = null;
+
+        if (Event::handle('StartGetNoticeFromUri', array($uri, &$notice))) {
+            $notice = Notice::getKV('uri', $uri);
+            Event::handle('EndGetNoticeFromUri', array($uri, $notice));
+        }
+
+        if (!$notice instanceof Notice) {
+            throw new UnknownUriException($uri);
+        }
+
+        return $notice;
+    }
+
     /*
      * @param $root boolean If true, link to just the conversation root.
      *
@@ -228,6 +253,19 @@ class Notice extends Managed_DataObject
         return common_local_url('shownotice', array('notice' => $this->id), null, null, false);
     }
 
+    public function getTitle()
+    {
+        $title = null;
+        if (Event::handle('GetNoticeTitle', array($this, &$title))) {
+            // TRANS: Title of a notice posted without a title value.
+            // TRANS: %1$s is a user name, %2$s is the notice creation date/time.
+            $title = sprintf(_('%1$s\'s status on %2$s'),
+                             $this->getProfile()->getFancyName(),
+                             common_exact_date($this->created));
+        }
+        return $title;
+    }
+
     /*
      * Get the original representation URL of this notice.
      */
@@ -507,12 +545,22 @@ class Notice extends Managed_DataObject
                 throw new ClientException(_('You already repeated that notice.'));
             }
 
-            $notice->repeat_of = $repeat_of;
+            $notice->repeat_of = $repeat->id;
+            $notice->conversation = $repeat->conversation;
         } else {
-            $reply = self::getReplyTo($reply_to, $profile_id, $source, $final);
+            $reply = null;
 
-            if (!empty($reply)) {
+            // If $reply_to is specified, we check that it exists, and then
+            // return it if it does
+            if (!empty($reply_to)) {
+                $reply = Notice::getKV('id', $reply_to);
+            } elseif (in_array($source, array('xmpp', 'mail', 'sms'))) {
+                // If the source lacks capability of sending the "reply_to"
+                // metadata, let's try to find an inline replyto-reference.
+                $reply = self::getInlineReplyTo($profile, $final);
+            }
 
+            if ($reply instanceof Notice) {
                 if (!$reply->inScope($profile)) {
                     // TRANS: Client error displayed when trying to reply to a notice a the target has no access to.
                     // TRANS: %1$s is a user nickname, %2$d is a notice ID (number).
@@ -520,11 +568,17 @@ class Notice extends Managed_DataObject
                                                       $profile->nickname, $reply->id), 403);
                 }
 
-                $notice->reply_to     = $reply->id;
+                // If it's a repeat, the reply_to should be to the original
+                if (!empty($reply->repeat_of)) {
+                    $notice->reply_to = $reply->repeat_of;
+                } else {
+                    $notice->reply_to = $reply->id;
+                }
+                // But the conversation ought to be the same :)
                 $notice->conversation = $reply->conversation;
 
-                // If the original is private to a group, and notice has no group specified,
-                // make it to the same group(s)
+                // If the original is private to a group, and notice has
+                // no group specified, make it to the same group(s)
 
                 if (empty($groups) && ($reply->scope & Notice::GROUP_SCOPE)) {
                     $groups = array();
@@ -573,41 +627,13 @@ class Notice extends Managed_DataObject
             $notice->object_type = $object_type;
         }
 
-        if (is_null($scope)) { // 0 is a valid value
-            if (!empty($reply)) {
-                $notice->scope = $reply->scope;
-            } else {
-                $notice->scope = self::defaultScope();
-            }
+        if (is_null($scope) && $reply instanceof Notice) {
+            $notice->scope = $reply->scope;
         } else {
             $notice->scope = $scope;
         }
 
-        // For private streams
-
-        try {
-            $user = $profile->getUser();
-
-            if ($user->private_stream &&
-                ($notice->scope == Notice::PUBLIC_SCOPE ||
-                 $notice->scope == Notice::SITE_SCOPE)) {
-                $notice->scope |= Notice::FOLLOWER_SCOPE;
-            }
-        } catch (NoSuchUserException $e) {
-            // Cannot handle private streams for remote profiles
-        }
-
-        // Force the scope for private groups
-
-        foreach ($groups as $groupId) {
-            $group = User_group::getKV('id', $groupId);
-            if ($group instanceof User_group) {
-                if ($group->force_scope) {
-                    $notice->scope |= Notice::GROUP_SCOPE;
-                    break;
-                }
-            }
-        }
+        $notice->scope = self::figureOutScope($profile, $groups, $notice->scope);
 
         if (Event::handle('StartNoticeSave', array(&$notice))) {
 
@@ -695,6 +721,252 @@ class Notice extends Managed_DataObject
         return $notice;
     }
 
+    static function saveActivity(Activity $act, Profile $actor, array $options=array()) {
+
+        // First check if we're going to let this Activity through from the specific actor
+        if (!$actor->hasRight(Right::NEWNOTICE)) {
+            common_log(LOG_WARNING, "Attempted post from user disallowed to post: " . $actor->getNickname());
+
+            // TRANS: Client exception thrown when a user tries to post while being banned.
+            throw new ClientException(_m('You are banned from posting notices on this site.'), 403);
+        }
+        if (common_config('throttle', 'enabled') && !self::checkEditThrottle($actor->id)) {
+            common_log(LOG_WARNING, 'Excessive posting by profile #' . $actor->id . '; throttled.');
+            // TRANS: Client exception thrown when a user tries to post too many notices in a given time frame.
+            throw new ClientException(_m('Too many notices too fast; take a breather '.
+                                        'and post again in a few minutes.'));
+        }
+
+        // Get ActivityObject properties
+        $actobj = count($act->objects)==1 ? $act->objects[0] : null;
+        if (!is_null($actobj) && $actobj->id) {
+            $options['uri'] = $actobj->id;
+            if ($actobj->link) {
+                $options['url'] = $actobj->link;
+            } elseif ($act->link) {
+                $options['url'] = $act->link;
+            } elseif (preg_match('!^https?://!', $actobj->id)) {
+                $options['url'] = $actobj->id;
+            }
+        } else {
+            // implied object
+            $options['uri'] = $act->id;
+            $options['url'] = $act->link;
+        }
+
+        $defaults = array(
+                          'groups'   => array(),
+                          'is_local' => self::LOCAL_PUBLIC,
+                          'mentions' => array(),
+                          'reply_to' => null,
+                          'repeat_of' => null,
+                          'scope' => null,
+                          'source' => 'unknown',
+                          'tags' => array(),
+                          'uri' => null,
+                          'url' => null,
+                          'urls' => array(),
+                          'distribute' => true);
+
+        // options will have default values when nothing has been supplied
+        $options = array_merge($defaults, $options); 
+        foreach (array_keys($defaults) as $key) {
+            // Only convert the keynames we specify ourselves from 'defaults' array into variables
+            $$key = $options[$key];
+        }
+        extract($options, EXTR_SKIP);
+
+        $stored = new Notice();
+        if (!empty($uri)) {
+            $stored->uri = $uri;
+            if ($stored->find()) {
+                common_debug('cannot create duplicate Notice URI: '.$stored->uri);
+                throw new Exception('Notice URI already exists');
+            }
+        }
+
+        $stored->profile_id = $actor->id;
+        $stored->source = $source;
+        $stored->uri = $uri;
+        $stored->url = $url;
+        $stored->verb = $act->verb;
+
+        $autosource = common_config('public', 'autosource');
+
+        // Sandboxed are non-false, but not 1, either
+        if (!$actor->hasRight(Right::PUBLICNOTICE) ||
+            ($source && $autosource && in_array($source, $autosource))) {
+            $stored->is_local = Notice::LOCAL_NONPUBLIC;
+        }
+
+        // Maybe a missing act-time should be fatal if the actor is not local?
+        if (!empty($act->time)) {
+            $stored->created = common_sql_date($act->time);
+        } else {
+            $stored->created = common_sql_now();
+        }
+
+        $reply = null;
+        if ($act->context instanceof ActivityContext && !empty($act->context->replyToID)) {
+            $reply = self::getKV('uri', $act->context->replyToID);
+        }
+        if (!$reply instanceof Notice && $act->target instanceof ActivityObject) {
+            $reply = self::getKV('uri', $act->target->id);
+        }
+
+        if ($reply instanceof Notice) {
+            if (!$reply->inScope($actor)) {
+                // TRANS: Client error displayed when trying to reply to a notice a the target has no access to.
+                // TRANS: %1$s is a user nickname, %2$d is a notice ID (number).
+                throw new ClientException(sprintf(_m('%1$s has no right to reply to notice %2$d.'), $actor->getNickname(), $reply->id), 403);
+            }
+
+            $stored->reply_to     = $reply->id;
+            $stored->conversation = $reply->conversation;
+
+            // If the original is private to a group, and notice has no group specified,
+            // make it to the same group(s)
+            if (empty($groups) && ($reply->scope & Notice::GROUP_SCOPE)) {
+                $groups = array();
+                $replyGroups = $reply->getGroups();
+                foreach ($replyGroups as $group) {
+                    if ($actor->isMember($group)) {
+                        $groups[] = $group->id;
+                    }
+                }
+            }
+
+            if (is_null($scope)) {
+                $scope = $reply->scope;
+            }
+        }
+
+        if ($act->context instanceof ActivityContext) {
+            $location = $act->context->location;
+            if ($location) {
+                $stored->lat = $location->lat;
+                $stored->lon = $location->lon;
+                if ($location->location_id) {
+                    $stored->location_ns = $location->location_ns;
+                    $stored->location_id = $location->location_id;
+                }
+            }
+        } else {
+            $act->context = new ActivityContext();
+        }
+
+        $stored->scope = self::figureOutScope($actor, $groups, $scope);
+
+        foreach ($act->categories as $cat) {
+            if ($cat->term) {
+                $term = common_canonical_tag($cat->term);
+                if (!empty($term)) {
+                    $tags[] = $term;
+                }
+            }
+        }
+
+        foreach ($act->enclosures as $href) {
+            // @todo FIXME: Save these locally or....?
+            $urls[] = $href;
+        }
+
+        if (Event::handle('StartNoticeSave', array(&$stored))) {
+            // XXX: some of these functions write to the DB
+
+            try {
+                $stored->insert();    // throws exception on error
+
+                $object = null;
+                Event::handle('StoreActivityObject', array($act, $stored, $options, &$object));
+                if (empty($object)) {
+                    throw new ServerException('No object from StoreActivityObject '.$stored->uri . ': '.$act->asString());
+                }
+                $orig = clone($stored);
+                $stored->object_type = ActivityUtils::resolveUri($object->type, true);
+                $stored->update($orig);
+            } catch (Exception $e) {
+                if (empty($stored->id)) {
+                    common_debug('Failed to save stored object entry in database ('.$e->getMessage().')');
+                } else {
+                    common_debug('Failed to store activity object in database ('.$e->getMessage().'), deleting notice id '.$stored->id);
+                    $stored->delete();
+                }
+                throw $e;
+            }
+        }
+
+
+        // Save per-notice metadata...
+        $mentions = array();
+        $groups   = array();
+
+        // This event lets plugins filter out non-local recipients (attentions we don't care about)
+        // Used primarily for OStatus (and if we don't federate, all attentions would be local anyway)
+        Event::handle('GetLocalAttentions', array($actor, $act->context->attention, &$mentions, &$groups));
+
+        if (!empty($mentions)) {
+            $stored->saveKnownReplies($mentions);
+        } else {
+            $stored->saveReplies();
+        }
+
+        if (!empty($tags)) {
+            $stored->saveKnownTags($tags);
+        } else {
+            $stored->saveTags();
+        }
+
+        // Note: groups may save tags, so must be run after tags are saved
+        // to avoid errors on duplicates.
+        // Note: groups should always be set.
+
+        $stored->saveKnownGroups($groups);
+
+        if (!empty($urls)) {
+            $stored->saveKnownUrls($urls);
+        } else {
+            $stored->saveUrls();
+        }
+
+        if ($distribute) {
+            // Prepare inbox delivery, may be queued to background.
+            $stored->distribute();
+        }
+
+        return $stored;
+    }
+
+    static public function figureOutScope(Profile $actor, array $groups, $scope=null) {
+        if (is_null($scope)) {
+            $scope = self::defaultScope();
+        }
+
+        // For private streams
+        try {
+            $user = $actor->getUser();
+            // FIXME: We can't do bit comparison with == (Legacy StatusNet thing. Let's keep it for now.)
+            if ($user->private_stream && ($scope == Notice::PUBLIC_SCOPE || $scope == Notice::SITE_SCOPE)) {
+                $scope |= Notice::FOLLOWER_SCOPE;
+            }
+        } catch (NoSuchUserException $e) {
+            // TODO: Not a local user, so we don't know about scope preferences... yet!
+        }
+
+        // Force the scope for private groups
+        foreach ($groups as $group_id) {
+            $group = User_group::staticGet('id', $group_id);
+            if ($group instanceof User_group) {
+                if ($group->force_scope) {
+                    $scope |= Notice::GROUP_SCOPE;
+                    break;
+                }
+            }
+        }
+
+        return $scope;
+    }
+
     function blowOnInsert($conversation = false)
     {
         $this->blowStream('profile:notice_ids:%d', $this->profile_id);
@@ -822,7 +1094,11 @@ class Notice extends Managed_DataObject
         if (common_config('attachments', 'process_links')) {
             // @fixme validation?
             foreach (array_unique($urls) as $url) {
-                File::processNew($url, $this->id);
+                try {
+                    File::processNew($url, $this->id);
+                } catch (ServerException $e) {
+                    // Could not save URL. Log it?
+                }
             }
         }
     }
@@ -831,7 +1107,11 @@ class Notice extends Managed_DataObject
      * @private callback
      */
     function saveUrl($url, $notice_id) {
-        File::processNew($url, $notice_id);
+        try {
+            File::processNew($url, $notice_id);
+        } catch (ServerException $e) {
+            // Could not save URL. Log it?
+        }
     }
 
     static function checkDupes($profile_id, $content) {
@@ -880,12 +1160,11 @@ class Notice extends Managed_DataObject
         return true;
     }
 
-       protected $_attachments = -1;
+       protected $_attachments = array();
        
     function attachments() {
-
-               if ($this->_attachments != -1)  {
-            return $this->_attachments;
+               if (isset($this->_attachments[$this->id])) {
+            return $this->_attachments[$this->id];
         }
                
         $f2ps = File_to_post::listGet('post_id', array($this->id));
@@ -898,14 +1177,14 @@ class Notice extends Managed_DataObject
                
                $files = File::multiGet('id', $ids);
 
-               $this->_attachments = $files->fetchAll();
+               $this->_attachments[$this->id] = $files->fetchAll();
                
-        return $this->_attachments;
+        return $this->_attachments[$this->id];
     }
 
        function _setAttachments($attachments)
        {
-           $this->_attachments = $attachments;
+           $this->_attachments[$this->id] = $attachments;
        }
 
     function publicStream($offset=0, $limit=20, $since_id=0, $max_id=0)
@@ -1293,19 +1572,17 @@ class Notice extends Managed_DataObject
             return array();
         }
 
-        $sender = Profile::getKV($this->profile_id);
+        $sender = $this->getProfile();
 
         $replied = array();
 
         // If it's a reply, save for the replied-to author
         try {
             $parent = $this->getParent();
-            $author = $parent->getProfile();
-            if ($author instanceof Profile) {
-                $this->saveReply($author->id);
-                $replied[$author->id] = 1;
-                self::blow('reply:stream:%d', $author->id);
-            }
+            $parentauthor = $parent->getProfile();
+            $this->saveReply($parentauthor->id);
+            $replied[$parentauthor->id] = 1;
+            self::blow('reply:stream:%d', $parentauthor->id);
         } catch (Exception $e) {
             // Not a reply, since it has no parent!
         }
@@ -1359,7 +1636,7 @@ class Notice extends Managed_DataObject
         return $reply;
     }
 
-    protected $_replies = -1;
+    protected $_replies = array();
 
     /**
      * Pull the complete list of @-reply targets for this notice.
@@ -1368,8 +1645,8 @@ class Notice extends Managed_DataObject
      */
     function getReplies()
     {
-        if ($this->_replies != -1) {
-            return $this->_replies;
+        if (isset($this->_replies[$this->id])) {
+            return $this->_replies[$this->id];
         }
 
         $replyMap = Reply::listGet('notice_id', array($this->id));
@@ -1380,14 +1657,14 @@ class Notice extends Managed_DataObject
             $ids[] = $reply->profile_id;
         }
 
-        $this->_replies = $ids;
+        $this->_replies[$this->id] = $ids;
 
         return $ids;
     }
 
     function _setReplies($replies)
     {
-        $this->_replies = $replies;
+        $this->_replies[$this->id] = $replies;
     }
 
     /**
@@ -1435,7 +1712,7 @@ class Notice extends Managed_DataObject
      * @return array of Group objects
      */
     
-    protected $_groups = -1;
+    protected $_groups = array();
     
     function getGroups()
     {
@@ -1445,9 +1722,8 @@ class Notice extends Managed_DataObject
             return array();
         }
         
-        if ($this->_groups != -1)
-        {
-            return $this->_groups;
+        if (isset($this->_groups[$this->id])) {
+            return $this->_groups[$this->id];
         }
         
         $gis = Group_inbox::listGet('notice_id', array($this->id));
@@ -1461,14 +1737,14 @@ class Notice extends Managed_DataObject
                
                $groups = User_group::multiGet('id', $ids);
                
-               $this->_groups = $groups->fetchAll();
+               $this->_groups[$this->id] = $groups->fetchAll();
                
-               return $this->_groups;
+               return $this->_groups[$this->id];
     }
     
     function _setGroups($groups)
     {
-        $this->_groups = $groups;
+        $this->_groups[$this->id] = $groups;
     }
 
     /**
@@ -1693,16 +1969,18 @@ class Notice extends Managed_DataObject
 
         // favorite and repeated
 
+        $scoped = null;
         if (!empty($cur)) {
-            $cp = $cur->getProfile();
-            $noticeInfoAttr['favorite'] = ($cp->hasFave($this)) ? "true" : "false";
-            $noticeInfoAttr['repeated'] = ($cp->hasRepeated($this)) ? "true" : "false";
+            $scoped = $cur->getProfile();
+            $noticeInfoAttr['repeated'] = ($scoped->hasRepeated($this)) ? "true" : "false";
         }
 
         if (!empty($this->repeat_of)) {
             $noticeInfoAttr['repeat_of'] = $this->repeat_of;
         }
 
+        Event::handle('StatusNetApiNoticeInfo', array($this, &$noticeInfoAttr, $scoped));
+
         return array('statusnet:notice_info', $noticeInfoAttr, null);
     }
 
@@ -1726,75 +2004,37 @@ class Notice extends Managed_DataObject
      * Determine which notice, if any, a new notice is in reply to.
      *
      * For conversation tracking, we try to see where this notice fits
-     * in the tree. Rough algorithm is:
-     *
-     * if (reply_to is set and valid) {
-     *     return reply_to;
-     * } else if ((source not API or Web) and (content starts with "T NAME" or "@name ")) {
-     *     return ID of last notice by initial @name in content;
-     * }
+     * in the tree. Beware that this may very well give false positives
+     * and add replies to wrong threads (if there have been newer posts
+     * by the same user as we're replying to).
      *
-     * Note that all @nickname instances will still be used to save "reply" records,
-     * so the notice shows up in the mentioned users' "replies" tab.
-     *
-     * @param integer $reply_to   ID passed in by Web or API
-     * @param integer $profile_id ID of author
-     * @param string  $source     Source tag, like 'web' or 'gwibber'
+     * @param Profile $sender     Author profile
      * @param string  $content    Final notice content
      *
      * @return integer ID of replied-to notice, or null for not a reply.
      */
 
-    static function getReplyTo($reply_to, $profile_id, $source, $content)
+    static function getInlineReplyTo(Profile $sender, $content)
     {
-        static $lb = array('xmpp', 'mail', 'sms', 'omb');
-
-        // If $reply_to is specified, we check that it exists, and then
-        // return it if it does
-
-        if (!empty($reply_to)) {
-            $reply_notice = Notice::getKV('id', $reply_to);
-            if ($reply_notice instanceof Notice) {
-                return $reply_notice;
-            }
-        }
-
-        // If it's not a "low bandwidth" source (one where you can't set
-        // a reply_to argument), we return. This is mostly web and API
-        // clients.
-
-        if (!in_array($source, $lb)) {
-            return null;
-        }
-
         // Is there an initial @ or T?
-
-        if (preg_match('/^T ([A-Z0-9]{1,64}) /', $content, $match) ||
-            preg_match('/^@([a-z0-9]{1,64})\s+/', $content, $match)) {
+        if (preg_match('/^T ([A-Z0-9]{1,64}) /', $content, $match)
+                || preg_match('/^@([a-z0-9]{1,64})\s+/', $content, $match)) {
             $nickname = common_canonical_nickname($match[1]);
         } else {
             return null;
         }
 
         // Figure out who that is.
-
-        $sender = Profile::getKV('id', $profile_id);
-        if (!$sender instanceof Profile) {
-            return null;
-        }
-
         $recipient = common_relative_profile($sender, $nickname, common_sql_now());
 
-        if (!$recipient instanceof Profile) {
-            return null;
-        }
-
-        // Get their last notice
-
-        $last = $recipient->getCurrentNotice();
-
-        if ($last instanceof Notice) {
-            return $last;
+        if ($recipient instanceof Profile) {
+            // Get their last notice
+            $last = $recipient->getCurrentNotice();
+            if ($last instanceof Notice) {
+                return $last;
+            }
+            // Maybe in the future we want to handle something else below
+            // so don't return getCurrentNotice() immediately.
         }
 
         return null;
@@ -2022,24 +2262,6 @@ class Notice extends Managed_DataObject
         }
     }
 
-    function clearFaves()
-    {
-        $fave = new Fave();
-        $fave->notice_id = $this->id;
-
-        if ($fave->find()) {
-            while ($fave->fetch()) {
-                self::blow('fave:ids_by_user_own:%d', $fave->user_id);
-                self::blow('fave:ids_by_user_own:%d;last', $fave->user_id);
-                self::blow('fave:ids_by_user:%d', $fave->user_id);
-                self::blow('fave:ids_by_user:%d;last', $fave->user_id);
-                $fave->delete();
-            }
-        }
-
-        $fave->free();
-    }
-
     function clearTags()
     {
         $tag = new Notice_tag();
@@ -2571,14 +2793,13 @@ class Notice extends Managed_DataObject
                }
        }
 
-    static function _idsOf(&$notices)
+    static function _idsOf(array &$notices)
     {
                $ids = array();
                foreach ($notices as $notice) {
-                       $ids[] = $notice->id;
+                       $ids[$notice->id] = true;
                }
-               $ids = array_unique($ids);
-        return $ids;
+               return array_keys($ids);
     }
 
     static function fillAttachments(&$notices)
@@ -2610,47 +2831,6 @@ class Notice extends Managed_DataObject
                }
     }
 
-    protected $_faves;
-
-    /**
-     * All faves of this notice
-     *
-     * @return array Array of Fave objects
-     */
-
-    function getFaves()
-    {
-        if (isset($this->_faves) && is_array($this->_faves)) {
-            return $this->_faves;
-        }
-        $faveMap = Fave::listGet('notice_id', array($this->id));
-        $this->_faves = $faveMap[$this->id];
-        return $this->_faves;
-    }
-
-    function _setFaves($faves)
-    {
-        $this->_faves = $faves;
-    }
-
-    static function fillFaves(&$notices)
-    {
-        $ids = self::_idsOf($notices);
-        $faveMap = Fave::listGet('notice_id', $ids);
-        $cnt = 0;
-        $faved = array();
-        foreach ($faveMap as $id => $faves) {
-            $cnt += count($faves);
-            if (count($faves) > 0) {
-                $faved[] = $id;
-            }
-        }
-        foreach ($notices as $notice) {
-               $faves = $faveMap[$notice->id];
-            $notice->_setFaves($faves);
-        }
-    }
-
     static function fillReplies(&$notices)
     {
         $ids = self::_idsOf($notices);
@@ -2665,21 +2845,21 @@ class Notice extends Managed_DataObject
         }
     }
 
-    protected $_repeats;
+    protected $_repeats = array();
 
     function getRepeats()
     {
-        if (isset($this->_repeats) && is_array($this->_repeats)) {
-            return $this->_repeats;
+        if (isset($this->_repeats[$this->id])) {
+            return $this->_repeats[$this->id];
         }
         $repeatMap = Notice::listGet('repeat_of', array($this->id));
-        $this->_repeats = $repeatMap[$this->id];
-        return $this->_repeats;
+        $this->_repeats[$this->id] = $repeatMap[$this->id];
+        return $this->_repeats[$this->id];
     }
 
     function _setRepeats($repeats)
     {
-        $this->_repeats = $repeats;
+        $this->_repeats[$this->id] = $repeats;
     }
 
     static function fillRepeats(&$notices)