diff --git a/packages/backend/migration/1696548899000-background.js b/packages/backend/migration/1696548899000-background.js new file mode 100644 index 0000000000..59309b98c2 --- /dev/null +++ b/packages/backend/migration/1696548899000-background.js @@ -0,0 +1,19 @@ +export class Background1696548899000 { + name = 'Background1696548899000' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" ADD "backgroundId" character varying(32)`); + await queryRunner.query(`ALTER TABLE "user" ADD CONSTRAINT "REL_fwvhvbijn8nocsdpqhn012pfo5" UNIQUE ("backgroundId")`); + await queryRunner.query(`ALTER TABLE "user" ADD CONSTRAINT "FK_q5lm0tbgejtfskzg0rc4wd7t1n" FOREIGN KEY ("backgroundId") REFERENCES "drive_file"("id") ON DELETE SET NULL ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "user" ADD "backgroundUrl" character varying(512)`); + await queryRunner.query(`ALTER TABLE "user" ADD "backgroundBlurhash" character varying(128)`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" DROP CONSTRAINT "REL_fwvhvbijn8nocsdpqhn012pfo5"`); + await queryRunner.query(`ALTER TABLE "user" DROP CONSTRAINT "FK_q5lm0tbgejtfskzg0rc4wd7t1n"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "backgroundId"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "backgroundUrl"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "backgroundBlurhash"`); + } +} \ No newline at end of file diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts index cecbec9638..4b4872a2d6 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -423,6 +423,10 @@ export class DriveService { q.andWhere('file.id != :bannerId', { bannerId: user.bannerId }); } + if (user.backgroundId) { + q.andWhere('file.id != :backgroundId', { backgroundId: user.backgroundId }); + } + //This selete is hard coded, be careful if change database schema q.addSelect('SUM("file"."size") OVER (ORDER BY "file"."id" DESC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)', 'acc_usage'); q.orderBy('file.id', 'ASC'); diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index 155aee39a9..c862bffce5 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -454,9 +454,10 @@ export class ApRendererService { const id = this.userEntityService.genLocalUserUri(user.id); const isSystem = user.username.includes('.'); - const [avatar, banner, profile] = await Promise.all([ + const [avatar, banner, background, profile] = await Promise.all([ user.avatarId ? this.driveFilesRepository.findOneBy({ id: user.avatarId }) : undefined, user.bannerId ? this.driveFilesRepository.findOneBy({ id: user.bannerId }) : undefined, + user.backgroundId ? this.driveFilesRepository.findOneBy({ id: user.backgroundId }) : undefined, this.userProfilesRepository.findOneByOrFail({ userId: user.id }), ]); @@ -496,6 +497,7 @@ export class ApRendererService { summary: profile.description ? this.mfmService.toHtml(mfm.parse(profile.description)) : null, icon: avatar ? this.renderImage(avatar) : null, image: banner ? this.renderImage(banner) : null, + backgroundUrl: background ? this.renderImage(background) : null, tag, manuallyApprovesFollowers: user.isLocked, discoverable: user.isExplorable, @@ -650,6 +652,9 @@ export class ApRendererService { // Firefish firefish: "https://joinfirefish.org/ns#", speakAsCat: "firefish:speakAsCat", + // Sharkey + sharkey: "https://joinsharkey.org/ns#", + backgroundUrl: "sharkey:backgroundUrl", // vcard vcard: 'http://www.w3.org/2006/vcard/ns#', }, diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index bbb362646d..639d11add3 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -225,8 +225,8 @@ export class ApPersonService implements OnModuleInit { return null; } - private async resolveAvatarAndBanner(user: MiRemoteUser, icon: any, image: any): Promise<Pick<MiRemoteUser, 'avatarId' | 'bannerId' | 'avatarUrl' | 'bannerUrl' | 'avatarBlurhash' | 'bannerBlurhash'>> { - const [avatar, banner] = await Promise.all([icon, image].map(img => { + private async resolveAvatarAndBanner(user: MiRemoteUser, icon: any, image: any): Promise<Pick<MiRemoteUser, 'avatarId' | 'bannerId' | 'backgroundId' | 'avatarUrl' | 'bannerUrl' | 'backgroundUrl' | 'avatarBlurhash' | 'bannerBlurhash' | 'backgroundBlurhash'>> { + const [avatar, banner, background] = await Promise.all([icon, image].map(img => { if (img == null) return null; if (user == null) throw new Error('failed to create user: user is null'); return this.apImageService.resolveImage(user, img).catch(() => null); @@ -235,10 +235,13 @@ export class ApPersonService implements OnModuleInit { return { avatarId: avatar?.id ?? null, bannerId: banner?.id ?? null, + backgroundId: background?.id ?? null, avatarUrl: avatar ? this.driveFileEntityService.getPublicUrl(avatar, 'avatar') : null, bannerUrl: banner ? this.driveFileEntityService.getPublicUrl(banner) : null, + backgroundUrl: background ? this.driveFileEntityService.getPublicUrl(background) : null, avatarBlurhash: avatar?.blurhash ?? null, bannerBlurhash: banner?.blurhash ?? null, + backgroundBlurhash: background?.blurhash ?? null }; } diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 714459d76b..dbad82b1fe 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -308,6 +308,14 @@ export class UserEntityService implements OnModuleInit { bannerBlurhash: banner.blurhash, }); } + if (user.backgroundId != null && user.backgroundUrl === null) { + const background = await this.driveFilesRepository.findOneByOrFail({ id: user.backgroundId }); + user.backgroundUrl = this.driveFileEntityService.getPublicUrl(background); + this.usersRepository.update(user.id, { + backgroundUrl: user.backgroundUrl, + backgroundBlurhash: background.blurhash, + }); + } const meId = me ? me.id : null; const isMe = meId === user.id; @@ -385,6 +393,8 @@ export class UserEntityService implements OnModuleInit { lastFetchedAt: user.lastFetchedAt ? user.lastFetchedAt.toISOString() : null, bannerUrl: user.bannerUrl, bannerBlurhash: user.bannerBlurhash, + backgroundUrl: user.backgroundUrl, + backgroundBlurhash: user.backgroundBlurhash, isLocked: user.isLocked, isSilenced: this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote), isSuspended: user.isSuspended ?? falsy, @@ -429,6 +439,7 @@ export class UserEntityService implements OnModuleInit { ...(opts.detail && isMe ? { avatarId: user.avatarId, bannerId: user.bannerId, + backgroundId: user.backgroundId, isModerator: isModerator, isAdmin: isAdmin, injectFeaturedNote: profile!.injectFeaturedNote, diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts index 08c8243421..c5670f635b 100644 --- a/packages/backend/src/models/User.ts +++ b/packages/backend/src/models/User.ts @@ -124,6 +124,19 @@ export class MiUser { @JoinColumn() public banner: MiDriveFile | null; + @Column({ + ...id(), + nullable: true, + comment: 'The ID of background DriveFile.', + }) + public backgroundId: MiDriveFile['id'] | null; + + @OneToOne(type => MiDriveFile, { + onDelete: 'SET NULL', + }) + @JoinColumn() + public background: MiDriveFile | null; + @Column('varchar', { length: 512, nullable: true, }) @@ -134,6 +147,11 @@ export class MiUser { }) public bannerUrl: string | null; + @Column('varchar', { + length: 512, nullable: true, + }) + public backgroundUrl: string | null; + @Column('varchar', { length: 128, nullable: true, }) @@ -144,6 +162,11 @@ export class MiUser { }) public bannerBlurhash: string | null; + @Column('varchar', { + length: 128, nullable: true, + }) + public backgroundBlurhash: string | null; + @Index() @Column('varchar', { length: 128, array: true, default: '{}', diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index a8fb34acb1..bea2922f5a 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -122,6 +122,15 @@ export const packedUserDetailedNotMeOnlySchema = { type: 'string', nullable: true, optional: false, }, + backgroundUrl: { + type: 'string', + format: 'url', + nullable: true, optional: false, + }, + backgroundBlurhash: { + type: 'string', + nullable: true, optional: false, + }, isLocked: { type: 'boolean', nullable: false, optional: false, @@ -304,6 +313,11 @@ export const packedMeDetailedOnlySchema = { nullable: true, optional: false, format: 'id', }, + backgroundId: { + type: 'string', + nullable: true, optional: false, + format: 'id', + }, injectFeaturedNote: { type: 'boolean', nullable: true, optional: false, diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 93897b9c8f..1011a8d31a 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -60,6 +60,12 @@ export const meta = { id: '0d8f5629-f210-41c2-9433-735831a58595', }, + noSuchBackground: { + message: 'No such background file.', + code: 'NO_SUCH_BACKGROUND', + id: '0d8f5629-f210-41c2-9433-735831a58582', + }, + avatarNotAnImage: { message: 'The file specified as an avatar is not an image.', code: 'AVATAR_NOT_AN_IMAGE', @@ -72,6 +78,12 @@ export const meta = { id: '75aedb19-2afd-4e6d-87fc-67941256fa60', }, + backgroundNotAnImage: { + message: 'The file specified as a background is not an image.', + code: 'BACKGROUND_NOT_AN_IMAGE', + id: '75aedb19-2afd-4e6d-87fc-67941256fa40', + }, + noSuchPage: { message: 'No such page.', code: 'NO_SUCH_PAGE', @@ -133,6 +145,7 @@ export const paramDef = { lang: { type: 'string', enum: [null, ...Object.keys(langmap)] as string[], nullable: true }, avatarId: { type: 'string', format: 'misskey:id', nullable: true }, bannerId: { type: 'string', format: 'misskey:id', nullable: true }, + backgroundId: { type: 'string', format: 'misskey:id', nullable: true }, fields: { type: 'array', minItems: 0, @@ -300,6 +313,21 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- updates.bannerBlurhash = null; } + if (ps.backgroundId) { + const background = await this.driveFilesRepository.findOneBy({ id: ps.backgroundId }); + + if (background == null || background.userId !== user.id) throw new ApiError(meta.errors.noSuchBackground); + if (!background.type.startsWith('image/')) throw new ApiError(meta.errors.backgroundNotAnImage); + + updates.backgroundId = background.id; + updates.backgroundUrl = this.driveFileEntityService.getPublicUrl(background); + updates.backgroundBlurhash = background.blurhash; + } else if (ps.backgroundId === null) { + updates.backgroundId = null; + updates.backgroundUrl = null; + updates.backgroundBlurhash = null; + } + if (ps.pinnedPageId) { const page = await this.pagesRepository.findOneBy({ id: ps.pinnedPageId }); diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts index 93fbdaaa32..79da6d682d 100644 --- a/packages/backend/test/e2e/users.ts +++ b/packages/backend/test/e2e/users.ts @@ -95,6 +95,8 @@ describe('ユーザー', () => { lastFetchedAt: user.lastFetchedAt, bannerUrl: user.bannerUrl, bannerBlurhash: user.bannerBlurhash, + backgroundUrl: user.backgroundUrl, + backgroundBlurhash: user.backgroundBlurhash, isLocked: user.isLocked, isSilenced: user.isSilenced, isSuspended: user.isSuspended, @@ -366,6 +368,8 @@ describe('ユーザー', () => { assert.strictEqual(response.lastFetchedAt, null); assert.strictEqual(response.bannerUrl, null); assert.strictEqual(response.bannerBlurhash, null); + assert.strictEqual(response.backgroundUrl, null); + assert.strictEqual(response.backgroundBlurhash, null); assert.strictEqual(response.isLocked, false); assert.strictEqual(response.isSilenced, false); assert.strictEqual(response.isSuspended, false); @@ -561,6 +565,31 @@ describe('ユーザー', () => { assert.deepStrictEqual(response2, expected2, inspect(parameters)); }); + test('を書き換えることができる(Background)', async () => { + const aliceFile = (await uploadFile(alice)).body; + const parameters = { bannerId: aliceFile.id }; + const response = await successfulApiCall({ endpoint: 'i/update', parameters: parameters, user: alice }); + assert.match(response.backgroundUrl ?? '.', /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/); + assert.match(response.backgroundBlurhash ?? '.', /[ -~]{54}/); + const expected = { + ...meDetailed(alice, true), + backgroundId: aliceFile.id, + backgroundBlurhash: response.baackgroundBlurhash, + backgroundUrl: response.backgroundUrl, + }; + assert.deepStrictEqual(response, expected, inspect(parameters)); + + const parameters2 = { backgroundId: null }; + const response2 = await successfulApiCall({ endpoint: 'i/update', parameters: parameters2, user: alice }); + const expected2 = { + ...meDetailed(alice, true), + backgroundId: null, + backgroundBlurhash: null, + backgroundUrl: null, + }; + assert.deepStrictEqual(response2, expected2, inspect(parameters)); + }); + //#endregion //#region 自分の情報の更新(i/pin, i/unpin) diff --git a/packages/frontend/src/components/MkDrive.file.vue b/packages/frontend/src/components/MkDrive.file.vue index 530859bc00..0b6bde2c3b 100644 --- a/packages/frontend/src/components/MkDrive.file.vue +++ b/packages/frontend/src/components/MkDrive.file.vue @@ -22,6 +22,10 @@ SPDX-License-Identifier: AGPL-3.0-only <img :class="$style.labelImg" src="/client-assets/label.svg"/> <p :class="$style.labelText">{{ i18n.ts.banner }}</p> </div> + <div v-if="$i?.backgroundId == file.id" :class="[$style.label]"> + <img :class="$style.labelImg" src="/client-assets/label.svg"/> + <p :class="$style.labelText">Background</p> + </div> <div v-if="file.isSensitive" :class="[$style.label, $style.red]"> <img :class="$style.labelImg" src="/client-assets/label-red.svg"/> <p :class="$style.labelText">{{ i18n.ts.sensitive }}</p> diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index 904ed03ee2..9533707784 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -10,9 +10,11 @@ SPDX-License-Identifier: AGPL-3.0-only <MkAvatar :class="$style.avatar" :user="$i" @click="changeAvatar"/> <MkButton primary rounded @click="changeAvatar">{{ i18n.ts._profile.changeAvatar }}</MkButton> </div> + <MkButton primary rounded :class="$style.backgroundEdit" @click="changeBackground">Change Background</MkButton> <MkButton primary rounded :class="$style.bannerEdit" @click="changeBanner">{{ i18n.ts._profile.changeBanner }}</MkButton> </div> + <MkInput v-model="profile.name" :max="30" manualSave> <template #label>{{ i18n.ts._profile.name }}</template> </MkInput> @@ -254,6 +256,31 @@ function changeBanner(ev) { }); } +function changeBackground(ev) { + selectFile(ev.currentTarget ?? ev.target, i18n.ts.banner).then(async (file) => { + let originalOrCropped = file; + + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.t('cropImageAsk'), + okText: i18n.ts.cropYes, + cancelText: i18n.ts.cropNo, + }); + + if (!canceled) { + originalOrCropped = await os.cropImage(file, { + aspectRatio: 1, + }); + } + + const i = await os.apiWithDialog('i/update', { + backgroundId: originalOrCropped.id, + }); + $i.backgroundId = i.backgroundId; + $i.backgroundUrl = i.backgroundUrl; + }); +} + const headerActions = $computed(() => []); const headerTabs = $computed(() => []); @@ -292,6 +319,11 @@ definePageMetadata({ top: 16px; right: 16px; } +.backgroundEdit { + position: absolute; + top: 103px; + right: 16px; +} .metadataRoot { container-type: inline-size; diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index a4a4ac2fbf..2c81026de4 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkSpacer :contentMax="narrow ? 800 : 1100"> +<MkSpacer :contentMax="narrow ? 800 : 1100" :style="background"> <div ref="rootEl" class="ftskorzw" :class="{ wide: !narrow }" style="container-type: inline-size;"> <div class="main _gaps"> <!-- TODO --> @@ -236,6 +236,13 @@ if (props.user.listenbrainz) { } } +const background = computed(() => { + if (props.user.backgroundUrl == null) return {}; + return { + '--backgroundImageStatic': `url('${props.user.backgroundUrl}')` + }; +}); + watch($$(moderationNote), async () => { await os.api('admin/update-user-note', { userId: props.user.id, text: moderationNote }); }); @@ -338,6 +345,24 @@ onUnmounted(() => { <style lang="scss" scoped> .ftskorzw { + &::before { + content: ""; + position: fixed; + inset: 0; + background: var(--backgroundImageStatic); + background-size: cover; + background-position: center; + pointer-events: none; + filter: blur(8px) opacity(0.6); + // Funny CSS schenanigans to make background escape container + padding-left: 20px; + margin-left: -20px; + padding-right: 20px; + margin-right: -20px; + padding-top: 20px; + margin-top: -20px; + background-attachment: fixed; + } > .main { diff --git a/packages/misskey-js/src/api.types.ts b/packages/misskey-js/src/api.types.ts index b3837369ec..588de4f8e7 100644 --- a/packages/misskey-js/src/api.types.ts +++ b/packages/misskey-js/src/api.types.ts @@ -414,6 +414,7 @@ export type Endpoints = { birthday?: string | null; avatarId?: DriveFile['id'] | null; bannerId?: DriveFile['id'] | null; + backgroundId?: DriveFile['id'] | null; fields?: { name: string; value: string; diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts index 59df4582de..08d328c5b9 100644 --- a/packages/misskey-js/src/entities.ts +++ b/packages/misskey-js/src/entities.ts @@ -35,6 +35,8 @@ export type UserDetailed = UserLite & { bannerBlurhash: string | null; bannerColor: string | null; bannerUrl: string | null; + backgroundUrl: string | null; + backgroundBlurhash: string | null; birthday: string | null; createdAt: DateString; description: string | null; @@ -88,6 +90,7 @@ export type UserList = { export type MeDetailed = UserDetailed & { avatarId: DriveFile['id']; bannerId: DriveFile['id']; + backgroundId: DriveFile['id']; autoAcceptFollowed: boolean; alwaysMarkNsfw: boolean; carefulBot: boolean;