Merge tag '13.6.1' into io

This commit is contained in:
Cookie Ramen 2023-02-13 21:57:39 +09:00
commit 74a2b92154
No known key found for this signature in database
GPG key ID: 82A8B5D42246F02D
41 changed files with 816 additions and 265 deletions

View file

@ -67,6 +67,7 @@ export type Source = {
mediaProxy?: string;
proxyRemoteFiles?: boolean;
videoThumbnailGenerator?: string;
signToActivityPubGet?: boolean;
};
@ -89,6 +90,7 @@ export type Mixin = {
clientManifestExists: boolean;
mediaProxy: string;
externalMediaProxyEnabled: boolean;
videoThumbnailGenerator: string | null;
};
export type Config = Source & Mixin;
@ -144,6 +146,10 @@ export function loadConfig() {
mixin.mediaProxy = externalMediaProxy ?? internalMediaProxy;
mixin.externalMediaProxyEnabled = externalMediaProxy !== null && externalMediaProxy !== internalMediaProxy;
mixin.videoThumbnailGenerator = config.videoThumbnailGenerator ?
config.videoThumbnailGenerator.endsWith('/') ? config.videoThumbnailGenerator.substring(0, config.videoThumbnailGenerator.length - 1) : config.videoThumbnailGenerator
: null;
if (!config.redis.prefix) config.redis.prefix = mixin.host;
return Object.assign(config, mixin);

View file

@ -250,6 +250,14 @@ export class DriveService {
@bindThis
public async generateAlts(path: string, type: string, generateWeb: boolean) {
if (type.startsWith('video/')) {
if (this.config.videoThumbnailGenerator != null) {
// videoThumbnailGeneratorが指定されていたら動画サムネイル生成はスキップ
return {
webpublic: null,
thumbnail: null,
}
}
try {
const thumbnail = await this.videoProcessingService.generateVideoThumbnail(path);
return {

View file

@ -14,6 +14,8 @@ import { RoleService } from '@/core/RoleService.js';
@Injectable()
export class UserListService {
public static TooManyUsersError = class extends Error {};
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@ -36,7 +38,7 @@ export class UserListService {
userListId: list.id,
});
if (currentCount > (await this.roleService.getUserPolicies(me.id)).userEachUserListsLimit) {
throw new Error('Too many users');
throw new UserListService.TooManyUsersError();
}
await this.userListJoiningsRepository.insert({

View file

@ -6,6 +6,7 @@ import { ImageProcessingService } from '@/core/ImageProcessingService.js';
import type { IImage } from '@/core/ImageProcessingService.js';
import { createTempDir } from '@/misc/create-temp.js';
import { bindThis } from '@/decorators.js';
import { appendQuery, query } from '@/misc/prelude/url.js';
@Injectable()
export class VideoProcessingService {
@ -41,5 +42,18 @@ export class VideoProcessingService {
cleanup();
}
}
@bindThis
public getExternalVideoThumbnailUrl(url: string): string | null {
if (this.config.videoThumbnailGenerator == null) return null;
return appendQuery(
`${this.config.videoThumbnailGenerator}/thumbnail.webp`,
query({
thumbnail: '1',
url,
})
)
}
}

View file

@ -13,6 +13,7 @@ import { deepClone } from '@/misc/clone.js';
import { UtilityService } from '../UtilityService.js';
import { UserEntityService } from './UserEntityService.js';
import { DriveFolderEntityService } from './DriveFolderEntityService.js';
import { VideoProcessingService } from '../VideoProcessingService.js';
type PackOptions = {
detail?: boolean,
@ -43,6 +44,7 @@ export class DriveFileEntityService {
private utilityService: UtilityService,
private driveFolderEntityService: DriveFolderEntityService,
private videoProcessingService: VideoProcessingService,
) {
}
@ -72,40 +74,63 @@ export class DriveFileEntityService {
}
@bindThis
public getPublicUrl(file: DriveFile, mode? : 'static' | 'avatar'): string | null { // static = thumbnail
const proxiedUrl = (url: string) => appendQuery(
private getProxiedUrl(url: string, mode?: 'static' | 'avatar'): string | null {
return appendQuery(
`${this.config.mediaProxy}/${mode ?? 'image'}.webp`,
query({
url,
...(mode ? { [mode]: '1' } : {}),
})
);
)
}
@bindThis
public getThumbnailUrl(file: DriveFile): string | null {
if (file.type.startsWith('video')) {
if (file.thumbnailUrl) return file.thumbnailUrl;
if (this.config.videoThumbnailGenerator == null) {
return this.videoProcessingService.getExternalVideoThumbnailUrl(file.webpublicUrl ?? file.url ?? file.uri);
}
} else if (file.uri != null && file.userHost != null && this.config.externalMediaProxyEnabled) {
// 動画ではなくリモートかつメディアプロキシ
return this.getProxiedUrl(file.uri, 'static');
}
if (file.uri != null && file.isLink && this.config.proxyRemoteFiles) {
// リモートかつ期限切れはローカルプロキシを試みる
// 従来は/files/${thumbnailAccessKey}にアクセスしていたが、
// /filesはメディアプロキシにリダイレクトするようにしたため直接メディアプロキシを指定する
return this.getProxiedUrl(file.uri, 'static');
}
const url = file.webpublicUrl ?? file.url;
return file.thumbnailUrl ?? (isMimeImage(file.type, 'sharp-convertible-image') ? this.getProxiedUrl(url, 'static') : null);
}
@bindThis
public getPublicUrl(file: DriveFile, mode?: 'avatar'): string | null { // static = thumbnail
// リモートかつメディアプロキシ
if (file.uri != null && file.userHost != null && this.config.externalMediaProxyEnabled) {
if (!(mode === 'static' && file.type.startsWith('video'))) {
return proxiedUrl(file.uri);
}
return this.getProxiedUrl(file.uri, mode);
}
// リモートかつ期限切れはローカルプロキシを試みる
if (file.uri != null && file.isLink && this.config.proxyRemoteFiles) {
const key = mode === 'static' ? file.thumbnailAccessKey : file.webpublicAccessKey;
const key = file.webpublicAccessKey;
if (key && !key.match('/')) { // 古いものはここにオブジェクトストレージキーが入ってるので除外
const url = `${this.config.url}/files/${key}`;
if (mode === 'avatar') return proxiedUrl(file.uri);
if (mode === 'avatar') return this.getProxiedUrl(file.uri, 'avatar');
return url;
}
}
const url = file.webpublicUrl ?? file.url;
if (mode === 'static') {
return file.thumbnailUrl ?? (isMimeImage(file.type, 'sharp-convertible-image') ? proxiedUrl(url) : null);
}
if (mode === 'avatar') {
return proxiedUrl(url);
return this.getProxiedUrl(url, 'avatar');
}
return url;
}
@ -183,7 +208,7 @@ export class DriveFileEntityService {
blurhash: file.blurhash,
properties: opts.self ? file.properties : this.getPublicProperties(file),
url: opts.self ? file.url : this.getPublicUrl(file),
thumbnailUrl: this.getPublicUrl(file, 'static'),
thumbnailUrl: this.getThumbnailUrl(file),
comment: file.comment,
folderId: file.folderId,
folder: opts.detail && file.folderId ? this.driveFolderEntityService.pack(file.folderId, {
@ -218,7 +243,7 @@ export class DriveFileEntityService {
blurhash: file.blurhash,
properties: opts.self ? file.properties : this.getPublicProperties(file),
url: opts.self ? file.url : this.getPublicUrl(file),
thumbnailUrl: this.getPublicUrl(file, 'static'),
thumbnailUrl: this.getThumbnailUrl(file),
comment: file.comment,
folderId: file.folderId,
folder: opts.detail && file.folderId ? this.driveFolderEntityService.pack(file.folderId, {

View file

@ -441,6 +441,14 @@ export class ActivityPubServerService {
fastify.addContentTypeParser('application/activity+json', { parseAs: 'string' }, fastify.getDefaultJsonParser('ignore', 'ignore'));
fastify.addContentTypeParser('application/ld+json', { parseAs: 'string' }, fastify.getDefaultJsonParser('ignore', 'ignore'));
fastify.addHook('onRequest', (request, reply, done) => {
reply.header('Access-Control-Allow-Headers', 'Accept');
reply.header('Access-Control-Allow-Methods', 'GET, OPTIONS');
reply.header('Access-Control-Allow-Origin', '*');
reply.header('Access-Control-Expose-Headers', 'Vary');
done();
});
//#region Routing
// inbox (limit: 64kb)
fastify.post('/inbox', { bodyLimit: 1024 * 64 }, async (request, reply) => await this.inbox(request, reply));

View file

@ -150,6 +150,12 @@ export class FileServerService {
file.cleanup();
return await reply.redirect(301, url.toString());
} else if (file.mime.startsWith('video/')) {
const externalThumbnail = this.videoProcessingService.getExternalVideoThumbnailUrl(file.url);
if (externalThumbnail) {
file.cleanup();
return await reply.redirect(301, externalThumbnail);
}
image = await this.videoProcessingService.generateVideoThumbnail(file.path);
}
}

View file

@ -227,15 +227,17 @@ export class ApiCallService implements OnApplicationShutdown {
// TODO: 毎リクエスト計算するのもあれだしキャッシュしたい
const factor = user ? (await this.roleService.getUserPolicies(user.id)).rateLimitFactor : 1;
// Rate limit
await this.rateLimiterService.limit(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor, factor).catch(err => {
throw new ApiError({
message: 'Rate limit exceeded. Please try again later.',
code: 'RATE_LIMIT_EXCEEDED',
id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
httpStatusCode: 429,
if (factor > 0) {
// Rate limit
await this.rateLimiterService.limit(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor, factor).catch(err => {
throw new ApiError({
message: 'Rate limit exceeded. Please try again later.',
code: 'RATE_LIMIT_EXCEEDED',
id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
httpStatusCode: 429,
});
});
});
}
}
if (ep.meta.requireCredential || ep.meta.requireModerator || ep.meta.requireAdmin) {

View file

@ -45,6 +45,12 @@ export const meta = {
code: 'YOU_HAVE_BEEN_BLOCKED',
id: '990232c5-3f9d-4d83-9f3f-ef27b6332a4b',
},
tooManyUsers: {
message: 'You can not push users any more.',
code: 'TOO_MANY_USERS',
id: '2dd9752e-a338-413d-8eec-41814430989b',
},
},
} as const;
@ -110,8 +116,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
throw new ApiError(meta.errors.alreadyAdded);
}
// Push the user
await this.userListService.push(user, userList, me);
try {
await this.userListService.push(user, userList, me);
} catch (err) {
if (err instanceof UserListService.TooManyUsersError) {
throw new ApiError(meta.errors.tooManyUsers);
}
throw err;
}
});
}
}

View file

@ -11,6 +11,9 @@ window.onload = async () => {
// Send request
fetch(endpoint.indexOf('://') > -1 ? endpoint : `/api/${endpoint}`, {
headers: {
'Content-Type': 'application/json'
},
method: 'POST',
body: JSON.stringify(data),
credentials: 'omit',

View file

@ -78,7 +78,7 @@
</div>
<footer :class="$style.footer">
<MkReactionsViewer :note="appearNote" :max-number="16">
<template v-slot:more>
<template #more>
<button class="_button" :class="$style.reactionDetailsButton" @click="showReactions">
{{ i18n.ts.more }}
</button>
@ -156,6 +156,7 @@ import { useTooltip } from '@/scripts/use-tooltip';
import { claimAchievement } from '@/scripts/achievements';
import { getNoteSummary } from '@/scripts/get-note-summary';
import { shownNoteIds } from '@/os';
import { MenuItem } from '@/types/menu';
const props = defineProps<{
note: misskey.entities.Note;
@ -206,7 +207,7 @@ const translation = ref<any>(null);
const translating = ref(false);
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);
let renoteCollapsed = $ref(isRenote && (($i && ($i.id === note.userId)) || shownNoteIds.has(appearNote.id)));
let renoteCollapsed = $ref(defaultStore.state.collapseRenotes && isRenote && (($i && ($i.id === note.userId)) || shownNoteIds.has(appearNote.id)));
shownNoteIds.add(appearNote.id);
@ -247,7 +248,32 @@ useTooltip(renoteButton, async (showing) => {
function renote(viaKeyboard = false) {
pleaseLogin();
os.popupMenu([{
let items = [] as MenuItem[];
if (appearNote.channel) {
items = items.concat([{
text: i18n.ts.inChannelRenote,
icon: 'ti ti-repeat',
action: () => {
os.api('notes/create', {
renoteId: appearNote.id,
channelId: appearNote.channelId,
});
},
}, {
text: i18n.ts.inChannelQuote,
icon: 'ti ti-quote',
action: () => {
os.post({
renote: appearNote,
channel: appearNote.channel,
});
},
}, null]);
}
items = items.concat([{
text: i18n.ts.renote,
icon: 'ti ti-repeat',
action: () => {
@ -263,7 +289,9 @@ function renote(viaKeyboard = false) {
renote: appearNote,
});
},
}], renoteButton.value, {
}]);
os.popupMenu(items, renoteButton.value, {
viaKeyboard,
});
}
@ -704,6 +732,12 @@ function showReactions(): void {
}
}
@container (max-width: 250px) {
.quoteNote {
padding: 12px;
}
}
.muted {
padding: 8px;
text-align: center;

View file

@ -160,6 +160,7 @@ import { useNoteCapture } from '@/scripts/use-note-capture';
import { deepClone } from '@/scripts/clone';
import { useTooltip } from '@/scripts/use-tooltip';
import { claimAchievement } from '@/scripts/achievements';
import { MenuItem } from '@/types/menu';
const props = defineProps<{
note: misskey.entities.Note;
@ -241,7 +242,32 @@ useTooltip(renoteButton, async (showing) => {
function renote(viaKeyboard = false) {
pleaseLogin();
os.popupMenu([{
let items = [] as MenuItem[];
if (appearNote.channel) {
items = items.concat([{
text: i18n.ts.inChannelRenote,
icon: 'ti ti-repeat',
action: () => {
os.api('notes/create', {
renoteId: appearNote.id,
channelId: appearNote.channelId,
});
},
}, {
text: i18n.ts.inChannelQuote,
icon: 'ti ti-quote',
action: () => {
os.post({
renote: appearNote,
channel: appearNote.channel,
});
},
}, null]);
}
items = items.concat([{
text: i18n.ts.renote,
icon: 'ti ti-repeat',
action: () => {
@ -257,7 +283,9 @@ function renote(viaKeyboard = false) {
renote: appearNote,
});
},
}], renoteButton.value, {
}]);
os.popupMenu(items, renoteButton.value, {
viaKeyboard,
});
}

View file

@ -44,8 +44,8 @@ const showContent = $ref(false);
flex-shrink: 0;
display: block;
margin: 0 10px 0 0;
width: 40px;
height: 40px;
width: 34px;
height: 34px;
border-radius: 8px;
}
@ -72,6 +72,14 @@ const showContent = $ref(false);
padding: 0;
}
@container (min-width: 250px) {
.avatar {
margin: 0 10px 0 0;
width: 40px;
height: 40px;
}
}
@container (min-width: 350px) {
.avatar {
margin: 0 10px 0 0;

View file

@ -21,7 +21,7 @@
<div v-else ref="rootEl">
<div v-show="pagination.reversed && more" key="_more_" class="_margin">
<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? fetchMore : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMore">
<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? fetchMoreAhead : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMoreAhead">
{{ i18n.ts.loadMore }}
</MkButton>
<MkLoading v-else class="loading"/>

View file

@ -0,0 +1,234 @@
<template>
<div ref="el" :class="$style.tabs" @wheel="onTabWheel">
<div :class="$style.tabsInner">
<button v-for="t in tabs" :ref="(el) => tabRefs[t.key] = (el as HTMLElement)" v-tooltip.noDelay="t.title"
class="_button" :class="[$style.tab, { [$style.active]: t.key != null && t.key === props.tab, [$style.animate]: defaultStore.reactiveState.animation.value }]"
@mousedown="(ev) => onTabMousedown(t, ev)" @click="(ev) => onTabClick(t, ev)">
<div :class="$style.tabInner">
<i v-if="t.icon" :class="[$style.tabIcon, t.icon]"></i>
<div v-if="!t.iconOnly || (!defaultStore.reactiveState.animation.value && t.key === tab)"
:class="$style.tabTitle">{{ t.title }}</div>
<Transition v-else @enter="enter" @after-enter="afterEnter" @leave="leave" @after-leave="afterLeave"
mode="in-out">
<div v-show="t.key === tab" :class="[$style.tabTitle, $style.animate]">{{ t.title }}</div>
</Transition>
</div>
</button>
</div>
<div ref="tabHighlightEl"
:class="[$style.tabHighlight, { [$style.animate]: defaultStore.reactiveState.animation.value }]"></div>
</div>
</template>
<script lang="ts">
export type Tab = {
key: string;
title: string;
icon?: string;
iconOnly?: boolean;
onClick?: (ev: MouseEvent) => void;
} & {
iconOnly: true;
iccn: string;
};
</script>
<script lang="ts" setup>
import { onMounted, onUnmounted, watch, nextTick, Transition, shallowRef } from 'vue';
import { defaultStore } from '@/store';
const props = withDefaults(defineProps<{
tabs?: Tab[];
tab?: string;
rootEl?: HTMLElement;
}>(), {
tabs: () => ([] as Tab[]),
});
const emit = defineEmits<{
(ev: 'update:tab', key: string);
(ev: 'tabClick', key: string);
}>();
const el = shallowRef<HTMLElement | null>(null);
const tabRefs: Record<string, HTMLElement | null> = {};
const tabHighlightEl = shallowRef<HTMLElement | null>(null);
function onTabMousedown(tab: Tab, ev: MouseEvent): void {
// mousedownonClick
if (tab.key) {
emit('update:tab', tab.key);
}
}
function onTabClick(t: Tab, ev: MouseEvent): void {
emit('tabClick', t.key);
if (t.onClick) {
ev.preventDefault();
ev.stopPropagation();
t.onClick(ev);
}
if (t.key) {
emit('update:tab', t.key);
}
}
function renderTab() {
const tabEl = props.tab ? tabRefs[props.tab] : undefined;
if (tabEl && tabHighlightEl.value && tabHighlightEl.value.parentElement) {
// offsetWidth offsetLeft getBoundingClientRect 使
// https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4
const parentRect = tabHighlightEl.value.parentElement.getBoundingClientRect();
const rect = tabEl.getBoundingClientRect();
tabHighlightEl.value.style.width = rect.width + 'px';
tabHighlightEl.value.style.left = (rect.left - parentRect.left + tabHighlightEl.value.parentElement.scrollLeft) + 'px';
}
}
function onTabWheel(ev: WheelEvent) {
if (ev.deltaY !== 0 && ev.deltaX === 0) {
ev.preventDefault();
ev.stopPropagation();
(ev.currentTarget as HTMLElement).scrollBy({
left: ev.deltaY,
behavior: 'smooth',
});
}
return false;
}
let entering = false;
async function enter(el: HTMLElement) {
entering = true;
const elementWidth = el.getBoundingClientRect().width;
el.style.width = '0';
el.style.paddingLeft = '0';
el.offsetWidth; // force reflow
el.style.width = elementWidth + 'px';
el.style.paddingLeft = '';
nextTick(() => {
entering = false;
});
setTimeout(renderTab, 170);
}
function afterEnter(el: HTMLElement) {
//el.style.width = '';
}
async function leave(el: HTMLElement) {
const elementWidth = el.getBoundingClientRect().width;
el.style.width = elementWidth + 'px';
el.style.paddingLeft = '';
el.offsetWidth; // force reflow
el.style.width = '0';
el.style.paddingLeft = '0';
}
function afterLeave(el: HTMLElement) {
el.style.width = '';
}
let ro2: ResizeObserver | null;
onMounted(() => {
watch([() => props.tab, () => props.tabs], () => {
nextTick(() => {
if (entering) return;
renderTab();
});
}, {
immediate: true,
});
if (props.rootEl) {
ro2 = new ResizeObserver((entries, observer) => {
if (document.body.contains(el.value as HTMLElement)) {
nextTick(() => renderTab());
}
});
ro2.observe(props.rootEl);
}
});
onUnmounted(() => {
if (ro2) ro2.disconnect();
});
</script>
<style lang="scss" module>
.tabs {
display: block;
position: relative;
margin: 0;
height: var(--height);
font-size: 0.8em;
text-align: center;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.tabsInner {
display: inline-block;
height: var(--height);
white-space: nowrap;
}
.tab {
display: inline-block;
position: relative;
padding: 0 10px;
height: 100%;
font-weight: normal;
opacity: 0.7;
&:hover {
opacity: 1;
}
&.active {
opacity: 1;
}
&.animate {
transition: opacity 0.2s ease;
}
}
.tabInner {
display: flex;
align-items: center;
}
.tabIcon+.tabTitle {
padding-left: 8px;
}
.tabTitle {
overflow: hidden;
&.animate {
transition: width .15s linear, padding-left .15s linear;
}
}
.tabHighlight {
position: absolute;
bottom: 0;
height: 3px;
background: var(--accent);
border-radius: 999px;
transition: none;
pointer-events: none;
&.animate {
transition: width 0.15s ease, left 0.15s ease;
}
}
</style>

View file

@ -1,10 +1,10 @@
<template>
<div v-if="show" ref="el" :class="[$style.root]" :style="{ background: bg }">
<div :class="[$style.upper, { [$style.slim]: narrow, [$style.thin]: thin_ }]">
<div v-if="narrow && props.displayMyAvatar && $i" class="_button" :class="$style.buttonsLeft" @click="openAccountMenu">
<div v-if="!thin_ && narrow && props.displayMyAvatar && $i" class="_button" :class="$style.buttonsLeft" @click="openAccountMenu">
<MkAvatar :class="$style.avatar" :user="$i" />
</div>
<div v-else-if="narrow && !hideTitle" :class="$style.buttonsLeft" />
<div v-else-if="!thin_ && narrow && !hideTitle" :class="$style.buttonsLeft" />
<template v-if="metadata">
<div v-if="!hideTitle" :class="$style.titleContainer" @click="top">
@ -19,63 +19,28 @@
</div>
</div>
</div>
<div v-if="!narrow || hideTitle" :class="$style.tabs" @wheel="onTabWheel">
<div :class="$style.tabsInner">
<button v-for="t in tabs" :ref="(el) => tabRefs[t.key] = (el as HTMLElement)" v-tooltip.noDelay="t.title" class="_button" :class="[$style.tab, { [$style.active]: t.key != null && t.key === props.tab }]" @mousedown="(ev) => onTabMousedown(t, ev)" @click="(ev) => onTabClick(t, ev)">
<div :class="$style.tabInner">
<i v-if="t.icon" :class="[$style.tabIcon, t.icon]"></i>
<div v-if="!t.iconOnly" :class="$style.tabTitle">{{ t.title }}</div>
<Transition
v-else
@enter="enter"
@after-enter="afterEnter"
@leave="leave"
@after-leave="afterLeave"
mode="in-out"
>
<div v-if="t.key === tab" :class="$style.tabTitle">{{ t.title }}</div>
</Transition>
</div>
</button>
</div>
<div ref="tabHighlightEl" :class="$style.tabHighlight"></div>
</div>
<XTabs v-if="!narrow || hideTitle" :class="$style.tabs" :tab="tab" @update:tab="key => emit('update:tab', key)" :tabs="tabs" :root-el="el" @tab-click="onTabClick"/>
</template>
<div v-if="(narrow && !hideTitle) || (actions && actions.length > 0)" :class="$style.buttonsRight">
<div v-if="(!thin_ && narrow && !hideTitle) || (actions && actions.length > 0)" :class="$style.buttonsRight">
<template v-for="action in actions">
<button v-tooltip.noDelay="action.text" class="_button" :class="[$style.button, { [$style.highlighted]: action.highlighted }]" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button>
</template>
</div>
</div>
<div v-if="(narrow && !hideTitle) && hasTabs" :class="[$style.lower, { [$style.slim]: narrow, [$style.thin]: thin_ }]">
<div :class="$style.tabs" @wheel="onTabWheel">
<div :class="$style.tabsInner">
<button v-for="tab in tabs" :ref="(el) => tabRefs[tab.key] = (el as HTMLElement)" v-tooltip.noDelay="tab.title" class="_button" :class="[$style.tab, { [$style.active]: tab.key != null && tab.key === props.tab }]" @mousedown="(ev) => onTabMousedown(tab, ev)" @click="(ev) => onTabClick(tab, ev)">
<i v-if="tab.icon" :class="[$style.tabIcon, tab.icon]"></i>
<span v-if="!tab.iconOnly" :class="$style.tabTitle">{{ tab.title }}</span>
</button>
</div>
<div ref="tabHighlightEl" :class="$style.tabHighlight"></div>
</div>
<XTabs :class="$style.tabs" :tab="tab" @update:tab="key => emit('update:tab', key)" :tabs="tabs" :root-el="el" @tab-click="onTabClick"/>
</div>
</div>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, ref, inject, watch, nextTick } from 'vue';
import { onMounted, onUnmounted, ref, inject } from 'vue';
import tinycolor from 'tinycolor2';
import { scrollToTop } from '@/scripts/scroll';
import { globalEvents } from '@/events';
import { injectPageMetadata } from '@/scripts/page-metadata';
import { $i, openAccountMenu as openAccountMenu_ } from '@/account';
type Tab = {
key: string;
title: string;
icon?: string;
iconOnly?: boolean;
onClick?: (ev: MouseEvent) => void;
};
import XTabs, { Tab } from './MkPageHeader.tabs.vue'
const props = withDefaults(defineProps<{
tabs?: Tab[];
@ -102,8 +67,6 @@ const hideTitle = inject('shouldOmitHeaderTitle', false);
const thin_ = props.thin || inject('shouldHeaderThin', false);
let el = $shallowRef<HTMLElement | undefined>(undefined);
const tabRefs: Record<string, HTMLElement | null> = {};
let tabHighlightEl = $shallowRef<HTMLElement | null>(null);
const bg = ref<string | undefined>(undefined);
let narrow = $ref(false);
const hasTabs = $computed(() => props.tabs.length > 0);
@ -128,25 +91,8 @@ function openAccountMenu(ev: MouseEvent) {
}, ev);
}
function onTabMousedown(tab: Tab, ev: MouseEvent): void {
// mousedownonClick
if (tab.key) {
emit('update:tab', tab.key);
}
}
function onTabClick(t: Tab, ev: MouseEvent): void {
if (t.key === props.tab) {
top();
} else if (t.onClick) {
ev.preventDefault();
ev.stopPropagation();
t.onClick(ev);
}
if (t.key) {
emit('update:tab', t.key);
}
function onTabClick(): void {
top();
}
const calcBg = () => {
@ -156,88 +102,26 @@ const calcBg = () => {
bg.value = tinyBg.toRgbString();
};
let ro1: ResizeObserver | null;
let ro2: ResizeObserver | null;
function renderTab() {
const tabEl = props.tab ? tabRefs[props.tab] : undefined;
if (tabEl && tabHighlightEl && tabHighlightEl.parentElement) {
// offsetWidth offsetLeft getBoundingClientRect 使
// https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4
const parentRect = tabHighlightEl.parentElement.getBoundingClientRect();
const rect = tabEl.getBoundingClientRect();
tabHighlightEl.style.width = rect.width + 'px';
tabHighlightEl.style.left = (rect.left - parentRect.left + tabHighlightEl.parentElement.scrollLeft) + 'px';
}
}
function onTabWheel(ev: WheelEvent) {
if (ev.deltaY !== 0 && ev.deltaX === 0) {
ev.preventDefault();
ev.stopPropagation();
(ev.currentTarget as HTMLElement).scrollBy({
left: ev.deltaY,
behavior: 'smooth',
});
}
return false;
}
function enter(el: HTMLElement) {
const elementWidth = el.getBoundingClientRect().width;
el.style.width = '0';
el.offsetWidth; // reflow
el.style.width = elementWidth + 'px';
setTimeout(renderTab, 70);
}
function afterEnter(el: HTMLElement) {
el.style.width = '';
nextTick(renderTab);
}
function leave(el: HTMLElement) {
const elementWidth = el.getBoundingClientRect().width;
el.style.width = elementWidth + 'px';
el.offsetWidth; // reflow
el.style.width = '0';
}
function afterLeave(el: HTMLElement) {
el.style.width = '';
}
let ro: ResizeObserver | null;
onMounted(() => {
calcBg();
globalEvents.on('themeChanged', calcBg);
watch([() => props.tab, () => props.tabs], () => {
nextTick(() => renderTab());
}, {
immediate: true,
});
if (el && el.parentElement) {
narrow = el.parentElement.offsetWidth < 500;
ro1 = new ResizeObserver((entries, observer) => {
ro = new ResizeObserver((entries, observer) => {
if (el && el.parentElement && document.body.contains(el as HTMLElement)) {
narrow = el.parentElement.offsetWidth < 500;
}
});
ro1.observe(el.parentElement as HTMLElement);
}
if (el) {
ro2 = new ResizeObserver((entries, observer) => {
if (document.body.contains(el as HTMLElement)) {
nextTick(() => renderTab());
}
});
ro2.observe(el);
ro.observe(el.parentElement as HTMLElement);
}
});
onUnmounted(() => {
globalEvents.off('themeChanged', calcBg);
if (ro1) ro1.disconnect();
if (ro2) ro2.disconnect();
if (ro) ro.disconnect();
});
</script>
@ -258,6 +142,7 @@ onUnmounted(() => {
.upper {
--height: 50px;
display: flex;
gap: var(--margin);
height: var(--height);
.tabs:first-child {
@ -267,12 +152,9 @@ onUnmounted(() => {
padding-left: 16px;
mask-image: linear-gradient(90deg, rgba(0,0,0,0), rgb(0,0,0) 16px, rgb(0,0,0) 100%);
}
.tabs:last-child {
.tabs {
margin-right: auto;
}
.tabs:not(:last-child) {
margin-right: 0;
}
&.thin {
--height: 42px;
@ -286,19 +168,14 @@ onUnmounted(() => {
&.slim {
text-align: center;
gap: 0;
.tabs:first-child {
margin-left: 0;
}
> .titleContainer {
flex: 1;
margin: 0 auto;
max-width: 100%;
> *:first-child {
margin-left: auto;
}
> *:last-child {
margin-right: auto;
}
}
}
}
@ -314,8 +191,6 @@ onUnmounted(() => {
align-items: center;
min-width: var(--height);
height: var(--height);
margin: 0 var(--margin);
&:empty {
width: var(--height);
}
@ -323,12 +198,12 @@ onUnmounted(() => {
.buttonsLeft {
composes: buttons;
margin-right: auto;
margin: 0 var(--margin) 0 0;
}
.buttonsRight {
composes: buttons;
margin-left: auto;
margin: 0 0 0 var(--margin);
}
.avatar {
@ -373,7 +248,7 @@ onUnmounted(() => {
white-space: nowrap;
text-align: left;
font-weight: bold;
flex-shrink: 0;
flex-shrink: 1;
margin-left: 24px;
}
@ -418,68 +293,4 @@ onUnmounted(() => {
}
}
}
.tabs {
display: block;
position: relative;
margin: 0;
height: var(--height);
font-size: 0.8em;
text-align: center;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.tabsInner {
display: inline-block;
height: var(--height);
white-space: nowrap;
}
.tab {
display: inline-block;
position: relative;
padding: 0 10px;
height: 100%;
font-weight: normal;
opacity: 0.7;
transition: opacity 0.2s ease;
&:hover {
opacity: 1;
}
&.active {
opacity: 1;
}
}
.tabInner {
display: flex;
align-items: center;
}
.tabIcon + .tabTitle {
margin-left: 8px;
}
.tabTitle {
overflow: hidden;
transition: width 0.15s ease-in-out;
}
.tabHighlight {
position: absolute;
bottom: 0;
height: 3px;
background: var(--accent);
border-radius: 999px;
transition: width 0.15s ease, left 0.15s ease;
pointer-events: none;
}
</style>

View file

@ -17,6 +17,7 @@ import MkEmojiPickerWindow from '@/components/MkEmojiPickerWindow.vue';
import MkPopupMenu from '@/components/MkPopupMenu.vue';
import MkContextMenu from '@/components/MkContextMenu.vue';
import { MenuItem } from '@/types/menu';
import copyToClipboard from './scripts/copy-to-clipboard';
export const openingWindowsCount = ref(0);
@ -26,10 +27,32 @@ export const apiWithDialog = ((
token?: string | null | undefined,
) => {
const promise = api(endpoint, data, token);
promiseDialog(promise, null, (err) => {
promiseDialog(promise, null, async (err) => {
let title = null;
let text = err.message + '\n' + (err as any).id;
if (err.code === 'RATE_LIMIT_EXCEEDED') {
if (err.code === 'INTERNAL_ERROR') {
title = i18n.ts.internalServerError;
text = i18n.ts.internalServerErrorDescription;
const date = new Date().toISOString();
const { result } = await actions({
type: 'error',
title,
text,
actions: [{
value: 'ok',
text: i18n.ts.gotIt,
primary: true,
}, {
value: 'copy',
text: i18n.ts.copyErrorInfo,
}],
});
if (result === 'copy') {
copyToClipboard(`Endpoint: ${endpoint}\nInfo: ${JSON.stringify(err.info)}\nDate: ${date}`);
success();
}
return;
} else if (err.code === 'RATE_LIMIT_EXCEEDED') {
title = i18n.ts.cannotPerformTemporary;
text = i18n.ts.cannotPerformTemporaryDescription;
} else if (err.code.startsWith('TOO_MANY')) {

View file

@ -60,11 +60,17 @@ import { definePageMetadata } from '@/scripts/page-metadata';
let ads: any[] = $ref([]);
// ISOTZUTCTZ
const localTime = new Date();
const localTimeDiff = localTime.getTimezoneOffset() * 60 * 1000;
os.api('admin/ad/list').then(adsResponse => {
ads = adsResponse.map(r => {
const date = new Date(r.expiresAt);
date.setMilliseconds(date.getMilliseconds() - localTimeDiff);
return {
...r,
expiresAt: new Date(r.expiresAt).toISOString().slice(0, 16),
expiresAt: date.toISOString().slice(0, 16),
};
});
});

View file

@ -72,7 +72,7 @@
<MkSwitch v-model="policies.rateLimitFactor.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkRange :model-value="policies.rateLimitFactor.value * 100" :min="30" :max="300" :step="10" :text-converter="(v) => `${v}%`" @update:model-value="v => policies.rateLimitFactor.value = (v / 100)">
<MkRange :model-value="policies.rateLimitFactor.value * 100" :min="0" :max="400" :step="10" :text-converter="(v) => `${v}%`" @update:model-value="v => policies.rateLimitFactor.value = (v / 100)">
<template #label>{{ i18n.ts._role._options.rateLimitFactor }}</template>
<template #caption>{{ i18n.ts._role._options.descriptionOfRateLimitFactor }}</template>
</MkRange>

View file

@ -45,6 +45,7 @@
<div class="_gaps_m">
<div class="_gaps_s">
<MkSwitch v-model="collapseRenotes">{{ i18n.ts.collapseRenotes }}</MkSwitch>
<MkSwitch v-model="advancedMfm">{{ i18n.ts.enableAdvancedMfm }}</MkSwitch>
<MkSwitch v-if="advancedMfm" v-model="animatedMfm">{{ i18n.ts.enableAnimatedMfm }}</MkSwitch>
<MkSwitch v-model="reduceAnimation">{{ i18n.ts.reduceUiAnimation }}</MkSwitch>
@ -139,6 +140,7 @@ async function reloadAsk() {
const overridedDeviceKind = computed(defaultStore.makeGetterSetter('overridedDeviceKind'));
const serverDisconnectedBehavior = computed(defaultStore.makeGetterSetter('serverDisconnectedBehavior'));
const collapseRenotes = computed(defaultStore.makeGetterSetter('collapseRenotes'));
const reduceAnimation = computed(defaultStore.makeGetterSetter('animation', v => !v, v => !v));
const useBlurEffectForModal = computed(defaultStore.makeGetterSetter('useBlurEffectForModal'));
const useBlurEffect = computed(defaultStore.makeGetterSetter('useBlurEffect'));

View file

@ -32,6 +32,7 @@ import { i18n } from '@/i18n';
import { instance } from '@/instance';
import { $i } from '@/account';
import { definePageMetadata } from '@/scripts/page-metadata';
import type { Tab } from '@/components/global/MkPageHeader.tabs.vue';
provide('shouldOmitHeaderTitle', true);
@ -57,7 +58,7 @@ function queueUpdated(q: number): void {
}
function top(): void {
scroll(rootEl, { top: 0 });
if (rootEl) scroll(rootEl, { top: 0 });
}
async function chooseList(ev: MouseEvent): Promise<void> {
@ -150,7 +151,7 @@ const headerTabs = $computed(() => [{
title: i18n.ts.channel,
iconOnly: true,
onClick: chooseChannel,
}]);
}] as Tab[]);
const headerTabsWhenNotLogin = $computed(() => [
...(isLocalTimelineAvailable ? [{
@ -165,7 +166,7 @@ const headerTabsWhenNotLogin = $computed(() => [
icon: 'ti ti-whirl',
iconOnly: true,
}] : []),
]);
] as Tab[]);
definePageMetadata(computed(() => ({
title: i18n.ts.timeline,

View file

@ -75,15 +75,15 @@
</dl>
</div>
<div class="status">
<MkA v-click-anime :to="userPage(user)" :class="{ active: page === 'index' }">
<MkA v-click-anime :to="userPage(user)">
<b>{{ number(user.notesCount) }}</b>
<span>{{ i18n.ts.notes }}</span>
</MkA>
<MkA v-click-anime :to="userPage(user, 'following')" :class="{ active: page === 'following' }">
<MkA v-click-anime :to="userPage(user, 'following')">
<b>{{ number(user.followingCount) }}</b>
<span>{{ i18n.ts.following }}</span>
</MkA>
<MkA v-click-anime :to="userPage(user, 'followers')" :class="{ active: page === 'followers' }">
<MkA v-click-anime :to="userPage(user, 'followers')">
<b>{{ number(user.followersCount) }}</b>
<span>{{ i18n.ts.followers }}</span>
</MkA>
@ -100,6 +100,7 @@
<XPhotos :key="user.id" :user="user"/>
<XActivity :key="user.id" :user="user"/>
</template>
<XNotes :no-gap="true" :pagination="pagination"/>
</div>
</div>
<div v-if="!narrow" class="sub _gaps" style="container-type: inline-size;">
@ -132,6 +133,7 @@ import { i18n } from '@/i18n';
import { $i } from '@/account';
import { dateString } from '@/filters/date';
import { confetti } from '@/scripts/confetti';
import XNotes from '@/components/MkNotes.vue';
const XPhotos = defineAsyncComponent(() => import('./index.photos.vue'));
const XActivity = defineAsyncComponent(() => import('./index.activity.vue'));
@ -148,6 +150,14 @@ let narrow = $ref<null | boolean>(null);
let rootEl = $ref<null | HTMLElement>(null);
let bannerEl = $ref<null | HTMLElement>(null);
const pagination = {
endpoint: 'users/notes' as const,
limit: 10,
params: computed(() => ({
userId: props.user.id,
})),
};
const style = $computed(() => {
if (props.user.bannerUrl == null) return {};
return {

View file

@ -6,6 +6,7 @@
<div v-if="user">
<XHome v-if="tab === 'home'" :user="user"/>
<XTimeline v-else-if="tab === 'notes'" :user="user" />
<XActivity v-else-if="tab === 'activity'" :user="user"/>
<XAchievements v-else-if="tab === 'achievements'" :user="user"/>
<XReactions v-else-if="tab === 'reactions'" :user="user"/>
<XClips v-else-if="tab === 'clips'" :user="user"/>
@ -20,13 +21,10 @@
</template>
<script lang="ts" setup>
import { defineAsyncComponent, computed, inject, onMounted, onUnmounted, watch } from 'vue';
import calcAge from 's-age';
import { defineAsyncComponent, computed, watch } from 'vue';
import * as Acct from 'misskey-js/built/acct';
import * as misskey from 'misskey-js';
import { getScrollPosition } from '@/scripts/scroll';
import number from '@/filters/number';
import { userPage, acct as getAcct } from '@/filters/user';
import { acct as getAcct } from '@/filters/user';
import * as os from '@/os';
import { useRouter } from '@/router';
import { definePageMetadata } from '@/scripts/page-metadata';
@ -49,8 +47,6 @@ const props = withDefaults(defineProps<{
page: 'home',
});
const router = useRouter();
let tab = $ref(props.page);
let user = $ref<null | misskey.entities.UserDetailed>(null);
let error = $ref(null);

View file

@ -46,6 +46,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'account',
default: false,
},
collapseRenotes: {
where: 'account',
default: true,
},
rememberNoteVisibility: {
where: 'account',
default: false,