Merge branch 'develop' into refactor/check-hit-antenna

This commit is contained in:
naskya 2024-05-04 11:17:29 +09:00
commit d513a6d170
No known key found for this signature in database
GPG key ID: 712D413B3A9FED5C
51 changed files with 394 additions and 137 deletions

View file

@ -39,6 +39,9 @@ COPY packages/backend-rs packages/backend-rs/
# Compile backend-rs
RUN NODE_ENV='production' pnpm run --filter backend-rs build
# Copy/Overwrite index.js to mitigate the bug in napi-rs codegen
COPY packages/backend-rs/index.js packages/backend-rs/built/index.js
# Copy in the rest of the files to compile
COPY . ./
RUN NODE_ENV='production' pnpm run --filter firefish-js build

View file

@ -3,7 +3,7 @@ export
.PHONY: pre-commit
pre-commit: format entities napi-index
pre-commit: format entities napi
.PHONY: format
format:
@ -11,11 +11,12 @@ format:
.PHONY: entities
entities:
pnpm --filter=backend run build:debug
pnpm run migrate
$(MAKE) -C ./packages/backend-rs regenerate-entities
.PHONY: napi-index
napi-index:
.PHONY: napi
napi:
$(MAKE) -C ./packages/backend-rs update-index

View file

@ -1,4 +1,4 @@
COMPOSE='docker compose'
COMPOSE='podman-compose'
POSTGRES_PASSWORD=password
POSTGRES_USER=firefish
POSTGRES_DB=firefish_db

View file

@ -31,7 +31,7 @@ You can refer to [local-installation.md](./local-installation.md) to install the
1. Copy example config file
```sh
cp dev/config.example.env dev/config.env
# If you use container runtime other than Docker, you need to modify the "COMPOSE" variable
# If you use container runtime other than Podman, you need to modify the "COMPOSE" variable
# vim dev/config.env
```
1. Create `.config/default.yml` with the following content

View file

@ -5,17 +5,20 @@ Critical security updates are indicated by the :warning: icon.
- Server administrators should check [notice-for-admins.md](./notice-for-admins.md) as well.
- Third-party client/bot developers may want to check [api-change.md](./api-change.md) as well.
## Unreleased
## :warning: [v20240430](https://firefish.dev/firefish/firefish/-/merge_requests/10781/commits)
- Add ability to group similar notifications
- Add features to share links to an account in the three dots menu on the profile page
- Improve server logs
- Fix bugs
- Fix bugs (including a critical security issue)
- We are very thankful to @tesaguri and Laura Hausmann for helping to fix the security issue.
## [v20240424](https://firefish.dev/firefish/firefish/-/merge_requests/10765/commits)
- Improve the usability of the feature to prevent forgetting to write alt texts
- Add a server-wide setting for the maximum number of antennas each user can create
- Fix bugs
- Fix bugs (including a medium severity security issue)
- We are very thankful to @mei23 for kindly sharing the information about the security issue.
## [v20240421](https://firefish.dev/firefish/firefish/-/merge_requests/10756/commits)

View file

