1
1
mirror of https://github.com/kokonect-link/cherrypick synced 2024-12-15 15:18:22 +09:00

Merge pull request #10218 from misskey-dev/develop

Release: 13.9.2
This commit is contained in:
syuilo 2023-03-06 11:54:12 +09:00 committed by GitHub
commit ae517a99a7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
57 changed files with 881 additions and 334 deletions

View File

@ -7,5 +7,18 @@
"ghcr.io/devcontainers-contrib/features/pnpm:2": {} "ghcr.io/devcontainers-contrib/features/pnpm:2": {}
}, },
"forwardPorts": [3000], "forwardPorts": [3000],
"postCreateCommand": "sudo chmod 755 .devcontainer/init.sh && .devcontainer/init.sh" "postCreateCommand": "sudo chmod 755 .devcontainer/init.sh && .devcontainer/init.sh",
"customizations": {
"vscode": {
"extensions": [
"editorconfig.editorconfig",
"dbaeumer.vscode-eslint",
"Vue.volar",
"Vue.vscode-typescript-vue-plugin",
"Orta.vscode-jest",
"dbaeumer.vscode-eslint",
"mrmlnc.vscode-json5"
]
}
}
} }

View File

@ -16,7 +16,7 @@ services:
- external_network - external_network
redis: redis:
restart: always restart: unless-stopped
image: redis:7-alpine image: redis:7-alpine
networks: networks:
- internal_network - internal_network

View File

@ -4,6 +4,7 @@ set -xe
sudo chown -R node /workspace sudo chown -R node /workspace
git submodule update --init git submodule update --init
pnpm config set store-dir /home/node/.local/share/pnpm/store
pnpm install --frozen-lockfile pnpm install --frozen-lockfile
cp .devcontainer/devcontainer.yml .config/default.yml cp .devcontainer/devcontainer.yml .config/default.yml
pnpm build pnpm build

View File

@ -25,6 +25,8 @@ fluent-emojis/
!.yarn/sdks !.yarn/sdks
!.yarn/versions !.yarn/versions
.pnpm-store
.idea/ .idea/
packages/*/.vscode/ packages/*/.vscode/
packages/backend/test/docker-compose.yml packages/backend/test/docker-compose.yml

3
.gitignore vendored
View File

@ -20,6 +20,9 @@ packages/frontend/.yarn/cache
packages/backend/.yarn/cache packages/backend/.yarn/cache
packages/sw/.yarn/cache packages/sw/.yarn/cache
# pnpm
.pnpm-store
# Cypress # Cypress
cypress/screenshots cypress/screenshots
cypress/videos cypress/videos

View File

@ -1,9 +1,11 @@
{ {
"recommendations": [ "recommendations": [
"editorconfig.editorconfig", "editorconfig.editorconfig",
"eg2.vscode-npm-script",
"dbaeumer.vscode-eslint", "dbaeumer.vscode-eslint",
"Vue.volar", "Vue.volar",
"Vue.vscode-typescript-vue-plugin" "Vue.vscode-typescript-vue-plugin",
"Orta.vscode-jest",
"dbaeumer.vscode-eslint",
"mrmlnc.vscode-json5"
] ]
} }

View File

@ -5,5 +5,6 @@
"typescript.tsdk": "node_modules/typescript/lib", "typescript.tsdk": "node_modules/typescript/lib",
"files.associations": { "files.associations": {
"*.test.ts": "typescript" "*.test.ts": "typescript"
} },
"jest.autoRun": "off"
} }

View File

@ -5,11 +5,28 @@
- -
### Bugfixes ### Bugfixes
- x
You should also include the user name that made the change. You should also include the user name that made the change.
--> -->
## 13.9.2 (2023/03/06)
### Improvements
- クリップ、チャンネルページに共有ボタンを追加
- チャンネルでタイムライン上部に投稿フォームを表示するかどうかのオプションを追加
- ブラウザでメディアプロキシ(/proxy)からファイルを保存した際に、なるべくオリジナルのファイル名を継承するように
- ドライブの「URLからアップロード」で、content-dispositionのfilenameがあればそれをファイル名に
- Identiconがローカルとリモートで同じになるように
- これまでのIdenticonは異なる画像になります
- サーバーのパフォーマンスを改善
### Bugfixes
- ロールの権限で「一般ユーザー」のロールがいきなり設定できない問題を修正
- ユーザーページのバッジ表示を適切に折り返すように @arrow2nd
- fix(client): みつけるのロール一覧でコンディショナルロールが含まれるのを修正
- macOSでDev Containerが動作しない問題を修正 @RyotaK
## 13.9.1 (2023/03/03) ## 13.9.1 (2023/03/03)
### Bugfixes ### Bugfixes

View File

@ -15,7 +15,7 @@ Before creating an issue, please check the following:
- To avoid duplication, please search for similar issues before creating a new issue. - To avoid duplication, please search for similar issues before creating a new issue.
- Do not use Issues to ask questions or troubleshooting. - Do not use Issues to ask questions or troubleshooting.
- Issues should only be used to feature requests, suggestions, and bug tracking. - Issues should only be used to feature requests, suggestions, and bug tracking.
- Please ask questions or troubleshooting in the [Misskey Forum](https://forum.misskey.io/) or [Discord](https://discord.gg/Wp8gVStHW3). - Please ask questions or troubleshooting in ~~the [Misskey Forum](https://forum.misskey.io/)~~ [GitHub Discussions](https://github.com/misskey-dev/misskey/discussions) or [Discord](https://discord.gg/Wp8gVStHW3).
> **Warning** > **Warning**
> Do not close issues that are about to be resolved. It should remain open until a commit that actually resolves it is merged. > Do not close issues that are about to be resolved. It should remain open until a commit that actually resolves it is merged.

View File

@ -2,7 +2,9 @@
ARG NODE_VERSION=18.13.0-bullseye ARG NODE_VERSION=18.13.0-bullseye
FROM node:${NODE_VERSION} AS builder # build assets & compile TypeScript
FROM --platform=$BUILDPLATFORM node:${NODE_VERSION} AS native-builder
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \ --mount=type=cache,target=/var/lib/apt,sharing=locked \
@ -33,33 +35,49 @@ RUN git submodule update --init
RUN pnpm build RUN pnpm build
RUN rm -rf .git/ RUN rm -rf .git/
FROM node:${NODE_VERSION}-slim AS runner # build native dependencies for target platform
FROM --platform=$TARGETPLATFORM node:${NODE_VERSION} AS target-builder
RUN apt-get update \
&& apt-get install -yqq --no-install-recommends \
build-essential
RUN corepack enable
WORKDIR /misskey
COPY --link ["pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json", "./"]
COPY --link ["scripts", "./scripts"]
COPY --link ["packages/backend/package.json", "./packages/backend/"]
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \
pnpm i --frozen-lockfile --aggregate-output
FROM --platform=$TARGETPLATFORM node:${NODE_VERSION}-slim AS runner
ARG UID="991" ARG UID="991"
ARG GID="991" ARG GID="991"
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ RUN apt-get update \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
rm -f /etc/apt/apt.conf.d/docker-clean \
; echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache \
&& apt-get update \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \
ffmpeg tini curl \ ffmpeg tini curl \
&& corepack enable \ && corepack enable \
&& groupadd -g "${GID}" misskey \ && groupadd -g "${GID}" misskey \
&& useradd -l -u "${UID}" -g "${GID}" -m -d /misskey misskey \ && useradd -l -u "${UID}" -g "${GID}" -m -d /misskey misskey \
&& find / -type f -perm /u+s -ignore_readdir_race -exec chmod u-s {} \; \ && find / -type d -path /proc -prune -o -type f -perm /u+s -ignore_readdir_race -exec chmod u-s {} \; \
&& find / -type f -perm /g+s -ignore_readdir_race -exec chmod g-s {} \; && find / -type d -path /proc -prune -o -type f -perm /g+s -ignore_readdir_race -exec chmod g-s {} \; \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists
USER misskey USER misskey
WORKDIR /misskey WORKDIR /misskey
COPY --chown=misskey:misskey --from=builder /misskey/node_modules ./node_modules COPY --chown=misskey:misskey --from=target-builder /misskey/node_modules ./node_modules
COPY --chown=misskey:misskey --from=builder /misskey/built ./built COPY --chown=misskey:misskey --from=target-builder /misskey/packages/backend/node_modules ./packages/backend/node_modules
COPY --chown=misskey:misskey --from=builder /misskey/packages/backend/node_modules ./packages/backend/node_modules COPY --chown=misskey:misskey --from=native-builder /misskey/built ./built
COPY --chown=misskey:misskey --from=builder /misskey/packages/backend/built ./packages/backend/built COPY --chown=misskey:misskey --from=native-builder /misskey/packages/backend/built ./packages/backend/built
COPY --chown=misskey:misskey --from=builder /misskey/packages/frontend/node_modules ./packages/frontend/node_modules COPY --chown=misskey:misskey --from=native-builder /misskey/fluent-emojis /misskey/fluent-emojis
COPY --chown=misskey:misskey --from=builder /misskey/fluent-emojis /misskey/fluent-emojis
COPY --chown=misskey:misskey . ./ COPY --chown=misskey:misskey . ./
ENV NODE_ENV=production ENV NODE_ENV=production

View File

@ -345,7 +345,7 @@ basicInfo: "Grundlegende Informationen"
pinnedUsers: "Angeheftete Benutzer" pinnedUsers: "Angeheftete Benutzer"
pinnedUsersDescription: "Gib durch Leerzeichen getrennte Benutzer an, die an die \"Erkunden\"-Seite angeheftet werden sollen." pinnedUsersDescription: "Gib durch Leerzeichen getrennte Benutzer an, die an die \"Erkunden\"-Seite angeheftet werden sollen."
pinnedPages: "Angeheftete Seiten" pinnedPages: "Angeheftete Seiten"
pinnedPagesDescription: "Gib durch Leerzeilen getrennte Pfäde zu Seiten an, die an die Startseite dieser Instanz angeheftet werden sollen.\n" pinnedPagesDescription: "Gib durch Leerzeilen getrennte Pfade zu Seiten an, die an die Startseite dieser Instanz angeheftet werden sollen."
pinnedClipId: "ID des anzuheftenden Clips" pinnedClipId: "ID des anzuheftenden Clips"
pinnedNotes: "Angeheftete Notizen" pinnedNotes: "Angeheftete Notizen"
hcaptcha: "hCaptcha" hcaptcha: "hCaptcha"
@ -404,7 +404,7 @@ securityKey: "Sicherheitsschlüssel"
lastUsed: "Zuletzt benutzt" lastUsed: "Zuletzt benutzt"
lastUsedAt: "Zuletzt verwendet: {t}" lastUsedAt: "Zuletzt verwendet: {t}"
unregister: "Deaktivieren" unregister: "Deaktivieren"
passwordLessLogin: "Passwortloses Anmelden einrichten" passwordLessLogin: "Passwortloses Anmelden"
passwordLessLoginDescription: "Ermöglicht passwortfreies Einloggen, nur via Security-Token oder Passkey" passwordLessLoginDescription: "Ermöglicht passwortfreies Einloggen, nur via Security-Token oder Passkey"
resetPassword: "Passwort zurücksetzen" resetPassword: "Passwort zurücksetzen"
newPasswordIs: "Das neue Passwort ist „{password}“" newPasswordIs: "Das neue Passwort ist „{password}“"
@ -506,6 +506,7 @@ objectStorageSetPublicRead: "Bei Upload auf \"public-read\" stellen"
serverLogs: "Serverprotokolle" serverLogs: "Serverprotokolle"
deleteAll: "Alle löschen" deleteAll: "Alle löschen"
showFixedPostForm: "Bereich zum Schreiben neuer Notizen am Anfang der Chronik anzeigen" showFixedPostForm: "Bereich zum Schreiben neuer Notizen am Anfang der Chronik anzeigen"
showFixedPostFormInChannel: "Bereich zum Schreiben neuer Notizen am Anfang der Chronik anzeigen (Kanäle)"
newNoteRecived: "Es gibt neue Notizen" newNoteRecived: "Es gibt neue Notizen"
sounds: "Töne" sounds: "Töne"
sound: "Töne" sound: "Töne"
@ -955,6 +956,9 @@ exploreOtherServers: "Eine andere Instanz finden"
letsLookAtTimeline: "Die Chronik durchstöbern" letsLookAtTimeline: "Die Chronik durchstöbern"
disableFederationWarn: "Dies deaktiviert Föderation, aber alle Notizen bleiben, sofern nicht umgestellt, öffentlich. In den meisten Fällen wird diese Option nicht benötigt." disableFederationWarn: "Dies deaktiviert Föderation, aber alle Notizen bleiben, sofern nicht umgestellt, öffentlich. In den meisten Fällen wird diese Option nicht benötigt."
invitationRequiredToRegister: "Diese Instanz ist einladungsbasiert. Du musst einen validen Einladungscode eingeben, um dich zu registrieren." invitationRequiredToRegister: "Diese Instanz ist einladungsbasiert. Du musst einen validen Einladungscode eingeben, um dich zu registrieren."
emailNotSupported: "Diese Instanz unterstützt das Versenden von Emails nicht"
postToTheChannel: "In Kanal senden"
cannotBeChangedLater: "Kann später nicht mehr geändert werden."
_achievements: _achievements:
earnedAt: "Freigeschaltet am" earnedAt: "Freigeschaltet am"
_types: _types:
@ -1163,7 +1167,7 @@ _achievements:
description: "Du hast hier geklickt" description: "Du hast hier geklickt"
_justPlainLucky: _justPlainLucky:
title: "Pures Glück" title: "Pures Glück"
description: "Kann alle 10 Sekunden mit einer Warscheinlichkeit von 0.01% erhalten werden" description: "Kann alle 10 Sekunden mit einer Warscheinlichkeit von 0.005% erhalten werden"
_setNameToSyuilo: _setNameToSyuilo:
title: "Gottkomplex" title: "Gottkomplex"
description: "Setze deinen Namen auf \"syuilo\"" description: "Setze deinen Namen auf \"syuilo\""
@ -1510,7 +1514,7 @@ _2fa:
step2Url: "Nutzt du ein Desktopprogramm kannst du alternativ diese URL eingeben:" step2Url: "Nutzt du ein Desktopprogramm kannst du alternativ diese URL eingeben:"
step3Title: "Authentifizierungsscode eingeben" step3Title: "Authentifizierungsscode eingeben"
step3: "Gib zum Abschluss den Token ein, der von deiner App angezeigt wird." step3: "Gib zum Abschluss den Token ein, der von deiner App angezeigt wird."
step4: "Alle folgenden Anmeldungsversuche werden ab sofort die Eingabe eines solchen Tokens benötigen." step4: "Alle folgenden Anmeldeversuche werden ab sofort die Eingabe eines solchen Tokens benötigen."
securityKeyNotSupported: "Dein Browser unterstützt keine Security-Tokens." securityKeyNotSupported: "Dein Browser unterstützt keine Security-Tokens."
registerTOTPBeforeKey: "Um einen Security-Token oder einen Passkey zu registrieren, musst du zuerst eine Authentifizierungs-App registrieren." registerTOTPBeforeKey: "Um einen Security-Token oder einen Passkey zu registrieren, musst du zuerst eine Authentifizierungs-App registrieren."
securityKeyInfo: "Du kannst neben Fingerabdruck- oder PIN-Authentifizierung auf deinem Gerät auch Anmeldung mit Hilfe eines FIDO2-kompatiblen Hardware-Sicherheitsschlüssels einrichten." securityKeyInfo: "Du kannst neben Fingerabdruck- oder PIN-Authentifizierung auf deinem Gerät auch Anmeldung mit Hilfe eines FIDO2-kompatiblen Hardware-Sicherheitsschlüssels einrichten."

View File

@ -197,7 +197,7 @@ clearQueueConfirmText: "Any undelivered notes remaining in the queue will not be
clearCachedFiles: "Clear cache" clearCachedFiles: "Clear cache"
clearCachedFilesConfirm: "Are you sure that you want to delete all cached remote files?" clearCachedFilesConfirm: "Are you sure that you want to delete all cached remote files?"
blockedInstances: "Blocked Instances" blockedInstances: "Blocked Instances"
blockedInstancesDescription: "List the hostnames of the instances that you want to block. Listed instances will no longer be able to communicate with this instance." blockedInstancesDescription: "List the hostnames of the instances that you want to block separated by linebreaks. Listed instances will no longer be able to communicate with this instance."
muteAndBlock: "Mutes and Blocks" muteAndBlock: "Mutes and Blocks"
mutedUsers: "Muted users" mutedUsers: "Muted users"
blockedUsers: "Blocked users" blockedUsers: "Blocked users"
@ -506,6 +506,7 @@ objectStorageSetPublicRead: "Set \"public-read\" on upload"
serverLogs: "Server logs" serverLogs: "Server logs"
deleteAll: "Delete all" deleteAll: "Delete all"
showFixedPostForm: "Display the posting form at the top of the timeline" showFixedPostForm: "Display the posting form at the top of the timeline"
showFixedPostFormInChannel: "Display the posting form at the top of the timeline (Channels)"
newNoteRecived: "There are new notes" newNoteRecived: "There are new notes"
sounds: "Sounds" sounds: "Sounds"
sound: "Sounds" sound: "Sounds"
@ -955,6 +956,9 @@ exploreOtherServers: "Look for another instance"
letsLookAtTimeline: "Have a look at the timeline" letsLookAtTimeline: "Have a look at the timeline"
disableFederationWarn: "This will disable federation, but posts will continue to be public unless set otherwise. You usually do not need to use this setting." disableFederationWarn: "This will disable federation, but posts will continue to be public unless set otherwise. You usually do not need to use this setting."
invitationRequiredToRegister: "This instance is invite-only. You must enter a valid invite code sign up." invitationRequiredToRegister: "This instance is invite-only. You must enter a valid invite code sign up."
emailNotSupported: "This instance does not support sending emails"
postToTheChannel: "Post to channel"
cannotBeChangedLater: "This cannot be changed later."
_achievements: _achievements:
earnedAt: "Unlocked at" earnedAt: "Unlocked at"
_types: _types:
@ -1182,7 +1186,7 @@ _achievements:
_loggedInOnNewYearsDay: _loggedInOnNewYearsDay:
title: "Happy New Year!" title: "Happy New Year!"
description: "Logged in on the first day of the year" description: "Logged in on the first day of the year"
flavor: "To another great year!" flavor: "To another great year on this instance"
_cookieClicked: _cookieClicked:
title: "A game in which you click cookies" title: "A game in which you click cookies"
description: "Clicked the cookie" description: "Clicked the cookie"

View File

@ -54,8 +54,8 @@ copyUsername: "Copia nome utente"
searchUser: "Cerca utente" searchUser: "Cerca utente"
reply: "Rispondi" reply: "Rispondi"
loadMore: "Mostra di più" loadMore: "Mostra di più"
showMore: "Mostra di più" showMore: "Espandi"
showLess: "Chiudi" showLess: "Comprimi"
youGotNewFollower: "Ha iniziato a seguirti" youGotNewFollower: "Ha iniziato a seguirti"
receiveFollowRequest: "Hai ricevuto una richiesta di follow" receiveFollowRequest: "Hai ricevuto una richiesta di follow"
followRequestAccepted: "Richiesta di follow accettata" followRequestAccepted: "Richiesta di follow accettata"
@ -76,7 +76,7 @@ noLists: "Nessuna lista"
note: "Nota" note: "Nota"
notes: "Note" notes: "Note"
following: "Follow" following: "Follow"
followers: "Followers" followers: "Follower"
followsYou: "Ti segue" followsYou: "Ti segue"
createList: "Aggiungi una nuova lista" createList: "Aggiungi una nuova lista"
manageLists: "Gestisci liste" manageLists: "Gestisci liste"
@ -506,6 +506,7 @@ objectStorageSetPublicRead: "Imposta \"visibilità pubblica\" al momento di cari
serverLogs: "Log del server" serverLogs: "Log del server"
deleteAll: "Cancella cronologia" deleteAll: "Cancella cronologia"
showFixedPostForm: "Visualizzare la finestra di pubblicazione in cima alla timeline" showFixedPostForm: "Visualizzare la finestra di pubblicazione in cima alla timeline"
showFixedPostFormInChannel: "Per i canali, mostra il modulo di pubblicazione in cima alla timeline"
newNoteRecived: "Vedi le nuove note" newNoteRecived: "Vedi le nuove note"
sounds: "Impostazioni suoni" sounds: "Impostazioni suoni"
sound: "Impostazioni suoni" sound: "Impostazioni suoni"
@ -558,7 +559,7 @@ visibility: "Visibilità"
poll: "Sondaggio" poll: "Sondaggio"
useCw: "Nascondere media" useCw: "Nascondere media"
enablePlayer: "Apri in lettore video" enablePlayer: "Apri in lettore video"
disablePlayer: "Chiudi lettore video" disablePlayer: "Chiudi il lettore"
expandTweet: "Espandi tweet" expandTweet: "Espandi tweet"
themeEditor: "Editor di temi" themeEditor: "Editor di temi"
description: "Descrizione" description: "Descrizione"
@ -955,6 +956,9 @@ exploreOtherServers: "Trova altre istanze"
letsLookAtTimeline: "Sbircia la timeline" letsLookAtTimeline: "Sbircia la timeline"
disableFederationWarn: "Disabilita la federazione. Questo cambiamento non rende le pubblicazioni private. Di solito non è necessario abilitare questa opzione." disableFederationWarn: "Disabilita la federazione. Questo cambiamento non rende le pubblicazioni private. Di solito non è necessario abilitare questa opzione."
invitationRequiredToRegister: "L'accesso a questo nodo è solo ad invito. Devi inserire un codice d'invito valido. Puoi richiedere un codice all'amministratore." invitationRequiredToRegister: "L'accesso a questo nodo è solo ad invito. Devi inserire un codice d'invito valido. Puoi richiedere un codice all'amministratore."
emailNotSupported: "L'istanza non supporta l'invio di email"
postToTheChannel: "Pubblica sul canale"
cannotBeChangedLater: "Non sarà più modificabile"
_achievements: _achievements:
earnedAt: "Data di conseguimento" earnedAt: "Data di conseguimento"
_types: _types:
@ -1613,7 +1617,7 @@ _widgets:
clicker: "Cliccaggio" clicker: "Cliccaggio"
_cw: _cw:
hide: "Nascondere" hide: "Nascondere"
show: "Mostra di più" show: "Apri..."
chars: "{count} caratteri" chars: "{count} caratteri"
files: "{count} file" files: "{count} file"
_poll: _poll:

View File

@ -506,6 +506,7 @@ objectStorageSetPublicRead: "アップロード時に'public-read'を設定す
serverLogs: "サーバーログ" serverLogs: "サーバーログ"
deleteAll: "全て削除" deleteAll: "全て削除"
showFixedPostForm: "タイムライン上部に投稿フォームを表示する" showFixedPostForm: "タイムライン上部に投稿フォームを表示する"
showFixedPostFormInChannel: "タイムライン上部に投稿フォームを表示する(チャンネル)"
newNoteRecived: "新しいノートがあります" newNoteRecived: "新しいノートがあります"
sounds: "サウンド" sounds: "サウンド"
sound: "サウンド" sound: "サウンド"
@ -955,6 +956,9 @@ exploreOtherServers: "他のサーバーを探す"
letsLookAtTimeline: "タイムラインを見てみる" letsLookAtTimeline: "タイムラインを見てみる"
disableFederationWarn: "連合が無効になっています。無効にしても投稿が非公開にはなりません。ほとんどの場合、このオプションを有効にする必要はありません。" disableFederationWarn: "連合が無効になっています。無効にしても投稿が非公開にはなりません。ほとんどの場合、このオプションを有効にする必要はありません。"
invitationRequiredToRegister: "現在このサーバーは招待制です。招待コードをお持ちの方のみ登録できます。" invitationRequiredToRegister: "現在このサーバーは招待制です。招待コードをお持ちの方のみ登録できます。"
emailNotSupported: "このサーバーではメール配信はサポートされていません"
postToTheChannel: "チャンネルに投稿"
cannotBeChangedLater: "後から変更できません。"
_achievements: _achievements:
earnedAt: "獲得日時" earnedAt: "獲得日時"

View File

@ -506,6 +506,7 @@ objectStorageSetPublicRead: "アップロードした時に'public-read'を設
serverLogs: "サーバーログ" serverLogs: "サーバーログ"
deleteAll: "全て削除してや" deleteAll: "全て削除してや"
showFixedPostForm: "タイムラインの上の方で投稿できるようにやってくれへん?" showFixedPostForm: "タイムラインの上の方で投稿できるようにやってくれへん?"
showFixedPostFormInChannel: "タイムラインの上の方で投稿できるようにするわ(チャンネル)"
newNoteRecived: "新しいノートがあるで" newNoteRecived: "新しいノートがあるで"
sounds: "サウンド" sounds: "サウンド"
sound: "サウンド" sound: "サウンド"
@ -909,7 +910,7 @@ subscribePushNotification: "プッシュ通知をオンにするで"
unsubscribePushNotification: "プッシュ通知を止めるで" unsubscribePushNotification: "プッシュ通知を止めるで"
pushNotificationAlreadySubscribed: "プッシュ通知はオンになってるで" pushNotificationAlreadySubscribed: "プッシュ通知はオンになってるで"
pushNotificationNotSupported: "ブラウザかインスタンスがプッシュ通知に対応してないみたいやで。" pushNotificationNotSupported: "ブラウザかインスタンスがプッシュ通知に対応してないみたいやで。"
sendPushNotificationReadMessage: "通知やメッセージが既読ったらプッシュ通知を消すで" sendPushNotificationReadMessage: "通知やメッセージが既読になったらプッシュ通知を消すで"
sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}」っていう表示が一瞬表示されるようになるで。端末の電池使用量が増える可能性があるで。" sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}」っていう表示が一瞬表示されるようになるで。端末の電池使用量が増える可能性があるで。"
windowMaximize: "最大化" windowMaximize: "最大化"
windowRestore: "元に戻す" windowRestore: "元に戻す"
@ -955,6 +956,9 @@ exploreOtherServers: "他のサーバー見てみる"
letsLookAtTimeline: "タイムライン見てみーや" letsLookAtTimeline: "タイムライン見てみーや"
disableFederationWarn: "連合が無効になっとるで。無効にしても投稿は非公開ってわけちゃうねん。大体の場合はこのオプションを有効にする必要は別にないで。" disableFederationWarn: "連合が無効になっとるで。無効にしても投稿は非公開ってわけちゃうねん。大体の場合はこのオプションを有効にする必要は別にないで。"
invitationRequiredToRegister: "今このサーバー招待制になってもうてんねん。招待コードを持っとるんやったら登録できるで。" invitationRequiredToRegister: "今このサーバー招待制になってもうてんねん。招待コードを持っとるんやったら登録できるで。"
emailNotSupported: "このサーバーはメール配信がサポートされてへんみたいやわ"
postToTheChannel: "チャンネルに投稿"
cannotBeChangedLater: "後からは変えられへんで。"
_achievements: _achievements:
earnedAt: "貰った日ぃ" earnedAt: "貰った日ぃ"
_types: _types:
@ -1072,7 +1076,7 @@ _achievements:
description: "プロフィールを設定した" description: "プロフィールを設定した"
_markedAsCat: _markedAsCat:
title: "吾輩は猫やねん" title: "吾輩は猫やねん"
description: "アカウントがCatになってもうた" description: "アカウントをCatにしたった"
flavor: "名前はまだないねん。" flavor: "名前はまだないねん。"
_following1: _following1:
title: "はじめてのフォロー" title: "はじめてのフォロー"

