PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.12
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.12
1.1.12 1.1.11 1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 All 36 releases
desktop-mode / includes / feedback / usage.php

usage.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.12, at includes/feedback/usage.php

321 lines 10.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — usage feedback: the gate, the shell config, the
4 * payload, the forwarder and the REST route.
5 *
6 * The deactivation dialog only hears from people on their way out.
7 * This one asks the people who stayed: once a user has had
8 * OpenStation on for a while, the shell shows a small prompt asking
9 * whether they have two minutes to say how it is going. Saying yes
10 * opens a short form with three optional questions and an optional
11 * email field, for anyone happy to be followed up with.
12 *
13 * Nothing goes out until they click Send. The answers are forwarded
14 * server-side to the intake on openstation.blog, the way the
15 * deactivation answer is, and nothing about the site travels with
16 * them: no URL, no site id, no user name. Feedback without an email
17 * is anonymous; the email field starts empty and is never prefilled,
18 * so an address only leaves when its owner typed it.
19 *
20 * Once per user, whatever they answer. The dismissal is the
21 * `usage-feedback` slug in the seen-intros registry
22 * (`includes/seen-intros.php`), which the "Reset what's-new dialogs"
23 * button in OpenStation Preferences → Features clears along with
24 * every other intro; a submitted form is marked seen server-side in
25 * the same request that forwards it, so a lost client write can
26 * never re-ask someone who already answered.
27 *
28 * "A while" is {@see OPENSTATION_USAGE_FEEDBACK_MIN_DAYS} since the
29 * user turned OpenStation on, read from the `openstation_enabled_at`
30 * user meta the first-run stamps write. Time spent in the shell is
31 * not tracked, so that stamp is the only signal; a user who enabled
32 * before the stamp existed has no moment to count from and is never
33 * asked.
34 *
35 * @package OpenStation
36 */
37
38 defined( 'ABSPATH' ) || exit;
39
40 /** Slug stored in `desktop_mode_seen_intros` once the prompt was answered or dismissed. */
41 const OPENSTATION_USAGE_FEEDBACK_INTRO_SLUG = 'usage-feedback';
42
43 /** Whole days a user must have had OpenStation on before the prompt appears. */
44 const OPENSTATION_USAGE_FEEDBACK_MIN_DAYS = 7;
45
46 /**
47 * The questions the form asks, in its order, as the keys the intake
48 * stores: what we can do for them, what they mainly use OpenStation
49 * for, and what gets in their way or is missing.
50 */
51 const OPENSTATION_USAGE_FEEDBACK_QUESTIONS = array( 'requests', 'use_case', 'blockers' );
52
53 /** Longest answer forwarded, in characters. */
54 const OPENSTATION_USAGE_FEEDBACK_ANSWER_MAX = 1000;
55
56 /**
57 * Whole days since the user turned OpenStation on, or `null` when
58 * the moment is unknown (they enabled before the stamp existed).
59 *
60 * @param int $user_id User ID.
61 * @return int|null
62 */
63 function openstation_usage_feedback_days_enabled( $user_id ) {
64 $at = openstation_get_user_enabled_at( $user_id );
65 if ( $at <= 0 ) {
66 return null;
67 }
68 return max( 0, (int) floor( ( time() - $at ) / DAY_IN_SECONDS ) );
69 }
70
71 /**
72 * Whether this user is owed the prompt right now.
73 *
74 * Four gates: the feature is on, the user has OpenStation on, they
75 * have had it on for {@see OPENSTATION_USAGE_FEEDBACK_MIN_DAYS} whole
76 * days by a real stamp, and they have not answered or dismissed it.
77 *
78 * @param int $user_id User ID.
79 * @return bool
80 */
81 function openstation_usage_feedback_eligible( $user_id ) {
82 $user_id = (int) $user_id;
83 if ( $user_id <= 0 || ! openstation_usage_feedback_enabled() ) {
84 return false;
85 }
86 if ( ! openstation_is_enabled( $user_id ) ) {
87 return false;
88 }
89 $days = openstation_usage_feedback_days_enabled( $user_id );
90 if ( null === $days || $days < OPENSTATION_USAGE_FEEDBACK_MIN_DAYS ) {
91 return false;
92 }
93 return ! openstation_has_seen_intro( $user_id, OPENSTATION_USAGE_FEEDBACK_INTRO_SLUG );
94 }
95
96 /**
97 * What the shell needs to show the prompt, or `null` when this user
98 * is not owed it. Rides the shell config as `usageFeedback`.
99 *
100 * Deliberately carries no user data: the form's email field starts
101 * empty.
102 *
103 * @param int $user_id User ID. Defaults to the current user.
104 * @return array{ restUrl:string }|null
105 */
106 function openstation_usage_feedback_config( $user_id = 0 ) {
107 $user_id = (int) $user_id;
108 if ( $user_id <= 0 ) {
109 $user_id = get_current_user_id();
110 }
111 if ( ! openstation_usage_feedback_eligible( $user_id ) ) {
112 return null;
113 }
114 return array(
115 'restUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/feedback/usage' ) ),
116 );
117 }
118
119 /**
120 * Sanitise the submitted answers: known questions only, plain text,
121 * trimmed and capped.
122 *
123 * @param array $raw Question key => free text.
124 * @return array<string, string> Every question key, '' when unanswered.
125 */
126 function openstation_usage_feedback_answers( array $raw ) {
127 $answers = array();
128 foreach ( OPENSTATION_USAGE_FEEDBACK_QUESTIONS as $key ) {
129 $text = isset( $raw[ $key ] ) ? sanitize_textarea_field( (string) $raw[ $key ] ) : '';
130 if ( mb_strlen( $text ) > OPENSTATION_USAGE_FEEDBACK_ANSWER_MAX ) {
131 $text = mb_substr( $text, 0, OPENSTATION_USAGE_FEEDBACK_ANSWER_MAX );
132 }
133 $answers[ $key ] = $text;
134 }
135 return $answers;
136 }
137
138 /**
139 * Build the payload for one submission.
140 *
141 * The answers, the optional email, and what a reader needs to place
142 * them: versions, language, and how long the person has had
143 * OpenStation on. Nothing that identifies the site. The random
144 * per-submission id exists only so the intake can ignore a retry.
145 * Every field is listed in `readme.txt` under "External services";
146 * add one here and add it there in the same change.
147 *
148 * @param array<string, string> $answers Sanitised answers, from {@see openstation_usage_feedback_answers()}.
149 * @param string $email The address the user typed, already validated, or ''.
150 * @param int $user_id User ID, for the days-enabled count.
151 * @return array
152 */
153 function openstation_usage_feedback_payload( array $answers, $email, $user_id ) {
154 $days = openstation_usage_feedback_days_enabled( (int) $user_id );
155 return array(
156 'id' => wp_generate_uuid4(),
157 'requests' => (string) $answers['requests'],
158 'use_case' => (string) $answers['use_case'],
159 'blockers' => (string) $answers['blockers'],
160 'email' => (string) $email,
161 'plugin_version' => OPENSTATION_VERSION,
162 'wp_version' => get_bloginfo( 'version' ),
163 'locale' => get_user_locale( (int) $user_id ),
164 'days_enabled' => null === $days ? 0 : $days,
165 );
166 }
167
168 /**
169 * Forward one payload to the intake. Synchronous and short: the user
170 * is waiting on the form, and a failed send is reported so they can
171 * try again.
172 *
173 * @param array $payload The filtered payload.
174 * @return bool True on a 2xx answer.
175 */
176 function openstation_usage_feedback_forward( array $payload ) {
177 /**
178 * Filters the intake URL for usage feedback. Hosts that run their
179 * own intake point this at it; it receives the JSON payload by
180 * POST. An empty string skips the forward.
181 *
182 * @param string $endpoint Default {@see OPENSTATION_USAGE_FEEDBACK_ENDPOINT}.
183 */
184 $endpoint = (string) apply_filters( 'openstation_usage_feedback_endpoint', OPENSTATION_USAGE_FEEDBACK_ENDPOINT );
185 if ( '' === $endpoint ) {
186 return false;
187 }
188 $response = wp_remote_post(
189 $endpoint,
190 array(
191 'timeout' => 5,
192 'redirection' => 0,
193 'user-agent' => 'WP OpenStation feedback/' . OPENSTATION_VERSION,
194 'headers' => array( 'Content-Type' => 'application/json' ),
195 'body' => wp_json_encode( $payload ),
196 )
197 );
198 return ! is_wp_error( $response ) && 2 === (int) floor( wp_remote_retrieve_response_code( $response ) / 100 );
199 }
200
201 /**
202 * Register `POST /desktop-mode/v1/feedback/usage`.
203 */
204 function openstation_register_usage_feedback_route() {
205 $text = array(
206 'type' => 'string',
207 'default' => '',
208 );
209 register_rest_route(
210 'desktop-mode/v1',
211 '/feedback/usage',
212 array(
213 'methods' => WP_REST_Server::CREATABLE,
214 'callback' => 'openstation_rest_usage_feedback',
215 'permission_callback' => 'openstation_rest_usage_feedback_permission',
216 'args' => array(
217 'requests' => $text,
218 'use_case' => $text,
219 'blockers' => $text,
220 // Not `format: email`: the field is optional, and an
221 // empty string has to pass. The handler validates it.
222 'email' => array(
223 'type' => 'string',
224 'default' => '',
225 'maxLength' => 254,
226 ),
227 ),
228 )
229 );
230 }
231 add_action( 'rest_api_init', 'openstation_register_usage_feedback_route' );
232
233 /**
234 * Permission gate: OpenStation on for this account, plus the feature
235 * flag. The prompt only ever shows inside the shell, so the strict
236 * gate is the right one. No object-level check: the route stores
237 * nothing on the site but the caller's own seen-intro flag.
238 *
239 * @return true|WP_Error
240 */
241 function openstation_rest_usage_feedback_permission() {
242 $enabled = openstation_rest_require_enabled();
243 if ( true !== $enabled ) {
244 return $enabled;
245 }
246 if ( ! openstation_usage_feedback_enabled() ) {
247 return new WP_Error(
248 'rest_forbidden',
249 __( 'You are not allowed to do that.', 'desktop-mode' ),
250 array( 'status' => 403 )
251 );
252 }
253 return true;
254 }
255
256 /**
257 * Handler: validate, build, filter, forward, and on success record
258 * the prompt as answered so it never comes back.
259 *
260 * A failed forward answers `502` rather than a quiet `sent: false`:
261 * the user is waiting to know whether their answers arrived. The
262 * intro is NOT marked seen on failure, so they can try again or
263 * close the form.
264 *
265 * @param WP_REST_Request $request REST request.
266 * @return WP_REST_Response|WP_Error
267 */
268 function openstation_rest_usage_feedback( WP_REST_Request $request ) {
269 $answers = openstation_usage_feedback_answers(
270 array(
271 'requests' => $request->get_param( 'requests' ),
272 'use_case' => $request->get_param( 'use_case' ),
273 'blockers' => $request->get_param( 'blockers' ),
274 )
275 );
276 if ( '' === implode( '', $answers ) ) {
277 return new WP_Error(
278 'openstation_empty_feedback',
279 __( 'Answer at least one question first.', 'desktop-mode' ),
280 array( 'status' => 400 )
281 );
282 }
283
284 $email = trim( (string) $request->get_param( 'email' ) );
285 if ( '' !== $email ) {
286 $email = sanitize_email( $email );
287 if ( '' === $email || ! is_email( $email ) ) {
288 return new WP_Error(
289 'openstation_invalid_email',
290 __( 'That does not look like an email address.', 'desktop-mode' ),
291 array( 'status' => 400 )
292 );
293 }
294 }
295
296 $user_id = get_current_user_id();
297 $payload = openstation_usage_feedback_payload( $answers, $email, $user_id );
298
299 /**
300 * Filters the payload before it is forwarded. Return an empty
301 * array to suppress the send; the route then answers as if the
302 * forward failed.
303 *
304 * @param array $payload The submission.
305 */
306 $payload = (array) apply_filters( 'openstation_usage_feedback_payload', $payload );
307
308 $sent = ! empty( $payload ) && openstation_usage_feedback_forward( $payload );
309 if ( ! $sent ) {
310 return new WP_Error(
311 'openstation_usage_feedback_not_sent',
312 __( 'We could not send that right now.', 'desktop-mode' ),
313 array( 'status' => 502 )
314 );
315 }
316
317 openstation_mark_intro_seen( $user_id, OPENSTATION_USAGE_FEEDBACK_INTRO_SLUG );
318
319 return rest_ensure_response( array( 'sent' => true ) );
320 }
321