Merge pull request 'Ratioed: add statistics about reply likes and reply guy score' (#1589) from mexon/friendica-addons:mat/reply-guy-score into develop

Reviewed-on: https://git.friendi.ca/friendica/friendica-addons/pulls/1589
This commit is contained in:
Tobias Diekershoff 2025-01-16 06:43:41 +01:00
commit 0c96d0f4bb
4 changed files with 196 additions and 17 deletions

View file

@ -8,7 +8,9 @@ use Friendica\Core\Renderer;
use Friendica\Database\DBA; use Friendica\Database\DBA;
use Friendica\DI; use Friendica\DI;
use Friendica\Model\User; use Friendica\Model\User;
use Friendica\Model\Verb;
use Friendica\Module\Moderation\Users\Active; use Friendica\Module\Moderation\Users\Active;
use Friendica\Protocol\Activity;
/** /**
* This class implements the "Behaviour" panel in Moderation/Users * This class implements the "Behaviour" panel in Moderation/Users
@ -75,6 +77,11 @@ class RatioedPanel extends Active
$this->t('Comments last 24h'), $this->t('Comments last 24h'),
$this->t('Reactions last 24h'), $this->t('Reactions last 24h'),
$this->t('Ratio last 24h'), $this->t('Ratio last 24h'),
$this->t('Replies last month'),
$this->t('Reply likes'),
$this->t('Respondee likes'),
$this->t('OP likes'),
$this->t('Reply guy score'),
]; ];
$field_names = [ $field_names = [
'name', 'name',
@ -87,6 +94,11 @@ class RatioedPanel extends Active
'comments', 'comments',
'reactions', 'reactions',
'ratio', 'ratio',
'reply_count',
'reply_likes',
'reply_respondee_likes',
'reply_op_likes',
'reply_guy_score',
]; ];
$th_users = array_map(null, $header_titles, $valid_orders, $field_names); $th_users = array_map(null, $header_titles, $valid_orders, $field_names);
@ -125,6 +137,129 @@ class RatioedPanel extends Active
]); ]);
} }
protected function getReplyGuyRow($contact_uid)
{
$like_vid = Verb::getID(Activity::LIKE);
$post_vid = Verb::getID(Activity::POST);
/*
* This is a complicated query.
*
* The innermost select retrieves a chain of four posts: an
* original post, a target comment (possibly deep down in the
* thread), a reply from our user, and a like for that reply.
* If there's no like, we still want to count the reply, so we
* use an outer join.
*
* The second select adds "points" for different kinds of
* likes. The outermost select then counts up these points,
* and the number of distinct replies.
*/
$reply_guy_result = DBA::p('
SELECT
COUNT(distinct reply_id) AS replies_total,
SUM(like_point) AS like_total,
SUM(target_like_point) AS target_like_total,
SUM(original_like_point) AS original_like_total
FROM (
SELECT
reply_id,
like_date,
like_date IS NOT NULL AS like_point,
like_author = target_author AS target_like_point,
like_author = original_author AS original_like_point
FROM (
SELECT
original_post.`uri-id` AS original_id,
original_post.`author-id` AS original_author,
original_post.created AS original_date,
target_post.`uri-id` AS target_id,
target_post.`author-id` AS target_author,
target_post.created AS target_date,
reply_post.`uri-id` AS reply_id,
reply_post.`author-id` AS reply_author,
reply_post.created AS reply_date,
like_post.`uri-id` AS like_id,
like_post.`author-id` AS like_author,
like_post.created AS like_date
FROM
post AS original_post
JOIN
post AS target_post
ON
original_post.`uri-id` = target_post.`parent-uri-id`
JOIN
post AS reply_post
ON
target_post.`uri-id` = reply_post.`thr-parent-id` AND
reply_post.`author-id` = ? AND
reply_post.`author-id` != target_post.`author-id` AND
reply_post.`author-id` != original_post.`author-id` AND
reply_post.`uri-id` != reply_post.`thr-parent-id` AND
reply_post.vid = ? AND
reply_post.created > CURDATE() - INTERVAL 1 MONTH
LEFT OUTER JOIN
post AS like_post
ON
reply_post.`uri-id` = like_post.`thr-parent-id` AND
like_post.vid = ? AND
like_post.`author-id` != reply_post.`author-id`
) AS post_meta
) AS reply_counts
', $contact_uid, $post_vid, $like_vid);
return $reply_guy_result;
}
// https://stackoverflow.com/a/48283297/235936
protected function sigFig($value, $digits)
{
if ($value == 0) {
$decimalPlaces = $digits - 1;
} elseif ($value < 0) {
$decimalPlaces = $digits - floor(log10($value * -1)) - 1;
} else {
$decimalPlaces = $digits - floor(log10($value)) - 1;
}
$answer = ($decimalPlaces > 0) ?
number_format($value, $decimalPlaces) : round($value, $decimalPlaces);
return $answer;
}
protected function fillReplyGuyData(&$user) {
$reply_guy_result = $this->getReplyGuyRow($user['user_contact_uid']);
if (DBA::isResult($reply_guy_result)) {
$reply_guy_result_row = DBA::fetch($reply_guy_result);
$user['reply_count'] = $reply_guy_result_row['replies_total'] ?? 0;
$user['reply_likes'] = $reply_guy_result_row['like_total'] ?? 0;
$user['reply_respondee_likes'] = $reply_guy_result_row['target_like_total'] ?? 0;
$user['reply_op_likes'] = $reply_guy_result_row['original_like_total'] ?? 0;
$denominator = $user['reply_likes'] + $user['reply_respondee_likes'] + $user['reply_op_likes'];
if ($user['reply_count'] == 0) {
$user['reply_guy'] = false;
$user['reply_guy_score'] = 0;
}
elseif ($denominator == 0) {
$user['reply_guy'] = true;
$user['reply_guy_score'] = '∞';
}
else {
$reply_guy_score = $user['reply_count'] / $denominator;
$user['reply_guy'] = $reply_guy_score >= 1.0;
$user['reply_guy_score'] = $this->sigFig($reply_guy_score, 2);
}
}
else {
$user['reply_count'] = "error";
$user['reply_likes'] = "error";
$user['reply_respondee_likes'] = "error";
$user['reply_op_likes'] = "error";
$user['reply_guy'] = false;
$user['reply_guy_score'] = 0;
}
}
protected function setupUserCallback(): \Closure protected function setupUserCallback(): \Closure
{ {
Logger::debug("ratioed: setupUserCallback"); Logger::debug("ratioed: setupUserCallback");
@ -179,6 +314,8 @@ class RatioedPanel extends Active
$user['ratioed'] = false; $user['ratioed'] = false;
} }
$this->fillReplyGuyData($user);
$user = $parentCallback($user); $user = $parentCallback($user);
Logger::debug("ratioed: setupUserCallback", [ Logger::debug("ratioed: setupUserCallback", [
'uid' => $user['uid'], 'uid' => $user['uid'],

View file

@ -2,7 +2,7 @@
/** /**
* Name: Ratioed * Name: Ratioed
* Description: Additional moderation user table with statistics about user behaviour * Description: Additional moderation user table with statistics about user behaviour
* Version: 0.2 * Version: 0.3
* Author: Matthew Exon <http://mat.exon.name> * Author: Matthew Exon <http://mat.exon.name>
*/ */

View file

@ -2,7 +2,7 @@
<div class="panel-body"> <div class="panel-body">
<h2>Ratioed Plugin Help</h2> <h2>Ratioed Plugin Help</h2>
<p> <p>
This plugin provides administrators with additional statistics about This plugin provides moderators with additional statistics about
the behaviour of users. These may be useful as early warning signs the behaviour of users. These may be useful as early warning signs
that warrant more carefully watching the behaviour of a user. They that warrant more carefully watching the behaviour of a user. They
are <em>not</em> suitable as a trigger for instantly blocking, are <em>not</em> suitable as a trigger for instantly blocking,
@ -28,7 +28,7 @@
<p> <p>
This plugin allows viewing of an actual ratio, calculated over the This plugin allows viewing of an actual ratio, calculated over the
last 24 hours. This is a useful timeframe for sudden dogpiling last 24 hours. This is a useful timeframe for sudden dogpiling
events that administrators might not otherwise notice. The plugin events that moderators might not otherwise notice. The plugin
also calculates other statistics. also calculates other statistics.
</p> </p>
<h3>Explanation of Statistics</h3> <h3>Explanation of Statistics</h3>
@ -68,10 +68,63 @@
24h". It is intended to approximate the traditional ratio as 24h". It is intended to approximate the traditional ratio as
understood on Twitter. understood on Twitter.
</p> </p>
<h4>Replies last month</h4>
<p>
This is the number of times the user posted a reply to someone
else, on a thread the user did not start, any time in the last
month.
</p>
<h4>Reply likes</h4>
<p>
This is the number of likes received by the user on their
replies to other people's posts in the last month. Replies that
receive likes can be assumed to be more of a valuable
contribution than replies that do not.
</p>
<h4>Respondee likes</h4>
<p>
The number of times in the last month the user replied to
someone else's comment and that person then liked the reply.
Likes to replies are not necessarily a positive thing, but if
the person you're replying to approves the reply, that's a very
good sign. Of course it's also common in a debate for neither
side to like the other side's comments without that indicating
an unhealthy interaction, so interpret this statistic cautiously.
</p>
<h4>OP likes</h4>
<p>
The number of times in the last month the user replied on a
thread and the original poster that started the thread liked the
reply. While there is no formal concept of "ownership" of a
thread, conventionally the original poster is assumed to have
started the thread for a reason, and making replies that do not
fulfil that purpose are bad etiquette. Getting approval from
the original poster therefore is a good sign that the user is
posting replies that are wanted.
</p>
<h4>Reply guy score</h4>
<p>
A <a href="https://en.wikipedia.org/wiki/Reply_guy">"reply
guy"</a> is a common Internet phenomenon of people
(disproportionately male) posting unwanted comments on other
(disproportionately female) people's threads, derailing the
conversation. This score loosely quantifies this phenomenon,
as the ratio betwen the number of replies and the sum of likes,
respondee likes, and OP likes. This formula gives extra weight
to particularly relevant likes: a reply to a top-level post that
is liked by the original poster scores the maximum of 3
"points". A score above 1.0 might indicate cause for concern
for moderators.
</p>
<p>
Since this is indicative of long-term behaviour, the score is
calculated over a month instead of 24 hours.
</p>
</p>
<h3>Performance</h3> <h3>Performance</h3>
<p> <p>
The statistics are computed from scratch each time the page loads. The statistics are computed from scratch each time the page loads.
It's possible that this might put a heavy load on the database. and It's possible that this might put a heavy load on the database, and
the page may take a long time to load. the page may take a long time to load.
</p> </p>
<h3>Extending</h3> <h3>Extending</h3>

View file

@ -36,7 +36,7 @@
</thead> </thead>
<tbody> <tbody>
{{foreach $users as $u}} {{foreach $users as $u}}
<tr id="user-{{$u.uid}}" class="{{if $u.ratioed}}blocked{{/if}}"> <tr id="user-{{$u.uid}}" class="{{if $u.ratioed || $u.reply_guy}}blocked{{/if}}">
<td></td> <td></td>
<td><img class="avatar-nano" src="{{$u.micro}}" title="{{$u.nickname}}"></td> <td><img class="avatar-nano" src="{{$u.micro}}" title="{{$u.nickname}}"></td>
<td><a href="{{$u.url}}" title="{{$u.nickname}}"> {{$u.name}}</a></td> <td><a href="{{$u.url}}" title="{{$u.nickname}}"> {{$u.name}}</a></td>
@ -121,18 +121,7 @@
{{/foreach}} {{/foreach}}
</td> </td>
<td class="text-right"> <td class="text-right"></td>
{{if $u.is_deletable}}
<a href="{{$baseurl}}/moderation/users/active/block/{{$u.uid}}?t={{$form_security_token}}" class="admin-settings-action-link" title="{{$block}}">
<i class="fa fa-ban" aria-hidden="true"></i>
</a>
<a href="{{$baseurl}}/moderation/users/active/delete/{{$u.uid}}?t={{$form_security_token}}" class="admin-settings-action-link" title="{{$delete}}" onclick="return confirm_delete('{{$confirm_delete}}','{{$u.name}}')">
<i class="fa fa-trash" aria-hidden="true"></i>
</a>
{{else}}
&nbsp;
{{/if}}
</td>
</tr> </tr>
{{/foreach}} {{/foreach}}
</tbody> </tbody>