Merge branch 'develop' into fetch

This commit is contained in:
tamaina
2023-01-09 09:23:42 +00:00
157 changed files with 2213 additions and 1185 deletions

View File

@@ -1,5 +0,0 @@
Font Awesome Icons
-------------------------
Ⓒ Font Awesome
CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 577 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 844 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 507 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 689 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 772 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 930 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 798 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 991 B

View File

@@ -0,0 +1,24 @@
Tabler Icons
https://github.com/tabler/tabler-icons/blob/master/LICENSE
====
MIT License
Copyright (c) 2020-2022 Paweł Kuna
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 952 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 829 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

Before

Width:  |  Height:  |  Size: 174 B

After

Width:  |  Height:  |  Size: 174 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1011 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -72,7 +72,7 @@
"json5-loader": "4.0.1",
"jsonld": "8.1.0",
"jsrsasign": "10.6.1",
"mfm-js": "0.23.0",
"mfm-js": "0.23.1",
"mime-types": "2.1.35",
"misskey-js": "0.0.14",
"ms": "3.0.0-canary.1",

View File

@@ -15,8 +15,8 @@ import type { Packed } from '@/misc/schema.js';
import { DI } from '@/di-symbols.js';
import type { MutingsRepository, BlockingsRepository, NotesRepository, AntennaNotesRepository, AntennasRepository, UserGroupJoiningsRepository, UserListJoiningsRepository } from '@/models/index.js';
import { UtilityService } from '@/core/UtilityService.js';
import type { OnApplicationShutdown } from '@nestjs/common';
import { bindThis } from '@/decorators.js';
import type { OnApplicationShutdown } from '@nestjs/common';
@Injectable()
export class AntennaService implements OnApplicationShutdown {
@@ -135,7 +135,7 @@ export class AntennaService implements OnApplicationShutdown {
this.globalEventServie.publishMainStream(antenna.userId, 'unreadAntenna', antenna);
this.pushNotificationService.pushNotification(antenna.userId, 'unreadAntennaNote', {
antenna: { id: antenna.id, name: antenna.name },
note: await this.noteEntityService.pack(note)
note: await this.noteEntityService.pack(note),
});
}
}, 2000);
@@ -144,27 +144,19 @@ export class AntennaService implements OnApplicationShutdown {
// NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている
/**
* noteUserFollowers / antennaUserFollowing はどちらか一方が指定されていればよい
*/
@bindThis
public async checkHitAntenna(antenna: Antenna, note: (Note | Packed<'Note'>), noteUser: { id: User['id']; username: string; host: string | null; }, noteUserFollowers?: User['id'][], antennaUserFollowing?: User['id'][]): Promise<boolean> {
public async checkHitAntenna(antenna: Antenna, note: (Note | Packed<'Note'>), noteUser: { id: User['id']; username: string; host: string | null; }): Promise<boolean> {
if (note.visibility === 'specified') return false;
if (note.visibility === 'followers') return false;
// アンテナ作成者がノート作成者にブロックされていたらスキップ
const blockings = await this.blockingCache.fetch(noteUser.id, () => this.blockingsRepository.findBy({ blockerId: noteUser.id }).then(res => res.map(x => x.blockeeId)));
if (blockings.some(blocking => blocking === antenna.userId)) return false;
if (note.visibility === 'followers') {
if (noteUserFollowers && !noteUserFollowers.includes(antenna.userId)) return false;
if (antennaUserFollowing && !antennaUserFollowing.includes(note.userId)) return false;
}
if (!antenna.withReplies && note.replyId != null) return false;
if (antenna.src === 'home') {
if (noteUserFollowers && !noteUserFollowers.includes(antenna.userId)) return false;
if (antennaUserFollowing && !antennaUserFollowing.includes(note.userId)) return false;
// TODO
} else if (antenna.src === 'list') {
const listUsers = (await this.userListJoiningsRepository.findBy({
userListId: antenna.userListId!,

View File

@@ -398,13 +398,13 @@ export class FileInfoService {
.raw()
.ensureAlpha()
.resize(64, 64, { fit: 'inside' })
.toBuffer((err, buffer, { width, height }) => {
.toBuffer((err, buffer, info) => {
if (err) return reject(err);
let hash;
try {
hash = encode(new Uint8ClampedArray(buffer), width, height, 5, 5);
hash = encode(new Uint8ClampedArray(buffer), info.width, info.height, 5, 5);
} catch (e) {
return reject(e);
}

View File

@@ -92,13 +92,6 @@ export class PollService {
choice: choice,
userId: user.id,
});
// Notify
this.createNotificationService.createNotification(note.userId, 'pollVote', {
notifierId: user.id,
noteId: note.id,
choice: choice,
});
}
@bindThis

View File

@@ -22,23 +22,25 @@ export class EmojiEntityService {
@bindThis
public async pack(
src: Emoji['id'] | Emoji,
opts: { omitHost?: boolean; omitId?: boolean; } = {},
): Promise<Packed<'Emoji'>> {
const emoji = typeof src === 'object' ? src : await this.emojisRepository.findOneByOrFail({ id: src });
return {
id: emoji.id,
id: opts.omitId ? undefined : emoji.id,
aliases: emoji.aliases,
name: emoji.name,
category: emoji.category,
host: emoji.host,
host: opts.omitHost ? undefined : emoji.host,
};
}
@bindThis
public packMany(
emojis: any[],
opts: { omitHost?: boolean; omitId?: boolean; } = {},
) {
return Promise.all(emojis.map(x => this.pack(x)));
return Promise.all(emojis.map(x => this.pack(x, opts)));
}
}

View File

@@ -98,7 +98,7 @@ export class NotificationEntityService implements OnModuleInit {
}),
reaction: notification.reaction,
} : {}),
...(notification.type === 'pollVote' ? {
...(notification.type === 'pollVote' ? { // TODO: そのうち消す
note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, {
detail: true,
_hint_: options._hintForEachNotes_,

View File

@@ -0,0 +1,3 @@
export function sqlLikeEscape(s: string) {
return s.replace(/([%_])/g, '\\$1');
}

View File

@@ -55,11 +55,11 @@ export class Notification {
* 通知の種類。
* follow - フォローされた
* mention - 投稿で自分が言及された
* reply - (自分または自分がWatchしている)投稿返信された
* renote - (自分または自分がWatchしている)投稿がRenoteされた
* quote - (自分または自分がWatchしている)投稿が引用Renoteされた
* reaction - (自分または自分がWatchしている)投稿にリアクションされた
* pollVote - (自分または自分がWatchしている)投稿のアンケートに投票された
* reply - 投稿返信された
* renote - 投稿がRenoteされた
* quote - 投稿が引用Renoteされた
* reaction - 投稿にリアクションされた
* pollVote - 投稿のアンケートに投票された (廃止)
* pollEnded - 自分のアンケートもしくは自分が投票したアンケートが終了した
* receiveFollowRequest - フォローリクエストされた
* followRequestAccepted - 自分の送ったフォローリクエストが承認された

View File

@@ -3,7 +3,7 @@ export const packedEmojiSchema = {
properties: {
id: {
type: 'string',
optional: false, nullable: false,
optional: true, nullable: false,
format: 'id',
example: 'xxxxxxxxxx',
},
@@ -26,12 +26,8 @@ export const packedEmojiSchema = {
},
host: {
type: 'string',
optional: false, nullable: true,
optional: true, nullable: true,
description: 'The local host is represented with `null`.',
},
url: {
type: 'string',
optional: true, nullable: false,
},
},
} as const;

View File

@@ -220,6 +220,7 @@ import * as ep___messaging_messages_create from './endpoints/messaging/messages/
import * as ep___messaging_messages_delete from './endpoints/messaging/messages/delete.js';
import * as ep___messaging_messages_read from './endpoints/messaging/messages/read.js';
import * as ep___meta from './endpoints/meta.js';
import * as ep___emojis from './endpoints/emojis.js';
import * as ep___miauth_genToken from './endpoints/miauth/gen-token.js';
import * as ep___mute_create from './endpoints/mute/create.js';
import * as ep___mute_delete from './endpoints/mute/delete.js';
@@ -550,6 +551,7 @@ const $messaging_messages_create: Provider = { provide: 'ep:messaging/messages/c
const $messaging_messages_delete: Provider = { provide: 'ep:messaging/messages/delete', useClass: ep___messaging_messages_delete.default };
const $messaging_messages_read: Provider = { provide: 'ep:messaging/messages/read', useClass: ep___messaging_messages_read.default };
const $meta: Provider = { provide: 'ep:meta', useClass: ep___meta.default };
const $emojis: Provider = { provide: 'ep:emojis', useClass: ep___emojis.default };
const $miauth_genToken: Provider = { provide: 'ep:miauth/gen-token', useClass: ep___miauth_genToken.default };
const $mute_create: Provider = { provide: 'ep:mute/create', useClass: ep___mute_create.default };
const $mute_delete: Provider = { provide: 'ep:mute/delete', useClass: ep___mute_delete.default };
@@ -884,6 +886,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$messaging_messages_delete,
$messaging_messages_read,
$meta,
$emojis,
$miauth_genToken,
$mute_create,
$mute_delete,
@@ -1212,6 +1215,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$messaging_messages_delete,
$messaging_messages_read,
$meta,
$emojis,
$miauth_genToken,
$mute_create,
$mute_delete,

View File

@@ -219,6 +219,7 @@ import * as ep___messaging_messages_create from './endpoints/messaging/messages/
import * as ep___messaging_messages_delete from './endpoints/messaging/messages/delete.js';
import * as ep___messaging_messages_read from './endpoints/messaging/messages/read.js';
import * as ep___meta from './endpoints/meta.js';
import * as ep___emojis from './endpoints/emojis.js';
import * as ep___miauth_genToken from './endpoints/miauth/gen-token.js';
import * as ep___mute_create from './endpoints/mute/create.js';
import * as ep___mute_delete from './endpoints/mute/delete.js';
@@ -547,6 +548,7 @@ const eps = [
['messaging/messages/delete', ep___messaging_messages_delete],
['messaging/messages/read', ep___messaging_messages_read],
['meta', ep___meta],
['emojis', ep___emojis],
['miauth/gen-token', ep___miauth_genToken],
['mute/create', ep___mute_create],
['mute/delete', ep___mute_delete],

View File

@@ -5,6 +5,7 @@ import { QueryService } from '@/core/QueryService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
import { DI } from '@/di-symbols.js';
import { sqlLikeEscape } from '@/misc/sql-like-escape.js';
export const meta = {
tags: ['admin'],
@@ -92,7 +93,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
}
if (ps.query) {
q.andWhere('emoji.name like :query', { query: '%' + ps.query + '%' });
q.andWhere('emoji.name like :query', { query: '%' + sqlLikeEscape(ps.query) + '%' });
}
const emojis = await q

View File

@@ -5,6 +5,7 @@ import type { Emoji } from '@/models/entities/Emoji.js';
import { QueryService } from '@/core/QueryService.js';
import { DI } from '@/di-symbols.js';
import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
//import { sqlLikeEscape } from '@/misc/sql-like-escape.js';
export const meta = {
tags: ['admin'],
@@ -82,7 +83,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
let emojis: Emoji[];
if (ps.query) {
//q.andWhere('emoji.name ILIKE :q', { q: `%${ps.query}%` });
//q.andWhere('emoji.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` });
//const emojis = await q.take(ps.limit).getMany();
emojis = await q.getMany();

View File

@@ -3,6 +3,7 @@ import type { UsersRepository } from '@/models/index.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { sqlLikeEscape } from '@/misc/sql-like-escape.js';
export const meta = {
tags: ['admin'],
@@ -68,7 +69,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
}
if (ps.username) {
query.andWhere('user.usernameLower like :username', { username: ps.username.toLowerCase() + '%' });
query.andWhere('user.usernameLower like :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' });
}
if (ps.hostname) {

View File

@@ -0,0 +1,90 @@
import { IsNull, MoreThan } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
import type { EmojisRepository } from '@/models/index.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
export const meta = {
tags: ['meta'],
requireCredential: false,
res: {
type: 'object',
optional: false, nullable: false,
properties: {
emojis: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
properties: {
name: {
type: 'string',
optional: false, nullable: false,
},
aliases: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'string',
optional: false, nullable: false,
},
},
category: {
type: 'string',
optional: false, nullable: true,
},
},
},
},
},
},
} 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.config)
private config: Config,
@Inject(DI.emojisRepository)
private emojisRepository: EmojisRepository,
private emojiEntityService: EmojiEntityService,
) {
super(meta, paramDef, async (ps, me) => {
const emojis = await this.emojisRepository.find({
where: {
host: IsNull(),
},
order: {
category: 'ASC',
name: 'ASC',
},
cache: {
id: 'meta_emojis',
milliseconds: 3600000, // 1 hour
},
});
return {
emojis: await this.emojiEntityService.packMany(emojis, {
omitId: true,
omitHost: true,
}),
};
});
}
}

View File

@@ -4,6 +4,7 @@ import type { InstancesRepository } from '@/models/index.js';
import { InstanceEntityService } from '@/core/entities/InstanceEntityService.js';
import { MetaService } from '@/core/MetaService.js';
import { DI } from '@/di-symbols.js';
import { sqlLikeEscape } from '@/misc/sql-like-escape.js';
export const meta = {
tags: ['federation'],
@@ -120,7 +121,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
}
if (ps.host) {
query.andWhere('instance.host like :host', { host: '%' + ps.host.toLowerCase() + '%' });
query.andWhere('instance.host like :host', { host: '%' + sqlLikeEscape(ps.host.toLowerCase()) + '%' });
}
const instances = await query.take(ps.limit).skip(ps.offset).getMany();

View File

@@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { HashtagsRepository } from '@/models/index.js';
import { DI } from '@/di-symbols.js';
import { sqlLikeEscape } from '@/misc/sql-like-escape.js';
export const meta = {
tags: ['hashtags'],
@@ -37,7 +38,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
) {
super(meta, paramDef, async (ps, me) => {
const hashtags = await this.hashtagsRepository.createQueryBuilder('tag')
.where('tag.name like :q', { q: ps.query.toLowerCase() + '%' })
.where('tag.name like :q', { q: sqlLikeEscape(ps.query.toLowerCase()) + '%' })
.orderBy('tag.count', 'DESC')
.groupBy('tag.id')
.take(ps.limit)

View File

@@ -4,7 +4,6 @@ import type { AdsRepository, EmojisRepository, UsersRepository } from '@/models/
import { MAX_NOTE_TEXT_LENGTH, DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
import { MetaService } from '@/core/MetaService.js';
import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
@@ -152,43 +151,6 @@ export const meta = {
type: 'number',
optional: false, nullable: false,
},
emojis: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
properties: {
id: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
aliases: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'string',
optional: false, nullable: false,
},
},
category: {
type: 'string',
optional: false, nullable: true,
},
host: {
type: 'string',
optional: false, nullable: true,
description: 'The local host is represented with `null`.',
},
url: {
type: 'string',
optional: false, nullable: false,
format: 'url',
},
},
},
},
ads: {
type: 'array',
optional: false, nullable: false,
@@ -326,30 +288,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
@Inject(DI.adsRepository)
private adsRepository: AdsRepository,
@Inject(DI.emojisRepository)
private emojisRepository: EmojisRepository,
private userEntityService: UserEntityService,
private emojiEntityService: EmojiEntityService,
private metaService: MetaService,
) {
super(meta, paramDef, async (ps, me) => {
const instance = await this.metaService.fetch(true);
const emojis = await this.emojisRepository.find({
where: {
host: IsNull(),
},
order: {
category: 'ASC',
name: 'ASC',
},
cache: {
id: 'meta_emojis',
milliseconds: 3600000, // 1 hour
},
});
const ads = await this.adsRepository.find({
where: {
expiresAt: MoreThan(new Date()),
@@ -390,7 +334,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
backgroundImageUrl: instance.backgroundImageUrl,
logoImageUrl: instance.logoImageUrl,
maxNoteTextLength: MAX_NOTE_TEXT_LENGTH, // 後方互換性のため
emojis: await this.emojiEntityService.packMany(emojis),
defaultLightTheme: instance.defaultLightTheme,
defaultDarkTheme: instance.defaultDarkTheme,
ads: ads.map(ad => ({

View File

@@ -162,13 +162,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
userId: me.id,
});
// Notify
this.createNotificationService.createNotification(note.userId, 'pollVote', {
notifierId: me.id,
noteId: note.id,
choice: ps.choice,
});
// リモート投票の場合リプライ送信
if (note.userHost != null) {
const pollOwner = await this.usersRepository.findOneByOrFail({ id: note.userId }) as IRemoteUser;

View File

@@ -6,6 +6,7 @@ import { QueryService } from '@/core/QueryService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
import { sqlLikeEscape } from '@/misc/sql-like-escape.js';
export const meta = {
tags: ['notes'],
@@ -70,7 +71,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
}
query
.andWhere('note.text ILIKE :q', { q: `%${ps.query}%` })
.andWhere('note.text ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner')

View File

@@ -6,6 +6,7 @@ import type { User } from '@/models/entities/User.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { DI } from '@/di-symbols.js';
import { sqlLikeEscape } from '@/misc/sql-like-escape.js';
export const meta = {
tags: ['users'],
@@ -59,10 +60,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
if (ps.host) {
const q = this.usersRepository.createQueryBuilder('user')
.where('user.isSuspended = FALSE')
.andWhere('user.host LIKE :host', { host: ps.host.toLowerCase() + '%' });
.andWhere('user.host LIKE :host', { host: sqlLikeEscape(ps.host.toLowerCase()) + '%' });
if (ps.username) {
q.andWhere('user.usernameLower LIKE :username', { username: ps.username.toLowerCase() + '%' });
q.andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' });
}
q.andWhere('user.updatedAt IS NOT NULL');
@@ -83,7 +84,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
.where(`user.id IN (${ followingQuery.getQuery() })`)
.andWhere('user.id != :meId', { meId: me.id })
.andWhere('user.isSuspended = FALSE')
.andWhere('user.usernameLower LIKE :username', { username: ps.username.toLowerCase() + '%' })
.andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' })
.andWhere(new Brackets(qb => { qb
.where('user.updatedAt IS NULL')
.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
@@ -101,7 +102,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
.where(`user.id NOT IN (${ followingQuery.getQuery() })`)
.andWhere('user.id != :meId', { meId: me.id })
.andWhere('user.isSuspended = FALSE')
.andWhere('user.usernameLower LIKE :username', { username: ps.username.toLowerCase() + '%' })
.andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' })
.andWhere('user.updatedAt IS NOT NULL');
otherQuery.setParameters(followingQuery.getParameters());
@@ -116,7 +117,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
} else {
users = await this.usersRepository.createQueryBuilder('user')
.where('user.isSuspended = FALSE')
.andWhere('user.usernameLower LIKE :username', { username: ps.username.toLowerCase() + '%' })
.andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' })
.andWhere('user.updatedAt IS NOT NULL')
.orderBy('user.updatedAt', 'DESC')
.take(ps.limit - users.length)

View File

@@ -5,6 +5,7 @@ import type { User } from '@/models/entities/User.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { DI } from '@/di-symbols.js';
import { sqlLikeEscape } from '@/misc/sql-like-escape.js';
export const meta = {
tags: ['users'],
@@ -57,7 +58,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
if (isUsername) {
const usernameQuery = this.usersRepository.createQueryBuilder('user')
.where('user.usernameLower LIKE :username', { username: ps.query.replace('@', '').toLowerCase() + '%' })
.where('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.query.replace('@', '').toLowerCase()) + '%' })
.andWhere(new Brackets(qb => { qb
.where('user.updatedAt IS NULL')
.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
@@ -78,11 +79,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
} else {
const nameQuery = this.usersRepository.createQueryBuilder('user')
.where(new Brackets(qb => {
qb.where('user.name ILIKE :query', { query: '%' + ps.query + '%' });
qb.where('user.name ILIKE :query', { query: '%' + sqlLikeEscape(ps.query) + '%' });
// Also search username if it qualifies as username
if (this.userEntityService.validateLocalUsername(ps.query)) {
qb.orWhere('user.usernameLower LIKE :username', { username: '%' + ps.query.toLowerCase() + '%' });
qb.orWhere('user.usernameLower LIKE :username', { username: '%' + sqlLikeEscape(ps.query.toLowerCase()) + '%' });
}
}))
.andWhere(new Brackets(qb => { qb
@@ -106,7 +107,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
if (users.length < ps.limit) {
const profQuery = this.userProfilesRepository.createQueryBuilder('prof')
.select('prof.userId')
.where('prof.description ILIKE :query', { query: '%' + ps.query + '%' });
.where('prof.description ILIKE :query', { query: '%' + sqlLikeEscape(ps.query) + '%' });
if (ps.origin === 'local') {
profQuery.andWhere('prof.userHost IS NULL');

View File

@@ -312,7 +312,7 @@ export class ClientServerService {
fastify.get('/opensearch.xml', async (request, reply) => {
const meta = await this.metaService.fetch();
const name = meta.name || 'Misskey';
const name = meta.name ?? 'Misskey';
let content = '';
content += '<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/" xmlns:moz="http://www.mozilla.org/2006/browser/search/">';
content += `<ShortName>${name}</ShortName>`;
@@ -533,13 +533,12 @@ export class ClientServerService {
});
// Clip
// TODO: 非publicなclipのハンドリング
fastify.get<{ Params: { clip: string; } }>('/clips/:clip', async (request, reply) => {
const clip = await this.clipsRepository.findOneBy({
id: request.params.clip,
});
if (clip) {
if (clip && clip.isPublic) {
const _clip = await this.clipEntityService.pack(clip);
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: clip.userId });
const meta = await this.metaService.fetch();

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -38,7 +38,7 @@
"json5": "2.2.3",
"katex": "0.16.4",
"matter-js": "0.18.0",
"mfm-js": "0.23.0",
"mfm-js": "0.23.1",
"misskey-js": "0.0.14",
"photoswipe": "5.3.4",
"prismjs": "1.29.0",

View File

@@ -33,12 +33,12 @@
<option v-for="item in c.items" :key="item.value" :value="item.value">{{ item.text }}</option>
</MkSelect>
<MkButton v-else-if="c.type === 'postFormButton'" :primary="c.primary" :rounded="c.rounded" :small="size === 'small'" @click="openPostForm">{{ c.text }}</MkButton>
<FormFolder v-else-if="c.type === 'folder'" :default-open="c.opened">
<MkFolder v-else-if="c.type === 'folder'" :default-open="c.opened">
<template #label>{{ c.title }}</template>
<template v-for="child in c.children" :key="child">
<MkAsUi v-if="!g(child).hidden" :component="g(child)" :components="props.components" :size="size"/>
</template>
</FormFolder>
</MkFolder>
<div v-else-if="c.type === 'container'" :class="[$style.container, { [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace', [$style.containerCenter]: c.align === 'center' }]" :style="{ backgroundColor: c.bgColor ?? null, color: c.fgColor ?? null, borderWidth: c.borderWidth ? `${c.borderWidth}px` : 0, borderColor: c.borderColor ?? 'var(--divider)', padding: c.padding ? `${c.padding}px` : 0, borderRadius: c.rounded ? '8px' : 0 }">
<template v-for="child in c.children" :key="child">
<MkAsUi v-if="!g(child).hidden" :component="g(child)" :components="props.components" :size="size"/>
@@ -56,7 +56,7 @@ import MkSwitch from '@/components/MkSwitch.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkSelect from '@/components/MkSelect.vue';
import { AsUiComponent } from '@/scripts/aiscript/ui';
import FormFolder from '@/components/form/folder.vue';
import MkFolder from '@/components/MkFolder.vue';
const props = withDefaults(defineProps<{
component: AsUiComponent;

View File

@@ -47,6 +47,7 @@ import { emojilist } from '@/scripts/emojilist';
import { instance } from '@/instance';
import { i18n } from '@/i18n';
import { miLocalStorage } from '@/local-storage';
import { customEmojis } from '@/custom-emojis';
type EmojiDef = {
emoji: string;
@@ -86,7 +87,6 @@ for (const x of lib) {
emjdb.sort((a, b) => a.name.length - b.name.length);
//#region Construct Emoji DB
const customEmojis = instance.emojis;
const emojiDefinitions: EmojiDef[] = [];
for (const x of customEmojis) {
@@ -117,7 +117,6 @@ export default {
emojiDb,
emojiDefinitions,
emojilist,
customEmojis,
};
</script>

View File

@@ -0,0 +1,92 @@
<template>
<div>
<div v-if="game.ready" :class="$style.game">
<div :class="$style.cps" class="">{{ number(cps) }}cps</div>
<div :class="$style.count" class=""><i class="ti ti-cookie" style="font-size: 70%;"></i> {{ number(cookies) }}</div>
<button v-click-anime class="_button" :class="$style.button" @click="onClick">
<img src="/client-assets/cookie.png" :class="$style.img">
</button>
</div>
<div v-else>
<MkLoading/>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, defineAsyncComponent, onMounted, onUnmounted } from 'vue';
import MkPlusOneEffect from '@/components/MkPlusOneEffect.vue';
import * as os from '@/os';
import { useInterval } from '@/scripts/use-interval';
import * as game from '@/scripts/clicker-game';
import number from '@/filters/number';
defineProps<{
}>();
const saveData = game.saveData;
const cookies = computed(() => saveData.value?.cookies);
let cps = $ref(0);
let prevCookies = $ref(0);
function onClick(ev: MouseEvent) {
saveData.value!.cookies++;
saveData.value!.totalCookies++;
saveData.value!.totalHandmadeCookies++;
saveData.value!.clicked++;
const x = ev.clientX;
const y = ev.clientY;
os.popup(MkPlusOneEffect, { x, y }, {}, 'end');
}
useInterval(() => {
const diff = saveData.value!.cookies - prevCookies;
cps = diff;
prevCookies = saveData.value!.cookies;
}, 1000, {
immediate: false,
afterMounted: true,
});
useInterval(game.save, 1000 * 5, {
immediate: false,
afterMounted: true,
});
onMounted(async () => {
await game.load();
prevCookies = saveData.value!.cookies;
});
onUnmounted(() => {
game.save();
});
</script>
<style lang="scss" module>
.game {
padding: 16px;
text-align: center;
}
.cps {
position: absolute;
top: 12px;
left: 12px;
opacity: 0.5;
}
.count {
font-size: 1.3em;
margin-bottom: 6px;
}
.button {
}
.img {
max-width: 90px;
}
</style>

View File

@@ -74,7 +74,7 @@ function onMousedown(evt: Event) {
}
.fade-enter-active, .fade-leave-active {
transition: opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1), transform 0.5s cubic-bezier(0.16, 1, 0.3, 1);
transition: opacity 0.3s cubic-bezier(0.16, 1, 0.3, 1), transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
transform-origin: left top;
}

View File

@@ -1,12 +1,12 @@
<template>
<div class="omfetrab" :class="['s' + size, 'w' + width, 'h' + height, { asDrawer, asWindow }]" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : undefined }">
<input ref="search" :value="q" class="search" data-prevent-emoji-insert :class="{ filled: q != null && q != '' }" :placeholder="i18n.ts.search" type="search" @input="input()" @paste.stop="paste" @keyup.enter="done()">
<div ref="emojis" class="emojis">
<input ref="searchEl" :value="q" class="search" data-prevent-emoji-insert :class="{ filled: q != null && q != '' }" :placeholder="i18n.ts.search" type="search" @input="input()" @paste.stop="paste" @keyup.enter="done()">
<div ref="emojisEl" class="emojis">
<section class="result">
<div v-if="searchResultCustom.length > 0" class="body">
<button
v-for="emoji in searchResultCustom"
:key="emoji.id"
:key="emoji.name"
class="_button item"
:title="emoji.name"
tabindex="0"
@@ -85,9 +85,10 @@ import MkRippleEffect from '@/components/MkRippleEffect.vue';
import * as os from '@/os';
import { isTouchUsing } from '@/scripts/touch';
import { deviceKind } from '@/scripts/device-kind';
import { emojiCategories, instance } from '@/instance';
import { instance } from '@/instance';
import { i18n } from '@/i18n';
import { defaultStore } from '@/store';
import { getCustomEmojiCategories, customEmojis } from '@/custom-emojis';
const props = withDefaults(defineProps<{
showPinned?: boolean;
@@ -103,8 +104,9 @@ const emit = defineEmits<{
(ev: 'chosen', v: string): void;
}>();
const search = shallowRef<HTMLInputElement>();
const emojis = shallowRef<HTMLDivElement>();
const customEmojiCategories = getCustomEmojiCategories();
const searchEl = shallowRef<HTMLInputElement>();
const emojisEl = shallowRef<HTMLDivElement>();
const {
reactions: pinned,
@@ -118,15 +120,13 @@ const {
const size = computed(() => props.asReactionPicker ? reactionPickerSize.value : 1);
const width = computed(() => props.asReactionPicker ? reactionPickerWidth.value : 3);
const height = computed(() => props.asReactionPicker ? reactionPickerHeight.value : 2);
const customEmojiCategories = emojiCategories;
const customEmojis = instance.emojis;
const q = ref<string>('');
const searchResultCustom = ref<Misskey.entities.CustomEmoji[]>([]);
const searchResultUnicode = ref<UnicodeEmojiDef[]>([]);
const tab = ref<'index' | 'custom' | 'unicode' | 'tags'>('index');
watch(q, () => {
if (emojis.value) emojis.value.scrollTop = 0;
if (emojisEl.value) emojisEl.value.scrollTop = 0;
if (q.value === '') {
searchResultCustom.value = [];
@@ -268,14 +268,14 @@ watch(q, () => {
function focus() {
if (!['smartphone', 'tablet'].includes(deviceKind) && !isTouchUsing) {
search.value?.focus({
searchEl.value?.focus({
preventScroll: true,
});
}
}
function reset() {
if (emojis.value) emojis.value.scrollTop = 0;
if (emojisEl.value) emojisEl.value.scrollTop = 0;
q.value = '';
}
@@ -308,7 +308,7 @@ function input(): void {
// Using custom input event instead of v-model to respond immediately on
// Android, where composition happens on all languages
// (v-model does not update during composition)
q.value = search.value?.value.trim() ?? '';
q.value = searchEl.value?.value.trim() ?? '';
}
function paste(event: ClipboardEvent): void {

View File

@@ -59,6 +59,11 @@ function chosen(emoji: any) {
function opening() {
picker.value?.reset();
picker.value?.focus();
// 何故かちょっと待たないとフォーカスされない
setTimeout(() => {
picker.value?.focus();
}, 10);
}
</script>

View File

@@ -0,0 +1,154 @@
<template>
<div class="ssazuxis">
<header class="_button" :style="{ background: bg }" @click="showBody = !showBody">
<div class="title"><div><slot name="header"></slot></div></div>
<div class="divider"></div>
<button class="_button">
<template v-if="showBody"><i class="ti ti-chevron-up"></i></template>
<template v-else><i class="ti ti-chevron-down"></i></template>
</button>
</header>
<Transition
:name="$store.state.animation ? 'folder-toggle' : ''"
@enter="enter"
@after-enter="afterEnter"
@leave="leave"
@after-leave="afterLeave"
>
<div v-show="showBody">
<slot></slot>
</div>
</Transition>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import tinycolor from 'tinycolor2';
import { miLocalStorage } from '@/local-storage';
const miLocalStoragePrefix = 'ui:folder:' as const;
export default defineComponent({
props: {
expanded: {
type: Boolean,
required: false,
default: true,
},
persistKey: {
type: String,
required: false,
default: null,
},
},
data() {
return {
bg: null,
showBody: (this.persistKey && miLocalStorage.getItem(`${miLocalStoragePrefix}${this.persistKey}`)) ? (miLocalStorage.getItem(`${miLocalStoragePrefix}${this.persistKey}`) === 't') : this.expanded,
};
},
watch: {
showBody() {
if (this.persistKey) {
miLocalStorage.setItem(`${miLocalStoragePrefix}${this.persistKey}`, this.showBody ? 't' : 'f');
}
},
},
mounted() {
function getParentBg(el: Element | null): string {
if (el == null || el.tagName === 'BODY') return 'var(--bg)';
const bg = el.style.background || el.style.backgroundColor;
if (bg) {
return bg;
} else {
return getParentBg(el.parentElement);
}
}
const rawBg = getParentBg(this.$el);
const bg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
bg.setAlpha(0.85);
this.bg = bg.toRgbString();
},
methods: {
toggleContent(show: boolean) {
this.showBody = show;
},
enter(el) {
const elementHeight = el.getBoundingClientRect().height;
el.style.height = 0;
el.offsetHeight; // reflow
el.style.height = elementHeight + 'px';
},
afterEnter(el) {
el.style.height = null;
},
leave(el) {
const elementHeight = el.getBoundingClientRect().height;
el.style.height = elementHeight + 'px';
el.offsetHeight; // reflow
el.style.height = 0;
},
afterLeave(el) {
el.style.height = null;
},
},
});
</script>
<style lang="scss" scoped>
.folder-toggle-enter-active, .folder-toggle-leave-active {
overflow-y: clip;
transition: opacity 0.5s, height 0.5s !important;
}
.folder-toggle-enter-from {
opacity: 0;
}
.folder-toggle-leave-to {
opacity: 0;
}
.ssazuxis {
position: relative;
> header {
display: flex;
position: relative;
z-index: 10;
position: sticky;
top: var(--stickyTop, 0px);
padding: var(--x-padding);
-webkit-backdrop-filter: var(--blur, blur(8px));
backdrop-filter: var(--blur, blur(20px));
> .title {
display: grid;
place-content: center;
margin: 0;
padding: 12px 16px 12px 0;
}
> .divider {
flex: 1;
margin: auto;
height: 1px;
background: var(--divider);
}
> button {
padding: 12px 0 12px 16px;
}
}
}
@container (max-width: 500px) {
.ssazuxis {
> header {
> .title {
padding: 8px 10px 8px 0;
}
}
}
}
</style>

View File

@@ -1,161 +1,177 @@
<template>
<div class="ssazuxis">
<header class="_button" :style="{ background: bg }" @click="showBody = !showBody">
<div class="title"><slot name="header"></slot></div>
<div class="divider"></div>
<button class="_button">
<template v-if="showBody"><i class="ti ti-chevron-up"></i></template>
<template v-else><i class="ti ti-chevron-down"></i></template>
</button>
</header>
<Transition
:name="$store.state.animation ? 'folder-toggle' : ''"
@enter="enter"
@after-enter="afterEnter"
@leave="leave"
@after-leave="afterLeave"
>
<div v-show="showBody">
<slot></slot>
</div>
</Transition>
<div ref="rootEl" class="dwzlatin" :class="{ opened }">
<div class="header _button" @click="toggle">
<span class="icon"><slot name="icon"></slot></span>
<span class="text"><slot name="label"></slot></span>
<span class="right">
<span class="text"><slot name="suffix"></slot></span>
<i v-if="opened" class="ti ti-chevron-up icon"></i>
<i v-else class="ti ti-chevron-down icon"></i>
</span>
</div>
<div v-if="openedAtLeastOnce" class="body" :class="{ bgSame }" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : null }">
<Transition
:name="$store.state.animation ? 'folder-toggle' : ''"
@enter="enter"
@after-enter="afterEnter"
@leave="leave"
@after-leave="afterLeave"
>
<KeepAlive>
<div v-show="opened">
<MkSpacer :margin-min="14" :margin-max="22">
<slot></slot>
</MkSpacer>
</div>
</KeepAlive>
</Transition>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import tinycolor from 'tinycolor2';
import { miLocalStorage } from '@/local-storage';
<script lang="ts" setup>
import { nextTick, onMounted } from 'vue';
const miLocalStoragePrefix = 'ui:folder:' as const;
const props = withDefaults(defineProps<{
defaultOpen: boolean;
maxHeight: number | null;
}>(), {
defaultOpen: false,
maxHeight: null,
});
export default defineComponent({
props: {
expanded: {
type: Boolean,
required: false,
default: true,
},
persistKey: {
type: String,
required: false,
default: null,
},
},
data() {
return {
bg: null,
showBody: (this.persistKey && miLocalStorage.getItem(`${miLocalStoragePrefix}${this.persistKey}`)) ? (miLocalStorage.getItem(`${miLocalStoragePrefix}${this.persistKey}`) === 't') : this.expanded,
};
},
watch: {
showBody() {
if (this.persistKey) {
miLocalStorage.setItem(`${miLocalStoragePrefix}${this.persistKey}`, this.showBody ? 't' : 'f');
}
},
},
mounted() {
function getParentBg(el: Element | null): string {
if (el == null || el.tagName === 'BODY') return 'var(--bg)';
const bg = el.style.background || el.style.backgroundColor;
if (bg) {
return bg;
} else {
return getParentBg(el.parentElement);
}
}
const rawBg = getParentBg(this.$el);
const bg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
bg.setAlpha(0.85);
this.bg = bg.toRgbString();
},
methods: {
toggleContent(show: boolean) {
this.showBody = show;
},
const getBgColor = (el: HTMLElement) => {
const style = window.getComputedStyle(el);
if (style.backgroundColor && !['rgba(0, 0, 0, 0)', 'rgba(0,0,0,0)', 'transparent'].includes(style.backgroundColor)) {
return style.backgroundColor;
} else {
return el.parentElement ? getBgColor(el.parentElement) : 'transparent';
}
};
enter(el) {
const elementHeight = el.getBoundingClientRect().height;
el.style.height = 0;
el.offsetHeight; // reflow
el.style.height = elementHeight + 'px';
},
afterEnter(el) {
el.style.height = null;
},
leave(el) {
const elementHeight = el.getBoundingClientRect().height;
el.style.height = elementHeight + 'px';
el.offsetHeight; // reflow
el.style.height = 0;
},
afterLeave(el) {
el.style.height = null;
},
},
let rootEl = $ref<HTMLElement>();
let bgSame = $ref(false);
let opened = $ref(props.defaultOpen);
let openedAtLeastOnce = $ref(props.defaultOpen);
function enter(el) {
const elementHeight = el.getBoundingClientRect().height;
el.style.height = 0;
el.offsetHeight; // reflow
el.style.height = Math.min(elementHeight, props.maxHeight ?? Infinity) + 'px';
}
function afterEnter(el) {
el.style.height = null;
}
function leave(el) {
const elementHeight = el.getBoundingClientRect().height;
el.style.height = elementHeight + 'px';
el.offsetHeight; // reflow
el.style.height = 0;
}
function afterLeave(el) {
el.style.height = null;
}
function toggle() {
if (!opened) {
openedAtLeastOnce = true;
}
nextTick(() => {
opened = !opened;
});
}
onMounted(() => {
const computedStyle = getComputedStyle(document.documentElement);
const parentBg = getBgColor(rootEl.parentElement);
const myBg = computedStyle.getPropertyValue('--panel');
bgSame = parentBg === myBg;
});
</script>
<style lang="scss" scoped>
.folder-toggle-enter-active, .folder-toggle-leave-active {
overflow-y: clip;
transition: opacity 0.5s, height 0.5s !important;
transition: opacity 0.3s, height 0.3s, transform 0.3s !important;
}
.folder-toggle-enter-from {
opacity: 0;
}
.folder-toggle-leave-to {
.folder-toggle-enter-from, .folder-toggle-leave-to {
opacity: 0;
}
.ssazuxis {
position: relative;
.dwzlatin {
display: block;
> header {
> .header {
display: flex;
position: relative;
z-index: 10;
position: sticky;
top: var(--stickyTop, 0px);
padding: var(--x-padding);
-webkit-backdrop-filter: var(--blur, blur(8px));
backdrop-filter: var(--blur, blur(20px));
align-items: center;
width: 100%;
box-sizing: border-box;
padding: 10px 14px 10px 14px;
background: var(--buttonBg);
border-radius: 6px;
> .title {
display: grid;
place-content: center;
margin: 0;
padding: 12px 16px 12px 0;
&:hover {
text-decoration: none;
background: var(--buttonHoverBg);
}
> i {
margin-right: 6px;
}
&.active {
color: var(--accent);
background: var(--buttonHoverBg);
}
> .icon {
margin-right: 0.75em;
flex-shrink: 0;
text-align: center;
opacity: 0.8;
&:empty {
display: none;
& + .text {
padding-left: 4px;
}
}
}
> .divider {
flex: 1;
margin: auto;
height: 1px;
background: var(--divider);
> .text {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
padding-right: 12px;
}
> button {
padding: 12px 0 12px 16px;
> .right {
margin-left: auto;
opacity: 0.7;
white-space: nowrap;
> .text:not(:empty) {
margin-right: 0.75em;
}
}
}
}
@container (max-width: 500px) {
.ssazuxis {
> header {
> .title {
padding: 8px 10px 8px 0;
}
> .body {
background: var(--panel);
border-radius: 0 0 6px 6px;
container-type: inline-size;
overflow: auto;
&.bgSame {
background: var(--bg);
}
}
&.opened {
> .header {
border-radius: 6px 6px 0 0;
}
}
}

View File

@@ -1,6 +1,6 @@
<template>
<div :class="[$style.root, { yellow: instance.isNotResponding, red: instance.isBlocked, gray: instance.isSuspended }]">
<img class="icon" :src="getInstanceIcon(instance)" alt=""/>
<img class="icon" :src="getInstanceIcon(instance)" alt="" loading="lazy"/>
<div class="body">
<span class="host">{{ instance.name ?? instance.host }}</span>
<span class="sub _monospace"><b>{{ instance.host }}</b> / {{ instance.softwareName || '?' }} {{ instance.softwareVersion }}</span>

View File

@@ -1,6 +1,6 @@
<template>
<div :class="$style.root">
<MkFolder class="item">
<MkFoldableSection class="item">
<template #header>Chart</template>
<div :class="$style.chart">
<div class="selects">
@@ -34,9 +34,9 @@
<MkChart :src="chartSrc" :span="chartSpan" :limit="chartLimit" :detailed="true"></MkChart>
</div>
</div>
</MkFolder>
</MkFoldableSection>
<MkFolder class="item">
<MkFoldableSection class="item">
<template #header>Active users heatmap</template>
<MkSelect v-model="heatmapSrc" style="margin: 0 0 12px 0;">
<option value="active-users">Active users</option>
@@ -48,16 +48,16 @@
<div class="_panel" :class="$style.heatmap">
<MkHeatmap :src="heatmapSrc"/>
</div>
</MkFolder>
</MkFoldableSection>
<MkFolder class="item">
<MkFoldableSection class="item">
<template #header>Retention rate</template>
<div class="_panel" :class="$style.retention">
<MkRetentionHeatmap/>
</div>
</MkFolder>
</MkFoldableSection>
<MkFolder class="item">
<MkFoldableSection class="item">
<template #header>Federation</template>
<div :class="$style.federation">
<div class="pies">
@@ -71,7 +71,7 @@
</div>
</div>
</div>
</MkFolder>
</MkFoldableSection>
</div>
</template>
@@ -84,7 +84,7 @@ import { useChartTooltip } from '@/scripts/use-chart-tooltip';
import * as os from '@/os';
import { i18n } from '@/i18n';
import MkHeatmap from '@/components/MkHeatmap.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
import MkRetentionHeatmap from '@/components/MkRetentionHeatmap.vue';
import { initChart } from '@/scripts/init-chart';

View File

@@ -251,17 +251,18 @@ onBeforeUnmount(() => {
color: #fff;
&:before {
background: #d42e2e;
background: #d42e2e !important;
}
}
}
&:active,
&.active {
color: var(--fgOnAccent);
color: var(--fgOnAccent) !important;
opacity: 1;
&:before {
background: var(--accent);
background: var(--accent) !important;
}
}

View File

@@ -1,7 +1,7 @@
<template>
<Transition :name="transitionName" :duration="transitionDuration" appear @after-leave="emit('closed')" @enter="emit('opening')" @after-enter="onOpened">
<div v-show="manualShowing != null ? manualShowing : showing" v-hotkey.global="keymap" class="qzhlnise" :class="{ drawer: type === 'drawer', dialog: type === 'dialog' || type === 'dialog:top', popup: type === 'popup' }" :style="{ zIndex, pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }">
<div class="bg _modalBg" :class="{ transparent: transparentBg && (type === 'popup') }" :style="{ zIndex }" @click="onBgClick" @contextmenu.prevent.stop="() => {}"></div>
<div class="bg _modalBg" :class="{ transparent: transparentBg && (type === 'popup') }" :style="{ zIndex }" @click="onBgClick" @mousedown="onBgClick" @contextmenu.prevent.stop="() => {}"></div>
<div ref="content" class="content" :class="{ fixed, top: type === 'dialog:top' }" :style="{ zIndex }" @click.self="onBgClick">
<slot :max-height="maxHeight" :type="type"></slot>
</div>
@@ -63,6 +63,7 @@ let transformOrigin = $ref('center');
let showing = $ref(true);
let content = $shallowRef<HTMLElement>();
const zIndex = os.claimZIndex(props.zPriority);
let useSendAnime = $ref(false);
const type = $computed<ModalTypes>(() => {
if (props.preferType === 'auto') {
if (!defaultStore.state.disableDrawer && isTouchUsing && deviceKind === 'smartphone') {
@@ -74,15 +75,34 @@ const type = $computed<ModalTypes>(() => {
return props.preferType!;
}
});
let transitionName = $ref(defaultStore.state.animation ? (type === 'drawer') ? 'modal-drawer' : (type === 'popup') ? 'modal-popup' : 'modal' : '');
let transitionDuration = $ref(defaultStore.state.animation ? 200 : 0);
let transitionName = $computed((() =>
defaultStore.state.animation
? useSendAnime
? 'send'
: type === 'drawer'
? 'modal-drawer'
: type === 'popup'
? 'modal-popup'
: 'modal'
: ''
));
let transitionDuration = $computed((() =>
transitionName === 'send'
? 400
: transitionName === 'modal-popup'
? 100
: transitionName === 'modal'
? 200
: transitionName === 'modal-drawer'
? 200
: 0
));
let contentClicking = false;
function close(opts: { useSendAnimation?: boolean } = {}) {
if (opts.useSendAnimation) {
transitionName = 'send';
transitionDuration = 400;
useSendAnime = true;
}
// eslint-disable-next-line vue/no-mutating-props
@@ -308,12 +328,12 @@ defineExpose({
.modal-popup-enter-active, .modal-popup-leave-active {
> .bg {
transition: opacity 0.2s !important;
transition: opacity 0.1s !important;
}
> .content {
transform-origin: var(--transformOrigin);
transition: opacity 0.2s cubic-bezier(0, 0, 0.2, 1), transform 0.2s cubic-bezier(0, 0, 0.2, 1) !important;
transition: opacity 0.1s cubic-bezier(0, 0, 0.2, 1), transform 0.1s cubic-bezier(0, 0, 0.2, 1) !important;
}
}
.modal-popup-enter-from, .modal-popup-leave-to {

View File

@@ -13,7 +13,7 @@
<div v-if="appearNote._prId_" class="info"><i class="fas fa-bullhorn"></i> {{ i18n.ts.promotion }}<button class="_textButton hide" @click="readPromo()">{{ i18n.ts.hideThisNote }} <i class="ti ti-x"></i></button></div>
<div v-if="appearNote._featuredId_" class="info"><i class="ti ti-bolt"></i> {{ i18n.ts.featured }}</div>
<div v-if="isRenote" class="renote">
<MkAvatar class="avatar" :user="note.user"/>
<MkAvatar v-once class="avatar" :user="note.user"/>
<i class="ti ti-repeat"></i>
<I18n :src="i18n.ts.renotedBy" tag="span">
<template #user>
@@ -27,11 +27,16 @@
<i v-if="isMyRenote" class="ti ti-dots dropdownIcon"></i>
<MkTime :time="note.createdAt"/>
</button>
<MkVisibility :note="note"/>
<span v-if="note.visibility !== 'public'" style="margin-left: 0.5em;" :title="i18n.ts._visibility[note.visibility]">
<i v-if="note.visibility === 'home'" class="ti ti-home"></i>
<i v-else-if="note.visibility === 'followers'" class="ti ti-lock"></i>
<i v-else-if="note.visibility === 'specified'" ref="specified" class="ti ti-mail"></i>
</span>
<span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['localOnly']"><i class="ti ti-world-off"></i></span>
</div>
</div>
<article class="article" @contextmenu.stop="onContextmenu">
<MkAvatar class="avatar" :user="appearNote.user"/>
<MkAvatar v-once class="avatar" :user="appearNote.user"/>
<div class="main">
<MkNoteHeader class="header" :note="appearNote" :mini="true"/>
<MkInstanceTicker v-if="showTicker" class="ticker" :instance="appearNote.user.instance"/>
@@ -44,7 +49,7 @@
<div class="text">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<MkA v-if="appearNote.replyId" class="reply" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i"/>
<Mfm v-if="appearNote.text" v-once :text="appearNote.text" :author="appearNote.user" :i="$i"/>
<a v-if="appearNote.renote != null" class="rp">RN:</a>
<div v-if="translating || translation" class="translation">
<MkLoading v-if="translating" mini/>
@@ -75,14 +80,25 @@
<i class="ti ti-arrow-back-up"></i>
<p v-if="appearNote.repliesCount > 0" class="count">{{ appearNote.repliesCount }}</p>
</button>
<MkRenoteButton ref="renoteButton" class="button" :note="appearNote" :count="appearNote.renoteCount"/>
<button v-if="appearNote.myReaction == null" ref="reactButton" class="button _button" @click="react()">
<button
v-if="canRenote"
ref="renoteButton"
class="button _button"
@mousedown="renote()"
>
<i class="ti ti-repeat"></i>
<p v-if="appearNote.renoteCount > 0" class="count">{{ appearNote.renoteCount }}</p>
</button>
<button v-else class="button _button" disabled>
<i class="ti ti-ban"></i>
</button>
<button v-if="appearNote.myReaction == null" ref="reactButton" class="button _button" @mousedown="react()">
<i class="ti ti-plus"></i>
</button>
<button v-if="appearNote.myReaction != null" ref="reactButton" class="button _button reacted" @click="undoReact(appearNote)">
<i class="ti ti-minus"></i>
</button>
<button ref="menuButton" class="button _button" @click="menu()">
<button ref="menuButton" class="button _button" @mousedown="menu()">
<i class="ti ti-dots"></i>
</button>
</footer>
@@ -111,10 +127,9 @@ import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
import MkMediaList from '@/components/MkMediaList.vue';
import MkCwButton from '@/components/MkCwButton.vue';
import MkPoll from '@/components/MkPoll.vue';
import MkRenoteButton from '@/components/MkRenoteButton.vue';
import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
import MkUrlPreview from '@/components/MkUrlPreview.vue';
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
import MkVisibility from '@/components/MkVisibility.vue';
import { pleaseLogin } from '@/scripts/please-login';
import { focusPrev, focusNext } from '@/scripts/focus';
import { checkWordMute } from '@/scripts/check-word-mute';
@@ -128,6 +143,7 @@ import { i18n } from '@/i18n';
import { getNoteMenu } from '@/scripts/get-note-menu';
import { useNoteCapture } from '@/scripts/use-note-capture';
import { deepClone } from '@/scripts/clone';
import { useTooltip } from '@/scripts/use-tooltip';
const props = defineProps<{
note: misskey.entities.Note;
@@ -158,7 +174,7 @@ const isRenote = (
const el = shallowRef<HTMLElement>();
const menuButton = shallowRef<HTMLElement>();
const renoteButton = shallowRef<InstanceType<typeof MkRenoteButton>>();
const renoteButton = shallowRef<HTMLElement>();
const renoteTime = shallowRef<HTMLElement>();
const reactButton = shallowRef<HTMLElement>();
let appearNote = $computed(() => isRenote ? note.renote as misskey.entities.Note : note);
@@ -175,6 +191,7 @@ const translation = ref(null);
const translating = ref(false);
const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null;
const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance);
const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || appearNote.userId === $i.id);
const keymap = {
'r': () => reply(true),
@@ -193,6 +210,47 @@ useNoteCapture({
isDeletedRef: isDeleted,
});
useTooltip(renoteButton, async (showing) => {
const renotes = await os.api('notes/renotes', {
noteId: appearNote.id,
limit: 11,
});
const users = renotes.map(x => x.user);
if (users.length < 1) return;
os.popup(MkUsersTooltip, {
showing,
users,
count: appearNote.renoteCount,
targetElement: renoteButton.value,
}, {}, 'closed');
});
function renote(viaKeyboard = false) {
pleaseLogin();
os.popupMenu([{
text: i18n.ts.renote,
icon: 'ti ti-repeat',
action: () => {
os.api('notes/create', {
renoteId: appearNote.id,
});
},
}, {
text: i18n.ts.quote,
icon: 'ti ti-quote',
action: () => {
os.post({
renote: appearNote,
});
},
}], renoteButton.value, {
viaKeyboard,
});
}
function reply(viaKeyboard = false): void {
pleaseLogin();
os.post({

View File

@@ -25,7 +25,12 @@
<i v-if="isMyRenote" class="ti ti-dots dropdownIcon"></i>
<MkTime :time="note.createdAt"/>
</button>
<MkVisibility :note="note"/>
<span v-if="note.visibility !== 'public'" style="margin-left: 0.5em;" :title="i18n.ts._visibility[note.visibility]">
<i v-if="note.visibility === 'home'" class="ti ti-home"></i>
<i v-else-if="note.visibility === 'followers'" class="ti ti-lock"></i>
<i v-else-if="note.visibility === 'specified'" ref="specified" class="ti ti-mail"></i>
</span>
<span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['localOnly']"><i class="ti ti-world-off"></i></span>
</div>
</div>
<article class="article" @contextmenu.stop="onContextmenu">
@@ -38,7 +43,12 @@
</MkA>
<span v-if="appearNote.user.isBot" class="is-bot">bot</span>
<div class="info">
<MkVisibility :note="appearNote"/>
<span v-if="appearNote.visibility !== 'public'" style="margin-left: 0.5em;" :title="i18n.ts._visibility[appearNote.visibility]">
<i v-if="appearNote.visibility === 'home'" class="ti ti-home"></i>
<i v-else-if="appearNote.visibility === 'followers'" class="ti ti-lock"></i>
<i v-else-if="appearNote.visibility === 'specified'" ref="specified" class="ti ti-mail"></i>
</span>
<span v-if="appearNote.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['localOnly']"><i class="ti ti-world-off"></i></span>
</div>
</div>
<div class="username"><MkAcct :user="appearNote.user"/></div>
@@ -85,14 +95,25 @@
<i class="ti ti-arrow-back-up"></i>
<p v-if="appearNote.repliesCount > 0" class="count">{{ appearNote.repliesCount }}</p>
</button>
<MkRenoteButton ref="renoteButton" class="button" :note="appearNote" :count="appearNote.renoteCount"/>
<button v-if="appearNote.myReaction == null" ref="reactButton" class="button _button" @click="react()">
<button
v-if="canRenote"
ref="renoteButton"
class="button _button"
@mousedown="renote()"
>
<i class="ti ti-repeat"></i>
<p v-if="appearNote.renoteCount > 0" class="count">{{ appearNote.renoteCount }}</p>
</button>
<button v-else class="button _button" disabled>
<i class="ti ti-ban"></i>
</button>
<button v-if="appearNote.myReaction == null" ref="reactButton" class="button _button" @mousedown="react()">
<i class="ti ti-plus"></i>
</button>
<button v-if="appearNote.myReaction != null" ref="reactButton" class="button _button reacted" @click="undoReact(appearNote)">
<i class="ti ti-minus"></i>
</button>
<button ref="menuButton" class="button _button" @click="menu()">
<button ref="menuButton" class="button _button" @mousedown="menu()">
<i class="ti ti-dots"></i>
</button>
</footer>
@@ -121,10 +142,9 @@ import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
import MkMediaList from '@/components/MkMediaList.vue';
import MkCwButton from '@/components/MkCwButton.vue';
import MkPoll from '@/components/MkPoll.vue';
import MkRenoteButton from '@/components/MkRenoteButton.vue';
import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
import MkUrlPreview from '@/components/MkUrlPreview.vue';
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
import MkVisibility from '@/components/MkVisibility.vue';
import { pleaseLogin } from '@/scripts/please-login';
import { checkWordMute } from '@/scripts/check-word-mute';
import { userPage } from '@/filters/user';
@@ -138,6 +158,7 @@ import { i18n } from '@/i18n';
import { getNoteMenu } from '@/scripts/get-note-menu';
import { useNoteCapture } from '@/scripts/use-note-capture';
import { deepClone } from '@/scripts/clone';
import { useTooltip } from '@/scripts/use-tooltip';
const props = defineProps<{
note: misskey.entities.Note;
@@ -168,7 +189,7 @@ const isRenote = (
const el = shallowRef<HTMLElement>();
const menuButton = shallowRef<HTMLElement>();
const renoteButton = shallowRef<InstanceType<typeof MkRenoteButton>>();
const renoteButton = shallowRef<HTMLElement>();
const renoteTime = shallowRef<HTMLElement>();
const reactButton = shallowRef<HTMLElement>();
let appearNote = $computed(() => isRenote ? note.renote as misskey.entities.Note : note);
@@ -182,6 +203,7 @@ const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : n
const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance);
const conversation = ref<misskey.entities.Note[]>([]);
const replies = ref<misskey.entities.Note[]>([]);
const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || appearNote.userId === $i.id);
const keymap = {
'r': () => reply(true),
@@ -198,6 +220,47 @@ useNoteCapture({
isDeletedRef: isDeleted,
});
useTooltip(renoteButton, async (showing) => {
const renotes = await os.api('notes/renotes', {
noteId: appearNote.id,
limit: 11,
});
const users = renotes.map(x => x.user);
if (users.length < 1) return;
os.popup(MkUsersTooltip, {
showing,
users,
count: appearNote.renoteCount,
targetElement: renoteButton.value,
}, {}, 'closed');
});
function renote(viaKeyboard = false) {
pleaseLogin();
os.popupMenu([{
text: i18n.ts.renote,
icon: 'ti ti-repeat',
action: () => {
os.api('notes/create', {
renoteId: appearNote.id,
});
},
}, {
text: i18n.ts.quote,
icon: 'ti ti-quote',
action: () => {
os.post({
renote: appearNote,
});
},
}], renoteButton.value, {
viaKeyboard,
});
}
function reply(viaKeyboard = false): void {
pleaseLogin();
os.post({

View File

@@ -1,6 +1,6 @@
<template>
<header class="kkwtjztg">
<MkA v-user-preview="note.user.id" class="name" :to="userPage(note.user)">
<MkA v-once v-user-preview="note.user.id" class="name" :to="userPage(note.user)">
<MkUserName :user="note.user"/>
</MkA>
<div v-if="note.user.isBot" class="is-bot">bot</div>
@@ -9,7 +9,12 @@
<MkA class="created-at" :to="notePage(note)">
<MkTime :time="note.createdAt"/>
</MkA>
<MkVisibility :note="note"/>
<span v-if="note.visibility !== 'public'" style="margin-left: 0.5em;" :title="i18n.ts._visibility[note.visibility]">
<i v-if="note.visibility === 'home'" class="ti ti-home"></i>
<i v-else-if="note.visibility === 'followers'" class="ti ti-lock"></i>
<i v-else-if="note.visibility === 'specified'" ref="specified" class="ti ti-mail"></i>
</span>
<span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['localOnly']"><i class="ti ti-world-off"></i></span>
</div>
</header>
</template>
@@ -17,7 +22,7 @@
<script lang="ts" setup>
import { } from 'vue';
import * as misskey from 'misskey-js';
import MkVisibility from '@/components/MkVisibility.vue';
import { i18n } from '@/i18n';
import { notePage } from '@/filters/note';
import { userPage } from '@/filters/user';

View File

@@ -1,6 +1,6 @@
<template>
<div ref="elRef" class="qglefbjs" :class="notification.type">
<div class="head">
<div v-once class="head">
<MkAvatar v-if="notification.type === 'pollEnded'" class="icon" :user="notification.note.user"/>
<MkAvatar v-else-if="notification.user" class="icon" :user="notification.user"/>
<img v-else-if="notification.icon" class="icon" :src="notification.icon" alt=""/>
@@ -13,10 +13,9 @@
<i v-else-if="notification.type === 'reply'" class="ti ti-arrow-back-up"></i>
<i v-else-if="notification.type === 'mention'" class="ti ti-at"></i>
<i v-else-if="notification.type === 'quote'" class="ti ti-quote"></i>
<i v-else-if="notification.type === 'pollVote'" class="ti ti-chart-arrows"></i>
<i v-else-if="notification.type === 'pollEnded'" class="ti ti-chart-arrows"></i>
<!-- notification.reaction null になることはまずないがここでoptional chaining使うと一部ブラウザで刺さるので念の為 -->
<XReactionIcon
<MkReactionIcon
v-else-if="notification.type === 'reaction'"
ref="reactionRef"
:reaction="notification.reaction ? notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : notification.reaction"
@@ -32,42 +31,39 @@
<span v-else>{{ notification.header }}</span>
<MkTime v-if="withTime" :time="notification.createdAt" class="time"/>
</header>
<MkA v-if="notification.type === 'reaction'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
<i class="ti ti-quote"></i>
<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full"/>
<i class="ti ti-quote"></i>
</MkA>
<MkA v-if="notification.type === 'renote'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note.renote)">
<i class="ti ti-quote"></i>
<Mfm :text="getNoteSummary(notification.note.renote)" :plain="true" :nowrap="!full"/>
<i class="ti ti-quote"></i>
</MkA>
<MkA v-if="notification.type === 'reply'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full"/>
</MkA>
<MkA v-if="notification.type === 'mention'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full"/>
</MkA>
<MkA v-if="notification.type === 'quote'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full"/>
</MkA>
<MkA v-if="notification.type === 'pollVote'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
<i class="ti ti-quote"></i>
<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full"/>
<i class="ti ti-quote"></i>
</MkA>
<MkA v-if="notification.type === 'pollEnded'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
<i class="ti ti-quote"></i>
<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full"/>
<i class="ti ti-quote"></i>
</MkA>
<span v-if="notification.type === 'follow'" class="text" style="opacity: 0.6;">{{ i18n.ts.youGotNewFollower }}<div v-if="full"><MkFollowButton :user="notification.user" :full="true"/></div></span>
<span v-if="notification.type === 'followRequestAccepted'" class="text" style="opacity: 0.6;">{{ i18n.ts.followRequestAccepted }}</span>
<span v-if="notification.type === 'receiveFollowRequest'" class="text" style="opacity: 0.6;">{{ i18n.ts.receiveFollowRequest }}<div v-if="full && !followRequestDone"><button class="_textButton" @click="acceptFollowRequest()">{{ i18n.ts.accept }}</button> | <button class="_textButton" @click="rejectFollowRequest()">{{ i18n.ts.reject }}</button></div></span>
<span v-if="notification.type === 'groupInvited'" class="text" style="opacity: 0.6;">{{ i18n.ts.groupInvited }}: <b>{{ notification.invitation.group.name }}</b><div v-if="full && !groupInviteDone"><button class="_textButton" @click="acceptGroupInvitation()">{{ i18n.ts.accept }}</button> | <button class="_textButton" @click="rejectGroupInvitation()">{{ i18n.ts.reject }}</button></div></span>
<span v-if="notification.type === 'app'" class="text">
<Mfm :text="notification.body" :nowrap="!full"/>
</span>
<div v-once class="content">
<MkA v-if="notification.type === 'reaction'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
<i class="ti ti-quote"></i>
<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full"/>
<i class="ti ti-quote"></i>
</MkA>
<MkA v-else-if="notification.type === 'renote'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note.renote)">
<i class="ti ti-quote"></i>
<Mfm :text="getNoteSummary(notification.note.renote)" :plain="true" :nowrap="!full"/>
<i class="ti ti-quote"></i>
</MkA>
<MkA v-else-if="notification.type === 'reply'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full"/>
</MkA>
<MkA v-else-if="notification.type === 'mention'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full"/>
</MkA>
<MkA v-else-if="notification.type === 'quote'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full"/>
</MkA>
<MkA v-else-if="notification.type === 'pollEnded'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
<i class="ti ti-quote"></i>
<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full"/>
<i class="ti ti-quote"></i>
</MkA>
<span v-else-if="notification.type === 'follow'" class="text" style="opacity: 0.6;">{{ i18n.ts.youGotNewFollower }}<div v-if="full"><MkFollowButton :user="notification.user" :full="true"/></div></span>
<span v-else-if="notification.type === 'followRequestAccepted'" class="text" style="opacity: 0.6;">{{ i18n.ts.followRequestAccepted }}</span>
<span v-else-if="notification.type === 'receiveFollowRequest'" class="text" style="opacity: 0.6;">{{ i18n.ts.receiveFollowRequest }}<div v-if="full && !followRequestDone"><button class="_textButton" @click="acceptFollowRequest()">{{ i18n.ts.accept }}</button> | <button class="_textButton" @click="rejectFollowRequest()">{{ i18n.ts.reject }}</button></div></span>
<span v-else-if="notification.type === 'groupInvited'" class="text" style="opacity: 0.6;">{{ i18n.ts.groupInvited }}: <b>{{ notification.invitation.group.name }}</b><div v-if="full && !groupInviteDone"><button class="_textButton" @click="acceptGroupInvitation()">{{ i18n.ts.accept }}</button> | <button class="_textButton" @click="rejectGroupInvitation()">{{ i18n.ts.reject }}</button></div></span>
<span v-else-if="notification.type === 'app'" class="text">
<Mfm :text="notification.body" :nowrap="!full"/>
</span>
</div>
</div>
</div>
</template>
@@ -75,7 +71,7 @@
<script lang="ts" setup>
import { ref, shallowRef, onMounted, onUnmounted, watch } from 'vue';
import * as misskey from 'misskey-js';
import XReactionIcon from '@/components/MkReactionIcon.vue';
import MkReactionIcon from '@/components/MkReactionIcon.vue';
import MkFollowButton from '@/components/MkFollowButton.vue';
import XReactionTooltip from '@/components/MkReactionTooltip.vue';
import { getNoteSummary } from '@/scripts/get-note-summary';
@@ -239,12 +235,6 @@ useTooltip(reactionRef, (showing) => {
pointer-events: none;
}
&.pollVote {
padding: 3px;
background: #88a6b7;
pointer-events: none;
}
&.pollEnded {
padding: 3px;
background: #88a6b7;
@@ -275,23 +265,25 @@ useTooltip(reactionRef, (showing) => {
}
}
> .text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
> .content {
> .text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
> i {
vertical-align: super;
font-size: 50%;
opacity: 0.5;
}
> i {
vertical-align: super;
font-size: 50%;
opacity: 0.5;
}
> i:first-child {
margin-right: 4px;
}
> i:first-child {
margin-right: 4px;
}
> i:last-child {
margin-left: 4px;
> i:last-child {
margin-left: 4px;
}
}
}
}

View File

@@ -1,18 +1,14 @@
<template>
<div :class="$style.root" :style="{ zIndex, top: `${y - 64}px`, left: `${x - 64}px` }">
<span class="text" :class="{ up }">
<XReactionIcon class="icon" :reaction="reaction"/>
</span>
<span class="text" :class="{ up }">+1</span>
</div>
</template>
<script lang="ts" setup>
import { onMounted } from 'vue';
import * as os from '@/os';
import XReactionIcon from '@/components/MkReactionIcon.vue';
const props = withDefaults(defineProps<{
reaction: string;
x: number;
y: number;
}>(), {
@@ -23,8 +19,8 @@ const emit = defineEmits<{
}>();
let up = $ref(false);
const zIndex = os.claimZIndex('veryLow');
const angle = (90 - (Math.random() * 180)) + 'deg';
const zIndex = os.claimZIndex('middle');
const angle = (45 - (Math.random() * 90)) + 'deg';
onMounted(() => {
window.setTimeout(() => {
@@ -55,10 +51,11 @@ onMounted(() => {
right: 0;
bottom: 0;
margin: auto;
color: var(--accent);
color: #fff;
text-shadow: 0 0 6px #000;
font-size: 18px;
font-weight: bold;
transform: translateY(-30px);
transform: translateY(0px);
transition: transform 1s cubic-bezier(0,.5,0,1), opacity 1s cubic-bezier(.5,0,1,.5);
will-change: opacity, transform;

View File

@@ -18,7 +18,7 @@
<button ref="visibilityButton" v-tooltip="i18n.ts.visibility" class="_button visibility" :disabled="channel != null" @click="setVisibility">
<span v-if="visibility === 'public'"><i class="ti ti-world"></i></span>
<span v-if="visibility === 'home'"><i class="ti ti-home"></i></span>
<span v-if="visibility === 'followers'"><i class="ti ti-lock-open"></i></span>
<span v-if="visibility === 'followers'"><i class="ti ti-lock"></i></span>
<span v-if="visibility === 'specified'"><i class="ti ti-mail"></i></span>
</button>
<button v-tooltip="i18n.ts.previewNoteText" class="_button preview" :class="{ active: showPreview }" @click="showPreview = !showPreview"><i class="ti ti-eye"></i></button>

View File

@@ -0,0 +1,72 @@
<template>
<div :class="$style.root" :style="{ zIndex, top: `${y - 64}px`, left: `${x - 64}px` }">
<span class="text" :class="{ up }">
<MkReactionIcon class="icon" :reaction="reaction"/>
</span>
</div>
</template>
<script lang="ts" setup>
import { onMounted } from 'vue';
import * as os from '@/os';
import MkReactionIcon from '@/components/MkReactionIcon.vue';
const props = withDefaults(defineProps<{
reaction: string;
x: number;
y: number;
}>(), {
});
const emit = defineEmits<{
(ev: 'end'): void;
}>();
let up = $ref(false);
const zIndex = os.claimZIndex('middle');
const angle = (90 - (Math.random() * 180)) + 'deg';
onMounted(() => {
window.setTimeout(() => {
up = true;
}, 10);
window.setTimeout(() => {
emit('end');
}, 1100);
});
</script>
<style lang="scss" module>
.root {
pointer-events: none;
position: fixed;
width: 128px;
height: 128px;
&:global {
> .text {
display: block;
height: 1em;
text-align: center;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
margin: auto;
color: var(--accent);
font-size: 18px;
font-weight: bold;
transform: translateY(-30px);
transition: transform 1s cubic-bezier(0,.5,0,1), opacity 1s cubic-bezier(.5,0,1,.5);
will-change: opacity, transform;
&.up {
opacity: 0;
transform: translateY(-50px) rotateZ(v-bind(angle));
}
}
}
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<MkTooltip ref="tooltip" :showing="showing" :target-element="targetElement" :max-width="340" @closed="emit('closed')">
<div class="beeadbfb">
<XReactionIcon :reaction="reaction" class="icon" :no-style="true"/>
<MkReactionIcon :reaction="reaction" class="icon" :no-style="true"/>
<div class="name">{{ reaction.replace('@.', '') }}</div>
</div>
</MkTooltip>
@@ -10,7 +10,7 @@
<script lang="ts" setup>
import { } from 'vue';
import MkTooltip from './MkTooltip.vue';
import XReactionIcon from '@/components/MkReactionIcon.vue';
import MkReactionIcon from '@/components/MkReactionIcon.vue';
defineProps<{
showing: boolean;

View File

@@ -2,7 +2,7 @@
<MkTooltip ref="tooltip" :showing="showing" :target-element="targetElement" :max-width="340" @closed="emit('closed')">
<div class="bqxuuuey">
<div class="reaction">
<XReactionIcon :reaction="reaction" class="icon" :no-style="true"/>
<MkReactionIcon :reaction="reaction" class="icon" :no-style="true"/>
<div class="name">{{ getReactionName(reaction) }}</div>
</div>
<div class="users">
@@ -19,7 +19,7 @@
<script lang="ts" setup>
import { } from 'vue';
import MkTooltip from './MkTooltip.vue';
import XReactionIcon from '@/components/MkReactionIcon.vue';
import MkReactionIcon from '@/components/MkReactionIcon.vue';
import { getEmojiName } from '@/scripts/emojilist';
defineProps<{

View File

@@ -1,12 +1,12 @@
<template>
<button
ref="buttonRef"
ref="buttonEl"
v-ripple="canToggle"
class="hkzvhatu _button"
:class="{ reacted: note.myReaction == reaction, canToggle }"
@click="toggleReaction()"
>
<XReactionIcon class="icon" :reaction="reaction"/>
<MkReactionIcon class="icon" :reaction="reaction"/>
<span class="count">{{ count }}</span>
</button>
</template>
@@ -15,11 +15,11 @@
import { computed, onMounted, ref, shallowRef, watch } from 'vue';
import * as misskey from 'misskey-js';
import XDetails from '@/components/MkReactionsViewer.details.vue';
import XReactionIcon from '@/components/MkReactionIcon.vue';
import MkReactionIcon from '@/components/MkReactionIcon.vue';
import * as os from '@/os';
import { useTooltip } from '@/scripts/use-tooltip';
import { $i } from '@/account';
import MkPlusOneEffect from '@/components/MkPlusOneEffect.vue';
import MkReactionEffect from '@/components/MkReactionEffect.vue';
const props = defineProps<{
reaction: string;
@@ -28,7 +28,7 @@ const props = defineProps<{
note: misskey.entities.Note;
}>();
const buttonRef = shallowRef<HTMLElement>();
const buttonEl = shallowRef<HTMLElement>();
const canToggle = computed(() => !props.reaction.match(/@\w/) && $i);
@@ -58,10 +58,10 @@ const toggleReaction = () => {
const anime = () => {
if (document.hidden) return;
const rect = buttonRef.value.getBoundingClientRect();
const x = rect.left + (buttonRef.value.offsetWidth / 2);
const y = rect.top + (buttonRef.value.offsetHeight / 2);
os.popup(MkPlusOneEffect, { reaction: props.reaction, x, y }, {}, 'end');
const rect = buttonEl.value.getBoundingClientRect();
const x = rect.left + 16;
const y = rect.top + (buttonEl.value.offsetHeight / 2);
os.popup(MkReactionEffect, { reaction: props.reaction, x, y }, {}, 'end');
};
watch(() => props.count, (newCount, oldCount) => {
@@ -72,7 +72,7 @@ onMounted(() => {
if (!props.isInitial) anime();
});
useTooltip(buttonRef, async (showing) => {
useTooltip(buttonEl, async (showing) => {
const reactions = await os.apiGet('notes/reactions', {
noteId: props.note.id,
type: props.reaction,
@@ -87,7 +87,7 @@ useTooltip(buttonRef, async (showing) => {
reaction: props.reaction,
users,
count: props.count,
targetElement: buttonRef.value,
targetElement: buttonEl.value,
}, {}, 'closed');
}, 100);
</script>

View File

@@ -1,99 +0,0 @@
<template>
<button
v-if="canRenote"
ref="buttonRef"
class="eddddedb _button canRenote"
@click="renote()"
>
<i class="ti ti-repeat"></i>
<p v-if="count > 0" class="count">{{ count }}</p>
</button>
<button v-else class="eddddedb _button">
<i class="ti ti-ban"></i>
</button>
</template>
<script lang="ts" setup>
import { computed, ref, shallowRef } from 'vue';
import * as misskey from 'misskey-js';
import XDetails from '@/components/MkUsersTooltip.vue';
import { pleaseLogin } from '@/scripts/please-login';
import * as os from '@/os';
import { $i } from '@/account';
import { useTooltip } from '@/scripts/use-tooltip';
import { i18n } from '@/i18n';
const props = defineProps<{
note: misskey.entities.Note;
count: number;
}>();
const buttonRef = shallowRef<HTMLElement>();
const canRenote = computed(() => ['public', 'home'].includes(props.note.visibility) || props.note.userId === $i.id);
useTooltip(buttonRef, async (showing) => {
const renotes = await os.api('notes/renotes', {
noteId: props.note.id,
limit: 11,
});
const users = renotes.map(x => x.user);
if (users.length < 1) return;
os.popup(XDetails, {
showing,
users,
count: props.count,
targetElement: buttonRef.value,
}, {}, 'closed');
});
const renote = (viaKeyboard = false) => {
pleaseLogin();
os.popupMenu([{
text: i18n.ts.renote,
icon: 'ti ti-repeat',
action: () => {
os.api('notes/create', {
renoteId: props.note.id,
});
},
}, {
text: i18n.ts.quote,
icon: 'ti ti-quote',
action: () => {
os.post({
renote: props.note,
});
},
}], buttonRef.value, {
viaKeyboard,
});
};
</script>
<style lang="scss" scoped>
.eddddedb {
display: inline-block;
height: 32px;
margin: 2px;
padding: 0 6px;
border-radius: 4px;
&:not(.canRenote) {
cursor: default;
}
&.renoted {
background: var(--accent);
}
> .count {
display: inline;
margin-left: 8px;
opacity: 0.7;
}
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<div class="vblkjoeq">
<div class="label" @click="focus"><slot name="label"></slot></div>
<div ref="container" class="input" :class="{ inline, disabled, focused }" @click.prevent="onClick">
<div ref="container" class="input" :class="{ inline, disabled, focused }" @mousedown.prevent="show">
<div ref="prefixEl" class="prefix"><slot name="prefix"></slot></div>
<select
ref="inputEl"
@@ -118,7 +118,7 @@ onMounted(() => {
});
});
const onClick = (ev: MouseEvent) => {
function show(ev: MouseEvent) {
focused.value = true;
opening.value = true;
@@ -166,7 +166,7 @@ const onClick = (ev: MouseEvent) => {
}).then(() => {
focused.value = false;
});
};
}
</script>
<style lang="scss" scoped>
@@ -285,7 +285,7 @@ const onClick = (ev: MouseEvent) => {
<style lang="scss" module>
.chevron {
transition: transform 0.5s ease;
transition: transform 0.1s ease-out;
}
.chevronOpening {

View File

@@ -14,7 +14,7 @@
<template #prefix><i class="ti ti-lock"></i></template>
<template #caption><button class="_textButton" type="button" @click="resetPassword">{{ i18n.ts.forgotPassword }}</button></template>
</MkInput>
<MkButton type="submit" primary :disabled="signing" style="margin: 0 auto;">{{ signing ? i18n.ts.loggingIn : i18n.ts.login }}</MkButton>
<MkButton type="submit" large primary rounded :disabled="signing" style="margin: 0 auto;">{{ signing ? i18n.ts.loggingIn : i18n.ts.login }}</MkButton>
</div>
<div v-if="totpLogin" class="2fa-signin" :class="{ securityKeys: user && user.securityKeys }">
<div v-if="user && user.securityKeys" class="twofa-group tap-group">
@@ -36,7 +36,7 @@
<template #label>{{ i18n.ts.token }}</template>
<template #prefix><i class="ti ti-123"></i></template>
</MkInput>
<MkButton type="submit" :disabled="signing" primary style="margin: 0 auto;">{{ signing ? i18n.ts.loggingIn : i18n.ts.login }}</MkButton>
<MkButton type="submit" :disabled="signing" large primary rounded style="margin: 0 auto;">{{ signing ? i18n.ts.loggingIn : i18n.ts.login }}</MkButton>
</div>
</div>
</div>

View File

@@ -4,7 +4,7 @@
<span v-if="note.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<span v-if="note.deletedAt" style="opacity: 0.5">({{ i18n.ts.deleted }})</span>
<MkA v-if="note.replyId" class="reply" :to="`/notes/${note.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
<Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i"/>
<Mfm v-if="note.text" v-once :text="note.text" :author="note.user" :i="$i"/>
<MkA v-if="note.renoteId" class="rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA>
</div>
<details v-if="note.files.length > 0">

View File

@@ -1,48 +0,0 @@
<template>
<span v-if="note.visibility !== 'public'" :class="$style.visibility" :title="i18n.ts._visibility[note.visibility]">
<i v-if="note.visibility === 'home'" class="ti ti-home"></i>
<i v-else-if="note.visibility === 'followers'" class="ti ti-lock-open"></i>
<i v-else-if="note.visibility === 'specified'" ref="specified" class="ti ti-mail"></i>
</span>
<span v-if="note.localOnly" :class="$style.localOnly" :title="i18n.ts._visibility['localOnly']"><i class="ti ti-world-off"></i></span>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import XDetails from '@/components/MkUsersTooltip.vue';
import * as os from '@/os';
import { useTooltip } from '@/scripts/use-tooltip';
import { i18n } from '@/i18n';
const props = defineProps<{
note: {
visibility: string;
localOnly?: boolean;
visibleUserIds?: string[];
},
}>();
const specified = $shallowRef<HTMLElement>();
if (props.note.visibility === 'specified') {
useTooltip($$(specified), async (showing) => {
const users = await os.api('users/show', {
userIds: props.note.visibleUserIds,
limit: 10,
});
os.popup(XDetails, {
showing,
users,
count: props.note.visibleUserIds.length,
targetElement: specified,
}, {}, 'closed');
});
}
</script>
<style lang="scss" module>
.visibility, .localOnly {
margin-left: 0.5em;
}
</style>

View File

@@ -16,7 +16,7 @@
</div>
</button>
<button key="followers" class="_button item" :class="{ active: v === 'followers' }" data-index="3" @click="choose('followers')">
<div class="icon"><i class="ti ti-lock-open"></i></div>
<div class="icon"><i class="ti ti-lock"></i></div>
<div class="body">
<span>{{ i18n.ts._visibility.followers }}</span>
<span>{{ i18n.ts._visibility.followersDescription }}</span>

View File

@@ -1,175 +0,0 @@
<template>
<div ref="rootEl" class="dwzlatin" :class="{ opened }">
<div class="header _button" @click="toggle">
<span class="icon"><slot name="icon"></slot></span>
<span class="text"><slot name="label"></slot></span>
<span class="right">
<span class="text"><slot name="suffix"></slot></span>
<i v-if="opened" class="ti ti-chevron-up icon"></i>
<i v-else class="ti ti-chevron-down icon"></i>
</span>
</div>
<div v-if="openedAtLeastOnce" class="body" :class="{ bgSame }">
<Transition
:name="$store.state.animation ? 'folder-toggle' : ''"
@enter="enter"
@after-enter="afterEnter"
@leave="leave"
@after-leave="afterLeave"
>
<KeepAlive>
<div v-show="opened">
<MkSpacer :margin-min="14" :margin-max="22">
<slot></slot>
</MkSpacer>
</div>
</KeepAlive>
</Transition>
</div>
</div>
</template>
<script lang="ts" setup>
import { nextTick, onMounted } from 'vue';
const props = withDefaults(defineProps<{
defaultOpen: boolean;
}>(), {
defaultOpen: false,
});
const getBgColor = (el: HTMLElement) => {
const style = window.getComputedStyle(el);
if (style.backgroundColor && !['rgba(0, 0, 0, 0)', 'rgba(0,0,0,0)', 'transparent'].includes(style.backgroundColor)) {
return style.backgroundColor;
} else {
return el.parentElement ? getBgColor(el.parentElement) : 'transparent';
}
};
let rootEl = $ref<HTMLElement>();
let bgSame = $ref(false);
let opened = $ref(props.defaultOpen);
let openedAtLeastOnce = $ref(props.defaultOpen);
function enter(el) {
const elementHeight = el.getBoundingClientRect().height;
el.style.height = 0;
el.offsetHeight; // reflow
el.style.height = elementHeight + 'px';
}
function afterEnter(el) {
el.style.height = null;
}
function leave(el) {
const elementHeight = el.getBoundingClientRect().height;
el.style.height = elementHeight + 'px';
el.offsetHeight; // reflow
el.style.height = 0;
}
function afterLeave(el) {
el.style.height = null;
}
function toggle() {
if (!opened) {
openedAtLeastOnce = true;
}
nextTick(() => {
opened = !opened;
});
}
onMounted(() => {
const computedStyle = getComputedStyle(document.documentElement);
const parentBg = getBgColor(rootEl.parentElement);
const myBg = computedStyle.getPropertyValue('--panel');
bgSame = parentBg === myBg;
});
</script>
<style lang="scss" scoped>
.folder-toggle-enter-active, .folder-toggle-leave-active {
overflow-y: clip;
transition: opacity 0.3s, height 0.3s, transform 0.3s !important;
}
.folder-toggle-enter-from, .folder-toggle-leave-to {
opacity: 0;
}
.dwzlatin {
display: block;
> .header {
display: flex;
align-items: center;
width: 100%;
box-sizing: border-box;
padding: 10px 14px 10px 14px;
background: var(--buttonBg);
border-radius: 6px;
&:hover {
text-decoration: none;
background: var(--buttonHoverBg);
}
&.active {
color: var(--accent);
background: var(--buttonHoverBg);
}
> .icon {
margin-right: 0.75em;
flex-shrink: 0;
text-align: center;
opacity: 0.8;
&:empty {
display: none;
& + .text {
padding-left: 4px;
}
}
}
> .text {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
padding-right: 12px;
}
> .right {
margin-left: auto;
opacity: 0.7;
white-space: nowrap;
> .text:not(:empty) {
margin-right: 0.75em;
}
}
}
> .body {
background: var(--panel);
border-radius: 0 0 6px 6px;
container-type: inline-size;
&.bgSame {
background: var(--bg);
}
}
&.opened {
> .header {
border-radius: 6px 6px 0 0;
}
}
}
</style>

View File

@@ -0,0 +1,47 @@
import { api } from './os';
import { miLocalStorage } from './local-storage';
const storageCache = miLocalStorage.getItem('emojis');
export let customEmojis = storageCache ? JSON.parse(storageCache) : [];
fetchCustomEmojis();
export async function fetchCustomEmojis() {
const now = Date.now();
const lastFetchedAt = miLocalStorage.getItem('lastEmojisFetchedAt');
if (lastFetchedAt && (now - parseInt(lastFetchedAt)) < 1000 * 60 * 60) return;
const res = await api('emojis', {});
customEmojis = res.emojis;
miLocalStorage.setItem('emojis', JSON.stringify(customEmojis));
miLocalStorage.setItem('lastEmojisFetchedAt', now.toString());
}
let cachedCategories;
export function getCustomEmojiCategories() {
if (cachedCategories) return cachedCategories;
const categories = new Set();
for (const emoji of customEmojis) {
categories.add(emoji.category);
}
const res = Array.from(categories);
cachedCategories = res;
return res;
}
let cachedTags;
export function getCustomEmojiTags() {
if (cachedTags) return cachedTags;
const tags = new Set();
for (const emoji of customEmojis) {
for (const tag of emoji.aliases) {
tags.add(tag);
}
}
const res = Array.from(tags);
cachedTags = res;
return res;
}

View File

@@ -12,6 +12,8 @@ export default {
target.classList.add('_anime_bounce_standBy');
el.addEventListener('mousedown', () => {
target.classList.remove('_anime_bounce');
target.classList.add('_anime_bounce_standBy');
target.classList.add('_anime_bounce_ready');
@@ -22,10 +24,10 @@ export default {
el.addEventListener('click', () => {
target.classList.add('_anime_bounce');
target.classList.remove('_anime_bounce_ready');
});
el.addEventListener('animationend', () => {
target.classList.remove('_anime_bounce_ready');
target.classList.remove('_anime_bounce');
target.classList.add('_anime_bounce_standBy');
});

View File

@@ -5,11 +5,11 @@ import { miLocalStorage } from './local-storage';
// TODO: 他のタブと永続化されたstateを同期
const instanceData = miLocalStorage.getItem('instance');
const cached = miLocalStorage.getItem('instance');
// TODO: instanceをリアクティブにするかは再考の余地あり
export const instance: Misskey.entities.InstanceMetadata = reactive(instanceData ? JSON.parse(instanceData) : {
export const instance: Misskey.entities.InstanceMetadata = reactive(cached ? JSON.parse(cached) : {
// TODO: set default values
});
@@ -24,23 +24,3 @@ export async function fetchInstance() {
miLocalStorage.setItem('instance', JSON.stringify(instance));
}
export const emojiCategories = computed(() => {
if (instance.emojis == null) return [];
const categories = new Set();
for (const emoji of instance.emojis) {
categories.add(emoji.category);
}
return Array.from(categories);
});
export const emojiTags = computed(() => {
if (instance.emojis == null) return [];
const tags = new Set();
for (const emoji of instance.emojis) {
for (const tag of emoji.aliases) {
tags.add(tag);
}
}
return Array.from(tags);
});

View File

@@ -2,6 +2,8 @@ type Keys =
'v' |
'lastVersion' |
'instance' |
'emojis' | // TODO: indexed db
'lastEmojisFetchedAt' |
'account' |
'accounts' |
'latestDonationInfoShownAt' |

View File

@@ -7,92 +7,72 @@
<!-- たくさんあると邪魔
<div class="tags">
<span class="tag _button" v-for="tag in tags" :class="{ active: selectedTags.has(tag) }" @click="toggleTag(tag)">{{ tag }}</span>
<span class="tag _button" v-for="tag in customEmojiTags" :class="{ active: selectedTags.has(tag) }" @click="toggleTag(tag)">{{ tag }}</span>
</div>
-->
</div>
<MkFolder v-if="searchEmojis" class="emojis">
<MkFoldableSection v-if="searchEmojis" class="emojis">
<template #header>{{ $ts.searchResult }}</template>
<div class="zuvgdzyt">
<XEmoji v-for="emoji in searchEmojis" :key="emoji.name" class="emoji" :emoji="emoji"/>
</div>
</MkFolder>
</MkFoldableSection>
<MkFolder v-for="category in customEmojiCategories" :key="category" class="emojis">
<MkFoldableSection v-for="category in customEmojiCategories" v-once :key="category" class="emojis">
<template #header>{{ category || $ts.other }}</template>
<div class="zuvgdzyt">
<XEmoji v-for="emoji in customEmojis.filter(e => e.category === category)" :key="emoji.name" class="emoji" :emoji="emoji"/>
</div>
</MkFolder>
</MkFoldableSection>
</div>
</template>
<script lang="ts">
import { defineComponent, computed } from 'vue';
<script lang="ts" setup>
import { defineComponent, computed, watch } from 'vue';
import XEmoji from './emojis.emoji.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
import MkTab from '@/components/MkTab.vue';
import * as os from '@/os';
import { emojiCategories, emojiTags } from '@/instance';
import { customEmojis, getCustomEmojiCategories, getCustomEmojiTags } from '@/custom-emojis';
export default defineComponent({
components: {
MkButton,
MkInput,
MkSelect,
MkFolder,
MkTab,
XEmoji,
},
const customEmojiCategories = getCustomEmojiCategories();
const customEmojiTags = getCustomEmojiTags();
let q = $ref('');
let searchEmojis = $ref(null);
let selectedTags = $ref(new Set());
data() {
return {
q: '',
customEmojiCategories: emojiCategories,
customEmojis: this.$instance.emojis,
tags: emojiTags,
selectedTags: new Set(),
searchEmojis: null,
};
},
function search() {
if ((q === '' || q == null) && selectedTags.size === 0) {
searchEmojis = null;
return;
}
watch: {
q() { this.search(); },
selectedTags: {
handler() {
this.search();
},
deep: true,
},
},
if (selectedTags.size === 0) {
searchEmojis = customEmojis.filter(emoji => emoji.name.includes(q) || emoji.aliases.includes(q));
} else {
searchEmojis = customEmojis.filter(emoji => (emoji.name.includes(q) || emoji.aliases.includes(q)) && [...selectedTags].every(t => emoji.aliases.includes(t)));
}
}
methods: {
search() {
if ((this.q === '' || this.q == null) && this.selectedTags.size === 0) {
this.searchEmojis = null;
return;
}
function toggleTag(tag) {
if (selectedTags.has(tag)) {
selectedTags.delete(tag);
} else {
selectedTags.add(tag);
}
}
if (this.selectedTags.size === 0) {
this.searchEmojis = this.customEmojis.filter(emoji => emoji.name.includes(this.q) || emoji.aliases.includes(this.q));
} else {
this.searchEmojis = this.customEmojis.filter(emoji => (emoji.name.includes(this.q) || emoji.aliases.includes(this.q)) && [...this.selectedTags].every(t => emoji.aliases.includes(t)));
}
},
toggleTag(tag) {
if (this.selectedTags.has(tag)) {
this.selectedTags.delete(tag);
} else {
this.selectedTags.add(tag);
}
},
},
watch($$(q), () => {
search();
});
watch($$(selectedTags), () => {
search();
}, { deep: true });
</script>
<style lang="scss" scoped>

View File

@@ -36,7 +36,7 @@ import MkInput from '@/components/MkInput.vue';
import * as os from '@/os';
import { unique } from '@/scripts/array';
import { i18n } from '@/i18n';
import { emojiCategories } from '@/instance';
import { getCustomEmojiCategories } from '@/custom-emojis';
const props = defineProps<{
emoji: any,
@@ -46,7 +46,7 @@ let dialog = $ref(null);
let name: string = $ref(props.emoji.name);
let category: string = $ref(props.emoji.category);
let aliases: string = $ref(props.emoji.aliases.join(' '));
let categories: string[] = $ref(emojiCategories);
const categories = getCustomEmojiCategories();
const emit = defineEmits<{
(ev: 'done', v: { deleted?: boolean, updated?: any }): void,

View File

@@ -0,0 +1,123 @@
<template>
<div>
<MkStickyContainer>
<template #header><XHeader :actions="headerActions"/></template>
<MkSpacer :content-max="900">
<div class="taeiyrib">
<div class="query">
<MkInput v-model="host" :debounce="true" class="">
<template #prefix><i class="ti ti-search"></i></template>
<template #label>{{ i18n.ts.host }}</template>
</MkInput>
<FormSplit style="margin-top: var(--margin);">
<MkSelect v-model="state">
<template #label>{{ i18n.ts.state }}</template>
<option value="all">{{ i18n.ts.all }}</option>
<option value="federating">{{ i18n.ts.federating }}</option>
<option value="subscribing">{{ i18n.ts.subscribing }}</option>
<option value="publishing">{{ i18n.ts.publishing }}</option>
<option value="suspended">{{ i18n.ts.suspended }}</option>
<option value="blocked">{{ i18n.ts.blocked }}</option>
<option value="notResponding">{{ i18n.ts.notResponding }}</option>
</MkSelect>
<MkSelect v-model="sort">
<template #label>{{ i18n.ts.sort }}</template>
<option value="+pubSub">{{ i18n.ts.pubSub }} ({{ i18n.ts.descendingOrder }})</option>
<option value="-pubSub">{{ i18n.ts.pubSub }} ({{ i18n.ts.ascendingOrder }})</option>
<option value="+notes">{{ i18n.ts.notes }} ({{ i18n.ts.descendingOrder }})</option>
<option value="-notes">{{ i18n.ts.notes }} ({{ i18n.ts.ascendingOrder }})</option>
<option value="+users">{{ i18n.ts.users }} ({{ i18n.ts.descendingOrder }})</option>
<option value="-users">{{ i18n.ts.users }} ({{ i18n.ts.ascendingOrder }})</option>
<option value="+following">{{ i18n.ts.following }} ({{ i18n.ts.descendingOrder }})</option>
<option value="-following">{{ i18n.ts.following }} ({{ i18n.ts.ascendingOrder }})</option>
<option value="+followers">{{ i18n.ts.followers }} ({{ i18n.ts.descendingOrder }})</option>
<option value="-followers">{{ i18n.ts.followers }} ({{ i18n.ts.ascendingOrder }})</option>
<option value="+caughtAt">{{ i18n.ts.registeredAt }} ({{ i18n.ts.descendingOrder }})</option>
<option value="-caughtAt">{{ i18n.ts.registeredAt }} ({{ i18n.ts.ascendingOrder }})</option>
</MkSelect>
</FormSplit>
</div>
<MkPagination v-slot="{items}" ref="instances" :key="host + state" :pagination="pagination">
<div class="dqokceoj">
<MkA v-for="instance in items" :key="instance.id" v-tooltip.mfm="`Status: ${getStatus(instance)}`" class="instance" :to="`/instance-info/${instance.host}`">
<MkInstanceCardMini :instance="instance"/>
</MkA>
</div>
</MkPagination>
</div>
</MkSpacer>
</MkStickyContainer>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import XHeader from './_header_.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkPagination from '@/components/MkPagination.vue';
import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue';
import FormSplit from '@/components/form/split.vue';
import * as os from '@/os';
import { i18n } from '@/i18n';
import { dateString } from '@/filters/date';
import { definePageMetadata } from '@/scripts/page-metadata';
let host = $ref('');
let state = $ref('federating');
let sort = $ref('+pubSub');
const pagination = {
endpoint: 'federation/instances' as const,
limit: 10,
offsetMode: true,
params: computed(() => ({
sort: sort,
host: host !== '' ? host : null,
...(
state === 'federating' ? { federating: true } :
state === 'subscribing' ? { subscribing: true } :
state === 'publishing' ? { publishing: true } :
state === 'suspended' ? { suspended: true } :
state === 'blocked' ? { blocked: true } :
state === 'notResponding' ? { notResponding: true } :
{}),
})),
};
function getStatus(instance) {
if (instance.isSuspended) return 'Suspended';
if (instance.isBlocked) return 'Blocked';
if (instance.isNotResponding) return 'Error';
return 'Alive';
}
const headerActions = $computed(() => []);
const headerTabs = $computed(() => []);
definePageMetadata(computed(() => ({
title: i18n.ts.federation,
icon: 'ti ti-whirl',
})));
</script>
<style lang="scss" scoped>
.taeiyrib {
> .query {
background: var(--bg);
margin-bottom: 16px;
}
}
.dqokceoj {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
grid-gap: 12px;
> .instance:hover {
text-decoration: none;
}
}
</style>

View File

@@ -104,7 +104,7 @@ const menuDef = $computed(() => [{
}, {
icon: 'ti ti-whirl',
text: i18n.ts.federation,
to: '/about#federation',
to: '/admin/federation',
active: currentPage?.route.name === 'federation',
}, {
icon: 'ti ti-clock-play',

View File

@@ -4,24 +4,24 @@
<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
<FormSuspense :p="init">
<div class="_gaps_m">
<FormFolder>
<MkFolder>
<template #icon><i class="ti ti-brand-twitter"></i></template>
<template #label>Twitter</template>
<template #suffix>{{ enableTwitterIntegration ? i18n.ts.enabled : i18n.ts.disabled }}</template>
<XTwitter/>
</FormFolder>
<FormFolder>
</MkFolder>
<MkFolder>
<template #icon><i class="ti ti-brand-github"></i></template>
<template #label>GitHub</template>
<template #suffix>{{ enableGithubIntegration ? i18n.ts.enabled : i18n.ts.disabled }}</template>
<XGithub/>
</FormFolder>
<FormFolder>
</MkFolder>
<MkFolder>
<template #icon><i class="ti ti-brand-discord"></i></template>
<template #label>Discord</template>
<template #suffix>{{ enableDiscordIntegration ? i18n.ts.enabled : i18n.ts.disabled }}</template>
<XDiscord/>
</FormFolder>
</MkFolder>
</div>
</FormSuspense>
</MkSpacer>
@@ -34,7 +34,7 @@ import XTwitter from './integrations.twitter.vue';
import XGithub from './integrations.github.vue';
import XDiscord from './integrations.discord.vue';
import FormSuspense from '@/components/form/suspense.vue';
import FormFolder from '@/components/form/folder.vue';
import MkFolder from '@/components/MkFolder.vue';
import * as os from '@/os';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';

View File

@@ -36,7 +36,7 @@
<div class="icon"><i class="ti ti-icons"></i></div>
<div class="body">
<div class="value">
<MkNumber :value="$instance.emojis.length" style="margin-right: 0.5em;"/>
<MkNumber :value="customEmojis.length" style="margin-right: 0.5em;"/>
</div>
<div class="label">Custom emojis</div>
</div>
@@ -63,6 +63,7 @@ import number from '@/filters/number';
import MkNumberDiff from '@/components/MkNumberDiff.vue';
import MkNumber from '@/components/MkNumber.vue';
import { i18n } from '@/i18n';
import { customEmojis } from '@/custom-emojis';
let stats: any = $ref(null);
let usersComparedToThePrevDay = $ref<number>();

View File

@@ -1,60 +1,60 @@
<template>
<MkSpacer :content-max="1000">
<div ref="rootEl" class="edbbcaef">
<MkFolder class="item">
<MkFoldableSection class="item">
<template #header>Stats</template>
<XStats/>
</MkFolder>
</MkFoldableSection>
<MkFolder class="item">
<MkFoldableSection class="item">
<template #header>Active users</template>
<XActiveUsers/>
</MkFolder>
</MkFoldableSection>
<MkFolder class="item">
<MkFoldableSection class="item">
<template #header>Heatmap</template>
<XHeatmap/>
</MkFolder>
</MkFoldableSection>
<MkFolder class="item">
<MkFoldableSection class="item">
<template #header>Retention rate</template>
<XRetention/>
</MkFolder>
</MkFoldableSection>
<MkFolder class="item">
<MkFoldableSection class="item">
<template #header>Moderators</template>
<XModerators/>
</MkFolder>
</MkFoldableSection>
<MkFolder class="item">
<MkFoldableSection class="item">
<template #header>Federation</template>
<XFederation/>
</MkFolder>
</MkFoldableSection>
<MkFolder class="item">
<MkFoldableSection class="item">
<template #header>Instances</template>
<XInstances/>
</MkFolder>
</MkFoldableSection>
<MkFolder class="item">
<MkFoldableSection class="item">
<template #header>Ap requests</template>
<XApRequests/>
</MkFolder>
</MkFoldableSection>
<MkFolder class="item">
<MkFoldableSection class="item">
<template #header>New users</template>
<XUsers/>
</MkFolder>
</MkFoldableSection>
<MkFolder class="item">
<MkFoldableSection class="item">
<template #header>Deliver queue</template>
<XQueue domain="deliver"/>
</MkFolder>
</MkFoldableSection>
<MkFolder class="item">
<MkFoldableSection class="item">
<template #header>Inbox queue</template>
<XQueue domain="inbox"/>
</MkFolder>
</MkFoldableSection>
</div>
</MkSpacer>
</template>
@@ -79,7 +79,7 @@ import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
import { defaultStore } from '@/store';
import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
const rootEl = $shallowRef<HTMLElement>();
let serverInfo: any = $ref(null);

View File

@@ -1,12 +1,10 @@
<template>
<div class="pumxzjhg">
<div class="_table status">
<div class="_row">
<div class="_cell"><div class="_label">Process</div>{{ number(activeSincePrevTick) }}</div>
<div class="_cell"><div class="_label">Active</div>{{ number(active) }}</div>
<div class="_cell"><div class="_label">Waiting</div>{{ number(waiting) }}</div>
<div class="_cell"><div class="_label">Delayed</div>{{ number(delayed) }}</div>
</div>
<div class="pumxzjhg _gaps">
<div :class="$style.status">
<div class="item _panel"><div class="label">Process</div>{{ number(activeSincePrevTick) }}</div>
<div class="item _panel"><div class="label">Active</div>{{ number(active) }}</div>
<div class="item _panel"><div class="label">Waiting</div>{{ number(waiting) }}</div>
<div class="item _panel"><div class="label">Delayed</div>{{ number(delayed) }}</div>
</div>
<div class="charts">
<div class="chart">
@@ -26,15 +24,21 @@
<XChart ref="chartWaiting" type="waiting"/>
</div>
</div>
<div class="jobs">
<div v-if="jobs.length > 0">
<div v-for="job in jobs" :key="job[0]">
<span>{{ job[0] }}</span>
<span style="margin-left: 8px; opacity: 0.7;">({{ number(job[1]) }} jobs)</span>
<MkFolder :default-open="true" :max-height="250">
<template #icon><i class="ti ti-alert-triangle"></i></template>
<template #label>Errored instances</template>
<template #suffix>({{ number(jobs.reduce((a, b) => a + b[1], 0)) }} jobs)</template>
<div :class="$style.jobs">
<div v-if="jobs.length > 0">
<div v-for="job in jobs" :key="job[0]">
<MkA :to="`/instance-info/${job[0]}`" behavior="window">{{ job[0] }}</MkA>
<span style="margin-left: 8px; opacity: 0.7;">({{ number(job[1]) }} jobs)</span>
</div>
</div>
<span v-else style="opacity: 0.5;">{{ i18n.ts.noJobs }}</span>
</div>
<span v-else style="opacity: 0.5;">{{ i18n.ts.noJobs }}</span>
</div>
</MkFolder>
</div>
</template>
@@ -45,6 +49,7 @@ import number from '@/filters/number';
import * as os from '@/os';
import { stream } from '@/stream';
import { i18n } from '@/i18n';
import MkFolder from '@/components/MkFolder.vue';
const connection = markRaw(stream.useChannel('queueStats'));
@@ -115,14 +120,10 @@ onUnmounted(() => {
<style lang="scss" scoped>
.pumxzjhg {
> .status {
padding: 16px;
}
> .charts {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
gap: 10px;
> .chart {
min-width: 0;
@@ -135,15 +136,27 @@ onUnmounted(() => {
}
}
}
> .jobs {
margin-top: 16px;
padding: 16px;
max-height: 180px;
overflow: auto;
background: var(--panel);
border-radius: var(--radius);
}
}
</style>
<style lang="scss" module>
.status {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
grid-gap: 10px;
&:global {
> .item {
padding: 12px 16px;
> .label {
font-size: 80%;
opacity: 0.6;
}
}
}
}
.jobs {
}
</style>

View File

@@ -2,15 +2,17 @@
<MkStickyContainer>
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :content-max="800">
<div v-for="relay in relays" :key="relay.inbox" class="relaycxt _panel" style="padding: 16px;">
<div>{{ relay.inbox }}</div>
<div class="status">
<i v-if="relay.status === 'accepted'" class="ti ti-check icon accepted"></i>
<i v-else-if="relay.status === 'rejected'" class="ti ti-ban icon rejected"></i>
<i v-else class="ti ti-clock icon requesting"></i>
<span>{{ $t(`_relayStatus.${relay.status}`) }}</span>
<div class="_gaps">
<div v-for="relay in relays" :key="relay.inbox" class="relaycxt _panel" style="padding: 16px;">
<div>{{ relay.inbox }}</div>
<div class="status">
<i v-if="relay.status === 'accepted'" class="ti ti-check icon accepted"></i>
<i v-else-if="relay.status === 'rejected'" class="ti ti-ban icon rejected"></i>
<i v-else class="ti ti-clock icon requesting"></i>
<span>{{ $t(`_relayStatus.${relay.status}`) }}</span>
</div>
<MkButton class="button" inline danger @click="remove(relay.inbox)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton>
</div>
<MkButton class="button" inline danger @click="remove(relay.inbox)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton>
</div>
</MkSpacer>
</MkStickyContainer>

View File

@@ -4,7 +4,7 @@
<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
<FormSuspense :p="init">
<div class="_gaps_m">
<FormFolder>
<MkFolder>
<template #icon><i class="ti ti-shield"></i></template>
<template #label>{{ i18n.ts.botProtection }}</template>
<template v-if="enableHcaptcha" #suffix>hCaptcha</template>
@@ -13,9 +13,9 @@
<template v-else #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</template>
<XBotProtection/>
</FormFolder>
</MkFolder>
<FormFolder>
<MkFolder>
<template #icon><i class="ti ti-eye-off"></i></template>
<template #label>{{ i18n.ts.sensitiveMediaDetection }}</template>
<template v-if="sensitiveMediaDetection === 'all'" #suffix>{{ i18n.ts.all }}</template>
@@ -56,9 +56,9 @@
<MkButton primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
</div>
</FormFolder>
</MkFolder>
<FormFolder>
<MkFolder>
<template #label>Active Email Validation</template>
<template v-if="enableActiveEmailValidation" #suffix>Enabled</template>
<template v-else #suffix>Disabled</template>
@@ -69,9 +69,9 @@
<template #label>Enable</template>
</MkSwitch>
</div>
</FormFolder>
</MkFolder>
<FormFolder>
<MkFolder>
<template #label>Log IP address</template>
<template v-if="enableIpLogging" #suffix>Enabled</template>
<template v-else #suffix>Disabled</template>
@@ -81,9 +81,9 @@
<template #label>Enable</template>
</MkSwitch>
</div>
</FormFolder>
</MkFolder>
<FormFolder>
<MkFolder>
<template #label>Summaly Proxy</template>
<div class="_gaps_m">
@@ -94,7 +94,7 @@
<MkButton primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
</div>
</FormFolder>
</MkFolder>
</div>
</FormSuspense>
</MkSpacer>
@@ -105,7 +105,7 @@
import { } from 'vue';
import XBotProtection from './bot-protection.vue';
import XHeader from './_header_.vue';
import FormFolder from '@/components/form/folder.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkRadios from '@/components/MkRadios.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import FormInfo from '@/components/MkInfo.vue';

View File

@@ -53,14 +53,14 @@ definePageMetadata({
<style lang="scss" scoped>
.ruryvtyk {
> .announcement {
padding: 16px;
> .header {
padding: 16px;
margin-bottom: 16px;
font-weight: bold;
}
> .content {
padding: 0 16px;
> img {
display: block;
max-height: 300px;
@@ -69,7 +69,7 @@ definePageMetadata({
}
> .footer {
padding: 16px;
margin-top: 16px;
}
}
}

View File

@@ -0,0 +1,24 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader/></template>
<MkSpacer :content-max="800">
<MkClickerGame/>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import MkClickerGame from '@/components/MkClickerGame.vue';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
definePageMetadata({
title: '🍪👈',
icon: 'ti ti-cookie',
});
</script>
<style lang="scss" module>
</style>

View File

@@ -1,6 +1,6 @@
<template>
<button class="zuvgdzyu _button" @click="menu">
<img :src="`/emoji/${emoji.name}.webp`" class="img" :alt="emoji.name"/>
<img :src="`/emoji/${emoji.name}.webp`" class="img" loading="lazy"/>
<div class="body">
<div class="name _monospace">{{ emoji.name }}</div>
<div class="info">{{ emoji.aliases.join(' ') }}</div>
@@ -49,6 +49,7 @@ function menu(ev) {
> .img {
width: 42px;
height: 42px;
object-fit: contain;
}
> .body {

View File

@@ -6,52 +6,52 @@
</MkTab>
<div v-if="origin === 'local'">
<template v-if="tag == null">
<MkFolder class="_margin" persist-key="explore-pinned-users">
<MkFoldableSection class="_margin" persist-key="explore-pinned-users">
<template #header><i class="fas fa-bookmark ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.pinnedUsers }}</template>
<XUserList :pagination="pinnedUsers"/>
</MkFolder>
<MkFolder class="_margin" persist-key="explore-popular-users">
</MkFoldableSection>
<MkFoldableSection class="_margin" persist-key="explore-popular-users">
<template #header><i class="fas fa-chart-line ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularUsers }}</template>
<XUserList :pagination="popularUsers"/>
</MkFolder>
<MkFolder class="_margin" persist-key="explore-recently-updated-users">
</MkFoldableSection>
<MkFoldableSection class="_margin" persist-key="explore-recently-updated-users">
<template #header><i class="fas fa-comment-alt ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyUpdatedUsers }}</template>
<XUserList :pagination="recentlyUpdatedUsers"/>
</MkFolder>
<MkFolder class="_margin" persist-key="explore-recently-registered-users">
</MkFoldableSection>
<MkFoldableSection class="_margin" persist-key="explore-recently-registered-users">
<template #header><i class="ti ti-plus ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyRegisteredUsers }}</template>
<XUserList :pagination="recentlyRegisteredUsers"/>
</MkFolder>
</MkFoldableSection>
</template>
</div>
<div v-else>
<MkFolder ref="tagsEl" :foldable="true" :expanded="false" class="_margin">
<MkFoldableSection ref="tagsEl" :foldable="true" :expanded="false" class="_margin">
<template #header><i class="ti ti-hash ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularTags }}</template>
<div class="vxjfqztj">
<MkA v-for="tag in tagsLocal" :key="'local:' + tag.tag" :to="`/explore/tags/${tag.tag}`" class="local">{{ tag.tag }}</MkA>
<MkA v-for="tag in tagsRemote" :key="'remote:' + tag.tag" :to="`/explore/tags/${tag.tag}`">{{ tag.tag }}</MkA>
</div>
</MkFolder>
</MkFoldableSection>
<MkFolder v-if="tag != null" :key="`${tag}`" class="_margin">
<MkFoldableSection v-if="tag != null" :key="`${tag}`" class="_margin">
<template #header><i class="ti ti-hash ti-fw" style="margin-right: 0.5em;"></i>{{ tag }}</template>
<XUserList :pagination="tagUsers"/>
</MkFolder>
</MkFoldableSection>
<template v-if="tag == null">
<MkFolder class="_margin">
<MkFoldableSection class="_margin">
<template #header><i class="fas fa-chart-line ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularUsers }}</template>
<XUserList :pagination="popularUsersF"/>
</MkFolder>
<MkFolder class="_margin">
</MkFoldableSection>
<MkFoldableSection class="_margin">
<template #header><i class="fas fa-comment-alt ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyUpdatedUsers }}</template>
<XUserList :pagination="recentlyUpdatedUsersF"/>
</MkFolder>
<MkFolder class="_margin">
</MkFoldableSection>
<MkFoldableSection class="_margin">
<template #header><i class="fas fa-rocket ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyDiscoveredUsers }}</template>
<XUserList :pagination="recentlyRegisteredUsersF"/>
</MkFolder>
</MkFoldableSection>
</template>
</div>
</MkSpacer>
@@ -60,7 +60,7 @@
<script lang="ts" setup>
import { computed, watch } from 'vue';
import XUserList from '@/components/MkUserList.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
import MkTab from '@/components/MkTab.vue';
import number from '@/filters/number';
import * as os from '@/os';
@@ -72,7 +72,7 @@ const props = defineProps<{
}>();
let origin = $ref('local');
let tagsEl = $shallowRef<InstanceType<typeof MkFolder>>();
let tagsEl = $shallowRef<InstanceType<typeof MkFoldableSection>>();
let tagsLocal = $ref([]);
let tagsRemote = $ref([]);

View File

@@ -33,7 +33,7 @@
import { computed, watch } from 'vue';
import XFeatured from './explore.featured.vue';
import XUsers from './explore.users.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
import MkInput from '@/components/MkInput.vue';
import MkRadios from '@/components/MkRadios.vue';
import number from '@/filters/number';
@@ -51,7 +51,7 @@ const props = withDefaults(defineProps<{
});
let tab = $ref(props.initialTab);
let tagsEl = $shallowRef<InstanceType<typeof MkFolder>>();
let tagsEl = $shallowRef<InstanceType<typeof MkFoldableSection>>();
let searchQuery = $ref(null);
let searchOrigin = $ref('combined');

Some files were not shown because too many files have changed in this diff Show More