Merge branch 'develop' into refactor/types
This commit is contained in:
commit
1b143ebfaa
62 changed files with 2805 additions and 955 deletions
|
@ -13,8 +13,6 @@ redis:
|
|||
host: firefish_redis
|
||||
port: 6379
|
||||
|
||||
id: 'aid'
|
||||
|
||||
#allowedPrivateNetworks: [
|
||||
# '10.69.1.0/24'
|
||||
#]
|
||||
|
|
1242
Cargo.lock
generated
1242
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
24
Cargo.toml
24
Cargo.toml
|
@ -5,36 +5,40 @@ resolver = "2"
|
|||
[workspace.dependencies]
|
||||
macro_rs = { path = "packages/macro-rs" }
|
||||
|
||||
napi = { version = "2.16.2", default-features = false }
|
||||
napi-derive = "2.16.2"
|
||||
napi = { version = "2.16.4", default-features = false }
|
||||
napi-derive = "2.16.3"
|
||||
napi-build = "2.1.3"
|
||||
|
||||
argon2 = "0.5.3"
|
||||
basen = "0.1.0"
|
||||
bcrypt = "0.15.1"
|
||||
chrono = "0.4.37"
|
||||
chrono = "0.4.38"
|
||||
convert_case = "0.6.0"
|
||||
cuid2 = "0.1.2"
|
||||
emojis = "0.6.1"
|
||||
emojis = "0.6.2"
|
||||
idna = "0.5.0"
|
||||
image = "0.25.1"
|
||||
nom-exif = "1.2.0"
|
||||
once_cell = "1.19.0"
|
||||
openssl = "0.10.64"
|
||||
pretty_assertions = "1.4.0"
|
||||
proc-macro2 = "1.0.79"
|
||||
proc-macro2 = "1.0.81"
|
||||
quote = "1.0.36"
|
||||
rand = "0.8.5"
|
||||
redis = "0.25.3"
|
||||
regex = "1.10.4"
|
||||
reqwest = "0.12.4"
|
||||
rmp-serde = "1.2.0"
|
||||
sea-orm = "0.12.15"
|
||||
serde = "1.0.197"
|
||||
serde_json = "1.0.115"
|
||||
serde = "1.0.198"
|
||||
serde_json = "1.0.116"
|
||||
serde_yaml = "0.9.34"
|
||||
strum = "0.26.2"
|
||||
syn = "2.0.58"
|
||||
thiserror = "1.0.58"
|
||||
syn = "2.0.60"
|
||||
thiserror = "1.0.59"
|
||||
tokio = "1.37.0"
|
||||
tracing = "0.1.40"
|
||||
tracing-subscriber = "0.3.1"
|
||||
tracing-subscriber = "0.3.18"
|
||||
url = "2.5.0"
|
||||
urlencoding = "2.1.3"
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ FROM docker.io/node:20-alpine as build
|
|||
WORKDIR /firefish
|
||||
|
||||
# Install compilation dependencies
|
||||
RUN apk update && apk add --no-cache build-base linux-headers curl ca-certificates python3
|
||||
RUN apk update && apk add --no-cache build-base linux-headers curl ca-certificates python3 perl
|
||||
RUN curl --proto '=https' --tlsv1.2 --silent --show-error --fail https://sh.rustup.rs | sh -s -- -y
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
|
||||
|
@ -34,6 +34,7 @@ RUN corepack enable && corepack prepare pnpm@latest --activate && pnpm install -
|
|||
|
||||
# Copy in the rest of the rust files
|
||||
COPY packages/backend-rs packages/backend-rs/
|
||||
# COPY packages/macro-rs packages/macro-rs/
|
||||
|
||||
# Compile backend-rs
|
||||
RUN NODE_ENV='production' pnpm run --filter backend-rs build
|
||||
|
|
|
@ -5,6 +5,12 @@ 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
|
||||
|
||||
- Add features to share links to an account in the three dots menu on the profile page
|
||||
- Improve server logs
|
||||
- Fix bugs
|
||||
|
||||
## [v20240424](https://firefish.dev/firefish/firefish/-/merge_requests/10765/commits)
|
||||
|
||||
- Improve the usability of the feature to prevent forgetting to write alt texts
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
BEGIN;
|
||||
|
||||
DELETE FROM "migrations" WHERE name IN (
|
||||
'AlterAkaType1714099399879',
|
||||
'AddDriveFileUsage1713451569342',
|
||||
'ConvertCwVarcharToText1713225866247',
|
||||
'FixChatFileConstraint1712855579316',
|
||||
|
@ -24,6 +25,13 @@ DELETE FROM "migrations" WHERE name IN (
|
|||
'RemoveNativeUtilsMigration1705877093218'
|
||||
);
|
||||
|
||||
-- alter-aka-type
|
||||
ALTER TABLE "user" RENAME COLUMN "alsoKnownAs" TO "alsoKnownAsOld";
|
||||
ALTER TABLE "user" ADD COLUMN "alsoKnownAs" text;
|
||||
UPDATE "user" SET "alsoKnownAs" = array_to_string("alsoKnownAsOld", ',');
|
||||
COMMENT ON COLUMN "user"."alsoKnownAs" IS 'URIs the user is known as too';
|
||||
ALTER TABLE "user" DROP COLUMN "alsoKnownAsOld";
|
||||
|
||||
-- AddDriveFileUsage
|
||||
ALTER TABLE "drive_file" DROP COLUMN "usageHint";
|
||||
DROP TYPE "drive_file_usage_hint_enum";
|
||||
|
|
|
@ -19,7 +19,7 @@ deleteAndEditConfirm: Сигурни ли сте, че искате да изт
|
|||
copyUsername: Копиране на потребителското име
|
||||
searchUser: Търсене на потребител
|
||||
reply: Отговор
|
||||
showMore: Покажи още
|
||||
showMore: Показване на повече
|
||||
loadMore: Зареди още
|
||||
followRequestAccepted: Заявката за последване е приета
|
||||
importAndExport: Импорт/експорт на данни
|
||||
|
@ -336,6 +336,10 @@ _pages:
|
|||
title: Заглавие
|
||||
my: Моите страници
|
||||
pageSetting: Настройки на страницата
|
||||
url: Адрес на страницата
|
||||
summary: Кратко обобщение
|
||||
alignCenter: Центриране на елементите
|
||||
variables: Променливи
|
||||
_deck:
|
||||
_columns:
|
||||
notifications: Известия
|
||||
|
@ -398,7 +402,7 @@ sendMessage: Изпращане на съобщение
|
|||
jumpToPrevious: Премини към предишно
|
||||
newer: по-ново
|
||||
older: по-старо
|
||||
showLess: Покажи по-малко
|
||||
showLess: Показване на по-малко
|
||||
youGotNewFollower: те последва
|
||||
receiveFollowRequest: Заявка за последване получена
|
||||
mention: Споменаване
|
||||
|
@ -754,7 +758,7 @@ _feeds:
|
|||
general: Общи
|
||||
metadata: Метаданни
|
||||
disk: Диск
|
||||
featured: Препоръчани
|
||||
featured: Препоръчано
|
||||
yearsOld: на {age} години
|
||||
reload: Опресняване
|
||||
invites: Покани
|
||||
|
@ -940,3 +944,11 @@ showGapBetweenNotesInTimeline: Показване на празнина межд
|
|||
lookup: Поглеждане
|
||||
media: Мултимедия
|
||||
welcomeBackWithName: Добре дошли отново, {name}
|
||||
reduceUiAnimation: Намаляване на UI анимациите
|
||||
clickToFinishEmailVerification: Моля, натиснете [{ok}], за да завършите потвърждаването
|
||||
на ел. поща.
|
||||
_cw:
|
||||
show: Показване на съдържанието
|
||||
remoteFollow: Отдалечено последване
|
||||
messagingUnencryptedInfo: Чатовете във Firefish не са шифровани от край до край. Не
|
||||
споделяйте чувствителна информация през Firefish.
|
||||
|
|
|
@ -1011,6 +1011,8 @@ isSystemAccount: "This account is created and automatically operated by the syst
|
|||
Please do not moderate, edit, delete, or otherwise tamper with this account, or
|
||||
it may break your server."
|
||||
typeToConfirm: "Please enter {x} to confirm"
|
||||
useThisAccountConfirm: "Do you want to continue with this account?"
|
||||
inputAccountId: "Please input your account (e.g., @firefish@info.firefish.dev)"
|
||||
deleteAccount: "Delete account"
|
||||
document: "Documentation"
|
||||
numberOfPageCache: "Number of cached pages"
|
||||
|
@ -1157,6 +1159,9 @@ addRe: "Add \"re:\" at the beginning of comment in reply to a post with a conten
|
|||
confirm: "Confirm"
|
||||
importZip: "Import ZIP"
|
||||
exportZip: "Export ZIP"
|
||||
getQrCode: "Show QR code"
|
||||
remoteFollow: "Remote follow"
|
||||
copyRemoteFollowUrl: "Copy remote follow URL"
|
||||
emojiPackCreator: "Emoji pack creator"
|
||||
indexable: "Indexable"
|
||||
indexableDescription: "Allow built-in search to show your public posts"
|
||||
|
|
|
@ -928,6 +928,8 @@ colored: "Coloré"
|
|||
label: "Étiquette"
|
||||
localOnly: "Local seulement"
|
||||
account: "Comptes"
|
||||
getQrCode: "Obtenir le code QR"
|
||||
|
||||
_emailUnavailable:
|
||||
used: "Adresse non disponible"
|
||||
format: "Le format de cette adresse de courriel est invalide"
|
||||
|
|
|
@ -1825,6 +1825,7 @@ _notification:
|
|||
reacted: mereaksi postinganmu
|
||||
renoted: memposting ulang postinganmu
|
||||
voted: memilih di angketmu
|
||||
andCountUsers: dan {count} lebih banyak pengguna {acted}
|
||||
_deck:
|
||||
alwaysShowMainColumn: "Selalu tampilkan kolom utama"
|
||||
columnAlign: "Luruskan kolom"
|
||||
|
@ -2267,3 +2268,13 @@ markLocalFilesNsfwByDefaultDescription: Terlepas dari pengaturan ini, pengguna d
|
|||
menghapus sendiri tanda NSFW. Berkas yang ada tidak berpengaruh.
|
||||
noteEditHistory: Riwayat penyuntingan kiriman
|
||||
media: Media
|
||||
antennaLimit: Jumlah antena maksimum yang dapat dibuat oleh setiap pengguna
|
||||
showAddFileDescriptionAtFirstPost: Buka formulir secara otomatis untuk menulis deskripsi
|
||||
ketika mencoba mengirim berkas tanpa deskripsi
|
||||
remoteFollow: Ikuti jarak jauh
|
||||
foldNotification: Kelompokkan notifikasi yang sama
|
||||
getQrCode: Tampilkan kode QR
|
||||
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
|
||||
|
|
|
@ -1902,6 +1902,7 @@ _notification:
|
|||
reacted: がリアクションしました
|
||||
renoted: がブーストしました
|
||||
voted: が投票しました
|
||||
andCountUsers: と{count}人が{acted}しました
|
||||
_deck:
|
||||
alwaysShowMainColumn: "常にメインカラムを表示"
|
||||
columnAlign: "カラムの寄せ"
|
||||
|
@ -2059,3 +2060,10 @@ markLocalFilesNsfwByDefaultDescription: この設定が有効でも、ユーザ
|
|||
noteEditHistory: 編集履歴
|
||||
showAddFileDescriptionAtFirstPost: 説明の無い添付ファイルを投稿しようとした際に説明を書く画面を自動で開く
|
||||
antennaLimit: 各ユーザーが作れるアンテナの最大数
|
||||
inputAccountId: 'あなたのアカウントを入力してください(例: @firefish@info.firefish.dev)'
|
||||
remoteFollow: リモートフォロー
|
||||
cannotEditVisibility: 公開範囲は変更できません
|
||||
useThisAccountConfirm: このアカウントで操作を続けますか?
|
||||
getQrCode: QRコードを表示
|
||||
copyRemoteFollowUrl: リモートからフォローするURLをコピー
|
||||
foldNotification: 同じ種類の通知をまとめて表示する
|
||||
|
|
|
@ -879,6 +879,8 @@ driveCapOverrideCaption: "输入 0 或以下的值将容量重置为默认值。
|
|||
requireAdminForView: "您需要使用管理员账号登录才能查看。"
|
||||
isSystemAccount: "该账号由系统自动创建。请不要修改、编辑、删除或以其它方式篡改这个账号,否则可能会破坏您的服务器。"
|
||||
typeToConfirm: "输入 {x} 以确认操作"
|
||||
useThisAccountConfirm: "您想使用此帐户继续执行此操作吗?"
|
||||
inputAccountId: "请输入您的帐户(例如 @firefish@info.firefish.dev )"
|
||||
deleteAccount: "删除账号"
|
||||
document: "文档"
|
||||
numberOfPageCache: "缓存页数"
|
||||
|
@ -1974,6 +1976,9 @@ origin: 起源
|
|||
confirm: 确认
|
||||
importZip: 导入 ZIP
|
||||
exportZip: 导出 ZIP
|
||||
getQrCode: "获取二维码"
|
||||
remoteFollow: "远程关注"
|
||||
copyRemoteFollowUrl: "复制远程关注 URL"
|
||||
emojiPackCreator: 表情包创建工具
|
||||
objectStorageS3ForcePathStyleDesc: 打开此选项可构建格式为 "s3.amazonaws.com/<bucket>/" 而非 "<bucket>.s3.amazonaws.com"
|
||||
的端点 URL。
|
||||
|
|
10
package.json
10
package.json
|
@ -45,11 +45,11 @@
|
|||
"js-yaml": "4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.6.4",
|
||||
"@biomejs/cli-darwin-arm64": "^1.6.4",
|
||||
"@biomejs/cli-darwin-x64": "^1.6.4",
|
||||
"@biomejs/cli-linux-arm64": "^1.6.4",
|
||||
"@biomejs/cli-linux-x64": "^1.6.4",
|
||||
"@biomejs/biome": "1.7.1",
|
||||
"@biomejs/cli-darwin-arm64": "^1.7.1",
|
||||
"@biomejs/cli-darwin-x64": "^1.7.1",
|
||||
"@biomejs/cli-linux-arm64": "^1.7.1",
|
||||
"@biomejs/cli-linux-x64": "^1.7.1",
|
||||
"@types/node": "20.12.7",
|
||||
"execa": "8.0.1",
|
||||
"pnpm": "8.15.7",
|
||||
|
|
|
@ -24,10 +24,14 @@ chrono = { workspace = true }
|
|||
cuid2 = { workspace = true }
|
||||
emojis = { workspace = true }
|
||||
idna = { workspace = true }
|
||||
image = { workspace = true }
|
||||
nom-exif = { workspace = true }
|
||||
once_cell = { workspace = true }
|
||||
openssl = { workspace = true, features = ["vendored"] }
|
||||
rand = { workspace = true }
|
||||
redis = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["blocking"] }
|
||||
rmp-serde = { workspace = true }
|
||||
sea-orm = { workspace = true, features = ["sqlx-postgres", "runtime-tokio-rustls"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
|
|
33
packages/backend-rs/index.d.ts
vendored
33
packages/backend-rs/index.d.ts
vendored
|
@ -248,6 +248,11 @@ export function sqlLikeEscape(src: string): string
|
|||
export function safeForSql(src: string): boolean
|
||||
/** Convert milliseconds to a human readable string */
|
||||
export function formatMilliseconds(milliseconds: number): string
|
||||
export interface ImageSize {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
export function getImageSizeFromUrl(url: string): Promise<ImageSize>
|
||||
/** TODO: handle name collisions better */
|
||||
export interface NoteLikeForGetNoteSummary {
|
||||
fileIds: Array<string>
|
||||
|
@ -1012,10 +1017,10 @@ export interface User {
|
|||
isDeleted: boolean
|
||||
driveCapacityOverrideMb: number | null
|
||||
movedToUri: string | null
|
||||
alsoKnownAs: string | null
|
||||
speakAsCat: boolean
|
||||
emojiModPerm: UserEmojimodpermEnum
|
||||
isIndexable: boolean
|
||||
alsoKnownAs: Array<string> | null
|
||||
}
|
||||
export interface UserGroup {
|
||||
id: string
|
||||
|
@ -1144,6 +1149,7 @@ export interface Webhook {
|
|||
export function initializeRustLogger(): void
|
||||
export function watchNote(watcherId: string, noteAuthorId: string, noteId: string): Promise<void>
|
||||
export function unwatchNote(watcherId: string, noteId: string): Promise<void>
|
||||
export function publishToChannelStream(channelId: string, userId: string): void
|
||||
export enum ChatEvent {
|
||||
Message = 'message',
|
||||
Read = 'read',
|
||||
|
@ -1151,6 +1157,31 @@ export enum ChatEvent {
|
|||
Typing = 'typing'
|
||||
}
|
||||
export function publishToChatStream(senderUserId: string, receiverUserId: string, kind: ChatEvent, object: any): void
|
||||
export enum ChatIndexEvent {
|
||||
Message = 'message',
|
||||
Read = 'read'
|
||||
}
|
||||
export function publishToChatIndexStream(userId: string, kind: ChatIndexEvent, object: any): void
|
||||
export interface PackedEmoji {
|
||||
id: string
|
||||
aliases: Array<string>
|
||||
name: string
|
||||
category: string | null
|
||||
host: string | null
|
||||
url: string
|
||||
license: string | null
|
||||
width: number | null
|
||||
height: number | null
|
||||
}
|
||||
export function publishToBroadcastStream(emoji: PackedEmoji): void
|
||||
export function publishToGroupChatStream(groupId: string, kind: ChatEvent, object: any): void
|
||||
export interface AbuseUserReportLike {
|
||||
id: string
|
||||
targetUserId: string
|
||||
reporterId: string
|
||||
comment: string
|
||||
}
|
||||
export function publishToModerationStream(moderatorId: string, report: AbuseUserReportLike): void
|
||||
export function getTimestamp(id: string): number
|
||||
/**
|
||||
* The generated ID results in the form of `[8 chars timestamp] + [cuid2]`.
|
||||
|
|
|
@ -310,7 +310,7 @@ if (!nativeBinding) {
|
|||
throw new Error(`Failed to load native binding`)
|
||||
}
|
||||
|
||||
const { SECOND, MINUTE, HOUR, DAY, USER_ONLINE_THRESHOLD, USER_ACTIVE_THRESHOLD, FILE_TYPE_BROWSERSAFE, loadEnv, loadConfig, stringToAcct, acctToString, addNoteToAntenna, isBlockedServer, isSilencedServer, isAllowedServer, checkWordMute, getFullApAccount, isSelfHost, isSameOrigin, extractHost, toPuny, isUnicodeEmoji, sqlLikeEscape, safeForSql, formatMilliseconds, getNoteSummary, toMastodonId, fromMastodonId, fetchMeta, metaToPugArgs, nyaify, hashPassword, verifyPassword, isOldPasswordAlgorithm, decodeReaction, countReactions, toDbReaction, removeOldAttestationChallenges, AntennaSrcEnum, DriveFileUsageHintEnum, MutedNoteReasonEnum, NoteVisibilityEnum, NotificationTypeEnum, PageVisibilityEnum, PollNotevisibilityEnum, RelayStatusEnum, UserEmojimodpermEnum, UserProfileFfvisibilityEnum, UserProfileMutingnotificationtypesEnum, initializeRustLogger, watchNote, unwatchNote, ChatEvent, publishToChatStream, getTimestamp, genId, genIdAt, secureRndstr } = nativeBinding
|
||||
const { SECOND, MINUTE, HOUR, DAY, USER_ONLINE_THRESHOLD, USER_ACTIVE_THRESHOLD, FILE_TYPE_BROWSERSAFE, loadEnv, loadConfig, stringToAcct, acctToString, addNoteToAntenna, isBlockedServer, isSilencedServer, isAllowedServer, checkWordMute, getFullApAccount, isSelfHost, isSameOrigin, extractHost, toPuny, isUnicodeEmoji, sqlLikeEscape, safeForSql, formatMilliseconds, getImageSizeFromUrl, getNoteSummary, toMastodonId, fromMastodonId, fetchMeta, metaToPugArgs, nyaify, hashPassword, verifyPassword, isOldPasswordAlgorithm, decodeReaction, countReactions, toDbReaction, removeOldAttestationChallenges, AntennaSrcEnum, DriveFileUsageHintEnum, MutedNoteReasonEnum, NoteVisibilityEnum, NotificationTypeEnum, PageVisibilityEnum, PollNotevisibilityEnum, RelayStatusEnum, UserEmojimodpermEnum, UserProfileFfvisibilityEnum, UserProfileMutingnotificationtypesEnum, initializeRustLogger, watchNote, unwatchNote, publishToChannelStream, ChatEvent, publishToChatStream, ChatIndexEvent, publishToChatIndexStream, publishToBroadcastStream, publishToGroupChatStream, publishToModerationStream, getTimestamp, genId, genIdAt, secureRndstr } = nativeBinding
|
||||
|
||||
module.exports.SECOND = SECOND
|
||||
module.exports.MINUTE = MINUTE
|
||||
|
@ -337,6 +337,7 @@ module.exports.isUnicodeEmoji = isUnicodeEmoji
|
|||
module.exports.sqlLikeEscape = sqlLikeEscape
|
||||
module.exports.safeForSql = safeForSql
|
||||
module.exports.formatMilliseconds = formatMilliseconds
|
||||
module.exports.getImageSizeFromUrl = getImageSizeFromUrl
|
||||
module.exports.getNoteSummary = getNoteSummary
|
||||
module.exports.toMastodonId = toMastodonId
|
||||
module.exports.fromMastodonId = fromMastodonId
|
||||
|
@ -364,8 +365,14 @@ module.exports.UserProfileMutingnotificationtypesEnum = UserProfileMutingnotific
|
|||
module.exports.initializeRustLogger = initializeRustLogger
|
||||
module.exports.watchNote = watchNote
|
||||
module.exports.unwatchNote = unwatchNote
|
||||
module.exports.publishToChannelStream = publishToChannelStream
|
||||
module.exports.ChatEvent = ChatEvent
|
||||
module.exports.publishToChatStream = publishToChatStream
|
||||
module.exports.ChatIndexEvent = ChatIndexEvent
|
||||
module.exports.publishToChatIndexStream = publishToChatIndexStream
|
||||
module.exports.publishToBroadcastStream = publishToBroadcastStream
|
||||
module.exports.publishToGroupChatStream = publishToGroupChatStream
|
||||
module.exports.publishToModerationStream = publishToModerationStream
|
||||
module.exports.getTimestamp = getTimestamp
|
||||
module.exports.genId = genId
|
||||
module.exports.genIdAt = genIdAt
|
||||
|
|
200
packages/backend-rs/src/misc/get_image_size.rs
Normal file
200
packages/backend-rs/src/misc/get_image_size.rs
Normal file
|
@ -0,0 +1,200 @@
|
|||
use crate::misc::redis_cache::{get_cache, set_cache, CacheError};
|
||||
use crate::util::http_client;
|
||||
use image::{io::Reader, ImageError, ImageFormat};
|
||||
use nom_exif::{parse_jpeg_exif, EntryValue, ExifTag};
|
||||
use std::io::Cursor;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("Redis cache error: {0}")]
|
||||
CacheErr(#[from] CacheError),
|
||||
#[error("Reqwest error: {0}")]
|
||||
ReqwestErr(#[from] reqwest::Error),
|
||||
#[error("Image decoding error: {0}")]
|
||||
ImageErr(#[from] ImageError),
|
||||
#[error("Image decoding error: {0}")]
|
||||
IoErr(#[from] std::io::Error),
|
||||
#[error("Exif extraction error: {0}")]
|
||||
ExifErr(#[from] nom_exif::Error),
|
||||
#[error("Emoji meta attempt limit exceeded: {0}")]
|
||||
TooManyAttempts(String),
|
||||
#[error("Unsupported image type: {0}")]
|
||||
UnsupportedImageErr(String),
|
||||
}
|
||||
|
||||
const BROWSER_SAFE_IMAGE_TYPES: [ImageFormat; 8] = [
|
||||
ImageFormat::Png,
|
||||
ImageFormat::Jpeg,
|
||||
ImageFormat::Gif,
|
||||
ImageFormat::WebP,
|
||||
ImageFormat::Tiff,
|
||||
ImageFormat::Bmp,
|
||||
ImageFormat::Ico,
|
||||
ImageFormat::Avif,
|
||||
];
|
||||
|
||||
static MTX_GUARD: Mutex<()> = Mutex::const_new(());
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
#[crate::export(object)]
|
||||
pub struct ImageSize {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
#[crate::export]
|
||||
pub async fn get_image_size_from_url(url: &str) -> Result<ImageSize, Error> {
|
||||
let attempted: bool;
|
||||
|
||||
{
|
||||
let _ = MTX_GUARD.lock().await;
|
||||
|
||||
let key = format!("fetchImage:{}", url);
|
||||
attempted = get_cache::<bool>(&key)?.is_some();
|
||||
|
||||
if !attempted {
|
||||
set_cache(&key, &true, 10 * 60)?;
|
||||
}
|
||||
}
|
||||
|
||||
if attempted {
|
||||
tracing::warn!("attempt limit exceeded: {}", url);
|
||||
return Err(Error::TooManyAttempts(url.to_string()));
|
||||
}
|
||||
|
||||
tracing::info!("retrieving image size from {}", url);
|
||||
|
||||
let image_bytes = http_client()?.get(url).send().await?.bytes().await?;
|
||||
let reader = Reader::new(Cursor::new(&image_bytes)).with_guessed_format()?;
|
||||
|
||||
let format = reader.format();
|
||||
if format.is_none() || !BROWSER_SAFE_IMAGE_TYPES.contains(&format.unwrap()) {
|
||||
return Err(Error::UnsupportedImageErr(format!("{:?}", format)));
|
||||
}
|
||||
|
||||
let size = reader.into_dimensions()?;
|
||||
|
||||
let res = ImageSize {
|
||||
width: size.0,
|
||||
height: size.1,
|
||||
};
|
||||
|
||||
if format.unwrap() != ImageFormat::Jpeg {
|
||||
return Ok(res);
|
||||
}
|
||||
|
||||
// handle jpeg orientation
|
||||
// https://magnushoff.com/articles/jpeg-orientation/
|
||||
|
||||
let exif = parse_jpeg_exif(&*image_bytes)?;
|
||||
if exif.is_none() {
|
||||
return Ok(res);
|
||||
}
|
||||
|
||||
let orientation = exif.unwrap().get_value(&ExifTag::Orientation)?;
|
||||
let rotated =
|
||||
orientation.is_some() && matches!(orientation.unwrap(), EntryValue::U32(v) if v >= 5);
|
||||
|
||||
if !rotated {
|
||||
return Ok(res);
|
||||
}
|
||||
|
||||
Ok(ImageSize {
|
||||
width: size.1,
|
||||
height: size.0,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod unit_test {
|
||||
use super::{get_image_size_from_url, ImageSize};
|
||||
use crate::misc::redis_cache::delete_cache;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_image_size() {
|
||||
let png_url_1 = "https://firefish.dev/firefish/firefish/-/raw/5891a90f71a8b9d5ea99c683ade7e485c685d642/packages/backend/assets/splash.png";
|
||||
let png_url_2 = "https://firefish.dev/firefish/firefish/-/raw/5891a90f71a8b9d5ea99c683ade7e485c685d642/packages/backend/assets/notification-badges/at.png";
|
||||
let png_url_3 = "https://firefish.dev/firefish/firefish/-/raw/5891a90f71a8b9d5ea99c683ade7e485c685d642/packages/backend/assets/api-doc.png";
|
||||
let rotated_jpeg_url = "https://firefish.dev/firefish/firefish/-/raw/5891a90f71a8b9d5ea99c683ade7e485c685d642/packages/backend/test/resources/rotate.jpg";
|
||||
let webp_url_1 = "https://firefish.dev/firefish/firefish/-/raw/5891a90f71a8b9d5ea99c683ade7e485c685d642/custom/assets/badges/error.webp";
|
||||
let webp_url_2 = "https://firefish.dev/firefish/firefish/-/raw/5891a90f71a8b9d5ea99c683ade7e485c685d642/packages/backend/assets/screenshots/1.webp";
|
||||
let ico_url = "https://firefish.dev/firefish/firefish/-/raw/5891a90f71a8b9d5ea99c683ade7e485c685d642/packages/backend/assets/favicon.ico";
|
||||
let gif_url = "https://firefish.dev/firefish/firefish/-/raw/b9c3dfbd3d473cb2cee20c467eeae780bc401271/packages/backend/test/resources/anime.gif";
|
||||
let mp3_url = "https://firefish.dev/firefish/firefish/-/blob/5891a90f71a8b9d5ea99c683ade7e485c685d642/packages/backend/assets/sounds/aisha/1.mp3";
|
||||
|
||||
// Delete caches in case you run this test multiple times
|
||||
// (should be disabled in CI tasks)
|
||||
delete_cache(&format!("fetchImage:{}", png_url_1)).unwrap();
|
||||
delete_cache(&format!("fetchImage:{}", png_url_2)).unwrap();
|
||||
delete_cache(&format!("fetchImage:{}", png_url_3)).unwrap();
|
||||
delete_cache(&format!("fetchImage:{}", rotated_jpeg_url)).unwrap();
|
||||
delete_cache(&format!("fetchImage:{}", webp_url_1)).unwrap();
|
||||
delete_cache(&format!("fetchImage:{}", webp_url_2)).unwrap();
|
||||
delete_cache(&format!("fetchImage:{}", ico_url)).unwrap();
|
||||
delete_cache(&format!("fetchImage:{}", gif_url)).unwrap();
|
||||
delete_cache(&format!("fetchImage:{}", mp3_url)).unwrap();
|
||||
|
||||
let png_size_1 = ImageSize {
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
};
|
||||
let png_size_2 = ImageSize {
|
||||
width: 96,
|
||||
height: 96,
|
||||
};
|
||||
let png_size_3 = ImageSize {
|
||||
width: 1024,
|
||||
height: 354,
|
||||
};
|
||||
let rotated_jpeg_size = ImageSize {
|
||||
width: 256,
|
||||
height: 512,
|
||||
};
|
||||
let webp_size_1 = ImageSize {
|
||||
width: 256,
|
||||
height: 256,
|
||||
};
|
||||
let webp_size_2 = ImageSize {
|
||||
width: 1080,
|
||||
height: 2340,
|
||||
};
|
||||
let ico_size = ImageSize {
|
||||
width: 256,
|
||||
height: 256,
|
||||
};
|
||||
let gif_size = ImageSize {
|
||||
width: 256,
|
||||
height: 256,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
png_size_1,
|
||||
get_image_size_from_url(png_url_1).await.unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
png_size_2,
|
||||
get_image_size_from_url(png_url_2).await.unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
png_size_3,
|
||||
get_image_size_from_url(png_url_3).await.unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
rotated_jpeg_size,
|
||||
get_image_size_from_url(rotated_jpeg_url).await.unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
webp_size_1,
|
||||
get_image_size_from_url(webp_url_1).await.unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
webp_size_2,
|
||||
get_image_size_from_url(webp_url_2).await.unwrap()
|
||||
);
|
||||
assert_eq!(ico_size, get_image_size_from_url(ico_url).await.unwrap());
|
||||
assert_eq!(gif_size, get_image_size_from_url(gif_url).await.unwrap());
|
||||
assert!(get_image_size_from_url(mp3_url).await.is_err());
|
||||
}
|
||||
}
|
|
@ -6,6 +6,7 @@ pub mod convert_host;
|
|||
pub mod emoji;
|
||||
pub mod escape_sql;
|
||||
pub mod format_milliseconds;
|
||||
pub mod get_image_size;
|
||||
pub mod get_note_summary;
|
||||
pub mod mastodon_id;
|
||||
pub mod meta;
|
||||
|
|
|
@ -3,7 +3,7 @@ use redis::{Commands, RedisError};
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum Error {
|
||||
pub enum CacheError {
|
||||
#[error("Redis error: {0}")]
|
||||
RedisError(#[from] RedisError),
|
||||
#[error("Data serialization error: {0}")]
|
||||
|
@ -12,27 +12,37 @@ pub enum Error {
|
|||
DeserializeError(#[from] rmp_serde::decode::Error),
|
||||
}
|
||||
|
||||
fn prefix_key(key: &str) -> String {
|
||||
redis_key(format!("cache:{}", key))
|
||||
}
|
||||
|
||||
pub fn set_cache<V: for<'a> Deserialize<'a> + Serialize>(
|
||||
key: &str,
|
||||
value: &V,
|
||||
expire_seconds: u64,
|
||||
) -> Result<(), Error> {
|
||||
) -> Result<(), CacheError> {
|
||||
redis_conn()?.set_ex(
|
||||
redis_key(key),
|
||||
prefix_key(key),
|
||||
rmp_serde::encode::to_vec(&value)?,
|
||||
expire_seconds,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_cache<V: for<'a> Deserialize<'a> + Serialize>(key: &str) -> Result<Option<V>, Error> {
|
||||
let serialized_value: Option<Vec<u8>> = redis_conn()?.get(redis_key(key))?;
|
||||
pub fn get_cache<V: for<'a> Deserialize<'a> + Serialize>(
|
||||
key: &str,
|
||||
) -> Result<Option<V>, CacheError> {
|
||||
let serialized_value: Option<Vec<u8>> = redis_conn()?.get(prefix_key(key))?;
|
||||
Ok(match serialized_value {
|
||||
Some(v) => Some(rmp_serde::from_slice::<V>(v.as_ref())?),
|
||||
None => None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_cache(key: &str) -> Result<(), CacheError> {
|
||||
Ok(redis_conn()?.del(prefix_key(key))?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod unit_test {
|
||||
use super::{get_cache, set_cache};
|
||||
|
|
|
@ -71,14 +71,14 @@ pub struct Model {
|
|||
pub drive_capacity_override_mb: Option<i32>,
|
||||
#[sea_orm(column_name = "movedToUri")]
|
||||
pub moved_to_uri: Option<String>,
|
||||
#[sea_orm(column_name = "alsoKnownAs", column_type = "Text", nullable)]
|
||||
pub also_known_as: Option<String>,
|
||||
#[sea_orm(column_name = "speakAsCat")]
|
||||
pub speak_as_cat: bool,
|
||||
#[sea_orm(column_name = "emojiModPerm")]
|
||||
pub emoji_mod_perm: UserEmojimodpermEnum,
|
||||
#[sea_orm(column_name = "isIndexable")]
|
||||
pub is_indexable: bool,
|
||||
#[sea_orm(column_name = "alsoKnownAs")]
|
||||
pub also_known_as: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
|
|
|
@ -13,7 +13,7 @@ pub fn initialize_logger() {
|
|||
"info" => Level::INFO,
|
||||
"debug" => Level::DEBUG,
|
||||
"trace" => Level::TRACE,
|
||||
_ => Level::INFO,
|
||||
_ => Level::INFO, // Fallback
|
||||
});
|
||||
} else if let Some(levels) = &CONFIG.log_level {
|
||||
// `logLevel` config is Deprecated
|
||||
|
@ -27,13 +27,25 @@ pub fn initialize_logger() {
|
|||
builder = builder.with_max_level(Level::WARN);
|
||||
} else if levels.contains(&"error".to_string()) {
|
||||
builder = builder.with_max_level(Level::ERROR);
|
||||
} else {
|
||||
// Fallback
|
||||
builder = builder.with_max_level(Level::INFO);
|
||||
}
|
||||
} else {
|
||||
// Fallback
|
||||
builder = builder.with_max_level(Level::INFO);
|
||||
};
|
||||
|
||||
let subscriber = builder.with_level(true).pretty().finish();
|
||||
let subscriber = builder
|
||||
.without_time()
|
||||
.with_level(true)
|
||||
.with_ansi(true)
|
||||
.with_target(true)
|
||||
.with_thread_names(true)
|
||||
.with_line_number(true)
|
||||
.log_internal_errors(true)
|
||||
.compact()
|
||||
.finish();
|
||||
|
||||
tracing::subscriber::set_global_default(subscriber).expect("Failed to initialize the logger");
|
||||
}
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
pub mod antenna;
|
||||
pub mod channel;
|
||||
pub mod chat;
|
||||
pub mod chat_index;
|
||||
pub mod custom_emoji;
|
||||
pub mod group_chat;
|
||||
pub mod moderation;
|
||||
|
||||
use crate::config::CONFIG;
|
||||
use crate::database::redis_conn;
|
||||
|
@ -10,9 +15,9 @@ pub enum Stream {
|
|||
#[strum(serialize = "internal")]
|
||||
Internal,
|
||||
#[strum(serialize = "broadcast")]
|
||||
Broadcast,
|
||||
#[strum(to_string = "adminStream:{user_id}")]
|
||||
Admin { user_id: String },
|
||||
CustomEmoji,
|
||||
#[strum(to_string = "adminStream:{moderator_id}")]
|
||||
Moderation { moderator_id: String },
|
||||
#[strum(to_string = "user:{user_id}")]
|
||||
User { user_id: String },
|
||||
#[strum(to_string = "channelStream:{channel_id}")]
|
||||
|
@ -37,7 +42,7 @@ pub enum Stream {
|
|||
#[strum(to_string = "messagingStream:{group_id}")]
|
||||
GroupChat { group_id: String },
|
||||
#[strum(to_string = "messagingIndexStream:{user_id}")]
|
||||
MessagingIndex { user_id: String },
|
||||
ChatIndex { user_id: String },
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
|
@ -67,10 +72,7 @@ pub fn publish_to_stream(
|
|||
|
||||
redis_conn()?.publish(
|
||||
&CONFIG.host,
|
||||
format!(
|
||||
"{{ \"channel\": \"{}\", \"message\": {} }}",
|
||||
stream, message,
|
||||
),
|
||||
format!("{{\"channel\":\"{}\",\"message\":{}}}", stream, message),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
|
@ -84,10 +86,10 @@ mod unit_test {
|
|||
#[test]
|
||||
fn channel_to_string() {
|
||||
assert_eq!(Stream::Internal.to_string(), "internal");
|
||||
assert_eq!(Stream::Broadcast.to_string(), "broadcast");
|
||||
assert_eq!(Stream::CustomEmoji.to_string(), "broadcast");
|
||||
assert_eq!(
|
||||
Stream::Admin {
|
||||
user_id: "9tb42br63g5apjcq".to_string()
|
||||
Stream::Moderation {
|
||||
moderator_id: "9tb42br63g5apjcq".to_string()
|
||||
}
|
||||
.to_string(),
|
||||
"adminStream:9tb42br63g5apjcq"
|
||||
|
|
10
packages/backend-rs/src/service/stream/channel.rs
Normal file
10
packages/backend-rs/src/service/stream/channel.rs
Normal file
|
@ -0,0 +1,10 @@
|
|||
use crate::service::stream::{publish_to_stream, Error, Stream};
|
||||
|
||||
#[crate::export(js_name = "publishToChannelStream")]
|
||||
pub fn publish(channel_id: String, user_id: String) -> Result<(), Error> {
|
||||
publish_to_stream(
|
||||
&Stream::Channel { channel_id },
|
||||
Some("typing".to_string()),
|
||||
Some(format!("\"{}\"", user_id)),
|
||||
)
|
||||
}
|
|
@ -13,12 +13,15 @@ pub enum ChatEvent {
|
|||
Typing,
|
||||
}
|
||||
|
||||
// We want to merge `kind` and `object` into a single enum
|
||||
// https://github.com/napi-rs/napi-rs/issues/2036
|
||||
|
||||
#[crate::export(js_name = "publishToChatStream")]
|
||||
pub fn publish(
|
||||
sender_user_id: String,
|
||||
receiver_user_id: String,
|
||||
kind: ChatEvent,
|
||||
object: &serde_json::Value, // TODO?: change this to enum
|
||||
object: &serde_json::Value,
|
||||
) -> Result<(), Error> {
|
||||
publish_to_stream(
|
||||
&Stream::Chat {
|
||||
|
|
26
packages/backend-rs/src/service/stream/chat_index.rs
Normal file
26
packages/backend-rs/src/service/stream/chat_index.rs
Normal file
|
@ -0,0 +1,26 @@
|
|||
use crate::service::stream::{publish_to_stream, Error, Stream};
|
||||
|
||||
#[derive(strum::Display)]
|
||||
#[crate::export(string_enum = "camelCase")]
|
||||
pub enum ChatIndexEvent {
|
||||
#[strum(serialize = "message")]
|
||||
Message,
|
||||
#[strum(serialize = "read")]
|
||||
Read,
|
||||
}
|
||||
|
||||
// We want to merge `kind` and `object` into a single enum
|
||||
// https://github.com/napi-rs/napi-rs/issues/2036
|
||||
|
||||
#[crate::export(js_name = "publishToChatIndexStream")]
|
||||
pub fn publish(
|
||||
user_id: String,
|
||||
kind: ChatIndexEvent,
|
||||
object: &serde_json::Value,
|
||||
) -> Result<(), Error> {
|
||||
publish_to_stream(
|
||||
&Stream::ChatIndex { user_id },
|
||||
Some(kind.to_string()),
|
||||
Some(serde_json::to_string(object)?),
|
||||
)
|
||||
}
|
27
packages/backend-rs/src/service/stream/custom_emoji.rs
Normal file
27
packages/backend-rs/src/service/stream/custom_emoji.rs
Normal file
|
@ -0,0 +1,27 @@
|
|||
use crate::service::stream::{publish_to_stream, Error, Stream};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// TODO: define schema type in other place
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[crate::export(object)]
|
||||
pub struct PackedEmoji {
|
||||
pub id: String,
|
||||
pub aliases: Vec<String>,
|
||||
pub name: String,
|
||||
pub category: Option<String>,
|
||||
pub host: Option<String>,
|
||||
pub url: String,
|
||||
pub license: Option<String>,
|
||||
pub width: Option<i32>,
|
||||
pub height: Option<i32>,
|
||||
}
|
||||
|
||||
#[crate::export(js_name = "publishToBroadcastStream")]
|
||||
pub fn publish(emoji: &PackedEmoji) -> Result<(), Error> {
|
||||
publish_to_stream(
|
||||
&Stream::CustomEmoji,
|
||||
Some("emojiAdded".to_string()),
|
||||
Some(format!("{{\"emoji\":{}}}", serde_json::to_string(emoji)?)),
|
||||
)
|
||||
}
|
13
packages/backend-rs/src/service/stream/group_chat.rs
Normal file
13
packages/backend-rs/src/service/stream/group_chat.rs
Normal file
|
@ -0,0 +1,13 @@
|
|||
use crate::service::stream::{chat::ChatEvent, publish_to_stream, Error, Stream};
|
||||
|
||||
// We want to merge `kind` and `object` into a single enum
|
||||
// https://github.com/napi-rs/napi-rs/issues/2036
|
||||
|
||||
#[crate::export(js_name = "publishToGroupChatStream")]
|
||||
pub fn publish(group_id: String, kind: ChatEvent, object: &serde_json::Value) -> Result<(), Error> {
|
||||
publish_to_stream(
|
||||
&Stream::GroupChat { group_id },
|
||||
Some(kind.to_string()),
|
||||
Some(serde_json::to_string(object)?),
|
||||
)
|
||||
}
|
21
packages/backend-rs/src/service/stream/moderation.rs
Normal file
21
packages/backend-rs/src/service/stream/moderation.rs
Normal file
|
@ -0,0 +1,21 @@
|
|||
use crate::service::stream::{publish_to_stream, Error, Stream};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[crate::export(object)]
|
||||
pub struct AbuseUserReportLike {
|
||||
pub id: String,
|
||||
pub target_user_id: String,
|
||||
pub reporter_id: String,
|
||||
pub comment: String,
|
||||
}
|
||||
|
||||
#[crate::export(js_name = "publishToModerationStream")]
|
||||
pub fn publish(moderator_id: String, report: &AbuseUserReportLike) -> Result<(), Error> {
|
||||
publish_to_stream(
|
||||
&Stream::Moderation { moderator_id },
|
||||
Some("newAbuseUserReport".to_string()),
|
||||
Some(serde_json::to_string(report)?),
|
||||
)
|
||||
}
|
24
packages/backend-rs/src/util/http_client.rs
Normal file
24
packages/backend-rs/src/util/http_client.rs
Normal file
|
@ -0,0 +1,24 @@
|
|||
use crate::config::CONFIG;
|
||||
use once_cell::sync::OnceCell;
|
||||
use reqwest::{Client, Error, NoProxy, Proxy};
|
||||
use std::time::Duration;
|
||||
|
||||
static CLIENT: OnceCell<Client> = OnceCell::new();
|
||||
|
||||
pub fn http_client() -> Result<Client, Error> {
|
||||
CLIENT
|
||||
.get_or_try_init(|| {
|
||||
let mut builder = Client::builder().timeout(Duration::from_secs(5));
|
||||
|
||||
if let Some(proxy_url) = &CONFIG.proxy {
|
||||
let mut proxy = Proxy::all(proxy_url)?;
|
||||
if let Some(proxy_bypass_hosts) = &CONFIG.proxy_bypass_hosts {
|
||||
proxy = proxy.no_proxy(NoProxy::from_string(&proxy_bypass_hosts.join(",")));
|
||||
}
|
||||
builder = builder.proxy(proxy);
|
||||
}
|
||||
|
||||
builder.build()
|
||||
})
|
||||
.cloned()
|
||||
}
|
|
@ -1,2 +1,5 @@
|
|||
pub use http_client::http_client;
|
||||
|
||||
pub mod http_client;
|
||||
pub mod id;
|
||||
pub mod random;
|
||||
|
|
|
@ -22,21 +22,21 @@
|
|||
"@swc/core-android-arm64": "1.3.11"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bull-board/api": "5.15.5",
|
||||
"@bull-board/koa": "5.15.5",
|
||||
"@bull-board/ui": "5.15.5",
|
||||
"@bull-board/api": "5.16.0",
|
||||
"@bull-board/koa": "5.16.0",
|
||||
"@bull-board/ui": "5.16.0",
|
||||
"@discordapp/twemoji": "^15.0.3",
|
||||
"@koa/cors": "5.0.0",
|
||||
"@koa/multer": "3.0.2",
|
||||
"@koa/router": "12.0.1",
|
||||
"@ladjs/koa-views": "9.0.0",
|
||||
"@peertube/http-signature": "1.7.0",
|
||||
"@redocly/openapi-core": "1.11.0",
|
||||
"@redocly/openapi-core": "1.12.0",
|
||||
"@sinonjs/fake-timers": "11.2.2",
|
||||
"adm-zip": "0.5.10",
|
||||
"ajv": "8.12.0",
|
||||
"archiver": "7.0.1",
|
||||
"aws-sdk": "2.1599.0",
|
||||
"aws-sdk": "2.1608.0",
|
||||
"axios": "^1.6.8",
|
||||
"backend-rs": "workspace:*",
|
||||
"firefish-js": "workspace:*",
|
||||
|
@ -62,7 +62,7 @@
|
|||
"gunzip-maybe": "^1.4.2",
|
||||
"happy-dom": "^14.7.1",
|
||||
"hpagent": "1.2.0",
|
||||
"ioredis": "5.3.2",
|
||||
"ioredis": "5.4.1",
|
||||
"ip-cidr": "4.0.0",
|
||||
"is-svg": "5.0.0",
|
||||
"json5": "2.2.3",
|
||||
|
@ -126,7 +126,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@swc/cli": "0.3.12",
|
||||
"@swc/core": "1.4.13",
|
||||
"@swc/core": "1.5.0",
|
||||
"@types/adm-zip": "^0.5.5",
|
||||
"@types/color-convert": "^2.0.3",
|
||||
"@types/content-disposition": "^0.5.8",
|
||||
|
@ -156,7 +156,7 @@
|
|||
"@types/pug": "2.0.10",
|
||||
"@types/punycode": "2.1.4",
|
||||
"@types/qrcode": "1.5.5",
|
||||
"@types/qs": "6.9.14",
|
||||
"@types/qs": "6.9.15",
|
||||
"@types/random-seed": "0.3.5",
|
||||
"@types/ratelimiter": "3.4.6",
|
||||
"@types/rename": "1.0.7",
|
||||
|
@ -171,7 +171,7 @@
|
|||
"@types/websocket": "1.0.10",
|
||||
"@types/ws": "8.5.10",
|
||||
"cross-env": "7.0.3",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint": "^9.1.1",
|
||||
"mocha": "10.4.0",
|
||||
"pug": "3.0.2",
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
|
@ -179,7 +179,7 @@
|
|||
"ts-loader": "9.5.1",
|
||||
"ts-node": "10.9.2",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"type-fest": "4.15.0",
|
||||
"type-fest": "4.17.0",
|
||||
"typescript": "5.4.5",
|
||||
"webpack": "^5.91.0",
|
||||
"ws": "8.16.0"
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
import type { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AlterAkaType1714099399879 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user" RENAME COLUMN "alsoKnownAs" TO "alsoKnownAsOld"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user" ADD COLUMN "alsoKnownAs" character varying(512)[]`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE "user" SET "alsoKnownAs" = string_to_array("alsoKnownAsOld", ',')::character varying[]`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE "user" SET "alsoKnownAs" = NULL WHERE "alsoKnownAs" = '{}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`COMMENT ON COLUMN "user"."alsoKnownAs" IS 'URIs the user is known as too'`,
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "alsoKnownAsOld"`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user" RENAME COLUMN "alsoKnownAs" TO "alsoKnownAsOld"`,
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "user" ADD COLUMN "alsoKnownAs" text`);
|
||||
await queryRunner.query(
|
||||
`UPDATE "user" SET "alsoKnownAs" = array_to_string("alsoKnownAsOld", ',')`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`COMMENT ON COLUMN "user"."alsoKnownAs" IS 'URIs the user is known as too'`,
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "alsoKnownAsOld"`);
|
||||
}
|
||||
}
|
|
@ -1,59 +0,0 @@
|
|||
import probeImageSize from "probe-image-size";
|
||||
import { Mutex } from "redis-semaphore";
|
||||
|
||||
import { FILE_TYPE_BROWSERSAFE } from "backend-rs";
|
||||
import Logger from "@/services/logger.js";
|
||||
import { Cache } from "./cache.js";
|
||||
import { redisClient } from "@/db/redis.js";
|
||||
import { inspect } from "node:util";
|
||||
|
||||
export type Size = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
const cache = new Cache<boolean>("emojiMeta", 60 * 10); // once every 10 minutes for the same url
|
||||
const logger = new Logger("emoji");
|
||||
|
||||
export async function getEmojiSize(url: string): Promise<Size> {
|
||||
let attempted = true;
|
||||
|
||||
const lock = new Mutex(redisClient, "getEmojiSize");
|
||||
await lock.acquire();
|
||||
|
||||
try {
|
||||
attempted = (await cache.get(url)) === true;
|
||||
if (!attempted) {
|
||||
await cache.set(url, true);
|
||||
}
|
||||
} finally {
|
||||
await lock.release();
|
||||
}
|
||||
|
||||
if (attempted) {
|
||||
logger.warn(`Attempt limit exceeded: ${url}`);
|
||||
throw new Error("Too many attempts");
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug(`Retrieving emoji size from ${url}`);
|
||||
const { width, height, mime } = await probeImageSize(url, {
|
||||
timeout: 5000,
|
||||
});
|
||||
if (!(mime.startsWith("image/") && FILE_TYPE_BROWSERSAFE.includes(mime))) {
|
||||
throw new Error("Unsupported image type");
|
||||
}
|
||||
return { width, height };
|
||||
} catch (e) {
|
||||
throw new Error(`Unable to retrieve metadata:\n${inspect(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function getNormalSize(
|
||||
{ width, height }: Size,
|
||||
orientation?: number,
|
||||
): Size {
|
||||
return (orientation || 0) >= 5
|
||||
? { width: height, height: width }
|
||||
: { width, height };
|
||||
}
|
|
@ -88,7 +88,9 @@ export class User {
|
|||
})
|
||||
public movedToUri: string | null;
|
||||
|
||||
@Column("simple-array", {
|
||||
@Column("varchar", {
|
||||
length: 512,
|
||||
array: true,
|
||||
nullable: true,
|
||||
comment: "URIs the user is known as too",
|
||||
})
|
||||
|
|
|
@ -3,7 +3,7 @@ import { IsNull } from "typeorm";
|
|||
import { Emojis } from "@/models/index.js";
|
||||
|
||||
import { queueLogger } from "../../logger.js";
|
||||
import { getEmojiSize } from "@/misc/emoji-meta.js";
|
||||
import { getImageSizeFromUrl } from "backend-rs";
|
||||
import { inspect } from "node:util";
|
||||
|
||||
const logger = queueLogger.createSubLogger("local-emoji-size");
|
||||
|
@ -21,7 +21,7 @@ export async function setLocalEmojiSizes(
|
|||
|
||||
for (let i = 0; i < emojis.length; i++) {
|
||||
try {
|
||||
const size = await getEmojiSize(emojis[i].publicUrl);
|
||||
const size = await getImageSizeFromUrl(emojis[i].publicUrl);
|
||||
await Emojis.update(emojis[i].id, {
|
||||
width: size.width || null,
|
||||
height: size.height || null,
|
||||
|
|
|
@ -13,7 +13,13 @@ import { extractPollFromQuestion } from "./question.js";
|
|||
import vote from "@/services/note/polls/vote.js";
|
||||
import { apLogger } from "../logger.js";
|
||||
import type { DriveFile } from "@/models/entities/drive-file.js";
|
||||
import { extractHost, isSameOrigin, toPuny } from "backend-rs";
|
||||
import {
|
||||
type ImageSize,
|
||||
extractHost,
|
||||
getImageSizeFromUrl,
|
||||
isSameOrigin,
|
||||
toPuny,
|
||||
} from "backend-rs";
|
||||
import {
|
||||
Emojis,
|
||||
Polls,
|
||||
|
@ -46,7 +52,6 @@ import { UserProfiles } from "@/models/index.js";
|
|||
import { In } from "typeorm";
|
||||
import { config } from "@/config.js";
|
||||
import { truncate } from "@/misc/truncate.js";
|
||||
import { type Size, getEmojiSize } from "@/misc/emoji-meta.js";
|
||||
import { langmap } from "@/misc/langmap.js";
|
||||
import { inspect } from "node:util";
|
||||
|
||||
|
@ -488,11 +493,16 @@ export async function extractEmojis(
|
|||
tag.icon!.url !== exists.originalUrl ||
|
||||
!(exists.width && exists.height)
|
||||
) {
|
||||
let size: Size = { width: 0, height: 0 };
|
||||
let size: ImageSize | null = null;
|
||||
if (tag.icon?.url != null) {
|
||||
try {
|
||||
size = await getEmojiSize(tag.icon!.url);
|
||||
} catch {
|
||||
/* skip if any error happens */
|
||||
size = await getImageSizeFromUrl(tag.icon.url);
|
||||
} catch (err) {
|
||||
apLogger.info(
|
||||
`Failed to determine the size of the image: ${tag.icon.url}`,
|
||||
);
|
||||
apLogger.debug(inspect(err));
|
||||
}
|
||||
}
|
||||
await Emojis.update(
|
||||
{
|
||||
|
@ -504,8 +514,8 @@ export async function extractEmojis(
|
|||
originalUrl: tag.icon!.url,
|
||||
publicUrl: tag.icon!.url,
|
||||
updatedAt: new Date(),
|
||||
width: size.width || null,
|
||||
height: size.height || null,
|
||||
width: size?.width || null,
|
||||
height: size?.height || null,
|
||||
},
|
||||
);
|
||||
|
||||
|
@ -520,9 +530,9 @@ export async function extractEmojis(
|
|||
|
||||
apLogger.info(`register emoji host=${host}, name=${name}`);
|
||||
|
||||
let size: Size = { width: 0, height: 0 };
|
||||
let size: ImageSize = { width: 0, height: 0 };
|
||||
try {
|
||||
size = await getEmojiSize(tag.icon!.url);
|
||||
size = await getImageSizeFromUrl(tag.icon!.url);
|
||||
} catch {
|
||||
/* skip if any error happens */
|
||||
}
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import { publishMainStream } from "@/services/stream.js";
|
||||
import {
|
||||
publishMainStream,
|
||||
publishGroupMessagingStream,
|
||||
} from "@/services/stream.js";
|
||||
import { publishToChatStream, ChatEvent } from "backend-rs";
|
||||
import { publishMessagingIndexStream } from "@/services/stream.js";
|
||||
publishToChatStream,
|
||||
publishToGroupChatStream,
|
||||
publishToChatIndexStream,
|
||||
ChatEvent,
|
||||
ChatIndexEvent,
|
||||
} from "backend-rs";
|
||||
import { pushNotification } from "@/services/push-notification.js";
|
||||
import type { User, IRemoteUser } from "@/models/entities/user.js";
|
||||
import type { MessagingMessage } from "@/models/entities/messaging-message.js";
|
||||
|
@ -55,7 +57,7 @@ export async function readUserMessagingMessage(
|
|||
|
||||
// Publish event
|
||||
publishToChatStream(otherpartyId, userId, ChatEvent.Read, messageIds);
|
||||
publishMessagingIndexStream(userId, "read", messageIds);
|
||||
publishToChatIndexStream(userId, ChatIndexEvent.Read, messageIds);
|
||||
|
||||
if (!(await Users.getHasUnreadMessagingMessage(userId))) {
|
||||
// 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行
|
||||
|
@ -126,11 +128,11 @@ export async function readGroupMessagingMessage(
|
|||
}
|
||||
|
||||
// Publish event
|
||||
publishGroupMessagingStream(groupId, "read", {
|
||||
publishToGroupChatStream(groupId, ChatEvent.Read, {
|
||||
ids: reads,
|
||||
userId: userId,
|
||||
userId,
|
||||
});
|
||||
publishMessagingIndexStream(userId, "read", reads);
|
||||
publishToChatIndexStream(userId, ChatIndexEvent.Read, reads);
|
||||
|
||||
if (!(await Users.getHasUnreadMessagingMessage(userId))) {
|
||||
// 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行
|
||||
|
|
|
@ -1,12 +1,17 @@
|
|||
import define from "@/server/api/define.js";
|
||||
import { Emojis, DriveFiles } from "@/models/index.js";
|
||||
import { genId } from "backend-rs";
|
||||
import {
|
||||
type ImageSize,
|
||||
genId,
|
||||
getImageSizeFromUrl,
|
||||
publishToBroadcastStream,
|
||||
} from "backend-rs";
|
||||
import { insertModerationLog } from "@/services/insert-moderation-log.js";
|
||||
import { ApiError } from "@/server/api/error.js";
|
||||
import rndstr from "rndstr";
|
||||
import { publishBroadcastStream } from "@/services/stream.js";
|
||||
import { db } from "@/db/postgre.js";
|
||||
import { getEmojiSize } from "@/misc/emoji-meta.js";
|
||||
import { apiLogger } from "@/server/api/logger.js";
|
||||
import { inspect } from "node:util";
|
||||
|
||||
export const meta = {
|
||||
tags: ["admin", "emoji"],
|
||||
|
@ -49,7 +54,13 @@ export default define(meta, paramDef, async (ps, me) => {
|
|||
? file.name.split(".")[0]
|
||||
: `_${rndstr("a-z0-9", 8)}_`;
|
||||
|
||||
const size = await getEmojiSize(file.url);
|
||||
let size: ImageSize | null = null;
|
||||
try {
|
||||
size = await getImageSizeFromUrl(file.url);
|
||||
} catch (err) {
|
||||
apiLogger.info(`Failed to determine the image size: ${file.url}`);
|
||||
apiLogger.debug(inspect(err));
|
||||
}
|
||||
|
||||
const emoji = await Emojis.insert({
|
||||
id: genId(),
|
||||
|
@ -62,15 +73,13 @@ export default define(meta, paramDef, async (ps, me) => {
|
|||
publicUrl: file.webpublicUrl ?? file.url,
|
||||
type: file.webpublicType ?? file.type,
|
||||
license: null,
|
||||
width: size.width || null,
|
||||
height: size.height || null,
|
||||
width: size?.width || null,
|
||||
height: size?.height || null,
|
||||
}).then((x) => Emojis.findOneByOrFail(x.identifiers[0]));
|
||||
|
||||
await db.queryResultCache!.remove(["meta_emojis"]);
|
||||
|
||||
publishBroadcastStream("emojiAdded", {
|
||||
emoji: await Emojis.pack(emoji.id),
|
||||
});
|
||||
publishToBroadcastStream(await Emojis.pack(emoji));
|
||||
|
||||
insertModerationLog(me, "addEmoji", {
|
||||
emojiId: emoji.id,
|
||||
|
|
|
@ -1,12 +1,17 @@
|
|||
import define from "@/server/api/define.js";
|
||||
import { Emojis } from "@/models/index.js";
|
||||
import { genId } from "backend-rs";
|
||||
import {
|
||||
type ImageSize,
|
||||
genId,
|
||||
getImageSizeFromUrl,
|
||||
publishToBroadcastStream,
|
||||
} from "backend-rs";
|
||||
import { ApiError } from "@/server/api/error.js";
|
||||
import type { DriveFile } from "@/models/entities/drive-file.js";
|
||||
import { uploadFromUrl } from "@/services/drive/upload-from-url.js";
|
||||
import { publishBroadcastStream } from "@/services/stream.js";
|
||||
import { db } from "@/db/postgre.js";
|
||||
import { getEmojiSize } from "@/misc/emoji-meta.js";
|
||||
import { apiLogger } from "@/server/api/logger.js";
|
||||
import { inspect } from "node:util";
|
||||
|
||||
export const meta = {
|
||||
tags: ["admin", "emoji"],
|
||||
|
@ -76,7 +81,14 @@ export default define(meta, paramDef, async (ps, me) => {
|
|||
throw new ApiError();
|
||||
}
|
||||
|
||||
const size = await getEmojiSize(driveFile.url);
|
||||
let size: ImageSize | null = null;
|
||||
|
||||
try {
|
||||
size = await getImageSizeFromUrl(driveFile.url);
|
||||
} catch (err) {
|
||||
apiLogger.info(`Failed to determine the image size: ${driveFile.url}`);
|
||||
apiLogger.debug(inspect(err));
|
||||
}
|
||||
|
||||
const copied = await Emojis.insert({
|
||||
id: genId(),
|
||||
|
@ -88,15 +100,13 @@ export default define(meta, paramDef, async (ps, me) => {
|
|||
publicUrl: driveFile.webpublicUrl ?? driveFile.url,
|
||||
type: driveFile.webpublicType ?? driveFile.type,
|
||||
license: emoji.license,
|
||||
width: size.width || null,
|
||||
height: size.height || null,
|
||||
width: size?.width ?? null,
|
||||
height: size?.height ?? null,
|
||||
}).then((x) => Emojis.findOneByOrFail(x.identifiers[0]));
|
||||
|
||||
await db.queryResultCache!.remove(["meta_emojis"]);
|
||||
|
||||
publishBroadcastStream("emojiAdded", {
|
||||
emoji: await Emojis.pack(copied.id),
|
||||
});
|
||||
publishToBroadcastStream(await Emojis.pack(copied));
|
||||
|
||||
return {
|
||||
id: copied.id,
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
import * as mfm from "mfm-js";
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
import { publishAdminStream } from "@/services/stream.js";
|
||||
import { AbuseUserReports, UserProfiles, Users } from "@/models/index.js";
|
||||
import { genId } from "backend-rs";
|
||||
import { genId, publishToModerationStream } from "backend-rs";
|
||||
import { sendEmail } from "@/services/send-email.js";
|
||||
import { fetchMeta } from "backend-rs";
|
||||
import { getUser } from "@/server/api/common/getters.js";
|
||||
import { ApiError } from "@/server/api/error.js";
|
||||
import define from "@/server/api/define.js";
|
||||
|
@ -86,9 +84,8 @@ export default define(meta, paramDef, async (ps, me) => {
|
|||
],
|
||||
});
|
||||
|
||||
const meta = await fetchMeta(true);
|
||||
for (const moderator of moderators) {
|
||||
publishAdminStream(moderator.id, "newAbuseUserReport", {
|
||||
publishToModerationStream(moderator.id, {
|
||||
id: report.id,
|
||||
targetUserId: report.targetUserId,
|
||||
reporterId: report.reporterId,
|
||||
|
|
|
@ -15,10 +15,11 @@ import {
|
|||
import type { AccessToken } from "@/models/entities/access-token.js";
|
||||
import type { UserProfile } from "@/models/entities/user-profile.js";
|
||||
import {
|
||||
publishChannelStream,
|
||||
publishGroupMessagingStream,
|
||||
} from "@/services/stream.js";
|
||||
import { publishToChatStream, ChatEvent } from "backend-rs";
|
||||
publishToChannelStream,
|
||||
publishToChatStream,
|
||||
publishToGroupChatStream,
|
||||
ChatEvent,
|
||||
} from "backend-rs";
|
||||
import type { UserGroup } from "@/models/entities/user-group.js";
|
||||
import type { Packed } from "@/misc/schema.js";
|
||||
import { readNotification } from "@/server/api/common/read-notification.js";
|
||||
|
@ -512,9 +513,9 @@ export default class Connection {
|
|||
}
|
||||
}
|
||||
|
||||
private typingOnChannel(channel: ChannelModel["id"]) {
|
||||
private typingOnChannel(channelId: ChannelModel["id"]) {
|
||||
if (this.user) {
|
||||
publishChannelStream(channel, "typing", this.user.id);
|
||||
publishToChannelStream(channelId, this.user.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -530,8 +531,8 @@ export default class Connection {
|
|||
ChatEvent.Typing,
|
||||
this.user.id,
|
||||
);
|
||||
} else if (param.group) {
|
||||
publishGroupMessagingStream(param.group, "typing", this.user.id);
|
||||
} else if (param.group != null) {
|
||||
publishToGroupChatStream(param.group, ChatEvent.Typing, this.user.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -47,8 +47,8 @@ export default class Logger {
|
|||
return logger;
|
||||
}
|
||||
|
||||
private showThisLog(logLevel: Level, configLevel: string) {
|
||||
switch (configLevel) {
|
||||
private showThisLog(logLevel: Level, configMaxLevel: string) {
|
||||
switch (configMaxLevel) {
|
||||
case "error":
|
||||
return ["error"].includes(logLevel);
|
||||
case "warning":
|
||||
|
@ -75,7 +75,10 @@ export default class Logger {
|
|||
if (
|
||||
(config.maxLogLevel != null &&
|
||||
!this.showThisLog(level, config.maxLogLevel)) ||
|
||||
(config.logLevel != null && !config.logLevel.includes(level))
|
||||
(config.logLevel != null && !config.logLevel.includes(level)) ||
|
||||
(config.maxLogLevel == null &&
|
||||
config.logLevel == null &&
|
||||
!this.showThisLog(level, "info"))
|
||||
)
|
||||
return;
|
||||
if (!this.store) store = false;
|
||||
|
|
|
@ -7,13 +7,17 @@ import {
|
|||
Mutings,
|
||||
Users,
|
||||
} from "@/models/index.js";
|
||||
import { genId, publishToChatStream, toPuny, ChatEvent } from "backend-rs";
|
||||
import type { MessagingMessage } from "@/models/entities/messaging-message.js";
|
||||
import {
|
||||
publishMessagingIndexStream,
|
||||
publishMainStream,
|
||||
publishGroupMessagingStream,
|
||||
} from "@/services/stream.js";
|
||||
genId,
|
||||
publishToChatStream,
|
||||
publishToGroupChatStream,
|
||||
publishToChatIndexStream,
|
||||
toPuny,
|
||||
ChatEvent,
|
||||
ChatIndexEvent,
|
||||
} from "backend-rs";
|
||||
import type { MessagingMessage } from "@/models/entities/messaging-message.js";
|
||||
import { publishMainStream } from "@/services/stream.js";
|
||||
import { pushNotification } from "@/services/push-notification.js";
|
||||
import { Not } from "typeorm";
|
||||
import type { Note } from "@/models/entities/note.js";
|
||||
|
@ -57,7 +61,11 @@ export async function createMessage(
|
|||
ChatEvent.Message,
|
||||
messageObj,
|
||||
);
|
||||
publishMessagingIndexStream(message.userId, "message", messageObj);
|
||||
publishToChatIndexStream(
|
||||
message.userId,
|
||||
ChatIndexEvent.Message,
|
||||
messageObj,
|
||||
);
|
||||
publishMainStream(message.userId, "messagingMessage", messageObj);
|
||||
}
|
||||
|
||||
|
@ -69,19 +77,27 @@ export async function createMessage(
|
|||
ChatEvent.Message,
|
||||
messageObj,
|
||||
);
|
||||
publishMessagingIndexStream(recipientUser.id, "message", messageObj);
|
||||
publishToChatIndexStream(
|
||||
recipientUser.id,
|
||||
ChatIndexEvent.Message,
|
||||
messageObj,
|
||||
);
|
||||
publishMainStream(recipientUser.id, "messagingMessage", messageObj);
|
||||
}
|
||||
} else if (recipientGroup) {
|
||||
// グループのストリーム
|
||||
publishGroupMessagingStream(recipientGroup.id, "message", messageObj);
|
||||
} else if (recipientGroup != null) {
|
||||
// group's stream
|
||||
publishToGroupChatStream(recipientGroup.id, ChatEvent.Message, messageObj);
|
||||
|
||||
// メンバーのストリーム
|
||||
// member's stream
|
||||
const joinings = await UserGroupJoinings.findBy({
|
||||
userGroupId: recipientGroup.id,
|
||||
});
|
||||
for (const joining of joinings) {
|
||||
publishMessagingIndexStream(joining.userId, "message", messageObj);
|
||||
publishToChatIndexStream(
|
||||
joining.userId,
|
||||
ChatIndexEvent.Message,
|
||||
messageObj,
|
||||
);
|
||||
publishMainStream(joining.userId, "messagingMessage", messageObj);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
import { config } from "@/config.js";
|
||||
import { MessagingMessages, Users } from "@/models/index.js";
|
||||
import type { MessagingMessage } from "@/models/entities/messaging-message.js";
|
||||
import { publishGroupMessagingStream } from "@/services/stream.js";
|
||||
import { publishToChatStream, ChatEvent } from "backend-rs";
|
||||
import {
|
||||
publishToChatStream,
|
||||
publishToGroupChatStream,
|
||||
ChatEvent,
|
||||
} from "backend-rs";
|
||||
import { renderActivity } from "@/remote/activitypub/renderer/index.js";
|
||||
import renderDelete from "@/remote/activitypub/renderer/delete.js";
|
||||
import renderTombstone from "@/remote/activitypub/renderer/tombstone.js";
|
||||
|
@ -42,7 +45,7 @@ async function postDeleteMessage(message: MessagingMessage) {
|
|||
);
|
||||
deliver(user, activity, recipient.inbox);
|
||||
}
|
||||
} else if (message.groupId) {
|
||||
publishGroupMessagingStream(message.groupId, "deleted", message.id);
|
||||
} else if (message.groupId != null) {
|
||||
publishToGroupChatStream(message.groupId, ChatEvent.Deleted, message.id);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,21 +2,21 @@ import { redisClient } from "@/db/redis.js";
|
|||
import type { User } from "@/models/entities/user.js";
|
||||
import type { Note } from "@/models/entities/note.js";
|
||||
import type { UserList } from "@/models/entities/user-list.js";
|
||||
import type { UserGroup } from "@/models/entities/user-group.js";
|
||||
// import type { UserGroup } from "@/models/entities/user-group.js";
|
||||
import { config } from "@/config.js";
|
||||
// import type { Antenna } from "@/models/entities/antenna.js";
|
||||
import type { Channel } from "@/models/entities/channel.js";
|
||||
// import type { Channel } from "@/models/entities/channel.js";
|
||||
import type {
|
||||
StreamChannels,
|
||||
AdminStreamTypes,
|
||||
// AdminStreamTypes,
|
||||
// AntennaStreamTypes,
|
||||
BroadcastTypes,
|
||||
ChannelStreamTypes,
|
||||
// BroadcastTypes,
|
||||
// ChannelStreamTypes,
|
||||
DriveStreamTypes,
|
||||
GroupMessagingStreamTypes,
|
||||
// GroupMessagingStreamTypes,
|
||||
InternalStreamTypes,
|
||||
MainStreamTypes,
|
||||
MessagingIndexStreamTypes,
|
||||
// MessagingIndexStreamTypes,
|
||||
// MessagingStreamTypes,
|
||||
NoteStreamTypes,
|
||||
UserListStreamTypes,
|
||||
|
@ -64,16 +64,17 @@ class Publisher {
|
|||
);
|
||||
};
|
||||
|
||||
public publishBroadcastStream = <K extends keyof BroadcastTypes>(
|
||||
type: K,
|
||||
value?: BroadcastTypes[K],
|
||||
): void => {
|
||||
this.publish(
|
||||
"broadcast",
|
||||
type,
|
||||
typeof value === "undefined" ? null : value,
|
||||
);
|
||||
};
|
||||
/* ported to backend-rs */
|
||||
// public publishBroadcastStream = <K extends keyof BroadcastTypes>(
|
||||
// type: K,
|
||||
// value?: BroadcastTypes[K],
|
||||
// ): void => {
|
||||
// this.publish(
|
||||
// "broadcast",
|
||||
// type,
|
||||
// typeof value === "undefined" ? null : value,
|
||||
// );
|
||||
// };
|
||||
|
||||
public publishMainStream = <K extends keyof MainStreamTypes>(
|
||||
userId: User["id"],
|
||||
|
@ -110,17 +111,18 @@ class Publisher {
|
|||
});
|
||||
};
|
||||
|
||||
public publishChannelStream = <K extends keyof ChannelStreamTypes>(
|
||||
channelId: Channel["id"],
|
||||
type: K,
|
||||
value?: ChannelStreamTypes[K],
|
||||
): void => {
|
||||
this.publish(
|
||||
`channelStream:${channelId}`,
|
||||
type,
|
||||
typeof value === "undefined" ? null : value,
|
||||
);
|
||||
};
|
||||
/* ported to backend-rs */
|
||||
// public publishChannelStream = <K extends keyof ChannelStreamTypes>(
|
||||
// channelId: Channel["id"],
|
||||
// type: K,
|
||||
// value?: ChannelStreamTypes[K],
|
||||
// ): void => {
|
||||
// this.publish(
|
||||
// `channelStream:${channelId}`,
|
||||
// type,
|
||||
// typeof value === "undefined" ? null : value,
|
||||
// );
|
||||
// };
|
||||
|
||||
public publishUserListStream = <K extends keyof UserListStreamTypes>(
|
||||
listId: UserList["id"],
|
||||
|
@ -161,49 +163,52 @@ class Publisher {
|
|||
// );
|
||||
// };
|
||||
|
||||
public publishGroupMessagingStream = <
|
||||
K extends keyof GroupMessagingStreamTypes,
|
||||
>(
|
||||
groupId: UserGroup["id"],
|
||||
type: K,
|
||||
value?: GroupMessagingStreamTypes[K],
|
||||
): void => {
|
||||
this.publish(
|
||||
`messagingStream:${groupId}`,
|
||||
type,
|
||||
typeof value === "undefined" ? null : value,
|
||||
);
|
||||
};
|
||||
/* ported to backend-rs */
|
||||
// public publishGroupMessagingStream = <
|
||||
// K extends keyof GroupMessagingStreamTypes,
|
||||
// >(
|
||||
// groupId: UserGroup["id"],
|
||||
// type: K,
|
||||
// value?: GroupMessagingStreamTypes[K],
|
||||
// ): void => {
|
||||
// this.publish(
|
||||
// `messagingStream:${groupId}`,
|
||||
// type,
|
||||
// typeof value === "undefined" ? null : value,
|
||||
// );
|
||||
// };
|
||||
|
||||
public publishMessagingIndexStream = <
|
||||
K extends keyof MessagingIndexStreamTypes,
|
||||
>(
|
||||
userId: User["id"],
|
||||
type: K,
|
||||
value?: MessagingIndexStreamTypes[K],
|
||||
): void => {
|
||||
this.publish(
|
||||
`messagingIndexStream:${userId}`,
|
||||
type,
|
||||
typeof value === "undefined" ? null : value,
|
||||
);
|
||||
};
|
||||
/* ported to backend-rs */
|
||||
// public publishMessagingIndexStream = <
|
||||
// K extends keyof MessagingIndexStreamTypes,
|
||||
// >(
|
||||
// userId: User["id"],
|
||||
// type: K,
|
||||
// value?: MessagingIndexStreamTypes[K],
|
||||
// ): void => {
|
||||
// this.publish(
|
||||
// `messagingIndexStream:${userId}`,
|
||||
// type,
|
||||
// typeof value === "undefined" ? null : value,
|
||||
// );
|
||||
// };
|
||||
|
||||
public publishNotesStream = (note: Note): void => {
|
||||
this.publish("notesStream", null, note);
|
||||
};
|
||||
|
||||
public publishAdminStream = <K extends keyof AdminStreamTypes>(
|
||||
userId: User["id"],
|
||||
type: K,
|
||||
value?: AdminStreamTypes[K],
|
||||
): void => {
|
||||
this.publish(
|
||||
`adminStream:${userId}`,
|
||||
type,
|
||||
typeof value === "undefined" ? null : value,
|
||||
);
|
||||
};
|
||||
/* ported to backend-rs */
|
||||
// public publishAdminStream = <K extends keyof AdminStreamTypes>(
|
||||
// userId: User["id"],
|
||||
// type: K,
|
||||
// value?: AdminStreamTypes[K],
|
||||
// ): void => {
|
||||
// this.publish(
|
||||
// `adminStream:${userId}`,
|
||||
// type,
|
||||
// typeof value === "undefined" ? null : value,
|
||||
// );
|
||||
// };
|
||||
}
|
||||
|
||||
const publisher = new Publisher();
|
||||
|
@ -212,17 +217,15 @@ export default publisher;
|
|||
|
||||
export const publishInternalEvent = publisher.publishInternalEvent;
|
||||
export const publishUserEvent = publisher.publishUserEvent;
|
||||
export const publishBroadcastStream = publisher.publishBroadcastStream;
|
||||
// export const publishBroadcastStream = publisher.publishBroadcastStream;
|
||||
export const publishMainStream = publisher.publishMainStream;
|
||||
export const publishDriveStream = publisher.publishDriveStream;
|
||||
export const publishNoteStream = publisher.publishNoteStream;
|
||||
export const publishNotesStream = publisher.publishNotesStream;
|
||||
export const publishChannelStream = publisher.publishChannelStream;
|
||||
// export const publishChannelStream = publisher.publishChannelStream;
|
||||
export const publishUserListStream = publisher.publishUserListStream;
|
||||
// export const publishAntennaStream = publisher.publishAntennaStream;
|
||||
// export const publishMessagingStream = publisher.publishMessagingStream;
|
||||
export const publishGroupMessagingStream =
|
||||
publisher.publishGroupMessagingStream;
|
||||
export const publishMessagingIndexStream =
|
||||
publisher.publishMessagingIndexStream;
|
||||
export const publishAdminStream = publisher.publishAdminStream;
|
||||
// export const publishGroupMessagingStream = publisher.publishGroupMessagingStream;
|
||||
// export const publishMessagingIndexStream = publisher.publishMessagingIndexStream;
|
||||
// export const publishAdminStream = publisher.publishAdminStream;
|
||||
|
|
|
@ -28,13 +28,14 @@
|
|||
"@types/matter-js": "0.19.6",
|
||||
"@types/prismjs": "^1.26.3",
|
||||
"@types/punycode": "2.1.4",
|
||||
"@types/qrcode": "1.5.5",
|
||||
"@types/seedrandom": "3.0.8",
|
||||
"@types/textarea-caret": "^3.0.3",
|
||||
"@types/throttle-debounce": "5.0.2",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
"@types/uuid": "9.0.8",
|
||||
"@vitejs/plugin-vue": "5.0.4",
|
||||
"@vue/runtime-core": "3.4.21",
|
||||
"@vue/runtime-core": "3.4.25",
|
||||
"autobind-decorator": "2.4.0",
|
||||
"autosize": "6.0.1",
|
||||
"broadcast-channel": "7.0.0",
|
||||
|
@ -60,14 +61,18 @@
|
|||
"insert-text-at-cursor": "0.3.0",
|
||||
"json5": "2.2.3",
|
||||
"katex": "0.16.10",
|
||||
"long": "^5.2.3",
|
||||
"libopenmpt-wasm": "github:TheEssem/libopenmpt-packaging#build",
|
||||
"matter-js": "0.19.0",
|
||||
"mfm-js": "0.24.0",
|
||||
"multer": "1.4.5-lts.1",
|
||||
"moment": "2.30.1",
|
||||
"photoswipe": "5.4.3",
|
||||
"prismjs": "1.29.0",
|
||||
"punycode": "2.3.1",
|
||||
"rollup": "4.14.2",
|
||||
"qrcode": "1.5.3",
|
||||
"qrcode-vue3": "^1.6.8",
|
||||
"rollup": "4.16.4",
|
||||
"s-age": "1.1.2",
|
||||
"sass": "1.75.0",
|
||||
"seedrandom": "3.0.5",
|
||||
|
@ -75,19 +80,19 @@
|
|||
"swiper": "11.1.1",
|
||||
"syuilo-password-strength": "0.0.1",
|
||||
"textarea-caret": "3.1.0",
|
||||
"three": "0.163.0",
|
||||
"three": "0.164.1",
|
||||
"throttle-debounce": "5.0.0",
|
||||
"tinycolor2": "1.6.0",
|
||||
"tinyld": "^1.3.4",
|
||||
"typescript": "5.4.5",
|
||||
"unicode-emoji-json": "^0.6.0",
|
||||
"uuid": "9.0.1",
|
||||
"vite": "5.2.8",
|
||||
"vite": "5.2.10",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vue": "3.4.21",
|
||||
"vue": "3.4.25",
|
||||
"vue-draggable-plus": "^0.4.0",
|
||||
"vue-plyr": "^7.0.0",
|
||||
"vue-prism-editor": "2.0.0-alpha.2",
|
||||
"vue-tsc": "2.0.13"
|
||||
"vue-tsc": "2.0.14"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -113,6 +113,7 @@
|
|||
:detailed="true"
|
||||
:detailed-view="detailedView"
|
||||
:parent-id="appearNote.id"
|
||||
:is-long-judger="isLongJudger"
|
||||
@push="(e) => router.push(notePage(e))"
|
||||
@focusfooter="footerEl!.focus()"
|
||||
@expanded="(e) => setPostExpanded(e)"
|
||||
|
@ -325,6 +326,7 @@ const props = defineProps<{
|
|||
collapsedReply?: boolean;
|
||||
hideFooter?: boolean;
|
||||
hideEmojiViewer?: boolean;
|
||||
isLongJudger?: (note: entities.Note) => boolean;
|
||||
}>();
|
||||
|
||||
const inChannel = inject("inChannel", null);
|
||||
|
|
|
@ -26,7 +26,6 @@
|
|||
: notification.reaction
|
||||
"
|
||||
:custom-emojis="notification.note.emojis"
|
||||
:no-style="true"
|
||||
/>
|
||||
<XReactionIcon
|
||||
v-else-if="
|
||||
|
@ -60,18 +59,20 @@
|
|||
class="content"
|
||||
:note="removeReplyTo(notification.note.renote)"
|
||||
:hide-emoji-viewer="true"
|
||||
:is-long-judger="isLongJudger"
|
||||
/>
|
||||
<XNote
|
||||
v-else
|
||||
class="content"
|
||||
:note="removeReplyTo(notification.note)"
|
||||
:hide-emoji-viewer="true"
|
||||
:is-long-judger="isLongJudger"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, onUnmounted, ref } from "vue";
|
||||
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 XReactionIcon from "@/components/MkReactionIcon.vue";
|
||||
|
@ -114,12 +115,23 @@ const defaultReaction = ["⭐", "👍", "❤️"].includes(instance.defaultReact
|
|||
? instance.defaultReaction
|
||||
: "⭐";
|
||||
|
||||
const users = ref(props.notification.users.slice(0, 5));
|
||||
const userleft = ref(props.notification.users.length - users.value.length);
|
||||
const users = computed(() => props.notification.users.slice(0, 5));
|
||||
const userleft = computed(
|
||||
() => props.notification.users.length - users.value.length,
|
||||
);
|
||||
|
||||
let readObserver: IntersectionObserver | undefined;
|
||||
let connection: Connection<Channels["main"]> | null = null;
|
||||
|
||||
function isLongJudger(note: entities.Note) {
|
||||
return (
|
||||
note.text != null &&
|
||||
(note.text.split("\n").length > 5 ||
|
||||
note.text.length > 300 ||
|
||||
note.files.length > 4)
|
||||
);
|
||||
}
|
||||
|
||||
function getText() {
|
||||
let res = "";
|
||||
switch (props.notification.type) {
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
<template>
|
||||
<MkPagination ref="pagingComponent" :pagination="pagination">
|
||||
<MkPagination
|
||||
ref="pagingComponent"
|
||||
:pagination="pagination"
|
||||
:folder="convertNotification"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="_fullinfo">
|
||||
<img
|
||||
|
@ -11,9 +15,9 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<template #default="{ items: notifications }">
|
||||
<template #default="{ foldedItems: notifications }">
|
||||
<XList
|
||||
:items="convertNotification(notifications)"
|
||||
:items="notifications"
|
||||
v-slot="{ item: notification }"
|
||||
class="elsfgstc"
|
||||
:no-gap="true"
|
||||
|
@ -92,7 +96,7 @@ const pagination = Object.assign(
|
|||
},
|
||||
shouldFold
|
||||
? {
|
||||
limit: FETCH_LIMIT,
|
||||
limit: 50,
|
||||
secondFetchLimit: FETCH_LIMIT,
|
||||
}
|
||||
: {
|
||||
|
@ -134,11 +138,11 @@ const onNotification = (notification: entities.Notification) => {
|
|||
|
||||
let connection: StreamTypes.ChannelOf<"main"> | undefined;
|
||||
|
||||
function convertNotification(n: entities.Notification[]) {
|
||||
function convertNotification(ns: entities.Notification[]) {
|
||||
if (shouldFold) {
|
||||
return foldNotifications(n, FETCH_LIMIT);
|
||||
return foldNotifications(ns);
|
||||
} else {
|
||||
return n;
|
||||
return ns;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -38,7 +38,7 @@
|
|||
</MkButton>
|
||||
<MkLoading v-else class="loading" />
|
||||
</div>
|
||||
<slot :items="items"></slot>
|
||||
<slot :items="items" :foldedItems="foldedItems"></slot>
|
||||
<div
|
||||
v-show="!pagination.reversed && more"
|
||||
key="_more_"
|
||||
|
@ -66,8 +66,8 @@
|
|||
</transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup generic="E extends PagingKey">
|
||||
import type { ComponentPublicInstance, ComputedRef } from "vue";
|
||||
<script lang="ts" setup generic="E extends PagingKey, Fold extends PagingAble">
|
||||
import type { ComponentPublicInstance, ComputedRef, Ref } from "vue";
|
||||
import {
|
||||
computed,
|
||||
isRef,
|
||||
|
@ -79,12 +79,7 @@ import {
|
|||
} from "vue";
|
||||
import type { Endpoints, TypeUtils } from "firefish-js";
|
||||
import * as os from "@/os";
|
||||
import {
|
||||
getScrollContainer,
|
||||
getScrollPosition,
|
||||
isTopVisible,
|
||||
onScrollTop,
|
||||
} from "@/scripts/scroll";
|
||||
import { isTopVisible, onScrollTop } from "@/scripts/scroll";
|
||||
import MkButton from "@/components/MkButton.vue";
|
||||
import { i18n } from "@/i18n";
|
||||
import { defaultStore } from "@/store";
|
||||
|
@ -105,11 +100,15 @@ export type MkPaginationType<
|
|||
reload: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
prepend: (item: Item) => Promise<void>;
|
||||
append: (item: Item) => Promise<void>;
|
||||
append: (...item: Item[]) => Promise<void>;
|
||||
removeItem: (finder: (item: Item) => boolean) => boolean;
|
||||
updateItem: (id: string, replacer: (old: Item) => Item) => boolean;
|
||||
};
|
||||
|
||||
export type PagingAble = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type PagingKeyOf<T> = TypeUtils.EndpointsOf<T[]>;
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Used Intentionally
|
||||
export type PagingKey = PagingKeyOf<any>;
|
||||
|
@ -142,13 +141,18 @@ export interface Paging<E extends PagingKey = PagingKey> {
|
|||
|
||||
export type PagingOf<T> = Paging<TypeUtils.EndpointsOf<T[]>>;
|
||||
|
||||
type Item = Endpoints[E]["res"][number];
|
||||
type Param = Endpoints[E]["req"] | Record<string, never>;
|
||||
|
||||
const SECOND_FETCH_LIMIT_DEFAULT = 30;
|
||||
const FIRST_FETCH_LIMIT_DEFAULT = 10;
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
pagination: Paging<E>;
|
||||
disableAutoLoad?: boolean;
|
||||
displayLimit?: number;
|
||||
folder?: (i: Item[]) => Fold[];
|
||||
}>(),
|
||||
{
|
||||
displayLimit: 30,
|
||||
|
@ -156,7 +160,7 @@ const props = withDefaults(
|
|||
);
|
||||
|
||||
const slots = defineSlots<{
|
||||
default(props: { items: Item[] }): unknown;
|
||||
default(props: { items: Item[]; foldedItems: Fold[] }): unknown;
|
||||
empty(props: Record<string, never>): never;
|
||||
}>();
|
||||
|
||||
|
@ -165,13 +169,59 @@ const emit = defineEmits<{
|
|||
(ev: "status", hasError: boolean): void;
|
||||
}>();
|
||||
|
||||
type Param = Endpoints[E]["req"] | Record<string, never>;
|
||||
type Item = Endpoints[E]["res"][number];
|
||||
|
||||
const rootEl = ref<HTMLElement>();
|
||||
const items = ref<Item[]>([]);
|
||||
const foldedItems = ref([]) as Ref<Fold[]>;
|
||||
|
||||
// To improve performance, we do not use vue’s `computed` here
|
||||
function calculateItems() {
|
||||
function getItems<T>(folder: (ns: Item[]) => T[]) {
|
||||
const res = [
|
||||
folder(prepended.value.toReversed()),
|
||||
...arrItems.value.map((arr) => folder(arr)),
|
||||
folder(appended.value),
|
||||
].flat(1);
|
||||
if (props.pagination.reversed) {
|
||||
res.reverse();
|
||||
}
|
||||
return res;
|
||||
}
|
||||
items.value = getItems((x) => x);
|
||||
if (props.folder) foldedItems.value = getItems(props.folder);
|
||||
}
|
||||
|
||||
const queue = ref<Item[]>([]);
|
||||
|
||||
/**
|
||||
* The cached elements inserted front by `prepend` function
|
||||
*/
|
||||
const prepended = ref<Item[]>([]);
|
||||
/**
|
||||
* The array of "frozen" items
|
||||
*/
|
||||
const arrItems = ref<Item[][]>([]);
|
||||
/**
|
||||
* The cached elements inserted back by `append` function
|
||||
*/
|
||||
const appended = ref<Item[]>([]);
|
||||
|
||||
const idMap = new Map<string, boolean>();
|
||||
|
||||
const offset = ref(0);
|
||||
|
||||
type PagingByParam =
|
||||
| {
|
||||
offset: number;
|
||||
}
|
||||
| {
|
||||
sinceId: string;
|
||||
}
|
||||
| {
|
||||
untilId: string;
|
||||
}
|
||||
| Record<string, never>;
|
||||
let nextPagingBy: PagingByParam = {};
|
||||
|
||||
const fetching = ref(true);
|
||||
const moreFetching = ref(false);
|
||||
const more = ref(false);
|
||||
|
@ -184,54 +234,14 @@ const init = async (): Promise<void> => {
|
|||
queue.value = [];
|
||||
fetching.value = true;
|
||||
|
||||
const params = props.pagination.params ? unref(props.pagination.params) : {};
|
||||
await os
|
||||
.api(props.pagination.endpoint, {
|
||||
...params,
|
||||
limit: props.pagination.noPaging
|
||||
? props.pagination.limit || 10
|
||||
: (props.pagination.limit || 10) + 1,
|
||||
...(props.pagination.ascending
|
||||
? {
|
||||
// An initial value smaller than all possible ids must be filled in here.
|
||||
sinceId: "0",
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
.then(
|
||||
(res: Item[]) => {
|
||||
for (let i = 0; i < res.length; i++) {
|
||||
const item = res[i];
|
||||
if (props.pagination.reversed) {
|
||||
if (i === res.length - 2) item._shouldInsertAd_ = true;
|
||||
} else {
|
||||
if (i === 3) item._shouldInsertAd_ = true;
|
||||
}
|
||||
}
|
||||
if (
|
||||
!props.pagination.noPaging &&
|
||||
res.length > (props.pagination.limit || 10)
|
||||
) {
|
||||
res.pop();
|
||||
items.value = props.pagination.reversed ? res.toReversed() : res;
|
||||
more.value = true;
|
||||
} else {
|
||||
items.value = props.pagination.reversed ? res.toReversed() : res;
|
||||
more.value = false;
|
||||
}
|
||||
offset.value = res.length;
|
||||
error.value = false;
|
||||
fetching.value = false;
|
||||
},
|
||||
(_err) => {
|
||||
error.value = true;
|
||||
fetching.value = false;
|
||||
},
|
||||
);
|
||||
await fetch(true);
|
||||
};
|
||||
|
||||
const reload = (): Promise<void> => {
|
||||
items.value = [];
|
||||
arrItems.value = [];
|
||||
appended.value = [];
|
||||
prepended.value = [];
|
||||
idMap.clear();
|
||||
return init();
|
||||
};
|
||||
|
||||
|
@ -240,30 +250,18 @@ const refresh = async (): Promise<void> => {
|
|||
await os
|
||||
.api(props.pagination.endpoint, {
|
||||
...params,
|
||||
limit: items.value.length + 1,
|
||||
limit: (items.value.length || foldedItems.value.length) + 1,
|
||||
offset: 0,
|
||||
})
|
||||
.then(
|
||||
(res: Item[]) => {
|
||||
const ids = items.value.reduce(
|
||||
(a, b) => {
|
||||
a[b.id] = true;
|
||||
return a;
|
||||
},
|
||||
{} as Record<string, boolean>,
|
||||
);
|
||||
appended.value = [];
|
||||
prepended.value = [];
|
||||
|
||||
for (let i = 0; i < res.length; i++) {
|
||||
const item = res[i];
|
||||
if (!updateItem(item.id, (_old) => item)) {
|
||||
append(item);
|
||||
}
|
||||
delete ids[item.id];
|
||||
}
|
||||
// appended should be inserted into arrItems to fix the element position
|
||||
arrItems.value = [res];
|
||||
|
||||
for (const id in ids) {
|
||||
removeItem((i) => i.id === id);
|
||||
}
|
||||
calculateItems();
|
||||
},
|
||||
(_err) => {
|
||||
error.value = true;
|
||||
|
@ -272,7 +270,21 @@ const refresh = async (): Promise<void> => {
|
|||
);
|
||||
};
|
||||
|
||||
const fetchMore = async (): Promise<void> => {
|
||||
async function fetch(firstFetching?: boolean) {
|
||||
let limit: number;
|
||||
|
||||
if (firstFetching) {
|
||||
limit = props.pagination.noPaging
|
||||
? props.pagination.limit || FIRST_FETCH_LIMIT_DEFAULT
|
||||
: (props.pagination.limit || FIRST_FETCH_LIMIT_DEFAULT) + 1;
|
||||
|
||||
if (props.pagination.ascending) {
|
||||
nextPagingBy = {
|
||||
// An initial value smaller than all possible ids must be filled in here.
|
||||
sinceId: "0",
|
||||
};
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
!more.value ||
|
||||
fetching.value ||
|
||||
|
@ -282,145 +294,121 @@ const fetchMore = async (): Promise<void> => {
|
|||
return;
|
||||
moreFetching.value = true;
|
||||
backed.value = true;
|
||||
|
||||
limit =
|
||||
(props.pagination.secondFetchLimit ?? SECOND_FETCH_LIMIT_DEFAULT) + 1;
|
||||
}
|
||||
|
||||
const params = props.pagination.params ? unref(props.pagination.params) : {};
|
||||
|
||||
await os
|
||||
.api(props.pagination.endpoint, {
|
||||
...params,
|
||||
limit:
|
||||
(props.pagination.secondFetchLimit ?? SECOND_FETCH_LIMIT_DEFAULT) + 1,
|
||||
...(props.pagination.offsetMode
|
||||
? {
|
||||
offset: offset.value,
|
||||
}
|
||||
: props.pagination.reversed
|
||||
? {
|
||||
sinceId: items.value[0].id,
|
||||
}
|
||||
: props.pagination.ascending
|
||||
? {
|
||||
sinceId: items.value[items.value.length - 1].id,
|
||||
}
|
||||
: {
|
||||
untilId: items.value[items.value.length - 1].id,
|
||||
}),
|
||||
limit,
|
||||
...nextPagingBy,
|
||||
})
|
||||
.then(
|
||||
(res: Item[]) => {
|
||||
if (!props.pagination.reversed)
|
||||
for (let i = 0; i < res.length; i++) {
|
||||
const item = res[i];
|
||||
if (props.pagination.reversed) {
|
||||
if (i === res.length - 9) item._shouldInsertAd_ = true;
|
||||
if (i === res.length - (firstFetching ? 2 : 9))
|
||||
item._shouldInsertAd_ = true;
|
||||
} else {
|
||||
if (i === 10) item._shouldInsertAd_ = true;
|
||||
if (i === (firstFetching ? 3 : 10)) item._shouldInsertAd_ = true;
|
||||
}
|
||||
}
|
||||
if (
|
||||
res.length >
|
||||
(props.pagination.secondFetchLimit ?? SECOND_FETCH_LIMIT_DEFAULT)
|
||||
) {
|
||||
if (!props.pagination.noPaging && res.length > limit - 1) {
|
||||
res.pop();
|
||||
items.value = props.pagination.reversed
|
||||
? res.toReversed().concat(items.value)
|
||||
: items.value.concat(res);
|
||||
more.value = true;
|
||||
} else {
|
||||
items.value = props.pagination.reversed
|
||||
? res.toReversed().concat(items.value)
|
||||
: items.value.concat(res);
|
||||
more.value = false;
|
||||
}
|
||||
|
||||
offset.value += res.length;
|
||||
error.value = false;
|
||||
fetching.value = false;
|
||||
moreFetching.value = false;
|
||||
|
||||
const lastRes = res[res.length - 1];
|
||||
|
||||
if (props.pagination.offsetMode) {
|
||||
nextPagingBy = {
|
||||
offset: offset.value,
|
||||
};
|
||||
} else if (props.pagination.ascending) {
|
||||
nextPagingBy = {
|
||||
sinceId: lastRes?.id,
|
||||
};
|
||||
} else {
|
||||
nextPagingBy = {
|
||||
untilId: lastRes?.id,
|
||||
};
|
||||
}
|
||||
|
||||
if (firstFetching && props.folder != null) {
|
||||
// In this way, prepended has some initial values for folding
|
||||
prepended.value = res.toReversed();
|
||||
} else {
|
||||
// For ascending and offset modes, append and prepend may cause item duplication
|
||||
// so they need to be filtered out.
|
||||
if (props.pagination.offsetMode || props.pagination.ascending) {
|
||||
for (const item of appended.value) {
|
||||
idMap.set(item.id, true);
|
||||
}
|
||||
|
||||
// biome-ignore lint/style/noParameterAssign: assign it intentially
|
||||
res = res.filter((item) => {
|
||||
if (idMap.has(item)) return false;
|
||||
idMap.set(item, true);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// appended should be inserted into arrItems to fix the element position
|
||||
arrItems.value.push(appended.value);
|
||||
arrItems.value.push(res);
|
||||
appended.value = [];
|
||||
}
|
||||
|
||||
calculateItems();
|
||||
},
|
||||
(_err) => {
|
||||
error.value = true;
|
||||
fetching.value = false;
|
||||
moreFetching.value = false;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const fetchMore = async (): Promise<void> => {
|
||||
await fetch();
|
||||
};
|
||||
|
||||
const fetchMoreAhead = async (): Promise<void> => {
|
||||
if (
|
||||
!more.value ||
|
||||
fetching.value ||
|
||||
moreFetching.value ||
|
||||
items.value.length === 0
|
||||
)
|
||||
return;
|
||||
moreFetching.value = true;
|
||||
const params = props.pagination.params ? unref(props.pagination.params) : {};
|
||||
await os
|
||||
.api(props.pagination.endpoint, {
|
||||
...params,
|
||||
limit:
|
||||
(props.pagination.secondFetchLimit ?? SECOND_FETCH_LIMIT_DEFAULT) + 1,
|
||||
...(props.pagination.offsetMode
|
||||
? {
|
||||
offset: offset.value,
|
||||
}
|
||||
: props.pagination.reversed
|
||||
? {
|
||||
untilId: items.value[0].id,
|
||||
}
|
||||
: {
|
||||
sinceId: items.value[items.value.length - 1].id,
|
||||
}),
|
||||
})
|
||||
.then(
|
||||
(res: Item[]) => {
|
||||
if (
|
||||
res.length >
|
||||
(props.pagination.secondFetchLimit ?? SECOND_FETCH_LIMIT_DEFAULT)
|
||||
) {
|
||||
res.pop();
|
||||
items.value = props.pagination.reversed
|
||||
? res.toReversed().concat(items.value)
|
||||
: items.value.concat(res);
|
||||
more.value = true;
|
||||
} else {
|
||||
items.value = props.pagination.reversed
|
||||
? res.toReversed().concat(items.value)
|
||||
: items.value.concat(res);
|
||||
more.value = false;
|
||||
}
|
||||
offset.value += res.length;
|
||||
moreFetching.value = false;
|
||||
},
|
||||
(_err) => {
|
||||
moreFetching.value = false;
|
||||
},
|
||||
);
|
||||
await fetch();
|
||||
};
|
||||
|
||||
const prepend = (item: Item): void => {
|
||||
const prepend = (...item: Item[]): void => {
|
||||
// If there are too many prepended, merge them into arrItems
|
||||
if (
|
||||
prepended.value.length >
|
||||
(props.pagination.secondFetchLimit || SECOND_FETCH_LIMIT_DEFAULT)
|
||||
) {
|
||||
arrItems.value.unshift(prepended.value.toReversed());
|
||||
prepended.value = [];
|
||||
// We don't need to calculate here because it won't cause any changes in items
|
||||
}
|
||||
|
||||
if (props.pagination.reversed) {
|
||||
if (rootEl.value) {
|
||||
const container = getScrollContainer(rootEl.value);
|
||||
if (container == null) {
|
||||
// TODO?
|
||||
prepended.value.push(...item);
|
||||
calculateItems();
|
||||
} else {
|
||||
const pos = getScrollPosition(rootEl.value);
|
||||
const viewHeight = container.clientHeight;
|
||||
const height = container.scrollHeight;
|
||||
const isBottom = pos + viewHeight > height - 32;
|
||||
if (isBottom) {
|
||||
// オーバーフローしたら古いアイテムは捨てる
|
||||
if (items.value.length >= props.displayLimit) {
|
||||
// このやり方だとVue 3.2以降アニメーションが動かなくなる
|
||||
// items.value = items.value.slice(-props.displayLimit);
|
||||
while (items.value.length >= props.displayLimit) {
|
||||
items.value.shift();
|
||||
}
|
||||
more.value = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
items.value.push(item);
|
||||
// TODO
|
||||
} else {
|
||||
// 初回表示時はunshiftだけでOK
|
||||
// When displaying for the first time, just do this is OK
|
||||
if (!rootEl.value) {
|
||||
items.value.unshift(item);
|
||||
prepended.value.push(...item);
|
||||
calculateItems();
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -429,52 +417,63 @@ const prepend = (item: Item): void => {
|
|||
(document.body.contains(rootEl.value) && isTopVisible(rootEl.value));
|
||||
|
||||
if (isTop) {
|
||||
// Prepend the item
|
||||
items.value.unshift(item);
|
||||
|
||||
// オーバーフローしたら古いアイテムは捨てる
|
||||
if (items.value.length >= props.displayLimit) {
|
||||
// このやり方だとVue 3.2以降アニメーションが動かなくなる
|
||||
// this.items = items.value.slice(0, props.displayLimit);
|
||||
while (items.value.length >= props.displayLimit) {
|
||||
items.value.pop();
|
||||
}
|
||||
more.value = true;
|
||||
}
|
||||
prepended.value.push(...item);
|
||||
calculateItems();
|
||||
} else {
|
||||
queue.value.push(item);
|
||||
queue.value.push(...item);
|
||||
onScrollTop(rootEl.value, () => {
|
||||
for (const queueItem of queue.value) {
|
||||
prepend(queueItem);
|
||||
}
|
||||
prepend(...queue.value);
|
||||
queue.value = [];
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const append = (item: Item): void => {
|
||||
items.value.push(item);
|
||||
const append = (...items: Item[]): void => {
|
||||
appended.value.push(...items);
|
||||
calculateItems();
|
||||
};
|
||||
|
||||
const _removeItem = (arr: Item[], finder: (item: Item) => boolean): boolean => {
|
||||
const i = arr.findIndex(finder);
|
||||
if (i === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
arr.splice(i, 1);
|
||||
return true;
|
||||
};
|
||||
|
||||
const _updateItem = (
|
||||
arr: Item[],
|
||||
id: Item["id"],
|
||||
replacer: (old: Item) => Item,
|
||||
): boolean => {
|
||||
const i = arr.findIndex((item) => item.id === id);
|
||||
if (i === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
arr[i] = replacer(arr[i]);
|
||||
return true;
|
||||
};
|
||||
|
||||
const removeItem = (finder: (item: Item) => boolean): boolean => {
|
||||
const i = items.value.findIndex(finder);
|
||||
if (i === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
items.value.splice(i, 1);
|
||||
return true;
|
||||
const res =
|
||||
_removeItem(prepended.value, finder) ||
|
||||
_removeItem(appended.value, finder) ||
|
||||
arrItems.value.filter((arr) => _removeItem(arr, finder)).length > 0;
|
||||
calculateItems();
|
||||
return res;
|
||||
};
|
||||
|
||||
const updateItem = (id: Item["id"], replacer: (old: Item) => Item): boolean => {
|
||||
const i = items.value.findIndex((item) => item.id === id);
|
||||
if (i === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
items.value[i] = replacer(items.value[i]);
|
||||
return true;
|
||||
const res =
|
||||
_updateItem(prepended.value, id, replacer) ||
|
||||
_updateItem(appended.value, id, replacer) ||
|
||||
arrItems.value.filter((arr) => _updateItem(arr, id, replacer)).length > 0;
|
||||
calculateItems();
|
||||
return res;
|
||||
};
|
||||
|
||||
if (props.pagination.params && isRef<Param>(props.pagination.params)) {
|
||||
|
|
68
packages/client/src/components/MkQrCode.vue
Normal file
68
packages/client/src/components/MkQrCode.vue
Normal file
|
@ -0,0 +1,68 @@
|
|||
<template>
|
||||
<MkModal ref="modal" :z-priority="'middle'" @closed="$emit('closed')">
|
||||
<div :class="$style.root">
|
||||
<div :class="$style.title">
|
||||
<QRCodeVue3
|
||||
:value="qrCode"
|
||||
/>
|
||||
</div>
|
||||
<MkButton :class="$style.gotIt" primary full @click="gotIt()">{{
|
||||
i18n.ts.gotIt
|
||||
}}</MkButton>
|
||||
</div>
|
||||
</MkModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { shallowRef } from "vue";
|
||||
import MkModal from "@/components/MkModal.vue";
|
||||
import MkButton from "@/components/MkButton.vue";
|
||||
import { i18n } from "@/i18n";
|
||||
import QRCodeVue3 from "qrcode-vue3";
|
||||
|
||||
const props = defineProps<{
|
||||
qrCode: string;
|
||||
}>();
|
||||
|
||||
const modal = shallowRef<InstanceType<typeof MkModal>>();
|
||||
|
||||
const gotIt = () => {
|
||||
modal.value.close();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
margin: auto;
|
||||
position: relative;
|
||||
padding: 32px;
|
||||
min-width: 320px;
|
||||
max-width: 480px;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
background: var(--panel);
|
||||
border-radius: var(--radius);
|
||||
|
||||
> img {
|
||||
border-radius: 10px;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
|
||||
> p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.gotIt {
|
||||
margin: 8px 0 0 0;
|
||||
}
|
||||
</style>
|
|
@ -196,13 +196,26 @@ import { i18n } from "@/i18n";
|
|||
import { defaultStore } from "@/store";
|
||||
import icon from "@/scripts/icon";
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
note: entities.Note;
|
||||
parentId?: string;
|
||||
conversation?: entities.Note[];
|
||||
detailed?: boolean;
|
||||
detailedView?: boolean;
|
||||
}>();
|
||||
isLongJudger?: (note: entities.Note) => boolean;
|
||||
}>(),
|
||||
{
|
||||
isLongJudger: (note: entities.Note) => {
|
||||
return (
|
||||
note.text != null &&
|
||||
(note.text.split("\n").length > 10 ||
|
||||
note.text.length > 800 ||
|
||||
note.files.length > 4)
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: "push", v): void;
|
||||
|
@ -216,10 +229,7 @@ const showMoreButton = ref<HTMLElement>();
|
|||
const isLong =
|
||||
!props.detailedView &&
|
||||
props.note.cw == null &&
|
||||
((props.note.text != null &&
|
||||
(props.note.text.split("\n").length > 10 ||
|
||||
props.note.text.length > 800)) ||
|
||||
props.note.files.length > 4);
|
||||
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)
|
||||
|
|
|
@ -338,7 +338,7 @@ defineExpose({
|
|||
content: "";
|
||||
position: absolute;
|
||||
inset: -2px 0;
|
||||
border: 2px solid var(--accentDarken);
|
||||
border-bottom: 2px solid var(--accentDarken);
|
||||
mask: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
|
|
|
@ -7,6 +7,7 @@ import type { Component, MaybeRef, Ref } from "vue";
|
|||
import { defineAsyncComponent, markRaw, ref } from "vue";
|
||||
import { i18n } from "./i18n";
|
||||
import MkDialog from "@/components/MkDialog.vue";
|
||||
import MkQrCode from "@/components/MkQrCode.vue";
|
||||
import MkPostFormDialog from "@/components/MkPostFormDialog.vue";
|
||||
import MkToast from "@/components/MkToast.vue";
|
||||
import MkWaitingDialog from "@/components/MkWaitingDialog.vue";
|
||||
|
@ -1000,6 +1001,25 @@ export function post(
|
|||
});
|
||||
}
|
||||
|
||||
export async function displayQrCode(qrCode: string) {
|
||||
(
|
||||
await new Promise<(() => void) | undefined>((resolve) => {
|
||||
let dispose: (() => void) | undefined;
|
||||
popup(
|
||||
MkQrCode,
|
||||
{ qrCode },
|
||||
{
|
||||
closed: () => {
|
||||
resolve(dispose);
|
||||
},
|
||||
},
|
||||
).then((res) => {
|
||||
dispose = res.dispose;
|
||||
});
|
||||
})
|
||||
)?.();
|
||||
}
|
||||
|
||||
export const deckGlobalEvents = new EventEmitter();
|
||||
|
||||
/*
|
||||
|
|
75
packages/client/src/pages/follow-me.vue
Normal file
75
packages/client/src/pages/follow-me.vue
Normal file
|
@ -0,0 +1,75 @@
|
|||
<template>
|
||||
<div class="mk-follow-page"></div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { acct } from "firefish-js";
|
||||
import * as os from "@/os";
|
||||
import { i18n } from "@/i18n";
|
||||
import { host as hostRaw } from "@/config";
|
||||
import { isSignedIn, me } from "@/me";
|
||||
import { waiting } from "@/os";
|
||||
|
||||
const acctUri = new URL(location.href).searchParams.get("acct");
|
||||
if (acctUri == null) {
|
||||
throw new Error("acct required");
|
||||
}
|
||||
|
||||
// If the user is already logged in, ask whether to follow using the current account.
|
||||
if (isSignedIn(me)) {
|
||||
const { canceled } = await os.confirm({
|
||||
type: "question",
|
||||
text: i18n.ts.useThisAccountConfirm,
|
||||
});
|
||||
|
||||
// use the current account
|
||||
if (!canceled) {
|
||||
waiting();
|
||||
window.location.href = `/authorize-follow?acct=${acctUri}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise ask the user what the other account ID is
|
||||
const remoteAccountId = await os.inputText({
|
||||
text: i18n.ts.inputAccountId,
|
||||
});
|
||||
|
||||
// If the user do not want enter uri, the user will be redirected to the user page.
|
||||
if (!remoteAccountId.result) {
|
||||
waiting();
|
||||
window.location.href = `/@${acctUri}`;
|
||||
} else {
|
||||
const remoteAcctInfo = acct.parse(remoteAccountId.result);
|
||||
|
||||
// If the user on this server, redirect directly
|
||||
if (remoteAcctInfo.host === hostRaw || remoteAcctInfo.host === null) {
|
||||
waiting();
|
||||
window.location.href = `/authorize-follow?acct=${acctUri}`;
|
||||
} else {
|
||||
waiting();
|
||||
// If not, find the interaction url through webfinger interface
|
||||
fetch(
|
||||
`https://${remoteAcctInfo.host}/.well-known/webfinger?resource=${remoteAcctInfo.username}@${remoteAcctInfo.host}`,
|
||||
{
|
||||
method: "GET",
|
||||
},
|
||||
)
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
const subscribeUri = data.links.find(
|
||||
(link: { rel: string }) =>
|
||||
link.rel === "http://ostatus.org/schema/1.0/subscribe",
|
||||
).template;
|
||||
window.location.href = subscribeUri.replace(
|
||||
"{uri}",
|
||||
acctUri.includes("@") ? acctUri : `${acctUri}@${hostRaw}`,
|
||||
);
|
||||
})
|
||||
.catch((_) => {
|
||||
// TODO: It would be better to provide more information, but the priority of
|
||||
// waiting component is too high and the pop-up window will be blocked.
|
||||
window.location.href = `/@${acctUri}`;
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -325,6 +325,10 @@ export const routes: RouteDef[] = [
|
|||
component: page(() => import("./pages/follow.vue")),
|
||||
loginRequired: true,
|
||||
},
|
||||
{
|
||||
path: "/follow-me",
|
||||
component: page(() => import("./pages/follow-me.vue")),
|
||||
},
|
||||
{
|
||||
path: "/authorize_interaction",
|
||||
component: page(() => import("./pages/authorize_interaction.vue")),
|
||||
|
|
|
@ -20,7 +20,6 @@ interface FoldOption {
|
|||
*/
|
||||
export function foldItems<ItemFolded, Item>(
|
||||
ns: Item[],
|
||||
fetch_limit: number,
|
||||
classfier: (n: Item, index: number) => string,
|
||||
aggregator: (ns: Item[], key: string) => ItemFolded,
|
||||
_options?: FoldOption,
|
||||
|
@ -30,12 +29,10 @@ export function foldItems<ItemFolded, Item>(
|
|||
const options: FoldOption = _options ?? {};
|
||||
options.skipSingleElement ??= true;
|
||||
|
||||
for (let i = 0; i < ns.length; i += fetch_limit) {
|
||||
const toFold = ns.slice(i, i + fetch_limit);
|
||||
const toAppendKeys: string[] = [];
|
||||
const foldMap = new Map<string, Item[]>();
|
||||
|
||||
for (const [index, n] of toFold.entries()) {
|
||||
for (const [index, n] of ns.entries()) {
|
||||
const key = classfier(n, index);
|
||||
const arr = foldMap.get(key);
|
||||
if (arr != null) {
|
||||
|
@ -46,39 +43,34 @@ export function foldItems<ItemFolded, Item>(
|
|||
}
|
||||
}
|
||||
|
||||
res = res.concat(
|
||||
toAppendKeys.map((key) => {
|
||||
res = toAppendKeys.map((key) => {
|
||||
const arr = foldMap.get(key)!;
|
||||
if (arr?.length === 1 && options?.skipSingleElement) {
|
||||
return arr[0];
|
||||
}
|
||||
return aggregator(arr, key);
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
export function foldNotifications(
|
||||
ns: entities.Notification[],
|
||||
fetch_limit: number,
|
||||
) {
|
||||
export function foldNotifications(ns: entities.Notification[]) {
|
||||
// By the implement of MkPagination, lastId is unique and is safe for key
|
||||
const lastId = ns[ns.length - 1]?.id ?? "prepend";
|
||||
return foldItems(
|
||||
ns,
|
||||
fetch_limit,
|
||||
(n) => {
|
||||
switch (n.type) {
|
||||
case "renote":
|
||||
return `renote-of:${n.note.renote.id}`;
|
||||
return `renote-${n.note.renote.id}`;
|
||||
case "reaction":
|
||||
return `reaction:${n.reaction}:of:${n.note.id}`;
|
||||
return `reaction-${n.reaction}-of-${n.note.id}`;
|
||||
default: {
|
||||
return `${n.id}`;
|
||||
}
|
||||
}
|
||||
},
|
||||
(ns) => {
|
||||
(ns, key) => {
|
||||
const represent = ns[0];
|
||||
function check(
|
||||
ns: entities.Notification[],
|
||||
|
@ -94,6 +86,7 @@ export function foldNotifications(
|
|||
userIds: ns.map((nn) => nn.userId),
|
||||
users: ns.map((nn) => nn.user),
|
||||
notifications: ns!,
|
||||
id: `G-${lastId}-${key}`,
|
||||
} as NotificationFolded;
|
||||
},
|
||||
);
|
||||
|
|
|
@ -255,6 +255,27 @@ export function getUserMenu(user, router: Router = mainRouter) {
|
|||
router.push(`/user-info/${user.id}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: `${icon("ph-share")}`,
|
||||
text: i18n.ts.share,
|
||||
type: "parent",
|
||||
children: [
|
||||
{
|
||||
icon: "ph-qr-code ph-bold ph-lg",
|
||||
text: i18n.ts.getQrCode,
|
||||
action: () => {
|
||||
os.displayQrCode(`https://${host}/follow-me?acct=${user.username}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: `${icon("ph-hand-waving")}`,
|
||||
text: i18n.ts.copyRemoteFollowUrl,
|
||||
action: () => {
|
||||
copyToClipboard(`https://${host}/follow-me?acct=${user.username}`);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: `${icon("ph-newspaper")}`,
|
||||
text: i18n.ts._feeds.copyFeed,
|
||||
|
@ -290,6 +311,15 @@ export function getUserMenu(user, router: Router = mainRouter) {
|
|||
os.post({ specified: user });
|
||||
},
|
||||
},
|
||||
!isSignedIn(me)
|
||||
? {
|
||||
icon: `${icon("ph-hand-waving")}`,
|
||||
text: i18n.ts.remoteFollow,
|
||||
action: () => {
|
||||
router.push(`/follow-me?acct=${user.username}`);
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
isSignedIn(me) && me.id !== user.id
|
||||
? {
|
||||
type: "link",
|
||||
|
|
|
@ -4,6 +4,9 @@ export function getScrollContainer(el: HTMLElement | null): HTMLElement | null {
|
|||
if (el == null) return null;
|
||||
const overflow = window.getComputedStyle(el).getPropertyValue("overflow-y");
|
||||
if (overflow === "scroll" || overflow === "auto") {
|
||||
if (el.tagName === "HTML") {
|
||||
return null;
|
||||
}
|
||||
return el;
|
||||
} else {
|
||||
return getScrollContainer(el.parentElement);
|
||||
|
|
|
@ -22,7 +22,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@swc/cli": "0.3.12",
|
||||
"@swc/core": "1.4.13",
|
||||
"@swc/core": "1.5.0",
|
||||
"@swc/types": "^0.1.6",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "20.12.7",
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
"devDependencies": {
|
||||
"firefish-js": "workspace:*",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"vite": "5.2.8",
|
||||
"vite": "5.2.10",
|
||||
"vite-plugin-compression": "^0.5.1"
|
||||
}
|
||||
}
|
||||
|
|
700
pnpm-lock.yaml
700
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue