3 * @copyright Copyright (C) 2010-2023, 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\Widget;
29 use Friendica\Core\Logger;
30 use Friendica\Core\Protocol;
31 use Friendica\Core\Renderer;
32 use Friendica\Core\System;
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\InternalServerErrorException;
41 use Friendica\Network\HTTPException\NotFoundException;
42 use Friendica\Worker\UpdateContact;
45 * Manages and show Contacts and their content
47 class Contact extends BaseModule
49 const TAB_CONVERSATIONS = 1;
51 const TAB_PROFILE = 3;
52 const TAB_CONTACTS = 4;
53 const TAB_ADVANCED = 5;
56 private static function batchActions()
58 if (empty($_POST['contact_batch']) || !is_array($_POST['contact_batch'])) {
62 $redirectUrl = $_POST['redirect_url'] ?? 'contact';
64 self::checkFormSecurityTokenRedirectOnError($redirectUrl, 'contact_batch_actions');
66 $orig_records = Model\Contact::selectToArray(['id', 'uid'], ['id' => $_POST['contact_batch'], 'uid' => [0, DI::userSession()->getLocalUserId()], 'self' => false, 'deleted' => false]);
69 foreach ($orig_records as $orig_record) {
70 $cdata = Model\Contact::getPublicAndUserContactID($orig_record['id'], DI::userSession()->getLocalUserId());
71 if (empty($cdata) || DI::userSession()->getPublicContactId() === $cdata['public']) {
72 // No action available on your own contact
76 if (!empty($_POST['contacts_batch_update']) && $cdata['user']) {
77 self::updateContactFromPoll($cdata['user']);
81 if (!empty($_POST['contacts_batch_block'])) {
82 self::toggleBlockContact($cdata['public'], DI::userSession()->getLocalUserId());
86 if (!empty($_POST['contacts_batch_ignore'])) {
87 self::toggleIgnoreContact($cdata['public']);
91 if (!empty($_POST['contacts_batch_collapse'])) {
92 self::toggleCollapseContact($cdata['public']);
96 if ($count_actions > 0) {
97 DI::sysmsg()->addInfo(DI::l10n()->tt('%d contact edited.', '%d contacts edited.', $count_actions));
100 DI::baseUrl()->redirect($redirectUrl);
103 protected function post(array $request = [])
105 if (!DI::userSession()->getLocalUserId()) {
109 // @TODO: Replace with parameter from router
110 if (DI::args()->getArgv()[1] === 'batch') {
111 self::batchActions();
115 /* contact actions */
118 * @param int $contact_id Id of contact with uid != 0
119 * @throws NotFoundException
120 * @throws \Friendica\Network\HTTPException\InternalServerErrorException
121 * @throws \ImagickException
123 public static function updateContactFromPoll(int $contact_id)
125 $contact = DBA::selectFirst('contact', ['uid', 'url', 'network'], ['id' => $contact_id, 'uid' => DI::userSession()->getLocalUserId(), 'deleted' => false]);
126 if (!DBA::isResult($contact)) {
130 if ($contact['network'] == Protocol::OSTATUS) {
131 $result = Model\Contact::createFromProbeForUser($contact['uid'], $contact['url'], $contact['network']);
133 if ($result['success']) {
134 Model\Contact::update(['subhub' => 1], ['id' => $contact_id]);
137 // pull feed and consume it, which should subscribe to the hub.
138 Worker::add(Worker::PRIORITY_HIGH, 'OnePoll', $contact_id, 'force');
141 UpdateContact::add(Worker::PRIORITY_HIGH, $contact_id);
142 } catch (\InvalidArgumentException $e) {
143 Logger::notice($e->getMessage(), ['contact' => $contact, 'callstack' => System::callstack()]);
149 * Toggles the blocked status of a contact identified by id.
151 * @param int $contact_id Id of the contact with uid = 0
152 * @param int $owner_id Id of the user we want to block the contact for
155 private static function toggleBlockContact(int $contact_id, int $owner_id)
157 $blocked = !Model\Contact\User::isBlocked($contact_id, $owner_id);
158 Model\Contact\User::setBlocked($contact_id, $owner_id, $blocked);
162 * Toggles the ignored status of a contact identified by id.
164 * @param int $contact_id Id of the contact with uid = 0
167 private static function toggleIgnoreContact(int $contact_id)
169 $ignored = !Model\Contact\User::isIgnored($contact_id, DI::userSession()->getLocalUserId());
170 Model\Contact\User::setIgnored($contact_id, DI::userSession()->getLocalUserId(), $ignored);
174 * Toggles the collapsed status of a contact identified by id.
176 * @param int $contact_id Id of the contact with uid = 0
179 private static function toggleCollapseContact(int $contact_id)
181 $collapsed = !Model\Contact\User::isCollapsed($contact_id, DI::userSession()->getLocalUserId());
182 Model\Contact\User::setCollapsed($contact_id, DI::userSession()->getLocalUserId(), $collapsed);
185 protected function content(array $request = []): string
187 if (!DI::userSession()->getLocalUserId()) {
188 return Login::form($_SERVER['REQUEST_URI']);
191 $search = trim($_GET['search'] ?? '');
192 $nets = trim($_GET['nets'] ?? '');
193 $rel = trim($_GET['rel'] ?? '');
194 $group = trim($_GET['group'] ?? '');
196 $accounttype = $_GET['accounttype'] ?? '';
197 $accounttypeid = User::getAccountTypeByString($accounttype);
201 $page->registerFooterScript(Theme::getPathForFile('asset/typeahead.js/dist/typeahead.bundle.js'));
202 $page->registerFooterScript(Theme::getPathForFile('js/friendica-tagsinput/friendica-tagsinput.js'));
203 $page->registerStylesheet(Theme::getPathForFile('js/friendica-tagsinput/friendica-tagsinput.css'));
204 $page->registerStylesheet(Theme::getPathForFile('js/friendica-tagsinput/friendica-tagsinput-typeahead.css'));
207 $findpeople_widget = Widget::findPeople();
208 if (isset($_GET['add'])) {
209 $follow_widget = Widget::follow($_GET['add']);
211 $follow_widget = Widget::follow();
214 $account_widget = Widget::accountTypes($_SERVER['REQUEST_URI'], $accounttype);
215 $networks_widget = Widget::networks($_SERVER['REQUEST_URI'], $nets);
216 $rel_widget = Widget::contactRels($_SERVER['REQUEST_URI'], $rel);
217 $groups_widget = Widget::groups($_SERVER['REQUEST_URI'], $group);
219 DI::page()['aside'] .= $vcard_widget . $findpeople_widget . $follow_widget . $rel_widget . $groups_widget . $networks_widget . $account_widget;
221 $tpl = Renderer::getMarkupTemplate('contacts-head.tpl');
222 DI::page()['htmlhead'] .= Renderer::replaceMacros($tpl, [
223 '$baseurl' => DI::baseUrl()->get(true),
227 Nav::setSelected('contact');
229 $_SESSION['return_path'] = DI::args()->getQueryString();
231 $sql_values = [DI::userSession()->getLocalUserId()];
233 // @TODO: Replace with parameter from router
234 $type = DI::args()->getArgv()[1] ?? '';
238 $sql_extra = " AND `id` IN (SELECT `cid` FROM `user-contact` WHERE `user-contact`.`uid` = ? AND `user-contact`.`blocked`)";
239 // This makes the query look for contact.uid = 0
240 array_unshift($sql_values, 0);
243 $sql_extra = " AND `hidden` AND NOT `blocked` AND NOT `pending`";
246 $sql_extra = " AND `id` IN (SELECT `cid` FROM `user-contact` WHERE `user-contact`.`uid` = ? AND `user-contact`.`ignored`)";
247 // This makes the query look for contact.uid = 0
248 array_unshift($sql_values, 0);
251 $sql_extra = " AND `id` IN (SELECT `cid` FROM `user-contact` WHERE `user-contact`.`uid` = ? AND `user-contact`.`collapsed`)";
252 // This makes the query look for contact.uid = 0
253 array_unshift($sql_values, 0);
256 $sql_extra = " AND `archive` AND NOT `blocked` AND NOT `pending`";
259 $sql_extra = " AND `pending` AND NOT `archive` AND NOT `failed` AND ((`rel` = ?)
260 OR `id` IN (SELECT `contact-id` FROM `intro` WHERE `intro`.`uid` = ? AND NOT `ignore`))";
261 $sql_values[] = Model\Contact::SHARING;
262 $sql_values[] = DI::userSession()->getLocalUserId();
265 $sql_extra = " AND NOT `archive` AND NOT `blocked` AND NOT `pending`";
269 if (isset($accounttypeid)) {
270 $sql_extra .= " AND `contact-type` = ?";
271 $sql_values[] = $accounttypeid;
278 $search_hdr = $search;
279 $search_txt = preg_quote(trim($search, ' @!'));
280 $sql_extra .= " AND (`name` REGEXP ? OR `url` REGEXP ? OR `nick` REGEXP ? OR `addr` REGEXP ? OR `alias` REGEXP ?)";
281 $sql_values[] = $search_txt;
282 $sql_values[] = $search_txt;
283 $sql_values[] = $search_txt;
284 $sql_values[] = $search_txt;
285 $sql_values[] = $search_txt;
289 $sql_extra .= " AND network = ? ";
290 $sql_values[] = $nets;
295 $sql_extra .= " AND `rel` IN (?, ?)";
296 $sql_values[] = Model\Contact::FOLLOWER;
297 $sql_values[] = Model\Contact::FRIEND;
300 $sql_extra .= " AND `rel` IN (?, ?)";
301 $sql_values[] = Model\Contact::SHARING;
302 $sql_values[] = Model\Contact::FRIEND;
305 $sql_extra .= " AND `rel` = ?";
306 $sql_values[] = Model\Contact::FRIEND;
311 $sql_extra .= " AND `id` IN (SELECT `contact-id` FROM `group_member` WHERE `gid` = ?)";
312 $sql_values[] = $group;
315 $networks = Widget::unavailableNetworks();
316 $sql_extra .= " AND NOT `network` IN (" . substr(str_repeat("?, ", count($networks)), 0, -2) . ")";
317 $sql_values = array_merge($sql_values, $networks);
319 $condition = ["`uid` = ? AND NOT `self` AND NOT `deleted`" . $sql_extra];
320 $condition = array_merge($condition, $sql_values);
322 $total = DBA::count('contact', $condition);
324 $pager = new Pager(DI::l10n(), DI::args()->getQueryString());
328 $stmt = DBA::select('contact', [], $condition, ['order' => ['name'], 'limit' => [$pager->getStart(), $pager->getItemsPerPage()]]);
330 while ($contact = DBA::fetch($stmt)) {
331 $contact['blocked'] = Model\Contact\User::isBlocked($contact['id'], DI::userSession()->getLocalUserId());
332 $contact['readonly'] = Model\Contact\User::isIgnored($contact['id'], DI::userSession()->getLocalUserId());
333 $contacts[] = self::getContactTemplateVars($contact);
339 'label' => DI::l10n()->t('All Contacts'),
341 'sel' => !$type ? 'active' : '',
342 'title' => DI::l10n()->t('Show all contacts'),
343 'id' => 'showall-tab',
347 'label' => DI::l10n()->t('Pending'),
348 'url' => 'contact/pending',
349 'sel' => $type == 'pending' ? 'active' : '',
350 'title' => DI::l10n()->t('Only show pending contacts'),
351 'id' => 'showpending-tab',
355 'label' => DI::l10n()->t('Blocked'),
356 'url' => 'contact/blocked',
357 'sel' => $type == 'blocked' ? 'active' : '',
358 'title' => DI::l10n()->t('Only show blocked contacts'),
359 'id' => 'showblocked-tab',
363 'label' => DI::l10n()->t('Ignored'),
364 'url' => 'contact/ignored',
365 'sel' => $type == 'ignored' ? 'active' : '',
366 'title' => DI::l10n()->t('Only show ignored contacts'),
367 'id' => 'showignored-tab',
371 'label' => DI::l10n()->t('Collapsed'),
372 'url' => 'contact/collapsed',
373 'sel' => $type == 'collapsed' ? 'active' : '',
374 'title' => DI::l10n()->t('Only show collapsed contacts'),
375 'id' => 'showcollapsed-tab',
379 'label' => DI::l10n()->t('Archived'),
380 'url' => 'contact/archived',
381 'sel' => $type == 'archived' ? 'active' : '',
382 'title' => DI::l10n()->t('Only show archived contacts'),
383 'id' => 'showarchived-tab',
387 'label' => DI::l10n()->t('Hidden'),
388 'url' => 'contact/hidden',
389 'sel' => $type == 'hidden' ? 'active' : '',
390 'title' => DI::l10n()->t('Only show hidden contacts'),
391 'id' => 'showhidden-tab',
395 'label' => DI::l10n()->t('Groups'),
398 'title' => DI::l10n()->t('Organize your contact groups'),
399 'id' => 'contactgroups-tab',
404 $tabs_tpl = Renderer::getMarkupTemplate('common_tabs.tpl');
405 $tabs_html = Renderer::replaceMacros($tabs_tpl, ['$tabs' => $tabs]);
408 case 'followers': $header = DI::l10n()->t('Followers'); break;
409 case 'following': $header = DI::l10n()->t('Following'); break;
410 case 'mutuals': $header = DI::l10n()->t('Mutual friends'); break;
411 default: $header = DI::l10n()->t('Contacts');
415 case 'pending': $header .= ' - ' . DI::l10n()->t('Pending'); break;
416 case 'blocked': $header .= ' - ' . DI::l10n()->t('Blocked'); break;
417 case 'hidden': $header .= ' - ' . DI::l10n()->t('Hidden'); break;
418 case 'ignored': $header .= ' - ' . DI::l10n()->t('Ignored'); break;
419 case 'collapsed': $header .= ' - ' . DI::l10n()->t('Collapsed'); break;
420 case 'archived': $header .= ' - ' . DI::l10n()->t('Archived'); break;
423 $header .= $nets ? ' - ' . ContactSelector::networkToName($nets) : '';
425 $tpl = Renderer::getMarkupTemplate('contacts-template.tpl');
426 $o .= Renderer::replaceMacros($tpl, [
427 '$header' => $header,
428 '$tabs' => $tabs_html,
430 '$search' => $search_hdr,
431 '$desc' => DI::l10n()->t('Search your contacts'),
432 '$finding' => $searching ? DI::l10n()->t('Results for: %s', $search) : '',
433 '$submit' => DI::l10n()->t('Find'),
434 '$cmd' => DI::args()->getCommand(),
435 '$contacts' => $contacts,
436 '$form_security_token' => BaseModule::getFormSecurityToken('contact_batch_actions'),
438 '$batch_actions' => [
439 'contacts_batch_update' => DI::l10n()->t('Update'),
440 'contacts_batch_block' => DI::l10n()->t('Block') . '/' . DI::l10n()->t('Unblock'),
441 'contacts_batch_ignore' => DI::l10n()->t('Ignore') . '/' . DI::l10n()->t('Unignore'),
442 'contacts_batch_collapse' => DI::l10n()->t('Collapse') . '/' . DI::l10n()->t('Uncollapse'),
444 '$h_batch_actions' => DI::l10n()->t('Batch Actions'),
445 '$paginate' => $pager->renderFull($total),
452 * List of pages for the Contact TabBar
454 * Available Pages are 'Conversations', 'Profile', 'Contacts' and 'Common Friends'
456 * @param array $contact The contact array
457 * @param int $active_tab 1 if tab should be marked as active
459 * @return string HTML string of the contact page tabs buttons.
460 * @throws \Friendica\Network\HTTPException\InternalServerErrorException
461 * @throws \ImagickException
463 public static function getTabsHTML(array $contact, int $active_tab)
465 $cid = $pcid = $contact['id'];
466 $data = Model\Contact::getPublicAndUserContactID($contact['id'], DI::userSession()->getLocalUserId());
467 if (!empty($data['user']) && ($contact['id'] == $data['public'])) {
468 $cid = $data['user'];
469 } elseif (!empty($data['public'])) {
470 $pcid = $data['public'];
476 'label' => DI::l10n()->t('Profile'),
477 'url' => 'contact/' . $cid,
478 'sel' => (($active_tab == self::TAB_PROFILE) ? 'active' : ''),
479 'title' => DI::l10n()->t('Profile Details'),
480 'id' => 'profile-tab',
484 'label' => DI::l10n()->t('Conversations'),
485 'url' => 'contact/' . $pcid . '/conversations',
486 'sel' => (($active_tab == self::TAB_CONVERSATIONS) ? 'active' : ''),
487 'title' => DI::l10n()->t('Conversations started by this contact'),
488 'id' => 'status-tab',
492 'label' => DI::l10n()->t('Posts and Comments'),
493 'url' => 'contact/' . $pcid . '/posts',
494 'sel' => (($active_tab == self::TAB_POSTS) ? 'active' : ''),
495 'title' => DI::l10n()->t('Individual Posts and Replies'),
500 'label' => DI::l10n()->t('Media'),
501 'url' => 'contact/' . $pcid . '/media',
502 'sel' => (($active_tab == self::TAB_MEDIA) ? 'active' : ''),
503 'title' => DI::l10n()->t('Posts containing media objects'),
507 ['label' => DI::l10n()->t('Contacts'),
508 'url' => 'contact/' . $pcid . '/contacts',
509 'sel' => (($active_tab == self::TAB_CONTACTS) ? 'active' : ''),
510 'title' => DI::l10n()->t('View all known contacts'),
511 'id' => 'contacts-tab',
516 if (!empty($contact['network']) && in_array($contact['network'], [Protocol::FEED, Protocol::MAIL]) && ($cid != $pcid)) {
517 $tabs[] = ['label' => DI::l10n()->t('Advanced'),
518 'url' => 'contact/' . $cid . '/advanced/',
519 'sel' => (($active_tab == self::TAB_ADVANCED) ? 'active' : ''),
520 'title' => DI::l10n()->t('Advanced Contact Settings'),
521 'id' => 'advanced-tab',
526 $tab_tpl = Renderer::getMarkupTemplate('common_tabs.tpl');
527 $tab_str = Renderer::replaceMacros($tab_tpl, ['$tabs' => $tabs]);
533 * Return the fields for the contact template
535 * @param array $contact Contact array
536 * @return array Template fields
537 * @throws InternalServerErrorException
538 * @throws \ImagickException
540 public static function getContactTemplateVars(array $contact): array
544 if (!empty($contact['url']) && isset($contact['uid']) && ($contact['uid'] == 0) && DI::userSession()->getLocalUserId()) {
545 $personal = Model\Contact::getByURL($contact['url'], false, ['uid', 'rel', 'self'], DI::userSession()->getLocalUserId());
546 if (!empty($personal)) {
547 $contact['uid'] = $personal['uid'];
548 $contact['rel'] = $personal['rel'];
549 $contact['self'] = $personal['self'];
553 if (!empty($contact['uid']) && !empty($contact['rel']) && DI::userSession()->getLocalUserId() == $contact['uid']) {
554 switch ($contact['rel']) {
555 case Model\Contact::FRIEND:
556 $alt_text = DI::l10n()->t('Mutual Friendship');
559 case Model\Contact::FOLLOWER;
560 $alt_text = DI::l10n()->t('is a fan of yours');
563 case Model\Contact::SHARING;
564 $alt_text = DI::l10n()->t('you are a fan of');
572 $url = Model\Contact::magicLinkByContact($contact);
574 if (strpos($url, 'contact/redir/') === 0) {
575 $sparkle = ' class="sparkle" ';
580 if ($contact['pending']) {
581 if (in_array($contact['rel'], [Model\Contact::FRIEND, Model\Contact::SHARING])) {
582 $alt_text = DI::l10n()->t('Pending outgoing contact request');
584 $alt_text = DI::l10n()->t('Pending incoming contact request');
588 if ($contact['self']) {
589 $alt_text = DI::l10n()->t('This is you');
590 $url = $contact['url'];
595 'id' => $contact['id'],
597 'img_hover' => DI::l10n()->t('Visit %s\'s profile [%s]', $contact['name'], $contact['url']),
598 'photo_menu' => Model\Contact::photoMenu($contact, DI::userSession()->getLocalUserId()),
599 'thumb' => Model\Contact::getThumb($contact, true),
600 'alt_text' => $alt_text,
601 'name' => $contact['name'],
602 'nick' => $contact['nick'],
603 'details' => $contact['location'],
604 'tags' => $contact['keywords'],
605 'about' => $contact['about'],
606 'account_type' => Model\Contact::getAccountType($contact['contact-type']),
607 'sparkle' => $sparkle,
608 'itemurl' => ($contact['addr'] ?? '') ?: $contact['url'],
609 'network' => ContactSelector::networkToName($contact['network'], $contact['url'], $contact['protocol'], $contact['gsid']),