diff --git a/CHANGELOG.md b/CHANGELOG.md index 854c5f70ff..57ca09169a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,18 +12,22 @@ --> -## 2023.x.x (unreleased) +## 2023.11.0 (unreleased) ### General +- Feat: アイコンデコレーション機能 +- Enhance: すでにフォローしたすべての人の返信をTLに追加できるように - Feat: 絵文字のリクエスト機能が追加されました ## Client - Feat: プラグイン・テーマを外部サイトから直接インストールできるようになりました - 外部サイトでの実装が必要です。詳細は Misskey Hub をご覧ください https://misskey-hub.net/docs/advanced/publish-on-your-website.html +- Fix: 投稿フォームでのユーザー変更がプレビューに反映されない問題を修正 ### Server -- +- Fix: リストTLに自分のフォロワー限定投稿が含まれない問題を修正 +- Fix: ローカルタイムラインに投稿者自身の投稿への返信が含まれない問題を修正 ## 2023.10.2 @@ -35,7 +39,6 @@ - Enhance: フォロー/フォロー解除したときに過去分のHTLにも含まれる投稿が反映されるように - Enhance: ローカリゼーションの更新 - Enhance: 依存関係の更新 -- Enhance: すでにフォローしたすべての人の返信をTLに追加できるように ### Client - Enhance: TLの返信表示オプションを記憶するように diff --git a/locales/index.d.ts b/locales/index.d.ts index 1ac625ffe5..81352c786c 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1154,6 +1154,11 @@ export interface Locale { "privacyPolicyUrl": string; "tosAndPrivacyPolicy": string; "avatarDecorations": string; + "attach": string; + "detach": string; + "angle": string; + "flip": string; + "showAvatarDecorations": string; "_announcement": { "forExistingUsers": string; "forExistingUsersDescription": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index c33a800aa8..f6a9050331 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1151,6 +1151,11 @@ privacyPolicy: "プライバシーポリシー" privacyPolicyUrl: "プライバシーポリシーURL" tosAndPrivacyPolicy: "利用規約・プライバシーポリシー" avatarDecorations: "アイコンデコレーション" +attach: "付ける" +detach: "外す" +angle: "角度" +flip: "反転" +showAvatarDecorations: "アイコンのデコレーションを表示" _announcement: forExistingUsers: "既存ユーザーのみ" diff --git a/package.json b/package.json index 3329041e5a..bf9d30f579 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "misskey", - "version": "2023.11.0-beta.1", + "version": "2023.11.0-beta.2", "codename": "nasubi", "repository": { "type": "git", diff --git a/packages/backend/migration/1697941908548-avatar-decoration2.js b/packages/backend/migration/1697941908548-avatar-decoration2.js new file mode 100644 index 0000000000..9d15c1c3d0 --- /dev/null +++ b/packages/backend/migration/1697941908548-avatar-decoration2.js @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class AvatarDecoration21697941908548 { + name = 'AvatarDecoration21697941908548' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarDecorations"`); + await queryRunner.query(`ALTER TABLE "user" ADD "avatarDecorations" jsonb NOT NULL DEFAULT '[]'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarDecorations"`); + await queryRunner.query(`ALTER TABLE "user" ADD "avatarDecorations" character varying(512) array NOT NULL DEFAULT '{}'`); + } +} diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 364a300d23..fae512336d 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -868,7 +868,7 @@ export class NoteCreateService implements OnApplicationShutdown { if (note.visibility === 'followers') { // TODO: 重そうだから何とかしたい Set 使う? - userListMemberships = userListMemberships.filter(x => followings.some(f => f.followerId === x.userListUserId)); + userListMemberships = userListMemberships.filter(x => x.userListUserId === user.id || followings.some(f => f.followerId === x.userListUserId)); } // TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 66facce4c2..09a7e579f0 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -338,9 +338,11 @@ export class UserEntityService implements OnModuleInit { host: user.host, avatarUrl: user.avatarUrl ?? this.getIdenticonUrl(user), avatarBlurhash: user.avatarBlurhash, - avatarDecorations: user.avatarDecorations.length > 0 ? this.avatarDecorationService.getAll().then(decorations => decorations.filter(decoration => user.avatarDecorations.includes(decoration.id)).map(decoration => ({ - id: decoration.id, - url: decoration.url, + avatarDecorations: user.avatarDecorations.length > 0 ? this.avatarDecorationService.getAll().then(decorations => user.avatarDecorations.filter(ud => decorations.some(d => d.id === ud.id)).map(ud => ({ + id: ud.id, + angle: ud.angle || undefined, + flipH: ud.flipH || undefined, + url: decorations.find(d => d.id === ud.id)!.url, }))) : [], isBot: user.isBot, isCat: user.isCat, diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts index c98426a7b6..c3762fcd3e 100644 --- a/packages/backend/src/models/User.ts +++ b/packages/backend/src/models/User.ts @@ -138,10 +138,14 @@ export class MiUser { }) public bannerBlurhash: string | null; - @Column('varchar', { - length: 512, array: true, default: '{}', + @Column('jsonb', { + default: [], }) - public avatarDecorations: string[]; + public avatarDecorations: { + id: string; + angle: number; + flipH: boolean; + }[]; @Index() @Column('varchar', { diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index bf283fbeb2..75f3286eff 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -54,6 +54,14 @@ export const packedUserLiteSchema = { format: 'url', nullable: false, optional: false, }, + angle: { + type: 'number', + nullable: false, optional: true, + }, + flipH: { + type: 'boolean', + nullable: false, optional: true, + }, }, }, }, diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 79ead57a66..b03381a3f3 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -133,7 +133,13 @@ export const paramDef = { lang: { type: 'string', enum: [null, ...Object.keys(langmap)] as string[], nullable: true }, avatarId: { type: 'string', format: 'misskey:id', nullable: true }, avatarDecorations: { type: 'array', maxItems: 1, items: { - type: 'string', + type: 'object', + properties: { + id: { type: 'string', format: 'misskey:id' }, + angle: { type: 'number', nullable: true, maximum: 0.5, minimum: -0.5 }, + flipH: { type: 'boolean', nullable: true }, + }, + required: ['id'], } }, bannerId: { type: 'string', format: 'misskey:id', nullable: true }, fields: { @@ -309,7 +315,11 @@ export default class extends Endpoint { // eslint- .filter(d => d.roleIdsThatCanBeUsedThisDecoration.filter(roleId => allRoles.some(r => r.id === roleId)).length === 0 || myRoles.some(r => d.roleIdsThatCanBeUsedThisDecoration.includes(r.id))) .map(d => d.id); - updates.avatarDecorations = ps.avatarDecorations.filter(id => decorationIds.includes(id)); + updates.avatarDecorations = ps.avatarDecorations.filter(d => decorationIds.includes(d.id)).map(d => ({ + id: d.id, + angle: d.angle ?? 0, + flipH: d.flipH ?? false, + })); } if (ps.pinnedPageId) { diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts index 3b6c93fdf9..4b9882e834 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -120,7 +120,7 @@ export default class extends Endpoint { // eslint- if (me && (note.userId === me.id)) { return true; } - if (!ps.withReplies && note.replyId && (me == null || note.replyUserId !== me.id)) return false; + if (!ps.withReplies && note.replyId && note.replyUserId !== note.userId && (me == null || note.replyUserId !== me.id)) return false; if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false; if (me && isUserRelated(note, userIdsWhoMeMuting)) return false; if (note.renoteId) { diff --git a/packages/backend/src/server/api/openapi/schemas.ts b/packages/backend/src/server/api/openapi/schemas.ts index 0b9eb4fe24..1a1d973e56 100644 --- a/packages/backend/src/server/api/openapi/schemas.ts +++ b/packages/backend/src/server/api/openapi/schemas.ts @@ -26,7 +26,12 @@ export function convertSchemaToOpenApiSchema(schema: Schema) { if (schema.allOf) res.allOf = schema.allOf.map(convertSchemaToOpenApiSchema); if (schema.ref) { - res.$ref = `#/components/schemas/${schema.ref}`; + const $ref = `#/components/schemas/${schema.ref}`; + if (schema.nullable || schema.optional) { + res.allOf = [{ $ref }]; + } else { + res.$ref = $ref; + } } return res; diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index 28f07bf3f7..760bb8a574 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -526,6 +526,20 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); }); + test.concurrent('他人のその人自身への返信が含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const bobNote1 = await post(bob, { text: 'hi' }); + const bobNote2 = await post(bob, { text: 'hi', replyId: bobNote1.id }); + + await waitForPushToTl(); + + const res = await api('/notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); + }); + test.concurrent('チャンネル投稿が含まれない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); @@ -947,6 +961,22 @@ describe('Timelines', () => { assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi'); }); + test.concurrent('リスインしている自分の visibility: followers なノートが含まれる', async () => { + const [alice] = await Promise.all([signup(), signup()]); + + const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body); + await api('/users/lists/push', { listId: list.id, userId: alice.id }, alice); + await sleep(1000); + const aliceNote = await post(alice, { text: 'hi', visibility: 'followers' }); + + await waitForPushToTl(); + + const res = await api('/notes/user-list-timeline', { listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); + assert.strictEqual(res.body.find((note: any) => note.id === aliceNote.id).text, 'hi'); + }); + test.concurrent('リスインしているユーザーのチャンネルノートが含まれない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); diff --git a/packages/frontend/src/components/MkNotePreview.vue b/packages/frontend/src/components/MkNotePreview.vue index fc6ea89085..923c240cf0 100644 --- a/packages/frontend/src/components/MkNotePreview.vue +++ b/packages/frontend/src/components/MkNotePreview.vue @@ -5,14 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -48,18 +57,28 @@ const props = withDefaults(defineProps<{ link?: boolean; preview?: boolean; indicator?: boolean; - decoration?: string; + decoration?: { + url: string; + angle?: number; + flipH?: boolean; + flipV?: boolean; + }; + forceShowDecoration?: boolean; }>(), { target: null, link: false, preview: false, indicator: false, + decoration: undefined, + forceShowDecoration: false, }); const emit = defineEmits<{ (ev: 'click', v: MouseEvent): void; }>(); +const showDecoration = props.forceShowDecoration || defaultStore.state.showAvatarDecorations; + const bound = $computed(() => props.link ? { to: userPage(props.user), target: props.target } : {}); @@ -73,6 +92,30 @@ function onClick(ev: MouseEvent): void { emit('click', ev); } +function getDecorationAngle() { + let angle; + if (props.decoration) { + angle = props.decoration.angle ?? 0; + } else if (props.user.avatarDecorations.length > 0) { + angle = props.user.avatarDecorations[0].angle ?? 0; + } else { + angle = 0; + } + return angle === 0 ? undefined : `${angle * 360}deg`; +} + +function getDecorationScale() { + let scaleX; + if (props.decoration) { + scaleX = props.decoration.flipH ? -1 : 1; + } else if (props.user.avatarDecorations.length > 0) { + scaleX = props.user.avatarDecorations[0].flipH ? -1 : 1; + } else { + scaleX = 1; + } + return scaleX === 1 ? undefined : `${scaleX} 1`; +} + let color = $ref(); watch(() => props.user.avatarBlurhash, () => { diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue index 6314f9f5d2..c443cdeb76 100644 --- a/packages/frontend/src/pages/about-misskey.vue +++ b/packages/frontend/src/pages/about-misskey.vue @@ -294,6 +294,7 @@ const patrons = [ '美少女JKぐーちゃん', 'てば', 'たっくん', + 'SHO SEKIGUCHI', ]; let thereIsTreasure = $ref($i && !claimedAchievements.includes('foundTreasure')); diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue index f186cf2ae3..9508e04e1b 100644 --- a/packages/frontend/src/pages/settings/general.vue +++ b/packages/frontend/src/pages/settings/general.vue @@ -30,8 +30,6 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.showFixedPostForm }} {{ i18n.ts.showFixedPostFormInChannel }} {{ i18n.ts.withRepliesByDefaultForNewlyFollowed }} - {{ i18n.ts.showRepliesToOthersInTimelineAll }} - {{ i18n.ts.hideRepliesToOthersInTimelineAll }} @@ -119,6 +117,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.disableShowingAnimatedImages }} {{ i18n.ts.highlightSensitiveMedia }} {{ i18n.ts.squareAvatars }} + {{ i18n.ts.showAvatarDecorations }} {{ i18n.ts.useSystemFont }} {{ i18n.ts.disableDrawer }} {{ i18n.ts.forceShowAds }} @@ -203,7 +202,7 @@ import { unisonReload } from '@/scripts/unison-reload.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { miLocalStorage } from '@/local-storage.js'; -import { globalEvents } from '@/events'; +import { globalEvents } from '@/events.js'; import { claimAchievement } from '@/scripts/achievements.js'; const lang = ref(miLocalStorage.getItem('lang')); @@ -248,6 +247,7 @@ const instanceTicker = computed(defaultStore.makeGetterSetter('instanceTicker')) const enableInfiniteScroll = computed(defaultStore.makeGetterSetter('enableInfiniteScroll')); const useReactionPickerForContextMenu = computed(defaultStore.makeGetterSetter('useReactionPickerForContextMenu')); const squareAvatars = computed(defaultStore.makeGetterSetter('squareAvatars')); +const showAvatarDecorations = computed(defaultStore.makeGetterSetter('showAvatarDecorations')); const mediaListWithOneImageAppearance = computed(defaultStore.makeGetterSetter('mediaListWithOneImageAppearance')); const notificationPosition = computed(defaultStore.makeGetterSetter('notificationPosition')); const notificationStackAxis = computed(defaultStore.makeGetterSetter('notificationStackAxis')); @@ -334,15 +334,6 @@ async function setPinnedList() { defaultStore.set('pinnedUserLists', [list]); } -async function updateRepliesAll(withReplies: boolean) { - const { canceled } = os.confirm({ - type: 'warning', - text: withReplies ? i18n.ts.confirmShowRepliesAll : i18n.ts.confirmHideRepliesAll, - }); - if (canceled) return; - await os.api('following/update-all', { withReplies }); -} - function removePinnedList() { defaultStore.set('pinnedUserLists', []); } diff --git a/packages/frontend/src/pages/settings/other.vue b/packages/frontend/src/pages/settings/other.vue index e2fc021099..43a8632130 100644 --- a/packages/frontend/src/pages/settings/other.vue +++ b/packages/frontend/src/pages/settings/other.vue @@ -73,6 +73,13 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.registry }} + + +
+ {{ i18n.ts.showRepliesToOthersInTimelineAll }} + {{ i18n.ts.hideRepliesToOthersInTimelineAll }} +
+
@@ -138,6 +145,15 @@ async function reloadAsk() { unisonReload(); } +async function updateRepliesAll(withReplies: boolean) { + const { canceled } = os.confirm({ + type: 'warning', + text: withReplies ? i18n.ts.confirmShowRepliesAll : i18n.ts.confirmHideRepliesAll, + }); + if (canceled) return; + await os.api('following/update-all', { withReplies }); +} + watch([ enableCondensedLineForAcct, ], async () => { diff --git a/packages/frontend/src/pages/settings/profile.avatar-decoration-dialog.vue b/packages/frontend/src/pages/settings/profile.avatar-decoration-dialog.vue new file mode 100644 index 0000000000..4d571bc9ba --- /dev/null +++ b/packages/frontend/src/pages/settings/profile.avatar-decoration-dialog.vue @@ -0,0 +1,114 @@ + + + + + + + diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index c44a58d04a..2a0b678ed1 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- + {{ i18n.ts._profile.changeAvatar }}
{{ i18n.ts._profile.changeBanner }} @@ -92,10 +92,10 @@ SPDX-License-Identifier: AGPL-3.0-only v-for="avatarDecoration in avatarDecorations" :key="avatarDecoration.id" :class="[$style.avatarDecoration, { [$style.avatarDecorationActive]: $i.avatarDecorations.some(x => x.id === avatarDecoration.id) }]" - @click="toggleDecoration(avatarDecoration)" + @click="openDecoration(avatarDecoration)" > -
{{ avatarDecoration.name }}
- +
{{ avatarDecoration.name }}
+
@@ -266,18 +266,10 @@ function changeBanner(ev) { }); } -function toggleDecoration(avatarDecoration) { - if ($i.avatarDecorations.some(x => x.id === avatarDecoration.id)) { - os.apiWithDialog('i/update', { - avatarDecorations: [], - }); - $i.avatarDecorations = []; - } else { - os.apiWithDialog('i/update', { - avatarDecorations: [avatarDecoration.id], - }); - $i.avatarDecorations.push(avatarDecoration); - } +function openDecoration(avatarDecoration) { + os.popup(defineAsyncComponent(() => import('./profile.avatar-decoration-dialog.vue')), { + decoration: avatarDecoration, + }, {}, 'closed'); } const headerActions = $computed(() => []); @@ -377,13 +369,17 @@ definePageMetadata({ .avatarDecoration { cursor: pointer; - padding: 16px 16px 24px 16px; + padding: 16px 16px 28px 16px; border: solid 2px var(--divider); border-radius: 8px; text-align: center; + font-size: 90%; + overflow: clip; + contain: content; } .avatarDecorationActive { + background-color: var(--accentedBg); border-color: var(--accent); } @@ -391,6 +387,6 @@ definePageMetadata({ position: relative; z-index: 10; font-weight: bold; - margin-bottom: 16px; + margin-bottom: 20px; } diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 92d01e4caf..6196e684e1 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -293,6 +293,10 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: false, }, + showAvatarDecorations: { + where: 'device', + default: true, + }, postFormWithHashtags: { where: 'device', default: false, diff --git a/packages/frontend/src/ui/_common_/navbar.vue b/packages/frontend/src/ui/_common_/navbar.vue index 46d92867b1..cbaee675c0 100644 --- a/packages/frontend/src/ui/_common_/navbar.vue +++ b/packages/frontend/src/ui/_common_/navbar.vue @@ -58,9 +58,9 @@ SPDX-License-Identifier: AGPL-3.0-only