[UTIL][HTML] HTML abstraction class was extended with a more specialised Heading class

This little abstraction layer made it a bit easier to add a different title to a Note or Actor Feed Collection template, from whichever controller that uses it. Please, bear in mind, that abstract templates such as those found in Components\Collection, may act in a very 'declarative' way upon using them. This makes it difficult to dynamically choose what type of header is used without undergoing a mining operation in the likes of a pyramid of doom. Hence, this _little_ change.
This commit is contained in:
Eliseu Amaro 2022-02-16 03:01:25 +00:00 committed by Diogo Peralta Cordeiro
parent f66e178dfc
commit e70acd5c3b
Signed by: diogo
GPG Key ID: 18D2D35001FBFAB0
19 changed files with 227 additions and 132 deletions

View File

@ -4,7 +4,11 @@
{% block body %} {% block body %}
<section class="frame-section frame-section-padding"> <section class="frame-section frame-section-padding">
<h1 class="frame-section-title">{{ title }}</h1> <header class="feed-header">
{% if actors_feed_title is defined %}
{{ actors_feed_title.getHtml() }}
{% endif %}
</header>
{% set prepend_actors_collection = handle_event('PrependActorsCollection', request) %} {% set prepend_actors_collection = handle_event('PrependActorsCollection', request) %}
{% for widget in prepend_actors_collection %} {% for widget in prepend_actors_collection %}

View File

@ -14,18 +14,10 @@
{% endfor %} {% endfor %}
{% if notes is defined %} {% if notes is defined %}
<header class="feed-header" title="{{ 'Current page main header' | trans }}"> <header class="feed-header">
{% set current_path = app.request.get('_route') %} {% set current_path = app.request.get('_route') %}
{% if page_title is defined %} {% if notes_feed_title is defined %}
{% if current_path starts with 'feed_' or 'conversation' in current_path %} {{ notes_feed_title.getHtml() }}
<h1 class="section-title" role="heading">{{ page_title | trans }}</h1>
{% endif %}
{% else %}
{% if current_path starts with 'search' %}
<h3 class="heading-no-margin">{{ 'Notes found' | trans }}</h3>
{% else %}
<h1 class="section-title">{{ 'Notes' | trans }}</h1>
{% endif %}
{% endif %} {% endif %}
<nav class="feed-actions" title="{{ 'Actions that change how the feed behaves' | trans }}"> <nav class="feed-actions" title="{{ 'Actions that change how the feed behaves' | trans }}">
<details class="feed-actions-details" role="group"> <details class="feed-actions-details" role="group">

View File

@ -40,6 +40,7 @@ use App\Util\Exception\NoLoggedInUser;
use App\Util\Exception\NoSuchNoteException; use App\Util\Exception\NoSuchNoteException;
use App\Util\Exception\RedirectException; use App\Util\Exception\RedirectException;
use App\Util\Exception\ServerException; use App\Util\Exception\ServerException;
use App\Util\HTML\Heading;
use Component\Collection\Util\Controller\FeedController; use Component\Collection\Util\Controller\FeedController;
use Component\Conversation\Entity\ConversationMute; use Component\Conversation\Entity\ConversationMute;
use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\Extension\Core\Type\SubmitType;
@ -58,11 +59,14 @@ class Conversation extends FeedController
*/ */
public function showConversation(Request $request, int $conversation_id): array public function showConversation(Request $request, int $conversation_id): array
{ {
$page_title = _m('Conversation');
return [ return [
'_template' => 'collection/notes.html.twig', '_template' => 'collection/notes.html.twig',
'notes' => $this->query(query: "note-conversation:{$conversation_id}")['notes'] ?? [], 'notes' => $this->query(query: "note-conversation:{$conversation_id}")['notes'] ?? [],
'should_format' => false, 'should_format' => false,
'page_title' => _m('Conversation'), 'page_title' => $page_title,
'notes_feed_title' => (new Heading(1, [], $page_title)),
]; ];
} }

View File

@ -37,6 +37,7 @@ namespace Component\Feed\Controller;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Util\Common; use App\Util\Common;
use App\Util\HTML\Heading;
use Component\Collection\Util\Controller\FeedController; use Component\Collection\Util\Controller\FeedController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@ -47,11 +48,13 @@ class Feeds extends FeedController
*/ */
public function public(Request $request): array public function public(Request $request): array
{ {
$data = $this->query('note-local:true'); $data = $this->query('note-local:true');
$page_title = _m(\is_null(Common::user()) ? 'Feed' : 'Planet');
return [ return [
'_template' => 'collection/notes.html.twig', '_template' => 'collection/notes.html.twig',
'page_title' => _m(\is_null(Common::user()) ? 'Feed' : 'Planet'), 'page_title' => $page_title,
'notes' => $data['notes'], 'notes_feed_title' => (new Heading(level: 1, classes: ['feed-title'], text: $page_title)),
'notes' => $data['notes'],
]; ];
} }
@ -63,9 +66,10 @@ class Feeds extends FeedController
Common::ensureLoggedIn(); Common::ensureLoggedIn();
$data = $this->query('note-from:subscribed-person,subscribed-group,subscribed-organisation'); $data = $this->query('note-from:subscribed-person,subscribed-group,subscribed-organisation');
return [ return [
'_template' => 'collection/notes.html.twig', '_template' => 'collection/notes.html.twig',
'page_title' => _m('Home'), 'page_title' => _m('Home'),
'notes' => $data['notes'], 'notes_feed_title' => (new Heading(level: 1, classes: ['feed-title'], text: 'Home')),
'notes' => $data['notes'],
]; ];
} }
} }

