forked from woem.men/forkey
Merge remote-tracking branch 'origin/feature/approval' into feature/approval
This commit is contained in:
commit
efe20a4fe8
37 changed files with 598 additions and 100 deletions
|
@ -317,6 +317,7 @@ lightThemes: "Light themes"
|
||||||
darkThemes: "Dark themes"
|
darkThemes: "Dark themes"
|
||||||
syncDeviceDarkMode: "Sync Dark Mode with your device settings"
|
syncDeviceDarkMode: "Sync Dark Mode with your device settings"
|
||||||
drive: "Drive"
|
drive: "Drive"
|
||||||
|
driveSearchbarPlaceholder: "Search drive"
|
||||||
fileName: "Filename"
|
fileName: "Filename"
|
||||||
selectFile: "Select a file"
|
selectFile: "Select a file"
|
||||||
selectFiles: "Select files"
|
selectFiles: "Select files"
|
||||||
|
@ -530,6 +531,7 @@ mediaListWithOneImageAppearance: "Height of media lists with one image only"
|
||||||
limitTo: "Limit to {x}"
|
limitTo: "Limit to {x}"
|
||||||
noFollowRequests: "You don't have any pending follow requests"
|
noFollowRequests: "You don't have any pending follow requests"
|
||||||
openImageInNewTab: "Open images in new tab"
|
openImageInNewTab: "Open images in new tab"
|
||||||
|
warnForMissingAltText: "Warn you when you forget to put alt text"
|
||||||
dashboard: "Dashboard"
|
dashboard: "Dashboard"
|
||||||
local: "Local"
|
local: "Local"
|
||||||
remote: "Remote"
|
remote: "Remote"
|
||||||
|
@ -1054,6 +1056,9 @@ thisPostMayBeAnnoying: "This note may annoy others."
|
||||||
thisPostMayBeAnnoyingHome: "Post to home timeline"
|
thisPostMayBeAnnoyingHome: "Post to home timeline"
|
||||||
thisPostMayBeAnnoyingCancel: "Cancel"
|
thisPostMayBeAnnoyingCancel: "Cancel"
|
||||||
thisPostMayBeAnnoyingIgnore: "Post anyway"
|
thisPostMayBeAnnoyingIgnore: "Post anyway"
|
||||||
|
thisPostIsMissingAltTextCancel: "Cancel"
|
||||||
|
thisPostIsMissingAltTextIgnore: "Post anyway"
|
||||||
|
thisPostIsMissingAltText: "One of the files attached to this post is missing alt text. Please ensure all the attachments have alt text."
|
||||||
collapseRenotes: "Collapse renotes you've already seen"
|
collapseRenotes: "Collapse renotes you've already seen"
|
||||||
internalServerError: "Internal Server Error"
|
internalServerError: "Internal Server Error"
|
||||||
internalServerErrorDescription: "The server has run into an unexpected error."
|
internalServerErrorDescription: "The server has run into an unexpected error."
|
||||||
|
|
16
locales/index.d.ts
vendored
16
locales/index.d.ts
vendored
|
@ -1292,6 +1292,10 @@ export interface Locale extends ILocale {
|
||||||
* ドライブ
|
* ドライブ
|
||||||
*/
|
*/
|
||||||
"drive": string;
|
"drive": string;
|
||||||
|
/**
|
||||||
|
* 検索ドライブ
|
||||||
|
*/
|
||||||
|
"driveSearchbarPlaceholder": string;
|
||||||
/**
|
/**
|
||||||
* ファイル名
|
* ファイル名
|
||||||
*/
|
*/
|
||||||
|
@ -4239,6 +4243,18 @@ export interface Locale extends ILocale {
|
||||||
* このまま投稿
|
* このまま投稿
|
||||||
*/
|
*/
|
||||||
"thisPostMayBeAnnoyingIgnore": string;
|
"thisPostMayBeAnnoyingIgnore": string;
|
||||||
|
/**
|
||||||
|
* やめる
|
||||||
|
*/
|
||||||
|
"thisPostIsMissingAltTextCancel": string;
|
||||||
|
/**
|
||||||
|
* このまま投稿
|
||||||
|
*/
|
||||||
|
"thisPostIsMissingAltTextIgnore": string;
|
||||||
|
/**
|
||||||
|
* この投稿に添付されたファイルの 1 つに代替テキストがありません。すべての添付ファイルに代替テキストが含まれていることを確認してください。
|
||||||
|
*/
|
||||||
|
"thisPostIsMissingAltText": string;
|
||||||
/**
|
/**
|
||||||
* 見たことのあるリノートを省略して表示
|
* 見たことのあるリノートを省略して表示
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -319,6 +319,7 @@ lightThemes: "明るいテーマ"
|
||||||
darkThemes: "暗いテーマ"
|
darkThemes: "暗いテーマ"
|
||||||
syncDeviceDarkMode: "デバイスのダークモードと同期する"
|
syncDeviceDarkMode: "デバイスのダークモードと同期する"
|
||||||
drive: "ドライブ"
|
drive: "ドライブ"
|
||||||
|
driveSearchbarPlaceholder: "検索ドライブ"
|
||||||
fileName: "ファイル名"
|
fileName: "ファイル名"
|
||||||
selectFile: "ファイルを選択"
|
selectFile: "ファイルを選択"
|
||||||
selectFiles: "ファイルを選択"
|
selectFiles: "ファイルを選択"
|
||||||
|
@ -1055,6 +1056,9 @@ thisPostMayBeAnnoying: "この投稿は迷惑になる可能性があります
|
||||||
thisPostMayBeAnnoyingHome: "ホームに投稿"
|
thisPostMayBeAnnoyingHome: "ホームに投稿"
|
||||||
thisPostMayBeAnnoyingCancel: "やめる"
|
thisPostMayBeAnnoyingCancel: "やめる"
|
||||||
thisPostMayBeAnnoyingIgnore: "このまま投稿"
|
thisPostMayBeAnnoyingIgnore: "このまま投稿"
|
||||||
|
thisPostIsMissingAltTextCancel: "やめる"
|
||||||
|
thisPostIsMissingAltTextIgnore: "このまま投稿"
|
||||||
|
thisPostIsMissingAltText: "この投稿に添付されたファイルの 1 つに代替テキストがありません。すべての添付ファイルに代替テキストが含まれていることを確認してください。"
|
||||||
collapseRenotes: "見たことのあるリノートを省略して表示"
|
collapseRenotes: "見たことのあるリノートを省略して表示"
|
||||||
internalServerError: "サーバー内部エラー"
|
internalServerError: "サーバー内部エラー"
|
||||||
internalServerErrorDescription: "サーバー内部で予期しないエラーが発生しました。"
|
internalServerErrorDescription: "サーバー内部で予期しないエラーが発生しました。"
|
||||||
|
|
BIN
packages/backend/assets/transparent.png
Normal file
BIN
packages/backend/assets/transparent.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 68 B |
|
@ -140,6 +140,7 @@ import { ApQuestionService } from './activitypub/models/ApQuestionService.js';
|
||||||
import { QueueModule } from './QueueModule.js';
|
import { QueueModule } from './QueueModule.js';
|
||||||
import { QueueService } from './QueueService.js';
|
import { QueueService } from './QueueService.js';
|
||||||
import { LoggerService } from './LoggerService.js';
|
import { LoggerService } from './LoggerService.js';
|
||||||
|
import { MastodonUserConverterService } from './mastodon/MastodonUserConverterService.js';
|
||||||
import type { Provider } from '@nestjs/common';
|
import type { Provider } from '@nestjs/common';
|
||||||
|
|
||||||
//#region 文字列ベースでのinjection用(循環参照対応のため)
|
//#region 文字列ベースでのinjection用(循環参照対応のため)
|
||||||
|
@ -422,6 +423,9 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
ApQuestionService,
|
ApQuestionService,
|
||||||
QueueService,
|
QueueService,
|
||||||
|
|
||||||
|
// Mastodon
|
||||||
|
MastodonUserConverterService,
|
||||||
|
|
||||||
//#region 文字列ベースでのinjection用(循環参照対応のため)
|
//#region 文字列ベースでのinjection用(循環参照対応のため)
|
||||||
$LoggerService,
|
$LoggerService,
|
||||||
$AccountMoveService,
|
$AccountMoveService,
|
||||||
|
@ -696,6 +700,9 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
ApQuestionService,
|
ApQuestionService,
|
||||||
QueueService,
|
QueueService,
|
||||||
|
|
||||||
|
// Mastodon
|
||||||
|
MastodonUserConverterService,
|
||||||
|
|
||||||
//#region 文字列ベースでのinjection用(循環参照対応のため)
|
//#region 文字列ベースでのinjection用(循環参照対応のため)
|
||||||
$LoggerService,
|
$LoggerService,
|
||||||
$AccountMoveService,
|
$AccountMoveService,
|
||||||
|
|
|
@ -0,0 +1,208 @@
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import * as mfm from 'mfm-js';
|
||||||
|
import { MiLocalUser, MiUser } from '@/models/User.js';
|
||||||
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import type { Config } from '@/config.js';
|
||||||
|
import type { NotesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
|
||||||
|
import { extractMentions } from '@/misc/extract-mentions.js';
|
||||||
|
import { IMentionedRemoteUsers } from '@/models/Note.js';
|
||||||
|
import { UserEntityService, UserRelation } from '../entities/UserEntityService.js';
|
||||||
|
import { RoleService } from '../RoleService.js';
|
||||||
|
import { MfmService } from '../MfmService.js';
|
||||||
|
import { CustomEmojiService } from '../CustomEmojiService.js';
|
||||||
|
import { ApPersonService } from '../activitypub/models/ApPersonService.js';
|
||||||
|
import { IdService } from '../IdService.js';
|
||||||
|
|
||||||
|
export type Account = {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
acct: string;
|
||||||
|
url: string;
|
||||||
|
uri: string; // undocumented
|
||||||
|
display_name: string;
|
||||||
|
note: string;
|
||||||
|
avatar: string;
|
||||||
|
avatar_static: string;
|
||||||
|
header: string;
|
||||||
|
header_static: string;
|
||||||
|
locked: boolean;
|
||||||
|
fields: Field[];
|
||||||
|
emojis: Emoji[];
|
||||||
|
bot: boolean;
|
||||||
|
group: boolean;
|
||||||
|
discoverable: boolean | null;
|
||||||
|
noindex?: boolean | null;
|
||||||
|
moved?: Account | null;
|
||||||
|
suspended?: true;
|
||||||
|
limited?: true;
|
||||||
|
created_at: string;
|
||||||
|
last_status_at: string | null;
|
||||||
|
statuses_count: number;
|
||||||
|
followers_count: number;
|
||||||
|
following_count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MutedAccount = Account | {
|
||||||
|
mute_expires_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SuggestedAccount = {
|
||||||
|
source: 'staff' | 'past_interactions' | 'global',
|
||||||
|
account: Account
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Emoji = {
|
||||||
|
shortcode: string;
|
||||||
|
static_url: string;
|
||||||
|
url: string;
|
||||||
|
visible_in_picker: boolean;
|
||||||
|
category?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Field = {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
verified_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Source = {
|
||||||
|
privacy: string | null;
|
||||||
|
sensitive: boolean | null;
|
||||||
|
language: string | null;
|
||||||
|
note: string;
|
||||||
|
fields: Field[];
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MastodonUserConverterService {
|
||||||
|
public constructor(
|
||||||
|
@Inject(DI.config)
|
||||||
|
private config: Config,
|
||||||
|
|
||||||
|
@Inject(DI.usersRepository)
|
||||||
|
private usersRepository: UsersRepository,
|
||||||
|
|
||||||
|
@Inject(DI.userProfilesRepository)
|
||||||
|
private userProfilesRepository: UserProfilesRepository,
|
||||||
|
|
||||||
|
@Inject(DI.notesRepository)
|
||||||
|
private notesRepository: NotesRepository,
|
||||||
|
|
||||||
|
private userEntityService: UserEntityService,
|
||||||
|
private roleService: RoleService,
|
||||||
|
private mfmService: MfmService,
|
||||||
|
private customEmojiService: CustomEmojiService,
|
||||||
|
private apPersonService: ApPersonService,
|
||||||
|
private idService: IdService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async encode(u: MiUser, me: MiLocalUser | null): Promise<Account | null> {
|
||||||
|
return await this.#encodeInner(u, me, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async #encodeInner(u: MiUser, me: MiLocalUser | null, recurse: boolean): Promise<Account | null> {
|
||||||
|
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: u.id });
|
||||||
|
let acct = u.username;
|
||||||
|
let acctUrl = `https://${u.host ?? this.config.host}/@${u.username}`;
|
||||||
|
if (u.host) {
|
||||||
|
acct = `${u.username}@${u.host}`;
|
||||||
|
acctUrl = `https://${u.host}/@${u.username}`;
|
||||||
|
}
|
||||||
|
const meId = me ? me.id : null;
|
||||||
|
const isMe = meId === u.id;
|
||||||
|
const iAmModerator = me ? await this.roleService.isModerator(me) : false;
|
||||||
|
if (!iAmModerator && (u.isDeleted || u.isSuspended)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let relation: UserRelation | null = null;
|
||||||
|
if (meId && !isMe) {
|
||||||
|
relation = await this.userEntityService.getRelation(meId, u.id);
|
||||||
|
}
|
||||||
|
const followingCount =
|
||||||
|
(profile.followingVisibility === 'public') || isMe || iAmModerator ? u.followingCount :
|
||||||
|
(profile.followingVisibility === 'followers') && (relation && relation.isFollowing) ? u.followingCount :
|
||||||
|
0;
|
||||||
|
const followersCount =
|
||||||
|
(profile.followersVisibility === 'public') || isMe || iAmModerator ? u.followersCount :
|
||||||
|
(profile.followersVisibility === 'followers') && (relation && relation.isFollowing) ? u.followersCount :
|
||||||
|
0;
|
||||||
|
const avatarUrl = u.avatarUrl ?? this.userEntityService.getIdenticonUrl(u);
|
||||||
|
const bannerUrl = u.bannerUrl ?? `${this.config.url}/static-assets/transparent.png`;
|
||||||
|
const parsedDescription = mfm.parse(profile.description ?? '');
|
||||||
|
const parsedFields = profile.fields.map(({ name, value }) => ({ name, value, parsed: mfm.parse(value) }));
|
||||||
|
const mentions = await this.#extractMentions([...parsedDescription, ...parsedFields.flatMap(field => field.parsed)]);
|
||||||
|
const verifiedLinks = new Set(profile.verifiedLinks);
|
||||||
|
const fields = parsedFields.map(({ name, value, parsed }) => {
|
||||||
|
let verifiedAt: string | null = null;
|
||||||
|
if (verifiedLinks.has(value)) {
|
||||||
|
verifiedAt = new Date().toISOString();
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
value: this.mfmService.toHtml(parsed, mentions) ?? '',
|
||||||
|
verified_at: verifiedAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const emojis = Object.entries(await this.customEmojiService.populateEmojis(u.emojis, u.host)).map(([shortcode, url]) => ({
|
||||||
|
shortcode,
|
||||||
|
url,
|
||||||
|
static_url: url,
|
||||||
|
visible_in_picker: true,
|
||||||
|
}));
|
||||||
|
const lastStatus = await this.notesRepository.createQueryBuilder()
|
||||||
|
.where({ userId: u.id })
|
||||||
|
.orderBy('id', 'DESC')
|
||||||
|
.select('id')
|
||||||
|
.getOne();
|
||||||
|
const lastStatusAt = lastStatus ? this.idService.parse(lastStatus.id).date.toDateString() : null;
|
||||||
|
let moved = undefined;
|
||||||
|
if (recurse && u.movedToUri) {
|
||||||
|
moved = await this.#encodeInner(await this.apPersonService.resolvePerson(u.movedToUri), me, false);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: u.id,
|
||||||
|
username: u.username,
|
||||||
|
acct,
|
||||||
|
display_name: u.name ?? u.username,
|
||||||
|
locked: u.isLocked,
|
||||||
|
bot: u.isBot,
|
||||||
|
discoverable: u.isExplorable,
|
||||||
|
group: false, // unimplemented
|
||||||
|
created_at: u.createdAt.toISOString(),
|
||||||
|
note: this.mfmService.toHtml(parsedDescription, mentions) ?? '',
|
||||||
|
url: profile.url ?? acctUrl,
|
||||||
|
uri: u.uri ?? acctUrl,
|
||||||
|
avatar: avatarUrl,
|
||||||
|
avatar_static: avatarUrl,
|
||||||
|
header: bannerUrl,
|
||||||
|
header_static: bannerUrl,
|
||||||
|
followers_count: followersCount || 0,
|
||||||
|
following_count: followingCount || 0,
|
||||||
|
statuses_count: u.notesCount,
|
||||||
|
noindex: profile.noCrawle,
|
||||||
|
emojis,
|
||||||
|
last_status_at: lastStatusAt,
|
||||||
|
suspended: u.isSuspended || undefined,
|
||||||
|
fields,
|
||||||
|
moved,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async #extractMentions(nodes: mfm.MfmNode[]): Promise<IMentionedRemoteUsers> {
|
||||||
|
const mentions = extractMentions(nodes).filter(mention => mention.host !== null);
|
||||||
|
if (mentions.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
let query = this.usersRepository.createQueryBuilder('user');
|
||||||
|
for (const { username, host } of mentions) {
|
||||||
|
query = query.orWhere(
|
||||||
|
'("user"."usernameLower" = :username AND host = :host)',
|
||||||
|
{ username: username.toLowerCase(), host: host?.toLowerCase() },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return await query
|
||||||
|
.innerJoinAndSelect(this.userProfilesRepository.metadata.targetName, 'profile', '"user".id = profile."userId"')
|
||||||
|
.select(['uri', 'url', 'username', 'host'])
|
||||||
|
.getRawMany();
|
||||||
|
}
|
||||||
|
}
|
|
@ -46,10 +46,12 @@ import { UserListChannelService } from './api/stream/channels/user-list.js';
|
||||||
import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js';
|
import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js';
|
||||||
import { ReversiChannelService } from './api/stream/channels/reversi.js';
|
import { ReversiChannelService } from './api/stream/channels/reversi.js';
|
||||||
import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js';
|
import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js';
|
||||||
|
import { MastodonEndpointsModule } from './api/MastodonEndpointsModule.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
EndpointsModule,
|
EndpointsModule,
|
||||||
|
MastodonEndpointsModule,
|
||||||
CoreModule,
|
CoreModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
|
|
|
@ -19,10 +19,11 @@ import { bindThis } from '@/decorators.js';
|
||||||
import { RoleService } from '@/core/RoleService.js';
|
import { RoleService } from '@/core/RoleService.js';
|
||||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
import { ApiError } from './error.js';
|
import { ApiError, MastodonApiError } from './error.js';
|
||||||
import { RateLimiterService } from './RateLimiterService.js';
|
import { RateLimiterService } from './RateLimiterService.js';
|
||||||
import { ApiLoggerService } from './ApiLoggerService.js';
|
import { ApiLoggerService } from './ApiLoggerService.js';
|
||||||
import { AuthenticateService, AuthenticationError } from './AuthenticateService.js';
|
import { AuthenticateService, AuthenticationError } from './AuthenticateService.js';
|
||||||
|
import { IMastodonEndpoint } from './mastodon-endpoints.js';
|
||||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||||
import type { IEndpointMeta, IEndpoint } from './endpoints.js';
|
import type { IEndpointMeta, IEndpoint } from './endpoints.js';
|
||||||
|
@ -182,6 +183,32 @@ export class ApiCallService implements OnApplicationShutdown {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async handleMastodonRequest(
|
||||||
|
endpoint: IMastodonEndpoint & { exec: any },
|
||||||
|
request: FastifyRequest<{ Body: Record<string, unknown> | undefined, Querystring: Record<string, unknown> }>,
|
||||||
|
reply: FastifyReply,
|
||||||
|
) {
|
||||||
|
const body = request.method === 'GET'
|
||||||
|
? request.query
|
||||||
|
: request.body;
|
||||||
|
const token = request.headers.authorization?.startsWith('Bearer ')
|
||||||
|
? request.headers.authorization.slice(7)
|
||||||
|
: null;
|
||||||
|
const [user] = await this.authenticateService.authenticate(token);
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await endpoint.exec(body, user, token, null, request.ip, request.headers);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof MastodonApiError) {
|
||||||
|
reply.status(e.httpStatusCode).send({ error: e.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
reply.send(res);
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private send(reply: FastifyReply, x?: any, y?: ApiError) {
|
private send(reply: FastifyReply, x?: any, y?: ApiError) {
|
||||||
if (x == null) {
|
if (x == null) {
|
||||||
|
|
|
@ -17,6 +17,7 @@ import endpoints from './endpoints.js';
|
||||||
import { ApiCallService } from './ApiCallService.js';
|
import { ApiCallService } from './ApiCallService.js';
|
||||||
import { SignupApiService } from './SignupApiService.js';
|
import { SignupApiService } from './SignupApiService.js';
|
||||||
import { SigninApiService } from './SigninApiService.js';
|
import { SigninApiService } from './SigninApiService.js';
|
||||||
|
import { IMastodonEndpoint, mastodonEndpoints } from './mastodon-endpoints.js';
|
||||||
import type { FastifyInstance, FastifyPluginOptions } from 'fastify';
|
import type { FastifyInstance, FastifyPluginOptions } from 'fastify';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
@ -105,6 +106,32 @@ export class ApiServerService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createEndpoint = (endpoint: IMastodonEndpoint): IMastodonEndpoint & { exec: any } => ({
|
||||||
|
name: endpoint.name,
|
||||||
|
method: endpoint.method,
|
||||||
|
meta: endpoint.meta,
|
||||||
|
params: endpoint.params,
|
||||||
|
exec: this.moduleRef.get(`mep:${endpoint.method}:${endpoint.name}`, { strict: false }).exec,
|
||||||
|
});
|
||||||
|
const groupedMastodonEndpoints = Array.from(Map.groupBy(mastodonEndpoints.map(createEndpoint), endpoint => endpoint.name))
|
||||||
|
.map(([name, endpoints]) => ({ name, endpoints: new Map(endpoints.map(endpoint => [endpoint.method, endpoint])) }));
|
||||||
|
for (const { name, endpoints } of groupedMastodonEndpoints) {
|
||||||
|
fastify.all<{
|
||||||
|
Params: { endpoint: string; },
|
||||||
|
Body: Record<string, unknown>,
|
||||||
|
Querystring: Record<string, unknown>,
|
||||||
|
}>('/' + name, async (request, reply) => {
|
||||||
|
const ep = endpoints.get(request.method);
|
||||||
|
if (!ep) {
|
||||||
|
reply.code(405);
|
||||||
|
reply.send();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.apiCallService.handleMastodonRequest(ep, request, reply);
|
||||||
|
return reply;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fastify.post<{
|
fastify.post<{
|
||||||
Body: {
|
Body: {
|
||||||
username: string;
|
username: string;
|
||||||
|
|
19
packages/backend/src/server/api/MastodonEndpointsModule.ts
Normal file
19
packages/backend/src/server/api/MastodonEndpointsModule.ts
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import { Module, Provider } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { CoreModule } from '@/core/CoreModule.js';
|
||||||
|
import * as mep___accounts_lookup_v1_get from './mastodon/accounts/lookup/v1/get.js';
|
||||||
|
|
||||||
|
const $accounts_lookup_v1_get: Provider = { provide: 'mep:GET:v1/accounts/lookup', useClass: mep___accounts_lookup_v1_get.default };
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
CoreModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
$accounts_lookup_v1_get,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
$accounts_lookup_v1_get,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class MastodonEndpointsModule {}
|
22
packages/backend/src/server/api/ajv.ts
Normal file
22
packages/backend/src/server/api/ajv.ts
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import _Ajv from 'ajv';
|
||||||
|
|
||||||
|
const Ajv = _Ajv.default;
|
||||||
|
|
||||||
|
const ajv = new Ajv({
|
||||||
|
useDefaults: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
ajv.addFormat('misskey:id', /^[a-zA-Z0-9]+$/);
|
||||||
|
ajv.addFormat('url', {
|
||||||
|
type: 'string',
|
||||||
|
validate: (url: string) => {
|
||||||
|
try {
|
||||||
|
new URL(url);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export { ajv };
|
|
@ -4,32 +4,13 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import _Ajv from 'ajv';
|
|
||||||
import type { Schema, SchemaType } from '@/misc/json-schema.js';
|
import type { Schema, SchemaType } from '@/misc/json-schema.js';
|
||||||
import type { MiLocalUser } from '@/models/User.js';
|
import type { MiLocalUser } from '@/models/User.js';
|
||||||
import type { MiAccessToken } from '@/models/AccessToken.js';
|
import type { MiAccessToken } from '@/models/AccessToken.js';
|
||||||
|
import { ajv } from './ajv.js';
|
||||||
import { ApiError } from './error.js';
|
import { ApiError } from './error.js';
|
||||||
import type { IEndpointMeta } from './endpoints.js';
|
import type { IEndpointMeta } from './endpoints.js';
|
||||||
|
|
||||||
const Ajv = _Ajv.default;
|
|
||||||
|
|
||||||
const ajv = new Ajv({
|
|
||||||
useDefaults: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
ajv.addFormat('misskey:id', /^[a-zA-Z0-9]+$/);
|
|
||||||
ajv.addFormat('url', {
|
|
||||||
type: 'string',
|
|
||||||
validate: (url: string) => {
|
|
||||||
try {
|
|
||||||
new URL(url);
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export type Response = Record<string, any> | void;
|
export type Response = Record<string, any> | void;
|
||||||
|
|
||||||
type File = {
|
type File = {
|
||||||
|
|
|
@ -4,11 +4,13 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { Brackets } from 'typeorm';
|
||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
import type { DriveFilesRepository } from '@/models/_.js';
|
import type { DriveFilesRepository } from '@/models/_.js';
|
||||||
import { QueryService } from '@/core/QueryService.js';
|
import { QueryService } from '@/core/QueryService.js';
|
||||||
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
|
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import { sqlLikeEscape } from '@/misc/sql-like-escape.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
tags: ['drive'],
|
tags: ['drive'],
|
||||||
|
@ -37,6 +39,7 @@ export const paramDef = {
|
||||||
folderId: { type: 'string', format: 'misskey:id', nullable: true, default: null },
|
folderId: { type: 'string', format: 'misskey:id', nullable: true, default: null },
|
||||||
type: { type: 'string', nullable: true, pattern: /^[a-zA-Z\/\-*]+$/.toString().slice(1, -1) },
|
type: { type: 'string', nullable: true, pattern: /^[a-zA-Z\/\-*]+$/.toString().slice(1, -1) },
|
||||||
sort: { type: 'string', nullable: true, enum: ['+createdAt', '-createdAt', '+name', '-name', '+size', '-size', null] },
|
sort: { type: 'string', nullable: true, enum: ['+createdAt', '-createdAt', '+name', '-name', '+size', '-size', null] },
|
||||||
|
searchQuery: { type: 'string', default: '' },
|
||||||
},
|
},
|
||||||
required: [],
|
required: [],
|
||||||
} as const;
|
} as const;
|
||||||
|
@ -60,6 +63,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
query.andWhere('file.folderId IS NULL');
|
query.andWhere('file.folderId IS NULL');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ps.searchQuery.length > 0) {
|
||||||
|
const args = { searchQuery: `%${sqlLikeEscape(ps.searchQuery)}%` };
|
||||||
|
query.andWhere(new Brackets((qb) => {
|
||||||
|
qb
|
||||||
|
.where('file.name ILIKE :searchQuery', args)
|
||||||
|
.orWhere('file.comment ILIKE :searchQuery', args);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
if (ps.type) {
|
if (ps.type) {
|
||||||
if (ps.type.endsWith('/*')) {
|
if (ps.type.endsWith('/*')) {
|
||||||
query.andWhere('file.type like :type', { type: ps.type.replace('/*', '/') + '%' });
|
query.andWhere('file.type like :type', { type: ps.type.replace('/*', '/') + '%' });
|
||||||
|
|
|
@ -9,6 +9,7 @@ import type { DriveFoldersRepository } from '@/models/_.js';
|
||||||
import { QueryService } from '@/core/QueryService.js';
|
import { QueryService } from '@/core/QueryService.js';
|
||||||
import { DriveFolderEntityService } from '@/core/entities/DriveFolderEntityService.js';
|
import { DriveFolderEntityService } from '@/core/entities/DriveFolderEntityService.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import { sqlLikeEscape } from '@/misc/sql-like-escape.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
tags: ['drive'],
|
tags: ['drive'],
|
||||||
|
@ -35,6 +36,7 @@ export const paramDef = {
|
||||||
sinceId: { type: 'string', format: 'misskey:id' },
|
sinceId: { type: 'string', format: 'misskey:id' },
|
||||||
untilId: { type: 'string', format: 'misskey:id' },
|
untilId: { type: 'string', format: 'misskey:id' },
|
||||||
folderId: { type: 'string', format: 'misskey:id', nullable: true, default: null },
|
folderId: { type: 'string', format: 'misskey:id', nullable: true, default: null },
|
||||||
|
searchQuery: { type: 'string', default: '' },
|
||||||
},
|
},
|
||||||
required: [],
|
required: [],
|
||||||
} as const;
|
} as const;
|
||||||
|
@ -58,6 +60,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
query.andWhere('folder.parentId IS NULL');
|
query.andWhere('folder.parentId IS NULL');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ps.searchQuery.length > 0) {
|
||||||
|
query.andWhere('folder.name ILIKE :searchQuery', { searchQuery: `%${sqlLikeEscape(ps.searchQuery)}%` });
|
||||||
|
}
|
||||||
|
|
||||||
const folders = await query.limit(ps.limit).getMany();
|
const folders = await query.limit(ps.limit).getMany();
|
||||||
|
|
||||||
return await Promise.all(folders.map(folder => this.driveFolderEntityService.pack(folder)));
|
return await Promise.all(folders.map(folder => this.driveFolderEntityService.pack(folder)));
|
||||||
|
|
|
@ -23,3 +23,15 @@ export class ApiError extends Error {
|
||||||
this.info = info ?? undefined;
|
this.info = info ?? undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class MastodonApiError extends Error {
|
||||||
|
constructor(message: string | undefined, public httpStatusCode: number) {
|
||||||
|
super(message ?? 'no error message available');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MastodonNotFoundError extends MastodonApiError {
|
||||||
|
constructor() {
|
||||||
|
super('Record not found', 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
39
packages/backend/src/server/api/mastodon-endpoint-base.ts
Normal file
39
packages/backend/src/server/api/mastodon-endpoint-base.ts
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
import type { Schema, SchemaType } from '@/misc/json-schema.js';
|
||||||
|
import { MiLocalUser } from '@/models/User.js';
|
||||||
|
import { MiAccessToken } from '@/models/AccessToken.js';
|
||||||
|
import { IMastodonEndpointMeta } from './mastodon-endpoints.js';
|
||||||
|
import { ajv } from './ajv.js';
|
||||||
|
import { MastodonApiError } from './error.js';
|
||||||
|
|
||||||
|
type Executor<T extends IMastodonEndpointMeta, Ps extends Schema> =
|
||||||
|
(params: SchemaType<Ps>, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null) => Promise<unknown>;
|
||||||
|
|
||||||
|
export abstract class MastodonEndpoint<T extends IMastodonEndpointMeta, Ps extends Schema> {
|
||||||
|
public exec: (
|
||||||
|
params: SchemaType<Ps>,
|
||||||
|
user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null,
|
||||||
|
token: MiAccessToken | null,
|
||||||
|
file?: File,
|
||||||
|
ip?: string | null,
|
||||||
|
headers?: Record<string, string> | null,
|
||||||
|
) => Promise<unknown>;
|
||||||
|
|
||||||
|
constructor(meta: T, paramDef: Ps, exec: (
|
||||||
|
params: SchemaType<Ps>,
|
||||||
|
user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null,
|
||||||
|
token: MiAccessToken | null,
|
||||||
|
file?: File,
|
||||||
|
ip?: string | null,
|
||||||
|
headers?: Record<string, string> | null,
|
||||||
|
) => Promise<unknown>) {
|
||||||
|
const validate = ajv.compile(paramDef);
|
||||||
|
this.exec = (params, user, token, file, ip, headers) => {
|
||||||
|
const valid = validate(params);
|
||||||
|
if (!valid) {
|
||||||
|
const error = validate.errors![0];
|
||||||
|
throw new MastodonApiError(`${error.schemaPath}: ${error.message}`, 400);
|
||||||
|
}
|
||||||
|
return exec(params, user, token, file, ip, headers);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
30
packages/backend/src/server/api/mastodon-endpoints.ts
Normal file
30
packages/backend/src/server/api/mastodon-endpoints.ts
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import { Schema } from '@/misc/json-schema.js';
|
||||||
|
import * as mep___accounts_lookup_v1_get from './mastodon/accounts/lookup/v1/get.js';
|
||||||
|
|
||||||
|
const eps = [
|
||||||
|
['GET', 'v1/accounts/lookup', mep___accounts_lookup_v1_get],
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface IMastodonEndpointMeta {
|
||||||
|
readonly requireCredential: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMastodonEndpoint {
|
||||||
|
name: string;
|
||||||
|
method: string;
|
||||||
|
meta: IMastodonEndpointMeta;
|
||||||
|
params: Schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mastodonEndpoints: IMastodonEndpoint[] = (eps as [string, string, any]).map(([method, name, ep]) => {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
method,
|
||||||
|
get meta() {
|
||||||
|
return ep.meta;
|
||||||
|
},
|
||||||
|
get params() {
|
||||||
|
return ep.schema;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { MastodonEndpoint } from '@/server/api/mastodon-endpoint-base.js';
|
||||||
|
import * as Acct from '@/misc/acct.js';
|
||||||
|
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
|
||||||
|
import { MastodonUserConverterService } from '@/core/mastodon/MastodonUserConverterService.js';
|
||||||
|
import { MastodonNotFoundError } from '@/server/api/error.js';
|
||||||
|
|
||||||
|
export const meta = {
|
||||||
|
requireCredential: false,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const paramDef = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
acct: { type: 'string', minLength: 1 },
|
||||||
|
},
|
||||||
|
required: ['acct'],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export default class extends MastodonEndpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||||
|
constructor(
|
||||||
|
private remoteUserResolveService: RemoteUserResolveService,
|
||||||
|
private mastodonUserConverterService: MastodonUserConverterService,
|
||||||
|
) {
|
||||||
|
super(meta, paramDef, async (ps, me) => {
|
||||||
|
const { username, host } = Acct.parse(ps.acct);
|
||||||
|
let user;
|
||||||
|
try {
|
||||||
|
user = await this.remoteUserResolveService.resolveUser(username, host);
|
||||||
|
} catch (e) {
|
||||||
|
throw new MastodonNotFoundError();
|
||||||
|
}
|
||||||
|
const encoded = await this.mastodonUserConverterService.encode(user, me);
|
||||||
|
if (!encoded) {
|
||||||
|
throw new MastodonNotFoundError();
|
||||||
|
}
|
||||||
|
return encoded;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -10,7 +10,7 @@ import * as assert from 'assert';
|
||||||
// https://github.com/node-fetch/node-fetch/pull/1664
|
// https://github.com/node-fetch/node-fetch/pull/1664
|
||||||
import { Blob } from 'node-fetch';
|
import { Blob } from 'node-fetch';
|
||||||
import { MiUser } from '@/models/_.js';
|
import { MiUser } from '@/models/_.js';
|
||||||
import { api, initTestDb, post, sendEnvUpdateRequest, signup, simpleGet, uploadFile } from '../utils.js';
|
import { api, initTestDb, post, relativeFetch, sendEnvUpdateRequest, signup, simpleGet, uploadFile } from '../utils.js';
|
||||||
import type * as misskey from 'misskey-js';
|
import type * as misskey from 'misskey-js';
|
||||||
|
|
||||||
describe('Endpoints', () => {
|
describe('Endpoints', () => {
|
||||||
|
@ -1062,4 +1062,16 @@ describe('Endpoints', () => {
|
||||||
assert.strictEqual(resCarol.body.memo, memoCarolToBob);
|
assert.strictEqual(resCarol.body.memo, memoCarolToBob);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('v1/accounts/lookup', () => {
|
||||||
|
test('regular usage', async () => {
|
||||||
|
const response: any = await (await relativeFetch('api/v1/accounts/lookup?acct=alice')).json();
|
||||||
|
assert.strictEqual(response.username, 'alice');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('404', async () => {
|
||||||
|
const response = await relativeFetch('api/v1/accounts/lookup?acct=fourohfour');
|
||||||
|
assert.strictEqual(response.status, 404);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,18 +0,0 @@
|
||||||
LICENSE
|
|
||||||
-------
|
|
||||||
|
|
||||||
Copyright (c) by [さわらつき](https://github.com/SAWARATSUKI)
|
|
||||||
|
|
||||||
The following files:
|
|
||||||
|
|
||||||
- misskey-io.png
|
|
||||||
- about-icon.png
|
|
||||||
|
|
||||||
are licensed under proprietary license. To view a copy of this license, visit https://github.com/SAWARATSUKI/KawaiiLogos/blob/main/README_EN.md#license
|
|
||||||
AND SHOULD NOT be considered as part of this project, which is licensed under AGPL-3.0-only.
|
|
||||||
|
|
||||||
The following files:
|
|
||||||
|
|
||||||
- about-icon.png (uwl.png)
|
|
||||||
|
|
||||||
were copied from [misskey-dev/misskey-hub-next](https://github.com/misskey-dev/misskey-hub-next/tree/221a89760e80d75a00600c4e37e22c0f226a6017/public/img/uwu)
|
|
Binary file not shown.
Before Width: | Height: | Size: 152 KiB |
Binary file not shown.
Before Width: | Height: | Size: 157 KiB |
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
import { computed, watch, version as vueVersion, App } from 'vue';
|
import { computed, watch, version as vueVersion, App } from 'vue';
|
||||||
import { compareVersions } from 'compare-versions';
|
import { compareVersions } from 'compare-versions';
|
||||||
|
import VueGtag, { bootstrap as gtagBootstrap, GtagConsent, GtagConsentParams } from 'vue-gtag';
|
||||||
import widgets from '@/widgets/index.js';
|
import widgets from '@/widgets/index.js';
|
||||||
import directives from '@/directives/index.js';
|
import directives from '@/directives/index.js';
|
||||||
import components from '@/components/index.js';
|
import components from '@/components/index.js';
|
||||||
|
@ -24,7 +25,6 @@ import { miLocalStorage } from '@/local-storage.js';
|
||||||
import { fetchCustomEmojis } from '@/custom-emojis.js';
|
import { fetchCustomEmojis } from '@/custom-emojis.js';
|
||||||
import { setupRouter } from '@/router/definition.js';
|
import { setupRouter } from '@/router/definition.js';
|
||||||
import { mainRouter } from '@/router/main.js';
|
import { mainRouter } from '@/router/main.js';
|
||||||
import VueGtag, { bootstrap as gtagBootstrap, GtagConsent, GtagConsentParams } from 'vue-gtag';
|
|
||||||
|
|
||||||
export async function common(createVue: () => App<Element>) {
|
export async function common(createVue: () => App<Element>) {
|
||||||
console.info(`Misskey v${version}`);
|
console.info(`Misskey v${version}`);
|
||||||
|
@ -144,17 +144,6 @@ export async function common(createVue: () => App<Element>) {
|
||||||
}
|
}
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
//#region kawaii
|
|
||||||
if (params.has('kawaii') || params.has('uwu')) {
|
|
||||||
const v = params.get('kawaii') ?? params.get('uwu');
|
|
||||||
if (v === 'false' || v === '0' || v === 'no' || v === 'off') {
|
|
||||||
miLocalStorage.removeItem('kawaii');
|
|
||||||
} else {
|
|
||||||
miLocalStorage.setItem('kawaii', 'true');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
//#endregion
|
|
||||||
|
|
||||||
// NOTE: この処理は必ずクライアント更新チェック処理より後に来ること(テーマ再構築のため)
|
// NOTE: この処理は必ずクライアント更新チェック処理より後に来ること(テーマ再構築のため)
|
||||||
watch(defaultStore.reactiveState.darkMode, (darkMode) => {
|
watch(defaultStore.reactiveState.darkMode, (darkMode) => {
|
||||||
applyTheme(darkMode ? ColdDeviceStorage.get('darkTheme') : ColdDeviceStorage.get('lightTheme'));
|
applyTheme(darkMode ? ColdDeviceStorage.get('darkTheme') : ColdDeviceStorage.get('lightTheme'));
|
||||||
|
|
|
@ -30,7 +30,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<span v-if="folder != null" :class="[$style.navPathItem, $style.navSeparator]"><i class="ti ti-chevron-right"></i></span>
|
<span v-if="folder != null" :class="[$style.navPathItem, $style.navSeparator]"><i class="ti ti-chevron-right"></i></span>
|
||||||
<span v-if="folder != null" :class="[$style.navPathItem, $style.navCurrent]">{{ folder.name }}</span>
|
<span v-if="folder != null" :class="[$style.navPathItem, $style.navCurrent]">{{ folder.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="_button" :class="$style.navMenu" @click="showMenu"><i class="ti ti-dots"></i></button>
|
<div :class="$style.navMenu">
|
||||||
|
<!-- "Search drive via alt text or file names" -->
|
||||||
|
<MkInput v-model="searchQuery" :autofocus="true" type="search" :placeholder="i18n.ts.driveSearchbarPlaceholder" @enter="fetch">
|
||||||
|
<template #prefix><i class="ti ti-search"></i></template>
|
||||||
|
</MkInput>
|
||||||
|
|
||||||
|
<button class="_button" :class="$style.navMenu" @click="showMenu"><i class="ti ti-dots"></i></button>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<div
|
<div
|
||||||
ref="main"
|
ref="main"
|
||||||
|
@ -102,6 +109,7 @@ import type { MenuItem } from '@/types/menu.js';
|
||||||
import XNavFolder from '@/components/MkDrive.navFolder.vue';
|
import XNavFolder from '@/components/MkDrive.navFolder.vue';
|
||||||
import XFolder from '@/components/MkDrive.folder.vue';
|
import XFolder from '@/components/MkDrive.folder.vue';
|
||||||
import XFile from '@/components/MkDrive.file.vue';
|
import XFile from '@/components/MkDrive.file.vue';
|
||||||
|
import MkInput from '@/components/MkInput.vue';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||||
import { useStream } from '@/stream.js';
|
import { useStream } from '@/stream.js';
|
||||||
|
@ -110,6 +118,8 @@ import { i18n } from '@/i18n.js';
|
||||||
import { uploadFile, uploads } from '@/scripts/upload.js';
|
import { uploadFile, uploads } from '@/scripts/upload.js';
|
||||||
import { claimAchievement } from '@/scripts/achievements.js';
|
import { claimAchievement } from '@/scripts/achievements.js';
|
||||||
|
|
||||||
|
const searchQuery = ref('');
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
initialFolder?: Misskey.entities.DriveFolder;
|
initialFolder?: Misskey.entities.DriveFolder;
|
||||||
type?: string;
|
type?: string;
|
||||||
|
@ -540,6 +550,7 @@ async function fetch() {
|
||||||
const foldersPromise = misskeyApi('drive/folders', {
|
const foldersPromise = misskeyApi('drive/folders', {
|
||||||
folderId: folder.value ? folder.value.id : null,
|
folderId: folder.value ? folder.value.id : null,
|
||||||
limit: foldersMax + 1,
|
limit: foldersMax + 1,
|
||||||
|
searchQuery: searchQuery.value.toString().trim(),
|
||||||
}).then(fetchedFolders => {
|
}).then(fetchedFolders => {
|
||||||
if (fetchedFolders.length === foldersMax + 1) {
|
if (fetchedFolders.length === foldersMax + 1) {
|
||||||
moreFolders.value = true;
|
moreFolders.value = true;
|
||||||
|
@ -552,6 +563,7 @@ async function fetch() {
|
||||||
folderId: folder.value ? folder.value.id : null,
|
folderId: folder.value ? folder.value.id : null,
|
||||||
type: props.type,
|
type: props.type,
|
||||||
limit: filesMax + 1,
|
limit: filesMax + 1,
|
||||||
|
searchQuery: searchQuery.value.toString().trim(),
|
||||||
}).then(fetchedFiles => {
|
}).then(fetchedFiles => {
|
||||||
if (fetchedFiles.length === filesMax + 1) {
|
if (fetchedFiles.length === filesMax + 1) {
|
||||||
moreFiles.value = true;
|
moreFiles.value = true;
|
||||||
|
@ -578,6 +590,7 @@ function fetchMoreFolders() {
|
||||||
type: props.type,
|
type: props.type,
|
||||||
untilId: folders.value[folders.value.length - 1].id,
|
untilId: folders.value[folders.value.length - 1].id,
|
||||||
limit: max + 1,
|
limit: max + 1,
|
||||||
|
searchQuery: searchQuery.value.toString().trim(),
|
||||||
}).then(folders => {
|
}).then(folders => {
|
||||||
if (folders.length === max + 1) {
|
if (folders.length === max + 1) {
|
||||||
moreFolders.value = true;
|
moreFolders.value = true;
|
||||||
|
@ -601,6 +614,7 @@ function fetchMoreFiles() {
|
||||||
type: props.type,
|
type: props.type,
|
||||||
untilId: files.value[files.value.length - 1].id,
|
untilId: files.value[files.value.length - 1].id,
|
||||||
limit: max + 1,
|
limit: max + 1,
|
||||||
|
searchQuery: searchQuery.value.toString().trim(),
|
||||||
}).then(files => {
|
}).then(files => {
|
||||||
if (files.length === max + 1) {
|
if (files.length === max + 1) {
|
||||||
moreFiles.value = true;
|
moreFiles.value = true;
|
||||||
|
@ -747,8 +761,13 @@ onBeforeUnmount(() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.navMenu {
|
.navMenu {
|
||||||
|
display: flex;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
padding: 0 12px;
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navMenu > *:not(:last-child) {
|
||||||
|
padding-right: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main {
|
.main {
|
||||||
|
|
|
@ -101,7 +101,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { inject, watch, nextTick, onMounted, onUnmounted, defineAsyncComponent, provide, shallowRef, ref, computed } from 'vue';
|
import { inject, watch, nextTick, onMounted, onUnmounted, defineAsyncComponent, provide, shallowRef, ref, computed, toRaw } from 'vue';
|
||||||
import * as mfm from 'mfm-js';
|
import * as mfm from 'mfm-js';
|
||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
import insertTextAtCursor from 'insert-text-at-cursor';
|
import insertTextAtCursor from 'insert-text-at-cursor';
|
||||||
|
@ -760,6 +760,31 @@ async function post(ev?: MouseEvent) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (defaultStore.state.warnMissingAltText) {
|
||||||
|
const filesData = toRaw(files.value);
|
||||||
|
|
||||||
|
const isMissingAltText = filesData.filter(
|
||||||
|
file => file.type.startsWith('image/') || file.type.startsWith('video/') || file.type.startsWith('audio/'),
|
||||||
|
).some(file => !file.comment);
|
||||||
|
|
||||||
|
if (isMissingAltText) {
|
||||||
|
const { canceled, result } = await os.actions({
|
||||||
|
type: 'warning',
|
||||||
|
text: i18n.ts.thisPostIsMissingAltText,
|
||||||
|
actions: [{
|
||||||
|
value: 'cancel',
|
||||||
|
text: i18n.ts.thisPostIsMissingAltTextCancel,
|
||||||
|
}, {
|
||||||
|
value: 'ignore',
|
||||||
|
text: i18n.ts.thisPostIsMissingAltTextIgnore,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (canceled) return;
|
||||||
|
if (result === 'cancel') return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let postData = {
|
let postData = {
|
||||||
text: text.value === '' ? null : text.value,
|
text: text.value === '' ? null : text.value,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
|
@ -874,9 +899,9 @@ async function post(ev?: MouseEvent) {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
text: err.message + '\n' + (err as any).id,
|
text: err.message + '\n' + (err as any).id,
|
||||||
});
|
});
|
||||||
emit("postError");
|
emit('postError');
|
||||||
});
|
});
|
||||||
emit("posting");
|
emit('posting');
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancel() {
|
function cancel() {
|
||||||
|
@ -893,7 +918,7 @@ async function insertEmoji(ev: MouseEvent) {
|
||||||
os.openEmojiPicker(
|
os.openEmojiPicker(
|
||||||
(ev.currentTarget ?? ev.target) as HTMLElement,
|
(ev.currentTarget ?? ev.target) as HTMLElement,
|
||||||
{ asReactionPicker: false },
|
{ asReactionPicker: false },
|
||||||
textareaEl.value
|
textareaEl.value,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,8 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<template>
|
<template>
|
||||||
<div v-if="instance" :class="$style.root">
|
<div v-if="instance" :class="$style.root">
|
||||||
<div :class="[$style.main, $style.panel]">
|
<div :class="[$style.main, $style.panel]">
|
||||||
<img v-if="kawaiiMode" src="/client-assets/kawaii/misskey-io.png" alt="Logo by @sawaratsuki@misskey.io" :class="$style.mainIconAlt"/>
|
<img :src="instance.iconUrl || '/favicon.ico'" alt="" :class="$style.mainIcon"/>
|
||||||
<img v-else :src="instance.iconUrl || '/favicon.ico'" alt="" :class="$style.mainIcon"/>
|
|
||||||
<button class="_button _acrylic" :class="$style.mainMenu" @click="showMenu"><i class="ti ti-dots"></i></button>
|
<button class="_button _acrylic" :class="$style.mainMenu" @click="showMenu"><i class="ti ti-dots"></i></button>
|
||||||
<div :class="$style.mainFg">
|
<div :class="$style.mainFg">
|
||||||
<h1 :class="$style.mainTitle">
|
<h1 :class="$style.mainTitle">
|
||||||
|
@ -85,12 +84,10 @@ import * as os from '@/os.js';
|
||||||
import { misskeyApiGet } from '@/scripts/misskey-api.js';
|
import { misskeyApiGet } from '@/scripts/misskey-api.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { instance } from '@/instance.js';
|
import { instance } from '@/instance.js';
|
||||||
import { miLocalStorage } from '@/local-storage.js';
|
|
||||||
import MkNumber from '@/components/MkNumber.vue';
|
import MkNumber from '@/components/MkNumber.vue';
|
||||||
import XActiveUsersChart from '@/components/MkVisitorDashboard.ActiveUsersChart.vue';
|
import XActiveUsersChart from '@/components/MkVisitorDashboard.ActiveUsersChart.vue';
|
||||||
import { openInstanceMenu } from '@/ui/_common_/common';
|
import { openInstanceMenu } from '@/ui/_common_/common';
|
||||||
|
|
||||||
const kawaiiMode = miLocalStorage.getItem('kawaii') === 'true';
|
|
||||||
const stats = ref<Misskey.entities.StatsResponse | null>(null);
|
const stats = ref<Misskey.entities.StatsResponse | null>(null);
|
||||||
|
|
||||||
misskeyApiGet('stats').then((res) => {
|
misskeyApiGet('stats').then((res) => {
|
||||||
|
|
|
@ -227,6 +227,22 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven
|
||||||
}
|
}
|
||||||
return h(MkSparkle, {}, genEl(token.children, scale));
|
return h(MkSparkle, {}, genEl(token.children, scale));
|
||||||
}
|
}
|
||||||
|
case 'fade': {
|
||||||
|
// Dont run with reduced motion on
|
||||||
|
if (!defaultStore.state.animation) {
|
||||||
|
style = '';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const direction = token.props.args.out
|
||||||
|
? 'alternate-reverse'
|
||||||
|
: 'alternate';
|
||||||
|
const speed = validTime(token.props.args.speed) ?? '1.5s';
|
||||||
|
const delay = validTime(token.props.args.delay) ?? '0s';
|
||||||
|
const loop = safeParseFloat(token.props.args.loop) ?? 'infinite';
|
||||||
|
style = `animation: mfm-fade ${speed} ${delay} linear ${loop}; animation-direction: ${direction};`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'rotate': {
|
case 'rotate': {
|
||||||
const degrees = safeParseFloat(token.props.args.deg) ?? 90;
|
const degrees = safeParseFloat(token.props.args.deg) ?? 90;
|
||||||
style = `transform: rotate(${degrees}deg); transform-origin: center center;`;
|
style = `transform: rotate(${degrees}deg); transform-origin: center center;`;
|
||||||
|
@ -239,6 +255,22 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven
|
||||||
style = `transform: translateX(${x}em) translateY(${y}em);`;
|
style = `transform: translateX(${x}em) translateY(${y}em);`;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'crop': {
|
||||||
|
const top = Number.parseFloat(
|
||||||
|
(token.props.args.top ?? '0').toString(),
|
||||||
|
);
|
||||||
|
const right = Number.parseFloat(
|
||||||
|
(token.props.args.right ?? '0').toString(),
|
||||||
|
);
|
||||||
|
const bottom = Number.parseFloat(
|
||||||
|
(token.props.args.bottom ?? '0').toString(),
|
||||||
|
);
|
||||||
|
const left = Number.parseFloat(
|
||||||
|
(token.props.args.left ?? '0').toString(),
|
||||||
|
);
|
||||||
|
style = `clip-path: inset(${top}% ${right}% ${bottom}% ${left}%);`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'scale': {
|
case 'scale': {
|
||||||
if (!defaultStore.state.advancedMfm) {
|
if (!defaultStore.state.advancedMfm) {
|
||||||
style = '';
|
style = '';
|
||||||
|
|
|
@ -122,7 +122,7 @@ export const DEFAULT_SERVER_ERROR_IMAGE_URL = 'https://xn--931a.moe/assets/error
|
||||||
export const DEFAULT_NOT_FOUND_IMAGE_URL = 'https://xn--931a.moe/assets/not-found.jpg';
|
export const DEFAULT_NOT_FOUND_IMAGE_URL = 'https://xn--931a.moe/assets/not-found.jpg';
|
||||||
export const DEFAULT_INFO_IMAGE_URL = 'https://xn--931a.moe/assets/info.jpg';
|
export const DEFAULT_INFO_IMAGE_URL = 'https://xn--931a.moe/assets/info.jpg';
|
||||||
|
|
||||||
export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'scale', 'position', 'fg', 'bg', 'border', 'font', 'blur', 'rainbow', 'sparkle', 'rotate', 'ruby', 'unixtime'];
|
export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'scale', 'position', 'fg', 'bg', 'border', 'font', 'blur', 'rainbow', 'sparkle', 'rotate', 'ruby', 'unixtime', 'crop', 'fade'];
|
||||||
export const MFM_PARAMS: Record<typeof MFM_TAGS[number], string[]> = {
|
export const MFM_PARAMS: Record<typeof MFM_TAGS[number], string[]> = {
|
||||||
tada: ['speed=', 'delay='],
|
tada: ['speed=', 'delay='],
|
||||||
jelly: ['speed=', 'delay='],
|
jelly: ['speed=', 'delay='],
|
||||||
|
@ -146,4 +146,6 @@ export const MFM_PARAMS: Record<typeof MFM_TAGS[number], string[]> = {
|
||||||
rotate: ['deg='],
|
rotate: ['deg='],
|
||||||
ruby: [],
|
ruby: [],
|
||||||
unixtime: [],
|
unixtime: [],
|
||||||
|
fade: ['speed=', 'delay=', 'loop=', 'out'],
|
||||||
|
crop: ['top=', 'bottom=', 'left=', 'right='],
|
||||||
};
|
};
|
||||||
|
|
|
@ -40,7 +40,6 @@ type Keys =
|
||||||
'lastEmojisFetchedAt' | // DEPRECATED, stored in indexeddb (13.9.0~)
|
'lastEmojisFetchedAt' | // DEPRECATED, stored in indexeddb (13.9.0~)
|
||||||
'emojis' | // DEPRECATED, stored in indexeddb (13.9.0~);
|
'emojis' | // DEPRECATED, stored in indexeddb (13.9.0~);
|
||||||
`channelLastReadedAt:${string}` |
|
`channelLastReadedAt:${string}` |
|
||||||
'kawaii' |
|
|
||||||
'gaConsent' |
|
'gaConsent' |
|
||||||
'gtagConsent'
|
'gtagConsent'
|
||||||
;
|
;
|
||||||
|
|
|
@ -15,8 +15,6 @@ import { ui, host } from '@/config.js';
|
||||||
import { unisonReload } from '@/scripts/unison-reload.js';
|
import { unisonReload } from '@/scripts/unison-reload.js';
|
||||||
import { instance } from '@/instance.js';
|
import { instance } from '@/instance.js';
|
||||||
|
|
||||||
const kawaiiMode = miLocalStorage.getItem('kawaii') === 'true';
|
|
||||||
|
|
||||||
export const navbarItemDef = reactive({
|
export const navbarItemDef = reactive({
|
||||||
notifications: {
|
notifications: {
|
||||||
title: i18n.ts.notifications,
|
title: i18n.ts.notifications,
|
||||||
|
@ -180,15 +178,6 @@ export const navbarItemDef = reactive({
|
||||||
show: computed(() => $i != null),
|
show: computed(() => $i != null),
|
||||||
to: `/@${$i?.username}`,
|
to: `/@${$i?.username}`,
|
||||||
},
|
},
|
||||||
kawaii: {
|
|
||||||
title: kawaiiMode ? 'no uwu plz' : 'uwu?',
|
|
||||||
icon: kawaiiMode ? 'ti ti-mood-smile' : 'ti ti-mood-wink',
|
|
||||||
action: (ev) => {
|
|
||||||
if (kawaiiMode) miLocalStorage.removeItem('kawaii');
|
|
||||||
else miLocalStorage.setItem('kawaii', 'true');
|
|
||||||
location.reload();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
support: {
|
support: {
|
||||||
title: i18n.tsx.supportThisInstance({ name: instance.name ?? host }),
|
title: i18n.tsx.supportThisInstance({ name: instance.name ?? host }),
|
||||||
icon: 'ti ti-pig-money',
|
icon: 'ti ti-pig-money',
|
||||||
|
|
|
@ -11,9 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<div class="_gaps_m znqjceqz">
|
<div class="_gaps_m znqjceqz">
|
||||||
<div v-panel class="about">
|
<div v-panel class="about">
|
||||||
<div ref="containerEl" class="container" :class="{ playing: easterEggEngine != null }">
|
<div ref="containerEl" class="container" :class="{ playing: easterEggEngine != null }">
|
||||||
<img v-if="kawaiiMode" src="/client-assets/kawaii/about-icon.png" alt="" class="iconAlt" draggable="false" @load="iconLoaded" @click="gravity"/>
|
<img src="/client-assets/about-icon.png" alt="" class="icon" draggable="false" @load="iconLoaded" @click="gravity"/>
|
||||||
<img v-else src="/client-assets/about-icon.png" alt="" class="icon" draggable="false" @load="iconLoaded" @click="gravity"/>
|
|
||||||
<Mfm v-if="kawaiiMode" text="Logo by @sawaratsuki@misskey.io" class="iconCredit"/>
|
|
||||||
<div class="misskey">Misskey</div>
|
<div class="misskey">Misskey</div>
|
||||||
<div class="version">v{{ version }}</div>
|
<div class="version">v{{ version }}</div>
|
||||||
<span v-for="emoji in easterEggEmojis" :key="emoji.id" class="emoji" :data-physics-x="emoji.left" :data-physics-y="emoji.top" :class="{ _physics_circle_: !emoji.emoji.startsWith(':') }">
|
<span v-for="emoji in easterEggEmojis" :key="emoji.id" class="emoji" :data-physics-x="emoji.left" :data-physics-y="emoji.top" :class="{ _physics_circle_: !emoji.emoji.startsWith(':') }">
|
||||||
|
@ -139,14 +137,11 @@ import { physics } from '@/scripts/physics.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { instance } from '@/instance.js';
|
import { instance } from '@/instance.js';
|
||||||
import { defaultStore } from '@/store.js';
|
import { defaultStore } from '@/store.js';
|
||||||
import { miLocalStorage } from '@/local-storage.js';
|
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||||
import { claimAchievement, claimedAchievements } from '@/scripts/achievements.js';
|
import { claimAchievement, claimedAchievements } from '@/scripts/achievements.js';
|
||||||
import { $i } from '@/account.js';
|
import { $i } from '@/account.js';
|
||||||
|
|
||||||
const kawaiiMode = miLocalStorage.getItem('kawaii') === 'true';
|
|
||||||
|
|
||||||
const patronsWithIcon = [{
|
const patronsWithIcon = [{
|
||||||
name: 'カイヤン',
|
name: 'カイヤン',
|
||||||
icon: 'https://assets.misskey-hub.net/patrons/a2820716883e408cb87773e377ce7c8d.jpg',
|
icon: 'https://assets.misskey-hub.net/patrons/a2820716883e408cb87773e377ce7c8d.jpg',
|
||||||
|
|
|
@ -11,9 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<div class="_gaps_m">
|
<div class="_gaps_m">
|
||||||
<div :class="$style.banner" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }">
|
<div :class="$style.banner" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }">
|
||||||
<div style="overflow: clip;">
|
<div style="overflow: clip;">
|
||||||
<img v-if="kawaiiMode" src="/client-assets/kawaii/misskey-io.png" alt="" :class="$style.bannerIconAlt"/>
|
<img :src="instance.iconUrl ?? instance.faviconUrl ?? '/favicon.ico'" alt="" :class="$style.bannerIcon"/>
|
||||||
<img v-else :src="instance.iconUrl ?? instance.faviconUrl ?? '/favicon.ico'" alt="" :class="$style.bannerIcon"/>
|
|
||||||
<Mfm v-if="kawaiiMode" text="Logo by @sawaratsuki@misskey.io" :class="$style.iconCredit"/>
|
|
||||||
<div :class="$style.bannerName">
|
<div :class="$style.bannerName">
|
||||||
<b>{{ instance.name ?? host }}</b>
|
<b>{{ instance.name ?? host }}</b>
|
||||||
</div>
|
</div>
|
||||||
|
@ -170,7 +168,6 @@ import { i18n } from '@/i18n.js';
|
||||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||||
import { claimAchievement } from '@/scripts/achievements.js';
|
import { claimAchievement } from '@/scripts/achievements.js';
|
||||||
import { instance } from '@/instance.js';
|
import { instance } from '@/instance.js';
|
||||||
import { miLocalStorage } from '@/local-storage.js';
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
initialTab?: string;
|
initialTab?: string;
|
||||||
|
@ -178,7 +175,6 @@ const props = withDefaults(defineProps<{
|
||||||
initialTab: 'overview',
|
initialTab: 'overview',
|
||||||
});
|
});
|
||||||
|
|
||||||
const kawaiiMode = miLocalStorage.getItem('kawaii') === 'true';
|
|
||||||
const stats = ref<Misskey.entities.StatsResponse | null>(null);
|
const stats = ref<Misskey.entities.StatsResponse | null>(null);
|
||||||
const tab = ref(props.initialTab);
|
const tab = ref(props.initialTab);
|
||||||
|
|
||||||
|
|
|
@ -162,6 +162,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
<div class="_gaps_m">
|
<div class="_gaps_m">
|
||||||
<div class="_gaps_s">
|
<div class="_gaps_s">
|
||||||
|
<MkSwitch v-model="warnMissingAltText">{{ i18n.ts.warnForMissingAltText }}</MkSwitch>
|
||||||
<MkSwitch v-model="imageNewTab">{{ i18n.ts.openImageInNewTab }}</MkSwitch>
|
<MkSwitch v-model="imageNewTab">{{ i18n.ts.openImageInNewTab }}</MkSwitch>
|
||||||
<MkSwitch v-model="useReactionPickerForContextMenu">{{ i18n.ts.useReactionPickerForContextMenu }}</MkSwitch>
|
<MkSwitch v-model="useReactionPickerForContextMenu">{{ i18n.ts.useReactionPickerForContextMenu }}</MkSwitch>
|
||||||
<MkSwitch v-model="enableInfiniteScroll">{{ i18n.ts.enableInfiniteScroll }}</MkSwitch>
|
<MkSwitch v-model="enableInfiniteScroll">{{ i18n.ts.enableInfiniteScroll }}</MkSwitch>
|
||||||
|
@ -299,6 +300,7 @@ const forceShowAds = computed(defaultStore.makeGetterSetter('forceShowAds'));
|
||||||
const loadRawImages = computed(defaultStore.makeGetterSetter('loadRawImages'));
|
const loadRawImages = computed(defaultStore.makeGetterSetter('loadRawImages'));
|
||||||
const highlightSensitiveMedia = computed(defaultStore.makeGetterSetter('highlightSensitiveMedia'));
|
const highlightSensitiveMedia = computed(defaultStore.makeGetterSetter('highlightSensitiveMedia'));
|
||||||
const imageNewTab = computed(defaultStore.makeGetterSetter('imageNewTab'));
|
const imageNewTab = computed(defaultStore.makeGetterSetter('imageNewTab'));
|
||||||
|
const warnMissingAltText = computed(defaultStore.makeGetterSetter('warnMissingAltText'));
|
||||||
const nsfw = computed(defaultStore.makeGetterSetter('nsfw'));
|
const nsfw = computed(defaultStore.makeGetterSetter('nsfw'));
|
||||||
const showFixedPostForm = computed(defaultStore.makeGetterSetter('showFixedPostForm'));
|
const showFixedPostForm = computed(defaultStore.makeGetterSetter('showFixedPostForm'));
|
||||||
const showFixedPostFormInChannel = computed(defaultStore.makeGetterSetter('showFixedPostFormInChannel'));
|
const showFixedPostFormInChannel = computed(defaultStore.makeGetterSetter('showFixedPostFormInChannel'));
|
||||||
|
|
|
@ -75,6 +75,7 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [
|
||||||
'showRenotesCount',
|
'showRenotesCount',
|
||||||
'showReactionsCount',
|
'showReactionsCount',
|
||||||
'loadRawImages',
|
'loadRawImages',
|
||||||
|
'warnMissingAltText',
|
||||||
'imageNewTab',
|
'imageNewTab',
|
||||||
'dataSaver',
|
'dataSaver',
|
||||||
'disableShowingAnimatedImages',
|
'disableShowingAnimatedImages',
|
||||||
|
|
|
@ -247,6 +247,10 @@ export const defaultStore = markRaw(new Storage('base', {
|
||||||
where: 'device',
|
where: 'device',
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
warnMissingAltText: {
|
||||||
|
where: 'device',
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
imageNewTab: {
|
imageNewTab: {
|
||||||
where: 'device',
|
where: 'device',
|
||||||
default: false,
|
default: false,
|
||||||
|
|
|
@ -672,3 +672,12 @@ html[data-color-mode=dark] ._woodenFrame {
|
||||||
0% { filter: hue-rotate(0deg) contrast(150%) saturate(150%); }
|
0% { filter: hue-rotate(0deg) contrast(150%) saturate(150%); }
|
||||||
100% { filter: hue-rotate(360deg) contrast(150%) saturate(150%); }
|
100% { filter: hue-rotate(360deg) contrast(150%) saturate(150%); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes mfm-fade {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -9,8 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<div :class="$style.top">
|
<div :class="$style.top">
|
||||||
<div :class="$style.banner" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }"></div>
|
<div :class="$style.banner" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }"></div>
|
||||||
<button v-tooltip.noDelay.right="instance.name ?? i18n.ts.instance" class="_button" :class="$style.instance" @click="openInstanceMenu">
|
<button v-tooltip.noDelay.right="instance.name ?? i18n.ts.instance" class="_button" :class="$style.instance" @click="openInstanceMenu">
|
||||||
<img v-if="kawaiiMode" src="/client-assets/kawaii/misskey-io.png" alt="" :class="$style.instanceIconAlt"/>
|
<img :src="instance.iconUrl || instance.faviconUrl || '/favicon.ico'" alt="" :class="$style.instanceIcon"/>
|
||||||
<img v-else :src="instance.iconUrl || instance.faviconUrl || '/favicon.ico'" alt="" :class="$style.instanceIcon"/>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div :class="$style.middle">
|
<div :class="$style.middle">
|
||||||
|
@ -69,10 +68,8 @@ import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js';
|
||||||
import { defaultStore } from '@/store.js';
|
import { defaultStore } from '@/store.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { instance } from '@/instance.js';
|
import { instance } from '@/instance.js';
|
||||||
import { miLocalStorage } from '@/local-storage.js';
|
|
||||||
|
|
||||||
const iconOnly = ref(false);
|
const iconOnly = ref(false);
|
||||||
const kawaiiMode = miLocalStorage.getItem('kawaii') === 'true';
|
|
||||||
|
|
||||||
const menu = computed(() => defaultStore.state.menu);
|
const menu = computed(() => defaultStore.state.menu);
|
||||||
const otherMenuItemIndicated = computed(() => {
|
const otherMenuItemIndicated = computed(() => {
|
||||||
|
@ -176,11 +173,6 @@ function more(ev: MouseEvent) {
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.instanceIconAlt {
|
|
||||||
display: inline-block;
|
|
||||||
width: 85%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bottom {
|
.bottom {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
@ -367,11 +359,6 @@ function more(ev: MouseEvent) {
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.instanceIconAlt {
|
|
||||||
display: inline-block;
|
|
||||||
width: 85%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bottom {
|
.bottom {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
|
Loading…
Reference in a new issue