Merge remote-tracking branch 'mi-dev/develop' into emoji

# Conflicts:
#	CHANGELOG.md
#	packages/misskey-js/etc/misskey-js.api.md
This commit is contained in:
mattyatea 2023-10-22 21:06:59 +09:00
commit b511ab2199
No known key found for this signature in database
GPG Key ID: 068E54E2C33BEF9A
26 changed files with 323 additions and 57 deletions

View File

@ -12,18 +12,22 @@
--> -->
## 2023.x.x (unreleased) ## 2023.11.0 (unreleased)
### General ### General
- Feat: アイコンデコレーション機能
- Enhance: すでにフォローしたすべての人の返信をTLに追加できるように
- Feat: 絵文字のリクエスト機能が追加されました - Feat: 絵文字のリクエスト機能が追加されました
## Client ## Client
- Feat: プラグイン・テーマを外部サイトから直接インストールできるようになりました - Feat: プラグイン・テーマを外部サイトから直接インストールできるようになりました
- 外部サイトでの実装が必要です。詳細は Misskey Hub をご覧ください - 外部サイトでの実装が必要です。詳細は Misskey Hub をご覧ください
https://misskey-hub.net/docs/advanced/publish-on-your-website.html https://misskey-hub.net/docs/advanced/publish-on-your-website.html
- Fix: 投稿フォームでのユーザー変更がプレビューに反映されない問題を修正
### Server ### Server
- - Fix: リストTLに自分のフォロワー限定投稿が含まれない問題を修正
- Fix: ローカルタイムラインに投稿者自身の投稿への返信が含まれない問題を修正
## 2023.10.2 ## 2023.10.2
@ -35,7 +39,6 @@
- Enhance: フォロー/フォロー解除したときに過去分のHTLにも含まれる投稿が反映されるように - Enhance: フォロー/フォロー解除したときに過去分のHTLにも含まれる投稿が反映されるように
- Enhance: ローカリゼーションの更新 - Enhance: ローカリゼーションの更新
- Enhance: 依存関係の更新 - Enhance: 依存関係の更新
- Enhance: すでにフォローしたすべての人の返信をTLに追加できるように
### Client ### Client
- Enhance: TLの返信表示オプションを記憶するように - Enhance: TLの返信表示オプションを記憶するように

5
locales/index.d.ts vendored
View File

