diff --git a/src/BaseCollection.php b/src/BaseCollection.php index 97e772f16..f2a64151d 100644 --- a/src/BaseCollection.php +++ b/src/BaseCollection.php @@ -129,6 +129,24 @@ class BaseCollection extends \ArrayIterator return new static(array_reverse($this->getArrayCopy()), $this->getTotalCount()); } + /** + * Split the collection in smaller collections no bigger than the provided length + * + * @param int $length + * @return static[] + */ + public function chunk(int $length): array + { + if ($length < 1) { + throw new \RangeException('BaseCollection->chunk(): Size parameter expected to be greater than 0'); + } + + return array_map(function ($array) { + return new static($array); + }, array_chunk($this->getArrayCopy(), $length)); + } + + /** * @inheritDoc * diff --git a/src/Content/Image.php b/src/Content/Image.php new file mode 100644 index 000000000..cc2fd5122 --- /dev/null +++ b/src/Content/Image.php @@ -0,0 +1,154 @@ +. + * + */ + +namespace Friendica\Content; + +use Friendica\Content\Image\Collection\MasonryImageRow; +use Friendica\Content\Image\Entity\MasonryImage; +use Friendica\Content\Post\Collection\PostMedias; +use Friendica\Core\Renderer; + +class Image +{ + public static function getBodyAttachHtml(PostMedias $PostMediaImages): string + { + $media = ''; + + if ($PostMediaImages->haveDimensions()) { + if (count($PostMediaImages) > 1) { + $media = self::getHorizontalMasonryHtml($PostMediaImages); + } elseif (count($PostMediaImages) == 1) { + $media = Renderer::replaceMacros(Renderer::getMarkupTemplate('content/image/single_with_height_allocation.tpl'), [ + '$image' => $PostMediaImages[0], + '$allocated_height' => $PostMediaImages[0]->getAllocatedHeight(), + '$allocated_max_width' => ($PostMediaImages[0]->previewWidth ?? $PostMediaImages[0]->width) . 'px', + ]); + } + } else { + if (count($PostMediaImages) > 1) { + $media = self::getImageGridHtml($PostMediaImages); + } elseif (count($PostMediaImages) == 1) { + $media = Renderer::replaceMacros(Renderer::getMarkupTemplate('content/image/single.tpl'), [ + '$image' => $PostMediaImages[0], + ]); + } + } + + return $media; + } + + /** + * @param PostMedias $images + * @return string + * @throws \Friendica\Network\HTTPException\ServiceUnavailableException + */ + private static function getImageGridHtml(PostMedias $images): string + { + // Image for first column (fc) and second column (sc) + $images_fc = []; + $images_sc = []; + + for ($i = 0; $i < count($images); $i++) { + ($i % 2 == 0) ? ($images_fc[] = $images[$i]) : ($images_sc[] = $images[$i]); + } + + return Renderer::replaceMacros(Renderer::getMarkupTemplate('content/image/grid.tpl'), [ + 'columns' => [ + 'fc' => $images_fc, + 'sc' => $images_sc, + ], + ]); + } + + /** + * Creates a horizontally masoned gallery with a fixed maximum number of pictures per row. + * + * For each row, we calculate how much of the total width each picture will take depending on their aspect ratio + * and how much relative height it needs to accomodate all pictures next to each other with their height normalized. + * + * @param array $images + * @return string + * @throws \Friendica\Network\HTTPException\ServiceUnavailableException + */ + private static function getHorizontalMasonryHtml(PostMedias $images): string + { + static $column_size = 2; + + $rows = array_map( + function (PostMedias $PostMediaImages) { + if ($singleImageInRow = count($PostMediaImages) == 1) { + $PostMediaImages[] = $PostMediaImages[0]; + } + + $widths = []; + $heights = []; + foreach ($PostMediaImages as $PostMediaImage) { + if ($PostMediaImage->width && $PostMediaImage->height) { + $widths[] = $PostMediaImage->width; + $heights[] = $PostMediaImage->height; + } else { + $widths[] = $PostMediaImage->previewWidth; + $heights[] = $PostMediaImage->previewHeight; + } + } + + $maxHeight = max($heights); + + // Corrected width preserving aspect ratio when all images on a row are the same height + $correctedWidths = []; + foreach ($widths as $i => $width) { + $correctedWidths[] = $width * $maxHeight / $heights[$i]; + } + + $totalWidth = array_sum($correctedWidths); + + $row_images2 = []; + + if ($singleImageInRow) { + unset($PostMediaImages[1]); + } + + foreach ($PostMediaImages as $i => $PostMediaImage) { + $row_images2[] = new MasonryImage( + $PostMediaImage->uriId, + $PostMediaImage->url, + $PostMediaImage->preview, + $PostMediaImage->description ?? '', + 100 * $correctedWidths[$i] / $totalWidth, + 100 * $maxHeight / $correctedWidths[$i] + ); + } + + // This magic value will stay constant for each image of any given row and is ultimately + // used to determine the height of the row container relative to the available width. + $commonHeightRatio = 100 * $correctedWidths[0] / $totalWidth / ($widths[0] / $heights[0]); + + return new MasonryImageRow($row_images2, count($row_images2), $commonHeightRatio); + }, + $images->chunk($column_size) + ); + + return Renderer::replaceMacros(Renderer::getMarkupTemplate('content/image/horizontal_masonry.tpl'), [ + '$rows' => $rows, + '$column_size' => $column_size, + ]); + } +} diff --git a/src/Content/Image/Collection/MasonryImageRow.php b/src/Content/Image/Collection/MasonryImageRow.php new file mode 100644 index 000000000..ff507786f --- /dev/null +++ b/src/Content/Image/Collection/MasonryImageRow.php @@ -0,0 +1,57 @@ +. + * + */ + +namespace Friendica\Content\Image\Collection; + +use Friendica\Content\Image\Entity; +use Friendica\BaseCollection; +use Friendica\Content\Image\Entity\MasonryImage; + +class MasonryImageRow extends BaseCollection +{ + /** @var ?float */ + protected $heightRatio; + + /** + * @param MasonryImage[] $entities + * @param int|null $totalCount + * @param float|null $heightRatio + */ + public function __construct(array $entities = [], int $totalCount = null, float $heightRatio = null) + { + parent::__construct($entities, $totalCount); + + $this->heightRatio = $heightRatio; + } + + /** + * @return Entity\MasonryImage + */ + public function current(): Entity\MasonryImage + { + return parent::current(); + } + + public function getHeightRatio(): ?float + { + return $this->heightRatio; + } +} diff --git a/src/Content/Image/Entity/MasonryImage.php b/src/Content/Image/Entity/MasonryImage.php new file mode 100644 index 000000000..e85688ea2 --- /dev/null +++ b/src/Content/Image/Entity/MasonryImage.php @@ -0,0 +1,60 @@ +. + * + */ + +namespace Friendica\Content\Image\Entity; + +use Friendica\BaseEntity; +use Psr\Http\Message\UriInterface; + +/** + * @property-read int $uriId + * @property-read UriInterface $url + * @property-read ?UriInterface $preview + * @property-read string $description + * @property-read float $heightRatio + * @property-read float $widthRatio + * @see \Friendica\Content\Image::getHorizontalMasonryHtml() + */ +class MasonryImage extends BaseEntity +{ + /** @var int */ + protected $uriId; + /** @var UriInterface */ + protected $url; + /** @var ?UriInterface */ + protected $preview; + /** @var string */ + protected $description; + /** @var float Ratio of the width of the image relative to the total width of the images on the row */ + protected $widthRatio; + /** @var float Ratio of the height of the image relative to its width for height allocation */ + protected $heightRatio; + + public function __construct(int $uriId, UriInterface $url, ?UriInterface $preview, string $description, float $widthRatio, float $heightRatio) + { + $this->url = $url; + $this->uriId = $uriId; + $this->preview = $preview; + $this->description = $description; + $this->widthRatio = $widthRatio; + $this->heightRatio = $heightRatio; + } +} diff --git a/src/Content/Post/Collection/PostMedias.php b/src/Content/Post/Collection/PostMedias.php new file mode 100644 index 000000000..9f7d10d0c --- /dev/null +++ b/src/Content/Post/Collection/PostMedias.php @@ -0,0 +1,57 @@ +. + * + */ + +namespace Friendica\Content\Post\Collection; + +use Friendica\BaseCollection; +use Friendica\Content\Post\Entity; + +class PostMedias extends BaseCollection +{ + /** + * @param Entity\PostMedia[] $entities + * @param int|null $totalCount + */ + public function __construct(array $entities = [], int $totalCount = null) + { + parent::__construct($entities, $totalCount); + } + + /** + * @return Entity\PostMedia + */ + public function current(): Entity\PostMedia + { + return parent::current(); + } + + /** + * Determine whether all the collection's item have at least one set of dimensions provided + * + * @return bool + */ + public function haveDimensions(): bool + { + return array_reduce($this->getArrayCopy(), function (bool $carry, Entity\PostMedia $item) { + return $carry && $item->hasDimensions(); + }, true); + } +} diff --git a/src/Content/Post/Entity/PostMedia.php b/src/Content/Post/Entity/PostMedia.php new file mode 100644 index 000000000..822062419 --- /dev/null +++ b/src/Content/Post/Entity/PostMedia.php @@ -0,0 +1,300 @@ +. + * + */ + +namespace Friendica\Content\Post\Entity; + +use Friendica\BaseEntity; +use Friendica\Network\Entity\MimeType; +use Friendica\Util\Images; +use Friendica\Util\Proxy; +use Psr\Http\Message\UriInterface; + + +/** + * @property-read int $id + * @property-read int $uriId + * @property-read ?int $activityUriId + * @property-read UriInterface $url + * @property-read int $type + * @property-read MimeType $mimetype + * @property-read ?int $width + * @property-read ?int $height + * @property-read ?int $size + * @property-read ?UriInterface $preview + * @property-read ?int $previewWidth + * @property-read ?int $previewHeight + * @property-read ?string $description + * @property-read ?string $name + * @property-read ?UriInterface $authorUrl + * @property-read ?string $authorName + * @property-read ?UriInterface $authorImage + * @property-read ?UriInterface $publisherUrl + * @property-read ?string $publisherName + * @property-read ?UriInterface $publisherImage + * @property-read ?string $blurhash + */ +class PostMedia extends BaseEntity +{ + const TYPE_UNKNOWN = 0; + const TYPE_IMAGE = 1; + const TYPE_VIDEO = 2; + const TYPE_AUDIO = 3; + const TYPE_TEXT = 4; + const TYPE_APPLICATION = 5; + const TYPE_TORRENT = 16; + const TYPE_HTML = 17; + const TYPE_XML = 18; + const TYPE_PLAIN = 19; + const TYPE_ACTIVITY = 20; + const TYPE_ACCOUNT = 21; + const TYPE_DOCUMENT = 128; + + /** @var int */ + protected $id; + /** @var int */ + protected $uriId; + /** @var UriInterface */ + protected $url; + /** @var int One of TYPE_* */ + protected $type; + /** @var MimeType */ + protected $mimetype; + /** @var ?int */ + protected $activityUriId; + /** @var ?int In pixels */ + protected $width; + /** @var ?int In pixels */ + protected $height; + /** @var ?int In bytes */ + protected $size; + /** @var ?UriInterface Preview URL */ + protected $preview; + /** @var ?int In pixels */ + protected $previewWidth; + /** @var ?int In pixels */ + protected $previewHeight; + /** @var ?string Alternative text like for images */ + protected $description; + /** @var ?string */ + protected $name; + /** @var ?UriInterface */ + protected $authorUrl; + /** @var ?string */ + protected $authorName; + /** @var ?UriInterface Image URL */ + protected $authorImage; + /** @var ?UriInterface */ + protected $publisherUrl; + /** @var ?string */ + protected $publisherName; + /** @var ?UriInterface Image URL */ + protected $publisherImage; + /** @var ?string Blurhash string representation for images + * @see https://github.com/woltapp/blurhash + * @see https://blurha.sh/ + */ + protected $blurhash; + + public function __construct( + int $uriId, + UriInterface $url, + int $type, + MimeType $mimetype, + ?int $activityUriId, + ?int $width = null, + ?int $height = null, + ?int $size = null, + ?UriInterface $preview = null, + ?int $previewWidth = null, + ?int $previewHeight = null, + ?string $description = null, + ?string $name = null, + ?UriInterface $authorUrl = null, + ?string $authorName = null, + ?UriInterface $authorImage = null, + ?UriInterface $publisherUrl = null, + ?string $publisherName = null, + ?UriInterface $publisherImage = null, + ?string $blurhash = null, + int $id = null + ) + { + $this->uriId = $uriId; + $this->url = $url; + $this->type = $type; + $this->mimetype = $mimetype; + $this->activityUriId = $activityUriId; + $this->width = $width; + $this->height = $height; + $this->size = $size; + $this->preview = $preview; + $this->previewWidth = $previewWidth; + $this->previewHeight = $previewHeight; + $this->description = $description; + $this->name = $name; + $this->authorUrl = $authorUrl; + $this->authorName = $authorName; + $this->authorImage = $authorImage; + $this->publisherUrl = $publisherUrl; + $this->publisherName = $publisherName; + $this->publisherImage = $publisherImage; + $this->blurhash = $blurhash; + $this->id = $id; + } + + + /** + * Get media link for given media id + * + * @param string $size One of the Proxy::SIZE_* constants + * @return string media link + */ + public function getPhotoPath(string $size = ''): string + { + return '/photo/media/' . + (Proxy::getPixelsFromSize($size) ? Proxy::getPixelsFromSize($size) . '/' : '') . + $this->id; + } + + /** + * Get preview path for given media id relative to the base URL + * + * @param string $size One of the Proxy::SIZE_* constants + * @return string preview link + */ + public function getPreviewPath(string $size = ''): string + { + return '/photo/preview/' . + (Proxy::getPixelsFromSize($size) ? Proxy::getPixelsFromSize($size) . '/' : '') . + $this->id; + + } + + /** + * Computes the allocated height value used in the content/image/single_with_height_allocation.tpl template + * + * Either base or preview dimensions need to be set at runtime. + * + * @return string + */ + public function getAllocatedHeight(): string + { + if (!$this->hasDimensions()) { + throw new \RangeException('Either width and height or previewWidth and previewHeight must be defined to use this method.'); + } + + if ($this->width && $this->height) { + $width = $this->width; + $height = $this->height; + } else { + $width = $this->previewWidth; + $height = $this->previewHeight; + } + + return (100 * $height / $width) . '%'; + } + + /** + * Return a new PostMedia entity with a different preview URI and an optional proxy size name. + * The new entity preview's width and height are rescaled according to the provided size. + * + * @param \GuzzleHttp\Psr7\Uri $preview + * @param string $size + * @return $this + */ + public function withPreview(\GuzzleHttp\Psr7\Uri $preview, string $size = ''): self + { + if ($this->width || $this->height) { + $newWidth = $this->width; + $newHeight = $this->height; + } else { + $newWidth = $this->previewWidth; + $newHeight = $this->previewHeight; + } + + if ($newWidth && $newHeight && $size) { + $dimensionts = Images::getScalingDimensions($newWidth, $newHeight, Proxy::getPixelsFromSize($size)); + $newWidth = $dimensionts['width']; + $newHeight = $dimensionts['height']; + } + + return new static( + $this->uriId, + $this->url, + $this->type, + $this->mimetype, + $this->activityUriId, + $this->width, + $this->height, + $this->size, + $preview, + $newWidth, + $newHeight, + $this->description, + $this->name, + $this->authorUrl, + $this->authorName, + $this->authorImage, + $this->publisherUrl, + $this->publisherName, + $this->publisherImage, + $this->blurhash, + $this->id, + ); + } + + public function withUrl(\GuzzleHttp\Psr7\Uri $url): self + { + return new static( + $this->uriId, + $url, + $this->type, + $this->mimetype, + $this->activityUriId, + $this->width, + $this->height, + $this->size, + $this->preview, + $this->previewWidth, + $this->previewHeight, + $this->description, + $this->name, + $this->authorUrl, + $this->authorName, + $this->authorImage, + $this->publisherUrl, + $this->publisherName, + $this->publisherImage, + $this->blurhash, + $this->id, + ); + } + + /** + * Checks the media has at least one full set of dimensions, needed for the height allocation feature + * + * @return bool + */ + public function hasDimensions(): bool + { + return $this->width && $this->height || $this->previewWidth && $this->previewHeight; + } +} diff --git a/src/Content/Post/Factory/PostMedia.php b/src/Content/Post/Factory/PostMedia.php new file mode 100644 index 000000000..fe71b2197 --- /dev/null +++ b/src/Content/Post/Factory/PostMedia.php @@ -0,0 +1,117 @@ +. + * + */ + +namespace Friendica\Content\Post\Factory; + +use Friendica\BaseFactory; +use Friendica\Capabilities\ICanCreateFromTableRow; +use Friendica\Content\Post\Entity; +use Friendica\Network; +use GuzzleHttp\Psr7\Uri; +use Psr\Log\LoggerInterface; +use stdClass; + +class PostMedia extends BaseFactory implements ICanCreateFromTableRow +{ + /** @var Network\Factory\MimeType */ + private $mimeTypeFactory; + + public function __construct(Network\Factory\MimeType $mimeTypeFactory, LoggerInterface $logger) + { + parent::__construct($logger); + + $this->mimeTypeFactory = $mimeTypeFactory; + } + + /** + * @inheritDoc + */ + public function createFromTableRow(array $row) + { + return new Entity\PostMedia( + $row['uri-id'], + $row['url'] ? new Uri($row['url']) : null, + $row['type'], + $this->mimeTypeFactory->createFromContentType($row['mimetype']), + $row['media-uri-id'], + $row['width'], + $row['height'], + $row['size'], + $row['preview'] ? new Uri($row['preview']) : null, + $row['preview-width'], + $row['preview-height'], + $row['description'], + $row['name'], + $row['author-url'] ? new Uri($row['author-url']) : null, + $row['author-name'], + $row['author-image'] ? new Uri($row['author-image']) : null, + $row['publisher-url'] ? new Uri($row['publisher-url']) : null, + $row['publisher-name'], + $row['publisher-image'] ? new Uri($row['publisher-image']) : null, + $row['blurhash'], + $row['id'] + ); + } + + public function createFromBlueskyImageEmbed(int $uriId, stdClass $image): Entity\PostMedia + { + return new Entity\PostMedia( + $uriId, + new Uri($image->fullsize), + Entity\PostMedia::TYPE_IMAGE, + new Network\Entity\MimeType('unkn', 'unkn'), + null, + null, + null, + null, + new Uri($image->thumb), + null, + null, + $image->alt, + ); + } + + + public function createFromBlueskyExternalEmbed(int $uriId, stdClass $external): Entity\PostMedia + { + return new Entity\PostMedia( + $uriId, + new Uri($external->uri), + Entity\PostMedia::TYPE_HTML, + new Network\Entity\MimeType('text', 'html'), + null, + null, + null, + null, + null, + null, + null, + $external->description, + $external->title + ); + } + + public function createFromAttachment(int $uriId, array $attachment) + { + $attachment['uri-id'] = $uriId; + return $this->createFromTableRow($attachment); + } +} diff --git a/src/Content/Post/Repository/PostMedia.php b/src/Content/Post/Repository/PostMedia.php new file mode 100644 index 000000000..405d9eb86 --- /dev/null +++ b/src/Content/Post/Repository/PostMedia.php @@ -0,0 +1,204 @@ +. + * + */ + +namespace Friendica\Content\Post\Repository; + +use Friendica\BaseCollection; +use Friendica\BaseRepository; +use Friendica\Content\Post\Collection; +use Friendica\Content\Post\Entity; +use Friendica\Content\Post\Factory; +use Friendica\Database\Database; +use Friendica\Util\Strings; +use Psr\Log\LoggerInterface; + +class PostMedia extends BaseRepository +{ + protected static $table_name = 'post-media'; + + public function __construct(Database $database, LoggerInterface $logger, Factory\PostMedia $factory) + { + parent::__construct($database, $logger, $factory); + } + + protected function _select(array $condition, array $params = []): BaseCollection + { + $rows = $this->db->selectToArray(static::$table_name, [], $condition, $params); + + $Entities = new Collection\PostMedias(); + foreach ($rows as $fields) { + $Entities[] = $this->factory->createFromTableRow($fields); + } + + return $Entities; + } + + public function selectOneById(int $postMediaId): Entity\PostMedia + { + return $this->_selectOne(['id' => $postMediaId]); + } + + public function selectByUriId(int $uriId): Collection\PostMedias + { + return $this->_select(['uri-id' => $uriId]); + } + + public function save(Entity\PostMedia $PostMedia): Entity\PostMedia + { + $fields = [ + 'uri-id' => $PostMedia->uriId, + 'url' => $PostMedia->url->__toString(), + 'type' => $PostMedia->type, + 'mimetype' => $PostMedia->mimetype->__toString(), + 'height' => $PostMedia->height, + 'width' => $PostMedia->width, + 'size' => $PostMedia->size, + 'preview' => $PostMedia->preview ? $PostMedia->preview->__toString() : null, + 'preview-height' => $PostMedia->previewHeight, + 'preview-width' => $PostMedia->previewWidth, + 'description' => $PostMedia->description, + 'name' => $PostMedia->name, + 'author-url' => $PostMedia->authorUrl ? $PostMedia->authorUrl->__toString() : null, + 'author-name' => $PostMedia->authorName, + 'author-image' => $PostMedia->authorImage ? $PostMedia->authorImage->__toString() : null, + 'publisher-url' => $PostMedia->publisherUrl ? $PostMedia->publisherUrl->__toString() : null, + 'publisher-name' => $PostMedia->publisherName, + 'publisher-image' => $PostMedia->publisherImage ? $PostMedia->publisherImage->__toString() : null, + 'media-uri-id' => $PostMedia->activityUriId, + 'blurhash' => $PostMedia->blurhash, + ]; + + if ($PostMedia->id) { + $this->db->update(self::$table_name, $fields, ['id' => $PostMedia->id]); + } else { + $this->db->insert(self::$table_name, $fields, Database::INSERT_IGNORE); + + $newPostMediaId = $this->db->lastInsertId(); + + $PostMedia = $this->selectOneById($newPostMediaId); + } + + return $PostMedia; + } + + + /** + * Split the attachment media in the three segments "visual", "link" and "additional" + * + * @param int $uri_id URI id + * @param array $links list of links that shouldn't be added + * @param bool $has_media + * @return Collection\PostMedias[] Three collections in "visual", "link" and "additional" keys + */ + public function splitAttachments(int $uri_id, array $links = [], bool $has_media = true): array + { + $attachments = [ + 'visual' => new Collection\PostMedias(), + 'link' => new Collection\PostMedias(), + 'additional' => new Collection\PostMedias(), + ]; + + if (!$has_media) { + return $attachments; + } + + $PostMedias = $this->selectByUriId($uri_id); + if (!count($PostMedias)) { + return $attachments; + } + + $heights = []; + $selected = ''; + $previews = []; + + foreach ($PostMedias as $PostMedia) { + foreach ($links as $link) { + if (Strings::compareLink($link, $PostMedia->url)) { + continue 2; + } + } + + // Avoid adding separate media entries for previews + foreach ($previews as $preview) { + if (Strings::compareLink($preview, $PostMedia->url)) { + continue 2; + } + } + + // Currently these two types are ignored here. + // Posts are added differently and contacts are not displayed as attachments. + if (in_array($PostMedia->type, [Entity\PostMedia::TYPE_ACCOUNT, Entity\PostMedia::TYPE_ACTIVITY])) { + continue; + } + + if (!empty($PostMedia->preview)) { + $previews[] = $PostMedia->preview; + } + + //$PostMedia->filetype = $filetype; + //$PostMedia->subtype = $subtype; + + if ($PostMedia->type == Entity\PostMedia::TYPE_HTML || ($PostMedia->mimetype->type == 'text' && $PostMedia->mimetype->subtype == 'html')) { + $attachments['link'][] = $PostMedia; + continue; + } + + if ( + in_array($PostMedia->type, [Entity\PostMedia::TYPE_AUDIO, Entity\PostMedia::TYPE_IMAGE]) || + in_array($PostMedia->mimetype->type, ['audio', 'image']) + ) { + $attachments['visual'][] = $PostMedia; + } elseif (($PostMedia->type == Entity\PostMedia::TYPE_VIDEO) || ($PostMedia->mimetype->type == 'video')) { + if (!empty($PostMedia->height)) { + // Peertube videos are delivered in many different resolutions. We pick a moderate one. + // Since only Peertube provides a "height" parameter, this wouldn't be executed + // when someone for example on Mastodon was sharing multiple videos in a single post. + $heights[$PostMedia->height] = (string)$PostMedia->url; + $video[(string) $PostMedia->url] = $PostMedia; + } else { + $attachments['visual'][] = $PostMedia; + } + } else { + $attachments['additional'][] = $PostMedia; + } + } + + if (!empty($heights)) { + ksort($heights); + foreach ($heights as $height => $url) { + if (empty($selected) || $height <= 480) { + $selected = $url; + } + } + + if (!empty($selected)) { + $attachments['visual'][] = $video[$selected]; + unset($video[$selected]); + foreach ($video as $element) { + $attachments['additional'][] = $element; + } + } + } + + return $attachments; + } + +} diff --git a/src/DI.php b/src/DI.php index 681c8fcbc..5ffce5175 100644 --- a/src/DI.php +++ b/src/DI.php @@ -731,4 +731,9 @@ abstract class DI { return self::$dice->create(Util\Emailer::class); } + + public static function postMediaRepository(): Content\Post\Repository\PostMedia + { + return self::$dice->create(Content\Post\Repository\PostMedia::class); + } } diff --git a/src/Model/Item.php b/src/Model/Item.php index bed726704..457d76ec7 100644 --- a/src/Model/Item.php +++ b/src/Model/Item.php @@ -22,6 +22,9 @@ namespace Friendica\Model; use Friendica\Contact\LocalRelationship\Entity\LocalRelationship; +use Friendica\Content\Image; +use Friendica\Content\Post\Collection\PostMedias; +use Friendica\Content\Post\Entity\PostMedia; use Friendica\Content\Text\BBCode; use Friendica\Content\Text\HTML; use Friendica\Core\Hook; @@ -34,6 +37,7 @@ use Friendica\Database\DBA; use Friendica\DI; use Friendica\Model\Post\Category; use Friendica\Network\HTTPException\InternalServerErrorException; +use Friendica\Network\HTTPException\ServiceUnavailableException; use Friendica\Protocol\Activity; use Friendica\Protocol\ActivityPub; use Friendica\Protocol\Delivery; @@ -3175,15 +3179,15 @@ class Item if (!empty($shared_item['uri-id'])) { $shared_uri_id = $shared_item['uri-id']; $shared_links[] = strtolower($shared_item['plink']); - $shared_attachments = Post\Media::splitAttachments($shared_uri_id, [], $shared_item['has-media']); - $shared_links = array_merge($shared_links, array_column($shared_attachments['visual'], 'url')); - $shared_links = array_merge($shared_links, array_column($shared_attachments['link'], 'url')); - $shared_links = array_merge($shared_links, array_column($shared_attachments['additional'], 'url')); - $item['body'] = self::replaceVisualAttachments($shared_attachments, $item['body']); + $sharedSplitAttachments = DI::postMediaRepository()->splitAttachments($shared_uri_id, [], $shared_item['has-media']); + $shared_links = array_merge($shared_links, $sharedSplitAttachments['visual']->column('url')); + $shared_links = array_merge($shared_links, $sharedSplitAttachments['link']->column('url')); + $shared_links = array_merge($shared_links, $sharedSplitAttachments['additional']->column('url')); + $item['body'] = self::replaceVisualAttachments($sharedSplitAttachments['visual'], $item['body']); } - $attachments = Post\Media::splitAttachments($item['uri-id'], $shared_links, $item['has-media'] ?? false); - $item['body'] = self::replaceVisualAttachments($attachments, $item['body'] ?? ''); + $itemSplitAttachments = DI::postMediaRepository()->splitAttachments($item['uri-id'], $shared_links, $item['has-media'] ?? false); + $item['body'] = self::replaceVisualAttachments($itemSplitAttachments['visual'], $item['body'] ?? ''); self::putInCache($item); $item['body'] = $body; @@ -3208,7 +3212,7 @@ class Item $filter_reasons[] = DI::l10n()->t('Content warning: %s', $item['content-warning']); } - $item['attachments'] = $attachments; + $item['attachments'] = $itemSplitAttachments; $hook_data = [ 'item' => $item, @@ -3237,11 +3241,11 @@ class Item return $s; } - if (!empty($shared_attachments)) { - $s = self::addGallery($s, $shared_attachments, $item['uri-id']); - $s = self::addVisualAttachments($shared_attachments, $shared_item, $s, true); - $s = self::addLinkAttachment($shared_uri_id ?: $item['uri-id'], $shared_attachments, $body, $s, true, $quote_shared_links); - $s = self::addNonVisualAttachments($shared_attachments, $item, $s, true); + if (!empty($sharedSplitAttachments)) { + $s = self::addGallery($s, $sharedSplitAttachments['visual']); + $s = self::addVisualAttachments($sharedSplitAttachments['visual'], $shared_item, $s, true); + $s = self::addLinkAttachment($shared_uri_id ?: $item['uri-id'], $sharedSplitAttachments, $body, $s, true, $quote_shared_links); + $s = self::addNonVisualAttachments($sharedSplitAttachments['additional'], $item, $s, true); $body = BBCode::removeSharedData($body); } @@ -3251,10 +3255,10 @@ class Item $s = substr($s, 0, $pos); } - $s = self::addGallery($s, $attachments, $item['uri-id']); - $s = self::addVisualAttachments($attachments, $item, $s, false); - $s = self::addLinkAttachment($item['uri-id'], $attachments, $body, $s, false, $shared_links); - $s = self::addNonVisualAttachments($attachments, $item, $s, false); + $s = self::addGallery($s, $itemSplitAttachments['visual']); + $s = self::addVisualAttachments($itemSplitAttachments['visual'], $item, $s, false); + $s = self::addLinkAttachment($item['uri-id'], $itemSplitAttachments, $body, $s, false, $shared_links); + $s = self::addNonVisualAttachments($itemSplitAttachments['additional'], $item, $s, false); $s = self::addQuestions($item, $s); // Map. @@ -3282,45 +3286,34 @@ class Item return $hook_data['html']; } - /** - * @param array $images - * @return string - * @throws \Friendica\Network\HTTPException\ServiceUnavailableException - */ - private static function makeImageGrid(array $images): string - { - // Image for first column (fc) and second column (sc) - $images_fc = []; - $images_sc = []; - - for ($i = 0; $i < count($images); $i++) { - ($i % 2 == 0) ? ($images_fc[] = $images[$i]) : ($images_sc[] = $images[$i]); - } - - return Renderer::replaceMacros(Renderer::getMarkupTemplate('content/image_grid.tpl'), [ - 'columns' => [ - 'fc' => $images_fc, - 'sc' => $images_sc, - ], - ]); - } - /** * Modify links to pictures to links for the "Fancybox" gallery * - * @param string $s - * @param array $attachments - * @param integer $uri_id + * @param string $s + * @param PostMedias $PostMedias * @return string */ - private static function addGallery(string $s, array $attachments, int $uri_id): string + private static function addGallery(string $s, PostMedias $PostMedias): string { - foreach ($attachments['visual'] as $attachment) { - if (empty($attachment['preview']) || ($attachment['type'] != Post\Media::IMAGE)) { + foreach ($PostMedias as $PostMedia) { + if (!$PostMedia->preview || ($PostMedia->type !== Post\Media::IMAGE)) { continue; } - $s = str_replace('hasDimensions()) { + $pattern = '#(.*?)">#'; + + $s = preg_replace_callback($pattern, function () use ($PostMedia) { + return Renderer::replaceMacros(Renderer::getMarkupTemplate('content/image/single_with_height_allocation.tpl'), [ + '$image' => $PostMedia, + '$allocated_height' => $PostMedia->getAllocatedHeight(), + ]); + }, $s); + } else { + $s = str_replace('startRecording('rendering'); - foreach ($attachments['visual'] as $attachment) { - if (!empty($attachment['preview'])) { - if (Network::isLocalLink($attachment['preview'])) { + foreach ($PostMedias as $PostMedia) { + if ($PostMedia->preview) { + if (DI::baseUrl()->isLocalUri($PostMedia->preview)) { continue; } - $proxy = Post\Media::getPreviewUrlForId($attachment['id'], Proxy::SIZE_LARGE); - $search = ['[img=' . $attachment['preview'] . ']', ']' . $attachment['preview'] . '[/img]']; + $proxy = DI::baseUrl() . $PostMedia->getPreviewPath(Proxy::SIZE_LARGE); + $search = ['[img=' . $PostMedia->preview . ']', ']' . $PostMedia->preview . '[/img]']; $replace = ['[img=' . $proxy . ']', ']' . $proxy . '[/img]']; $body = str_replace($search, $replace, $body); - } elseif ($attachment['filetype'] == 'image') { - if (Network::isLocalLink($attachment['url'])) { + } elseif ($PostMedia->mimetype->type == 'image') { + if (DI::baseUrl()->isLocalUri($PostMedia->url)) { continue; } - $proxy = Post\Media::getUrlForId($attachment['id']); - $search = ['[img=' . $attachment['url'] . ']', ']' . $attachment['url'] . '[/img]']; + $proxy = DI::baseUrl() . $PostMedia->getPreviewPath(Proxy::SIZE_LARGE); + $search = ['[img=' . $PostMedia->url . ']', ']' . $PostMedia->url . '[/img]']; $replace = ['[img=' . $proxy . ']', ']' . $proxy . '[/img]']; $body = str_replace($search, $replace, $body); @@ -3414,29 +3407,34 @@ class Item /** * Add visual attachments to the content * - * @param array $attachments - * @param array $item - * @param string $content + * @param PostMedias $PostMedias + * @param array $item + * @param string $content + * @param bool $shared * @return string modified content + * @throws ServiceUnavailableException */ - private static function addVisualAttachments(array $attachments, array $item, string $content, bool $shared): string + private static function addVisualAttachments(PostMedias $PostMedias, array $item, string $content, bool $shared): string { DI::profiler()->startRecording('rendering'); $leading = ''; $trailing = ''; - $images = []; + $images = new PostMedias(); // @todo In the future we should make a single for the template engine with all media in it. This allows more flexibilty. - foreach ($attachments['visual'] as $attachment) { - if (self::containsLink($item['body'], $attachment['preview'] ?? $attachment['url'], $attachment['type'])) { + foreach ($PostMedias as $PostMedia) { + if (self::containsLink($item['body'], $PostMedia->preview ?? $PostMedia->url, $PostMedia->type)) { continue; } - if ($attachment['filetype'] == 'image') { - $preview_url = Post\Media::getPreviewUrlForId($attachment['id'], ($attachment['width'] > $attachment['height']) ? Proxy::SIZE_MEDIUM : Proxy::SIZE_LARGE); - } elseif (!empty($attachment['preview'])) { - $preview_url = Post\Media::getPreviewUrlForId($attachment['id'], Proxy::SIZE_LARGE); + if ($PostMedia->mimetype->type == 'image') { + $preview_size = $PostMedia->width > $PostMedia->height ? Proxy::SIZE_MEDIUM : Proxy::SIZE_LARGE; + $preview_url = DI::baseUrl() . $PostMedia->getPreviewPath($preview_size); + } elseif ($PostMedia->preview) { + $preview_size = Proxy::SIZE_LARGE; + $preview_url = DI::baseUrl() . $PostMedia->getPreviewPath($preview_size); } else { + $preview_size = 0; $preview_url = ''; } @@ -3444,15 +3442,15 @@ class Item continue; } - if (($attachment['filetype'] == 'video')) { + if ($PostMedia->mimetype->type == 'video') { /// @todo Move the template to /content as well $media = Renderer::replaceMacros(Renderer::getMarkupTemplate('video_top.tpl'), [ '$video' => [ - 'id' => $attachment['id'], - 'src' => $attachment['url'], - 'name' => $attachment['name'] ?: $attachment['url'], + 'id' => $PostMedia->id, + 'src' => (string)$PostMedia->url, + 'name' => $PostMedia->name ?: $PostMedia->url, 'preview' => $preview_url, - 'mime' => $attachment['mimetype'], + 'mime' => (string)$PostMedia->mimetype, ], ]); if (($item['post-type'] ?? null) == Item::PT_VIDEO) { @@ -3460,13 +3458,13 @@ class Item } else { $trailing .= $media; } - } elseif ($attachment['filetype'] == 'audio') { + } elseif ($PostMedia->mimetype->type == 'audio') { $media = Renderer::replaceMacros(Renderer::getMarkupTemplate('content/audio.tpl'), [ '$audio' => [ - 'id' => $attachment['id'], - 'src' => $attachment['url'], - 'name' => $attachment['name'] ?: $attachment['url'], - 'mime' => $attachment['mimetype'], + 'id' => $PostMedia->id, + 'src' => (string)$PostMedia->url, + 'name' => $PostMedia->name ?: $PostMedia->url, + 'mime' => (string)$PostMedia->mimetype, ], ]); if (($item['post-type'] ?? null) == Item::PT_AUDIO) { @@ -3474,23 +3472,17 @@ class Item } else { $trailing .= $media; } - } elseif ($attachment['filetype'] == 'image') { - $src_url = Post\Media::getUrlForId($attachment['id']); + } elseif ($PostMedia->mimetype->type == 'image') { + $src_url = DI::baseUrl() . $PostMedia->getPhotoPath(); if (self::containsLink($item['body'], $src_url)) { continue; } - $images[] = ['src' => $src_url, 'preview' => $preview_url, 'attachment' => $attachment, 'uri_id' => $item['uri-id']]; + + $images[] = $PostMedia->withUrl(new Uri($src_url))->withPreview(new Uri($preview_url), $preview_size); } } - $media = ''; - if (count($images) > 1) { - $media = self::makeImageGrid($images); - } elseif (count($images) == 1) { - $media = Renderer::replaceMacros(Renderer::getMarkupTemplate('content/image.tpl'), [ - '$image' => $images[0], - ]); - } + $media = Image::getBodyAttachHtml($images); // On Diaspora posts the attached pictures are leading if ($item['network'] == Protocol::DIASPORA) { @@ -3519,59 +3511,62 @@ class Item /** * Add link attachment to the content * - * @param array $attachments - * @param string $body - * @param string $content - * @param bool $shared - * @param array $ignore_links A list of URLs to ignore + * @param int $uriid + * @param PostMedias[] $attachments + * @param string $body + * @param string $content + * @param bool $shared + * @param array $ignore_links A list of URLs to ignore * @return string modified content + * @throws InternalServerErrorException + * @throws ServiceUnavailableException */ private static function addLinkAttachment(int $uriid, array $attachments, string $body, string $content, bool $shared, array $ignore_links): string { DI::profiler()->startRecording('rendering'); // Don't show a preview when there is a visual attachment (audio or video) - $types = array_column($attachments['visual'], 'type'); - $preview = !in_array(Post\Media::IMAGE, $types) && !in_array(Post\Media::VIDEO, $types); + $types = $attachments['visual']->column('type'); + $preview = !in_array(PostMedia::TYPE_IMAGE, $types) && !in_array(PostMedia::TYPE_VIDEO, $types); - if (!empty($attachments['link'])) { - foreach ($attachments['link'] as $link) { - $found = false; - foreach ($ignore_links as $ignore_link) { - if (Strings::compareLink($link['url'], $ignore_link)) { - $found = true; - } - } - // @todo Judge between the links to use the one with most information - if (!$found && (empty($attachment) || !empty($link['author-name']) || - (empty($attachment['name']) && !empty($link['name'])) || - (empty($attachment['description']) && !empty($link['description'])) || - (empty($attachment['preview']) && !empty($link['preview'])))) { - $attachment = $link; + /** @var ?PostMedia $attachment */ + $attachment = null; + foreach ($attachments['link'] as $PostMedia) { + $found = false; + foreach ($ignore_links as $ignore_link) { + if (Strings::compareLink($PostMedia->url, $ignore_link)) { + $found = true; } } + // @todo Judge between the links to use the one with most information + if (!$found && (empty($attachment) || $PostMedia->authorName || + (!$attachment->name && $PostMedia->name) || + (!$attachment->description && $PostMedia->description) || + (!$attachment->preview && $PostMedia->preview))) { + $attachment = $PostMedia; + } } if (!empty($attachment)) { $data = [ 'after' => '', - 'author_name' => $attachment['author-name'] ?? '', - 'author_url' => $attachment['author-url'] ?? '', - 'description' => $attachment['description'] ?? '', + 'author_name' => $attachment->authorName ?? '', + 'author_url' => (string)($attachment->authorUrl ?? ''), + 'description' => $attachment->description ?? '', 'image' => '', 'preview' => '', - 'provider_name' => $attachment['publisher-name'] ?? '', - 'provider_url' => $attachment['publisher-url'] ?? '', + 'provider_name' => $attachment->publisherName ?? '', + 'provider_url' => (string)($attachment->publisherUrl ?? ''), 'text' => '', - 'title' => $attachment['name'] ?? '', + 'title' => $attachment->name ?? '', 'type' => 'link', - 'url' => $attachment['url'] + 'url' => (string)$attachment->url, ]; - if ($preview && !empty($attachment['preview'])) { - if ($attachment['preview-width'] >= 500) { - $data['image'] = Post\Media::getPreviewUrlForId($attachment['id'], Proxy::SIZE_MEDIUM); + if ($preview && $attachment->preview) { + if ($attachment->previewWidth >= 500) { + $data['image'] = DI::baseUrl() . $attachment->getPreviewPath(Proxy::SIZE_MEDIUM); } else { - $data['preview'] = Post\Media::getPreviewUrlForId($attachment['id'], Proxy::SIZE_MEDIUM); + $data['preview'] = DI::baseUrl() . $attachment->getPreviewPath(Proxy::SIZE_MEDIUM); } } @@ -3639,19 +3634,21 @@ class Item } /** - * Add non visual attachments to the content + * Add non-visual attachments to the content * - * @param array $attachments - * @param array $item - * @param string $content + * @param PostMedias $PostMedias + * @param array $item + * @param string $content * @return string modified content + * @throws InternalServerErrorException + * @throws \ImagickException */ - private static function addNonVisualAttachments(array $attachments, array $item, string $content): string + private static function addNonVisualAttachments(PostMedias $PostMedias, array $item, string $content): string { DI::profiler()->startRecording('rendering'); $trailing = ''; - foreach ($attachments['additional'] as $attachment) { - if (strpos($item['body'], $attachment['url'])) { + foreach ($PostMedias as $PostMedia) { + if (strpos($item['body'], $PostMedia->url)) { continue; } @@ -3662,16 +3659,16 @@ class Item 'url' => $item['author-link'], 'alias' => $item['author-alias'] ]; - $the_url = Contact::magicLinkByContact($author, $attachment['url']); + $the_url = Contact::magicLinkByContact($author, $PostMedia->url); - $title = Strings::escapeHtml(trim(($attachment['description'] ?? '') ?: $attachment['url'])); + $title = Strings::escapeHtml(trim($PostMedia->description ?? '' ?: $PostMedia->url)); - if (!empty($attachment['size'])) { - $title .= ' ' . $attachment['size'] . ' ' . DI::l10n()->t('bytes'); + if ($PostMedia->size) { + $title .= ' ' . $PostMedia->size . ' ' . DI::l10n()->t('bytes'); } /// @todo Use a template - $icon = '
'; + $icon = '
'; $trailing .= '
' . $icon . ''; } diff --git a/src/Model/Post/Media.php b/src/Model/Post/Media.php index e0c8d3561..64f44c198 100644 --- a/src/Model/Post/Media.php +++ b/src/Model/Post/Media.php @@ -874,113 +874,6 @@ class Media return DBA::delete('post-media', ['id' => $id]); } - /** - * Split the attachment media in the three segments "visual", "link" and "additional" - * - * @param int $uri_id URI id - * @param array $links list of links that shouldn't be added - * @param bool $has_media - * @return array attachments - */ - public static function splitAttachments(int $uri_id, array $links = [], bool $has_media = true): array - { - $attachments = ['visual' => [], 'link' => [], 'additional' => []]; - - if (!$has_media) { - return $attachments; - } - - $media = self::getByURIId($uri_id); - if (empty($media)) { - return $attachments; - } - - $heights = []; - $selected = ''; - $previews = []; - - foreach ($media as $medium) { - foreach ($links as $link) { - if (Strings::compareLink($link, $medium['url'])) { - continue 2; - } - } - - // Avoid adding separate media entries for previews - foreach ($previews as $preview) { - if (Strings::compareLink($preview, $medium['url'])) { - continue 2; - } - } - - // Currently these two types are ignored here. - // Posts are added differently and contacts are not displayed as attachments. - if (in_array($medium['type'], [self::ACCOUNT, self::ACTIVITY])) { - continue; - } - - if (!empty($medium['preview'])) { - $previews[] = $medium['preview']; - } - - $type = explode('/', explode(';', $medium['mimetype'] ?? '')[0]); - if (count($type) < 2) { - Logger::info('Unknown MimeType', ['type' => $type, 'media' => $medium]); - $filetype = 'unkn'; - $subtype = 'unkn'; - } else { - $filetype = strtolower($type[0]); - $subtype = strtolower($type[1]); - } - - $medium['filetype'] = $filetype; - $medium['subtype'] = $subtype; - - if ($medium['type'] == self::HTML || (($filetype == 'text') && ($subtype == 'html'))) { - $attachments['link'][] = $medium; - continue; - } - - if ( - in_array($medium['type'], [self::AUDIO, self::IMAGE]) || - in_array($filetype, ['audio', 'image']) - ) { - $attachments['visual'][] = $medium; - } elseif (($medium['type'] == self::VIDEO) || ($filetype == 'video')) { - if (!empty($medium['height'])) { - // Peertube videos are delivered in many different resolutions. We pick a moderate one. - // Since only Peertube provides a "height" parameter, this wouldn't be executed - // when someone for example on Mastodon was sharing multiple videos in a single post. - $heights[$medium['height']] = $medium['url']; - $video[$medium['url']] = $medium; - } else { - $attachments['visual'][] = $medium; - } - } else { - $attachments['additional'][] = $medium; - } - } - - if (!empty($heights)) { - ksort($heights); - foreach ($heights as $height => $url) { - if (empty($selected) || $height <= 480) { - $selected = $url; - } - } - - if (!empty($selected)) { - $attachments['visual'][] = $video[$selected]; - unset($video[$selected]); - foreach ($video as $element) { - $attachments['additional'][] = $element; - } - } - } - - return $attachments; - } - /** * Add media attachments to the body * @@ -1119,25 +1012,9 @@ class Media */ public static function getPreviewUrlForId(int $id, string $size = ''): string { - $url = DI::baseUrl() . '/photo/preview/'; - switch ($size) { - case Proxy::SIZE_MICRO: - $url .= Proxy::PIXEL_MICRO . '/'; - break; - case Proxy::SIZE_THUMB: - $url .= Proxy::PIXEL_THUMB . '/'; - break; - case Proxy::SIZE_SMALL: - $url .= Proxy::PIXEL_SMALL . '/'; - break; - case Proxy::SIZE_MEDIUM: - $url .= Proxy::PIXEL_MEDIUM . '/'; - break; - case Proxy::SIZE_LARGE: - $url .= Proxy::PIXEL_LARGE . '/'; - break; - } - return $url . $id; + return '/photo/preview/' . + (Proxy::getPixelsFromSize($size) ? Proxy::getPixelsFromSize($size) . '/' : '') . + $id; } /** @@ -1149,24 +1026,8 @@ class Media */ public static function getUrlForId(int $id, string $size = ''): string { - $url = DI::baseUrl() . '/photo/media/'; - switch ($size) { - case Proxy::SIZE_MICRO: - $url .= Proxy::PIXEL_MICRO . '/'; - break; - case Proxy::SIZE_THUMB: - $url .= Proxy::PIXEL_THUMB . '/'; - break; - case Proxy::SIZE_SMALL: - $url .= Proxy::PIXEL_SMALL . '/'; - break; - case Proxy::SIZE_MEDIUM: - $url .= Proxy::PIXEL_MEDIUM . '/'; - break; - case Proxy::SIZE_LARGE: - $url .= Proxy::PIXEL_LARGE . '/'; - break; - } - return $url . $id; + return '/photo/media/' . + (Proxy::getPixelsFromSize($size) ? Proxy::getPixelsFromSize($size) . '/' : '') . + $id; } } diff --git a/src/Model/Profile.php b/src/Model/Profile.php index b80c83881..9c7aab54a 100644 --- a/src/Model/Profile.php +++ b/src/Model/Profile.php @@ -813,12 +813,14 @@ class Profile /** * Set the visitor cookies (see remote_user()) for signed HTTP requests - ( + * + * @param array $server The content of the $_SERVER superglobal * @return array Visitor contact array + * @throws InternalServerErrorException */ - public static function addVisitorCookieForHTTPSigner(): array + public static function addVisitorCookieForHTTPSigner(array $server): array { - $requester = HTTPSignature::getSigner('', $_SERVER); + $requester = HTTPSignature::getSigner('', $server); if (empty($requester)) { return []; } diff --git a/src/Module/Photo.php b/src/Module/Photo.php index c8e0656d2..be2408edf 100644 --- a/src/Module/Photo.php +++ b/src/Module/Photo.php @@ -77,7 +77,7 @@ class Photo extends BaseApi throw new NotModifiedException(); } - Profile::addVisitorCookieForHTTPSigner(); + Profile::addVisitorCookieForHTTPSigner($this->server); $customsize = 0; $square_resize = true; diff --git a/src/Network/Entity/MimeType.php b/src/Network/Entity/MimeType.php new file mode 100644 index 000000000..4400a9df6 --- /dev/null +++ b/src/Network/Entity/MimeType.php @@ -0,0 +1,69 @@ +. + * + */ + +namespace Friendica\Network\Entity; + +use Friendica\BaseEntity; + +/** + * Implementation of the Content-Type header value from the MIME type RFC + * + * @see https://www.rfc-editor.org/rfc/rfc2045#section-5 + * + * @property-read string $type + * @property-read string $subtype + * @property-read array $parameters + */ +class MimeType extends BaseEntity +{ + /** @var string */ + protected $type; + /** @var string */ + protected $subtype; + /** @var array */ + protected $parameters; + + public function __construct(string $type, string $subtype, array $parameters = []) + { + $this->type = $type; + $this->subtype = $subtype; + $this->parameters = $parameters; + } + + public function __toString(): string + { + $parameters = array_map(function (string $attribute, string $value) { + if ( + strpos($value, '"') !== false || + strpos($value, '\\') !== false || + strpos($value, "\r") !== false + ) { + $value = '"' . str_replace(['\\', '"', "\r"], ['\\\\', '\\"', "\\\r"], $value) . '"'; + } + + return '; ' . $attribute . '=' . $value; + }, array_keys($this->parameters), array_values($this->parameters)); + + return $this->type . '/' . + $this->subtype . + implode('', $parameters); + } +} diff --git a/src/Network/Factory/MimeType.php b/src/Network/Factory/MimeType.php new file mode 100644 index 000000000..eb096582c --- /dev/null +++ b/src/Network/Factory/MimeType.php @@ -0,0 +1,76 @@ +. + * + */ + +namespace Friendica\Network\Factory; + +use Friendica\BaseFactory; +use Friendica\Core\System; +use Friendica\Network\Entity; + +/** + * Implementation of the Content-Type header value from the MIME type RFC + * + * @see https://www.rfc-editor.org/rfc/rfc2045#section-5 + */ +class MimeType extends BaseFactory +{ + public function createFromContentType(?string $contentType): Entity\MimeType + { + if ($contentType) { + $parameterStrings = explode(';', $contentType); + $mimetype = array_shift($parameterStrings); + + $types = explode('/', $mimetype); + if (count($types) >= 2) { + $filetype = strtolower($types[0]); + $subtype = strtolower($types[1]); + } else { + $this->logger->notice('Unknown MimeType', ['type' => $contentType, 'callstack' => System::callstack(10)]); + } + + $parameters = []; + foreach ($parameterStrings as $parameterString) { + $parameterString = trim($parameterString); + $parameterParts = explode('=', $parameterString, 2); + if (count($parameterParts) < 2) { + continue; + } + + $attribute = trim($parameterParts[0]); + $valueString = trim($parameterParts[1]); + + if ($valueString[0] == '"' && $valueString[strlen($valueString) - 1] == '"') { + $valueString = substr(str_replace(['\\"', '\\\\', "\\\r"], ['"', '\\', "\r"], $valueString), 1, -1); + } + + $value = preg_replace('#\s*\([^()]*?\)#', '', $valueString); + + $parameters[$attribute] = $value; + } + } + + return new Entity\MimeType( + $filetype ?? 'unkn', + $subtype ?? 'unkn', + $parameters ?? [], + ); + } +} diff --git a/src/Util/Proxy.php b/src/Util/Proxy.php index aa0f66bbf..423e676ae 100644 --- a/src/Util/Proxy.php +++ b/src/Util/Proxy.php @@ -211,4 +211,21 @@ class Proxy return $matches[1] . self::proxifyUrl(htmlspecialchars_decode($matches[2])) . $matches[3]; } + public static function getPixelsFromSize(string $size): int + { + switch ($size) { + case Proxy::SIZE_MICRO: + return Proxy::PIXEL_MICRO; + case Proxy::SIZE_THUMB: + return Proxy::PIXEL_THUMB; + case Proxy::SIZE_SMALL: + return Proxy::PIXEL_SMALL; + case Proxy::SIZE_MEDIUM: + return Proxy::PIXEL_MEDIUM; + case Proxy::SIZE_LARGE: + return Proxy::PIXEL_LARGE; + default: + return 0; + } + } } diff --git a/tests/src/BaseCollectionTest.php b/tests/src/BaseCollectionTest.php new file mode 100644 index 000000000..ce7fb4670 --- /dev/null +++ b/tests/src/BaseCollectionTest.php @@ -0,0 +1,66 @@ +. + * + */ + +namespace Friendica\Test\src; + +use Friendica\BaseCollection; +use Friendica\BaseEntity; +use Mockery\Mock; +use PHPUnit\Framework\TestCase; + +class BaseCollectionTest extends TestCase +{ + public function testChunk() + { + $entity1 = \Mockery::mock(BaseEntity::class); + $entity2 = \Mockery::mock(BaseEntity::class); + $entity3 = \Mockery::mock(BaseEntity::class); + $entity4 = \Mockery::mock(BaseEntity::class); + + $collection = new BaseCollection([$entity1, $entity2]); + + $this->assertEquals([new BaseCollection([$entity1]), new BaseCollection([$entity2])], $collection->chunk(1)); + $this->assertEquals([new BaseCollection([$entity1, $entity2])], $collection->chunk(2)); + + $collection = new BaseCollection([$entity1, $entity2, $entity3]); + + $this->assertEquals([new BaseCollection([$entity1]), new BaseCollection([$entity2]), new BaseCollection([$entity3])], $collection->chunk(1)); + $this->assertEquals([new BaseCollection([$entity1, $entity2]), new BaseCollection([$entity3])], $collection->chunk(2)); + $this->assertEquals([new BaseCollection([$entity1, $entity2, $entity3])], $collection->chunk(3)); + + $collection = new BaseCollection([$entity1, $entity2, $entity3, $entity4]); + + $this->assertEquals([new BaseCollection([$entity1, $entity2]), new BaseCollection([$entity3, $entity4])], $collection->chunk(2)); + $this->assertEquals([new BaseCollection([$entity1, $entity2, $entity3]), new BaseCollection([$entity4])], $collection->chunk(3)); + $this->assertEquals([new BaseCollection([$entity1, $entity2, $entity3, $entity4])], $collection->chunk(4)); + } + + public function testChunkLengthException() + { + $this->expectException(\RangeException::class); + + $entity1 = \Mockery::mock(BaseEntity::class); + + $collection = new BaseCollection([$entity1]); + + $collection->chunk(0); + } +} diff --git a/tests/src/Network/MimeTypeTest.php b/tests/src/Network/MimeTypeTest.php new file mode 100644 index 000000000..64a52d8d6 --- /dev/null +++ b/tests/src/Network/MimeTypeTest.php @@ -0,0 +1,152 @@ +. + * + */ + +namespace Friendica\Test\src\Network; + +use Friendica\Network\Entity; +use Friendica\Network\Factory; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +class MimeTypeTest extends TestCase +{ + public function dataCreateFromContentType(): array + { + return [ + 'image/jpg' => [ + 'expected' => new Entity\MimeType('image', 'jpg'), + 'contentType' => 'image/jpg', + ], + 'image/jpg;charset=utf8' => [ + 'expected' => new Entity\MimeType('image', 'jpg', ['charset' => 'utf8']), + 'contentType' => 'image/jpg; charset=utf8', + ], + 'image/jpg; charset=utf8' => [ + 'expected' => new Entity\MimeType('image', 'jpg', ['charset' => 'utf8']), + 'contentType' => 'image/jpg; charset=utf8', + ], + 'image/jpg; charset = utf8' => [ + 'expected' => new Entity\MimeType('image', 'jpg', ['charset' => 'utf8']), + 'contentType' => 'image/jpg; charset=utf8', + ], + 'image/jpg; charset="utf8"' => [ + 'expected' => new Entity\MimeType('image', 'jpg', ['charset' => 'utf8']), + 'contentType' => 'image/jpg; charset="utf8"', + ], + 'image/jpg; charset="\"utf8\""' => [ + 'expected' => new Entity\MimeType('image', 'jpg', ['charset' => '"utf8"']), + 'contentType' => 'image/jpg; charset="\"utf8\""', + ], + 'image/jpg; charset="\"utf8\" (comment)"' => [ + 'expected' => new Entity\MimeType('image', 'jpg', ['charset' => '"utf8"']), + 'contentType' => 'image/jpg; charset="\"utf8\" (comment)"', + ], + 'image/jpg; charset=utf8 (comment)' => [ + 'expected' => new Entity\MimeType('image', 'jpg', ['charset' => 'utf8']), + 'contentType' => 'image/jpg; charset="utf8 (comment)"', + ], + 'image/jpg; charset=utf8; attribute=value' => [ + 'expected' => new Entity\MimeType('image', 'jpg', ['charset' => 'utf8', 'attribute' => 'value']), + 'contentType' => 'image/jpg; charset=utf8; attribute=value', + ], + 'empty' => [ + 'expected' => new Entity\MimeType('unkn', 'unkn'), + 'contentType' => '', + ], + 'unknown' => [ + 'expected' => new Entity\MimeType('unkn', 'unkn'), + 'contentType' => 'unknown', + ], + ]; + } + + /** + * @dataProvider dataCreateFromContentType + * @param Entity\MimeType $expected + * @param string $contentType + * @return void + */ + public function testCreateFromContentType(Entity\MimeType $expected, string $contentType) + { + $factory = new Factory\MimeType(new NullLogger()); + + $this->assertEquals($expected, $factory->createFromContentType($contentType)); + } + + public function dataToString(): array + { + return [ + 'image/jpg' => [ + 'expected' => 'image/jpg', + 'mimeType' => new Entity\MimeType('image', 'jpg'), + ], + 'image/jpg;charset=utf8' => [ + 'expected' => 'image/jpg; charset=utf8', + 'mimeType' => new Entity\MimeType('image', 'jpg', ['charset' => 'utf8']), + ], + 'image/jpg; charset="\"utf8\""' => [ + 'expected' => 'image/jpg; charset="\"utf8\""', + 'mimeType' => new Entity\MimeType('image', 'jpg', ['charset' => '"utf8"']), + ], + 'image/jpg; charset=utf8; attribute=value' => [ + 'expected' => 'image/jpg; charset=utf8; attribute=value', + 'mimeType' => new Entity\MimeType('image', 'jpg', ['charset' => 'utf8', 'attribute' => 'value']), + ], + 'empty' => [ + 'expected' => 'unkn/unkn', + 'mimeType' => new Entity\MimeType('unkn', 'unkn'), + ], + ]; + } + + /** + * @dataProvider dataToString + * @param string $expected + * @param Entity\MimeType $mimeType + * @return void + */ + public function testToString(string $expected, Entity\MimeType $mimeType) + { + $this->assertEquals($expected, $mimeType->__toString()); + } + + public function dataRoundtrip(): array + { + return [ + ['image/jpg'], + ['image/jpg; charset=utf8'], + ['image/jpg; charset="\"utf8\""'], + ['image/jpg; charset=utf8; attribute=value'], + ]; + } + + /** + * @dataProvider dataRoundtrip + * @param string $expected + * @return void + */ + public function testRoundtrip(string $expected) + { + $factory = new Factory\MimeType(new NullLogger()); + + $this->assertEquals($expected, $factory->createFromContentType($expected)->__toString()); + } +} diff --git a/view/global.css b/view/global.css index 714bb55db..ecab5a1c1 100644 --- a/view/global.css +++ b/view/global.css @@ -706,6 +706,39 @@ audio { * Image grid settings END **/ +/* This helps allocating space for image before they are loaded, preventing content shifting once they are. + * Inspired by https://www.smashingmagazine.com/2016/08/ways-to-reduce-content-shifting-on-page-load/ + * Please note: The space is effectively allocated using padding-bottom using the image ratio as a value. + * This ratio is never known in advance so no value is set in the stylesheet. + */ +figure.img-allocated-height { + position: relative; + background: center / auto rgba(0, 0, 0, 0.05) url(/images/icons/image.png) no-repeat; + margin: 0; +} +figure.img-allocated-height img{ + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + width: 100%; +} + +/** + * Horizontal masonry settings START + **/ +.masonry-row { + display: -ms-flexbox; /* IE10 */ + display: flex; + /* Both the following values should be the same to ensure consistent margins between images in the grid */ + column-gap: 5px; + margin-top: 5px; +} +/** + * Horizontal masonry settings AND + **/ + #contactblock .icon { width: 48px; height: 48px; diff --git a/view/templates/content/image.tpl b/view/templates/content/image.tpl deleted file mode 100644 index 81fad073c..000000000 --- a/view/templates/content/image.tpl +++ /dev/null @@ -1,5 +0,0 @@ -{{if $image.preview}} -{{$image.attachment.description}} -{{else}} -{{$image.attachment.description}} -{{/if}} diff --git a/view/templates/content/image_grid.tpl b/view/templates/content/image/grid.tpl similarity index 62% rename from view/templates/content/image_grid.tpl rename to view/templates/content/image/grid.tpl index 95e49ee3e..091e69e8e 100644 --- a/view/templates/content/image_grid.tpl +++ b/view/templates/content/image/grid.tpl @@ -1,12 +1,12 @@
{{foreach $columns.fc as $img}} - {{include file="content/image.tpl" image=$img}} + {{include file="content/image/single.tpl" image=$img}} {{/foreach}}
{{foreach $columns.sc as $img}} - {{include file="content/image.tpl" image=$img}} + {{include file="content/image/single.tpl" image=$img}} {{/foreach}}
-
\ No newline at end of file + diff --git a/view/templates/content/image/horizontal_masonry.tpl b/view/templates/content/image/horizontal_masonry.tpl new file mode 100644 index 000000000..223a9c4a4 --- /dev/null +++ b/view/templates/content/image/horizontal_masonry.tpl @@ -0,0 +1,12 @@ +{{foreach $rows as $images}} +
+ {{foreach $images as $image}} + {{* The absolute pixel value in the calc() should be mirrored from the .imagegrid-row column-gap value *}} + {{include file="content/image/single_with_height_allocation.tpl" + image=$image + allocated_height="calc(`$image->heightRatio * $image->widthRatio / 100`% - 5px / `$column_size`)" + allocated_width="`$image->widthRatio`%" + }} + {{/foreach}} +
+{{/foreach}} diff --git a/view/templates/content/image/single.tpl b/view/templates/content/image/single.tpl new file mode 100644 index 000000000..b3dfb7413 --- /dev/null +++ b/view/templates/content/image/single.tpl @@ -0,0 +1,5 @@ +{{if $image->preview}} +{{$image->description}} +{{else}} +{{$image->description}} +{{/if}} diff --git a/view/templates/content/image/single_with_height_allocation.tpl b/view/templates/content/image/single_with_height_allocation.tpl new file mode 100644 index 000000000..1d70194be --- /dev/null +++ b/view/templates/content/image/single_with_height_allocation.tpl @@ -0,0 +1,20 @@ +{{* The padding-top height allocation trick only works if the
fills its parent's width completely or with flex. 🤷‍♂️ + As a result, we need to add a wrapping element for non-flex (non-image grid) environments, mostly single-image cases. + *}} +{{if $allocated_max_width}} +
+{{/if}} + +
+ {{if $image->preview}} + + {{$image->description}} + + {{else}} + {{$image->description}} + {{/if}} +
+ +{{if $allocated_max_width}} +
+{{/if}} diff --git a/view/theme/frio/scheme/black.css b/view/theme/frio/scheme/black.css index debf9d99b..561f708a8 100644 --- a/view/theme/frio/scheme/black.css +++ b/view/theme/frio/scheme/black.css @@ -394,3 +394,7 @@ input[type="text"].tt-input { textarea#profile-jot-text:focus + #preview_profile-jot-text, textarea.comment-edit-text:focus + .comment-edit-form .preview { border-color: $link_color; } + +figure.img-allocated-height { + background-color: rgba(255, 255, 255, 0.15); +} diff --git a/view/theme/frio/scheme/dark.css b/view/theme/frio/scheme/dark.css index add36fff1..434681c55 100644 --- a/view/theme/frio/scheme/dark.css +++ b/view/theme/frio/scheme/dark.css @@ -354,3 +354,7 @@ input[type="text"].tt-input { textarea#profile-jot-text:focus + #preview_profile-jot-text, textarea.comment-edit-text:focus + .comment-edit-form .preview { border-color: $link_color; } + +figure.img-allocated-height { + background-color: rgba(255, 255, 255, 0.05); +}