PluginProbe
wpForo Forum / 3.1.1
wpForo Forum v3.1.1
3.1.5 3.1.4 3.1.2 3.1.1 3.1.0 3.0.9 3.0.8 3.0.7 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.1.1 1.1.2 1.2.0 1.3.0 1.3.1 1.4.0 1.4.1 1.4.10 1.4.11 1.4.12 1.4.13 1.4.2 All 137 releases
wpforo / classes / Activity.php

Activity.php in wpForo Forum 3.1.1, at classes/Activity.php

1,402 lines 57.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace wpforo\classes;
4
5 use stdClass;
6
7 // Exit if accessed directly
8 if( ! defined( 'ABSPATH' ) ) exit;
9
10 class Activity {
11 private $default;
12 public $activity;
13 public $actions;
14 public $notifications = [];
15
16 public function __construct() {
17 add_action( 'wpforo_after_init', [ $this, 'init' ] );
18 }
19
20 public function init() {
21 $this->init_defaults();
22 $this->activity = $this->default->activity;
23 $this->init_hooks();
24 $this->init_actions();
25 if( is_user_logged_in() && wpforo_setting( 'notifications', 'notifications' ) ) {
26 $this->notifications = $this->get_notifications();
27 }
28 }
29
30 private function init_actions() {
31 $this->actions = [
32 'wpforo_topic' => [
33 'title' => wpforo_phrase( 'New Topic', false ),
34 'icon' => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g id="_01_align_center" data-name="01 align center"><path d="M21,0H3A3,3,0,0,0,0,3V20H6.9l3.808,3.218a2,2,0,0,0,2.582,0L17.1,20H24V3A3,3,0,0,0,21,0Zm1,18H16.366L12,21.69,7.634,18H2V3A1,1,0,0,1,3,2H21a1,1,0,0,1,1,1Z"></path><rect x="6" y="5" width="6" height="2"></rect><rect x="6" y="9" width="12" height="2"></rect><rect x="6" y="13" width="12" height="2"></rect></g></svg>',
35 'description' => wpforo_phrase( '%1$s created a new topic, %2$s', false ),
36 'before' => '<li class="wpf-wpforo_topic">',
37 'after' => '</li>',
38 ],
39 'wpforo_post' => [
40 'title' => wpforo_phrase( 'New Reply', false ),
41 'icon' => '<svg style="transform: rotate(180deg);" height="12" width="12" viewBox="0 0 512 512"><path fill="currentColor" d="M8.309 189.836L184.313 37.851C199.719 24.546 224 35.347 224 56.015v80.053c160.629 1.839 288 34.032 288 186.258c0 61.441-39.581 122.309-83.333 154.132c-13.653 9.931-33.111-2.533-28.077-18.631c45.344-145.012-21.507-183.51-176.59-185.742V360c0 20.7-24.3 31.453-39.687 18.164l-176.004-152c-11.071-9.562-11.086-26.753 0-36.328"></path></svg>',
42 'description' => wpforo_phrase( '%1$s posted a reply, %2$s', false ),
43 'before' => '<li class="wpf-wpforo_post">',
44 'after' => '</li>',
45 ],
46 'edit_topic' => [
47 'title' => wpforo_phrase( 'Edit Topic', false ),
48 'icon' => '<svg height="12" width="12" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M362.7 19.3L314.3 67.7 444.3 197.7l48.4-48.4c25-25 25-65.5 0-90.5L453.3 19.3c-25-25-65.5-25-90.5 0zm-71 71L58.6 323.5c-10.4 10.4-18 23.3-22.2 37.4L1 481.2C-1.5 489.7 .8 498.8 7 505s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L421.7 220.3 291.7 90.3z"/></svg>',
49 'description' => wpforo_phrase( 'This topic was modified %s by %s', false ),
50 'before' => '<div class="wpf-post-edited"><svg height="12" width="12" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M362.7 19.3L314.3 67.7 444.3 197.7l48.4-48.4c25-25 25-65.5 0-90.5L453.3 19.3c-25-25-65.5-25-90.5 0zm-71 71L58.6 323.5c-10.4 10.4-18 23.3-22.2 37.4L1 481.2C-1.5 489.7 .8 498.8 7 505s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L421.7 220.3 291.7 90.3z"/></svg>',
51 'after' => '</div>',
52 ],
53 'edit_post' => [
54 'title' => wpforo_phrase( 'Edit Post', false ),
55 'icon' => '<svg height="12" width="12" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M362.7 19.3L314.3 67.7 444.3 197.7l48.4-48.4c25-25 25-65.5 0-90.5L453.3 19.3c-25-25-65.5-25-90.5 0zm-71 71L58.6 323.5c-10.4 10.4-18 23.3-22.2 37.4L1 481.2C-1.5 489.7 .8 498.8 7 505s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L421.7 220.3 291.7 90.3z"/></svg>',
56 'description' => wpforo_phrase( 'This post was modified %s by %s', false ),
57 'before' => '<div class="wpf-post-edited"><svg height="12" width="12" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M362.7 19.3L314.3 67.7 444.3 197.7l48.4-48.4c25-25 25-65.5 0-90.5L453.3 19.3c-25-25-65.5-25-90.5 0zm-71 71L58.6 323.5c-10.4 10.4-18 23.3-22.2 37.4L1 481.2C-1.5 489.7 .8 498.8 7 505s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L421.7 220.3 291.7 90.3z"/></svg>',
58 'after' => '</div>',
59 ],
60 'new_reply' => [
61 'title' => wpforo_phrase( 'New Reply', false ),
62 'icon' => '<svg style="transform: rotate(180deg); vertical-align: bottom;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M23,24a1,1,0,0,1-1-1,6.006,6.006,0,0,0-6-6H10.17v1.586A2,2,0,0,1,6.756,20L.877,14.121a3,3,0,0,1,0-4.242L6.756,4A2,2,0,0,1,10.17,5.414V7H15a9.01,9.01,0,0,1,9,9v7A1,1,0,0,1,23,24ZM8.17,5.414,2.291,11.293a1,1,0,0,0,0,1.414L8.17,18.586V16a1,1,0,0,1,1-1H16a7.984,7.984,0,0,1,6,2.714V16a7.008,7.008,0,0,0-7-7H9.17a1,1,0,0,1-1-1Z"></path></svg>',
63 'description' => wpforo_phrase( 'New reply from %1$s, %2$s', false ),
64 'before' => '<li class="wpf-new_reply">',
65 'after' => '</li>',
66 ],
67 'new_like' => [
68 'title' => wpforo_phrase( 'New Like', false ),
69 'icon' => '<svg xmlns="http://www.w3.org/2000/svg" style="width: 17px; height: 17px;" height="12" width="12" viewBox="0 0 24 24"><g id="_01_align_center" data-name="01 align center"><path d="M15.021,7l.336-2.041a3.044,3.044,0,0,0-4.208-3.287A3.139,3.139,0,0,0,9.582,3.225L7.717,7H3a3,3,0,0,0-3,3v9a3,3,0,0,0,3,3H22.018L24,10.963,24.016,7ZM2,19V10A1,1,0,0,1,3,9H7V20H3A1,1,0,0,1,2,19Zm20-8.3L20.33,20H9V8.909l2.419-4.9A1.07,1.07,0,0,1,13.141,3.8a1.024,1.024,0,0,1,.233.84L12.655,9H22Z"></path></g></svg>',
70 'description' => wpforo_phrase( 'New like from %1$s, %2$s', false ),
71 'before' => '<li class="wpf-new_like">',
72 'after' => '</li>',
73 ],
74 'new_dislike' => [
75 'title' => wpforo_phrase( 'New Dislike', false ),
76 'icon' => '<svg xmlns="http://www.w3.org/2000/svg" style="width: 17px; height: 17px; transform: rotate(180deg);" viewBox="0 0 24 24"><g id="_01_align_center" data-name="01 align center"><path d="M15.021,7l.336-2.041a3.044,3.044,0,0,0-4.208-3.287A3.139,3.139,0,0,0,9.582,3.225L7.717,7H3a3,3,0,0,0-3,3v9a3,3,0,0,0,3,3H22.018L24,10.963,24.016,7ZM2,19V10A1,1,0,0,1,3,9H7V20H3A1,1,0,0,1,2,19Zm20-8.3L20.33,20H9V8.909l2.419-4.9A1.07,1.07,0,0,1,13.141,3.8a1.024,1.024,0,0,1,.233.84L12.655,9H22Z"></path></g></svg>',
77 'description' => wpforo_phrase( 'New dislike from %1$s, %2$s', false ),
78 'before' => '<li class="wpf-new_dislike">',
79 'after' => '</li>',
80 ],
81 'new_up_vote' => [
82 'title' => wpforo_phrase( 'New Up Vote', false ),
83 'icon' => '<svg width="12px" height="12px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" style="width: 32px; height: 32px;"> <g id="_24x24_On_Light_Arrow-Top" data-name="24x24/On Light/Arrow-Top" transform="translate(24) rotate(90)"> <rect id="view-box" width="24" height="24" fill="none"/> <path id="Shape" d="M.22,10.22A.75.75,0,0,0,1.28,11.28l5-5a.75.75,0,0,0,0-1.061l-5-5A.75.75,0,0,0,.22,1.28l4.47,4.47Z" transform="translate(14.75 17.75) rotate(180)"/> </g> </svg>',
84 'description' => wpforo_phrase( 'New up vote from %1$s, %2$s', false ),
85 'before' => '<li class="wpf-new_up_vote">',
86 'after' => '</li>',
87 ],
88 'new_down_vote' => [
89 'title' => wpforo_phrase( 'New Down Vote', false ),
90 'icon' => '<svg width="12px" height="12px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" style="width: 32px; height: 32px;" transform="matrix(-1, 0, 0, -1, 0, 0)"> <g id="SVGRepo_bgCarrier" stroke-width="0"/> <g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/> <g id="SVGRepo_iconCarrier"> <g id="_24x24_On_Light_Arrow-Top" data-name="24x24/On Light/Arrow-Top" transform="translate(24) rotate(90)"> <rect id="view-box" width="24" height="24" fill="none"/> <path id="Shape" d="M.22,10.22A.75.75,0,0,0,1.28,11.28l5-5a.75.75,0,0,0,0-1.061l-5-5A.75.75,0,0,0,.22,1.28l4.47,4.47Z" transform="translate(14.75 17.75) rotate(180)"/> </g> </g> </svg>',
91 'description' => wpforo_phrase( 'New down vote from %1$s, %2$s', false ),
92 'before' => '<li class="wpf-new_down_vote">',
93 'after' => '</li>',
94 ],
95 'new_reaction' => [
96 'title' => wpforo_phrase( 'New Reaction', false ),
97 'icon' => '<svg height="12" width="12" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M349.4 44.6c5.9-13.7 1.5-29.7-10.6-38.5s-28.6-8-39.9 1.8l-256 224c-10 8.8-13.6 22.9-8.9 35.3S50.7 288 64 288H175.5L98.6 467.4c-5.9 13.7-1.5 29.7 10.6 38.5s28.6 8 39.9-1.8l256-224c10-8.8 13.6-22.9 8.9-35.3s-16.6-20.7-30-20.7H272.5L349.4 44.6z"/></svg>',
98 'description' => wpforo_phrase( 'New Reaction from %1$s, %2$s', false ),
99 'before' => '<li class="wpf-new_reaction">',
100 'after' => '</li>',
101 ],
102 'new_mention' => [
103 'title' => wpforo_phrase( 'New User Mentioning', false ),
104 'icon' => '<svg height="12" width="12" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M256 8C118.9 8 8 118.9 8 256c0 137.1 110.9 248 248 248 48.2 0 95.3-14.1 135.4-40.2 12-7.8 14.6-24.3 5.6-35.4l-10.2-12.4c-7.7-9.4-21.2-11.7-31.4-5.1C325.9 429.8 291.3 440 256 440c-101.5 0-184-82.5-184-184S154.5 72 256 72c100.1 0 184 57.6 184 160 0 38.8-21.1 79.7-58.2 83.7-17.3-.5-16.9-12.9-13.5-30l23.4-121.1C394.7 149.8 383.3 136 368.2 136h-45a13.5 13.5 0 0 0 -13.4 12l0 .1c-14.7-17.9-40.4-21.8-60-21.8-74.6 0-137.8 62.2-137.8 151.5 0 65.3 36.8 105.9 96 105.9 27 0 57.4-15.6 75-38.3 9.5 34.1 40.6 34.1 70.7 34.1C462.6 379.4 504 307.8 504 232 504 95.7 394 8 256 8zm-21.7 304.4c-22.2 0-36.1-15.6-36.1-40.8 0-45 30.8-72.7 58.6-72.7 22.3 0 35.6 15.2 35.6 40.8 0 45.1-33.9 72.7-58.2 72.7z"/></svg>',
105 'description' => wpforo_phrase( '%1$s has mentioned you, %2$s', false ),
106 'before' => '<li class="wpf-new_mention">',
107 'after' => '</li>',
108 ],
109 'new_favorite' => [
110 'title' => wpforo_phrase( 'New Favorite', false ),
111 'icon' => '<svg width="12px" height="12px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="width: 19px; height: 19px; margin-top: 1px;"> <path d="M5 6.2C5 5.07989 5 4.51984 5.21799 4.09202C5.40973 3.71569 5.71569 3.40973 6.09202 3.21799C6.51984 3 7.07989 3 8.2 3H15.8C16.9201 3 17.4802 3 17.908 3.21799C18.2843 3.40973 18.5903 3.71569 18.782 4.09202C19 4.51984 19 5.07989 19 6.2V21L12 16L5 21V6.2Z" stroke-width="2" stroke-linejoin="round"/> </svg>',
112 'description' => wpforo_phrase( '%1$s favorited, %2$s', false ),
113 'before' => '<li class="wpf-new_favorite">',
114 'after' => '</li>',
115 ],
116 'topic_solved' => [
117 'title' => wpforo_phrase( 'Topic Solved', false ),
118 'icon' => '<svg width="12px" height="12px" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 415.582 415.582"> <g> <path d="M411.47,96.426l-46.319-46.32c-5.482-5.482-14.371-5.482-19.853,0L152.348,243.058l-82.066-82.064 c-5.48-5.482-14.37-5.482-19.851,0l-46.319,46.32c-5.482,5.481-5.482,14.37,0,19.852l138.311,138.31 c2.741,2.742,6.334,4.112,9.926,4.112c3.593,0,7.186-1.37,9.926-4.112L411.47,116.277c2.633-2.632,4.111-6.203,4.111-9.925 C415.582,102.628,414.103,99.059,411.47,96.426z"/> </g> </svg>',
119 'description' => wpforo_phrase( '%1$s marked as solved, %2$s', false ),
120 'before' => '<li class="wpf-topic_solved">',
121 'after' => '</li>',
122 ],
123 'topic_closed' => [
124 'title' => wpforo_phrase( 'Topic Closed', false ),
125 'icon' => '<svg height="12" width="12" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M144 144v48H304V144c0-44.2-35.8-80-80-80s-80 35.8-80 80zM80 192V144C80 64.5 144.5 0 224 0s144 64.5 144 144v48h16c35.3 0 64 28.7 64 64V448c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V256c0-35.3 28.7-64 64-64H80z"/></svg>',
126 'description' => wpforo_phrase( '%1$s closed the topic, %2$s', false ),
127 'before' => '<li class="wpf-topic_closed">',
128 'after' => '</li>',
129 ],
130 'post_answer' => [
131 'title' => wpforo_phrase( 'Marked as Answer', false ),
132 'icon' => '<svg height="12" width="12" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" style="width: 21px; height: 21px; margin-top: 2px;"><path d="M12.0867962,18 L6,21.8042476 L6,18 L4,18 C2.8954305,18 2,17.1045695 2,16 L2,4 C2,2.8954305 2.8954305,2 4,2 L20,2 C21.1045695,2 22,2.8954305 22,4 L22,16 C22,17.1045695 21.1045695,18 20,18 L12.0867962,18 Z M8,18.1957524 L11.5132038,16 L20,16 L20,4 L4,4 L4,16 L8,16 L8,18.1957524 Z M11,10.5857864 L15.2928932,6.29289322 L16.7071068,7.70710678 L11,13.4142136 L7.29289322,9.70710678 L8.70710678,8.29289322 L11,10.5857864 Z" fill-rule="evenodd"></path></svg>',
133 'description' => wpforo_phrase( '%1$s marked as answer, %2$s', false ),
134 'before' => '<li class="wpf-post_answer">',
135 'after' => '</li>',
136 ],
137 'default' => [
138 'title' => wpforo_phrase( 'New Notification', false ),
139 'icon' => '<svg height="24" width="24" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M224 0c-17.7 0-32 14.3-32 32V51.2C119 66 64 130.6 64 208v18.8c0 47-17.3 92.4-48.5 127.6l-7.4 8.3c-8.4 9.4-10.4 22.9-5.3 34.4S19.4 416 32 416H416c12.6 0 24-7.4 29.2-18.9s3.1-25-5.3-34.4l-7.4-8.3C401.3 319.2 384 273.9 384 226.8V208c0-77.4-55-142-128-156.8V32c0-17.7-14.3-32-32-32zm45.3 493.3c12-12 18.7-28.3 18.7-45.3H224 160c0 17 6.7 33.3 18.7 45.3s28.3 18.7 45.3 18.7s33.3-6.7 45.3-18.7z"/></svg>',
140 'description' => wpforo_phrase( 'New notification from %1$s, %2$s', false ),
141 'before' => '<li class="wpf-new_note">',
142 'after' => '</li>',
143 ],
144 ];
145
146 $this->actions = apply_filters( 'wpforo_register_actions', $this->actions );
147 }
148
149 private function init_defaults() {
150 $this->default = new stdClass();
151 $this->default->activity = [
152 'id' => 0,
153 'type' => '',
154 'itemid' => 0,
155 'itemtype' => '',
156 'itemid_second' => 0,
157 'userid' => 0,
158 'name' => '',
159 'email' => '',
160 'date' => 0,
161 'content' => '',
162 'permalink' => '',
163 'new' => 0,
164 ];
165 $this->default->activity_format = [
166 'id' => '%d',
167 'type' => '%s',
168 'itemid' => '%d',
169 'itemtype' => '%s',
170 'itemid_second' => '%d',
171 'userid' => '%d',
172 'name' => '%s',
173 'email' => '%s',
174 'date' => '%d',
175 'content' => '%s',
176 'permalink' => '%s',
177 'new' => '%d',
178 ];
179 $this->default->sql_select_args = [
180 'type' => null,
181 'itemid' => null,
182 'itemtype' => null,
183 'itemid_second' => null,
184 'userid' => null,
185 'new' => null,
186 'include' => [],
187 'exclude' => [],
188 'userids_include' => [],
189 'userids_exclude' => [],
190 'types_include' => [],
191 'types_exclude' => [],
192 'itemids_include' => [],
193 'itemids_exclude' => [],
194 'itemtypes_include' => [],
195 'itemtypes_exclude' => [],
196 'emails_include' => [],
197 'emails_exclude' => [],
198 'date_from' => null,
199 'orderby' => 'id',
200 'order' => 'ASC',
201 'offset' => null,
202 'row_count' => null,
203 ];
204
205 $this->default = apply_filters( 'wpforo_activity_after_init_defaults', $this->default );
206 }
207
208 private function init_hooks() {
209 if( wpforo_setting( 'posting', 'edit_topic' ) ) {
210 add_action( 'wpforo_after_edit_topic', [ $this, 'after_edit_topic' ] );
211 }
212 if( wpforo_setting( 'posting', 'edit_post' ) ) {
213 add_action( 'wpforo_after_edit_post', [ $this, 'after_edit_post' ] );
214 }
215
216 // Register activity cleanup cron hook
217 add_action( 'wpforo_activity_cleanup', [ $this, 'cron_cleanup_old_activities' ] );
218
219 // Schedule activity cleanup cron on admin init if retention_days > 0 and not already scheduled
220 if( is_admin() ) {
221 add_action( 'admin_init', [ $this, 'maybe_schedule_cleanup' ] );
222 }
223
224 // Activity log entries for new topics and posts (always enabled for Recent Activity page)
225 add_action( 'wpforo_after_add_topic', [ $this, 'log_new_topic' ], 10, 2 );
226 add_action( 'wpforo_after_add_post', [ $this, 'log_new_post' ], 10, 2 );
227
228 // Log activity when previously unapproved content is approved
229 // wpforo_topic_approve fires for first posts (topics)
230 // wpforo_post_approve fires for non-first posts (replies)
231 add_action( 'wpforo_topic_approve', [ $this, 'log_approved_topic' ] );
232 add_action( 'wpforo_post_approve', [ $this, 'log_approved_post' ] );
233
234 // Clean up activities when topics/posts are deleted
235 add_action( 'wpforo_after_delete_topic', [ $this, 'after_delete_topic' ] );
236 add_action( 'wpforo_after_delete_post', [ $this, 'after_delete_post' ] );
237
238 // Log activity when topic is marked as solved or closed
239 add_action( 'wpforo_topic_solved', [ $this, 'log_topic_solved' ], 10, 2 );
240 add_action( 'wpforo_topic_closed', [ $this, 'log_topic_closed' ] );
241
242 // Log activity when a post is marked as the answer (Q&A layout)
243 add_action( 'wpforo_answer', [ $this, 'log_post_answer' ], 10, 2 );
244
245 // Log activity when a post is bookmarked (favorited)
246 add_action( 'wpforo_after_add_bookmark', [ $this, 'log_new_favorite' ] );
247
248 // Activity logging hooks - always enabled for What's New page
249 // These create activity entries regardless of notification settings
250 add_action( 'wpforo_vote', [ $this, 'after_vote' ], 10, 2 );
251 add_action( 'wpforo_react_post', [ &$this, 'after_react' ], 10, 2 );
252 add_action( 'wpforo_unreact_post', [ &$this, 'after_unreact' ] );
253
254 // Notification UI features - only when notifications enabled
255 if( WPF()->current_userid && wpforo_setting( 'notifications', 'notifications' ) ) {
256 if( wpforo_setting( 'notifications', 'notifications_bar' ) ) {
257 add_action( 'wpforo_before_search_toggle', [ $this, 'bell' ] );
258 }
259 add_action( 'wpforo_after_add_post', [ $this, 'after_add_post' ], 10, 2 );
260 add_action( 'wpforo_post_status_update', [ $this, 'update_notification' ], 10, 2 );
261 }
262 }
263
264 private function filter_built_html_rows( $rows ) {
265 $_rows = [];
266 foreach( $rows as $row_key => $row ) {
267 $in_array = false;
268 if( $_rows ) {
269 foreach( $_rows as $_row_key => $_row ) {
270 if( in_array( $row, $_row ) ) {
271 $in_array = true;
272 $match_key = $_row_key;
273 break;
274 }
275 }
276 }
277 if( $in_array && isset( $match_key ) ) {
278 $_rows[ $match_key ]['times'] ++;
279 } else {
280 $_rows[ $row_key ]['html'] = $row;
281 $_rows[ $row_key ]['times'] = 1;
282 }
283 }
284
285 $rows = [];
286 foreach( $_rows as $_row ) {
287 $times = '';
288 if( $_row['times'] > 1 ) {
289 $times = ' ' . sprintf(
290 wpforo_phrase( '%d times', false ),
291 $_row['times']
292 );
293 }
294
295 $rows[] = sprintf( $_row['html'], $times );
296 }
297
298 $limit = wpforo_setting( 'posting', 'edit_log_display_limit' );
299 if( $limit ) $rows = array_slice( $rows, ( - 1 * $limit ), $limit );
300
301 return $rows;
302 }
303
304 private function parse_activity( $data ) {
305 // Ensure defaults are initialized
306 if( ! isset( $this->default->activity ) ) {
307 $this->init_defaults();
308 }
309 return apply_filters( 'wpforo_activty_parse_activity', array_merge( $this->default->activity, $data ) );
310 }
311
312 private function parse_args( $args ) {
313 // Ensure defaults are initialized
314 if( ! isset( $this->default->sql_select_args ) ) {
315 $this->init_defaults();
316 }
317 $args = wpforo_parse_args( $args, $this->default->sql_select_args );
318
319 $args['include'] = wpforo_parse_args( $args['include'] );
320 $args['exclude'] = wpforo_parse_args( $args['exclude'] );
321
322 $args['userids_include'] = wpforo_parse_args( $args['userids_include'] );
323 $args['userids_exclude'] = wpforo_parse_args( $args['userids_exclude'] );
324
325 $args['types_include'] = wpforo_parse_args( $args['types_include'] );
326 $args['types_exclude'] = wpforo_parse_args( $args['types_exclude'] );
327
328 $args['itemids_include'] = wpforo_parse_args( $args['itemids_include'] );
329 $args['itemids_exclude'] = wpforo_parse_args( $args['itemids_exclude'] );
330
331 $args['itemtypes_include'] = wpforo_parse_args( $args['itemtypes_include'] );
332 $args['itemtypes_exclude'] = wpforo_parse_args( $args['itemtypes_exclude'] );
333
334 $args['emails_include'] = wpforo_parse_args( $args['emails_include'] );
335 $args['emails_exclude'] = wpforo_parse_args( $args['emails_exclude'] );
336
337 return $args;
338 }
339
340 private function build_sql_select( $args, $count_only = false ) {
341 $args = $this->parse_args( $args );
342
343 $wheres = [];
344
345 if( ! is_null( $args['type'] ) ) $wheres[] = "`type` = '" . esc_sql( $args['type'] ) . "'";
346 if( ! is_null( $args['itemid'] ) ) $wheres[] = "`itemid` = " . wpforo_bigintval( $args['itemid'] );
347 if( ! is_null( $args['itemtype'] ) ) $wheres[] = "`itemtype` = '" . esc_sql( $args['itemtype'] ) . "'";
348 if( ! is_null( $args['itemid_second'] ) ) $wheres[] = "`itemid_second` = " . wpforo_bigintval( $args['itemid_second'] );
349 if( ! is_null( $args['userid'] ) ) $wheres[] = "`userid` = " . intval( $args['userid'] );
350 if( ! is_null( $args['new'] ) ) $wheres[] = "`new` = " . intval( $args['new'] );
351
352 if( ! empty( $args['include'] ) ) $wheres[] = "`id` IN(" . implode( ',', array_map( 'wpforo_bigintval', $args['include'] ) ) . ")";
353 if( ! empty( $args['exclude'] ) ) $wheres[] = "`id` NOT IN(" . implode( ',', array_map( 'wpforo_bigintval', $args['exclude'] ) ) . ")";
354
355 if( ! empty( $args['userids_include'] ) ) $wheres[] = "`userid` IN(" . implode( ',', array_map( 'wpforo_bigintval', $args['userids_include'] ) ) . ")";
356 if( ! empty( $args['userids_exclude'] ) ) $wheres[] = "`userid` NOT IN(" . implode( ',', array_map( 'wpforo_bigintval', $args['userids_exclude'] ) ) . ")";
357
358 if( ! empty( $args['types_include'] ) ) $wheres[] = "`type` IN('" . implode( "','", array_map( 'esc_sql', $args['types_include'] ) ) . "')";
359 if( ! empty( $args['types_exclude'] ) ) $wheres[] = "`type` NOT IN('" . implode( "','", array_map( 'esc_sql', $args['types_exclude'] ) ) . "')";
360
361 if( ! empty( $args['itemids_include'] ) ) $wheres[] = "`itemid` IN(" . implode( ',', array_map( 'wpforo_bigintval', $args['itemids_include'] ) ) . ")";
362 if( ! empty( $args['itemids_exclude'] ) ) $wheres[] = "`itemid` NOT IN(" . implode( ',', array_map( 'wpforo_bigintval', $args['itemids_exclude'] ) ) . ")";
363
364 if( ! empty( $args['itemtypes_include'] ) ) $wheres[] = "`itemtype` IN('" . implode( "','", array_map( 'esc_sql', $args['itemtypes_include'] ) ) . "')";
365 if( ! empty( $args['itemtypes_exclude'] ) ) $wheres[] = "`itemtype` NOT IN('" . implode( "','", array_map( 'esc_sql', $args['itemtypes_exclude'] ) ) . "')";
366
367 if( ! empty( $args['emails_include'] ) ) $wheres[] = "`email` IN('" . implode( "','", array_map( 'esc_sql', $args['emails_include'] ) ) . "')";
368 if( ! empty( $args['emails_exclude'] ) ) $wheres[] = "`email` NOT IN('" . implode( "','", array_map( 'esc_sql', $args['emails_exclude'] ) ) . "')";
369
370 // Date filtering for time period
371 if( ! empty( $args['date_from'] ) ) $wheres[] = "`date` >= " . intval( $args['date_from'] );
372
373 if( $count_only ) {
374 $sql = "SELECT COUNT(*) FROM " . WPF()->tables->activity;
375 if( $wheres ) $sql .= " WHERE " . implode( " AND ", $wheres );
376 return $sql;
377 }
378
379 $sql = "SELECT * FROM " . WPF()->tables->activity;
380 if( $wheres ) $sql .= " WHERE " . implode( " AND ", $wheres );
381 $sql .= " ORDER BY " . esc_sql( $args['orderby'] ) . " " . esc_sql( $args['order'] );
382 if( $args['row_count'] ) {
383 if( ! empty( $args['offset'] ) ) {
384 $sql .= " LIMIT " . wpforo_bigintval( $args['offset'] ) . "," . wpforo_bigintval( $args['row_count'] );
385 } else {
386 $sql .= " LIMIT " . wpforo_bigintval( $args['row_count'] );
387 }
388 }
389
390 return $sql;
391 }
392
393 public function get_activity( $args ) {
394 if( ! $args ) return false;
395
396 return $this->parse_activity( (array) WPF()->db->get_row( $this->build_sql_select( $args ), ARRAY_A ) );
397 }
398
399 public function get_activities( $args, &$items_count = 0, $count = true, $check_access = false ) {
400 if( ! $args ) return [];
401
402 // If not checking access, use simple query
403 if( ! $check_access ) {
404 if( $count ) {
405 $count_sql = $this->build_sql_select( $args, true );
406 $items_count = (int) WPF()->db->get_var( $count_sql );
407 }
408 return array_map( [ $this, 'parse_activity' ], (array) WPF()->db->get_results( $this->build_sql_select( $args ), ARRAY_A ) );
409 }
410
411 // With access control: fetch and filter, retry if empty (max 10 iterations)
412 $row_count = isset( $args['row_count'] ) ? (int) $args['row_count'] : 20;
413 $original_offset = isset( $args['offset'] ) ? (int) $args['offset'] : 0;
414 $activities = [];
415 $iteration = 0;
416 $max_iterations = 10;
417 $db_has_more = true; // Assume there's more until proven otherwise
418
419 while( $iteration < $max_iterations ) {
420 $args['offset'] = $original_offset + ( $iteration * $row_count );
421 $args['row_count'] = $row_count;
422
423 $batch = array_map( [ $this, 'parse_activity' ], (array) WPF()->db->get_results( $this->build_sql_select( $args ), ARRAY_A ) );
424
425 // No more results from database
426 if( empty( $batch ) ) {
427 $db_has_more = false;
428 break;
429 }
430
431 // Filter this batch
432 $filtered = $this->access_filter( $batch );
433 $activities = array_merge( $activities, $filtered );
434
435 // We have enough results
436 if( count( $activities ) >= $row_count ) {
437 $activities = array_slice( $activities, 0, $row_count );
438 break;
439 }
440
441 $iteration++;
442 }
443
444 if( $count ) {
445 // Set items_count to indicate if there might be more activities to load
446 // If DB has more or we hit max iterations, assume there could be more
447 if( $db_has_more || $iteration >= $max_iterations ) {
448 // Set to a high number to trigger "has_more" in pagination
449 $items_count = PHP_INT_MAX;
450 } else {
451 $items_count = count( $activities );
452 }
453 }
454
455 return $activities;
456 }
457
458 /**
459 * Filter activities based on current user access permissions.
460 * Removes activities that the current user doesn't have permission to see.
461 *
462 * @param array $activities List of activities
463 * @return array Filtered activities
464 */
465 private function access_filter( $activities ) {
466 if( empty( $activities ) ) return [];
467
468 $filtered = [];
469 foreach( $activities as $activity ) {
470 if( $this->activity_view_access( $activity ) ) {
471 $filtered[] = $activity;
472 }
473 }
474
475 return $filtered;
476 }
477
478 /**
479 * Check if current user can view an activity.
480 *
481 * @param array $activity Activity data
482 * @return bool True if user can view, false otherwise
483 */
484 private function activity_view_access( $activity ) {
485 if( empty( $activity['itemid'] ) ) return false;
486
487 // Determine the post and topic based on activity type
488 $post = null;
489 $topic = null;
490
491 if( $activity['type'] === 'wpforo_topic' || $activity['type'] === 'edit_topic' ) {
492 // itemid is topicid
493 $topic = wpforo_topic( $activity['itemid'] );
494 if( ! $topic ) return false;
495 // Get first post for the topic
496 $post = wpforo_post( wpfval( $topic, 'first_postid' ) );
497 } else {
498 // itemid is postid for all other activity types
499 $post = wpforo_post( $activity['itemid'] );
500 if( ! $post ) return false;
501 $topic = wpforo_topic( $post['topicid'] );
502 }
503
504 if( ! $post || ! $topic ) return false;
505
506 $forumid = (int) wpfval( $post, 'forumid' );
507 if( ! $forumid ) return false;
508
509 // Allow users to see their own posts and actions on their own posts
510 if( WPF()->current_userid ) {
511 // User's own post
512 if( (int) wpfval( $post, 'userid' ) === WPF()->current_userid ) {
513 return true;
514 }
515 // User's own topic
516 if( (int) wpfval( $topic, 'userid' ) === WPF()->current_userid ) {
517 return true;
518 }
519 }
520
521 // Check forum access (vf - view forum)
522 if( ! WPF()->perm->forum_can( 'vf', $forumid ) ) {
523 return false;
524 }
525
526 // Check view topic access (vt)
527 if( ! WPF()->perm->forum_can( 'vt', $forumid ) ) {
528 return false;
529 }
530
531 // Check view reply access for non-first posts
532 if( ! (int) wpfval( $post, 'is_first_post' ) && ! WPF()->perm->forum_can( 'vr', $forumid ) ) {
533 return false;
534 }
535
536 // Check unapproved post access
537 if( (int) wpfval( $post, 'status' ) ) {
538 // Post is unapproved (status = 1)
539 if( ! WPF()->perm->forum_can( 'au', $forumid ) ) {
540 return false;
541 }
542 }
543
544 // Check private topic access
545 if( (int) wpfval( $topic, 'private' ) ) {
546 if( ! WPF()->perm->forum_can( 'vp', $forumid ) ) {
547 return false;
548 }
549 }
550
551 return true;
552 }
553
554 public function get_activities_count( $args ) {
555 if( ! $args ) return 0;
556
557 return (int) WPF()->db->get_var( $this->build_sql_select( $args, true ) );
558 }
559
560 public function after_edit_topic( $topic ) {
561 $data = [
562 'type' => 'edit_topic',
563 'itemid' => $topic['topicid'],
564 'itemtype' => 'topic',
565 'userid' => WPF()->current_userid,
566 'name' => WPF()->current_user_display_name,
567 'email' => WPF()->current_user_email,
568 'permalink' => wpforo_topic( $topic['topicid'], 'url' ),
569 ];
570
571 $this->add( $data );
572 }
573
574 public function after_edit_post( $post ) {
575 $data = [
576 'type' => 'edit_post',
577 'itemid' => $post['postid'],
578 'itemtype' => 'post',
579 'userid' => WPF()->current_userid,
580 'name' => WPF()->current_user_display_name,
581 'email' => WPF()->current_user_email,
582 'permalink' => wpforo_post( $post['postid'], 'url' ),
583 ];
584
585 $this->add( $data );
586 }
587
588 /**
589 * Log new topic creation for Recent Activity page
590 *
591 * @param array $topic Topic data
592 * @param array $forum Forum data
593 */
594 public function log_new_topic( $topic, $forum = [] ) {
595 // Don't log unapproved or private topics
596 if( ! empty( $topic['status'] ) || ! empty( $topic['private'] ) ) return;
597
598 $data = [
599 'type' => 'wpforo_topic',
600 'itemid' => $topic['topicid'],
601 'itemtype' => 'activity',
602 'itemid_second'=> wpfval( $forum, 'forumid' ) ?: wpfval( $topic, 'forumid' ),
603 'userid' => wpfval( $topic, 'userid' ) ?: WPF()->current_userid,
604 'name' => wpfval( $topic, 'name' ) ?: WPF()->current_user_display_name,
605 'email' => wpfval( $topic, 'email' ) ?: WPF()->current_user_email,
606 'content' => wpfval( $topic, 'title' ),
607 'permalink' => wpfval( $topic, 'url' ) ?: WPF()->topic->get_url( $topic['topicid'] ),
608 ];
609
610 $this->add( $data );
611 }
612
613 /**
614 * Log new post/reply creation for Recent Activity page
615 *
616 * @param array $post Post data
617 * @param array $topic Topic data
618 */
619 public function log_new_post( $post, $topic = [] ) {
620 // Don't log unapproved posts or first posts (those are logged as topics)
621 if( ! empty( $post['status'] ) ) return;
622 if( ! empty( $post['is_first_post'] ) ) return;
623
624 $data = [
625 'type' => 'wpforo_post',
626 'itemid' => $post['postid'],
627 'itemtype' => 'activity',
628 'itemid_second'=> wpfval( $topic, 'topicid' ) ?: wpfval( $post, 'topicid' ),
629 'userid' => wpfval( $post, 'userid' ) ?: WPF()->current_userid,
630 'name' => wpfval( $post, 'name' ) ?: WPF()->current_user_display_name,
631 'email' => wpfval( $post, 'email' ) ?: WPF()->current_user_email,
632 'content' => wpfval( $topic, 'title' ) ?: wpfval( $post, 'title' ),
633 'permalink' => wpfval( $post, 'posturl' ) ?: WPF()->post->get_url( $post['postid'] ),
634 ];
635
636 $this->add( $data );
637 }
638
639 /**
640 * Log activity when a previously unapproved topic is approved
641 * This is called via wpforo_topic_approve hook (for first posts)
642 *
643 * @param array $topic Topic data
644 */
645 public function log_approved_topic( $topic ) {
646 if( empty( $topic['topicid'] ) ) return;
647
648 // Don't log private topics
649 if( ! empty( $topic['private'] ) ) return;
650
651 // Check if activity already exists (avoid duplicates)
652 $existing = $this->get_activity( [
653 'type' => 'wpforo_topic',
654 'itemid' => $topic['topicid'],
655 'itemtype' => 'activity',
656 ] );
657 if( wpfval($existing, 'id') ) return;
658
659 $forum = WPF()->forum->get_forum( $topic['forumid'] );
660
661 $data = [
662 'type' => 'wpforo_topic',
663 'itemid' => $topic['topicid'],
664 'itemtype' => 'activity',
665 'itemid_second'=> wpfval( $forum, 'forumid' ) ?: $topic['forumid'],
666 'userid' => $topic['userid'],
667 'name' => wpfval( $topic, 'name' ) ?: '',
668 'email' => wpfval( $topic, 'email' ) ?: '',
669 'content' => $topic['title'],
670 'permalink' => WPF()->topic->get_url( $topic['topicid'] ),
671 ];
672
673 $this->add( $data );
674 }
675
676 /**
677 * Log activity when a previously unapproved post/reply is approved
678 * This is called via wpforo_post_approve hook (for non-first posts only)
679 *
680 * @param array $post Post data
681 */
682 public function log_approved_post( $post ) {
683 if( empty( $post['postid'] ) || empty( $post['topicid'] ) ) return;
684
685 // Skip first posts - they are handled by log_approved_topic via wpforo_topic_approve
686 if( ! empty( $post['is_first_post'] ) ) return;
687
688 // Check if activity already exists (avoid duplicates)
689 $existing = $this->get_activity( [
690 'type' => 'wpforo_post',
691 'itemid' => $post['postid'],
692 'itemtype' => 'activity',
693 ] );
694 if( wpfval($existing, 'id') ) return;
695
696 $topic = WPF()->topic->get_topic( $post['topicid'] );
697
698 $data = [
699 'type' => 'wpforo_post',
700 'itemid' => $post['postid'],
701 'itemtype' => 'activity',
702 'itemid_second'=> $post['topicid'],
703 'userid' => $post['userid'],
704 'name' => wpfval( $post, 'name' ) ?: '',
705 'email' => wpfval( $post, 'email' ) ?: '',
706 'content' => wpfval( $topic, 'title' ) ?: '',
707 'permalink' => WPF()->post->get_url( $post['postid'] ),
708 ];
709
710 $this->add( $data );
711 }
712
713 /**
714 * Delete activities when a topic is deleted
715 *
716 * @param array $topic Topic data
717 */
718 public function after_delete_topic( $topic ) {
719 if( empty( $topic['topicid'] ) ) return;
720
721 // Delete the topic activity entry
722 $this->delete( [
723 'itemid' => $topic['topicid'],
724 'type' => 'wpforo_topic',
725 ] );
726
727 // Also delete any activities that reference this topic via itemid_second
728 // (e.g., reactions, favorites, solved markers on posts within this topic)
729 WPF()->db->delete(
730 WPF()->tables->activity,
731 [ 'itemid_second' => $topic['topicid'] ],
732 [ '%d' ]
733 );
734 }
735
736 /**
737 * Delete activities when a post is deleted
738 *
739 * @param array $post Post data
740 */
741 public function after_delete_post( $post ) {
742 if( empty( $post['postid'] ) ) return;
743
744 // Delete all activities related to this post (replies, reactions, etc.)
745 $this->delete( [ 'itemid' => $post['postid'] ] );
746 }
747
748 /**
749 * Log activity when a topic is marked as solved
750 *
751 * @param array $topic Topic data
752 * @param array $post Post data (the answer post)
753 */
754 public function log_topic_solved( $topic, $post = [] ) {
755 if( empty( $topic['topicid'] ) ) return;
756
757 // Check for existing activity to prevent duplicates
758 $existing = $this->get_activity( [
759 'type' => 'topic_solved',
760 'itemid' => $topic['topicid'],
761 'itemtype' => 'activity',
762 ] );
763 if( wpfval($existing, 'id') ) return;
764
765 $data = [
766 'type' => 'topic_solved',
767 'itemid' => $topic['topicid'],
768 'itemtype' => 'activity',
769 'itemid_second' => WPF()->current_userid,
770 'userid' => $topic['userid'],
771 'name' => WPF()->current_user_display_name,
772 'email' => WPF()->current_user_email,
773 'content' => $topic['title'],
774 'permalink' => WPF()->topic->get_url( $topic['topicid'] ),
775 ];
776
777 $this->add( $data );
778 }
779
780 /**
781 * Log activity when a topic is closed
782 *
783 * @param array $topic Topic data
784 */
785 public function log_topic_closed( $topic ) {
786 if( empty( $topic['topicid'] ) ) return;
787
788 // Check for existing activity to prevent duplicates
789 $existing = $this->get_activity( [
790 'type' => 'topic_closed',
791 'itemid' => $topic['topicid'],
792 'itemtype' => 'activity',
793 ] );
794 if( wpfval($existing, 'id') ) return;
795
796 $data = [
797 'type' => 'topic_closed',
798 'itemid' => $topic['topicid'],
799 'itemtype' => 'activity',
800 'itemid_second' => WPF()->current_userid,
801 'userid' => $topic['userid'],
802 'name' => WPF()->current_user_display_name,
803 'email' => WPF()->current_user_email,
804 'content' => $topic['title'],
805 'permalink' => WPF()->topic->get_url( $topic['topicid'] ),
806 ];
807
808 $this->add( $data );
809 }
810
811 /**
812 * Log activity when a post is marked as the answer (Q&A layout)
813 *
814 * @param int $status Answer status (1 = marked as answer, 0 = unmarked)
815 * @param array $post Post data
816 */
817 public function log_post_answer( $status, $post ) {
818 // Only log when marking as answer, not when unmarking
819 if( ! $status || empty( $post['postid'] ) ) return;
820
821 $topic = WPF()->topic->get_topic( $post['topicid'] );
822 if( ! $topic ) return;
823
824 // Check for existing activity to prevent duplicates
825 $existing = $this->get_activity( [
826 'type' => 'post_answer',
827 'itemid' => $post['postid'],
828 'itemtype' => 'activity',
829 ] );
830 if( wpfval($existing, 'id') ) return;
831
832 $data = [
833 'type' => 'post_answer',
834 'itemid' => $post['postid'],
835 'itemtype' => 'activity',
836 'itemid_second' => WPF()->current_userid,
837 'userid' => $post['userid'],
838 'name' => WPF()->current_user_display_name,
839 'email' => WPF()->current_user_email,
840 'content' => $topic['title'],
841 'permalink' => WPF()->post->get_url( $post['postid'] ),
842 ];
843
844 $this->add( $data );
845 }
846
847 /**
848 * Log activity when a post is bookmarked (favorited)
849 *
850 * @param array $bookmark Bookmark data with postid, userid, etc.
851 */
852 public function log_new_favorite( $bookmark ) {
853 if( empty( $bookmark['postid'] ) ) return;
854
855 $post = WPF()->post->get_post( $bookmark['postid'] );
856 if( ! $post ) return;
857
858 $topic = WPF()->topic->get_topic( $post['topicid'] );
859 if( ! $topic ) return;
860
861 // Check for existing activity to prevent duplicates (same user favoriting same post)
862 $existing = $this->get_activity( [
863 'type' => 'new_favorite',
864 'itemid' => $bookmark['postid'],
865 'itemid_second' => $bookmark['userid'],
866 'itemtype' => 'activity',
867 ] );
868 if( wpfval($existing, 'id') ) return;
869
870 $member = wpforo_member( $bookmark['userid'] );
871
872 $data = [
873 'type' => 'new_favorite',
874 'itemid' => $bookmark['postid'],
875 'itemtype' => 'activity',
876 'itemid_second' => $bookmark['userid'],
877 'userid' => $post['userid'],
878 'name' => wpfval( $member, 'display_name' ) ?: '',
879 'email' => wpfval( $member, 'user_email' ) ?: '',
880 'content' => $topic['title'],
881 'permalink' => WPF()->post->get_url( $bookmark['postid'] ),
882 ];
883
884 $this->add( $data );
885 }
886
887 public function after_add_post( $post, $topic ) {
888 $this->add_notification_new_reply( 'new_reply', $post, $topic );
889 }
890
891 private function add( $data ) {
892 if( empty( $data ) ) return false;
893 $activity = array_merge( $this->default->activity, $data );
894 unset( $activity['id'] );
895
896 if( ! $activity['type'] || ! $activity['itemid'] || ! $activity['itemtype'] ) return false;
897 if( ! $activity['date'] ) $activity['date'] = time();
898
899 $activity = apply_filters( 'wpforo_add_activity_data_filter', $activity );
900 do_action( 'wpforo_before_add_activity', $activity );
901
902 $activity = wpforo_array_ordered_intersect_key( $activity, $this->default->activity_format );
903 if( WPF()->db->insert(
904 WPF()->tables->activity,
905 $activity,
906 wpforo_array_ordered_intersect_key( $this->default->activity_format, $activity )
907 ) ) {
908 $activity['id'] = WPF()->db->insert_id;
909 do_action( 'wpforo_after_add_activity', $activity );
910
911 return $activity['id'];
912 }
913
914 return false;
915 }
916
917 private function edit( $data, $where ) {
918 if( empty( $data ) || empty( $where ) ) return false;
919 if( is_numeric( $where ) ) $where = [ 'id' => $where ];
920 $data = (array) $data;
921 $where = (array) $where;
922
923 $data = apply_filters( 'wpforo_activity_edit_data_filter', $data );
924 $where = apply_filters( 'wpforo_activity_edit_where_filter', $where );
925 do_action( 'wpforo_before_edit_activity', $data, $where );
926
927 $data = wpforo_array_ordered_intersect_key( $data, $this->default->activity_format );
928 $where = wpforo_array_ordered_intersect_key( $where, $this->default->activity_format );
929 if( false !== WPF()->db->update(
930 WPF()->tables->activity,
931 $data,
932 $where,
933 wpforo_array_ordered_intersect_key( $this->default->activity_format, $data ),
934 wpforo_array_ordered_intersect_key( $this->default->activity_format, $where )
935 ) ) {
936 do_action( 'wpforo_after_edit_activity', $data, $where );
937
938 return true;
939 }
940
941 return false;
942 }
943
944 private function delete( $where ): bool {
945 if( empty( $where ) ) return false;
946 if( wpforo_is_id( $where ) ) $where = [ 'id' => $where ];
947 $where = (array) $where;
948
949 $where = apply_filters( 'wpforo_activity_delete_where_filter', $where );
950 do_action( 'wpforo_before_delete_activity', $where );
951
952 $where = wpforo_array_ordered_intersect_key( $where, $this->default->activity_format );
953 if( false !== WPF()->db->delete(
954 WPF()->tables->activity,
955 $where,
956 wpforo_array_ordered_intersect_key( $this->default->activity_format, $where )
957 ) ) {
958 do_action( 'wpforo_after_delete_activity', $where );
959
960 return true;
961 }
962
963 return false;
964 }
965
966 /**
967 * Delete an activity by ID (public method with permission check)
968 *
969 * @param int $activity_id Activity ID to delete
970 * @return bool True on success, false on failure
971 */
972 public function delete_activity( $activity_id ): bool {
973 $activity_id = wpforo_bigintval( $activity_id );
974 if( ! $activity_id ) return false;
975
976 // Permission check - must have moderation permission
977 if( ! WPF()->usergroup->can( 'aum' ) ) {
978 return false;
979 }
980
981 return $this->delete( [ 'id' => $activity_id ] );
982 }
983
984 public function build( $itemtype, $itemid, $type, $echo = false ): string {
985 $rows = [];
986 $args = [
987 'itemtypes_include' => $itemtype,
988 'itemids_include' => $itemid,
989 'types_include' => $type,
990 ];
991 if( $activities = $this->get_activities( $args ) ) {
992 foreach( $activities as $activity ) {
993 switch( $activity['type'] ) {
994 case 'edit_topic':
995 case 'edit_post':
996 $rows[] = $this->_build_edit_topic_edit_post( $activity );
997 break;
998 }
999 }
1000 }
1001
1002 $rows = $this->filter_built_html_rows( $rows );
1003
1004 $html = ( $rows ? implode( '', $rows ) : '' );
1005 if( $echo ) echo $html;
1006
1007 return $html;
1008 }
1009
1010 private function _build_edit_topic_edit_post( $activity ) {
1011 $html = '';
1012 $type = $activity['type'];
1013 $userid = $activity['userid'];
1014 $date = wpforo_date( $activity['date'], 'ago', false ) . '%s';
1015
1016 if( $userid ) {
1017 $profile_url = wpforo_member( $userid, 'profile_url' );
1018 $display_name = wpforo_member( $userid, 'display_name' );
1019 $user = sprintf( '<a href="%s">%s</a>', $profile_url, $display_name );
1020 } else {
1021 $user = $activity['name'] ?: wpforo_phrase( 'Guest', false );
1022 }
1023
1024 if( wpfval( $this->actions, $type, 'before' ) ) {
1025 $html = $this->actions[ $type ]['before'];
1026 $html = apply_filters( 'wpforo_activity_action_html_before', $html, $activity );
1027 }
1028 if( wpfval( $this->actions, $type, 'description' ) ) {
1029 $html .= sprintf( $this->actions[ $activity['type'] ]['description'], $date, str_replace( '%', '%%', $user ) );
1030 $html = apply_filters( 'wpforo_activity_action_html', $html, $activity );
1031 }
1032 if( wpfval( $this->actions, $type, 'after' ) ) {
1033 $html .= $this->actions[ $type ]['after'];
1034 $html = apply_filters( 'wpforo_activity_action_html_after', $html, $activity );
1035 }
1036
1037 return $html;
1038 }
1039
1040 public function bell( $class = 'wpf-alerts' ) {
1041 wp_enqueue_script( 'wpforo-widgets-js' );
1042
1043 $class = ( ! $class ) ? 'wpf-alerts' : $class;
1044 $count = ( ! empty( $this->notifications ) ) ? count( $this->notifications ) : 0;
1045 $phrase = ( $count > 1 ) ? wpforo_phrase( 'You have new notifications', false ) : wpforo_phrase( 'You have a new notification', false );
1046 $tooltip = ' wpf-tooltip="' . esc_attr( $phrase ) . '" wpf-tooltip-size="middle"';
1047 ?>
1048 <div class="<?php echo esc_attr( $class ) ?> <?php echo ( $count ) ? 'wpf-new' : ''; ?>">
1049 <?php if( $count ): ?>
1050 <div class="wpf-bell" <?php echo $tooltip ?>>
1051 <svg height="24" width="24" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512">
1052 <path fill="currentColor"
1053 d="M224 0c-17.7 0-32 14.3-32 32V51.2C119 66 64 130.6 64 208v18.8c0 47-17.3 92.4-48.5 127.6l-7.4 8.3c-8.4 9.4-10.4 22.9-5.3 34.4S19.4 416 32 416H416c12.6 0 24-7.4 29.2-18.9s3.1-25-5.3-34.4l-7.4-8.3C401.3 319.2 384 273.9 384 226.8V208c0-77.4-55-142-128-156.8V32c0-17.7-14.3-32-32-32zm45.3 493.3c12-12 18.7-28.3 18.7-45.3H224 160c0 17 6.7 33.3 18.7 45.3s28.3 18.7 45.3 18.7s33.3-6.7 45.3-18.7z"/>
1054 </svg>
1055 <span class="wpf-alerts-count"><?php echo $count ?></span>
1056 </div>
1057 <?php else: ?>
1058 <div class="wpf-bell">
1059 <svg width="24" height="24" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512">
1060 <path fill="currentColor"
1061 d="M224 0c-17.7 0-32 14.3-32 32V51.2C119 66 64 130.6 64 208v25.4c0 45.4-15.5 89.5-43.8 124.9L5.3 377c-5.8 7.2-6.9 17.1-2.9 25.4S14.8 416 24 416H424c9.2 0 17.6-5.3 21.6-13.6s2.9-18.2-2.9-25.4l-14.9-18.6C399.5 322.9 384 278.8 384 233.4V208c0-77.4-55-142-128-156.8V32c0-17.7-14.3-32-32-32zm0 96c61.9 0 112 50.1 112 112v25.4c0 47.9 13.9 94.6 39.7 134.6H72.3C98.1 328 112 281.3 112 233.4V208c0-61.9 50.1-112 112-112zm64 352H224 160c0 17 6.7 33.3 18.7 45.3s28.3 18.7 45.3 18.7s33.3-6.7 45.3-18.7s18.7-28.3 18.7-45.3z"/>
1062 </svg>
1063 </div>
1064 <?php endif; ?>
1065 </div>
1066 <?php
1067 }
1068
1069 public function notifications() {
1070 ?>
1071 <div class="wpf-notifications">
1072 <div class="wpf-notification-head">
1073 <svg width="12" height="12" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512">
1074 <path fill="currentColor"
1075 d="M224 0c-17.7 0-32 14.3-32 32V51.2C119 66 64 130.6 64 208v25.4c0 45.4-15.5 89.5-43.8 124.9L5.3 377c-5.8 7.2-6.9 17.1-2.9 25.4S14.8 416 24 416H424c9.2 0 17.6-5.3 21.6-13.6s2.9-18.2-2.9-25.4l-14.9-18.6C399.5 322.9 384 278.8 384 233.4V208c0-77.4-55-142-128-156.8V32c0-17.7-14.3-32-32-32zm0 96c61.9 0 112 50.1 112 112v25.4c0 47.9 13.9 94.6 39.7 134.6H72.3C98.1 328 112 281.3 112 233.4V208c0-61.9 50.1-112 112-112zm64 352H224 160c0 17 6.7 33.3 18.7 45.3s28.3 18.7 45.3 18.7s33.3-6.7 45.3-18.7s18.7-28.3 18.7-45.3z"/>
1076 </svg> <?php wpforo_phrase( 'Notifications' ) ?>
1077 </div>
1078 <div class="wpf-notification-content">
1079 <div class="wpf-nspin">
1080 <svg width="24" height="24" viewBox="0 0 24 24">
1081 <g stroke="currentColor">
1082 <circle cx="12" cy="12" r="9.5" fill="none" stroke-linecap="round" stroke-width="3">
1083 <animate attributeName="stroke-dasharray" calcMode="spline" dur="1.5s" keySplines="0.42,0,0.58,1;0.42,0,0.58,1;0.42,0,0.58,1" keyTimes="0;0.475;0.95;1"
1084 repeatCount="indefinite" values="0 150;42 150;42 150;42 150"></animate>
1085 <animate attributeName="stroke-dashoffset" calcMode="spline" dur="1.5s" keySplines="0.42,0,0.58,1;0.42,0,0.58,1;0.42,0,0.58,1" keyTimes="0;0.475;0.95;1"
1086 repeatCount="indefinite" values="0;-16;-59;-59"></animate>
1087 </circle>
1088 <animateTransform attributeName="transform" dur="2s" repeatCount="indefinite" type="rotate" values="0 12 12;360 12 12"></animateTransform>
1089 </g>
1090 </svg>
1091 </div>
1092 </div>
1093 <div class="wpf-notification-actions">
1094 <span class="wpf-action wpf-notification-action-clear-all" data-foro_n="<?php echo wp_create_nonce( 'wpforo_clear_notifications' ) ?>"><?php wpforo_phrase( 'Clear all' ) ?></span>
1095 </div>
1096 </div>
1097 <?php
1098 }
1099
1100 public function notifications_list( $echo = true ) {
1101 $items = [];
1102 $list_html = '';
1103 if( ! empty( $this->notifications ) && is_array( $this->notifications ) ) {
1104 $list_html .= '<ul>';
1105 foreach( $this->notifications as $n ) {
1106 if( $type = wpfval( $n, 'type' ) ) {
1107 $html = wpfval( $this->actions, $type ) ? $this->actions[ $type ] : $this->actions['default'];
1108 $items[ $n['id'] ] = $html['before'];
1109 if( wpfval( $n, 'itemid_second' ) ) {
1110 $member = wpforo_member( $n['itemid_second'] );
1111 $member_name = wpfval( $member, 'display_name' ) ? $member['display_name'] : wpforo_phrase( 'Guest', false );
1112 } else {
1113 $member_name = wpfval( $n, 'name' ) ? $n['name'] : wpforo_phrase( 'Guest', false );
1114 }
1115 if( strpos( (string) $n['permalink'], '#' ) === false ) {
1116 $n['permalink'] = wp_nonce_url( $n['permalink'] . '?_nread=' . $n['id'], 'wpforo_mark_notification_read', 'foro_n' );
1117 } else {
1118 $n['permalink'] = str_replace( '#', '?_nread=' . $n['id'] . '#', $n['permalink'] );
1119 $n['permalink'] = wp_nonce_url( $n['permalink'], 'wpforo_mark_notification_read', 'foro_n' );
1120 }
1121 $date = wpforo_date( $n['date'], 'ago', false );
1122 $length = apply_filters( 'wpforo_notification_description_length', 40 );
1123 $items[ $n['id'] ] .= '<div class="wpf-nleft">' . $html['icon'] . '</div>';
1124 $items[ $n['id'] ] .= '<div class="wpf-nright">';
1125 $items[ $n['id'] ] .= '<a href="' . esc_url_raw( $n['permalink'] ) . '">';
1126 $items[ $n['id'] ] .= sprintf( $html['description'], '<strong>' . $member_name . '</strong>', $date );
1127 $items[ $n['id'] ] .= '</a>';
1128 $items[ $n['id'] ] .= '<div class="wpf-ndesc">' . stripslashes( wpforo_text( (string) $n['content'], $length, false ) ) . '</div>';
1129 $items[ $n['id'] ] .= '</div>';
1130 $items[ $n['id'] ] .= $html['after'];
1131 }
1132 }
1133 $items = apply_filters( 'wpforo_notifications_list', $items );
1134 $list_html .= implode( "\r\n", $items );
1135 $list_html .= '</ul>';
1136 } else {
1137 $list_html = $this->get_no_notifications_html();
1138 }
1139 if( $echo ) echo $list_html;
1140
1141 return $list_html;
1142 }
1143
1144 public function get_no_notifications_html() {
1145 return '<div class="wpf-no-notification">' . wpforo_phrase( 'You have no new notifications', false ) . '</div>';
1146 }
1147
1148 public function get_notifications() {
1149 $args = [ 'itemtype' => 'alert', 'userid' => WPF()->current_userid, 'new' => 1, 'row_count' => 100, 'orderby' => 'date', 'order' => 'DESC' ];
1150 $args = apply_filters( 'wpforo_get_notifications_args', $args );
1151
1152 return $this->get_activities( $args );
1153 }
1154
1155 public function add_notification_new_reply( $type, $post, $topic = [] ): void {
1156 if( ! wpfval( $post, 'status' ) ) {
1157 $notification = [
1158 'type' => $type,
1159 'itemid' => $post['postid'],
1160 'itemtype' => 'alert',
1161 'itemid_second' => $post['userid'],
1162 'name' => $post['name'],
1163 'email' => $post['email'],
1164 'content' => $post['title'],
1165 'permalink' => $post['posturl'],
1166 'new' => 1,
1167 ];
1168 // Notify replied person
1169 $replied_post = wpforo_post( $post['parentid'] );
1170 if( ! empty( $replied_post ) && wpfval( $replied_post, 'userid' ) != wpfval( $post, 'userid' ) ) {
1171 $notification['userid'] = $replied_post['userid'];
1172 $notification = apply_filters( 'wpforo_add_notification_new_reply_data', $notification, $type, $post, $topic, $replied_post );
1173 $this->add( $notification );
1174 }
1175 // Notify the topic author
1176 if( ! empty( $topic ) && $topic['userid'] != $post['userid'] && ! ( ! empty( $replied_post ) && $topic['userid'] == $replied_post['userid'] ) ) {
1177 $notification['userid'] = $topic['userid'];
1178 $notification = apply_filters( 'wpforo_add_notification_new_reply_data', $notification, $type, $post, $topic, $replied_post );
1179 $this->add( $notification );
1180 }
1181 }
1182 }
1183
1184 public function add_notification( $type, $args ): void {
1185 if( $args['userid'] != WPF()->current_userid ) {
1186 $length = apply_filters( 'wpforo_notification_saved_description_length', 50 );
1187 $notification = [
1188 'type' => $type,
1189 'itemid' => $args['itemid'],
1190 'itemtype' => 'alert',
1191 'itemid_second' => WPF()->current_userid,
1192 'userid' => $args['userid'],
1193 'name' => WPF()->current_user_display_name,
1194 'email' => WPF()->current_user_email,
1195 'content' => wpforo_text( $args['content'], $length, false ),
1196 'permalink' => ( wpfval( $args, 'permalink' ) ? $args['permalink'] : '#' ),
1197 'new' => 1,
1198 ];
1199 $notification = apply_filters( 'wpforo_add_notification_data', $notification, $type, $args );
1200 $this->add( $notification );
1201 }
1202 }
1203
1204 public function clear_all_reaction_notifications( $postid ) {
1205 if( $postid = wpforo_bigintval( $postid ) ) {
1206 $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";
1207 $sql = WPF()->db->prepare( $sql, $postid, WPF()->current_userid );
1208 WPF()->db->query( $sql );
1209 }
1210 }
1211
1212 public function after_react( $reaction, $post ): void {
1213 if( $post ) {
1214 $this->clear_all_reaction_notifications( wpfval( $reaction, 'postid' ) );
1215 $args = [
1216 'itemid' => $post['postid'],
1217 'userid' => $post['userid'],
1218 'content' => $post['body'],
1219 'permalink' => WPF()->post->get_url( $post['postid'] ),
1220 ];
1221 switch( wpfval( $reaction, 'type' ) ) {
1222 case 'up':
1223 $ntype = 'new_like';
1224 break;
1225 case 'down':
1226 $ntype = 'new_dislike';
1227 break;
1228 default:
1229 $ntype = 'new_reaction';
1230 break;
1231 }
1232 $this->add_notification( $ntype, $args );
1233 }
1234 }
1235
1236 public function after_unreact( $args ): void {
1237 if( $postid = wpforo_bigintval( wpfval( $args, 'postid' ) ) ) {
1238 $this->clear_all_reaction_notifications( $postid );
1239 }
1240 }
1241
1242 public function after_vote( $reaction, $post ) {
1243 if( $post ) {
1244 if( $reaction == 1 ) {
1245 $args = [
1246 'itemid' => $post['postid'],
1247 'userid' => $post['userid'],
1248 'content' => $post['body'],
1249 'permalink' => WPF()->post->get_url( $post['postid'] ),
1250 ];
1251 $this->add_notification( 'new_up_vote', $args );
1252 $args = [
1253 'type' => 'new_down_vote',
1254 'itemid' => $post['postid'],
1255 'itemtype' => 'alert',
1256 'itemid_second' => WPF()->current_userid,
1257 ];
1258 $this->delete_notification( $args );
1259 } elseif( $reaction == - 1 ) {
1260 $args = [
1261 'itemid' => $post['postid'],
1262 'userid' => $post['userid'],
1263 'content' => $post['body'],
1264 'permalink' => WPF()->post->get_url( $post['postid'] ),
1265 ];
1266 $this->add_notification( 'new_down_vote', $args );
1267 $args = [
1268 'type' => 'new_up_vote',
1269 'itemid' => $post['postid'],
1270 'itemtype' => 'alert',
1271 'itemid_second' => WPF()->current_userid,
1272 ];
1273 $this->delete_notification( $args );
1274 }
1275 }
1276 }
1277
1278 public function delete_notification( $args ) {
1279 $this->delete( $args );
1280 }
1281
1282 public function update_notification( $post, $status ) {
1283 $post['status'] = $status = intval( $status );
1284 $post['posturl'] = WPF()->post->get_url( $post['postid'] );
1285 if( wpfval( $post, 'topicid' ) ) {
1286 $topic = WPF()->topic->get_topic( $post['topicid'] );
1287 if( $status ) {
1288 $args = [
1289 'type' => 'new_reply',
1290 'itemid' => $post['postid'],
1291 'itemtype' => 'alert',
1292 ];
1293 $this->delete_notification( $args );
1294 } else {
1295 $this->add_notification_new_reply( 'new_reply', $post, $topic );
1296 }
1297 }
1298 }
1299
1300 public function read_notification( $id, $userid = null ) {
1301 $userid = is_null( $userid ) ? WPF()->current_userid : $userid;
1302 $where = [
1303 'id' => $id,
1304 'userid' => $userid,
1305 ];
1306 // Mark as read instead of deleting (preserves activity for Recent Activity page)
1307 $this->edit( [ 'new' => 0 ], $where );
1308 }
1309
1310 public function clear_notifications( $userid = null ) {
1311 $userid = is_null( $userid ) ? WPF()->current_userid : $userid;
1312 $where = [
1313 'userid' => $userid,
1314 'itemtype' => 'alert',
1315 ];
1316 // Mark all as read instead of deleting (preserves activity for Recent Activity page)
1317 $this->edit( [ 'new' => 0 ], $where );
1318 }
1319
1320 /**
1321 * Delete activities older than specified number of days
1322 *
1323 * @param int $days Number of days to retain activities
1324 *
1325 * @return int Number of deleted activities
1326 */
1327 public function cleanup_old_activities( int $days ): int {
1328 if( $days <= 0 ) {
1329 return 0;
1330 }
1331
1332 // The date field is stored as Unix timestamp (integer)
1333 $cutoff_timestamp = strtotime( "-{$days} days" );
1334
1335 $result = WPF()->db->query(
1336 WPF()->db->prepare(
1337 "DELETE FROM `" . WPF()->tables->activity . "` WHERE `date` < %d",
1338 $cutoff_timestamp
1339 )
1340 );
1341
1342 return $result !== false ? (int) $result : 0;
1343 }
1344
1345 /**
1346 * Cron callback for activity cleanup
1347 * Called daily by WordPress cron if scheduled
1348 */
1349 public function cron_cleanup_old_activities(): void {
1350 $retention_days = (int) wpforo_setting( 'activity', 'retention_days' );
1351
1352 // If 0, auto-cleanup is disabled
1353 if( $retention_days <= 0 ) {
1354 return;
1355 }
1356
1357 $deleted = $this->cleanup_old_activities( $retention_days );
1358
1359 if( $deleted > 0 ) {
1360 \wpforo_ai_log( 'info', "Cron cleanup: deleted {$deleted} activities older than {$retention_days} days", 'Activity' );
1361 }
1362 }
1363
1364 /**
1365 * Schedule activity cleanup cron if not already scheduled
1366 */
1367 public function schedule_cleanup(): void {
1368 if( ! wp_next_scheduled( 'wpforo_activity_cleanup' ) ) {
1369 // Schedule to run at 6 AM server time (off-peak hours)
1370 wp_schedule_event( strtotime( 'tomorrow 6:00am' ), 'daily', 'wpforo_activity_cleanup' );
1371 }
1372 }
1373
1374 /**
1375 * Unschedule activity cleanup cron
1376 */
1377 public function unschedule_cleanup(): void {
1378 $timestamp = wp_next_scheduled( 'wpforo_activity_cleanup' );
1379 if( $timestamp ) {
1380 wp_unschedule_event( $timestamp, 'wpforo_activity_cleanup' );
1381 }
1382 }
1383
1384 /**
1385 * Check and schedule cleanup cron on admin init if needed
1386 * This ensures first-time setup when viewing activity settings
1387 */
1388 public function maybe_schedule_cleanup(): void {
1389 // Only run on wpForo settings pages
1390 if( empty( $_GET['page'] ) || strpos( $_GET['page'], 'wpforo' ) === false ) {
1391 return;
1392 }
1393
1394 $retention_days = (int) wpforo_setting( 'activity', 'retention_days' );
1395
1396 if( $retention_days > 0 ) {
1397 $this->schedule_cleanup();
1398 }
1399 }
1400
1401 }
1402