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\Conversation;
24 use Friendica\BaseModule;
25 use Friendica\Content\BoundariesPager;
26 use Friendica\Content\Conversation;
27 use Friendica\Content\GroupManager;
28 use Friendica\Content\Nav;
29 use Friendica\Content\Widget;
30 use Friendica\Content\Text\HTML;
31 use Friendica\Core\ACL;
32 use Friendica\Core\Hook;
33 use Friendica\Core\PConfig\Capability\IManagePersonalConfigValues;
34 use Friendica\Core\Renderer;
35 use Friendica\Core\Session\Capability\IHandleUserSessions;
36 use Friendica\Database\DBA;
38 use Friendica\Model\Contact;
39 use Friendica\Model\Circle;
40 use Friendica\Model\Item;
41 use Friendica\Model\Post;
42 use Friendica\Model\Profile;
43 use Friendica\Model\User;
44 use Friendica\Model\Verb;
45 use Friendica\Module\Contact as ModuleContact;
46 use Friendica\Module\Security\Login;
47 use Friendica\Protocol\Activity;
48 use Friendica\Util\DateTimeFormat;
50 class Network extends BaseModule
53 private static $circleId;
55 private static $groupContactId;
57 private static $selectedTab;
59 private static $min_id;
61 private static $max_id;
63 private static $accountTypeString;
65 private static $accountType;
67 private static $network;
69 private static $itemsPerPage;
71 private static $dateFrom;
73 private static $dateTo;
77 private static $mention;
79 protected static $order;
81 protected function content(array $request = []): string
83 if (!DI::userSession()->getLocalUserId()) {
87 $this->parseRequest($_GET);
91 DI::page()['aside'] .= Widget::accountTypes($module, self::$accountTypeString);
92 DI::page()['aside'] .= Circle::sidebarWidget($module, $module . '/circle', 'standard', self::$circleId);
93 DI::page()['aside'] .= GroupManager::widget($module . '/group', DI::userSession()->getLocalUserId(), self::$groupContactId);
94 DI::page()['aside'] .= Widget::postedByYear($module . '/archive', DI::userSession()->getLocalUserId(), false);
95 DI::page()['aside'] .= Widget::networks($module, !self::$groupContactId ? self::$network : '');
96 DI::page()['aside'] .= Widget\SavedSearches::getHTML(DI::args()->getQueryString());
97 DI::page()['aside'] .= Widget::fileAs('filed', '');
99 $arr = ['query' => DI::args()->getQueryString()];
100 Hook::callAll('network_content_init', $arr);
104 // Fetch a page full of parent items for this page
105 $params = ['limit' => self::$itemsPerPage];
106 $table = 'network-thread-view';
108 $items = self::getItems($table, $params);
110 if (DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'system', 'infinite_scroll') && ($_GET['mode'] ?? '') != 'minimal') {
111 $tpl = Renderer::getMarkupTemplate('infinite_scroll_head.tpl');
112 $o .= Renderer::replaceMacros($tpl, ['$reload_uri' => DI::args()->getQueryString()]);
115 if (!(isset($_GET['mode']) AND ($_GET['mode'] == 'raw'))) {
116 $o .= self::getTabsHTML(self::$selectedTab);
118 Nav::setSelected(DI::args()->get(0));
122 if (self::$groupContactId) {
123 // If self::$groupContactId belongs to a community group or a private group, add a mention to the status editor
124 $condition = ["`id` = ? AND `contact-type` = ?", self::$groupContactId, Contact::TYPE_COMMUNITY];
125 $contact = DBA::selectFirst('contact', ['addr'], $condition);
126 if (!empty($contact['addr'])) {
127 $content = '!' . $contact['addr'];
133 $default_permissions = [];
134 if (self::$circleId) {
135 $default_permissions['allow_gid'] = [self::$circleId];
139 if (self::$groupContactId) {
140 $allowedCids[] = (int) self::$groupContactId;
141 } elseif (self::$network) {
143 'uid' => DI::userSession()->getLocalUserId(),
144 'network' => self::$network,
149 'rel' => [Contact::SHARING, Contact::FRIEND],
151 $contactStmt = DBA::select('contact', ['id'], $condition);
152 while ($contact = DBA::fetch($contactStmt)) {
153 $allowedCids[] = (int) $contact['id'];
155 DBA::close($contactStmt);
158 if (count($allowedCids)) {
159 $default_permissions['allow_cid'] = $allowedCids;
163 'lockstate' => self::$circleId || self::$groupContactId || self::$network || ACL::getLockstateForUserId($a->getLoggedInUserId()) ? 'lock' : 'unlock',
164 'acl' => ACL::getFullSelectorHTML(DI::page(), $a->getLoggedInUserId(), true, $default_permissions),
165 'bang' => ((self::$circleId || self::$groupContactId || self::$network) ? '!' : ''),
166 'content' => $content,
169 $o .= DI::conversation()->statusEditor($x);
172 if (self::$circleId) {
173 $circle = DBA::selectFirst('group', ['name'], ['id' => self::$circleId, 'uid' => DI::userSession()->getLocalUserId()]);
174 if (!DBA::isResult($circle)) {
175 DI::sysmsg()->addNotice(DI::l10n()->t('No such circle'));
178 $o = Renderer::replaceMacros(Renderer::getMarkupTemplate('section_title.tpl'), [
179 '$title' => DI::l10n()->t('Circle: %s', $circle['name'])
181 } elseif (self::$groupContactId) {
182 $contact = Contact::getById(self::$groupContactId);
183 if (DBA::isResult($contact)) {
184 $o = Renderer::replaceMacros(Renderer::getMarkupTemplate('contact/list.tpl'), [
185 'contacts' => [ModuleContact::getContactTemplateVars($contact)],
186 'id' => DI::args()->get(0),
189 DI::sysmsg()->addNotice(DI::l10n()->t('Invalid contact.'));
191 } elseif (!DI::config()->get('theme', 'hide_eventlist')) {
192 $o .= Profile::getBirthdays();
193 $o .= Profile::getEventsReminderHTML();
196 if (self::$order === 'received') {
197 $ordering = '`received`';
198 } elseif (self::$order === 'created') {
199 $ordering = '`created`';
201 $ordering = '`commented`';
204 $o .= DI::conversation()->render($items, Conversation::MODE_NETWORK, false, false, $ordering, DI::userSession()->getLocalUserId());
206 if (DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'system', 'infinite_scroll')) {
207 $o .= HTML::scrollLoader();
209 $pager = new BoundariesPager(
211 DI::args()->getQueryString(),
212 $items[0][self::$order] ?? null,
213 $items[count($items) - 1][self::$order] ?? null,
217 $o .= $pager->renderMinimal(count($items));
226 * @param array $condition The array with the SQL condition
227 * @throws \Friendica\Network\HTTPException\InternalServerErrorException
229 private static function setItemsSeenByCondition(array $condition)
231 if (empty($condition)) {
235 $unseen = Post::exists($condition);
238 /// @todo handle huge "unseen" updates in the background to avoid timeout errors
239 Item::update(['unseen' => false], $condition);
244 * Get the network tabs menu
246 * @param string $selectedTab
247 * @return string Html of the network tabs
248 * @throws \Friendica\Network\HTTPException\InternalServerErrorException
250 private static function getTabsHTML(string $selectedTab)
252 $cmd = DI::args()->getCommand();
257 'label' => DI::l10n()->t('Latest Activity'),
258 'url' => $cmd . '?' . http_build_query(['order' => 'commented']),
259 'sel' => !$selectedTab || $selectedTab == 'commented' ? 'active' : '',
260 'title' => DI::l10n()->t('Sort by latest activity'),
261 'id' => 'activity-order-tab',
265 'label' => DI::l10n()->t('Latest Posts'),
266 'url' => $cmd . '?' . http_build_query(['order' => 'received']),
267 'sel' => $selectedTab == 'received' ? 'active' : '',
268 'title' => DI::l10n()->t('Sort by post received date'),
269 'id' => 'post-order-tab',
273 'label' => DI::l10n()->t('Latest Creation'),
274 'url' => $cmd . '?' . http_build_query(['order' => 'created']),
275 'sel' => $selectedTab == 'created' ? 'active' : '',
276 'title' => DI::l10n()->t('Sort by post creation date'),
277 'id' => 'creation-order-tab',
281 'label' => DI::l10n()->t('Personal'),
282 'url' => $cmd . '?' . http_build_query(['mention' => true]),
283 'sel' => $selectedTab == 'mention' ? 'active' : '',
284 'title' => DI::l10n()->t('Posts that mention or involve you'),
285 'id' => 'personal-tab',
289 'label' => DI::l10n()->t('Starred'),
290 'url' => $cmd . '?' . http_build_query(['star' => true]),
291 'sel' => $selectedTab == 'star' ? 'active' : '',
292 'title' => DI::l10n()->t('Favourite Posts'),
293 'id' => 'starred-posts-tab',
298 $arr = ['tabs' => $tabs];
299 Hook::callAll('network_tabs', $arr);
301 $tpl = Renderer::getMarkupTemplate('common_tabs.tpl');
303 return Renderer::replaceMacros($tpl, ['$tabs' => $arr['tabs']]);
306 protected function parseRequest(array $get)
308 self::$circleId = (int)($this->parameters['circle_id'] ?? 0);
310 self::$groupContactId = (int)($this->parameters['contact_id'] ?? 0);
312 self::$selectedTab = self::getTimelineOrderBySession(DI::userSession(), DI::pConfig());
314 if (!empty($get['star'])) {
315 self::$selectedTab = 'star';
318 self::$star = self::$selectedTab == 'star';
321 if (!empty($get['mention'])) {
322 self::$selectedTab = 'mention';
323 self::$mention = true;
325 self::$mention = self::$selectedTab == 'mention';
328 if (!empty($get['order'])) {
329 self::$selectedTab = $get['order'];
330 self::$order = $get['order'];
332 self::$mention = false;
333 } elseif (in_array(self::$selectedTab, ['received', 'star'])) {
334 self::$order = 'received';
335 } elseif (self::$selectedTab == 'created') {
336 self::$order = 'created';
338 self::$order = 'commented';
341 self::$selectedTab = self::$selectedTab ?? self::$order;
343 // Prohibit combined usage of "star" and "mention"
344 if (self::$selectedTab == 'star') {
345 self::$mention = false;
346 } elseif (self::$selectedTab == 'mention') {
350 DI::session()->set('network-tab', self::$selectedTab);
351 DI::pConfig()->set(DI::userSession()->getLocalUserId(), 'network.view', 'selected_tab', self::$selectedTab);
353 self::$accountTypeString = $get['accounttype'] ?? $this->parameters['accounttype'] ?? '';
354 self::$accountType = User::getAccountTypeByString(self::$accountTypeString);
356 self::$network = $get['nets'] ?? '';
358 self::$dateFrom = $this->parameters['from'] ?? '';
359 self::$dateTo = $this->parameters['to'] ?? '';
361 if (DI::mode()->isMobile()) {
362 self::$itemsPerPage = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'system', 'itemspage_mobile_network',
363 DI::config()->get('system', 'itemspage_network_mobile'));
365 self::$itemsPerPage = DI::pConfig()->get(DI::userSession()->getLocalUserId(), 'system', 'itemspage_network',
366 DI::config()->get('system', 'itemspage_network'));
369 self::$min_id = $get['min_id'] ?? null;
370 self::$max_id = $get['max_id'] ?? null;
372 switch (self::$order) {
374 self::$max_id = $get['last_received'] ?? self::$max_id;
377 self::$max_id = $get['last_created'] ?? self::$max_id;
380 self::$max_id = $get['last_uriid'] ?? self::$max_id;
383 self::$order = 'commented';
384 self::$max_id = $get['last_commented'] ?? self::$max_id;
388 protected static function getItems(string $table, array $params, array $conditionFields = [])
390 $conditionFields['uid'] = DI::userSession()->getLocalUserId();
391 $conditionStrings = [];
393 if (!is_null(self::$accountType)) {
394 $conditionFields['contact-type'] = self::$accountType;
398 $conditionFields['starred'] = true;
400 if (self::$mention) {
401 $conditionFields['mention'] = true;
403 if (self::$network) {
404 $conditionFields['network'] = self::$network;
407 if (self::$dateFrom) {
408 $conditionStrings = DBA::mergeConditions($conditionStrings, ["`received` <= ? ", DateTimeFormat::convert(self::$dateFrom, 'UTC', DI::app()->getTimeZone())]);
411 $conditionStrings = DBA::mergeConditions($conditionStrings, ["`received` >= ? ", DateTimeFormat::convert(self::$dateTo, 'UTC', DI::app()->getTimeZone())]);
414 if (self::$circleId) {
415 $conditionStrings = DBA::mergeConditions($conditionStrings, ["`contact-id` IN (SELECT `contact-id` FROM `group_member` WHERE `gid` = ?)", self::$circleId]);
416 } elseif (self::$groupContactId) {
417 $conditionStrings = DBA::mergeConditions($conditionStrings,
418 ["((`contact-id` = ?) OR `uri-id` IN (SELECT `parent-uri-id` FROM `post-user-view` WHERE (`contact-id` = ? AND `gravity` = ? AND `vid` = ? AND `uid` = ?)))",
419 self::$groupContactId, self::$groupContactId, Item::GRAVITY_ACTIVITY, Verb::getID(Activity::ANNOUNCE), DI::userSession()->getLocalUserId()]);
422 // Currently only the order modes "received" and "commented" are in use
423 if (isset(self::$max_id)) {
424 switch (self::$order) {
426 $conditionStrings = DBA::mergeConditions($conditionStrings, ["`received` < ?", self::$max_id]);
429 $conditionStrings = DBA::mergeConditions($conditionStrings, ["`commented` < ?", self::$max_id]);
432 $conditionStrings = DBA::mergeConditions($conditionStrings, ["`created` < ?", self::$max_id]);
435 $conditionStrings = DBA::mergeConditions($conditionStrings, ["`uri-id` < ?", self::$max_id]);
440 if (isset(self::$min_id)) {
441 switch (self::$order) {
443 $conditionStrings = DBA::mergeConditions($conditionStrings, ["`received` > ?", self::$min_id]);
446 $conditionStrings = DBA::mergeConditions($conditionStrings, ["`commented` > ?", self::$min_id]);
449 $conditionStrings = DBA::mergeConditions($conditionStrings, ["`created` > ?", self::$min_id]);
452 $conditionStrings = DBA::mergeConditions($conditionStrings, ["`uri-id` > ?", self::$min_id]);
457 if (isset(self::$min_id) && !isset(self::$max_id)) {
458 // min_id quirk: querying in reverse order with min_id gets the most recent rows, regardless of how close
459 // they are to min_id. We change the query ordering to get the expected data, and we need to reverse the
460 // order of the results.
461 $params['order'] = [self::$order => false];
463 $params['order'] = [self::$order => true];
466 $items = DBA::selectToArray($table, [], DBA::mergeConditions($conditionFields, $conditionStrings), $params);
468 // min_id quirk, continued
469 if (isset(self::$min_id) && !isset(self::$max_id)) {
470 $items = array_reverse($items);
473 if (DBA::isResult($items)) {
474 $parents = array_column($items, 'uri-id');
479 // We aren't going to try and figure out at the item, circle, and page
480 // level which items you've seen and which you haven't. If you're looking
481 // at the top level network page just mark everything seen.
482 if (!self::$circleId && !self::$groupContactId && !self::$star && !self::$mention) {
483 $condition = ['unseen' => true, 'uid' => DI::userSession()->getLocalUserId()];
484 self::setItemsSeenByCondition($condition);
485 } elseif (!empty($parents)) {
486 $condition = ['unseen' => true, 'uid' => DI::userSession()->getLocalUserId(), 'parent-uri-id' => $parents];
487 self::setItemsSeenByCondition($condition);
494 * Returns the selected network tab of the currently logged-in user
496 * @param IHandleUserSessions $session
497 * @param IManagePersonalConfigValues $pconfig
500 public static function getTimelineOrderBySession(IHandleUserSessions $session, IManagePersonalConfigValues $pconfig): string
502 return $session->get('network-tab')
503 ?? $pconfig->get($session->getLocalUserId(), 'network.view', 'selected_tab')