diff --git a/database.sql b/database.sql index 855c913580..49f282db0a 100644 --- a/database.sql +++ b/database.sql @@ -1,6 +1,6 @@ -- ------------------------------------------ -- Friendica 2022.12-rc (Giant Rhubarb) --- DB_UPDATE_VERSION 1500 +-- DB_UPDATE_VERSION 1501 -- ------------------------------------------ @@ -129,6 +129,7 @@ CREATE TABLE IF NOT EXISTS `contact` ( `xmpp` varchar(255) NOT NULL DEFAULT '' COMMENT 'XMPP address', `matrix` varchar(255) NOT NULL DEFAULT '' COMMENT 'Matrix address', `avatar` varbinary(383) NOT NULL DEFAULT '' COMMENT '', + `blurhash` varbinary(255) COMMENT 'BlurHash representation of the avatar', `header` varbinary(383) COMMENT 'Header picture', `url` varbinary(383) NOT NULL DEFAULT '' COMMENT '', `nurl` varbinary(383) NOT NULL DEFAULT '' COMMENT '', @@ -1297,6 +1298,9 @@ CREATE TABLE IF NOT EXISTS `post-link` ( `uri-id` int unsigned NOT NULL COMMENT 'Id of the item-uri table entry that contains the item uri', `url` varbinary(511) NOT NULL COMMENT 'External URL', `mimetype` varchar(60) COMMENT '', + `height` smallint unsigned COMMENT 'Height of the media', + `width` smallint unsigned COMMENT 'Width of the media', + `blurhash` varbinary(255) COMMENT 'BlurHash representation of the link', PRIMARY KEY(`id`), UNIQUE INDEX `uri-id-url` (`uri-id`,`url`), FOREIGN KEY (`uri-id`) REFERENCES `item-uri` (`id`) ON UPDATE RESTRICT ON DELETE CASCADE diff --git a/doc/database/db_contact.md b/doc/database/db_contact.md index 6f5a395366..f8999054fd 100644 --- a/doc/database/db_contact.md +++ b/doc/database/db_contact.md @@ -21,6 +21,7 @@ Fields | xmpp | XMPP address | varchar(255) | NO | | | | | matrix | Matrix address | varchar(255) | NO | | | | | avatar | | varbinary(383) | NO | | | | +| blurhash | BlurHash representation of the avatar | varbinary(255) | YES | | NULL | | | header | Header picture | varbinary(383) | YES | | NULL | | | url | | varbinary(383) | NO | | | | | nurl | | varbinary(383) | NO | | | | diff --git a/doc/database/db_post-link.md b/doc/database/db_post-link.md index a162453567..cee382f3d7 100644 --- a/doc/database/db_post-link.md +++ b/doc/database/db_post-link.md @@ -6,12 +6,15 @@ Post related external links Fields ------ -| Field | Description | Type | Null | Key | Default | Extra | -| -------- | --------------------------------------------------------- | -------------- | ---- | --- | ------- | -------------- | -| id | sequential ID | int unsigned | NO | PRI | NULL | auto_increment | -| uri-id | Id of the item-uri table entry that contains the item uri | int unsigned | NO | | NULL | | -| url | External URL | varbinary(511) | NO | | NULL | | -| mimetype | | varchar(60) | YES | | NULL | | +| Field | Description | Type | Null | Key | Default | Extra | +| -------- | --------------------------------------------------------- | ----------------- | ---- | --- | ------- | -------------- | +| id | sequential ID | int unsigned | NO | PRI | NULL | auto_increment | +| uri-id | Id of the item-uri table entry that contains the item uri | int unsigned | NO | | NULL | | +| url | External URL | varbinary(511) | NO | | NULL | | +| mimetype | | varchar(60) | YES | | NULL | | +| height | Height of the media | smallint unsigned | YES | | NULL | | +| width | Width of the media | smallint unsigned | YES | | NULL | | +| blurhash | BlurHash representation of the link | varbinary(255) | YES | | NULL | | Indexes ------------ diff --git a/src/Contact/Avatar.php b/src/Contact/Avatar.php index 711a8549f2..02a5a45d4e 100644 --- a/src/Contact/Avatar.php +++ b/src/Contact/Avatar.php @@ -90,6 +90,8 @@ class Avatar $filename = self::getFilename($contact['url']); $timestamp = time(); + $fields['blurhash'] = $image->getBlurHash(); + $fields['photo'] = self::storeAvatarCache($image, $filename, Proxy::PIXEL_SMALL, $timestamp); $fields['thumb'] = self::storeAvatarCache($image, $filename, Proxy::PIXEL_THUMB, $timestamp); $fields['micro'] = self::storeAvatarCache($image, $filename, Proxy::PIXEL_MICRO, $timestamp); diff --git a/src/Core/Storage/Type/ExternalResource.php b/src/Core/Storage/Type/ExternalResource.php index 34cd85edfb..9ac116f71d 100644 --- a/src/Core/Storage/Type/ExternalResource.php +++ b/src/Core/Storage/Type/ExternalResource.php @@ -59,7 +59,7 @@ class ExternalResource implements ICanReadFromStorage } catch (Exception $exception) { throw new ReferenceStorageException(sprintf('External resource failed to get %s', $reference), $exception->getCode(), $exception); } - if ($fetchResult->isSuccess()) { + if (!empty($fetchResult) && $fetchResult->isSuccess()) { Logger::debug('Got picture', ['Content-Type' => $fetchResult->getHeader('Content-Type'), 'uid' => $data->uid, 'url' => $data->url]); return $fetchResult->getBody(); } else { diff --git a/src/Model/Contact.php b/src/Model/Contact.php index c20435278f..8f9cb4d051 100644 --- a/src/Model/Contact.php +++ b/src/Model/Contact.php @@ -34,11 +34,15 @@ use Friendica\Core\Worker; use Friendica\Database\Database; use Friendica\Database\DBA; use Friendica\DI; +use Friendica\Network\HTTPClient\Client\HttpClientAccept; +use Friendica\Network\HTTPClient\Client\HttpClientOptions; use Friendica\Network\HTTPException; use Friendica\Network\Probe; +use Friendica\Object\Image; use Friendica\Protocol\Activity; use Friendica\Protocol\ActivityPub; use Friendica\Util\DateTimeFormat; +use Friendica\Util\HTTPSignature; use Friendica\Util\Images; use Friendica\Util\Network; use Friendica\Util\Proxy; @@ -2193,7 +2197,7 @@ class Contact */ public static function updateAvatar(int $cid, string $avatar, bool $force = false, bool $create_cache = false) { - $contact = DBA::selectFirst('contact', ['uid', 'avatar', 'photo', 'thumb', 'micro', 'xmpp', 'addr', 'nurl', 'url', 'network', 'uri-id'], + $contact = DBA::selectFirst('contact', ['uid', 'avatar', 'photo', 'thumb', 'micro', 'blurhash', 'xmpp', 'addr', 'nurl', 'url', 'network', 'uri-id'], ['id' => $cid, 'self' => false]); if (!DBA::isResult($contact)) { return; @@ -2203,8 +2207,19 @@ class Contact // Only update the cached photo links of public contacts when they already are cached if (($uid == 0) && !$force && empty($contact['thumb']) && empty($contact['micro']) && !$create_cache) { - if ($contact['avatar'] != $avatar) { - self::update(['avatar' => $avatar], ['id' => $cid]); + if (($contact['avatar'] != $avatar) || empty($contact['blurhash'])) { + $update_fields = ['avatar' => $avatar]; + $fetchResult = HTTPSignature::fetchRaw($avatar, 0, [HttpClientOptions::ACCEPT_CONTENT => [HttpClientAccept::IMAGE]]); + + $img_str = $fetchResult->getBody(); + if (!empty($img_str)) { + $image = new Image($img_str, Images::getMimeTypeByData($img_str)); + if ($image->isValid()) { + $update_fields['blurhash'] = $image->getBlurHash(); + } + } + + self::update($update_fields, ['id' => $cid]); Logger::info('Only update the avatar', ['id' => $cid, 'avatar' => $avatar, 'contact' => $contact]); } return; @@ -2275,7 +2290,7 @@ class Contact if ($update) { $photos = Photo::importProfilePhoto($avatar, $uid, $cid, true); if ($photos) { - $fields = ['avatar' => $avatar, 'photo' => $photos[0], 'thumb' => $photos[1], 'micro' => $photos[2], 'avatar-date' => DateTimeFormat::utcNow()]; + $fields = ['avatar' => $avatar, 'photo' => $photos[0], 'thumb' => $photos[1], 'micro' => $photos[2], 'blurhash' => $photos[3], 'avatar-date' => DateTimeFormat::utcNow()]; $update = !empty($fields); Logger::debug('Created new cached avatars', ['id' => $cid, 'uid' => $uid, 'owner-uid' => $local_uid]); } else { diff --git a/src/Model/Photo.php b/src/Model/Photo.php index 16acab69aa..82b8bd9dda 100644 --- a/src/Model/Photo.php +++ b/src/Model/Photo.php @@ -313,6 +313,28 @@ class Photo return $fields; } + /** + * Construct a photo array for a given image data string + * + * @param string $image_data Image data + * @param string $mimetype Image mime type. Is guessed by file name when empty. + * + * @return array + * @throws \Exception + */ + public static function createPhotoForImageData(string $image_data, string $mimetype = ''): array + { + $fields = self::getFields(); + $values = array_fill(0, count($fields), ''); + + $photo = array_combine($fields, $values); + $photo['data'] = $image_data; + $photo['type'] = $mimetype ?: Images::getMimeTypeByData($image_data); + $photo['cacheable'] = false; + + return $photo; + } + /** * Construct a photo array for a system resource image * @@ -647,7 +669,11 @@ class Photo $micro = Contact::getDefaultAvatar($contact, Proxy::SIZE_MICRO); } - return [$image_url, $thumb, $micro]; + $photo = DBA::selectFirst( + 'photo', ['blurhash'], ['uid' => $uid, 'contact-id' => $cid, 'scale' => 4, 'photo-type' => self::CONTACT_AVATAR] + ); + + return [$image_url, $thumb, $micro, $photo['blurhash']]; } /** diff --git a/src/Model/Post/Link.php b/src/Model/Post/Link.php index 83e5da7b31..83e5bea99f 100644 --- a/src/Model/Post/Link.php +++ b/src/Model/Post/Link.php @@ -28,7 +28,10 @@ use Friendica\Database\DBA; use Friendica\DI; use Friendica\Network\HTTPClient\Client\HttpClientAccept; use Friendica\Network\HTTPClient\Client\HttpClientOptions; +use Friendica\Util\HTTPSignature; +use Friendica\Util\Images; use Friendica\Util\Proxy; +use Friendica\Object\Image; /** * Class Link @@ -72,12 +75,14 @@ class Link if (!empty($link['id'])) { $id = $link['id']; Logger::info('Found', ['id' => $id, 'uri-id' => $uriId, 'url' => $url]); - } else { - $mime = self::fetchMimeType($url); + } else { + $fields = self::fetchMimeType($url); + $fields['uri-id'] = $uriId; + $fields['url'] = $url; - DBA::insert('post-link', ['uri-id' => $uriId, 'url' => $url, 'mimetype' => $mime], Database::INSERT_IGNORE); + DBA::insert('post-link', $fields, Database::INSERT_IGNORE); $id = DBA::lastInsertId(); - Logger::info('Inserted', ['id' => $id, 'uri-id' => $uriId, 'url' => $url]); + Logger::info('Inserted', $fields); } if (empty($id)) { @@ -114,19 +119,28 @@ class Link * * @param string $url URL to fetch * @param string $accept Comma-separated list of expected response MIME type(s) - * @return string Discovered MIME type or empty string on failure + * @return array Discovered MIME type and blurhash or empty array on failure */ - private static function fetchMimeType(string $url, string $accept = HttpClientAccept::DEFAULT): string + private static function fetchMimeType(string $url, string $accept = HttpClientAccept::DEFAULT): array { $timeout = DI::config()->get('system', 'xrd_timeout'); - $curlResult = DI::httpClient()->head($url, [HttpClientOptions::TIMEOUT => $timeout, HttpClientOptions::ACCEPT_CONTENT => $accept]); + $curlResult = HTTPSignature::fetchRaw($url, 0, [HttpClientOptions::TIMEOUT => $timeout, HttpClientOptions::ACCEPT_CONTENT => $accept]); + if (!$curlResult->isSuccess()) { + return []; + } + $fields = ['mimetype' => $curlResult->getHeader('Content-Type')[0]]; - if ($curlResult->isSuccess() && empty($media['mimetype'])) { - return $curlResult->getHeader('Content-Type')[0] ?? ''; + $img_str = $curlResult->getBody(); + $image = new Image($img_str, Images::getMimeTypeByData($img_str)); + if ($image->isValid()) { + $fields['mimetype'] = $image->getType(); + $fields['width'] = $image->getWidth(); + $fields['height'] = $image->getHeight(); + $fields['blurhash'] = $image->getBlurHash(); } - return ''; + return $fields; } /** diff --git a/src/Model/Post/UserNotification.php b/src/Model/Post/UserNotification.php index b26fb292bc..d872d5c41c 100644 --- a/src/Model/Post/UserNotification.php +++ b/src/Model/Post/UserNotification.php @@ -440,6 +440,12 @@ class UserNotification } } + // Only check on posts by the user itself + $cdata = Contact::getPublicAndUserContactID($item['contact-id'], $item['uid']); + if (empty($cdata['user']) || ($item['author-id'] != $cdata['public'])) { + return false; + } + // Check if the contact posted or shared something directly if (DBA::exists('contact', ['id' => $item['contact-id'], 'notify_new_posts' => true])) { return true; diff --git a/src/Module/Moderation/BaseUsers.php b/src/Module/Moderation/BaseUsers.php index 62e41c58c9..14bbd60766 100644 --- a/src/Module/Moderation/BaseUsers.php +++ b/src/Module/Moderation/BaseUsers.php @@ -137,7 +137,7 @@ abstract class BaseUsers extends BaseModeration $user['account_type'] = ($user['page_flags_raw'] == 0) ? $account_types[$user['account-type']] : ''; $user['register_date'] = Temporal::getRelativeDate($user['register_date']); - $user['login_date'] = Temporal::getRelativeDate($user['last-activity']); + $user['login_date'] = Temporal::getRelativeDate($user['last-activity'], null, false); $user['lastitem_date'] = Temporal::getRelativeDate($user['last-item']); $user['is_admin'] = in_array($user['email'], $adminlist); $user['is_deletable'] = !$user['account_removed'] && intval($user['uid']) != $this->session->getLocalUserId(); diff --git a/src/Module/Photo.php b/src/Module/Photo.php index 78e403e6c7..5f1d65845b 100644 --- a/src/Module/Photo.php +++ b/src/Module/Photo.php @@ -285,14 +285,14 @@ class Photo extends BaseModule return MPhoto::createPhotoForExternalResource($media['url'], (int)DI::userSession()->getLocalUserId(), $media['mimetype'], $media['blurhash'], $media['width'], $media['height']); case 'link': - $link = DBA::selectFirst('post-link', ['url', 'mimetype'], ['id' => $id]); + $link = DBA::selectFirst('post-link', ['url', 'mimetype', 'blurhash', 'width', 'height'], ['id' => $id]); if (empty($link)) { return false; } - return MPhoto::createPhotoForExternalResource($link['url'], (int)DI::userSession()->getLocalUserId(), $link['mimetype'] ?? ''); + return MPhoto::createPhotoForExternalResource($link['url'], (int)DI::userSession()->getLocalUserId(), $link['mimetype'] ?? '', $link['blurhash'] ?? '', $link['width'] ?? 0, $link['height'] ?? 0); case 'contact': - $fields = ['uid', 'uri-id', 'url', 'nurl', 'avatar', 'photo', 'xmpp', 'addr', 'network', 'failed', 'updated']; + $fields = ['uid', 'uri-id', 'url', 'nurl', 'avatar', 'photo', 'blurhash', 'xmpp', 'addr', 'network', 'failed', 'updated']; $contact = Contact::getById($id, $fields); if (empty($contact)) { return false; @@ -364,7 +364,11 @@ class Photo extends BaseModule Logger::debug('Expected Content-Type', ['mime' => $mimetext, 'url' => $url]); } } - if (empty($mimetext)) { + if (empty($mimetext) && !empty($contact['blurhash'])) { + $image = New Image('', 'image/png'); + $image->getFromBlurHash($contact['blurhash'], $customsize, $customsize); + return MPhoto::createPhotoForImageData($image->asString()); + } elseif (empty($mimetext)) { if ($customsize <= Proxy::PIXEL_MICRO) { $url = Contact::getDefaultAvatar($contact ?: [], Proxy::SIZE_MICRO); } elseif ($customsize <= Proxy::PIXEL_THUMB) { @@ -373,7 +377,7 @@ class Photo extends BaseModule $url = Contact::getDefaultAvatar($contact ?: [], Proxy::SIZE_SMALL); } } - return MPhoto::createPhotoForExternalResource($url, 0, $mimetext); + return MPhoto::createPhotoForExternalResource($url, 0, $mimetext, $contact['blurhash'], $customsize, $customsize); case 'header': $fields = ['uid', 'url', 'header', 'network', 'gsid']; $contact = Contact::getById($id, $fields); diff --git a/src/Util/Temporal.php b/src/Util/Temporal.php index 3946550628..f6805a9222 100644 --- a/src/Util/Temporal.php +++ b/src/Util/Temporal.php @@ -305,13 +305,14 @@ class Temporal * Results relative to current timezone. * Limited to range of timestamps. * - * @param string $posted_date MySQL-formatted date string (YYYY-MM-DD HH:MM:SS) - * @param string $format (optional) Parsed with sprintf() + * @param string $posted_date MySQL-formatted date string (YYYY-MM-DD HH:MM:SS) + * @param string $format (optional) Parsed with sprintf() + * @param bool $compare_time Compare date (false) or date and time (true). "true" is default. * %1$d %2$s ago, e.g. 22 hours ago, 1 minute ago * * @return string with relative date */ - public static function getRelativeDate(string $posted_date = null, string $format = null): string + public static function getRelativeDate(string $posted_date = null, string $format = null, bool $compare_time = true): string { if (empty($posted_date) || $posted_date <= DBA::NULL_DATETIME) { return DI::l10n()->t('never'); @@ -324,11 +325,18 @@ class Temporal return DI::l10n()->t('never'); } + $now = time(); + + if (!$compare_time) { + $now = mktime(0, 0, 0, date('m', $now), date('d', $now), date('Y', $now)); + $abs = mktime(0, 0, 0, date('m', $abs), date('d', $abs), date('Y', $abs)); + } + $isfuture = false; - $etime = time() - $abs; + $etime = $now - $abs; if ($etime < 1 && $etime >= 0) { - return DI::l10n()->t('less than a second ago'); + return $compare_time ? DI::l10n()->t('less than a second ago') : DI::l10n()->t('today'); } if ($etime < 0){ diff --git a/static/dbstructure.config.php b/static/dbstructure.config.php index d5bbed666a..d9ff07178b 100644 --- a/static/dbstructure.config.php +++ b/static/dbstructure.config.php @@ -55,7 +55,7 @@ use Friendica\Database\DBA; if (!defined('DB_UPDATE_VERSION')) { - define('DB_UPDATE_VERSION', 1500); + define('DB_UPDATE_VERSION', 1501); } return [ @@ -186,6 +186,7 @@ return [ "xmpp" => ["type" => "varchar(255)", "not null" => "1", "default" => "", "comment" => "XMPP address"], "matrix" => ["type" => "varchar(255)", "not null" => "1", "default" => "", "comment" => "Matrix address"], "avatar" => ["type" => "varbinary(383)", "not null" => "1", "default" => "", "comment" => ""], + "blurhash" => ["type" => "varbinary(255)", "comment" => "BlurHash representation of the avatar"], "header" => ["type" => "varbinary(383)", "comment" => "Header picture"], "url" => ["type" => "varbinary(383)", "not null" => "1", "default" => "", "comment" => ""], "nurl" => ["type" => "varbinary(383)", "not null" => "1", "default" => "", "comment" => ""], @@ -1323,6 +1324,9 @@ return [ "uri-id" => ["type" => "int unsigned", "not null" => "1", "foreign" => ["item-uri" => "id"], "comment" => "Id of the item-uri table entry that contains the item uri"], "url" => ["type" => "varbinary(511)", "not null" => "1", "comment" => "External URL"], "mimetype" => ["type" => "varchar(60)", "comment" => ""], + "height" => ["type" => "smallint unsigned", "comment" => "Height of the media"], + "width" => ["type" => "smallint unsigned", "comment" => "Width of the media"], + "blurhash" => ["type" => "varbinary(255)", "comment" => "BlurHash representation of the link"], ], "indexes" => [ "PRIMARY" => ["id"],