Merge remote-tracking branch 'misskey-original/develop' into develop
# Conflicts: # locales/index.d.ts # locales/ja-JP.yml # package.json # packages/backend/src/core/RoleService.ts # packages/backend/src/server/api/endpoints/notes/create.ts # packages/frontend/src/components/MkTimeline.vue # packages/frontend/src/const.ts # packages/frontend/src/pages/admin/roles.editor.vue # packages/frontend/src/pages/settings/general.vue # packages/frontend/src/pages/timeline.vue # packages/frontend/src/store.ts # packages/frontend/src/ui/_common_/stream-indicator.vue # packages/frontend/src/ui/universal.vue
This commit is contained in:
		
						commit
						9199a493f1
					
				|  | @ -14,7 +14,7 @@ jobs: | |||
|       - run: corepack enable | ||||
| 
 | ||||
|       - name: Setup Node.js | ||||
|         uses: actions/setup-node@v3.8.1 | ||||
|         uses: actions/setup-node@v4.0.0 | ||||
|         with: | ||||
|           node-version-file: '.node-version' | ||||
|           cache: 'pnpm' | ||||
|  |  | |||
|  | @ -44,7 +44,7 @@ jobs: | |||
|         version: 8 | ||||
|         run_install: false | ||||
|     - name: Use Node.js ${{ matrix.node-version }} | ||||
|       uses: actions/setup-node@v3.8.1 | ||||
|       uses: actions/setup-node@v4.0.0 | ||||
|       with: | ||||
|         node-version: ${{ matrix.node-version }} | ||||
|         cache: 'pnpm' | ||||
|  | @ -126,7 +126,7 @@ jobs: | |||
|         version: 8 | ||||
|         run_install: false | ||||
|     - name: Use Node.js ${{ matrix.node-version }} | ||||
|       uses: actions/setup-node@v3.8.1 | ||||
|       uses: actions/setup-node@v4.0.0 | ||||
|       with: | ||||
|         node-version: ${{ matrix.node-version }} | ||||
|         cache: 'pnpm' | ||||
|  |  | |||
|  | @ -19,7 +19,7 @@ jobs: | |||
|       with: | ||||
|         version: 8 | ||||
|         run_install: false | ||||
|     - uses: actions/setup-node@v3.8.1 | ||||
|     - uses: actions/setup-node@v4.0.0 | ||||
|       with: | ||||
|         node-version-file: '.node-version' | ||||
|         cache: 'pnpm' | ||||
|  | @ -46,7 +46,7 @@ jobs: | |||
|       with: | ||||
|         version: 7 | ||||
|         run_install: false | ||||
|     - uses: actions/setup-node@v3.8.1 | ||||
|     - uses: actions/setup-node@v4.0.0 | ||||
|       with: | ||||
|         node-version-file: '.node-version' | ||||
|         cache: 'pnpm' | ||||
|  | @ -72,7 +72,7 @@ jobs: | |||
|       with: | ||||
|         version: 7 | ||||
|         run_install: false | ||||
|     - uses: actions/setup-node@v3.8.1 | ||||
|     - uses: actions/setup-node@v4.0.0 | ||||
|       with: | ||||
|         node-version-file: '.node-version' | ||||
|         cache: 'pnpm' | ||||
|  |  | |||
|  | @ -38,7 +38,7 @@ jobs: | |||
|         version: 8 | ||||
|         run_install: false | ||||
|     - name: Use Node.js ${{ matrix.node-version }} | ||||
|       uses: actions/setup-node@v3.8.1 | ||||
|       uses: actions/setup-node@v4.0.0 | ||||
|       with: | ||||
|         node-version: ${{ matrix.node-version }} | ||||
|         cache: 'pnpm' | ||||
|  |  | |||
|  | @ -25,7 +25,7 @@ jobs: | |||
|         version: 8 | ||||
|         run_install: false | ||||
|     - name: Use Node.js ${{ matrix.node-version }} | ||||
|       uses: actions/setup-node@v3.8.1 | ||||
|       uses: actions/setup-node@v4.0.0 | ||||
|       with: | ||||
|         node-version: ${{ matrix.node-version }} | ||||
|         cache: 'pnpm' | ||||
|  | @ -83,7 +83,7 @@ jobs: | |||
|         version: 7 | ||||
|         run_install: false | ||||
|     - name: Use Node.js ${{ matrix.node-version }} | ||||
|       uses: actions/setup-node@v3.8.1 | ||||
|       uses: actions/setup-node@v4.0.0 | ||||
|       with: | ||||
|         node-version: ${{ matrix.node-version }} | ||||
|         cache: 'pnpm' | ||||
|  |  | |||
|  | @ -26,7 +26,7 @@ jobs: | |||
|       - run: corepack enable | ||||
| 
 | ||||
|       - name: Setup Node.js ${{ matrix.node-version }} | ||||
|         uses: actions/setup-node@v3.8.1 | ||||
|         uses: actions/setup-node@v4.0.0 | ||||
|         with: | ||||
|           node-version: ${{ matrix.node-version }} | ||||
|           cache: 'pnpm' | ||||
|  |  | |||
|  | @ -28,7 +28,7 @@ jobs: | |||
|         version: 8 | ||||
|         run_install: false | ||||
|     - name: Use Node.js ${{ matrix.node-version }} | ||||
|       uses: actions/setup-node@v3.8.1 | ||||
|       uses: actions/setup-node@v4.0.0 | ||||
|       with: | ||||
|         node-version: ${{ matrix.node-version }} | ||||
|         cache: 'pnpm' | ||||
|  |  | |||
							
								
								
									
										12
									
								
								CHANGELOG.md
								
								
								
								
							
							
						
						
									
										12
									
								
								CHANGELOG.md
								
								
								
								
							|  | @ -16,6 +16,10 @@ | |||
| 
 | ||||
| ### General | ||||
| - Feat: アイコンデコレーション機能 | ||||
| 	- サーバーで用意された画像をアイコンに重ねることができます | ||||
| 	- 画像のテンプレートはこちらです: https://misskey-hub.net/avatar-decoration-template.png | ||||
| 		- 最大でも黄色いエリア内にデコレーションを収めることを推奨します。 | ||||
| 		- 画像は512x512pxを推奨します。 | ||||
| - Enhance: すでにフォローしたすべての人の返信をTLに追加できるように | ||||
| - Enhance: ローカリゼーションの更新 | ||||
| - Enhance: 依存関係の更新 | ||||
|  | @ -24,6 +28,9 @@ | |||
| - Feat: プラグイン・テーマを外部サイトから直接インストールできるようになりました | ||||
| 	- 外部サイトでの実装が必要です。詳細は Misskey Hub をご覧ください | ||||
| 	  https://misskey-hub.net/docs/advanced/publish-on-your-website.html | ||||
| - Enhance: スワイプしてタイムラインを再読込できるように | ||||
| 	- PCの場合は右上のボタンからでも再読込できます | ||||
| - Enhance: タイムラインの自動更新を無効にできるように | ||||
| - Enhance: コードのシンタックスハイライトエンジンをShikiに変更 | ||||
|   - AiScriptのシンタックスハイライトに対応 | ||||
|   - MFMでAiScriptをハイライトする場合、コードブロックの開始部分を ` ```is ` もしくは ` ```aiscript ` としてください | ||||
|  | @ -36,6 +43,8 @@ | |||
| - Fix: 「検索」MFMにおいて一部の検索キーワードが正しく認識されない問題を修正 | ||||
| - Fix: 一部の言語でMisskey Webがクラッシュする問題を修正 | ||||
| - Fix: チャンネルの作成・更新時に失敗した場合何も表示されない問題を修正 #11983 | ||||
| - Fix: 個人カードのemojiがバッテリーになっている問題を修正 | ||||
| - Fix: 標準テーマと同じIDを使用してインストールできてしまう問題を修正 | ||||
| 
 | ||||
| ### Server | ||||
| - Enhance: RedisへのTLのキャッシュをオフにできるように | ||||
|  | @ -48,7 +57,8 @@ | |||
| - Fix: RedisへのTLキャッシュが有効の場合にHTL/LTL/STLが空になることがある問題を修正 | ||||
| - Fix: STLでフォローしていないチャンネルが取得される問題を修正 | ||||
| - Fix: `hashtags/trend`にてRedisからトレンドの情報が取得できない際にInternal Server Errorになる問題を修正 | ||||
| - Fix: HTLをリロードまたは遡行したとき、フォローしているチャンネルのノートが含まれない問題を修正 #11765 | ||||
| - Fix: HTLをリロードまたは遡行したとき、フォローしているチャンネルのノートが含まれない問題を修正 #11765 #12181 | ||||
| - Fix: リノートをリノートできるのを修正 | ||||
| 
 | ||||