View File

@ -168,7 +168,9 @@ done: "ສຳເລັດ"
processing: "ກຳລັງປະມວນຜົນ" processing: "ກຳລັງປະມວນຜົນ"
preview: "ສະແດງເປັນຕົວຢ່າງ" preview: "ສະແດງເປັນຕົວຢ່າງ"
default: "ຄ່າເລີ່ມຕົ້ນ" default: "ຄ່າເລີ່ມຕົ້ນ"
federating: "ສະຫະພັນ"
blocked: "ບລັອກແລ້ວ " blocked: "ບລັອກແລ້ວ "
suspended: "ໂຈະ"
all: "ທັງໝົດ" all: "ທັງໝົດ"
subscribing: "ສະໝັກສະມາຊິກແລັວ" subscribing: "ສະໝັກສະມາຊິກແລັວ"
publishing: "ການ​ພິມ​ເຜີຍ​ແຜ່" publishing: "ການ​ພິມ​ເຜີຍ​ແຜ່"
@ -177,15 +179,35 @@ instanceFollowing: "ກຳລັງຕິດຕາມສຸດຕົວຢ່າ
instanceFollowers: "ຜູ້ຕິດຕາມຕົວຢ່າງ" instanceFollowers: "ຜູ້ຕິດຕາມຕົວຢ່າງ"
instanceUsers: "ຜູ້​ຊົມ​ໃຊ້​ຂອງ​ຕົວ​ຢ່າງ​ນີ້​" instanceUsers: "ຜູ້​ຊົມ​ໃຊ້​ຂອງ​ຕົວ​ຢ່າງ​ນີ້​"
changePassword: "ປ່ຽນ​ລະ​ຫັດ​ຜ່ານ" changePassword: "ປ່ຽນ​ລະ​ຫັດ​ຜ່ານ"
security: "ຄວາມປອດໄພ"
retypedNotMatch: "ວັດສະດຸປ້ອນບໍ່ກົງກັນ"
currentPassword: "ລະຫັດຜ່ານປະຈຸບັນ"
more: "ເພີ່ມເຕີມ!"
featured: "ໄຮໄລທ໌" featured: "ໄຮໄລທ໌"
usernameOrUserId: "ຊື່ຜູ້ໃຊ້ ຫຼື id ຜູ້ໃຊ້"
noSuchUser: "ບໍ່ພົບຜູ້ໃຊ້"
lookup: "ຄົ້ນ​ຫາ"
announcements: "ປະກາດ" announcements: "ປະກາດ"
imageUrl: "URL ຮູບພາບ"
remove: "ລຶບ" remove: "ລຶບ"
removed: "ລຶບແລ້ວ"
resetAreYouSure: "ຣີ​ເຊັດບໍ?"
saved: "ບັນທຶກແລ້ວ"
messaging: "ແຊ໋ດ" messaging: "ແຊ໋ດ"
upload: "ອັບໂຫຼດ"
keepOriginalUploading: "ຮັກສາຮູບພາບຕົ້ນສະບັບ"
fromUrl: "ຈາກ URL"
uploadFromUrl: "ອັບໂຫຼດຈາກ URL"
uploadFromUrlDescription: "URL ຂອງໄຟລ໌ທີ່ທ່ານຕ້ອງການອັບໂຫລດ"
messageRead: "ອ່ານແລ້ວ"
startMessaging: "ເລີ່ມການສົນທະນາໃໝ່"
nUsersRead: "ອ່ານໂດຍ {n}"
tos: "ເງື່ອນໄຂການໃຫ້ບໍລິການ" tos: "ເງື່ອນໄຂການໃຫ້ບໍລິການ"
start: "ເລີ່ມຕົ້ນນຳໃຊ້ເລີຍ" start: "ເລີ່ມຕົ້ນນຳໃຊ້ເລີຍ"
home: "ໜ້າຫຼັກ" home: "ໜ້າຫຼັກ"
images: "ຮູບພາບ" images: "ຮູບພາບ"
birthday: "ວັນເກີດ" birthday: "ວັນເກີດ"
yearsOld: "{age} ປີ"
registeredDate: "ວັນທີ່ເປັນສະມາຊິກ" registeredDate: "ວັນທີ່ເປັນສະມາຊິກ"
location: "ທີ່ຕັ້ງ" location: "ທີ່ຕັ້ງ"
theme: "ແທ໋ມ" theme: "ແທ໋ມ"
@ -193,17 +215,96 @@ light: "ສະຫວ່າງ"
dark: "ມືດ" dark: "ມືດ"
lightThemes: "ຊຸດຮູບແບບສະຫວ່າງ" lightThemes: "ຊຸດຮູບແບບສະຫວ່າງ"
darkThemes: "ຮູບແບບສີສັນມືດ" darkThemes: "ຮູບແບບສີສັນມືດ"
drive: "ຂັບ"
fileName: "ຊື່ໄຟລ໌" fileName: "ຊື່ໄຟລ໌"
selectFile: "ເລືອກໄຟລ໌" selectFile: "ເລືອກໄຟລ໌"
selectFiles: "ເລືອກໄຟລ໌" selectFiles: "ເລືອກໄຟລ໌"
selectFolder: "ເລືອກໂຟລເດີ"
selectFolders: "ເລືອກໂຟລເດີ"
renameFile: "ປ່ຽນຊື່ໄຟລ໌"
folderName: "ຊື່ໂຟນເດີ"
createFolder: "​ສ້າງ​ໂຟ​ລ​ເດີ"
renameFolder: "ປ່ຽນຊື່ໂຟນເດີນີ້"
deleteFolder: "ລົບໂຟ​ລ​ເດີ​"
addFile: "ເພີ່ມໄຟລ໌"
emptyDrive: "Drive ຂອງທ່ານຫວ່າງເປົ່າ"
emptyFolder: "ໂຟນເດີນີ້ເປົ່າຫວ່າງ"
unableToDelete: "ບໍ່​ສາ​ມາດລົບໄດ້"
inputNewFileName: "ໃສ່ຊື່ໄຟລ໌ໃໝ່"
inputNewDescription: "ໃສ່ຄຳບັນຍາຍໃໝ່"
inputNewFolderName: "ໃສ່ຊື່ໂຟນເດີໃໝ່"
circularReferenceFolder: "ໂຟນເດີປາຍທາງແມ່ນໂຟນເດີຍ່ອຍຂອງໂຟນເດີທີ່ທ່ານຕ້ອງການຍ້າຍ"
rename: "ປ່ຽນຊື່"
nsfw: "NSFW" nsfw: "NSFW"
watch: "ເບິ່ງ"
unwatch: "ຢຸດເບິ່ງ"
accept: "ອະນຸຍາດ" accept: "ອະນຸຍາດ"
reject: "ປະຕິເສດ"
normal: "ປົກກະຕິ"
instanceName: "ຊື່ເຊີເວີ້"
instanceDescription: "ຄໍາອະທິບາຍຕົວຢ່າງ"
maintainerName: "ຜູ້ດູແລ"
maintainerEmail: "ອີເມວ admin"
tosUrl: "ເງື່ອນໄຂການໃຫ້ບໍລິການ URL"
thisYear: "ປີນີ້"
thisMonth: "ເດືອນນີ້"
today: "ມື້ນີ້"
dayX: "ວັນ {day}"
monthX: "ເດືອນ {month}"
yearX: "ປີ {year}"
pages: "ໜ້າ"
integration: "ຄວາມສຳພັນຂອງ"
connectService: "ເຊື່ອມຕໍ່"
disconnectService: "ຕັດການເຊື່ອມຕໍ່"
enableLocalTimeline: "ເປີດໃຊ້ທາມລາຍທ້ອງຖິ່ນ"
enableGlobalTimeline: "ເປີດໃຊ້ທາມລາຍທົ່ວໂລກ"
disablingTimelinesInfo: "ຜູ້ເບິ່ງແຍງລະບົບ ແລະຜູ້ຄວບຄຸມຈະມີການເຂົ້າເຖິງທຸກກຳນົດເວລາ, ເຖິງແມ່ນວ່າຈະບໍ່ໄດ້ເປີດໃຊ້ງານກໍຕາມ"
registration: "ລົງທະບຽນ"
enableRegistration: "ເປີດໃຊ້ການລົງທະບຽນຜູ້ໃຊ້ໃໝ່"
invite: "ເຊີນ"
driveCapacityPerLocalAccount: "ຄວາມອາດສາມາດຂັບຕໍ່ຜູ້ໃຊ້ທ້ອງຖິ່ນ"
driveCapacityPerRemoteAccount: "ໄດຣຟ໌ຄວາມອາດສາມາດຕໍ່ຜູ້ໃຊ້ທາງໄກ"
pinnedNotes: "ບັນທຶກທີ່ປັກໝຸດໄວ້" pinnedNotes: "ບັນທຶກທີ່ປັກໝຸດໄວ້"
userList: "ລາຍການ" userList: "ລາຍການ"
about: "ກ່ຽວກັບ"
aboutMisskey: "ກ່ຽວກັບ Misskey"
administrator: "ຜູ້ບໍລິຫານ"
share: "ແບ່ງປັນ"
notFound: "ບໍ່ພົບ"
cacheClear: "ລຶບລ້າງແຄສ"
invites: "ເຊີນ"
title: "ຫົວຂໍ້"
text: "ຂໍ້ຄວາມ"
enable: "ເປີດໃຊ້"
next: "ຕໍ່ໄປ"
invitations: "ເຊີນ"
language: "ພາສາ"
native: "ພາ​ສາ​ແມ່"
category: "ຫມວດຫມູ່"
tags: "ແທ໋ກ"
createAccount: "ສ້າງບັນຊີ"
existingAccount: "ທີ່ມີຢູ່"
dashboard: "ໜ້າປັດ"
local: "ທ້ອງຖິ່ນ"
objectStorageRegion: "ພາກ​ພື້ນ"
sounds: "ສຽງ"
sound: "ສຽງ"
none: "ບໍ່ມີ"
volume: "ລະດັບສຽງ"
details: "ລາຍລະອຽດ"
install: "ຕິດຕັ້ງ"
uninstall: "ຖອນການຕິດຕັ້ງ"
state: "ສະຖານະ"
sort: "ຈັດຮຽງໂດຍ"
ascendingOrder: "ນ້ອຍໄປຫາໃຫຍ່"
descendingOrder: "ໃຫຍ່ຫານ້ອຍ"
output: "ຜົນຜະລິດ"
script: "ບົດ​ຄວາມ"
smtpHost: "ໂຮດສ" smtpHost: "ໂຮດສ"
smtpUser: "ຊື່ຜູ້ໃຊ້" smtpUser: "ຊື່ຜູ້ໃຊ້"
smtpPass: "ລະຫັດຜ່ານ" smtpPass: "ລະຫັດຜ່ານ"
clearCache: "ລຶບລ້າງແຄສ" clearCache: "ລຶບລ້າງແຄສ"
info: "ກ່ຽວກັບ"
user: "ຜູ້ໃຊ້ຕ່າງໆ" user: "ຜູ້ໃຊ້ຕ່າງໆ"
searchByGoogle: "ຄົ້ນຫາ" searchByGoogle: "ຄົ້ນຫາ"
file: "ໄຟລ໌" file: "ໄຟລ໌"
@ -244,6 +345,8 @@ _charts:
federation: "ສະຫະພັນ" federation: "ສະຫະພັນ"
_timelines: _timelines:
home: "ໜ້າຫຼັກ" home: "ໜ້າຫຼັກ"
_play:
script: "ບົດ​ຄວາມ"
_pages: _pages:
blocks: blocks:
image: "ຮູບພາບ" image: "ຮູບພາບ"

View File

@ -268,7 +268,7 @@ remoteUserCaution: "เนื่องจากผู้ใช้งานรา
activity: "กิจกรรม" activity: "กิจกรรม"
images: "รูปภาพ" images: "รูปภาพ"
birthday: "วันเกิด" birthday: "วันเกิด"
yearsOld: "{อายุ} ปี" yearsOld: "{age} ปี"
registeredDate: "วันที่สมัครสมาชิก" registeredDate: "วันที่สมัครสมาชิก"
location: "ตำแหน่งที่ตั้ง" location: "ตำแหน่งที่ตั้ง"
theme: "ธีม" theme: "ธีม"
@ -506,6 +506,7 @@ objectStorageSetPublicRead: "ตั้งค่า \"public-read\" ในกา
serverLogs: "บันทึกของเซิร์ฟเวอร์" serverLogs: "บันทึกของเซิร์ฟเวอร์"
deleteAll: "ลบทั้งหมด" deleteAll: "ลบทั้งหมด"
showFixedPostForm: "แสดงแบบฟอร์มการโพสต์ที่ด้านบนสุดของไทม์ไลน์" showFixedPostForm: "แสดงแบบฟอร์มการโพสต์ที่ด้านบนสุดของไทม์ไลน์"
showFixedPostFormInChannel: "แสดงแบบฟอร์มกำลังโพสต์ที่ด้านบนของไทม์ไลน์ (แชนแนล)"
newNoteRecived: "มีโน้ตใหม่" newNoteRecived: "มีโน้ตใหม่"
sounds: "เสียง" sounds: "เสียง"
sound: "เสียง" sound: "เสียง"
@ -955,6 +956,9 @@ exploreOtherServers: "มองหาอินสแตนซ์อื่น"
letsLookAtTimeline: "ลองดูที่ไทม์ไลน์" letsLookAtTimeline: "ลองดูที่ไทม์ไลน์"
disableFederationWarn: "การดำเนินการนี้ถ้าหากจะปิดใช้งานการรวมศูนย์ แต่โพสต์ดังกล่าวนั้นจะยังคงเป็นสาธารณะต่อไป ยกเว้นแต่ว่าจะตั้งค่าเป็นอย่างอื่น โดยปกติคุณไม่จำเป็นต้องใช้การตั้งค่านี้นะ" disableFederationWarn: "การดำเนินการนี้ถ้าหากจะปิดใช้งานการรวมศูนย์ แต่โพสต์ดังกล่าวนั้นจะยังคงเป็นสาธารณะต่อไป ยกเว้นแต่ว่าจะตั้งค่าเป็นอย่างอื่น โดยปกติคุณไม่จำเป็นต้องใช้การตั้งค่านี้นะ"
invitationRequiredToRegister: "อินสแตนซ์นี้เป็นแบบรับเชิญเท่านั้น คุณต้องป้อนรหัสเชิญที่ถูกต้องถึงจะลงทะเบียนได้นะค่ะ" invitationRequiredToRegister: "อินสแตนซ์นี้เป็นแบบรับเชิญเท่านั้น คุณต้องป้อนรหัสเชิญที่ถูกต้องถึงจะลงทะเบียนได้นะค่ะ"
emailNotSupported: "อินสแตนซ์นี้ไม่รองรับการส่งอีเมลนะค่ะ"
postToTheChannel: "โพสต์ลงช่อง"
cannotBeChangedLater: "สิ่งนี้ไม่สามารถเปลี่ยนแปลงได้ในภายหลังนะ"
_achievements: _achievements:
earnedAt: "ได้รับเมื่อ" earnedAt: "ได้รับเมื่อ"
_types: _types:

View File

