# This code was forked from the LiveJournal project owned and operated # by Live Journal, Inc. The code has been modified and expanded by # Dreamwidth Studios, LLC. These files were originally licensed under # the terms of the license supplied by Live Journal, Inc, which can # currently be found at: # # http://code.livejournal.org/trac/livejournal/browser/trunk/LICENSE-LiveJournal.txt # # In accordance with the original license, this code and all its # modifications are provided under the GNU General Public License. # A copy of that license can be found in the LICENSE file included as # part of this distribution. # This package is for managing a queue of notifications # for a user. # Mischa Spiegelmock, 4/28/06 package LJ::NotificationInbox; use strict; use Carp qw(croak); use LJ::NotificationItem; use LJ::Event; use LJ::NotificationArchive; # constructor takes a $u sub new { my ( $class, $u ) = @_; croak "Invalid args to construct LJ::NotificationInbox" unless $class && $u; croak "Invalid user" unless LJ::isu($u); # return singleton from $u if it already exists return $u->{_notification_inbox} if $u->{_notification_inbox}; my $self = { uid => $u->userid, count => undef, # defined once ->count is loaded/cached items => undef, # defined to arrayref once items loaded bookmarks => undef, # defined to arrayref }; return $u->{_notification_inbox} = bless $self, $class; } # returns the user object associated with this queue *owner = \&u; sub u { my $self = shift; return LJ::load_userid( $self->{uid} ); } # Returns a list of LJ::NotificationItems in this queue. sub items { my $self = shift; croak "items is an object method" unless ( ref $self ) eq __PACKAGE__; return @{ $self->{items} } if defined $self->{items}; my @qids = $self->_load; my @items = (); foreach my $qid (@qids) { push @items, LJ::NotificationItem->new( $self->owner, $qid ); } $self->{items} = \@items; # optimization: # now items are defined ... if any are comment # objects we'll instantiate those ahead of time # so that if one has its data loaded they will # all benefit from a coalesced load $self->instantiate_comment_singletons; $self->instantiate_message_singletons; return @items; } # returns a list of all notification items except for sent user messages sub all_items { my $self = shift; return grep { $_->event && $_->event->class ne "LJ::Event::UserMessageSent" } $self->items; } # returns a list of friend-related notificationitems sub friend_items { my $self = shift; my @friend_events = friend_event_list(); my %friend_events = map { "LJ::Event::" . $_ => 1 } @friend_events; return grep { $friend_events{ $_->event->class } } $self->items; } # returns a list of friend-related notificationitems sub circle_items { my $self = shift; my @friend_events = circle_event_list(); my %friend_events = map { "LJ::Event::" . $_ => 1 } @friend_events; return grep { $friend_events{ $_->event->class } } $self->items; } # returns a list of non user-messaging notificationitems sub non_usermsg_items { my $self = shift; my @usermsg_events = qw( UserMessageRecvd UserMessageSent ); @usermsg_events = ( @usermsg_events, ( LJ::Hooks::run_hook('usermsg_notification_types') || () ) ); my %usermsg_events = map { "LJ::Event::" . $_ => 1 } @usermsg_events; return grep { !$usermsg_events{ $_->event->class } } $self->items; } # returns a list of non user-message recvd notificationitems sub usermsg_recvd_items { my $self = shift; my @events = ('UserMessageRecvd'); return $self->subset_items(@events); } # returns a list of non user-message recvd notificationitems sub usermsg_sent_items { my $self = shift; my @events = ('UserMessageSent'); return $self->subset_items(@events); } sub usermsg_sent_last_items { my $self = shift; my @events = ('UserMessageSent'); return $self->subset_items(@events); } sub birthday_items { my $self = shift; my @events = ('Birthday'); return $self->subset_items(@events); } sub encircled_items { my $self = shift; my @events = ('AddedToCircle'); return $self->subset_items(@events); } sub entrycomment_items { my $self = shift; my @events = entrycomment_event_list(); return $self->subset_items(@events); } sub pollvote_items { return $_[0]->subset_items('PollVote'); } sub communitymembership_items { my $self = shift; my @community_events = communitymembership_event_list(); my %community_events = map { "LJ::Event::" . $_ => 1 } @community_events; return grep { $community_events{ $_->event->class } } $self->items; } sub sitenotices_items { my $self = shift; my @site_events = sitenotices_event_list(); my %site_events = map { "LJ::Event::" . $_ => 1 } @site_events; return grep { $site_events{ $_->event->class } } $self->items; } # return a subset of notificationitems sub subset_items { my ( $self, @subset ) = @_; my %subset_events = map { "LJ::Event::" . $_ => 1 } @subset; return grep { $subset_events{ $_->event->class } } $self->items; } sub singleentry_items { my ( $self, $itemid ) = @_; my %related_events = map { $_ => 1 } LJ::Event::JournalNewComment->related_events; return grep { $related_events{ $_->event->etypeid } && $_->event->comment && $_->event->comment ->entry # may have been deleted, which breaks all filter to entry comments && $_->event->comment->entry->ditemid == $itemid } $self->items; } # return flagged notifications sub bookmark_items { my $self = shift; return grep { $self->is_bookmark( $_->qid ) } $self->items; } # return archived notifications sub archived_items { my $self = shift; my $u = $self->u; my $archive = $u->notification_archive; return $archive->items; } # return unread notifications sub unread_items { my $self = shift; return grep { $_->unread } $self->items; } sub count { my $self = shift; return $self->{count} if defined $self->{count}; if ( defined $self->{items} ) { return $self->{count} = scalar @{ $self->{items} }; } my $u = $self->owner; return $self->{count} = $u->selectrow_array( "SELECT COUNT(*) FROM notifyqueue WHERE userid=?", undef, $u->id ); } # returns number of unread items in inbox # returns a maximum of 1000, if you get 1000 it's safe to # assume "more than 1000" sub unread_count { my $self = shift; # cached unread count my $unread = LJ::MemCache::get( $self->_unread_memkey ); return $unread if defined $unread; # not cached, load from DB my $u = $self->u or die "No user"; my $sth = $u->prepare("SELECT COUNT(*) FROM notifyqueue WHERE userid=? AND state='N' LIMIT 1000"); $sth->execute( $u->id ); die $sth->errstr if $sth->err; ($unread) = $sth->fetchrow_array; # cache it LJ::MemCache::set( $self->_unread_memkey, $unread, 30 * 60 ); return $unread; } # load the items in this queue # returns internal items hashref sub _load { my $self = shift; my $u = $self->u or die "No user object"; # is it memcached? my $qids; $qids = LJ::MemCache::get( $self->_memkey ) and return @$qids; # not cached, load my $sth = $u->prepare( "SELECT userid, qid, journalid, etypeid, arg1, arg2, state, createtime " . "FROM notifyqueue WHERE userid=?" ); $sth->execute( $u->{userid} ); die $sth->errstr if $sth->err; my @items = (); while ( my $row = $sth->fetchrow_hashref ) { my $qid = $row->{qid}; # load this item into process cache so it's ready to go my $qitem = LJ::NotificationItem->new( $u, $qid ); $qitem->absorb_row($row); push @items, $qitem; } # sort based on create time @items = sort { $a->when_unixtime <=> $b->when_unixtime } @items; # get sorted list of ids my @item_ids = map { $_->qid } @items; # cache LJ::MemCache::set( $self->_memkey, \@item_ids, 86400 ); return @item_ids; } sub instantiate_comment_singletons { my $self = shift; # instantiate all the comment singletons so that they will all be # loaded efficiently later as soon as preload_rows is called on # the first comment object my @comment_items = grep { $_->event && ( $_->event->class eq 'LJ::Event::JournalNewComment' || $_->event->class eq 'LJ::Event::JournalNewComment::TopLevel' || $_->event->class eq 'LJ::Event::JournalNewComment::Edited' ) } $self->items; my @comment_events = map { $_->event } @comment_items; # instantiate singletons LJ::Comment->new( $_->event_journal, jtalkid => $_->jtalkid ) foreach @comment_events; return 1; } sub instantiate_message_singletons { my $self = shift; # instantiate all the message singletons so that they will all be # loaded efficiently later as soon as preload_rows is called on # the first message object my @message_items = grep { $_->event && $_->event->class eq 'LJ::Event::UserMessageRecvd' } $self->items; my @message_events = map { $_->event } @message_items; # instantiate singletons LJ::Message->load( { msgid => $_->arg1, journalid => $_->u->{userid} } ) foreach @message_events; return 1; } sub _memkey { my $self = shift; my $userid = $self->u->id; return [ $userid, "inbox:$userid" ]; } sub _unread_memkey { my $self = shift; my $userid = $self->u->id; return [ $userid, "inbox:newct:${userid}" ]; } sub _bookmark_memkey { my $self = shift; my $userid = $self->u->id; return [ $userid, "inbox:bookmarks:${userid}" ]; } # deletes an Event that is queued for this user # args: Queue ID to remove from queue sub delete_from_queue { my ( $self, $qitem ) = @_; croak "delete_from_queue is an object method" unless ( ref $self ) eq __PACKAGE__; my $qid = $qitem->qid; croak "no queueid for queue item passed to delete_from_queue" unless int($qid); my $u = $self->u or die "No user object"; $u->do( "DELETE FROM notifyqueue WHERE userid=? AND qid=?", undef, $u->id, $qid ); die $u->errstr if $u->err; # invalidate caches $self->expire_cache; return 1; } sub expire_cache { my $self = shift; $self->{count} = undef; $self->{items} = undef; LJ::MemCache::delete( $self->_memkey ); LJ::MemCache::delete( $self->_unread_memkey ); } # FIXME: make this faster sub oldest_item { my $self = shift; my @items = $self->items; my $oldest; foreach my $item (@items) { $oldest = $item if !$oldest || $item->when_unixtime < $oldest->when_unixtime; } return $oldest; } # This will enqueue an event object # Returns the enqueued item sub enqueue { my ( $self, %opts ) = @_; my $evt = delete $opts{event}; my $archive = delete $opts{archive} || LJ::is_enabled('esn_archive'); croak "No event" unless $evt; croak "Extra args passed to enqueue" if %opts; my $u = $self->u or die "No user"; # if over the max, delete the oldest notification my $max = $u->count_inbox_max; my $skip = $max - 1; # number to skip to get to max if ( $max && $self->count >= $max ) { # Get list of bookmarks and ignore them when checking inbox limits my $bmarks = join ',', map { $self->is_bookmark( $_->qid ) ? $_->qid : () } $self->items; my $bookmark_sql = ''; $bookmark_sql = "AND qid NOT IN ($bmarks) " if ($bmarks); my $too_old_qid = $u->selectrow_array( "SELECT qid FROM notifyqueue " . "WHERE userid=? $bookmark_sql" . "ORDER BY qid DESC LIMIT $skip,1", undef, $u->id ); if ($too_old_qid) { $u->do( "DELETE FROM notifyqueue WHERE userid=? AND qid <= ? $bookmark_sql", undef, $u->id, $too_old_qid ); $self->expire_cache; } } # get a qid my $qid = LJ::alloc_user_counter( $u, 'Q' ) or die "Could not alloc new queue ID"; my %item = ( qid => $qid, userid => $u->{userid}, journalid => $evt->u->{userid}, etypeid => $evt->etypeid, arg1 => $evt->arg1, arg2 => $evt->arg2, state => $evt->mark_read ? 'R' : 'N', createtime => $evt->eventtime_unix || time() ); # insert this event into the notifyqueue table $u->do( "INSERT INTO notifyqueue (" . join( ",", keys %item ) . ") VALUES (" . join( ",", map { '?' } values %item ) . ")", undef, values %item ) or die $u->errstr; if ($archive) { # insert into the notifyarchive table with State defaulted to space $item{state} = ' '; $u->do( "INSERT INTO notifyarchive (" . join( ",", keys %item ) . ") VALUES (" . join( ",", map { '?' } values %item ) . ")", undef, values %item ) or die $u->errstr; } # invalidate memcache $self->expire_cache; return LJ::NotificationItem->new( $u, $qid ); } # return true if item is bookmarked sub is_bookmark { my ( $self, $qid ) = @_; # load bookmarks if they don't already exist $self->load_bookmarks unless defined $self->{bookmarks}; return $self->{bookmarks}{$qid} ? 1 : 0; } # populate the bookmark hash sub load_bookmarks { my ($self) = @_; my $u = $self->u; my $uid = $self->u->id; my $row = LJ::MemCache::get( $self->_bookmark_memkey ); $self->{bookmarks} = (); if ($row) { my @qids = unpack( "N*", $row ); foreach my $qid (@qids) { $self->{bookmarks}{$qid} = 1; } return; } my $sql = "SELECT qid FROM notifybookmarks WHERE userid=?"; my $qids = $u->selectcol_arrayref( $sql, undef, $uid ); die "Failed to load bookmarks: " . $u->errstr . "\n" if $u->err; foreach my $qid (@$qids) { $self->{bookmarks}{$qid} = 1; } $row = pack( "N*", @$qids ); LJ::MemCache::set( $self->_bookmark_memkey, $row, 3600 ); return; } # add a bookmark sub add_bookmark { my ( $self, $qid ) = @_; my $u = $self->u; my $uid = $self->u->id; return 0 unless $self->can_add_bookmark; my $sql = "INSERT IGNORE INTO notifybookmarks (userid, qid) VALUES (?, ?)"; $u->do( $sql, undef, $uid, $qid ); die "Failed to add bookmark: " . $u->errstr . "\n" if $u->err; # Make sure notice is in inbox $self->ensure_queued($qid); $self->{bookmarks}{$qid} = 1 if defined $self->{bookmarks}; LJ::MemCache::delete( $self->_bookmark_memkey ); return 1; } # remove bookmark sub remove_bookmark { my ( $self, $qid ) = @_; my $u = $self->u; my $uid = $self->u->id; my $sql = "DELETE FROM notifybookmarks WHERE userid=? AND qid=?"; $u->do( $sql, undef, $uid, $qid ); die "Failed to remove bookmark: " . $u->errstr . "\n" if $u->err; delete $self->{bookmarks}->{$qid} if defined $self->{bookmarks}; LJ::MemCache::delete( $self->_bookmark_memkey ); return 1; } # add or remove bookmark based on whether it is already bookmarked sub toggle_bookmark { my ( $self, $qid ) = @_; my $ret = $self->is_bookmark($qid) ? $self->remove_bookmark($qid) : $self->add_bookmark($qid); return $ret; } # return true if can add a bookmark sub can_add_bookmark { my ( $self, $count ) = @_; my $max = $self->u->count_bookmark_max; $count = $count || 1; my $bookmark_count = scalar $self->bookmark_items; return 0 if ( ( $bookmark_count + $count ) > $max ); return 1; } # return count of unread bookmarks sub bookmark_count { my ( $self, $count ) = @_; my $unread_bookmark_count = scalar grep { $_->unread } $self->bookmark_items; return $unread_bookmark_count; } sub delete_all { my ( $self, $view, %args ) = @_; my @items; # Unless in folder 'Bookmarks', don't fetch any bookmarks if ( $view eq 'all' ) { @items = $self->all_items; push @items, $self->usermsg_sent_items; } elsif ( $view eq 'usermsg_recvd' ) { @items = $self->usermsg_recvd_items; } elsif ( $view eq 'circle' ) { @items = $self->circle_items; push @items, $self->birthday_items; push @items, $self->encircled_items; } elsif ( $view eq 'birthday' ) { @items = $self->birthday_items; } elsif ( $view eq 'encircled' ) { @items = $self->encircled_items; } elsif ( $view eq 'entrycomment' ) { @items = $self->entrycomment_items; } elsif ( $view eq 'bookmark' ) { @items = $self->bookmark_items; } elsif ( $view eq 'usermsg_sent' ) { @items = $self->usermsg_sent_items; } elsif ( $view eq 'usermsg_sent_last' ) { @items = $self->usermsg_sent_last_items; } elsif ( $view eq 'singleentry' && $args{itemid} ) { my $itemid = $args{itemid} + 0; return unless $itemid; @items = $self->singleentry_items($itemid); } elsif ( $view eq 'pollvote' ) { @items = $self->pollvote_items; } elsif ( $view eq 'communitymembership' ) { @items = $self->communitymembership_items; } elsif ( $view eq 'sitenotices' ) { @items = $self->sitenotices_items; } elsif ( $view eq 'unread' ) { @items = $self->unread_items; } @items = grep { !$self->is_bookmark( $_->qid ) } @items unless $view eq 'bookmark'; my @ret; foreach my $item (@items) { push @ret, { qid => $item->qid }; } # Delete items foreach my $item (@items) { $item->delete; } return @ret; } sub mark_all_read { my ( $self, $view, %args ) = @_; my @items; # Only get items in currently viewed folder and subfolders if ( $view eq 'all' ) { @items = $self->all_items; push @items, $self->usermsg_sent_items; } elsif ( $view eq 'usermsg_recvd' ) { @items = $self->usermsg_recvd_items; } elsif ( $view eq 'circle' ) { @items = $self->circle_items; push @items, $self->birthday_items; push @items, $self->encircled_items; } elsif ( $view eq 'birthday' ) { @items = $self->birthday_items; } elsif ( $view eq 'encircled' ) { @items = $self->encircled_items; } elsif ( $view eq 'entrycomment' ) { @items = $self->entrycomment_items; } elsif ( $view eq 'bookmark' ) { @items = $self->bookmark_items; } elsif ( $view eq 'usermsg_sent' ) { @items = $self->usermsg_sent_items; } elsif ( $view eq 'usermsg_sent_last' ) { @items = $self->usermsg_sent_last_items; } elsif ( $view eq 'singleentry' && $args{itemid} ) { my $itemid = $args{itemid} + 0; return unless $itemid; @items = $self->singleentry_items($itemid); } elsif ( $view eq 'pollvote' ) { @items = $self->pollvote_items; } elsif ( $view eq 'communitymembership' ) { @items = $self->communitymembership_items; } elsif ( $view eq 'sitenotices' ) { @items = $self->sitenotices_items; } elsif ( $view eq 'unread' ) { @items = $self->unread_items; } # Mark read $_->mark_read foreach @items; return @items; } # Copy archive notice to inbox # Needed when bookmarking a notice that only lives in archive sub ensure_queued { my ( $self, $qid ) = @_; my $u = $self->u or die "No user object"; my $sth = $u->prepare( "SELECT userid, qid, journalid, etypeid, arg1, arg2, state, createtime " . "FROM notifyarchive WHERE userid=? AND qid=?" ); $sth->execute( $u->{userid}, $qid ); die $sth->errstr if $sth->err; my $row = $sth->fetchrow_hashref; if ($row) { my %item = ( qid => $row->{qid}, userid => $row->{userid}, journalid => $row->{journalid}, etypeid => $row->{etypeid}, arg1 => $row->{arg1}, arg2 => $row->{arg2}, state => 'R', createtime => $row->{createtime} ); # insert this event into the notifyqueue table $u->do( "INSERT IGNORE INTO notifyqueue (" . join( ",", keys %item ) . ") VALUES (" . join( ",", map { '?' } values %item ) . ")", undef, values %item ) or die $u->errstr; # invalidate memcache $self->expire_cache; } return; } # return a count of a subset of notificationitems sub subset_unread_count { my ( $self, @subset ) = @_; my %subset_events = map { "LJ::Event::" . $_ => 1 } @subset; my @events = grep { $_->event && $subset_events{ $_->event->class } && $_->unread } $self->items; return scalar @events; } sub all_event_count { my $self = shift; my @events = grep { $_->event && $_->event->class ne 'LJ::Event::UserMessageSent' && $_->unread } $self->items; return scalar @events; } sub friend_event_count { my $self = shift; return $self->subset_unread_count( friend_event_list() ); } sub circle_event_count { my $self = shift; return $self->subset_unread_count( circle_event_list() ); } sub entrycomment_event_count { my $self = shift; return $self->subset_unread_count( entrycomment_event_list() ); } sub pollvote_event_count { my $self = shift; return $self->subset_unread_count('PollVote'); } sub usermsg_recvd_event_count { my $self = shift; my @events = ('UserMessageRecvd'); return $self->subset_unread_count(@events); } sub usermsg_sent_event_count { my $self = shift; my @events = ('UserMessageSent'); return $self->subset_unread_count(@events); } sub communitymembership_event_count { my $self = shift; return $self->subset_unread_count( communitymembership_event_list() ); } sub sitenotices_event_count { my $self = shift; return $self->subset_unread_count( sitenotices_event_list() ); } # Methods that return Arrays of Event categories sub friend_event_list { my @events = qw( AddedToCircle RemovedFromCircle InvitedFriendJoins CommunityInvite NewUserpic ); @events = ( @events, ( LJ::Hooks::run_hook('friend_notification_types') || () ) ); return @events; } sub circle_event_list { my @events = qw( AddedToCircle RemovedFromCircle InvitedFriendJoins CommunityInvite NewUserpic Birthday ); @events = ( @events, ( LJ::Hooks::run_hook('friend_notification_types') || () ) ); return @events; } sub entrycomment_event_list { my @events = ( 'JournalNewEntry', 'JournalNewComment', 'JournalNewComment::TopLevel', 'JournalNewComment::Edited' ); return @events; } sub communitymembership_event_list { my @events = ( 'CommunityJoinApprove', 'CommunityJoinReject', 'CommunityJoinRequest' ); return @events; } sub sitenotices_event_list { my @events = ( 'ImportStatus', 'OfficialPost', 'SecurityAttributeChanged', 'UserExpunged', 'VgiftApproved', 'VgiftDelivered', 'XPostFailure', 'XPostSuccess' ); return @events; } 1;