Compare commits

...

43 Commits

Author SHA1 Message Date
laozhoubuluo dd4d16c687 Merge branch 'feat/update_email_tips' into 'develop'
feat: update email tips


See merge request firefish/firefish!10716
2024-05-07 20:05:42 +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
naskya 4277ad0b59
meta: update COPYING & include LICENSE in pre-built images 2024-05-07 20:54:47 +09:00
naskya fc65d8c1c3
docs: update api-change.md 2024-05-07 20:52:11 +09:00
naskya 3b3d457c3e
ci: restrict paths 2024-05-07 18:34:18 +09:00
naskya 1128e243d3
container: fix dockerignore 2024-05-07 18:01:05 +09:00
naskya 39e08f57e8
ci: remove unneeded argument 2024-05-07 18:01:05 +09:00
naskya 09ef642905
ci: skip builds if unneeded 2024-05-07 17:36:23 +09:00
naskya 1b8748bc8c
another attempt to build an image inside container inside container 2024-05-07 17:30:57 +09:00
naskya 82c98ae72f
ci: modify buildah args 2024-05-07 07:26:33 +09:00
naskya 5b3f93457b
dev: add renovate 2024-05-07 06:58:00 +09:00
naskya 4d9c0f8e7b
ci: fix syntax 2024-05-07 06:11:31 +09:00
naskya bf2b624bc9
ci: build OCI container image on develop 2024-05-07 05:52:43 +09:00
naskya 5261eb24b6
ci: restrict project path 2024-05-07 05:26:05 +09:00
naskya d440e9b388
ci: revise tasks 2024-05-07 04:58:59 +09:00
naskya 14b285f882 Merge branch 'refactor/is-safe-url' into 'develop'
refactor (backend): port isValidUrl to backend-rs


See merge request firefish/firefish!10795
2024-05-06 17:11:51 +00:00
naskya baa5c402db
ci: apt-get update first & fix paths 2024-05-07 01:54:29 +09:00
naskya 5b01d3574f
refactor (backend): port isValidUrl to backend-rs 2024-05-07 00:56:37 +09:00
naskya e3a98ebc72 Merge branch 'userLang' into 'develop'
Add server-side per-user UI language

Co-authored-by: eana <coder@apps.1a23.com>

See merge request firefish/firefish!10793
2024-05-06 15:31:18 +00:00
naskya 7fe7f90350
ci: revise build config 2024-05-07 00:22:51 +09:00
naskya 8ed942e00f
chore: update auto-generated files 2024-05-06 23:13:31 +09:00
naskya ddfdd038ad
chore: update downgrade.sql 2024-05-06 23:10:39 +09:00
naskya 7fdd44cf8d
locale: update translations 2024-05-06 23:07:57 +09:00
eana ef57735e6a fix typo 2024-05-06 05:26:38 +00:00
eana e7c33835b2 Add server-side per-user UI language 2024-05-06 05:14:44 +00: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
51 changed files with 1192 additions and 363 deletions

View File

@ -51,12 +51,11 @@ title.svg
/dev
/docs
/scripts
!/scripts/copy-assets.mjs
biome.json
COPYING
CODE_OF_CONDUCT.md
CONTRIBUTING.md
Dockerfile
LICENSE
Procfile
README.md
SECURITY.md

View File

