summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMar0xy <marie@kaifa.ch>2023-10-06 02:32:09 +0200
committerMar0xy <marie@kaifa.ch>2023-10-06 02:32:09 +0200
commit4e6439763544f7b96009dd1411035343fb561d2a (patch)
tree6f0d183391c27f383297ee1713e821ade4e60d20
parentmerge: add speakAsCat extension and non-nya speech option (diff)
downloadsharkey-4e6439763544f7b96009dd1411035343fb561d2a.tar.gz
sharkey-4e6439763544f7b96009dd1411035343fb561d2a.tar.bz2
sharkey-4e6439763544f7b96009dd1411035343fb561d2a.zip
add: profile backgrounds
-rw-r--r--packages/backend/migration/1696548899000-background.js19
-rw-r--r--packages/backend/src/core/DriveService.ts4
-rw-r--r--packages/backend/src/core/activitypub/ApRendererService.ts7
-rw-r--r--packages/backend/src/core/activitypub/models/ApPersonService.ts7
-rw-r--r--packages/backend/src/core/entities/UserEntityService.ts11
-rw-r--r--packages/backend/src/models/User.ts23
-rw-r--r--packages/backend/src/models/json-schema/user.ts14
-rw-r--r--packages/backend/src/server/api/endpoints/i/update.ts28
-rw-r--r--packages/backend/test/e2e/users.ts29
-rw-r--r--packages/frontend/src/components/MkDrive.file.vue4
-rw-r--r--packages/frontend/src/pages/settings/profile.vue32
-rw-r--r--packages/frontend/src/pages/user/home.vue27
-rw-r--r--packages/misskey-js/src/api.types.ts1
-rw-r--r--packages/misskey-js/src/entities.ts3
14 files changed, 205 insertions, 4 deletions
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,
})
@@ -135,6 +148,11 @@ export class MiUser {
public bannerUrl: string | null;
@Column('varchar', {
+ length: 512, nullable: true,
+ })
+ public backgroundUrl: string | null;
+
+ @Column('varchar', {
length: 128, nullable: true,
})
public avatarBlurhash: string | null;
@@ -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;