@ -2,7 +2,7 @@
_lang_: "中文(简体)" _lang_: "中文(简体)"
headlineMisskey: "通过帖子连接在一起的网络" headlineMisskey: "通过帖子连接在一起的网络"
introMisskey: "欢迎Misskey是一个开源的、去中心化的“微博客”服务。\n通过编写「帖文」来和大家分享你的以及你周围的事情吧📡\n通过「回应」功能可以让你快速地对大家的帖文表达反馈👍\n来探索新的世界吧🚀" introMisskey: "欢迎Misskey是一个开源的、去中心化的“微博客”服务。\n通过编写「帖文」来和大家分享你的以及你周围的事情吧📡\n通过「回应」功能可以让你快速地对大家的帖文表达反馈👍\n来探索新的世界吧🚀"
poweredByMisskeyDescription: "{name} 由开源平台 <b>Misskey</b> 驱动(也被称为 Misskey 实例" poweredByMisskeyDescription: "{name} 由开源平台 <b>Misskey</b> 驱动(也被称为 Misskey 服务器"
monthAndDay: "{month}月 {day}日" monthAndDay: "{month}月 {day}日"
search: "搜索" search: "搜索"
notifications: "通知" notifications: "通知"
@ -18,7 +18,7 @@ enterUsername: "输入用户名"
renotedBy: "由 {user} 转贴" renotedBy: "由 {user} 转贴"
noNotes: "没有帖子" noNotes: "没有帖子"
noNotifications: "无通知" noNotifications: "无通知"
instance: "实例" instance: "服务器"
settings: "设置" settings: "设置"
basicSettings: "基本设置" basicSettings: "基本设置"
otherSettings: "其他设置" otherSettings: "其他设置"
@ -144,7 +144,7 @@ emojiUrl: "表情符号地址"
addEmoji: "添加表情符号" addEmoji: "添加表情符号"
settingGuide: "推荐配置" settingGuide: "推荐配置"
cacheRemoteFiles: "远程文件缓存" cacheRemoteFiles: "远程文件缓存"
cacheRemoteFilesDescription: "当禁用此设定时远程文件将直接从远程实例载入。禁用后会减小储存空间需求,但是会增加流量,因为缩略图不会被生成。" cacheRemoteFilesDescription: "当禁用此设定时远程文件将直接从远程服务器载入。禁用后会减小储存空间需求,但是会增加流量,因为缩略图不会被生成。"
flagAsBot: "这是一个机器人账号" flagAsBot: "这是一个机器人账号"
flagAsBotDescription: "如果此帐户由程序控制请启用此项。启用后此标志可以帮助其他开发人员防止机器人之间产生无限互动的行为并让Misskey的内部系统将此帐户识别为机器人。" flagAsBotDescription: "如果此帐户由程序控制请启用此项。启用后此标志可以帮助其他开发人员防止机器人之间产生无限互动的行为并让Misskey的内部系统将此帐户识别为机器人。"
flagAsCat: "将这个账户设定为一只猫" flagAsCat: "将这个账户设定为一只猫"
@ -154,7 +154,7 @@ flagShowTimelineRepliesDescription: "启用时,时间线除了显示用户的
autoAcceptFollowed: "自动允许关注者的关注" autoAcceptFollowed: "自动允许关注者的关注"
addAccount: "添加账户" addAccount: "添加账户"
loginFailed: "登录失败" loginFailed: "登录失败"
showOnRemote: "转到所在实例显示" showOnRemote: "转到所在服务器显示"
general: "常规设置" general: "常规设置"
wallpaper: "壁纸" wallpaper: "壁纸"
setWallpaper: "设置壁纸" setWallpaper: "设置壁纸"
@ -169,7 +169,7 @@ selectUser: "选择用户"
recipient: "收件人" recipient: "收件人"
annotation: "注解" annotation: "注解"
federation: "联合" federation: "联合"
instances: "实例" instances: "服务器"
registeredAt: "初次观测" registeredAt: "初次观测"
latestRequestReceivedAt: "上次收到的请求" latestRequestReceivedAt: "上次收到的请求"
latestStatus: "最后状态" latestStatus: "最后状态"
@ -178,7 +178,7 @@ charts: "图表"
perHour: "每小时" perHour: "每小时"
perDay: "每天" perDay: "每天"
stopActivityDelivery: "停止发送活动" stopActivityDelivery: "停止发送活动"
blockThisInstance: "阻止此实例向本实例推流" blockThisInstance: "阻止此服务器向本服务器推流"
operations: "操作" operations: "操作"
software: "软件" software: "软件"
version: "版本" version: "版本"
@ -189,15 +189,15 @@ jobQueue: "作业队列"
cpuAndMemory: "CPU和内存" cpuAndMemory: "CPU和内存"
network: "网络" network: "网络"
disk: "存储" disk: "存储"
instanceInfo: "实例信息" instanceInfo: "服务器信息"
statistics: "统计" statistics: "统计"
clearQueue: "清除队列" clearQueue: "清除队列"
clearQueueConfirmTitle: "确定清除队列?" clearQueueConfirmTitle: "确定清除队列?"
clearQueueConfirmText: "未送达的帖子将不会送达。 通常,您不需要这样做。" clearQueueConfirmText: "未送达的帖子将不会送达。 通常,您不需要这样做。"
clearCachedFiles: "清除缓存" clearCachedFiles: "清除缓存"
clearCachedFilesConfirm: "确定要清除缓存文件?" clearCachedFilesConfirm: "确定要清除缓存文件?"
blockedInstances: "被阻拦的实例" blockedInstances: "被阻拦的服务器"
blockedInstancesDescription: "设定要阻拦的实例,以换行来进行分割。被阻拦的实例将无法与本实例进行交换通讯。" blockedInstancesDescription: "设定要阻拦的服务器,以换行来进行分割。被阻拦的服务器将无法与本服务器进行交换通讯。"
muteAndBlock: "屏蔽/拉黑" muteAndBlock: "屏蔽/拉黑"
mutedUsers: "已屏蔽用户" mutedUsers: "已屏蔽用户"
blockedUsers: "被拉黑的用户" blockedUsers: "被拉黑的用户"
@ -220,9 +220,9 @@ all: "全部"
subscribing: "已订阅" subscribing: "已订阅"
publishing: "投递中" publishing: "投递中"
notResponding: "没有响应" notResponding: "没有响应"
instanceFollowing: "关注实例" instanceFollowing: "关注服务器"
instanceFollowers: "关注实例" instanceFollowers: "关注的服务器"
instanceUsers: "实例用户" instanceUsers: "服务器用户"
changePassword: "修改密码" changePassword: "修改密码"
security: "安全" security: "安全"
retypedNotMatch: "两次输入不一致!" retypedNotMatch: "两次输入不一致!"
@ -264,7 +264,7 @@ basicNotesBeforeCreateAccount: "基本注意事项"
tos: "服务条款" tos: "服务条款"
start: "开始" start: "开始"
home: "首页" home: "首页"
remoteUserCaution: "由于此用户来自其它实例,显示的信息可能不完整。" remoteUserCaution: "由于此用户来自其它服务器,显示的信息可能不完整。"
activity: "活动" activity: "活动"
images: "图片" images: "图片"
birthday: "生日" birthday: "生日"
@ -314,8 +314,8 @@ unwatch: "取消关注"
accept: "允许" accept: "允许"
reject: "拒绝" reject: "拒绝"
normal: "正常" normal: "正常"
instanceName: "实例名称" instanceName: "服务器名称"
instanceDescription: "实例介绍" instanceDescription: "服务器简介"
maintainerName: "管理员名称" maintainerName: "管理员名称"
maintainerEmail: "管理员电子邮箱" maintainerEmail: "管理员电子邮箱"
tosUrl: "服务条款URL" tosUrl: "服务条款URL"
@ -345,7 +345,7 @@ basicInfo: "基本信息"
pinnedUsers: "置顶用户" pinnedUsers: "置顶用户"
pinnedUsersDescription: "在「发现」页面中使用换行标记想要置顶的用户。" pinnedUsersDescription: "在「发现」页面中使用换行标记想要置顶的用户。"
pinnedPages: "固定页面" pinnedPages: "固定页面"
pinnedPagesDescription: "输入您要固定到实例首页的页面路径,以换行符分隔。" pinnedPagesDescription: "输入您要固定到服务器首页的页面路径,以换行符分隔。"
pinnedClipId: "置顶的便签ID" pinnedClipId: "置顶的便签ID"
pinnedNotes: "已置顶的帖子" pinnedNotes: "已置顶的帖子"
hcaptcha: "hCaptcha" hcaptcha: "hCaptcha"
@ -506,6 +506,7 @@ objectStorageSetPublicRead: "上传时设置为public-read"
serverLogs: "服务器日志" serverLogs: "服务器日志"
deleteAll: "全部删除" deleteAll: "全部删除"
showFixedPostForm: "在时间线顶部显示发帖框" showFixedPostForm: "在时间线顶部显示发帖框"
showFixedPostFormInChannel: "在时间线顶部显示发帖对话框(频道)"
newNoteRecived: "有新的帖子" newNoteRecived: "有新的帖子"
sounds: "提示音" sounds: "提示音"
sound: "提示音" sound: "提示音"
@ -538,7 +539,7 @@ updateRemoteUser: "更新远程用户信息"
deleteAllFiles: "删除所有文件" deleteAllFiles: "删除所有文件"
deleteAllFilesConfirm: "要删除所有文件吗?" deleteAllFilesConfirm: "要删除所有文件吗?"
removeAllFollowing: "取消所有关注" removeAllFollowing: "取消所有关注"
removeAllFollowingDescription: "取消{host}的所有关注者。当实例不存在时执行。" removeAllFollowingDescription: "取消{host}的所有关注者。当服务器不再存在时执行。"
userSuspended: "该用户已被冻结。" userSuspended: "该用户已被冻结。"
userSilenced: "该用户已被禁言。" userSilenced: "该用户已被禁言。"
yourAccountSuspendedTitle: "账户已被冻结" yourAccountSuspendedTitle: "账户已被冻结"
@ -635,15 +636,15 @@ abuseReported: "内容已发送。感谢您提交信息。"
reporter: "举报者" reporter: "举报者"
reporteeOrigin: "举报来源" reporteeOrigin: "举报来源"
reporterOrigin: "举报者来源" reporterOrigin: "举报者来源"
forwardReport: "将该举报信息转发给远程实例" forwardReport: "将该举报信息转发给远程服务器"
forwardReportIsAnonymous: "勾选则在远程实例上显示的举报者是匿名的系统账号,而不是您的账号。" forwardReportIsAnonymous: "勾选则在远程服务器上显示的举报者是匿名的系统账号,而不是您的账号。"
send: "发送" send: "发送"
abuseMarkAsResolved: "处理完毕" abuseMarkAsResolved: "处理完毕"
openInNewTab: "在新标签页中打开" openInNewTab: "在新标签页中打开"
openInSideView: "在侧边栏中打开" openInSideView: "在侧边栏中打开"
defaultNavigationBehaviour: "默认导航" defaultNavigationBehaviour: "默认导航"
editTheseSettingsMayBreakAccount: "编辑这些设置可以会损坏您的账号" editTheseSettingsMayBreakAccount: "编辑这些设置可以会损坏您的账号"
instanceTicker: "帖子的实例信息" instanceTicker: "帖子的服务器来源"
waitingFor: "等待{x}" waitingFor: "等待{x}"
random: "随机" random: "随机"
system: "系统" system: "系统"
@ -732,7 +733,7 @@ capacity: "容量"
inUse: "已使用" inUse: "已使用"
editCode: "编辑代码" editCode: "编辑代码"
apply: "应用" apply: "应用"
receiveAnnouncementFromInstance: "从实例接收通知" receiveAnnouncementFromInstance: "从服务器接收通知"
emailNotification: "邮件通知" emailNotification: "邮件通知"
publish: "发布" publish: "发布"
inChannelSearch: "频道内搜索" inChannelSearch: "频道内搜索"
@ -760,7 +761,7 @@ active: "活动"
offline: "离线" offline: "离线"
notRecommended: "不推荐" notRecommended: "不推荐"
botProtection: "Bot防御" botProtection: "Bot防御"
instanceBlocking: "被阻拦的实例" instanceBlocking: "被阻拦的服务器"
selectAccount: "选择账户" selectAccount: "选择账户"
switchAccount: "切换账户" switchAccount: "切换账户"
enabled: "已启用" enabled: "已启用"
@ -844,8 +845,8 @@ themeColor: "主题颜色"
size: "大小" size: "大小"
numberOfColumn: "列数" numberOfColumn: "列数"
searchByGoogle: "Google" searchByGoogle: "Google"
instanceDefaultLightTheme: "实例默认浅色主题" instanceDefaultLightTheme: "服务器默认浅色主题"
instanceDefaultDarkTheme: "实例默认深色主题" instanceDefaultDarkTheme: "服务器默认深色主题"
instanceDefaultThemeDescription: "以对象格式键入主题代码" instanceDefaultThemeDescription: "以对象格式键入主题代码"
mutePeriod: "屏蔽期限" mutePeriod: "屏蔽期限"
period: "截止时间" period: "截止时间"
@ -898,7 +899,7 @@ cannotUploadBecauseInappropriate: "因为可能含有不适宜的内容,无法
cannotUploadBecauseNoFreeSpace: "因为已无可用空间,无法上传。" cannotUploadBecauseNoFreeSpace: "因为已无可用空间,无法上传。"
beta: "测试" beta: "测试"
enableAutoSensitive: "自动 NSFW 识别" enableAutoSensitive: "自动 NSFW 识别"
enableAutoSensitiveDescription: "如果可用,请使用机器学习在媒体上自动设置 NSFW 标志。即使关闭此功能,也可能会根据实例自动设置。" enableAutoSensitiveDescription: "如果可用,请使用机器学习在媒体上自动设置 NSFW 标志。即使关闭此功能,也可能会根据服务器自动设置。"
activeEmailValidationDescription: "开启用户的电子邮件地址验证,判断它是一次性的电子邮件地址,还是可以实际通信的地址。关闭时,则只检查字符串是否正确。" activeEmailValidationDescription: "开启用户的电子邮件地址验证,判断它是一次性的电子邮件地址,还是可以实际通信的地址。关闭时,则只检查字符串是否正确。"
navbar: "导航栏" navbar: "导航栏"
shuffle: "随机" shuffle: "随机"
@ -908,7 +909,7 @@ pushNotification: "推送通知"
subscribePushNotification: "启用推送通知消息" subscribePushNotification: "启用推送通知消息"
unsubscribePushNotification: "停用推送通知消息" unsubscribePushNotification: "停用推送通知消息"
pushNotificationAlreadySubscribed: "推送通知消息已启用" pushNotificationAlreadySubscribed: "推送通知消息已启用"
pushNotificationNotSupported: "浏览器或实例不支持推送通知消息" pushNotificationNotSupported: "浏览器或服务器不支持推送通知消息"
sendPushNotificationReadMessage: "删除已读推送通知消息" sendPushNotificationReadMessage: "删除已读推送通知消息"
sendPushNotificationReadMessageCaption: "“{emptyPushNotificationMessage}”的通知消息将会显示。您终端设备的电池消耗可能会增加。" sendPushNotificationReadMessageCaption: "“{emptyPushNotificationMessage}”的通知消息将会显示。您终端设备的电池消耗可能会增加。"
windowMaximize: "最大化" windowMaximize: "最大化"
@ -950,11 +951,14 @@ collapseRenotes: "省略显示已经看过的转发内容"
internalServerError: "内部服务器错误" internalServerError: "内部服务器错误"
internalServerErrorDescription: "内部服务器发生了预期外的错误" internalServerErrorDescription: "内部服务器发生了预期外的错误"
copyErrorInfo: "复制错误信息" copyErrorInfo: "复制错误信息"
joinThisServer: "在本实例上注册" joinThisServer: "在本服务器上注册"
exploreOtherServers: "探索其他实例" exploreOtherServers: "探索其他服务器"
letsLookAtTimeline: "时间线" letsLookAtTimeline: "时间线"
disableFederationWarn: "联合被禁用。 禁用它并不能使帖子变成私人的。 在大多数情况下,这个选项不需要被启用。" disableFederationWarn: "联合被禁用。 禁用它并不能使帖子变成私人的。 在大多数情况下,这个选项不需要被启用。"
invitationRequiredToRegister: "此实例目前只允许拥有邀请码的人注册。" invitationRequiredToRegister: "此服务器目前只允许拥有邀请码的人注册。"
emailNotSupported: "此服务器不支持发送邮件"
postToTheChannel: "发布到频道"
cannotBeChangedLater: "之后不能再更改。"
_achievements: _achievements:
earnedAt: "达成时间" earnedAt: "达成时间"
_types: _types:
@ -1145,7 +1149,7 @@ _achievements:
description: "在首页时间线的流速超过20npm" description: "在首页时间线的流速超过20npm"
_viewInstanceChart: _viewInstanceChart:
title: "分析师" title: "分析师"
description: "查看了实例信息中的图表" description: "查看了服务器信息中的图表"
_outputHelloWorldOnScratchpad: _outputHelloWorldOnScratchpad:
title: "Hello, world!" title: "Hello, world!"
description: "在AiScript控制台中输出 hello world" description: "在AiScript控制台中输出 hello world"
@ -1182,7 +1186,7 @@ _achievements:
_loggedInOnNewYearsDay: _loggedInOnNewYearsDay:
title: "恭贺新禧" title: "恭贺新禧"
description: "在元旦登入" description: "在元旦登入"
flavor: "今年也请对本实例多多指教!" flavor: "今年也请对本服务器多多指教!"
_cookieClicked: _cookieClicked:
title: "点击饼干小游戏" title: "点击饼干小游戏"
description: "点击了可疑的饼干" description: "点击了可疑的饼干"
@ -1197,7 +1201,7 @@ _role:
name: "角色名称" name: "角色名称"
description: "角色描述" description: "角色描述"
permission: "角色权限" permission: "角色权限"
descriptionOfPermission: "<b>监察员</b>可以执行基本的审核操作。\n<b>管理员</b>可以更改实例的所有设置。" descriptionOfPermission: "<b>监察员</b>可以执行基本地审核操作。\n<b>管理员</b>可以更改服务器的所有设置。"
assignTarget: "授权对象" assignTarget: "授权对象"
descriptionOfAssignTarget: "<b>手动</b>指手动选择谁被包括在这个角色中。\n<b>符合条件</b>指设置条件以自动包括符合条件的用户。" descriptionOfAssignTarget: "<b>手动</b>指手动选择谁被包括在这个角色中。\n<b>符合条件</b>指设置条件以自动包括符合条件的用户。"
manual: "手动" manual: "手动"
@ -1287,7 +1291,7 @@ _ad:
_forgotPassword: _forgotPassword:
enterEmail: "请输入您验证账号时用的电子邮箱地址,密码重置链接将发送至该邮箱上。" enterEmail: "请输入您验证账号时用的电子邮箱地址,密码重置链接将发送至该邮箱上。"
ifNoEmail: "如果您没有使用电子邮件地址进行验证,请联系管理员。" ifNoEmail: "如果您没有使用电子邮件地址进行验证,请联系管理员。"
contactAdmin: "该实例不支持发送电子邮件。如果您想重设密码,请联系管理员。" contactAdmin: "该服务器不支持发送电子邮件。如果您想重设密码,请联系管理员。"
_gallery: _gallery:
my: "我的图库" my: "我的图库"
liked: "喜欢的图片" liked: "喜欢的图片"
@ -1372,10 +1376,10 @@ _wordMute:
hard: "硬屏蔽" hard: "硬屏蔽"
mutedNotes: "被屏蔽的帖子" mutedNotes: "被屏蔽的帖子"
_instanceMute: _instanceMute:
instanceMuteDescription: "屏蔽配置实例中的所有帖子和转帖,包括实例的用户回复。" instanceMuteDescription: "屏蔽配置服务器中的所有帖子和转帖,包括这些服务器上的用户回复。"
instanceMuteDescription2: "设置时用换行符来分隔" instanceMuteDescription2: "设置时用换行符来分隔"
title: "隐藏实例已设置的帖子。" title: "隐藏服务器已设置的帖子。"
heading: "屏蔽实例" heading: "屏蔽服务器"
_theme: _theme:
explore: "寻找主题" explore: "寻找主题"
install: "安装主题" install: "安装主题"
@ -1583,7 +1587,7 @@ _weekday:
saturday: "星期六" saturday: "星期六"
_widgets: _widgets:
profile: "个人资料" profile: "个人资料"
instanceInfo: "实例信息" instanceInfo: "服务器信息"
memo: "便签" memo: "便签"
notifications: "通知" notifications: "通知"
timeline: "时间线" timeline: "时间线"
@ -1597,7 +1601,7 @@ _widgets:
digitalClock: "数字时钟" digitalClock: "数字时钟"
unixClock: "UNIX时钟" unixClock: "UNIX时钟"
federation: "联邦宇宙" federation: "联邦宇宙"
instanceCloud: "实例云" instanceCloud: "服务器云"
postForm: "投稿窗口" postForm: "投稿窗口"
slideshow: "幻灯片展示" slideshow: "幻灯片展示"
button: "按钮" button: "按钮"
@ -1648,7 +1652,7 @@ _visibility:
specified: "指定用户" specified: "指定用户"
specifiedDescription: "仅发送至指定用户" specifiedDescription: "仅发送至指定用户"
disableFederation: "不参与联合" disableFederation: "不参与联合"
disableFederationDescription: "不发送到其他实例" disableFederationDescription: "不发送到其他服务器"
_postForm: _postForm:
replyPlaceholder: "回复这个帖子..." replyPlaceholder: "回复这个帖子..."
quotePlaceholder: "引用这个帖子..." quotePlaceholder: "引用这个帖子..."

View File

@ -506,6 +506,7 @@ objectStorageSetPublicRead: "上傳時設定為\"public-read\""
serverLogs: "伺服器日誌" serverLogs: "伺服器日誌"
deleteAll: "刪除所有記錄" deleteAll: "刪除所有記錄"
showFixedPostForm: "於時間軸頁頂顯示「發送貼文」方框" showFixedPostForm: "於時間軸頁頂顯示「發送貼文」方框"
showFixedPostFormInChannel: "於時間軸頁頂顯示「發送貼文」方框(頻道)"
newNoteRecived: "發現新的貼文" newNoteRecived: "發現新的貼文"
sounds: "音效" sounds: "音效"
sound: "音效" sound: "音效"
@ -955,6 +956,8 @@ exploreOtherServers: "探索其他伺服器"
letsLookAtTimeline: "看看時間軸" letsLookAtTimeline: "看看時間軸"
disableFederationWarn: "聯邦被停用了。即使停用也不會讓您的貼文不公開,在大多數情況下,不需要啟用這個選項。" disableFederationWarn: "聯邦被停用了。即使停用也不會讓您的貼文不公開,在大多數情況下,不需要啟用這個選項。"
invitationRequiredToRegister: "目前這個伺服器為邀請制,必須擁有邀請碼才能註冊。" invitationRequiredToRegister: "目前這個伺服器為邀請制,必須擁有邀請碼才能註冊。"
emailNotSupported: "這個伺服器不支援寄送郵件"
postToTheChannel: "發布到頻道"
_achievements: _achievements:
earnedAt: "獲得日期" earnedAt: "獲得日期"
_types: _types:

View File

@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "13.9.1", "version": "13.9.2",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",

View File

@ -124,6 +124,7 @@
"seedrandom": "3.0.5", "seedrandom": "3.0.5",
"semver": "7.3.8", "semver": "7.3.8",
"sharp": "0.31.3", "sharp": "0.31.3",
"sharp-read-bmp": "github:misskey-dev/sharp-read-bmp",
"strict-event-emitter-types": "2.0.0", "strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0", "stringz": "2.1.0",
"summaly": "github:misskey-dev/summaly", "summaly": "github:misskey-dev/summaly",

View File

