init_defaults(); $this->activity = $this->default->activity; $this->init_hooks(); $this->init_actions(); if( is_user_logged_in() && wpforo_setting( 'notifications', 'notifications' ) ) { $this->notifications = $this->get_notifications(); } } private function init_actions() { $this->actions = [ 'wpforo_topic' => [ 'title' => wpforo_phrase( 'New Topic', false ), 'icon' => '', 'description' => wpforo_phrase( '%1$s created a new topic, %2$s', false ), 'before' => '
  • ', 'after' => '
  • ', ], 'wpforo_post' => [ 'title' => wpforo_phrase( 'New Reply', false ), 'icon' => '', 'description' => wpforo_phrase( '%1$s posted a reply, %2$s', false ), 'before' => '
  • ', 'after' => '
  • ', ], 'edit_topic' => [ 'title' => wpforo_phrase( 'Edit Topic', false ), 'icon' => '', 'description' => wpforo_phrase( 'This topic was modified %s by %s', false ), 'before' => '
    ', 'after' => '
    ', ], 'edit_post' => [ 'title' => wpforo_phrase( 'Edit Post', false ), 'icon' => '', 'description' => wpforo_phrase( 'This post was modified %s by %s', false ), 'before' => '
    ', 'after' => '
    ', ], 'new_reply' => [ 'title' => wpforo_phrase( 'New Reply', false ), 'icon' => '', 'description' => wpforo_phrase( 'New reply from %1$s, %2$s', false ), 'before' => '
  • ', 'after' => '
  • ', ], 'new_like' => [ 'title' => wpforo_phrase( 'New Like', false ), 'icon' => '', 'description' => wpforo_phrase( 'New like from %1$s, %2$s', false ), 'before' => '
  • ', 'after' => '
  • ', ], 'new_dislike' => [ 'title' => wpforo_phrase( 'New Dislike', false ), 'icon' => '', 'description' => wpforo_phrase( 'New dislike from %1$s, %2$s', false ), 'before' => '
  • ', 'after' => '
  • ', ], 'new_up_vote' => [ 'title' => wpforo_phrase( 'New Up Vote', false ), 'icon' => ' ', 'description' => wpforo_phrase( 'New up vote from %1$s, %2$s', false ), 'before' => '
  • ', 'after' => '
  • ', ], 'new_down_vote' => [ 'title' => wpforo_phrase( 'New Down Vote', false ), 'icon' => ' ', 'description' => wpforo_phrase( 'New down vote from %1$s, %2$s', false ), 'before' => '
  • ', 'after' => '
  • ', ], 'new_reaction' => [ 'title' => wpforo_phrase( 'New Reaction', false ), 'icon' => '', 'description' => wpforo_phrase( 'New Reaction from %1$s, %2$s', false ), 'before' => '
  • ', 'after' => '
  • ', ], 'new_mention' => [ 'title' => wpforo_phrase( 'New User Mentioning', false ), 'icon' => '', 'description' => wpforo_phrase( '%1$s has mentioned you, %2$s', false ), 'before' => '
  • ', 'after' => '
  • ', ], 'new_favorite' => [ 'title' => wpforo_phrase( 'New Favorite', false ), 'icon' => ' ', 'description' => wpforo_phrase( '%1$s favorited, %2$s', false ), 'before' => '
  • ', 'after' => '
  • ', ], 'topic_solved' => [ 'title' => wpforo_phrase( 'Topic Solved', false ), 'icon' => ' ', 'description' => wpforo_phrase( '%1$s marked as solved, %2$s', false ), 'before' => '
  • ', 'after' => '
  • ', ], 'topic_closed' => [ 'title' => wpforo_phrase( 'Topic Closed', false ), 'icon' => '', 'description' => wpforo_phrase( '%1$s closed the topic, %2$s', false ), 'before' => '
  • ', 'after' => '
  • ', ], 'post_answer' => [ 'title' => wpforo_phrase( 'Marked as Answer', false ), 'icon' => '', 'description' => wpforo_phrase( '%1$s marked as answer, %2$s', false ), 'before' => '
  • ', 'after' => '
  • ', ], 'default' => [ 'title' => wpforo_phrase( 'New Notification', false ), 'icon' => '', 'description' => wpforo_phrase( 'New notification from %1$s, %2$s', false ), 'before' => '
  • ', 'after' => '
  • ', ], ]; $this->actions = apply_filters( 'wpforo_register_actions', $this->actions ); } private function init_defaults() { $this->default = new stdClass(); $this->default->activity = [ 'id' => 0, 'type' => '', 'itemid' => 0, 'itemtype' => '', 'itemid_second' => 0, 'userid' => 0, 'name' => '', 'email' => '', 'date' => 0, 'content' => '', 'permalink' => '', 'new' => 0, ]; $this->default->activity_format = [ 'id' => '%d', 'type' => '%s', 'itemid' => '%d', 'itemtype' => '%s', 'itemid_second' => '%d', 'userid' => '%d', 'name' => '%s', 'email' => '%s', 'date' => '%d', 'content' => '%s', 'permalink' => '%s', 'new' => '%d', ]; $this->default->sql_select_args = [ 'type' => null, 'itemid' => null, 'itemtype' => null, 'itemid_second' => null, 'userid' => null, 'new' => null, 'include' => [], 'exclude' => [], 'userids_include' => [], 'userids_exclude' => [], 'types_include' => [], 'types_exclude' => [], 'itemids_include' => [], 'itemids_exclude' => [], 'itemtypes_include' => [], 'itemtypes_exclude' => [], 'emails_include' => [], 'emails_exclude' => [], 'date_from' => null, 'orderby' => 'id', 'order' => 'ASC', 'offset' => null, 'row_count' => null, ]; $this->default = apply_filters( 'wpforo_activity_after_init_defaults', $this->default ); } private function init_hooks() { if( wpforo_setting( 'posting', 'edit_topic' ) ) { add_action( 'wpforo_after_edit_topic', [ $this, 'after_edit_topic' ] ); } if( wpforo_setting( 'posting', 'edit_post' ) ) { add_action( 'wpforo_after_edit_post', [ $this, 'after_edit_post' ] ); } // Register activity cleanup cron hook add_action( 'wpforo_activity_cleanup', [ $this, 'cron_cleanup_old_activities' ] ); // Schedule activity cleanup cron on admin init if retention_days > 0 and not already scheduled if( is_admin() ) { add_action( 'admin_init', [ $this, 'maybe_schedule_cleanup' ] ); } // Activity log entries for new topics and posts (always enabled for Recent Activity page) add_action( 'wpforo_after_add_topic', [ $this, 'log_new_topic' ], 10, 2 ); add_action( 'wpforo_after_add_post', [ $this, 'log_new_post' ], 10, 2 ); // Log activity when previously unapproved content is approved // wpforo_topic_approve fires for first posts (topics) // wpforo_post_approve fires for non-first posts (replies) add_action( 'wpforo_topic_approve', [ $this, 'log_approved_topic' ] ); add_action( 'wpforo_post_approve', [ $this, 'log_approved_post' ] ); // Clean up activities when topics/posts are deleted add_action( 'wpforo_after_delete_topic', [ $this, 'after_delete_topic' ] ); add_action( 'wpforo_after_delete_post', [ $this, 'after_delete_post' ] ); // Log activity when topic is marked as solved or closed add_action( 'wpforo_topic_solved', [ $this, 'log_topic_solved' ], 10, 2 ); add_action( 'wpforo_topic_closed', [ $this, 'log_topic_closed' ] ); // Log activity when a post is marked as the answer (Q&A layout) add_action( 'wpforo_answer', [ $this, 'log_post_answer' ], 10, 2 ); // Log activity when a post is bookmarked (favorited) add_action( 'wpforo_after_add_bookmark', [ $this, 'log_new_favorite' ] ); // Activity logging hooks - always enabled for What's New page // These create activity entries regardless of notification settings add_action( 'wpforo_vote', [ $this, 'after_vote' ], 10, 2 ); add_action( 'wpforo_react_post', [ &$this, 'after_react' ], 10, 2 ); add_action( 'wpforo_unreact_post', [ &$this, 'after_unreact' ] ); // Notification UI features - only when notifications enabled if( WPF()->current_userid && wpforo_setting( 'notifications', 'notifications' ) ) { if( wpforo_setting( 'notifications', 'notifications_bar' ) ) { add_action( 'wpforo_before_search_toggle', [ $this, 'bell' ] ); } add_action( 'wpforo_after_add_post', [ $this, 'after_add_post' ], 10, 2 ); add_action( 'wpforo_post_status_update', [ $this, 'update_notification' ], 10, 2 ); } } private function filter_built_html_rows( $rows ) { $_rows = []; foreach( $rows as $row_key => $row ) { $in_array = false; if( $_rows ) { foreach( $_rows as $_row_key => $_row ) { if( in_array( $row, $_row ) ) { $in_array = true; $match_key = $_row_key; break; } } } if( $in_array && isset( $match_key ) ) { $_rows[ $match_key ]['times'] ++; } else { $_rows[ $row_key ]['html'] = $row; $_rows[ $row_key ]['times'] = 1; } } $rows = []; foreach( $_rows as $_row ) { $times = ''; if( $_row['times'] > 1 ) { $times = ' ' . sprintf( wpforo_phrase( '%d times', false ), $_row['times'] ); } $rows[] = sprintf( $_row['html'], $times ); } $limit = wpforo_setting( 'posting', 'edit_log_display_limit' ); if( $limit ) $rows = array_slice( $rows, ( - 1 * $limit ), $limit ); return $rows; } private function parse_activity( $data ) { // Ensure defaults are initialized if( ! isset( $this->default->activity ) ) { $this->init_defaults(); } return apply_filters( 'wpforo_activty_parse_activity', array_merge( $this->default->activity, $data ) ); } private function parse_args( $args ) { // Ensure defaults are initialized if( ! isset( $this->default->sql_select_args ) ) { $this->init_defaults(); } $args = wpforo_parse_args( $args, $this->default->sql_select_args ); $args['include'] = wpforo_parse_args( $args['include'] ); $args['exclude'] = wpforo_parse_args( $args['exclude'] ); $args['userids_include'] = wpforo_parse_args( $args['userids_include'] ); $args['userids_exclude'] = wpforo_parse_args( $args['userids_exclude'] ); $args['types_include'] = wpforo_parse_args( $args['types_include'] ); $args['types_exclude'] = wpforo_parse_args( $args['types_exclude'] ); $args['itemids_include'] = wpforo_parse_args( $args['itemids_include'] ); $args['itemids_exclude'] = wpforo_parse_args( $args['itemids_exclude'] ); $args['itemtypes_include'] = wpforo_parse_args( $args['itemtypes_include'] ); $args['itemtypes_exclude'] = wpforo_parse_args( $args['itemtypes_exclude'] ); $args['emails_include'] = wpforo_parse_args( $args['emails_include'] ); $args['emails_exclude'] = wpforo_parse_args( $args['emails_exclude'] ); return $args; } private function build_sql_select( $args, $count_only = false ) { $args = $this->parse_args( $args ); $wheres = []; if( ! is_null( $args['type'] ) ) $wheres[] = "`type` = '" . esc_sql( $args['type'] ) . "'"; if( ! is_null( $args['itemid'] ) ) $wheres[] = "`itemid` = " . wpforo_bigintval( $args['itemid'] ); if( ! is_null( $args['itemtype'] ) ) $wheres[] = "`itemtype` = '" . esc_sql( $args['itemtype'] ) . "'"; if( ! is_null( $args['itemid_second'] ) ) $wheres[] = "`itemid_second` = " . wpforo_bigintval( $args['itemid_second'] ); if( ! is_null( $args['userid'] ) ) $wheres[] = "`userid` = " . intval( $args['userid'] ); if( ! is_null( $args['new'] ) ) $wheres[] = "`new` = " . intval( $args['new'] ); if( ! empty( $args['include'] ) ) $wheres[] = "`id` IN(" . implode( ',', array_map( 'wpforo_bigintval', $args['include'] ) ) . ")"; if( ! empty( $args['exclude'] ) ) $wheres[] = "`id` NOT IN(" . implode( ',', array_map( 'wpforo_bigintval', $args['exclude'] ) ) . ")"; if( ! empty( $args['userids_include'] ) ) $wheres[] = "`userid` IN(" . implode( ',', array_map( 'wpforo_bigintval', $args['userids_include'] ) ) . ")"; if( ! empty( $args['userids_exclude'] ) ) $wheres[] = "`userid` NOT IN(" . implode( ',', array_map( 'wpforo_bigintval', $args['userids_exclude'] ) ) . ")"; if( ! empty( $args['types_include'] ) ) $wheres[] = "`type` IN('" . implode( "','", array_map( 'esc_sql', $args['types_include'] ) ) . "')"; if( ! empty( $args['types_exclude'] ) ) $wheres[] = "`type` NOT IN('" . implode( "','", array_map( 'esc_sql', $args['types_exclude'] ) ) . "')"; if( ! empty( $args['itemids_include'] ) ) $wheres[] = "`itemid` IN(" . implode( ',', array_map( 'wpforo_bigintval', $args['itemids_include'] ) ) . ")"; if( ! empty( $args['itemids_exclude'] ) ) $wheres[] = "`itemid` NOT IN(" . implode( ',', array_map( 'wpforo_bigintval', $args['itemids_exclude'] ) ) . ")"; if( ! empty( $args['itemtypes_include'] ) ) $wheres[] = "`itemtype` IN('" . implode( "','", array_map( 'esc_sql', $args['itemtypes_include'] ) ) . "')"; if( ! empty( $args['itemtypes_exclude'] ) ) $wheres[] = "`itemtype` NOT IN('" . implode( "','", array_map( 'esc_sql', $args['itemtypes_exclude'] ) ) . "')"; if( ! empty( $args['emails_include'] ) ) $wheres[] = "`email` IN('" . implode( "','", array_map( 'esc_sql', $args['emails_include'] ) ) . "')"; if( ! empty( $args['emails_exclude'] ) ) $wheres[] = "`email` NOT IN('" . implode( "','", array_map( 'esc_sql', $args['emails_exclude'] ) ) . "')"; // Date filtering for time period if( ! empty( $args['date_from'] ) ) $wheres[] = "`date` >= " . intval( $args['date_from'] ); if( $count_only ) { $sql = "SELECT COUNT(*) FROM " . WPF()->tables->activity; if( $wheres ) $sql .= " WHERE " . implode( " AND ", $wheres ); return $sql; } $sql = "SELECT * FROM " . WPF()->tables->activity; if( $wheres ) $sql .= " WHERE " . implode( " AND ", $wheres ); $sql .= " ORDER BY " . esc_sql( $args['orderby'] ) . " " . esc_sql( $args['order'] ); if( $args['row_count'] ) { if( ! empty( $args['offset'] ) ) { $sql .= " LIMIT " . wpforo_bigintval( $args['offset'] ) . "," . wpforo_bigintval( $args['row_count'] ); } else { $sql .= " LIMIT " . wpforo_bigintval( $args['row_count'] ); } } return $sql; } public function get_activity( $args ) { if( ! $args ) return false; return $this->parse_activity( (array) WPF()->db->get_row( $this->build_sql_select( $args ), ARRAY_A ) ); } public function get_activities( $args, &$items_count = 0, $count = true, $check_access = false ) { if( ! $args ) return []; // If not checking access, use simple query if( ! $check_access ) { if( $count ) { $count_sql = $this->build_sql_select( $args, true ); $items_count = (int) WPF()->db->get_var( $count_sql ); } return array_map( [ $this, 'parse_activity' ], (array) WPF()->db->get_results( $this->build_sql_select( $args ), ARRAY_A ) ); } // With access control: fetch and filter, retry if empty (max 10 iterations) $row_count = isset( $args['row_count'] ) ? (int) $args['row_count'] : 20; $original_offset = isset( $args['offset'] ) ? (int) $args['offset'] : 0; $activities = []; $iteration = 0; $max_iterations = 10; $db_has_more = true; // Assume there's more until proven otherwise while( $iteration < $max_iterations ) { $args['offset'] = $original_offset + ( $iteration * $row_count ); $args['row_count'] = $row_count; $batch = array_map( [ $this, 'parse_activity' ], (array) WPF()->db->get_results( $this->build_sql_select( $args ), ARRAY_A ) ); // No more results from database if( empty( $batch ) ) { $db_has_more = false; break; } // Filter this batch $filtered = $this->access_filter( $batch ); $activities = array_merge( $activities, $filtered ); // We have enough results if( count( $activities ) >= $row_count ) { $activities = array_slice( $activities, 0, $row_count ); break; } $iteration++; } if( $count ) { // Set items_count to indicate if there might be more activities to load // If DB has more or we hit max iterations, assume there could be more if( $db_has_more || $iteration >= $max_iterations ) { // Set to a high number to trigger "has_more" in pagination $items_count = PHP_INT_MAX; } else { $items_count = count( $activities ); } } return $activities; } /** * Filter activities based on current user access permissions. * Removes activities that the current user doesn't have permission to see. * * @param array $activities List of activities * @return array Filtered activities */ private function access_filter( $activities ) { if( empty( $activities ) ) return []; $filtered = []; foreach( $activities as $activity ) { if( $this->activity_view_access( $activity ) ) { $filtered[] = $activity; } } return $filtered; } /** * Check if current user can view an activity. * * @param array $activity Activity data * @return bool True if user can view, false otherwise */ private function activity_view_access( $activity ) { if( empty( $activity['itemid'] ) ) return false; // Determine the post and topic based on activity type $post = null; $topic = null; if( $activity['type'] === 'wpforo_topic' || $activity['type'] === 'edit_topic' ) { // itemid is topicid $topic = wpforo_topic( $activity['itemid'] ); if( ! $topic ) return false; // Get first post for the topic $post = wpforo_post( wpfval( $topic, 'first_postid' ) ); } else { // itemid is postid for all other activity types $post = wpforo_post( $activity['itemid'] ); if( ! $post ) return false; $topic = wpforo_topic( $post['topicid'] ); } if( ! $post || ! $topic ) return false; $forumid = (int) wpfval( $post, 'forumid' ); if( ! $forumid ) return false; // Allow users to see their own posts and actions on their own posts if( WPF()->current_userid ) { // User's own post if( (int) wpfval( $post, 'userid' ) === WPF()->current_userid ) { return true; } // User's own topic if( (int) wpfval( $topic, 'userid' ) === WPF()->current_userid ) { return true; } } // Check forum access (vf - view forum) if( ! WPF()->perm->forum_can( 'vf', $forumid ) ) { return false; } // Check view topic access (vt) if( ! WPF()->perm->forum_can( 'vt', $forumid ) ) { return false; } // Check view reply access for non-first posts if( ! (int) wpfval( $post, 'is_first_post' ) && ! WPF()->perm->forum_can( 'vr', $forumid ) ) { return false; } // Check unapproved post access if( (int) wpfval( $post, 'status' ) ) { // Post is unapproved (status = 1) if( ! WPF()->perm->forum_can( 'au', $forumid ) ) { return false; } } // Check private topic access if( (int) wpfval( $topic, 'private' ) ) { if( ! WPF()->perm->forum_can( 'vp', $forumid ) ) { return false; } } return true; } public function get_activities_count( $args ) { if( ! $args ) return 0; return (int) WPF()->db->get_var( $this->build_sql_select( $args, true ) ); } public function after_edit_topic( $topic ) { $data = [ 'type' => 'edit_topic', 'itemid' => $topic['topicid'], 'itemtype' => 'topic', 'userid' => WPF()->current_userid, 'name' => WPF()->current_user_display_name, 'email' => WPF()->current_user_email, 'permalink' => wpforo_topic( $topic['topicid'], 'url' ), ]; $this->add( $data ); } public function after_edit_post( $post ) { $data = [ 'type' => 'edit_post', 'itemid' => $post['postid'], 'itemtype' => 'post', 'userid' => WPF()->current_userid, 'name' => WPF()->current_user_display_name, 'email' => WPF()->current_user_email, 'permalink' => wpforo_post( $post['postid'], 'url' ), ]; $this->add( $data ); } /** * Log new topic creation for Recent Activity page * * @param array $topic Topic data * @param array $forum Forum data */ public function log_new_topic( $topic, $forum = [] ) { // Don't log unapproved or private topics if( ! empty( $topic['status'] ) || ! empty( $topic['private'] ) ) return; $data = [ 'type' => 'wpforo_topic', 'itemid' => $topic['topicid'], 'itemtype' => 'activity', 'itemid_second'=> wpfval( $forum, 'forumid' ) ?: wpfval( $topic, 'forumid' ), 'userid' => wpfval( $topic, 'userid' ) ?: WPF()->current_userid, 'name' => wpfval( $topic, 'name' ) ?: WPF()->current_user_display_name, 'email' => wpfval( $topic, 'email' ) ?: WPF()->current_user_email, 'content' => wpfval( $topic, 'title' ), 'permalink' => wpfval( $topic, 'url' ) ?: WPF()->topic->get_url( $topic['topicid'] ), ]; $this->add( $data ); } /** * Log new post/reply creation for Recent Activity page * * @param array $post Post data * @param array $topic Topic data */ public function log_new_post( $post, $topic = [] ) { // Don't log unapproved posts or first posts (those are logged as topics) if( ! empty( $post['status'] ) ) return; if( ! empty( $post['is_first_post'] ) ) return; $data = [ 'type' => 'wpforo_post', 'itemid' => $post['postid'], 'itemtype' => 'activity', 'itemid_second'=> wpfval( $topic, 'topicid' ) ?: wpfval( $post, 'topicid' ), 'userid' => wpfval( $post, 'userid' ) ?: WPF()->current_userid, 'name' => wpfval( $post, 'name' ) ?: WPF()->current_user_display_name, 'email' => wpfval( $post, 'email' ) ?: WPF()->current_user_email, 'content' => wpfval( $topic, 'title' ) ?: wpfval( $post, 'title' ), 'permalink' => wpfval( $post, 'posturl' ) ?: WPF()->post->get_url( $post['postid'] ), ]; $this->add( $data ); } /** * Log activity when a previously unapproved topic is approved * This is called via wpforo_topic_approve hook (for first posts) * * @param array $topic Topic data */ public function log_approved_topic( $topic ) { if( empty( $topic['topicid'] ) ) return; // Don't log private topics if( ! empty( $topic['private'] ) ) return; // Check if activity already exists (avoid duplicates) $existing = $this->get_activity( [ 'type' => 'wpforo_topic', 'itemid' => $topic['topicid'], 'itemtype' => 'activity', ] ); if( wpfval($existing, 'id') ) return; $forum = WPF()->forum->get_forum( $topic['forumid'] ); $data = [ 'type' => 'wpforo_topic', 'itemid' => $topic['topicid'], 'itemtype' => 'activity', 'itemid_second'=> wpfval( $forum, 'forumid' ) ?: $topic['forumid'], 'userid' => $topic['userid'], 'name' => wpfval( $topic, 'name' ) ?: '', 'email' => wpfval( $topic, 'email' ) ?: '', 'content' => $topic['title'], 'permalink' => WPF()->topic->get_url( $topic['topicid'] ), ]; $this->add( $data ); } /** * Log activity when a previously unapproved post/reply is approved * This is called via wpforo_post_approve hook (for non-first posts only) * * @param array $post Post data */ public function log_approved_post( $post ) { if( empty( $post['postid'] ) || empty( $post['topicid'] ) ) return; // Skip first posts - they are handled by log_approved_topic via wpforo_topic_approve if( ! empty( $post['is_first_post'] ) ) return; // Check if activity already exists (avoid duplicates) $existing = $this->get_activity( [ 'type' => 'wpforo_post', 'itemid' => $post['postid'], 'itemtype' => 'activity', ] ); if( wpfval($existing, 'id') ) return; $topic = WPF()->topic->get_topic( $post['topicid'] ); $data = [ 'type' => 'wpforo_post', 'itemid' => $post['postid'], 'itemtype' => 'activity', 'itemid_second'=> $post['topicid'], 'userid' => $post['userid'], 'name' => wpfval( $post, 'name' ) ?: '', 'email' => wpfval( $post, 'email' ) ?: '', 'content' => wpfval( $topic, 'title' ) ?: '', 'permalink' => WPF()->post->get_url( $post['postid'] ), ]; $this->add( $data ); } /** * Delete activities when a topic is deleted * * @param array $topic Topic data */ public function after_delete_topic( $topic ) { if( empty( $topic['topicid'] ) ) return; // Delete the topic activity entry $this->delete( [ 'itemid' => $topic['topicid'], 'type' => 'wpforo_topic', ] ); // Also delete any activities that reference this topic via itemid_second // (e.g., reactions, favorites, solved markers on posts within this topic) WPF()->db->delete( WPF()->tables->activity, [ 'itemid_second' => $topic['topicid'] ], [ '%d' ] ); } /** * Delete activities when a post is deleted * * @param array $post Post data */ public function after_delete_post( $post ) { if( empty( $post['postid'] ) ) return; // Delete all activities related to this post (replies, reactions, etc.) $this->delete( [ 'itemid' => $post['postid'] ] ); } /** * Log activity when a topic is marked as solved * * @param array $topic Topic data * @param array $post Post data (the answer post) */ public function log_topic_solved( $topic, $post = [] ) { if( empty( $topic['topicid'] ) ) return; // Check for existing activity to prevent duplicates $existing = $this->get_activity( [ 'type' => 'topic_solved', 'itemid' => $topic['topicid'], 'itemtype' => 'activity', ] ); if( wpfval($existing, 'id') ) return; $data = [ 'type' => 'topic_solved', 'itemid' => $topic['topicid'], 'itemtype' => 'activity', 'itemid_second' => WPF()->current_userid, 'userid' => $topic['userid'], 'name' => WPF()->current_user_display_name, 'email' => WPF()->current_user_email, 'content' => $topic['title'], 'permalink' => WPF()->topic->get_url( $topic['topicid'] ), ]; $this->add( $data ); } /** * Log activity when a topic is closed * * @param array $topic Topic data */ public function log_topic_closed( $topic ) { if( empty( $topic['topicid'] ) ) return; // Check for existing activity to prevent duplicates $existing = $this->get_activity( [ 'type' => 'topic_closed', 'itemid' => $topic['topicid'], 'itemtype' => 'activity', ] ); if( wpfval($existing, 'id') ) return; $data = [ 'type' => 'topic_closed', 'itemid' => $topic['topicid'], 'itemtype' => 'activity', 'itemid_second' => WPF()->current_userid, 'userid' => $topic['userid'], 'name' => WPF()->current_user_display_name, 'email' => WPF()->current_user_email, 'content' => $topic['title'], 'permalink' => WPF()->topic->get_url( $topic['topicid'] ), ]; $this->add( $data ); } /** * Log activity when a post is marked as the answer (Q&A layout) * * @param int $status Answer status (1 = marked as answer, 0 = unmarked) * @param array $post Post data */ public function log_post_answer( $status, $post ) { // Only log when marking as answer, not when unmarking if( ! $status || empty( $post['postid'] ) ) return; $topic = WPF()->topic->get_topic( $post['topicid'] ); if( ! $topic ) return; // Check for existing activity to prevent duplicates $existing = $this->get_activity( [ 'type' => 'post_answer', 'itemid' => $post['postid'], 'itemtype' => 'activity', ] ); if( wpfval($existing, 'id') ) return; $data = [ 'type' => 'post_answer', 'itemid' => $post['postid'], 'itemtype' => 'activity', 'itemid_second' => WPF()->current_userid, 'userid' => $post['userid'], 'name' => WPF()->current_user_display_name, 'email' => WPF()->current_user_email, 'content' => $topic['title'], 'permalink' => WPF()->post->get_url( $post['postid'] ), ]; $this->add( $data ); } /** * Log activity when a post is bookmarked (favorited) * * @param array $bookmark Bookmark data with postid, userid, etc. */ public function log_new_favorite( $bookmark ) { if( empty( $bookmark['postid'] ) ) return; $post = WPF()->post->get_post( $bookmark['postid'] ); if( ! $post ) return; $topic = WPF()->topic->get_topic( $post['topicid'] ); if( ! $topic ) return; // Check for existing activity to prevent duplicates (same user favoriting same post) $existing = $this->get_activity( [ 'type' => 'new_favorite', 'itemid' => $bookmark['postid'], 'itemid_second' => $bookmark['userid'], 'itemtype' => 'activity', ] ); if( wpfval($existing, 'id') ) return; $member = wpforo_member( $bookmark['userid'] ); $data = [ 'type' => 'new_favorite', 'itemid' => $bookmark['postid'], 'itemtype' => 'activity', 'itemid_second' => $bookmark['userid'], 'userid' => $post['userid'], 'name' => wpfval( $member, 'display_name' ) ?: '', 'email' => wpfval( $member, 'user_email' ) ?: '', 'content' => $topic['title'], 'permalink' => WPF()->post->get_url( $bookmark['postid'] ), ]; $this->add( $data ); } public function after_add_post( $post, $topic ) { $this->add_notification_new_reply( 'new_reply', $post, $topic ); } private function add( $data ) { if( empty( $data ) ) return false; $activity = array_merge( $this->default->activity, $data ); unset( $activity['id'] ); if( ! $activity['type'] || ! $activity['itemid'] || ! $activity['itemtype'] ) return false; if( ! $activity['date'] ) $activity['date'] = time(); $activity = apply_filters( 'wpforo_add_activity_data_filter', $activity ); do_action( 'wpforo_before_add_activity', $activity ); $activity = wpforo_array_ordered_intersect_key( $activity, $this->default->activity_format ); if( WPF()->db->insert( WPF()->tables->activity, $activity, wpforo_array_ordered_intersect_key( $this->default->activity_format, $activity ) ) ) { $activity['id'] = WPF()->db->insert_id; do_action( 'wpforo_after_add_activity', $activity ); return $activity['id']; } return false; } private function edit( $data, $where ) { if( empty( $data ) || empty( $where ) ) return false; if( is_numeric( $where ) ) $where = [ 'id' => $where ]; $data = (array) $data; $where = (array) $where; $data = apply_filters( 'wpforo_activity_edit_data_filter', $data ); $where = apply_filters( 'wpforo_activity_edit_where_filter', $where ); do_action( 'wpforo_before_edit_activity', $data, $where ); $data = wpforo_array_ordered_intersect_key( $data, $this->default->activity_format ); $where = wpforo_array_ordered_intersect_key( $where, $this->default->activity_format ); if( false !== WPF()->db->update( WPF()->tables->activity, $data, $where, wpforo_array_ordered_intersect_key( $this->default->activity_format, $data ), wpforo_array_ordered_intersect_key( $this->default->activity_format, $where ) ) ) { do_action( 'wpforo_after_edit_activity', $data, $where ); return true; } return false; } private function delete( $where ): bool { if( empty( $where ) ) return false; if( wpforo_is_id( $where ) ) $where = [ 'id' => $where ]; $where = (array) $where; $where = apply_filters( 'wpforo_activity_delete_where_filter', $where ); do_action( 'wpforo_before_delete_activity', $where ); $where = wpforo_array_ordered_intersect_key( $where, $this->default->activity_format ); if( false !== WPF()->db->delete( WPF()->tables->activity, $where, wpforo_array_ordered_intersect_key( $this->default->activity_format, $where ) ) ) { do_action( 'wpforo_after_delete_activity', $where ); return true; } return false; } /** * Delete an activity by ID (public method with permission check) * * @param int $activity_id Activity ID to delete * @return bool True on success, false on failure */ public function delete_activity( $activity_id ): bool { $activity_id = wpforo_bigintval( $activity_id ); if( ! $activity_id ) return false; // Permission check - must have moderation permission if( ! WPF()->usergroup->can( 'aum' ) ) { return false; } return $this->delete( [ 'id' => $activity_id ] ); } public function build( $itemtype, $itemid, $type, $echo = false ): string { $rows = []; $args = [ 'itemtypes_include' => $itemtype, 'itemids_include' => $itemid, 'types_include' => $type, ]; if( $activities = $this->get_activities( $args ) ) { foreach( $activities as $activity ) { switch( $activity['type'] ) { case 'edit_topic': case 'edit_post': $rows[] = $this->_build_edit_topic_edit_post( $activity ); break; } } } $rows = $this->filter_built_html_rows( $rows ); $html = ( $rows ? implode( '', $rows ) : '' ); if( $echo ) echo $html; return $html; } private function _build_edit_topic_edit_post( $activity ) { $html = ''; $type = $activity['type']; $userid = $activity['userid']; $date = wpforo_date( $activity['date'], 'ago', false ) . '%s'; if( $userid ) { $profile_url = wpforo_member( $userid, 'profile_url' ); $display_name = wpforo_member( $userid, 'display_name' ); $user = sprintf( '%s', $profile_url, $display_name ); } else { $user = $activity['name'] ?: wpforo_phrase( 'Guest', false ); } if( wpfval( $this->actions, $type, 'before' ) ) { $html = $this->actions[ $type ]['before']; $html = apply_filters( 'wpforo_activity_action_html_before', $html, $activity ); } if( wpfval( $this->actions, $type, 'description' ) ) { $html .= sprintf( $this->actions[ $activity['type'] ]['description'], $date, str_replace( '%', '%%', $user ) ); $html = apply_filters( 'wpforo_activity_action_html', $html, $activity ); } if( wpfval( $this->actions, $type, 'after' ) ) { $html .= $this->actions[ $type ]['after']; $html = apply_filters( 'wpforo_activity_action_html_after', $html, $activity ); } return $html; } public function bell( $class = 'wpf-alerts' ) { wp_enqueue_script( 'wpforo-widgets-js' ); $class = ( ! $class ) ? 'wpf-alerts' : $class; $count = ( ! empty( $this->notifications ) ) ? count( $this->notifications ) : 0; $phrase = ( $count > 1 ) ? wpforo_phrase( 'You have new notifications', false ) : wpforo_phrase( 'You have a new notification', false ); $tooltip = ' wpf-tooltip="' . esc_attr( $phrase ) . '" wpf-tooltip-size="middle"'; ?>
    >
    notifications ) && is_array( $this->notifications ) ) { $list_html .= ''; } else { $list_html = $this->get_no_notifications_html(); } if( $echo ) echo $list_html; return $list_html; } public function get_no_notifications_html() { return '
    ' . wpforo_phrase( 'You have no new notifications', false ) . '
    '; } public function get_notifications() { $args = [ 'itemtype' => 'alert', 'userid' => WPF()->current_userid, 'new' => 1, 'row_count' => 100, 'orderby' => 'date', 'order' => 'DESC' ]; $args = apply_filters( 'wpforo_get_notifications_args', $args ); return $this->get_activities( $args ); } public function add_notification_new_reply( $type, $post, $topic = [] ): void { if( ! wpfval( $post, 'status' ) ) { $notification = [ 'type' => $type, 'itemid' => $post['postid'], 'itemtype' => 'alert', 'itemid_second' => $post['userid'], 'name' => $post['name'], 'email' => $post['email'], 'content' => $post['title'], 'permalink' => $post['posturl'], 'new' => 1, ]; // Notify replied person $replied_post = wpforo_post( $post['parentid'] ); if( ! empty( $replied_post ) && wpfval( $replied_post, 'userid' ) != wpfval( $post, 'userid' ) ) { $notification['userid'] = $replied_post['userid']; $notification = apply_filters( 'wpforo_add_notification_new_reply_data', $notification, $type, $post, $topic, $replied_post ); $this->add( $notification ); } // Notify the topic author if( ! empty( $topic ) && $topic['userid'] != $post['userid'] && ! ( ! empty( $replied_post ) && $topic['userid'] == $replied_post['userid'] ) ) { $notification['userid'] = $topic['userid']; $notification = apply_filters( 'wpforo_add_notification_new_reply_data', $notification, $type, $post, $topic, $replied_post ); $this->add( $notification ); } } } public function add_notification( $type, $args ): void { if( $args['userid'] != WPF()->current_userid ) { $length = apply_filters( 'wpforo_notification_saved_description_length', 50 ); $notification = [ 'type' => $type, 'itemid' => $args['itemid'], 'itemtype' => 'alert', 'itemid_second' => WPF()->current_userid, 'userid' => $args['userid'], 'name' => WPF()->current_user_display_name, 'email' => WPF()->current_user_email, 'content' => wpforo_text( $args['content'], $length, false ), 'permalink' => ( wpfval( $args, 'permalink' ) ? $args['permalink'] : '#' ), 'new' => 1, ]; $notification = apply_filters( 'wpforo_add_notification_data', $notification, $type, $args ); $this->add( $notification ); } } public function clear_all_reaction_notifications( $postid ) { if( $postid = wpforo_bigintval( $postid ) ) { $sql = "DELETE FROM `" . WPF()->tables->activity . "` WHERE `itemtype` = 'alert' AND `type` IN('new_like', 'new_dislike', 'new_reaction') AND `itemid` = %d AND `itemid_second` = %d"; $sql = WPF()->db->prepare( $sql, $postid, WPF()->current_userid ); WPF()->db->query( $sql ); } } public function after_react( $reaction, $post ): void { if( $post ) { $this->clear_all_reaction_notifications( wpfval( $reaction, 'postid' ) ); $args = [ 'itemid' => $post['postid'], 'userid' => $post['userid'], 'content' => $post['body'], 'permalink' => WPF()->post->get_url( $post['postid'] ), ]; switch( wpfval( $reaction, 'type' ) ) { case 'up': $ntype = 'new_like'; break; case 'down': $ntype = 'new_dislike'; break; default: $ntype = 'new_reaction'; break; } $this->add_notification( $ntype, $args ); } } public function after_unreact( $args ): void { if( $postid = wpforo_bigintval( wpfval( $args, 'postid' ) ) ) { $this->clear_all_reaction_notifications( $postid ); } } public function after_vote( $reaction, $post ) { if( $post ) { if( $reaction == 1 ) { $args = [ 'itemid' => $post['postid'], 'userid' => $post['userid'], 'content' => $post['body'], 'permalink' => WPF()->post->get_url( $post['postid'] ), ]; $this->add_notification( 'new_up_vote', $args ); $args = [ 'type' => 'new_down_vote', 'itemid' => $post['postid'], 'itemtype' => 'alert', 'itemid_second' => WPF()->current_userid, ]; $this->delete_notification( $args ); } elseif( $reaction == - 1 ) { $args = [ 'itemid' => $post['postid'], 'userid' => $post['userid'], 'content' => $post['body'], 'permalink' => WPF()->post->get_url( $post['postid'] ), ]; $this->add_notification( 'new_down_vote', $args ); $args = [ 'type' => 'new_up_vote', 'itemid' => $post['postid'], 'itemtype' => 'alert', 'itemid_second' => WPF()->current_userid, ]; $this->delete_notification( $args ); } } } public function delete_notification( $args ) { $this->delete( $args ); } public function update_notification( $post, $status ) { $post['status'] = $status = intval( $status ); $post['posturl'] = WPF()->post->get_url( $post['postid'] ); if( wpfval( $post, 'topicid' ) ) { $topic = WPF()->topic->get_topic( $post['topicid'] ); if( $status ) { $args = [ 'type' => 'new_reply', 'itemid' => $post['postid'], 'itemtype' => 'alert', ]; $this->delete_notification( $args ); } else { $this->add_notification_new_reply( 'new_reply', $post, $topic ); } } } public function read_notification( $id, $userid = null ) { $userid = is_null( $userid ) ? WPF()->current_userid : $userid; $where = [ 'id' => $id, 'userid' => $userid, ]; // Mark as read instead of deleting (preserves activity for Recent Activity page) $this->edit( [ 'new' => 0 ], $where ); } public function clear_notifications( $userid = null ) { $userid = is_null( $userid ) ? WPF()->current_userid : $userid; $where = [ 'userid' => $userid, 'itemtype' => 'alert', ]; // Mark all as read instead of deleting (preserves activity for Recent Activity page) $this->edit( [ 'new' => 0 ], $where ); } /** * Delete activities older than specified number of days * * @param int $days Number of days to retain activities * * @return int Number of deleted activities */ public function cleanup_old_activities( int $days ): int { if( $days <= 0 ) { return 0; } // The date field is stored as Unix timestamp (integer) $cutoff_timestamp = strtotime( "-{$days} days" ); $result = WPF()->db->query( WPF()->db->prepare( "DELETE FROM `" . WPF()->tables->activity . "` WHERE `date` < %d", $cutoff_timestamp ) ); return $result !== false ? (int) $result : 0; } /** * Cron callback for activity cleanup * Called daily by WordPress cron if scheduled */ public function cron_cleanup_old_activities(): void { $retention_days = (int) wpforo_setting( 'activity', 'retention_days' ); // If 0, auto-cleanup is disabled if( $retention_days <= 0 ) { return; } $deleted = $this->cleanup_old_activities( $retention_days ); if( $deleted > 0 ) { \wpforo_ai_log( 'info', "Cron cleanup: deleted {$deleted} activities older than {$retention_days} days", 'Activity' ); } } /** * Schedule activity cleanup cron if not already scheduled */ public function schedule_cleanup(): void { if( ! wp_next_scheduled( 'wpforo_activity_cleanup' ) ) { // Schedule to run at 6 AM server time (off-peak hours) wp_schedule_event( strtotime( 'tomorrow 6:00am' ), 'daily', 'wpforo_activity_cleanup' ); } } /** * Unschedule activity cleanup cron */ public function unschedule_cleanup(): void { $timestamp = wp_next_scheduled( 'wpforo_activity_cleanup' ); if( $timestamp ) { wp_unschedule_event( $timestamp, 'wpforo_activity_cleanup' ); } } /** * Check and schedule cleanup cron on admin init if needed * This ensures first-time setup when viewing activity settings */ public function maybe_schedule_cleanup(): void { // Only run on wpForo settings pages if( empty( $_GET['page'] ) || strpos( $_GET['page'], 'wpforo' ) === false ) { return; } $retention_days = (int) wpforo_setting( 'activity', 'retention_days' ); if( $retention_days > 0 ) { $this->schedule_cleanup(); } } }