From d0800f7661f13fabe7bb6f4aa0ea50764f106405 Mon Sep 17 00:00:00 2001 From: kontrollanten <6680299+kontrollanten@users.noreply.github.com> Date: Mon, 28 Feb 2022 08:34:43 +0100 Subject: Implement avatar miniatures (#4639) * client: remove unused file * refactor(client/my-actor-avatar): size from input Read size from component input instead of scss, to make it possible to use smaller avatar images when implemented. * implement avatar miniatures close #4560 * fix(test): max file size * fix(search-index): normalize res acc to avatarMini * refactor avatars to an array * client/search: resize channel avatar to 120 * refactor(client/videos): remove unused function * client(actor-avatar): set default size * fix tests and avatars full result When findOne is used only an array containting one avatar is returned. * update migration version and version notations * server/search: harmonize normalizing * Cleanup avatar miniature PR Co-authored-by: Chocobozzz --- server/models/actor/actor-follow.ts | 263 ++++++++++++++++++++---------------- server/models/actor/actor-image.ts | 67 +++++++-- server/models/actor/actor.ts | 129 ++++++++---------- 3 files changed, 259 insertions(+), 200 deletions(-) (limited to 'server/models/actor') diff --git a/server/models/actor/actor-follow.ts b/server/models/actor/actor-follow.ts index 006282530..0f4d3c0a6 100644 --- a/server/models/actor/actor-follow.ts +++ b/server/models/actor/actor-follow.ts @@ -1,5 +1,5 @@ import { difference, values } from 'lodash' -import { IncludeOptions, Op, QueryTypes, Transaction, WhereOptions } from 'sequelize' +import { Includeable, IncludeOptions, literal, Op, QueryTypes, Transaction, WhereOptions } from 'sequelize' import { AfterCreate, AfterDestroy, @@ -30,12 +30,12 @@ import { MActorFollowFormattable, MActorFollowSubscriptions } from '@server/types/models' -import { AttributesOnly } from '@shared/typescript-utils' import { ActivityPubActorType } from '@shared/models' +import { AttributesOnly } from '@shared/typescript-utils' import { FollowState } from '../../../shared/models/actors' import { ActorFollow } from '../../../shared/models/actors/follow.model' import { logger } from '../../helpers/logger' -import { ACTOR_FOLLOW_SCORE, CONSTRAINTS_FIELDS, FOLLOW_STATES, SERVER_ACTOR_NAME } from '../../initializers/constants' +import { ACTOR_FOLLOW_SCORE, CONSTRAINTS_FIELDS, FOLLOW_STATES, SERVER_ACTOR_NAME, SORTABLE_COLUMNS } from '../../initializers/constants' import { AccountModel } from '../account/account' import { ServerModel } from '../server/server' import { doesExist } from '../shared/query' @@ -375,43 +375,46 @@ export class ActorFollowModel extends Model { + const actorModel = forCount + ? ActorModel.unscoped() + : ActorModel + + return { + distinct: true, + offset: start, + limit: count, + order: getFollowsSort(sort), + where: followWhere, + include: [ + { + model: actorModel, + required: true, + as: 'ActorFollower', + where: { + id } - ] - } - ] + }, + { + model: actorModel, + as: 'ActorFollowing', + required: true, + where: followingWhere, + include: [ + { + model: ServerModel, + required: true + } + ] + } + ] + } } - return ActorFollowModel.findAndCountAll(query) - .then(({ rows, count }) => { - return { - data: rows, - total: count - } - }) + return Promise.all([ + ActorFollowModel.count(getQuery(true)), + ActorFollowModel.findAll(getQuery(false)) + ]).then(([ total, data ]) => ({ total, data })) } static listFollowersForApi (options: { @@ -429,11 +432,17 @@ export class ActorFollowModel extends Model { + const actorModel = forCount + ? ActorModel.unscoped() + : ActorModel + + return { + distinct: true, + + offset: start, + limit: count, + order: getFollowsSort(sort), + where: followWhere, + include: [ + { + model: actorModel, + required: true, + as: 'ActorFollower', + where: followerWhere + }, + { + model: actorModel, + as: 'ActorFollowing', + required: true, + where: { + id: { + [Op.in]: actorIds + } } } - } - ] + ] + } } - return ActorFollowModel.findAndCountAll(query) - .then(({ rows, count }) => { - return { - data: rows, - total: count - } - }) + return Promise.all([ + ActorFollowModel.count(getQuery(true)), + ActorFollowModel.findAll(getQuery(false)) + ]).then(([ total, data ]) => ({ total, data })) } static listSubscriptionsForApi (options: { @@ -497,58 +510,68 @@ export class ActorFollowModel extends Model { + let channelInclude: Includeable[] = [] + + if (forCount !== true) { + channelInclude = [ + { + attributes: { + exclude: unusedActorAttributesForAPI + }, + model: ActorModel, + required: true + }, + { + model: AccountModel.unscoped(), + required: true, + include: [ + { + attributes: { + exclude: unusedActorAttributesForAPI }, - { - model: AccountModel.unscoped(), - required: true, - include: [ - { - attributes: { - exclude: unusedActorAttributesForAPI - }, - model: ActorModel, - required: true - } - ] - } - ] - } - ] - } - ] + model: ActorModel, + required: true + } + ] + } + ] + } + + return { + attributes: forCount === true + ? [] + : SORTABLE_COLUMNS.USER_SUBSCRIPTIONS, + distinct: true, + offset: start, + limit: count, + order: getSort(sort), + where, + include: [ + { + attributes: [ 'id' ], + model: ActorModel.unscoped(), + as: 'ActorFollowing', + required: true, + include: [ + { + model: VideoChannelModel.unscoped(), + required: true, + include: channelInclude + } + ] + } + ] + } } - return ActorFollowModel.findAndCountAll(query) - .then(({ rows, count }) => { - return { - data: rows.map(r => r.ActorFollowing.VideoChannel), - total: count - } - }) + return Promise.all([ + ActorFollowModel.count(getQuery(true)), + ActorFollowModel.findAll(getQuery(false)) + ]).then(([ total, rows ]) => ({ + total, + data: rows.map(r => r.ActorFollowing.VideoChannel) + })) } static async keepUnfollowedInstance (hosts: string[]) { diff --git a/server/models/actor/actor-image.ts b/server/models/actor/actor-image.ts index 8edff5ab4..f74ab735e 100644 --- a/server/models/actor/actor-image.ts +++ b/server/models/actor/actor-image.ts @@ -1,15 +1,29 @@ import { remove } from 'fs-extra' import { join } from 'path' -import { AfterDestroy, AllowNull, Column, CreatedAt, Default, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' -import { MActorImageFormattable } from '@server/types/models' +import { + AfterDestroy, + AllowNull, + BelongsTo, + Column, + CreatedAt, + Default, + ForeignKey, + Is, + Model, + Table, + UpdatedAt +} from 'sequelize-typescript' +import { MActorImage, MActorImageFormattable } from '@server/types/models' +import { getLowercaseExtension } from '@shared/core-utils' +import { ActivityIconObject, ActorImageType } from '@shared/models' import { AttributesOnly } from '@shared/typescript-utils' -import { ActorImageType } from '@shared/models' import { ActorImage } from '../../../shared/models/actors/actor-image.model' import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' import { logger } from '../../helpers/logger' import { CONFIG } from '../../initializers/config' -import { LAZY_STATIC_PATHS } from '../../initializers/constants' +import { LAZY_STATIC_PATHS, MIMETYPES, WEBSERVER } from '../../initializers/constants' import { throwIfNotValid } from '../utils' +import { ActorModel } from './actor' @Table({ tableName: 'actorImage', @@ -17,6 +31,10 @@ import { throwIfNotValid } from '../utils' { fields: [ 'filename' ], unique: true + }, + { + fields: [ 'actorId', 'type', 'width' ], + unique: true } ] }) @@ -55,6 +73,18 @@ export class ActorImageModel extends Model ActorModel) + @Column + actorId: number + + @BelongsTo(() => ActorModel, { + foreignKey: { + allowNull: false + }, + onDelete: 'CASCADE' + }) + Actor: ActorModel + @AfterDestroy static removeFilesAndSendDelete (instance: ActorImageModel) { logger.info('Removing actor image file %s.', instance.filename) @@ -74,20 +104,41 @@ export class ActorImageModel extends Model>> { @UpdatedAt updatedAt: Date - @ForeignKey(() => ActorImageModel) - @Column - avatarId: number - - @ForeignKey(() => ActorImageModel) - @Column - bannerId: number - - @BelongsTo(() => ActorImageModel, { + @HasMany(() => ActorImageModel, { + as: 'Avatars', + onDelete: 'cascade', + hooks: true, foreignKey: { - name: 'avatarId', - allowNull: true + allowNull: false }, - as: 'Avatar', - onDelete: 'set null', - hooks: true + scope: { + type: ActorImageType.AVATAR + } }) - Avatar: ActorImageModel + Avatars: ActorImageModel[] - @BelongsTo(() => ActorImageModel, { + @HasMany(() => ActorImageModel, { + as: 'Banners', + onDelete: 'cascade', + hooks: true, foreignKey: { - name: 'bannerId', - allowNull: true + allowNull: false }, - as: 'Banner', - onDelete: 'set null', - hooks: true + scope: { + type: ActorImageType.BANNER + } }) - Banner: ActorImageModel + Banners: ActorImageModel[] @HasMany(() => ActorFollowModel, { foreignKey: { @@ -386,8 +379,7 @@ export class ActorModel extends Model>> { transaction } - return ActorModel.scope(ScopeNames.FULL) - .findOne(query) + return ActorModel.scope(ScopeNames.FULL).findOne(query) } return ModelCache.Instance.doCache({ @@ -410,8 +402,7 @@ export class ActorModel extends Model>> { transaction } - return ActorModel.unscoped() - .findOne(query) + return ActorModel.unscoped().findOne(query) } return ModelCache.Instance.doCache({ @@ -532,55 +523,50 @@ export class ActorModel extends Model>> { } toFormattedSummaryJSON (this: MActorSummaryFormattable) { - let avatar: ActorImage = null - if (this.Avatar) { - avatar = this.Avatar.toFormattedJSON() - } - return { url: this.url, name: this.preferredUsername, host: this.getHost(), - avatar + avatars: (this.Avatars || []).map(a => a.toFormattedJSON()), + + // TODO: remove, deprecated in 4.2 + avatar: this.hasImage(ActorImageType.AVATAR) + ? this.Avatars[0].toFormattedJSON() + : undefined } } toFormattedJSON (this: MActorFormattable) { - const base = this.toFormattedSummaryJSON() - - let banner: ActorImage = null - if (this.Banner) { - banner = this.Banner.toFormattedJSON() - } + return { + ...this.toFormattedSummaryJSON(), - return Object.assign(base, { id: this.id, hostRedundancyAllowed: this.getRedundancyAllowed(), followingCount: this.followingCount, followersCount: this.followersCount, - banner, - createdAt: this.getCreatedAt() - }) + createdAt: this.getCreatedAt(), + + banners: (this.Banners || []).map(b => b.toFormattedJSON()), + + // TODO: remove, deprecated in 4.2 + banner: this.hasImage(ActorImageType.BANNER) + ? this.Banners[0].toFormattedJSON() + : undefined + } } toActivityPubObject (this: MActorAPChannel | MActorAPAccount, name: string) { let icon: ActivityIconObject + let icons: ActivityIconObject[] let image: ActivityIconObject - if (this.avatarId) { - const extension = getLowercaseExtension(this.Avatar.filename) - - icon = { - type: 'Image', - mediaType: MIMETYPES.IMAGE.EXT_MIMETYPE[extension], - height: this.Avatar.height, - width: this.Avatar.width, - url: this.getAvatarUrl() - } + if (this.hasImage(ActorImageType.AVATAR)) { + icon = getBiggestActorImage(this.Avatars).toActivityPubObject() + icons = this.Avatars.map(a => a.toActivityPubObject()) } - if (this.bannerId) { - const banner = (this as MActorAPChannel).Banner + if (this.hasImage(ActorImageType.BANNER)) { + const banner = getBiggestActorImage((this as MActorAPChannel).Banners) const extension = getLowercaseExtension(banner.filename) image = { @@ -588,7 +574,7 @@ export class ActorModel extends Model>> { mediaType: MIMETYPES.IMAGE.EXT_MIMETYPE[extension], height: banner.height, width: banner.width, - url: this.getBannerUrl() + url: ActorImageModel.getImageUrl(banner) } } @@ -612,7 +598,10 @@ export class ActorModel extends Model>> { publicKeyPem: this.publicKey }, published: this.getCreatedAt().toISOString(), + icon, + icons, + image } @@ -677,16 +666,12 @@ export class ActorModel extends Model>> { return this.Server ? this.Server.redundancyAllowed : false } - getAvatarUrl () { - if (!this.avatarId) return undefined - - return WEBSERVER.URL + this.Avatar.getStaticPath() - } - - getBannerUrl () { - if (!this.bannerId) return undefined + hasImage (type: ActorImageType) { + const images = type === ActorImageType.AVATAR + ? this.Avatars + : this.Banners - return WEBSERVER.URL + this.Banner.getStaticPath() + return Array.isArray(images) && images.length !== 0 } isOutdated () { -- cgit v1.2.3