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' => '',
],
'edit_post' => [
'title' => wpforo_phrase( 'Edit Post', false ),
'icon' => '',
'description' => wpforo_phrase( 'This post was modified %s by %s', false ),
'before' => '',
],
'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 .= '';
foreach( $this->notifications as $n ) {
if( $type = wpfval( $n, 'type' ) ) {
$html = wpfval( $this->actions, $type ) ? $this->actions[ $type ] : $this->actions['default'];
$items[ $n['id'] ] = $html['before'];
if( wpfval( $n, 'itemid_second' ) ) {
$member = wpforo_member( $n['itemid_second'] );
$member_name = wpfval( $member, 'display_name' ) ? $member['display_name'] : wpforo_phrase( 'Guest', false );
} else {
$member_name = wpfval( $n, 'name' ) ? $n['name'] : wpforo_phrase( 'Guest', false );
}
if( strpos( (string) $n['permalink'], '#' ) === false ) {
$n['permalink'] = wp_nonce_url( $n['permalink'] . '?_nread=' . $n['id'], 'wpforo_mark_notification_read', 'foro_n' );
} else {
$n['permalink'] = str_replace( '#', '?_nread=' . $n['id'] . '#', $n['permalink'] );
$n['permalink'] = wp_nonce_url( $n['permalink'], 'wpforo_mark_notification_read', 'foro_n' );
}
$date = wpforo_date( $n['date'], 'ago', false );
$length = apply_filters( 'wpforo_notification_description_length', 40 );
$items[ $n['id'] ] .= '' . $html['icon'] . '
';
$items[ $n['id'] ] .= '';
$items[ $n['id'] ] .= $html['after'];
}
}
$items = apply_filters( 'wpforo_notifications_list', $items );
$list_html .= implode( "\r\n", $items );
$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();
}
}
}