@ -1,4 +1,4 @@
image: docker.io/node:18-alpine
image: docker.io/rust:slim-bookworm
services:
- name: docker.io/groonga/pgroonga:latest-alpine-12-slim
@ -8,40 +8,167 @@ services:
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == 'merge_request_event'
- if: $CI_PROJECT_PATH == 'firefish/firefish'
when: always
- if: $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == 'main'
when: never
- if: $CI_MERGE_REQUEST_PROJECT_PATH == 'firefish/firefish'
when: always
- when: never
cache:
paths:
- node_modules/
- target/
- node_modules
# - /usr/local/cargo/registry/index
# - /usr/local/cargo/registry/cache
- target/debug/deps
- target/debug/build
stages:
- test
- build
- dependency
variables:
POSTGRES_DB: firefish_db
POSTGRES_USER: firefish
POSTGRES_PASSWORD: password
POSTGRES_HOST_AUTH_METHOD: trust
POSTGRES_DB: 'firefish_db'
POSTGRES_USER: 'firefish'
POSTGRES_PASSWORD: 'password'
POSTGRES_HOST_AUTH_METHOD: 'trust'
DEBIAN_FRONTEND: 'noninteractive'
CARGO_PROFILE_DEV_OPT_LEVEL: '0'
CARGO_PROFILE_DEV_LTO: 'off'
CARGO_PROFILE_DEV_DEBUG: 'none'
default:
before_script:
- apk add --update build-base linux-headers curl ca-certificates python3 perl postgresql-client
- curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
- . "${HOME}/.cargo/env"
- apt-get update && apt-get -y upgrade
- apt-get -y --no-install-recommends install curl
- curl -fsSL 'https://deb.nodesource.com/setup_18.x' | bash -
- apt-get install -y --no-install-recommends build-essential clang mold python3 perl nodejs postgresql-client
- corepack enable
- corepack prepare pnpm@latest --activate
- cp .config/ci.yml .config/default.yml
- cp ci/cargo/config.toml /usr/local/cargo/config.toml
- export PGPASSWORD="${POSTGRES_PASSWORD}"
- psql --host postgres --user "${POSTGRES_USER}" --dbname "${POSTGRES_DB}" --command 'CREATE EXTENSION pgroonga'
build_and_cargo_unit_test:
build_test:
stage: test
rules:
- if: $CI_PIPELINE_SOURCE == 'push' || $CI_PIPELINE_SOURCE == 'merge_request_event'
changes:
paths:
- packages/backend/*
- packages/backend-rs/*
- packages/macro-rs/*
- packages/megalodon/*
- scripts/**/*
- package.json
- pnpm-lock.yaml
- Cargo.toml
- Cargo.lock
script:
- pnpm install --frozen-lockfile
- 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
services: []
rules:
- if: $CI_COMMIT_BRANCH == 'develop'
changes:
paths:
- packages/**/*
- locales/**/*
- scripts/copy-assets.mjs
- package.json
- pnpm-lock.yaml
- Cargo.toml
- Cargo.lock
- Dockerfile
- .dockerignore
before_script:
- apt-get update && apt-get -y upgrade
- apt-get install -y --no-install-recommends buildah ca-certificates fuse-overlayfs
- buildah login --username "${CI_REGISTRY_USER}" --password "${CI_REGISTRY_PASSWORD}" "${CI_REGISTRY}"
- export IMAGE_TAG="${CI_REGISTRY}/${CI_PROJECT_PATH}/develop:not-for-production"
script:
- buildah build --isolation chroot --device /dev/fuse:rw --security-opt seccomp=unconfined --security-opt apparmor=unconfined --cap-add all --tag "${IMAGE_TAG}" --platform linux/amd64 .
- buildah inspect "${IMAGE_TAG}"
- buildah push "${IMAGE_TAG}"
cargo_unit_test:
stage: test
rules:
- if: $CI_PIPELINE_SOURCE == 'merge_request_event' || $CI_COMMIT_BRANCH == 'develop'
changes:
paths:
- packages/backend-rs/**/*
- packages/macro-rs/**/*
- Cargo.toml
- Cargo.lock
- if: $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == 'main'
when: never
script:
- cargo check --features napi
- pnpm install --frozen-lockfile
- mkdir packages/backend-rs/built
- cp packages/backend-rs/index.js packages/backend-rs/built/index.js
- cp packages/backend-rs/index.d.ts packages/backend-rs/built/index.d.ts
- pnpm --filter='!backend-rs' run build:debug
- cargo test
cargo_clippy:
stage: test
rules:
- if: $CI_PIPELINE_SOURCE == 'merge_request_event' || $CI_COMMIT_BRANCH == 'develop'
changes:
paths:
- packages/backend-rs/**/*
- packages/macro-rs/**/*
- Cargo.toml
- Cargo.lock
- if: $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == 'main'
when: never
script:
- rustup component add clippy
- cargo clippy -- -D warnings
renovate:
stage: dependency
image:
name: docker.io/renovate/renovate:37-slim
entrypoint: [""]
rules:
- if: $RENOVATE && $CI_PIPELINE_SOURCE == 'schedule'
services: []
before_script: []
script:
- renovate --platform gitlab --token "${API_TOKEN}" --endpoint "${CI_SERVER_URL}/api/v4" "${CI_PROJECT_PATH}"