View File

@ -22,12 +22,11 @@ declare(strict_types = 1);
namespace Component\Group; namespace Component\Group;
use App\Core\Event; use App\Core\Event;
use App\Entity\Activity;
use Component\Notification\Notification;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Core\Modules\Component; use App\Core\Modules\Component;
use App\Core\Router\RouteLoader; use App\Core\Router\RouteLoader;
use App\Core\Router\Router; use App\Core\Router\Router;
use App\Entity\Activity;
use App\Entity\Actor; use App\Entity\Actor;
use App\Util\Common; use App\Util\Common;
use App\Util\HTML; use App\Util\HTML;
@ -35,6 +34,7 @@ use App\Util\Nickname;
use Component\Circle\Controller\SelfTagsSettings; use Component\Circle\Controller\SelfTagsSettings;
use Component\Group\Controller as C; use Component\Group\Controller as C;
use Component\Group\Entity\LocalGroup; use Component\Group\Entity\LocalGroup;
use Component\Notification\Notification;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
class Group extends Component class Group extends Component
@ -51,15 +51,10 @@ class Group extends Component
/** /**
* Enqueues a notification for an Actor (such as person or group) which means * Enqueues a notification for an Actor (such as person or group) which means
* it shows up in their home feed and such. * it shows up in their home feed and such.
* @param Actor $sender
* @param Activity $activity
* @param array $targets
* @param string|null $reason
* @return bool
*/ */
public function onNewNotificationWithTargets(Actor $sender, Activity $activity, array $targets = [], ?string $reason = null): bool public function onNewNotificationWithTargets(Actor $sender, Activity $activity, array $targets = [], ?string $reason = null): bool
{ {
foreach($targets as $target) { foreach ($targets as $target) {
if ($target->isGroup()) { if ($target->isGroup()) {
// The Group announces to its subscribers // The Group announces to its subscribers
Notification::notify($target, $activity, $target->getSubscribers(), $reason); Notification::notify($target, $activity, $target->getSubscribers(), $reason);
@ -78,11 +73,10 @@ class Group extends Component
$group = $vars['actor']; $group = $vars['actor'];
if (!\is_null($actor) && $group->isGroup()) { if (!\is_null($actor) && $group->isGroup()) {
if ($actor->canModerate($group)) { if ($actor->canModerate($group)) {
$url = Router::url('group_settings', ['id' => $group->getId()]); $url = Router::url('group_settings', ['id' => $group->getId()]);
$res[] = HTML::html(['a' => ['attrs' => ['href' => $url, 'title' => _m('Edit group settings'), 'class' => 'profile-extra-actions'], _m('Group settings')]]); $res[] = HTML::html(['a' => ['attrs' => ['href' => $url, 'title' => _m('Edit group settings'), 'class' => 'profile-extra-actions'], _m('Group settings')]]);
} }
$res[] = HTML::html(['a' => ['attrs' => ['href' => Router::url('blog_post', ['in' => $group->getId()]), 'title' => _m('Make a new blog post'), 'class' => 'profile-extra-actions'], _m('Post in blog')]]); $res[] = HTML::html(['a' => ['attrs' => ['href' => Router::url('blog_post', ['in' => $group->getId()]), 'title' => _m('Make a new blog post'), 'class' => 'profile-extra-actions'], _m('Post in blog')]]);
} }
return Event::next; return Event::next;
} }
@ -90,7 +84,7 @@ class Group extends Component
public function onPopulateSettingsTabs(Request $request, string $section, array &$tabs): bool public function onPopulateSettingsTabs(Request $request, string $section, array &$tabs): bool
{ {
if ($section === 'profile' && $request->get('_route') === 'group_settings') { if ($section === 'profile' && $request->get('_route') === 'group_settings') {
$group_id = (int)$request->get('id'); $group_id = (int) $request->get('id');
$group = Actor::getById($group_id); $group = Actor::getById($group_id);
$tabs[] = [ $tabs[] = [
'title' => 'Self tags', 'title' => 'Self tags',

View File

@ -46,7 +46,7 @@ class Link extends Component
preg_match_all($this->getURLRegex(), $content, $matched_urls); preg_match_all($this->getURLRegex(), $content, $matched_urls);
$matched_urls = array_unique($matched_urls[1]); $matched_urls = array_unique($matched_urls[1]);
foreach ($matched_urls as $match) { foreach ($matched_urls as $match) {
if (in_array($match, $ignore)) { if (\in_array($match, $ignore)) {
continue; continue;
} }
try { try {

View File

@ -30,6 +30,7 @@ use App\Entity as E;
use App\Entity\LocalUser; use App\Entity\LocalUser;
use App\Util\Exception\ClientException; use App\Util\Exception\ClientException;
use App\Util\Exception\ServerException; use App\Util\Exception\ServerException;
use App\Util\HTML\Heading;
use Component\Collection\Util\Controller\FeedController; use Component\Collection\Util\Controller\FeedController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@ -75,10 +76,12 @@ class PersonFeed extends FeedController
public function personView(Request $request, Actor $person): array public function personView(Request $request, Actor $person): array
{ {
return [ return [
'_template' => 'actor/view.html.twig', '_template' => 'actor/view.html.twig',
'actor' => $person, 'actor' => $person,
'nickname' => $person->getNickname(), 'nickname' => $person->getNickname(),
'notes' => E\Note::getAllNotesByActor($person), 'notes' => E\Note::getAllNotesByActor($person),
'page_title' => _m($person->getNickname() . '\'s profile'),
'notes_feed_title' => (new Heading(level: 2, classes: ['section-title'], text: 'Notes by ' . $person->getNickname())),
]; ];
} }
} }

View File

@ -30,6 +30,7 @@ use App\Util\Exception\BugFoundException;
use App\Util\Exception\RedirectException; use App\Util\Exception\RedirectException;
use App\Util\Form\FormFields; use App\Util\Form\FormFields;
use App\Util\Formatting; use App\Util\Formatting;
use App\Util\HTML\Heading;
use Component\Collection\Util\Controller\FeedController; use Component\Collection\Util\Controller\FeedController;
use Component\Search as Comp; use Component\Search as Comp;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
@ -135,6 +136,8 @@ class Search extends FeedController
'search_form' => Comp\Search::searchForm($request, query: $q, add_subscribe: !\is_null($actor)), 'search_form' => Comp\Search::searchForm($request, query: $q, add_subscribe: !\is_null($actor)),
'search_builder_form' => $search_builder_form->createView(), 'search_builder_form' => $search_builder_form->createView(),
'notes' => $notes ?? [], 'notes' => $notes ?? [],
'notes_feed_title' => (new Heading(level: 3, classes: ['section-title'], text: 'Notes found')),
'actors_feed_title' => (new Heading(level: 3, classes: ['section-title'], text: 'Actors found')),
'actors' => $actors ?? [], 'actors' => $actors ?? [],
'page' => 1, // TODO paginate 'page' => 1, // TODO paginate
]; ];

View File

@ -39,8 +39,6 @@ use App\Core\DB\DB;
use App\Core\Event; use App\Core\Event;
use App\Core\GSFile; use App\Core\GSFile;
use App\Core\HTTPClient; use App\Core\HTTPClient;
use App\Util\HTML;
use Plugin\ActivityPub\Util\Explorer;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Core\Log; use App\Core\Log;
use App\Core\Router\Router; use App\Core\Router\Router;
@ -52,6 +50,7 @@ use App\Util\Exception\DuplicateFoundException;
use App\Util\Exception\NoSuchActorException; use App\Util\Exception\NoSuchActorException;
use App\Util\Exception\ServerException; use App\Util\Exception\ServerException;
use App\Util\Formatting; use App\Util\Formatting;
use App\Util\HTML;
use App\Util\TemporaryFile; use App\Util\TemporaryFile;
use Component\Attachment\Entity\ActorToAttachment; use Component\Attachment\Entity\ActorToAttachment;
use Component\Attachment\Entity\AttachmentToNote; use Component\Attachment\Entity\AttachmentToNote;
@ -66,6 +65,7 @@ use Exception;
use InvalidArgumentException; use InvalidArgumentException;
use Plugin\ActivityPub\ActivityPub; use Plugin\ActivityPub\ActivityPub;
use Plugin\ActivityPub\Entity\ActivitypubObject; use Plugin\ActivityPub\Entity\ActivitypubObject;
use Plugin\ActivityPub\Util\Explorer;
use Plugin\ActivityPub\Util\Model; use Plugin\ActivityPub\Util\Model;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
@ -118,8 +118,8 @@ class Note extends Model
$type_note = \is_string($json) ? self::jsonToType($json) : $json; $type_note = \is_string($json) ? self::jsonToType($json) : $json;
$actor_id = null; $actor_id = null;
$actor = null; $actor = null;
$to = $type_note->has('to') ? (is_string($type_note->get('to')) ? [$type_note->get('to')] : $type_note->get('to')) : []; $to = $type_note->has('to') ? (\is_string($type_note->get('to')) ? [$type_note->get('to')] : $type_note->get('to')) : [];
$cc = $type_note->has('cc') ? (is_string($type_note->get('cc')) ? [$type_note->get('cc')] : $type_note->get('cc')) : []; $cc = $type_note->has('cc') ? (\is_string($type_note->get('cc')) ? [$type_note->get('cc')] : $type_note->get('cc')) : [];
if ($json instanceof AbstractObject if ($json instanceof AbstractObject
&& \array_key_exists('test_authority', $options) && \array_key_exists('test_authority', $options)
&& $options['test_authority'] && $options['test_authority']
@ -184,7 +184,7 @@ class Note extends Model
continue; continue;
} }
try { try {
$actor = ActivityPub::getActorByUri($target); $actor = ActivityPub::getActorByUri($target);
$object_mentions_ids[$actor->getId()] = $target; $object_mentions_ids[$actor->getId()] = $target;
// If $to is a group, set note's scope as Group // If $to is a group, set note's scope as Group
if ($actor->isGroup()) { if ($actor->isGroup()) {
@ -199,7 +199,7 @@ class Note extends Model
continue; continue;
} }
try { try {
$actor = ActivityPub::getActorByUri($target); $actor = ActivityPub::getActorByUri($target);
$object_mentions_ids[$actor->getId()] = $target; $object_mentions_ids[$actor->getId()] = $target;
} catch (Exception $e) { } catch (Exception $e) {
Log::debug('ActivityPub->Model->Note->fromJson->getActorByUri', [$e]); Log::debug('ActivityPub->Model->Note->fromJson->getActorByUri', [$e]);
@ -248,7 +248,7 @@ class Note extends Model
case 'Mention': case 'Mention':
case 'Group': case 'Group':
try { try {
$actor = ActivityPub::getActorByUri($ap_tag->get('href')); $actor = ActivityPub::getActorByUri($ap_tag->get('href'));
$object_mentions_ids[$actor->getId()] = $ap_tag->get('href'); $object_mentions_ids[$actor->getId()] = $ap_tag->get('href');
} catch (Exception $e) { } catch (Exception $e) {
Log::debug('ActivityPub->Model->Note->fromJson->getActorByUri', [$e]); Log::debug('ActivityPub->Model->Note->fromJson->getActorByUri', [$e]);
@ -258,7 +258,7 @@ class Note extends Model
$explorer = new Explorer(); $explorer = new Explorer();
try { try {
$actors = $explorer->lookup($ap_tag->get('href')); $actors = $explorer->lookup($ap_tag->get('href'));
foreach($actors as $actor) { foreach ($actors as $actor) {
$object_mentions_ids[$actor->getId()] = $ap_tag->get('href'); $object_mentions_ids[$actor->getId()] = $ap_tag->get('href');
} }
} catch (Exception $e) { } catch (Exception $e) {
@ -356,7 +356,6 @@ class Note extends Model
break; break;
case VisibilityScope::GROUP: case VisibilityScope::GROUP:
// Will have the group in the To // Will have the group in the To
// no break
case VisibilityScope::COLLECTION: case VisibilityScope::COLLECTION:
// Since we don't support sending unlisted/followers-only // Since we don't support sending unlisted/followers-only
// notices, arriving here means we're instead answering to that type // notices, arriving here means we're instead answering to that type

View File

@ -87,6 +87,15 @@
padding: unset; padding: unset;
} }
.feed-header > h1,
.feed-header > h2,
.feed-header > h3,
.feed-header > h4,
.feed-header > h5,
.feed-header > h6 {
margin-bottom: unset;
}
.feed-actions-details summary, .feed-actions-details summary,
.note-actions-extra-details summary { .note-actions-extra-details summary {
display: block; display: block;

View File

@ -241,7 +241,7 @@
display: block; display: block;
} }
.profile-info-url-nickname { .profile-info-url strong {
font-size: 1.215rem; font-size: 1.215rem;
font-weight: 900; font-weight: 900;
} }

View File

@ -74,12 +74,12 @@ use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\RouterInterface; use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Security as SSecurity; use Symfony\Component\Security\Core\Security as SSecurity;
use Symfony\Component\Security\Http\Util\TargetPathTrait; use Symfony\Component\Security\Http\Util\TargetPathTrait;
use Symfony\Component\Yaml;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Contracts\Translation\TranslatorInterface;
use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface; use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;
use SymfonyCasts\Bundle\VerifyEmail\VerifyEmailHelperInterface; use SymfonyCasts\Bundle\VerifyEmail\VerifyEmailHelperInterface;
use Twig\Environment; use Twig\Environment;
use Symfony\Component\Yaml;
/** /**
* @codeCoverageIgnore * @codeCoverageIgnore
@ -230,8 +230,8 @@ class GNUsocial implements EventSubscriberInterface
$local_file = INSTALLDIR . '/social.local.yaml'; $local_file = INSTALLDIR . '/social.local.yaml';
if (!file_exists($local_file)) { if (!file_exists($local_file)) {
$node_name = $_ENV['CONFIG_NODE_NAME']; $node_name = $_ENV['CONFIG_NODE_NAME'];
$domain = $_ENV['CONFIG_DOMAIN']; $domain = $_ENV['CONFIG_DOMAIN'];
$yaml = (new Yaml\Dumper(indentation: 2))->dump(['parameters' => ['locals' => ['gnusocial' => ['site' => ['server' => $domain, 'name' => $node_name]]]]], Yaml\Yaml::DUMP_OBJECT_AS_MAP); $yaml = (new Yaml\Dumper(indentation: 2))->dump(['parameters' => ['locals' => ['gnusocial' => ['site' => ['server' => $domain, 'name' => $node_name]]]]], Yaml\Yaml::DUMP_OBJECT_AS_MAP);
file_put_contents($local_file, $yaml); file_put_contents($local_file, $yaml);
} }

View File

@ -36,7 +36,6 @@ use App\Core\DB\DB;
use App\Core\Event; use App\Core\Event;
use App\Core\Log; use App\Core\Log;
use App\Entity\Actor; use App\Entity\Actor;
use App\Entity\Note;
use App\Util\Exception\NicknameException; use App\Util\Exception\NicknameException;
use App\Util\Exception\ServerException; use App\Util\Exception\ServerException;
use Component\Circle\Circle; use Component\Circle\Circle;

View File

@ -30,16 +30,31 @@ declare(strict_types = 1);
namespace App\Util; namespace App\Util;
use BadMethodCallException; use BadMethodCallException;
use const ENT_QUOTES;
use const ENT_SUBSTITUTE;
use Functional as F; use Functional as F;
use HtmlSanitizer\SanitizerInterface; use HtmlSanitizer\SanitizerInterface;
use InvalidArgumentException; use InvalidArgumentException;
use function str_starts_with;
/** /**
* @mixin SanitizerInterface * @mixin SanitizerInterface
*
* @method static string sanitize(string $html) * @method static string sanitize(string $html)
*/ */
abstract class HTML abstract class HTML
{ {
/**
* Tags whose content is sensitive to indentation, so we shouldn't indent them
*/
public const NO_INDENT_TAGS = ['a', 'b', 'em', 'i', 'q', 's', 'p', 'sub', 'sup', 'u'];
public const ALLOWED_TAGS = ['p', 'b', 'br', 'a', 'span', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
public const FORBIDDEN_ATTRIBUTES = [
'onerror', 'form', 'onforminput', 'onbeforescriptexecute', 'formaction', 'onfocus', 'onload',
'data', 'event', 'autofocus', 'onactivate', 'onanimationstart', 'onwebkittransitionend', 'onblur', 'poster',
'onratechange', 'ontoggle', 'onscroll', 'actiontype', 'dirname', 'srcdoc',
];
public const SELF_CLOSING_TAG = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'link', 'meta', 'param', 'source', 'track', 'wbr'];
private static ?SanitizerInterface $sanitizer; private static ?SanitizerInterface $sanitizer;
public static function setSanitizer($sanitizer): void public static function setSanitizer($sanitizer): void
@ -47,21 +62,6 @@ abstract class HTML
self::$sanitizer = $sanitizer; self::$sanitizer = $sanitizer;
} }
/**
* Tags whose content is sensitive to indentation, so we shouldn't indent them
*/
public const NO_INDENT_TAGS = ['a', 'b', 'em', 'i', 'q', 's', 'p', 'sub', 'sup', 'u'];
public const ALLOWED_TAGS = ['p', 'b', 'br', 'a', 'span', 'div', 'hr'];
public const FORBIDDEN_ATTRIBUTES = [
'onerror', 'form', 'onforminput', 'onbeforescriptexecute', 'formaction', 'onfocus', 'onload',
'data', 'event', 'autofocus', 'onactivate', 'onanimationstart', 'onwebkittransitionend', 'onblur', 'poster',
'onratechange', 'ontoggle', 'onscroll', 'actiontype', 'dirname', 'srcdoc',
];
public const SELF_CLOSING_TAG = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'link', 'meta', 'param', 'source', 'track', 'wbr'];
/** /**
* Creates an HTML tag without attributes * Creates an HTML tag without attributes
*/ */
@ -78,13 +78,11 @@ abstract class HTML
$html = '<' . $tag . (\is_string($attrs) ? ($attrs ? ' ' : '') . $attrs : self::attr($attrs, $options)); $html = '<' . $tag . (\is_string($attrs) ? ($attrs ? ' ' : '') . $attrs : self::attr($attrs, $options));
if (\in_array($tag, self::SELF_CLOSING_TAG)) { if (\in_array($tag, self::SELF_CLOSING_TAG)) {
$html .= '>'; $html .= '>';
} elseif (($options['indent'] ?? true) && !\in_array($tag, self::NO_INDENT_TAGS)) {
$inner = Formatting::indent($content);
$html .= ">\n" . ($inner === '' ? '' : $inner . "\n") . "</{$tag}>";
} else { } else {
if (!\in_array($tag, self::NO_INDENT_TAGS) && ($options['indent'] ?? true)) { $html .= ">{$content}</{$tag}>";
$inner = Formatting::indent($content);
$html .= ">\n" . ($inner == '' ? '' : $inner . "\n") . "</{$tag}>";
} else {
$html .= ">{$content}</{$tag}>";
}
} }
return $html; return $html;
} }
@ -102,12 +100,11 @@ abstract class HTML
*/ */
private static function process_attribute(string $val, string $key, array $options): string private static function process_attribute(string $val, string $key, array $options): string
{ {
if (\in_array($key, array_merge($options['forbidden_attributes'] ?? [], self::FORBIDDEN_ATTRIBUTES)) if (\in_array($key, array_merge($options['forbidden_attributes'] ?? [], self::FORBIDDEN_ATTRIBUTES)) || str_starts_with($val, 'javascript:')) {
|| str_starts_with($val, 'javascript:')) {
throw new InvalidArgumentException("HTML::html: Attribute {$key} is not allowed"); throw new InvalidArgumentException("HTML::html: Attribute {$key} is not allowed");
} }
if (!($options['raw'] ?? false)) { if (!($options['raw'] ?? false)) {
$val = htmlspecialchars($val, flags: \ENT_QUOTES | \ENT_SUBSTITUTE, double_encode: false); $val = htmlspecialchars($val, flags: ENT_QUOTES | ENT_SUBSTITUTE, double_encode: false);
} }
return "{$key}=\"{$val}\""; return "{$key}=\"{$val}\"";
} }
@ -122,7 +119,7 @@ abstract class HTML
if ($options['raw'] ?? false) { if ($options['raw'] ?? false) {
return $html; return $html;
} else { } else {
return htmlspecialchars($html, flags: \ENT_QUOTES | \ENT_SUBSTITUTE, double_encode: false); return htmlspecialchars($html, flags: ENT_QUOTES | ENT_SUBSTITUTE, double_encode: false);
} }
} else { } else {
$out = ''; $out = '';
@ -134,10 +131,10 @@ abstract class HTML
$is_tag = \is_string($tag) && preg_match('/[A-Za-z][A-Za-z0-9]*/', $tag); $is_tag = \is_string($tag) && preg_match('/[A-Za-z][A-Za-z0-9]*/', $tag);
$inner = self::html($contents, $options); $inner = self::html($contents, $options);
if ($is_tag) { if ($is_tag) {
if (!\in_array($tag, array_merge($options['allowed_tags'] ?? [], self::ALLOWED_TAGS))) { if (!\in_array($tag, array_merge($options['allowed_tags'] ?? [], self::ALLOWED_TAGS), true)) {
throw new InvalidArgumentException("HTML::html: Tag {$tag} is not allowed"); throw new InvalidArgumentException("HTML::html: Tag {$tag} is not allowed");
} }
if (!empty($inner) && !\in_array($tag, self::NO_INDENT_TAGS) && ($options['indent'] ?? true)) { if (!empty($inner) && !\in_array($tag, self::NO_INDENT_TAGS, true) && ($options['indent'] ?? true)) {
$inner = "\n" . Formatting::indent($inner) . "\n"; $inner = "\n" . Formatting::indent($inner) . "\n";
} }
$out .= "<{$tag}{$attrs}>{$inner}</{$tag}>"; $out .= "<{$tag}{$attrs}>{$inner}</{$tag}>";
@ -154,8 +151,8 @@ abstract class HTML
{ {
if (method_exists(self::$sanitizer, $name)) { if (method_exists(self::$sanitizer, $name)) {
return self::$sanitizer->{$name}(...$args); return self::$sanitizer->{$name}(...$args);
} else {
throw new BadMethodCallException("Method Security::{$name} doesn't exist");
} }
throw new BadMethodCallException("Method Security::{$name} doesn't exist");
} }
} }

90
src/Util/HTML/Heading.php Normal file
View File

@ -0,0 +1,90 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
// GNU social is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// GNU social is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
namespace App\Util\HTML;
use function App\Core\I18n\_m;
use App\Util\HTML;
use Twig\Markup;
class Heading extends HTML
{
private string $heading_type = 'h1';
private string $heading_text;
private array $classes = [];
public function __construct(int $level, array $classes, string $text)
{
$this->setHeadingText($text);
foreach ($classes as $class) {
$this->addClass($class);
}
if ($level >= 1 && $level <= 6) {
$this->heading_type = 'h' . $level;
}
}
public function addClass(string $c): self
{
if (!\in_array($c, $this->classes, true)) {
$this->classes[] = $c;
}
return $this;
}
public function getHtml(): Markup
{
return new Markup($this->__toString(), 'UTF-8');
}
public function __toString()
{
return $this::html([$this->getHeadingType() => ['attrs' => ['class' => !empty($this->getClasses()) ? implode(' ', $this->getClasses()) : ''], _m($this->getHeadingText())]]);
}
public function getHeadingType(): string
{
return $this->heading_type;
}
public function setHeadingType(string $value): static
{
$this->heading_type = $value;
return $this;
}
public function getClasses(): array
{
return $this->classes;
}
public function getHeadingText(): string
{
return $this->heading_text;
}
public function setHeadingText(string $value): static
{
$this->heading_text = $value;
return $this;
}
}

View File

@ -1,11 +1,13 @@
{% extends '/collection/notes.html.twig' %} {% extends '/collection/notes.html.twig' %}
{% block title %}{% trans %}%nickname%'s profile{% endtrans %}{% endblock %} {% block title %}
{% if page_title is defined %}
{{ page_title }}
{% endif %}
{% endblock %}
{% block body %} {% block body %}
{% block profile_view %} {% include 'cards/blocks/profile.html.twig' with { profile_card_type: 'main' } %}
{% include '/cards/blocks/profile.html.twig' %} <hr>
{% endblock profile_view %}
{{ parent() }} {{ parent() }}
{% endblock body %} {% endblock body %}

View File

@ -73,21 +73,9 @@
<div class="note-text" tabindex="0" <div class="note-text" tabindex="0"
title="{{ 'Main note content' | trans }}"> title="{{ 'Main note content' | trans }}">
{% set paragraph_array = note.getRenderedSplit() %} {% set paragraph_array = note.getRenderedSplit() %}
{% if 'conversation' not in app.request.get('_route') and paragraph_array | length > 3 %} {% for paragraph in paragraph_array %}
<p>{{ paragraph_array[0] | raw }}</p> <p>{{ paragraph | raw }}</p>
<details class="note-text-details"> {% endfor %}
<summary class="note-text-details-summary">
<small>{% trans %}Expand to see all content{% endtrans %}</small>
</summary>
{% for paragraph in paragraph_array | slice(1, paragraph_array | length) %}
<p>{{ paragraph | raw }}</p>
{% endfor %}
</details>
{% else %}
{% for paragraph in paragraph_array %}
<p>{{ paragraph | raw }}</p>
{% endfor %}
{% endif %}
</div> </div>
{% endblock note_text %} {% endblock note_text %}

View File

@ -1,15 +1,15 @@
{% set actor_fullname = actor.getFullname() %}
{% set actor_nickname = actor.getNickname() %}
{% set actor_avatar = actor.getAvatarUrl() %}
{% set actor_avatar_dimensions = actor.getAvatarDimensions() %}
{% set actor_tags = actor.getSelfTags() %}
{% set actor_has_bio = actor.hasBio() %}
{% set actor_uri = actor.getUri() %}
{% set actor_url = actor.getUrl() %}
{% set actor_is_local = actor.getIsLocal() %}
{% set mention = mention(actor) %}
{% block profile_view %} {% block profile_view %}
{% set actor_fullname = actor.getFullname() %}
{% set actor_nickname = actor.getNickname() %}
{% set actor_avatar = actor.getAvatarUrl() %}
{% set actor_avatar_dimensions = actor.getAvatarDimensions() %}
{% set actor_tags = actor.getSelfTags() %}
{% set actor_has_bio = actor.hasBio() %}
{% set actor_uri = actor.getUri() %}
{% set actor_url = actor.getUrl() %}
{% set actor_is_local = actor.getIsLocal() %}
{% set mention = mention(actor) %}
<section id='profile-{{ actor.id }}' class='profile' <section id='profile-{{ actor.id }}' class='profile'
title="{% trans %} %actor_nickname%'s profile information{% endtrans %}"> title="{% trans %} %actor_nickname%'s profile information{% endtrans %}">
<header> <header>
@ -19,12 +19,20 @@
title="{% trans %} %actor_nickname%'s avatar{% endtrans %}" title="{% trans %} %actor_nickname%'s avatar{% endtrans %}"
width="{{ actor_avatar_dimensions['width'] }}" width="{{ actor_avatar_dimensions['width'] }}"
height="{{ actor_avatar_dimensions['height'] }}"> height="{{ actor_avatar_dimensions['height'] }}">
<section> <div>
<a class="profile-info-url" href="{{ actor_url }}"> <a class="profile-info-url" href="{{ actor_url }}">
<strong class="profile-info-url-nickname" {% if profile_card_type is defined and profile_card_type starts with 'main' %}
title="{% trans %} %actor_nickname%'s profile {% endtrans %}"> <h1 class="profile-info-url-nickname"
{% if actor_fullname is not null %}{{ actor_fullname }}{% else %}{{ actor_nickname }}{% endif %} title="{% trans %} %actor_nickname%'s profile {% endtrans %}">
</strong> {% if actor_fullname is not null %}{{ actor_fullname }}{% else %}{{ actor_nickname }}{% endif %}
</h1>
{% else %}
<strong class="profile-info-url-nickname"
title="{% trans %} %actor_nickname%'s profile {% endtrans %}">
{% if actor_fullname is not null %}{{ actor_fullname }}{% else %}{{ actor_nickname }}{% endif %}
</strong>
{% endif %}
{% if not actor_is_local %} {% if not actor_is_local %}
<span class="profile-info-url-remote"> <span class="profile-info-url-remote">
<a href="{{ actor_uri }}" class="u-url" title="{% trans %} %actor_nickname%'s permalink {% endtrans %}">{{ mention }}</a> <a href="{{ actor_uri }}" class="u-url" title="{% trans %} %actor_nickname%'s permalink {% endtrans %}">{{ mention }}</a>
@ -40,7 +48,7 @@
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
</section> </div>
</div> </div>
<div class="profile-stats"> <div class="profile-stats">
<span class="profile-stats-subscriptions" <span class="profile-stats-subscriptions"

View File

@ -21,7 +21,6 @@ declare(strict_types = 1);
namespace App\Tests\Util; namespace App\Tests\Util;
use App\Util\HTML;
use Jchook\AssertThrows\AssertThrows; use Jchook\AssertThrows\AssertThrows;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use TypeError; use TypeError;
@ -32,17 +31,17 @@ class HTMLTest extends WebTestCase
public function testHTML() public function testHTML()
{ {
static::assertSame('', HTML::html('')); static::assertSame('', HTML\HTML::html(''));
static::assertSame('<a></a>', HTML::html(['a' => ''])); static::assertSame('<a></a>', HTML\HTML::html(['a' => '']));
static::assertSame("<div>\n <p></p>\n</div>", HTML::html(['div' => ['p' => '']])); static::assertSame("<div>\n <p></p>\n</div>", HTML\HTML::html(['div' => ['p' => '']]));
static::assertSame("<div>\n <div>\n <p></p>\n </div>\n</div>", HTML::html(['div' => ['div' => ['p' => '']]])); static::assertSame("<div>\n <div>\n <p></p>\n </div>\n</div>", HTML\HTML::html(['div' => ['div' => ['p' => '']]]));
static::assertSame("<div>\n <div>\n <div>\n <p></p>\n </div>\n </div>\n</div>", HTML::html(['div' => ['div' => ['div' => ['p' => '']]]])); static::assertSame("<div>\n <div>\n <div>\n <p></p>\n </div>\n </div>\n</div>", HTML\HTML::html(['div' => ['div' => ['div' => ['p' => '']]]]));
static::assertSame('<a href="test"><p></p></a>', HTML::html(['a' => ['attrs' => ['href' => 'test'], 'p' => '']])); static::assertSame('<a href="test"><p></p></a>', HTML\HTML::html(['a' => ['attrs' => ['href' => 'test'], 'p' => '']]));
static::assertSame('<a><p>foo</p><br></a>', HTML::html(['a' => ['p' => 'foo', 'br' => 'empty']])); static::assertSame('<a><p>foo</p><br></a>', HTML\HTML::html(['a' => ['p' => 'foo', 'br' => 'empty']]));
static::assertSame("<div>\n <a><p>foo</p><br></a>\n</div>", HTML::html(['div' => ['a' => ['p' => 'foo', 'br' => 'empty']]])); static::assertSame("<div>\n <a><p>foo</p><br></a>\n</div>", HTML\HTML::html(['div' => ['a' => ['p' => 'foo', 'br' => 'empty']]]));
static::assertSame('<div><a><p>foo</p><br></a></div>', HTML::html(['div' => ['a' => ['p' => 'foo', 'br' => 'empty']]], options: ['indent' => false])); static::assertSame('<div><a><p>foo</p><br></a></div>', HTML\HTML::html(['div' => ['a' => ['p' => 'foo', 'br' => 'empty']]], options: ['indent' => false]));
static::assertThrows(TypeError::class, fn () => HTML::html(1)); static::assertThrows(TypeError::class, fn () => HTML\HTML::html(1));
static::assertSame('<a href="test">foo</a>', HTML::tag('a', ['href' => 'test'], content: 'foo', options: ['empty' => false])); static::assertSame('<a href="test">foo</a>', HTML\HTML::tag('a', ['href' => 'test'], content: 'foo', options: ['empty' => false]));
static::assertSame('<br>', HTML::tag('br', attrs: null, content: null, options: ['empty' => true])); static::assertSame('<br>', HTML\HTML::tag('br', attrs: null, content: null, options: ['empty' => true]));
} }
} }