@ -6,6 +6,7 @@ import IPCIDR from 'ip-cidr';
import PrivateIp from 'private-ip'; import PrivateIp from 'private-ip';
import chalk from 'chalk'; import chalk from 'chalk';
import got, * as Got from 'got'; import got, * as Got from 'got';
import { parse } from 'content-disposition';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import { HttpRequestService } from '@/core/HttpRequestService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js';
@ -32,13 +33,18 @@ export class DownloadService {
} }
@bindThis @bindThis
public async downloadUrl(url: string, path: string): Promise<void> { public async downloadUrl(url: string, path: string): Promise<{
filename: string;
}> {
this.logger.info(`Downloading ${chalk.cyan(url)} to ${chalk.cyanBright(path)} ...`); this.logger.info(`Downloading ${chalk.cyan(url)} to ${chalk.cyanBright(path)} ...`);
const timeout = 30 * 1000; const timeout = 30 * 1000;
const operationTimeout = 60 * 1000; const operationTimeout = 60 * 1000;
const maxSize = this.config.maxFileSize ?? 262144000; const maxSize = this.config.maxFileSize ?? 262144000;
const urlObj = new URL(url);
let filename = urlObj.pathname.split('/').pop() ?? 'untitled';
const req = got.stream(url, { const req = got.stream(url, {
headers: { headers: {
'User-Agent': this.config.userAgent, 'User-Agent': this.config.userAgent,
@ -77,6 +83,14 @@ export class DownloadService {
req.destroy(); req.destroy();
} }
} }
const contentDisposition = res.headers['content-disposition'];
if (contentDisposition != null) {
const parsed = parse(contentDisposition);
if (parsed.parameters.filename) {
filename = parsed.parameters.filename;
}
}
}).on('downloadProgress', (progress: Got.Progress) => { }).on('downloadProgress', (progress: Got.Progress) => {
if (progress.transferred > maxSize) { if (progress.transferred > maxSize) {
this.logger.warn(`maxSize exceeded (${progress.transferred} > ${maxSize}) on downloadProgress`); this.logger.warn(`maxSize exceeded (${progress.transferred} > ${maxSize}) on downloadProgress`);
@ -95,6 +109,10 @@ export class DownloadService {
} }
this.logger.succ(`Download finished: ${chalk.cyan(url)}`); this.logger.succ(`Download finished: ${chalk.cyan(url)}`);
return {
filename,
};
} }
@bindThis @bindThis

View File

@ -34,6 +34,7 @@ import { FileInfoService } from '@/core/FileInfoService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import type S3 from 'aws-sdk/clients/s3.js'; import type S3 from 'aws-sdk/clients/s3.js';
import { correctFilename } from '@/misc/correct-filename.js';
type AddFileArgs = { type AddFileArgs = {
/** User who wish to add file */ /** User who wish to add file */
@ -168,7 +169,7 @@ export class DriveService {
//#region Uploads //#region Uploads
this.registerLogger.info(`uploading original: ${key}`); this.registerLogger.info(`uploading original: ${key}`);
const uploads = [ const uploads = [
this.upload(key, fs.createReadStream(path), type, name), this.upload(key, fs.createReadStream(path), type, ext, name),
]; ];
if (alts.webpublic) { if (alts.webpublic) {
@ -176,7 +177,7 @@ export class DriveService {
webpublicUrl = `${ baseUrl }/${ webpublicKey }`; webpublicUrl = `${ baseUrl }/${ webpublicKey }`;
this.registerLogger.info(`uploading webpublic: ${webpublicKey}`); this.registerLogger.info(`uploading webpublic: ${webpublicKey}`);
uploads.push(this.upload(webpublicKey, alts.webpublic.data, alts.webpublic.type, name)); uploads.push(this.upload(webpublicKey, alts.webpublic.data, alts.webpublic.type, alts.webpublic.ext, name));
} }
if (alts.thumbnail) { if (alts.thumbnail) {
@ -184,7 +185,7 @@ export class DriveService {
thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`; thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`;
this.registerLogger.info(`uploading thumbnail: ${thumbnailKey}`); this.registerLogger.info(`uploading thumbnail: ${thumbnailKey}`);
uploads.push(this.upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type)); uploads.push(this.upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type, alts.thumbnail.ext));
} }
await Promise.all(uploads); await Promise.all(uploads);
@ -360,7 +361,7 @@ export class DriveService {
* Upload to ObjectStorage * Upload to ObjectStorage
*/ */
@bindThis @bindThis
private async upload(key: string, stream: fs.ReadStream | Buffer, type: string, filename?: string) { private async upload(key: string, stream: fs.ReadStream | Buffer, type: string, ext?: string | null, filename?: string) {
if (type === 'image/apng') type = 'image/png'; if (type === 'image/apng') type = 'image/png';
if (!FILE_TYPE_BROWSERSAFE.includes(type)) type = 'application/octet-stream'; if (!FILE_TYPE_BROWSERSAFE.includes(type)) type = 'application/octet-stream';
@ -374,7 +375,12 @@ export class DriveService {
CacheControl: 'max-age=31536000, immutable', CacheControl: 'max-age=31536000, immutable',
} as S3.PutObjectRequest; } as S3.PutObjectRequest;
if (filename) params.ContentDisposition = contentDisposition('inline', filename); if (filename) params.ContentDisposition = contentDisposition(
'inline',
// 拡張子からContent-Typeを設定してそうな挙動を示すオブジェクトストレージ (upcloud?) も存在するので、
// 許可されているファイル形式でしか拡張子をつけない
ext ? correctFilename(filename, ext) : filename,
);
if (meta.objectStorageSetPublicRead) params.ACL = 'public-read'; if (meta.objectStorageSetPublicRead) params.ACL = 'public-read';
const s3 = this.s3Service.getS3(meta); const s3 = this.s3Service.getS3(meta);
@ -466,7 +472,12 @@ export class DriveService {
//} //}
// detect name // detect name
const detectedName = name ?? (info.type.ext ? `untitled.${info.type.ext}` : 'untitled'); const detectedName = correctFilename(
// DriveFile.nameは256文字, validateFileNameは200文字制限であるため、
// extを付加してデータベースの文字数制限に当たることはまずない
(name && this.driveFileEntityService.validateFileName(name)) ? name : 'untitled',
info.type.ext
);
if (user && !force) { if (user && !force) {
// Check if there is a file with the same hash // Check if there is a file with the same hash
@ -736,10 +747,12 @@ export class DriveService {
requestIp = null, requestIp = null,
requestHeaders = null, requestHeaders = null,
}: UploadFromUrlArgs): Promise<DriveFile> { }: UploadFromUrlArgs): Promise<DriveFile> {
let name = new URL(url).pathname.split('/').pop() ?? null; // Create temp file
if (name == null || !this.driveFileEntityService.validateFileName(name)) { const [path, cleanup] = await createTemp();
name = null;
} try {
// write content at URL to temp file
const { filename: name } = await this.downloadService.downloadUrl(url, path);
// If the comment is same as the name, skip comment // If the comment is same as the name, skip comment
// (image.name is passed in when receiving attachment) // (image.name is passed in when receiving attachment)
@ -747,13 +760,6 @@ export class DriveService {
comment = null; comment = null;
} }
// Create temp file
const [path, cleanup] = await createTemp();
try {
// write content at URL to temp file
await this.downloadService.downloadUrl(url, path);
const driveFile = await this.addFile({ user, path, name, comment, folderId, force, isLink, url, uri, sensitive, requestIp, requestHeaders }); const driveFile = await this.addFile({ user, path, name, comment, folderId, force, isLink, url, uri, sensitive, requestIp, requestHeaders });
this.downloaderLogger.succ(`Got: ${driveFile.id}`); this.downloaderLogger.succ(`Got: ${driveFile.id}`);
return driveFile!; return driveFile!;

View File

@ -1,5 +1,5 @@
import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { forwardRef, Inject, Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { DataSource, In } from 'typeorm';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { NotesRepository, DriveFilesRepository } from '@/models/index.js'; import type { NotesRepository, DriveFilesRepository } from '@/models/index.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
@ -21,6 +21,7 @@ type PackOptions = {
}; };
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { isMimeImage } from '@/misc/is-mime-image.js'; import { isMimeImage } from '@/misc/is-mime-image.js';
import { isNotNull } from '@/misc/is-not-null.js';
@Injectable() @Injectable()
export class DriveFileEntityService { export class DriveFileEntityService {
@ -255,10 +256,33 @@ export class DriveFileEntityService {
@bindThis @bindThis
public async packMany( public async packMany(
files: (DriveFile['id'] | DriveFile)[], files: DriveFile[],
options?: PackOptions, options?: PackOptions,
): Promise<Packed<'DriveFile'>[]> { ): Promise<Packed<'DriveFile'>[]> {
const items = await Promise.all(files.map(f => this.packNullable(f, options))); const items = await Promise.all(files.map(f => this.packNullable(f, options)));
return items.filter((x): x is Packed<'DriveFile'> => x != null); return items.filter((x): x is Packed<'DriveFile'> => x != null);
} }
@bindThis
public async packManyByIdsMap(
fileIds: DriveFile['id'][],
options?: PackOptions,
): Promise<Map<Packed<'DriveFile'>['id'], Packed<'DriveFile'> | null>> {
const files = await this.driveFilesRepository.findBy({ id: In(fileIds) });
const packedFiles = await this.packMany(files, options);
const map = new Map<Packed<'DriveFile'>['id'], Packed<'DriveFile'> | null>(packedFiles.map(f => [f.id, f]));
for (const id of fileIds) {
if (!map.has(id)) map.set(id, null);
}
return map;
}
@bindThis
public async packManyByIds(
fileIds: DriveFile['id'][],
options?: PackOptions,
): Promise<Packed<'DriveFile'>[]> {
const filesMap = await this.packManyByIdsMap(fileIds, options);
return fileIds.map(id => filesMap.get(id)).filter(isNotNull);
}
} }

View File

@ -41,7 +41,8 @@ export class GalleryPostEntityService {
title: post.title, title: post.title,
description: post.description, description: post.description,
fileIds: post.fileIds, fileIds: post.fileIds,
files: this.driveFileEntityService.packMany(post.fileIds), // TODO: packMany causes N+1 queries
files: this.driveFileEntityService.packManyByIds(post.fileIds),
tags: post.tags.length > 0 ? post.tags : undefined, tags: post.tags.length > 0 ? post.tags : undefined,
isSensitive: post.isSensitive, isSensitive: post.isSensitive,
likedCount: post.likedCount, likedCount: post.likedCount,

View File

@ -11,6 +11,7 @@ import type { Note } from '@/models/entities/Note.js';
import type { NoteReaction } from '@/models/entities/NoteReaction.js'; import type { NoteReaction } from '@/models/entities/NoteReaction.js';
import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, DriveFilesRepository } from '@/models/index.js'; import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, DriveFilesRepository } from '@/models/index.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { isNotNull } from '@/misc/is-not-null.js';
import type { OnModuleInit } from '@nestjs/common'; import type { OnModuleInit } from '@nestjs/common';
import type { CustomEmojiService } from '../CustomEmojiService.js'; import type { CustomEmojiService } from '../CustomEmojiService.js';
import type { ReactionService } from '../ReactionService.js'; import type { ReactionService } from '../ReactionService.js';
@ -248,6 +249,21 @@ export class NoteEntityService implements OnModuleInit {
return true; return true;
} }
@bindThis
public async packAttachedFiles(fileIds: Note['fileIds'], packedFiles: Map<Note['fileIds'][number], Packed<'DriveFile'> | null>): Promise<Packed<'DriveFile'>[]> {
const missingIds = [];
for (const id of fileIds) {
if (!packedFiles.has(id)) missingIds.push(id);
}
if (missingIds.length) {
const additionalMap = await this.driveFileEntityService.packManyByIdsMap(missingIds);
for (const [k, v] of additionalMap) {
packedFiles.set(k, v);
}
}
return fileIds.map(id => packedFiles.get(id)).filter(isNotNull);
}
@bindThis @bindThis
public async pack( public async pack(
src: Note['id'] | Note, src: Note['id'] | Note,
@ -257,6 +273,7 @@ export class NoteEntityService implements OnModuleInit {
skipHide?: boolean; skipHide?: boolean;
_hint_?: { _hint_?: {
myReactions: Map<Note['id'], NoteReaction | null>; myReactions: Map<Note['id'], NoteReaction | null>;
packedFiles: Map<Note['fileIds'][number], Packed<'DriveFile'> | null>;
}; };
}, },
): Promise<Packed<'Note'>> { ): Promise<Packed<'Note'>> {
@ -284,6 +301,7 @@ export class NoteEntityService implements OnModuleInit {
const reactionEmojiNames = Object.keys(note.reactions) const reactionEmojiNames = Object.keys(note.reactions)
.filter(x => x.startsWith(':') && x.includes('@') && !x.includes('@.')) // リモートカスタム絵文字のみ .filter(x => x.startsWith(':') && x.includes('@') && !x.includes('@.')) // リモートカスタム絵文字のみ
.map(x => this.reactionService.decodeReaction(x).reaction.replaceAll(':', '')); .map(x => this.reactionService.decodeReaction(x).reaction.replaceAll(':', ''));
const packedFiles = options?._hint_?.packedFiles;
const packed: Packed<'Note'> = await awaitAll({ const packed: Packed<'Note'> = await awaitAll({
id: note.id, id: note.id,
@ -304,7 +322,7 @@ export class NoteEntityService implements OnModuleInit {
emojis: host != null ? this.customEmojiService.populateEmojis(note.emojis, host) : undefined, emojis: host != null ? this.customEmojiService.populateEmojis(note.emojis, host) : undefined,
tags: note.tags.length > 0 ? note.tags : undefined, tags: note.tags.length > 0 ? note.tags : undefined,
fileIds: note.fileIds, fileIds: note.fileIds,
files: this.driveFileEntityService.packMany(note.fileIds), files: packedFiles != null ? this.packAttachedFiles(note.fileIds, packedFiles) : this.driveFileEntityService.packManyByIds(note.fileIds),
replyId: note.replyId, replyId: note.replyId,
renoteId: note.renoteId, renoteId: note.renoteId,
channelId: note.channelId ?? undefined, channelId: note.channelId ?? undefined,
@ -388,11 +406,15 @@ export class NoteEntityService implements OnModuleInit {
} }
await this.customEmojiService.prefetchEmojis(this.customEmojiService.aggregateNoteEmojis(notes)); await this.customEmojiService.prefetchEmojis(this.customEmojiService.aggregateNoteEmojis(notes));
// TODO: 本当は renote とか reply がないのに renoteId とか replyId があったらここで解決しておく
const fileIds = notes.map(n => [n.fileIds, n.renote?.fileIds, n.reply?.fileIds]).flat(2).filter(isNotNull);
const packedFiles = await this.driveFileEntityService.packManyByIdsMap(fileIds);
return await Promise.all(notes.map(n => this.pack(n, me, { return await Promise.all(notes.map(n => this.pack(n, me, {
...options, ...options,
_hint_: { _hint_: {
myReactions: myReactionsMap, myReactions: myReactionsMap,
packedFiles,
}, },
}))); })));
} }

View File

@ -278,27 +278,27 @@ export class UserEntityService implements OnModuleInit {
@bindThis @bindThis
public async getAvatarUrl(user: User): Promise<string> { public async getAvatarUrl(user: User): Promise<string> {
if (user.avatar) { if (user.avatar) {
return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user.id); return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user);
} else if (user.avatarId) { } else if (user.avatarId) {
const avatar = await this.driveFilesRepository.findOneByOrFail({ id: user.avatarId }); const avatar = await this.driveFilesRepository.findOneByOrFail({ id: user.avatarId });
return this.driveFileEntityService.getPublicUrl(avatar, 'avatar') ?? this.getIdenticonUrl(user.id); return this.driveFileEntityService.getPublicUrl(avatar, 'avatar') ?? this.getIdenticonUrl(user);
} else { } else {
return this.getIdenticonUrl(user.id); return this.getIdenticonUrl(user);
} }
} }
@bindThis @bindThis
public getAvatarUrlSync(user: User): string { public getAvatarUrlSync(user: User): string {
if (user.avatar) { if (user.avatar) {
return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user.id); return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user);
} else { } else {
return this.getIdenticonUrl(user.id); return this.getIdenticonUrl(user);
} }
} }
@bindThis @bindThis
public getIdenticonUrl(userId: User['id']): string { public getIdenticonUrl(user: User): string {
return `${this.config.url}/identicon/${userId}`; return `${this.config.url}/identicon/${user.username.toLowerCase()}@${user.host ?? this.config.host}`;
} }
public async pack<ExpectsMe extends boolean | null = null, D extends boolean = false>( public async pack<ExpectsMe extends boolean | null = null, D extends boolean = false>(

View File

@ -0,0 +1,15 @@
// 与えられた拡張子とファイル名が一致しているかどうかを確認し、
// 一致していない場合は拡張子を付与して返す
export function correctFilename(filename: string, ext: string | null) {
const dotExt = ext ? ext.startsWith('.') ? ext : `.${ext}` : '.unknown';
if (filename.endsWith(dotExt)) {
return filename;
}
if (ext === 'jpg' && filename.endsWith('.jpeg')) {
return filename;
}
if (ext === 'tif' && filename.endsWith('.tiff')) {
return filename;
}
return `${filename}${dotExt}`;
}

View File

@ -4,6 +4,8 @@ const dictionary = {
'safe-file': FILE_TYPE_BROWSERSAFE, 'safe-file': FILE_TYPE_BROWSERSAFE,
'sharp-convertible-image': ['image/jpeg', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/avif', 'image/svg+xml'], 'sharp-convertible-image': ['image/jpeg', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/avif', 'image/svg+xml'],
'sharp-animation-convertible-image': ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/svg+xml'], 'sharp-animation-convertible-image': ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/svg+xml'],
'sharp-convertible-image-with-bmp': ['image/jpeg', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/avif', 'image/svg+xml', 'image/x-icon', 'image/bmp'],
'sharp-animation-convertible-image-with-bmp': ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/svg+xml', 'image/x-icon', 'image/bmp'],
}; };
export const isMimeImage = (mime: string, type: keyof typeof dictionary): boolean => dictionary[type].includes(mime); export const isMimeImage = (mime: string, type: keyof typeof dictionary): boolean => dictionary[type].includes(mime);

View File

@ -22,6 +22,8 @@ import { bindThis } from '@/decorators.js';
import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify'; import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify';
import { isMimeImage } from '@/misc/is-mime-image.js'; import { isMimeImage } from '@/misc/is-mime-image.js';
import sharp from 'sharp'; import sharp from 'sharp';
import { sharpBmp } from 'sharp-read-bmp';
import { correctFilename } from '@/misc/correct-filename.js';
const _filename = fileURLToPath(import.meta.url); const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename); const _dirname = dirname(_filename);
@ -51,15 +53,6 @@ export class FileServerService {
//this.createServer = this.createServer.bind(this); //this.createServer = this.createServer.bind(this);
} }
@bindThis
public commonReadableHandlerGenerator(reply: FastifyReply) {
return (err: Error): void => {
this.logger.error(err);
reply.code(500);
reply.header('Cache-Control', 'max-age=300');
};
}
@bindThis @bindThis
public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) { public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) {
fastify.addHook('onRequest', (request, reply, done) => { fastify.addHook('onRequest', (request, reply, done) => {
@ -140,7 +133,7 @@ export class FileServerService {
let image: IImageStreamable | null = null; let image: IImageStreamable | null = null;
if (file.fileRole === 'thumbnail') { if (file.fileRole === 'thumbnail') {
if (isMimeImage(file.mime, 'sharp-convertible-image')) { if (isMimeImage(file.mime, 'sharp-convertible-image-with-bmp')) {
reply.header('Cache-Control', 'max-age=31536000, immutable'); reply.header('Cache-Control', 'max-age=31536000, immutable');
const url = new URL(`${this.config.mediaProxy}/static.webp`); const url = new URL(`${this.config.mediaProxy}/static.webp`);
@ -190,13 +183,19 @@ export class FileServerService {
} }
reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(image.type) ? image.type : 'application/octet-stream'); reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(image.type) ? image.type : 'application/octet-stream');
reply.header('Content-Disposition',
contentDisposition(
'inline',
correctFilename(file.filename, image.ext)
)
);
return image.data; return image.data;
} }
if (file.fileRole !== 'original') { if (file.fileRole !== 'original') {
const filename = rename(file.file.name, { const filename = rename(file.filename, {
suffix: file.fileRole === 'thumbnail' ? '-thumb' : '-web', suffix: file.fileRole === 'thumbnail' ? '-thumb' : '-web',
extname: file.ext ? `.${file.ext}` : undefined, extname: file.ext ? `.${file.ext}` : '.unknown',
}).toString(); }).toString();
reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.mime) ? file.mime : 'application/octet-stream'); reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.mime) ? file.mime : 'application/octet-stream');
@ -204,12 +203,10 @@ export class FileServerService {
reply.header('Content-Disposition', contentDisposition('inline', filename)); reply.header('Content-Disposition', contentDisposition('inline', filename));
return fs.createReadStream(file.path); return fs.createReadStream(file.path);
} else { } else {
const stream = fs.createReadStream(file.path);
stream.on('error', this.commonReadableHandlerGenerator(reply));
reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.file.type) ? file.file.type : 'application/octet-stream'); reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.file.type) ? file.file.type : 'application/octet-stream');
reply.header('Cache-Control', 'max-age=31536000, immutable'); reply.header('Cache-Control', 'max-age=31536000, immutable');
reply.header('Content-Disposition', contentDisposition('inline', file.file.name)); reply.header('Content-Disposition', contentDisposition('inline', file.filename));
return stream; return fs.createReadStream(file.path);
} }
} catch (e) { } catch (e) {
if ('cleanup' in file) file.cleanup(); if ('cleanup' in file) file.cleanup();
@ -261,8 +258,8 @@ export class FileServerService {
} }
try { try {
const isConvertibleImage = isMimeImage(file.mime, 'sharp-convertible-image'); const isConvertibleImage = isMimeImage(file.mime, 'sharp-convertible-image-with-bmp');
const isAnimationConvertibleImage = isMimeImage(file.mime, 'sharp-animation-convertible-image'); const isAnimationConvertibleImage = isMimeImage(file.mime, 'sharp-animation-convertible-image-with-bmp');
if ( if (
'emoji' in request.query || 'emoji' in request.query ||
@ -286,7 +283,7 @@ export class FileServerService {
type: file.mime, type: file.mime,
}; };
} else { } else {
const data = sharp(file.path, { animated: !('static' in request.query) }) const data = (await sharpBmp(file.path, file.mime, { animated: !('static' in request.query) }))
.resize({ .resize({
height: 'emoji' in request.query ? 128 : 320, height: 'emoji' in request.query ? 128 : 320,
withoutEnlargement: true, withoutEnlargement: true,
@ -300,11 +297,11 @@ export class FileServerService {
}; };
} }
} else if ('static' in request.query) { } else if ('static' in request.query) {
image = this.imageProcessingService.convertToWebpStream(file.path, 498, 280); image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 498, 280);
} else if ('preview' in request.query) { } else if ('preview' in request.query) {
image = this.imageProcessingService.convertToWebpStream(file.path, 200, 200); image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 200, 200);
} else if ('badge' in request.query) { } else if ('badge' in request.query) {
const mask = sharp(file.path) const mask = (await sharpBmp(file.path, file.mime))
.resize(96, 96, { .resize(96, 96, {
fit: 'inside', fit: 'inside',
withoutEnlargement: false, withoutEnlargement: false,
@ -360,6 +357,12 @@ export class FileServerService {
reply.header('Content-Type', image.type); reply.header('Content-Type', image.type);
reply.header('Cache-Control', 'max-age=31536000, immutable'); reply.header('Cache-Control', 'max-age=31536000, immutable');
reply.header('Content-Disposition',
contentDisposition(
'inline',
correctFilename(file.filename, image.ext)
)
);
return image.data; return image.data;
} catch (e) { } catch (e) {
if ('cleanup' in file) file.cleanup(); if ('cleanup' in file) file.cleanup();
@ -369,8 +372,8 @@ export class FileServerService {
@bindThis @bindThis
private async getStreamAndTypeFromUrl(url: string): Promise< private async getStreamAndTypeFromUrl(url: string): Promise<
{ state: 'remote'; fileRole?: 'thumbnail' | 'webpublic' | 'original'; file?: DriveFile; mime: string; ext: string | null; path: string; cleanup: () => void; } { state: 'remote'; fileRole?: 'thumbnail' | 'webpublic' | 'original'; file?: DriveFile; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; }
| { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; mime: string; ext: string | null; path: string; } | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; filename: string; mime: string; ext: string | null; path: string; }
| '404' | '404'
| '204' | '204'
> { > {
@ -386,11 +389,11 @@ export class FileServerService {
@bindThis @bindThis
private async downloadAndDetectTypeFromUrl(url: string): Promise< private async downloadAndDetectTypeFromUrl(url: string): Promise<
{ state: 'remote' ; mime: string; ext: string | null; path: string; cleanup: () => void; } { state: 'remote' ; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; }
> { > {
const [path, cleanup] = await createTemp(); const [path, cleanup] = await createTemp();
try { try {
await this.downloadService.downloadUrl(url, path); const { filename } = await this.downloadService.downloadUrl(url, path);
const { mime, ext } = await this.fileInfoService.detectType(path); const { mime, ext } = await this.fileInfoService.detectType(path);
@ -398,6 +401,7 @@ export class FileServerService {
state: 'remote', state: 'remote',
mime, ext, mime, ext,
path, cleanup, path, cleanup,
filename,
}; };
} catch (e) { } catch (e) {
cleanup(); cleanup();
@ -407,8 +411,8 @@ export class FileServerService {
@bindThis @bindThis
private async getFileFromKey(key: string): Promise< private async getFileFromKey(key: string): Promise<
{ state: 'remote'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; url: string; mime: string; ext: string | null; path: string; cleanup: () => void; } { state: 'remote'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; filename: string; url: string; mime: string; ext: string | null; path: string; cleanup: () => void; }
| { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; mime: string; ext: string | null; path: string; } | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; filename: string; mime: string; ext: string | null; path: string; }
| '404' | '404'
| '204' | '204'
> { > {
@ -432,6 +436,7 @@ export class FileServerService {
url: file.uri, url: file.uri,
fileRole: isThumbnail ? 'thumbnail' : isWebpublic ? 'webpublic' : 'original', fileRole: isThumbnail ? 'thumbnail' : isWebpublic ? 'webpublic' : 'original',
file, file,
filename: file.name,
}; };
} }
@ -443,6 +448,7 @@ export class FileServerService {
state: 'stored_internal', state: 'stored_internal',
fileRole: isThumbnail ? 'thumbnail' : 'webpublic', fileRole: isThumbnail ? 'thumbnail' : 'webpublic',
file, file,
filename: file.name,
mime, ext, mime, ext,
path, path,
}; };
@ -452,6 +458,7 @@ export class FileServerService {
state: 'stored_internal', state: 'stored_internal',
fileRole: 'original', fileRole: 'original',
file, file,
filename: file.name,
mime: file.type, mime: file.type,
ext: null, ext: null,
path, path,

View File

@ -61,6 +61,13 @@
renderError('META_FETCH_V'); renderError('META_FETCH_V');
return; return;
} }
// for https://github.com/misskey-dev/misskey/issues/10202
if (lang == null || lang.toString == null || lang.toString() === 'null') {
console.error('invalid lang value detected!!!', typeof lang, lang);
lang = 'en-US';
}
const localRes = await window.fetch(`/assets/locales/${lang}.${v}.json`); const localRes = await window.fetch(`/assets/locales/${lang}.${v}.json`);
if (localRes.status === 200) { if (localRes.status === 200) {
localStorage.setItem('lang', lang); localStorage.setItem('lang', lang);

View File

@ -410,11 +410,19 @@ describe('Endpoints', () => {
}); });
test('ファイルに名前を付けられる', async () => { test('ファイルに名前を付けられる', async () => {
const res = await uploadFile(alice, { name: 'Belmond.jpg' });
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
assert.strictEqual(res.body.name, 'Belmond.jpg');
});
test('ファイルに名前を付けられるが、拡張子は正しいものになる', async () => {
const res = await uploadFile(alice, { name: 'Belmond.png' }); const res = await uploadFile(alice, { name: 'Belmond.png' });
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
assert.strictEqual(res.body.name, 'Belmond.png'); assert.strictEqual(res.body.name, 'Belmond.png.jpg');
}); });
test('ファイル無しで怒られる', async () => { test('ファイル無しで怒られる', async () => {

View File

@ -2,7 +2,7 @@ process.env.NODE_ENV = 'test';
import * as assert from 'assert'; import * as assert from 'assert';
import { Note } from '@/models/entities/Note.js'; import { Note } from '@/models/entities/Note.js';
import { signup, post, uploadUrl, startServer, initTestDb, api } from '../utils.js'; import { signup, post, uploadUrl, startServer, initTestDb, api, uploadFile } from '../utils.js';
import type { INestApplicationContext } from '@nestjs/common'; import type { INestApplicationContext } from '@nestjs/common';
describe('Note', () => { describe('Note', () => {
@ -213,6 +213,122 @@ describe('Note', () => {
assert.deepStrictEqual(noteDoc.mentions, [bob.id]); assert.deepStrictEqual(noteDoc.mentions, [bob.id]);
}); });
describe('添付ファイル情報', () => {
test('ファイルを添付した場合、投稿成功時にファイル情報入りのレスポンスが帰ってくる', async () => {
const file = await uploadFile(alice);
const res = await api('/notes/create', {
fileIds: [file.body.id],
}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
assert.strictEqual(res.body.createdNote.files.length, 1);
assert.strictEqual(res.body.createdNote.files[0].id, file.body.id);
});
test('ファイルを添付した場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => {
const file = await uploadFile(alice);
const createdNote = await api('/notes/create', {
fileIds: [file.body.id],
}, alice);
assert.strictEqual(createdNote.status, 200);
const res = await api('/notes', {
withFiles: true,
}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
const myNote = res.body.find((note: { id: string; files: { id: string }[] }) => note.id === createdNote.body.createdNote.id);
assert.notEqual(myNote, null);
assert.strictEqual(myNote.files.length, 1);
assert.strictEqual(myNote.files[0].id, file.body.id);
});
test('ファイルが添付されたノートをリノートした場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => {
const file = await uploadFile(alice);
const createdNote = await api('/notes/create', {
fileIds: [file.body.id],
}, alice);
assert.strictEqual(createdNote.status, 200);
const renoted = await api('/notes/create', {
renoteId: createdNote.body.createdNote.id,
}, alice);
assert.strictEqual(renoted.status, 200);
const res = await api('/notes', {
renote: true,
}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
const myNote = res.body.find((note: { id: string }) => note.id === renoted.body.createdNote.id);
assert.notEqual(myNote, null);
assert.strictEqual(myNote.renote.files.length, 1);
assert.strictEqual(myNote.renote.files[0].id, file.body.id);
});
test('ファイルが添付されたノートに返信した場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => {
const file = await uploadFile(alice);
const createdNote = await api('/notes/create', {
fileIds: [file.body.id],
}, alice);
assert.strictEqual(createdNote.status, 200);
const reply = await api('/notes/create', {
replyId: createdNote.body.createdNote.id,
text: 'this is reply',
}, alice);
assert.strictEqual(reply.status, 200);
const res = await api('/notes', {
reply: true,
}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
const myNote = res.body.find((note: { id: string }) => note.id === reply.body.createdNote.id);
assert.notEqual(myNote, null);
assert.strictEqual(myNote.reply.files.length, 1);
assert.strictEqual(myNote.reply.files[0].id, file.body.id);
});
test('ファイルが添付されたノートへの返信をリノートした場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => {
const file = await uploadFile(alice);
const createdNote = await api('/notes/create', {
fileIds: [file.body.id],
}, alice);
assert.strictEqual(createdNote.status, 200);
const reply = await api('/notes/create', {
replyId: createdNote.body.createdNote.id,
text: 'this is reply',
}, alice);
assert.strictEqual(reply.status, 200);
const renoted = await api('/notes/create', {
renoteId: reply.body.createdNote.id,
}, alice);
assert.strictEqual(renoted.status, 200);
const res = await api('/notes', {
renote: true,
}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
const myNote = res.body.find((note: { id: string }) => note.id === renoted.body.createdNote.id);
assert.notEqual(myNote, null);
assert.strictEqual(myNote.renote.reply.files.length, 1);
assert.strictEqual(myNote.renote.reply.files[0].id, file.body.id);
});
});
describe('notes/create', () => { describe('notes/create', () => {
test('投票を添付できる', async () => { test('投票を添付できる', async () => {
const res = await api('/notes/create', { const res = await api('/notes/create', {

View File

@ -0,0 +1,42 @@
import { describe, test, expect } from '@jest/globals';
import { contentDisposition } from '@/misc/content-disposition.js';
import { correctFilename } from '@/misc/correct-filename.js';
describe('misc:content-disposition', () => {
test('inline', () => {
expect(contentDisposition('inline', 'foo bar')).toBe('inline; filename=\"foo_bar\"; filename*=UTF-8\'\'foo%20bar');
});
test('attachment', () => {
expect(contentDisposition('attachment', 'foo bar')).toBe('attachment; filename=\"foo_bar\"; filename*=UTF-8\'\'foo%20bar');
});
test('non ascii', () => {
expect(contentDisposition('attachment', 'ファイル名')).toBe('attachment; filename=\"_____\"; filename*=UTF-8\'\'%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E5%90%8D');
});
});
describe('misc:correct-filename', () => {
test('simple', () => {
expect(correctFilename('filename', 'jpg')).toBe('filename.jpg');
});
test('with same ext', () => {
expect(correctFilename('filename.jpg', 'jpg')).toBe('filename.jpg');
});
test('.ext', () => {
expect(correctFilename('filename.jpg', '.jpg')).toBe('filename.jpg');
});
test('with different ext', () => {
expect(correctFilename('filename.webp', 'jpg')).toBe('filename.webp.jpg');
});
test('non ascii with space', () => {
expect(correctFilename('ファイル 名前', 'jpg')).toBe('ファイル 名前.jpg');
});
test('jpeg', () => {
expect(correctFilename('filename.jpeg', 'jpg')).toBe('filename.jpeg');
});
test('tiff', () => {
expect(correctFilename('filename.tiff', 'tif')).toBe('filename.tiff');
});
test('null ext', () => {
expect(correctFilename('filename', null)).toBe('filename.unknown');
});
});

View File

@ -1,6 +1,8 @@
<template> <template>
<div ref="rootEl" :class="[$style.root, { [$style.opened]: opened }]"> <div ref="rootEl" :class="$style.root">
<div :class="$style.header" class="_button" @click="toggle"> <MkStickyContainer>
<template #header>
<div :class="[$style.header, { [$style.opened]: opened }]" class="_button" @click="toggle">
<div :class="$style.headerIcon"><slot name="icon"></slot></div> <div :class="$style.headerIcon"><slot name="icon"></slot></div>
<div :class="$style.headerText"> <div :class="$style.headerText">
<div :class="$style.headerTextMain"> <div :class="$style.headerTextMain">
@ -16,7 +18,9 @@
<i v-else class="ti ti-chevron-down icon"></i> <i v-else class="ti ti-chevron-down icon"></i>
</div> </div>
</div> </div>
<div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : null }"> </template>
<div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : null, overflow: maxHeight ? `auto` : null }">
<Transition <Transition
:enter-active-class="$store.state.animation ? $style.transition_toggle_enterActive : ''" :enter-active-class="$store.state.animation ? $style.transition_toggle_enterActive : ''"
:leave-active-class="$store.state.animation ? $style.transition_toggle_leaveActive : ''" :leave-active-class="$store.state.animation ? $style.transition_toggle_leaveActive : ''"
@ -36,6 +40,7 @@
</KeepAlive> </KeepAlive>
</Transition> </Transition>
</div> </div>
</MkStickyContainer>
</div> </div>
</template> </template>
@ -117,12 +122,6 @@ onMounted(() => {
.root { .root {
display: block; display: block;
&.opened {
> .header {
border-radius: 6px 6px 0 0;
}
}
} }
.header { .header {
@ -132,6 +131,8 @@ onMounted(() => {
box-sizing: border-box; box-sizing: border-box;
padding: 9px 12px 9px 12px; padding: 9px 12px 9px 12px;
background: var(--buttonBg); background: var(--buttonBg);
-webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px));
border-radius: 6px; border-radius: 6px;
transition: border-radius 0.3s; transition: border-radius 0.3s;
@ -144,6 +145,10 @@ onMounted(() => {
color: var(--accent); color: var(--accent);
background: var(--buttonHoverBg); background: var(--buttonHoverBg);
} }
&.opened {
border-radius: 6px 6px 0 0;
}
} }
.headerUpper { .headerUpper {
@ -202,7 +207,6 @@ onMounted(() => {
background: var(--panel); background: var(--panel);
border-radius: 0 0 6px 6px; border-radius: 0 0 6px 6px;
container-type: inline-size; container-type: inline-size;
overflow: auto;
&.bgSame { &.bgSame {
background: var(--bg); background: var(--bg);

View File

@ -21,14 +21,14 @@
<div v-else ref="rootEl"> <div v-else ref="rootEl">
<div v-show="pagination.reversed && more" key="_more_" class="_margin"> <div v-show="pagination.reversed && more" key="_more_" class="_margin">
<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? fetchMoreAhead : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMoreAhead"> <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? fetchMoreAhead : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMoreAhead">
{{ i18n.ts.loadMore }} {{ i18n.ts.loadMore }}
</MkButton> </MkButton>
<MkLoading v-else class="loading"/> <MkLoading v-else class="loading"/>
</div> </div>
<slot :items="items" :fetching="fetching || moreFetching"></slot> <slot :items="items" :fetching="fetching || moreFetching"></slot>
<div v-show="!pagination.reversed && more" key="_more_" class="_margin"> <div v-show="!pagination.reversed && more" key="_more_" class="_margin">
<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? fetchMore : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMore"> <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? fetchMore : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMore">
{{ i18n.ts.loadMore }} {{ i18n.ts.loadMore }}
</MkButton> </MkButton>
<MkLoading v-else class="loading"/> <MkLoading v-else class="loading"/>

View File

@ -658,7 +658,14 @@ async function post(ev?: MouseEvent) {
if ((text.includes('love') || text.includes('❤')) && text.includes('misskey')) { if ((text.includes('love') || text.includes('❤')) && text.includes('misskey')) {
claimAchievement('iLoveMisskey'); claimAchievement('iLoveMisskey');
} }
if (text.includes('Efrlqw8ytg4'.toLowerCase()) || text.includes('XVCwzwxdHuA'.toLowerCase())) { if (
text.includes('https://youtu.be/Efrlqw8ytg4'.toLowerCase()) ||
text.includes('https://www.youtube.com/watch?v=Efrlqw8ytg4'.toLowerCase()) ||
text.includes('https://m.youtube.com/watch?v=Efrlqw8ytg4'.toLowerCase()) ||
text.includes('https://youtu.be/XVCwzwxdHuA'.toLowerCase()) ||
text.includes('https://www.youtube.com/watch?v=XVCwzwxdHuA'.toLowerCase()) ||
text.includes('https://m.youtube.com/watch?v=XVCwzwxdHuA'.toLowerCase())
) {
claimAchievement('brainDiver'); claimAchievement('brainDiver');
} }

View File

@ -9,6 +9,7 @@
<template #prefix>@</template> <template #prefix>@</template>
<template #suffix>@{{ host }}</template> <template #suffix>@{{ host }}</template>
<template #caption> <template #caption>
<div><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.cannotBeChangedLater }}</div>
<span v-if="usernameState === 'wait'" style="color:#999"><MkLoading :em="true"/> {{ i18n.ts.checking }}</span> <span v-if="usernameState === 'wait'" style="color:#999"><MkLoading :em="true"/> {{ i18n.ts.checking }}</span>
<span v-else-if="usernameState === 'ok'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.available }}</span> <span v-else-if="usernameState === 'ok'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.available }}</span>
<span v-else-if="usernameState === 'unavailable'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.unavailable }}</span> <span v-else-if="usernameState === 'unavailable'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.unavailable }}</span>

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="vrtktovh" :class="{ first }"> <div :class="[$style.root, { [$style.rootFirst]: first }]">
<div class="label"><slot name="label"></slot></div> <div :class="[$style.label, { [$style.labelFirst]: first }]"><slot name="label"></slot></div>
<div class="main"> <div :class="$style.main">
<slot></slot> <slot></slot>
</div> </div>
</div> </div>
@ -13,12 +13,17 @@ defineProps<{
}>(); }>();
</script> </script>
<style lang="scss" scoped> <style lang="scss" module>
.vrtktovh { .root {
border-top: solid 0.5px var(--divider); border-top: solid 0.5px var(--divider);
//border-bottom: solid 0.5px var(--divider); //border-bottom: solid 0.5px var(--divider);
}
> .label { .rootFirst {
border-top: none;
}
.label {
font-weight: bold; font-weight: bold;
padding: 1.5em 0 0 0; padding: 1.5em 0 0 0;
margin: 0 0 16px 0; margin: 0 0 16px 0;
@ -28,16 +33,11 @@ defineProps<{
} }
} }
> .main { .labelFirst {
margin: 1.5em 0 0 0;
}
&.first {
border-top: none;
> .label {
padding-top: 0; padding-top: 0;
} }
}
.main {
margin: 1.5em 0 0 0;
} }
</style> </style>

View File

@ -84,6 +84,12 @@
</div> </div>
<p>{{ i18n.ts._aboutMisskey.morePatrons }}</p> <p>{{ i18n.ts._aboutMisskey.morePatrons }}</p>
</FormSection> </FormSection>
<FormSection>
<template #label>Special thanks</template>
<div style="text-align: center;">
<a style="display: inline-block;" class="dcadvirth" title="DC Advirth" href="https://www.dotchain.ltd/advirth" target="_blank"><img width="200" src="https://misskey-hub.net/sponsors/dcadvirth.png" alt="DC Advirth"></a>
</div>
</FormSection>
</div> </div>
</MkSpacer> </MkSpacer>
</div> </div>
@ -203,6 +209,7 @@ const patrons = [
'pixeldesu', 'pixeldesu',
'あめ玉', 'あめ玉',
'氷月氷華里', '氷月氷華里',
'Ebise Lutica',
]; ];
let thereIsTreasure = $ref($i && !claimedAchievements.includes('foundTreasure')); let thereIsTreasure = $ref($i && !claimedAchievements.includes('foundTreasure'));

View File

@ -46,7 +46,8 @@ if (props.id) {
data = { data = {
name: 'New Role', name: 'New Role',
description: '', description: '',
rolePermission: 'normal', isAdministrator: false,
isModerator: false,
color: null, color: null,
iconUrl: null, iconUrl: null,
target: 'manual', target: 'manual',

View File

@ -11,7 +11,7 @@
<MkFolder> <MkFolder>
<template #icon><i class="ti ti-info-circle"></i></template> <template #icon><i class="ti ti-info-circle"></i></template>
<template #label>{{ i18n.ts.info }}</template> <template #label>{{ i18n.ts.info }}</template>
<XEditor v-model="role" readonly/> <XEditor :model-value="role" readonly/>
</MkFolder> </MkFolder>
<MkFolder v-if="role.target === 'manual'" default-open> <MkFolder v-if="role.target === 'manual'" default-open>
<template #icon><i class="ti ti-users"></i></template> <template #icon><i class="ti ti-users"></i></template>

View File

@ -4,7 +4,6 @@
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :content-max="700"> <MkSpacer :content-max="700">
<div class="_gaps"> <div class="_gaps">
<MkButton primary rounded @click="create"><i class="ti ti-plus"></i> {{ i18n.ts._role.new }}</MkButton>
<MkFolder> <MkFolder>
<template #label>{{ i18n.ts._role.baseRole }}</template> <template #label>{{ i18n.ts._role.baseRole }}</template>
<div class="_gaps_s"> <div class="_gaps_s">
@ -132,8 +131,20 @@
<MkButton primary rounded @click="updateBaseRole">{{ i18n.ts.save }}</MkButton> <MkButton primary rounded @click="updateBaseRole">{{ i18n.ts.save }}</MkButton>
</div> </div>
</MkFolder> </MkFolder>
<MkButton primary rounded @click="create"><i class="ti ti-plus"></i> {{ i18n.ts._role.new }}</MkButton>
<div class="_gaps_s"> <div class="_gaps_s">
<MkRolePreview v-for="role in roles" :key="role.id" :role="role" :for-moderation="true"/> <MkFoldableSection>
<template #header>Manual roles</template>
<div class="_gaps_s">
<MkRolePreview v-for="role in roles.filter(x => x.target === 'manual')" :key="role.id" :role="role" :for-moderation="true"/>
</div>
</MkFoldableSection>
<MkFoldableSection>
<template #header>Conditional roles</template>
<div class="_gaps_s">
<MkRolePreview v-for="role in roles.filter(x => x.target === 'conditional')" :key="role.id" :role="role" :for-moderation="true"/>
</div>
</MkFoldableSection>
</div> </div>
</div> </div>
</MkSpacer> </MkSpacer>
@ -155,6 +166,7 @@ import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata'; import { definePageMetadata } from '@/scripts/page-metadata';
import { instance } from '@/instance'; import { instance } from '@/instance';
import { useRouter } from '@/router'; import { useRouter } from '@/router';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
const ROLE_POLICIES = [ const ROLE_POLICIES = [
'gtlAvailable', 'gtlAvailable',

View File

@ -1,30 +1,25 @@
<template> <template>
<MkStickyContainer> <MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :content-max="700"> <MkSpacer :content-max="700" :class="$style.main">
<div v-if="channel && tab === 'timeline'" class="_gaps"> <div v-if="channel && tab === 'overview'" class="_gaps">
<div class="wpgynlbz _panel" :class="{ hide: !showBanner }"> <div class="_panel" :class="$style.bannerContainer">
<XChannelFollowButton :channel="channel" :full="true" class="subscribe"/> <XChannelFollowButton :channel="channel" :full="true" :class="$style.subscribe"/>
<button class="_button toggle" @click="() => showBanner = !showBanner"> <div :style="{ backgroundImage: channel.bannerUrl ? `url(${channel.bannerUrl})` : null }" :class="$style.banner">
<template v-if="showBanner"><i class="ti ti-chevron-up"></i></template> <div :class="$style.bannerStatus">
<template v-else><i class="ti ti-chevron-down"></i></template>
</button>
<div v-if="!showBanner" class="hideOverlay">
</div>
<div :style="{ backgroundImage: channel.bannerUrl ? `url(${channel.bannerUrl})` : null }" class="banner">
<div class="status">
<div><i class="ti ti-users ti-fw"></i><I18n :src="i18n.ts._channel.usersCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.usersCount }}</b></template></I18n></div> <div><i class="ti ti-users ti-fw"></i><I18n :src="i18n.ts._channel.usersCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.usersCount }}</b></template></I18n></div>
<div><i class="ti ti-pencil ti-fw"></i><I18n :src="i18n.ts._channel.notesCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.notesCount }}</b></template></I18n></div> <div><i class="ti ti-pencil ti-fw"></i><I18n :src="i18n.ts._channel.notesCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.notesCount }}</b></template></I18n></div>
</div> </div>
<div class="fade"></div> <div :class="$style.bannerFade"></div>
</div> </div>
<div v-if="channel.description" class="description"> <div v-if="channel.description" :class="$style.description">
<Mfm :text="channel.description" :is-note="false" :i="$i"/> <Mfm :text="channel.description" :is-note="false" :i="$i"/>
</div> </div>
</div> </div>
</div>
<div v-if="channel && tab === 'timeline'" class="_gaps">
<!-- スマホタブレットの場合キーボードが表示されると投稿が見づらくなるのでデスクトップ場合のみ自動でフォーカスを当てる --> <!-- スマホタブレットの場合キーボードが表示されると投稿が見づらくなるのでデスクトップ場合のみ自動でフォーカスを当てる -->
<MkPostForm v-if="$i" :channel="channel" class="post-form _panel" fixed :autofocus="deviceKind === 'desktop'"/> <MkPostForm v-if="$i && defaultStore.reactiveState.showFixedPostFormInChannel.value" :channel="channel" class="post-form _panel" fixed :autofocus="deviceKind === 'desktop'"/>
<MkTimeline :key="channelId" src="channel" :channel="channelId" @before="before" @after="after"/> <MkTimeline :key="channelId" src="channel" :channel="channelId" @before="before" @after="after"/>
</div> </div>
@ -32,6 +27,15 @@
<MkNotes :pagination="featuredPagination"/> <MkNotes :pagination="featuredPagination"/>
</div> </div>
</MkSpacer> </MkSpacer>
<template #footer>
<div :class="$style.footer">
<MkSpacer :content-max="700" :margin-min="16" :margin-max="16">
<div class="_buttonsCenter">
<MkButton inline rounded primary gradate @click="openPostForm()"><i class="ti ti-pencil"></i> {{ i18n.ts.postToTheChannel }}</MkButton>
</div>
</MkSpacer>
</div>
</template>
</MkStickyContainer> </MkStickyContainer>
</template> </template>
@ -47,6 +51,9 @@ import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata'; import { definePageMetadata } from '@/scripts/page-metadata';
import { deviceKind } from '@/scripts/device-kind'; import { deviceKind } from '@/scripts/device-kind';
import MkNotes from '@/components/MkNotes.vue'; import MkNotes from '@/components/MkNotes.vue';
import { url } from '@/config';
import MkButton from '@/components/MkButton.vue';
import { defaultStore } from '@/store';
const router = useRouter(); const router = useRouter();
@ -56,7 +63,6 @@ const props = defineProps<{
let tab = $ref('timeline'); let tab = $ref('timeline');
let channel = $ref(null); let channel = $ref(null);
let showBanner = $ref(true);
const featuredPagination = $computed(() => ({ const featuredPagination = $computed(() => ({
endpoint: 'notes/featured' as const, endpoint: 'notes/featured' as const,
limit: 10, limit: 10,
@ -76,13 +82,35 @@ function edit() {
router.push(`/channels/${channel.id}/edit`); router.push(`/channels/${channel.id}/edit`);
} }
function openPostForm() {
os.post({
channel: {
id: channel.id,
},
});
}
const headerActions = $computed(() => channel && channel.userId ? [{ const headerActions = $computed(() => channel && channel.userId ? [{
icon: 'ti ti-share',
text: i18n.ts.share,
handler: async (): Promise<void> => {
navigator.share({
title: channel.name,
text: channel.description,
url: `${url}/channels/${channel.id}`,
});
},
}, {
icon: 'ti ti-settings', icon: 'ti ti-settings',
text: i18n.ts.edit, text: i18n.ts.edit,
handler: edit, handler: edit,
}] : null); }] : null);
const headerTabs = $computed(() => [{ const headerTabs = $computed(() => [{
key: 'overview',
title: i18n.ts.overview,
icon: 'ti ti-info-circle',
}, {
key: 'timeline', key: 'timeline',
title: i18n.ts.timeline, title: i18n.ts.timeline,
icon: 'ti ti-home', icon: 'ti ti-home',
@ -98,41 +126,36 @@ definePageMetadata(computed(() => channel ? {
} : null)); } : null));
</script> </script>
<style lang="scss" scoped> <style lang="scss" module>
.wpgynlbz { .main {
position: relative; min-height: calc(var(--containerHeight) - (var(--stickyTop, 0px) + var(--stickyBottom, 0px)));
}
> .subscribe { .footer {
-webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px));
border-top: solid 0.5px var(--divider);
}
.bannerContainer {
position: relative;
}
.subscribe {
position: absolute; position: absolute;
z-index: 1; z-index: 1;
top: 16px; top: 16px;
left: 16px; left: 16px;
} }
> .toggle { .banner {
position: absolute;
z-index: 2;
top: 8px;
right: 8px;
font-size: 1.2em;
width: 48px;
height: 48px;
color: #fff;
background: rgba(0, 0, 0, 0.5);
border-radius: 100%;
> i {
vertical-align: middle;
}
}
> .banner {
position: relative; position: relative;
height: 200px; height: 200px;
background-position: center; background-position: center;
background-size: cover; background-size: cover;
}
> .fade { .bannerFade {
position: absolute; position: absolute;
bottom: 0; bottom: 0;
left: 0; left: 0;
@ -141,7 +164,7 @@ definePageMetadata(computed(() => channel ? {
background: linear-gradient(0deg, var(--panel), var(--X15)); background: linear-gradient(0deg, var(--panel), var(--X15));
} }
> .status { .bannerStatus {
position: absolute; position: absolute;
z-index: 1; z-index: 1;
bottom: 16px; bottom: 16px;
@ -152,48 +175,8 @@ definePageMetadata(computed(() => channel ? {
border-radius: 6px; border-radius: 6px;
color: #fff; color: #fff;
} }
}
> .description { .description {
padding: 16px; padding: 16px;
} }
> .hideOverlay {
position: absolute;
z-index: 1;
top: 0;
left: 0;
width: 100%;
height: 100%;
-webkit-backdrop-filter: var(--blur, blur(16px));
backdrop-filter: var(--blur, blur(16px));
background: rgba(0, 0, 0, 0.3);
}
&.hide {
> .subscribe {
display: none;
}
> .toggle {
top: 0;
right: 0;
height: 100%;
background: transparent;
}
> .banner {
height: 42px;
filter: blur(8px);
> * {
display: none;
}
}
> .description {
display: none;
}
}
}
</style> </style>

View File

@ -26,6 +26,7 @@ import { $i } from '@/account';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import * as os from '@/os'; import * as os from '@/os';
import { definePageMetadata } from '@/scripts/page-metadata'; import { definePageMetadata } from '@/scripts/page-metadata';
import { url } from '@/config';
const props = defineProps<{ const props = defineProps<{
clipId: string, clipId: string,
@ -82,7 +83,17 @@ const headerActions = $computed(() => clip && isOwned ? [{
...result, ...result,
}); });
}, },
}, { }, ...(clip.isPublic ? [{
icon: 'ti ti-share',
text: i18n.ts.share,
handler: async (): Promise<void> => {
navigator.share({
title: clip.name,
text: clip.description,
url: `${url}/clips/${clip.id}`,
});
},
}] : []), {
icon: 'ti ti-trash', icon: 'ti ti-trash',
text: i18n.ts.delete, text: i18n.ts.delete,
danger: true, danger: true,

View File

@ -16,7 +16,7 @@ let roles = $ref();
os.api('roles/list', { os.api('roles/list', {
limit: 30, limit: 30,
}).then(res => { }).then(res => {
roles = res; roles = res.filter(x => x.target === 'manual');
}); });
</script> </script>

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="_gaps_m"> <div v-if="instance.enableEmail" class="_gaps_m">
<FormSection first> <FormSection first>
<template #label>{{ i18n.ts.emailAddress }}</template> <template #label>{{ i18n.ts.emailAddress }}</template>
<MkInput v-model="emailAddress" type="email" manual-save> <MkInput v-model="emailAddress" type="email" manual-save>
@ -37,17 +37,22 @@
</div> </div>
</FormSection> </FormSection>
</div> </div>
<div v-if="!instance.enableEmail" class="_gaps_m">
<MkInfo>{{ i18n.ts.emailNotSupported }}</MkInfo>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, ref, watch } from 'vue'; import { onMounted, ref, watch } from 'vue';
import FormSection from '@/components/form/section.vue'; import FormSection from '@/components/form/section.vue';
import MkInfo from '@/components/MkInfo.vue';
import MkInput from '@/components/MkInput.vue'; import MkInput from '@/components/MkInput.vue';
import MkSwitch from '@/components/MkSwitch.vue'; import MkSwitch from '@/components/MkSwitch.vue';
import * as os from '@/os'; import * as os from '@/os';
import { $i } from '@/account'; import { $i } from '@/account';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata'; import { definePageMetadata } from '@/scripts/page-metadata';
import { instance } from '@/instance';
const emailAddress = ref($i!.email); const emailAddress = ref($i!.email);

View File

@ -21,6 +21,7 @@
</MkRadios> </MkRadios>
<MkSwitch v-model="showFixedPostForm">{{ i18n.ts.showFixedPostForm }}</MkSwitch> <MkSwitch v-model="showFixedPostForm">{{ i18n.ts.showFixedPostForm }}</MkSwitch>
<MkSwitch v-model="showFixedPostFormInChannel">{{ i18n.ts.showFixedPostFormInChannel }}</MkSwitch>
<FormSection> <FormSection>
<template #label>{{ i18n.ts.behavior }}</template> <template #label>{{ i18n.ts.behavior }}</template>
@ -156,6 +157,7 @@ const loadRawImages = computed(defaultStore.makeGetterSetter('loadRawImages'));
const imageNewTab = computed(defaultStore.makeGetterSetter('imageNewTab')); const imageNewTab = computed(defaultStore.makeGetterSetter('imageNewTab'));
const nsfw = computed(defaultStore.makeGetterSetter('nsfw')); const nsfw = computed(defaultStore.makeGetterSetter('nsfw'));
const showFixedPostForm = computed(defaultStore.makeGetterSetter('showFixedPostForm')); const showFixedPostForm = computed(defaultStore.makeGetterSetter('showFixedPostForm'));
const showFixedPostFormInChannel = computed(defaultStore.makeGetterSetter('showFixedPostFormInChannel'));
const numberOfPageCache = computed(defaultStore.makeGetterSetter('numberOfPageCache')); const numberOfPageCache = computed(defaultStore.makeGetterSetter('numberOfPageCache'));
const instanceTicker = computed(defaultStore.makeGetterSetter('instanceTicker')); const instanceTicker = computed(defaultStore.makeGetterSetter('instanceTicker'));
const enableInfiniteScroll = computed(defaultStore.makeGetterSetter('enableInfiniteScroll')); const enableInfiniteScroll = computed(defaultStore.makeGetterSetter('enableInfiniteScroll'));
@ -191,6 +193,7 @@ watch([
enableInfiniteScroll, enableInfiniteScroll,
squareAvatars, squareAvatars,
aiChanMode, aiChanMode,
showNoteActionsOnlyHover,
showGapBetweenNotesInTimeline, showGapBetweenNotesInTimeline,
instanceTicker, instanceTicker,
overridedDeviceKind, overridedDeviceKind,

View File

@ -73,6 +73,7 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [
'useBlurEffectForModal', 'useBlurEffectForModal',
'useBlurEffect', 'useBlurEffect',
'showFixedPostForm', 'showFixedPostForm',
'showFixedPostFormInChannel',
'enableInfiniteScroll', 'enableInfiniteScroll',
'useReactionPickerForContextMenu', 'useReactionPickerForContextMenu',
'showGapBetweenNotesInTimeline', 'showGapBetweenNotesInTimeline',

View File

@ -352,6 +352,9 @@ onUnmounted(() => {
> .roles { > .roles {
padding: 24px 24px 0 154px; padding: 24px 24px 0 154px;
font-size: 0.95em; font-size: 0.95em;
display: flex;
flex-wrap: wrap;
gap: 8px;
> .role { > .role {
border: solid 1px var(--color, var(--divider)); border: solid 1px var(--color, var(--divider));
@ -493,7 +496,7 @@ onUnmounted(() => {
> .roles { > .roles {
padding: 16px 16px 0 16px; padding: 16px 16px 0 16px;
text-align: center; justify-content: center;
} }
> .description { > .description {

View File

@ -197,6 +197,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'device', where: 'device',
default: false, default: false,
}, },
showFixedPostFormInChannel: {
where: 'device',
default: false,
},
enableInfiniteScroll: { enableInfiniteScroll: {
where: 'device', where: 'device',
default: true, default: true,
@ -271,7 +275,7 @@ export const defaultStore = markRaw(new Storage('base', {
}, },
numberOfPageCache: { numberOfPageCache: {
where: 'device', where: 'device',
default: 5, default: 3,
}, },
showNoteActionsOnlyHover: { showNoteActionsOnlyHover: {
where: 'device', where: 'device',

View File

@ -285,6 +285,12 @@ hr {
flex-wrap: wrap; flex-wrap: wrap;
} }
._buttonsCenter {
@extend ._buttons;
justify-content: center;
}
._borderButton { ._borderButton {
@extend ._button; @extend ._button;
display: block; display: block;

View File

@ -61,7 +61,6 @@ function post() {
channel: { channel: {
id: props.column.channelId, id: props.column.channelId,
}, },
instant: true,
}); });
} }

View File

@ -198,6 +198,7 @@ importers:
seedrandom: 3.0.5 seedrandom: 3.0.5
semver: 7.3.8 semver: 7.3.8
sharp: 0.31.3 sharp: 0.31.3
sharp-read-bmp: github:misskey-dev/sharp-read-bmp
strict-event-emitter-types: 2.0.0 strict-event-emitter-types: 2.0.0
stringz: 2.1.0 stringz: 2.1.0
summaly: github:misskey-dev/summaly summaly: github:misskey-dev/summaly
@ -305,6 +306,7 @@ importers:
seedrandom: 3.0.5 seedrandom: 3.0.5
semver: 7.3.8 semver: 7.3.8
sharp: 0.31.3 sharp: 0.31.3
sharp-read-bmp: github.com/misskey-dev/sharp-read-bmp/02d9dc189fa7df0c4bea09330be26741772dac01
strict-event-emitter-types: 2.0.0 strict-event-emitter-types: 2.0.0
stringz: 2.1.0 stringz: 2.1.0
summaly: github.com/misskey-dev/summaly/51f3870e1ff5e0b22102e804112b10cb72f3c494 summaly: github.com/misskey-dev/summaly/51f3870e1ff5e0b22102e804112b10cb72f3c494
@ -948,6 +950,10 @@ packages:
'@bull-board/api': 4.12.1 '@bull-board/api': 4.12.1
dev: false dev: false
/@canvas/image-data/1.0.0:
resolution: {integrity: sha512-BxOqI5LgsIQP1odU5KMwV9yoijleOPzHL18/YvNqF9KFSGF2K/DLlYAbDQsWqd/1nbaFuSkYD/191dpMtNh4vw==}
dev: false
/@chainsafe/is-ip/2.0.1: /@chainsafe/is-ip/2.0.1:
resolution: {integrity: sha512-nqSJ8u2a1Rv9FYbyI8qpDhTYujaKEyLknNrTejLYoSWmdeg+2WB7R6BZqPZYfrJzDxVi3rl6ZQuoaEvpKRZWgQ==} resolution: {integrity: sha512-nqSJ8u2a1Rv9FYbyI8qpDhTYujaKEyLknNrTejLYoSWmdeg+2WB7R6BZqPZYfrJzDxVi3rl6ZQuoaEvpKRZWgQ==}
dev: false dev: false
@ -3859,7 +3865,7 @@ packages:
/axios/0.24.0: /axios/0.24.0:
resolution: {integrity: sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==} resolution: {integrity: sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==}
dependencies: dependencies:
follow-redirects: 1.15.2 follow-redirects: 1.15.2_debug@4.3.4
transitivePeerDependencies: transitivePeerDependencies:
- debug - debug
dev: false dev: false
@ -3867,7 +3873,7 @@ packages:
/axios/0.27.2_debug@4.3.4: /axios/0.27.2_debug@4.3.4:
resolution: {integrity: sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==} resolution: {integrity: sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==}
dependencies: dependencies:
follow-redirects: 1.15.2 follow-redirects: 1.15.2_debug@4.3.4
form-data: 4.0.0 form-data: 4.0.0
transitivePeerDependencies: transitivePeerDependencies:
- debug - debug
@ -5201,6 +5207,23 @@ packages:
resolution: {integrity: sha512-ic1yEvwT6GuvaYwBLLY6/aFFgjZdySKTE8en/fkU3QICTmRtgtSlFn0u0BXN06InZwtfCelR7j8LRiDI/02iGA==} resolution: {integrity: sha512-ic1yEvwT6GuvaYwBLLY6/aFFgjZdySKTE8en/fkU3QICTmRtgtSlFn0u0BXN06InZwtfCelR7j8LRiDI/02iGA==}
dev: false dev: false
/decode-bmp/0.2.1:
resolution: {integrity: sha512-NiOaGe+GN0KJqi2STf24hfMkFitDUaIoUU3eKvP/wAbLe8o6FuW5n/x7MHPR0HKvBokp6MQY/j7w8lewEeVCIA==}
engines: {node: '>=8.6.0'}
dependencies:
'@canvas/image-data': 1.0.0
to-data-view: 1.1.0
dev: false
/decode-ico/0.4.1:
resolution: {integrity: sha512-69NZfbKIzux1vBOd31al3XnMnH+2mqDhEgLdpygErm4d60N+UwA5Sq5WFjmEDQzumgB9fElojGwWG0vybVfFmA==}
engines: {node: '>=8.6'}
dependencies:
'@canvas/image-data': 1.0.0
decode-bmp: 0.2.1
to-data-view: 1.1.0
dev: false
/decode-uri-component/0.2.2: /decode-uri-component/0.2.2:
resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
engines: {node: '>=0.10'} engines: {node: '>=0.10'}
@ -6766,7 +6789,7 @@ packages:
readable-stream: 2.3.7 readable-stream: 2.3.7
dev: false dev: false
/follow-redirects/1.15.2: /follow-redirects/1.15.2_debug@4.3.4:
resolution: {integrity: sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==} resolution: {integrity: sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==}
engines: {node: '>=4.0'} engines: {node: '>=4.0'}
peerDependencies: peerDependencies:
@ -6774,6 +6797,8 @@ packages:
peerDependenciesMeta: peerDependenciesMeta:
debug: debug:
optional: true optional: true
dependencies:
debug: 4.3.4
/for-each/0.3.3: /for-each/0.3.3:
resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==}
@ -12542,6 +12567,10 @@ packages:
is-negated-glob: 1.0.0 is-negated-glob: 1.0.0
dev: false dev: false
/to-data-view/1.1.0:
resolution: {integrity: sha512-1eAdufMg6mwgmlojAx3QeMnzB/BTVp7Tbndi3U7ftcT2zCZadjxkkmLmd97zmaxWi+sgGcgWrokmpEoy0Dn0vQ==}
dev: false
/to-fast-properties/2.0.0: /to-fast-properties/2.0.0:
resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==}
engines: {node: '>=4'} engines: {node: '>=4'}
@ -13676,6 +13705,16 @@ packages:
version: 2.2.1-misskey.3 version: 2.2.1-misskey.3
dev: false dev: false
github.com/misskey-dev/sharp-read-bmp/02d9dc189fa7df0c4bea09330be26741772dac01:
resolution: {tarball: https://codeload.github.com/misskey-dev/sharp-read-bmp/tar.gz/02d9dc189fa7df0c4bea09330be26741772dac01}
name: sharp-read-bmp
version: 1.0.0
dependencies:
decode-bmp: 0.2.1
decode-ico: 0.4.1
sharp: 0.31.3
dev: false
github.com/misskey-dev/summaly/51f3870e1ff5e0b22102e804112b10cb72f3c494: github.com/misskey-dev/summaly/51f3870e1ff5e0b22102e804112b10cb72f3c494:
resolution: {tarball: https://codeload.github.com/misskey-dev/summaly/tar.gz/51f3870e1ff5e0b22102e804112b10cb72f3c494} resolution: {tarball: https://codeload.github.com/misskey-dev/summaly/tar.gz/51f3870e1ff5e0b22102e804112b10cb72f3c494}
name: summaly name: summaly

View File

@ -40,8 +40,9 @@ const fs = require('fs');
const start = async () => { const start = async () => {
try { try {
const exist = fs.existsSync(__dirname + '/../packages/backend/built/boot/index.js') const stat = fs.statSync(__dirname + '/../packages/backend/built/boot/index.js');
if (!exist) throw new Error('not exist yet'); if (!stat) throw new Error('not exist yet');
if (stat.size === 0) throw new Error('not built yet');
await execa('pnpm', ['start'], { await execa('pnpm', ['start'], {
cwd: __dirname + '/../', cwd: __dirname + '/../',