View File

@ -26,10 +26,6 @@ RsaSignature2017 implementation by Transmute Industries Inc
License: MIT
https://github.com/transmute-industries/RsaSignature2017/blob/master/LICENSE
Machine learning model for sensitive images by Infinite Red, Inc.
License: MIT
https://github.com/infinitered/nsfwjs/blob/master/LICENSE
Chiptune2.js by Simon Gündling
License: MIT
https://github.com/deskjet/chiptune2.js#license

3
ci/cargo/config.toml Normal file
View File

@ -0,0 +1,3 @@
[target.x86_64-unknown-linux-gnu]
linker = "/usr/bin/clang"
rustflags = ["-C", "link-arg=--ld-path=/usr/bin/mold"]

View File

@ -2,6 +2,10 @@
Breaking changes are indicated by the :warning: icon.
## Unreleased
- Adding `lang` to the response of `i` and the request parameter of `i/update`.
## v20240504
- :warning: Removed `release` endpoint.

View File

@ -1,6 +1,7 @@
BEGIN;
DELETE FROM "migrations" WHERE name IN (
'AddUserProfileLanguage1714888400293',
'DropUnusedIndexes1714643926317',
'AlterAkaType1714099399879',
'AddDriveFileUsage1713451569342',
@ -764,9 +765,6 @@ CREATE SEQUENCE public.__chart_day__users_id_seq
CACHE 1;
ALTER SEQUENCE public.__chart_day__users_id_seq OWNED BY public.__chart_day__users.id;
-- drop-user-profile-language
ALTER TABLE "user_profile" ADD COLUMN "lang" character varying(32);
-- emoji-moderator
ALTER TABLE "user" DROP COLUMN "emojiModPerm";
DROP TYPE "public"."user_emojimodperm_enum";

View File

@ -766,6 +766,9 @@ confirmToUnclipAlreadyClippedNote: "This post is already part of the \"{name}\"
public: "Public"
i18nInfo: "Firefish is being translated into various languages by volunteers. You
can help at {link}."
i18nServerInfo: "New clients will be in {language} by default."
i18nServerChange: "Use {language} instead."
i18nServerSet: "Use {language} for new clients."
manageAccessTokens: "Manage access tokens"
accountInfo: "Account Info"
notesCount: "Number of posts"
@ -2247,3 +2250,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

@ -685,6 +685,9 @@ unclip: "クリップ解除"
confirmToUnclipAlreadyClippedNote: "この投稿はすでにクリップ「{name}」に含まれています。投稿をこのクリップから除外しますか?"
public: "公開"
i18nInfo: "Firefishは有志によって様々な言語に翻訳されています。{link}で翻訳に協力できます。"
i18nServerInfo: "新しい端末では{language}が既定の言語になります。"
i18nServerChange: "{language}に変更する。"
i18nServerSet: "新しい端末での表示言語を{language}にします。"
manageAccessTokens: "アクセストークンの管理"
accountInfo: "アカウント情報"
notesCount: "投稿の数"

View File

@ -667,6 +667,9 @@ unclip: "移除便签"
confirmToUnclipAlreadyClippedNote: "本帖已包含在便签 \"{name}\" 里。您想要将本帖从该便签中移除吗?"
public: "公开"
i18nInfo: "Firefish 已经被志愿者们翻译成了各种语言。如果您也有兴趣,可以通过 {link} 帮助翻译。"
i18nServerInfo: "新客户端将默认使用 {language}。"
i18nServerChange: "改为 {language}。"
i18nServerSet: "设定新客户端使用 {language}。"
manageAccessTokens: "管理访问令牌"
accountInfo: "账号信息"
notesCount: "帖子数量"
@ -2072,3 +2075,5 @@ noteEditHistory: "帖子编辑历史"
media: 媒体
slashQuote: "斜杠引用"
foldNotification: "将通知按同类型分组"
mergeThreadInTimeline: "将时间线内的连续回复合并成一串"
mergeRenotesInTimeline: "合并同一个帖子的转发"

View File

@ -661,6 +661,9 @@ unclip: "解除摘錄"
confirmToUnclipAlreadyClippedNote: "此貼文已包含在摘錄「{name}」中。 你想將貼文從這個摘錄中排除嗎?"
public: "公開"
i18nInfo: "Firefish已經被志願者們翻譯成各種語言版本如果想要幫忙的話可以進入{link}幫助翻譯。"
i18nServerInfo: "新客戶端將默認使用 {language}。"
i18nServerChange: "改為 {language}。"
i18nServerSet: "設定新客戶端使用 {language}。"
manageAccessTokens: "管理存取權杖"
accountInfo: "帳戶資訊"
notesCount: "貼文數量"

View File

@ -268,6 +268,7 @@ export interface NoteLikeForGetNoteSummary {
hasPoll: boolean
}
export function getNoteSummary(note: NoteLikeForGetNoteSummary): string
export function isSafeUrl(url: string): boolean
export function latestVersion(): Promise<string>
export function toMastodonId(firefishId: string): string | null
export function fromMastodonId(mastodonId: string): string | null
@ -1129,6 +1130,7 @@ export interface UserProfile {
preventAiLearning: boolean
isIndexable: boolean
mutedPatterns: Array<string>
lang: string | null
}
export interface UserPublickey {
userId: string
@ -1290,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, 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
@ -339,6 +339,7 @@ module.exports.safeForSql = safeForSql
module.exports.formatMilliseconds = formatMilliseconds
module.exports.getImageSizeFromUrl = getImageSizeFromUrl
module.exports.getNoteSummary = getNoteSummary
module.exports.isSafeUrl = isSafeUrl
module.exports.latestVersion = latestVersion
module.exports.toMastodonId = toMastodonId
module.exports.fromMastodonId = fromMastodonId
@ -380,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

@ -0,0 +1,34 @@
#[crate::export]
pub fn is_safe_url(url: &str) -> bool {
if let Ok(url) = url.parse::<url::Url>() {
if url.host_str().unwrap_or_default() == "unix"
|| !["http", "https"].contains(&url.scheme())
|| ![None, Some(80), Some(443)].contains(&url.port())
{
return false;
}
true
} else {
false
}
}
#[cfg(test)]
mod unit_test {
use super::is_safe_url;
#[test]
fn safe_url() {
assert!(is_safe_url("http://firefish.dev/firefish/firefish"));
assert!(is_safe_url("https://firefish.dev/firefish/firefish"));
assert!(is_safe_url("http://firefish.dev:80/firefish/firefish"));
assert!(is_safe_url("https://firefish.dev:80/firefish/firefish"));
assert!(is_safe_url("http://firefish.dev:443/firefish/firefish"));
assert!(is_safe_url("https://firefish.dev:443/firefish/firefish"));
assert!(!is_safe_url("https://unix/firefish/firefish"));
assert!(!is_safe_url("https://firefish.dev:35/firefish/firefish"));
assert!(!is_safe_url("ftp://firefish.dev/firefish/firefish"));
assert!(!is_safe_url("nyaa"));
assert!(!is_safe_url(""));
}
}

View File

@ -8,6 +8,7 @@ pub mod escape_sql;
pub mod format_milliseconds;
pub mod get_image_size;
pub mod get_note_summary;
pub mod is_safe_url;
pub mod latest_version;
pub mod mastodon_id;
pub mod meta;

View File

@ -78,6 +78,7 @@ pub struct Model {
pub is_indexable: bool,
#[sea_orm(column_name = "mutedPatterns")]
pub muted_patterns: Vec<String>,
pub lang: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

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

@ -0,0 +1,13 @@
import type { MigrationInterface, QueryRunner } from "typeorm";
export class AddUserProfileLanguage1714888400293 implements MigrationInterface {
async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user_profile" ADD COLUMN "lang" character varying(32)`,
);
}
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "lang"`);
}
}

View File

@ -7,10 +7,10 @@ import chalk from "chalk";
import Logger from "@/services/logger.js";
import IPCIDR from "ip-cidr";
import PrivateIp from "private-ip";
import { isValidUrl } from "./is-valid-url.js";
import { isSafeUrl } from "backend-rs";
export async function downloadUrl(url: string, path: string): Promise<void> {
if (!isValidUrl(url)) {
if (!isSafeUrl(url)) {
throw new StatusError("Invalid URL", 400);
}
@ -43,8 +43,8 @@ export async function downloadUrl(url: string, path: string): Promise<void> {
limit: 0,
},
})
.on("redirect", (res: Got.Response, opts: Got.NormalizedOptions) => {
if (!isValidUrl(opts.url)) {
.on("redirect", (_res: Got.Response, opts: Got.NormalizedOptions) => {
if (!isSafeUrl(opts.url)) {
downloadLogger.warn(`Invalid URL: ${opts.url}`);
req.destroy();
}

View File

@ -5,7 +5,7 @@ import CacheableLookup from "cacheable-lookup";
import fetch, { type RequestRedirect } from "node-fetch";
import { HttpProxyAgent, HttpsProxyAgent } from "hpagent";
import { config } from "@/config.js";
import { isValidUrl } from "./is-valid-url.js";
import { isSafeUrl } from "backend-rs";
export async function getJson(
url: string,
@ -60,7 +60,7 @@ export async function getResponse(args: {
size?: number;
redirect?: RequestRedirect;
}) {
if (!isValidUrl(args.url)) {
if (!isSafeUrl(args.url)) {
throw new StatusError("Invalid URL", 400);
}
@ -83,7 +83,7 @@ export async function getResponse(args: {
});
if (args.redirect === "manual" && [301, 302, 307, 308].includes(res.status)) {
if (!isValidUrl(res.url)) {
if (!isSafeUrl(res.url)) {
throw new StatusError("Invalid URL", 400);
}
return res;

View File

@ -1,20 +0,0 @@
export function isValidUrl(url: string | URL | undefined): boolean {
if (process.env.NODE_ENV !== "production") return true;
try {
if (url == null) return false;
const u = typeof url === "string" ? new URL(url) : url;
if (!u.protocol.match(/^https?:$/) || u.hostname === "unix") {
return false;
}
if (u.port !== "" && !["80", "443"].includes(u.port)) {
return false;
}
return true;
} catch {
return false;
}
}

View File

@ -50,6 +50,12 @@ export class UserProfile {
verified?: boolean;
}[];
@Column("varchar", {
length: 32,
nullable: true,
})
public lang: string | null;
@Column("varchar", {
length: 512,
nullable: true,

View File

@ -512,6 +512,7 @@ export const UserRepository = db.getRepository(User).extend({
description: profile!.description,
location: profile!.location,
birthday: profile!.birthday,
lang: profile!.lang,
fields: profile!.fields,
followersCount: followersCount ?? null,
followingCount: followingCount ?? null,

View File

@ -204,6 +204,12 @@ export const packedUserDetailedNotMeOnlySchema = {
optional: false,
example: "2018-03-12",
},
lang: {
type: "string",
nullable: true,
optional: false,
example: "ja-JP",
},
fields: {
type: "array",
nullable: false,

View File

@ -5,8 +5,8 @@ import { StatusError, getResponse } from "@/misc/fetch.js";
import { createSignedPost, createSignedGet } from "./ap-request.js";
import type { Response } from "node-fetch";
import type { IObject } from "./type.js";
import { isValidUrl } from "@/misc/is-valid-url.js";
import { apLogger } from "@/remote/activitypub/logger.js";
import { isSafeUrl } from "backend-rs";
export default async (user: { id: User["id"] }, url: string, object: any) => {
const body = JSON.stringify(object);
@ -44,7 +44,7 @@ export async function apGet(
user?: ILocalUser,
redirects: boolean = true,
): Promise<{ finalUrl: string; content: IObject }> {
if (!isValidUrl(url)) {
if (!isSafeUrl(url)) {
throw new StatusError("Invalid URL", 400);
}

View File

@ -87,6 +87,7 @@ export const paramDef = {
description: { ...Users.descriptionSchema, nullable: true },
location: { ...Users.locationSchema, nullable: true },
birthday: { ...Users.birthdaySchema, nullable: true },
lang: { type: "string", nullable: true },
avatarId: { type: "string", format: "misskey:id", nullable: true },
bannerId: { type: "string", format: "misskey:id", nullable: true },
fields: {
@ -154,6 +155,7 @@ export default define(meta, paramDef, async (ps, _user, token) => {
if (ps.name !== undefined) updates.name = ps.name;
if (ps.description !== undefined) profileUpdates.description = ps.description;
if (typeof ps.lang === "string") profileUpdates.lang = ps.lang;
if (ps.location !== undefined) profileUpdates.location = ps.location;
if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday;
if (ps.ffVisibility !== undefined)

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";
@ -511,7 +508,7 @@ export default async (
30,
);
}
publishNotesStream(noteToPublish);
publishToNotesStream(toRustObject(noteToPublish));
}
} finally {
await lock.release();

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

@ -132,6 +132,9 @@ export async function signIn(token: Account["token"], redirect?: string) {
if (_DEV_) console.log("logging as token ", token);
const newAccount = await fetchAccount(token);
localStorage.setItem("account", JSON.stringify(newAccount));
if (newAccount.lang) {
localStorage.setItem("lang", newAccount.lang);
}
document.cookie = `token=${token}; path=/; max-age=31536000`; // bull dashboardの認証とかで使う
await addAccount(newAccount.id, token);

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

@ -14,6 +14,12 @@
>
</template>
</I18n>
<I18n :src="i18n.ts.i18nServerInfo" v-if="serverLang" tag="span">
<template #language><strong>{{ langs.find(a => a[0] === serverLang)?.[1] ?? serverLang }}</strong></template>
</I18n>
<button class="_textButton" @click="updateServerLang" v-if="lang && lang !== serverLang">
{{i18n.t(serverLang ? "i18nServerChange" : "i18nServerSet", { language: langs.find(a => a[0] === lang)?.[1] ?? lang })}}
</button>
</template>
</FormSelect>
@ -134,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>
@ -404,6 +416,7 @@ import { deviceKind } from "@/scripts/device-kind";
import icon from "@/scripts/icon";
const lang = ref(localStorage.getItem("lang"));
const serverLang = ref(me?.lang);
const translateLang = ref(localStorage.getItem("translateLang"));
const fontSize = ref(localStorage.getItem("fontSize"));
const useSystemFont = ref(localStorage.getItem("useSystemFont") !== "f");
@ -549,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) {
@ -559,6 +578,14 @@ const foldNotification = computed(
// });
// }
function updateServerLang() {
os.api("i/update", {
lang: lang.value,
}).then((i) => {
serverLang.value = i.lang;
});
}
watch(swipeOnDesktop, () => {
defaultStore.set("swipeOnMobile", true);
});
@ -617,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'}

15
renovate.json Normal file
View File

@ -0,0 +1,15 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:base"],
"rangeStrategy": "bump",
"branchConcurrentLimit": 5,
"enabledManagers": ["npm", "cargo"],
"baseBranches": ["develop"],
"lockFileMaintenance": {
"enabled": true,
"recreateWhen": "always",
"rebaseStalePrs": true,
"branchTopic": "lock-file-maintenance",
"commitMessageAction": "Lock file maintenance"
}
}