Merge remote-branch 'misskey/develop'
This commit is contained in:
commit
b21813b9c9
|
@ -21,6 +21,8 @@
|
|||
### Client
|
||||
- Fix: サーバーメトリクスが90度傾いている
|
||||
- Fix: sparkle内にリンクを入れるとクリック不能になる問題の修正
|
||||
- deck UIのカラムのメニューからアンテナとリストの編集画面を開けるように
|
||||
- ドライブファイルのメニューで画像をクロップできるように
|
||||
|
||||
### Server
|
||||
- JSON.parse の回数を削減することで、ストリーミングのパフォーマンスを向上しました
|
||||
|
|
2
locales/index.d.ts
vendored
2
locales/index.d.ts
vendored
|
@ -171,8 +171,10 @@ export interface Locale {
|
|||
"suspendConfirm": string;
|
||||
"unsuspendConfirm": string;
|
||||
"selectList": string;
|
||||
"editList": string;
|
||||
"selectChannel": string;
|
||||
"selectAntenna": string;
|
||||
"editAntenna": string;
|
||||
"selectWidget": string;
|
||||
"editWidgets": string;
|
||||
"editWidgetsExit": string;
|
||||
|
|
|
@ -168,8 +168,10 @@ unblockConfirm: "ブロック解除しますか?"
|
|||
suspendConfirm: "凍結しますか?"
|
||||
unsuspendConfirm: "解凍しますか?"
|
||||
selectList: "リストを選択"
|
||||
editList: "リストを編集"
|
||||
selectChannel: "チャンネルを選択"
|
||||
selectAntenna: "アンテナを選択"
|
||||
editAntenna: "アンテナを編集"
|
||||
selectWidget: "ウィジェットを選択"
|
||||
editWidgets: "ウィジェットを編集"
|
||||
editWidgetsExit: "編集を終了"
|
||||
|
|
|
@ -8,7 +8,7 @@ import { DI } from '@/di-symbols.js';
|
|||
import { bindThis } from '@/decorators.js';
|
||||
import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js';
|
||||
import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, RelationshipQueue, SystemQueue, WebhookDeliverQueue } from './QueueModule.js';
|
||||
import type { DbJobData, RelationshipJobData, ThinUser } from '../queue/types.js';
|
||||
import type { DbJobData, DeliverJobData, RelationshipJobData, ThinUser } from '../queue/types.js';
|
||||
import type httpSignature from '@peertube/http-signature';
|
||||
import type * as Bull from 'bullmq';
|
||||
|
||||
|
@ -69,7 +69,7 @@ export class QueueService {
|
|||
if (content == null) return null;
|
||||
if (to == null) return null;
|
||||
|
||||
const data = {
|
||||
const data: DeliverJobData = {
|
||||
user: {
|
||||
id: user.id,
|
||||
},
|
||||
|
@ -88,6 +88,38 @@ export class QueueService {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ApDeliverManager-DeliverManager.execute()からinboxesを突っ込んでaddBulkしたい
|
||||
* @param user `{ id: string; }` この関数ではThinUserに変換しないので前もって変換してください
|
||||
* @param content IActivity | null
|
||||
* @param inboxes `Map<string, boolean>` / key: to (inbox url), value: isSharedInbox (whether it is sharedInbox)
|
||||
* @returns void
|
||||
*/
|
||||
@bindThis
|
||||
public async deliverMany(user: ThinUser, content: IActivity | null, inboxes: Map<string, boolean>) {
|
||||
const opts = {
|
||||
attempts: this.config.deliverJobMaxAttempts ?? 12,
|
||||
backoff: {
|
||||
type: 'custom',
|
||||
},
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
};
|
||||
|
||||
await this.deliverQueue.addBulk(Array.from(inboxes.entries()).map(d => ({
|
||||
name: d[0],
|
||||
data: {
|
||||
user,
|
||||
content,
|
||||
to: d[0],
|
||||
isSharedInbox: d[1],
|
||||
} as DeliverJobData,
|
||||
opts,
|
||||
})));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public inbox(activity: IActivity, signature: httpSignature.IParsedSignature) {
|
||||
const data = {
|
||||
|
|
|
@ -7,6 +7,8 @@ import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js';
|
|||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { IActivity } from '@/core/activitypub/type.js';
|
||||
import { ThinUser } from '@/queue/types.js';
|
||||
|
||||
interface IRecipe {
|
||||
type: string;
|
||||
|
@ -21,10 +23,10 @@ interface IDirectRecipe extends IRecipe {
|
|||
to: RemoteUser;
|
||||
}
|
||||
|
||||
const isFollowers = (recipe: any): recipe is IFollowersRecipe =>
|
||||
const isFollowers = (recipe: IRecipe): recipe is IFollowersRecipe =>
|
||||
recipe.type === 'Followers';
|
||||
|
||||
const isDirect = (recipe: any): recipe is IDirectRecipe =>
|
||||
const isDirect = (recipe: IRecipe): recipe is IDirectRecipe =>
|
||||
recipe.type === 'Direct';
|
||||
|
||||
@Injectable()
|
||||
|
@ -46,11 +48,11 @@ export class ApDeliverManagerService {
|
|||
|
||||
/**
|
||||
* Deliver activity to followers
|
||||
* @param actor
|
||||
* @param activity Activity
|
||||
* @param from Followee
|
||||
*/
|
||||
@bindThis
|
||||
public async deliverToFollowers(actor: { id: LocalUser['id']; host: null; }, activity: any) {
|
||||
public async deliverToFollowers(actor: { id: LocalUser['id']; host: null; }, activity: IActivity) {
|
||||
const manager = new DeliverManager(
|
||||
this.userEntityService,
|
||||
this.followingsRepository,
|
||||
|
@ -64,11 +66,12 @@ export class ApDeliverManagerService {
|
|||
|
||||
/**
|
||||
* Deliver activity to user
|
||||
* @param actor
|
||||
* @param activity Activity
|
||||
* @param to Target user
|
||||
*/
|
||||
@bindThis
|
||||
public async deliverToUser(actor: { id: LocalUser['id']; host: null; }, activity: any, to: RemoteUser) {
|
||||
public async deliverToUser(actor: { id: LocalUser['id']; host: null; }, activity: IActivity, to: RemoteUser) {
|
||||
const manager = new DeliverManager(
|
||||
this.userEntityService,
|
||||
this.followingsRepository,
|
||||
|
@ -81,7 +84,7 @@ export class ApDeliverManagerService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public createDeliverManager(actor: { id: User['id']; host: null; }, activity: any) {
|
||||
public createDeliverManager(actor: { id: User['id']; host: null; }, activity: IActivity | null) {
|
||||
return new DeliverManager(
|
||||
this.userEntityService,
|
||||
this.followingsRepository,
|
||||
|
@ -94,12 +97,15 @@ export class ApDeliverManagerService {
|
|||
}
|
||||
|
||||
class DeliverManager {
|
||||
private actor: { id: User['id']; host: null; };
|
||||
private activity: any;
|
||||
private actor: ThinUser;
|
||||
private activity: IActivity | null;
|
||||
private recipes: IRecipe[] = [];
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
* @param userEntityService
|
||||
* @param followingsRepository
|
||||
* @param queueService
|
||||
* @param actor Actor
|
||||
* @param activity Activity to deliver
|
||||
*/
|
||||
|
@ -109,9 +115,15 @@ class DeliverManager {
|
|||
private queueService: QueueService,
|
||||
|
||||
actor: { id: User['id']; host: null; },
|
||||
activity: any,
|
||||
activity: IActivity | null,
|
||||
) {
|
||||
this.actor = actor;
|
||||
// 型で弾いてはいるが一応ローカルユーザーかチェック
|
||||
if (actor.host != null) throw new Error('actor.host must be null');
|
||||
|
||||
// パフォーマンス向上のためキューに突っ込むのはidのみに絞る
|
||||
this.actor = {
|
||||
id: actor.id,
|
||||
};
|
||||
this.activity = activity;
|
||||
}
|
||||
|
||||
|
@ -155,9 +167,8 @@ class DeliverManager {
|
|||
*/
|
||||
@bindThis
|
||||
public async execute() {
|
||||
if (!this.userEntityService.isLocalUser(this.actor)) return;
|
||||
|
||||
// The value flags whether it is shared or not.
|
||||
// key: inbox URL, value: whether it is sharedInbox
|
||||
const inboxes = new Map<string, boolean>();
|
||||
|
||||
/*
|
||||
|
@ -201,9 +212,6 @@ class DeliverManager {
|
|||
.forEach(recipe => inboxes.set(recipe.to.inbox!, false));
|
||||
|
||||
// deliver
|
||||
for (const inbox of inboxes) {
|
||||
// inbox[0]: inbox, inbox[1]: whether it is sharedInbox
|
||||
this.queueService.deliver(this.actor, this.activity, inbox[0], inbox[1]);
|
||||
}
|
||||
this.queueService.deliverMany(this.actor, this.activity, inboxes);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -47,6 +47,7 @@ const emit = defineEmits<{
|
|||
const props = defineProps<{
|
||||
file: misskey.entities.DriveFile;
|
||||
aspectRatio: number;
|
||||
uploadFolder?: string | null;
|
||||
}>();
|
||||
|
||||
const imgUrl = getProxiedImageUrl(props.file.url, undefined, true);
|
||||
|
@ -58,11 +59,17 @@ let loading = $ref(true);
|
|||
const ok = async () => {
|
||||
const promise = new Promise<misskey.entities.DriveFile>(async (res) => {
|
||||
const croppedCanvas = await cropper?.getCropperSelection()?.$toCanvas();
|
||||
croppedCanvas.toBlob(blob => {
|
||||
croppedCanvas?.toBlob(blob => {
|
||||
if (!blob) return;
|
||||
const formData = new FormData();
|
||||
formData.append('file', blob);
|
||||
formData.append('i', $i.token);
|
||||
if (defaultStore.state.uploadFolder) {
|
||||
formData.append('name', `cropped_${props.file.name}`);
|
||||
formData.append('isSensitive', props.file.isSensitive ? 'true' : 'false');
|
||||
formData.append('comment', props.file.comment ?? 'null');
|
||||
formData.append('i', $i!.token);
|
||||
if (props.uploadFolder || props.uploadFolder === null) {
|
||||
formData.append('folderId', props.uploadFolder ?? 'null');
|
||||
} else if (defaultStore.state.uploadFolder) {
|
||||
formData.append('folderId', defaultStore.state.uploadFolder);
|
||||
}
|
||||
|
||||
|
@ -82,12 +89,12 @@ const ok = async () => {
|
|||
const f = await promise;
|
||||
|
||||
emit('ok', f);
|
||||
dialogEl.close();
|
||||
dialogEl!.close();
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
emit('cancel');
|
||||
dialogEl.close();
|
||||
dialogEl!.close();
|
||||
};
|
||||
|
||||
const onImageLoad = () => {
|
||||
|
@ -100,7 +107,7 @@ const onImageLoad = () => {
|
|||
};
|
||||
|
||||
onMounted(() => {
|
||||
cropper = new Cropper(imgEl, {
|
||||
cropper = new Cropper(imgEl!, {
|
||||
});
|
||||
|
||||
const computedStyle = getComputedStyle(document.documentElement);
|
||||
|
@ -112,13 +119,13 @@ onMounted(() => {
|
|||
selection.outlined = true;
|
||||
|
||||
window.setTimeout(() => {
|
||||
cropper.getCropperImage()!.$center('contain');
|
||||
cropper!.getCropperImage()!.$center('contain');
|
||||
selection.$center();
|
||||
}, 100);
|
||||
|
||||
// モーダルオープンアニメーションが終わったあとで再度調整
|
||||
window.setTimeout(() => {
|
||||
cropper.getCropperImage()!.$center('contain');
|
||||
cropper!.getCropperImage()!.$center('contain');
|
||||
selection.$center();
|
||||
}, 500);
|
||||
});
|
||||
|
|
|
@ -44,6 +44,7 @@ import { getDriveFileMenu } from '@/scripts/get-drive-file-menu';
|
|||
|
||||
const props = withDefaults(defineProps<{
|
||||
file: Misskey.entities.DriveFile;
|
||||
folder: Misskey.entities.DriveFolder | null;
|
||||
isSelected?: boolean;
|
||||
selectMode?: boolean;
|
||||
}>(), {
|
||||
|
@ -65,12 +66,12 @@ function onClick(ev: MouseEvent) {
|
|||
if (props.selectMode) {
|
||||
emit('chosen', props.file);
|
||||
} else {
|
||||
os.popupMenu(getDriveFileMenu(props.file), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
|
||||
os.popupMenu(getDriveFileMenu(props.file, props.folder), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function onContextmenu(ev: MouseEvent) {
|
||||
os.contextMenu(getDriveFileMenu(props.file), ev);
|
||||
os.contextMenu(getDriveFileMenu(props.file, props.folder), ev);
|
||||
}
|
||||
|
||||
function onDragstart(ev: DragEvent) {
|
||||
|
|
|
@ -65,6 +65,7 @@
|
|||
v-anim="i"
|
||||
:class="$style.file"
|
||||
:file="file"
|
||||
:folder="folder"
|
||||
:selectMode="select === 'file'"
|
||||
:isSelected="selectedFiles.some(x => x.id === file.id)"
|
||||
@chosen="chooseFile"
|
||||
|
|
|
@ -21,14 +21,14 @@
|
|||
|
||||
<div v-else ref="rootEl">
|
||||
<div v-show="pagination.reversed && more" key="_more_" class="_margin">
|
||||
<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? fetchMoreAhead : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMoreAhead">
|
||||
<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMoreAhead : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMoreAhead">
|
||||
{{ i18n.ts.loadMore }}
|
||||
</MkButton>
|
||||
<MkLoading v-else class="loading"/>
|
||||
</div>
|
||||
<slot :items="items" :fetching="fetching || moreFetching"></slot>
|
||||
<slot :items="Array.from(items.values())" :fetching="fetching || moreFetching"></slot>
|
||||
<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 rounded @click="fetchMore">
|
||||
<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMore : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMore">
|
||||
{{ i18n.ts.loadMore }}
|
||||
</MkButton>
|
||||
<MkLoading v-else class="loading"/>
|
||||
|
@ -44,12 +44,13 @@ import * as os from '@/os';
|
|||
import { onScrollTop, isTopVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isBottomVisible } from '@/scripts/scroll';
|
||||
import { useDocumentVisibility } from '@/scripts/use-document-visibility';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { defaultStore } from '@/store';
|
||||
import { MisskeyEntity } from '@/types/date-separated-list';
|
||||
import { i18n } from '@/i18n';
|
||||
import { defaultStore } from '@/store';
|
||||
|
||||
const SECOND_FETCH_LIMIT = 30;
|
||||
const TOLERANCE = 16;
|
||||
const APPEAR_MINIMUM_INTERVAL = 600;
|
||||
|
||||
export type Paging<E extends keyof misskey.Endpoints = keyof misskey.Endpoints> = {
|
||||
endpoint: E;
|
||||
|
@ -71,6 +72,16 @@ export type Paging<E extends keyof misskey.Endpoints = keyof misskey.Endpoints>
|
|||
|
||||
pageEl?: HTMLElement;
|
||||
};
|
||||
|
||||
type MisskeyEntityMap = Map<string, MisskeyEntity>;
|
||||
|
||||
function arrayToEntries(entities: MisskeyEntity[]): [string, MisskeyEntity][] {
|
||||
return entities.map(en => [en.id, en]);
|
||||
}
|
||||
|
||||
function concatMapWithArray(map: MisskeyEntityMap, entities: MisskeyEntity[]): MisskeyEntityMap {
|
||||
return new Map([...map, ...arrayToEntries(entities)]);
|
||||
}
|
||||
</script>
|
||||
<script lang="ts" setup>
|
||||
import { infoImageUrl } from '@/instance';
|
||||
|
@ -94,21 +105,38 @@ let backed = $ref(false);
|
|||
|
||||
let scrollRemove = $ref<(() => void) | null>(null);
|
||||
|
||||
const items = ref<MisskeyEntity[]>([]);
|
||||
const queue = ref<MisskeyEntity[]>([]);
|
||||
/**
|
||||
* 表示するアイテムのソース
|
||||
* 最新が0番目
|
||||
*/
|
||||
const items = ref<MisskeyEntityMap>(new Map());
|
||||
|
||||
/**
|
||||
* タブが非アクティブなどの場合に更新を貯めておく
|
||||
* 最新が0番目
|
||||
*/
|
||||
const queue = ref<MisskeyEntityMap>(new Map());
|
||||
|
||||
const offset = ref(0);
|
||||
|
||||
/**
|
||||
* 初期化中かどうか(trueならMkLoadingで全て隠す)
|
||||
*/
|
||||
const fetching = ref(true);
|
||||
|
||||
const moreFetching = ref(false);
|
||||
const more = ref(false);
|
||||
const preventAppearFetchMore = ref(false);
|
||||
const preventAppearFetchMoreTimer = ref<number | null>(null);
|
||||
const isBackTop = ref(false);
|
||||
const empty = computed(() => items.value.length === 0);
|
||||
const empty = computed(() => items.value.size === 0);
|
||||
const error = ref(false);
|
||||
const {
|
||||
enableInfiniteScroll,
|
||||
} = defaultStore.reactiveState;
|
||||
|
||||
const contentEl = $computed(() => props.pagination.pageEl ?? rootEl);
|
||||
const scrollableElement = $computed(() => getScrollContainer(contentEl));
|
||||
const scrollableElement = $computed(() => contentEl ? getScrollContainer(contentEl) : document.body);
|
||||
|
||||
const visibility = useDocumentVisibility();
|
||||
|
||||
|
@ -133,9 +161,9 @@ watch([() => props.pagination.reversed, $$(scrollableElement)], () => {
|
|||
}, { immediate: true });
|
||||
|
||||
watch($$(rootEl), () => {
|
||||
scrollObserver.disconnect();
|
||||
scrollObserver?.disconnect();
|
||||
nextTick(() => {
|
||||
if (rootEl) scrollObserver.observe(rootEl);
|
||||
if (rootEl) scrollObserver?.observe(rootEl);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -155,31 +183,32 @@ if (props.pagination.params && isRef(props.pagination.params)) {
|
|||
}
|
||||
|
||||
watch(queue, (a, b) => {
|
||||
if (a.length === 0 && b.length === 0) return;
|
||||
emit('queue', queue.value.length);
|
||||
if (a.size === 0 && b.size === 0) return;
|
||||
emit('queue', queue.value.size);
|
||||
}, { deep: true });
|
||||
|
||||
async function init(): Promise<void> {
|
||||
queue.value = [];
|
||||
queue.value = new Map();
|
||||
fetching.value = true;
|
||||
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
|
||||
await os.api(props.pagination.endpoint, {
|
||||
...params,
|
||||
limit: props.pagination.noPaging ? (props.pagination.limit || 10) : (props.pagination.limit || 10) + 1,
|
||||
limit: props.pagination.limit ?? 10,
|
||||
}).then(res => {
|
||||
for (let i = 0; i < res.length; i++) {
|
||||
const item = res[i];
|
||||
if (i === 3) item._shouldInsertAd_ = true;
|
||||
}
|
||||
if (!props.pagination.noPaging && (res.length > (props.pagination.limit || 10))) {
|
||||
res.pop();
|
||||
if (props.pagination.reversed) moreFetching.value = true;
|
||||
items.value = res;
|
||||
more.value = true;
|
||||
} else {
|
||||
items.value = res;
|
||||
|
||||
if (res.length === 0 || props.pagination.noPaging) {
|
||||
concatItems(res);
|
||||
more.value = false;
|
||||
} else {
|
||||
if (props.pagination.reversed) moreFetching.value = true;
|
||||
concatItems(res);
|
||||
more.value = true;
|
||||
}
|
||||
|
||||
offset.value = res.length;
|
||||
error.value = false;
|
||||
fetching.value = false;
|
||||
|
@ -190,21 +219,22 @@ async function init(): Promise<void> {
|
|||
}
|
||||
|
||||
const reload = (): Promise<void> => {
|
||||
items.value = [];
|
||||
items.value = new Map();
|
||||
queue.value = new Map();
|
||||
return init();
|
||||
};
|
||||
|
||||
const fetchMore = async (): Promise<void> => {
|
||||
if (!more.value || fetching.value || moreFetching.value || items.value.length === 0) return;
|
||||
if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return;
|
||||
moreFetching.value = true;
|
||||
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
|
||||
await os.api(props.pagination.endpoint, {
|
||||
...params,
|
||||
limit: SECOND_FETCH_LIMIT + 1,
|
||||
limit: SECOND_FETCH_LIMIT,
|
||||
...(props.pagination.offsetMode ? {
|
||||
offset: offset.value,
|
||||
} : {
|
||||
untilId: items.value[items.value.length - 1].id,
|
||||
untilId: Array.from(items.value.keys())[items.value.size - 1],
|
||||
}),
|
||||
}).then(res => {
|
||||
for (let i = 0; i < res.length; i++) {
|
||||
|
@ -216,7 +246,7 @@ const fetchMore = async (): Promise<void> => {
|
|||
const oldHeight = scrollableElement ? scrollableElement.scrollHeight : getBodyScrollHeight();
|
||||
const oldScroll = scrollableElement ? scrollableElement.scrollTop : window.scrollY;
|
||||
|
||||
items.value = items.value.concat(_res);
|
||||
items.value = concatMapWithArray(items.value, _res);
|
||||
|
||||
return nextTick(() => {
|
||||
if (scrollableElement) {
|
||||
|
@ -229,30 +259,28 @@ const fetchMore = async (): Promise<void> => {
|
|||
});
|
||||
};
|
||||
|
||||
if (res.length > SECOND_FETCH_LIMIT) {
|
||||
res.pop();
|
||||
|
||||
if (props.pagination.reversed) {
|
||||
reverseConcat(res).then(() => {
|
||||
more.value = true;
|
||||
moreFetching.value = false;
|
||||
});
|
||||
} else {
|
||||
items.value = items.value.concat(res);
|
||||
more.value = true;
|
||||
moreFetching.value = false;
|
||||
}
|
||||
} else {
|
||||
if (res.length === 0) {
|
||||
if (props.pagination.reversed) {
|
||||
reverseConcat(res).then(() => {
|
||||
more.value = false;
|
||||
moreFetching.value = false;
|
||||
});
|
||||
} else {
|
||||
items.value = items.value.concat(res);
|
||||
items.value = concatMapWithArray(items.value, res);
|
||||
more.value = false;
|
||||
moreFetching.value = false;
|
||||
}
|
||||
} else {
|
||||
if (props.pagination.reversed) {
|
||||
reverseConcat(res).then(() => {
|
||||
more.value = true;
|
||||
moreFetching.value = false;
|
||||
});
|
||||
} else {
|
||||
items.value = concatMapWithArray(items.value, res);
|
||||
more.value = true;
|
||||
moreFetching.value = false;
|
||||
}
|
||||
}
|
||||
offset.value += res.length;
|
||||
}, err => {
|
||||
|
@ -261,25 +289,24 @@ const fetchMore = async (): Promise<void> => {
|
|||
};
|
||||
|
||||
const fetchMoreAhead = async (): Promise<void> => {
|
||||
if (!more.value || fetching.value || moreFetching.value || items.value.length === 0) return;
|
||||
if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return;
|
||||
moreFetching.value = true;
|
||||
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
|
||||
await os.api(props.pagination.endpoint, {
|
||||
...params,
|
||||
limit: SECOND_FETCH_LIMIT + 1,
|
||||
limit: SECOND_FETCH_LIMIT,
|
||||
...(props.pagination.offsetMode ? {
|
||||
offset: offset.value,
|
||||
} : {
|
||||
sinceId: items.value[items.value.length - 1].id,
|
||||
sinceId: Array.from(items.value.keys())[items.value.size - 1],
|
||||
}),
|
||||
}).then(res => {
|
||||
if (res.length > SECOND_FETCH_LIMIT) {
|
||||
res.pop();
|
||||
items.value = items.value.concat(res);
|
||||
more.value = true;
|
||||
} else {
|
||||
items.value = items.value.concat(res);
|
||||
if (res.length === 0) {
|
||||
items.value = concatMapWithArray(items.value, res);
|
||||
more.value = false;
|
||||
} else {
|
||||
items.value = concatMapWithArray(items.value, res);
|
||||
more.value = true;
|
||||
}
|
||||
offset.value += res.length;
|
||||
moreFetching.value = false;
|
||||
|
@ -288,7 +315,32 @@ const fetchMoreAhead = async (): Promise<void> => {
|
|||
});
|
||||
};
|
||||
|
||||
const isTop = (): boolean => isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl, TOLERANCE);
|
||||
/**
|
||||
* Appear(IntersectionObserver)によってfetchMoreが呼ばれる場合、
|
||||
* APPEAR_MINIMUM_INTERVALミリ秒以内に2回fetchMoreが呼ばれるのを防ぐ
|
||||
*/
|
||||
const fetchMoreApperTimeoutFn = (): void => {
|
||||
preventAppearFetchMore.value = false;
|
||||
preventAppearFetchMoreTimer.value = null;
|
||||
};
|
||||
const fetchMoreAppearTimeout = (): void => {
|
||||
preventAppearFetchMore.value = true;
|
||||
preventAppearFetchMoreTimer.value = window.setTimeout(fetchMoreApperTimeoutFn, APPEAR_MINIMUM_INTERVAL);
|
||||
};
|
||||
|
||||
const appearFetchMore = async (): Promise<void> => {
|
||||
if (preventAppearFetchMore.value) return;
|
||||
await fetchMore();
|
||||
fetchMoreAppearTimeout();
|
||||
};
|
||||
|
||||
const appearFetchMoreAhead = async (): Promise<void> => {
|
||||
if (preventAppearFetchMore.value) return;
|
||||
await fetchMoreAhead();
|
||||
fetchMoreAppearTimeout();
|
||||
};
|
||||
|
||||
const isTop = (): boolean => isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl!, TOLERANCE);
|
||||
|
||||
watch(visibility, () => {
|
||||
if (visibility.value === 'hidden') {
|
||||
|
@ -310,10 +362,15 @@ watch(visibility, () => {
|
|||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 最新のものとして1つだけアイテムを追加する
|
||||
* ストリーミングから降ってきたアイテムはこれで追加する
|
||||
* @param item アイテム
|
||||
*/
|
||||
const prepend = (item: MisskeyEntity): void => {
|
||||
// 初回表示時はunshiftだけでOK
|
||||
if (!rootEl) {
|
||||
items.value.unshift(item);
|
||||
if (items.value.size === 0) {
|
||||
items.value.set(item.id, item);
|
||||
fetching.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -321,38 +378,55 @@ const prepend = (item: MisskeyEntity): void => {
|
|||
else prependQueue(item);
|
||||
};
|
||||
|
||||
/**
|
||||
* 新着アイテムをitemsの先頭に追加し、displayLimitを適用する
|
||||
* @param newItems 新しいアイテムの配列
|
||||
*/
|
||||
function unshiftItems(newItems: MisskeyEntity[]) {
|
||||
const length = newItems.length + items.value.length;
|
||||
items.value = [...newItems, ...items.value].slice(0, props.displayLimit);
|
||||
const length = newItems.length + items.value.size;
|
||||
items.value = new Map([...arrayToEntries(newItems), ...items.value].slice(0, props.displayLimit));
|
||||
|
||||
if (length >= props.displayLimit) more.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 古いアイテムをitemsの末尾に追加し、displayLimitを適用する
|
||||
* @param oldItems 古いアイテムの配列
|
||||
*/
|
||||
function concatItems(oldItems: MisskeyEntity[]) {
|
||||
const length = oldItems.length + items.value.size;
|
||||
items.value = new Map([...items.value, ...arrayToEntries(oldItems)].slice(0, props.displayLimit));
|
||||
|
||||
if (length >= props.displayLimit) more.value = true;
|
||||
}
|
||||
|
||||
function executeQueue() {
|
||||
if (queue.value.length === 0) return;
|
||||
unshiftItems(queue.value);
|
||||
queue.value = [];
|
||||
unshiftItems(Array.from(queue.value.values()));
|
||||
queue.value = new Map();
|
||||
}
|
||||
|
||||
function prependQueue(newItem: MisskeyEntity) {
|
||||
queue.value.unshift(newItem);
|
||||
if (queue.value.length >= props.displayLimit) {
|
||||
queue.value.pop();
|
||||
}
|
||||
queue.value = new Map([[newItem.id, newItem], ...queue.value].slice(0, props.displayLimit) as [string, MisskeyEntity][]);
|
||||
}
|
||||
|
||||
/*
|
||||
* アイテムを末尾に追加する(使うの?)
|
||||
*/
|
||||
const appendItem = (item: MisskeyEntity): void => {
|
||||
items.value.push(item);
|
||||
items.value.set(item.id, item);
|
||||
};
|
||||
|
||||
const removeItem = (finder: (item: MisskeyEntity) => boolean) => {
|
||||
const i = items.value.findIndex(finder);
|
||||
items.value.splice(i, 1);
|
||||
const removeItem = (id: string) => {
|
||||
items.value.delete(id);
|
||||
queue.value.delete(id);
|
||||
};
|
||||
|
||||
const updateItem = (id: MisskeyEntity['id'], replacer: (old: MisskeyEntity) => MisskeyEntity): void => {
|
||||
const i = items.value.findIndex(item => item.id === id);
|
||||
items.value[i] = replacer(items.value[i]);
|
||||
const item = items.value.get(id);
|
||||
if (item) items.value.set(id, replacer(item));
|
||||
|
||||
const queueItem = queue.value.get(id);
|
||||
if (queueItem) queue.value.set(id, replacer(queueItem));
|
||||
};
|
||||
|
||||
const inited = init();
|
||||
|
@ -366,7 +440,7 @@ onDeactivated(() => {
|
|||
});
|
||||
|
||||
function toBottom() {
|
||||
scrollToBottom(contentEl);
|
||||
scrollToBottom(contentEl!);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
@ -390,7 +464,11 @@ onBeforeUnmount(() => {
|
|||
clearTimeout(timerForSetPause);
|
||||
timerForSetPause = null;
|
||||
}
|
||||
scrollObserver.disconnect();
|
||||
if (preventAppearFetchMoreTimer.value) {
|
||||
clearTimeout(preventAppearFetchMoreTimer.value);
|
||||
preventAppearFetchMoreTimer.value = null;
|
||||
}
|
||||
scrollObserver?.disconnect();
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
|
|
|
@ -67,7 +67,7 @@
|
|||
<div v-if="maxTextLength - textLength < 100" :class="['_acrylic', $style.textCount, { [$style.textOver]: textLength > maxTextLength }]">{{ maxTextLength - textLength }}</div>
|
||||
</div>
|
||||
<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
|
||||
<XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName"/>
|
||||
<XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName" @replaceFile="replaceFile"/>
|
||||
<MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/>
|
||||
<MkNotePreview v-if="showPreview" :class="$style.preview" :text="text"/>
|
||||
<div v-if="showingOptions" style="padding: 8px 16px;">
|
||||
|
@ -441,7 +441,11 @@ function updateFileName(file, name) {
|
|||
files[files.findIndex(x => x.id === file.id)].name = name;
|
||||
}
|
||||
|
||||
function upload(file: File, name?: string) {
|
||||
function replaceFile(file: misskey.entities.DriveFile, newFile: misskey.entities.DriveFile): void {
|
||||
files[files.findIndex(x => x.id === file.id)] = newFile;
|
||||
}
|
||||
|
||||
function upload(file: File, name?: string): void {
|
||||
uploadFile(file, defaultStore.state.uploadFolder, name).then(res => {
|
||||
files.push(res);
|
||||
});
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent } from 'vue';
|
||||
import * as misskey from 'cherrypick-js';
|
||||
import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
|
||||
import * as os from '@/os';
|
||||
import { i18n } from '@/i18n';
|
||||
|
@ -30,8 +31,9 @@ const props = defineProps<{
|
|||
const emit = defineEmits<{
|
||||
(ev: 'update:modelValue', value: any[]): void;
|
||||
(ev: 'detach', id: string): void;
|
||||
(ev: 'changeSensitive'): void;
|
||||
(ev: 'changeName'): void;
|
||||
(ev: 'changeSensitive', file: misskey.entities.DriveFile, isSensitive: boolean): void;
|
||||
(ev: 'changeName', file: misskey.entities.DriveFile, newName: string): void;
|
||||
(ev: 'replaceFile', file: misskey.entities.DriveFile, newFile: misskey.entities.DriveFile): void;
|
||||
}>();
|
||||
|
||||
let menuShowing = false;
|
||||
|
@ -85,8 +87,15 @@ async function describe(file) {
|
|||
}, 'closed');
|
||||
}
|
||||
|
||||
function showFileMenu(file, ev: MouseEvent) {
|
||||
async function crop(file: misskey.entities.DriveFile): Promise<void> {
|
||||
const newFile = await os.cropImage(file, { aspectRatio: NaN });
|
||||
emit('replaceFile', file, newFile);
|
||||
}
|
||||
|
||||
function showFileMenu(file: misskey.entities.DriveFile, ev: MouseEvent): void {
|
||||
if (menuShowing) return;
|
||||
|
||||
const isImage = file.type.startsWith('image/');
|
||||
os.popupMenu([{
|
||||
text: i18n.ts.renameFile,
|
||||
icon: 'ti ti-forms',
|
||||
|
@ -99,7 +108,11 @@ function showFileMenu(file, ev: MouseEvent) {
|
|||
text: i18n.ts.describeFile,
|
||||
icon: 'ti ti-text-caption',
|
||||
action: () => { describe(file); },
|
||||
}, {
|
||||
}, ...isImage ? [{
|
||||
text: i18n.ts.cropImage,
|
||||
icon: 'ti ti-crop',
|
||||
action: () : void => { crop(file); },
|
||||
}] : [], {
|
||||
text: i18n.ts.attachCancel,
|
||||
icon: 'ti ti-circle-x',
|
||||
action: () => { detachMedia(file.id); },
|
||||
|
|
|
@ -9,10 +9,10 @@
|
|||
|
||||
<script lang="ts" setup>
|
||||
import isChromatic from 'chromatic/isChromatic';
|
||||
import { onUnmounted } from 'vue';
|
||||
import { onMounted, onUnmounted } from 'vue';
|
||||
import { i18n } from '@/i18n';
|
||||
import { dateTimeFormat } from '@/scripts/intl-const';
|
||||
import {defaultStore} from "@/store";
|
||||
import { defaultStore } from '@/store';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
time: Date | string | number | null;
|
||||
|
@ -30,12 +30,12 @@ const invalid = Number.isNaN(_time);
|
|||
const absolute = !invalid ? dateTimeFormat.format(_time) : i18n.ts._ago.invalid;
|
||||
|
||||
let now = $ref((props.origin ?? new Date()).getTime());
|
||||
const ago = $computed(() => (now - _time) / 1000/*ms*/);
|
||||
|
||||
const relative = $computed<string>(() => {
|
||||
// if (props.mode === 'absolute') return ''; // absoluteではrelativeを使わないので計算しない
|
||||
if (invalid) return i18n.ts._ago.invalid;
|
||||
|
||||
const ago = (now - _time) / 1000/*ms*/;
|
||||
|
||||
if (defaultStore.state.enableMarkByDate) {
|
||||
return (
|
||||
ago >= 86400 ? i18n.t('_ago.daysAgo', { n: Math.round(ago / 86400).toString() }) :
|
||||
|
@ -59,19 +59,25 @@ const relative = $computed<string>(() => {
|
|||
});
|
||||
|
||||
let tickId: number;
|
||||
let currentInterval: number;
|
||||
|
||||
function tick() {
|
||||
now = props.origin ?? (new Date()).getTime();
|
||||
const ago = (now - _time) / 1000/*ms*/;
|
||||
const next = ago < 60 ? 10000 : ago < 3600 ? 60000 : 180000;
|
||||
now = (new Date()).getTime();
|
||||
const nextInterval = ago < 60 ? 10000 : ago < 3600 ? 60000 : 180000;
|
||||
|
||||
tickId = window.setTimeout(tick, next);
|
||||
if (currentInterval !== nextInterval) {
|
||||
if (tickId) window.clearInterval(tickId);
|
||||
currentInterval = nextInterval;
|
||||
tickId = window.setInterval(tick, nextInterval);
|
||||
}
|
||||
}
|
||||
|
||||
if (props.mode === 'relative' || props.mode === 'detail') {
|
||||
tick();
|
||||
if (!invalid && props.origin === null && (props.mode === 'relative' || props.mode === 'detail')) {
|
||||
onMounted(() => {
|
||||
tick();
|
||||
});
|
||||
onUnmounted(() => {
|
||||
window.clearTimeout(tickId);
|
||||
if (tickId) window.clearInterval(tickId);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -474,11 +474,13 @@ export async function pickEmoji(src: HTMLElement | null, opts) {
|
|||
|
||||
export async function cropImage(image: Misskey.entities.DriveFile, options: {
|
||||
aspectRatio: number;
|
||||
uploadFolder?: string | null;
|
||||
}): Promise<Misskey.entities.DriveFile> {
|
||||
return new Promise((resolve, reject) => {
|
||||
popup(defineAsyncComponent(() => import('@/components/MkCropperDialog.vue')), {
|
||||
file: image,
|
||||
aspectRatio: options.aspectRatio,
|
||||
uploadFolder: options.uploadFolder,
|
||||
}, {
|
||||
ok: x => {
|
||||
resolve(x);
|
||||
|
|
|
@ -75,7 +75,7 @@ const pagination = {
|
|||
};
|
||||
|
||||
function resolved(reportId) {
|
||||
reports.removeItem(item => item.id === reportId);
|
||||
reports.removeItem(reportId);
|
||||
}
|
||||
|
||||
const headerActions = $computed(() => []);
|
||||
|
|
|
@ -144,7 +144,7 @@ const edit = (emoji) => {
|
|||
...result.updated,
|
||||
}));
|
||||
} else if (result.deleted) {
|
||||
emojisPaginationComponent.value.removeItem((item) => item.id === emoji.id);
|
||||
emojisPaginationComponent.value.removeItem(emoji.id);
|
||||
}
|
||||
},
|
||||
}, 'closed');
|
||||
|
|
|
@ -3,6 +3,7 @@ import { defineAsyncComponent } from 'vue';
|
|||
import { i18n } from '@/i18n';
|
||||
import copyToClipboard from '@/scripts/copy-to-clipboard';
|
||||
import * as os from '@/os';
|
||||
import { MenuItem } from '@/types/menu';
|
||||
|
||||
function rename(file: Misskey.entities.DriveFile) {
|
||||
os.inputText({
|
||||
|
@ -66,7 +67,8 @@ async function deleteFile(file: Misskey.entities.DriveFile) {
|
|||
});
|
||||
}
|
||||
|
||||
export function getDriveFileMenu(file: Misskey.entities.DriveFile) {
|
||||
export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Misskey.entities.DriveFolder | null): MenuItem[] {
|
||||
const isImage = file.type.startsWith('image/');
|
||||
return [{
|
||||
text: i18n.ts.rename,
|
||||
icon: 'ti ti-forms',
|
||||
|
@ -79,7 +81,14 @@ export function getDriveFileMenu(file: Misskey.entities.DriveFile) {
|
|||
text: i18n.ts.describeFile,
|
||||
icon: 'ti ti-text-caption',
|
||||
action: () => describe(file),
|
||||
}, null, {
|
||||
}, ...isImage ? [{
|
||||
text: i18n.ts.cropImage,
|
||||
icon: 'ti ti-crop',
|
||||
action: () => os.cropImage(file, {
|
||||
aspectRatio: NaN,
|
||||
uploadFolder: folder ? folder.id : folder
|
||||
}),
|
||||
}] : [], null, {
|
||||
text: i18n.ts.createNoteFromTheFile,
|
||||
icon: 'ti ti-pencil',
|
||||
action: () => os.post({
|
||||
|
|
|
@ -44,11 +44,22 @@ async function setAntenna() {
|
|||
});
|
||||
}
|
||||
|
||||
const menu = [{
|
||||
icon: 'ti ti-pencil',
|
||||
text: i18n.ts.selectAntenna,
|
||||
action: setAntenna,
|
||||
}];
|
||||
function editAntenna() {
|
||||
os.pageWindow('my/antennas/' + props.column.antennaId);
|
||||
}
|
||||
|
||||
const menu = [
|
||||
{
|
||||
icon: 'ti ti-pencil',
|
||||
text: i18n.ts.selectAntenna,
|
||||
action: setAntenna,
|
||||
},
|
||||
{
|
||||
icon: 'ti ti-settings',
|
||||
text: i18n.ts.editAntenna,
|
||||
action: editAntenna,
|
||||
},
|
||||
];
|
||||
|
||||
/*
|
||||
function focus() {
|
||||
|
|
|
@ -42,9 +42,20 @@ async function setList() {
|
|||
});
|
||||
}
|
||||
|
||||
const menu = [{
|
||||
icon: 'ti ti-pencil',
|
||||
text: i18n.ts.selectList,
|
||||
action: setList,
|
||||
}];
|
||||
function editList() {
|
||||
os.pageWindow('my/lists/' + props.column.listId);
|
||||
}
|
||||
|
||||
const menu = [
|
||||
{
|
||||
icon: 'ti ti-pencil',
|
||||
text: i18n.ts.selectList,
|
||||
action: setList,
|
||||
},
|
||||
{
|
||||
icon: 'ti ti-settings',
|
||||
text: i18n.ts.editList,
|
||||
action: editList,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
|
Loading…
Reference in a new issue