3 * @copyright Copyright (C) 2010-2021, the Friendica project
5 * @license GNU AGPL version 3 or any later version
7 * This program is free software: you can redistribute it and/or modify
8 * it under the terms of the GNU Affero General Public License as
9 * published by the Free Software Foundation, either version 3 of the
10 * License, or (at your option) any later version.
12 * This program is distributed in the hope that it will be useful,
13 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 * GNU Affero General Public License for more details.
17 * You should have received a copy of the GNU Affero General Public License
18 * along with this program. If not, see <https://www.gnu.org/licenses/>.
22 namespace Friendica\Module;
24 use Friendica\BaseModule;
25 use Friendica\Content\ContactSelector;
26 use Friendica\Content\Nav;
27 use Friendica\Content\Pager;
28 use Friendica\Content\Text\BBCode;
29 use Friendica\Content\Widget;
30 use Friendica\Core\Hook;
31 use Friendica\Core\Protocol;
32 use Friendica\Core\Renderer;
33 use Friendica\Core\Theme;
34 use Friendica\Core\Worker;
35 use Friendica\Database\DBA;
38 use Friendica\Model\User;
39 use Friendica\Module\Security\Login;
40 use Friendica\Network\HTTPException\BadRequestException;
41 use Friendica\Network\HTTPException\NotFoundException;
42 use Friendica\Util\DateTimeFormat;
43 use Friendica\Util\Strings;
46 * Manages and show Contacts and their content
48 class Contact extends BaseModule
50 const TAB_CONVERSATIONS = 1;
52 const TAB_PROFILE = 3;
53 const TAB_CONTACTS = 4;
54 const TAB_ADVANCED = 5;
57 private static function batchActions()
59 if (empty($_POST['contact_batch']) || !is_array($_POST['contact_batch'])) {
63 $redirectUrl = $_POST['redirect_url'] ?? 'contact';
65 self::checkFormSecurityTokenRedirectOnError($redirectUrl, 'contact_batch_actions');
67 $orig_records = Model\Contact::selectToArray(['id', 'uid'], ['id' => $_POST['contact_batch'], 'uid' => [0, local_user()], 'self' => false, 'deleted' => false]);
70 foreach ($orig_records as $orig_record) {
71 $cdata = Model\Contact::getPublicAndUserContactID($orig_record['id'], local_user());
72 if (empty($cdata) || public_contact() === $cdata['public']) {
73 // No action available on your own contact
77 if (!empty($_POST['contacts_batch_update']) && $cdata['user']) {
78 self::updateContactFromPoll($cdata['user']);
82 if (!empty($_POST['contacts_batch_block'])) {
83 self::toggleBlockContact($cdata['public'], local_user());
87 if (!empty($_POST['contacts_batch_ignore'])) {
88 self::toggleIgnoreContact($cdata['public']);
92 if ($count_actions > 0) {
93 info(DI::l10n()->tt('%d contact edited.', '%d contacts edited.', $count_actions));
96 DI::baseUrl()->redirect($redirectUrl);
99 public function post()
105 // @TODO: Replace with parameter from router
106 if (DI::args()->getArgv()[1] === 'batch') {
107 self::batchActions();
111 /* contact actions */
114 * @param int $contact_id Id of contact with uid != 0
115 * @throws NotFoundException
116 * @throws \Friendica\Network\HTTPException\InternalServerErrorException
117 * @throws \ImagickException
119 private static function updateContactFromPoll(int $contact_id)
121 $contact = DBA::selectFirst('contact', ['uid', 'url', 'network'], ['id' => $contact_id, 'uid' => local_user(), 'deleted' => false]);
122 if (!DBA::isResult($contact)) {
126 if ($contact['network'] == Protocol::OSTATUS) {
127 $result = Model\Contact::createFromProbeForUser($contact['uid'], $contact['url'], $contact['network']);
129 if ($result['success']) {
130 Model\Contact::update(['subhub' => 1], ['id' => $contact_id]);
133 // pull feed and consume it, which should subscribe to the hub.
134 Worker::add(PRIORITY_HIGH, 'OnePoll', $contact_id, 'force');
136 Worker::add(PRIORITY_HIGH, 'UpdateContact', $contact_id);
141 * @param int $contact_id Id of the contact with uid != 0
142 * @throws \Friendica\Network\HTTPException\InternalServerErrorException
143 * @throws \ImagickException
145 private static function updateContactFromProbe(int $contact_id)
147 $contact = DBA::selectFirst('contact', ['url'], ['id' => $contact_id, 'uid' => local_user(), 'deleted' => false]);
148 if (!DBA::isResult($contact)) {
152 // Update the entry in the contact table
153 Model\Contact::updateFromProbe($contact_id);
157 * Toggles the blocked status of a contact identified by id.
159 * @param int $contact_id Id of the contact with uid = 0
160 * @param int $owner_id Id of the user we want to block the contact for
163 private static function toggleBlockContact(int $contact_id, int $owner_id)
165 $blocked = !Model\Contact\User::isBlocked($contact_id, $owner_id);
166 Model\Contact\User::setBlocked($contact_id, $owner_id, $blocked);
170 * Toggles the ignored status of a contact identified by id.
172 * @param int $contact_id Id of the contact with uid = 0
175 private static function toggleIgnoreContact(int $contact_id)
177 $ignored = !Model\Contact\User::isIgnored($contact_id, local_user());
178 Model\Contact\User::setIgnored($contact_id, local_user(), $ignored);
181 public function content($update = 0): string
184 return Login::form($_SERVER['REQUEST_URI']);
187 $search = trim($_GET['search'] ?? '');
188 $nets = trim($_GET['nets'] ?? '');
189 $rel = trim($_GET['rel'] ?? '');
190 $group = trim($_GET['group'] ?? '');
192 $accounttype = $_GET['accounttype'] ?? '';
193 $accounttypeid = User::getAccountTypeByString($accounttype);
197 $page->registerFooterScript(Theme::getPathForFile('asset/typeahead.js/dist/typeahead.bundle.js'));
198 $page->registerFooterScript(Theme::getPathForFile('js/friendica-tagsinput/friendica-tagsinput.js'));
199 $page->registerStylesheet(Theme::getPathForFile('js/friendica-tagsinput/friendica-tagsinput.css'));
200 $page->registerStylesheet(Theme::getPathForFile('js/friendica-tagsinput/friendica-tagsinput-typeahead.css'));
203 // @TODO: Replace with parameter from router
204 if (DI::args()->getArgc() == 2 && intval(DI::args()->getArgv()[1])) {
205 $contact_id = intval(DI::args()->getArgv()[1]);
207 // Ensure to use the user contact when the public contact was provided
208 $data = Model\Contact::getPublicAndUserContactID($contact_id, local_user());
209 if (!empty($data['user']) && ($contact_id == $data['public'])) {
210 $contact_id = $data['user'];
214 $contact = DBA::selectFirst('contact', [], [
216 'uid' => [0, local_user()],
220 // Don't display contacts that are about to be deleted
221 if (DBA::isResult($contact) && !empty($contact['network']) && $contact['network'] == Protocol::PHANTOM) {
227 if (DBA::isResult($contact)) {
228 if ($contact['self']) {
229 DI::baseUrl()->redirect('profile/' . $contact['nick'] . '/profile');
232 $vcard_widget = Widget\VCard::getHTML($contact);
234 $findpeople_widget = '';
236 $account_widget = '';
237 $networks_widget = '';
240 if ($contact['uid'] != 0) {
241 $groups_widget = Model\Group::sidebarWidget('contact', 'group', 'full', 'everyone', $contact_id);
247 $findpeople_widget = Widget::findPeople();
248 if (isset($_GET['add'])) {
249 $follow_widget = Widget::follow($_GET['add']);
251 $follow_widget = Widget::follow();
254 $account_widget = Widget::accounttypes($_SERVER['REQUEST_URI'], $accounttype);
255 $networks_widget = Widget::networks($_SERVER['REQUEST_URI'], $nets);
256 $rel_widget = Widget::contactRels($_SERVER['REQUEST_URI'], $rel);
257 $groups_widget = Widget::groups($_SERVER['REQUEST_URI'], $group);
260 DI::page()['aside'] .= $vcard_widget . $findpeople_widget . $follow_widget . $account_widget . $groups_widget . $networks_widget . $rel_widget;
262 $tpl = Renderer::getMarkupTemplate('contacts-head.tpl');
263 DI::page()['htmlhead'] .= Renderer::replaceMacros($tpl, [
264 '$baseurl' => DI::baseUrl()->get(true),
268 Nav::setSelected('contact');
270 if (DI::args()->getArgc() == 3) {
271 $contact_id = intval(DI::args()->getArgv()[1]);
273 throw new BadRequestException();
276 // @TODO: Replace with parameter from router
277 $cmd = DI::args()->getArgv()[2];
279 $orig_record = DBA::selectFirst('contact', [], ['id' => $contact_id, 'uid' => [0, local_user()], 'self' => false, 'deleted' => false]);
280 if (!DBA::isResult($orig_record)) {
281 throw new NotFoundException(DI::l10n()->t('Contact not found'));
284 self::checkFormSecurityTokenRedirectOnError('contact/' . $contact_id, 'contact_action', 't');
286 $cdata = Model\Contact::getPublicAndUserContactID($orig_record['id'], local_user());
288 throw new NotFoundException(DI::l10n()->t('Contact not found'));
291 if ($cmd === 'update' && $cdata['user']) {
292 self::updateContactFromPoll($cdata['user']);
295 if ($cmd === 'updateprofile' && $cdata['user']) {
296 self::updateContactFromProbe($cdata['user']);
299 if ($cmd === 'block') {
300 if (public_contact() === $cdata['public']) {
301 throw new BadRequestException(DI::l10n()->t('You can\'t block yourself'));
304 self::toggleBlockContact($cdata['public'], local_user());
306 $blocked = Model\Contact\User::isBlocked($contact_id, local_user());
307 info(($blocked ? DI::l10n()->t('Contact has been blocked') : DI::l10n()->t('Contact has been unblocked')));
310 if ($cmd === 'ignore') {
311 if (public_contact() === $cdata['public']) {
312 throw new BadRequestException(DI::l10n()->t('You can\'t ignore yourself'));
315 self::toggleIgnoreContact($cdata['public']);
317 $ignored = Model\Contact\User::isIgnored($cdata['public'], local_user());
318 info(($ignored ? DI::l10n()->t('Contact has been ignored') : DI::l10n()->t('Contact has been unignored')));
321 DI::baseUrl()->redirect('contact/' . $contact_id);
325 $_SESSION['return_path'] = DI::args()->getQueryString();
327 $sql_values = [local_user()];
329 // @TODO: Replace with parameter from router
330 $type = DI::args()->getArgv()[1] ?? '';
334 $sql_extra = " AND EXISTS(SELECT `id` from `user-contact` WHERE `contact`.`id` = `user-contact`.`cid` and `user-contact`.`uid` = ? and `user-contact`.`blocked`)";
335 // This makes the query look for contact.uid = 0
336 array_unshift($sql_values, 0);
339 $sql_extra = " AND `hidden` AND NOT `blocked` AND NOT `pending`";
342 $sql_extra = " AND EXISTS(SELECT `id` from `user-contact` WHERE `contact`.`id` = `user-contact`.`cid` and `user-contact`.`uid` = ? and `user-contact`.`ignored`)";
343 // This makes the query look for contact.uid = 0
344 array_unshift($sql_values, 0);
347 $sql_extra = " AND `archive` AND NOT `blocked` AND NOT `pending`";
350 $sql_extra = " AND `pending` AND NOT `archive` AND NOT `failed` AND ((`rel` = ?)
351 OR EXISTS (SELECT `id` FROM `intro` WHERE `contact-id` = `contact`.`id` AND NOT `ignore`))";
352 $sql_values[] = Model\Contact::SHARING;
355 $sql_extra = " AND NOT `archive` AND NOT `blocked` AND NOT `pending`";
359 if (isset($accounttypeid)) {
360 $sql_extra .= " AND `contact-type` = ?";
361 $sql_values[] = $accounttypeid;
368 $search_hdr = $search;
369 $search_txt = preg_quote($search);
370 $sql_extra .= " AND (name REGEXP ? OR url REGEXP ? OR nick REGEXP ?)";
371 $sql_values[] = $search_txt;
372 $sql_values[] = $search_txt;
373 $sql_values[] = $search_txt;
377 $sql_extra .= " AND network = ? ";
378 $sql_values[] = $nets;
383 $sql_extra .= " AND `rel` IN (?, ?)";
384 $sql_values[] = Model\Contact::FOLLOWER;
385 $sql_values[] = Model\Contact::FRIEND;
388 $sql_extra .= " AND `rel` IN (?, ?)";
389 $sql_values[] = Model\Contact::SHARING;
390 $sql_values[] = Model\Contact::FRIEND;
393 $sql_extra .= " AND `rel` = ?";
394 $sql_values[] = Model\Contact::FRIEND;
399 $sql_extra .= " AND EXISTS(SELECT `id` FROM `group_member` WHERE `gid` = ? AND `contact`.`id` = `contact-id`)";
400 $sql_values[] = $group;
403 $networks = Widget::unavailableNetworks();
404 $sql_extra .= " AND NOT `network` IN (" . substr(str_repeat("?, ", count($networks)), 0, -2) . ")";
405 $sql_values = array_merge($sql_values, $networks);
407 $condition = ["`uid` = ? AND NOT `self` AND NOT `deleted`" . $sql_extra];
408 $condition = array_merge($condition, $sql_values);
410 $total = DBA::count('contact', $condition);
412 $pager = new Pager(DI::l10n(), DI::args()->getQueryString());
416 $stmt = DBA::select('contact', [], $condition, ['order' => ['name'], 'limit' => [$pager->getStart(), $pager->getItemsPerPage()]]);
418 while ($contact = DBA::fetch($stmt)) {
419 $contact['blocked'] = Model\Contact\User::isBlocked($contact['id'], local_user());
420 $contact['readonly'] = Model\Contact\User::isIgnored($contact['id'], local_user());
421 $contacts[] = self::getContactTemplateVars($contact);
427 'label' => DI::l10n()->t('All Contacts'),
429 'sel' => !$type ? 'active' : '',
430 'title' => DI::l10n()->t('Show all contacts'),
431 'id' => 'showall-tab',
435 'label' => DI::l10n()->t('Pending'),
436 'url' => 'contact/pending',
437 'sel' => $type == 'pending' ? 'active' : '',
438 'title' => DI::l10n()->t('Only show pending contacts'),
439 'id' => 'showpending-tab',
443 'label' => DI::l10n()->t('Blocked'),
444 'url' => 'contact/blocked',
445 'sel' => $type == 'blocked' ? 'active' : '',
446 'title' => DI::l10n()->t('Only show blocked contacts'),
447 'id' => 'showblocked-tab',
451 'label' => DI::l10n()->t('Ignored'),
452 'url' => 'contact/ignored',
453 'sel' => $type == 'ignored' ? 'active' : '',
454 'title' => DI::l10n()->t('Only show ignored contacts'),
455 'id' => 'showignored-tab',
459 'label' => DI::l10n()->t('Archived'),
460 'url' => 'contact/archived',
461 'sel' => $type == 'archived' ? 'active' : '',
462 'title' => DI::l10n()->t('Only show archived contacts'),
463 'id' => 'showarchived-tab',
467 'label' => DI::l10n()->t('Hidden'),
468 'url' => 'contact/hidden',
469 'sel' => $type == 'hidden' ? 'active' : '',
470 'title' => DI::l10n()->t('Only show hidden contacts'),
471 'id' => 'showhidden-tab',
475 'label' => DI::l10n()->t('Groups'),
478 'title' => DI::l10n()->t('Organize your contact groups'),
479 'id' => 'contactgroups-tab',
484 $tabs_tpl = Renderer::getMarkupTemplate('common_tabs.tpl');
485 $tabs_html = Renderer::replaceMacros($tabs_tpl, ['$tabs' => $tabs]);
488 case 'followers': $header = DI::l10n()->t('Followers'); break;
489 case 'following': $header = DI::l10n()->t('Following'); break;
490 case 'mutuals': $header = DI::l10n()->t('Mutual friends'); break;
491 default: $header = DI::l10n()->t('Contacts');
495 case 'pending': $header .= ' - ' . DI::l10n()->t('Pending'); break;
496 case 'blocked': $header .= ' - ' . DI::l10n()->t('Blocked'); break;
497 case 'hidden': $header .= ' - ' . DI::l10n()->t('Hidden'); break;
498 case 'ignored': $header .= ' - ' . DI::l10n()->t('Ignored'); break;
499 case 'archived': $header .= ' - ' . DI::l10n()->t('Archived'); break;
502 $header .= $nets ? ' - ' . ContactSelector::networkToName($nets) : '';
504 $tpl = Renderer::getMarkupTemplate('contacts-template.tpl');
505 $o .= Renderer::replaceMacros($tpl, [
506 '$header' => $header,
507 '$tabs' => $tabs_html,
509 '$search' => $search_hdr,
510 '$desc' => DI::l10n()->t('Search your contacts'),
511 '$finding' => $searching ? DI::l10n()->t('Results for: %s', $search) : '',
512 '$submit' => DI::l10n()->t('Find'),
513 '$cmd' => DI::args()->getCommand(),
514 '$contacts' => $contacts,
515 '$form_security_token' => BaseModule::getFormSecurityToken('contact_batch_actions'),
517 '$batch_actions' => [
518 'contacts_batch_update' => DI::l10n()->t('Update'),
519 'contacts_batch_block' => DI::l10n()->t('Block') . '/' . DI::l10n()->t('Unblock'),
520 'contacts_batch_ignore' => DI::l10n()->t('Ignore') . '/' . DI::l10n()->t('Unignore'),
522 '$h_batch_actions' => DI::l10n()->t('Batch Actions'),
523 '$paginate' => $pager->renderFull($total),
530 * List of pages for the Contact TabBar
532 * Available Pages are 'Status', 'Profile', 'Contacts' and 'Common Friends'
534 * @param array $contact The contact array
535 * @param int $active_tab 1 if tab should be marked as active
537 * @return string HTML string of the contact page tabs buttons.
538 * @throws \Friendica\Network\HTTPException\InternalServerErrorException
539 * @throws \ImagickException
541 public static function getTabsHTML(array $contact, int $active_tab)
543 $cid = $pcid = $contact['id'];
544 $data = Model\Contact::getPublicAndUserContactID($contact['id'], local_user());
545 if (!empty($data['user']) && ($contact['id'] == $data['public'])) {
546 $cid = $data['user'];
547 } elseif (!empty($data['public'])) {
548 $pcid = $data['public'];
554 'label' => DI::l10n()->t('Status'),
555 'url' => 'contact/' . $pcid . '/conversations',
556 'sel' => (($active_tab == self::TAB_CONVERSATIONS) ? 'active' : ''),
557 'title' => DI::l10n()->t('Conversations started by this contact'),
558 'id' => 'status-tab',
562 'label' => DI::l10n()->t('Posts and Comments'),
563 'url' => 'contact/' . $pcid . '/posts',
564 'sel' => (($active_tab == self::TAB_POSTS) ? 'active' : ''),
565 'title' => DI::l10n()->t('Status Messages and Posts'),
570 'label' => DI::l10n()->t('Media'),
571 'url' => 'contact/' . $pcid . '/media',
572 'sel' => (($active_tab == self::TAB_MEDIA) ? 'active' : ''),
573 'title' => DI::l10n()->t('Posts containing media objects'),
578 'label' => DI::l10n()->t('Profile'),
579 'url' => 'contact/' . $cid,
580 'sel' => (($active_tab == self::TAB_PROFILE) ? 'active' : ''),
581 'title' => DI::l10n()->t('Profile Details'),
582 'id' => 'profile-tab',
585 ['label' => DI::l10n()->t('Contacts'),
586 'url' => 'contact/' . $pcid . '/contacts',
587 'sel' => (($active_tab == self::TAB_CONTACTS) ? 'active' : ''),
588 'title' => DI::l10n()->t('View all known contacts'),
589 'id' => 'contacts-tab',
594 if (!empty($contact['network']) && in_array($contact['network'], [Protocol::FEED, Protocol::MAIL]) && ($cid != $pcid)) {
595 $tabs[] = ['label' => DI::l10n()->t('Advanced'),
596 'url' => 'contact/' . $cid . '/advanced/',
597 'sel' => (($active_tab == self::TAB_ADVANCED) ? 'active' : ''),
598 'title' => DI::l10n()->t('Advanced Contact Settings'),
599 'id' => 'advanced-tab',
604 $tab_tpl = Renderer::getMarkupTemplate('common_tabs.tpl');
605 $tab_str = Renderer::replaceMacros($tab_tpl, ['$tabs' => $tabs]);
611 * Return the fields for the contact template
613 * @param array $contact Contact array
614 * @return array Template fields
616 public static function getContactTemplateVars(array $contact)
620 if (!empty($contact['url']) && isset($contact['uid']) && ($contact['uid'] == 0) && local_user()) {
621 $personal = Model\Contact::getByURL($contact['url'], false, ['uid', 'rel', 'self'], local_user());
622 if (!empty($personal)) {
623 $contact['uid'] = $personal['uid'];
624 $contact['rel'] = $personal['rel'];
625 $contact['self'] = $personal['self'];
629 if (!empty($contact['uid']) && !empty($contact['rel']) && local_user() == $contact['uid']) {
630 switch ($contact['rel']) {
631 case Model\Contact::FRIEND:
632 $alt_text = DI::l10n()->t('Mutual Friendship');
635 case Model\Contact::FOLLOWER;
636 $alt_text = DI::l10n()->t('is a fan of yours');
639 case Model\Contact::SHARING;
640 $alt_text = DI::l10n()->t('you are a fan of');
648 $url = Model\Contact::magicLinkByContact($contact);
650 if (strpos($url, 'redir/') === 0) {
651 $sparkle = ' class="sparkle" ';
656 if ($contact['pending']) {
657 if (in_array($contact['rel'], [Model\Contact::FRIEND, Model\Contact::SHARING])) {
658 $alt_text = DI::l10n()->t('Pending outgoing contact request');
660 $alt_text = DI::l10n()->t('Pending incoming contact request');
664 if ($contact['self']) {
665 $alt_text = DI::l10n()->t('This is you');
666 $url = $contact['url'];
671 'id' => $contact['id'],
673 'img_hover' => DI::l10n()->t('Visit %s\'s profile [%s]', $contact['name'], $contact['url']),
674 'photo_menu' => Model\Contact::photoMenu($contact),
675 'thumb' => Model\Contact::getThumb($contact, true),
676 'alt_text' => $alt_text,
677 'name' => $contact['name'],
678 'nick' => $contact['nick'],
679 'details' => $contact['location'],
680 'tags' => $contact['keywords'],
681 'about' => $contact['about'],
682 'account_type' => Model\Contact::getAccountType($contact),
683 'sparkle' => $sparkle,
684 'itemurl' => ($contact['addr'] ?? '') ?: $contact['url'],
685 'network' => ContactSelector::networkToName($contact['network'], $contact['url'], $contact['protocol'], $contact['gsid']),