Merge pull request #13476 from MrPetovan/bug/13467-image-reliable-dimensions

Redux horizontal masonry, height allocation feature with ensured dimensions
This commit is contained in:
Tobias Diekershoff 2023-10-04 08:16:09 +02:00 committed by GitHub
commit 2911895cdb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 1577 additions and 292 deletions

View file

@ -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
*

154
src/Content/Image.php Normal file
View file

@ -0,0 +1,154 @@
<?php
/**
* @copyright Copyright (C) 2010-2023, the Friendica project
*
* @license GNU AGPL version 3 or any later version
*
* This program 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.
*
* This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
*
*/
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,
]);
}
}

View file

@ -0,0 +1,57 @@
<?php
/**
* @copyright Copyright (C) 2010-2023, the Friendica project
*
* @license GNU AGPL version 3 or any later version
*
* This program 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.
*
* This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
*
*/
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;
}
}

View file

@ -0,0 +1,60 @@
<?php
/**
* @copyright Copyright (C) 2010-2023, the Friendica project
*
* @license GNU AGPL version 3 or any later version
*
* This program 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.
*
* This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
*
*/
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;
}
}

View file

@ -0,0 +1,57 @@
<?php
/**
* @copyright Copyright (C) 2010-2023, the Friendica project
*
* @license GNU AGPL version 3 or any later version
*
* This program 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.
*
* This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
*
*/
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);
}
}

View file

@ -0,0 +1,300 @@
<?php
/**
* @copyright Copyright (C) 2010-2023, the Friendica project
*
* @license GNU AGPL version 3 or any later version
*
* This program 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.
*
* This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
*
*/
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;
}
}

View file

@ -0,0 +1,117 @@
<?php
/**
* @copyright Copyright (C) 2010-2023, the Friendica project
*
* @license GNU AGPL version 3 or any later version
*
* This program 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.
*
* This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
*
*/
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);
}
}

View file

@ -0,0 +1,204 @@
<?php
/**
* @copyright Copyright (C) 2010-2023, the Friendica project
*
* @license GNU AGPL version 3 or any later version
*
* This program 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.
*
* This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
*
*/
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;
}
}

View file

@ -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);
}
}

View file

@ -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('<a href="' . $attachment['url'] . '"', '<a data-fancybox="' . $uri_id . '" href="' . $attachment['url'] . '"', $s);
if ($PostMedia->hasDimensions()) {
$pattern = '#<a href="' . preg_quote($PostMedia->url) . '">(.*?)"></a>#';
$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('<a href="' . $PostMedia->url . '"', '<a data-fancybox="uri-id-' . $PostMedia->uriId . '" href="' . $PostMedia->url . '"', $s);
}
}
return $s;
}
@ -3378,30 +3371,30 @@ class Item
/**
* Replace visual attachments in the body
*
* @param array $attachments
* @param string $body
* @param PostMedias $PostMedias
* @param string $body
* @return string modified body
*/
private static function replaceVisualAttachments(array $attachments, string $body): string
private static function replaceVisualAttachments(PostMedias $PostMedias, string $body): string
{
DI::profiler()->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 = '<div class="attachtype icon s22 type-' . $attachment['filetype'] . ' subtype-' . $attachment['subtype'] . '"></div>';
$icon = '<div class="attachtype icon s22 type-' . $PostMedia->mimetype->type . ' subtype-' . $PostMedia->mimetype->subtype . '"></div>';
$trailing .= '<a href="' . strip_tags($the_url) . '" title="' . $title . '" class="attachlink" target="_blank" rel="noopener noreferrer" >' . $icon . '</a>';
}

View file

@ -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;
}
}

View file

@ -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 [];
}

View file

@ -77,7 +77,7 @@ class Photo extends BaseApi
throw new NotModifiedException();
}
Profile::addVisitorCookieForHTTPSigner();
Profile::addVisitorCookieForHTTPSigner($this->server);
$customsize = 0;
$square_resize = true;

View file

@ -0,0 +1,69 @@
<?php
/**
* @copyright Copyright (C) 2010-2023, the Friendica project
*
* @license GNU AGPL version 3 or any later version
*
* This program 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.
*
* This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
*
*/
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);
}
}

View file

@ -0,0 +1,76 @@
<?php
/**
* @copyright Copyright (C) 2010-2023, the Friendica project
*
* @license GNU AGPL version 3 or any later version
*
* This program 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.
*
* This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
*
*/
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 ?? [],
);
}
}

View file

@ -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;
}
}
}

View file

@ -0,0 +1,66 @@
<?php
/**
* @copyright Copyright (C) 2010-2023, the Friendica project
*
* @license GNU AGPL version 3 or any later version
*
* This program 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.
*
* This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
*
*/
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);
}
}

View file

@ -0,0 +1,152 @@
<?php
/**
* @copyright Copyright (C) 2010-2023, the Friendica project
*
* @license GNU AGPL version 3 or any later version
*
* This program 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.
*
* This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
*
*/
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());
}
}

View file

@ -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;

View file

@ -1,5 +0,0 @@
{{if $image.preview}}
<a data-fancybox="{{$image.uri_id}}" href="{{$image.attachment.url}}"><img src="{{$image.preview}}" alt="{{$image.attachment.description}}" title="{{$image.attachment.description}}" loading="lazy"></a>
{{else}}
<img src="{{$image.src}}" alt="{{$image.attachment.description}}" title="{{$image.attachment.description}}" loading="lazy">
{{/if}}

View file

@ -1,12 +1,12 @@
<div class="imagegrid-row">
<div class="imagegrid-column">
{{foreach $columns.fc as $img}}
{{include file="content/image.tpl" image=$img}}
{{include file="content/image/single.tpl" image=$img}}
{{/foreach}}
</div>
<div class="imagegrid-column">
{{foreach $columns.sc as $img}}
{{include file="content/image.tpl" image=$img}}
{{include file="content/image/single.tpl" image=$img}}
{{/foreach}}
</div>
</div>
</div>

View file

@ -0,0 +1,12 @@
{{foreach $rows as $images}}
<div class="masonry-row" style="height: {{$images->getHeightRatio()}}%">
{{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}}
</div>
{{/foreach}}

View file

@ -0,0 +1,5 @@
{{if $image->preview}}
<a data-fancybox="{{$image->uriId}}" href="{{$image->url}}"><img src="{{$image->preview}}" alt="{{$image->description}}" title="{{$image->description}}" loading="lazy"></a>
{{else}}
<img src="{{$image->url}}" alt="{{$image->description}}" title="{{$image->description}}" loading="lazy">
{{/if}}

View file

@ -0,0 +1,20 @@
{{* The padding-top height allocation trick only works if the <figure> 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}}
<div style="max-width: {{$allocated_max_width|default:"auto"}};">
{{/if}}
<figure class="img-allocated-height" style="width: {{$allocated_width|default:"auto"}}; padding-bottom: {{$allocated_height}}">
{{if $image->preview}}
<a data-fancybox="uri-id-{{$image->uriId}}" href="{{$image->url}}">
<img src="{{$image->preview}}" alt="{{$image->description}}" title="{{$image->description}}" loading="lazy">
</a>
{{else}}
<img src="{{$image->url}}" alt="{{$image->description}}" title="{{$image->description}}" loading="lazy">
{{/if}}
</figure>
{{if $allocated_max_width}}
</div>
{{/if}}

View file

@ -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);
}

View file

@ -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);
}