--- /dev/null
+<?php
+/**
+ * Name: Ratioed
+ * Description: Additional moderation user table with statistics about user behaviour
+ * Version: 0.1
+ * Author: Matthew Exon <http://mat.exon.name>
+ */
+
+use Friendica\Content\Pager;
+use Friendica\Core\Hook;
+use Friendica\Core\Logger;
+use Friendica\Core\Renderer;
+use Friendica\Database\DBA;
+use Friendica\DI;
+use Friendica\Model\User;
+use Friendica\Module\Moderation\Users\Active;
+
+/**
+ * Sets up the addon hooks and updates data in the database if needed
+ */
+function ratioed_install()
+{
+ Hook::register('moderation_users_tabs', 'addon/ratioed/ratioed.php', 'ratioed_users_tabs');
+
+ Logger::info("ratioed: installed");
+}
+
+/**
+ * @brief Uninstallation hook for ratioed plugin
+ */
+function ratioed_uninstall() {
+ Hook::unregister('moderation_users_tabs', 'addon/ratioed/ratioed.php', 'ratioed_users_tabs');
+
+ Logger::info("ratioed: uninstalled");
+}
+
+/**
+ * This is a statement rather than an actual function definition. The simple
+ * existence of this method is checked to figure out if the addon offers a
+ * module.
+ */
+function ratioed_module() {}
+
+/**
+ * @brief Adds additional users tab to the moderation panel
+ *
+ * @param array $arr Parameters, including "tabs" which is the list to modify, and "selectedTab", which is the currently selected tab ID
+ */
+function ratioed_users_tabs(array &$arr) {
+ Logger::debug("ratioed: users tabs");
+
+ array_push($arr['tabs'], [
+ 'label' => DI::l10n()->t('Behaviour'),
+ 'url' => 'ratioed',
+ 'sel' => $arr['selectedTab'] == 'ratioed' ? 'active' : '',
+ 'title' => DI::l10n()->t('Statistics about users behaviour'),
+ 'id' => 'admin-users-ratioed',
+ 'accesskey' => 'r',
+ ]);
+}
+
+class Ratioed extends Friendica\Module\Moderation\Users\Active
+{
+ protected function content(array $request = []): string
+ {
+ Friendica\Module\Moderation\Users\Active::content();
+
+ $action = $this->parameters['action'] ?? '';
+ $uid = $this->parameters['uid'] ?? 0;
+
+ if ($uid) {
+ $user = User::getById($uid, ['username', 'blocked']);
+ if (!$user) {
+ $this->systemMessages->addNotice($this->t('User not found'));
+ $this->baseUrl->redirect('moderation/users');
+ }
+ }
+
+ switch ($action) {
+ case 'delete':
+ if ($this->session->getLocalUserId() != $uid) {
+ self::checkFormSecurityTokenRedirectOnError('moderation/users/active', 'moderation_users_active', 't');
+ // delete user
+ User::remove($uid);
+
+ $this->systemMessages->addNotice($this->t('User "%s" deleted', $user['username']));
+ } else {
+ $this->systemMessages->addNotice($this->t('You can\'t remove yourself'));
+ }
+
+ $this->baseUrl->redirect('moderation/users/active');
+ break;
+ case 'block':
+ self::checkFormSecurityTokenRedirectOnError('moderation/users/active', 'moderation_users_active', 't');
+ User::block($uid);
+ $this->systemMessages->addNotice($this->t('User "%s" blocked', $user['username']));
+ $this->baseUrl->redirect('moderation/users/active');
+ break;
+ }
+ $pager = new Pager($this->l10n, $this->args->getQueryString(), 100);
+
+ $valid_orders = [
+ 'name',
+ 'email',
+ 'register_date',
+ 'last-activity',
+ 'last-item',
+ 'page-flags',
+ ];
+
+ $order = 'last-item';
+ $order_direction = '-';
+ if (!empty($request['o'])) {
+ $new_order = $request['o'];
+ if ($new_order[0] === '-') {
+ $order_direction = '-';
+ $new_order = substr($new_order, 1);
+ }
+
+ if (in_array($new_order, $valid_orders)) {
+ $order = $new_order;
+ }
+ }
+
+ $users = User::getList($pager->getStart(), $pager->getItemsPerPage(), 'active', $order, ($order_direction == '-'));
+
+ $users = array_map($this->setupUserCallback(), $users);
+
+ $header_titles = [
+ $this->t('Name'),
+ $this->t('Email'),
+ $this->t('Register date'),
+ $this->t('Last login'),
+ $this->t('Last public item'),
+ $this->t('Type'),
+ $this->t('Blocked by'),
+ $this->t('Comments last 24h'),
+ $this->t('Reactions last 24h'),
+ $this->t('Ratio last 24h'),
+ ];
+ $field_names = [
+ 'name',
+ 'email',
+ 'register_date',
+ 'login_date',
+ 'lastitem_date',
+ 'page_flags',
+ 'blocked_by',
+ 'comments',
+ 'reactions',
+ 'ratio',
+ ];
+ $th_users = array_map(null, $header_titles, $valid_orders, $field_names);
+
+ $count = $this->database->count('user', ["`verified` AND NOT `blocked` AND NOT `account_removed` AND NOT `account_expired` AND `uid` != ?", 0]);
+
+ $t = Renderer::getMarkupTemplate('ratioed.tpl', 'addon/ratioed');
+ return self::getTabsHTML('ratioed') . Renderer::replaceMacros($t, [
+ // strings //
+ '$title' => $this->t('Moderation'),
+ '$page' => $this->t('Behaviour'),
+ '$select_all' => $this->t('select all'),
+ '$delete' => $this->t('Delete'),
+ '$block' => $this->t('Block'),
+ '$blocked' => $this->t('User blocked'),
+ '$siteadmin' => $this->t('Site admin'),
+ '$accountexpired' => $this->t('Account expired'),
+ '$h_newuser' => $this->t('Create a new user'),
+
+ '$th_users' => $th_users,
+ '$order_users' => $order,
+ '$order_direction_users' => $order_direction,
+
+ '$confirm_delete_multi' => $this->t('Selected users will be deleted!\n\nEverything these users had posted on this site will be permanently deleted!\n\nAre you sure?'),
+ '$confirm_delete' => $this->t('The user {0} will be deleted!\n\nEverything this user has posted on this site will be permanently deleted!\n\nAre you sure?'),
+
+ '$form_security_token' => self::getFormSecurityToken('moderation_users_active'),
+
+ // values //
+ '$baseurl' => $this->baseUrl,
+ '$query_string' => $this->args->getQueryString(),
+
+ '$users' => $users,
+ '$count' => $count,
+ '$pager' => $pager->renderFull($count),
+ ]);
+ }
+
+ protected function setupUserCallback(): \Closure
+ {
+ Logger::debug("ratioed: setupUserCallback");
+ $parentCallback = parent::setupUserCallback();
+ return function ($user) use ($parentCallback) {
+ $blocked_count = DBA::count('user-contact', ['uid' => $user['uid'], 'is-blocked' => 1]);
+ $user['blocked_by'] = $blocked_count;
+
+ $self_contact_result = DBA::p('SELECT admin_contact.id AS user_contact_uid FROM contact AS admin_contact JOIN contact AS user_contact ON admin_contact.`uri-id` = user_contact.`uri-id` AND admin_contact.self = 0 AND user_contact.self = 1 WHERE user_contact.uid = ?', $user['uid']);
+ if (DBA::isResult($self_contact_result)) {
+ $self_contact_result_row = DBA::fetch($self_contact_result);
+ $user['user_contact_uid'] = $self_contact_result_row['user_contact_uid'];
+ }
+ else {
+ $user['user_contact_uid'] = NULL;
+ }
+
+ if ($user['user_contact_uid']) {
+ $post_engagement_result = DBA::p('SELECT SUM(`comments`) AS `comment_count`, SUM(`activities`) AS `activities_count` FROM `post-engagement` WHERE `post-engagement`.created > DATE_SUB(now(), INTERVAL 1 DAY) AND `post-engagement`.`owner-id` = ?', $user['user_contact_uid']);
+ if (DBA::isResult($post_engagement_result)) {
+ $post_engagement_result_row = DBA::fetch($post_engagement_result);
+ $user['comments'] = $post_engagement_result_row['comment_count'];
+ $user['reactions'] = $post_engagement_result_row['activities_count'];
+ if ($user['reactions'] > 0) {
+ $user['ratio'] = number_format($user['comments'] / $user['reactions'], 1, '.', '');
+ $user['ratioed'] = (float)($user['ratio']) >= 2.0;
+ }
+ else {
+ if ($user['comments'] == 0) {
+ $user['ratio'] = '0';
+ $user['ratioed'] = false;
+ }
+ else {
+ $user['ratio'] = '∞';
+ $user['ratioed'] = false;
+ }
+ }
+ }
+ else {
+ $user['comments'] = 'error';
+ $user['reactions'] = 'error';
+ $user['ratio'] = 'error';
+ $user['ratioed'] = false;
+ }
+ }
+ else {
+ $user['comments'] = 'error';
+ $user['reactions'] = 'error';
+ $user['ratio'] = 'error';
+ $user['ratioed'] = false;
+ }
+
+ $user = $parentCallback($user);
+ Logger::debug("ratioed: setupUserCallback", [
+ 'uid' => $user['uid'],
+ 'blocked_by' => $user['blocked_by'],
+ 'comments' => $user['comments'],
+ 'reactions' => $user['reactions'],
+ 'ratio' => $user['ratio'],
+ 'ratioed' => $user['ratioed'],
+ ]);
+ return $user;
+ };
+ }
+}
+
+/**
+ * @brief Displays the ratioed tab in the moderation panel
+ */
+function ratioed_content() {
+ Logger::debug("ratioed: content");
+
+ $ratioed = DI::getDice()->create(Ratioed::class, [$_SERVER]);
+ $httpException = DI::getDice()->create(Friendica\Module\Special\HTTPException::class);
+ $ratioed->run($httpException);
+}
--- /dev/null
+<script type="text/javascript" src="view/theme/frio/js/mod_admin.js?v={{constant('\Friendica\App::VERSION')}}"></script>
+<link rel="stylesheet" href="view/theme/frio/css/mod_admin.css?v={{constant('\Friendica\App::VERSION')}}" type="text/css" media="screen"/>
+
+<div id="admin-users" class="adminpage generic-page-wrapper">
+ <h1>{{$title}} - {{$page}} ({{$count}})</h1>
+ <p>
+ <a href="{{$base_url}}/moderation/users/create" class="btn btn-primary"><i class="fa fa-user-plus"></i> {{$h_newuser}}</a>
+ </p>
+ <form action="{{$baseurl}}/{{$query_string}}" method="post">
+ <input type="hidden" name="form_security_token" value="{{$form_security_token}}">
+ <table id="users" class="table table-hover">
+ <thead>
+ <tr>
+ <th>
+ <div class="checkbox">
+ <input type="checkbox" id="admin-settings-users-select" class="selecttoggle" data-select-class="users_ckbx"/>
+ <label for="admin-settings-users-select"></label>
+ </div>
+ </th>
+ <th></th>
+ {{foreach $th_users as $k=>$th}}
+ {{if $k < 2 || $order_users == $th.1 || ($k==5 && !in_array($order_users,[$th_users.2.1, $th_users.3.1, $th_users.4.1])) }}
+ <th class="th-{{$k}}">
+ <a href="{{$baseurl}}/moderation/users/active?o={{if $order_direction_users == "+"}}-{{/if}}{{$th.1}}" class="table-order">
+ {{if $order_users == $th.1}}
+ {{if $order_direction_users == "+"}}
+ ↓
+ {{else}}
+ ↑
+ {{/if}}
+ {{else}}
+ ↕
+ {{/if}}
+ {{$th.0}}
+ </a>
+ </th>
+ {{/if}}
+ {{/foreach}}
+ <th></th>
+ </tr>
+ </thead>
+ <tbody>
+ {{foreach $users as $u}}
+ <tr id="user-{{$u.uid}}" class="{{if $u.ratioed}}blocked{{/if}}">
+ <td>
+ {{if $u.is_deletable}}
+ <div class="checkbox">
+ <input type="checkbox" class="users_ckbx" id="id_user_{{$u.uid}}" name="user[]" value="{{$u.uid}}"/>
+ <label for="id_user_{{$u.uid}}"></label>
+ </div>
+ {{else}}
+
+ {{/if}}
+ </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>{{$u.email}}</td>
+ {{if $order_users == $th_users.2.1}}
+ <td>{{$u.register_date}}</td>
+ {{/if}}
+
+ {{if $order_users == $th_users.3.1}}
+ <td>{{$u.login_date}}</td>
+ {{/if}}
+
+ {{if $order_users == $th_users.4.1}}
+ <td>{{$u.lastitem_date}}</td>
+ {{/if}}
+
+ {{if !in_array($order_users,[$th_users.2.1, $th_users.3.1, $th_users.4.1]) }}
+ <td>
+ <i class="fa
+ {{if $u.page_flags_raw==0}}fa-user{{/if}} {{* PAGE_NORMAL *}}
+ {{if $u.page_flags_raw==1}}fa-bullhorn{{/if}} {{* PAGE_SOAPBOX *}}
+ {{if $u.page_flags_raw==2}}fa-users{{/if}} {{* PAGE_COMMUNITY *}}
+ {{if $u.page_flags_raw==3}}fa-heart{{/if}} {{* PAGE_FREELOVE *}}
+ {{if $u.page_flags_raw==4}}fa-rss{{/if}} {{* PAGE_BLOG *}}
+ {{if $u.page_flags_raw==5}}fa-user-secret{{/if}} {{* PAGE_PRVGROUP *}}
+ " title="{{$u.page_flags}}">
+ </i>
+ {{if $u.page_flags_raw==0 && $u.account_type_raw > 0}}
+ <i class="fa
+ {{if $u.account_type_raw==1}}fa-sitemap{{/if}} {{* ACCOUNT_TYPE_ORGANISATION *}}
+ {{if $u.account_type_raw==2}}fa-newspaper-o{{/if}} {{* ACCOUNT_TYPE_NEWS *}}
+ {{if $u.account_type_raw==3}}fa-comments{{/if}} {{* ACCOUNT_TYPE_COMMUNITY *}}
+ " title="{{$u.account_type}}">
+ </i>
+ {{/if}}
+ {{if $u.is_admin}}<i class="fa fa-user-secret text-primary" title="{{$siteadmin}}"></i>{{/if}}
+ {{if $u.account_expired}}<i class="fa fa-clock-o text-warning" title="{{$accountexpired}}"></i>{{/if}}
+ </td>
+ {{/if}}
+
+ <td class="text-right">
+ <button type="button" class="btn-link admin-settings-action-link" onclick="return details({{$u.uid}})"><span class="caret"></span></button>
+ </td>
+ </tr>
+ <tr id="user-{{$u.uid}}-detail" class=" details hidden {{if $u.blocked != 0}}blocked{{/if}}">
+ <td> </td>
+ <td colspan="4">
+ {{if $order_users != $th_users.2.1}}
+ <p>
+ <a href="{{$baseurl}}/ratioed?o={{if $order_direction_users == "+"}}-{{/if}}{{$th_users.2.1}}" class="btn-link table-order">
+ ↕ {{$th_users.2.0}}</a> : {{$u.register_date}}
+ </p>
+ {{/if}}
+
+ {{if $order_users != $th_users.3.1}}
+ <p>
+ <a href="{{$baseurl}}/ratioed?o={{if $order_direction_users == "+"}}-{{/if}}{{$th_users.3.1}}" class="btn-link table-order">
+ ↕ {{$th_users.3.0}}</a> : {{$u.login_date}}
+ </p>
+ {{/if}}
+
+ {{if $order_users != $th_users.4.1}}
+ <p>
+ <a href="{{$baseurl}}/ratioed?o={{if $order_direction_users == "+"}}-{{/if}}{{$th_users.4.1}}" class="btn-link table-order">
+ ↕ {{$th_users.4.0}}</a> : {{$u.lastitem_date}}
+ </p>
+ {{/if}}
+
+ {{if in_array($order_users,[$th_users.2.1, $th_users.3.1, $th_users.4.1]) }}
+ <p>
+ <a href="{{$baseurl}}/ratioed?o={{if $order_direction_users == "+"}}-{{/if}}{{$th_users.5.1}}" class="btn-link table-order">
+ ↕ {{$th_users.5.0}}</a> : {{$u.page_flags}}{{if $u.page_flags_raw==0 && $u.account_type_raw > 0}}, {{$u.account_type}}{{/if}} {{if $u.is_admin}}({{$siteadmin}}){{/if}} {{if $u.account_expired}}({{$accountexpired}}){{/if}}
+ </p>
+ {{/if}}
+
+ {{foreach $th_users as $k=>$th}}
+ {{if $order_users != $th.1 && $k > 5}}
+ <p>
+ {{$th.0}} : {{$u[$th.2]}}
+ </p>
+ {{/if}}
+ {{/foreach}}
+
+ </td>
+ <td class="text-right">
+ {{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}}
+
+ {{/if}}
+ </td>
+ </tr>
+ {{/foreach}}
+ </tbody>
+ </table>
+ <div class="panel-footer">
+ <button type="submit" name="page_users_block" value="1" class="btn btn-warning">
+ <i class="fa fa-ban" aria-hidden="true"></i> {{$block}}
+ </button>
+ <button type="submit" name="page_users_delete" value="1" class="btn btn-danger" onclick="return confirm_delete('{{$confirm_delete_multi}}')">
+ <i class="fa fa-trash" aria-hidden="true"></i> {{$delete}}
+ </button>
+ </div>
+ {{$pager nofilter}}
+ </form>
+</div>