| ## 2023.10.2 | ||||
| 
 | ||||
|  |  | |||
|  | @ -1012,6 +1012,7 @@ export interface Locale { | |||
|     "color": string; | ||||
|     "manageCustomEmojis": string; | ||||
|     "requestCustomEmojis": string; | ||||
|     "manageAvatarDecorations": string; | ||||
|     "youCannotCreateAnymore": string; | ||||
|     "cannotPerformTemporary": string; | ||||
|     "cannotPerformTemporaryDescription": string; | ||||
|  | @ -1186,6 +1187,10 @@ export interface Locale { | |||
|     "angle": string; | ||||
|     "flip": string; | ||||
|     "showAvatarDecorations": string; | ||||
|     "releaseToRefresh": string; | ||||
|     "refreshing": string; | ||||
|     "pullDownToRefresh": string; | ||||
|     "disableStreamingTimeline": string; | ||||
|     "_announcement": { | ||||
|         "forExistingUsers": string; | ||||
|         "forExistingUsersDescription": string; | ||||
|  | @ -1607,6 +1612,7 @@ export interface Locale { | |||
|             "inviteExpirationTime": string; | ||||
|             "canManageCustomEmojis": string; | ||||
|             "canRequestCustomEmojis": string; | ||||
|             "canManageAvatarDecorations": string; | ||||
|             "driveCapacity": string; | ||||
|             "alwaysMarkNsfw": string; | ||||
|             "pinMax": string; | ||||
|  |  | |||
|  | @ -1009,6 +1009,7 @@ unassign: "アサインを解除" | |||
| color: "色" | ||||
| manageCustomEmojis: "カスタム絵文字の管理" | ||||
| requestCustomEmojis: "カスタム絵文字のリクエスト" | ||||
| manageAvatarDecorations: "アバターデコレーションの管理" | ||||
| youCannotCreateAnymore: "これ以上作成することはできません。" | ||||
| cannotPerformTemporary: "一時的に利用できません" | ||||
| cannotPerformTemporaryDescription: "操作回数が制限を超過するため一時的に利用できません。しばらく時間を置いてから再度お試しください。" | ||||
|  | @ -1183,6 +1184,10 @@ detach: "外す" | |||
| angle: "角度" | ||||
| flip: "反転" | ||||
| showAvatarDecorations: "アイコンのデコレーションを表示" | ||||
| releaseToRefresh: "離してリロード" | ||||
| refreshing: "リロード中" | ||||
| pullDownToRefresh: "引っ張ってリロード" | ||||
| disableStreamingTimeline: "タイムラインのリアルタイム更新を無効にする" | ||||
| 
 | ||||
| _announcement: | ||||
|   forExistingUsers: "既存ユーザーのみ" | ||||
|  | @ -1528,6 +1533,7 @@ _role: | |||
|     inviteExpirationTime: "招待コードの有効期限" | ||||
|     canManageCustomEmojis: "カスタム絵文字の管理" | ||||
|     canRequestCustomEmojis: "カスタム絵文字のリクエスト" | ||||
|     canManageAvatarDecorations: "アバターデコレーションの管理" | ||||
|     driveCapacity: "ドライブ容量" | ||||
|     alwaysMarkNsfw: "ファイルにNSFWを常に付与" | ||||
|     pinMax: "ノートのピン留めの最大数" | ||||
|  |  | |||
|  | @ -1,6 +1,6 @@ | |||
| { | ||||
| 	"name": "misskey", | ||||
| 	"version": "2023.11.0-beta.5-prismisskey.1", | ||||
| 	"version": "2023.11.0-beta.6-prismisskey.1", | ||||
| 	"codename": "nasubi", | ||||
| 	"repository": { | ||||
| 		"type": "git", | ||||
|  |  | |||
|  | @ -24,6 +24,7 @@ import { bindThis } from '@/decorators.js'; | |||
| import { MetaService } from '@/core/MetaService.js'; | ||||
| import { SearchService } from '@/core/SearchService.js'; | ||||
| import { ModerationLogService } from '@/core/ModerationLogService.js'; | ||||
| import { isPureRenote } from '@/misc/is-pure-renote.js'; | ||||
| 
 | ||||
| @Injectable() | ||||
| export class NoteDeleteService { | ||||
|  | @ -77,8 +78,8 @@ export class NoteDeleteService { | |||
| 			if (this.userEntityService.isLocalUser(user) && !note.localOnly) { | ||||
| 				let renote: MiNote | null = null; | ||||
| 
 | ||||
| 				// if deletd note is renote
 | ||||
| 				if (note.renoteId && note.text == null && !note.hasPoll && (note.fileIds == null || note.fileIds.length === 0)) { | ||||
| 				// if deleted note is renote
 | ||||
| 				if (isPureRenote(note)) { | ||||
| 					renote = await this.notesRepository.findOneBy({ | ||||
| 						id: note.renoteId, | ||||
| 					}); | ||||
|  |  | |||
|  | @ -34,6 +34,7 @@ export type RolePolicies = { | |||
| 	inviteExpirationTime: number; | ||||
| 	canManageCustomEmojis: boolean; | ||||
| 	canRequestCustomEmojis: boolean; | ||||
| 	canManageAvatarDecorations: boolean; | ||||
| 	canSearchNotes: boolean; | ||||
| 	canUseTranslator: boolean; | ||||
| 	canHideAds: boolean; | ||||
|  | @ -60,6 +61,7 @@ export const DEFAULT_POLICIES: RolePolicies = { | |||
| 	inviteLimitCycle: 60 * 24 * 7, | ||||
| 	inviteExpirationTime: 0, | ||||
| 	canManageCustomEmojis: false, | ||||
| 	canManageAvatarDecorations: false, | ||||
| 	canRequestCustomEmojis: false, | ||||
| 	canSearchNotes: false, | ||||
| 	canUseTranslator: true, | ||||
|  | @ -311,6 +313,7 @@ export class RoleService implements OnApplicationShutdown { | |||
| 			inviteLimitCycle: calc('inviteLimitCycle', vs => Math.max(...vs)), | ||||
| 			inviteExpirationTime: calc('inviteExpirationTime', vs => Math.max(...vs)), | ||||
| 			canManageCustomEmojis: calc('canManageCustomEmojis', vs => vs.some(v => v === true)), | ||||
| 			canManageAvatarDecorations: calc('canManageAvatarDecorations', vs => vs.some(v => v === true)), | ||||
| 			canRequestCustomEmojis: calc('canRequestCustomEmojis', vs => vs.some(v => v === true)), | ||||
| 			canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)), | ||||
| 			canUseTranslator: calc('canUseTranslator', vs => vs.some(v => v === true)), | ||||
|  |  | |||
|  | @ -319,9 +319,17 @@ export class ApPersonService implements OnModuleInit { | |||
| 					emojis, | ||||
| 				})) as MiRemoteUser; | ||||
| 
 | ||||
| 				let _description: string | null = null; | ||||
| 
 | ||||
| 				if (person._misskey_summary) { | ||||
| 					_description = truncate(person._misskey_summary, summaryLength); | ||||
| 				} else if (person.summary) { | ||||
| 					_description = this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag); | ||||
| 				} | ||||
| 
 | ||||
| 				await transactionalEntityManager.save(new MiUserProfile({ | ||||
| 					userId: user.id, | ||||
| 					description: person._misskey_summary ? truncate(person._misskey_summary, summaryLength) : person.summary ? this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag) : null, | ||||
| 					description: _description, | ||||
| 					url, | ||||
| 					fields, | ||||
| 					birthday: bday?.[0] ?? null, | ||||
|  | @ -487,10 +495,18 @@ export class ApPersonService implements OnModuleInit { | |||
| 			}); | ||||
| 		} | ||||
| 
 | ||||
| 		let _description: string | null = null; | ||||
| 
 | ||||
| 		if (person._misskey_summary) { | ||||
| 			_description = truncate(person._misskey_summary, summaryLength); | ||||
| 		} else if (person.summary) { | ||||
| 			_description = this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag); | ||||
| 		} | ||||
| 
 | ||||
| 		await this.userProfilesRepository.update({ userId: exist.id }, { | ||||
| 			url, | ||||
| 			fields, | ||||
| 			description: person.summary ? this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag) : null, | ||||
| 			description: _description, | ||||
| 			birthday: bday?.[0] ?? null, | ||||
| 			location: person['vcard:Address'] ?? null, | ||||
| 		}); | ||||
|  |  | |||
|  | @ -0,0 +1,10 @@ | |||
| import type { MiNote } from '@/models/Note.js'; | ||||
| 
 | ||||
| export function isPureRenote(note: MiNote): note is MiNote & { renoteId: NonNullable<MiNote['renoteId']> } { | ||||
| 	if (!note.renoteId) return false; | ||||
| 
 | ||||
| 	if (note.text) return false; // it's quoted with text
 | ||||
| 	if (note.fileIds.length !== 0) return false; // it's quoted with files
 | ||||
| 	if (note.hasPoll) return false; // it's quoted with poll
 | ||||
| 	return true; | ||||
| } | ||||
|  | @ -26,6 +26,7 @@ import { UtilityService } from '@/core/UtilityService.js'; | |||
| import { UserEntityService } from '@/core/entities/UserEntityService.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { IActivity } from '@/core/activitypub/type.js'; | ||||
| import { isPureRenote } from '@/misc/is-pure-renote.js'; | ||||
| import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify'; | ||||
| import type { FindOptionsWhere } from 'typeorm'; | ||||
| 
 | ||||
|  | @ -88,7 +89,7 @@ export class ActivityPubServerService { | |||
| 	 */ | ||||
| 	@bindThis | ||||
| 	private async packActivity(note: MiNote): Promise<any> { | ||||
| 		if (note.renoteId && note.text == null && !note.hasPoll && (note.fileIds == null || note.fileIds.length === 0)) { | ||||
| 		if (isPureRenote(note)) { | ||||
| 			const renote = await this.notesRepository.findOneByOrFail({ id: note.renoteId }); | ||||
| 			return this.apRendererService.renderAnnounce(renote.uri ? renote.uri : `${this.config.url}/notes/${renote.id}`, note); | ||||
| 		} | ||||
|  |  | |||
|  | @ -11,7 +11,7 @@ export const meta = { | |||
| 	tags: ['admin'], | ||||
| 
 | ||||
| 	requireCredential: true, | ||||
| 	requireModerator: true, | ||||
| 	requireRolePolicy: 'canManageAvatarDecorations', | ||||
| } as const; | ||||
| 
 | ||||
| export const paramDef = { | ||||
|  |  | |||
|  | @ -13,8 +13,7 @@ export const meta = { | |||
| 	tags: ['admin'], | ||||
| 
 | ||||
| 	requireCredential: true, | ||||
| 	requireModerator: true, | ||||
| 
 | ||||
| 	requireRolePolicy: 'canManageAvatarDecorations', | ||||
| 	errors: { | ||||
| 	}, | ||||
| } as const; | ||||
|  |  | |||
|  | @ -16,7 +16,7 @@ export const meta = { | |||
| 	tags: ['admin'], | ||||
| 
 | ||||
| 	requireCredential: true, | ||||
| 	requireModerator: true, | ||||
| 	requireRolePolicy: 'canManageAvatarDecorations', | ||||
| 
 | ||||
| 	res: { | ||||
| 		type: 'array', | ||||
|  |  | |||
|  | @ -13,7 +13,7 @@ export const meta = { | |||
| 	tags: ['admin'], | ||||
| 
 | ||||
| 	requireCredential: true, | ||||
| 	requireModerator: true, | ||||
| 	requireRolePolicy: 'canManageAvatarDecorations', | ||||
| 
 | ||||
| 	errors: { | ||||
| 	}, | ||||
|  |  | |||
|  | @ -18,6 +18,7 @@ import { NoteCreateService } from '@/core/NoteCreateService.js'; | |||
| import { DI } from '@/di-symbols.js'; | ||||
| import { ApiError } from '../../error.js'; | ||||
| import {noteVisibilities} from "@/types.js"; | ||||
| import { isPureRenote } from '@/misc/is-pure-renote.js'; | ||||
| 
 | ||||
| export const meta = { | ||||
| 	tags: ['notes'], | ||||
|  | @ -222,7 +223,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | |||
| 
 | ||||
| 				if (renote == null) { | ||||
| 					throw new ApiError(meta.errors.noSuchRenoteTarget); | ||||
| 				} else if (renote.renoteId && !renote.text && !renote.fileIds && !renote.hasPoll) { | ||||
| 				} else if (isPureRenote(renote)) { | ||||
| 					throw new ApiError(meta.errors.cannotReRenote); | ||||
| 				} | ||||
| 
 | ||||
|  | @ -255,7 +256,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | |||
| 
 | ||||
| 				if (reply == null) { | ||||
| 					throw new ApiError(meta.errors.noSuchReplyTarget); | ||||
| 				} else if (reply.renoteId && !reply.text && !reply.fileIds && !reply.hasPoll) { | ||||
| 				} else if (isPureRenote(reply)) { | ||||
| 					throw new ApiError(meta.errors.cannotReplyToPureRenote); | ||||
| 				} | ||||
| 
 | ||||
|  |  | |||
|  | @ -183,7 +183,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- | |||
| 			const followingChannelIds = followingChannels.map(x => x.followeeId); | ||||
| 			query.andWhere(new Brackets(qb => { | ||||
| 				qb | ||||
| 					.where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }) | ||||
| 					.where(new Brackets(qb2 => { | ||||
| 						qb2 | ||||
| 							.where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }) | ||||
| 							.andWhere('note.channelId IS NULL'); | ||||
| 					})) | ||||
| 					.orWhere('note.channelId IN (:...followingChannelIds)', { followingChannelIds }); | ||||
| 			})); | ||||
| 		} else if (followees.length > 0) { | ||||
|  |  | |||
|  | @ -67,6 +67,8 @@ export default abstract class Channel { | |||
| 	} | ||||
| 
 | ||||
| 	public abstract init(params: any): void; | ||||
| 
 | ||||
| 	public dispose?(): void; | ||||
| 
 | ||||
| 	public onMessage?(type: string, body: any): void; | ||||
| } | ||||
|  |  | |||
|  | @ -8,7 +8,7 @@ import { common } from './common.js'; | |||
| import { version, ui, lang, updateLocale } from '@/config.js'; | ||||
| import { i18n, updateI18n } from '@/i18n.js'; | ||||
| import { confirm, alert, post, popup, toast } from '@/os.js'; | ||||
| import { useStream } from '@/stream.js'; | ||||
| import { useStream, isReloading } from '@/stream.js'; | ||||
| import * as sound from '@/scripts/sound.js'; | ||||
| import { $i, refreshAccount, login, updateAccount, signout } from '@/account.js'; | ||||
| import { defaultStore, ColdDeviceStorage } from '@/store.js'; | ||||
|  | @ -39,6 +39,7 @@ export async function mainBoot() { | |||
| 
 | ||||
| 	let reloadDialogShowing = false; | ||||
| 	stream.on('_disconnected_', async () => { | ||||
| 		if (isReloading) return; | ||||
| 		if (defaultStore.state.serverDisconnectedBehavior === 'reload') { | ||||
| 			location.reload(); | ||||
| 		} else if (defaultStore.state.serverDisconnectedBehavior === 'dialog') { | ||||
|  |  | |||
|  | @ -166,6 +166,8 @@ defineExpose({ | |||
| 
 | ||||
| <style lang="scss" module> | ||||
| .root { | ||||
| 	overscroll-behavior: none; | ||||
| 
 | ||||
| 	min-height: 100%; | ||||
| 	background: var(--bg); | ||||
| 
 | ||||
|  |  | |||
|  | @ -102,6 +102,7 @@ const props = withDefaults(defineProps<{ | |||
| 
 | ||||
| const emit = defineEmits<{ | ||||
| 	(ev: 'queue', count: number): void; | ||||
| 	(ev: 'status', error: boolean): void; | ||||
| }>(); | ||||
| 
 | ||||
| let rootEl = $shallowRef<HTMLElement>(); | ||||
|  | @ -193,6 +194,11 @@ watch(queue, (a, b) => { | |||
| 	emit('queue', queue.value.size); | ||||
| }, { deep: true }); | ||||
| 
 | ||||
| watch(error, (n, o) => { | ||||
| 	if (n === o) return; | ||||
| 	emit('status', n); | ||||
| }); | ||||
| 
 | ||||
| async function init(): Promise<void> { | ||||
| 	items.value = new Map(); | ||||
| 	queue.value = new Map(); | ||||
|  |  | |||
|  | @ -0,0 +1,240 @@ | |||
| <!-- | ||||
| SPDX-FileCopyrightText: syuilo and other misskey contributors | ||||
| SPDX-License-Identifier: AGPL-3.0-only | ||||
| --> | ||||
| 
 | ||||
| <template> | ||||
| <div ref="rootEl"> | ||||
| 	<div v-if="isPullStart" :class="$style.frame" :style="`--frame-min-height: ${pullDistance / (PULL_BRAKE_BASE + (pullDistance / PULL_BRAKE_FACTOR))}px;`"> | ||||
| 		<div :class="$style.frameContent"> | ||||
| 			<MkLoading v-if="isRefreshing" :class="$style.loader" :em="true"/> | ||||
| 			<i v-else class="ti ti-arrow-bar-to-down" :class="[$style.icon, { [$style.refresh]: isPullEnd }]"></i> | ||||
| 			<div :class="$style.text"> | ||||
| 				<template v-if="isPullEnd">{{ i18n.ts.releaseToRefresh }}</template> | ||||
| 				<template v-else-if="isRefreshing">{{ i18n.ts.refreshing }}</template> | ||||
| 				<template v-else>{{ i18n.ts.pullDownToRefresh }}</template> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</div> | ||||
| 	<div :class="{ [$style.slotClip]: isPullStart }"> | ||||
| 		<slot/> | ||||
| 	</div> | ||||
| </div> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts" setup> | ||||
| import { onMounted, onUnmounted } from 'vue'; | ||||
| import { deviceKind } from '@/scripts/device-kind.js'; | ||||
| import { i18n } from '@/i18n.js'; | ||||
| 
 | ||||
| const SCROLL_STOP = 10; | ||||
| const MAX_PULL_DISTANCE = Infinity; | ||||
| const FIRE_THRESHOLD = 230; | ||||
| const RELEASE_TRANSITION_DURATION = 200; | ||||
| const PULL_BRAKE_BASE = 2; | ||||
| const PULL_BRAKE_FACTOR = 200; | ||||
| 
 | ||||
| let isPullStart = $ref(false); | ||||
| let isPullEnd = $ref(false); | ||||
| let isRefreshing = $ref(false); | ||||
| let pullDistance = $ref(0); | ||||
| 
 | ||||
| let supportPointerDesktop = false; | ||||
| let startScreenY: number | null = null; | ||||
| 
 | ||||
| const rootEl = $shallowRef<HTMLDivElement>(); | ||||
| let scrollEl: HTMLElement | null = null; | ||||
| 
 | ||||
| let disabled = false; | ||||
| 
 | ||||
| const emits = defineEmits<{ | ||||
| 	(ev: 'refresh'): void; | ||||
| }>(); | ||||
| 
 | ||||
| function getScrollableParentElement(node) { | ||||
| 	if (node == null) { | ||||
| 		return null; | ||||
| 	} | ||||
| 
 | ||||
| 	if (node.scrollHeight > node.clientHeight) { | ||||
| 		return node; | ||||
| 	} else { | ||||
| 		return getScrollableParentElement(node.parentNode); | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| function getScreenY(event) { | ||||
| 	if (supportPointerDesktop) { | ||||
| 		return event.screenY; | ||||
| 	} | ||||
| 	return event.touches[0].screenY; | ||||
| } | ||||
| 
 | ||||
| function moveStart(event) { | ||||
| 	if (!isPullStart && !isRefreshing && !disabled) { | ||||
| 		isPullStart = true; | ||||
| 		startScreenY = getScreenY(event); | ||||
| 		pullDistance = 0; | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| function moveBySystem(to: number): Promise<void> { | ||||
| 	return new Promise(r => { | ||||
| 		const startHeight = pullDistance; | ||||
| 		const overHeight = pullDistance - to; | ||||
| 		if (overHeight < 1) { | ||||
| 			r(); | ||||
| 			return; | ||||
| 		} | ||||
| 		const startTime = Date.now(); | ||||
| 		let intervalId = setInterval(() => { | ||||
| 			const time = Date.now() - startTime; | ||||
| 			if (time > RELEASE_TRANSITION_DURATION) { | ||||
| 				pullDistance = to; | ||||
| 				clearInterval(intervalId); | ||||
| 				r(); | ||||
| 				return; | ||||
| 			} | ||||
| 			const nextHeight = startHeight - (overHeight / RELEASE_TRANSITION_DURATION) * time; | ||||
| 			if (pullDistance < nextHeight) return; | ||||
| 			pullDistance = nextHeight; | ||||
| 		}, 1); | ||||
| 	}); | ||||
| } | ||||
| 
 | ||||
| async function fixOverContent() { | ||||
| 	if (pullDistance > FIRE_THRESHOLD) { | ||||
| 		await moveBySystem(FIRE_THRESHOLD); | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| async function closeContent() { | ||||
| 	if (pullDistance > 0) { | ||||
| 		await moveBySystem(0); | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| function moveEnd() { | ||||
| 	if (isPullStart && !isRefreshing) { | ||||
| 		startScreenY = null; | ||||
| 		if (isPullEnd) { | ||||
| 			isPullEnd = false; | ||||
| 			isRefreshing = true; | ||||
| 			fixOverContent().then(() => emits('refresh')); | ||||
| 		} else { | ||||
| 			closeContent().then(() => isPullStart = false); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| function moving(event) { | ||||
| 	if (!isPullStart || isRefreshing || disabled) return; | ||||
| 
 | ||||
| 	if (!scrollEl) { | ||||
| 		scrollEl = getScrollableParentElement(rootEl); | ||||
| 	} | ||||
| 	if ((scrollEl?.scrollTop ?? 0) > (supportPointerDesktop ? SCROLL_STOP : SCROLL_STOP + pullDistance)) { | ||||
| 		pullDistance = 0; | ||||
| 		isPullEnd = false; | ||||
| 		moveEnd(); | ||||
| 		return; | ||||
| 	} | ||||
| 
 | ||||
| 	if (startScreenY === null) { | ||||
| 		startScreenY = getScreenY(event); | ||||
| 	} | ||||
| 	const moveScreenY = getScreenY(event); | ||||
| 
 | ||||
| 	const moveHeight = moveScreenY - startScreenY!; | ||||
| 	pullDistance = Math.min(Math.max(moveHeight, 0), MAX_PULL_DISTANCE); | ||||
| 
 | ||||
| 	isPullEnd = pullDistance >= FIRE_THRESHOLD; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * emit(refresh)が完了したことを知らせる関数 | ||||
|  * | ||||
|  * タイムアウトがないのでこれを最終的に実行しないと出たままになる | ||||
|  */ | ||||
| function refreshFinished() { | ||||
| 	closeContent().then(() => { | ||||
| 		isPullStart = false; | ||||
| 		isRefreshing = false; | ||||
| 	}); | ||||
| } | ||||
| 
 | ||||
| function setDisabled(value) { | ||||
| 	disabled = value; | ||||
| } | ||||
| 
 | ||||
| onMounted(() => { | ||||
| 	// マウス操作でpull to refreshするのは不便そう | ||||
| 	//supportPointerDesktop = !!window.PointerEvent && deviceKind === 'desktop'; | ||||
| 
 | ||||
| 	if (supportPointerDesktop) { | ||||
| 		rootEl.addEventListener('pointerdown', moveStart); | ||||
| 		// ポインターの場合、ポップアップ系の動作をするとdownだけ発火されてupが発火されないため | ||||
| 		window.addEventListener('pointerup', moveEnd); | ||||
| 		rootEl.addEventListener('pointermove', moving, { passive: true }); | ||||
| 	} else { | ||||
| 		rootEl.addEventListener('touchstart', moveStart); | ||||
| 		rootEl.addEventListener('touchend', moveEnd); | ||||
| 		rootEl.addEventListener('touchmove', moving, { passive: true }); | ||||
| 	} | ||||
| }); | ||||
| 
 | ||||
| onUnmounted(() => { | ||||
| 	if (supportPointerDesktop) window.removeEventListener('pointerup', moveEnd); | ||||
| }); | ||||
| 
 | ||||
| defineExpose({ | ||||
| 	refreshFinished, | ||||
| 	setDisabled, | ||||
| }); | ||||
| </script> | ||||
| 
 | ||||
| <style lang="scss" module> | ||||
| .frame { | ||||
| 	position: relative; | ||||
| 	overflow: clip; | ||||
| 
 | ||||
| 	width: 100%; | ||||
| 	min-height: var(--frame-min-height, 0px); | ||||
| 
 | ||||
| 	mask-image: linear-gradient(90deg, #000 0%, #000 80%, transparent); | ||||
| 	-webkit-mask-image: -webkit-linear-gradient(90deg, #000 0%, #000 80%, transparent); | ||||
| 
 | ||||
| 	pointer-events: none; | ||||
| } | ||||
| 
 | ||||
| .frameContent { | ||||
| 	position: absolute; | ||||
| 	bottom: 0; | ||||
| 	width: 100%; | ||||
| 	margin: 5px 0; | ||||
| 	display: flex; | ||||
| 	flex-direction: column; | ||||
| 	align-items: center; | ||||
| 	font-size: 14px; | ||||
| 
 | ||||
| 	> .icon, > .loader { | ||||
| 		margin: 6px 0; | ||||
| 	} | ||||
| 
 | ||||
| 	> .icon { | ||||
| 		transition: transform .25s; | ||||
| 
 | ||||
| 		&.refresh { | ||||
| 			transform: rotate(180deg); | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	> .text { | ||||
| 		margin: 5px 0; | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| .slotClip { | ||||
| 	overflow-y: clip; | ||||
| } | ||||
| </style> | ||||
|  | @ -4,13 +4,16 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| --> | ||||
| 
 | ||||
| <template> | ||||
| <MkNotes ref="tlComponent" :noGap="!defaultStore.state.showGapBetweenNotesInTimeline" :pagination="pagination" @queue="emit('queue', $event)"/> | ||||
| <MkPullToRefresh ref="prComponent" @refresh="() => reloadTimeline(true)"> | ||||
| 	<MkNotes ref="tlComponent" :noGap="!defaultStore.state.showGapBetweenNotesInTimeline" :pagination="pagination" @queue="emit('queue', $event)" @status="prComponent.setDisabled($event)"/> | ||||
| </MkPullToRefresh> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts" setup> | ||||
| import { computed, provide, onUnmounted } from 'vue'; | ||||
| import MkNotes from '@/components/MkNotes.vue'; | ||||
| import { useStream } from '@/stream.js'; | ||||
| import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; | ||||
| import { useStream, reloadStream } from '@/stream.js'; | ||||
| import * as sound from '@/scripts/sound.js'; | ||||
| import { $i } from '@/account.js'; | ||||
| import { instance } from '@/instance.js'; | ||||
|  | @ -39,6 +42,7 @@ const emit = defineEmits<{ | |||
| 
 | ||||
| provide('inChannel', computed(() => props.src === 'channel')); | ||||
| 
 | ||||
| const prComponent: InstanceType<typeof MkPullToRefresh> = $ref(); | ||||
| const tlComponent: InstanceType<typeof MkNotes> = $ref(); | ||||
| 
 | ||||
| let tlNotesCount = 0; | ||||
|  | @ -65,29 +69,86 @@ let connection; | |||
| let connection2; | ||||
| 
 | ||||
| const stream = useStream(); | ||||
| const connectChannel = () => { | ||||
| 	if (props.src === 'antenna') { | ||||
| 		connection = stream.useChannel('antenna', { | ||||
| 			antennaId: props.antenna, | ||||
| 		}); | ||||
| 	} else if (props.src === 'home') { | ||||
| 		connection = stream.useChannel('homeTimeline', { | ||||
| 			withRenotes: props.withRenotes, | ||||
| 			withFiles: props.onlyFiles ? true : undefined, | ||||
| 		}); | ||||
| 		connection2 = stream.useChannel('main'); | ||||
| 	} else if (props.src === 'local') { | ||||
| 		connection = stream.useChannel('localTimeline', { | ||||
| 			withRenotes: props.withRenotes, | ||||
| 			withReplies: props.withReplies, | ||||
| 			withFiles: props.onlyFiles ? true : undefined, | ||||
| 		}); | ||||
| 	} else if (props.src === 'media') { | ||||
| 		endpoint = 'notes/hybrid-timeline'; | ||||
| 		query = { | ||||
| 			withFiles: true, | ||||
| 			withRenotes: props.withRenotes, | ||||
| 			withReplies: props.withReplies, | ||||
| 		}; | ||||
| 		connection = stream.useChannel('hybridTimeline', { | ||||
| 			withFiles: true, | ||||
| 			withRenotes: props.withRenotes, | ||||
| 			withReplies: props.withReplies, | ||||
| 		}); | ||||
| 		connection.on('note', prepend); | ||||
| 	} else if (props.src === 'social') { | ||||
| 		connection = stream.useChannel('hybridTimeline', { | ||||
| 			withRenotes: props.withRenotes, | ||||
| 			withReplies: props.withReplies, | ||||
| 			withFiles: props.onlyFiles ? true : undefined, | ||||
| 		}); | ||||
| 	} else if (props.src === 'global') { | ||||
| 		connection = stream.useChannel('globalTimeline', { | ||||
| 			withRenotes: props.withRenotes, | ||||
| 			withFiles: props.onlyFiles ? true : undefined, | ||||
| 		}); | ||||
| 	} else if (props.src === 'mentions') { | ||||
| 		connection = stream.useChannel('main'); | ||||
| 		connection.on('mention', prepend); | ||||
| 	} else if (props.src === 'directs') { | ||||
| 		const onNote = note => { | ||||
| 			if (note.visibility === 'specified') { | ||||
| 				prepend(note); | ||||
| 			} | ||||
| 		}; | ||||
| 		connection = stream.useChannel('main'); | ||||
| 		connection.on('mention', onNote); | ||||
| 	} else if (props.src === 'list') { | ||||
| 		connection = stream.useChannel('userList', { | ||||
| 			withFiles: props.onlyFiles ? true : undefined, | ||||
| 			listId: props.list, | ||||
| 		}); | ||||
| 	} else if (props.src === 'channel') { | ||||
| 		connection = stream.useChannel('channel', { | ||||
| 			channelId: props.channel, | ||||
| 		}); | ||||
| 	} else if (props.src === 'role') { | ||||
| 		connection = stream.useChannel('roleTimeline', { | ||||
| 			roleId: props.role, | ||||
| 		}); | ||||
| 	} | ||||
| 	if (props.src !== 'directs' || props.src !== 'mentions') connection.on('note', prepend); | ||||
| }; | ||||
| 
 | ||||
| if (props.src === 'antenna') { | ||||
| 	endpoint = 'antennas/notes'; | ||||
| 	query = { | ||||
| 		antennaId: props.antenna, | ||||
| 	}; | ||||
| 	connection = stream.useChannel('antenna', { | ||||
| 		antennaId: props.antenna, | ||||
| 	}); | ||||
| 	connection.on('note', prepend); | ||||
| } else if (props.src === 'home') { | ||||
| 	endpoint = 'notes/timeline'; | ||||
| 	query = { | ||||
| 		withRenotes: props.withRenotes, | ||||
| 		withFiles: props.onlyFiles ? true : undefined, | ||||
| 	}; | ||||
| 	connection = stream.useChannel('homeTimeline', { | ||||
| 		withRenotes: props.withRenotes, | ||||
| 		withFiles: props.onlyFiles ? true : undefined, | ||||
| 	}); | ||||
| 	connection.on('note', prepend); | ||||
| 
 | ||||
| 	connection2 = stream.useChannel('main'); | ||||
| } else if (props.src === 'local') { | ||||
| 	endpoint = 'notes/local-timeline'; | ||||
| 	query = { | ||||
|  | @ -95,25 +156,6 @@ if (props.src === 'antenna') { | |||
| 		withReplies: props.withReplies, | ||||
| 		withFiles: props.onlyFiles ? true : undefined, | ||||
| 	}; | ||||
| 	connection = stream.useChannel('localTimeline', { | ||||
| 		withRenotes: props.withRenotes, | ||||
| 		withReplies: props.withReplies, | ||||
| 		withFiles: props.onlyFiles ? true : undefined, | ||||
| 	}); | ||||
| 	connection.on('note', prepend); | ||||
| } else if (props.src === 'media') { | ||||
| 	endpoint = 'notes/hybrid-timeline'; | ||||
| 	query = { | ||||
| 		withFiles: true, | ||||
| 		withRenotes: props.withRenotes, | ||||
| 		withReplies: props.withReplies, | ||||
| 	}; | ||||
| 	connection = stream.useChannel('hybridTimeline', { | ||||
| 		withFiles: true, | ||||
| 		withRenotes: props.withRenotes, | ||||
| 		withReplies: props.withReplies, | ||||
| 	}); | ||||
| 	connection.on('note', prepend); | ||||
| } else if (props.src === 'social') { | ||||
| 	endpoint = 'notes/hybrid-timeline'; | ||||
| 	query = { | ||||
|  | @ -121,68 +163,44 @@ if (props.src === 'antenna') { | |||
| 		withReplies: props.withReplies, | ||||
| 		withFiles: props.onlyFiles ? true : undefined, | ||||
| 	}; | ||||
| 	connection = stream.useChannel('hybridTimeline', { | ||||
| 		withRenotes: props.withRenotes, | ||||
| 		withReplies: props.withReplies, | ||||
| 		withFiles: props.onlyFiles ? true : undefined, | ||||
| 	}); | ||||
| 	connection.on('note', prepend); | ||||
| }else if (props.src === 'global') { | ||||
| 	endpoint = 'notes/global-timeline'; | ||||
| 	query = { | ||||
| 		withRenotes: props.withRenotes, | ||||
| 		withFiles: props.onlyFiles ? true : undefined, | ||||
| 	}; | ||||
| 	connection = stream.useChannel('globalTimeline', { | ||||
| 		withRenotes: props.withRenotes, | ||||
| 		withFiles: props.onlyFiles ? true : undefined, | ||||
| 	}); | ||||
| 	connection.on('note', prepend); | ||||
| } else if (props.src === 'mentions') { | ||||
| 	endpoint = 'notes/mentions'; | ||||
| 	connection = stream.useChannel('main'); | ||||
| 	connection.on('mention', prepend); | ||||
| } else if (props.src === 'directs') { | ||||
| 	endpoint = 'notes/mentions'; | ||||
| 	query = { | ||||
| 		visibility: 'specified', | ||||
| 	}; | ||||
| 	const onNote = note => { | ||||
| 		if (note.visibility === 'specified') { | ||||
| 			prepend(note); | ||||
| 		} | ||||
| 	}; | ||||
| 	connection = stream.useChannel('main'); | ||||
| 	connection.on('mention', onNote); | ||||
| } else if (props.src === 'list') { | ||||
| 	endpoint = 'notes/user-list-timeline'; | ||||
| 	query = { | ||||
| 		withFiles: props.onlyFiles ? true : undefined, | ||||
| 		listId: props.list, | ||||
| 	}; | ||||
| 	connection = stream.useChannel('userList', { | ||||
| 		withFiles: props.onlyFiles ? true : undefined, | ||||
| 		listId: props.list, | ||||
| 	}); | ||||
| 	connection.on('note', prepend); | ||||
| } else if (props.src === 'channel') { | ||||
| 	endpoint = 'channels/timeline'; | ||||
| 	query = { | ||||
| 		channelId: props.channel, | ||||
| 	}; | ||||
| 	connection = stream.useChannel('channel', { | ||||
| 		channelId: props.channel, | ||||
| 	}); | ||||
| 	connection.on('note', prepend); | ||||
| } else if (props.src === 'role') { | ||||
| 	endpoint = 'roles/notes'; | ||||
| 	query = { | ||||
| 		roleId: props.role, | ||||
| 	}; | ||||
| 	connection = stream.useChannel('roleTimeline', { | ||||
| 		roleId: props.role, | ||||
| } | ||||
| 
 | ||||
| if (!defaultStore.state.disableStreamingTimeline) { | ||||
| 	connectChannel(); | ||||
| 
 | ||||
| 	onUnmounted(() => { | ||||
| 		connection.dispose(); | ||||
| 		if (connection2) connection2.dispose(); | ||||
| 	}); | ||||
| 	connection.on('note', prepend); | ||||
| } | ||||
| 
 | ||||
| const pagination = { | ||||
|  | @ -191,9 +209,19 @@ const pagination = { | |||
| 	params: query, | ||||
| }; | ||||
| 
 | ||||
| onUnmounted(() => { | ||||
| 	connection.dispose(); | ||||
| 	if (connection2) connection2.dispose(); | ||||
| const reloadTimeline = (fromPR = false) => { | ||||
| 	tlNotesCount = 0; | ||||
| 
 | ||||
| 	tlComponent.pagingComponent?.reload().then(() => { | ||||
| 		reloadStream(); | ||||
| 		if (fromPR) prComponent.refreshFinished(); | ||||
| 	}); | ||||
| }; | ||||
| 
 | ||||
| //const pullRefresh = () => reloadTimeline(true); | ||||
| 
 | ||||
| defineExpose({ | ||||
| 	reloadTimeline, | ||||
| }); | ||||
| 
 | ||||
| /* TODO | ||||
|  |  | |||
|  | @ -68,6 +68,7 @@ export const ROLE_POLICIES = [ | |||
| 	'inviteExpirationTime', | ||||
| 	'canManageCustomEmojis', | ||||
| 	'canRequestCustomEmojis', | ||||
| 	'canManageAvatarDecorations', | ||||
| 	'canSearchNotes', | ||||
| 	'canUseTranslator', | ||||
| 	'canHideAds', | ||||
|  |  | |||
|  | @ -1061,7 +1061,7 @@ | |||
| 	["💰", "moneybag", 6], | ||||
| 	["🪙", "coin", 6], | ||||
| 	["💳", "credit_card", 6], | ||||
| 	["🪫", "identification_card", 6], | ||||
| 	["🪪", "identification_card", 6], | ||||
| 	["💎", "gem", 6], | ||||
| 	["⚖", "balance_scale", 6], | ||||
| 	["🧰", "toolbox", 6], | ||||
|  |  | |||
|  | @ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 				<MkInput v-model="ad.url" type="url"> | ||||
| 					<template #label>URL</template> | ||||
| 				</MkInput> | ||||
| 				<MkInput v-model="ad.imageUrl"> | ||||
| 				<MkInput v-model="ad.imageUrl" type="url"> | ||||
| 					<template #label>{{ i18n.ts.imageUrl }}</template> | ||||
| 				</MkInput> | ||||
| 				<MkRadios v-model="ad.place"> | ||||
|  |  | |||
|  | @ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 					<MkTextarea v-model="announcement.text"> | ||||
| 						<template #label>{{ i18n.ts.text }}</template> | ||||
| 					</MkTextarea> | ||||
| 					<MkInput v-model="announcement.imageUrl"> | ||||
| 					<MkInput v-model="announcement.imageUrl" type="url"> | ||||
| 						<template #label>{{ i18n.ts.imageUrl }}</template> | ||||
| 					</MkInput> | ||||
| 					<MkRadios v-model="announcement.icon"> | ||||
|  |  | |||
|  | @ -10,12 +10,12 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 		<MkSpacer :contentMax="700" :marginMin="16" :marginMax="32"> | ||||
| 			<FormSuspense :p="init"> | ||||
| 				<div class="_gaps_m"> | ||||
| 					<MkInput v-model="iconUrl"> | ||||
| 					<MkInput v-model="iconUrl" type="url"> | ||||
| 						<template #prefix><i class="ti ti-link"></i></template> | ||||
| 						<template #label>{{ i18n.ts._serverSettings.iconUrl }}</template> | ||||
| 					</MkInput> | ||||
| 
 | ||||
| 					<MkInput v-model="app192IconUrl"> | ||||
| 					<MkInput v-model="app192IconUrl" type="url"> | ||||
| 						<template #prefix><i class="ti ti-link"></i></template> | ||||
| 						<template #label>{{ i18n.ts._serverSettings.iconUrl }} (App/192px)</template> | ||||
| 						<template #caption> | ||||
|  | @ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 						</template> | ||||
| 					</MkInput> | ||||
| 
 | ||||
| 					<MkInput v-model="app512IconUrl"> | ||||
| 					<MkInput v-model="app512IconUrl" type="url"> | ||||
| 						<template #prefix><i class="ti ti-link"></i></template> | ||||
| 						<template #label>{{ i18n.ts._serverSettings.iconUrl }} (App/512px)</template> | ||||
| 						<template #caption> | ||||
|  | @ -37,27 +37,27 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 						</template> | ||||
| 					</MkInput> | ||||
| 
 | ||||
| 					<MkInput v-model="bannerUrl"> | ||||
| 					<MkInput v-model="bannerUrl" type="url"> | ||||
| 						<template #prefix><i class="ti ti-link"></i></template> | ||||
| 						<template #label>{{ i18n.ts.bannerUrl }}</template> | ||||
| 					</MkInput> | ||||
| 
 | ||||
| 					<MkInput v-model="backgroundImageUrl"> | ||||
| 					<MkInput v-model="backgroundImageUrl" type="url"> | ||||
| 						<template #prefix><i class="ti ti-link"></i></template> | ||||
| 						<template #label>{{ i18n.ts.backgroundImageUrl }}</template> | ||||
| 					</MkInput> | ||||
| 
 | ||||
| 					<MkInput v-model="notFoundImageUrl"> | ||||
| 					<MkInput v-model="notFoundImageUrl" type="url"> | ||||
| 						<template #prefix><i class="ti ti-link"></i></template> | ||||
| 						<template #label>{{ i18n.ts.notFoundDescription }}</template> | ||||
| 					</MkInput> | ||||
| 
 | ||||
| 					<MkInput v-model="infoImageUrl"> | ||||
| 					<MkInput v-model="infoImageUrl" type="url"> | ||||
| 						<template #prefix><i class="ti ti-link"></i></template> | ||||
| 						<template #label>{{ i18n.ts.nothing }}</template> | ||||
| 					</MkInput> | ||||
| 
 | ||||
| 					<MkInput v-model="serverErrorImageUrl"> | ||||
| 					<MkInput v-model="serverErrorImageUrl" type="url"> | ||||
| 						<template #prefix><i class="ti ti-link"></i></template> | ||||
| 						<template #label>{{ i18n.ts.somethingHappened }}</template> | ||||
| 					</MkInput> | ||||
|  |  | |||
|  | @ -132,7 +132,7 @@ const menuDef = $computed(() => [{ | |||
| 	}, { | ||||
| 		icon: 'ti ti-sparkles', | ||||
| 		text: i18n.ts.avatarDecorations, | ||||
| 		to: '/admin/avatar-decorations', | ||||
| 		to: '/avatar-decorations', | ||||
| 		active: currentPage?.route.name === 'avatarDecorations', | ||||
| 	}, { | ||||
| 		icon: 'ti ti-whirl', | ||||
|  |  | |||
|  | @ -20,12 +20,12 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 
 | ||||
| 					<FormLink to="/admin/server-rules">{{ i18n.ts.serverRules }}</FormLink> | ||||
| 
 | ||||
| 					<MkInput v-model="tosUrl"> | ||||
| 					<MkInput v-model="tosUrl" type="url"> | ||||
| 						<template #prefix><i class="ti ti-link"></i></template> | ||||
| 						<template #label>{{ i18n.ts.tosUrl }}</template> | ||||
| 					</MkInput> | ||||
| 
 | ||||
| 					<MkInput v-model="privacyPolicyUrl"> | ||||
| 					<MkInput v-model="privacyPolicyUrl" type="url"> | ||||
| 						<template #prefix><i class="ti ti-link"></i></template> | ||||
| 						<template #label>{{ i18n.ts.privacyPolicyUrl }}</template> | ||||
| 					</MkInput> | ||||
|  |  | |||
|  | @ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 				<MkSwitch v-model="useObjectStorage">{{ i18n.ts.useObjectStorage }}</MkSwitch> | ||||
| 
 | ||||
| 				<template v-if="useObjectStorage"> | ||||
| 					<MkInput v-model="objectStorageBaseUrl" :placeholder="'https://example.com'"> | ||||
| 					<MkInput v-model="objectStorageBaseUrl" :placeholder="'https://example.com'" type="url"> | ||||
| 						<template #label>{{ i18n.ts.objectStorageBaseUrl }}</template> | ||||
| 						<template #caption>{{ i18n.ts.objectStorageBaseUrlDesc }}</template> | ||||
| 					</MkInput> | ||||
|  |  | |||
|  | @ -21,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 		<template #label>{{ i18n.ts.color }}</template> | ||||
| 	</MkColorInput> | ||||
| 
 | ||||
| 	<MkInput v-model="role.iconUrl"> | ||||
| 	<MkInput v-model="role.iconUrl" type="url"> | ||||
| 		<template #label>{{ i18n.ts._role.iconUrl }}</template> | ||||
| 	</MkInput> | ||||
| 
 | ||||
|  | @ -339,6 +339,26 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 				</div> | ||||
| 			</MkFolder> | ||||
| 
 | ||||
| 			<MkFolder v-if="matchQuery([i18n.ts._role._options.canManageAvatarDecorations, 'canManageAvatarDecorations'])"> | ||||
| 				<template #label>{{ i18n.ts._role._options.canManageAvatarDecorations }}</template> | ||||
| 				<template #suffix> | ||||
| 					<span v-if="role.policies.canManageAvatarDecorations.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span> | ||||
| 					<span v-else>{{ role.policies.canManageAvatarDecorations.value ? i18n.ts.yes : i18n.ts.no }}</span> | ||||
| 					<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canManageAvatarDecorations)"></i></span> | ||||
| 				</template> | ||||
| 				<div class="_gaps"> | ||||
| 					<MkSwitch v-model="role.policies.canManageAvatarDecorations.useDefault" :readonly="readonly"> | ||||
| 						<template #label>{{ i18n.ts._role.useBaseValue }}</template> | ||||
| 					</MkSwitch> | ||||
| 					<MkSwitch v-model="role.policies.canManageAvatarDecorations.value" :disabled="role.policies.canManageAvatarDecorations.useDefault" :readonly="readonly"> | ||||
| 						<template #label>{{ i18n.ts.enable }}</template> | ||||
| 					</MkSwitch> | ||||
| 					<MkRange v-model="role.policies.canManageAvatarDecorations.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> | ||||
| 						<template #label>{{ i18n.ts._role.priority }}</template> | ||||
| 					</MkRange> | ||||
| 				</div> | ||||
| 			</MkFolder> | ||||
| 
 | ||||
| 			<MkFolder v-if="matchQuery([i18n.ts._role._options.canSearchNotes, 'canSearchNotes'])"> | ||||
| 				<template #label>{{ i18n.ts._role._options.canSearchNotes }}</template> | ||||
| 				<template #suffix> | ||||
|  |  | |||
|  | @ -87,6 +87,14 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 							</MkInput> | ||||
| 						</MkFolder> | ||||
| 
 | ||||
| 						<MkFolder v-if="matchQuery([i18n.ts._role._options.canManageAvatarDecorations, 'canManageAvatarDecorations'])"> | ||||
| 							<template #label>{{ i18n.ts._role._options.canManageAvatarDecorations }}</template> | ||||
| 							<template #suffix>{{ policies.canManageAvatarDecorations ? i18n.ts.yes : i18n.ts.no }}</template> | ||||
| 							<MkSwitch v-model="policies.canManageAvatarDecorations"> | ||||
| 								<template #label>{{ i18n.ts.enable }}</template> | ||||
| 							</MkSwitch> | ||||
| 						</MkFolder> | ||||
| 
 | ||||
| 						<MkFolder v-if="matchQuery([i18n.ts._role._options.canManageCustomEmojis, 'canManageCustomEmojis'])"> | ||||
| 							<template #label>{{ i18n.ts._role._options.canManageCustomEmojis }}</template> | ||||
| 							<template #suffix>{{ policies.canManageCustomEmojis ? i18n.ts.yes : i18n.ts.no }}</template> | ||||
|  |  | |||
|  | @ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 						</MkInput> | ||||
| 					</FormSplit> | ||||
| 
 | ||||
| 					<MkInput v-model="impressumUrl"> | ||||
| 					<MkInput v-model="impressumUrl" type="url"> | ||||
| 						<template #label>{{ i18n.ts.impressumUrl }}</template> | ||||
| 						<template #prefix><i class="ti ti-link"></i></template> | ||||
| 						<template #caption>{{ i18n.ts.impressumDescription }}</template> | ||||
|  |  | |||
|  | @ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 
 | ||||
| <template> | ||||
| <MkStickyContainer> | ||||
| 	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> | ||||
| 	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> | ||||
| 	<MkSpacer :contentMax="900"> | ||||
| 		<div class="_gaps"> | ||||
| 			<MkFolder v-for="avatarDecoration in avatarDecorations" :key="avatarDecoration.id ?? avatarDecoration._id" :defaultOpen="avatarDecoration.id == null"> | ||||
|  | @ -35,7 +35,6 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 
 | ||||
| <script lang="ts" setup> | ||||
| import { } from 'vue'; | ||||
| import XHeader from './_header_.vue'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import MkInput from '@/components/MkInput.vue'; | ||||
| import MkTextarea from '@/components/MkTextarea.vue'; | ||||
|  | @ -173,6 +173,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 				<MkSwitch v-model="imageNewTab">{{ i18n.ts.openImageInNewTab }}</MkSwitch> | ||||
| 				<MkSwitch v-model="enableInfiniteScroll">{{ i18n.ts.enableInfiniteScroll }}</MkSwitch> | ||||
| 				<MkSwitch v-model="keepScreenOn">{{ i18n.ts.keepScreenOn }}</MkSwitch> | ||||
| 				<MkSwitch v-model="disableStreamingTimeline">{{ i18n.ts.disableStreamingTimeline }}</MkSwitch> | ||||
| 			</div> | ||||
| 			<MkSelect v-model="serverDisconnectedBehavior"> | ||||
| 				<template #label>{{ i18n.ts.whenServerDisconnected }}</template> | ||||
|  | @ -295,6 +296,7 @@ const showGlobalTimeline = computed(defaultStore.makeGetterSetter('showGlobalTim | |||
| const showVisibilityColor = computed(defaultStore.makeGetterSetter('showVisibilityColor')) | ||||
| const FeaturedOrNote = computed(defaultStore.makeGetterSetter('FeaturedOrNote')) | ||||
| const defaultWithReplies = computed(defaultStore.makeGetterSetter('defaultWithReplies')); | ||||
| const disableStreamingTimeline = computed(defaultStore.makeGetterSetter('disableStreamingTimeline')); | ||||
| 
 | ||||
| watch(lang, () => { | ||||
| 	miLocalStorage.setItem('lang', lang.value as string); | ||||
|  | @ -359,6 +361,7 @@ watch([ | |||
|   enableonlyAndWithSave, | ||||
|   FeaturedOrNote, | ||||
|     showGlobalTimeline | ||||
| 	disableStreamingTimeline, | ||||
| ], async () => { | ||||
| 	await reloadAsk(); | ||||
| }); | ||||
|  |  | |||
|  | @ -44,6 +44,7 @@ import { $i } from '@/account.js'; | |||
| import { definePageMetadata } from '@/scripts/page-metadata.js'; | ||||
| import { miLocalStorage } from '@/local-storage.js'; | ||||
| import { antennasCache, userListsCache } from '@/cache.js'; | ||||
| import { deviceKind } from '@/scripts/device-kind.js'; | ||||
| 
 | ||||
| provide('shouldOmitHeaderTitle', true); | ||||
| 
 | ||||
|  | @ -140,8 +141,15 @@ function focus(): void { | |||
|     tlComponent.focus(); | ||||
| } | ||||
| 
 | ||||
| const headerActions = $computed(() => [{ | ||||
|     icon: 'ti ti-dots', | ||||
| const headerActions = $computed(() => [ | ||||
|     ...[deviceKind === 'desktop' ? { | ||||
| 		icon: 'ti ti-refresh', | ||||
| 		text: i18n.ts.reload, | ||||
| 		handler: (ev) => { | ||||
| 			console.log('called'); | ||||
| 			tlComponent.reloadTimeline(); | ||||
| 		}, | ||||
| 	} : {}], {icon: 'ti ti-dots', | ||||
|     text: i18n.ts.options, | ||||
|     handler: (ev) => { | ||||
|         os.popupMenu([{ | ||||
|  |  | |||
|  | @ -317,6 +317,10 @@ export const routes = [{ | |||
| }, { | ||||
| 	path: '/custom-emojis-manager', | ||||
| 	component: page(() => import('./pages/custom-emojis-manager.vue')), | ||||
| }, { | ||||
| 	path: '/avatar-decorations', | ||||
| 	name: 'avatarDecorations', | ||||
| 	component: page(() => import('./pages/avatar-decorations.vue')), | ||||
| }, { | ||||
| 	path: '/registry/keys/system/:path(*)?', | ||||
| 	component: page(() => import('./pages/registry.keys.vue')), | ||||
|  | @ -354,7 +358,7 @@ export const routes = [{ | |||
| 	}, { | ||||
| 		path: '/avatar-decorations', | ||||
| 		name: 'avatarDecorations', | ||||
| 		component: page(() => import('./pages/admin/avatar-decorations.vue')), | ||||
| 		component: page(() => import('./pages/avatar-decorations.vue')), | ||||
| 	}, { | ||||
| 		path: '/queue', | ||||
| 		name: 'queue', | ||||
|  |  | |||
|  | @ -464,6 +464,10 @@ export const defaultStore = markRaw(new Storage('base', { | |||
| 		where: 'device', | ||||
| 		default: false, | ||||
| 	}, | ||||
| 	disableStreamingTimeline: { | ||||
| 		where: 'device', | ||||
| 		default: false, | ||||
| 	}, | ||||
| })); | ||||
| 
 | ||||
| 
 | ||||
|  |  | |||
|  | @ -9,6 +9,9 @@ import { $i } from '@/account.js'; | |||
| import { url } from '@/config.js'; | ||||
| 
 | ||||
| let stream: Misskey.Stream | null = null; | ||||
| let timeoutHeartBeat: number | null = null; | ||||
| 
 | ||||
| export let isReloading: boolean = false; | ||||
| 
 | ||||
| export function useStream(): Misskey.Stream { | ||||
| 	if (stream) return stream; | ||||
|  | @ -17,7 +20,20 @@ export function useStream(): Misskey.Stream { | |||
| 		token: $i.token, | ||||
| 	} : null)); | ||||
| 
 | ||||
| 	window.setTimeout(heartbeat, 1000 * 60); | ||||
| 	timeoutHeartBeat = window.setTimeout(heartbeat, 1000 * 60); | ||||
| 
 | ||||
| 	return stream; | ||||
| } | ||||
| 
 | ||||
| export function reloadStream() { | ||||
| 	if (!stream) return useStream(); | ||||
| 	if (timeoutHeartBeat) window.clearTimeout(timeoutHeartBeat); | ||||
| 	isReloading = true; | ||||
| 
 | ||||
| 	stream.close(); | ||||
| 	stream.once('_connected_', () => isReloading = false); | ||||
| 	stream.stream.reconnect(); | ||||
| 	timeoutHeartBeat = window.setTimeout(heartbeat, 1000 * 60); | ||||
| 
 | ||||
| 	return stream; | ||||
| } | ||||
|  | @ -26,5 +42,5 @@ function heartbeat(): void { | |||
| 	if (stream != null && document.visibilityState === 'visible') { | ||||
| 		stream.heartbeat(); | ||||
| 	} | ||||
| 	window.setTimeout(heartbeat, 1000 * 60); | ||||
| 	timeoutHeartBeat = window.setTimeout(heartbeat, 1000 * 60); | ||||
| } | ||||
|  |  | |||
|  | @ -3,7 +3,7 @@ | |||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
| 
 | ||||
| import { Theme } from '@/scripts/theme.js'; | ||||
| import { Theme, getBuiltinThemes } from '@/scripts/theme.js'; | ||||
| import { miLocalStorage } from '@/local-storage.js'; | ||||
| import { api } from '@/os.js'; | ||||
| import { $i } from '@/account.js'; | ||||
|  | @ -29,6 +29,10 @@ export async function fetchThemes(): Promise<void> { | |||
| 
 | ||||
| export async function addTheme(theme: Theme): Promise<void> { | ||||
| 	if ($i == null) return; | ||||
| 	const builtinThemes = await getBuiltinThemes(); | ||||
| 	if (builtinThemes.some(t => t.id === theme.id)) { | ||||
| 		throw new Error('builtin theme'); | ||||
| 	} | ||||
| 	await fetchThemes(); | ||||
| 	const themes = getThemes().concat(theme); | ||||
| 	await api('i/registry/set', { scope: ['client'], key: 'themes', value: themes }); | ||||
|  |  | |||
|  | @ -67,6 +67,11 @@ export function openInstanceMenu(ev: MouseEvent) { | |||
| 			to: '/custom-emojis-manager', | ||||
| 			text: i18n.ts.manageCustomEmojis, | ||||
| 			icon: 'ti ti-icons', | ||||
| 		} : undefined, ($i && ($i.isAdmin || $i.policies.canManageAvatarDecorations)) ? { | ||||
| 			type: 'link', | ||||
| 			to: '/avatar-decorations', | ||||
| 			text: i18n.ts.manageAvatarDecorations, | ||||
| 			icon: 'ti ti-sparkles', | ||||
| 		} : undefined], | ||||
| 	}, null, (instance.impressumUrl) ? { | ||||
| 		text: i18n.ts.impressum, | ||||
|  |  | |||
|  | @ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only | |||
| 
 | ||||
| <script lang="ts" setup> | ||||
| import { onUnmounted } from 'vue'; | ||||
| import { useStream } from '@/stream.js'; | ||||
| import { useStream, isReloading } from '@/stream.js'; | ||||
| import { i18n } from '@/i18n.js'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import * as os from '@/os.js'; | ||||
|  | @ -27,6 +27,7 @@ let hasDisconnected = $ref(false); | |||
| let timeoutId = $ref<number>(); | ||||
| 
 | ||||
| function onDisconnected() { | ||||
| 	if (isReloading) return; | ||||
| 	window.clearTimeout(timeoutId); | ||||
| 	timeoutId = window.setTimeout(() => { | ||||
| 		hasDisconnected = true; | ||||
|  |  | |||
|  | @ -398,7 +398,7 @@ $widgets-hide-threshold: 1090px; | |||
|   min-width: 0; | ||||
|   overflow: auto; | ||||
|   overflow-y: scroll; | ||||
|   overscroll-behavior: contain; | ||||
|   overscroll-behavior: none; | ||||
|   background: var(--bg); | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -72,6 +72,13 @@ module.exports = { | |||
| 			{ 'blankLine': 'always', 'prev': 'function', 'next': '*' }, | ||||
| 			{ 'blankLine': 'always', 'prev': '*', 'next': 'function' }, | ||||
| 		], | ||||
| 		'lines-between-class-members': ['error', { | ||||
| 			enforce: [{ | ||||
| 				blankLine: 'always', | ||||
| 				prev: 'method', | ||||
| 				next: '*', | ||||
| 			}] | ||||
| 		}], | ||||
| 		'@typescript-eslint/func-call-spacing': ['error', 'never'], | ||||
| 		'@typescript-eslint/no-explicit-any': ['warn'], | ||||
| 		'@typescript-eslint/no-unused-vars': ['warn'], | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue