Merge branch 'develop' of https://github.com/misskey-dev/misskey into storybook
This commit is contained in:
		
						commit
						bf9846a269
					
				|  | @ -15,7 +15,8 @@ | |||
| ## 13.x.x (unreleased) | ||||
| 
 | ||||
| ### General | ||||
| - | ||||
| - チャンネルをお気に入りに登録できるように | ||||
| - チャンネルにノートをピン留めできるように | ||||
| 
 | ||||
| ### Client | ||||
| - 検索ページでURLを入力した際に照会したときと同等の挙動をするように | ||||
|  |  | |||
|  | @ -984,6 +984,7 @@ enableChartsForRemoteUser: "リモートユーザーのチャートを生成" | |||
| enableChartsForFederatedInstances: "リモートサーバーのチャートを生成" | ||||
| showClipButtonInNoteFooter: "ノートのアクションにクリップを追加" | ||||
| largeNoteReactions: "ノートのリアクションを大きく表示" | ||||
| noteIdOrUrl: "ノートIDまたはURL" | ||||
| 
 | ||||
| _achievements: | ||||
|   earnedAt: "獲得日時" | ||||
|  |  | |||
|  | @ -0,0 +1,21 @@ | |||
| export class channelFavorite1680228513388 { | ||||
|     name = 'channelFavorite1680228513388' | ||||
| 
 | ||||
|     async up(queryRunner) { | ||||
|         await queryRunner.query(`CREATE TABLE "channel_favorite" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "channelId" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, CONSTRAINT "PK_59bddfd54d48689a298d41af00c" PRIMARY KEY ("id")); COMMENT ON COLUMN "channel_favorite"."createdAt" IS 'The created date of the ChannelFavorite.'`); | ||||
|         await queryRunner.query(`CREATE INDEX "IDX_735a5544f9249d412255f47f95" ON "channel_favorite" ("createdAt") `); | ||||
|         await queryRunner.query(`CREATE INDEX "IDX_d3ca0db011b75ac2a940a2337d" ON "channel_favorite" ("channelId") `); | ||||
|         await queryRunner.query(`CREATE INDEX "IDX_8302bd27226605ece14842fb25" ON "channel_favorite" ("userId") `); | ||||
|         await queryRunner.query(`ALTER TABLE "channel_favorite" ADD CONSTRAINT "FK_d3ca0db011b75ac2a940a2337d2" FOREIGN KEY ("channelId") REFERENCES "channel"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); | ||||
|         await queryRunner.query(`ALTER TABLE "channel_favorite" ADD CONSTRAINT "FK_8302bd27226605ece14842fb25a" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); | ||||
|     } | ||||
| 
 | ||||
|     async down(queryRunner) { | ||||
|         await queryRunner.query(`ALTER TABLE "channel_favorite" DROP CONSTRAINT "FK_8302bd27226605ece14842fb25a"`); | ||||
|         await queryRunner.query(`ALTER TABLE "channel_favorite" DROP CONSTRAINT "FK_d3ca0db011b75ac2a940a2337d2"`); | ||||
|         await queryRunner.query(`DROP INDEX "public"."IDX_8302bd27226605ece14842fb25"`); | ||||
|         await queryRunner.query(`DROP INDEX "public"."IDX_d3ca0db011b75ac2a940a2337d"`); | ||||
|         await queryRunner.query(`DROP INDEX "public"."IDX_735a5544f9249d412255f47f95"`); | ||||
|         await queryRunner.query(`DROP TABLE "channel_favorite"`); | ||||
|     } | ||||
| } | ||||
|  | @ -0,0 +1,11 @@ | |||
| export class channelNotePining1680238118084 { | ||||
|     name = 'channelNotePining1680238118084' | ||||
| 
 | ||||
|     async up(queryRunner) { | ||||
|         await queryRunner.query(`ALTER TABLE "channel" ADD "pinnedNoteIds" character varying(128) array NOT NULL DEFAULT '{}'`); | ||||
|     } | ||||
| 
 | ||||
|     async down(queryRunner) { | ||||
|         await queryRunner.query(`ALTER TABLE "channel" DROP COLUMN "pinnedNoteIds"`); | ||||
|     } | ||||
| } | ||||
|  | @ -1,13 +1,14 @@ | |||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, NoteUnreadsRepository } from '@/models/index.js'; | ||||
| import type { ChannelFavoritesRepository, ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, NoteUnreadsRepository, NotesRepository } from '@/models/index.js'; | ||||
| import type { Packed } from '@/misc/json-schema.js'; | ||||
| import type { } from '@/models/entities/Blocking.js'; | ||||
| import type { User } from '@/models/entities/User.js'; | ||||
| import type { Channel } from '@/models/entities/Channel.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { UserEntityService } from './UserEntityService.js'; | ||||
| import { DriveFileEntityService } from './DriveFileEntityService.js'; | ||||
| import { NoteEntityService } from './NoteEntityService.js'; | ||||
| import { In } from 'typeorm'; | ||||
| 
 | ||||
| @Injectable() | ||||
| export class ChannelEntityService { | ||||
|  | @ -18,13 +19,19 @@ export class ChannelEntityService { | |||
| 		@Inject(DI.channelFollowingsRepository) | ||||
| 		private channelFollowingsRepository: ChannelFollowingsRepository, | ||||
| 
 | ||||
| 		@Inject(DI.channelFavoritesRepository) | ||||
| 		private channelFavoritesRepository: ChannelFavoritesRepository, | ||||
| 
 | ||||
| 		@Inject(DI.notesRepository) | ||||
| 		private notesRepository: NotesRepository, | ||||
| 
 | ||||
| 		@Inject(DI.noteUnreadsRepository) | ||||
| 		private noteUnreadsRepository: NoteUnreadsRepository, | ||||
| 
 | ||||
| 		@Inject(DI.driveFilesRepository) | ||||
| 		private driveFilesRepository: DriveFilesRepository, | ||||
| 
 | ||||
| 		private userEntityService: UserEntityService, | ||||
| 		private noteEntityService: NoteEntityService, | ||||
| 		private driveFileEntityService: DriveFileEntityService, | ||||
| 	) { | ||||
| 	} | ||||
|  | @ -33,6 +40,7 @@ export class ChannelEntityService { | |||
| 	public async pack( | ||||
| 		src: Channel['id'] | Channel, | ||||
| 		me?: { id: User['id'] } | null | undefined, | ||||
| 		detailed?: boolean, | ||||
| 	): Promise<Packed<'Channel'>> { | ||||
| 		const channel = typeof src === 'object' ? src : await this.channelsRepository.findOneByOrFail({ id: src }); | ||||
| 		const meId = me ? me.id : null; | ||||
|  | @ -46,6 +54,17 @@ export class ChannelEntityService { | |||
| 			followeeId: channel.id, | ||||
| 		}) : null; | ||||
| 
 | ||||
| 		const favorite = meId ? await this.channelFavoritesRepository.findOneBy({ | ||||
| 			userId: meId, | ||||
| 			channelId: channel.id, | ||||
| 		}) : null; | ||||
| 
 | ||||
| 		const pinnedNotes = channel.pinnedNoteIds.length > 0 ? await this.notesRepository.find({ | ||||
| 			where: { | ||||
| 				id: In(channel.pinnedNoteIds), | ||||
| 			}, | ||||
| 		}) : []; | ||||
| 
 | ||||
| 		return { | ||||
| 			id: channel.id, | ||||
| 			createdAt: channel.createdAt.toISOString(), | ||||
|  | @ -54,13 +73,19 @@ export class ChannelEntityService { | |||
| 			description: channel.description, | ||||
| 			userId: channel.userId, | ||||
| 			bannerUrl: banner ? this.driveFileEntityService.getPublicUrl(banner) : null, | ||||
| 			pinnedNoteIds: channel.pinnedNoteIds, | ||||
| 			usersCount: channel.usersCount, | ||||
| 			notesCount: channel.notesCount, | ||||
| 
 | ||||
| 			...(me ? { | ||||
| 				isFollowing: following != null, | ||||
| 				isFavorited: favorite != null, | ||||
| 				hasUnreadNote, | ||||
| 			} : {}), | ||||
| 
 | ||||
| 			...(detailed ? { | ||||
| 				pinnedNotes: await this.noteEntityService.packMany(pinnedNotes, me), | ||||
| 			} : {}), | ||||
| 		}; | ||||
| 	} | ||||
| } | ||||
|  |  | |||
|  | @ -61,7 +61,7 @@ export const DI = { | |||
| 	mutedNotesRepository: Symbol('mutedNotesRepository'), | ||||
| 	channelsRepository: Symbol('channelsRepository'), | ||||
| 	channelFollowingsRepository: Symbol('channelFollowingsRepository'), | ||||
| 	channelNotePiningsRepository: Symbol('channelNotePiningsRepository'), | ||||
| 	channelFavoritesRepository: Symbol('channelFavoritesRepository'), | ||||
| 	registryItemsRepository: Symbol('registryItemsRepository'), | ||||
| 	webhooksRepository: Symbol('webhooksRepository'), | ||||
| 	adsRepository: Symbol('adsRepository'), | ||||
|  |  | |||
|  | @ -1,6 +1,6 @@ | |||
| import { Module } from '@nestjs/common'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Notification, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, RenoteMuting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, AntennaNote, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelNotePining, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment, ClipFavorite } from './index.js'; | ||||
| import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Notification, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, RenoteMuting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, AntennaNote, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelFavorite, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment, ClipFavorite } from './index.js'; | ||||
| import type { DataSource } from 'typeorm'; | ||||
| import type { Provider } from '@nestjs/common'; | ||||
| 
 | ||||
|  | @ -340,9 +340,9 @@ const $channelFollowingsRepository: Provider = { | |||
| 	inject: [DI.db], | ||||
| }; | ||||
| 
 | ||||
| const $channelNotePiningsRepository: Provider = { | ||||
| 	provide: DI.channelNotePiningsRepository, | ||||
| 	useFactory: (db: DataSource) => db.getRepository(ChannelNotePining), | ||||
| const $channelFavoritesRepository: Provider = { | ||||
| 	provide: DI.channelFavoritesRepository, | ||||
| 	useFactory: (db: DataSource) => db.getRepository(ChannelFavorite), | ||||
| 	inject: [DI.db], | ||||
| }; | ||||
| 
 | ||||
|  | @ -460,7 +460,7 @@ const $roleAssignmentsRepository: Provider = { | |||
| 		$mutedNotesRepository, | ||||
| 		$channelsRepository, | ||||
| 		$channelFollowingsRepository, | ||||
| 		$channelNotePiningsRepository, | ||||
| 		$channelFavoritesRepository, | ||||
| 		$registryItemsRepository, | ||||
| 		$webhooksRepository, | ||||
| 		$adsRepository, | ||||
|  | @ -528,7 +528,7 @@ const $roleAssignmentsRepository: Provider = { | |||
| 		$mutedNotesRepository, | ||||
| 		$channelsRepository, | ||||
| 		$channelFollowingsRepository, | ||||
| 		$channelNotePiningsRepository, | ||||
| 		$channelFavoritesRepository, | ||||
| 		$registryItemsRepository, | ||||
| 		$webhooksRepository, | ||||
| 		$adsRepository, | ||||
|  |  | |||
|  | @ -59,6 +59,11 @@ export class Channel { | |||
| 	@JoinColumn() | ||||
| 	public banner: DriveFile | null; | ||||
| 
 | ||||
| 	@Column('varchar', { | ||||
| 		array: true, length: 128, default: '{}', | ||||
| 	}) | ||||
| 	public pinnedNoteIds: string[]; | ||||
| 
 | ||||
| 	@Index() | ||||
| 	@Column('integer', { | ||||
| 		default: 0, | ||||
|  |  | |||
|  | @ -1,21 +1,24 @@ | |||
| import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; | ||||
| import { id } from '../id.js'; | ||||
| import { Note } from './Note.js'; | ||||
| import { User } from './User.js'; | ||||
| import { Channel } from './Channel.js'; | ||||
| 
 | ||||
| @Entity() | ||||
| @Index(['channelId', 'noteId'], { unique: true }) | ||||
| export class ChannelNotePining { | ||||
| @Index(['userId', 'channelId'], { unique: true }) | ||||
| export class ChannelFavorite { | ||||
| 	@PrimaryColumn(id()) | ||||
| 	public id: string; | ||||
| 
 | ||||
| 	@Index() | ||||
| 	@Column('timestamp with time zone', { | ||||
| 		comment: 'The created date of the ChannelNotePining.', | ||||
| 		comment: 'The created date of the ChannelFavorite.', | ||||
| 	}) | ||||
| 	public createdAt: Date; | ||||
| 
 | ||||
| 	@Index() | ||||
| 	@Column(id()) | ||||
| 	@Column({ | ||||
| 		...id(), | ||||
| 	}) | ||||
| 	public channelId: Channel['id']; | ||||
| 
 | ||||
| 	@ManyToOne(type => Channel, { | ||||
|  | @ -24,12 +27,15 @@ export class ChannelNotePining { | |||
| 	@JoinColumn() | ||||
| 	public channel: Channel | null; | ||||
| 
 | ||||
| 	@Column(id()) | ||||
| 	public noteId: Note['id']; | ||||
| 	@Index() | ||||
| 	@Column({ | ||||
| 		...id(), | ||||
| 	}) | ||||
| 	public userId: User['id']; | ||||
| 
 | ||||
| 	@ManyToOne(type => Note, { | ||||
| 	@ManyToOne(type => User, { | ||||
| 		onDelete: 'CASCADE', | ||||
| 	}) | ||||
| 	@JoinColumn() | ||||
| 	public note: Note | null; | ||||
| 	public user: User | null; | ||||
| } | ||||
|  | @ -10,7 +10,7 @@ import { AttestationChallenge } from '@/models/entities/AttestationChallenge.js' | |||
| import { AuthSession } from '@/models/entities/AuthSession.js'; | ||||
| import { Blocking } from '@/models/entities/Blocking.js'; | ||||
| import { ChannelFollowing } from '@/models/entities/ChannelFollowing.js'; | ||||
| import { ChannelNotePining } from '@/models/entities/ChannelNotePining.js'; | ||||
| import { ChannelFavorite } from '@/models/entities/ChannelFavorite.js'; | ||||
| import { Clip } from '@/models/entities/Clip.js'; | ||||
| import { ClipNote } from '@/models/entities/ClipNote.js'; | ||||
| import { ClipFavorite } from '@/models/entities/ClipFavorite.js'; | ||||
|  | @ -79,7 +79,7 @@ export { | |||
| 	AuthSession, | ||||
| 	Blocking, | ||||
| 	ChannelFollowing, | ||||
| 	ChannelNotePining, | ||||
| 	ChannelFavorite, | ||||
| 	Clip, | ||||
| 	ClipNote, | ||||
| 	ClipFavorite, | ||||
|  | @ -147,7 +147,7 @@ export type AttestationChallengesRepository = Repository<AttestationChallenge>; | |||
| export type AuthSessionsRepository = Repository<AuthSession>; | ||||
| export type BlockingsRepository = Repository<Blocking>; | ||||
| export type ChannelFollowingsRepository = Repository<ChannelFollowing>; | ||||
| export type ChannelNotePiningsRepository = Repository<ChannelNotePining>; | ||||
| export type ChannelFavoritesRepository = Repository<ChannelFavorite>; | ||||
| export type ClipsRepository = Repository<Clip>; | ||||
| export type ClipNotesRepository = Repository<ClipNote>; | ||||
| export type ClipFavoritesRepository = Repository<ClipFavorite>; | ||||
|  |  | |||
|  | @ -42,10 +42,22 @@ export const packedChannelSchema = { | |||
| 			type: 'boolean', | ||||
| 			optional: true, nullable: false, | ||||
| 		}, | ||||
| 		isFavorited: { | ||||
| 			type: 'boolean', | ||||
| 			optional: true, nullable: false, | ||||
| 		}, | ||||
| 		userId: { | ||||
| 			type: 'string', | ||||
| 			nullable: true, optional: false, | ||||
| 			format: 'id', | ||||
| 		}, | ||||
| 		pinnedNoteIds: { | ||||
| 			type: 'array', | ||||
| 			nullable: false, optional: false, | ||||
| 			items: { | ||||
| 				type: 'string', | ||||
| 				format: 'id', | ||||
| 			}, | ||||
| 		}, | ||||
| 	}, | ||||
| } as const; | ||||
|  |  | |||
|  | @ -18,7 +18,7 @@ import { AttestationChallenge } from '@/models/entities/AttestationChallenge.js' | |||
| import { AuthSession } from '@/models/entities/AuthSession.js'; | ||||
| import { Blocking } from '@/models/entities/Blocking.js'; | ||||
| import { ChannelFollowing } from '@/models/entities/ChannelFollowing.js'; | ||||
| import { ChannelNotePining } from '@/models/entities/ChannelNotePining.js'; | ||||
| import { ChannelFavorite } from '@/models/entities/ChannelFavorite.js'; | ||||
| import { Clip } from '@/models/entities/Clip.js'; | ||||
| import { ClipNote } from '@/models/entities/ClipNote.js'; | ||||
| import { ClipFavorite } from '@/models/entities/ClipFavorite.js'; | ||||
|  | @ -175,7 +175,7 @@ export const entities = [ | |||
| 	MutedNote, | ||||
| 	Channel, | ||||
| 	ChannelFollowing, | ||||
| 	ChannelNotePining, | ||||
| 	ChannelFavorite, | ||||
| 	RegistryItem, | ||||
| 	Ad, | ||||
| 	PasswordResetRequest, | ||||
|  |  | |||
|  | @ -95,6 +95,9 @@ import * as ep___channels_show from './endpoints/channels/show.js'; | |||
| import * as ep___channels_timeline from './endpoints/channels/timeline.js'; | ||||
| import * as ep___channels_unfollow from './endpoints/channels/unfollow.js'; | ||||
| import * as ep___channels_update from './endpoints/channels/update.js'; | ||||
| import * as ep___channels_favorite from './endpoints/channels/favorite.js'; | ||||
| import * as ep___channels_unfavorite from './endpoints/channels/unfavorite.js'; | ||||
| import * as ep___channels_myFavorites from './endpoints/channels/my-favorites.js'; | ||||
| import * as ep___charts_activeUsers from './endpoints/charts/active-users.js'; | ||||
| import * as ep___charts_apRequest from './endpoints/charts/ap-request.js'; | ||||
| import * as ep___charts_drive from './endpoints/charts/drive.js'; | ||||
|  | @ -424,6 +427,9 @@ const $channels_show: Provider = { provide: 'ep:channels/show', useClass: ep___c | |||
| const $channels_timeline: Provider = { provide: 'ep:channels/timeline', useClass: ep___channels_timeline.default }; | ||||
| const $channels_unfollow: Provider = { provide: 'ep:channels/unfollow', useClass: ep___channels_unfollow.default }; | ||||
| const $channels_update: Provider = { provide: 'ep:channels/update', useClass: ep___channels_update.default }; | ||||
| const $channels_favorite: Provider = { provide: 'ep:channels/favorite', useClass: ep___channels_favorite.default }; | ||||
| const $channels_unfavorite: Provider = { provide: 'ep:channels/unfavorite', useClass: ep___channels_unfavorite.default }; | ||||
| const $channels_myFavorites: Provider = { provide: 'ep:channels/my-favorites', useClass: ep___channels_myFavorites.default }; | ||||
| const $charts_activeUsers: Provider = { provide: 'ep:charts/active-users', useClass: ep___charts_activeUsers.default }; | ||||
| const $charts_apRequest: Provider = { provide: 'ep:charts/ap-request', useClass: ep___charts_apRequest.default }; | ||||
| const $charts_drive: Provider = { provide: 'ep:charts/drive', useClass: ep___charts_drive.default }; | ||||
|  | @ -757,6 +763,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention | |||
| 		$channels_timeline, | ||||
| 		$channels_unfollow, | ||||
| 		$channels_update, | ||||
| 		$channels_favorite, | ||||
| 		$channels_unfavorite, | ||||
| 		$channels_myFavorites, | ||||
| 		$charts_activeUsers, | ||||
| 		$charts_apRequest, | ||||
| 		$charts_drive, | ||||
|  | @ -1084,6 +1093,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention | |||
| 		$channels_timeline, | ||||
| 		$channels_unfollow, | ||||
| 		$channels_update, | ||||
| 		$channels_favorite, | ||||
| 		$channels_unfavorite, | ||||
| 		$channels_myFavorites, | ||||
| 		$charts_activeUsers, | ||||
| 		$charts_apRequest, | ||||
| 		$charts_drive, | ||||
|  |  | |||
|  | @ -95,6 +95,9 @@ import * as ep___channels_show from './endpoints/channels/show.js'; | |||
| import * as ep___channels_timeline from './endpoints/channels/timeline.js'; | ||||
| import * as ep___channels_unfollow from './endpoints/channels/unfollow.js'; | ||||
| import * as ep___channels_update from './endpoints/channels/update.js'; | ||||
| import * as ep___channels_favorite from './endpoints/channels/favorite.js'; | ||||
| import * as ep___channels_unfavorite from './endpoints/channels/unfavorite.js'; | ||||
| import * as ep___channels_myFavorites from './endpoints/channels/my-favorites.js'; | ||||
| import * as ep___charts_activeUsers from './endpoints/charts/active-users.js'; | ||||
| import * as ep___charts_apRequest from './endpoints/charts/ap-request.js'; | ||||
| import * as ep___charts_drive from './endpoints/charts/drive.js'; | ||||
|  | @ -422,6 +425,9 @@ const eps = [ | |||
| 	['channels/timeline', ep___channels_timeline], | ||||
| 	['channels/unfollow', ep___channels_unfollow], | ||||
| 	['channels/update', ep___channels_update], | ||||
| 	['channels/favorite', ep___channels_favorite], | ||||
| 	['channels/unfavorite', ep___channels_unfavorite], | ||||
| 	['channels/my-favorites', ep___channels_myFavorites], | ||||
| 	['charts/active-users', ep___charts_activeUsers], | ||||
| 	['charts/ap-request', ep___charts_apRequest], | ||||
| 	['charts/drive', ep___charts_drive], | ||||
|  |  | |||
|  | @ -0,0 +1,61 @@ | |||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import type { ChannelFavoritesRepository, ChannelsRepository } from '@/models/index.js'; | ||||
| import { IdService } from '@/core/IdService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { ApiError } from '../../error.js'; | ||||
| 
 | ||||
| export const meta = { | ||||
| 	tags: ['channels'], | ||||
| 
 | ||||
| 	requireCredential: true, | ||||
| 
 | ||||
| 	kind: 'write:channels', | ||||
| 
 | ||||
| 	errors: { | ||||
| 		noSuchChannel: { | ||||
| 			message: 'No such channel.', | ||||
| 			code: 'NO_SUCH_CHANNEL', | ||||
| 			id: '4938f5f3-6167-4c04-9149-6607b7542861', | ||||
| 		}, | ||||
| 	}, | ||||
| } as const; | ||||
| 
 | ||||
| export const paramDef = { | ||||
| 	type: 'object', | ||||
| 	properties: { | ||||
| 		channelId: { type: 'string', format: 'misskey:id' }, | ||||
| 	}, | ||||
| 	required: ['channelId'], | ||||
| } as const; | ||||
| 
 | ||||
| // eslint-disable-next-line import/no-default-export
 | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.channelsRepository) | ||||
| 		private channelsRepository: ChannelsRepository, | ||||
| 
 | ||||
| 		@Inject(DI.channelFavoritesRepository) | ||||
| 		private channelFavoritesRepository: ChannelFavoritesRepository, | ||||
| 
 | ||||
| 		private idService: IdService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const channel = await this.channelsRepository.findOneBy({ | ||||
| 				id: ps.channelId, | ||||
| 			}); | ||||
| 
 | ||||
| 			if (channel == null) { | ||||
| 				throw new ApiError(meta.errors.noSuchChannel); | ||||
| 			} | ||||
| 
 | ||||
| 			await this.channelFavoritesRepository.insert({ | ||||
| 				id: this.idService.genId(), | ||||
| 				createdAt: new Date(), | ||||
| 				userId: me.id, | ||||
| 				channelId: channel.id, | ||||
| 			}); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|  | @ -0,0 +1,54 @@ | |||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import type { ChannelFavoritesRepository } from '@/models/index.js'; | ||||
| import { QueryService } from '@/core/QueryService.js'; | ||||
| import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| 
 | ||||
| export const meta = { | ||||
| 	tags: ['channels', 'account'], | ||||
| 
 | ||||
| 	requireCredential: true, | ||||
| 
 | ||||
| 	kind: 'read:channels', | ||||
| 
 | ||||
| 	res: { | ||||
| 		type: 'array', | ||||
| 		optional: false, nullable: false, | ||||
| 		items: { | ||||
| 			type: 'object', | ||||
| 			optional: false, nullable: false, | ||||
| 			ref: 'Channel', | ||||
| 		}, | ||||
| 	}, | ||||
| } as const; | ||||
| 
 | ||||
| export const paramDef = { | ||||
| 	type: 'object', | ||||
| 	properties: { | ||||
| 	}, | ||||
| 	required: [], | ||||
| } as const; | ||||
| 
 | ||||
| // eslint-disable-next-line import/no-default-export
 | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.channelFavoritesRepository) | ||||
| 		private channelFavoritesRepository: ChannelFavoritesRepository, | ||||
| 
 | ||||
| 		private channelEntityService: ChannelEntityService, | ||||
| 		private queryService: QueryService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const query = this.channelFavoritesRepository.createQueryBuilder('favorite') | ||||
| 				.andWhere('favorite.userId = :meId', { meId: me.id }) | ||||
| 				.leftJoinAndSelect('favorite.channel', 'channel'); | ||||
| 
 | ||||
| 			const favorites = await query | ||||
| 				.getMany(); | ||||
| 
 | ||||
| 			return await Promise.all(favorites.map(x => this.channelEntityService.pack(x.channel!, me))); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|  | @ -51,7 +51,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { | |||
| 				throw new ApiError(meta.errors.noSuchChannel); | ||||
| 			} | ||||
| 
 | ||||
| 			return await this.channelEntityService.pack(channel, me); | ||||
| 			return await this.channelEntityService.pack(channel, me, true); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|  |  | |||
|  | @ -0,0 +1,56 @@ | |||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import type { ChannelFavoritesRepository, ChannelsRepository } from '@/models/index.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { ApiError } from '../../error.js'; | ||||
| 
 | ||||
| export const meta = { | ||||
| 	tags: ['channels'], | ||||
| 
 | ||||
| 	requireCredential: true, | ||||
| 
 | ||||
| 	kind: 'write:channels', | ||||
| 
 | ||||
| 	errors: { | ||||
| 		noSuchChannel: { | ||||
| 			message: 'No such channel.', | ||||
| 			code: 'NO_SUCH_CHANNEL', | ||||
| 			id: '353c68dd-131a-476c-aa99-88a345e83668', | ||||
| 		}, | ||||
| 	}, | ||||
| } as const; | ||||
| 
 | ||||
| export const paramDef = { | ||||
| 	type: 'object', | ||||
| 	properties: { | ||||
| 		channelId: { type: 'string', format: 'misskey:id' }, | ||||
| 	}, | ||||
| 	required: ['channelId'], | ||||
| } as const; | ||||
| 
 | ||||
| // eslint-disable-next-line import/no-default-export
 | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.channelsRepository) | ||||
| 		private channelsRepository: ChannelsRepository, | ||||
| 
 | ||||
| 		@Inject(DI.channelFavoritesRepository) | ||||
| 		private channelFavoritesRepository: ChannelFavoritesRepository, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const channel = await this.channelsRepository.findOneBy({ | ||||
| 				id: ps.channelId, | ||||
| 			}); | ||||
| 
 | ||||
| 			if (channel == null) { | ||||
| 				throw new ApiError(meta.errors.noSuchChannel); | ||||
| 			} | ||||
| 
 | ||||
| 			await this.channelFavoritesRepository.delete({ | ||||
| 				userId: me.id, | ||||
| 				channelId: channel.id, | ||||
| 			}); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|  | @ -3,8 +3,8 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; | |||
| import type { DriveFilesRepository, ChannelsRepository } from '@/models/index.js'; | ||||
| import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { ApiError } from '../../error.js'; | ||||
| import { RoleService } from '@/core/RoleService.js'; | ||||
| import { ApiError } from '../../error.js'; | ||||
| 
 | ||||
| export const meta = { | ||||
| 	tags: ['channels'], | ||||
|  | @ -47,6 +47,12 @@ export const paramDef = { | |||
| 		name: { type: 'string', minLength: 1, maxLength: 128 }, | ||||
| 		description: { type: 'string', nullable: true, minLength: 1, maxLength: 2048 }, | ||||
| 		bannerId: { type: 'string', format: 'misskey:id', nullable: true }, | ||||
| 		pinnedNoteIds: { | ||||
| 			type: 'array', | ||||
| 			items: { | ||||
| 				type: 'string', format: 'misskey:id', | ||||
| 			}, | ||||
| 		}, | ||||
| 	}, | ||||
| 	required: ['channelId'], | ||||
| } as const; | ||||
|  | @ -64,7 +70,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { | |||
| 		private channelEntityService: ChannelEntityService, | ||||
| 
 | ||||
| 		private roleService: RoleService, | ||||
| 		) { | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const channel = await this.channelsRepository.findOneBy({ | ||||
| 				id: ps.channelId, | ||||
|  | @ -97,6 +103,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { | |||
| 			await this.channelsRepository.update(channel.id, { | ||||
| 				...(ps.name !== undefined ? { name: ps.name } : {}), | ||||
| 				...(ps.description !== undefined ? { description: ps.description } : {}), | ||||
| 				...(ps.pinnedNoteIds !== undefined ? { pinnedNoteIds: ps.pinnedNoteIds } : {}), | ||||
| 				...(banner ? { bannerId: banner.id } : {}), | ||||
| 			}); | ||||
| 
 | ||||
|  |  | |||
|  | @ -169,6 +169,7 @@ const props = defineProps<{ | |||
| }>(); | ||||
| 
 | ||||
| const inChannel = inject('inChannel', null); | ||||
| const currentClip = inject<Ref<misskey.entities.Clip> | null>('currentClip', null); | ||||
| 
 | ||||
| let note = $ref(deepClone(props.note)); | ||||
| 
 | ||||
|  | @ -370,8 +371,6 @@ function undoReact(note): void { | |||
| 	}); | ||||
| } | ||||
| 
 | ||||
| const currentClipPage = inject<Ref<misskey.entities.Clip> | null>('currentClipPage', null); | ||||
| 
 | ||||
| function onContextmenu(ev: MouseEvent): void { | ||||
| 	const isLink = (el: HTMLElement) => { | ||||
| 		if (el.tagName === 'A') return true; | ||||
|  | @ -386,18 +385,18 @@ function onContextmenu(ev: MouseEvent): void { | |||
| 		ev.preventDefault(); | ||||
| 		react(); | ||||
| 	} else { | ||||
| 		os.contextMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClipPage }), ev).then(focus); | ||||
| 		os.contextMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClip: currentClip?.value }), ev).then(focus); | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| function menu(viaKeyboard = false): void { | ||||
| 	os.popupMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClipPage }), menuButton.value, { | ||||
| 	os.popupMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClip: currentClip?.value }), menuButton.value, { | ||||
| 		viaKeyboard, | ||||
| 	}).then(focus); | ||||
| } | ||||
| 
 | ||||
| async function clip() { | ||||
| 	os.popupMenu(await getNoteClipMenu({ note: note, isDeleted, currentClipPage }), clipButton.value).then(focus); | ||||
| 	os.popupMenu(await getNoteClipMenu({ note: note, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus); | ||||
| } | ||||
| 
 | ||||
| function showRenoteMenu(viaKeyboard = false): void { | ||||
|  |  | |||
|  | @ -19,7 +19,7 @@ | |||
| 				:ad="true" | ||||
| 				:class="$style.notes" | ||||
| 			> | ||||
| 				<XNote :key="note._featuredId_ || note._prId_ || note.id" :class="$style.note" :note="note"/> | ||||
| 				<MkNote :key="note._featuredId_ || note._prId_ || note.id" :class="$style.note" :note="note"/> | ||||
| 			</MkDateSeparatedList> | ||||
| 		</div> | ||||
| 	</template> | ||||
|  | @ -28,7 +28,7 @@ | |||
| 
 | ||||
| <script lang="ts" setup> | ||||
| import { shallowRef } from 'vue'; | ||||
| import XNote from '@/components/MkNote.vue'; | ||||
| import MkNote from '@/components/MkNote.vue'; | ||||
| import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue'; | ||||
| import MkPagination, { Paging } from '@/components/MkPagination.vue'; | ||||
| import { i18n } from '@/i18n'; | ||||
|  |  | |||
|  | @ -9,7 +9,7 @@ | |||
| 
 | ||||
| 	<template #default="{ items: notifications }"> | ||||
| 		<MkDateSeparatedList v-slot="{ item: notification }" :class="$style.list" :items="notifications" :no-gap="true"> | ||||
| 			<XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note"/> | ||||
| 			<MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note"/> | ||||
| 			<XNotification v-else :key="notification.id" :notification="notification" :with-time="true" :full="true" class="_panel notification"/> | ||||
| 		</MkDateSeparatedList> | ||||
| 	</template> | ||||
|  | @ -21,7 +21,7 @@ import { onUnmounted, onMounted, computed, shallowRef } from 'vue'; | |||
| import MkPagination, { Paging } from '@/components/MkPagination.vue'; | ||||
| import XNotification from '@/components/MkNotification.vue'; | ||||
| import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue'; | ||||
| import XNote from '@/components/MkNote.vue'; | ||||
| import MkNote from '@/components/MkNote.vue'; | ||||
| import { stream } from '@/stream'; | ||||
| import { $i } from '@/account'; | ||||
| import { i18n } from '@/i18n'; | ||||
|  |  | |||
|  | @ -52,7 +52,7 @@ | |||
| 		<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags"> | ||||
| 		<XPostFormAttaches v-model="files" :class="$style.attaches" @detach="detachFile" @change-sensitive="updateFileSensitive" @change-name="updateFileName"/> | ||||
| 		<MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/> | ||||
| 		<XNotePreview v-if="showPreview" :class="$style.preview" :text="text"/> | ||||
| 		<MkNotePreview v-if="showPreview" :class="$style.preview" :text="text"/> | ||||
| 		<div v-if="showingOptions" style="padding: 0 16px;"> | ||||
| 			<MkSelect v-model="reactionAcceptance" small> | ||||
| 				<template #label>{{ i18n.ts.reactionAcceptance }}</template> | ||||
|  | @ -87,7 +87,7 @@ import { toASCII } from 'punycode/'; | |||
| import * as Acct from 'misskey-js/built/acct'; | ||||
| import MkSelect from './MkSelect.vue'; | ||||
| import MkNoteSimple from '@/components/MkNoteSimple.vue'; | ||||
| import XNotePreview from '@/components/MkNotePreview.vue'; | ||||
| import MkNotePreview from '@/components/MkNotePreview.vue'; | ||||
| import XPostFormAttaches from '@/components/MkPostFormAttaches.vue'; | ||||
| import MkPollEditor from '@/components/MkPollEditor.vue'; | ||||
| import { host, url } from '@/config'; | ||||
|  |  | |||
|  | @ -1,21 +1,21 @@ | |||
| <template> | ||||
| <div class="voxdxuby"> | ||||
| 	<XNote v-if="note && !block.detailed" :key="note.id + ':normal'" v-model:note="note"/> | ||||
| 	<XNoteDetailed v-if="note && block.detailed" :key="note.id + ':detail'" v-model:note="note"/> | ||||
| 	<MkNote v-if="note && !block.detailed" :key="note.id + ':normal'" v-model:note="note"/> | ||||
| 	<MkNoteDetailed v-if="note && block.detailed" :key="note.id + ':detail'" v-model:note="note"/> | ||||
| </div> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { defineComponent, onMounted, PropType, Ref, ref } from 'vue'; | ||||
| import XNote from '@/components/MkNote.vue'; | ||||
| import XNoteDetailed from '@/components/MkNoteDetailed.vue'; | ||||
| import MkNote from '@/components/MkNote.vue'; | ||||
| import MkNoteDetailed from '@/components/MkNoteDetailed.vue'; | ||||
| import * as os from '@/os'; | ||||
| import { NoteBlock } from '@/scripts/hpml/block'; | ||||
| 
 | ||||
| export default defineComponent({ | ||||
| 	components: { | ||||
| 		XNote, | ||||
| 		XNoteDetailed, | ||||
| 		MkNote, | ||||
| 		MkNoteDetailed, | ||||
| 	}, | ||||
| 	props: { | ||||
| 		block: { | ||||
|  |  | |||
|  | @ -2,7 +2,7 @@ | |||
| <MkStickyContainer> | ||||
| 	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> | ||||
| 	<MkSpacer :content-max="700"> | ||||
| 		<div class="_gaps_m"> | ||||
| 		<div v-if="channel" class="_gaps_m"> | ||||
| 			<MkInput v-model="name"> | ||||
| 				<template #label>{{ i18n.ts.name }}</template> | ||||
| 			</MkInput> | ||||
|  | @ -11,13 +11,37 @@ | |||
| 				<template #label>{{ i18n.ts.description }}</template> | ||||
| 			</MkTextarea> | ||||
| 
 | ||||
| 			<div class="banner"> | ||||
| 			<div> | ||||
| 				<MkButton v-if="bannerId == null" @click="setBannerImage"><i class="ti ti-plus"></i> {{ i18n.ts._channel.setBanner }}</MkButton> | ||||
| 				<div v-else-if="bannerUrl"> | ||||
| 					<img :src="bannerUrl" style="width: 100%;"/> | ||||
| 					<MkButton @click="removeBannerImage()"><i class="ti ti-trash"></i> {{ i18n.ts._channel.removeBanner }}</MkButton> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 
 | ||||
| 			<MkFolder :default-open="true"> | ||||
| 				<template #label>{{ i18n.ts.pinnedNotes }}</template> | ||||
| 				 | ||||
| 				<div class="_gaps"> | ||||
| 					<MkButton primary rounded @click="addPinnedNote()"><i class="ti ti-plus"></i></MkButton> | ||||
| 
 | ||||
| 					<Sortable  | ||||
| 						v-model="pinnedNotes" | ||||
| 						item-key="id" | ||||
| 						:handle="'.' + $style.pinnedNoteHandle" | ||||
| 						:animation="150" | ||||
| 					> | ||||
| 						<template #item="{element,index}"> | ||||
| 							<div :class="$style.pinnedNote"> | ||||
| 								<button class="_button" :class="$style.pinnedNoteHandle"><i class="ti ti-menu"></i></button> | ||||
| 								{{ element.id }} | ||||
| 								<button class="_button" :class="$style.pinnedNoteRemove" @click="removePinnedNote(index)"><i class="ti ti-x"></i></button> | ||||
| 							</div> | ||||
| 						</template> | ||||
| 					</Sortable> | ||||
| 				</div> | ||||
| 			</MkFolder> | ||||
| 
 | ||||
| 			<div> | ||||
| 				<MkButton primary @click="save()"><i class="ti ti-device-floppy"></i> {{ channelId ? i18n.ts.save : i18n.ts.create }}</MkButton> | ||||
| 			</div> | ||||
|  | @ -27,7 +51,7 @@ | |||
| </template> | ||||
| 
 | ||||
| <script lang="ts" setup> | ||||
| import { computed, watch } from 'vue'; | ||||
| import { computed, ref, watch, defineAsyncComponent } from 'vue'; | ||||
| import MkTextarea from '@/components/MkTextarea.vue'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import MkInput from '@/components/MkInput.vue'; | ||||
|  | @ -36,6 +60,9 @@ import * as os from '@/os'; | |||
| import { useRouter } from '@/router'; | ||||
| import { definePageMetadata } from '@/scripts/page-metadata'; | ||||
| import { i18n } from '@/i18n'; | ||||
| import MkFolder from '@/components/MkFolder.vue'; | ||||
| 
 | ||||
| const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); | ||||
| 
 | ||||
| const router = useRouter(); | ||||
| 
 | ||||
|  | @ -48,6 +75,7 @@ let name = $ref(null); | |||
| let description = $ref(null); | ||||
| let bannerUrl = $ref<string | null>(null); | ||||
| let bannerId = $ref<string | null>(null); | ||||
| const pinnedNotes = ref([]); | ||||
| 
 | ||||
| watch(() => bannerId, async () => { | ||||
| 	if (bannerId == null) { | ||||
|  | @ -70,15 +98,36 @@ async function fetchChannel() { | |||
| 	description = channel.description; | ||||
| 	bannerId = channel.bannerId; | ||||
| 	bannerUrl = channel.bannerUrl; | ||||
| 	pinnedNotes.value = channel.pinnedNoteIds.map(id => ({ | ||||
| 		id, | ||||
| 	})); | ||||
| } | ||||
| 
 | ||||
| fetchChannel(); | ||||
| 
 | ||||
| async function addPinnedNote() { | ||||
| 	const { canceled, result: value } = await os.inputText({ | ||||
| 		title: i18n.ts.noteIdOrUrl, | ||||
| 	}); | ||||
| 	if (canceled) return; | ||||
| 	const note = await os.apiWithDialog('notes/show', { | ||||
| 		noteId: value.includes('/') ? value.split('/').pop() : value, | ||||
| 	}); | ||||
| 	pinnedNotes.value = [{ | ||||
| 		id: note.id, | ||||
| 	}, ...pinnedNotes.value]; | ||||
| } | ||||
| 
 | ||||
| function removePinnedNote(index: number) { | ||||
| 	pinnedNotes.value.splice(index, 1); | ||||
| } | ||||
| 
 | ||||
| function save() { | ||||
| 	const params = { | ||||
| 		name: name, | ||||
| 		description: description, | ||||
| 		bannerId: bannerId, | ||||
| 		pinnedNoteIds: pinnedNotes.value.map(x => x.id), | ||||
| 	}; | ||||
| 
 | ||||
| 	if (props.channelId) { | ||||
|  | @ -117,6 +166,32 @@ definePageMetadata(computed(() => props.channelId ? { | |||
| })); | ||||
| </script> | ||||
| 
 | ||||
| <style lang="scss" scoped> | ||||
| <style lang="scss" module> | ||||
| .pinnedNote { | ||||
| 	position: relative; | ||||
| 	display: block; | ||||
| 	line-height: 2.85rem; | ||||
| 	text-overflow: ellipsis; | ||||
| 	overflow: hidden; | ||||
| 	white-space: nowrap; | ||||
| 	color: var(--navFg); | ||||
| } | ||||
| 
 | ||||
| .pinnedNoteRemove { | ||||
| 	position: absolute; | ||||
| 	z-index: 10000; | ||||
| 	width: 32px; | ||||
| 	height: 32px; | ||||
| 	color: #ff2a2a; | ||||
| 	right: 8px; | ||||
| 	opacity: 0.8; | ||||
| } | ||||
| 
 | ||||
| .pinnedNoteHandle { | ||||
| 	cursor: move; | ||||
| 	width: 32px; | ||||
| 	height: 32px; | ||||
| 	margin: 0 8px; | ||||
| 	opacity: 0.5; | ||||
| } | ||||
| </style> | ||||
|  |  | |||
|  | @ -16,6 +16,16 @@ | |||
| 					<Mfm :text="channel.description" :is-note="false" :i="$i"/> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 
 | ||||
| 			<MkButton v-if="favorited" v-tooltip="i18n.ts.unfavorite" as-like class="button" rounded primary @click="unfavorite()"><i class="ti ti-star"></i></MkButton> | ||||
| 			<MkButton v-else v-tooltip="i18n.ts.favorite" as-like class="button" rounded @click="favorite()"><i class="ti ti-star"></i></MkButton> | ||||
| 
 | ||||
| 			<MkFoldableSection> | ||||
| 				<template #header><i class="ti ti-pin ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.pinnedNotes }}</template> | ||||
| 				<div v-if="channel.pinnedNotes.length > 0" class="_gaps"> | ||||
| 					<MkNote v-for="note in channel.pinnedNotes" :key="note.id" class="_panel" :note="note"/> | ||||
| 				</div> | ||||
| 			</MkFoldableSection> | ||||
| 		</div> | ||||
| 		<div v-if="channel && tab === 'timeline'" class="_gaps"> | ||||
| 			<!-- スマホ・タブレットの場合、キーボードが表示されると投稿が見づらくなるので、デスクトップ場合のみ自動でフォーカスを当てる --> | ||||
|  | @ -54,6 +64,8 @@ import MkNotes from '@/components/MkNotes.vue'; | |||
| import { url } from '@/config'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import { defaultStore } from '@/store'; | ||||
| import MkNote from '@/components/MkNote.vue'; | ||||
| import MkFoldableSection from '@/components/MkFoldableSection.vue'; | ||||
| 
 | ||||
| const router = useRouter(); | ||||
| 
 | ||||
|  | @ -63,6 +75,7 @@ const props = defineProps<{ | |||
| 
 | ||||
| let tab = $ref('timeline'); | ||||
| let channel = $ref(null); | ||||
| let favorited = $ref(false); | ||||
| const featuredPagination = $computed(() => ({ | ||||
| 	endpoint: 'notes/featured' as const, | ||||
| 	limit: 10, | ||||
|  | @ -76,6 +89,7 @@ watch(() => props.channelId, async () => { | |||
| 	channel = await os.api('channels/show', { | ||||
| 		channelId: props.channelId, | ||||
| 	}); | ||||
| 	favorited = channel.isFavorited; | ||||
| }, { immediate: true }); | ||||
| 
 | ||||
| function edit() { | ||||
|  | @ -90,6 +104,27 @@ function openPostForm() { | |||
| 	}); | ||||
| } | ||||
| 
 | ||||
| function favorite() { | ||||
| 	os.apiWithDialog('channels/favorite', { | ||||
| 		channelId: channel.id, | ||||
| 	}).then(() => { | ||||
| 		favorited = true; | ||||
| 	}); | ||||
| } | ||||
| 
 | ||||
| async function unfavorite() { | ||||
| 	const confirm = await os.confirm({ | ||||
| 		type: 'warning', | ||||
| 		text: i18n.ts.unfavoriteConfirm, | ||||
| 	}); | ||||
| 	if (confirm.canceled) return; | ||||
| 	os.apiWithDialog('channels/unfavorite', { | ||||
| 		channelId: channel.id, | ||||
| 	}).then(() => { | ||||
| 		favorited = false; | ||||
| 	}); | ||||
| } | ||||
| 
 | ||||
| const headerActions = $computed(() => { | ||||
| 	if (channel && channel.userId) { | ||||
| 		const share = { | ||||
|  |  | |||
|  | @ -2,17 +2,22 @@ | |||
| <MkStickyContainer> | ||||
| 	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> | ||||
| 	<MkSpacer :content-max="700"> | ||||
| 		<div v-if="tab === 'featured'" class="grwlizim featured"> | ||||
| 		<div v-if="tab === 'featured'"> | ||||
| 			<MkPagination v-slot="{items}" :pagination="featuredPagination"> | ||||
| 				<MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/> | ||||
| 			</MkPagination> | ||||
| 		</div> | ||||
| 		<div v-else-if="tab === 'following'" class="grwlizim following"> | ||||
| 		<div v-else-if="tab === 'favorites'"> | ||||
| 			<MkPagination v-slot="{items}" :pagination="favoritesPagination"> | ||||
| 				<MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/> | ||||
| 			</MkPagination> | ||||
| 		</div> | ||||
| 		<div v-else-if="tab === 'following'"> | ||||
| 			<MkPagination v-slot="{items}" :pagination="followingPagination"> | ||||
| 				<MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/> | ||||
| 			</MkPagination> | ||||
| 		</div> | ||||
| 		<div v-else-if="tab === 'owned'" class="grwlizim owned"> | ||||
| 		<div v-else-if="tab === 'owned'"> | ||||
| 			<MkButton class="new" @click="create()"><i class="ti ti-plus"></i></MkButton> | ||||
| 			<MkPagination v-slot="{items}" :pagination="ownedPagination"> | ||||
| 				<MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/> | ||||
|  | @ -39,13 +44,17 @@ const featuredPagination = { | |||
| 	endpoint: 'channels/featured' as const, | ||||
| 	noPaging: true, | ||||
| }; | ||||
| const favoritesPagination = { | ||||
| 	endpoint: 'channels/my-favorites' as const, | ||||
| 	limit: 100, | ||||
| }; | ||||
| const followingPagination = { | ||||
| 	endpoint: 'channels/followed' as const, | ||||
| 	limit: 5, | ||||
| 	limit: 10, | ||||
| }; | ||||
| const ownedPagination = { | ||||
| 	endpoint: 'channels/owned' as const, | ||||
| 	limit: 5, | ||||
| 	limit: 10, | ||||
| }; | ||||
| 
 | ||||
| function create() { | ||||
|  | @ -62,10 +71,14 @@ const headerTabs = $computed(() => [{ | |||
| 	key: 'featured', | ||||
| 	title: i18n.ts._channel.featured, | ||||
| 	icon: 'ti ti-comet', | ||||
| }, { | ||||
| 	key: 'favorites', | ||||
| 	title: i18n.ts.favorites, | ||||
| 	icon: 'ti ti-star', | ||||
| }, { | ||||
| 	key: 'following', | ||||
| 	title: i18n.ts._channel.following, | ||||
| 	icon: 'ti ti-heart', | ||||
| 	icon: 'ti ti-eye', | ||||
| }, { | ||||
| 	key: 'owned', | ||||
| 	title: i18n.ts._channel.owned, | ||||
|  |  | |||
|  | @ -57,7 +57,7 @@ watch(() => props.clipId, async () => { | |||
| 	immediate: true, | ||||
| });  | ||||
| 
 | ||||
| provide('currentClipPage', $$(clip)); | ||||
| provide('currentClip', $$(clip)); | ||||
| 
 | ||||
| function favorite() { | ||||
| 	os.apiWithDialog('clips/favorite', { | ||||
|  |  | |||
|  | @ -12,7 +12,7 @@ | |||
| 
 | ||||
| 			<template #default="{ items }"> | ||||
| 				<MkDateSeparatedList v-slot="{ item }" :items="items" :direction="'down'" :no-gap="false" :ad="false"> | ||||
| 					<XNote :key="item.id" :note="item.note" :class="$style.note"/> | ||||
| 					<MkNote :key="item.id" :note="item.note" :class="$style.note"/> | ||||
| 				</MkDateSeparatedList> | ||||
| 			</template> | ||||
| 		</MkPagination> | ||||
|  | @ -22,7 +22,7 @@ | |||
| 
 | ||||
| <script lang="ts" setup> | ||||
| import MkPagination from '@/components/MkPagination.vue'; | ||||
| import XNote from '@/components/MkNote.vue'; | ||||
| import MkNote from '@/components/MkNote.vue'; | ||||
| import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue'; | ||||
| import { i18n } from '@/i18n'; | ||||
| import { definePageMetadata } from '@/scripts/page-metadata'; | ||||
|  |  | |||
|  | @ -13,7 +13,7 @@ | |||
| 						<MkButton v-if="!showNext && hasNext" class="load next" @click="showNext = true"><i class="ti ti-chevron-up"></i></MkButton> | ||||
| 						<div class="note _margin _gaps_s"> | ||||
| 							<MkRemoteCaution v-if="note.user.host != null" :href="note.url ?? note.uri"/> | ||||
| 							<XNoteDetailed :key="note.id" v-model:note="note" class="note"/> | ||||
| 							<MkNoteDetailed :key="note.id" v-model:note="note" class="note"/> | ||||
| 						</div> | ||||
| 						<div v-if="clips && clips.length > 0" class="clips _margin"> | ||||
| 							<div class="title">{{ i18n.ts.clip }}</div> | ||||
|  | @ -41,7 +41,7 @@ | |||
| <script lang="ts" setup> | ||||
| import { computed, watch } from 'vue'; | ||||
| import * as misskey from 'misskey-js'; | ||||
| import XNoteDetailed from '@/components/MkNoteDetailed.vue'; | ||||
| import MkNoteDetailed from '@/components/MkNoteDetailed.vue'; | ||||
| import MkNotes from '@/components/MkNotes.vue'; | ||||
| import MkRemoteCaution from '@/components/MkRemoteCaution.vue'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
|  |  | |||
|  | @ -10,8 +10,8 @@ | |||
| 		</MkInput> | ||||
| 		<MkSwitch v-model="props.modelValue.detailed"><span>{{ $ts._pages.blocks._note.detailed }}</span></MkSwitch> | ||||
| 
 | ||||
| 		<XNote v-if="note && !props.modelValue.detailed" :key="note.id + ':normal'" v-model:note="note" style="margin-bottom: 16px;"/> | ||||
| 		<XNoteDetailed v-if="note && props.modelValue.detailed" :key="note.id + ':detail'" v-model:note="note" style="margin-bottom: 16px;"/> | ||||
| 		<MkNote v-if="note && !props.modelValue.detailed" :key="note.id + ':normal'" v-model:note="note" style="margin-bottom: 16px;"/> | ||||
| 		<MkNoteDetailed v-if="note && props.modelValue.detailed" :key="note.id + ':detail'" v-model:note="note" style="margin-bottom: 16px;"/> | ||||
| 	</section> | ||||
| </XContainer> | ||||
| </template> | ||||
|  | @ -22,8 +22,8 @@ import { watch } from 'vue'; | |||
| import XContainer from '../page-editor.container.vue'; | ||||
| import MkInput from '@/components/MkInput.vue'; | ||||
| import MkSwitch from '@/components/MkSwitch.vue'; | ||||
| import XNote from '@/components/MkNote.vue'; | ||||
| import XNoteDetailed from '@/components/MkNoteDetailed.vue'; | ||||
| import MkNote from '@/components/MkNote.vue'; | ||||
| import MkNoteDetailed from '@/components/MkNoteDetailed.vue'; | ||||
| import * as os from '@/os'; | ||||
| 
 | ||||
| const props = defineProps<{ | ||||
|  |  | |||
|  | @ -83,7 +83,7 @@ async function chooseAntenna(ev: MouseEvent): Promise<void> { | |||
| } | ||||
| 
 | ||||
| async function chooseChannel(ev: MouseEvent): Promise<void> { | ||||
| 	const channels = await os.api('channels/followed', { | ||||
| 	const channels = await os.api('channels/my-favorites', { | ||||
| 		limit: 100, | ||||
| 	}); | ||||
| 	const items = channels.map(channel => ({ | ||||
|  |  | |||
|  | @ -93,7 +93,7 @@ | |||
| 
 | ||||
| 			<div class="contents _gaps"> | ||||
| 				<div v-if="user.pinnedNotes.length > 0" class="_gaps"> | ||||
| 					<XNote v-for="note in user.pinnedNotes" :key="note.id" class="note _panel" :note="note" :pinned="true"/> | ||||
| 					<MkNote v-for="note in user.pinnedNotes" :key="note.id" class="note _panel" :note="note" :pinned="true"/> | ||||
| 				</div> | ||||
| 				<MkInfo v-else-if="$i && $i.id === user.id">{{ i18n.ts.userPagePinTip }}</MkInfo> | ||||
| 				<template v-if="narrow"> | ||||
|  | @ -115,7 +115,7 @@ | |||
| import { defineAsyncComponent, computed, onMounted, onUnmounted } from 'vue'; | ||||
| import calcAge from 's-age'; | ||||
| import * as misskey from 'misskey-js'; | ||||
| import XNote from '@/components/MkNote.vue'; | ||||
| import MkNote from '@/components/MkNote.vue'; | ||||
| import MkFollowButton from '@/components/MkFollowButton.vue'; | ||||
| import MkRemoteCaution from '@/components/MkRemoteCaution.vue'; | ||||
| import MkOmit from '@/components/MkOmit.vue'; | ||||
|  |  | |||
|  | @ -38,12 +38,12 @@ | |||
| <script lang="ts"> | ||||
| import { defineComponent } from 'vue'; | ||||
| import { toUnicode } from 'punycode/'; | ||||
| import XTimeline from './welcome.timeline.vue'; | ||||
| import XSigninDialog from '@/components/MkSigninDialog.vue'; | ||||
| import XSignupDialog from '@/components/MkSignupDialog.vue'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import XNote from '@/components/MkNote.vue'; | ||||
| import MkNote from '@/components/MkNote.vue'; | ||||
| import MkFeaturedPhotos from '@/components/MkFeaturedPhotos.vue'; | ||||
| import XTimeline from './welcome.timeline.vue'; | ||||
| import { host, instanceName } from '@/config'; | ||||
| import * as os from '@/os'; | ||||
| import number from '@/filters/number'; | ||||
|  | @ -51,7 +51,7 @@ import number from '@/filters/number'; | |||
| export default defineComponent({ | ||||
| 	components: { | ||||
| 		MkButton, | ||||
| 		XNote, | ||||
| 		MkNote, | ||||
| 		XTimeline, | ||||
| 		MkFeaturedPhotos, | ||||
| 	}, | ||||
|  | @ -118,7 +118,7 @@ export default defineComponent({ | |||
| 				text: this.$ts.help, | ||||
| 				icon: 'ti ti-question-circle', | ||||
| 				action: () => { | ||||
| 					window.open(`https://misskey-hub.net/help.md`, '_blank'); | ||||
| 					window.open('https://misskey-hub.net/help.md', '_blank'); | ||||
| 				}, | ||||
| 			}], ev.currentTarget ?? ev.target); | ||||
| 		}, | ||||
|  |  | |||
|  | @ -61,7 +61,7 @@ import { toUnicode } from 'punycode/'; | |||
| import XSigninDialog from '@/components/MkSigninDialog.vue'; | ||||
| import XSignupDialog from '@/components/MkSignupDialog.vue'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import XNote from '@/components/MkNote.vue'; | ||||
| import MkNote from '@/components/MkNote.vue'; | ||||
| import MkFeaturedPhotos from '@/components/MkFeaturedPhotos.vue'; | ||||
| import XTimeline from './welcome.timeline.vue'; | ||||
| import { host, instanceName } from '@/config'; | ||||
|  | @ -71,7 +71,7 @@ import number from '@/filters/number'; | |||
| export default defineComponent({ | ||||
| 	components: { | ||||
| 		MkButton, | ||||
| 		XNote, | ||||
| 		MkNote, | ||||
| 		MkFeaturedPhotos, | ||||
| 		XTimeline, | ||||
| 	}, | ||||
|  |  | |||
|  | @ -15,7 +15,7 @@ import { clipsCache } from '@/cache'; | |||
| export async function getNoteClipMenu(props: { | ||||
| 	note: misskey.entities.Note; | ||||
| 	isDeleted: Ref<boolean>; | ||||
| 	currentClipPage?: Ref<misskey.entities.Clip>; | ||||
| 	currentClip?: misskey.entities.Clip; | ||||
| }) { | ||||
| 	const isRenote = ( | ||||
| 		props.note.renote != null && | ||||
|  | @ -42,7 +42,7 @@ export async function getNoteClipMenu(props: { | |||
| 						}); | ||||
| 						if (!confirm.canceled) { | ||||
| 							os.apiWithDialog('clips/remove-note', { clipId: clip.id, noteId: appearNote.id }); | ||||
| 							if (props.currentClipPage?.value.id === clip.id) props.isDeleted.value = true; | ||||
| 							if (props.currentClip?.id === clip.id) props.isDeleted.value = true; | ||||
| 						} | ||||
| 					} else { | ||||
| 						os.alert({ | ||||
|  | @ -92,7 +92,7 @@ export function getNoteMenu(props: { | |||
| 	translation: Ref<any>; | ||||
| 	translating: Ref<boolean>; | ||||
| 	isDeleted: Ref<boolean>; | ||||
| 	currentClipPage?: Ref<misskey.entities.Clip>; | ||||
| 	currentClip?: misskey.entities.Clip; | ||||
| }) { | ||||
| 	const isRenote = ( | ||||
| 		props.note.renote != null && | ||||
|  | @ -176,7 +176,7 @@ export function getNoteMenu(props: { | |||
| 	} | ||||
| 
 | ||||
| 	async function unclip(): Promise<void> { | ||||
| 		os.apiWithDialog('clips/remove-note', { clipId: props.currentClipPage.value.id, noteId: appearNote.id }); | ||||
| 		os.apiWithDialog('clips/remove-note', { clipId: props.currentClip.id, noteId: appearNote.id }); | ||||
| 		props.isDeleted.value = true; | ||||
| 	} | ||||
| 
 | ||||
|  | @ -230,7 +230,7 @@ export function getNoteMenu(props: { | |||
| 
 | ||||
| 		menu = [ | ||||
| 			...( | ||||
| 				props.currentClipPage?.value.userId === $i.id ? [{ | ||||
| 				props.currentClip?.userId === $i.id ? [{ | ||||
| 					icon: 'ti ti-backspace', | ||||
| 					text: i18n.ts.unclip, | ||||
| 					danger: true, | ||||
|  | @ -294,7 +294,7 @@ export function getNoteMenu(props: { | |||
| 				text: i18n.ts.muteThread, | ||||
| 				action: () => toggleThreadMute(true), | ||||
| 			}), | ||||
| 			appearNote.userId === $i.id ? ($i.pinnedNoteIds || []).includes(appearNote.id) ? { | ||||
| 			appearNote.userId === $i.id ? ($i.pinnedNoteIds ?? []).includes(appearNote.id) ? { | ||||
| 				icon: 'ti ti-pinned-off', | ||||
| 				text: i18n.ts.unpin, | ||||
| 				action: () => togglePin(false), | ||||
|  |  | |||
|  | @ -0,0 +1,9 @@ | |||
| module.exports = { | ||||
| 	parserOptions: { | ||||
| 		tsconfigRootDir: __dirname, | ||||
| 		project: ['./tsconfig.json'], | ||||
| 	}, | ||||
| 	extends: [ | ||||
| 		'../shared/.eslintrc.js', | ||||
| 	], | ||||
| }; | ||||
|  | @ -1,57 +0,0 @@ | |||
| module.exports = { | ||||
| 	root: true, | ||||
| 	parser: '@typescript-eslint/parser', | ||||
| 	parserOptions: { | ||||
| 		tsconfigRootDir: __dirname, | ||||
| 		project: ['./tsconfig.json'], | ||||
| 	}, | ||||
| 	plugins: [ | ||||
| 		'@typescript-eslint', | ||||
| 	], | ||||
| 	extends: [ | ||||
| 		'eslint:recommended', | ||||
| 		'plugin:@typescript-eslint/recommended', | ||||
| 	], | ||||
| 	rules: { | ||||
| 		'indent': ['error', 'tab', { | ||||
| 			'SwitchCase': 1, | ||||
| 			'MemberExpression': 'off', | ||||
| 			'flatTernaryExpressions': true, | ||||
| 			'ArrayExpression': 'first', | ||||
| 			'ObjectExpression': 'first', | ||||
| 		}], | ||||
| 		'eol-last': ['error', 'always'], | ||||
| 		'semi': ['error', 'always'], | ||||
| 		'quotes': ['error', 'single'], | ||||
| 		'comma-dangle': ['error', 'always-multiline'], | ||||
| 		'keyword-spacing': ['error', { | ||||
| 			'before': true, | ||||
| 			'after': true, | ||||
| 		}], | ||||
| 		'key-spacing': ['error', { | ||||
| 			'beforeColon': false, | ||||
| 			'afterColon': true, | ||||
| 		}], | ||||
| 		'space-infix-ops': ['error'], | ||||
| 		'space-before-blocks': ['error', 'always'], | ||||
| 		'object-curly-spacing': ['error', 'always'], | ||||
| 		'nonblock-statement-body-position': ['error', 'beside'], | ||||
| 		'eqeqeq': ['error', 'always', { 'null': 'ignore' }], | ||||
| 		'no-multiple-empty-lines': ['error', { 'max': 1 }], | ||||
| 		'no-multi-spaces': ['error'], | ||||
| 		'no-var': ['error'], | ||||
| 		'prefer-arrow-callback': ['error'], | ||||
| 		'no-throw-literal': ['error'], | ||||
| 		'no-param-reassign': ['warn'], | ||||
| 		'no-constant-condition': ['warn'], | ||||
| 		'no-empty-pattern': ['warn'], | ||||
| 		'@typescript-eslint/no-unnecessary-condition': ['error'], | ||||
| 		'@typescript-eslint/no-inferrable-types': ['warn'], | ||||
| 		'@typescript-eslint/no-non-null-assertion': ['warn'], | ||||
| 		'@typescript-eslint/explicit-function-return-type': ['warn'], | ||||
| 		'@typescript-eslint/no-misused-promises': ['error', { | ||||
| 			'checksVoidReturn': false, | ||||
| 		}], | ||||
| 		'@typescript-eslint/consistent-type-imports': 'error', | ||||
| 	}, | ||||
| }; | ||||
|  | @ -49,7 +49,7 @@ export class APIClient { | |||
| 		this.credential = opts.credential; | ||||
| 		// ネイティブ関数をそのまま変数に代入して使おうとするとChromiumではIllegal invocationエラーが発生するため、
 | ||||
| 		// 環境で実装されているfetchを使う場合は無名関数でラップして使用する
 | ||||
| 		this.fetch = opts.fetch || ((...args) => fetch(...args)); | ||||
| 		this.fetch = opts.fetch ?? ((...args) => fetch(...args)); | ||||
| 	} | ||||
| 
 | ||||
| 	public request<E extends keyof Endpoints, P extends Endpoints[E]['req']>( | ||||
|  |  | |||
|  | @ -36,7 +36,7 @@ export default class Stream extends EventEmitter<StreamEvents> { | |||
| 		WebSocket?: any; | ||||
| 	}) { | ||||
| 		super(); | ||||
| 		options = options || { }; | ||||
| 		options = options ?? { }; | ||||
| 
 | ||||
| 		const query = urlQuery({ | ||||
| 			i: user?.token, | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue