Compare commits

...

21 Commits

Author SHA1 Message Date
laozhoubuluo 377b4653d5 Merge branch 'refactor/dont_federate_initially_flag' into 'develop'
refactor: use dont federate initially flag instead of visibility hack


See merge request firefish/firefish!10738
2024-05-07 20:04:09 +00:00
naskya 769f52c8ee Merge branch 'fix/reactive' into 'develop'
fix: use reactive MkTime

Co-authored-by: Lhcfl <Lhcfl@outlook.com>

See merge request firefish/firefish!10796
2024-05-07 19:59:12 +00:00
naskya 8a00d82f36
ci: add firefish-js 2024-05-08 04:49:13 +09:00
naskya 34ed877f57
ci: don't build the backend on client-only changes 2024-05-08 04:41:20 +09:00
Lhcfl f5074f35cc fix: use reactive MkTime 2024-05-08 03:00:07 +08:00
naskya a847dd55ad
ci: fix cargo clippy task 2024-05-08 03:58:21 +09:00
naskya 5382dc5da8
refactor (backend): port publishNotesStream to backend-rs 2024-05-08 02:15:07 +09:00
naskya 989e93f2a0
fix: migrate back from happy-dom to JSDOM (closes #10924 #10914 #10842)
this reverts commit 4565867b8b.
2024-05-08 01:52:15 +09:00
naskya df81cb6a85 Merge branch 'feat/collepse-reply-timeline' into 'develop'
feat: collepse renotes and replies in timeline

Co-authored-by: Lhcfl <Lhcfl@outlook.com>

Closes #10908

See merge request firefish/firefish!10788
2024-05-07 16:20:45 +00:00
Lhcfl 31168cc7b2 fix: use reacive MkSubNoteContent 2024-05-07 23:42:40 +08:00
Lhcfl 42886f054d fix: use reactive previewableCount 2024-05-07 23:31:45 +08:00
Lhcfl 1d0ea11eea fix: use note capture in MkNoteSimple 2024-05-07 23:23:19 +08:00
Lhcfl 24602c4745 update locales 2024-05-07 22:49:09 +08:00
Lhcfl 33923a59fa fix: use reactive MkNoteHeader 2024-05-07 22:37:09 +08:00
Lhcfl 8067ed4084 Merge branch 'develop' of https://firefish.dev/firefish/firefish into feat/collepse-reply-timeline 2024-05-07 22:34:45 +08:00
老周部落 508cb25f54
refactor: use dont federate initially flag instead of visibility hack 2024-05-06 22:37:50 +08:00
Lhcfl 46d0679845 little patch 2024-05-03 00:56:10 +08:00
Lhcfl 160e7f26a6 feat: collepse renotes and replies 2024-05-03 00:22:25 +08:00
Lhcfl 9138c3726a dev: use reactiveState in foldNotification 2024-05-02 01:07:57 +08:00
Lhcfl 425b333474 set collapseReplyInTimeline default to false 2024-05-02 00:57:00 +08:00
Lhcfl d1c76b3882 feat: allow collepse replied posts in timeline 2024-05-02 00:53:52 +08:00
34 changed files with 1009 additions and 359 deletions

4
.gitignore vendored
View File

@ -56,10 +56,6 @@ packages/backend/assets/instance.css
packages/backend/assets/sounds/None.mp3
packages/backend/assets/LICENSE
!/packages/backend/queue/processors/db
!/packages/backend/src/db
!/packages/backend/src/server/api/endpoints/drive/files
packages/megalodon/lib
packages/megalodon/.idea

View File

@ -56,9 +56,11 @@ build_test:
- if: $CI_PIPELINE_SOURCE == 'push' || $CI_PIPELINE_SOURCE == 'merge_request_event'
changes:
paths:
- packages/**/*
- packages/backend/*
- packages/backend-rs/*
- packages/macro-rs/*
- packages/megalodon/*
- scripts/**/*
- locales/**/*
- package.json
- pnpm-lock.yaml
- Cargo.toml
@ -68,6 +70,33 @@ build_test:
- pnpm run build:debug
- pnpm run migrate
client_build_test:
stage: test
rules:
- if: $CI_PIPELINE_SOURCE == 'push' || $CI_PIPELINE_SOURCE == 'merge_request_event'
changes:
paths:
- packages/client/*
- packages/firefish-js/*
- packages/sw/*
- scripts/**/*
- locales/**/*
- package.json
- pnpm-lock.yaml
- if: $CI_PIPELINE_SOURCE == 'push' || $CI_PIPELINE_SOURCE == 'merge_request_event'
changes:
paths:
- packages/backend/*
- packages/backend-rs/*
- packages/macro-rs/*
- packages/megalodon/*
- Cargo.toml
- Cargo.lock
when: never
script:
- pnpm install --frozen-lockfile
- pnpm --filter 'firefish-js' --filter 'client' --filter 'sw' run build:debug
container_image_build:
stage: build
image: docker.io/debian:bookworm-slim
@ -119,7 +148,7 @@ cargo_unit_test:
cargo_clippy:
stage: test
rules:
- if: $CI_PIPELINE_SOURCE == 'merge_request_event'
- if: $CI_PIPELINE_SOURCE == 'merge_request_event' || $CI_COMMIT_BRANCH == 'develop'
changes:
paths:
- packages/backend-rs/**/*
@ -129,6 +158,7 @@ cargo_clippy:
- if: $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == 'main'
when: never
script:
- rustup component add clippy
- cargo clippy -- -D warnings
renovate:

View File

@ -2244,3 +2244,5 @@ incorrectLanguageWarning: "It looks like your post is in {detected}, but you sel
noteEditHistory: "Post edit history"
slashQuote: "Chain quote"
foldNotification: "Group similar notifications"
mergeThreadInTimeline: "Merge multiple posts in the same thread in timelines"
mergeRenotesInTimeline: "Group multiple boosts of the same post"

View File

@ -2071,3 +2071,5 @@ noteEditHistory: "帖子编辑历史"
media: 媒体
slashQuote: "斜杠引用"
foldNotification: "将通知按同类型分组"
mergeThreadInTimeline: "将时间线内的连续回复合并成一串"
mergeRenotesInTimeline: "合并同一个帖子的转发"

View File

@ -1292,6 +1292,7 @@ export interface AbuseUserReportLike {
comment: string
}
export function publishToModerationStream(moderatorId: string, report: AbuseUserReportLike): void
export function publishToNotesStream(note: Note): void
export function getTimestamp(id: string): number
/**
* The generated ID results in the form of `[8 chars timestamp] + [cuid2]`.

View File

@ -310,7 +310,7 @@ if (!nativeBinding) {
throw new Error(`Failed to load native binding`)
}
const { SECOND, MINUTE, HOUR, DAY, USER_ONLINE_THRESHOLD, USER_ACTIVE_THRESHOLD, FILE_TYPE_BROWSERSAFE, loadEnv, loadConfig, stringToAcct, acctToString, addNoteToAntenna, isBlockedServer, isSilencedServer, isAllowedServer, checkWordMute, getFullApAccount, isSelfHost, isSameOrigin, extractHost, toPuny, isUnicodeEmoji, sqlLikeEscape, safeForSql, formatMilliseconds, getImageSizeFromUrl, getNoteSummary, isSafeUrl, latestVersion, toMastodonId, fromMastodonId, fetchMeta, metaToPugArgs, nyaify, hashPassword, verifyPassword, isOldPasswordAlgorithm, decodeReaction, countReactions, toDbReaction, removeOldAttestationChallenges, AntennaSrcEnum, DriveFileUsageHintEnum, MutedNoteReasonEnum, NoteVisibilityEnum, NotificationTypeEnum, PageVisibilityEnum, PollNotevisibilityEnum, RelayStatusEnum, UserEmojimodpermEnum, UserProfileFfvisibilityEnum, UserProfileMutingnotificationtypesEnum, initializeRustLogger, fetchNodeinfo, nodeinfo_2_1, nodeinfo_2_0, Protocol, Inbound, Outbound, watchNote, unwatchNote, publishToChannelStream, ChatEvent, publishToChatStream, ChatIndexEvent, publishToChatIndexStream, publishToBroadcastStream, publishToGroupChatStream, publishToModerationStream, getTimestamp, genId, genIdAt, secureRndstr } = nativeBinding
const { SECOND, MINUTE, HOUR, DAY, USER_ONLINE_THRESHOLD, USER_ACTIVE_THRESHOLD, FILE_TYPE_BROWSERSAFE, loadEnv, loadConfig, stringToAcct, acctToString, addNoteToAntenna, isBlockedServer, isSilencedServer, isAllowedServer, checkWordMute, getFullApAccount, isSelfHost, isSameOrigin, extractHost, toPuny, isUnicodeEmoji, sqlLikeEscape, safeForSql, formatMilliseconds, getImageSizeFromUrl, getNoteSummary, isSafeUrl, latestVersion, toMastodonId, fromMastodonId, fetchMeta, metaToPugArgs, nyaify, hashPassword, verifyPassword, isOldPasswordAlgorithm, decodeReaction, countReactions, toDbReaction, removeOldAttestationChallenges, AntennaSrcEnum, DriveFileUsageHintEnum, MutedNoteReasonEnum, NoteVisibilityEnum, NotificationTypeEnum, PageVisibilityEnum, PollNotevisibilityEnum, RelayStatusEnum, UserEmojimodpermEnum, UserProfileFfvisibilityEnum, UserProfileMutingnotificationtypesEnum, initializeRustLogger, fetchNodeinfo, nodeinfo_2_1, nodeinfo_2_0, Protocol, Inbound, Outbound, watchNote, unwatchNote, publishToChannelStream, ChatEvent, publishToChatStream, ChatIndexEvent, publishToChatIndexStream, publishToBroadcastStream, publishToGroupChatStream, publishToModerationStream, publishToNotesStream, getTimestamp, genId, genIdAt, secureRndstr } = nativeBinding
module.exports.SECOND = SECOND
module.exports.MINUTE = MINUTE
@ -381,6 +381,7 @@ module.exports.publishToChatIndexStream = publishToChatIndexStream
module.exports.publishToBroadcastStream = publishToBroadcastStream
module.exports.publishToGroupChatStream = publishToGroupChatStream
module.exports.publishToModerationStream = publishToModerationStream
module.exports.publishToNotesStream = publishToNotesStream
module.exports.getTimestamp = getTimestamp
module.exports.genId = genId
module.exports.genIdAt = genIdAt

View File

@ -5,6 +5,7 @@ pub mod chat_index;
pub mod custom_emoji;
pub mod group_chat;
pub mod moderation;
pub mod new_note;
use crate::config::CONFIG;
use crate::database::redis_conn;
@ -25,7 +26,7 @@ pub enum Stream {
#[strum(to_string = "noteStream:{note_id}")]
Note { note_id: String },
#[strum(serialize = "notesStream")]
Notes,
NewNote,
#[strum(to_string = "userListStream:{list_id}")]
UserList { list_id: String },
#[strum(to_string = "mainStream:{user_id}")]

View File

@ -0,0 +1,10 @@
use crate::model::entity::note;
use crate::service::stream::{publish_to_stream, Error, Stream};
// for napi export (https://github.com/napi-rs/napi-rs/issues/2060)
type Note = note::Model;
#[crate::export(js_name = "publishToNotesStream")]
pub fn publish(note: &Note) -> Result<(), Error> {
publish_to_stream(&Stream::NewNote, None, Some(serde_json::to_string(note)?))
}

View File

@ -59,11 +59,11 @@
"form-data": "^4.0.0",
"got": "14.2.1",
"gunzip-maybe": "^1.4.2",
"happy-dom": "^14.7.1",
"hpagent": "1.2.0",
"ioredis": "5.4.1",
"ip-cidr": "4.0.0",
"is-svg": "5.0.0",
"jsdom": "24.0.0",
"json5": "2.2.3",
"jsonld": "8.3.2",
"jsrsasign": "11.1.0",
@ -131,6 +131,7 @@
"@types/content-disposition": "^0.5.8",
"@types/escape-regexp": "0.0.3",
"@types/fluent-ffmpeg": "2.1.24",
"@types/jsdom": "21.1.6",
"@types/jsonld": "1.5.13",
"@types/jsrsasign": "10.5.13",
"@types/katex": "0.16.7",

View File

@ -1,21 +1,17 @@
import { type HTMLElement, Window } from "happy-dom";
import { JSDOM } from "jsdom";
import type * as mfm from "mfm-js";
import katex from "katex";
import { config } from "@/config.js";
import { intersperse } from "@/prelude/array.js";
import type { IMentionedRemoteUsers } from "@/models/entities/note.js";
function toMathMl(code: string, displayMode: boolean): HTMLElement | null {
const { window } = new Window();
const document = window.document;
document.body.innerHTML = katex.renderToString(code, {
function toMathMl(code: string, displayMode: boolean): MathMLElement | null {
const rendered = katex.renderToString(code, {
throwOnError: false,
output: "mathml",
displayMode,
});
return document.querySelector("math");
return JSDOM.fragment(rendered).querySelector("math");
}
export function toHtml(
@ -26,7 +22,7 @@ export function toHtml(
return null;
}
const { window } = new Window();
const { window } = new JSDOM("");
const doc = window.document;

View File

@ -1,3 +1,5 @@
import { noteVisibilities } from "@/types.js";
export type Post = {
text: string | undefined;
cw: string | null;
@ -12,7 +14,9 @@ export function parse(acct: any): Post {
cw: acct.cw,
localOnly: acct.localOnly,
createdAt: new Date(acct.createdAt),
visibility: `hidden${acct.visibility || ""}`,
visibility: noteVisibilities.includes(acct.visibility)
? acct.visibility
: "public",
};
}

View File

@ -83,22 +83,27 @@ export async function importCkPost(
logger.info("Post updated");
}
if (note == null) {
note = await create(user, {
createdAt: createdAt,
files: files.length === 0 ? undefined : files,
poll: undefined,
text: text || undefined,
reply: post.replyId ? job.data.parent : null,
renote: post.renoteId ? job.data.parent : null,
cw: cw,
localOnly,
visibility: visibility,
visibleUsers: [],
channel: null,
apMentions: new Array(0),
apHashtags: undefined,
apEmojis: undefined,
});
note = await create(
user,
{
createdAt: createdAt,
files: files.length === 0 ? undefined : files,
poll: undefined,
text: text || undefined,
reply: post.replyId ? job.data.parent : null,
renote: post.renoteId ? job.data.parent : null,
cw: cw,
localOnly,
visibility: visibility,
visibleUsers: [],
channel: null,
apMentions: new Array(0),
apHashtags: undefined,
apEmojis: undefined,
},
false,
true,
);
logger.debug("New post has been created");
} else {
logger.info("This post already exists");

View File

@ -109,24 +109,30 @@ export async function importMastoPost(
logger.info("Post updated");
}
if (note == null) {
note = await create(user, {
createdAt: isRenote
? new Date(post.published)
: new Date(post.object.published),
files: files.length === 0 ? undefined : files,
poll: undefined,
text: text || undefined,
reply,
renote,
cw: !isRenote && post.object.sensitive ? post.object.summary : undefined,
localOnly: false,
visibility: "hiddenpublic",
visibleUsers: [],
channel: null,
apMentions: new Array(0),
apHashtags: undefined,
apEmojis: undefined,
});
note = await create(
user,
{
createdAt: isRenote
? new Date(post.published)
: new Date(post.object.published),
files: files.length === 0 ? undefined : files,
poll: undefined,
text: text || undefined,
reply,
renote,
cw:
!isRenote && post.object.sensitive ? post.object.summary : undefined,
localOnly: false,
visibility: "public",
visibleUsers: [],
channel: null,
apMentions: new Array(0),
apHashtags: undefined,
apEmojis: undefined,
},
false,
true,
);
logger.debug("New post has been created");
} else {
logger.info("This post already exists");

View File

@ -1,8 +1,8 @@
import { URL } from "node:url";
import { Window } from "happy-dom";
import { type DOMWindow, JSDOM } from "jsdom";
import fetch from "node-fetch";
import tinycolor from "tinycolor2";
import { getJson, getAgentByUrl } from "@/misc/fetch.js";
import { getJson, getHtml, getAgentByUrl } from "@/misc/fetch.js";
import {
type Instance,
MAX_LENGTH_INSTANCE,
@ -112,15 +112,13 @@ export async function fetchInstanceMetadata(
}
}
async function fetchDom(instance: Instance): Promise<Window["document"]> {
async function fetchDom(instance: Instance): Promise<DOMWindow["document"]> {
logger.info(`Fetching HTML of ${instance.host} ...`);
const window = new Window({
url: `https://${instance.host}`,
});
const doc = window.document;
const html = await getHtml(`https://${instance.host}`);
const { window } = new JSDOM(html);
return doc;
return window.document;
}
async function fetchManifest(
@ -137,7 +135,7 @@ async function fetchManifest(
async function fetchFaviconUrl(
instance: Instance,
doc: Window["document"] | null,
doc: DOMWindow["document"] | null,
): Promise<string | null> {
const url = `https://${instance.host}`;
@ -169,7 +167,7 @@ async function fetchFaviconUrl(
async function fetchIconUrl(
instance: Instance,
doc: Window["document"] | null,
doc: DOMWindow["document"] | null,
manifest: Record<string, any> | null,
): Promise<string | null> {
if (manifest?.icons && manifest.icons.length > 0 && manifest.icons[0].src) {
@ -219,9 +217,9 @@ async function getThemeColor(
async function getSiteName(
info: Nodeinfo | null,
doc: Window["document"] | null,
doc: DOMWindow["document"] | null,
manifest: Record<string, any> | null,
): Promise<string | undefined | null> {
): Promise<string | null> {
if (info?.metadata) {
if (info.metadata.nodeName || info.metadata.name) {
return info.metadata.nodeName || info.metadata.name;
@ -247,7 +245,7 @@ async function getSiteName(
async function getDescription(
info: Nodeinfo | null,
doc: Window["document"] | null,
doc: DOMWindow["document"] | null,
manifest: Record<string, any> | null,
): Promise<string | null> {
if (info?.metadata) {

View File

@ -1,12 +1,11 @@
import { Window } from "happy-dom";
import type { HTMLAnchorElement, HTMLLinkElement } from "happy-dom";
import { JSDOM } from "jsdom";
import { config } from "@/config.js";
import { getHtml } from "@/misc/fetch.js";
async function getRelMeLinks(url: string): Promise<string[]> {
try {
const dom = new Window({
url: url,
});
const html = await getHtml(url);
const dom = new JSDOM(html);
const allLinks = [...dom.window.document.querySelectorAll("a, link")];
const relMeLinks = allLinks
.filter((a) => {

View File

@ -1,9 +1,5 @@
import * as mfm from "mfm-js";
import {
publishMainStream,
publishNotesStream,
publishNoteStream,
} from "@/services/stream.js";
import { publishMainStream, publishNoteStream } from "@/services/stream.js";
import DeliverManager from "@/remote/activitypub/deliver-manager.js";
import renderNote from "@/remote/activitypub/renderer/note.js";
import renderCreate from "@/remote/activitypub/renderer/create.js";
@ -49,6 +45,7 @@ import {
genId,
genIdAt,
isSilencedServer,
publishToNotesStream,
} from "backend-rs";
import { countSameRenotes } from "@/misc/count-same-renotes.js";
import { deliverToRelays, getCachedRelays } from "../relay.js";
@ -175,11 +172,12 @@ export default async (
},
data: Option,
silent = false,
dontFederateInitially = false,
) =>
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: FIXME
new Promise<Note>(async (res, rej) => {
const dontFederateInitially =
data.visibility?.startsWith("hidden") === true;
const dontFederateInitiallyFlag =
data.visibility === "hidden" ? true : dontFederateInitially;
// If you reply outside the channel, match the scope of the target.
// TODO (I think it's a process that could be done on the client side, but it's server side for now.)
@ -213,8 +211,6 @@ export default async (
if (data.channel != null) data.visibility = "public";
if (data.channel != null) data.visibleUsers = [];
if (data.channel != null) data.localOnly = true;
if (data.visibility.startsWith("hidden") && data.visibility !== "hidden")
data.visibility = data.visibility.slice(6);
// enforce silent clients on server
if (
@ -477,7 +473,7 @@ export default async (
}
}
if (!dontFederateInitially) {
if (!dontFederateInitiallyFlag) {
let publishKey: string;
let noteToPublish: Note;
const relays = await getCachedRelays();
@ -511,7 +507,7 @@ export default async (
30,
);
}
publishNotesStream(noteToPublish);
publishToNotesStream(toRustObject(noteToPublish));
}
} finally {
await lock.release();
@ -615,7 +611,7 @@ export default async (
});
//#region AP deliver
if (Users.isLocalUser(user) && !dontFederateInitially) {
if (Users.isLocalUser(user) && !dontFederateInitiallyFlag) {
(async () => {
const noteActivity = await renderNoteOrRenoteActivity(data, note);
const dm = new DeliverManager(user, noteActivity);

View File

@ -193,9 +193,10 @@ class Publisher {
// );
// };
public publishNotesStream = (note: Note): void => {
this.publish("notesStream", null, note);
};
/* ported to backend-rs */
// public publishNotesStream = (note: Note): void => {
// this.publish("notesStream", null, note);
// };
/* ported to backend-rs */
// public publishAdminStream = <K extends keyof AdminStreamTypes>(
@ -221,7 +222,7 @@ export const publishUserEvent = publisher.publishUserEvent;
export const publishMainStream = publisher.publishMainStream;
export const publishDriveStream = publisher.publishDriveStream;
export const publishNoteStream = publisher.publishNoteStream;
export const publishNotesStream = publisher.publishNotesStream;
// export const publishNotesStream = publisher.publishNotesStream;
// export const publishChannelStream = publisher.publishChannelStream;
export const publishUserListStream = publisher.publishUserListStream;
// export const publishAntennaStream = publisher.publishAntennaStream;

View File

@ -40,7 +40,7 @@
</template>
<script lang="ts" setup>
import { onMounted, ref } from "vue";
import { computed, onMounted, ref } from "vue";
import type { entities } from "firefish-js";
import PhotoSwipeLightbox from "photoswipe/lightbox";
import PhotoSwipe from "photoswipe";
@ -207,9 +207,9 @@ const isModule = (file: entities.DriveFile): boolean => {
);
};
const previewableCount = props.mediaList.filter((media) =>
previewable(media),
).length;
const previewableCount = computed(
() => props.mediaList.filter((media) => previewable(media)).length,
);
</script>
<style lang="scss" scoped>

