Compare commits
21 Commits
e0df57a5d5
...
d50c8d3479
Author | SHA1 | Date |
---|---|---|
laozhoubuluo | d50c8d3479 | |
naskya | df81cb6a85 | |
Lhcfl | 31168cc7b2 | |
Lhcfl | 42886f054d | |
Lhcfl | 1d0ea11eea | |
Lhcfl | 24602c4745 | |
Lhcfl | 33923a59fa | |
Lhcfl | 8067ed4084 | |
naskya | 4277ad0b59 | |
naskya | fc65d8c1c3 | |
naskya | 3b3d457c3e | |
naskya | 1128e243d3 | |
naskya | 39e08f57e8 | |
naskya | 09ef642905 | |
naskya | 1b8748bc8c | |
老周部落 | 58d1ddb523 | |
Lhcfl | 46d0679845 | |
Lhcfl | 160e7f26a6 | |
Lhcfl | 9138c3726a | |
Lhcfl | 425b333474 | |
Lhcfl | d1c76b3882 |
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -52,6 +52,17 @@ default:
|
|||
|
||||
build_test:
|
||||
stage: test
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == 'push' || $CI_PIPELINE_SOURCE == 'merge_request_event'
|
||||
changes:
|
||||
paths:
|
||||
- packages/**/*
|
||||
- scripts/**/*
|
||||
- locales/**/*
|
||||
- package.json
|
||||
- pnpm-lock.yaml
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
script:
|
||||
- pnpm install --frozen-lockfile
|
||||
- pnpm run build:debug
|
||||
|
@ -61,20 +72,33 @@ container_image_build:
|
|||
stage: build
|
||||
image: docker.io/debian:bookworm-slim
|
||||
services: []
|
||||
before_script: []
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == 'develop'
|
||||
script:
|
||||
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
|
||||
- apt-get install -y --no-install-recommends buildah ca-certificates fuse-overlayfs
|
||||
- buildah login --username "${CI_REGISTRY_USER}" --password "${CI_REGISTRY_PASSWORD}" "${CI_REGISTRY}"
|
||||
- buildah build --security-opt seccomp=unconfined --cap-add all --tag "${CI_REGISTRY}/${CI_PROJECT_PATH}/develop:not-for-production" --platform linux/amd64 .
|
||||
- buildah push "${CI_REGISTRY}/${CI_PROJECT_PATH}/develop:not-for-production" "docker://${CI_REGISTRY}/${CI_PROJECT_PATH}/develop:not-for-production"
|
||||
- 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'
|
||||
- if: $CI_PIPELINE_SOURCE == 'merge_request_event' || $CI_COMMIT_BRANCH == 'develop'
|
||||
changes:
|
||||
paths:
|
||||
- packages/backend-rs/**/*
|
||||
|
|
4
COPYING
4
COPYING
|
@ -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
|
||||
|
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
Breaking changes are indicated by the :warning: icon.
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Adding `lang` to the response of `i` and the request parameter of `i/update`.
|
||||
|
||||
## v20240504
|
||||
|
@ -26,6 +28,14 @@ Breaking changes are indicated by the :warning: icon.
|
|||
## v20240405
|
||||
|
||||
- Added `notes/history` endpoint.
|
||||
- With the addition of new features, the following endpoints are added:
|
||||
- export/import antennas
|
||||
- `i/export-antennas`
|
||||
- `i/import-antennas`
|
||||
- export favorites
|
||||
- `i/export-favorites`
|
||||
- export clips
|
||||
- `i/export-clips`
|
||||
|
||||
## v20240319
|
||||
|
||||
|
|
|
@ -1809,6 +1809,9 @@ _exportOrImport:
|
|||
muteList: "Muted users"
|
||||
blockingList: "Blocked users"
|
||||
userLists: "User lists"
|
||||
antennas: "Antennas"
|
||||
favorites: "Favorites"
|
||||
clips: "Clips"
|
||||
excludeMutingUsers: "Exclude muted users"
|
||||
excludeInactiveUsers: "Exclude inactive users"
|
||||
_charts:
|
||||
|
@ -2244,3 +2247,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"
|
||||
|
|
|
@ -1430,6 +1430,9 @@ _exportOrImport:
|
|||
muteList: "已静音用户"
|
||||
blockingList: "已屏蔽用户"
|
||||
userLists: "列表"
|
||||
antennas: "天线"
|
||||
favorites: "收藏"
|
||||
clips: "便签"
|
||||
excludeMutingUsers: "排除已静音用户"
|
||||
excludeInactiveUsers: "排除不活跃用户"
|
||||
_charts:
|
||||
|
@ -2071,3 +2074,5 @@ noteEditHistory: "帖子编辑历史"
|
|||
media: 媒体
|
||||
slashQuote: "斜杠引用"
|
||||
foldNotification: "将通知按同类型分组"
|
||||
mergeThreadInTimeline: "将时间线内的连续回复合并成一串"
|
||||
mergeRenotesInTimeline: "合并同一个帖子的转发"
|
||||
|
|
|
@ -1422,6 +1422,9 @@ _exportOrImport:
|
|||
muteList: "靜音"
|
||||
blockingList: "封鎖"
|
||||
userLists: "清單"
|
||||
antennas: "天線"
|
||||
favorites: "最愛列表"
|
||||
clips: "摘錄"
|
||||
excludeMutingUsers: "排除被靜音的使用者"
|
||||
excludeInactiveUsers: "排除不活躍帳戶"
|
||||
_charts:
|
||||
|
|
|
@ -295,6 +295,45 @@ export function createExportUserListsJob(user: ThinUser) {
|
|||
);
|
||||
}
|
||||
|
||||
export function createExportAntennasJob(user: ThinUser) {
|
||||
return dbQueue.add(
|
||||
"exportAntennas",
|
||||
{
|
||||
user: user,
|
||||
},
|
||||
{
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function createExportFavoritesJob(user: ThinUser) {
|
||||
return dbQueue.add(
|
||||
"exportFavorites",
|
||||
{
|
||||
user: user,
|
||||
},
|
||||
{
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function createExportClipsJob(user: ThinUser) {
|
||||
return dbQueue.add(
|
||||
"exportClips",
|
||||
{
|
||||
user: user,
|
||||
},
|
||||
{
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function createImportFollowingJob(
|
||||
user: ThinUser,
|
||||
fileId: DriveFile["id"],
|
||||
|
@ -438,6 +477,23 @@ export function createImportCustomEmojisJob(
|
|||
);
|
||||
}
|
||||
|
||||
export function createImportAntennasJob(
|
||||
user: ThinUser,
|
||||
fileId: DriveFile["id"],
|
||||
) {
|
||||
return dbQueue.add(
|
||||
"importAntennas",
|
||||
{
|
||||
user: user,
|
||||
fileId: fileId,
|
||||
},
|
||||
{
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function createDeleteAccountJob(
|
||||
user: ThinUser,
|
||||
opts: { soft?: boolean } = {},
|
||||
|
|
|
@ -0,0 +1,106 @@
|
|||
import type Bull from "bull";
|
||||
import * as fs from "node:fs";
|
||||
|
||||
import { queueLogger } from "../../logger.js";
|
||||
import { addFile } from "@/services/drive/add-file.js";
|
||||
import { format as dateFormat } from "date-fns";
|
||||
import { createTemp } from "@/misc/create-temp.js";
|
||||
import { Users, UserListJoinings, Antennas } from "@/models/index.js";
|
||||
import { In } from "typeorm";
|
||||
import type { DbUserJobData } from "@/queue/types.js";
|
||||
import type { User } from "@/models/entities/user.js";
|
||||
import { getFullApAccount } from "@/misc/convert-host.js";
|
||||
import { inspect } from "node:util";
|
||||
|
||||
const logger = queueLogger.createSubLogger("export-antennas");
|
||||
|
||||
export async function exportAntennas(
|
||||
job: Bull.Job<DbUserJobData>,
|
||||
done: any,
|
||||
): Promise<void> {
|
||||
logger.info(`Exporting antennas of ${job.data.user.id} ...`);
|
||||
|
||||
const user = await Users.findOneBy({ id: job.data.user.id });
|
||||
if (user == null) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create temp file
|
||||
const [path, cleanup] = await createTemp();
|
||||
|
||||
logger.info(`Temp file is ${path}`);
|
||||
|
||||
try {
|
||||
const stream = fs.createWriteStream(path, { flags: "a" });
|
||||
const write = (input: string): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.write(input, (err) => {
|
||||
if (err) {
|
||||
logger.error(inspect(err));
|
||||
reject();
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const antennas = await Antennas.findBy({ userId: job.data.user.id });
|
||||
write("[");
|
||||
for (const [index, antenna] of antennas.entries()) {
|
||||
let users: User[] | undefined;
|
||||
if (antenna.userListId !== null) {
|
||||
const joinings = await UserListJoinings.findBy({
|
||||
userListId: antenna.userListId,
|
||||
});
|
||||
users = await Users.findBy({
|
||||
id: In(joinings.map((j) => j.userId)),
|
||||
});
|
||||
}
|
||||
write(
|
||||
JSON.stringify({
|
||||
name: antenna.name,
|
||||
src: antenna.src,
|
||||
keywords: antenna.keywords,
|
||||
excludeKeywords: antenna.excludeKeywords,
|
||||
users: antenna.users,
|
||||
userListAcct:
|
||||
typeof users !== "undefined"
|
||||
? users.map((u) => {
|
||||
getFullApAccount(u.username, u.host);
|
||||
})
|
||||
: null,
|
||||
caseSensitive: antenna.caseSensitive,
|
||||
withReplies: antenna.withReplies,
|
||||
withFile: antenna.withFile,
|
||||
notify: antenna.notify,
|
||||
}),
|
||||
);
|
||||
if (antennas.length - 1 !== index) {
|
||||
write(", ");
|
||||
}
|
||||
}
|
||||
write("]");
|
||||
|
||||
stream.end();
|
||||
logger.succ(`Exported to: ${path}`);
|
||||
|
||||
const fileName = `antennas-${dateFormat(
|
||||
new Date(),
|
||||
"yyyy-MM-dd-HH-mm-ss",
|
||||
)}.json`;
|
||||
const driveFile = await addFile({
|
||||
user,
|
||||
path,
|
||||
name: fileName,
|
||||
force: true,
|
||||
});
|
||||
|
||||
logger.succ(`Exported to: ${driveFile.id}`);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
done();
|
||||
}
|
|
@ -0,0 +1,184 @@
|
|||
import type Bull from "bull";
|
||||
import * as fs from "node:fs";
|
||||
|
||||
import { queueLogger } from "../../logger.js";
|
||||
import { addFile } from "@/services/drive/add-file.js";
|
||||
import { format as dateFormat } from "date-fns";
|
||||
import { createTemp } from "@/misc/create-temp.js";
|
||||
import { Users, Clips, ClipNotes, Polls } from "@/models/index.js";
|
||||
import { MoreThan } from "typeorm";
|
||||
import type { DbUserJobData } from "@/queue/types.js";
|
||||
import type { Clip } from "@/models/entities/clip.js";
|
||||
import type { ClipNote } from "@/models/entities/clip-note.js";
|
||||
import type { User } from "@/models/entities/user.js";
|
||||
import type { Note } from "@/models/entities/note.js";
|
||||
import type { Poll } from "@/models/entities/poll.js";
|
||||
import { config } from "@/config.js";
|
||||
import { inspect } from "node:util";
|
||||
|
||||
const logger = queueLogger.createSubLogger("export-clips");
|
||||
|
||||
export async function exportClips(
|
||||
job: Bull.Job<DbUserJobData>,
|
||||
done: any,
|
||||
): Promise<void> {
|
||||
logger.info(`Exporting clips of ${job.data.user.id} ...`);
|
||||
|
||||
const user = await Users.findOneBy({ id: job.data.user.id });
|
||||
if (user == null) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create temp file
|
||||
const [path, cleanup] = await createTemp();
|
||||
|
||||
logger.info(`Temp file is ${path}`);
|
||||
|
||||
try {
|
||||
const stream = fs.createWriteStream(path, { flags: "a" });
|
||||
|
||||
const write = (text: string): Promise<void> => {
|
||||
return new Promise<void>((res, rej) => {
|
||||
stream.write(text, (err) => {
|
||||
if (err) {
|
||||
logger.error(inspect(err));
|
||||
rej(err);
|
||||
} else {
|
||||
res();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
await write("[");
|
||||
|
||||
let exportedClipsCount = 0;
|
||||
let cursor: Clip["id"] | null = null;
|
||||
|
||||
while (true) {
|
||||
const clips = await Clips.find({
|
||||
where: {
|
||||
userId: user.id,
|
||||
...(cursor ? { id: MoreThan(cursor) } : {}),
|
||||
},
|
||||
take: 100,
|
||||
order: {
|
||||
id: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (clips.length === 0) {
|
||||
job.progress(100);
|
||||
break;
|
||||
}
|
||||
|
||||
cursor = clips.at(-1)?.id ?? null;
|
||||
|
||||
for (const clip of clips) {
|
||||
// Stringify but remove the last `]}`
|
||||
const clip_export = {
|
||||
id: clip.id,
|
||||
name: clip.name,
|
||||
description: clip.description,
|
||||
clipNotes: [],
|
||||
};
|
||||
const content = JSON.stringify(clip_export).slice(0, -2);
|
||||
const isFirst = exportedClipsCount === 0;
|
||||
await write(isFirst ? content : `,\n${content}`);
|
||||
|
||||
let exportedClipNotesCount = 0;
|
||||
let cursor: ClipNote["id"] | null = null;
|
||||
|
||||
while (true) {
|
||||
const clipNotes = (await ClipNotes.find({
|
||||
where: {
|
||||
clipId: clip.id,
|
||||
...(cursor ? { id: MoreThan(cursor) } : {}),
|
||||
},
|
||||
take: 100,
|
||||
order: {
|
||||
id: 1,
|
||||
},
|
||||
relations: ["note", "note.user"],
|
||||
})) as (ClipNote & { note: Note & { user: User } })[];
|
||||
|
||||
if (clipNotes.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
cursor = clipNotes.at(-1)?.id ?? null;
|
||||
|
||||
for (const clipNote of clipNotes) {
|
||||
let poll: Poll | undefined;
|
||||
if (clipNote.note.hasPoll) {
|
||||
poll = await Polls.findOneByOrFail({ noteId: clipNote.note.id });
|
||||
}
|
||||
const clipnode_export = {
|
||||
id: clipNote.id,
|
||||
createdAt: new Date(clip.createdAt).toISOString(),
|
||||
note: {
|
||||
id: clipNote.note.id,
|
||||
text: clipNote.note.text,
|
||||
createdAt: new Date(clipNote.note.createdAt).toISOString(),
|
||||
fileIds: clipNote.note.fileIds,
|
||||
replyId: clipNote.note.replyId,
|
||||
renoteId: clipNote.note.renoteId,
|
||||
poll: poll,
|
||||
cw: clipNote.note.cw,
|
||||
visibility: clipNote.note.visibility,
|
||||
visibleUserIds: clipNote.note.visibleUserIds,
|
||||
localOnly: clipNote.note.localOnly,
|
||||
objectUrl: `${config.url}/notes/${clipNote.note.id}`, // add objectUrl for future import, firefish only
|
||||
uri: clipNote.note.uri,
|
||||
url: clipNote.note.url,
|
||||
user: {
|
||||
id: clipNote.note.user.id,
|
||||
name: clipNote.note.user.name,
|
||||
username: clipNote.note.user.username,
|
||||
host: clipNote.note.user.host,
|
||||
uri: clipNote.note.user.uri,
|
||||
},
|
||||
},
|
||||
};
|
||||
const content = JSON.stringify(clipnode_export);
|
||||
const isFirst = exportedClipNotesCount === 0;
|
||||
await write(isFirst ? content : `,\n${content}`);
|
||||
|
||||
exportedClipNotesCount++;
|
||||
}
|
||||
}
|
||||
|
||||
await write("]}");
|
||||
exportedClipsCount++;
|
||||
}
|
||||
|
||||
const total = await Clips.countBy({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
job.progress(exportedClipsCount / total);
|
||||
}
|
||||
|
||||
await write("]");
|
||||
|
||||
stream.end();
|
||||
logger.succ(`Exported to: ${path}`);
|
||||
|
||||
const fileName = `clips-${dateFormat(
|
||||
new Date(),
|
||||
"yyyy-MM-dd-HH-mm-ss",
|
||||
)}.json`;
|
||||
const driveFile = await addFile({
|
||||
user,
|
||||
path,
|
||||
name: fileName,
|
||||
force: true,
|
||||
});
|
||||
|
||||
logger.succ(`Exported to: ${driveFile.id}`);
|
||||
} finally {
|
||||
cleanup();
|
||||
done();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,144 @@
|
|||
import type Bull from "bull";
|
||||
import * as fs from "node:fs";
|
||||
|
||||
import { queueLogger } from "../../logger.js";
|
||||
import { addFile } from "@/services/drive/add-file.js";
|
||||
import { format as dateFormat } from "date-fns";
|
||||
import { createTemp } from "@/misc/create-temp.js";
|
||||
import { Users, NoteFavorites, Polls } from "@/models/index.js";
|
||||
import { MoreThan } from "typeorm";
|
||||
import type { DbUserJobData } from "@/queue/types.js";
|
||||
import type { NoteFavorite } from "@/models/entities/note-favorite.js";
|
||||
import type { Note } from "@/models/entities/note.js";
|
||||
import type { User } from "@/models/entities/user.js";
|
||||
import type { Poll } from "@/models/entities/poll.js";
|
||||
import { config } from "@/config.js";
|
||||
import { inspect } from "node:util";
|
||||
|
||||
const logger = queueLogger.createSubLogger("export-favorites");
|
||||
|
||||
export async function exportFavorites(
|
||||
job: Bull.Job<DbUserJobData>,
|
||||
done: any,
|
||||
): Promise<void> {
|
||||
logger.info(`Exporting favorites of ${job.data.user.id} ...`);
|
||||
|
||||
const user = await Users.findOneBy({ id: job.data.user.id });
|
||||
if (user == null) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create temp file
|
||||
const [path, cleanup] = await createTemp();
|
||||
|
||||
logger.info(`Temp file is ${path}`);
|
||||
|
||||
try {
|
||||
const stream = fs.createWriteStream(path, { flags: "a" });
|
||||
|
||||
const write = (text: string): Promise<void> => {
|
||||
return new Promise<void>((res, rej) => {
|
||||
stream.write(text, (err) => {
|
||||
if (err) {
|
||||
logger.error(inspect(err));
|
||||
rej(err);
|
||||
} else {
|
||||
res();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
await write("[");
|
||||
|
||||
let exportedFavoritesCount = 0;
|
||||
let cursor: NoteFavorite["id"] | null = null;
|
||||
|
||||
while (true) {
|
||||
const favorites = (await NoteFavorites.find({
|
||||
where: {
|
||||
userId: user.id,
|
||||
...(cursor ? { id: MoreThan(cursor) } : {}),
|
||||
},
|
||||
take: 100,
|
||||
order: {
|
||||
id: 1,
|
||||
},
|
||||
relations: ["note", "note.user"],
|
||||
})) as (NoteFavorite & { note: Note & { user: User } })[];
|
||||
|
||||
if (favorites.length === 0) {
|
||||
job.progress(100);
|
||||
break;
|
||||
}
|
||||
|
||||
cursor = favorites.at(-1)?.id ?? null;
|
||||
|
||||
for (const favorite of favorites) {
|
||||
let poll: Poll | undefined;
|
||||
if (favorite.note.hasPoll) {
|
||||
poll = await Polls.findOneByOrFail({ noteId: favorite.note.id });
|
||||
}
|
||||
const favorite_export = {
|
||||
id: favorite.id,
|
||||
createdAt: new Date(favorite.createdAt).toISOString(),
|
||||
note: {
|
||||
id: favorite.note.id,
|
||||
text: favorite.note.text,
|
||||
createdAt: new Date(favorite.note.createdAt).toISOString(),
|
||||
fileIds: favorite.note.fileIds,
|
||||
replyId: favorite.note.replyId,
|
||||
renoteId: favorite.note.renoteId,
|
||||
poll: poll,
|
||||
cw: favorite.note.cw,
|
||||
visibility: favorite.note.visibility,
|
||||
visibleUserIds: favorite.note.visibleUserIds,
|
||||
localOnly: favorite.note.localOnly,
|
||||
objectUrl: `${config.url}/notes/${favorite.note.id}`, // add objectUrl for future import, firefish only
|
||||
uri: favorite.note.uri,
|
||||
url: favorite.note.url,
|
||||
user: {
|
||||
id: favorite.note.user.id,
|
||||
name: favorite.note.user.name,
|
||||
username: favorite.note.user.username,
|
||||
host: favorite.note.user.host,
|
||||
uri: favorite.note.user.uri,
|
||||
},
|
||||
},
|
||||
};
|
||||
const content = JSON.stringify(favorite_export);
|
||||
const isFirst = exportedFavoritesCount === 0;
|
||||
await write(isFirst ? content : `,\n${content}`);
|
||||
exportedFavoritesCount++;
|
||||
}
|
||||
|
||||
const total = await NoteFavorites.countBy({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
job.progress(exportedFavoritesCount / total);
|
||||
}
|
||||
|
||||
await write("]");
|
||||
|
||||
stream.end();
|
||||
logger.succ(`Exported to: ${path}`);
|
||||
|
||||
const fileName = `favorites-${dateFormat(
|
||||
new Date(),
|
||||
"yyyy-MM-dd-HH-mm-ss",
|
||||
)}.json`;
|
||||
const driveFile = await addFile({
|
||||
user,
|
||||
path,
|
||||
name: fileName,
|
||||
force: true,
|
||||
});
|
||||
|
||||
logger.succ(`Exported to: ${driveFile.id}`);
|
||||
} finally {
|
||||
cleanup();
|
||||
done();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,131 @@
|
|||
import type Bull from "bull";
|
||||
|
||||
import Ajv from "ajv";
|
||||
import { queueLogger } from "../../logger.js";
|
||||
import { downloadTextFile } from "@/misc/download-text-file.js";
|
||||
import { DriveFiles, Users, Antennas } from "@/models/index.js";
|
||||
import { genId } from "@/misc/gen-id.js";
|
||||
import type { DbUserImportJobData } from "@/queue/types.js";
|
||||
import { publishInternalEvent } from "@/services/stream.js";
|
||||
import { inspect } from "node:util";
|
||||
|
||||
const logger = queueLogger.createSubLogger("import-antennas");
|
||||
|
||||
export async function importAntennas(
|
||||
job: Bull.Job<DbUserImportJobData>,
|
||||
done: any,
|
||||
): Promise<void> {
|
||||
logger.info(`Importing antennas of ${job.data.user.id} ...`);
|
||||
|
||||
const user = await Users.findOneBy({ id: job.data.user.id });
|
||||
if (user == null) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
const file = await DriveFiles.findOneBy({
|
||||
id: job.data.fileId,
|
||||
});
|
||||
if (file == null) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
const antennas = JSON.parse(await downloadTextFile(file.url));
|
||||
const now = new Date();
|
||||
try {
|
||||
const validate = new Ajv().compile({
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", minLength: 1, maxLength: 100 },
|
||||
src: { type: "string", enum: ["home", "all", "users", "list"] },
|
||||
userListAcct: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
},
|
||||
nullable: true,
|
||||
},
|
||||
keywords: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
excludeKeywords: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
users: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
caseSensitive: { type: "boolean" },
|
||||
withReplies: { type: "boolean" },
|
||||
withFile: { type: "boolean" },
|
||||
notify: { type: "boolean" },
|
||||
},
|
||||
required: [
|
||||
"name",
|
||||
"src",
|
||||
"keywords",
|
||||
"excludeKeywords",
|
||||
"users",
|
||||
"caseSensitive",
|
||||
"withReplies",
|
||||
"withFile",
|
||||
"notify",
|
||||
],
|
||||
});
|
||||
for (const antenna of antennas) {
|
||||
if (
|
||||
antenna.keywords.length === 0 ||
|
||||
antenna.keywords[0].every((x) => x === "")
|
||||
)
|
||||
continue;
|
||||
if (!validate(antenna)) {
|
||||
logger.warn("Validation Failed");
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await Antennas.insert({
|
||||
id: genId(),
|
||||
createdAt: now,
|
||||
userId: job.data.user.id,
|
||||
name: antenna.name,
|
||||
src:
|
||||
antenna.src === "list" && antenna.userListAcct
|
||||
? "users"
|
||||
: antenna.src,
|
||||
userListId: null,
|
||||
keywords: antenna.keywords,
|
||||
excludeKeywords: antenna.excludeKeywords,
|
||||
users: (antenna.src === "list" && antenna.userListAcct !== null
|
||||
? antenna.userListAcct
|
||||
: antenna.users
|
||||
).filter(Boolean),
|
||||
caseSensitive: antenna.caseSensitive,
|
||||
withReplies: antenna.withReplies,
|
||||
withFile: antenna.withFile,
|
||||
notify: antenna.notify,
|
||||
}).then((x) => Antennas.findOneByOrFail(x.identifiers[0]));
|
||||
logger.succ(`Antenna created: ${result.id}`);
|
||||
publishInternalEvent("antennaCreated", result);
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.error(inspect(err));
|
||||
} finally {
|
||||
logger.succ("Imported");
|
||||
done();
|
||||
}
|
||||
}
|
|
@ -7,6 +7,9 @@ import { exportFollowing } from "./export-following.js";
|
|||
import { exportMute } from "./export-mute.js";
|
||||
import { exportBlocking } from "./export-blocking.js";
|
||||
import { exportUserLists } from "./export-user-lists.js";
|
||||
import { exportAntennas } from "./export-antennas.js";
|
||||
import { exportFavorites } from "./export-favorites.js";
|
||||
import { exportClips } from "./export-clips.js";
|
||||
import { importFollowing } from "./import-following.js";
|
||||
import { importUserLists } from "./import-user-lists.js";
|
||||
import { deleteAccount } from "./delete-account.js";
|
||||
|
@ -16,6 +19,7 @@ import { importMastoPost } from "./import-masto-post.js";
|
|||
import { importCkPost } from "./import-firefish-post.js";
|
||||
import { importBlocking } from "./import-blocking.js";
|
||||
import { importCustomEmojis } from "./import-custom-emojis.js";
|
||||
import { importAntennas } from "./import-antennas.js";
|
||||
|
||||
const jobs = {
|
||||
deleteDriveFiles,
|
||||
|
@ -25,6 +29,9 @@ const jobs = {
|
|||
exportMute,
|
||||
exportBlocking,
|
||||
exportUserLists,
|
||||
exportAntennas,
|
||||
exportFavorites,
|
||||
exportClips,
|
||||
importFollowing,
|
||||
importMuting,
|
||||
importBlocking,
|
||||
|
@ -33,6 +40,7 @@ const jobs = {
|
|||
importMastoPost,
|
||||
importCkPost,
|
||||
importCustomEmojis,
|
||||
importAntennas,
|
||||
deleteAccount,
|
||||
} as Record<
|
||||
string,
|
||||
|
|
|
@ -179,6 +179,9 @@ import * as ep___i_exportMute from "./endpoints/i/export-mute.js";
|
|||
import * as ep___i_exportNotes from "./endpoints/i/export-notes.js";
|
||||
import * as ep___i_importPosts from "./endpoints/i/import-posts.js";
|
||||
import * as ep___i_exportUserLists from "./endpoints/i/export-user-lists.js";
|
||||
import * as ep___i_exportAntennas from "./endpoints/i/export-antennas.js";
|
||||
import * as ep___i_exportFavorites from "./endpoints/i/export-favorites.js";
|
||||
import * as ep___i_exportClips from "./endpoints/i/export-clips.js";
|
||||
import * as ep___i_favorites from "./endpoints/i/favorites.js";
|
||||
import * as ep___i_gallery_likes from "./endpoints/i/gallery/likes.js";
|
||||
import * as ep___i_gallery_posts from "./endpoints/i/gallery/posts.js";
|
||||
|
@ -187,6 +190,7 @@ import * as ep___i_importBlocking from "./endpoints/i/import-blocking.js";
|
|||
import * as ep___i_importFollowing from "./endpoints/i/import-following.js";
|
||||
import * as ep___i_importMuting from "./endpoints/i/import-muting.js";
|
||||
import * as ep___i_importUserLists from "./endpoints/i/import-user-lists.js";
|
||||
import * as ep___i_importAntennas from "./endpoints/i/import-antennas.js";
|
||||
import * as ep___i_notifications from "./endpoints/i/notifications.js";
|
||||
import * as ep___i_pageLikes from "./endpoints/i/page-likes.js";
|
||||
import * as ep___i_pages from "./endpoints/i/pages.js";
|
||||
|
@ -528,6 +532,9 @@ const eps = [
|
|||
["i/export-notes", ep___i_exportNotes],
|
||||
["i/import-posts", ep___i_importPosts],
|
||||
["i/export-user-lists", ep___i_exportUserLists],
|
||||
["i/export-antennas", ep___i_exportAntennas],
|
||||
["i/export-favorites", ep___i_exportFavorites],
|
||||
["i/export-clips", ep___i_exportClips],
|
||||
["i/favorites", ep___i_favorites],
|
||||
["i/gallery/likes", ep___i_gallery_likes],
|
||||
["i/gallery/posts", ep___i_gallery_posts],
|
||||
|
@ -536,6 +543,7 @@ const eps = [
|
|||
["i/import-following", ep___i_importFollowing],
|
||||
["i/import-muting", ep___i_importMuting],
|
||||
["i/import-user-lists", ep___i_importUserLists],
|
||||
["i/import-antennas", ep___i_importAntennas],
|
||||
["i/notifications", ep___i_notifications],
|
||||
["i/page-likes", ep___i_pageLikes],
|
||||
["i/pages", ep___i_pages],
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
import define from "@/server/api/define.js";
|
||||
import { createExportAntennasJob } from "@/queue/index.js";
|
||||
import { HOUR } from "@/const.js";
|
||||
|
||||
export const meta = {
|
||||
secure: true,
|
||||
requireCredential: true,
|
||||
limit: {
|
||||
duration: HOUR,
|
||||
max: 1,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: [],
|
||||
} as const;
|
||||
|
||||
export default define(meta, paramDef, async (ps, user) => {
|
||||
createExportAntennasJob(user);
|
||||
});
|
|
@ -0,0 +1,22 @@
|
|||
import define from "@/server/api/define.js";
|
||||
import { createExportClipsJob } from "@/queue/index.js";
|
||||
import { HOUR } from "@/const.js";
|
||||
|
||||
export const meta = {
|
||||
secure: true,
|
||||
requireCredential: true,
|
||||
limit: {
|
||||
duration: HOUR,
|
||||
max: 1,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: [],
|
||||
} as const;
|
||||
|
||||
export default define(meta, paramDef, async (ps, user) => {
|
||||
createExportClipsJob(user);
|
||||
});
|
|
@ -0,0 +1,22 @@
|
|||
import define from "@/server/api/define.js";
|
||||
import { createExportFavoritesJob } from "@/queue/index.js";
|
||||
import { HOUR } from "@/const.js";
|
||||
|
||||
export const meta = {
|
||||
secure: true,
|
||||
requireCredential: true,
|
||||
limit: {
|
||||
duration: HOUR,
|
||||
max: 1,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: [],
|
||||
} as const;
|
||||
|
||||
export default define(meta, paramDef, async (ps, user) => {
|
||||
createExportFavoritesJob(user);
|
||||
});
|
|
@ -0,0 +1,81 @@
|
|||
import define from "@/server/api/define.js";
|
||||
import { createImportAntennasJob } from "@/queue/index.js";
|
||||
import { ApiError } from "@/server/api/error.js";
|
||||
import { DriveFiles, Antennas } from "@/models/index.js";
|
||||
import { downloadTextFile } from "@/misc/download-text-file.js";
|
||||
import { HOUR } from "@/const.js";
|
||||
|
||||
export const meta = {
|
||||
secure: true,
|
||||
requireCredential: true,
|
||||
limit: {
|
||||
duratition: HOUR,
|
||||
max: 1,
|
||||
},
|
||||
|
||||
errors: {
|
||||
noSuchFile: {
|
||||
message: "No such file.",
|
||||
code: "NO_SUCH_FILE",
|
||||
id: "b98644cf-a5ac-4277-a502-0b8054a709a3",
|
||||
},
|
||||
|
||||
unexpectedFileType: {
|
||||
message: "Must be a JSON file.",
|
||||
code: "UNEXPECTED_FILE_TYPE",
|
||||
id: "660f3599-bce0-4f95-9dde-311fd841c183",
|
||||
},
|
||||
|
||||
tooBigFile: {
|
||||
message: "That file is too big.",
|
||||
code: "TOO_BIG_FILE",
|
||||
id: "dee9d4ed-ad07-43ed-8b34-b2856398bc60",
|
||||
},
|
||||
|
||||
emptyFile: {
|
||||
message: "That file is empty.",
|
||||
code: "EMPTY_FILE",
|
||||
id: "31a1b42c-06f7-42ae-8a38-a661c5c9f691",
|
||||
},
|
||||
|
||||
tooManyAntennas: {
|
||||
message: "You cannot create antenna any more.",
|
||||
code: "TOO_MANY_ANTENNAS",
|
||||
id: "c3a5a51e-04d4-11ee-be56-0242ac120002",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: "object",
|
||||
properties: {
|
||||
fileId: { type: "string", format: "misskey:id" },
|
||||
},
|
||||
required: ["fileId"],
|
||||
} as const;
|
||||
|
||||
export default define(meta, paramDef, async (ps, user) => {
|
||||
const file = await DriveFiles.findOneBy({ id: ps.fileId });
|
||||
|
||||
if (file == null) throw new ApiError(meta.errors.noSuchFile);
|
||||
if (file.size > 2_000_000) throw new ApiError(meta.errors.tooBigFile);
|
||||
if (file.size === 0) throw new ApiError(meta.errors.emptyFile);
|
||||
|
||||
let antennas;
|
||||
try {
|
||||
antennas = JSON.parse(await downloadTextFile(file.url));
|
||||
} catch (e) {
|
||||
throw new ApiError(meta.errors.unexpectedFileType);
|
||||
}
|
||||
|
||||
const currentAntennasCount = await Antennas.findBy({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
// In the future, should consider evolving to be configurable. Misskey v13 here is a configuration item.
|
||||
if (currentAntennasCount + antennas.length > 5 && !user.isAdmin) {
|
||||
throw new ApiError(meta.errors.tooManyAntennas);
|
||||
}
|
||||
|
||||
createImportAntennasJob(user, file.id);
|
||||
});
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -172,6 +172,71 @@
|
|||
>
|
||||
</FormFolder>
|
||||
</FormSection>
|
||||
<FormSection>
|
||||
<template #label>{{ i18n.ts._exportOrImport.antennas }}</template>
|
||||
<FormFolder class="_formBlock">
|
||||
<template #label>{{ i18n.ts.export }}</template>
|
||||
<template #icon
|
||||
><i :class="icon('ph-download-simple')"></i
|
||||
></template>
|
||||
<MkButton
|
||||
primary
|
||||
:class="$style.button"
|
||||
inline
|
||||
@click="exportAntennas()"
|
||||
><i :class="icon('ph-download-simple')"></i>
|
||||
{{ i18n.ts.export }}</MkButton
|
||||
>
|
||||
</FormFolder>
|
||||
<FormFolder class="_formBlock">
|
||||
<template #label>{{ i18n.ts.import }}</template>
|
||||
<template #icon
|
||||
><i :class="icon('ph-upload-simple')"></i
|
||||
></template>
|
||||
<MkButton
|
||||
primary
|
||||
:class="$style.button"
|
||||
inline
|
||||
@click="importAntennas($event)"
|
||||
><i :class="icon('ph-upload-simple')"></i>
|
||||
{{ i18n.ts.import }}</MkButton
|
||||
>
|
||||
</FormFolder>
|
||||
</FormSection>
|
||||
<FormSection>
|
||||
<template #label>{{ i18n.ts._exportOrImport.favorites }}</template>
|
||||
<FormFolder class="_formBlock">
|
||||
<template #label>{{ i18n.ts.export }}</template>
|
||||
<template #icon
|
||||
><i :class="icon('ph-download-simple')"></i
|
||||
></template>
|
||||
<MkButton
|
||||
primary
|
||||
:class="$style.button"
|
||||
inline
|
||||
@click="exportFavorites()"
|
||||
><i :class="icon('ph-download-simple')"></i>
|
||||
{{ i18n.ts.export }}</MkButton
|
||||
>
|
||||
</FormFolder>
|
||||
</FormSection>
|
||||
<FormSection>
|
||||
<template #label>{{ i18n.ts._exportOrImport.clips }}</template>
|
||||
<FormFolder class="_formBlock">
|
||||
<template #label>{{ i18n.ts.export }}</template>
|
||||
<template #icon
|
||||
><i :class="icon('ph-download-simple')"></i
|
||||
></template>
|
||||
<MkButton
|
||||
primary
|
||||
:class="$style.button"
|
||||
inline
|
||||
@click="exportClips()"
|
||||
><i :class="icon('ph-download-simple')"></i>
|
||||
{{ i18n.ts.export }}</MkButton
|
||||
>
|
||||
</FormFolder>
|
||||
</FormSection>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -248,6 +313,18 @@ const exportMuting = () => {
|
|||
os.api("i/export-mute", {}).then(onExportSuccess).catch(onError);
|
||||
};
|
||||
|
||||
const exportAntennas = () => {
|
||||
os.api("i/export-antennas", {}).then(onExportSuccess).catch(onError);
|
||||
};
|
||||
|
||||
const exportFavorites = () => {
|
||||
os.api("i/export-favorites", {}).then(onExportSuccess).catch(onError);
|
||||
};
|
||||
|
||||
const exportClips = () => {
|
||||
os.api("i/export-clips", {}).then(onExportSuccess).catch(onError);
|
||||
};
|
||||
|
||||
const importFollowing = async (ev) => {
|
||||
const file = await selectFile(ev.currentTarget ?? ev.target);
|
||||
os.api("i/import-following", { fileId: file.id })
|
||||
|
@ -276,6 +353,13 @@ const importBlocking = async (ev) => {
|
|||
.catch(onError);
|
||||
};
|
||||
|
||||
const importAntennas = async (ev) => {
|
||||
const file = await selectFile(ev.currentTarget ?? ev.target);
|
||||
os.api("i/import-antennas", { fileId: file.id })
|
||||
.then(onImportSuccess)
|
||||
.catch(onError);
|
||||
};
|
||||
|
||||
definePageMetadata({
|
||||
title: i18n.ts.importAndExport,
|
||||
icon: `${icon("ph-package")}`,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -454,6 +454,14 @@ export const defaultStore = markRaw(
|
|||
where: "deviceAccount",
|
||||
default: true,
|
||||
},
|
||||
mergeThreadInTimeline: {
|
||||
where: "deviceAccount",
|
||||
default: true,
|
||||
},
|
||||
mergeRenotesInTimeline: {
|
||||
where: "deviceAccount",
|
||||
default: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
@ -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[];
|
||||
};
|
||||
|
|
|
@ -491,6 +491,9 @@ export type Endpoints = {
|
|||
"i/export-mute": { req: TODO; res: TODO };
|
||||
"i/export-notes": { req: TODO; res: TODO };
|
||||
"i/export-user-lists": { req: TODO; res: TODO };
|
||||
"i/export-antennas": { req: TODO; res: TODO };
|
||||
"i/export-favorites": { req: TODO; res: TODO };
|
||||
"i/export-clips": { req: TODO; res: TODO };
|
||||
"i/favorites": {
|
||||
req: {
|
||||
limit?: number;
|
||||
|
@ -511,6 +514,7 @@ export type Endpoints = {
|
|||
"i/get-word-muted-notes-count": { req: TODO; res: TODO };
|
||||
"i/import-following": { req: TODO; res: TODO };
|
||||
"i/import-user-lists": { req: TODO; res: TODO };
|
||||
"i/import-antennas": { req: TODO; res: TODO };
|
||||
"i/move": { req: TODO; res: TODO };
|
||||
"i/known-as": { req: TODO; res: TODO };
|
||||
"i/notifications": {
|
||||
|
|
Loading…
Reference in New Issue