@ -1154,6 +1154,11 @@ export interface Locale {
"privacyPolicyUrl": string; "privacyPolicyUrl": string;
"tosAndPrivacyPolicy": string; "tosAndPrivacyPolicy": string;
"avatarDecorations": string; "avatarDecorations": string;
"attach": string;
"detach": string;
"angle": string;
"flip": string;
"showAvatarDecorations": string;
"_announcement": { "_announcement": {
"forExistingUsers": string; "forExistingUsers": string;
"forExistingUsersDescription": string; "forExistingUsersDescription": string;

View File

@ -1151,6 +1151,11 @@ privacyPolicy: "プライバシーポリシー"
privacyPolicyUrl: "プライバシーポリシーURL" privacyPolicyUrl: "プライバシーポリシーURL"
tosAndPrivacyPolicy: "利用規約・プライバシーポリシー" tosAndPrivacyPolicy: "利用規約・プライバシーポリシー"
avatarDecorations: "アイコンデコレーション" avatarDecorations: "アイコンデコレーション"
attach: "付ける"
detach: "外す"
angle: "角度"
flip: "反転"
showAvatarDecorations: "アイコンのデコレーションを表示"
_announcement: _announcement:
forExistingUsers: "既存ユーザーのみ" forExistingUsers: "既存ユーザーのみ"

View File

@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "2023.11.0-beta.1", "version": "2023.11.0-beta.2",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",

View File

@ -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 '{}'`);
}
}

View File

@ -868,7 +868,7 @@ export class NoteCreateService implements OnApplicationShutdown {
if (note.visibility === 'followers') { if (note.visibility === 'followers') {
// TODO: 重そうだから何とかしたい Set 使う? // 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万件程度を目安に分割して実行するようにする // TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする

View File

@ -338,9 +338,11 @@ export class UserEntityService implements OnModuleInit {
host: user.host, host: user.host,
avatarUrl: user.avatarUrl ?? this.getIdenticonUrl(user), avatarUrl: user.avatarUrl ?? this.getIdenticonUrl(user),
avatarBlurhash: user.avatarBlurhash, avatarBlurhash: user.avatarBlurhash,
avatarDecorations: user.avatarDecorations.length > 0 ? this.avatarDecorationService.getAll().then(decorations => decorations.filter(decoration => user.avatarDecorations.includes(decoration.id)).map(decoration => ({ avatarDecorations: user.avatarDecorations.length > 0 ? this.avatarDecorationService.getAll().then(decorations => user.avatarDecorations.filter(ud => decorations.some(d => d.id === ud.id)).map(ud => ({
id: decoration.id, id: ud.id,
url: decoration.url, angle: ud.angle || undefined,
flipH: ud.flipH || undefined,
url: decorations.find(d => d.id === ud.id)!.url,
}))) : [], }))) : [],
isBot: user.isBot, isBot: user.isBot,
isCat: user.isCat, isCat: user.isCat,

View File

@ -138,10 +138,14 @@ export class MiUser {
}) })
public bannerBlurhash: string | null; public bannerBlurhash: string | null;
@Column('varchar', { @Column('jsonb', {
length: 512, array: true, default: '{}', default: [],
}) })
public avatarDecorations: string[]; public avatarDecorations: {
id: string;
angle: number;
flipH: boolean;
}[];
@Index() @Index()
@Column('varchar', { @Column('varchar', {

View File

@ -54,6 +54,14 @@ export const packedUserLiteSchema = {
format: 'url', format: 'url',
nullable: false, optional: false, nullable: false, optional: false,
}, },
angle: {
type: 'number',
nullable: false, optional: true,
},
flipH: {
type: 'boolean',
nullable: false, optional: true,
},
}, },
}, },
}, },

View File

@ -133,7 +133,13 @@ export const paramDef = {
lang: { type: 'string', enum: [null, ...Object.keys(langmap)] as string[], nullable: true }, lang: { type: 'string', enum: [null, ...Object.keys(langmap)] as string[], nullable: true },
avatarId: { type: 'string', format: 'misskey:id', nullable: true }, avatarId: { type: 'string', format: 'misskey:id', nullable: true },
avatarDecorations: { type: 'array', maxItems: 1, items: { 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 }, bannerId: { type: 'string', format: 'misskey:id', nullable: true },
fields: { fields: {
@ -309,7 +315,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.filter(d => d.roleIdsThatCanBeUsedThisDecoration.filter(roleId => allRoles.some(r => r.id === roleId)).length === 0 || myRoles.some(r => d.roleIdsThatCanBeUsedThisDecoration.includes(r.id))) .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); .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) { if (ps.pinnedPageId) {

View File

@ -120,7 +120,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (me && (note.userId === me.id)) { if (me && (note.userId === me.id)) {
return true; 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, userIdsWhoBlockingMe)) return false;
if (me && isUserRelated(note, userIdsWhoMeMuting)) return false; if (me && isUserRelated(note, userIdsWhoMeMuting)) return false;
if (note.renoteId) { if (note.renoteId) {

View File

@ -26,7 +26,12 @@ export function convertSchemaToOpenApiSchema(schema: Schema) {
if (schema.allOf) res.allOf = schema.allOf.map(convertSchemaToOpenApiSchema); if (schema.allOf) res.allOf = schema.allOf.map(convertSchemaToOpenApiSchema);
if (schema.ref) { 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; return res;

View File

@ -526,6 +526,20 @@ describe('Timelines', () => {
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); 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 () => { test.concurrent('チャンネル投稿が含まれない', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]); 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'); 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 () => { test.concurrent('リスインしているユーザーのチャンネルノートが含まれない', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]); const [alice, bob] = await Promise.all([signup(), signup()]);

View File

@ -5,14 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<div :class="$style.root"> <div :class="$style.root">
<MkAvatar :class="$style.avatar" :user="$i" link preview/> <MkAvatar :class="$style.avatar" :user="user" link preview/>
<div :class="$style.main"> <div :class="$style.main">
<div :class="$style.header"> <div :class="$style.header">
<MkUserName :user="$i" :nowrap="true"/> <MkUserName :user="user" :nowrap="true"/>
</div> </div>
<div> <div>
<div> <div>
<Mfm :text="text.trim()" :author="$i" :i="$i"/> <Mfm :text="text.trim()" :author="user" :i="user"/>
</div> </div>
</div> </div>
</div> </div>
@ -21,10 +21,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { } from 'vue'; import { } from 'vue';
import { $i } from '@/account.js'; import * as Misskey from 'misskey-js';
const props = defineProps<{ const props = defineProps<{
text: string; text: string;
user: Misskey.entities.User;
}>(); }>();
</script> </script>

View File

@ -73,7 +73,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags"> <input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
<XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName" @replaceFile="replaceFile"/> <XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName" @replaceFile="replaceFile"/>
<MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/> <MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/>
<MkNotePreview v-if="showPreview" :class="$style.preview" :text="text"/> <MkNotePreview v-if="showPreview" :class="$style.preview" :text="text" :user="postAccount ?? $i"/>
<div v-if="showingOptions" style="padding: 8px 16px;"> <div v-if="showingOptions" style="padding: 8px 16px;">
</div> </div>
<footer :class="$style.footer"> <footer :class="$style.footer">

View File

@ -34,6 +34,7 @@ const props = withDefaults(defineProps<{
textConverter?: (value: number) => string, textConverter?: (value: number) => string,
showTicks?: boolean; showTicks?: boolean;
easing?: boolean; easing?: boolean;
continuousUpdate?: boolean;
}>(), { }>(), {
step: 1, step: 1,
textConverter: (v) => v.toString(), textConverter: (v) => v.toString(),
@ -123,6 +124,10 @@ const onMousedown = (ev: MouseEvent | TouchEvent) => {
const pointerX = ev.touches && ev.touches.length > 0 ? ev.touches[0].clientX : ev.clientX; const pointerX = ev.touches && ev.touches.length > 0 ? ev.touches[0].clientX : ev.clientX;
const pointerPositionOnContainer = pointerX - (containerRect.left + (thumbWidth / 2)); const pointerPositionOnContainer = pointerX - (containerRect.left + (thumbWidth / 2));
rawValue.value = Math.min(1, Math.max(0, pointerPositionOnContainer / (containerEl.value!.offsetWidth - thumbWidth))); rawValue.value = Math.min(1, Math.max(0, pointerPositionOnContainer / (containerEl.value!.offsetWidth - thumbWidth)));
if (props.continuousUpdate) {
emit('update:modelValue', finalValue.value);
}
}; };
let beforeValue = finalValue.value; let beforeValue = finalValue.value;

View File

@ -23,7 +23,16 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</div> </div>
</div> </div>
<img v-if="decoration || user.avatarDecorations.length > 0" :class="[$style.decoration]" :src="decoration ?? user.avatarDecorations[0].url" alt=""> <img
v-if="showDecoration && (decoration || user.avatarDecorations.length > 0)"
:class="[$style.decoration]"
:src="decoration?.url ?? user.avatarDecorations[0].url"
:style="{
rotate: getDecorationAngle(),
scale: getDecorationScale(),
}"
alt=""
>
</component> </component>
</template> </template>
@ -48,18 +57,28 @@ const props = withDefaults(defineProps<{
link?: boolean; link?: boolean;
preview?: boolean; preview?: boolean;
indicator?: boolean; indicator?: boolean;
decoration?: string; decoration?: {
url: string;
angle?: number;
flipH?: boolean;
flipV?: boolean;
};
forceShowDecoration?: boolean;
}>(), { }>(), {
target: null, target: null,
link: false, link: false,
preview: false, preview: false,
indicator: false, indicator: false,
decoration: undefined,
forceShowDecoration: false,
}); });
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'click', v: MouseEvent): void; (ev: 'click', v: MouseEvent): void;
}>(); }>();
const showDecoration = props.forceShowDecoration || defaultStore.state.showAvatarDecorations;
const bound = $computed(() => props.link const bound = $computed(() => props.link
? { to: userPage(props.user), target: props.target } ? { to: userPage(props.user), target: props.target }
: {}); : {});
@ -73,6 +92,30 @@ function onClick(ev: MouseEvent): void {
emit('click', ev); 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<string | undefined>(); let color = $ref<string | undefined>();
watch(() => props.user.avatarBlurhash, () => { watch(() => props.user.avatarBlurhash, () => {

View File

@ -294,6 +294,7 @@ const patrons = [
'美少女JKぐーちゃん', '美少女JKぐーちゃん',
'てば', 'てば',
'たっくん', 'たっくん',
'SHO SEKIGUCHI',
]; ];
let thereIsTreasure = $ref($i && !claimedAchievements.includes('foundTreasure')); let thereIsTreasure = $ref($i && !claimedAchievements.includes('foundTreasure'));

View File

@ -30,8 +30,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-model="showFixedPostForm">{{ i18n.ts.showFixedPostForm }}</MkSwitch> <MkSwitch v-model="showFixedPostForm">{{ i18n.ts.showFixedPostForm }}</MkSwitch>
<MkSwitch v-model="showFixedPostFormInChannel">{{ i18n.ts.showFixedPostFormInChannel }}</MkSwitch> <MkSwitch v-model="showFixedPostFormInChannel">{{ i18n.ts.showFixedPostFormInChannel }}</MkSwitch>
<MkSwitch v-model="defaultWithReplies">{{ i18n.ts.withRepliesByDefaultForNewlyFollowed }}</MkSwitch> <MkSwitch v-model="defaultWithReplies">{{ i18n.ts.withRepliesByDefaultForNewlyFollowed }}</MkSwitch>
<MkButton danger @click="updateRepliesAll(true)"><i class="ti ti-messages"></i> {{ i18n.ts.showRepliesToOthersInTimelineAll }}</MkButton>
<MkButton danger @click="updateRepliesAll(false)"><i class="ti ti-messages-off"></i> {{ i18n.ts.hideRepliesToOthersInTimelineAll }}</MkButton>
<MkFolder> <MkFolder>
<template #label>{{ i18n.ts.pinnedList }}</template> <template #label>{{ i18n.ts.pinnedList }}</template>
<!-- 複数ピン止め管理できるようにしたいけどめんどいので一旦ひとつのみ --> <!-- 複数ピン止め管理できるようにしたいけどめんどいので一旦ひとつのみ -->
@ -119,6 +117,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-model="disableShowingAnimatedImages">{{ i18n.ts.disableShowingAnimatedImages }}</MkSwitch> <MkSwitch v-model="disableShowingAnimatedImages">{{ i18n.ts.disableShowingAnimatedImages }}</MkSwitch>
<MkSwitch v-model="highlightSensitiveMedia">{{ i18n.ts.highlightSensitiveMedia }}</MkSwitch> <MkSwitch v-model="highlightSensitiveMedia">{{ i18n.ts.highlightSensitiveMedia }}</MkSwitch>
<MkSwitch v-model="squareAvatars">{{ i18n.ts.squareAvatars }}</MkSwitch> <MkSwitch v-model="squareAvatars">{{ i18n.ts.squareAvatars }}</MkSwitch>
<MkSwitch v-model="showAvatarDecorations">{{ i18n.ts.showAvatarDecorations }}</MkSwitch>
<MkSwitch v-model="useSystemFont">{{ i18n.ts.useSystemFont }}</MkSwitch> <MkSwitch v-model="useSystemFont">{{ i18n.ts.useSystemFont }}</MkSwitch>
<MkSwitch v-model="disableDrawer">{{ i18n.ts.disableDrawer }}</MkSwitch> <MkSwitch v-model="disableDrawer">{{ i18n.ts.disableDrawer }}</MkSwitch>
<MkSwitch v-model="forceShowAds">{{ i18n.ts.forceShowAds }}</MkSwitch> <MkSwitch v-model="forceShowAds">{{ i18n.ts.forceShowAds }}</MkSwitch>
@ -203,7 +202,7 @@ import { unisonReload } from '@/scripts/unison-reload.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js'; import { definePageMetadata } from '@/scripts/page-metadata.js';
import { miLocalStorage } from '@/local-storage.js'; import { miLocalStorage } from '@/local-storage.js';
import { globalEvents } from '@/events'; import { globalEvents } from '@/events.js';
import { claimAchievement } from '@/scripts/achievements.js'; import { claimAchievement } from '@/scripts/achievements.js';
const lang = ref(miLocalStorage.getItem('lang')); const lang = ref(miLocalStorage.getItem('lang'));
@ -248,6 +247,7 @@ const instanceTicker = computed(defaultStore.makeGetterSetter('instanceTicker'))
const enableInfiniteScroll = computed(defaultStore.makeGetterSetter('enableInfiniteScroll')); const enableInfiniteScroll = computed(defaultStore.makeGetterSetter('enableInfiniteScroll'));
const useReactionPickerForContextMenu = computed(defaultStore.makeGetterSetter('useReactionPickerForContextMenu')); const useReactionPickerForContextMenu = computed(defaultStore.makeGetterSetter('useReactionPickerForContextMenu'));
const squareAvatars = computed(defaultStore.makeGetterSetter('squareAvatars')); const squareAvatars = computed(defaultStore.makeGetterSetter('squareAvatars'));
const showAvatarDecorations = computed(defaultStore.makeGetterSetter('showAvatarDecorations'));
const mediaListWithOneImageAppearance = computed(defaultStore.makeGetterSetter('mediaListWithOneImageAppearance')); const mediaListWithOneImageAppearance = computed(defaultStore.makeGetterSetter('mediaListWithOneImageAppearance'));
const notificationPosition = computed(defaultStore.makeGetterSetter('notificationPosition')); const notificationPosition = computed(defaultStore.makeGetterSetter('notificationPosition'));
const notificationStackAxis = computed(defaultStore.makeGetterSetter('notificationStackAxis')); const notificationStackAxis = computed(defaultStore.makeGetterSetter('notificationStackAxis'));
@ -334,15 +334,6 @@ async function setPinnedList() {
defaultStore.set('pinnedUserLists', [list]); 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() { function removePinnedList() {
defaultStore.set('pinnedUserLists', []); defaultStore.set('pinnedUserLists', []);
} }

View File

@ -73,6 +73,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<FormSection> <FormSection>
<FormLink to="/registry"><template #icon><i class="ti ti-adjustments"></i></template>{{ i18n.ts.registry }}</FormLink> <FormLink to="/registry"><template #icon><i class="ti ti-adjustments"></i></template>{{ i18n.ts.registry }}</FormLink>
</FormSection> </FormSection>
<FormSection>
<div class="_gaps_s">
<MkButton danger @click="updateRepliesAll(true)"><i class="ti ti-messages"></i> {{ i18n.ts.showRepliesToOthersInTimelineAll }}</MkButton>
<MkButton danger @click="updateRepliesAll(false)"><i class="ti ti-messages-off"></i> {{ i18n.ts.hideRepliesToOthersInTimelineAll }}</MkButton>
</div>
</FormSection>
</div> </div>
</template> </template>
@ -138,6 +145,15 @@ async function reloadAsk() {
unisonReload(); 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([ watch([
enableCondensedLineForAcct, enableCondensedLineForAcct,
], async () => { ], async () => {

View File

@ -0,0 +1,114 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModalWindow
ref="dialog"
:width="400"
:height="450"
@close="cancel"
@closed="emit('closed')"
>
<template #header>{{ i18n.ts.avatarDecorations }}</template>
<div>
<MkSpacer :marginMin="20" :marginMax="28">
<div style="text-align: center;">
<div :class="$style.name">{{ decoration.name }}</div>
<MkAvatar style="width: 64px; height: 64px; margin-bottom: 20px;" :user="$i" :decoration="{ url: decoration.url, angle, flipH }" forceShowDecoration/>
</div>
<div class="_gaps_s">
<MkRange v-model="angle" continuousUpdate :min="-0.5" :max="0.5" :step="0.025" :textConverter="(v) => `${Math.floor(v * 360)}°`">
<template #label>{{ i18n.ts.angle }}</template>
</MkRange>
<MkSwitch v-model="flipH">
<template #label>{{ i18n.ts.flip }}</template>
</MkSwitch>
</div>
</MkSpacer>
<div :class="$style.footer" class="_buttonsCenter">
<MkButton v-if="using" primary rounded @click="attach"><i class="ti ti-check"></i> {{ i18n.ts.update }}</MkButton>
<MkButton v-if="using" rounded @click="detach"><i class="ti ti-x"></i> {{ i18n.ts.detach }}</MkButton>
<MkButton v-else primary rounded @click="attach"><i class="ti ti-check"></i> {{ i18n.ts.attach }}</MkButton>
</div>
</div>
</MkModalWindow>
</template>
<script lang="ts" setup>
import { shallowRef, ref, computed } from 'vue';
import MkButton from '@/components/MkButton.vue';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import MkFolder from '@/components/MkFolder.vue';
import MkInfo from '@/components/MkInfo.vue';
import MkRange from '@/components/MkRange.vue';
import { $i } from '@/account.js';
const props = defineProps<{
decoration: {
id: string;
url: string;
}
}>();
const emit = defineEmits<{
(ev: 'closed'): void;
}>();
const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
const using = computed(() => $i.avatarDecorations.some(x => x.id === props.decoration.id));
const angle = ref(using.value ? $i.avatarDecorations.find(x => x.id === props.decoration.id).angle ?? 0 : 0);
const flipH = ref(using.value ? $i.avatarDecorations.find(x => x.id === props.decoration.id).flipH ?? false : false);
function cancel() {
dialog.value.close();
}
async function attach() {
const decoration = {
id: props.decoration.id,
angle: angle.value,
flipH: flipH.value,
};
await os.apiWithDialog('i/update', {
avatarDecorations: [decoration],
});
$i.avatarDecorations = [decoration];
dialog.value.close();
}
async function detach() {
await os.apiWithDialog('i/update', {
avatarDecorations: [],
});
$i.avatarDecorations = [];
dialog.value.close();
}
</script>
<style lang="scss" module>
.name {
position: relative;
z-index: 10;
font-weight: bold;
margin-bottom: 28px;
}
.footer {
position: sticky;
bottom: 0;
left: 0;
padding: 12px;
border-top: solid 0.5px var(--divider);
-webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px));
}
</style>

View File

@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps_m"> <div class="_gaps_m">
<div :class="$style.avatarAndBanner" :style="{ backgroundImage: $i.bannerUrl ? `url(${ $i.bannerUrl })` : null }"> <div :class="$style.avatarAndBanner" :style="{ backgroundImage: $i.bannerUrl ? `url(${ $i.bannerUrl })` : null }">
<div :class="$style.avatarContainer"> <div :class="$style.avatarContainer">
<MkAvatar :class="$style.avatar" :user="$i" @click="changeAvatar"/> <MkAvatar :class="$style.avatar" :user="$i" forceShowDecoration @click="changeAvatar"/>
<MkButton primary rounded @click="changeAvatar">{{ i18n.ts._profile.changeAvatar }}</MkButton> <MkButton primary rounded @click="changeAvatar">{{ i18n.ts._profile.changeAvatar }}</MkButton>
</div> </div>
<MkButton primary rounded :class="$style.bannerEdit" @click="changeBanner">{{ i18n.ts._profile.changeBanner }}</MkButton> <MkButton primary rounded :class="$style.bannerEdit" @click="changeBanner">{{ i18n.ts._profile.changeBanner }}</MkButton>
@ -92,10 +92,10 @@ SPDX-License-Identifier: AGPL-3.0-only
v-for="avatarDecoration in avatarDecorations" v-for="avatarDecoration in avatarDecorations"
:key="avatarDecoration.id" :key="avatarDecoration.id"
:class="[$style.avatarDecoration, { [$style.avatarDecorationActive]: $i.avatarDecorations.some(x => x.id === avatarDecoration.id) }]" :class="[$style.avatarDecoration, { [$style.avatarDecorationActive]: $i.avatarDecorations.some(x => x.id === avatarDecoration.id) }]"
@click="toggleDecoration(avatarDecoration)" @click="openDecoration(avatarDecoration)"
> >
<div :class="$style.avatarDecorationName">{{ avatarDecoration.name }}</div> <div :class="$style.avatarDecorationName"><MkCondensedLine :minScale="2 / 3">{{ avatarDecoration.name }}</MkCondensedLine></div>
<MkAvatar style="width: 64px; height: 64px;" :user="$i" :decoration="avatarDecoration.url"/> <MkAvatar style="width: 60px; height: 60px;" :user="$i" :decoration="{ url: avatarDecoration.url }" forceShowDecoration/>
</div> </div>
</div> </div>
</MkFolder> </MkFolder>
@ -266,18 +266,10 @@ function changeBanner(ev) {
}); });
} }
function toggleDecoration(avatarDecoration) { function openDecoration(avatarDecoration) {
if ($i.avatarDecorations.some(x => x.id === avatarDecoration.id)) { os.popup(defineAsyncComponent(() => import('./profile.avatar-decoration-dialog.vue')), {
os.apiWithDialog('i/update', { decoration: avatarDecoration,
avatarDecorations: [], }, {}, 'closed');
});
$i.avatarDecorations = [];
} else {
os.apiWithDialog('i/update', {
avatarDecorations: [avatarDecoration.id],
});
$i.avatarDecorations.push(avatarDecoration);
}
} }
const headerActions = $computed(() => []); const headerActions = $computed(() => []);
@ -377,13 +369,17 @@ definePageMetadata({
.avatarDecoration { .avatarDecoration {
cursor: pointer; cursor: pointer;
padding: 16px 16px 24px 16px; padding: 16px 16px 28px 16px;
border: solid 2px var(--divider); border: solid 2px var(--divider);
border-radius: 8px; border-radius: 8px;
text-align: center; text-align: center;
font-size: 90%;
overflow: clip;
contain: content;
} }
.avatarDecorationActive { .avatarDecorationActive {
background-color: var(--accentedBg);
border-color: var(--accent); border-color: var(--accent);
} }
@ -391,6 +387,6 @@ definePageMetadata({
position: relative; position: relative;
z-index: 10; z-index: 10;
font-weight: bold; font-weight: bold;
margin-bottom: 16px; margin-bottom: 20px;
} }
</style> </style>

View File

@ -293,6 +293,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'device', where: 'device',
default: false, default: false,
}, },
showAvatarDecorations: {
where: 'device',
default: true,
},
postFormWithHashtags: { postFormWithHashtags: {
where: 'device', where: 'device',
default: false, default: false,

View File

@ -58,9 +58,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { computed, defineAsyncComponent, ref, watch } from 'vue'; import { computed, defineAsyncComponent, ref, watch } from 'vue';
import { openInstanceMenu } from './common'; import { openInstanceMenu } from './common.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { navbarItemDef } from '@/navbar'; import { navbarItemDef } from '@/navbar.js';
import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js'; import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
@ -230,6 +230,7 @@ function more(ev: MouseEvent) {
text-align: left; text-align: left;
box-sizing: border-box; box-sizing: border-box;
margin-top: 16px; margin-top: 16px;
overflow: clip;
} }
.avatar { .avatar {
@ -401,6 +402,7 @@ function more(ev: MouseEvent) {
display: block; display: block;
text-align: center; text-align: center;
width: 100%; width: 100%;
overflow: clip;
} }
.avatar { .avatar {

View File

@ -284,7 +284,6 @@ type CustomEmoji = {
url: string; url: string;
category: string; category: string;
aliases: string[]; aliases: string[];
draft: boolean;
}; };
// @public (undocumented) // @public (undocumented)
@ -2997,6 +2996,8 @@ type UserLite = {
avatarDecorations: { avatarDecorations: {
id: ID; id: ID;
url: string; url: string;
angle?: number;
flipH?: boolean;
}[]; }[];
emojis: { emojis: {
name: string; name: string;
@ -3022,8 +3023,8 @@ type UserSorting = '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+u
// src/api.types.ts:16:32 - (ae-forgotten-export) The symbol "TODO" needs to be exported by the entry point index.d.ts // src/api.types.ts:16:32 - (ae-forgotten-export) The symbol "TODO" needs to be exported by the entry point index.d.ts
// src/api.types.ts:18:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts // src/api.types.ts:18:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts
// src/api.types.ts:633:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts // src/api.types.ts:633:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts
// src/entities.ts:113:2 - (ae-forgotten-export) The symbol "notificationTypes_2" needs to be exported by the entry point index.d.ts // src/entities.ts:115:2 - (ae-forgotten-export) The symbol "notificationTypes_2" needs to be exported by the entry point index.d.ts
// src/entities.ts:610:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts // src/entities.ts:611:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts
// src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts // src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts
// (No @packageDocumentation comment for this package) // (No @packageDocumentation comment for this package)

View File

@ -19,6 +19,8 @@ export type UserLite = {
avatarDecorations: { avatarDecorations: {
id: ID; id: ID;
url: string; url: string;
angle?: number;
flipH?: boolean;
}[]; }[];
emojis: { emojis: {
name: string; name: string;