View File

@ -1,7 +1,7 @@
<template>
<div
v-if="!muted.muted"
v-show="!isDeleted"
v-show="!isDeleted && renotes?.length !== 0"
:id="appearNote.historyId || appearNote.id"
ref="el"
v-hotkey="keymap"
@ -10,13 +10,20 @@
:aria-label="accessibleLabel"
class="tkcbzcuz note-container"
:tabindex="!isDeleted ? '-1' : undefined"
:class="{ renote: isRenote }"
:class="{ renote: isRenote || (renotesSliced && renotesSliced.length > 0) }"
>
<MkNoteSub
v-if="appearNote.reply && !detailedView && !collapsedReply"
v-if="appearNote.reply && !detailedView && !collapsedReply && !parents"
:note="appearNote.reply"
class="reply-to"
/>
<MkNoteSub
v-else-if="!detailedView && !collapsedReply && parents"
v-for="n of parents"
:key="n.id"
:note="n"
class="reply-to"
/>
<div
v-if="!detailedView"
class="note-context"
@ -41,35 +48,6 @@
<div v-if="pinned" class="info">
<i :class="icon('ph-push-pin')"></i>{{ i18n.ts.pinnedNote }}
</div>
<div v-if="isRenote" class="renote">
<i :class="icon('ph-rocket-launch')"></i>
<I18n :src="i18n.ts.renotedBy" tag="span">
<template #user>
<MkA
v-user-preview="note.userId"
class="name"
:to="userPage(note.user)"
@click.stop
>
<MkUserName :user="note.user" />
</MkA>
</template>
</I18n>
<div class="info">
<button
ref="renoteTime"
class="_button time"
@click.stop="showRenoteMenu()"
>
<i
v-if="isMyRenote"
:class="icon('ph-dots-three-outline dropdownIcon')"
></i>
<MkTime :time="note.createdAt" />
</button>
<MkVisibility :note="note" />
</div>
</div>
<div v-if="collapsedReply && appearNote.reply" class="info">
<MkAvatar class="avatar" :user="appearNote.reply.user" />
<MkUserName
@ -85,6 +63,71 @@
:custom-emojis="note.emojis"
/>
</div>
<div v-if="isRenote || (renotesSliced && renotesSliced.length > 0)" class="renote">
<i :class="icon('ph-rocket-launch')"></i>
<I18n
v-if="renotesSliced == null"
:src="i18n.ts.renotedBy"
tag="span"
>
<template #user>
<MkAvatar class="avatar" :user="note.user" />
<MkA
v-user-preview="note.userId"
class="name"
:to="userPage(note.user)"
@click.stop
>
<MkUserName :user="note.user" />
</MkA>
</template>
</I18n>
<I18n
v-else
:src="i18n.ts.renotedBy"
tag="span"
>
<template #user>
<template
v-for="(renote, index) in renotesSliced"
>
<MkAvatar
class="avatar"
:user="renote.user"
/>
<MkA
v-user-preview="renote.userId"
class="name"
:to="userPage(renote.user)"
@click.stop
>
<MkUserName :user="renote.user" />
</MkA>
{{
index !== renotesSliced.length - 1
? ", "
: renotesSliced.length < renotes!.length
? "..."
: ""
}}
</template>
</template>
</I18n>
<div class="info">
<button
ref="renoteTime"
class="_button time"
@click.stop="showRenoteMenu()"
>
<i
v-if="isMyNote"
:class="icon('ph-dots-three-outline dropdownIcon')"
></i>
<MkTime :time="note.createdAt" />
</button>
<MkVisibility :note="note" />
</div>
</div>
</div>
<article
class="article"
@ -279,7 +322,7 @@
</template>
<script lang="ts" setup>
import { computed, inject, onMounted, ref } from "vue";
import { computed, inject, onMounted, ref, watch } from "vue";
import type { Ref } from "vue";
import type { entities } from "firefish-js";
import MkSubNoteContent from "./MkSubNoteContent.vue";
@ -310,17 +353,13 @@ import { notePage } from "@/filters/note";
import { deepClone } from "@/scripts/clone";
import { getNoteSummary } from "@/scripts/get-note-summary";
import icon from "@/scripts/icon";
import type { NoteTranslation } from "@/types/note";
const router = useRouter();
type NoteType = entities.Note & {
_featuredId_?: string;
_prId_?: string;
};
import type { NoteTranslation, NoteType } from "@/types/note";
import { isRenote as _isRenote, isDeleted as _isDeleted } from "@/scripts/note";
const props = defineProps<{
note: NoteType;
parents?: NoteType[];
renotes?: entities.Note[];
pinned?: boolean;
detailedView?: boolean;
collapsedReply?: boolean;
@ -329,37 +368,20 @@ const props = defineProps<{
isLongJudger?: (note: entities.Note) => boolean;
}>();
//#region Constants
const router = useRouter();
const inChannel = inject("inChannel", null);
const note = ref(deepClone(props.note));
const softMuteReasonI18nSrc = (what?: string) => {
if (what === "note") return i18n.ts.userSaysSomethingReason;
if (what === "reply") return i18n.ts.userSaysSomethingReasonReply;
if (what === "renote") return i18n.ts.userSaysSomethingReasonRenote;
if (what === "quote") return i18n.ts.userSaysSomethingReasonQuote;
// I don't think here is reachable, but just in case
return i18n.ts.userSaysSomething;
const keymap = {
r: () => reply(true),
"e|a|plus": () => react(true),
q: () => renoteButton.value!.renote(true),
"up|k": focusBefore,
"down|j": focusAfter,
esc: blur,
"m|o": () => menu(true),
// FIXME: What's this?
// s: () => showContent.value !== showContent.value,
};
// plugin
if (noteViewInterruptors.length > 0) {
onMounted(async () => {
let result = deepClone(note.value);
for (const interruptor of noteViewInterruptors) {
result = await interruptor.handler(result);
}
note.value = result;
});
}
const isRenote =
note.value.renote != null &&
note.value.text == null &&
note.value.fileIds.length === 0 &&
note.value.poll == null;
const el = ref<HTMLElement | null>(null);
const footerEl = ref<HTMLElement>();
const menuButton = ref<HTMLElement>();
@ -367,42 +389,179 @@ const starButton = ref<InstanceType<typeof XStarButton>>();
const renoteButton = ref<InstanceType<typeof XRenoteButton> | null>(null);
const renoteTime = ref<HTMLElement>();
const reactButton = ref<HTMLElement | null>(null);
const appearNote = computed(() =>
isRenote ? (note.value.renote as NoteType) : note.value,
);
const isMyRenote = isSignedIn(me) && me.id === note.value.userId;
// const showContent = ref(false);
const isDeleted = ref(false);
const muted = ref(
getWordSoftMute(
note.value,
me?.id,
defaultStore.state.mutedWords,
defaultStore.state.mutedLangs,
),
);
const translation = ref<NoteTranslation | null>(null);
const translating = ref(false);
const enableEmojiReactions = defaultStore.state.enableEmojiReactions;
const expandOnNoteClick = defaultStore.state.expandOnNoteClick;
const enableEmojiReactions = defaultStore.reactiveState.enableEmojiReactions;
const expandOnNoteClick = defaultStore.reactiveState.expandOnNoteClick;
const lang = localStorage.getItem("lang");
const translateLang = localStorage.getItem("translateLang");
const targetLang = (translateLang || lang || navigator.language)?.slice(0, 2);
const currentClipPage = inject<Ref<entities.Clip> | null>(
"currentClipPage",
null,
);
//#endregion
const isForeignLanguage: boolean =
defaultStore.state.detectPostLanguage &&
appearNote.value.text != null &&
(() => {
const postLang = detectLanguage(appearNote.value.text);
return postLang !== "" && postLang !== targetLang;
})();
//#region Variables bound to Notes
let capture: ReturnType<typeof useNoteCapture> | undefined;
const note = ref(deepClone(props.note));
const postIsExpanded = ref(false);
const translation = ref<NoteTranslation | null>(null);
const translating = ref(false);
const isDeleted = ref(false);
const renotes = ref(props.renotes?.filter((rn) => !_isDeleted(rn.id)));
//#endregion
//#region computed
const renotesSliced = computed(() => renotes.value?.slice(0, 5));
const isRenote = computed(() => _isRenote(note.value));
const appearNote = computed(() =>
isRenote.value ? (note.value.renote as NoteType) : note.value,
);
const isMyNote = computed(
() => isSignedIn(me) && me.id === note.value.userId && props.renotes == null,
);
const muted = computed(() =>
getWordSoftMute(
note.value,
me?.id,
defaultStore.reactiveState.mutedWords.value,
defaultStore.reactiveState.mutedLangs.value,
),
);
const isForeignLanguage = computed(
() =>
defaultStore.state.detectPostLanguage &&
appearNote.value.text != null &&
(() => {
const postLang = detectLanguage(appearNote.value.text);
return postLang !== "" && postLang !== targetLang;
})(),
);
const reactionCount = computed(() =>
Object.values(appearNote.value.reactions).reduce(
(partialSum, val) => partialSum + val,
0,
),
);
const accessibleLabel = computed(() => {
let label = `${appearNote.value.user.username}; `;
if (appearNote.value.renote) {
label += `${i18n.ts.renoted} ${appearNote.value.renote.user.username}; `;
if (appearNote.value.renote.cw) {
label += `${i18n.ts.cw}: ${appearNote.value.renote.cw}; `;
if (postIsExpanded.value) {
label += `${appearNote.value.renote.text}; `;
}
} else {
label += `${appearNote.value.renote.text}; `;
}
} else {
if (appearNote.value.cw) {
label += `${i18n.ts.cw}: ${appearNote.value.cw}; `;
if (postIsExpanded.value) {
label += `${appearNote.value.text}; `;
}
} else {
label += `${appearNote.value.text}; `;
}
}
const date = new Date(appearNote.value.createdAt);
label += `${date.toLocaleTimeString()}`;
return label;
});
//#endregion
async function pluginInit(newNote: NoteType) {
// plugin
if (noteViewInterruptors.length > 0) {
let result = deepClone(newNote);
for (const interruptor of noteViewInterruptors) {
result = await interruptor.handler(result);
}
note.value = result;
}
}
function recalculateRenotes() {
renotes.value = props.renotes?.filter((rn) => !_isDeleted(rn.id));
}
async function init(newNote: NoteType, first = false) {
if (!first) {
// plugin
if (noteViewInterruptors.length > 0) {
await pluginInit(newNote);
} else {
note.value = deepClone(newNote);
}
}
translation.value = null;
translating.value = false;
postIsExpanded.value = false;
isDeleted.value = _isDeleted(note.value.id);
if (appearNote.value.historyId == null) {
capture?.close();
capture = useNoteCapture({
rootEl: el,
note: appearNote,
isDeletedRef: isDeleted,
});
if (isRenote.value === true) {
useNoteCapture({
rootEl: el,
note,
isDeletedRef: isDeleted,
});
}
if (props.renotes) {
const renoteDeletedTrigger = ref(false);
for (const renote of props.renotes) {
useNoteCapture({
rootEl: el,
note: ref(renote),
isDeletedRef: renoteDeletedTrigger,
});
}
watch(renoteDeletedTrigger, recalculateRenotes);
}
}
}
init(props.note, true);
onMounted(() => {
pluginInit(note.value);
});
watch(isDeleted, () => {
if (isDeleted.value === true) {
if (props.parents && props.parents.length > 0) {
let noteTakePlace: NoteType | null = null;
while (noteTakePlace == null || _isDeleted(noteTakePlace.id)) {
if (props.parents.length === 0) {
return;
}
noteTakePlace = props.parents[props.parents.length - 1];
props.parents.pop();
}
noteTakePlace.repliesCount -= 1;
init(noteTakePlace);
isDeleted.value = false;
}
}
});
watch(
() => props.note.id,
(o, n) => {
if (o !== n && _isDeleted(note.value.id) !== true) {
init(props.note);
}
},
);
watch(() => props.renotes?.length, recalculateRenotes);
async function translate_(noteId: string, targetLang: string) {
return await os.api("notes/translate", {
@ -431,24 +590,14 @@ async function translate() {
translating.value = false;
}
const keymap = {
r: () => reply(true),
"e|a|plus": () => react(true),
q: () => renoteButton.value!.renote(true),
"up|k": focusBefore,
"down|j": focusAfter,
esc: blur,
"m|o": () => menu(true),
// FIXME: What's this?
// s: () => showContent.value !== showContent.value,
};
function softMuteReasonI18nSrc(what?: string) {
if (what === "note") return i18n.ts.userSaysSomethingReason;
if (what === "reply") return i18n.ts.userSaysSomethingReasonReply;
if (what === "renote") return i18n.ts.userSaysSomethingReasonRenote;
if (what === "quote") return i18n.ts.userSaysSomethingReasonQuote;
if (appearNote.value.historyId == null) {
useNoteCapture({
rootEl: el,
note: appearNote,
isDeletedRef: isDeleted,
});
// I don't think here is reachable, but just in case
return i18n.ts.userSaysSomething;
}
function reply(_viaKeyboard = false): void {
@ -489,11 +638,6 @@ function undoReact(note: NoteType): void {
});
}
const currentClipPage = inject<Ref<entities.Clip> | null>(
"currentClipPage",
null,
);
function onContextmenu(ev: MouseEvent): void {
const isLink = (el: HTMLElement): boolean => {
if (el.tagName === "A") return true;
@ -582,7 +726,7 @@ function menu(viaKeyboard = false): void {
}
function showRenoteMenu(viaKeyboard = false): void {
if (!isMyRenote) return;
if (!isMyNote.value) return;
os.popupMenu(
[
{
@ -643,39 +787,10 @@ function readPromo() {
isDeleted.value = true;
}
const postIsExpanded = ref(false);
function setPostExpanded(val: boolean) {
postIsExpanded.value = val;
}
const accessibleLabel = computed(() => {
let label = `${appearNote.value.user.username}; `;
if (appearNote.value.renote) {
label += `${i18n.ts.renoted} ${appearNote.value.renote.user.username}; `;
if (appearNote.value.renote.cw) {
label += `${i18n.ts.cw}: ${appearNote.value.renote.cw}; `;
if (postIsExpanded.value) {
label += `${appearNote.value.renote.text}; `;
}
} else {
label += `${appearNote.value.renote.text}; `;
}
} else {
if (appearNote.value.cw) {
label += `${i18n.ts.cw}: ${appearNote.value.cw}; `;
if (postIsExpanded.value) {
label += `${appearNote.value.text}; `;
}
} else {
label += `${appearNote.value.text}; `;
}
}
const date = new Date(appearNote.value.createdAt);
label += `${date.toLocaleTimeString()}`;
return label;
});
defineExpose({
focus,
blur,
@ -749,6 +864,7 @@ defineExpose({
position: relative;
padding: 0 32px 0 32px;
display: flex;
flex-wrap: wrap;
z-index: 1;
&:first-child {
margin-top: 20px;
@ -801,6 +917,16 @@ defineExpose({
margin-right: 4px;
}
.avatar {
width: 1.2em;
height: 1.2em;
border-radius: 2em;
overflow: hidden;
margin-right: 0.4em;
background: var(--panelHighlight);
transform: translateY(-4px);
}
> span {
overflow: hidden;
flex-shrink: 1;

View File

@ -48,8 +48,6 @@
</template>
<script lang="ts" setup>
import { ref } from "vue";
import type { entities } from "firefish-js";
import { defaultStore } from "@/store";
import MkVisibility from "@/components/MkVisibility.vue";
@ -66,18 +64,16 @@ const props = defineProps<{
canOpenServerInfo?: boolean;
}>();
const note = ref(props.note);
const showTicker =
defaultStore.state.instanceTicker === "always" ||
(defaultStore.state.instanceTicker === "remote" && note.value.user.instance);
(defaultStore.state.instanceTicker === "remote" && props.note.user.instance);
function openServerInfo() {
if (!props.canOpenServerInfo || !defaultStore.state.openServerInfo) return;
const instanceInfoUrl =
note.value.user.host == null
props.note.user.host == null
? "/about"
: `/instance-info/${note.value.user.host}`;
: `/instance-info/${props.note.user.host}`;
pageWindow(instanceInfoUrl);
}
</script>

View File

@ -1,5 +1,10 @@
<template>
<div v-size="{ min: [350, 500] }" class="yohlumlk">
<div
v-show="!deleted"
v-size="{ min: [350, 500] }"
class="yohlumlk"
ref="el"
>
<MkAvatar class="avatar" :user="note.user" />
<div class="main">
<XNoteHeader class="header" :note="note" :mini="true" />
@ -14,11 +19,40 @@
import type { entities } from "firefish-js";
import XNoteHeader from "@/components/MkNoteHeader.vue";
import MkSubNoteContent from "@/components/MkSubNoteContent.vue";
import { computed, ref, watch } from "vue";
import { deepClone } from "@/scripts/clone";
import { useNoteCapture } from "@/scripts/use-note-capture";
import { isDeleted } from "@/scripts/note";
defineProps<{
const props = defineProps<{
note: entities.Note;
pinned?: boolean;
}>();
const rootEl = ref<HTMLElement | null>(null);
const note = ref(deepClone(props.note));
const deleted = computed(() => isDeleted(note.value.id));
let capture = useNoteCapture({
note,
rootEl,
});
function reload() {
note.value = deepClone(props.note);
capture.close();
capture = useNoteCapture({
note,
rootEl,
});
}
watch(
() => props.note.id,
(o, n) => {
if (o === n) return;
reload();
},
);
</script>
<style lang="scss" scoped>

View File

@ -3,6 +3,7 @@
ref="pagingComponent"
:pagination="pagination"
:disable-auto-load="disableAutoLoad"
:folder
>
<template #empty>
<div class="_fullinfo">
@ -15,7 +16,7 @@
</div>
</template>
<template #default="{ items: notes }">
<template #default="{ foldedItems: notes }">
<div ref="tlEl" class="giivymft" :class="{ noGap }">
<XList
ref="notes"
@ -28,6 +29,21 @@
class="notes"
>
<XNote
v-if="'folded' in note && note.folded === 'thread'"
:key="note.id"
class="qtqtichx"
:note="note.note"
:parents="note.parents"
/>
<XNote
v-else-if="'folded' in note && note.folded === 'renote'"
:key="note.key"
class="qtqtichx"
:note="note.note"
:renotes="note.renotesArr"
/>
<XNote
v-else
:key="note._featuredId_ || note._prId_ || note.id"
class="qtqtichx"
:note="note"
@ -51,14 +67,21 @@ import XList from "@/components/MkDateSeparatedList.vue";
import MkPagination from "@/components/MkPagination.vue";
import { i18n } from "@/i18n";
import { scroll } from "@/scripts/scroll";
import type { NoteFolded, NoteThread, NoteType } from "@/types/note";
const tlEl = ref<HTMLElement>();
defineProps<{
pagination: PagingOf<entities.Note>;
noGap?: boolean;
disableAutoLoad?: boolean;
}>();
withDefaults(
defineProps<{
pagination: PagingOf<entities.Note>;
noGap?: boolean;
disableAutoLoad?: boolean;
folder?: (ns: entities.Note[]) => (NoteType | NoteThread | NoteFolded)[];
}>(),
{
folder: (ns: entities.Note[]) => ns,
},
);
const pagingComponent = ref<MkPaginationType<
PagingKeyOf<entities.Note>

View File

@ -79,29 +79,35 @@ const stream = useStream();
const pagingComponent = ref<MkPaginationType<"i/notifications"> | null>(null);
const shouldFold = defaultStore.state.foldNotification;
const shouldFold = defaultStore.reactiveState.foldNotification;
const convertNotification = computed(() =>
shouldFold.value ? foldNotifications : (ns: entities.Notification[]) => ns,
);
const FETCH_LIMIT = 90;
const pagination = Object.assign(
{
endpoint: "i/notifications" as const,
params: computed(() => ({
includeTypes: props.includeTypes ?? undefined,
excludeTypes: props.includeTypes
? undefined
: me?.mutingNotificationTypes,
unreadOnly: props.unreadOnly,
})),
},
shouldFold
? {
limit: 50,
secondFetchLimit: FETCH_LIMIT,
}
: {
limit: 30,
},
const pagination = computed(() =>
Object.assign(
{
endpoint: "i/notifications" as const,
params: computed(() => ({
includeTypes: props.includeTypes ?? undefined,
excludeTypes: props.includeTypes
? undefined
: me?.mutingNotificationTypes,
unreadOnly: props.unreadOnly,
})),
},
shouldFold.value
? {
limit: 50,
secondFetchLimit: FETCH_LIMIT,
}
: {
limit: 30,
},
),
);
function isNoteNotification(
@ -138,14 +144,6 @@ const onNotification = (notification: entities.Notification) => {
let connection: StreamTypes.ChannelOf<"main"> | undefined;
function convertNotification(ns: entities.Notification[]) {
if (shouldFold) {
return foldNotifications(ns);
} else {
return ns;
}
}
onMounted(() => {
connection = stream.useChannel("main");
connection.on("notification", onNotification);

View File

@ -365,9 +365,9 @@ async function fetch(firstFetching?: boolean) {
}
// biome-ignore lint/style/noParameterAssign: assign it intentially
res = res.filter((item) => {
if (idMap.has(item)) return false;
idMap.set(item, true);
res = res.filter((it) => {
if (idMap.has(it.id)) return false;
idMap.set(it.id, true);
return true;
});
}
@ -435,8 +435,20 @@ const prepend = (...item: Item[]): void => {
}
};
const append = (...items: Item[]): void => {
appended.value.push(...items);
const append = (...it: Item[]): void => {
// If there are too many appended, merge them into arrItems
if (
appended.value.length >
(props.pagination.secondFetchLimit || SECOND_FETCH_LIMIT_DEFAULT)
) {
for (const item of appended.value) {
idMap.set(item.id, true);
}
arrItems.value.push(appended.value);
appended.value = [];
// We don't need to calculate here because it won't cause any changes in items
}
appended.value.push(...it);
calculateItems();
};
@ -486,6 +498,8 @@ if (props.pagination.params && isRef<Param>(props.pagination.params)) {
watch(props.pagination.params, reload, { deep: true });
}
watch(() => props.folder, calculateItems);
watch(
queue,
(a, b) => {

View File

@ -178,7 +178,7 @@
</template>
<script lang="ts" setup>
import { ref } from "vue";
import { computed, ref, watch } from "vue";
import type { entities } from "firefish-js";
import * as mfm from "mfm-js";
import * as os from "@/os";
@ -226,24 +226,35 @@ const emit = defineEmits<{
const cwButton = ref<HTMLElement>();
const showMoreButton = ref<HTMLElement>();
const isLong =
!props.detailedView &&
props.note.cw == null &&
props.isLongJudger(props.note);
const collapsed = ref(props.note.cw == null && isLong);
const urls = props.note.text
? extractUrlFromMfm(mfm.parse(props.note.text)).slice(0, 5)
: null;
const showContent = ref(false);
const mfms = props.note.text
? extractMfmWithAnimation(mfm.parse(props.note.text))
: null;
const hasMfm = ref(mfms && mfms.length > 0);
const isLong = computed(
() =>
!props.detailedView &&
props.note.cw == null &&
props.isLongJudger(props.note),
);
const urls = computed(() =>
props.note.text
? extractUrlFromMfm(mfm.parse(props.note.text)).slice(0, 5)
: null,
);
const mfms = computed(() =>
props.note.text ? extractMfmWithAnimation(mfm.parse(props.note.text)) : null,
);
const hasMfm = computed(() => mfms.value && mfms.value.length > 0);
const disableMfm = ref(defaultStore.state.animatedMfm);
const showContent = ref(false);
const collapsed = ref(props.note.cw == null && isLong.value);
watch(
() => props.note.id,
(o, n) => {
if (o !== n) return;
disableMfm.value = defaultStore.state.animatedMfm;
showContent.value = false;
collapsed.value = props.note.cw == null && isLong.value;
},
);
async function toggleMfm() {
if (disableMfm.value) {

View File

@ -30,6 +30,7 @@
:pagination="pagination"
@queue="(x) => (queue = x)"
@status="pullToRefreshComponent?.setDisabled($event)"
:folder
/>
</MkPullToRefresh>
<XNotes
@ -39,6 +40,7 @@
:pagination="pagination"
@queue="(x) => (queue = x)"
@status="pullToRefreshComponent?.setDisabled($event)"
:folder
/>
</template>
@ -54,6 +56,8 @@ import { isSignedIn, me } from "@/me";
import { i18n } from "@/i18n";
import { defaultStore } from "@/store";
import icon from "@/scripts/icon";
import { foldNotes } from "@/scripts/fold";
import type { NoteType } from "@/types/note";
export type TimelineSource =
| "antenna"
@ -85,6 +89,12 @@ const emit = defineEmits<{
const tlComponent = ref<InstanceType<typeof XNotes>>();
const pullToRefreshComponent = ref<InstanceType<typeof MkPullToRefresh>>();
const folder = computed(() => {
const mergeThread = defaultStore.reactiveState.mergeThreadInTimeline.value;
const mergeRenotes = defaultStore.reactiveState.mergeRenotesInTimeline.value;
return (ns: NoteType[]) => foldNotes(ns, mergeThread, mergeRenotes);
});
let endpoint: TypeUtils.EndpointsOf<entities.Note[]>; // keyof Endpoints
let query: {
antennaId?: string | undefined;

View File

@ -10,7 +10,7 @@
</template>
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, ref } from "vue";
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import { i18n } from "@/i18n";
import { dateTimeFormat } from "@/scripts/intl-const";
@ -25,7 +25,7 @@ const props = withDefaults(
},
);
const _time =
const _time = computed(() =>
props.time == null
? Number.NaN
: typeof props.time === "number"
@ -33,16 +33,19 @@ const _time =
: (props.time instanceof Date
? props.time
: new Date(props.time)
).getTime();
const invalid = Number.isNaN(_time);
const absolute = !invalid ? dateTimeFormat.format(_time) : i18n.ts._ago.invalid;
).getTime(),
);
const invalid = computed(() => Number.isNaN(_time.value));
const absolute = computed(() =>
!invalid.value ? dateTimeFormat.format(_time.value) : i18n.ts._ago.invalid,
);
const now = ref(props.origin?.getTime() ?? Date.now());
const relative = computed<string>(() => {
if (props.mode === "absolute") return ""; // absoluterelative使
if (invalid) return i18n.ts._ago.invalid;
if (invalid.value) return i18n.ts._ago.invalid;
const ago = (now.value - _time) / 1000; /* ms */
const ago = (now.value - _time.value) / 1000; /* ms */
return ago >= 31536000
? i18n.t("_ago.yearsAgo", { n: Math.floor(ago / 31536000).toString() })
: ago >= 2592000
@ -74,15 +77,25 @@ const relative = computed<string>(() => {
: i18n.ts._ago.future;
});
let tickId: number;
let tickId: number | undefined;
function tick() {
if (
invalid.value ||
props.origin ||
(props.mode !== "relative" && props.mode !== "detail")
) {
if (tickId) window.clearInterval(tickId);
tickId = undefined;
return;
}
const _now = Date.now();
const agoPrev = (now.value - _time) / 1000; /* ms */ // interval
const agoPrev = (now.value - _time.value) / 1000; /* ms */ // interval
now.value = _now;
const ago = (now.value - _time) / 1000; /* ms */ // interval
const ago = (now.value - _time.value) / 1000; /* ms */ // interval
const prev = agoPrev < 60 ? 10000 : agoPrev < 3600 ? 60000 : 180000;
const next = ago < 60 ? 10000 : ago < 3600 ? 60000 : 180000;
@ -94,16 +107,13 @@ function tick() {
}
}
if (
!invalid &&
!props.origin &&
(props.mode === "relative" || props.mode === "detail")
) {
onMounted(() => {
tick();
});
onUnmounted(() => {
if (tickId) window.clearInterval(tickId);
});
}
watch(() => props.time, tick);
onMounted(() => {
tick();
});
onUnmounted(() => {
if (tickId) window.clearInterval(tickId);
});
</script>

View File

@ -140,6 +140,12 @@
<FormSwitch v-model="foldNotification" class="_formBlock">{{
i18n.ts.foldNotification
}}</FormSwitch>
<FormSwitch v-model="mergeThreadInTimeline" class="_formBlock">{{
i18n.ts.mergeThreadInTimeline
}}</FormSwitch>
<FormSwitch v-model="mergeRenotesInTimeline" class="_formBlock">{{
i18n.ts.mergeRenotesInTimeline
}}</FormSwitch>
<FormSelect v-model="serverDisconnectedBehavior" class="_formBlock">
<template #label>{{ i18n.ts.whenServerDisconnected }}</template>
@ -556,6 +562,12 @@ const autocorrectNoteLanguage = computed(
const foldNotification = computed(
defaultStore.makeGetterSetter("foldNotification"),
);
const mergeThreadInTimeline = computed(
defaultStore.makeGetterSetter("mergeThreadInTimeline"),
);
const mergeRenotesInTimeline = computed(
defaultStore.makeGetterSetter("mergeRenotesInTimeline"),
);
// This feature (along with injectPromo) is currently disabled
// function onChangeInjectFeaturedNote(v) {
@ -632,7 +644,6 @@ watch(
enableTimelineStreaming,
enablePullToRefresh,
pullToRefreshThreshold,
foldNotification,
],
async () => {
await reloadAsk();

View File

@ -3,6 +3,9 @@ import type {
FoldableNotification,
NotificationFolded,
} from "@/types/notification";
import type { NoteType, NoteThread, NoteFolded } from "@/types/note";
import { me } from "@/me";
import { isDeleted, isRenote } from "./note";
interface FoldOption {
/** If items length is 1, skip aggregation */
@ -91,3 +94,94 @@ export function foldNotifications(ns: entities.Notification[]) {
},
);
}
export function foldNotes(ns: NoteType[], foldReply = true, foldRenote = true) {
// By the implement of MkPagination, lastId is unique and is safe for key
const lastId = ns[ns.length - 1]?.id ?? "prepend";
function foldReplies(ns: NoteType[]) {
const res: Array<NoteType | NoteThread> = [];
const threads = new Map<NoteType["id"], NoteType[]>();
for (const n of [...ns].reverse()) {
if (isDeleted(n.id)) {
continue;
}
if (n.replyId && threads.has(n.replyId)) {
const th = threads.get(n.replyId)!;
threads.delete(n.replyId);
th.push(n);
threads.set(n.id, th);
} else if (n.reply?.replyId && threads.has(n.reply.replyId)) {
const th = threads.get(n.reply.replyId)!;
threads.delete(n.reply.replyId);
th.push(n.reply, n);
threads.set(n.id, th);
} else {
threads.set(n.id, [n]);
}
}
for (const n of ns) {
const conversation = threads.get(n.id);
if (conversation == null) continue;
const first = conversation[0];
const last = conversation[conversation.length - 1];
if (conversation.length === 1) {
res.push(first);
continue;
}
res.push({
// The same note can only appear once in the timeline, so the ID will not be repeated
id: first.id,
createdAt: last.createdAt,
folded: "thread",
note: last,
parents: (first.reply ? [first.reply] : []).concat(
conversation.slice(0, -1),
),
});
}
return res;
}
let res: (NoteType | NoteThread | NoteFolded)[] = ns;
if (foldReply) {
res = foldReplies(ns);
}
if (foldRenote) {
res = foldItems(
res,
(n) => {
// never fold my renotes
if (!("folded" in n) && isRenote(n) && n.userId !== me?.id)
return `renote-${n.renoteId}`;
return n.id;
},
(ns, key) => {
const represent = ns[0];
if (!key.startsWith("renote-")) {
return represent;
}
return {
id: `G-${lastId}-${key}`,
key: `G-${lastId}-${key}`,
createdAt: represent.createdAt,
folded: "renote",
note: (represent as entities.Note).renote!,
renotesArr: ns as entities.Note[],
};
},
{
skipSingleElement: false,
},
);
}
return res;
}

View File

@ -0,0 +1,20 @@
import type { entities } from "firefish-js";
import { deletedNoteIds } from "./use-note-capture";
export function isRenote(note: entities.Note): note is entities.Note & {
renote: entities.Note;
text: null;
renoteId: string;
poll: undefined;
} {
return (
note.renote != null &&
note.text == null &&
note.fileIds.length === 0 &&
note.poll == null
);
}
export function isDeleted(noteId: string) {
return deletedNoteIds.has(noteId);
}

View File

@ -1,22 +1,62 @@
import type { Ref } from "vue";
import { onUnmounted } from "vue";
import type { entities } from "firefish-js";
import { onUnmounted, ref } from "vue";
import { useStream } from "@/stream";
import { isSignedIn, me } from "@/me";
import * as os from "@/os";
import type { NoteType } from "@/types/note";
export const deletedNoteIds = new Map<NoteType["id"], boolean>();
const noteRefs = new Map<NoteType["id"], Ref<NoteType>[]>();
function addToNoteRefs(note: Ref<NoteType>) {
const refs = noteRefs.get(note.value.id);
if (refs) {
refs.push(note);
} else {
noteRefs.set(note.value.id, [note]);
}
}
function eachNote(id: NoteType["id"], cb: (note: Ref<NoteType>) => void) {
const refs = noteRefs.get(id);
if (refs) {
for (const n of refs) {
// n.value.id maybe changed
if (n.value.id === id) {
cb(n);
}
}
}
}
export function useNoteCapture(props: {
rootEl: Ref<HTMLElement | null>;
note: Ref<entities.Note>;
isDeletedRef: Ref<boolean>;
onReplied?: (note: entities.Note) => void;
note: Ref<NoteType>;
isDeletedRef?: Ref<boolean>;
onReplied?: (note: NoteType) => void;
}) {
let closed = false;
const note = props.note;
const connection = isSignedIn(me) ? useStream() : null;
addToNoteRefs(note);
function onDeleted() {
if (props.isDeletedRef) props.isDeletedRef.value = true;
deletedNoteIds.set(note.value.id, true);
if (note.value.replyId) {
eachNote(note.value.replyId, (n) => n.value.repliesCount--);
}
if (note.value.renoteId) {
eachNote(note.value.renoteId, (n) => n.value.renoteCount--);
}
}
async function onStreamNoteUpdated(noteData): Promise<void> {
const { type, id, body } = noteData;
if (closed) return;
if (id !== note.value.id) return;
switch (type) {
@ -87,7 +127,7 @@ export function useNoteCapture(props: {
}
case "deleted": {
props.isDeletedRef.value = true;
onDeleted();
break;
}
@ -96,17 +136,14 @@ export function useNoteCapture(props: {
const editedNote = await os.api("notes/show", {
noteId: id,
});
const keys = new Set<string>();
Object.keys(editedNote)
.concat(Object.keys(note.value))
.forEach((key) => keys.add(key));
keys.forEach((key) => {
for (const key of [
...new Set(Object.keys(editedNote).concat(Object.keys(note.value))),
]) {
note.value[key] = editedNote[key];
});
}
} catch {
// delete the note if failing to get the edited note
props.isDeletedRef.value = true;
onDeleted();
}
break;
}
@ -147,4 +184,10 @@ export function useNoteCapture(props: {
connection.off("_connected_", onStreamConnected);
}
});
return {
close: () => {
closed = true;
},
};
}

View File

@ -454,6 +454,14 @@ export const defaultStore = markRaw(
where: "deviceAccount",
default: true,
},
mergeThreadInTimeline: {
where: "deviceAccount",
default: true,
},
mergeRenotesInTimeline: {
where: "deviceAccount",
default: true,
},
}),
);

View File

@ -1,4 +1,4 @@
import type { noteVisibilities } from "firefish-js";
import type { entities, noteVisibilities } from "firefish-js";
export type NoteVisibility = (typeof noteVisibilities)[number] | "private";
@ -6,3 +6,25 @@ export interface NoteTranslation {
sourceLang: string;
text: string;
}
export type NoteType = entities.Note & {
_featuredId_?: string;
_prId_?: string;
};
export type NoteFolded = {
id: string;
key: string;
createdAt: entities.Note["createdAt"];
folded: "renote";
note: entities.Note;
renotesArr: entities.Note[];
};
export type NoteThread = {
id: string;
createdAt: entities.Note["createdAt"];
folded: "thread";
note: entities.Note;
parents: entities.Note[];
};

View File

@ -153,9 +153,6 @@ importers:
gunzip-maybe:
specifier: ^1.4.2
version: 1.4.2
happy-dom:
specifier: ^14.7.1
version: 14.7.1
hpagent:
specifier: 1.2.0
version: 1.2.0
@ -168,6 +165,9 @@ importers:
is-svg:
specifier: 5.0.0
version: 5.0.0
jsdom:
specifier: 24.0.0
version: 24.0.0
json5:
specifier: 2.2.3
version: 2.2.3
@ -368,6 +368,9 @@ importers:
'@types/fluent-ffmpeg':
specifier: 2.1.24
version: 2.1.24
'@types/jsdom':
specifier: 21.1.6
version: 21.1.6
'@types/jsonld':
specifier: 1.5.13
version: 1.5.13
@ -4114,6 +4117,14 @@ packages:
pretty-format: 29.7.0
dev: true
/@types/jsdom@21.1.6:
resolution: {integrity: sha512-/7kkMsC+/kMs7gAYmmBR9P0vGTnOoLhQhyhQJSlXGI5bzTHp6xdo0TtKWQAsz6pmSAeVqKSbqeyP6hytqr9FDw==}
dependencies:
'@types/node': 20.12.7
'@types/tough-cookie': 4.0.5
parse5: 7.1.2
dev: true
/@types/json-schema@7.0.12:
resolution: {integrity: sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==}
dev: true
@ -4416,6 +4427,10 @@ packages:
resolution: {integrity: sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==}
dev: true
/@types/tough-cookie@4.0.5:
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
dev: true
/@types/unist@2.0.7:
resolution: {integrity: sha512-cputDpIbFgLUaGQn6Vqg3/YsJwxUwHLO13v3i5ouxT4lat0khip9AEWxtERujXV9wxIB1EyF97BSJFt6vpdI8g==}
dev: true
@ -6712,6 +6727,13 @@ packages:
hasBin: true
dev: true
/cssstyle@4.0.1:
resolution: {integrity: sha512-8ZYiJ3A/3OkDd093CBT/0UKDWry7ak4BdPTFP2+QEP7cmhouyq/Up709ASSj2cK02BbZiMgk7kYjZNS4QP5qrQ==}
engines: {node: '>=18'}
dependencies:
rrweb-cssom: 0.6.0
dev: false
/csstype@3.1.2:
resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==}
dev: true
@ -6743,6 +6765,14 @@ packages:
engines: {node: '>= 12'}
dev: false
/data-urls@5.0.0:
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
engines: {node: '>=18'}
dependencies:
whatwg-mimetype: 4.0.0
whatwg-url: 14.0.0
dev: false
/date-fns@3.6.0:
resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==}
@ -6816,6 +6846,10 @@ packages:
engines: {node: '>=10'}
dev: true
/decimal.js@10.4.3:
resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==}
dev: false
/decode-uri-component@0.2.2:
resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
engines: {node: '>=0.10'}
@ -9019,15 +9053,6 @@ packages:
engines: {node: '>=0.8.0'}
dev: true
/happy-dom@14.7.1:
resolution: {integrity: sha512-v60Q0evZ4clvMcrAh5/F8EdxDdfHdFrtffz/CNe10jKD+nFweZVxM91tW+UyY2L4AtpgIaXdZ7TQmiO1pfcwbg==}
engines: {node: '>=16.0.0'}
dependencies:
entities: 4.5.0
webidl-conversions: 7.0.0
whatwg-mimetype: 3.0.0
dev: false
/hard-rejection@2.1.0:
resolution: {integrity: sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==}
engines: {node: '>=6'}
@ -9129,6 +9154,13 @@ packages:
engines: {node: '>=14'}
dev: false
/html-encoding-sniffer@4.0.0:
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
engines: {node: '>=18'}
dependencies:
whatwg-encoding: 3.1.1
dev: false
/html-entities@2.3.2:
resolution: {integrity: sha512-c3Ab/url5ksaT0WyleslpBEthOzWhrjQbg75y7XUsfSzi3Dgzt0l8w5e7DylRn15MTlMMD58dTfzddNS2kcAjQ==}
dev: false
@ -9199,6 +9231,16 @@ packages:
toidentifier: 1.0.1
dev: false
/http-proxy-agent@7.0.2:
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
engines: {node: '>= 14'}
dependencies:
agent-base: 7.1.0
debug: 4.3.4(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
dev: false
/http2-wrapper@1.0.3:
resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==}
engines: {node: '>=10.19.0'}
@ -9239,6 +9281,16 @@ packages:
- supports-color
dev: false
/https-proxy-agent@7.0.4:
resolution: {integrity: sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==}
engines: {node: '>= 14'}
dependencies:
agent-base: 7.1.0
debug: 4.3.4(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
dev: false
/human-signals@2.1.0:
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
engines: {node: '>=10.17.0'}
@ -9606,6 +9658,10 @@ packages:
engines: {node: '>=0.10.0'}
dev: false
/is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
dev: false
/is-promise@2.2.2:
resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==}
@ -10375,6 +10431,42 @@ packages:
engines: {node: '>=12.0.0'}
dev: true
/jsdom@24.0.0:
resolution: {integrity: sha512-UDS2NayCvmXSXVP6mpTj+73JnNQadZlr9N68189xib2tx5Mls7swlTNao26IoHv46BZJFvXygyRtyXd1feAk1A==}
engines: {node: '>=18'}
peerDependencies:
canvas: ^2.11.2
peerDependenciesMeta:
canvas:
optional: true
dependencies:
cssstyle: 4.0.1
data-urls: 5.0.0
decimal.js: 10.4.3
form-data: 4.0.0
html-encoding-sniffer: 4.0.0
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.4
is-potential-custom-element-name: 1.0.1
nwsapi: 2.2.9
parse5: 7.1.2
rrweb-cssom: 0.6.0
saxes: 6.0.0
symbol-tree: 3.2.4
tough-cookie: 4.1.4
w3c-xmlserializer: 5.0.0
webidl-conversions: 7.0.0
whatwg-encoding: 3.1.1
whatwg-mimetype: 4.0.0
whatwg-url: 14.0.0
ws: 8.16.0
xml-name-validator: 5.0.0
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
dev: false
/jsesc@0.5.0:
resolution: {integrity: sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==}
hasBin: true
@ -11579,6 +11671,10 @@ packages:
boolbase: 1.0.0
dev: true
/nwsapi@2.2.9:
resolution: {integrity: sha512-2f3F0SEEer8bBu0dsNCFF50N0cTThV1nWFYcEYFZttdW0lDAoybv9cQoK7X7/68Z89S7FoRrVjP1LPX4XRf9vg==}
dev: false
/oauth@0.10.0:
resolution: {integrity: sha512-1orQ9MT1vHFGQxhuy7E/0gECD3fd2fCC+PIX+/jgmU/gI3EpRocXtmtvxCO5x3WZ443FLTLFWNDjl5MPJf9u+Q==}
dev: false
@ -11885,7 +11981,6 @@ packages:
resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==}
dependencies:
entities: 4.5.0
dev: false
/parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
@ -12365,6 +12460,10 @@ packages:
resolution: {integrity: sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==}
dev: true
/psl@1.9.0:
resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==}
dev: false
/pug-attrs@3.0.0:
resolution: {integrity: sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA==}
dependencies:
@ -12524,6 +12623,10 @@ packages:
deprecated: The querystring API is considered Legacy. new code should use the URLSearchParams API instead.
dev: false
/querystringify@2.2.0:
resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
dev: false
/queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
dev: true
@ -12760,6 +12863,10 @@ packages:
/require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
/requires-port@1.0.0:
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
dev: false
/resolve-alpn@1.2.1:
resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==}
@ -12886,6 +12993,10 @@ packages:
fsevents: 2.3.3
dev: true
/rrweb-cssom@0.6.0:
resolution: {integrity: sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==}
dev: false
/rss-parser@3.13.0:
resolution: {integrity: sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w==}
dependencies:
@ -12964,6 +13075,13 @@ packages:
/sax@1.2.4:
resolution: {integrity: sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==}
/saxes@6.0.0:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
dependencies:
xmlchars: 2.2.0
dev: false
/schema-utils@3.3.0:
resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==}
engines: {node: '>= 10.13.0'}
@ -13569,6 +13687,10 @@ packages:
engines: {node: '>= 4.7.0'}
dev: true
/symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
dev: false
/synckit@0.6.2:
resolution: {integrity: sha512-Vhf+bUa//YSTYKseDiiEuQmhGCoIF3CVBhunm3r/DQnYiGT4JssmnKQc44BIyOZRK2pKjXXAgbhfmbeoC9CJpA==}
engines: {node: '>=12.20'}
@ -13788,9 +13910,26 @@ packages:
'@tokenizer/token': 0.3.0
ieee754: 1.2.1
/tough-cookie@4.1.4:
resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==}
engines: {node: '>=6'}
dependencies:
psl: 1.9.0
punycode: 2.3.1
universalify: 0.2.0
url-parse: 1.5.10
dev: false
/tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
/tr46@5.0.0:
resolution: {integrity: sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==}
engines: {node: '>=18'}
dependencies:
punycode: 2.3.1
dev: false
/trace-redirect@1.0.6:
resolution: {integrity: sha512-UUfa1DjjU5flcjMdaFIiIEGDTyu2y/IiMjOX4uGXa7meKBS4vD4f2Uy/tken9Qkd4Jsm4sRsfZcIIPqrRVF3Mg==}
dev: false
@ -14270,6 +14409,11 @@ packages:
engines: {node: '>= 4.0.0'}
dev: false
/universalify@0.2.0:
resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==}
engines: {node: '>= 4.0.0'}
dev: false
/universalify@2.0.0:
resolution: {integrity: sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==}
engines: {node: '>= 10.0.0'}
@ -14310,6 +14454,13 @@ packages:
dependencies:
punycode: 2.3.1
/url-parse@1.5.10:
resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==}
dependencies:
querystringify: 2.2.0
requires-port: 1.0.0
dev: false
/url-polyfill@1.1.12:
resolution: {integrity: sha512-mYFmBHCapZjtcNHW0MDq9967t+z4Dmg5CJ0KqysK3+ZbyoNOWQHksGCTWwDhxGXllkWlOc10Xfko6v4a3ucM6A==}
dev: true
@ -14581,6 +14732,13 @@ packages:
typescript: 5.4.5
dev: true
/w3c-xmlserializer@5.0.0:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
dependencies:
xml-name-validator: 5.0.0
dev: false
/walker@1.0.8:
resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==}
dependencies:
@ -14691,9 +14849,24 @@ packages:
- supports-color
dev: false
/whatwg-mimetype@3.0.0:
resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
engines: {node: '>=12'}
/whatwg-encoding@3.1.1:
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
engines: {node: '>=18'}
dependencies:
iconv-lite: 0.6.3
dev: false
/whatwg-mimetype@4.0.0:
resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
engines: {node: '>=18'}
dev: false
/whatwg-url@14.0.0:
resolution: {integrity: sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==}
engines: {node: '>=18'}
dependencies:
tr46: 5.0.0
webidl-conversions: 7.0.0
dev: false
/whatwg-url@5.0.0:
@ -14823,7 +14996,6 @@ packages:
optional: true
utf-8-validate:
optional: true
dev: true
/xev@3.0.2:
resolution: {integrity: sha512-8kxuH95iMXzHZj+fwqfA4UrPcYOy6bGIgfWzo9Ji23JoEc30ge/Z++Ubkiuy8c0+M64nXmmxrmJ7C8wnuBhluw==}
@ -14841,6 +15013,11 @@ packages:
engines: {node: '>=12'}
dev: true
/xml-name-validator@5.0.0:
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
engines: {node: '>=18'}
dev: false
/xml2js@0.5.0:
resolution: {integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==}
engines: {node: '>=4.0.0'}
@ -14862,6 +15039,10 @@ packages:
engines: {node: '>=4.0'}
dev: false
/xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
dev: false
/xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}