@ -5,6 +5,7 @@ DELETE FROM "migrations" WHERE name IN (
'DropUnusedUserprofileColumns1714259023878',
'AntennaJsonbToArray1714192520471',
'RenameType1714191375157',
'DropUnusedIndexes1714643926317',
'AlterAkaType1714099399879',
'AddDriveFileUsage1713451569342',
'ConvertCwVarcharToText1713225866247',
@ -74,6 +75,22 @@ ALTER TABLE "emoji" RENAME COLUMN "mimeType" TO "type";
ALTER TABLE "moderation_log" RENAME COLUMN "kind" TO "type";
ALTER TABLE "notification" RENAME COLUMN "kind" TO "type";
-- drop-unused-indexes
CREATE INDEX "IDX_01f4581f114e0ebd2bbb876f0b" ON "note_reaction" ("createdAt");
CREATE INDEX "IDX_0610ebcfcfb4a18441a9bcdab2" ON "poll" ("userId");
CREATE INDEX "IDX_25dfc71b0369b003a4cd434d0b" ON "note" ("attachedFileTypes");
CREATE INDEX "IDX_2710a55f826ee236ea1a62698f" ON "hashtag" ("mentionedUsersCount");
CREATE INDEX "IDX_4c02d38a976c3ae132228c6fce" ON "hashtag" ("mentionedRemoteUsersCount");
CREATE INDEX "IDX_51c063b6a133a9cb87145450f5" ON "note" ("fileIds");
CREATE INDEX "IDX_54ebcb6d27222913b908d56fd8" ON "note" ("mentions");
CREATE INDEX "IDX_7fa20a12319c7f6dc3aed98c0a" ON "poll" ("userHost");
CREATE INDEX "IDX_88937d94d7443d9a99a76fa5c0" ON "note" ("tags");
CREATE INDEX "IDX_b11a5e627c41d4dc3170f1d370" ON "notification" ("createdAt");
CREATE INDEX "IDX_c8dfad3b72196dd1d6b5db168a" ON "drive_file" ("createdAt");
CREATE INDEX "IDX_d57f9030cd3af7f63ffb1c267c" ON "hashtag" ("attachedUsersCount");
CREATE INDEX "IDX_e5848eac4940934e23dbc17581" ON "drive_file" ("uri");
CREATE INDEX "IDX_fa99d777623947a5b05f394cae" ON "user" ("tags");
-- alter-aka-type
ALTER TABLE "user" RENAME COLUMN "alsoKnownAs" TO "alsoKnownAsOld";
ALTER TABLE "user" ADD COLUMN "alsoKnownAs" text;

View file

@ -269,7 +269,7 @@ In this instruction, we use [Caddy](https://caddyserver.com/) to make the Firefi
WorkingDirectory=/home/firefish/firefish
Environment="NODE_ENV=production"
Environment="npm_config_cache=/tmp"
Environment="NODE_OPTIONS=--max-old-space-size=3072"
Environment="NODE_OPTIONS=--max-old-space-size=3072"
# uncomment the following line if you use jemalloc (note that the path varies on different environments)
# Environment="LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2"
StandardOutput=journal

View file

@ -2,12 +2,33 @@
You can skip intermediate versions when upgrading from an old version, but please read the notices and follow the instructions for each intermediate version before [upgrading](./upgrade.md).
## Unreleased
## v20240430
### For all users
You can control the verbosity of the server log by adding `maxLogLevel` in `.config/default.yml`. `logLevels` has been deprecated in favor of this setting. (see also: <https://firefish.dev/firefish/firefish/-/blob/eac0c1c47cd23789dcc395ab08b074934409fd96/.config/example.yml#L152>)
### For systemd/pm2 users
Not only Firefish but also Node.js has recently fixed a few security issues:
- https://nodejs.org/en/blog/vulnerability/april-2024-security-releases
- https://nodejs.org/en/blog/vulnerability/april-2024-security-releases-2
So, it is highly recommended that you upgrade your Node.js version as well. The new versions are
- Node v18.20.2 (v18.x LTS)
- Node v20.12.2 (v20.x LTS)
- Node v21.7.3 (v21.x)
You can check your Node.js version by this command:
```sh
node --version
```
[Node v22](https://nodejs.org/en/blog/announcements/v22-release-announce) was also released several days ago, but we have not yet tested Firefish with this version.
## v20240413
### For all users

View file

@ -738,6 +738,7 @@ _notification:
reacted: Ha reaccionat a la teva publicació
renoted: Ha impulsat la teva publicació
voted: Ha votat a la teva enquesta
andCountUsers: I {count} usuaris més {acted}
_deck:
_columns:
notifications: "Notificacions"
@ -2292,3 +2293,11 @@ media: Multimèdia
antennaLimit: El nombre màxim d'antenes que pot crear un usuari
showAddFileDescriptionAtFirstPost: Obra de forma automàtica un formulari per escriure
una descripció quant intentes publicar un fitxer que no en té
remoteFollow: Seguiment remot
cannotEditVisibility: No pots canviar la visibilitat
useThisAccountConfirm: Vols continuar amb aquest compte?
inputAccountId: Sisplau introdueix el teu compte (per exemple @firefish@info.firefish.dev)
getQrCode: Mostrar el codi QR
copyRemoteFollowUrl: Còpia la adreça URL del seguidor remot
foldNotification: Agrupar les notificacions similars
slashQuote: Cita encadenada

View file

@ -2239,4 +2239,5 @@ autocorrectNoteLanguage: "Show a warning if the post language does not match the
incorrectLanguageWarning: "It looks like your post is in {detected}, but you selected
{current}.\nWould you like to set the language to {detected} instead?"
noteEditHistory: "Post edit history"
slashQuote: "Chain quote"
foldNotification: "Group similar notifications"

View file

@ -928,7 +928,7 @@ colored: "Coloré"
label: "Étiquette"
localOnly: "Local seulement"
account: "Comptes"
getQrCode: "Obtenir le code QR"
getQrCode: "Afficher le code QR"
_emailUnavailable:
used: "Adresse non disponible"
@ -1836,6 +1836,7 @@ _notification:
reacted: a réagit à votre publication
renoted: a boosté votre publication
voted: a voté pour votre sondage
andCountUsers: et {count} utilisateur(s) de plus {acted}
_deck:
alwaysShowMainColumn: "Toujours afficher la colonne principale"
columnAlign: "Aligner les colonnes"
@ -2321,3 +2322,13 @@ markLocalFilesNsfwByDefaultDescription: Indépendamment de ce réglage, les util
ne sont pas affectés.
noteEditHistory: Historique des publications
media: Multimédia
antennaLimit: Le nombre maximal d'antennes que chaque utilisateur peut créer
showAddFileDescriptionAtFirstPost: Ouvrez automatiquement un formulaire pour écrire
une description lorsque vous tentez de publier des fichiers sans description
foldNotification: Grouper les notifications similaires
cannotEditVisibility: Vous ne pouvez pas modifier la visibilité
useThisAccountConfirm: Voulez-vous continuer avec ce compte?
inputAccountId: Veuillez saisir votre compte (par exemple, @firefish@info.firefish.dev)
remoteFollow: Abonnement à distance
copyRemoteFollowUrl: Copier l'URL d'abonnement à distance
slashQuote: Citation enchaînée

View file

@ -2278,3 +2278,4 @@ cannotEditVisibility: Kamu tidak bisa menyunting keterlihatan
useThisAccountConfirm: Apakah kamu ingin melanjutkan dengan akun ini?
inputAccountId: Silakan memasukkan akunmu (misalnya, @firefish@info.firefish.dev)
copyRemoteFollowUrl: Salin URL ikuti jarak jauh
slashQuote: Kutipan rantai

View file

@ -1902,7 +1902,7 @@ _notification:
reacted: がリアクションしました
renoted: がブーストしました
voted: が投票しました
andCountUsers: と{count}人{acted}しました
andCountUsers: と{count}人{acted}
_deck:
alwaysShowMainColumn: "常にメインカラムを表示"
columnAlign: "カラムの寄せ"
@ -2067,3 +2067,4 @@ useThisAccountConfirm: このアカウントで操作を続けますか?
getQrCode: QRコードを表示
copyRemoteFollowUrl: リモートからフォローするURLをコピー
foldNotification: 同じ種類の通知をまとめて表示する
slashQuote: 繋げて引用

View file

@ -2066,4 +2066,5 @@ autocorrectNoteLanguage: 当帖子语言不符合自动检测的结果的时候
incorrectLanguageWarning: "看上去您帖子使用的语言是{detected},但您选择的语言是{current}。\n要改为以{detected}发帖吗?"
noteEditHistory: "帖子编辑历史"
media: 媒体
slashQuote: "斜杠引用"
foldNotification: "将通知按同类型分组"

View file

@ -1,6 +1,6 @@
{
"name": "firefish",
"version": "20240424",
"version": "20240430",
"repository": {
"type": "git",
"url": "https://firefish.dev/firefish/firefish.git"

View file

@ -19,6 +19,13 @@ export function fromHtml(html: string, hashtagNames?: string[]): string {
return appendChildren(childNodes, background).join("").trim();
}
/**
* We only exclude text containing asterisks, since the other marks can almost be considered intentionally used.
*/
function escapeAmbiguousMfmMarks(text: string) {
return text.includes("*") ? `<plain>${text}</plain>` : text;
}
/**
* Get only the text, ignoring all formatting inside
* @param node
@ -62,7 +69,7 @@ export function fromHtml(html: string, hashtagNames?: string[]): string {
background = "",
): (string | string[])[] {
if (treeAdapter.isTextNode(node)) {
return [node.value];
return [escapeAmbiguousMfmMarks(node.value)];
}
// Skip comment or document type node

View file

@ -0,0 +1,65 @@
import type { MigrationInterface, QueryRunner } from "typeorm";
export class DropUnusedIndexes1714643926317 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "IDX_01f4581f114e0ebd2bbb876f0b"`);
await queryRunner.query(`DROP INDEX "IDX_0610ebcfcfb4a18441a9bcdab2"`);
await queryRunner.query(`DROP INDEX "IDX_25dfc71b0369b003a4cd434d0b"`);
await queryRunner.query(`DROP INDEX "IDX_2710a55f826ee236ea1a62698f"`);
await queryRunner.query(`DROP INDEX "IDX_4c02d38a976c3ae132228c6fce"`);
await queryRunner.query(`DROP INDEX "IDX_51c063b6a133a9cb87145450f5"`);
await queryRunner.query(`DROP INDEX "IDX_54ebcb6d27222913b908d56fd8"`);
await queryRunner.query(`DROP INDEX "IDX_7fa20a12319c7f6dc3aed98c0a"`);
await queryRunner.query(`DROP INDEX "IDX_88937d94d7443d9a99a76fa5c0"`);
await queryRunner.query(`DROP INDEX "IDX_b11a5e627c41d4dc3170f1d370"`);
await queryRunner.query(`DROP INDEX "IDX_c8dfad3b72196dd1d6b5db168a"`);
await queryRunner.query(`DROP INDEX "IDX_d57f9030cd3af7f63ffb1c267c"`);
await queryRunner.query(`DROP INDEX "IDX_e5848eac4940934e23dbc17581"`);
await queryRunner.query(`DROP INDEX "IDX_fa99d777623947a5b05f394cae"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE INDEX "IDX_01f4581f114e0ebd2bbb876f0b" ON "note_reaction" ("createdAt")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_0610ebcfcfb4a18441a9bcdab2" ON "poll" ("userId")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_25dfc71b0369b003a4cd434d0b" ON "note" ("attachedFileTypes")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_2710a55f826ee236ea1a62698f" ON "hashtag" ("mentionedUsersCount")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_4c02d38a976c3ae132228c6fce" ON "hashtag" ("mentionedRemoteUsersCount")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_51c063b6a133a9cb87145450f5" ON "note" ("fileIds")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_54ebcb6d27222913b908d56fd8" ON "note" ("mentions")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_7fa20a12319c7f6dc3aed98c0a" ON "poll" ("userHost")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_88937d94d7443d9a99a76fa5c0" ON "note" ("tags")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_b11a5e627c41d4dc3170f1d370" ON "notification" ("createdAt")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_c8dfad3b72196dd1d6b5db168a" ON "drive_file" ("createdAt")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_d57f9030cd3af7f63ffb1c267c" ON "hashtag" ("attachedUsersCount")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_e5848eac4940934e23dbc17581" ON "drive_file" ("uri")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_fa99d777623947a5b05f394cae" ON "user" ("tags")`,
);
}
}

View file

@ -23,7 +23,6 @@ export class DriveFile {
@PrimaryColumn(id())
public id: string;
@Index()
@Column("timestamp without time zone", {
comment: "The created date of the DriveFile.",
})
@ -147,7 +146,6 @@ export class DriveFile {
})
public webpublicAccessKey: string | null;
@Index()
@Column("varchar", {
length: 512,
nullable: true,

View file

@ -19,7 +19,6 @@ export class Hashtag {
})
public mentionedUserIds: User["id"][];
@Index()
@Column("integer", {
default: 0,
})
@ -43,7 +42,6 @@ export class Hashtag {
})
public mentionedRemoteUserIds: User["id"][];
@Index()
@Column("integer", {
default: 0,
})
@ -55,7 +53,6 @@ export class Hashtag {
})
public attachedUserIds: User["id"][];
@Index()
@Column("integer", {
default: 0,
})

View file

@ -17,7 +17,6 @@ export class NoteReaction {
@PrimaryColumn(id())
public id: string;
@Index()
@Column("timestamp without time zone", {
comment: "The created date of the NoteReaction.",
})

View file

@ -139,7 +139,6 @@ export class Note {
// FIXME: file id is not removed from this array even if the file is deleted
// TODO: drop this column and use note_files
@Index()
@Column({
...id(),
array: true,
@ -147,7 +146,6 @@ export class Note {
})
public fileIds: DriveFile["id"][];
@Index()
@Column("varchar", {
length: 256,
array: true,
@ -163,7 +161,6 @@ export class Note {
})
public visibleUserIds: User["id"][];
@Index()
@Column({
...id(),
array: true,
@ -184,7 +181,6 @@ export class Note {
})
public emojis: string[];
@Index()
@Column("varchar", {
length: 128,
array: true,

View file

@ -20,7 +20,6 @@ export class Notification {
@PrimaryColumn(id())
public id: string;
@Index()
@Column("timestamp without time zone", {
comment: "The created date of the Notification.",
})

View file

@ -44,14 +44,12 @@ export class Poll {
})
public noteVisibility: (typeof noteVisibilities)[number];
@Index()
@Column({
...id(),
comment: "[Denormalized]",
})
public userId: User["id"];
@Index()
@Column("varchar", {
length: 512,
nullable: true,

View file

@ -116,7 +116,6 @@ export class User {
})
public bannerId: DriveFile["id"] | null;
@Index()
@Column("varchar", {
length: 128,
array: true,

View file

@ -24,7 +24,7 @@ const logger = new Logger("inbox");
// Processing when an activity arrives in the user's inbox
export default async (job: Bull.Job<InboxJobData>): Promise<string> => {
const signature = job.data.signature; // HTTP-signature
const activity = job.data.activity;
let activity = job.data.activity;
//#region Log
const info = Object.assign({}, activity) as any;
@ -149,6 +149,8 @@ export default async (job: Bull.Job<InboxJobData>): Promise<string> => {
return "skip: LD-Signatureの検証に失敗しました";
}
activity = await ldSignature.compactToWellKnown(activity);
// もう一度actorチェック
if (authUser.user.uri !== activity.actor) {
return `skip: LD-Signature user(${authUser.user.uri}) !== activity.actor(${activity.actor})`;

View file

@ -518,6 +518,54 @@ const activitystreams = {
},
};
export const WellKnownContext = {
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{
// as non-standards
manuallyApprovesFollowers: "as:manuallyApprovesFollowers",
movedTo: {
"@id": "https://www.w3.org/ns/activitystreams#movedTo",
"@type": "@id",
},
movedToUri: "as:movedTo",
sensitive: "as:sensitive",
Hashtag: "as:Hashtag",
quoteUri: "fedibird:quoteUri",
quoteUrl: "as:quoteUrl",
// Mastodon
toot: "http://joinmastodon.org/ns#",
Emoji: "toot:Emoji",
featured: "toot:featured",
discoverable: "toot:discoverable",
indexable: "toot:indexable",
// schema
schema: "http://schema.org#",
PropertyValue: "schema:PropertyValue",
value: "schema:value",
// Firefish
firefish: "https://firefish.dev/ns#",
speakAsCat: "firefish:speakAsCat",
// Misskey
misskey: "https://misskey-hub.net/ns#",
_misskey_talk: "misskey:_misskey_talk",
_misskey_reaction: "misskey:_misskey_reaction",
_misskey_votes: "misskey:_misskey_votes",
_misskey_summary: "misskey:_misskey_summary",
isCat: "misskey:isCat",
// Fedibird
fedibird: "http://fedibird.com/ns#",
// vcard
vcard: "http://www.w3.org/2006/vcard/ns#",
// litepub
litepub: "http://litepub.social/ns#",
ChatMessage: "litepub:ChatMessage",
directMessage: "litepub:directMessage",
},
],
};
export const CONTEXTS: Record<string, unknown> = {
"https://w3id.org/identity/v1": id_v1,
"https://w3id.org/security/v1": security_v1,

View file

@ -1,6 +1,6 @@
import * as crypto from "node:crypto";
import jsonld from "jsonld";
import { CONTEXTS } from "./contexts.js";
import { CONTEXTS, WellKnownContext } from "./contexts.js";
import fetch from "node-fetch";
import { httpAgent, httpsAgent } from "@/misc/fetch.js";
@ -89,6 +89,13 @@ export class LdSignature {
});
}
public async compactToWellKnown(data: any): Promise<any> {
const options = { documentLoader: this.getLoader() };
const context = WellKnownContext as any;
delete data["signature"];
return await jsonld.compact(data, context, options);
}
private getLoader() {
return async (url: string): Promise<any> => {
if (!url.match("^https?://")) throw new Error(`Invalid URL ${url}`);

View file

@ -4,6 +4,7 @@ import { getUserKeypair } from "@/misc/keypair-store.js";
import type { User } from "@/models/entities/user.js";
import { LdSignature } from "../misc/ld-signature.js";
import type { IActivity } from "../type.js";
import { WellKnownContext } from "@/remote/activitypub/misc/contexts.js";
export const renderActivity = (x: any): IActivity | null => {
if (x == null) return null;
@ -12,52 +13,7 @@ export const renderActivity = (x: any): IActivity | null => {
x.id = `${config.url}/${uuid()}`;
}
return Object.assign(
{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{
// as non-standards
manuallyApprovesFollowers: "as:manuallyApprovesFollowers",
movedToUri: "as:movedTo",
sensitive: "as:sensitive",
Hashtag: "as:Hashtag",
quoteUri: "fedibird:quoteUri",
quoteUrl: "as:quoteUrl",
// Mastodon
toot: "http://joinmastodon.org/ns#",
Emoji: "toot:Emoji",
featured: "toot:featured",
discoverable: "toot:discoverable",
indexable: "toot:indexable",
// schema
schema: "http://schema.org#",
PropertyValue: "schema:PropertyValue",
value: "schema:value",
// Firefish
firefish: "https://firefish.dev/ns#",
speakAsCat: "firefish:speakAsCat",
// Misskey
misskey: "https://misskey-hub.net/ns#",
_misskey_talk: "misskey:_misskey_talk",
_misskey_reaction: "misskey:_misskey_reaction",
_misskey_votes: "misskey:_misskey_votes",
_misskey_summary: "misskey:_misskey_summary",
isCat: "misskey:isCat",
// Fedibird
fedibird: "http://fedibird.com/ns#",
// vcard
vcard: "http://www.w3.org/2006/vcard/ns#",
// ChatMessage
litepub: "http://litepub.social/ns#",
ChatMessage: "litepub:ChatMessage",
directMessage: "litepub:directMessage",
},
],
},
x,
);
return Object.assign({}, WellKnownContext, x);
};
export const attachLdSignature = async (

View file

@ -126,6 +126,7 @@ const contextmenu = computed((): MenuItem[] => {
text: i18n.ts.copyLink,
action: () => {
copyToClipboard(pageUrl.value);
os.success();
},
},
];

View file

@ -215,7 +215,7 @@
@click.stop="react()"
>
<i :class="icon('ph-smiley')"></i>
<p class="count" v-if="reactionCount > 0 && hideEmojiViewer">{{reactionCount}}</p>
<p v-if="reactionCount > 0 && hideEmojiViewer" class="count">{{reactionCount}}</p>
</button>
<button
v-if="
@ -228,7 +228,7 @@
@click.stop="undoReact(appearNote)"
>
<i :class="icon('ph-minus')"></i>
<p class="count" v-if="reactionCount > 0 && hideEmojiViewer">{{reactionCount}}</p>
<p v-if="reactionCount > 0 && hideEmojiViewer" class="count">{{reactionCount}}</p>
</button>
<XQuoteButton class="button" :note="appearNote" />
<button
@ -546,6 +546,7 @@ function onContextmenu(ev: MouseEvent): void {
text: i18n.ts.copyLink,
action: () => {
copyToClipboard(`${url}${notePage(appearNote.value)}`);
os.success();
},
},
appearNote.value.user.host != null

View file

@ -81,8 +81,8 @@
</MkTab>
<MkPagination
ref="repliesPagingComponent"
v-if="tab === 'replies' && note.repliesCount > 0"
ref="repliesPagingComponent"
v-slot="{ items }"
:pagination="repliesPagination"
>

View file

@ -107,7 +107,7 @@
@click.stop="react()"
>
<i :class="icon('ph-smiley')"></i>
<p class="count" v-if="reactionCount > 0 && hideEmojiViewer">{{reactionCount}}</p>
<p v-if="reactionCount > 0 && hideEmojiViewer" class="count">{{reactionCount}}</p>
</button>
<button
v-if="
@ -120,7 +120,7 @@
@click.stop="undoReact(appearNote)"
>
<i :class="icon('ph-minus')"></i>
<p class="count" v-if="reactionCount > 0 && hideEmojiViewer">{{reactionCount}}</p>
<p v-if="reactionCount > 0 && hideEmojiViewer" class="count">{{reactionCount}}</p>
</button>
<XQuoteButton class="button" :note="appearNote" />
<button
@ -500,6 +500,7 @@ function onContextmenu(ev: MouseEvent): void {
text: i18n.ts.copyLink,
action: () => {
copyToClipboard(`${url}${notePage(appearNote.value)}`);
os.success();
},
},
note.value.user.host != null

View file

@ -75,6 +75,7 @@
import { computed, onMounted, onUnmounted, ref } from "vue";
import type { Connection } from "firefish-js/src/streaming";
import type { Channels } from "firefish-js/src/streaming.types";
import type { entities } from "firefish-js";
import XReactionIcon from "@/components/MkReactionIcon.vue";
import XReactionTooltip from "@/components/MkReactionTooltip.vue";
import { i18n } from "@/i18n";
@ -89,7 +90,6 @@ import type {
ReactionNotificationFolded,
} from "@/types/notification";
import XNote from "@/components/MkNote.vue";
import type { entities } from "firefish-js";
const props = withDefaults(
defineProps<{

View file

@ -17,8 +17,8 @@
<template #default="{ foldedItems: notifications }">
<XList
:items="notifications"
v-slot="{ item: notification }"
:items="notifications"
class="elsfgstc"
:no-gap="true"
>
@ -71,7 +71,7 @@ import { foldNotifications } from "@/scripts/fold";
import { defaultStore } from "@/store";
const props = defineProps<{
includeTypes?: (typeof notificationTypes)[number][];
includeTypes?: (typeof notificationTypes)[number][] | null;
unreadOnly?: boolean;
}>();

View file

@ -43,6 +43,7 @@ import { i18n } from "@/i18n";
import type { PageMetadata } from "@/scripts/page-metadata";
import { provideMetadataReceiver } from "@/scripts/page-metadata";
import icon from "@/scripts/icon";
import * as os from "@/os";
const props = defineProps<{
initialPath: string;
@ -121,6 +122,7 @@ const contextmenu = computed(() => [
text: i18n.ts.copyLink,
action: () => {
copyToClipboard(url + router.getCurrentPath());
os.success();
},
},
]);

View file

@ -5,7 +5,7 @@
>
<MkLoading v-if="fetching" />
<MkError v-else-if="error" @retry="init()" />
<MkError v-else-if="error" @retry="reload()" />
<div v-else-if="empty" key="_empty_" class="empty">
<slot name="empty">
@ -38,7 +38,7 @@
</MkButton>
<MkLoading v-else class="loading" />
</div>
<slot :items="items" :foldedItems="foldedItems"></slot>
<slot :items="items" :folded-items="foldedItems"></slot>
<div
v-show="!pagination.reversed && more"
key="_more_"
@ -105,9 +105,9 @@ export type MkPaginationType<
updateItem: (id: string, replacer: (old: Item) => Item) => boolean;
};
export type PagingAble = {
export interface PagingAble {
id: string;
};
}
export type PagingKeyOf<T> = TypeUtils.EndpointsOf<T[]>;
// biome-ignore lint/suspicious/noExplicitAny: Used Intentionally
@ -173,11 +173,15 @@ const rootEl = ref<HTMLElement>();
const items = ref<Item[]>([]);
const foldedItems = ref([]) as Ref<Fold[]>;
function toReversed<T>(arr: T[]) {
return [...arr].reverse();
}
// To improve performance, we do not use vues `computed` here
function calculateItems() {
function getItems<T>(folder: (ns: Item[]) => T[]) {
const res = [
folder(prepended.value.toReversed()),
folder(toReversed(prepended.value)),
...arrItems.value.map((arr) => folder(arr)),
folder(appended.value),
].flat(1);
@ -242,6 +246,8 @@ const reload = (): Promise<void> => {
appended.value = [];
prepended.value = [];
idMap.clear();
offset.value = 0;
nextPagingBy = {};
return init();
};
@ -349,7 +355,7 @@ async function fetch(firstFetching?: boolean) {
if (firstFetching && props.folder != null) {
// In this way, prepended has some initial values for folding
prepended.value = res.toReversed();
prepended.value = toReversed(res);
} else {
// For ascending and offset modes, append and prepend may cause item duplication
// so they need to be filtered out.
@ -396,7 +402,7 @@ const prepend = (...item: Item[]): void => {
prepended.value.length >
(props.pagination.secondFetchLimit || SECOND_FETCH_LIMIT_DEFAULT)
) {
arrItems.value.unshift(prepended.value.toReversed());
arrItems.value.unshift(toReversed(prepended.value));
prepended.value = [];
// We don't need to calculate here because it won't cause any changes in items
}
@ -477,7 +483,7 @@ const updateItem = (id: Item["id"], replacer: (old: Item) => Item): boolean => {
};
if (props.pagination.params && isRef<Param>(props.pagination.params)) {
watch(props.pagination.params, init, { deep: true });
watch(props.pagination.params, reload, { deep: true });
}
watch(

View file

@ -373,6 +373,11 @@ const props = withDefaults(
autofocus?: boolean;
showMfmCheatSheet?: boolean;
editId?: entities.Note["id"];
selectRange?: [
start: number,
end: number,
direction?: "forward" | "backward" | "none",
];
}>(),
{
initialVisibleUsers: () => [],
@ -692,10 +697,14 @@ function togglePoll() {
function focus() {
if (textareaEl.value) {
textareaEl.value.focus();
textareaEl.value.setSelectionRange(
textareaEl.value.value.length,
textareaEl.value.value.length,
);
if (props.selectRange) {
textareaEl.value.setSelectionRange(...props.selectRange);
} else {
textareaEl.value.setSelectionRange(
textareaEl.value.value.length,
textareaEl.value.value.length,
);
}
}
}

View file

@ -44,6 +44,11 @@ const props = defineProps<{
fixed?: boolean;
autofocus?: boolean;
editId?: entities.Note["id"];
selectRange?: [
start: number,
end: number,
direction?: "forward" | "backward" | "none",
];
}>();
const emit = defineEmits<{

View file

@ -44,6 +44,7 @@ const FIRE_THRESHOLD = defaultStore.state.pullToRefreshThreshold;
const RELEASE_TRANSITION_DURATION = 200;
const PULL_BRAKE_BASE = 1.5;
const PULL_BRAKE_FACTOR = 170;
const MAX_PULL_TAN_ANGLE = Math.tan((1 / 6) * Math.PI); // 30°
const pullStarted = ref(false);
const pullEnded = ref(false);
@ -53,6 +54,7 @@ const pullDistance = ref(0);
let disabled = false;
const supportPointerDesktop = false;
let startScreenY: number | null = null;
let startScreenX: number | null = null;
const rootEl = shallowRef<HTMLDivElement>();
let scrollEl: HTMLElement | null = null;
@ -72,11 +74,16 @@ function getScreenY(event) {
if (supportPointerDesktop) return event.screenY;
return event.touches[0].screenY;
}
function getScreenX(event) {
if (supportPointerDesktop) return event.screenX;
return event.touches[0].screenX;
}
function moveStart(event) {
if (!pullStarted.value && !isRefreshing.value && !disabled) {
pullStarted.value = true;
startScreenY = getScreenY(event);
startScreenX = getScreenX(event);
pullDistance.value = 0;
}
}
@ -117,6 +124,7 @@ async function closeContent() {
function moveEnd() {
if (pullStarted.value && !isRefreshing.value) {
startScreenY = null;
startScreenX = null;
if (pullEnded.value) {
pullEnded.value = false;
isRefreshing.value = true;
@ -146,11 +154,17 @@ function moving(event: TouchEvent | PointerEvent) {
moveEnd();
return;
}
if (startScreenY === null) {
startScreenY = getScreenY(event);
}
startScreenX ??= getScreenX(event);
startScreenY ??= getScreenY(event);
const moveScreenY = getScreenY(event);
const moveScreenX = getScreenX(event);
const moveHeight = moveScreenY - startScreenY!;
const moveWidth = moveScreenX - startScreenX!;
if (Math.abs(moveWidth / moveHeight) > MAX_PULL_TAN_ANGLE) {
if (Math.abs(moveWidth) > 30) pullStarted.value = false;
return;
}
pullDistance.value = Math.min(Math.max(moveHeight, 0), MAX_PULL_DISTANCE);
if (pullDistance.value > 0) {
@ -203,7 +217,7 @@ function unregisterEventListenersForReadyToPull() {
onMounted(() => {
if (rootEl.value == null) return;
scrollEl = getScrollContainer(rootEl.value);
scrollEl = getScrollContainer(rootEl.value) ?? document.querySelector("HTML");
if (scrollEl == null) return;
scrollEl.addEventListener("scroll", onScrollContainerScroll, {

View file

@ -15,12 +15,12 @@
<script lang="ts" setup>
import { shallowRef } from "vue";
import QRCodeVue3 from "qrcode-vue3";
import MkModal from "@/components/MkModal.vue";
import MkButton from "@/components/MkButton.vue";
import { i18n } from "@/i18n";
import QRCodeVue3 from "qrcode-vue3";
const props = defineProps<{
defineProps<{
qrCode: string;
}>();

View file

@ -1,6 +1,7 @@
<template>
<button
v-if="canRenote && defaultStore.state.seperateRenoteQuote"
ref="el"
v-tooltip.noDelay.bottom="i18n.ts.quote"
class="eddddedb _button"
@click.stop="quote()"
@ -10,8 +11,8 @@
</template>
<script lang="ts" setup>
import { computed } from "vue";
import type { entities } from "firefish-js";
import { computed, ref } from "vue";
import { acct, type entities } from "firefish-js";
import { pleaseLogin } from "@/scripts/please-login";
import * as os from "@/os";
import { me } from "@/me";
@ -23,6 +24,8 @@ const props = defineProps<{
note: entities.Note;
}>();
const el = ref<HTMLButtonElement>();
const canRenote = computed(
() =>
["public", "home"].includes(props.note.visibility) ||
@ -31,10 +34,57 @@ const canRenote = computed(
function quote(): void {
pleaseLogin();
if (
props.note.renote != null &&
(props.note.text != null ||
props.note.fileIds.length === 0 ||
props.note.poll != null)
) {
menu();
} else {
normalQuote();
}
}
function normalQuote(): void {
os.post({
renote: props.note,
});
}
function slashQuote(): void {
os.post({
initialText: ` // @${acct.toString(props.note.user)}: ${props.note.text}`,
selectRange: [0, 0],
renote: props.note.renote,
channel: props.note.channel,
});
}
function menu(viaKeyboard = false): void {
os.popupMenu(
[
{
text: i18n.ts.quote,
icon: `${icon("ph-quotes")}`,
action: normalQuote,
},
{
text: i18n.ts.slashQuote,
icon: `${icon("ph-notches")}`,
action: slashQuote,
},
],
el.value,
{
viaKeyboard,
},
).then(focus);
}
function focus(): void {
el.value!.focus();
}
</script>
<style lang="scss" scoped>

View file

@ -25,8 +25,8 @@
</div>
<MkPagination
ref="pagingComponent"
:pagination="pagination"
v-slot="{ items }"
:pagination="pagination"
>
<MkUserCardMini v-for="{ user: user } in items" :key="user.id" :user="user" />
</MkPagination>

View file

@ -22,7 +22,7 @@
<script lang="ts" setup>
import { computed, ref } from "vue";
import type { entities } from "firefish-js";
import { acct, type entities } from "firefish-js";
import Ripple from "@/components/MkRipple.vue";
import XDetails from "@/components/MkUsersTooltip.vue";
import { pleaseLogin } from "@/scripts/please-login";
@ -223,6 +223,28 @@ const renote = (viaKeyboard = false, ev?: MouseEvent) => {
});
},
});
if (
props.note.renote != null &&
(props.note.text != null ||
props.note.fileIds.length === 0 ||
props.note.poll != null)
) {
buttonActions.push({
text: i18n.ts.slashQuote,
icon: `${icon("ph-notches")}`,
danger: false,
action: () => {
os.post({
initialText: ` // @${acct.toString(props.note.user)}: ${
props.note.text
}`,
selectRange: [0, 0],
renote: props.note.renote,
channel: props.note.channel,
});
},
});
}
}
if (hasRenotedBefore.value) {

View file

@ -382,7 +382,7 @@ function focusFooter(ev) {
> .body {
min-height: 2em;
max-height: 5em;
filter: blur(4px);
filter: blur(5px);
:deep(span) {
animation: none !important;
transform: none !important;

View file

@ -80,6 +80,7 @@ function onContextmenu(ev) {
text: i18n.ts.copyLink,
action: () => {
copyToClipboard(`${url}${props.to}`);
os.success();
},
},
],

View file

@ -27,6 +27,7 @@
>
<swiper-slide>
<XNotifications
:key="'tab1'"
class="notifications"
:include-types="includeTypes"
:unread-only="false"
@ -34,16 +35,18 @@
</swiper-slide>
<swiper-slide>
<XNotifications
v-if="tab === 'reactions'"
:key="'tab2'"
class="notifications"
:include-types="['reaction']"
:unread-only="false"
/>
</swiper-slide>
<swiper-slide>
<XNotes :pagination="mentionsPagination" />
<XNotes v-if="tab === 'mentions'" :key="'tab3'" :pagination="mentionsPagination" />
</swiper-slide>
<swiper-slide>
<XNotes :pagination="directNotesPagination" />
<XNotes v-if="tab === 'directNotes'" :key="'tab4'" :pagination="directNotesPagination" />
</swiper-slide>
</swiper>
</MkSpacer>
@ -54,6 +57,7 @@
import { computed, ref, watch } from "vue";
import { Virtual } from "swiper/modules";
import { Swiper, SwiperSlide } from "swiper/vue";
import type { Swiper as SwiperType } from "swiper/types";
import { notificationTypes } from "firefish-js";
import XNotifications from "@/components/MkNotifications.vue";
import XNotes from "@/components/MkNotes.vue";
@ -70,7 +74,7 @@ const tabs = ["all", "reactions", "mentions", "directNotes"];
const tab = ref(tabs[0]);
watch(tab, () => syncSlide(tabs.indexOf(tab.value)));
const includeTypes = ref<string[] | null>(null);
const includeTypes = ref<(typeof notificationTypes)[number][] | null>(null);
os.api("notifications/mark-all-as-read");
const MOBILE_THRESHOLD = 500;
@ -98,7 +102,7 @@ const directNotesPagination = {
function setFilter(ev) {
const typeItems = notificationTypes.map((t) => ({
text: i18n.t(`_notification._types.${t}`),
active: includeTypes.value && includeTypes.value.includes(t),
active: includeTypes.value?.includes(t),
action: () => {
includeTypes.value = [t];
},
@ -121,25 +125,23 @@ function setFilter(ev) {
}
const headerActions = computed(() =>
[
tab.value === "all"
? {
tab.value === "all"
? [
{
text: i18n.ts.filter,
icon: `${icon("ph-funnel")}`,
highlighted: includeTypes.value != null,
handler: setFilter,
}
: undefined,
tab.value === "all"
? {
},
{
text: i18n.ts.markAllAsRead,
icon: `${icon("ph-check")}`,
handler: () => {
os.apiWithDialog("notifications/mark-all-as-read");
},
}
: undefined,
].filter((x) => x !== undefined),
},
]
: [],
);
const headerTabs = computed(() => [
@ -172,18 +174,19 @@ definePageMetadata(
})),
);
let swiperRef = null;
let swiperRef: SwiperType | null = null;
function setSwiperRef(swiper) {
function setSwiperRef(swiper: SwiperType) {
swiperRef = swiper;
syncSlide(tabs.indexOf(tab.value));
}
function onSlideChange() {
tab.value = tabs[swiperRef.activeIndex];
if (tab.value !== tabs[swiperRef!.activeIndex])
tab.value = tabs[swiperRef!.activeIndex];
}
function syncSlide(index) {
swiperRef.slideTo(index);
function syncSlide(index: number) {
if (index !== swiperRef!.activeIndex) swiperRef!.slideTo(index);
}
</script>

View file

@ -131,6 +131,10 @@
i18n.ts.autocorrectNoteLanguage
}}</FormSwitch>
<FormSwitch v-model="foldNotification" class="_formBlock">{{
i18n.ts.foldNotification
}}</FormSwitch>
<FormSelect v-model="serverDisconnectedBehavior" class="_formBlock">
<template #label>{{ i18n.ts.whenServerDisconnected }}</template>
<option value="reload">
@ -327,13 +331,6 @@
</FormSelect>
</FormSection>
<FormSection>
<template #label>{{ i18n.ts.experimentalFeatures }}</template>
<FormSwitch v-model="foldNotification" class="_formBlock">{{
i18n.ts.foldNotification
}}</FormSwitch>
</FormSection>
<FormSection>
<template #label>{{ i18n.ts.forMobile }}</template>
<FormSwitch

View file

@ -1,8 +1,8 @@
import type { entities } from "firefish-js";
import type {
FoldableNotification,
NotificationFolded,
} from "@/types/notification";
import type { entities } from "firefish-js";
interface FoldOption {
/** If items length is 1, skip aggregation */

View file

@ -246,6 +246,7 @@ export function getUserMenu(user, router: Router = mainRouter) {
text: i18n.ts.copyUsername,
action: () => {
copyToClipboard(`@${user.username}@${user.host || host}`);
os.success();
},
},
{
@ -272,6 +273,7 @@ export function getUserMenu(user, router: Router = mainRouter) {
text: i18n.ts.copyRemoteFollowUrl,
action: () => {
copyToClipboard(`https://${host}/follow-me?acct=${user.username}`);
os.success();
},
},
],
@ -286,6 +288,7 @@ export function getUserMenu(user, router: Router = mainRouter) {
text: i18n.ts._feeds.rss,
action: () => {
copyToClipboard(`https://${host}/@${user.username}.rss`);
os.success();
},
},
{
@ -293,6 +296,7 @@ export function getUserMenu(user, router: Router = mainRouter) {
text: i18n.ts._feeds.atom,
action: () => {
copyToClipboard(`https://${host}/@${user.username}.atom`);
os.success();
},
},
{
@ -300,6 +304,7 @@ export function getUserMenu(user, router: Router = mainRouter) {
text: i18n.ts._feeds.jsonFeed,
action: () => {
copyToClipboard(`https://${host}/@${user.username}.json`);
os.success();
},
},
],

View file

@ -452,7 +452,7 @@ export const defaultStore = markRaw(
},
foldNotification: {
where: "deviceAccount",
default: false,
default: true,
},
}),
);

View file

@ -4,7 +4,7 @@ export type FoldableNotification =
| entities.RenoteNotification
| entities.ReactionNotification;
type Fold<T extends FoldableNotification> = {
interface Fold<T extends FoldableNotification> {
id: string;
type: T["type"];
createdAt: T["createdAt"];
@ -13,7 +13,7 @@ type Fold<T extends FoldableNotification> = {
userIds: entities.User["id"][];
users: entities.User[];
notifications: T[];
};
}
export type RenoteNotificationFolded = Fold<entities.RenoteNotification>;