Merge branch 'ap-susp-update' into remote-suspend
This commit is contained in:
commit
e6112c092b
|
@ -13,3 +13,7 @@ trim_trailing_whitespace = false
|
|||
|
||||
[*.{yml,yaml}]
|
||||
indent_style = space
|
||||
|
||||
[packages/backend/migration/*.js]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
|
|
@ -15,3 +15,5 @@ redis:
|
|||
host: 127.0.0.1
|
||||
port: 56312
|
||||
id: aidx
|
||||
|
||||
proxyRemoteFiles: true
|
||||
|
|
|
@ -152,3 +152,47 @@ jobs:
|
|||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./packages/backend/coverage/coverage-final.json
|
||||
|
||||
migration:
|
||||
name: Migration tests (backend)
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version-file:
|
||||
- .node-version
|
||||
#- .github/min.node-version
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
ports:
|
||||
- 54312:5432
|
||||
env:
|
||||
POSTGRES_DB: test-misskey
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4.2.2
|
||||
with:
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4.1.0
|
||||
- name: Get current date
|
||||
id: current-date
|
||||
run: echo "today=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4.4.0
|
||||
with:
|
||||
node-version-file: ${{ matrix.node-version-file }}
|
||||
cache: 'pnpm'
|
||||
- run: pnpm i --frozen-lockfile
|
||||
- name: Check pnpm-lock.yaml
|
||||
run: git diff --exit-code pnpm-lock.yaml
|
||||
- name: Copy Configure
|
||||
run: cp .github/misskey/test.yml .config
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
- name: Run migrations
|
||||
run: MISSKEY_CONFIG_YML=test.yml pnpm --filter backend migrate
|
||||
- name: Check no migrations are remaining
|
||||
run: MISSKEY_CONFIG_YML=test.yml pnpm --filter backend check-migrations
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
- Feat: クリップ内でノートを検索できるように
|
||||
- Feat: Playを検索できるように
|
||||
- Feat: モデレーションにおいて、特定のドライブファイルを添付しているチャットメッセージを一覧できるように
|
||||
- Enhance: ウォーターマーク機能をロールで制御可能に
|
||||
|
||||
### Client
|
||||
- Feat: モデログを検索できるように
|
||||
|
@ -20,6 +21,7 @@
|
|||
- Fix: 数時間後Misskeyのタブに戻った際に、タブがスロットリングされている間の更新アニメーションを延々見せ続けられる問題を修正
|
||||
- Fix: 非ログイン時のハイライトノートの画像がCWの有無を考慮せず表示される問題を修正
|
||||
- Fix: レンジ選択・ドロップダウンにて、操作を無効にすべきところで無効にならない問題を修正
|
||||
- Fix: Pull to refreshが有効なときに横スクロールができない問題を修正
|
||||
|
||||
### Server
|
||||
- Enhance: sinceId/untilIdが指定可能なエンドポイントにおいて、sinceDate/untilDateも指定可能に
|
||||
|
|
|
@ -18,6 +18,7 @@ WORKDIR /misskey
|
|||
|
||||
COPY --link ["pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json", "./"]
|
||||
COPY --link ["scripts", "./scripts"]
|
||||
COPY --link ["patches", "./patches"]
|
||||
COPY --link ["packages/backend/package.json", "./packages/backend/"]
|
||||
COPY --link ["packages/frontend-shared/package.json", "./packages/frontend-shared/"]
|
||||
COPY --link ["packages/frontend/package.json", "./packages/frontend/"]
|
||||
|
@ -53,6 +54,7 @@ WORKDIR /misskey
|
|||
|
||||
COPY --link ["pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json", "./"]
|
||||
COPY --link ["scripts", "./scripts"]
|
||||
COPY --link ["patches", "./patches"]
|
||||
COPY --link ["packages/backend/package.json", "./packages/backend/"]
|
||||
COPY --link ["packages/misskey-js/package.json", "./packages/misskey-js/"]
|
||||
COPY --link ["packages/misskey-reversi/package.json", "./packages/misskey-reversi/"]
|
||||
|
|
|
@ -78,6 +78,8 @@ describe('After setup instance', () => {
|
|||
cy.get('[data-cy-signup-password] input').type('alice1234');
|
||||
cy.get('[data-cy-signup-submit]').should('be.disabled');
|
||||
cy.get('[data-cy-signup-password-retype] input').type('alice1234');
|
||||
cy.get('[data-cy-signup-submit]').should('be.disabled');
|
||||
cy.get('[data-cy-signup-invitation-code] input').type('test-invitation-code');
|
||||
cy.get('[data-cy-signup-submit]').should('not.be.disabled');
|
||||
cy.get('[data-cy-signup-submit]').click();
|
||||
|
||||
|
|
|
@ -2806,6 +2806,7 @@ _fileViewer:
|
|||
url: "URL"
|
||||
uploadedAt: "Pujat el"
|
||||
attachedNotes: "Notes amb aquest fitxer"
|
||||
usage: "Ús "
|
||||
thisPageCanBeSeenFromTheAuthor: "Aquesta pàgina només la pot veure l'usuari que ha pujat aquest fitxer."
|
||||
_externalResourceInstaller:
|
||||
title: "Instal·lar des d'un lloc extern"
|
||||
|
|
|
@ -2806,6 +2806,7 @@ _fileViewer:
|
|||
url: "URL"
|
||||
uploadedAt: "Uploaded at"
|
||||
attachedNotes: "Attached notes"
|
||||
usage: "Used"
|
||||
thisPageCanBeSeenFromTheAuthor: "This page can only be seen by the user who uploaded this file."
|
||||
_externalResourceInstaller:
|
||||
title: "Install from external site"
|
||||
|
@ -3182,6 +3183,7 @@ drafts: "Drafts"
|
|||
_drafts:
|
||||
select: "Select Draft"
|
||||
cannotCreateDraftAnymore: "The number of drafts that can be created has been exceeded."
|
||||
cannotCreateDraft: "You cannot create a draft with this content."
|
||||
delete: "Delete Draft"
|
||||
deleteAreYouSure: "Delete draft?"
|
||||
noDrafts: "No drafts"
|
||||
|
|
|
@ -2806,6 +2806,7 @@ _fileViewer:
|
|||
url: "URL"
|
||||
uploadedAt: "Subido el"
|
||||
attachedNotes: "Notas adjuntas"
|
||||
usage: "Utilizado"
|
||||
thisPageCanBeSeenFromTheAuthor: "Esta página solo puede ser vista por el autor."
|
||||
_externalResourceInstaller:
|
||||
title: "Instalar desde sitio externo"
|
||||
|
|
|
@ -5231,7 +5231,7 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"prohibitedWordsForNameOfUser": string;
|
||||
/**
|
||||
* このリストに含まれる文字列がユーザーの名前に含まれる場合、ユーザーの名前の変更を拒否します。モデレーター権限を持つユーザーはこの制限の影響を受けません。
|
||||
* このリストに含まれる文字列がユーザーの名前に含まれる場合、ユーザーの名前の変更を拒否します。モデレーター権限を持つユーザーはこの制限の影響を受けません。ユーザー名(username)に対しても全て小文字に置き換えて検査します。
|
||||
*/
|
||||
"prohibitedWordsForNameOfUserDescription": string;
|
||||
/**
|
||||
|
@ -7795,6 +7795,10 @@ export interface Locale extends ILocale {
|
|||
* サーバーサイドのノートの下書きの作成可能数
|
||||
*/
|
||||
"noteDraftLimit": string;
|
||||
/**
|
||||
* ウォーターマーク機能の使用可否
|
||||
*/
|
||||
"watermarkAvailable": string;
|
||||
};
|
||||
"_condition": {
|
||||
/**
|
||||
|
|
|
@ -1303,7 +1303,7 @@ messageToFollower: "フォロワーへのメッセージ"
|
|||
target: "対象"
|
||||
testCaptchaWarning: "CAPTCHAのテストを目的とした機能です。<strong>本番環境で使用しないでください。</strong>"
|
||||
prohibitedWordsForNameOfUser: "禁止ワード(ユーザーの名前)"
|
||||
prohibitedWordsForNameOfUserDescription: "このリストに含まれる文字列がユーザーの名前に含まれる場合、ユーザーの名前の変更を拒否します。モデレーター権限を持つユーザーはこの制限の影響を受けません。"
|
||||
prohibitedWordsForNameOfUserDescription: "このリストに含まれる文字列がユーザーの名前に含まれる場合、ユーザーの名前の変更を拒否します。モデレーター権限を持つユーザーはこの制限の影響を受けません。ユーザー名(username)に対しても全て小文字に置き換えて検査します。"
|
||||
yourNameContainsProhibitedWords: "変更しようとした名前に禁止された文字列が含まれています"
|
||||
yourNameContainsProhibitedWordsDescription: "名前に禁止されている文字列が含まれています。この名前を使用したい場合は、サーバー管理者にお問い合わせください。"
|
||||
thisContentsAreMarkedAsSigninRequiredByAuthor: "投稿者により、表示にはログインが必要と設定されています"
|
||||
|
@ -2019,6 +2019,7 @@ _role:
|
|||
uploadableFileTypes_caption: "MIMEタイプを指定します。改行で区切って複数指定できるほか、アスタリスク(*)でワイルドカード指定できます。(例: image/*)"
|
||||
uploadableFileTypes_caption2: "ファイルによっては種別を判定できないことがあります。そのようなファイルを許可する場合は {x} を指定に追加してください。"
|
||||
noteDraftLimit: "サーバーサイドのノートの下書きの作成可能数"
|
||||
watermarkAvailable: "ウォーターマーク機能の使用可否"
|
||||
_condition:
|
||||
roleAssignedTo: "マニュアルロールにアサイン済み"
|
||||
isLocal: "ローカルユーザー"
|
||||
|
|
|
@ -2806,6 +2806,7 @@ _fileViewer:
|
|||
url: "URL"
|
||||
uploadedAt: "업로드 날짜"
|
||||
attachedNotes: "첨부된 노트"
|
||||
usage: "이용"
|
||||
thisPageCanBeSeenFromTheAuthor: "이 페이지는 파일 소유자만 열람할 수 있습니다"
|
||||
_externalResourceInstaller:
|
||||
title: "외부 사이트로부터 설치"
|
||||
|
@ -3182,6 +3183,7 @@ drafts: "초안"
|
|||
_drafts:
|
||||
select: "초안 선택"
|
||||
cannotCreateDraftAnymore: "초안 작성 가능 수를 초과했습니다."
|
||||
cannotCreateDraft: "이 내용으로는 초안을 작성할 수 없습니다. "
|
||||
delete: "초안 삭제\n"
|
||||
deleteAreYouSure: "초안을 삭제하시겠습니까?"
|
||||
noDrafts: "초안 없음\n"
|
||||
|
|
|
@ -146,7 +146,7 @@ enterFileName: "พิมพ์ชื่อไฟล์"
|
|||
mute: "ปิดเสียง"
|
||||
unmute: "ยกเลิกการปิดเสียง"
|
||||
renoteMute: "ปิดเสียงรีโน้ต"
|
||||
renoteUnmute: "เปิดเสียง รีโน้ต"
|
||||
renoteUnmute: "เลิกปิดเสียงรีโน้ต"
|
||||
block: "บล็อก"
|
||||
unblock: "เลิกบล็อก"
|
||||
suspend: "ระงับ"
|
||||
|
@ -242,8 +242,8 @@ silencedInstances: "ปิดปากเซิร์ฟเวอร์นี้
|
|||
silencedInstancesDescription: "ระบุโฮสต์ของเซิร์ฟเวอร์ที่ต้องการปิดปาก คั่นด้วยการขึ้นบรรทัดใหม่, บัญชีทั้งหมดของเซิร์ฟเวอร์ดังกล่าวจะถือว่าถูกปิดปากเช่นกัน ทำได้เฉพาะคำขอติดตามเท่านั้น และไม่สามารถกล่าวถึงบัญชีในเซิร์ฟเวอร์นี้ได้หากไม่ได้ถูกติดตามกลับ | สิ่งนี้ไม่มีผลต่ออินสแตนซ์ที่ถูกบล็อก"
|
||||
mediaSilencedInstances: "เซิร์ฟเวอร์ที่ถูกปิดปากสื่อ"
|
||||
mediaSilencedInstancesDescription: "ระบุโฮสต์ของเซิร์ฟเวอร์ที่ต้องการปิดปากสื่อ คั่นด้วยการขึ้นบรรทัดใหม่, ไฟล์ที่ถูกส่งจากบัญชีของเซิร์ฟเวอร์ดังกล่าวจะถือว่าถูกปิดปาก แล้วจะถูกติดเครื่องหมายว่ามีเนื้อหาละเอียดอ่อน และเอโมจิแบบกำหนดเองก็จะใช้ไม่ได้ด้วย | สิ่งนี้ไม่มีผลต่ออินสแตนซ์ที่ถูกบล็อก"
|
||||
federationAllowedHosts: "เซิร์ฟเวอร์ที่เปิดให้บริการแบบเฟเดอเรชั่น"
|
||||
federationAllowedHostsDescription: "ระบุชื่อโฮสต์ของเซิร์ฟเวอร์ที่คุณต้องการอนุญาตให้เชื่อมต่อแบบเฟเดอเรชั่น โดยต้องเว้นวรรคแต่ละบรรทัด"
|
||||
federationAllowedHosts: "เซิร์ฟเวอร์ที่อนุญาตให้เชื่อมกับสหพันธ์"
|
||||
federationAllowedHostsDescription: "ระบุโฮสต์ของเซิร์ฟเวอร์ที่อนุญาตให้เชื่อมกับสหพันธ์ โดยแยกแต่ละรายการด้วยบรรทัดใหม่"
|
||||
muteAndBlock: "ปิดเสียงและบล็อก"
|
||||
mutedUsers: "ผู้ใช้ที่ถูกปิดเสียง"
|
||||
blockedUsers: "ผู้ใช้ที่ถูกบล็อก"
|
||||
|
@ -298,9 +298,11 @@ uploadFromUrl: "อัปโหลดจาก URL"
|
|||
uploadFromUrlDescription: "URL ของไฟล์ที่คุณต้องการอัปโหลด"
|
||||
uploadFromUrlRequested: "ร้องขอการอัปโหลดแล้ว"
|
||||
uploadFromUrlMayTakeTime: "การอัปโหลดอาจใช้เวลาสักครู่จึงจะเสร็จสมบูรณ์"
|
||||
uploadNFiles: "อัปโหลด {n} ไฟล์"
|
||||
explore: "สำรวจ"
|
||||
messageRead: "อ่านแล้ว"
|
||||
noMoreHistory: "ไม่มีประวัติเพิ่มเติม"
|
||||
startChat: "เริ่มแชต"
|
||||
nUsersRead: "อ่านโดย {n}"
|
||||
agreeTo: "ฉันยอมรับ {0}"
|
||||
agree: "ยอมรับ"
|
||||
|
@ -325,6 +327,7 @@ dark: "มืด"
|
|||
lightThemes: "ธีมสว่าง"
|
||||
darkThemes: "ธีมมืด"
|
||||
syncDeviceDarkMode: "ซิงค์โหมดมืดกับการตั้งค่าอุปกรณ์ของคุณ"
|
||||
switchDarkModeManuallyWhenSyncEnabledConfirm: "“{x}” เปิดอยู่ ต้องการปิดการซิงค์และสลับโหมดด้วยตนเองหรือไม่?"
|
||||
drive: "ไดรฟ์"
|
||||
fileName: "ชื่อไฟล์"
|
||||
selectFile: "เลือกไฟล์"
|
||||
|
@ -365,7 +368,7 @@ reject: "ปฏิเสธ"
|
|||
normal: "ปกติ"
|
||||
instanceName: "ชื่อเซิร์ฟเวอร์"
|
||||
instanceDescription: "คำอธิบายแนะนำเซิร์ฟเวอร์"
|
||||
maintainerName: "ผู้ดูแล"
|
||||
maintainerName: "ชื่อผู้ดูแลระบบ"
|
||||
maintainerEmail: "อีเมลผู้ดูแลระบบ"
|
||||
tosUrl: "URL เงื่อนไขการให้บริการ"
|
||||
thisYear: "ปีนี้"
|
||||
|
@ -423,6 +426,7 @@ antennaExcludeBots: "ยกเว้นบัญชีบอต"
|
|||
antennaKeywordsDescription: "คั่นด้วยเว้นวรรคสำหรับเงื่อนไข AND, หรือขึ้นบรรทัดใหม่สำหรับเงื่อนไข OR"
|
||||
notifyAntenna: "แจ้งเตือนเกี่ยวกับโน้ตใหม่"
|
||||
withFileAntenna: "เฉพาะโน้ตที่มีไฟล์"
|
||||
excludeNotesInSensitiveChannel: "ไม่รวมโน้ตจากช่องเนื้อหาละเอียดอ่อน"
|
||||
enableServiceworker: "เปิดใช้งานการแจ้งเตือนแบบพุชไปยังเบราว์เซอร์ของคุณ"
|
||||
antennaUsersDescription: "ระบุหนึ่งชื่อผู้ใช้ต่อบรรทัด"
|
||||
caseSensitive: "อักษรพิมพ์ใหญ่-พิมพ์เล็กความหมายต่างกัน"
|
||||
|
@ -453,17 +457,17 @@ totpDescription: "ใช้แอปยืนยันตัวตนเพื
|
|||
moderator: "ผู้ควบคุม"
|
||||
moderation: "การกลั่นกรอง"
|
||||
moderationNote: "โน้ตการกลั่นกรอง"
|
||||
moderationNoteDescription: "คุณสามารถใส่โน้ตส่วนตัวที่เฉพาะผู้ดูแลระบบเท่านั้นที่สามารถเข้าถึงได้"
|
||||
moderationNoteDescription: "สามารถจดเมโมที่จะแบ่งปันเฉพาะระหว่างผู้ควบคุมได้"
|
||||
addModerationNote: "เพิ่มโน้ตการกลั่นกรอง"
|
||||
moderationLogs: "ปูมการควบคุมดูแล"
|
||||
nUsersMentioned: "กล่าวถึงโดยผู้ใช้ {n} ราย"
|
||||
securityKeyAndPasskey: "ความปลอดภัยและรหัสผ่าน"
|
||||
securityKey: "กุญแจความปลอดภัย"
|
||||
securityKeyAndPasskey: "Security key และ Passkey"
|
||||
securityKey: "Security Key"
|
||||
lastUsed: "ใช้ล่าสุด"
|
||||
lastUsedAt: "ใช้งานครั้งล่าสุด: {t}"
|
||||
unregister: "เลิกติดตาม"
|
||||
passwordLessLogin: "เข้าสู่ระบบแบบไม่ใช้รหัสผ่าน"
|
||||
passwordLessLoginDescription: "อนุญาตให้เข้าสู่ระบบโดยไม่ต้องใช้รหัสผ่านโดยใช้รหัสรักษาความปลอดภัยหรือรหัสผ่านเท่านั้น"
|
||||
passwordLessLoginDescription: "เข้าสู่ระบบโดยไม่ใช้รหัสผ่าน โดยใช้เฉพาะ Security Key หรือ Passkey เท่านั้น"
|
||||
resetPassword: "รีเซ็ตรหัสผ่าน"
|
||||
newPasswordIs: "รหัสผ่านใหม่คือ “{password}”"
|
||||
reduceUiAnimation: "ลดภาพเคลื่อนไหว UI"
|
||||
|
@ -573,8 +577,10 @@ showFixedPostForm: "แสดงแบบฟอร์มการโพสต์
|
|||
showFixedPostFormInChannel: "แสดงแบบฟอร์มการโพสต์ที่ด้านบนของไทม์ไลน์ (ช่อง)"
|
||||
withRepliesByDefaultForNewlyFollowed: "แสดงการตอบกลับจากผู้ใช้ที่คุณเพิ่งติดตามลงไทม์ไลน์ตามค่าเริ่มต้น"
|
||||
newNoteRecived: "มีโน้ตใหม่"
|
||||
newNote: "โน้ตใหม่"
|
||||
sounds: "เสียง"
|
||||
sound: "เสียง"
|
||||
notificationSoundSettings: "ตั้งค่าเสียงแจ้งเตือน"
|
||||
listen: "ฟัง"
|
||||
none: "ไม่มี"
|
||||
showInPage: "แสดงในเพจ"
|
||||
|
@ -606,8 +612,8 @@ output: "เอาท์พุต"
|
|||
script: "สคริปต์"
|
||||
disablePagesScript: "ปิดการใช้งาน AiScript บนเพจ"
|
||||
updateRemoteUser: "อัปเดตข้อมูลผู้ใช้งานระยะไกล"
|
||||
unsetUserAvatar: "เลิกตั้งอวตาร"
|
||||
unsetUserAvatarConfirm: "ต้องการเลิกตั้งอวตารใข่ไหม?"
|
||||
unsetUserAvatar: "เลิกตั้งไอคอน"
|
||||
unsetUserAvatarConfirm: "ต้องการเลิกตั้งไอคอนประจำตัวหรือไม่?"
|
||||
unsetUserBanner: "เลิกตั้งแบนเนอร์"
|
||||
unsetUserBannerConfirm: "ต้องการเลิกตั้งแบนเนอร์?"
|
||||
deleteAllFiles: "ลบไฟล์ทั้งหมด"
|
||||
|
@ -682,13 +688,15 @@ smtpSecure: "ใช้โดยนัย SSL/TLS สำหรับการเ
|
|||
smtpSecureInfo: "ปิดสิ่งนี้เมื่อใช้ STARTTLS"
|
||||
testEmail: "ทดสอบการส่งอีเมล"
|
||||
wordMute: "ปิดเสียงคำ"
|
||||
wordMuteDescription: "ย่อโน้ตที่มีวลีที่ระบุ สามารถดูโน้ตที่ย่อแล้วได้โดยคลิกที่โน้ตเหล่านั้น"
|
||||
hardWordMute: "ปิดเสียงคำแบบแข็งโป๊ก"
|
||||
hardWordMuteDescription: "ซ่อนหมายเหตุที่มีวลีที่ระบุ ต่างจากการปิดเสียงคำ โน้ตต่างๆ จะถูกซ่อนไว้อย่างสมบูรณ์"
|
||||
showMutedWord: "แสดงคำที่ถูกปิดเสียง"
|
||||
hardWordMuteDescription: "จะซ่อนโน้ตที่มีคำที่ระบุไว้ ซึ่งไม่เหมือนการปิดเสียงคำ ในกรณีนี้โน้ตจะไม่แสดงเลย"
|
||||
regexpError: "เกิดข้อผิดพลาดใน regular expression"
|
||||
regexpErrorDescription: "เกิดข้อผิดพลาดใน regular expression บรรทัดที่ {line} ของการปิดเสียงคำ {tab} :"
|
||||
instanceMute: "ปิดเสียงเซิร์ฟเวอร์"
|
||||
userSaysSomething: "{name} พูดอะไรบางอย่าง"
|
||||
userSaysSomethingAbout: "{name} พูดอะไรบางอย่างเกี่ยวกับ \"{word}\""
|
||||
userSaysSomethingAbout: "{name} พูดบางอย่างเกี่ยวกับ “{word}”"
|
||||
makeActive: "เปิดใช้งาน"
|
||||
display: "แสดงผล"
|
||||
copy: "คัดลอก"
|
||||
|
@ -758,7 +766,7 @@ yes: "ใช่"
|
|||
no: "ไม่"
|
||||
driveFilesCount: "จำนวนไฟล์ไดรฟ์"
|
||||
driveUsage: "การใช้พื้นที่ไดรฟ์"
|
||||
noCrawle: "ปฏิเสธการจัดทำดัชนีของโปรแกรมรวบรวมข้อมูล"
|
||||
noCrawle: "ปฏิเสธการจัดทำดัชนีของ Crawler (โปรแกรมรวบรวมข้อมูล)"
|
||||
noCrawleDescription: "ขอให้เครื่องมือค้นหาไม่จัดทำดัชนีหน้าโปรไฟล์ โน้ต หน้าเพจ ฯลฯ"
|
||||
lockedAccountInfo: "แม้ว่าการอนุมัติการติดตามถูกเปิดใช้งานอยู่ทุกคนก็ยังคงสามารถเห็นโน้ตของคุณได้ เว้นแต่ว่าคุณจะเปลี่ยนการเปิดเผยโน้ตของคุณเป็น “เฉพาะผู้ติดตาม”"
|
||||
alwaysMarkSensitive: "ทำเครื่องหมายว่ามีเนื้อหาละเอียดอ่อนเป็นค่าเริ่มต้น"
|
||||
|
@ -881,7 +889,7 @@ previewNoteText: "แสดงตัวอย่าง"
|
|||
customCss: "CSS ที่กำหนดเอง"
|
||||
customCssWarn: "ควรใช้การตั้งค่านี้เฉพาะต่อเมื่อคุณรู้มันใช้ทำอะไร การตั้งค่าที่ไม่เหมาะสมอาจทำให้ไคลเอ็นต์ไม่สามารถใช้งานได้อย่างถูกต้อง"
|
||||
global: "ทั่วโลก"
|
||||
squareAvatars: "แสดงผลอวตารเป็นสี่เหลี่ยม"
|
||||
squareAvatars: "แสดงไอคอนประจำตัวเป็นสี่เหลี่ยม"
|
||||
sent: "ส่ง"
|
||||
received: "ได้รับแล้ว"
|
||||
searchResult: "ผลการค้นหา"
|
||||
|
@ -948,6 +956,9 @@ oneHour: "1 ชั่วโมง"
|
|||
oneDay: "1 วัน"
|
||||
oneWeek: "1 สัปดาห์"
|
||||
oneMonth: "หนึ่งเดือน"
|
||||
threeMonths: "3 เดือน"
|
||||
oneYear: "1 ปี"
|
||||
threeDays: "3 วัน"
|
||||
reflectMayTakeTime: "อาจจำเป็นต้องใช้เวลาสักระยะหนึ่งจึงจะเห็นแสดงผลได้นะ"
|
||||
failedToFetchAccountInformation: "ไม่สามารถเรียกดึงข้อมูลบัญชีได้"
|
||||
rateLimitExceeded: "เกินขีดจำกัดอัตรา"
|
||||
|
@ -972,6 +983,7 @@ document: "เอกสาร"
|
|||
numberOfPageCache: "จำนวนหน้าเพจที่แคช"
|
||||
numberOfPageCacheDescription: "การเพิ่มจำนวนนี้จะช่วยเพิ่มความสะดวกให้กับผู้ใช้งาน แต่จะทำให้เซิร์ฟเวอร์โหลดมากขึ้นและต้องใช้หน่วยความจำมากขึ้นอีกด้วย"
|
||||
logoutConfirm: "ต้องการออกจากระบบใช่ไหม?"
|
||||
logoutWillClearClientData: "เมื่อออกจากระบบ ข้อมูลการตั้งค่าของไคลเอนต์จะถูกลบออกจากเบราว์เซอร์ เพื่อให้สามารถกู้คืนข้อมูลการตั้งค่าได้เมื่อกลับมาเข้าสู่ระบบอีกครั้ง โปรดเปิดใช้งานการสำรองข้อมูลการตั้งค่าอัตโนมัติ"
|
||||
lastActiveDate: "ใช้งานล่าสุดเมื่อ"
|
||||
statusbar: "แถบสถานะ"
|
||||
pleaseSelect: "ตัวเลือก"
|
||||
|
@ -990,6 +1002,7 @@ failedToUpload: "การอัปโหลดล้มเหลว"
|
|||
cannotUploadBecauseInappropriate: "ไม่สามารถอัปโหลดไฟล์นี้ได้เนื่องจากระบบตรวจพบบางส่วนของไฟล์ว่านี้อาจจะเป็น NSFW"
|
||||
cannotUploadBecauseNoFreeSpace: "ไม่สามารถอัปโหลดได้เนื่องจากไม่มีพื้นที่ว่างในไดรฟ์เหลือแล้ว"
|
||||
cannotUploadBecauseExceedsFileSizeLimit: "ไม่สามารถอัปโหลดไฟล์นี้ได้แล้วเนื่องจากเกินขีดจำกัดของขนาดไฟล์แล้ว"
|
||||
cannotUploadBecauseUnallowedFileType: "ไม่สามารถอัปโหลดได้เนื่องจากเป็นชนิดไฟล์ที่ไม่ได้รับอนุญาต"
|
||||
beta: "เบต้า"
|
||||
enableAutoSensitive: "ทำเครื่องหมายว่ามีเนื้อหาที่ละเอียดอ่อนโดยอัตโนมัติ"
|
||||
enableAutoSensitiveDescription: "อนุญาตให้ตรวจหาและทำเครื่องหมายสื่อว่ามีเนื้อหาโดยละเอียดอ่อนโดยอัตโนมัติ ผ่าน Machine Learning หากเป็นไปได้ แม้ว่าคุณจะปิดคุณสมบัตินี้ ก็อาจถูกตั้งค่าโดยอัตโนมัติ ทั้งนี้ขึ้นอยู่กับเซิร์ฟเวอร์"
|
||||
|
@ -1009,7 +1022,7 @@ windowMaximize: "ขยายใหญ่สุด"
|
|||
windowMinimize: "ย่อเล็กที่สุด"
|
||||
windowRestore: "เลิกทำ"
|
||||
caption: "คำอธิบาย"
|
||||
loggedInAsBot: "ล็อกอินเป็นบอตอยู่ในขณะนี้"
|
||||
loggedInAsBot: "เข้าสู่ระบบเป็นบอตอยู่ในขณะนี้"
|
||||
tools: "เครื่องมือ"
|
||||
cannotLoad: "ไม่สามารถโหลดได้"
|
||||
numberOfProfileView: "มุมมองโปรไฟล์"
|
||||
|
@ -1058,7 +1071,7 @@ exploreOtherServers: "มองหาเซิร์ฟเวอร์อื่
|
|||
letsLookAtTimeline: "มาดูไทม์ไลน์กัน"
|
||||
disableFederationConfirm: "ปิดใช้งานสหพันธ์เลยใช่ไหม?"
|
||||
disableFederationConfirmWarn: "โพสต์จะยังคงเป็นสาธารณะต่อไป เว้นแต่จะตั้งค่าเป็นอย่างอื่น"
|
||||
disableFederationOk: "ปิดการใช้งาน"
|
||||
disableFederationOk: "ปิดการใช้งานสหพันธ์"
|
||||
invitationRequiredToRegister: "เซิร์ฟเวอร์นี้เป็นแบบรับเชิญ เฉพาะผู้มีรหัสเชิญเท่านั้นถึงสามารถลงทะเบียนได้"
|
||||
emailNotSupported: "เซิร์ฟเวอร์นี้ไม่รองรับการส่งอีเมล"
|
||||
postToTheChannel: "โพสต์ลงช่อง"
|
||||
|
@ -1088,7 +1101,7 @@ retryAllQueuesConfirmTitle: "ลองใหม่ทั้งหมดจริ
|
|||
retryAllQueuesConfirmText: "สิ่งนี้จะเพิ่มการโหลดเซิร์ฟเวอร์ชั่วคราวนะ"
|
||||
enableChartsForRemoteUser: "สร้างแผนภูมิข้อมูลผู้ใช้ระยะไกล"
|
||||
enableChartsForFederatedInstances: "สร้างแผนภูมิของเซิร์ฟเวอร์ระยะไกล"
|
||||
enableStatsForFederatedInstances: "ดึงข้อมูลสถิติจากเซิร์ฟเวอร์ที่อยู่ห่างไกล"
|
||||
enableStatsForFederatedInstances: "ดึงข้อมูลจากเซิร์ฟเวอร์ระยะไกล"
|
||||
showClipButtonInNoteFooter: "เพิ่ม “คลิป” ไปยังเมนูสั่งการของโน้ต"
|
||||
reactionsDisplaySize: "ขนาดของรีแอคชั่น"
|
||||
limitWidthOfReaction: "จำกัดความกว้างสูงสุดของรีแอคชั่นและแสดงให้เล็กลง"
|
||||
|
@ -1219,13 +1232,13 @@ impressumDescription: "การติดป้ายกำกับ (Impressum)
|
|||
privacyPolicy: "นโยบายความเป็นส่วนตัว"
|
||||
privacyPolicyUrl: "URL นโยบายความเป็นส่วนตัว"
|
||||
tosAndPrivacyPolicy: "เงื่อนไขในการให้บริการและนโยบายความเป็นส่วนตัว"
|
||||
avatarDecorations: "การตกแต่งอวตาร"
|
||||
avatarDecorations: "ของตกแต่งไอคอน"
|
||||
attach: "แนบ"
|
||||
detach: "นำออก"
|
||||
detachAll: "เอาออกทั้งหมด"
|
||||
angle: "แองเกิล"
|
||||
flip: "พลิก"
|
||||
showAvatarDecorations: "แสดงตกแต่งอวตาร"
|
||||
showAvatarDecorations: "แสดงของตกแต่งไอคอน"
|
||||
releaseToRefresh: "ปล่อยเพื่อรีเฟรช"
|
||||
refreshing: "กำลังรีเฟรช..."
|
||||
pullDownToRefresh: "ดึงลงเพื่อรีเฟรช"
|
||||
|
@ -1281,51 +1294,208 @@ clipNoteLimitExceeded: "ไม่สามารถเพิ่มโน้ต
|
|||
performance: "ประสิทธิภาพ"
|
||||
modified: "แก้ไข"
|
||||
discard: "ละทิ้ง"
|
||||
thereAreNChanges: "มีอยู่ {n} เปลี่ยนแปลง(s)"
|
||||
thereAreNChanges: "มีการเปลี่ยนแปลง {n} รายการ"
|
||||
signinWithPasskey: "ลงชื่อเข้าใช้ด้วย Passkey"
|
||||
unknownWebAuthnKey: "พาสคีย์ไม่ถูกต้องค่ะ"
|
||||
passkeyVerificationFailed: "การยืนยันกุญแจดิจิทัลไม่สำเร็จค่ะ"
|
||||
passkeyVerificationSucceededButPasswordlessLoginDisabled: "การยืนยันพาสคีย์สำเร็จแล้ว แต่การลงชื่อเข้าใช้แบบไม่ต้องใส่รหัสผ่านถูกปิดใช้งานแล้ว"
|
||||
unknownWebAuthnKey: "เป็น Passkey ที่ยังไม่ได้ลงทะเบียน"
|
||||
passkeyVerificationFailed: "การยืนยัน Passkey ล้มเหลว"
|
||||
passkeyVerificationSucceededButPasswordlessLoginDisabled: "การยืนยัน Passkey สำเร็จ แต่การเข้าสู่ระบบแบบไม่ใช้รหัสผ่านถูกปิดใช้งานอยู่"
|
||||
messageToFollower: "ข้อความถึงผู้ติดตาม"
|
||||
target: "เป้า"
|
||||
testCaptchaWarning: "ฟังก์ชันนี้มีไว้สำหรับทดสอบ CAPTCHA เท่านั้น\n<strong>ห้ามนำไปใช้ในระบบจริงโดยเด็ดขาด</strong>"
|
||||
prohibitedWordsForNameOfUser: "คำนี้ไม่สามารถใช้เป็นชื่อผู้ใช้ได้"
|
||||
prohibitedWordsForNameOfUserDescription: "หากมีสตริงใดๆ ในรายการนี้ปรากฏอยู่ในชื่อของผู้ใช้ ชื่อนั้นจะถูกปฏิเสธ ผู้ใช้ที่มีสิทธิ์แต่ผู้ดูแลระบบนั้นจะไม่ได้รับผลกระทบใดๆจากข้อจำกัดนี้ค่ะ"
|
||||
prohibitedWordsForNameOfUserDescription: "จะไม่อนุญาตให้เปลี่ยนชื่อผู้ใช้หากชื่อของผู้ใช้มีข้อความที่อยู่ในรายการนี้ แต่ผู้ใช้ที่มีสิทธิ์เป็นผู้ควบคุมจะไม่ได้รับผลกระทบจากข้อจำกัดนี้"
|
||||
yourNameContainsProhibitedWords: "ชื่อของคุณนั้นมีคำที่ต้องห้าม"
|
||||
yourNameContainsProhibitedWordsDescription: "ถ้าหากคุณต้องการใช้ชื่อนี้ กรุณาติดต่อผู้ดูแลระบบของเซิร์ฟเวอร์นะค่ะ"
|
||||
federationDisabled: "เซิร์ฟเวอร์นี้ปิดการใช้งานการรวมกลุ่ม คุณไม่สามารถโต้ตอบกับผู้ใช้บนเซิร์ฟเวอร์อื่นได้"
|
||||
reactAreYouSure: "คุณต้องการที่จะตอบสนองต่อ \" {emoji}\" หรือไม่?"
|
||||
markAsSensitiveConfirm: "คุณต้องการทำเครื่องหมายสื่อนี้ว่าละเอียดอ่อนหรือไม่?"
|
||||
unmarkAsSensitiveConfirm: "คุณต้องการลบการกำหนดความไวของสื่อนี้หรือไม่?"
|
||||
thisContentsAreMarkedAsSigninRequiredByAuthor: "ผู้โพสต์ได้ตั้งค่าว่าต้องเข้าสู่ระบบจึงจะสามารถดูได้"
|
||||
lockdown: "ล็อกดาวน์"
|
||||
pleaseSelectAccount: "โปรดเลือกบัญชี"
|
||||
availableRoles: "บทบาทที่ใช้ได้"
|
||||
acknowledgeNotesAndEnable: "เปิดใช้งานหลังจากที่เข้าใจข้อควรระวังแล้ว"
|
||||
federationSpecified: "เซิร์ฟเวอร์นี้ดำเนินงานในระบบกลุ่มไวท์ลิสต์ ไม่สามารถติดต่อกับเซิร์ฟเวอร์อื่นที่ไม่ได้รับอนุญาตจากผู้ดูแลระบบได้"
|
||||
federationDisabled: "เซิร์ฟเวอร์นี้ปิดใช้งานสหพันธ์ ไม่สามารถติดต่อหรือแลกเปลี่ยนข้อมูลกับผู้ใช้จากเซิร์ฟเวอร์อื่นได้"
|
||||
draft: "ร่าง"
|
||||
confirmOnReact: "ยืนยันเมื่อทำการรีแอคชั่น"
|
||||
reactAreYouSure: "ต้องการใส่รีแอคชั่นด้วย \"{emoji}\" หรือไม่?"
|
||||
markAsSensitiveConfirm: "ต้องการตั้งค่าสื่อนี้ว่าเป็นเนื้อหาละเอียดอ่อนหรือไม่?"
|
||||
unmarkAsSensitiveConfirm: "ต้องการยกเลิกการระบุว่าสื่อนี้มีเนื้อหาละเอียดอ่อนหรือไม่?"
|
||||
preferences: "การตั้งค่าสภาพแวดล้อม"
|
||||
accessibility: "การช่วยการเข้าถึง"
|
||||
preferencesProfile: "โปรไฟล์การกำหนดค่า"
|
||||
copyPreferenceId: "คัดลือก ID การตั้งค่า"
|
||||
resetToDefaultValue: "คืนค่าเป็นค่าเริ่มต้น"
|
||||
overrideByAccount: "เขียนทับด้วยบัญชี"
|
||||
untitled: "ไม่มีชื่อ"
|
||||
noName: "ไม่มีชื่อ"
|
||||
skip: "ข้าม"
|
||||
restore: "กู้คืน"
|
||||
syncBetweenDevices: "ซิงค์ระหว่างอุปกรณ์"
|
||||
preferenceSyncConflictTitle: "การตั้งค่ามีอยู่บนเซิร์ฟเวอร์"
|
||||
preferenceSyncConflictText: "รายการการตั้งค่าที่เปิดใช้งานการซิงโครไนซ์จะจัดเก็บค่าไว้บนเซิร์ฟเวอร์ และพบค่าที่จัดเก็บบนเซิร์ฟเวอร์สำหรับรายการการตั้งค่านี้ คุณต้องการทำอย่างไร?"
|
||||
preferenceSyncConflictText: "การตั้งค่าที่เปิดใช้งานการซิงค์จะบันทึกค่าลงในเซิร์ฟเวอร์ อย่างไรก็ดี พบว่ามีค่าการตั้งค่านี้ที่เคยบันทึกไว้ในเซิร์ฟเวอร์แล้ว ต้องการดำเนินการอย่างไร?"
|
||||
preferenceSyncConflictChoiceMerge: "รวมเข้าด้วยกัน"
|
||||
preferenceSyncConflictChoiceServer: "เขียนทับด้วยค่าการตั้งค่าเซิร์ฟเวอร์"
|
||||
preferenceSyncConflictChoiceDevice: "เขียนทับด้วยค่าการตั้งค่าอุปกรณ์"
|
||||
preferenceSyncConflictChoiceCancel: "ยกเลิกการเปิดใช้งานการซิงค์"
|
||||
paste: "วาง"
|
||||
emojiPalette: "จานสีเอโมจิ"
|
||||
postForm: "แบบฟอร์มการโพสต์"
|
||||
textCount: "จำนวนอักขระ"
|
||||
information: "เกี่ยวกับ"
|
||||
chat: "แชต"
|
||||
migrateOldSettings: "ย้ายข้อมูลการตั้งค่าเก่า"
|
||||
migrateOldSettings_description: "โดยปกติจะทำโดยอัตโนมัติ แต่หากด้วยเหตุผลบางประการที่ไม่สามารถย้ายได้สำเร็จ สามารถสั่งย้ายด้วยตนเองได้ การตั้งค่าปัจจุบันจะถูกเขียนทับ"
|
||||
compress: "บีบอัด"
|
||||
right: "ขวา"
|
||||
bottom: "ภายใต้"
|
||||
top: "บน"
|
||||
embed: "ฝัง"
|
||||
settingsMigrating: "กำลังย้ายการตั้งค่า กรุณารอสักครู่... (สามารถย้ายด้วยตนเองภายหลังได้ที่ การตั้งค่า → อื่นๆ → ย้ายข้อมูลการตั้งค่าเก่า)"
|
||||
readonly: "อ่านได้อย่างเดียว"
|
||||
goToDeck: "กลับไปยังเด็ค"
|
||||
federationJobs: "งานสหพันธ์"
|
||||
driveAboutTip: "ในไดรฟ์จะแสดงรายการไฟล์ที่เคยอัปโหลดไว้ก่อนหน้า<br>\nสามารถนำมาใช้ซ้ำเมื่อแนบไฟล์ในโน้ต หรือตั้งค่าให้อัปโหลดไฟล์ล่วงหน้าเพื่อนำไปโพสต์ทีหลังได้<br>\n<b>โปรดระวัง เมื่อลบไฟล์ ไฟล์นั้นจะไม่แสดงในทุกที่ที่เคยใช้ไฟล์นี้ (โน้ต, หน้าเพจ, อวตาร, แบนเนอร์ ฯลฯ)</b><br>\nสามารถสร้างโฟลเดอร์เพื่อจัดระเบียบได้"
|
||||
scrollToClose: "เลื่อนเพื่อปิด"
|
||||
advice: "คำแนะนำ"
|
||||
realtimeMode: "โหมดเรียลไทม์"
|
||||
turnItOn: "เปิดใช้งาน"
|
||||
turnItOff: "ปิดใช้งาน"
|
||||
emojiMute: "ปิดเสียงเอโมจิ"
|
||||
emojiUnmute: "เลิกปิดเสียงเอโมจิ"
|
||||
muteX: "ปิดเสียง {x}"
|
||||
unmuteX: "เลิกปิดเสียง {x}"
|
||||
abort: "หยุดและยกเลิก"
|
||||
tip: "คำแนะนำและเคล็ดลับ"
|
||||
redisplayAllTips: "แสดงคำแนะนำและเคล็ดลับทั้งหมดอีกครั้ง"
|
||||
hideAllTips: "ซ่อนคำแนะนำและเคล็ดลับทั้งหมด"
|
||||
defaultImageCompressionLevel: "ความละเอียดเริ่มต้นสำหรับการบีบอัดภาพ"
|
||||
defaultImageCompressionLevel_description: "หากตั้งค่าต่ำ จะรักษาคุณภาพภาพได้ดีขึ้นแต่ขนาดไฟล์จะเพิ่มขึ้น<br>หากตั้งค่าสูง จะลดขนาดไฟล์ได้ แต่คุณภาพภาพจะลดลง"
|
||||
_order:
|
||||
newest: "เรียงจากใหม่ไปเก่า"
|
||||
oldest: "เรียงจากเก่าไปใหม่"
|
||||
_chat:
|
||||
noMessagesYet: "ยังไม่มีข้อความ"
|
||||
newMessage: "ข้อความใหม่"
|
||||
individualChat: "แชตส่วนตัว"
|
||||
individualChat_description: "สามารถแชตแบบตัวต่อตัวกับผู้ใช้ที่ระบุไว้ได้"
|
||||
roomChat: "ห้องแชต"
|
||||
roomChat_description: "สามารถแชตแบบกลุ่มหลายคนได้\nและสามารถแชตกับผู้ใช้ที่ไม่ได้อนุญาตแชตส่วนตัวได้ หากอีกฝ่ายยอมรับ"
|
||||
createRoom: "สร้างห้อง"
|
||||
inviteUserToChat: "เชิญผู้ใช้และเริ่มแชตได้เลย"
|
||||
yourRooms: "ห้องที่สร้างไว้"
|
||||
joiningRooms: "ห้องที่เข้าร่วมอยู่"
|
||||
invitations: "คำเชิญ"
|
||||
noInvitations: "ไม่มีคำเชิญ"
|
||||
history: "ประวัติ"
|
||||
noHistory: "ไม่มีประวัติ"
|
||||
noRooms: "ไม่มีห้อง"
|
||||
inviteUser: "เชิญผู้ใช้"
|
||||
sentInvitations: "คำเชิญที่ส่งไปแล้ว"
|
||||
join: "เข้าร่วม"
|
||||
ignore: "ไม่สนใจ"
|
||||
leave: "ออกจากห้อง"
|
||||
members: "สมาชิก"
|
||||
searchMessages: "ค้นหาข้อความ"
|
||||
home: "หน้าหลัก"
|
||||
send: "ส่ง"
|
||||
newline: "ขึ้นบรรทัดใหม่"
|
||||
muteThisRoom: "ปิดเสียงห้องนี้"
|
||||
deleteRoom: "ลบห้อง"
|
||||
chatNotAvailableForThisAccountOrServer: "แชตไม่ได้เปิดใช้งานบนเซิร์ฟเวอร์นี้ หรือบัญชีนี้"
|
||||
chatIsReadOnlyForThisAccountOrServer: "แชตบนเซิร์ฟเวอร์นี้ หรือบัญชีนี้ เป็นแบบอ่านอย่างเดียว ไม่สามารถส่งข้อความใหม่ สร้างหรือเข้าร่วมห้องแชตได้"
|
||||
chatNotAvailableInOtherAccount: "บัญชีคู่สนทนาไม่สามารถใช้ฟังก์ชันแชตได้"
|
||||
cannotChatWithTheUser: "ไม่สามารถเริ่มแชตกับผู้ใช้นี้ได้"
|
||||
cannotChatWithTheUser_description: "แชตใช้งานไม่ได้ หรือคู่สนทนายังไม่ได้เปิดแชต"
|
||||
youAreNotAMemberOfThisRoomButInvited: "คุณไม่ได้เป็นผู้เข้าร่วมห้องนี้ แต่มีคำเชิญส่งมา หากต้องการเข้าร่วม กรุณายืนยันคำเชิญ"
|
||||
doYouAcceptInvitation: "ต้องการยอมรับคำเชิญหรือไม่?"
|
||||
chatWithThisUser: "แชตเลย"
|
||||
thisUserAllowsChatOnlyFromFollowers: "ผู้ใช้นี้รับแชตเฉพาะจากผู้ติดตามเท่านั้น"
|
||||
thisUserAllowsChatOnlyFromFollowing: "ผู้ใช้นี้รับแชตเฉพาะจากผู้ที่เขาติดตามเท่านั้น"
|
||||
thisUserAllowsChatOnlyFromMutualFollowing: "ผู้ใช้นี้รับแชตเฉพาะจากผู้ที่ติดตามซึ่งกันและกันทั้งสองฝ่ายเท่านั้น"
|
||||
thisUserNotAllowedChatAnyone: "ผู้ใช้นี้ไม่รับแชตจากใครเลย"
|
||||
chatAllowedUsers: "ผู้ที่อนุญาตให้แชตด้วย"
|
||||
chatAllowedUsers_note: "ไม่ว่าจะตั้งค่ายังไง คุณยังสามารถแชตกับคนที่คุณส่งข้อความไปหาได้"
|
||||
_chatAllowedUsers:
|
||||
everyone: "ใครก็ได้หมด"
|
||||
followers: "เฉพาะผู้ติดตามเท่านั้น"
|
||||
following: "เฉพาะผู้ที่ตัวเองติดตามเท่านั้น"
|
||||
mutual: "เฉพาะผู้ใช้ที่ติดตามซึ่งกันและกันทั้งสองฝ่ายเท่านั้น"
|
||||
none: "ไม่อนุญาตให้ใครเลย"
|
||||
_emojiPalette:
|
||||
palettes: "จานสี"
|
||||
enableSyncBetweenDevicesForPalettes: "เปิดใช้งานการซิงค์จานสีระหว่างอุปกรณ์"
|
||||
paletteForMain: "จานสีหลักที่ใช้"
|
||||
paletteForReaction: "จานสีที่ใช้ในการรีแอคชั่น"
|
||||
_settings:
|
||||
driveBanner: "สามารถจัดการและตั้งค่าไดรฟ์ ตรวจสอบการใช้งาน และตั้งค่าการอัปโหลดไฟล์ได้"
|
||||
pluginBanner: "สามารถขยายความสามารถของไคลเอนต์ด้วยปลั๊กอินได้ ติดตั้ง ตั้งค่า และจัดการปลั๊กอินแต่ละตัวได้"
|
||||
notificationsBanner: "สามารถตั้งค่าประเภทและขอบเขตของการแจ้งเตือนที่รับจากเซิร์ฟเวอร์ รวมถึงการแจ้งเตือนแบบพุช"
|
||||
api: "API"
|
||||
webhook: "Webhook"
|
||||
serviceConnection: "การเชื่อมต่อกับบริการ"
|
||||
serviceConnectionBanner: "สามารถจัดการและตั้งค่า Access Token และ Webhook เพื่อเชื่อมต่อกับแอปหรือบริการภายนอกได้"
|
||||
accountData: "ข้อมูลบัญชี"
|
||||
accountDataBanner: "สามารถจัดการข้อมูลบัญชีได้โดยส่งออกหรือนำเข้าไฟล์เก็บถาวร"
|
||||
muteAndBlockBanner: "สามารถตั้งค่าการซ่อนเนื้อหา และจำกัดการกระทำจากผู้ใช้เฉพาะรายได้"
|
||||
accessibilityBanner: "สามารถปรับแต่งรูปลักษณ์และพฤติกรรมของไคลเอนต์เพื่อให้เหมาะกับการใช้งานของตนเองมากขึ้น"
|
||||
privacyBanner: "สามารถตั้งค่าความเป็นส่วนตัวของบัญชี เช่น ขอบเขตการเผยแพร่เนื้อหา ความสามารถในการค้นหา และการอนุมัติผู้ติดตาม"
|
||||
securityBanner: "สามารถตั้งค่าความปลอดภัยของบัญชี เช่น รหัสผ่าน วิธีการเข้าสู่ระบบ แอปยืนยันตัวตน Passkey เป็นต้น"
|
||||
preferencesBanner: "คุณสามารถกำหนดค่าพฤติกรรมโดยรวมของไคลเอนต์ได้ตามความต้องการของคุณ"
|
||||
appearanceBanner: "สามารถตั้งค่ารูปลักษณ์และวิธีการแสดงผลของไคลเอนต์ตามความชอบได้"
|
||||
soundsBanner: "สามารถตั้งค่าเสียงที่จะเล่นบนไคลเอนต์ได้"
|
||||
timelineAndNote: "ไทม์ไลน์และโน้ต"
|
||||
makeEveryTextElementsSelectable: "อนุญาตให้เลือกข้อความทั้งหมดได้"
|
||||
makeEveryTextElementsSelectable_description: "หากเปิดใช้งาน อาจทำให้ความสะดวกในการใช้งานลดลงในบางสถานการณ์"
|
||||
useStickyIcons: "ทำให้ไอคอนเคลื่อนตามการเลื่อน"
|
||||
enableHighQualityImagePlaceholders: "แสดงภาพตัวแทนคุณภาพสูง"
|
||||
uiAnimations: "ภาพเคลื่อนไหวของ UI"
|
||||
showNavbarSubButtons: "แสดงปุ่มรองบนแถบนำทาง"
|
||||
ifOn: "เมื่อเปิดใช้งาน"
|
||||
ifOff: "เมื่อปิดใช้งาน"
|
||||
enableSyncThemesBetweenDevices: "ซิงค์ธีมที่ติดตั้งระหว่างอุปกรณ์"
|
||||
enablePullToRefresh: "ดึงเพื่ออัปเดต"
|
||||
enablePullToRefresh_description: "สำหรับเมาส์ ให้กดปุ่มล้อกลางค้างไว้แล้วลาก"
|
||||
realtimeMode_description: "เชื่อมต่อกับเซิร์ฟเวอร์และอัปเดตเนื้อหาแบบเรียลไทม์ อาจทำให้ใช้ปริมาณข้อมูลและแบตเตอรี่มากขึ้นได้"
|
||||
contentsUpdateFrequency: "ความถี่ในการดึงข้อมูลเนื้อหา"
|
||||
contentsUpdateFrequency_description: "ยิ่งตั้งค่าสูง เนื้อหาจะอัปเดตแบบเรียลไทม์มากขึ้น แต่ประสิทธิภาพอาจลดลง และการใช้ข้อมูลกับแบตเตอรี่จะเพิ่มมากขึ้น"
|
||||
contentsUpdateFrequency_description2: "เมื่อโหมดเรียลไทม์เปิดอยู่ เนื้อหาจะอัปเดตแบบเรียลไทม์โดยไม่ขึ้นกับการตั้งค่านี้"
|
||||
showUrlPreview: "แสดงตัวอย่าง URL"
|
||||
showAvailableReactionsFirstInNote: "แสดงรีแอคชั่นที่ใช้ได้ไว้หน้าสุด"
|
||||
_chat:
|
||||
showSenderName: "แสดงชื่อผู้ส่ง"
|
||||
sendOnEnter: "กด Enter เพื่อส่ง"
|
||||
_preferencesProfile:
|
||||
profileName: "ชื่อโปรไฟล์"
|
||||
profileNameDescription: "กรุณาตั้งชื่อเพื่อระบุอุปกรณ์นี้"
|
||||
profileNameDescription2: "เช่น: “คอมเครื่องหลัก”, “มือถือ” ฯลฯ"
|
||||
manageProfiles: "จัดการโปรไฟล์"
|
||||
_preferencesBackup:
|
||||
autoBackup: "สำรองโดยอัตโนมัติ"
|
||||
restoreFromBackup: "คืนค่าจากข้อมูลสำรอง"
|
||||
noBackupsFoundTitle: "ไม่พบข้อมูลสำรอง"
|
||||
noBackupsFoundDescription: "ไม่พบข้อมูลสำรองที่สร้างโดยอัตโนมัติ แต่หากมีข้อมูลสำรองที่บันทึกด้วยตนเอง สามารถนำเข้ามาเพื่อกู้คืนได้"
|
||||
selectBackupToRestore: "กรุณาเลือกข้อมูลสำรองที่ต้องการกู้คืน"
|
||||
youNeedToNameYourProfileToEnableAutoBackup: "จำเป็นต้องตั้งชื่อโปรไฟล์ก่อนจึงจะเปิดใช้งานการสำรองข้อมูลอัตโนมัติได้"
|
||||
autoPreferencesBackupIsNotEnabledForThisDevice: "ยังไม่ได้เปิดใช้งานการสำรองข้อมูลอัตโนมัติบนอุปกรณ์นี้"
|
||||
backupFound: "พบข้อมูลสำรองของการตั้งค่าแล้ว"
|
||||
_accountSettings:
|
||||
requireSigninToViewContents: "ต้องเข้าสู่ระบบเพื่อดูเนื้อหา"
|
||||
requireSigninToViewContentsDescription1: "ต้องเข้าสู่ระบบเพื่อดูบันทึกและเนื้อหาอื่น ๆ ทั้งหมดที่คุณสร้าง คาดว่าจะมีประสิทธิผลในการป้องกันไม่ให้ข้อมูลถูกเก็บรวบรวมโดยโปรแกรมรวบรวมข้อมูล"
|
||||
requireSigninToViewContentsDescription2: "นอกจากนี้ จะไม่สามารถดูจากเซิร์ฟเวอร์ที่ไม่รองรับการดูตัวอย่าง URL (OGP), การฝังในหน้าเว็บ หรือการอ้างอิงหมายเหตุได้"
|
||||
requireSigninToViewContentsDescription3: "เนื้อหาที่ถูกรวมเข้ากับเซิร์ฟเวอร์ระยะไกลอาจไม่อยู่ภายใต้ข้อจำกัดเหล่านี้"
|
||||
requireSigninToViewContentsDescription1: "กำหนดให้ต้องเข้าสู่ระบบก่อนจึงจะสามารถดูโน้ตหรือเนื้อหาทั้งหมดที่สร้างไว้ได้ ซึ่งช่วยป้องกันไม่ให้ข้อมูลถูกเก็บโดยบอตหรือ Crawler (โปรแกรมรวบรวมข้อมูล)"
|
||||
requireSigninToViewContentsDescription2: "จะไม่สามารถแสดงผลจากเซิร์ฟเวอร์ที่ไม่รองรับการแสดงตัวอย่าง URL (OGP), การฝังในหน้าเว็บ, หรือการอ้างอิงโน้ตได้"
|
||||
requireSigninToViewContentsDescription3: "เนื้อหาที่ถูกรวมผ่านสหพันธ์จากเซิร์ฟเวอร์ระยะไกลอาจไม่อยู่ภายใต้ข้อจำกัดเหล่านี้"
|
||||
makeNotesFollowersOnlyBefore: "แสดงโน้ตเก่าเฉพาะกับผู้ติดตามเท่านั้น"
|
||||
makeNotesFollowersOnlyBeforeDescription: "ขณะที่เปิดฟังก์ชันนี้ โน้ตที่เก่ากว่าหรือเลยเวลาที่กำหนดจะแสดงเฉพาะกับผู้ติดตามเท่านั้น หากปิดใช้งาน สถานะการเปิดเผยจะกลับไปเป็นแบบเดิม"
|
||||
makeNotesHiddenBefore: "ทำให้โน้ตเก่าทั้งหมดเป็นแบบส่วนตัว"
|
||||
makeNotesHiddenBeforeDescription: "ขณะที่เปิดฟังก์ชันนี้ โน้ตที่เก่ากว่าหรือเลยเวลาที่กำหนดจะแสดงเฉพาะกับตนเอง (กลายเป็นแบบส่วนตัว) หากปิดใช้งาน สถานะการเปิดเผยจะกลับไปเป็นแบบเดิม"
|
||||
mayNotEffectForFederatedNotes: "โน้ตที่ถูกรวมผ่านสหพันธ์จากเซิร์ฟเวอร์ระยะไกลอาจไม่ได้รับผลจากการตั้งค่านี้"
|
||||
mayNotEffectSomeSituations: "ข้อจำกัดเหล่านี้เป็นเพียงการกรองเบื้องต้น ในบางกรณี เช่น การดูจากเซิร์ฟเวอร์อื่นหรือในระหว่างการตรวจสอบโดยผู้ดูแล อาจไม่สามารถใช้งานได้"
|
||||
notesHavePassedSpecifiedPeriod: "โน้ตที่เลยเวลาที่กำหนดไว้แล้ว"
|
||||
notesOlderThanSpecifiedDateAndTime: "โน้ตก่อนเวลาที่กำหนดไว้"
|
||||
_abuseUserReport:
|
||||
forward: "ส่งต่อ"
|
||||
forwardDescription: "ส่งรายงานไปยังเซิร์ฟเวอร์ระยะไกลโดยใช้บัญชีระบบที่ไม่ระบุตัวตน"
|
||||
resolve: "แก้ไข"
|
||||
accept: "ยอมรับ"
|
||||
reject: "ปฏิเสธ"
|
||||
resolveTutorial: "ถ้าหากรายงานนี้มีเนื้อหาถูกต้อง ให้เลือก \"ยอมรับ\" เพื่อปิดเคสกรณีนี้โดยถือว่าได้รับการแก้ไขแล้ว\nถ้าหากเนื้อหาในรายงานนี้นั้นไม่ถูกต้อง ให้เลือก \"ปฏิเสธ\" เพื่อปิดเคสกรณีนี้โดยถือว่าไม่ได้รับการแก้ไข"
|
||||
resolveTutorial: "ให้เลือก “ยอมรับ” หากรายงานนี้มีเนื้อหาชอบธรรม เพื่อทำเครื่องหมายว่ากรณีนี้ได้รับการแก้ไขในทางบวก\nให้เลือก “ปฏิเสธ” หากรายงานนี้มีเนื้อหาไม่สมเหตุผล เพื่อทำเครื่องหมายว่ากรณีนี้ได้รับการแก้ไขในทางลบ"
|
||||
_delivery:
|
||||
status: "สถานะการจัดส่ง"
|
||||
stop: "ระงับการส่ง"
|
||||
|
@ -1335,6 +1505,7 @@ _delivery:
|
|||
manuallySuspended: "หยุดชั่วคราวด้วยตนเอง"
|
||||
goneSuspended: "เซิร์ฟเวอร์ถูกระงับเนื่องจากมีการลบเซิร์ฟเวอร์นี้"
|
||||
autoSuspendedForNotResponding: "เซิร์ฟเวอร์ถูกระงับเนื่องจากไม่ตอบสนอง"
|
||||
softwareSuspended: "หยุดให้บริการ เนื่องจากเป็นซอฟต์แวร์ที่ถูกระงับการเผยแพร่"
|
||||
_bubbleGame:
|
||||
howToPlay: "วิธีเล่น"
|
||||
hold: "ถือไว้"
|
||||
|
@ -1449,7 +1620,7 @@ _timelineDescription:
|
|||
_serverRules:
|
||||
description: "ชุดของกฎที่จะแสดงก่อนการลงทะเบียนเราขอแนะนำให้ตั้งค่าสรุปข้อกำหนดในการให้บริการ"
|
||||
_serverSettings:
|
||||
iconUrl: "URL ไอคอน"
|
||||
iconUrl: "URL ของไอคอน"
|
||||
appIconDescription: "ระบุไอคอนที่จะใช้เมื่อ {host} แสดงเป็นแอป"
|
||||
appIconUsageExample: "ตัวอย่างเช่น เมื่อถูกเพิ่มเป็น PWA หรือบุ๊กมาร์กบนหน้าจอหลักในสมาร์ทโฟน"
|
||||
appIconStyleRecommendation: "เนื่องจากอาจถูกครอบตัดเป็นสี่เหลี่ยมหรือวงกลม จึงแนะนำให้ใช้ภาพที่เผื่อพื้นที่รอบๆ ตัวโลโก้ไอคอนไว้"
|
||||
|
@ -1463,7 +1634,26 @@ _serverSettings:
|
|||
reactionsBufferingDescription: "เมื่อเปิดใช้งานฟังก์ชันนี้ก็จะช่วยลด latency ในการสร้างปฏิกิริยา แต่อาจจะส่งผลให้ memory footprint ของ Redis เพิ่มขึ้นนะ"
|
||||
inquiryUrl: "URL สำหรับการติดต่อสอบถาม"
|
||||
inquiryUrlDescription: "ระบุ URL ของหน้าเว็บที่มีแบบฟอร์มสำหรับติดต่อผู้ดูแลเซิร์ฟเวอร์ หรือข้อมูลการติดต่อของผู้ดูแลเซิร์ฟเวอร์"
|
||||
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "ถ้าหากไม่มีการตรวจสอบจากผู้ดูแลระบบหรือไม่มีความเคลื่อนไหวมาเป็นระยะเวลาหนึ่ง ระบบจะทำการปิดใช้งานฟังก์ชันนี้โดยอัตโนมัติ เพื่อลดความเสี่ยงในการถูกโจมตีด้วยสแปมและอื่นๆ"
|
||||
openRegistration: "เปิดให้สร้างบัญชีได้"
|
||||
openRegistrationWarning: "การเปิดให้ลงทะเบียนมีความเสี่ยง แนะนำให้เปิดใช้งานเฉพาะในกรณีที่สามารถตรวจสอบเซิร์ฟเวอร์อย่างสม่ำเสมอและมีระบบรับมือกับปัญหาได้ทันท่วงที"
|
||||
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "หากไม่พบกิจกรรมของผู้ควบคุมในช่วงระยะเวลาหนึ่ง การตั้งค่านี้จะถูกปิดโดยอัตโนมัติเพื่อป้องกันสแปม"
|
||||
deliverSuspendedSoftware: "ซอฟต์แวร์ที่หยุดการเผยแพร่"
|
||||
deliverSuspendedSoftwareDescription: "เนื่องจากเหตุผลด้านช่องโหว่ เป็นต้น สามารถหยุดการแจกจ่ายโดยระบุชื่อซอฟต์แวร์ของเซิร์ฟเวอร์และช่วงของเวอร์ชันได้ ข้อมูลเวอร์ชันนี้เป็นข้อมูลที่เซิร์ฟเวอร์ให้มา จึงไม่สามารถรับประกันความน่าเชื่อถือได้ สามารถใช้การระบุช่วงเวอร์ชันแบบ semver ได้ แต่ถ้าระบุเป็น >= 2024.3.1 จะไม่รวมเวอร์ชันแบบกำหนดเอง เช่น 2024.3.1-custom.0 จึงแนะนำให้ระบุเป็น >= 2024.3.1-0 ซึ่งเป็นการระบุแบบ prerelease"
|
||||
singleUserMode: "โหมดผู้ใช้คนเดียว"
|
||||
singleUserMode_description: "หากมีเพียงตัวเองคนเดียวที่ใช้เซิร์ฟเวอร์นี้ การเปิดใช้งานโหมดนี้จะช่วยปรับการทำงานให้เหมาะสมที่สุด"
|
||||
signToActivityPubGet: "ลงนามในคำขอ GET"
|
||||
signToActivityPubGet_description: "โดยปกติควรเปิดใช้งาน แต่หากพบปัญหาเกี่ยวกับการสื่อสารในสหพันธ์ การปิดใช้งานอาจช่วยแก้ไขได้ แต่ในบางกรณี เซิร์ฟเวอร์อาจไม่สามารถสื่อสารได้เลยหากปิดใช้งานนี้"
|
||||
proxyRemoteFiles: "พร็อกซีไฟล์ระยะไกล"
|
||||
proxyRemoteFiles_description: "เมื่อเปิดใช้งาน จะทำหน้าที่เป็นพร็อกซีสำหรับไฟล์จากระยะไกล ช่วยในการสร้างภาพขนาดย่อและปกป้องความเป็นส่วนตัวของผู้ใช้"
|
||||
allowExternalApRedirect: "อนุญาตการเปลี่ยนเส้นทางการสืบค้นผ่าน ActivityPub"
|
||||
allowExternalApRedirect_description: "เมื่อเปิดใช้งาน จะอนุญาตให้เซิร์ฟเวอร์อื่นสืบค้นเนื้อหาของบุคคลที่สามผ่านเซิร์ฟเวอร์นี้ได้ แต่มีความเสี่ยงที่อาจเกิดการปลอมแปลงเนื้อหา"
|
||||
userGeneratedContentsVisibilityForVisitor: "ขอบเขตการเปิดเผยเนื้อหาที่ผู้ใช้สร้างต่อบุคคลที่ไม่ได้เข้าร่วม (แขก)"
|
||||
userGeneratedContentsVisibilityForVisitor_description: "ช่วยป้องกันปัญหาที่อาจเกิดขึ้นจากเนื้อหาระยะไกลที่ไม่เหมาะสม ซึ่งอาจถูกเผยแพร่ออกสู่อินเทอร์เน็ตโดยไม่ตั้งใจผ่านเซิร์ฟเวอร์ของตนเอง โดยเฉพาะในกรณีที่การดูแลควบคุมไม่ทั่วถึง"
|
||||
userGeneratedContentsVisibilityForVisitor_description2: "การเปิดเผยเนื้อหาทั้งหมดในเซิร์ฟเวอร์รวมทั้งเนื้อหาที่รับมาจากระยะไกลสู่สาธารณะบนอินเทอร์เน็ตโดยไม่มีข้อจำกัดใดๆ มีความเสี่ยงโดยเฉพาะอย่างยิ่งสำหรับผู้ชมที่ไม่เข้าใจลักษณะของระบบแบบกระจาย อาจทำให้เกิดความเข้าใจผิดคิดว่าเนื้อหาที่มาจากระยะไกลนั้นเป็นเนื้อหาที่สร้างขึ้นภายในเซิร์ฟเวอร์นี้ จึงควรใช้ความระมัดระวังอย่างมาก"
|
||||
_userGeneratedContentsVisibilityForVisitor:
|
||||
all: "ทั้งหมดสาธารณะ"
|
||||
localOnly: "เผยแพร่เป็นสาธารณะเฉพาะเนื้อหาท้องถิ่น เนื้อหาระยะไกลให้เป็นส่วนตัว"
|
||||
none: "ทั้งหมดส่วนตัว"
|
||||
_accountMigration:
|
||||
moveFrom: "ย้ายจากบัญชีอื่นมาที่บัญชีนี้"
|
||||
moveFromSub: "สร้างนามแฝงไปยังบัญชีอื่น"
|
||||
|
@ -1753,13 +1943,15 @@ _role:
|
|||
baseRole: "แม่แบบบทบาท"
|
||||
useBaseValue: "ใช้ตามแม่แบบบทบาท"
|
||||
chooseRoleToAssign: "เลือกบทบาทที่ต้องการกำหนด"
|
||||
iconUrl: "URL ไอคอน"
|
||||
iconUrl: "URL ของไอคอน"
|
||||
asBadge: "แสดงเป็นตรา"
|
||||
descriptionOfAsBadge: "เมื่อเปิดใช้งาน ไอคอนบทบาทจะปรากฏถัดจากชื่อผู้ใช้"
|
||||
descriptionOfAsBadge: "หากเปิดใช้งาน จะมีไอคอนของบทบาท แสดงถัดจากชื่อผู้ใช้"
|
||||
isExplorable: "ค้นหาผู้ใช้ได้ง่ายขึ้นโดยดูจากบทบาท"
|
||||
descriptionOfIsExplorable: "เมื่อเปิดใช้งาน ไทมไลน์บทบาทนี้และสมาชิกที่มีบทบาทนี้จะเปิดเผยเป็นสาธารณะ"
|
||||
displayOrder: "ลำดับการแสดงผล"
|
||||
descriptionOfDisplayOrder: "เลขที่สูงกว่าจะแสดงบน UI ก่อน"
|
||||
preserveAssignmentOnMoveAccount: "โอนสถานะการมอบหมายไปยังบัญชีที่ย้ายไป"
|
||||
preserveAssignmentOnMoveAccount_description: "เมื่อเปิดใช้งาน บัญชีที่ได้รับบทบาทนี้เมื่อถูกย้ายไปบัญชีใหม่ บทบาทนี้จะถูกถ่ายทอดไปยังบัญชีปลายทางด้วย"
|
||||
canEditMembersByModerator: "อนุญาตให้ผู้ควบคุมแก้ไขสมาชิก"
|
||||
descriptionOfCanEditMembersByModerator: "เมื่อเปิดใช้ นอกเหนือจากผู้ควบคุมและผู้ดูแลระบบแล้ว จะสามารถเพิ่มถอนบทบาทนี้แก่ผู้ใช้ได้ แต่เมื่อปิดใช้ จะมีเฉพาะผู้ดูแลระบบเท่านั้นที่จะสามารถดำเนินการได้"
|
||||
priority: "ลำดับความสำคัญ"
|
||||
|
@ -1779,8 +1971,9 @@ _role:
|
|||
canManageCustomEmojis: "จัดการเอโมจิที่กำหนดเอง"
|
||||
canManageAvatarDecorations: "จัดการตกแต่งอวตาร"
|
||||
driveCapacity: "ความจุของไดรฟ์"
|
||||
maxFileSize: "ขนาดไฟล์สูงสุดที่สามารถอัปโหลดได้"
|
||||
alwaysMarkNsfw: "ทำเครื่องหมายไฟล์ว่าเป็น NSFW เสมอ"
|
||||
canUpdateBioMedia: "อนุญาตให้ปรับปรุงไอคอนและแบนเนอร์"
|
||||
canUpdateBioMedia: "อนุญาตให้เปลี่ยนไอคอนประจำตัวและแบนเนอร์"
|
||||
pinMax: "จํานวนสูงสุดของโน้ตที่ปักหมุดไว้"
|
||||
antennaMax: "จำนวนสูงสุดของเสาอากาศ"
|
||||
wordMuteMax: "จำนวนอักขระสูงสุดที่อนุญาตในการปิดเสียงคำ"
|
||||
|
@ -1794,12 +1987,17 @@ _role:
|
|||
canHideAds: "ซ่อนโฆษณา"
|
||||
canSearchNotes: "การใช้การค้นหาโน้ต"
|
||||
canUseTranslator: "การใช้งานแปล"
|
||||
avatarDecorationLimit: "จำนวนการตกแต่งไอคอนสูงสุดที่สามารถติดตั้งได้"
|
||||
avatarDecorationLimit: "จำนวนของตกแต่งไอคอนสูงสุดที่สามารถติดตั้งได้"
|
||||
canImportAntennas: "อนุญาตให้นำเข้าเสาอากาศ"
|
||||
canImportBlocking: "อนุญาตให้นำเข้าการบล็อก"
|
||||
canImportFollowing: "อนุญาตให้นำเข้ารายการต่อไปนี้"
|
||||
canImportMuting: "อนุญาตให้นำเข้าการปิดกั้น"
|
||||
canImportMuting: "อนุญาตให้นำเข้าการปิดเสียง"
|
||||
canImportUserLists: "อนุญาตให้นำเข้ารายการ"
|
||||
chatAvailability: "อนุญาตให้แชต"
|
||||
uploadableFileTypes: "ประเภทไฟล์ที่สามารถอัปโหลดได้"
|
||||
uploadableFileTypes_caption: "สามารถระบุ MIME type ได้ โดยใช้การขึ้นบรรทัดใหม่เพื่อแยกหลายรายการ และสามารถใช้ดอกจัน (*) เพื่อระบุแบบไวลด์การ์ดได้ (เช่น: image/*)"
|
||||
uploadableFileTypes_caption2: "ไฟล์บางประเภทอาจไม่สามารถระบุชนิดได้ หากต้องการอนุญาตไฟล์ลักษณะนั้น กรุณาเพิ่ม {x} ลงในรายการที่อนุญาต"
|
||||
noteDraftLimit: "จำนวนโน้ตฉบับร่างที่สามารถสร้างได้บนฝั่งเซิร์ฟเวอร์"
|
||||
_condition:
|
||||
roleAssignedTo: "มอบหมายให้มีบทบาทแบบทำมือ"
|
||||
isLocal: "ผู้ใช้ท้องถิ่น"
|
||||
|
@ -1959,10 +2157,12 @@ _theme:
|
|||
install: "ติดตั้งธีม"
|
||||
manage: "จัดการธีม"
|
||||
code: "โค้ดธีม"
|
||||
description: "รายละเอียด"
|
||||
copyThemeCode: "คัดลอกรหัสธีม"
|
||||
description: "คำอธิบาย"
|
||||
installed: "{name} ได้รับการติดตั้ง"
|
||||
installedThemes: "ธีมที่ติดตั้ง"
|
||||
builtinThemes: "ธีมในตัว"
|
||||
instanceTheme: "ธีมของเซิร์ฟเวอร์"
|
||||
alreadyInstalled: "ธีมนี้ได้รับการติดตั้งแล้ว"
|
||||
invalid: "รูปแบบของธีมนี้ไม่ถูกต้องนะ"
|
||||
make: "ทำธีม"
|
||||
|
@ -1990,7 +2190,7 @@ _theme:
|
|||
fg: "ข้อความ"
|
||||
focus: "โฟกัส"
|
||||
indicator: "ตัวบ่งชี้"
|
||||
panel: "แผงควบคุม"
|
||||
panel: "แผง"
|
||||
shadow: "เงา"
|
||||
header: "ส่วนหัว"
|
||||
navBg: "พื้นหลังแถบด้านข้าง"
|
||||
|
@ -2000,7 +2200,7 @@ _theme:
|
|||
link: "ลิงก์"
|
||||
hashtag: "แฮชแท็ก"
|
||||
mention: "กล่าวถึง"
|
||||
mentionMe: "ได้กล่าวถึง (ฉัน)"
|
||||
mentionMe: "ได้กล่าวถึงคุณ"
|
||||
renote: "รีโน้ต"
|
||||
modalBg: "พื้นหลังโมดอล"
|
||||
divider: "ตัวแบ่ง"
|
||||
|
@ -2024,6 +2224,7 @@ _sfx:
|
|||
noteMy: "โน้ตของตัวเอง"
|
||||
notification: "การเเจ้งเตือน"
|
||||
reaction: "เมื่อเลือกรีแอคชั่น"
|
||||
chatMessage: "ข้อความของแชต"
|
||||
_soundSettings:
|
||||
driveFile: "ใช้เสียงจากไดรฟ์"
|
||||
driveFileWarn: "เลือกไฟล์ในไดรฟ์ของคุณ"
|
||||
|
@ -2066,15 +2267,15 @@ _2fa:
|
|||
step3: "ป้อนโทเค็นที่แอปของคุณให้มาเพื่อเสร็จสิ้นการตั้งค่า"
|
||||
setupCompleted: "ตั้งค่าสำเร็จแล้ว"
|
||||
step4: "นับจากนี้เป็นต้นไปการพยายามเข้าสู่ระบบในอนาคตนั้น อาจจะต้องขอโทเค็นในการเข้าสู่ระบบดังกล่าว"
|
||||
securityKeyNotSupported: "เบราว์เซอร์ของคุณไม่รองรับคีย์ความปลอดภัยนะ"
|
||||
registerTOTPBeforeKey: "กรุณาตั้งค่าแอปยืนยันตัวตนเพื่อลงทะเบียนรหัสความปลอดภัยหรือรหัสผ่าน"
|
||||
securityKeyInfo: "นอกจากนี้การตรวจสอบความถูกต้องด้วยลายนิ้วมือหรือ PIN แล้ว คุณยังสามารถตั้งค่าการตรวจสอบสิทธิ์ผ่านคีย์ความปลอดภัยของฮาร์ดแวร์ที่รองรับ FIDO2 เพื่อเพิ่มความปลอดภัยให้กับบัญชีของคุณ"
|
||||
registerSecurityKey: "ลงทะเบียนรหัสความปลอดภัยหรือรหัสผ่าน"
|
||||
securityKeyNotSupported: "เว็บเบราว์เซอร์ที่ใช้งานอยู่ไม่รองรับ Security Key"
|
||||
registerTOTPBeforeKey: "ก่อนลงทะเบียน Security Key หรือ Passkey กรุณาตั้งค่าแอปยืนยันตัวตนก่อน"
|
||||
securityKeyInfo: "ลงทะเบียนกุญแจที่มาจาก WebAuthn เช่น Security Key แบบฮาร์ดแวร์ที่รองรับ FIDO2 การยืนยันตัวตนด้วยชีวมิติหรือ PIN บนอุปกรณ์ และ Passkey"
|
||||
registerSecurityKey: "ลงทะเบียน Security Key หรือ Passkey"
|
||||
securityKeyName: "ป้อนชื่อคีย์"
|
||||
tapSecurityKey: "กรุณาทำตามเบราว์เซอร์ของคุณเพื่อลงทะเบียนรหัสความปลอดภัยหรือรหัสผ่าน"
|
||||
removeKey: "ลบคีย์ความปลอดภัยออก"
|
||||
tapSecurityKey: "กรุณาทำตามคำแนะนำของเบราว์เซอร์เพื่อลงทะเบียน Security Key หรือ Passkey"
|
||||
removeKey: "ลบ Security Key ออก"
|
||||
removeKeyConfirm: "ลบข้อมูลสำรอง {name} มั้ย?"
|
||||
whyTOTPOnlyRenew: "ไม่สามารถลบแอปตัวรับรองความถูกต้องได้ตราบใดที่มีการลงทะเบียนคีย์ความปลอดภัยไว้แล้ว"
|
||||
whyTOTPOnlyRenew: "ไม่สามารถลบแอปตัวรับรองความถูกต้องได้ตราบใดที่ยังมีการลงทะเบียน Security Key อยู่"
|
||||
renewTOTP: "ตั้งค่าแอปยืนยันตัวตน"
|
||||
renewTOTPConfirm: "วิธีการแบบนี้จะทําให้รหัสยืนยันจากแอพก่อนหน้าของคุณหยุดทํางานเลยนะ"
|
||||
renewTOTPOk: "ตั้งค่าคอนฟิกใหม่"
|
||||
|
@ -2171,6 +2372,7 @@ _permissions:
|
|||
"read:federation": "รับข้อมูลเกี่ยวกับสหพันธ์"
|
||||
"write:report-abuse": "รายงานการละเมิด"
|
||||
"write:chat": "เขียนหรือลบข้อความแชท"
|
||||
"read:chat": "อ่านแชต"
|
||||
_auth:
|
||||
shareAccessTitle: "การให้สิทธิ์แอปพลิเคชัน"
|
||||
shareAccess: "คุณต้องการอนุญาตให้ \"{name}\" เข้าถึงบัญชีนี้เลยมั้ย?"
|
||||
|
@ -2179,8 +2381,11 @@ _auth:
|
|||
permissionAsk: "แอปพลิเคชันนี้ขอสิทธิ์ดังต่อไปนี้"
|
||||
pleaseGoBack: "กรุณากลับไปที่แอปพลิเคชัน"
|
||||
callback: "กำลังกลับไปที่แอปพลิเคชัน"
|
||||
accepted: "การเข้าถึงได้รับอนุญาต"
|
||||
denied: "ปฏิเสธการเข้าใช้"
|
||||
scopeUser: "กำลังดำเนินการในฐานะผู้ใช้ต่อไปนี้"
|
||||
pleaseLogin: "กรุณาเข้าสู่ระบบเพื่ออนุมัติแอปพลิเคชัน"
|
||||
byClickingYouWillBeRedirectedToThisUrl: "หากอนุญาตการเข้าถึง ระบบจะเปลี่ยนเส้นทางไปยัง URL ด้านล่างโดยอัตโนมัติ"
|
||||
_antennaSources:
|
||||
all: "โน้ตทั้งหมด"
|
||||
homeTimeline: "โน้ตจากผู้ใช้ที่ติดตาม"
|
||||
|
@ -2226,6 +2431,7 @@ _widgets:
|
|||
chooseList: "เลือกรายชื่อ"
|
||||
clicker: "คลิกเกอร์"
|
||||
birthdayFollowings: "วันเกิดผู้ใช้ในวันนี้"
|
||||
chat: "แชต"
|
||||
_cw:
|
||||
hide: "ซ่อน"
|
||||
show: "โหลดเพิ่มเติม"
|
||||
|
@ -2265,6 +2471,8 @@ _visibility:
|
|||
disableFederation: "การปิดใช้งานสหพันธ์"
|
||||
disableFederationDescription: "อย่าส่งข้อมูลไปยังเซิร์ฟเวอร์อื่น"
|
||||
_postForm:
|
||||
quitInspiteOfThereAreUnuploadedFilesConfirm: "มีไฟล์ที่ยังไม่ได้อัปโหลด ต้องการละทิ้งและปิดฟอร์มหรือไม่?"
|
||||
uploaderTip: "ไฟล์ยังไม่ได้อัปโหลด สามารถตั้งค่าต่างๆ ได้จากเมนูของไฟล์ เช่น การเปลี่ยนชื่อ การครอปรูป การใส่ลายน้ำ และการบีบอัด ไฟล์จะถูกอัปโหลดโดยอัตโนมัติเมื่อโพสต์โน้ต"
|
||||
replyPlaceholder: "ตอบกลับโน้ตนี้..."
|
||||
quotePlaceholder: "อ้างโน้ตนี้..."
|
||||
channelPlaceholder: "โพสต์ลงช่อง..."
|
||||
|
@ -2285,7 +2493,7 @@ _profile:
|
|||
metadataDescription: "ใช้สิ่งเหล่านี้ คุณสามารถแสดงฟิลด์ข้อมูลเพิ่มเติมในโปรไฟล์ของคุณ"
|
||||
metadataLabel: "ป้ายชื่อ"
|
||||
metadataContent: "เนื้อหา"
|
||||
changeAvatar: "เปลี่ยนอวาตาร์"
|
||||
changeAvatar: "เปลี่ยนไอคอนประจำตัว"
|
||||
changeBanner: "เปลี่ยนแบนเนอร์"
|
||||
verifiedLinkDescription: "หากป้อน URL ที่มีลิงก์ไปยังโปรไฟล์ของคุณ ไอคอนการยืนยันความเป็นเจ้าของจะแสดงถัดจากฟิลด์นั้น ๆ"
|
||||
avatarDecorationMax: "คุณสามารถเพิ่มการตกแต่งได้สูงสุด {max}"
|
||||
|
@ -2298,7 +2506,7 @@ _exportOrImport:
|
|||
clips: "คลิป"
|
||||
followingList: "กำลังติดตาม"
|
||||
muteList: "ปิดเสียง"
|
||||
blockingList: "บล็อค"
|
||||
blockingList: "บล็อก"
|
||||
userLists: "รายชื่อ"
|
||||
excludeMutingUsers: "ยกเว้นผู้ใช้ที่ปิดเสียง"
|
||||
excludeInactiveUsers: "ยกเว้นผู้ใช้ที่ไม่ได้ใช้งาน"
|
||||
|
@ -2368,7 +2576,7 @@ _pages:
|
|||
featured: "เป็นที่นิยม"
|
||||
inspector: "ตัวตรวจสอบ"
|
||||
contents: "เนื้อหา"
|
||||
content: "บล็อคหน้าเพจ"
|
||||
content: "บล็อกหน้าเพจ"
|
||||
variables: "ตัวแปร"
|
||||
title: "หัวข้อ"
|
||||
url: "URL ของหน้า"
|
||||
|
@ -2380,7 +2588,7 @@ _pages:
|
|||
fontSansSerif: "Sans Serif"
|
||||
eyeCatchingImageSet: "ตั้งค่าภาพขนาดย่อ"
|
||||
eyeCatchingImageRemove: "ลบภาพขนาดย่อ"
|
||||
chooseBlock: "เพิ่มบล็อค"
|
||||
chooseBlock: "เพิ่มบล็อก"
|
||||
enterSectionTitle: "ป้อนชื่อหัวข้อ"
|
||||
selectType: "เลือกชนิด"
|
||||
contentBlocks: "เนื้อหา"
|
||||
|
@ -2416,6 +2624,7 @@ _notification:
|
|||
newNote: "โพสต์ใหม่"
|
||||
unreadAntennaNote: "เสาอากาศ {name}"
|
||||
roleAssigned: "ได้รับบทบาท"
|
||||
chatRoomInvitationReceived: "ได้รับคำเชิญเข้าร่วมห้องแชต"
|
||||
emptyPushNotificationMessage: "อัปเดตการแจ้งเตือนแบบพุชแล้ว"
|
||||
achievementEarned: "รับความสำเร็จ"
|
||||
testNotification: "ทดสอบการแจ้งเตือน"
|
||||
|
@ -2428,7 +2637,9 @@ _notification:
|
|||
followedBySomeUsers: "มีผู้ติดตาม {n} ราย"
|
||||
flushNotification: "ล้างประวัติการแจ้งเตือน"
|
||||
exportOfXCompleted: "การดำเนินการส่งออก {x} ได้เสร็จสิ้นลงแล้ว"
|
||||
login: "มีคนล็อกอิน"
|
||||
login: "มีการเข้าสู่ระบบ"
|
||||
createToken: "สร้างโทเค็นการเข้าถึงแล้ว"
|
||||
createTokenDescription: "หากไม่ทราบสาเหตุของคำเชิญ กรุณาลบโทเค็นการเข้าถึงผ่านทาง “{text}”"
|
||||
_types:
|
||||
all: "ทั้งหมด"
|
||||
note: "โน้ตใหม่"
|
||||
|
@ -2442,9 +2653,11 @@ _notification:
|
|||
receiveFollowRequest: "ได้รับคำร้องขอติดตาม"
|
||||
followRequestAccepted: "อนุมัติให้ติดตามแล้ว"
|
||||
roleAssigned: "ให้บทบาท"
|
||||
chatRoomInvitationReceived: "เชิญเข้าห้องแชต"
|
||||
achievementEarned: "ปลดล็อกความสำเร็จแล้ว"
|
||||
exportCompleted: "กระบวนการส่งออกข้อมูลได้เสร็จสิ้นสมบูรณ์แล้ว"
|
||||
login: "เข้าสู่ระบบ"
|
||||
createToken: "สร้างโทเค็นการเข้าถึง"
|
||||
test: "ทดสอบระบบแจ้งเตือน"
|
||||
app: "การแจ้งเตือนจากแอปที่มีลิงก์"
|
||||
_actions:
|
||||
|
@ -2454,6 +2667,9 @@ _notification:
|
|||
_deck:
|
||||
alwaysShowMainColumn: "แสดงคอลัมน์หลักเสมอ"
|
||||
columnAlign: "จัดแนวคอลัมน์"
|
||||
columnGap: "ช่องห่างระว่างคอลัมน์"
|
||||
deckMenuPosition: "ตำแหน่งเมนูเด็ค"
|
||||
navbarPosition: "ตำแหน่งของแถบนำทาง"
|
||||
addColumn: "เพิ่มคอลัมน์"
|
||||
newNoteNotificationSettings: "ตั้งค่าการแจ้งเตือนเมื่อมีโน้ตใหม่"
|
||||
configureColumn: "ตั้งค่าคอลัมน์"
|
||||
|
@ -2472,6 +2688,7 @@ _deck:
|
|||
useSimpleUiForNonRootPages: "แสดง UI ของ Root Page อย่างง่าย "
|
||||
usedAsMinWidthWhenFlexible: "ความกว้างขั้นต่ำนั้นจะถูกใช้งานสำหรับสิ่งนี้เมื่อเปิดใช้งานตัวเลือก \"ปรับความกว้างอัตโนมัติ\" หากเลือกเปิดใช้งานแล้ว"
|
||||
flexible: "ปรับความกว้างอัตโนมัติ"
|
||||
enableSyncBetweenDevicesForProfiles: "เปิดใช้งานการซิงค์ข้อมูลโปรไฟล์ระหว่างอุปกรณ์"
|
||||
_columns:
|
||||
main: "หลัก"
|
||||
widgets: "วิดเจ็ต"
|
||||
|
@ -2483,6 +2700,7 @@ _deck:
|
|||
mentions: "กล่าวถึงคุณ"
|
||||
direct: "ไดเร็กต์"
|
||||
roleTimeline: "บทบาทไทม์ไลน์"
|
||||
chat: "แชต"
|
||||
_dialog:
|
||||
charactersExceeded: "คุณกำลังมีตัวอักขระเกินขีดจำกัดสูงสุดแล้วนะ! ปัจจุบันอยู่ที่ {current} จาก {max}"
|
||||
charactersBelow: "คุณกำลังใช้อักขระต่ำกว่าขีดจำกัดขั้นต่ำเลยนะ! ปัจจุบันอยู่ที่ {current} จาก {min}"
|
||||
|
@ -2511,8 +2729,8 @@ _webhookSettings:
|
|||
abuseReport: "เมื่อมีการรายงานจากผู้ใช้"
|
||||
abuseReportResolved: "เมื่อมีการจัดการกับการรายงานจากผู้ใช้"
|
||||
userCreated: "เมื่อผู้ใช้ถูกสร้างขึ้น"
|
||||
inactiveModeratorsWarning: "เมื่อผู้ดูแลระบบไม่ได้ใช้งานมานานระยะหนึ่ง"
|
||||
inactiveModeratorsInvitationOnlyChanged: "เมื่อผู้ดูแลระบบที่ไม่ได้ใช้งานมานาน และเซิร์ฟเวอร์เปลี่ยนเป็นแบบเชิญเข้าร่วมเท่านั้น"
|
||||
inactiveModeratorsWarning: "เมื่อผู้ควบคุมไม่มีความเคลื่อนไหวในช่วงระยะเวลาหนึ่ง"
|
||||
inactiveModeratorsInvitationOnlyChanged: "เมื่อผู้ควบคุมไม่มีความเคลื่อนไหวในช่วงระยะเวลาหนึ่ง ระบบจะเปลี่ยนเป็นแบบใช้คำเชิญโดยอัตโนมัติ"
|
||||
deleteConfirm: "ต้องการลบ Webhook ใช่ไหม?"
|
||||
testRemarks: "คลิกปุ่มทางด้านขวาของสวิตช์เพื่อส่ง Webhook ทดสอบที่มีข้อมูลจำลอง"
|
||||
_abuseReport:
|
||||
|
@ -2564,10 +2782,10 @@ _moderationLogTypes:
|
|||
createAd: "สร้างโฆษณาแล้ว"
|
||||
deleteAd: "ลบโฆษณาออกแล้ว"
|
||||
updateAd: "อัปเดตโฆษณาแล้ว"
|
||||
createAvatarDecoration: "สร้างการตกแต่งไอคอนแล้ว"
|
||||
updateAvatarDecoration: "อัปเดตการตกแต่งไอคอนแล้ว"
|
||||
deleteAvatarDecoration: "ลบการตกแต่งไอคอนแล้ว"
|
||||
unsetUserAvatar: "ลบไอคอนผู้ใช้"
|
||||
createAvatarDecoration: "สร้างของตกแต่งไอคอนแล้ว"
|
||||
updateAvatarDecoration: "อัปเดตของตกแต่งไอคอนแล้ว"
|
||||
deleteAvatarDecoration: "ลบของตกแต่งไอคอนแล้ว"
|
||||
unsetUserAvatar: "เลิกตั้งไอคอนประจำตัวแล้ว"
|
||||
unsetUserBanner: "ลบแบนเนอร์ผู้ใช้"
|
||||
createSystemWebhook: "สร้าง SystemWebhook"
|
||||
updateSystemWebhook: "อัปเดต SystemWebhook"
|
||||
|
@ -2579,6 +2797,8 @@ _moderationLogTypes:
|
|||
deletePage: "เพจถูกลบออกไปแล้ว"
|
||||
deleteFlash: "Play ถูกลบออกไปแล้ว"
|
||||
deleteGalleryPost: "โพสต์แกลเลอรี่ถูกลบออกแล้ว"
|
||||
deleteChatRoom: "ลบห้องแชต"
|
||||
updateProxyAccountDescription: "อัปเดตคำอธิบายของบัญชีพร็อกซี"
|
||||
_fileViewer:
|
||||
title: "รายละเอียดไฟล์"
|
||||
type: "ประเภทไฟล์"
|
||||
|
@ -2586,6 +2806,7 @@ _fileViewer:
|
|||
url: "URL"
|
||||
uploadedAt: "วันที่เข้าร่วม"
|
||||
attachedNotes: "โน้ตที่แนบมาด้วย"
|
||||
usage: "ใช้แล้ว"
|
||||
thisPageCanBeSeenFromTheAuthor: "หน้าเพจนี้จะสามารถปรากฏได้โดยผู้ใช้ที่อัปโหลดไฟล์นี้เท่านั้น"
|
||||
_externalResourceInstaller:
|
||||
title: "ติดตั้งจากไซต์ภายนอก"
|
||||
|
@ -2631,8 +2852,14 @@ _dataSaver:
|
|||
title: "โหลดสื่อ"
|
||||
description: "กันไม่ให้ภาพและวิดีโอโหลดโดยอัตโนมัติ แตะรูปภาพ/วิดีโอที่ซ่อนอยู่เพื่อโหลด"
|
||||
_avatar:
|
||||
title: "รูปไอคอน"
|
||||
description: "ระงับการเคลื่อนไหวของภาพไอคอน ภาพเคลื่อนไหวอาจมีขนาดไฟล์ใหญ่กว่าภาพปกติ ดังนั้นจึงสามารถช่วยในการลดการใช้ข้อมูล"
|
||||
title: "ปิดใช้งานภาพเคลื่อนไหวของไอคอนประจำตัว"
|
||||
description: "ภาพเคลื่อนไหวของไอคอนประจำตัวจะหยุดทำงาน ภาพแบบเคลื่อนไหวมักมีขนาดไฟล์ใหญ่กว่าภาพปกติ จึงช่วยลดปริมาณการใช้ข้อมูลได้มากขึ้น"
|
||||
_urlPreviewThumbnail:
|
||||
title: "ซ่อนภาพขนาดย่อของการแสดงตัวอย่าง URL"
|
||||
description: "ภาพขนาดย่อของการตัวอย่าง URL จะไม่ถูกโหลดอีกต่อไป"
|
||||
_disableUrlPreview:
|
||||
title: "ปิดการใช้งานแสดงตัวอย่าง URL"
|
||||
description: "ปิดฟังก์ชันแสดงตัวอย่าง URL แตกต่างจากการซ่อนเพียงภาพขนาดย่อ ฟังก์ชันนี้จะช่วยลดการโหลดข้อมูลจากลิงก์ปลายทางทั้งหมด"
|
||||
_code:
|
||||
title: "ไฮไลต์โค้ด"
|
||||
description: "หากใช้สัญลักษณ์ไฮไลต์โค้ดใน MFM ฯลฯ สัญลักษณ์เหล่านั้นจะไม่โหลดจนกว่าจะแตะ การไฮไลต์ไวยากรณ์(syntax)จำเป็นต้องดาวน์โหลดไฟล์คำจำกัดความของไฮไลต์สำหรับแต่ละภาษา ดังนั้นการปิดใช้งานการโหลดไฟล์เหล่านี้โดยอัตโนมัติจึงคาดว่าจะช่วยลดปริมาณข้อมูลการสื่อสารได้"
|
||||
|
@ -2683,13 +2910,15 @@ _reversi:
|
|||
allowIrregularRules: "อนุญาตกฎที่ไม่ปรกติ (โหมดฟรีทุกอย่าง)"
|
||||
disallowIrregularRules: "ไม่อนุญาตกฎที่ไม่ปรกติ"
|
||||
showBoardLabels: "แสดงหมายเลขแถว/คอลัมน์บนกระดาน"
|
||||
useAvatarAsStone: "ใช้รูปอวตารเป็นหมาก"
|
||||
useAvatarAsStone: "ใช้ไอคอนประจำตัวเป็นหมาก"
|
||||
_offlineScreen:
|
||||
title: "ออฟไลน์ - ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ได้"
|
||||
header: "ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ได้"
|
||||
_urlPreviewSetting:
|
||||
title: "การตั้งค่าการแสดงตัวอย่าง URL"
|
||||
enable: "เปิดใช้งานการแสดงตัวอย่าง URL"
|
||||
allowRedirect: "อนุญาตการเปลี่ยนเส้นทางไปยังปลายทางของการแสดงตัวอย่าง"
|
||||
allowRedirectDescription: "ตั้งค่าว่าจะติดตามลิงก์ที่เปลี่ยนเส้นทาง (redirect) เพื่อแสดงตัวอย่างหรือไม่ เมื่อมีการป้อน URL ที่มีการเปลี่ยนเส้นทาง หากปิดการใช้งาน จะช่วยประหยัดทรัพยากรของเซิร์ฟเวอร์ แต่จะไม่สามารถแสดงเนื้อหาจากปลายทางที่เปลี่ยนเส้นทางได้"
|
||||
timeout: "เวลาจำกัดในการโหลดตัวอย่าง URL (ms)"
|
||||
timeoutDescription: "หากเวลาที่ใช้ในการโหลดเกินค่านี้ จะไม่มีการสร้างการแสดงตัวอย่าง"
|
||||
maximumContentLength: "ค่าสูงสุดของ Content-Length (byte)"
|
||||
|
@ -2710,6 +2939,62 @@ _contextMenu:
|
|||
app: "แอปพลิเคชัน"
|
||||
appWithShift: "แอปฟลิเคชันด้วยปุ่มยกแคร่ (Shift)"
|
||||
native: "UI ของเบราว์เซอร์"
|
||||
_gridComponent:
|
||||
_error:
|
||||
requiredValue: "ค่านี้จำเป็นต้องกรอก"
|
||||
columnTypeNotSupport: "การตรวจสอบค่าด้วย regex รองรับเฉพาะคอลัมน์ที่เป็น type:text"
|
||||
patternNotMatch: "ค่านี้ไม่ตรงกับรูปแบบ {pattern}"
|
||||
notUnique: "ค่านี้ต้องไม่ซ้ำกับค่าที่มีอยู่"
|
||||
_roleSelectDialog:
|
||||
notSelected: "ยังไม่มีการเลือก"
|
||||
_customEmojisManager:
|
||||
_gridCommon:
|
||||
copySelectionRows: "คัดลอกแถวที่เลือกไว้"
|
||||
copySelectionRanges: "คัดลือกที่เลือกไว้"
|
||||
deleteSelectionRows: "ลบแถวที่เลือกไว้"
|
||||
deleteSelectionRanges: "ล้างค่าช่วงที่เลือก"
|
||||
searchSettings: "ตั้งค่าการค้นหา"
|
||||
searchSettingCaption: "ตั้งค่าเงื่อนไขการค้นหาอย่างละเอียด"
|
||||
searchLimit: "จำนวนรายการที่แสดง"
|
||||
sortOrder: "ลำดับการเรียง"
|
||||
registrationLogs: "ปูมการลงทะเบียน"
|
||||
registrationLogsCaption: "จะแสดงปูมเมื่อมีการอัปเดตหรือลบเอโมจิ หากดำเนินการอัปเดต/ลบ หรือเปลี่ยนหน้า/รีโหลด หน้านี้ ปูมจะหายไป"
|
||||
alertEmojisRegisterFailedDescription: "การอัปเดตหรือลบเอโมจิล้มเหลว กรุณาตรวจสอบรายละเอียดในปูมการลงทะเบียน"
|
||||
_logs:
|
||||
showSuccessLogSwitch: "แสดงปูมที่สำเร็จ"
|
||||
failureLogNothing: "ไม่มีปูมความล้มเหลว"
|
||||
logNothing: "ไม่มีปูม"
|
||||
_remote:
|
||||
selectionRowDetail: "รายละเอียดของแถวที่เลือก"
|
||||
importSelectionRows: "นำเข้าแถวที่เลือก"
|
||||
importSelectionRangesRows: "นำเข้าแถวในช่วงที่เลือก"
|
||||
importEmojisButton: "นำเข้าเอโมจิที่ทำเครื่องหมายไว้"
|
||||
confirmImportEmojisTitle: "นำเข้าเอโมจิ"
|
||||
confirmImportEmojisDescription: "จะนำเข้าเอโมจิ {count} รายการที่ได้รับจากระยะไกล ทั้งนี้โปรดระมัดระวังเรื่องสิทธิ์การใช้งานเอโมจิ ดำเนินการหรือไม่?"
|
||||
_local:
|
||||
tabTitleList: "รายการเอโมจิที่ลงทะเบียนไว้แล้ว"
|
||||
tabTitleRegister: "ลงทะเบียนเอโมจิ"
|
||||
_list:
|
||||
emojisNothing: "ยังไม่มีเอโมจิที่ลงทะเบียนไว้"
|
||||
markAsDeleteTargetRows: "กำหนดแถวที่เลือกให้เป็นรายการสำหรับลบ"
|
||||
markAsDeleteTargetRanges: "กำหนดช่วงแถวที่เลือกให้เป็นรายการสำหรับลบ"
|
||||
alertUpdateEmojisNothingDescription: "ไม่มีการเปลี่ยนแปลงเอโมจิ"
|
||||
alertDeleteEmojisNothingDescription: "ไม่มีเอโมจิที่อยู่ในรายการสำหรับลบ"
|
||||
confirmMovePage: "ต้องการเปลี่ยนหน้าหรือไม่?"
|
||||
confirmChangeView: "ต้องการเปลี่ยนการแสดงผลหรือไม่?"
|
||||
confirmUpdateEmojisDescription: "จะอัปเดตเอโมจิ {count} รายการ ดำเนินการหรือไม่?"
|
||||
confirmDeleteEmojisDescription: "จะลบเอโมจิที่ถูกทำเครื่องหมายไว้ {count} รายการ ดำเนินการหรือไม่?"
|
||||
confirmResetDescription: "การเปลี่ยนแปลงทั้งหมดที่ทำมาจะถูกรีเซ็ต"
|
||||
confirmMovePageDesciption: "มีการเปลี่ยนแปลงเอโมจิในหน้านี้ หากเปลี่ยนหน้าโดยไม่บันทึก การเปลี่ยนแปลงทั้งหมดจะถูกละทิ้ง"
|
||||
dialogSelectRoleTitle: "ค้นหาบทบาทที่ตั้งค่าไว้ด้วยเอโมจิ"
|
||||
_register:
|
||||
uploadSettingTitle: "ตั้งค่าการอัปโหลด"
|
||||
uploadSettingDescription: "สามารถกำหนดพฤติกรรมขณะอัปโหลดเอโมจิจากหน้าจอนี้ได้"
|
||||
directoryToCategoryLabel: "ป้อนชื่อไดเรกทอรีเป็น \"category\""
|
||||
directoryToCategoryCaption: "เมื่อทำการลากและวางไดเรกทอรี ชื่อจะถูกป้อนเป็น \"category\""
|
||||
confirmRegisterEmojisDescription: "จะลงทะเบียนเอโมจิที่แสดงในรายการเป็นเอโมจิแบบกำหนดเองใหม่\nดำเนินการต่อหรือไม่? (เพื่อหลีกเลี่ยงภาระโหลดหนัก ระบบจะสามารถลงทะเบียนอีโมจิได้สูงสุด {count} รายการต่อครั้ง)"
|
||||
confirmClearEmojisDescription: "ต้องการยกเลิกการแก้ไขและล้างรายการเอโมจิที่แสดงอยู่หรือไม่?"
|
||||
confirmUploadEmojisDescription: "จะอัปโหลดไฟล์ {count} รายการที่ลากและวางไปยังไดรฟ์ ดำเนินการหรือไม่?"
|
||||
_embedCodeGen:
|
||||
title: "ปรับแต่งโค้ดฝัง"
|
||||
header: "แสดงส่วนหัว"
|
||||
|
@ -2724,15 +3009,137 @@ _embedCodeGen:
|
|||
generateCode: "สร้างโค้ดสำหรับการฝัง"
|
||||
codeGenerated: "รหัสถูกสร้างขึ้นแล้ว"
|
||||
codeGeneratedDescription: "นำโค้ดที่สร้างแล้วไปวางในเว็บไซต์ของคุณเพื่อฝังเนื้อหา"
|
||||
_selfXssPrevention:
|
||||
warning: "คำเตือน"
|
||||
title: "“ข้อความที่บอกให้วางบางอย่างในหน้าจอนี้” ทั้งหมดเป็นการหลอกลวง"
|
||||
description1: "ถ้าวางบางอย่างที่นี่ อาจทำให้ผู้ไม่หวังดีเข้าควบคุมบัญชี หรือขโมยข้อมูลส่วนตัวได้"
|
||||
description2: "ถ้าไม่เข้าใจอย่างชัดเจนว่าสิ่งที่กำลังจะวางคืออะไร %cให้หยุดการทำงานทันทีแล้วปิดหน้าต่างนี้"
|
||||
description3: "ดูรายละเอียดเพิ่มเติมได้ที่นี่: {link}"
|
||||
_followRequest:
|
||||
recieved: "คำขอที่ได้รับ"
|
||||
sent: "คำที่ส่งไป"
|
||||
_remoteLookupErrors:
|
||||
_federationNotAllowed:
|
||||
title: "ไม่สามารถสื่อสารกับเซิร์ฟเวอร์นี้ได้"
|
||||
description: "การสื่อสารกับเซิร์ฟเวอร์นี้อาจถูกปิดใช้งาน หรือเซิร์ฟเวอร์นี้อาจจะได้บล็อกคุณ หรือคุณอาจจะได้บล็อกเซิร์ฟเวอร์นี้อยู่\nกรุณาติดต่อผู้ดูแลระบบเซิร์ฟเวอร์เพื่อสอบถามรายละเอียดเพิ่มเติม"
|
||||
_uriInvalid:
|
||||
title: "URI ไม่ถูกต้อง"
|
||||
description: "มีปัญหาเกี่ยวกับ URI ที่ป้อน โปรดตรวจสอบว่าไม่มีอักขระที่ไม่สามารถใช้กับ URI"
|
||||
_requestFailed:
|
||||
title: "การร้องขอล้มเหลว"
|
||||
description: "การสื่อสารกับเซิร์ฟเวอร์นี้ล้มเหลว เซิร์ฟเวอร์ปลายทางอาจล่ม หรืออาจป้อน URI ที่ไม่ถูกต้องหรือไม่มีอยู่"
|
||||
_responseInvalid:
|
||||
title: "ข้อมูลตอบสนองกลับไม่ถูกต้อง"
|
||||
description: "สามารถเชื่อมต่อกับเซิร์ฟเวอร์นี้ได้ แต่ข้อมูลที่ได้รับไม่ถูกต้อง หากกำลังดึงข้อมูลจากเซิร์ฟเวอร์บุคคลที่สาม โปรดใช้ URI ที่สามารถดึงข้อมูลได้จากเซิร์ฟเวอร์ต้นทางโดยตรง"
|
||||
_noSuchObject:
|
||||
title: "ไม่พบหน้าที่ต้องการ"
|
||||
description: "ไม่พบทรัพยากรที่ร้องขอ กรุณาตรวจสอบ URI อีกครั้ง"
|
||||
_captcha:
|
||||
verify: "กรุณาผ่าน CAPTCHA"
|
||||
testSiteKeyMessage: "สามารถดูตัวอย่างได้โดยป้อนค่าทดสอบใน site key และ secret key\nดูรายละเอียดเพิ่มเติมได้ที่หน้าด้านล่างนี้"
|
||||
_error:
|
||||
_requestFailed:
|
||||
title: "การร้องขอ CAPTCHA ล้มเหลว"
|
||||
text: "โปรดลองใหม่ภายหลัง หรือ ตรวจสอบการตั้งค่าอีกครั้ง"
|
||||
_verificationFailed:
|
||||
title: "การยืนยัน CAPTCHA ล้มเหลว"
|
||||
text: "กรุณาตรวจสอบอีกครั้งว่าการตั้งค่าถูกต้องหรือไม่"
|
||||
_unknown:
|
||||
title: "CAPTCHA เกิดข้อผิดพลาด"
|
||||
text: "เกิดข้อผิดพลาดที่ไม่คาดคิด"
|
||||
_bootErrors:
|
||||
title: "การโหลดล้มเหลว"
|
||||
serverError: "หากปัญหายังคงอยู่แม้ว่าจะรอสักครู่แล้วโหลดหน้าใหม่อีกครั้ง โปรดติดต่อผู้ดูแลระบบเซิร์ฟเวอร์พร้อมรหัสข้อผิดพลาดต่อไปนี้"
|
||||
solution: "สิ่งต่อไปนี้อาจช่วยแก้ไขปัญหาได้"
|
||||
solution1: "อัปเดตเบราว์เซอร์และระบบปฏิบัติการเป็นรุ่นล่าสุด"
|
||||
solution2: "ปิดใช้งานตัวบล็อกโฆษณา"
|
||||
solution3: "ล้างแคชเบราว์เซอร์"
|
||||
solution4: "(Tor Browser) ตั้งค่า dom.webaudio.enabled เป็น true"
|
||||
otherOption: "ตัวเลือกเพิ่มเติม"
|
||||
otherOption1: "ลบการตั้งค่าและแคชของไคลเอนต์"
|
||||
otherOption2: "เริ่มใช้งานไคลเอนต์แบบง่าย"
|
||||
otherOption3: "เปิดเครื่องมือซ่อมแซม"
|
||||
_search:
|
||||
searchScopeAll: "ทั้งหมด"
|
||||
searchScopeLocal: "ท้องถิ่น"
|
||||
searchScopeServer: "ระบุเซิร์ฟเวอร์"
|
||||
searchScopeUser: "ผู้ใช้เฉพาะ"
|
||||
pleaseEnterServerHost: "กรุณากรอกโฮสต์ของเซิร์ฟเวอร์"
|
||||
pleaseSelectUser: "กรุณาเลือกผู้ใช้"
|
||||
serverHostPlaceholder: "ตัวอย่าง: misskey.example.com"
|
||||
_serverSetupWizard:
|
||||
installCompleted: "การติดตั้ง Misskey เสร็จสมบูรณ์แล้ว!"
|
||||
firstCreateAccount: "ขั้นแรก ให้สร้างบัญชีผู้ดูแลระบบ"
|
||||
accountCreated: "บัญชีผู้ดูแลระบบถูกสร้างขึ้นแล้ว!"
|
||||
serverSetting: "การตั้งค่าเซิร์ฟเวอร์"
|
||||
youCanEasilyConfigureOptimalServerSettingsWithThisWizard: "สามารถตั้งค่าเซิร์ฟเวอร์ได้อย่างง่ายดายด้วยวิซาร์ดนี้"
|
||||
settingsYouMakeHereCanBeChangedLater: "สามารถเปลี่ยนแปลงการตั้งค่าเหล่านี้ในภายหลังได้"
|
||||
howWillYouUseMisskey: "ต้องการใช้ Misskey อย่างไร?"
|
||||
_use:
|
||||
single: "เซิร์ฟเวอร์คนเดียว"
|
||||
single_description: "ใช้งานเป็นเซิร์ฟเวอร์ส่วนตัวสำหรับตัวเองคนเดียว"
|
||||
single_youCanCreateMultipleAccounts: "แม้จะใช้งานเป็นเซิร์ฟเวอร์ส่วนตัวสำหรับคนเดียว ก็สามารถสร้างบัญชีผู้ใช้หลายบัญชีได้ตามความจำเป็น"
|
||||
group: "เซิร์ฟเวอร์กลุ่ม"
|
||||
group_description: "เชิญผู้ใช้ที่เชื่อถือได้ มาเข้าร่วมใช้งานแบบหลายคน"
|
||||
open: "เซิร์ฟเวอร์สาธารณะ"
|
||||
open_description: "เปิดรับผู้ใช้จำนวนมากแบบไม่จำกัด"
|
||||
openServerAdvice: "การเปิดรับผู้ใช้จำนวนมากมีความเสี่ยง ควรบริหารจัดการด้วยระบบดูแลที่เข้มงวดเพื่อรับมือกับปัญหาที่อาจเกิดขึ้น"
|
||||
openServerAntiSpamAdvice: "เพื่อป้องกันไม่ให้เซิร์ฟเวอร์ของตนกลายเป็นแหล่งส่งสแปม ควรเปิดใช้งานฟีเจอร์ป้องกันบอต เช่น reCAPTCHA และใส่ใจเรื่องความปลอดภัยอย่างเคร่งครัด"
|
||||
howManyUsersDoYouExpect: "คาดว่าจะมีผู้ใช้งานประมาณกี่คน?"
|
||||
_scale:
|
||||
small: "น้อยกว่า 100 คน (ขนาดเล็ก)"
|
||||
medium: "เกิน 100 คน แต่น้อยกว่า 1000 คน (ขนาดกลาง)"
|
||||
large: "เกิน 1000 คน (ขนาดใหญ่)"
|
||||
largeScaleServerAdvice: "เซิร์ฟเวอร์ขนาดใหญ่อาจต้องการความรู้ด้านโครงสร้างพื้นฐานขั้นสูง เช่น การบาลานซ์โหลด หรือการทำสำเนาฐานข้อมูล"
|
||||
doYouConnectToFediverse: "เชื่อมต่อกับ Fediverse หรือไม่?"
|
||||
doYouConnectToFediverse_description1: "หากเชื่อมต่อกับเครือข่ายที่ประกอบด้วยเซิร์ฟเวอร์แบบกระจาย (Fediverse) จะสามารถแลกเปลี่ยนเนื้อหากับเซิร์ฟเวอร์อื่นๆ ได้"
|
||||
doYouConnectToFediverse_description2: "การเชื่อมต่อกับ Fediverse เรียกว่า “สหพันธ์”"
|
||||
youCanConfigureMoreFederationSettingsLater: "หลังจากนี้ยังสามารถตั้งค่าแบบขั้นสูง เช่น การกำหนดเซิร์ฟเวอร์ที่อนุญาตให้สหพันธ์ต่อกันได้เพิ่มเติม"
|
||||
adminInfo: "ข้อมูลผู้ดูแลระบ"
|
||||
adminInfo_description: "ตั้งค่าข้อมูลผู้ดูแลระบบที่จะใช้รับคำถามและติดต่อ"
|
||||
adminInfo_mustBeFilled: "หากเปิดใช้เซิร์ฟเวอร์สาธารณะ หรือเปิดใช้งานสหพันธ์ จะต้องกรอกข้อมูลนี้"
|
||||
followingSettingsAreRecommended: "แนะนำให้ตั้งค่าตามด้านล่างนี้"
|
||||
applyTheseSettings: "ใช้การตั้งค่านี้"
|
||||
skipSettings: "ข้ามการตั้งค่า"
|
||||
settingsCompleted: "การตั้งค่าเสร็จสมบูรณ์แล้ว!"
|
||||
settingsCompleted_description: "ขอบคุณที่สละเวลามาตั้งค่า ตอนนี้เซิร์ฟเวอร์พร้อมใช้งานได้ทันที"
|
||||
settingsCompleted_description2: "การตั้งค่าเซิร์ฟเวอร์อย่างละเอียดสามารถทำได้จาก “แผงควบคุม”"
|
||||
donationRequest: "คำขอรับบริจาค"
|
||||
_donationRequest:
|
||||
text1: "Misskey เป็นซอฟต์แวร์ฟรีที่พัฒนาโดยอาสาสมัคร"
|
||||
text2: "เพื่อให้การพัฒนางานนี้สามารถดำเนินต่อไปได้ในอนาคต หากไม่เป็นการรบกวน รบกวนพิจารณาร่วมสมทบทุนด้วยนะคะ"
|
||||
text3: "นอกจากนี้ยังมีสิทธิพิเศษสำหรับผู้สนับสนุนอีกด้วยค่ะ"
|
||||
_uploader:
|
||||
editImage: "แก้ไขรูปภาพ"
|
||||
compressedToX: "บีบอัดเป็น {x}"
|
||||
savedXPercent: "ประหยัดไป {x}%"
|
||||
abortConfirm: "มีไฟล์ที่ยังไม่ได้อัปโหลด ต้องการยกเลิกหรือไม่?"
|
||||
doneConfirm: "มีไฟล์ที่ยังไม่ได้อัปโหลด ต้องการดำเนินการให้เสร็จสิ้นหรือไม่?"
|
||||
maxFileSizeIsX: "ขนาดไฟล์สูงสุดที่สามารถอัปโหลดได้คือ {x}"
|
||||
allowedTypes: "ประเภทไฟล์ที่สามารถอัปโหลดได้"
|
||||
tip: "ยังไม่มีไฟล์ถูกอัปโหลด สามารถ ตรวจสอบ ลบชื่อไฟล์ บีบอัด หรือครอปตัดภาพ ก่อนอัปโหลดได้ในหน้านี้ เมื่อพร้อมแล้วให้กดปุ่ม “อัปโหลด” เพื่อเริ่มการอัปโหลด"
|
||||
_clientPerformanceIssueTip:
|
||||
title: "หากรู้สึกว่าแบตเตอรี่หมดเร็ว"
|
||||
makeSureDisabledAdBlocker: "โปรดปิดการใช้งานตัวบล็อกโฆษณา"
|
||||
makeSureDisabledAdBlocker_description: "ตัวบล็อกโฆษณาอาจส่งผลต่อประสิทธิภาพ โปรดตรวจสอบว่าไม่ได้เปิดใช้งานผ่านฟังก์ชันของระบบปฏิบัติการ เบราว์เซอร์ หรือส่วนเสริมใดๆ"
|
||||
makeSureDisabledCustomCss: "โปรดปิดการใช้งาน CSS แบบกำหนดเอง"
|
||||
makeSureDisabledCustomCss_description: "การเขียนทับสไตล์อาจส่งผลต่อประสิทธิภาพ โปรดตรวจสอบว่าไม่มี CSS แบบกำหนดเองหรือส่วนเสริมที่แก้ไขสไตล์เปิดใช้งานอยู่"
|
||||
makeSureDisabledAddons: "โปรดปิดการใช้งานส่วนเสริม"
|
||||
makeSureDisabledAddons_description: "ส่วนเสริมบางตัวอาจรบกวนการทำงานของไคลเอนต์และทำให้ประสิทธิภาพลดลง กรุณาลองปิดส่วนเสริมในเบราว์เซอร์แล้วตรวจสอบอีกครั้ง"
|
||||
_clip:
|
||||
tip: "คลิปเป็นฟังก์ชันที่สามารถรวมโน้ตเข้าด้วยกัน"
|
||||
_userLists:
|
||||
tip: "สามารถสร้างรายชื่อที่มีผู้ใช้ใดก็ได้ เมื่อสร้างแล้ว รายชื่อนั้นจะแสดงเป็นไทม์ไลน์ได้"
|
||||
watermark: "ลายน้ำ"
|
||||
defaultPreset: "พรีเซ็ตเริ่มต้น"
|
||||
_watermarkEditor:
|
||||
tip: "สามารถเพิ่มลายน้ำ เช่น ข้อมูลเครดิต ลงในภาพได้"
|
||||
quitWithoutSaveConfirm: "ต้องการออกโดยไม่บันทึกหรือไม่?"
|
||||
driveFileTypeWarn: "ไม่รองรับไฟล์นี้"
|
||||
driveFileTypeWarnDescription: "กรุณาเลือกไฟล์ภาพ"
|
||||
title: "แก้ไขลายน้ำ"
|
||||
cover: "ซ้อนทับทั่วทั้งพื้นที่"
|
||||
repeat: "ปูให้เต็มพื้นที่"
|
||||
opacity: "ความทึบแสง"
|
||||
scale: "ขนาด"
|
||||
text: "ข้อความ"
|
||||
|
@ -2740,4 +3147,50 @@ _watermarkEditor:
|
|||
type: "รูปแบบ"
|
||||
image: "รูปภาพ"
|
||||
advanced: "ขั้นสูง"
|
||||
stripe: "ริ้ว"
|
||||
stripeWidth: "ความกว้างเส้น"
|
||||
stripeFrequency: "จำนวนเส้น"
|
||||
angle: "แองเกิล"
|
||||
polkadot: "ลายจุด"
|
||||
checker: "ช่องตาราง"
|
||||
polkadotMainDotOpacity: "ความทึบของจุดหลัก"
|
||||
polkadotMainDotRadius: "ขนาดของจุดหลัก"
|
||||
polkadotSubDotOpacity: "ความทึบของจุดรอง"
|
||||
polkadotSubDotRadius: "ขนาดของจุดรอง"
|
||||
polkadotSubDotDivisions: "จำนวนจุดรอง"
|
||||
_imageEffector:
|
||||
title: "เอฟเฟกต์"
|
||||
addEffect: "เพิ่มเอฟเฟกต์"
|
||||
discardChangesConfirm: "ต้องการทิ้งการเปลี่ยนแปลงแล้วออกหรือไม่?"
|
||||
_fxs:
|
||||
chromaticAberration: "ความคลาดสี"
|
||||
glitch: "กลิตช์"
|
||||
mirror: "กระจก"
|
||||
invert: "กลับสี"
|
||||
grayscale: "ขาวดำเทา"
|
||||
colorAdjust: "ปรับแก้สี"
|
||||
colorClamp: "บีบอัดสี"
|
||||
colorClampAdvanced: "บีบอัดสี (ขั้นสูง)"
|
||||
distort: "บิดเบี้ยว"
|
||||
threshold: "สองสี"
|
||||
zoomLines: "เส้นความเข้มข้น"
|
||||
stripe: "ริ้ว"
|
||||
polkadot: "ลายจุด"
|
||||
checker: "ช่องตาราง"
|
||||
blockNoise: "บล็อกที่มีการรบกวน"
|
||||
tearing: "ฉีกขาด"
|
||||
drafts: "ร่าง"
|
||||
_drafts:
|
||||
select: "เลือกฉบับร่าง"
|
||||
cannotCreateDraftAnymore: "ถึงจำนวนจำกัดที่ฉบับร่างที่สามารถสร้างได้แล้ว"
|
||||
cannotCreateDraft: "ไม่สามารถสร้างฉบับร่างด้วยเนื้อหานี้ได้"
|
||||
delete: "ลบฉบับร่าง"
|
||||
deleteAreYouSure: "ต้องการลบฉบับร่างหรือไม่?"
|
||||
noDrafts: "ไม่มีฉบับร่าง"
|
||||
replyTo: "ตอบกลับ {user}"
|
||||
quoteOf: "อ้างอิงถึงโน้ตของ {user}"
|
||||
postTo: "โพสต์ไปยัง {channel}"
|
||||
saveToDraft: "บันทึกเป็นฉบับร่าง"
|
||||
restoreFromDraft: "คืนค่าจากฉบับร่าง"
|
||||
restore: "กู้คืน"
|
||||
listDrafts: "รายการฉบับร่าง"
|
||||
|
|
|
@ -8,6 +8,9 @@ search: "Пошук"
|
|||
notifications: "Сповіщення"
|
||||
username: "Ім'я користувача"
|
||||
password: "Пароль"
|
||||
initialPasswordForSetup: "Початковий пароль для налаштування"
|
||||
initialPasswordIsIncorrect: "Початковий пароль для налаштування неправильний"
|
||||
initialPasswordForSetupDescription: "Використайте пароль, вказаний у конфігураційному файлі, якщо ви встановлювали Misskey власноруч.\nЯкщо використовуєте сервіси хостингу Misskey, використайте наданий пароль.\nЯкщо ви не маєте паролю, лишіть порожнім щоб продовжити. "
|
||||
forgotPassword: "Я забув пароль"
|
||||
fetchingAsApObject: "Отримуємо з федіверсу..."
|
||||
ok: "OK"
|
||||
|
@ -45,6 +48,7 @@ pin: "Закріпити"
|
|||
unpin: "Відкріпити"
|
||||
copyContent: "Скопіювати контент"
|
||||
copyLink: "Скопіювати посилання"
|
||||
copyRemoteLink: "Копіювати віддалене посилання"
|
||||
delete: "Видалити"
|
||||
deleteAndEdit: "Видалити й редагувати"
|
||||
deleteAndEditConfirm: "Ви впевнені, що хочете видалити цю нотатку та відредагувати її? Ви втратите всі реакції, поширення та відповіді на неї."
|
||||
|
@ -57,6 +61,7 @@ copyUserId: "Копіювати ID користувача"
|
|||
copyNoteId: "блокнот ID користувача"
|
||||
copyFileId: "Скопіювати ідентифікатор файлу."
|
||||
searchUser: "Пошук користувачів"
|
||||
searchThisUsersNotes: "Пошук нотаток користувача"
|
||||
reply: "Відповісти"
|
||||
loadMore: "Показати більше"
|
||||
showMore: "Показати більше"
|
||||
|
@ -105,9 +110,11 @@ enterEmoji: "Введіть емодзі"
|
|||
renote: "Поширити"
|
||||
unrenote: "Відміна поширення"
|
||||
renoted: "Поширити запис."
|
||||
renotedToX: "Поширено до {name}"
|
||||
cantRenote: "Неможливо поширити."
|
||||
cantReRenote: "Поширення не можливо поширити."
|
||||
quote: "Цитата"
|
||||
inChannelRenote: "Поширено у канал"
|
||||
pinnedNote: "Закріплений запис"
|
||||
pinned: "Закріпити"
|
||||
you: "Ви"
|
||||
|
@ -116,6 +123,7 @@ sensitive: "NSFW"
|
|||
add: "Додати"
|
||||
reaction: "Реакції"
|
||||
reactions: "Реакції"
|
||||
emojiPicker: "Вибір реакції"
|
||||
reactionSettingDescription2: "Перемістити щоб змінити порядок, Клацнути мишою щоб видалити, Натиснути \"+\" щоб додати."
|
||||
rememberNoteVisibility: "Пам’ятати параметри видимісті"
|
||||
attachCancel: "Видалити вкладення"
|
||||
|
@ -289,7 +297,9 @@ folderName: "Ім'я теки"
|
|||
createFolder: "Створити теку"
|
||||
renameFolder: "Перейменувати теку"
|
||||
deleteFolder: "Видалити теку"
|
||||
folder: "Тека"
|
||||
addFile: "Додати файл"
|
||||
showFile: "Показати файл"
|
||||
emptyDrive: "Диск порожній"
|
||||
emptyFolder: "Тека порожня"
|
||||
unableToDelete: "Видалення неможливе"
|
||||
|
@ -302,6 +312,7 @@ copyUrl: "Копіювати URL"
|
|||
rename: "Перейменувати"
|
||||
avatar: "Аватар"
|
||||
banner: "Банер"
|
||||
displayOfSensitiveMedia: "Показ чутливого медіа"
|
||||
whenServerDisconnected: "Коли зв’язок із сервером втрачено"
|
||||
disconnectedFromServer: "Зв’язок із сервером було перервано"
|
||||
reload: "Оновити"
|
||||
|
@ -348,8 +359,11 @@ hcaptcha: "hCaptcha"
|
|||
enableHcaptcha: "Увімкнути hCaptcha"
|
||||
hcaptchaSiteKey: "Ключ сайту"
|
||||
hcaptchaSecretKey: "Секретний ключ"
|
||||
mcaptcha: "MCaptcha"
|
||||
enableMcaptcha: "Увімкнути MCaptcha"
|
||||
mcaptchaSiteKey: "Ключ сайту"
|
||||
mcaptchaSecretKey: "Секретний ключ"
|
||||
mcaptchaInstanceUrl: "Посилання на сервер MCaptcha"
|
||||
recaptcha: "reCAPTCHA"
|
||||
enableRecaptcha: "Увімкнути reCAPTCHA"
|
||||
recaptchaSiteKey: "Ключ сайту"
|
||||
|
|
|
@ -2806,6 +2806,7 @@ _fileViewer:
|
|||
url: "URL"
|
||||
uploadedAt: "添加日期"
|
||||
attachedNotes: "附加到的帖子"
|
||||
usage: "使用"
|
||||
thisPageCanBeSeenFromTheAuthor: "此页只能被该文件的上传者查看。"
|
||||
_externalResourceInstaller:
|
||||
title: "从外部站点安装"
|
||||
|
|
|
@ -2806,6 +2806,7 @@ _fileViewer:
|
|||
url: "URL"
|
||||
uploadedAt: "加入日期"
|
||||
attachedNotes: "含有附件的貼文"
|
||||
usage: "使用情況"
|
||||
thisPageCanBeSeenFromTheAuthor: "本頁面僅限上傳了這個檔案的使用者可以檢視。"
|
||||
_externalResourceInstaller:
|
||||
title: "從外部網站安裝"
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "misskey",
|
||||
"version": "2025.7.0-beta.0",
|
||||
"version": "2025.7.0-beta.2",
|
||||
"codename": "nasubi",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
@ -83,6 +83,9 @@
|
|||
"pnpm": {
|
||||
"overrides": {
|
||||
"@aiscript-dev/aiscript-languageserver": "-"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"typeorm": "patches/typeorm.patch"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class FollowingIsFollowerSuspended1752410859370 {
|
||||
name = 'FollowingIsFollowerSuspended1752410859370'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_ce62b50d882d4e9dee10ad0d2f"`);
|
||||
await queryRunner.query(`ALTER TABLE "following" ADD "isFollowerSuspended" boolean NOT NULL DEFAULT false`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_1896254b78a41a50e0396fdabd" ON "following" ("followeeId", "followerHost", "isFollowerSuspended", "isFollowerHibernated") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_d2b8dbf0b772042f4fe241a29d" ON "following" ("followerId", "followeeId", "isFollowerSuspended") `);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_d2b8dbf0b772042f4fe241a29d"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_1896254b78a41a50e0396fdabd"`);
|
||||
await queryRunner.query(`ALTER TABLE "following" DROP COLUMN "isFollowerSuspended"`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_ce62b50d882d4e9dee10ad0d2f" ON "following" ("followeeId", "followerHost", "isFollowerHibernated") `);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class FollowingIsFollowerSuspendedCopySuspendedState1752410900000 {
|
||||
name = 'FollowingIsFollowerSuspendedCopySuspendedState1752410900000'
|
||||
|
||||
async up(queryRunner) {
|
||||
// Update existing records based on user suspension status
|
||||
await queryRunner.query(`
|
||||
UPDATE "following"
|
||||
SET "isFollowerSuspended" = "user"."isSuspended"
|
||||
FROM "user"
|
||||
WHERE "following"."followerId" = "user"."id"
|
||||
`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class NoActionOnDraftRelation1752502434151 {
|
||||
name = 'NoActionOnDraftRelation1752502434151'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" DROP CONSTRAINT "FK_NOTE_DRAFT_CHANNEL_ID"`);
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" DROP CONSTRAINT "FK_NOTE_DRAFT_USER_ID"`);
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" DROP CONSTRAINT "FK_NOTE_DRAFT_RENOTE_ID"`);
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" DROP CONSTRAINT "FK_NOTE_DRAFT_REPLY_ID"`);
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" ADD CONSTRAINT "FK_e4983f28b4b18b03491536052f5" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" DROP CONSTRAINT "FK_e4983f28b4b18b03491536052f5"`);
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" ADD CONSTRAINT "FK_NOTE_DRAFT_REPLY_ID" FOREIGN KEY ("replyId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" ADD CONSTRAINT "FK_NOTE_DRAFT_RENOTE_ID" FOREIGN KEY ("renoteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" ADD CONSTRAINT "FK_NOTE_DRAFT_USER_ID" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" ADD CONSTRAINT "FK_NOTE_DRAFT_CHANNEL_ID" FOREIGN KEY ("channelId") REFERENCES "channel"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class MigrationCleanup1752509043847 {
|
||||
name = 'MigrationCleanup1752509043847'
|
||||
|
||||
async up(queryRunner) {
|
||||
// 1745378064470-composite-note-index.js created a index ON "note" ("userId", "id" DESC) as IDX_724b311e6f883751f261ebe378 but should be named IDX_a6f649630f55af3888e5a42919
|
||||
await queryRunner.query(`ALTER INDEX "IDX_724b311e6f883751f261ebe378" RENAME TO "IDX_a6f649630f55af3888e5a42919"`);
|
||||
|
||||
// 1713656541000-abuse-report-notification.js generated system_webhook with hand-written SQL with CURRENT_TIMESTAMP as the default value, but its representation in TypeORM is `now()`
|
||||
// see https://github.com/typeorm/typeorm/blob/f351757a15b9d2bd9d4222c69dcfd2316f46b5d1/src/driver/postgres/PostgresDriver.ts#L1575
|
||||
await queryRunner.query(`ALTER TABLE "system_webhook" ALTER COLUMN "updatedAt" SET DEFAULT now()`);
|
||||
|
||||
// 1702718871541-ffVisibility.js defined a enum type "user_profile_followersVisibility_enum" but it should be "user_profile_followersvisibility_enum" (lowercase 'v') in typeorm
|
||||
await queryRunner.query(`ALTER TYPE "public"."user_profile_followersVisibility_enum" RENAME TO "user_profile_followersvisibility_enum"`);
|
||||
|
||||
// 1713656541000-abuse-report-notification.js generated abuse_report_notification_recipient with hand-written SQL with CURRENT_TIMESTAMP as the default value, but its representation in TypeORM is `now()`
|
||||
await queryRunner.query(`ALTER TABLE "abuse_report_notification_recipient" ALTER COLUMN "updatedAt" SET DEFAULT now()`);
|
||||
|
||||
// 1690796169261-play-visibility.js added visibility column to flash table but it forgot to set NOT NULL constraint
|
||||
await queryRunner.query(`ALTER TABLE "flash" ALTER COLUMN "visibility" SET NOT NULL`);
|
||||
|
||||
// 1736686850345-createNoteDraft.js created note_draft with hand-written SQL but several types and comments are not correctly defined
|
||||
await queryRunner.query(`CREATE TYPE "public"."note_draft_visibility_enum" AS ENUM('public', 'home', 'followers', 'specified')`);
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "id" SET DATA TYPE character varying(32)`);
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "replyId" SET DATA TYPE character varying(32)`);
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "renoteId" SET DATA TYPE character varying(32)`);
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "userId" SET DATA TYPE character varying(32)`);
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "channelId" SET DATA TYPE character varying(32)`);
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "fileIds" SET DATA TYPE character varying(32) array`);
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "visibleUserIds" SET DATA TYPE character varying(32) array`);
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "visibility" SET DATA TYPE "public"."note_draft_visibility_enum" USING visibility::note_draft_visibility_enum`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "note_draft"."replyId" IS 'The ID of reply target.'`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "note_draft"."renoteId" IS 'The ID of renote target.'`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "note_draft"."userId" IS 'The ID of author.'`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "note_draft"."channelId" IS 'The ID of source channel.'`);
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "fileIds" SET NOT NULL`);
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "hasPoll" SET NOT NULL`);
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "pollChoices" SET NOT NULL`);
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "pollMultiple" SET NOT NULL`);
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "localOnly" SET NOT NULL`);
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "visibleUserIds" SET NOT NULL`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "visibleUserIds" DROP NOT NULL`);
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "localOnly" DROP NOT NULL`);
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "pollMultiple" DROP NOT NULL`);
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "pollChoices" DROP NOT NULL`);
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "hasPoll" DROP NOT NULL`);
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "fileIds" DROP NOT NULL`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "note_draft"."channelId" IS NULL`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "note_draft"."userId" IS 'The ID of author.'`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "note_draft"."renoteId" IS NULL`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "note_draft"."replyId" IS NULL`);
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "visibility" SET DATA TYPE varchar`);
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "visibleUserIds" SET DATA TYPE varchar[]`);
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "fileIds" SET DATA TYPE varchar[]`);
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "channelId" SET DATA TYPE varchar`);
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "userId" SET DATA TYPE varchar`);
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "renoteId" SET DATA TYPE varchar`);
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "replyId" SET DATA TYPE varchar`);
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "id" SET DATA TYPE varchar`);
|
||||
await queryRunner.query(`DROP TYPE "public"."note_draft_visibility_enum"`);
|
||||
|
||||
await queryRunner.query(`ALTER TABLE "flash" ALTER COLUMN "visibility" DROP NOT NULL`);
|
||||
|
||||
await queryRunner.query(`ALTER TABLE "abuse_report_notification_recipient" ALTER COLUMN "updatedAt" SET DEFAULT CURRENT_TIMESTAMP`);
|
||||
|
||||
await queryRunner.query(`ALTER TYPE "public"."user_profile_followersvisibility_enum" RENAME TO "user_profile_followersVisibility_enum"`);
|
||||
|
||||
await queryRunner.query(`ALTER TABLE "system_webhook" ALTER COLUMN "updatedAt" SET DEFAULT CURRENT_TIMESTAMP`);
|
||||
|
||||
await queryRunner.query(`ALTER INDEX "IDX_a6f649630f55af3888e5a42919" RENAME TO "IDX_724b311e6f883751f261ebe378"`);
|
||||
}
|
||||
}
|
|
@ -33,6 +33,7 @@
|
|||
"test:fed": "pnpm jest:fed",
|
||||
"test-and-coverage": "pnpm jest-and-coverage",
|
||||
"test-and-coverage:e2e": "pnpm build && pnpm build:test && pnpm jest-and-coverage:e2e",
|
||||
"check-migrations": "node scripts/check_migrations_clean.js",
|
||||
"generate-api-json": "node ./scripts/generate_api_json.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
// This script checks if the database migrations has been generated correctly.
|
||||
|
||||
import dataSource from '../ormconfig.js';
|
||||
|
||||
await dataSource.initialize();
|
||||
|
||||
const sqlInMemory = await dataSource.driver.createSchemaBuilder().log();
|
||||
|
||||
if (sqlInMemory.upQueries.length > 0 || sqlInMemory.downQueries.length > 0) {
|
||||
console.error('There are several pending migrations. Please make sure you have generated the migrations correctly, or configured entities class correctly.');
|
||||
for (const query of sqlInMemory.upQueries) {
|
||||
console.error(`- ${query.query}`);
|
||||
}
|
||||
for (const query of sqlInMemory.downQueries) {
|
||||
console.error(`- ${query.query}`);
|
||||
}
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('All migrations are clean.');
|
||||
process.exit(0);
|
||||
}
|
|
@ -20,6 +20,8 @@ import { CacheService } from '@/core/CacheService.js';
|
|||
import { isReply } from '@/misc/is-reply.js';
|
||||
import { isInstanceMuted } from '@/misc/is-instance-muted.js';
|
||||
|
||||
type NoteFilter = (note: MiNote) => boolean;
|
||||
|
||||
type TimelineOptions = {
|
||||
untilId: string | null,
|
||||
sinceId: string | null,
|
||||
|
@ -28,7 +30,7 @@ type TimelineOptions = {
|
|||
me?: { id: MiUser['id'] } | undefined | null,
|
||||
useDbFallback: boolean,
|
||||
redisTimelines: FanoutTimelineName[],
|
||||
noteFilter?: (note: MiNote) => boolean,
|
||||
noteFilter?: NoteFilter,
|
||||
alwaysIncludeMyNotes?: boolean;
|
||||
ignoreAuthorFromBlock?: boolean;
|
||||
ignoreAuthorFromMute?: boolean;
|
||||
|
@ -79,7 +81,7 @@ export class FanoutTimelineEndpointService {
|
|||
const shouldFallbackToDb = noteIds.length === 0 || ps.sinceId != null && ps.sinceId < oldestNoteId;
|
||||
|
||||
if (!shouldFallbackToDb) {
|
||||
let filter = ps.noteFilter ?? (_note => true);
|
||||
let filter = ps.noteFilter ?? (_note => true) as NoteFilter;
|
||||
|
||||
if (ps.alwaysIncludeMyNotes && ps.me) {
|
||||
const me = ps.me;
|
||||
|
@ -145,16 +147,12 @@ export class FanoutTimelineEndpointService {
|
|||
{
|
||||
const parentFilter = filter;
|
||||
filter = (note) => {
|
||||
const noteJoined = note as MiNote & {
|
||||
renoteUser: MiUser | null;
|
||||
replyUser: MiUser | null;
|
||||
};
|
||||
if (!ps.ignoreAuthorFromUserSuspension) {
|
||||
if (note.user!.isSuspended) return false;
|
||||
if (note.user!.isRemoteSuspended) return false;
|
||||
}
|
||||
if (note.userId !== note.renoteUserId && noteJoined.renoteUser?.isSuspended) return false;
|
||||
if (note.userId !== note.replyUserId && noteJoined.replyUser?.isSuspended) return false;
|
||||
if (note.userId !== note.renoteUserId && note.renote?.user?.isSuspended) return false;
|
||||
if (note.userId !== note.replyUserId && note.reply?.user?.isSuspended) return false;
|
||||
|
||||
return parentFilter(note);
|
||||
};
|
||||
|
@ -201,7 +199,7 @@ export class FanoutTimelineEndpointService {
|
|||
return await ps.dbFallback(ps.untilId, ps.sinceId, ps.limit);
|
||||
}
|
||||
|
||||
private async getAndFilterFromDb(noteIds: string[], noteFilter: (note: MiNote) => boolean, idCompare: (a: string, b: string) => number): Promise<MiNote[]> {
|
||||
private async getAndFilterFromDb(noteIds: string[], noteFilter: NoteFilter, idCompare: (a: string, b: string) => number): Promise<MiNote[]> {
|
||||
const query = this.notesRepository.createQueryBuilder('note')
|
||||
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
|
|
|
@ -545,6 +545,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
// TODO: キャッシュ
|
||||
this.followingsRepository.findBy({
|
||||
followeeId: user.id,
|
||||
isFollowerSuspended: false,
|
||||
notify: 'normal',
|
||||
}).then(async followings => {
|
||||
if (note.visibility !== 'specified') {
|
||||
|
@ -850,6 +851,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
where: {
|
||||
followeeId: user.id,
|
||||
followerHost: IsNull(),
|
||||
isFollowerSuspended: false,
|
||||
isFollowerHibernated: false,
|
||||
},
|
||||
select: ['followerId', 'withReplies'],
|
||||
|
|
|
@ -173,6 +173,9 @@ export class QueueService {
|
|||
@bindThis
|
||||
public async deliverMany(user: ThinUser, content: IActivity | null, inboxes: Map<string, boolean>) {
|
||||
if (content == null) return null;
|
||||
inboxes.delete(null as unknown as string); // remove null inboxes
|
||||
if (inboxes.size === 0) return null;
|
||||
|
||||
const contentBody = JSON.stringify(content);
|
||||
const digest = ApRequestCreator.createDigest(contentBody);
|
||||
|
||||
|
|
|
@ -67,6 +67,7 @@ export type RolePolicies = {
|
|||
chatAvailability: 'available' | 'readonly' | 'unavailable';
|
||||
uploadableFileTypes: string[];
|
||||
noteDraftLimit: number;
|
||||
watermarkAvailable: boolean;
|
||||
};
|
||||
|
||||
export const DEFAULT_POLICIES: RolePolicies = {
|
||||
|
@ -111,6 +112,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
|
|||
'audio/*',
|
||||
],
|
||||
noteDraftLimit: 10,
|
||||
watermarkAvailable: true,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
|
@ -433,6 +435,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
return [...set];
|
||||
}),
|
||||
noteDraftLimit: calc('noteDraftLimit', vs => Math.max(...vs)),
|
||||
watermarkAvailable: calc('watermarkAvailable', vs => vs.some(v => v === true)),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -93,6 +93,11 @@ export class SignupService {
|
|||
if (isPreserved) {
|
||||
throw new Error('USED_USERNAME');
|
||||
}
|
||||
|
||||
const hasProhibitedWords = this.utilityService.isKeyWordIncluded(username.toLowerCase(), this.meta.prohibitedWordsForNameOfUser);
|
||||
if (hasProhibitedWords) {
|
||||
throw new Error('USED_USERNAME');
|
||||
}
|
||||
}
|
||||
|
||||
const keyPair = await new Promise<string[]>((res, rej) =>
|
||||
|
|
|
@ -229,9 +229,7 @@ export class UserFollowingService implements OnModuleInit {
|
|||
followee: {
|
||||
id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox']
|
||||
},
|
||||
follower: {
|
||||
id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox']
|
||||
},
|
||||
follower: MiUser,
|
||||
silent = false,
|
||||
withReplies?: boolean,
|
||||
): Promise<void> {
|
||||
|
@ -244,6 +242,7 @@ export class UserFollowingService implements OnModuleInit {
|
|||
followerId: follower.id,
|
||||
followeeId: followee.id,
|
||||
withReplies: withReplies,
|
||||
isFollowerSuspended: follower.isSuspended,
|
||||
|
||||
// 非正規化
|
||||
followerHost: follower.host,
|
||||
|
@ -734,6 +733,7 @@ export class UserFollowingService implements OnModuleInit {
|
|||
return this.followingsRepository.createQueryBuilder('following')
|
||||
.select('following.followeeId')
|
||||
.where('following.followerId = :followerId', { followerId: userId })
|
||||
.andWhere('following.isFollowerSuspended = false')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
|
@ -743,6 +743,7 @@ export class UserFollowingService implements OnModuleInit {
|
|||
where: {
|
||||
followerId,
|
||||
followeeId,
|
||||
isFollowerSuspended: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
@ -7,12 +7,11 @@ import { Inject, Injectable } from '@nestjs/common';
|
|||
import { Not, IsNull } from 'typeorm';
|
||||
import type { FollowingsRepository, FollowRequestsRepository, UsersRepository } from '@/models/_.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { RelationshipJobData } from '@/queue/types.js';
|
||||
import { ApDeliverManagerService } from './activitypub/ApDeliverManagerService.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import { AccountUpdateService } from '@/core/AccountUpdateService.js';
|
||||
|
||||
|
@ -29,9 +28,10 @@ export class UserSuspendService {
|
|||
private followRequestsRepository: FollowRequestsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private queueService: QueueService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private accountUpadateService: AccountUpdateService,
|
||||
private apRendererService: ApRendererService,
|
||||
private apDeliverManagerService: ApDeliverManagerService,
|
||||
private moderationLogService: ModerationLogService,
|
||||
) {
|
||||
}
|
||||
|
@ -49,8 +49,8 @@ export class UserSuspendService {
|
|||
});
|
||||
|
||||
(async () => {
|
||||
await this.postSuspend(user).catch(e => {});
|
||||
await this.unFollowAll(user).catch(e => {});
|
||||
await this.postSuspend(user).catch((e: any) => {});
|
||||
await this.suspendFollowings(user).catch((e: any) => {});
|
||||
})();
|
||||
}
|
||||
|
||||
|
@ -67,7 +67,8 @@ export class UserSuspendService {
|
|||
});
|
||||
|
||||
(async () => {
|
||||
await this.postUnsuspend(user).catch(e => {});
|
||||
await this.postUnsuspend(user).catch((e: any) => {});
|
||||
await this.restoreFollowings(user).catch((e: any) => {});
|
||||
})();
|
||||
}
|
||||
|
||||
|
@ -84,6 +85,11 @@ export class UserSuspendService {
|
|||
|
||||
if (this.userEntityService.isLocalUser(user)) {
|
||||
this.accountUpadateService.publishToFollowers(user.id);
|
||||
const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user));
|
||||
const manager = this.apDeliverManagerService.createDeliverManager(user, content);
|
||||
manager.addAllKnowingSharedInboxRecipe();
|
||||
manager.addFollowersRecipe();
|
||||
manager.execute();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -93,28 +99,36 @@ export class UserSuspendService {
|
|||
|
||||
if (this.userEntityService.isLocalUser(user)) {
|
||||
this.accountUpadateService.publishToFollowers(user.id);
|
||||
const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user), user));
|
||||
const manager = this.apDeliverManagerService.createDeliverManager(user, content);
|
||||
manager.addAllKnowingSharedInboxRecipe();
|
||||
manager.addFollowersRecipe();
|
||||
manager.execute();
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async unFollowAll(follower: MiUser) {
|
||||
const followings = await this.followingsRepository.find({
|
||||
where: {
|
||||
private async suspendFollowings(follower: MiUser) {
|
||||
await this.followingsRepository.update(
|
||||
{
|
||||
followerId: follower.id,
|
||||
followeeId: Not(IsNull()),
|
||||
},
|
||||
});
|
||||
|
||||
const jobs: RelationshipJobData[] = [];
|
||||
for (const following of followings) {
|
||||
if (following.followeeId && following.followerId) {
|
||||
jobs.push({
|
||||
from: { id: following.followerId },
|
||||
to: { id: following.followeeId },
|
||||
silent: true,
|
||||
});
|
||||
{
|
||||
isFollowerSuspended: true,
|
||||
}
|
||||
}
|
||||
this.queueService.createUnfollowJob(jobs);
|
||||
);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async restoreFollowings(follower: MiUser) {
|
||||
// フォロー関係を復元(isFollowerSuspended: false)に変更
|
||||
await this.followingsRepository.update(
|
||||
{
|
||||
followerId: follower.id,
|
||||
},
|
||||
{
|
||||
isFollowerSuspended: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,10 +9,12 @@ import { DI } from '@/di-symbols.js';
|
|||
import type { FollowingsRepository } from '@/models/_.js';
|
||||
import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { IActivity } from '@/core/activitypub/type.js';
|
||||
import { ThinUser } from '@/queue/types.js';
|
||||
import { AccountUpdateService } from '@/core/AccountUpdateService.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { ApLoggerService } from './ApLoggerService.js';
|
||||
|
||||
interface IRecipe {
|
||||
type: string;
|
||||
|
@ -27,12 +29,19 @@ interface IDirectRecipe extends IRecipe {
|
|||
to: MiRemoteUser;
|
||||
}
|
||||
|
||||
interface IAllKnowingSharedInboxRecipe extends IRecipe {
|
||||
type: 'AllKnowingSharedInbox';
|
||||
}
|
||||
|
||||
const isFollowers = (recipe: IRecipe): recipe is IFollowersRecipe =>
|
||||
recipe.type === 'Followers';
|
||||
|
||||
const isDirect = (recipe: IRecipe): recipe is IDirectRecipe =>
|
||||
recipe.type === 'Direct';
|
||||
|
||||
const isAllKnowingSharedInbox = (recipe: IRecipe): recipe is IAllKnowingSharedInboxRecipe =>
|
||||
recipe.type === 'AllKnowingSharedInbox';
|
||||
|
||||
class DeliverManager {
|
||||
private actor: ThinUser;
|
||||
private activity: IActivity | null;
|
||||
|
@ -40,16 +49,15 @@ class DeliverManager {
|
|||
|
||||
/**
|
||||
* Constructor
|
||||
* @param userEntityService
|
||||
* @param followingsRepository
|
||||
* @param queueService
|
||||
* @param actor Actor
|
||||
* @param activity Activity to deliver
|
||||
*/
|
||||
constructor(
|
||||
private userEntityService: UserEntityService,
|
||||
private followingsRepository: FollowingsRepository,
|
||||
private queueService: QueueService,
|
||||
private logger: Logger,
|
||||
|
||||
actor: { id: MiUser['id']; host: null; },
|
||||
activity: IActivity | null,
|
||||
|
@ -91,6 +99,18 @@ class DeliverManager {
|
|||
this.addRecipe(recipe);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add recipe for all-knowing shared inbox deliver
|
||||
*/
|
||||
@bindThis
|
||||
public addAllKnowingSharedInboxRecipe(): void {
|
||||
const deliver: IAllKnowingSharedInboxRecipe = {
|
||||
type: 'AllKnowingSharedInbox',
|
||||
};
|
||||
|
||||
this.addRecipe(deliver);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add recipe
|
||||
* @param recipe Recipe
|
||||
|
@ -104,11 +124,30 @@ class DeliverManager {
|
|||
* Execute delivers
|
||||
*/
|
||||
@bindThis
|
||||
public async execute(): Promise<void> {
|
||||
public async execute(opts: { ignoreSuspend?: boolean } = {}): Promise<void> {
|
||||
//#region collect inboxes by recipes
|
||||
// The value flags whether it is shared or not.
|
||||
// key: inbox URL, value: whether it is sharedInbox
|
||||
const inboxes = new Map<string, boolean>();
|
||||
|
||||
if (this.recipes.some(r => isAllKnowingSharedInbox(r))) {
|
||||
// all-knowing shared inbox
|
||||
const followings = await this.followingsRepository.createQueryBuilder('f')
|
||||
.select([
|
||||
'f.followerSharedInbox',
|
||||
'f.followeeSharedInbox',
|
||||
])
|
||||
.where('f.followerSharedInbox IS NOT NULL')
|
||||
.orWhere('f.followeeSharedInbox IS NOT NULL')
|
||||
.distinct()
|
||||
.getRawMany<{ f_followerSharedInbox: string | null; f_followeeSharedInbox: string | null; }>();
|
||||
|
||||
for (const following of followings) {
|
||||
if (following.f_followeeSharedInbox) inboxes.set(following.f_followeeSharedInbox, true);
|
||||
if (following.f_followerSharedInbox) inboxes.set(following.f_followerSharedInbox, true);
|
||||
}
|
||||
}
|
||||
|
||||
// build inbox list
|
||||
// Process follower recipes first to avoid duplication when processing direct recipes later.
|
||||
if (this.recipes.some(r => isFollowers(r))) {
|
||||
|
@ -119,6 +158,7 @@ class DeliverManager {
|
|||
where: {
|
||||
followeeId: this.actor.id,
|
||||
followerHost: Not(IsNull()),
|
||||
isFollowerSuspended: opts.ignoreSuspend ? undefined : false,
|
||||
},
|
||||
select: {
|
||||
followerSharedInbox: true,
|
||||
|
@ -142,34 +182,40 @@ class DeliverManager {
|
|||
|
||||
inboxes.set(recipe.to.inbox, false);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
// deliver
|
||||
await this.queueService.deliverMany(this.actor, this.activity, inboxes);
|
||||
this.logger.info(`Deliver queues dispatched: inboxes=${inboxes.size} actorId=${this.actor.id} activityId=${this.activity?.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ApDeliverManagerService {
|
||||
private logger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private queueService: QueueService,
|
||||
private apLoggerService: ApLoggerService,
|
||||
) {
|
||||
this.logger = this.apLoggerService.logger.createSubLogger('deliver-manager');
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver activity to followers
|
||||
* @param actor
|
||||
* @param activity Activity
|
||||
* @param forceMainKey Force to use main (rsa) key
|
||||
*/
|
||||
@bindThis
|
||||
public async deliverToFollowers(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity): Promise<void> {
|
||||
const manager = new DeliverManager(
|
||||
this.userEntityService,
|
||||
this.followingsRepository,
|
||||
this.queueService,
|
||||
this.logger,
|
||||
actor,
|
||||
activity,
|
||||
);
|
||||
|
@ -186,9 +232,9 @@ export class ApDeliverManagerService {
|
|||
@bindThis
|
||||
public async deliverToUser(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity, to: MiRemoteUser): Promise<void> {
|
||||
const manager = new DeliverManager(
|
||||
this.userEntityService,
|
||||
this.followingsRepository,
|
||||
this.queueService,
|
||||
this.logger,
|
||||
actor,
|
||||
activity,
|
||||
);
|
||||
|
@ -205,9 +251,9 @@ export class ApDeliverManagerService {
|
|||
@bindThis
|
||||
public async deliverToUsers(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity, targets: MiRemoteUser[]): Promise<void> {
|
||||
const manager = new DeliverManager(
|
||||
this.userEntityService,
|
||||
this.followingsRepository,
|
||||
this.queueService,
|
||||
this.logger,
|
||||
actor,
|
||||
activity,
|
||||
);
|
||||
|
@ -218,10 +264,9 @@ export class ApDeliverManagerService {
|
|||
@bindThis
|
||||
public createDeliverManager(actor: { id: MiUser['id']; host: null; }, activity: IActivity | null): DeliverManager {
|
||||
return new DeliverManager(
|
||||
this.userEntityService,
|
||||
this.followingsRepository,
|
||||
this.queueService,
|
||||
|
||||
this.logger,
|
||||
actor,
|
||||
activity,
|
||||
);
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { EntityNotFoundError } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import { awaitAll } from '@/misc/prelude/await-all.js';
|
||||
|
@ -90,6 +91,17 @@ export class NoteDraftEntityService implements OnModuleInit {
|
|||
const packedFiles = options?._hint_?.packedFiles;
|
||||
const packedUsers = options?._hint_?.packedUsers;
|
||||
|
||||
async function nullIfEntityNotFound<T>(promise: Promise<T>): Promise<T | null> {
|
||||
try {
|
||||
return await promise;
|
||||
} catch (err) {
|
||||
if (err instanceof EntityNotFoundError) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const packed: Packed<'NoteDraft'> = await awaitAll({
|
||||
id: noteDraft.id,
|
||||
createdAt: this.idService.parse(noteDraft.id).date.toISOString(),
|
||||
|
@ -117,15 +129,15 @@ export class NoteDraftEntityService implements OnModuleInit {
|
|||
} : undefined,
|
||||
|
||||
...(opts.detail ? {
|
||||
reply: noteDraft.replyId ? this.noteEntityService.pack(noteDraft.replyId, me, {
|
||||
reply: noteDraft.replyId ? nullIfEntityNotFound(this.noteEntityService.pack(noteDraft.replyId, me, {
|
||||
detail: false,
|
||||
skipHide: opts.skipHide,
|
||||
}) : undefined,
|
||||
})) : undefined,
|
||||
|
||||
renote: noteDraft.renoteId ? this.noteEntityService.pack(noteDraft.renoteId, me, {
|
||||
renote: noteDraft.renoteId ? nullIfEntityNotFound(this.noteEntityService.pack(noteDraft.renoteId, me, {
|
||||
detail: true,
|
||||
skipHide: opts.skipHide,
|
||||
}) : undefined,
|
||||
})) : undefined,
|
||||
|
||||
poll: noteDraft.hasPoll ? {
|
||||
choices: noteDraft.pollChoices,
|
||||
|
|
|
@ -22,7 +22,7 @@ export class MiAbuseReportNotificationRecipient {
|
|||
/**
|
||||
* 有効かどうか.
|
||||
*/
|
||||
@Index()
|
||||
@Index('IDX_abuse_report_notification_recipient_isActive')
|
||||
@Column('boolean', {
|
||||
default: true,
|
||||
})
|
||||
|
@ -47,7 +47,7 @@ export class MiAbuseReportNotificationRecipient {
|
|||
/**
|
||||
* 通知方法.
|
||||
*/
|
||||
@Index()
|
||||
@Index('IDX_abuse_report_notification_recipient_method')
|
||||
@Column('varchar', {
|
||||
length: 64,
|
||||
})
|
||||
|
@ -56,10 +56,11 @@ export class MiAbuseReportNotificationRecipient {
|
|||
/**
|
||||
* 通知先のユーザID.
|
||||
*/
|
||||
@Index()
|
||||
@Index('IDX_abuse_report_notification_recipient_userId')
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true,
|
||||
default: null,
|
||||
})
|
||||
public userId: MiUser['id'] | null;
|
||||
|
||||
|
@ -75,17 +76,20 @@ export class MiAbuseReportNotificationRecipient {
|
|||
/**
|
||||
* 通知先のユーザプロフィール.
|
||||
*/
|
||||
@ManyToOne(type => MiUserProfile, {})
|
||||
@ManyToOne(type => MiUserProfile, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'userId', referencedColumnName: 'userId', foreignKeyConstraintName: 'FK_abuse_report_notification_recipient_userId2' })
|
||||
public userProfile: MiUserProfile | null;
|
||||
|
||||
/**
|
||||
* 通知先のシステムWebhookId.
|
||||
*/
|
||||
@Index()
|
||||
@Index('IDX_abuse_report_notification_recipient_systemWebhookId')
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true,
|
||||
default: null,
|
||||
})
|
||||
public systemWebhookId: string | null;
|
||||
|
||||
|
@ -95,6 +99,6 @@ export class MiAbuseReportNotificationRecipient {
|
|||
@ManyToOne(type => MiSystemWebhook, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn()
|
||||
@JoinColumn({ name: 'systemWebhookId', referencedColumnName: 'id', foreignKeyConstraintName: 'FK_abuse_report_notification_recipient_systemWebhookId' })
|
||||
public systemWebhook: MiSystemWebhook | null;
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import { id } from './util/id.js';
|
|||
|
||||
@Entity('emoji')
|
||||
@Index(['name', 'host'], { unique: true })
|
||||
@Index('IDX_EMOJI_ROLE_IDS', { synchronize: false }) // GIN for roleIdsThatCanBeUsedThisEmojiAsReaction in production
|
||||
export class MiEmoji {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
@ -32,6 +33,7 @@ export class MiEmoji {
|
|||
@Column('varchar', {
|
||||
length: 128, nullable: true,
|
||||
})
|
||||
@Index('IDX_EMOJI_CATEGORY')
|
||||
public category: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
|
|
|
@ -9,7 +9,8 @@ import { MiUser } from './User.js';
|
|||
|
||||
@Entity('following')
|
||||
@Index(['followerId', 'followeeId'], { unique: true })
|
||||
@Index(['followeeId', 'followerHost', 'isFollowerHibernated'])
|
||||
@Index(['followerId', 'followeeId', 'isFollowerSuspended'])
|
||||
@Index(['followeeId', 'followerHost', 'isFollowerSuspended', 'isFollowerHibernated'])
|
||||
export class MiFollowing {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
@ -45,6 +46,11 @@ export class MiFollowing {
|
|||
})
|
||||
public isFollowerHibernated: boolean;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
public isFollowerSuspended: boolean;
|
||||
|
||||
// タイムラインにその人のリプライまで含めるかどうか
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
|
|
|
@ -59,7 +59,7 @@ export class MiMeta {
|
|||
public maintainerEmail: string | null;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
default: true,
|
||||
})
|
||||
public disableRegistration: boolean;
|
||||
|
||||
|
@ -570,7 +570,7 @@ export class MiMeta {
|
|||
public bannedEmailDomains: string[];
|
||||
|
||||
@Column('varchar', {
|
||||
length: 1024, array: true, default: '{ "admin", "administrator", "root", "system", "maintainer", "host", "mod", "moderator", "owner", "superuser", "staff", "auth", "i", "me", "everyone", "all", "mention", "mentions", "example", "user", "users", "account", "accounts", "official", "help", "helps", "support", "supports", "info", "information", "informations", "announce", "announces", "announcement", "announcements", "notice", "notification", "notifications", "dev", "developer", "developers", "tech", "misskey" }',
|
||||
length: 1024, array: true, default: ['admin', 'administrator', 'root', 'system', 'maintainer', 'host', 'mod', 'moderator', 'owner', 'superuser', 'staff', 'auth', 'i', 'me', 'everyone', 'all', 'mention', 'mentions', 'example', 'user', 'users', 'account', 'accounts', 'official', 'help', 'helps', 'support', 'supports', 'info', 'information', 'informations', 'announce', 'announces', 'announcement', 'announcements', 'notice', 'notification', 'notifications', 'dev', 'developer', 'developers', 'tech', 'misskey'],
|
||||
})
|
||||
public preservedUsernames: string[];
|
||||
|
||||
|
@ -635,7 +635,7 @@ export class MiMeta {
|
|||
public urlPreviewMaximumContentLength: number;
|
||||
|
||||
@Column('boolean', {
|
||||
default: true,
|
||||
default: false,
|
||||
})
|
||||
public urlPreviewRequireContentLength: boolean;
|
||||
|
||||
|
@ -648,6 +648,7 @@ export class MiMeta {
|
|||
@Column('varchar', {
|
||||
length: 1024,
|
||||
nullable: true,
|
||||
default: null,
|
||||
})
|
||||
public urlPreviewUserAgent: string | null;
|
||||
|
||||
|
|
|
@ -20,7 +20,8 @@ import type { MiDriveFile } from './DriveFile.js';
|
|||
// You should not use `@Index({ concurrent: true })` decorator because database initialization for test will fail
|
||||
// because it will always run CREATE INDEX in transaction based on decorators.
|
||||
// Not appending `{ concurrent: true }` to `@Index` will not cause any problem in production,
|
||||
@Index(['userId', 'id'])
|
||||
|
||||
@Index(['userId', 'id']) // Note: this index is ("userId", "id" DESC) in production, but not in test.
|
||||
@Entity('note')
|
||||
export class MiNote {
|
||||
@PrimaryColumn(id())
|
||||
|
|
|
@ -12,11 +12,13 @@ import { MiNote } from './Note.js';
|
|||
import type { MiDriveFile } from './DriveFile.js';
|
||||
|
||||
@Entity('note_draft')
|
||||
@Index('IDX_NOTE_DRAFT_FILE_IDS', { synchronize: false }) // GIN for fileIds in production
|
||||
@Index('IDX_NOTE_DRAFT_VISIBLE_USER_IDS', { synchronize: false }) // GIN for visibleUserIds in production
|
||||
export class MiNoteDraft {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Index()
|
||||
@Index('IDX_NOTE_DRAFT_REPLY_ID')
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true,
|
||||
|
@ -24,13 +26,14 @@ export class MiNoteDraft {
|
|||
})
|
||||
public replyId: MiNote['id'] | null;
|
||||
|
||||
// There is a possibility that replyId is not null but reply is null when the reply note is deleted.
|
||||
@ManyToOne(type => MiNote, {
|
||||
onDelete: 'CASCADE',
|
||||
createForeignKeyConstraints: false,
|
||||
})
|
||||
@JoinColumn()
|
||||
public reply: MiNote | null;
|
||||
|
||||
@Index()
|
||||
@Index('IDX_NOTE_DRAFT_RENOTE_ID')
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true,
|
||||
|
@ -38,8 +41,9 @@ export class MiNoteDraft {
|
|||
})
|
||||
public renoteId: MiNote['id'] | null;
|
||||
|
||||
// There is a possibility that renoteId is not null but renote is null when the renote note is deleted.
|
||||
@ManyToOne(type => MiNote, {
|
||||
onDelete: 'CASCADE',
|
||||
createForeignKeyConstraints: false,
|
||||
})
|
||||
@JoinColumn()
|
||||
public renote: MiNote | null;
|
||||
|
@ -55,7 +59,7 @@ export class MiNoteDraft {
|
|||
})
|
||||
public cw: string | null;
|
||||
|
||||
@Index()
|
||||
@Index('IDX_NOTE_DRAFT_USER_ID')
|
||||
@Column({
|
||||
...id(),
|
||||
comment: 'The ID of author.',
|
||||
|
@ -106,7 +110,7 @@ export class MiNoteDraft {
|
|||
})
|
||||
public hashtag: string | null;
|
||||
|
||||
@Index()
|
||||
@Index('IDX_NOTE_DRAFT_CHANNEL_ID')
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true,
|
||||
|
@ -114,8 +118,10 @@ export class MiNoteDraft {
|
|||
})
|
||||
public channelId: MiChannel['id'] | null;
|
||||
|
||||
// There is a possibility that channelId is not null but channel is null when the channel is deleted.
|
||||
// (deleting channel is not implemented so it's not happening now but may happen in the future)
|
||||
@ManyToOne(type => MiChannel, {
|
||||
onDelete: 'CASCADE',
|
||||
createForeignKeyConstraints: false,
|
||||
})
|
||||
@JoinColumn()
|
||||
public channel: MiChannel | null;
|
||||
|
|
|
@ -29,7 +29,7 @@ export class MiUserProfile {
|
|||
})
|
||||
public location: string | null;
|
||||
|
||||
@Index()
|
||||
// Note: There's index named IDX_de22cd2b445eee31ae51cdbe99 for SUBSTR("birthday", 6, 5)
|
||||
@Column('char', {
|
||||
length: 10, nullable: true,
|
||||
comment: 'The birthday (YYYY-MM-DD) of the User.',
|
||||
|
|
|
@ -51,11 +51,13 @@ export const packedNoteDraftSchema = {
|
|||
type: 'object',
|
||||
optional: true, nullable: true,
|
||||
ref: 'Note',
|
||||
description: 'The reply target note contents if exists. If the reply target has been deleted since the draft was created, this will be null while replyId is not null.',
|
||||
},
|
||||
renote: {
|
||||
type: 'object',
|
||||
optional: true, nullable: true,
|
||||
ref: 'Note',
|
||||
description: 'The renote target note contents if exists. If the renote target has been deleted since the draft was created, this will be null while renoteId is not null.',
|
||||
},
|
||||
visibility: {
|
||||
type: 'string',
|
||||
|
|
|
@ -313,6 +313,10 @@ export const packedRolePoliciesSchema = {
|
|||
type: 'integer',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
watermarkAvailable: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
|
@ -129,7 +129,8 @@ export class SignupApiService {
|
|||
|
||||
let ticket: MiRegistrationTicket | null = null;
|
||||
|
||||
if (this.meta.disableRegistration) {
|
||||
// テスト時はこの機構は障害となるため無効にする
|
||||
if (process.env.NODE_ENV !== 'test' && this.meta.disableRegistration) {
|
||||
if (invitationCode == null || typeof invitationCode !== 'string') {
|
||||
reply.code(400);
|
||||
return;
|
||||
|
|
|
@ -50,7 +50,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
|
||||
.andWhere('following.followeeHost = :host', { host: ps.host });
|
||||
.andWhere('following.followeeHost = :host', { host: ps.host })
|
||||
.andWhere('following.isFollowerSuspended = false');
|
||||
|
||||
const followings = await query
|
||||
.limit(ps.limit)
|
||||
|
|
|
@ -50,7 +50,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
|
||||
.andWhere('following.followerHost = :host', { host: ps.host });
|
||||
.andWhere('following.followerHost = :host', { host: ps.host })
|
||||
.andWhere('following.isFollowerSuspended = false');
|
||||
|
||||
const followings = await query
|
||||
.limit(ps.limit)
|
||||
|
|
|
@ -94,11 +94,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
this.followingsRepository.count({
|
||||
where: {
|
||||
followeeHost: Not(IsNull()),
|
||||
isFollowerSuspended: false,
|
||||
},
|
||||
}),
|
||||
this.followingsRepository.count({
|
||||
where: {
|
||||
followerHost: Not(IsNull()),
|
||||
isFollowerSuspended: false,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
|
|
@ -237,7 +237,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
}
|
||||
|
||||
if (ps.withRenotes === false) {
|
||||
query.andWhere('note.renoteId IS NULL');
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb.orWhere('note.renoteId IS NULL');
|
||||
qb.orWhere(new Brackets(qb => {
|
||||
qb.orWhere('note.text IS NOT NULL');
|
||||
qb.orWhere('note.fileIds != \'{}\'');
|
||||
}));
|
||||
}));
|
||||
}
|
||||
//#endregion
|
||||
|
||||
|
|
|
@ -125,6 +125,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
where: {
|
||||
followeeId: user.id,
|
||||
followerId: me.id,
|
||||
isFollowerSuspended: false,
|
||||
},
|
||||
});
|
||||
if (!isFollowing) {
|
||||
|
@ -136,6 +137,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
|
||||
.andWhere('following.followeeId = :userId', { userId: user.id })
|
||||
.andWhere('following.isFollowerSuspended = false')
|
||||
.innerJoinAndSelect('following.follower', 'follower');
|
||||
|
||||
const followings = await query
|
||||
|
|
|
@ -133,6 +133,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
where: {
|
||||
followeeId: user.id,
|
||||
followerId: me.id,
|
||||
isFollowerSuspended: false,
|
||||
},
|
||||
});
|
||||
if (!isFollowing) {
|
||||
|
@ -144,6 +145,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
|
||||
.andWhere('following.followerId = :userId', { userId: user.id })
|
||||
.andWhere('following.isFollowerSuspended = false')
|
||||
.innerJoinAndSelect('following.followee', 'followee');
|
||||
|
||||
if (ps.birthday) {
|
||||
|
|
|
@ -68,7 +68,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
const followingQuery = this.followingsRepository.createQueryBuilder('following')
|
||||
.select('following.followeeId')
|
||||
.where('following.followerId = :followerId', { followerId: me.id });
|
||||
.where('following.followerId = :followerId', { followerId: me.id })
|
||||
.andWhere('following.isFollowerSuspended = false');
|
||||
|
||||
query
|
||||
.andWhere(`user.id NOT IN (${ followingQuery.getQuery() })`);
|
||||
|
|
|
@ -74,6 +74,10 @@ services:
|
|||
source: ../../../pnpm-workspace.yaml
|
||||
target: /misskey/pnpm-workspace.yaml
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../../patches
|
||||
target: /misskey/patches
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ./certificates/rootCA.crt
|
||||
target: /usr/local/share/ca-certificates/rootCA.crt
|
||||
|
|
|
@ -74,6 +74,10 @@ services:
|
|||
source: ../../../pnpm-workspace.yaml
|
||||
target: /misskey/pnpm-workspace.yaml
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../../patches
|
||||
target: /misskey/patches
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ./certificates/rootCA.crt
|
||||
target: /usr/local/share/ca-certificates/rootCA.crt
|
||||
|
@ -118,6 +122,10 @@ services:
|
|||
source: ../../../pnpm-workspace.yaml
|
||||
target: /misskey/pnpm-workspace.yaml
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../../patches
|
||||
target: /misskey/patches
|
||||
read_only: true
|
||||
working_dir: /misskey
|
||||
command: >
|
||||
bash -c "
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,620 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
import { jest } from '@jest/globals';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
|
||||
import type { IActivity } from '@/core/activitypub/type.js';
|
||||
import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js';
|
||||
import { ApLoggerService } from '@/core/activitypub/ApLoggerService.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { FollowingsRepository, UsersRepository } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
||||
|
||||
describe('ApDeliverManagerService', () => {
|
||||
let service: ApDeliverManagerService;
|
||||
let followingsRepository: jest.Mocked<FollowingsRepository>;
|
||||
let queueService: jest.Mocked<QueueService>;
|
||||
let apLoggerService: jest.Mocked<ApLoggerService>;
|
||||
|
||||
const mockLocalUser: MiLocalUser = {
|
||||
id: 'local-user-id',
|
||||
host: null,
|
||||
} as MiLocalUser;
|
||||
|
||||
const mockRemoteUser1: MiRemoteUser & { inbox: string; sharedInbox: string; } = {
|
||||
id: 'remote-user-1',
|
||||
host: 'remote.example.com',
|
||||
inbox: 'https://remote.example.com/inbox',
|
||||
sharedInbox: 'https://remote.example.com/shared-inbox',
|
||||
} as MiRemoteUser & { inbox: string; sharedInbox: string; };
|
||||
|
||||
const mockRemoteUser2: MiRemoteUser & { inbox: string; } = {
|
||||
id: 'remote-user-2',
|
||||
host: 'another.example.com',
|
||||
inbox: 'https://another.example.com/inbox',
|
||||
sharedInbox: null,
|
||||
} as MiRemoteUser & { inbox: string; };
|
||||
|
||||
const mockActivity: IActivity = {
|
||||
type: 'Create',
|
||||
id: 'activity-id',
|
||||
actor: 'https://local.example.com/users/local-user-id',
|
||||
object: {
|
||||
type: 'Note',
|
||||
id: 'note-id',
|
||||
content: 'Hello, world!',
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ApDeliverManagerService,
|
||||
{
|
||||
provide: DI.followingsRepository,
|
||||
useValue: {
|
||||
find: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: QueueService,
|
||||
useValue: {
|
||||
deliverMany: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ApLoggerService,
|
||||
useValue: {
|
||||
logger: {
|
||||
createSubLogger: jest.fn().mockReturnValue({
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ApDeliverManagerService>(ApDeliverManagerService);
|
||||
followingsRepository = module.get(DI.followingsRepository);
|
||||
queueService = module.get(QueueService);
|
||||
apLoggerService = module.get(ApLoggerService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('deliverToFollowers', () => {
|
||||
it('should deliver activity to all followers', async () => {
|
||||
const mockFollowings = [
|
||||
{
|
||||
followerSharedInbox: 'https://remote1.example.com/shared-inbox',
|
||||
followerInbox: 'https://remote1.example.com/inbox',
|
||||
},
|
||||
{
|
||||
followerSharedInbox: 'https://remote2.example.com/shared-inbox',
|
||||
followerInbox: 'https://remote2.example.com/inbox',
|
||||
},
|
||||
{
|
||||
followerSharedInbox: null,
|
||||
followerInbox: 'https://remote3.example.com/inbox',
|
||||
},
|
||||
];
|
||||
|
||||
followingsRepository.find.mockResolvedValue(mockFollowings as any);
|
||||
|
||||
await service.deliverToFollowers(mockLocalUser, mockActivity);
|
||||
|
||||
expect(followingsRepository.find).toHaveBeenCalledWith({
|
||||
where: {
|
||||
followeeId: mockLocalUser.id,
|
||||
followerHost: expect.anything(), // Not(IsNull())
|
||||
isFollowerSuspended: false,
|
||||
},
|
||||
select: {
|
||||
followerSharedInbox: true,
|
||||
followerInbox: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(queueService.deliverMany).toHaveBeenCalledWith(
|
||||
{ id: mockLocalUser.id },
|
||||
mockActivity,
|
||||
expect.any(Map),
|
||||
);
|
||||
|
||||
// 呼び出されたinboxesを確認
|
||||
const [, , inboxes] = queueService.deliverMany.mock.calls[0];
|
||||
expect(inboxes.size).toBe(3);
|
||||
expect(inboxes.has('https://remote1.example.com/shared-inbox')).toBe(true);
|
||||
expect(inboxes.has('https://remote2.example.com/shared-inbox')).toBe(true);
|
||||
expect(inboxes.has('https://remote3.example.com/inbox')).toBe(true);
|
||||
});
|
||||
|
||||
it('should exclude suspended followers by default', async () => {
|
||||
followingsRepository.find.mockResolvedValue([]);
|
||||
|
||||
await service.deliverToFollowers(mockLocalUser, mockActivity);
|
||||
|
||||
expect(followingsRepository.find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
isFollowerSuspended: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deliverToUser', () => {
|
||||
it('should deliver activity to specific remote user', async () => {
|
||||
await service.deliverToUser(mockLocalUser, mockActivity, mockRemoteUser1);
|
||||
|
||||
expect(queueService.deliverMany).toHaveBeenCalledWith(
|
||||
{ id: mockLocalUser.id },
|
||||
mockActivity,
|
||||
expect.any(Map),
|
||||
);
|
||||
|
||||
const [, , inboxes] = queueService.deliverMany.mock.calls[0];
|
||||
expect(inboxes.size).toBe(1);
|
||||
expect(inboxes.has(mockRemoteUser1.inbox)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle user without shared inbox', async () => {
|
||||
await service.deliverToUser(mockLocalUser, mockActivity, mockRemoteUser2);
|
||||
|
||||
const [, , inboxes] = queueService.deliverMany.mock.calls[0];
|
||||
expect(inboxes.size).toBe(1);
|
||||
expect(inboxes.has(mockRemoteUser2.inbox)).toBe(true);
|
||||
});
|
||||
|
||||
it('should skip user with null inbox', async () => {
|
||||
const userWithoutInbox = {
|
||||
...mockRemoteUser1,
|
||||
inbox: null,
|
||||
} as MiRemoteUser;
|
||||
|
||||
await service.deliverToUser(mockLocalUser, mockActivity, userWithoutInbox);
|
||||
|
||||
const [, , inboxes] = queueService.deliverMany.mock.calls[0];
|
||||
expect(inboxes.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deliverToUsers', () => {
|
||||
it('should deliver activity to multiple remote users', async () => {
|
||||
await service.deliverToUsers(mockLocalUser, mockActivity, [mockRemoteUser1, mockRemoteUser2]);
|
||||
|
||||
const [, , inboxes] = queueService.deliverMany.mock.calls[0];
|
||||
expect(inboxes.size).toBe(2);
|
||||
expect(inboxes.has(mockRemoteUser1.inbox)).toBe(true);
|
||||
expect(inboxes.has(mockRemoteUser2.inbox)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDeliverManager', () => {
|
||||
it('should create a DeliverManager instance', () => {
|
||||
const manager = service.createDeliverManager(mockLocalUser, mockActivity);
|
||||
|
||||
expect(manager).toBeDefined();
|
||||
expect(typeof manager.addFollowersRecipe).toBe('function');
|
||||
expect(typeof manager.addDirectRecipe).toBe('function');
|
||||
expect(typeof manager.addAllKnowingSharedInboxRecipe).toBe('function');
|
||||
expect(typeof manager.execute).toBe('function');
|
||||
});
|
||||
|
||||
it('should allow manual recipe management', async () => {
|
||||
const manager = service.createDeliverManager(mockLocalUser, mockActivity);
|
||||
|
||||
followingsRepository.find.mockResolvedValue([
|
||||
{
|
||||
followerSharedInbox: null,
|
||||
followerInbox: 'https://follower.example.com/inbox',
|
||||
},
|
||||
] as any);
|
||||
|
||||
// フォロワー配信のレシピを追加
|
||||
manager.addFollowersRecipe();
|
||||
// ダイレクト配信のレシピを追加
|
||||
manager.addDirectRecipe(mockRemoteUser1);
|
||||
|
||||
await manager.execute();
|
||||
|
||||
const [, , inboxes] = queueService.deliverMany.mock.calls[0];
|
||||
expect(inboxes.size).toBe(2);
|
||||
expect(inboxes.has('https://follower.example.com/inbox')).toBe(true);
|
||||
expect(inboxes.has(mockRemoteUser1.inbox)).toBe(true);
|
||||
});
|
||||
|
||||
it('should support ignoreSuspend option', async () => {
|
||||
const manager = service.createDeliverManager(mockLocalUser, mockActivity);
|
||||
|
||||
followingsRepository.find.mockResolvedValue([]);
|
||||
|
||||
manager.addFollowersRecipe();
|
||||
await manager.execute({ ignoreSuspend: true });
|
||||
|
||||
expect(followingsRepository.find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
isFollowerSuspended: undefined, // ignoreSuspend: true なので undefined
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('followers and directs mixture: 先にfollowersでsharedInboxが追加されていた場合、directsでユーザーがそのsharedInboxを持っていたらinboxを追加しない', async () => {
|
||||
const manager = service.createDeliverManager(mockLocalUser, mockActivity);
|
||||
followingsRepository.find.mockResolvedValue([
|
||||
{
|
||||
followerSharedInbox: mockRemoteUser1.sharedInbox,
|
||||
followerInbox: mockRemoteUser2.inbox,
|
||||
},
|
||||
] as any);
|
||||
manager.addFollowersRecipe();
|
||||
manager.addDirectRecipe(mockRemoteUser1);
|
||||
await manager.execute();
|
||||
const [, , inboxes] = queueService.deliverMany.mock.calls[0];
|
||||
expect(inboxes.size).toBe(1);
|
||||
expect(inboxes.has(mockRemoteUser1.sharedInbox)).toBe(true);
|
||||
expect(inboxes.has(mockRemoteUser1.inbox)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should throw error for non-local actor', () => {
|
||||
const remoteActor = { id: 'remote-id', host: 'remote.example.com' } as any;
|
||||
|
||||
expect(() => {
|
||||
service.createDeliverManager(remoteActor, mockActivity);
|
||||
}).toThrow('actor.host must be null');
|
||||
});
|
||||
|
||||
it('should throw error when follower has null inbox', async () => {
|
||||
const mockFollowings = [
|
||||
{
|
||||
followerSharedInbox: null,
|
||||
followerInbox: null, // null inbox
|
||||
},
|
||||
];
|
||||
|
||||
followingsRepository.find.mockResolvedValue(mockFollowings as any);
|
||||
|
||||
await expect(service.deliverToFollowers(mockLocalUser, mockActivity)).rejects.toThrow('inbox is null');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AllKnowingSharedInbox recipe', () => {
|
||||
it('should collect all shared inboxes when using AllKnowingSharedInbox', async () => {
|
||||
const mockQueryBuilder = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
orWhere: jest.fn().mockReturnThis(),
|
||||
distinct: jest.fn().mockReturnThis(),
|
||||
getRawMany: jest.fn<any>().mockResolvedValue([
|
||||
{ f_followerSharedInbox: 'https://shared1.example.com/inbox' },
|
||||
{ f_followeeSharedInbox: 'https://shared2.example.com/inbox' },
|
||||
]),
|
||||
};
|
||||
|
||||
followingsRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const manager = service.createDeliverManager(mockLocalUser, mockActivity);
|
||||
manager.addAllKnowingSharedInboxRecipe();
|
||||
|
||||
await manager.execute();
|
||||
|
||||
expect(followingsRepository.createQueryBuilder).toHaveBeenCalledWith('f');
|
||||
expect(mockQueryBuilder.select).toHaveBeenCalledWith([
|
||||
'f.followerSharedInbox',
|
||||
'f.followeeSharedInbox',
|
||||
]);
|
||||
|
||||
const [, , inboxes] = queueService.deliverMany.mock.calls[0];
|
||||
expect(inboxes.size).toBe(2);
|
||||
expect(inboxes.has('https://shared1.example.com/inbox')).toBe(true);
|
||||
expect(inboxes.has('https://shared2.example.com/inbox')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ApDeliverManagerService (SQL)', () => {
|
||||
// followerにデータを挿入して、SQLの動作を確認します
|
||||
let app: TestingModule;
|
||||
let service: ApDeliverManagerService;
|
||||
let followingsRepository: FollowingsRepository;
|
||||
let usersRepository: UsersRepository;
|
||||
let queueService: jest.Mocked<QueueService>;
|
||||
|
||||
async function createUser(data: Partial<{ id: string; username: string; host: string | null; inbox: string | null; sharedInbox: string | null; isSuspended: boolean }> = {}): Promise<any> {
|
||||
const user = {
|
||||
id: secureRndstr(16),
|
||||
username: secureRndstr(16),
|
||||
usernameLower: (data.username ?? secureRndstr(16)).toLowerCase(),
|
||||
host: data.host ?? null,
|
||||
inbox: data.inbox ?? null,
|
||||
sharedInbox: data.sharedInbox ?? null,
|
||||
isSuspended: data.isSuspended ?? false,
|
||||
...data,
|
||||
};
|
||||
|
||||
await usersRepository.insert(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
async function createFollowing(follower: any, followee: any, data: Partial<{
|
||||
followerInbox: string | null;
|
||||
followerSharedInbox: string | null;
|
||||
followeeInbox: string | null;
|
||||
followeeSharedInbox: string | null;
|
||||
isFollowerSuspended: boolean;
|
||||
}> = {}): Promise<any> {
|
||||
const following = {
|
||||
id: secureRndstr(16),
|
||||
followerId: follower.id,
|
||||
followeeId: followee.id,
|
||||
followerHost: follower.host,
|
||||
followeeHost: followee.host,
|
||||
followerInbox: data.followerInbox ?? follower.inbox,
|
||||
followerSharedInbox: data.followerSharedInbox ?? follower.sharedInbox,
|
||||
followeeInbox: data.followeeInbox ?? null,
|
||||
followeeSharedInbox: data.followeeSharedInbox ?? null,
|
||||
isFollowerSuspended: data.isFollowerSuspended ?? false,
|
||||
isFollowerHibernated: false,
|
||||
withReplies: false,
|
||||
notify: null,
|
||||
};
|
||||
|
||||
await followingsRepository.insert(following);
|
||||
return following;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
const { Test } = await import('@nestjs/testing');
|
||||
const { GlobalModule } = await import('@/GlobalModule.js');
|
||||
const { DI } = await import('@/di-symbols.js');
|
||||
|
||||
app = await Test.createTestingModule({
|
||||
imports: [GlobalModule],
|
||||
providers: [
|
||||
ApDeliverManagerService,
|
||||
{
|
||||
provide: QueueService,
|
||||
useFactory: () => ({
|
||||
deliverMany: jest.fn(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
provide: ApLoggerService,
|
||||
useValue: {
|
||||
logger: {
|
||||
createSubLogger: jest.fn().mockReturnValue({
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
app.enableShutdownHooks();
|
||||
|
||||
service = app.get<ApDeliverManagerService>(ApDeliverManagerService);
|
||||
followingsRepository = app.get<FollowingsRepository>(DI.followingsRepository);
|
||||
usersRepository = app.get<UsersRepository>(DI.usersRepository);
|
||||
queueService = app.get<QueueService>(QueueService) as jest.Mocked<QueueService>;
|
||||
|
||||
// Reset mocks
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe('deliverToFollowers with real data', () => {
|
||||
it('should deliver to followers excluding suspended ones', async () => {
|
||||
// Create local user (followee)
|
||||
const localUser = await createUser({
|
||||
host: null,
|
||||
username: 'localuser',
|
||||
});
|
||||
|
||||
// Create remote followers
|
||||
const activeFollower = await createUser({
|
||||
host: 'active.example.com',
|
||||
username: 'activefollower',
|
||||
inbox: 'https://active.example.com/inbox',
|
||||
sharedInbox: 'https://active.example.com/shared-inbox',
|
||||
isSuspended: false,
|
||||
});
|
||||
|
||||
const suspendedFollower = await createUser({
|
||||
host: 'suspended.example.com',
|
||||
username: 'suspendedfollower',
|
||||
inbox: 'https://suspended.example.com/inbox',
|
||||
sharedInbox: 'https://suspended.example.com/shared-inbox',
|
||||
isSuspended: true,
|
||||
});
|
||||
|
||||
const followerWithoutSharedInbox = await createUser({
|
||||
host: 'noshared.example.com',
|
||||
username: 'noshared',
|
||||
inbox: 'https://noshared.example.com/inbox',
|
||||
sharedInbox: null,
|
||||
isSuspended: false,
|
||||
});
|
||||
|
||||
// Create following relationships
|
||||
await createFollowing(activeFollower, localUser, {
|
||||
followerInbox: activeFollower.inbox,
|
||||
followerSharedInbox: activeFollower.sharedInbox,
|
||||
isFollowerSuspended: false,
|
||||
});
|
||||
|
||||
await createFollowing(suspendedFollower, localUser, {
|
||||
followerInbox: suspendedFollower.inbox,
|
||||
followerSharedInbox: suspendedFollower.sharedInbox,
|
||||
isFollowerSuspended: true, // 凍結されたフォロワー
|
||||
});
|
||||
|
||||
await createFollowing(followerWithoutSharedInbox, localUser, {
|
||||
followerInbox: followerWithoutSharedInbox.inbox,
|
||||
followerSharedInbox: null,
|
||||
isFollowerSuspended: false,
|
||||
});
|
||||
|
||||
const mockActivity = {
|
||||
type: 'Create',
|
||||
id: 'test-activity',
|
||||
actor: `https://local.example.com/users/${localUser.id}`,
|
||||
object: { type: 'Note', content: 'Hello' },
|
||||
} as any;
|
||||
|
||||
// Execute delivery
|
||||
await service.deliverToFollowers(localUser, mockActivity);
|
||||
|
||||
// Verify delivery was queued
|
||||
expect(queueService.deliverMany).toHaveBeenCalledTimes(1);
|
||||
const [actor, activity, inboxes] = queueService.deliverMany.mock.calls[0];
|
||||
|
||||
expect(actor.id).toBe(localUser.id);
|
||||
expect(activity).toBe(mockActivity);
|
||||
|
||||
// Check inboxes - should include active followers but exclude suspended ones
|
||||
expect(inboxes.size).toBe(2);
|
||||
expect(inboxes.has('https://active.example.com/shared-inbox')).toBe(true);
|
||||
expect(inboxes.has('https://noshared.example.com/inbox')).toBe(true);
|
||||
expect(inboxes.has('https://suspended.example.com/shared-inbox')).toBe(false);
|
||||
});
|
||||
|
||||
it('should include suspended followers when ignoreSuspend is true', async () => {
|
||||
const localUser = await createUser({ host: null });
|
||||
const suspendedFollower = await createUser({
|
||||
host: 'suspended.example.com',
|
||||
inbox: 'https://suspended.example.com/inbox',
|
||||
isSuspended: true,
|
||||
});
|
||||
|
||||
await createFollowing(suspendedFollower, localUser, {
|
||||
isFollowerSuspended: true,
|
||||
});
|
||||
|
||||
const manager = service.createDeliverManager(localUser, { type: 'Test' } as any);
|
||||
manager.addFollowersRecipe();
|
||||
|
||||
// Execute with ignoreSuspend: true
|
||||
await manager.execute({ ignoreSuspend: true });
|
||||
|
||||
const [, , inboxes] = queueService.deliverMany.mock.calls[0];
|
||||
expect(inboxes.size).toBe(1);
|
||||
expect(inboxes.has('https://suspended.example.com/inbox')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle mixed follower types correctly', async () => {
|
||||
const localUser = await createUser({ host: null });
|
||||
|
||||
// フォロワー1: shared inbox あり
|
||||
const follower1 = await createUser({
|
||||
host: 'server1.example.com',
|
||||
inbox: 'https://server1.example.com/users/user1/inbox',
|
||||
sharedInbox: 'https://server1.example.com/inbox',
|
||||
});
|
||||
|
||||
// フォロワー2: 同じサーバーの別ユーザー(shared inbox は同じ)
|
||||
const follower2 = await createUser({
|
||||
host: 'server1.example.com',
|
||||
inbox: 'https://server1.example.com/users/user2/inbox',
|
||||
sharedInbox: 'https://server1.example.com/inbox',
|
||||
});
|
||||
|
||||
// フォロワー3: 別サーバー、shared inbox なし
|
||||
const follower3 = await createUser({
|
||||
host: 'server2.example.com',
|
||||
inbox: 'https://server2.example.com/users/user3/inbox',
|
||||
sharedInbox: null,
|
||||
});
|
||||
|
||||
await createFollowing(follower1, localUser);
|
||||
await createFollowing(follower2, localUser);
|
||||
await createFollowing(follower3, localUser);
|
||||
|
||||
await service.deliverToFollowers(localUser, { type: 'Test' } as any);
|
||||
|
||||
const [, , inboxes] = queueService.deliverMany.mock.calls[0];
|
||||
|
||||
// shared inbox は重複排除されるので、2つのinboxのみ
|
||||
expect(inboxes.size).toBe(2);
|
||||
expect(inboxes.has('https://server1.example.com/inbox')).toBe(true); // shared inbox
|
||||
expect(inboxes.has('https://server2.example.com/users/user3/inbox')).toBe(true); // individual inbox
|
||||
|
||||
// individual inbox は shared inbox があるので使用されない
|
||||
expect(inboxes.has('https://server1.example.com/users/user1/inbox')).toBe(false);
|
||||
expect(inboxes.has('https://server1.example.com/users/user2/inbox')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AllKnowingSharedInbox with real data', () => {
|
||||
it('should collect all unique shared inboxes from database', async () => {
|
||||
// Create users with various inbox configurations
|
||||
const user1 = await createUser({ host: null });
|
||||
const user2 = await createUser({ host: null });
|
||||
|
||||
const remoteUser1 = await createUser({
|
||||
host: 'server1.example.com',
|
||||
sharedInbox: 'https://server1.example.com/shared',
|
||||
});
|
||||
|
||||
const remoteUser2 = await createUser({
|
||||
host: 'server2.example.com',
|
||||
sharedInbox: 'https://server2.example.com/shared',
|
||||
});
|
||||
|
||||
const remoteUser3 = await createUser({
|
||||
host: 'server1.example.com', // 同じサーバー
|
||||
sharedInbox: 'https://server1.example.com/shared', // 同じ shared inbox
|
||||
});
|
||||
|
||||
// Create following relationships
|
||||
await createFollowing(remoteUser1, user1, {
|
||||
followerSharedInbox: 'https://server1.example.com/shared',
|
||||
});
|
||||
|
||||
await createFollowing(user1, remoteUser2, {
|
||||
followerSharedInbox: null,
|
||||
followeeSharedInbox: 'https://server2.example.com/shared',
|
||||
});
|
||||
|
||||
await createFollowing(remoteUser3, user2, {
|
||||
followerSharedInbox: 'https://server1.example.com/shared', // 重複
|
||||
});
|
||||
|
||||
const manager = service.createDeliverManager(user1, { type: 'Test' } as any);
|
||||
manager.addAllKnowingSharedInboxRecipe();
|
||||
|
||||
await manager.execute();
|
||||
|
||||
const [, , inboxes] = queueService.deliverMany.mock.calls[0];
|
||||
|
||||
// 重複は除去されて2つのユニークな shared inbox
|
||||
expect(inboxes.size).toBe(2);
|
||||
expect(inboxes.has('https://server1.example.com/shared')).toBe(true);
|
||||
expect(inboxes.has('https://server2.example.com/shared')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,416 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
import { jest } from '@jest/globals';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { GlobalModule } from '@/GlobalModule.js';
|
||||
import { UserSuspendService } from '@/core/UserSuspendService.js';
|
||||
import {
|
||||
MiFollowing,
|
||||
MiUser,
|
||||
FollowingsRepository,
|
||||
FollowRequestsRepository,
|
||||
UsersRepository,
|
||||
} from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
||||
import { randomString } from '../utils.js';
|
||||
|
||||
function genHost() {
|
||||
return randomString() + '.example.com';
|
||||
}
|
||||
|
||||
describe('UserSuspendService', () => {
|
||||
let app: TestingModule;
|
||||
let userSuspendService: UserSuspendService;
|
||||
let usersRepository: UsersRepository;
|
||||
let followingsRepository: FollowingsRepository;
|
||||
let followRequestsRepository: FollowRequestsRepository;
|
||||
let userEntityService: jest.Mocked<UserEntityService>;
|
||||
let queueService: jest.Mocked<QueueService>;
|
||||
let globalEventService: jest.Mocked<GlobalEventService>;
|
||||
let apRendererService: jest.Mocked<ApRendererService>;
|
||||
let moderationLogService: jest.Mocked<ModerationLogService>;
|
||||
|
||||
async function createUser(data: Partial<MiUser> = {}): Promise<MiUser> {
|
||||
const user = {
|
||||
id: secureRndstr(16),
|
||||
username: secureRndstr(16),
|
||||
usernameLower: secureRndstr(16).toLowerCase(),
|
||||
host: null,
|
||||
isSuspended: false,
|
||||
...data,
|
||||
} as MiUser;
|
||||
|
||||
await usersRepository.insert(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
async function createFollowing(follower: MiUser, followee: MiUser, data: Partial<MiFollowing> = {}): Promise<MiFollowing> {
|
||||
const following = {
|
||||
id: secureRndstr(16),
|
||||
followerId: follower.id,
|
||||
followeeId: followee.id,
|
||||
isFollowerSuspended: false,
|
||||
isFollowerHibernated: false,
|
||||
withReplies: false,
|
||||
notify: null,
|
||||
followerHost: follower.host,
|
||||
followerInbox: null,
|
||||
followerSharedInbox: null,
|
||||
followeeHost: followee.host,
|
||||
followeeInbox: null,
|
||||
followeeSharedInbox: null,
|
||||
...data,
|
||||
} as MiFollowing;
|
||||
|
||||
await followingsRepository.insert(following);
|
||||
return following;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
app = await Test.createTestingModule({
|
||||
imports: [GlobalModule],
|
||||
providers: [
|
||||
UserSuspendService,
|
||||
{
|
||||
provide: UserEntityService,
|
||||
useFactory: () => ({
|
||||
isLocalUser: jest.fn(),
|
||||
genLocalUserUri: jest.fn(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
provide: QueueService,
|
||||
useFactory: () => ({
|
||||
deliver: jest.fn(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
provide: GlobalEventService,
|
||||
useFactory: () => ({
|
||||
publishInternalEvent: jest.fn(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
provide: ApRendererService,
|
||||
useFactory: () => ({
|
||||
addContext: jest.fn(),
|
||||
renderDelete: jest.fn(),
|
||||
renderUndo: jest.fn(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
provide: ModerationLogService,
|
||||
useFactory: () => ({
|
||||
log: jest.fn(),
|
||||
}),
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
app.enableShutdownHooks();
|
||||
|
||||
userSuspendService = app.get<UserSuspendService>(UserSuspendService);
|
||||
usersRepository = app.get<UsersRepository>(DI.usersRepository);
|
||||
followingsRepository = app.get<FollowingsRepository>(DI.followingsRepository);
|
||||
followRequestsRepository = app.get<FollowRequestsRepository>(DI.followRequestsRepository);
|
||||
userEntityService = app.get<UserEntityService>(UserEntityService) as jest.Mocked<UserEntityService>;
|
||||
queueService = app.get<QueueService>(QueueService) as jest.Mocked<QueueService>;
|
||||
globalEventService = app.get<GlobalEventService>(GlobalEventService) as jest.Mocked<GlobalEventService>;
|
||||
apRendererService = app.get<ApRendererService>(ApRendererService) as jest.Mocked<ApRendererService>;
|
||||
moderationLogService = app.get<ModerationLogService>(ModerationLogService) as jest.Mocked<ModerationLogService>;
|
||||
|
||||
// Reset mocks
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe('suspend', () => {
|
||||
test('should suspend user and update database', async () => {
|
||||
const user = await createUser();
|
||||
const moderator = await createUser();
|
||||
|
||||
await userSuspendService.suspend(user, moderator);
|
||||
|
||||
// ユーザーが凍結されているかチェック
|
||||
const suspendedUser = await usersRepository.findOneBy({ id: user.id });
|
||||
expect(suspendedUser?.isSuspended).toBe(true);
|
||||
|
||||
// モデレーションログが記録されているかチェック
|
||||
expect(moderationLogService.log).toHaveBeenCalledWith(moderator, 'suspend', {
|
||||
userId: user.id,
|
||||
userUsername: user.username,
|
||||
userHost: user.host,
|
||||
});
|
||||
});
|
||||
|
||||
test('should mark follower relationships as suspended', async () => {
|
||||
const user = await createUser();
|
||||
const followee1 = await createUser();
|
||||
const followee2 = await createUser();
|
||||
const moderator = await createUser();
|
||||
|
||||
// ユーザーがフォローしている関係を作成
|
||||
await createFollowing(user, followee1);
|
||||
await createFollowing(user, followee2);
|
||||
|
||||
await userSuspendService.suspend(user, moderator);
|
||||
await setTimeout(250);
|
||||
|
||||
// フォロー関係が論理削除されているかチェック
|
||||
const followings = await followingsRepository.find({
|
||||
where: { followerId: user.id },
|
||||
});
|
||||
|
||||
expect(followings).toHaveLength(2);
|
||||
followings.forEach(following => {
|
||||
expect(following.isFollowerSuspended).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('should publish internal event for suspension', async () => {
|
||||
const user = await createUser();
|
||||
const moderator = await createUser();
|
||||
|
||||
await userSuspendService.suspend(user, moderator);
|
||||
await setTimeout(250);
|
||||
|
||||
// 内部イベントが発行されているかチェック(非同期処理のため少し待つ)
|
||||
await setTimeout(100);
|
||||
|
||||
expect(globalEventService.publishInternalEvent).toHaveBeenCalledWith(
|
||||
'userChangeSuspendedState',
|
||||
{ id: user.id, isSuspended: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unsuspend', () => {
|
||||
test('should unsuspend user and update database', async () => {
|
||||
const user = await createUser({ isSuspended: true });
|
||||
const moderator = await createUser();
|
||||
|
||||
await userSuspendService.unsuspend(user, moderator);
|
||||
await setTimeout(250);
|
||||
|
||||
// ユーザーの凍結が解除されているかチェック
|
||||
const unsuspendedUser = await usersRepository.findOneBy({ id: user.id });
|
||||
expect(unsuspendedUser?.isSuspended).toBe(false);
|
||||
|
||||
// モデレーションログが記録されているかチェック
|
||||
expect(moderationLogService.log).toHaveBeenCalledWith(moderator, 'unsuspend', {
|
||||
userId: user.id,
|
||||
userUsername: user.username,
|
||||
userHost: user.host,
|
||||
});
|
||||
});
|
||||
|
||||
test('should restore follower relationships', async () => {
|
||||
const user = await createUser({ isSuspended: true });
|
||||
const followee1 = await createUser();
|
||||
const followee2 = await createUser();
|
||||
const moderator = await createUser();
|
||||
|
||||
// 凍結状態のフォロー関係を作成
|
||||
await createFollowing(user, followee1, { isFollowerSuspended: true });
|
||||
await createFollowing(user, followee2, { isFollowerSuspended: true });
|
||||
|
||||
await userSuspendService.unsuspend(user, moderator);
|
||||
await setTimeout(250);
|
||||
|
||||
// フォロー関係が復元されているかチェック
|
||||
const followings = await followingsRepository.find({
|
||||
where: { followerId: user.id },
|
||||
});
|
||||
|
||||
expect(followings).toHaveLength(2);
|
||||
followings.forEach(following => {
|
||||
expect(following.isFollowerSuspended).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test('should publish internal event for unsuspension', async () => {
|
||||
const user = await createUser({ isSuspended: true });
|
||||
const moderator = await createUser();
|
||||
|
||||
await userSuspendService.unsuspend(user, moderator);
|
||||
await setTimeout(250);
|
||||
|
||||
// 内部イベントが発行されているかチェック(非同期処理のため少し待つ)
|
||||
await setTimeout(100);
|
||||
|
||||
expect(globalEventService.publishInternalEvent).toHaveBeenCalledWith(
|
||||
'userChangeSuspendedState',
|
||||
{ id: user.id, isSuspended: false },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration test: suspend and unsuspend cycle', () => {
|
||||
test('should preserve follow relationships through suspend/unsuspend cycle', async () => {
|
||||
const user = await createUser();
|
||||
const followee1 = await createUser();
|
||||
const followee2 = await createUser();
|
||||
const moderator = await createUser();
|
||||
|
||||
// 初期のフォロー関係を作成
|
||||
await createFollowing(user, followee1);
|
||||
await createFollowing(user, followee2);
|
||||
|
||||
// 初期状態の確認
|
||||
let followings = await followingsRepository.find({
|
||||
where: { followerId: user.id },
|
||||
});
|
||||
expect(followings).toHaveLength(2);
|
||||
followings.forEach(following => {
|
||||
expect(following.isFollowerSuspended).toBe(false);
|
||||
});
|
||||
|
||||
// 凍結
|
||||
await userSuspendService.suspend(user, moderator);
|
||||
await setTimeout(250);
|
||||
|
||||
// 凍結後の状態確認
|
||||
followings = await followingsRepository.find({
|
||||
where: { followerId: user.id },
|
||||
});
|
||||
expect(followings).toHaveLength(2);
|
||||
followings.forEach(following => {
|
||||
expect(following.isFollowerSuspended).toBe(true);
|
||||
});
|
||||
|
||||
// 凍結解除
|
||||
const suspendedUser = await usersRepository.findOneByOrFail({ id: user.id });
|
||||
await userSuspendService.unsuspend(suspendedUser, moderator);
|
||||
await setTimeout(250);
|
||||
|
||||
// 凍結解除後の状態確認
|
||||
followings = await followingsRepository.find({
|
||||
where: { followerId: user.id },
|
||||
});
|
||||
expect(followings).toHaveLength(2);
|
||||
followings.forEach(following => {
|
||||
expect(following.isFollowerSuspended).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ActivityPub delivery', () => {
|
||||
test('should deliver Delete activity on suspend of local user', async () => {
|
||||
const localUser = await createUser({ host: null });
|
||||
const moderator = await createUser();
|
||||
|
||||
userEntityService.isLocalUser.mockReturnValue(true);
|
||||
userEntityService.genLocalUserUri.mockReturnValue(`https://example.com/users/${localUser.id}`);
|
||||
apRendererService.renderDelete.mockReturnValue({ type: 'Delete' } as any);
|
||||
apRendererService.addContext.mockReturnValue({ '@context': '...', type: 'Delete' } as any);
|
||||
|
||||
await userSuspendService.suspend(localUser, moderator);
|
||||
await setTimeout(250);
|
||||
|
||||
// ActivityPub配信が呼ばれているかチェック
|
||||
expect(userEntityService.isLocalUser).toHaveBeenCalledWith(localUser);
|
||||
expect(apRendererService.renderDelete).toHaveBeenCalled();
|
||||
expect(apRendererService.addContext).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should deliver Undo Delete activity on unsuspend of local user', async () => {
|
||||
const localUser = await createUser({ host: null, isSuspended: true });
|
||||
const moderator = await createUser();
|
||||
|
||||
userEntityService.isLocalUser.mockReturnValue(true);
|
||||
userEntityService.genLocalUserUri.mockReturnValue(`https://example.com/users/${localUser.id}`);
|
||||
apRendererService.renderDelete.mockReturnValue({ type: 'Delete' } as any);
|
||||
apRendererService.renderUndo.mockReturnValue({ type: 'Undo' } as any);
|
||||
apRendererService.addContext.mockReturnValue({ '@context': '...', type: 'Undo' } as any);
|
||||
|
||||
await userSuspendService.unsuspend(localUser, moderator);
|
||||
await setTimeout(250);
|
||||
|
||||
// ActivityPub配信が呼ばれているかチェック
|
||||
expect(userEntityService.isLocalUser).toHaveBeenCalledWith(localUser);
|
||||
expect(apRendererService.renderDelete).toHaveBeenCalled();
|
||||
expect(apRendererService.renderUndo).toHaveBeenCalled();
|
||||
expect(apRendererService.addContext).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should not deliver any activity on suspend of remote user', async () => {
|
||||
const remoteUser = await createUser({ host: 'remote.example.com' });
|
||||
const moderator = await createUser();
|
||||
|
||||
userEntityService.isLocalUser.mockReturnValue(false);
|
||||
|
||||
await userSuspendService.suspend(remoteUser, moderator);
|
||||
await setTimeout(250);
|
||||
|
||||
// ActivityPub配信が呼ばれていないことをチェック
|
||||
expect(userEntityService.isLocalUser).toHaveBeenCalledWith(remoteUser);
|
||||
expect(apRendererService.renderDelete).not.toHaveBeenCalled();
|
||||
expect(queueService.deliver).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote user suspension', () => {
|
||||
test('should suspend remote user without AP delivery', async () => {
|
||||
const remoteUser = await createUser({ host: genHost() });
|
||||
const moderator = await createUser();
|
||||
|
||||
await userSuspendService.suspend(remoteUser, moderator);
|
||||
await setTimeout(250);
|
||||
|
||||
// ユーザーが凍結されているかチェック
|
||||
const suspendedUser = await usersRepository.findOneBy({ id: remoteUser.id });
|
||||
expect(suspendedUser?.isSuspended).toBe(true);
|
||||
|
||||
// モデレーションログが記録されているかチェック
|
||||
expect(moderationLogService.log).toHaveBeenCalledWith(moderator, 'suspend', {
|
||||
userId: remoteUser.id,
|
||||
userUsername: remoteUser.username,
|
||||
userHost: remoteUser.host,
|
||||
});
|
||||
|
||||
// ActivityPub配信が呼ばれていないことを確認
|
||||
expect(queueService.deliver).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote user unsuspension', () => {
|
||||
test('should unsuspend remote user without AP delivery', async () => {
|
||||
const remoteUser = await createUser({ host: genHost(), isSuspended: true });
|
||||
const moderator = await createUser();
|
||||
|
||||
await userSuspendService.unsuspend(remoteUser, moderator);
|
||||
|
||||
await setTimeout(250);
|
||||
|
||||
// ユーザーの凍結が解除されているかチェック
|
||||
const unsuspendedUser = await usersRepository.findOneBy({ id: remoteUser.id });
|
||||
expect(unsuspendedUser?.isSuspended).toBe(false);
|
||||
|
||||
// モデレーションログが記録されているかチェック
|
||||
expect(moderationLogService.log).toHaveBeenCalledWith(moderator, 'unsuspend', {
|
||||
userId: remoteUser.id,
|
||||
userUsername: remoteUser.username,
|
||||
userHost: remoteUser.host,
|
||||
});
|
||||
|
||||
// ActivityPub配信が呼ばれていないことを確認
|
||||
expect(queueService.deliver).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -112,6 +112,7 @@ export const ROLE_POLICIES = [
|
|||
'chatAvailability',
|
||||
'uploadableFileTypes',
|
||||
'noteDraftLimit',
|
||||
'watermarkAvailable',
|
||||
] as const;
|
||||
|
||||
export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'scale', 'position', 'fg', 'bg', 'border', 'font', 'blur', 'rainbow', 'sparkle', 'rotate', 'ruby', 'unixtime'];
|
||||
|
|
|
@ -42,6 +42,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
</I18n>
|
||||
</div>
|
||||
<div v-else-if="draft.replyId" class="_nowrap">
|
||||
<i class="ti ti-arrow-back-up"></i> <I18n :src="i18n.ts._drafts.replyTo" tag="span">
|
||||
<template #user>
|
||||
{{ i18n.ts.deletedNote }}
|
||||
</template>
|
||||
</I18n>
|
||||
</div>
|
||||
<div v-if="draft.renote && draft.text != null" class="_nowrap">
|
||||
<i class="ti ti-quote"></i> <I18n :src="i18n.ts._drafts.quoteOf" tag="span">
|
||||
<template #user>
|
||||
|
@ -50,6 +57,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
</I18n>
|
||||
</div>
|
||||
<div v-else-if="draft.renoteId" class="_nowrap">
|
||||
<i class="ti ti-quote"></i> <I18n :src="i18n.ts._drafts.quoteOf" tag="span">
|
||||
<template #user>
|
||||
{{ i18n.ts.deletedNote }}
|
||||
</template>
|
||||
</I18n>
|
||||
</div>
|
||||
<div v-if="draft.channel" class="_nowrap">
|
||||
<i class="ti ti-device-tv"></i> {{ i18n.tsx._drafts.postTo({ channel: draft.channel.name }) }}
|
||||
</div>
|
||||
|
|
|
@ -69,13 +69,13 @@ function getScreenY(event: TouchEvent | MouseEvent | PointerEvent): number {
|
|||
function lockDownScroll() {
|
||||
if (scrollEl == null) return;
|
||||
scrollEl.style.touchAction = 'pan-x pan-down pinch-zoom';
|
||||
scrollEl.style.overscrollBehavior = 'none';
|
||||
scrollEl.style.overscrollBehavior = 'auto none';
|
||||
}
|
||||
|
||||
function unlockDownScroll() {
|
||||
if (scrollEl == null) return;
|
||||
scrollEl.style.touchAction = 'auto';
|
||||
scrollEl.style.overscrollBehavior = 'contain';
|
||||
scrollEl.style.overscrollBehavior = 'auto contain';
|
||||
}
|
||||
|
||||
function moveStartByMouse(event: MouseEvent) {
|
||||
|
|
|
@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
<div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 32px;">
|
||||
<form class="_gaps_m" autocomplete="new-password" @submit.prevent="onSubmit">
|
||||
<MkInput v-if="instance.disableRegistration" v-model="invitationCode" type="text" :spellcheck="false" required>
|
||||
<MkInput v-if="instance.disableRegistration" v-model="invitationCode" type="text" :spellcheck="false" required data-cy-signup-invitation-code>
|
||||
<template #label>{{ i18n.ts.invitationCode }}</template>
|
||||
<template #prefix><i class="ti ti-key"></i></template>
|
||||
</MkInput>
|
||||
|
@ -138,6 +138,7 @@ const shouldDisableSubmitting = computed((): boolean => {
|
|||
instance.enableTurnstile && !turnstileResponse.value ||
|
||||
instance.enableTestcaptcha && !testcaptchaResponse.value ||
|
||||
instance.emailRequiredForSignup && emailState.value !== 'ok' ||
|
||||
instance.disableRegistration && invitationCode.value === '' ||
|
||||
usernameState.value !== 'ok' ||
|
||||
passwordRetypeState.value !== 'match';
|
||||
});
|
||||
|
|
|
@ -104,6 +104,8 @@ export function useUploader(options: {
|
|||
multiple?: boolean;
|
||||
features?: UploaderFeatures;
|
||||
} = {}) {
|
||||
const $i = ensureSignin();
|
||||
|
||||
const events = new EventEmitter<{
|
||||
'itemUploaded': (ctx: { item: UploaderItem; }) => void;
|
||||
}>();
|
||||
|
@ -132,7 +134,7 @@ export function useUploader(options: {
|
|||
uploaded: null,
|
||||
uploadFailed: false,
|
||||
compressionLevel: prefer.s.defaultImageCompressionLevel,
|
||||
watermarkPresetId: uploaderFeatures.value.watermark ? prefer.s.defaultWatermarkPresetId : null,
|
||||
watermarkPresetId: uploaderFeatures.value.watermark && $i.policies.watermarkAvailable ? prefer.s.defaultWatermarkPresetId : null,
|
||||
file: markRaw(file),
|
||||
});
|
||||
const reactiveItem = items.value.at(-1)!;
|
||||
|
@ -264,6 +266,7 @@ export function useUploader(options: {
|
|||
|
||||
if (
|
||||
uploaderFeatures.value.watermark &&
|
||||
$i.policies.watermarkAvailable &&
|
||||
WATERMARK_SUPPORTED_TYPES.includes(item.file.type) &&
|
||||
!item.preprocessing &&
|
||||
!item.uploading &&
|
||||
|
@ -500,7 +503,7 @@ export function useUploader(options: {
|
|||
|
||||
let preprocessedFile: Blob | File = item.file;
|
||||
|
||||
const needsWatermark = item.watermarkPresetId != null && WATERMARK_SUPPORTED_TYPES.includes(preprocessedFile.type);
|
||||
const needsWatermark = item.watermarkPresetId != null && WATERMARK_SUPPORTED_TYPES.includes(preprocessedFile.type) && $i.policies.watermarkAvailable;
|
||||
const preset = prefer.s.watermarkPresets.find(p => p.id === item.watermarkPresetId);
|
||||
if (needsWatermark && preset != null) {
|
||||
const canvas = window.document.createElement('canvas');
|
||||
|
|
|
@ -780,6 +780,26 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</MkRange>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder v-if="matchQuery([i18n.ts._role._options.watermarkAvailable, 'watermarkAvailable'])">
|
||||
<template #label>{{ i18n.ts._role._options.watermarkAvailable }}</template>
|
||||
<template #suffix>
|
||||
<span v-if="role.policies.watermarkAvailable.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
|
||||
<span v-else>{{ role.policies.watermarkAvailable.value ? i18n.ts.yes : i18n.ts.no }}</span>
|
||||
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.watermarkAvailable)"></i></span>
|
||||
</template>
|
||||
<div class="_gaps">
|
||||
<MkSwitch v-model="role.policies.watermarkAvailable.useDefault" :readonly="readonly">
|
||||
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
|
||||
</MkSwitch>
|
||||
<MkSwitch v-model="role.policies.watermarkAvailable.value" :disabled="role.policies.watermarkAvailable.useDefault" :readonly="readonly">
|
||||
<template #label>{{ i18n.ts.enable }}</template>
|
||||
</MkSwitch>
|
||||
<MkRange v-model="role.policies.watermarkAvailable.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
|
||||
<template #label>{{ i18n.ts._role.priority }}</template>
|
||||
</MkRange>
|
||||
</div>
|
||||
</MkFolder>
|
||||
</div>
|
||||
</FormSlot>
|
||||
</div>
|
||||
|
|
|
@ -291,6 +291,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkInput v-model="policies.noteDraftLimit" type="number" :min="0">
|
||||
</MkInput>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder v-if="matchQuery([i18n.ts._role._options.watermarkAvailable, 'watermarkAvailable'])">
|
||||
<template #label>{{ i18n.ts._role._options.watermarkAvailable }}</template>
|
||||
<template #suffix>{{ policies.watermarkAvailable ? i18n.ts.yes : i18n.ts.no }}</template>
|
||||
<MkSwitch v-model="policies.watermarkAvailable">
|
||||
<template #label>{{ i18n.ts.enable }}</template>
|
||||
</MkSwitch>
|
||||
</MkFolder>
|
||||
</div>
|
||||
</MkFolder>
|
||||
<MkButton primary rounded @click="create"><i class="ti ti-plus"></i> {{ i18n.ts._role.new }}</MkButton>
|
||||
|
|
|
@ -87,7 +87,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<div class="_gaps_m">
|
||||
<SearchMarker :keywords="['watermark', 'credit']">
|
||||
<MkFolder>
|
||||
<MkFolder v-if="$i.policies.watermarkAvailable">
|
||||
<template #icon><i class="ti ti-copyright"></i></template>
|
||||
<template #label><SearchLabel>{{ i18n.ts.watermark }}</SearchLabel></template>
|
||||
<template #caption>{{ i18n.ts._watermarkEditor.tip }}</template>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"type": "module",
|
||||
"name": "misskey-js",
|
||||
"version": "2025.7.0-beta.0",
|
||||
"version": "2025.7.0-beta.2",
|
||||
"description": "Misskey SDK for JavaScript",
|
||||
"license": "MIT",
|
||||
"main": "./built/index.js",
|
||||
|
|
|
@ -4401,7 +4401,9 @@ export type components = {
|
|||
* @example xxxxxxxxxx
|
||||
*/
|
||||
renoteId?: string | null;
|
||||
/** @description The reply target note contents if exists. If the reply target has been deleted since the draft was created, this will be null while replyId is not null. */
|
||||
reply?: components['schemas']['Note'] | null;
|
||||
/** @description The renote target note contents if exists. If the renote target has been deleted since the draft was created, this will be null while renoteId is not null. */
|
||||
renote?: components['schemas']['Note'] | null;
|
||||
/** @enum {string} */
|
||||
visibility: 'public' | 'home' | 'followers' | 'specified';
|
||||
|
@ -5225,6 +5227,7 @@ export type components = {
|
|||
/** @enum {string} */
|
||||
chatAvailability: 'available' | 'readonly' | 'unavailable';
|
||||
noteDraftLimit: number;
|
||||
watermarkAvailable: boolean;
|
||||
};
|
||||
ReversiGameLite: {
|
||||
/** Format: id */
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
diff --git a/driver/postgres/PostgresDriver.js b/driver/postgres/PostgresDriver.js
|
||||
index 278f29c1f3deec4939bb4ed90e6edae167f704e0..9a84c3098dda915d6c33e24d925a8fa09af9095e 100644
|
||||
--- a/driver/postgres/PostgresDriver.js
|
||||
+++ b/driver/postgres/PostgresDriver.js
|
||||
@@ -785,10 +785,10 @@ class PostgresDriver {
|
||||
const tableColumnDefault = typeof tableColumn.default === "string"
|
||||
? JSON.parse(tableColumn.default.substring(1, tableColumn.default.length - 1))
|
||||
: tableColumn.default;
|
||||
- return OrmUtils_1.OrmUtils.deepCompare(columnMetadata.default, tableColumnDefault);
|
||||
+ return OrmUtils_1.OrmUtils.deepCompare(columnMetadata.default, tableColumnDefault ?? null);
|
||||
}
|
||||
const columnDefault = this.lowerDefaultValueIfNecessary(this.normalizeDefault(columnMetadata));
|
||||
- return columnDefault === tableColumn.default;
|
||||
+ return columnDefault === tableColumn.default || columnDefault === undefined && tableColumn.default.toLowerCase() === 'null';
|
||||
}
|
||||
/**
|
||||
* Normalizes "isUnique" value of the column.
|
|
@ -9,6 +9,11 @@ overrides:
|
|||
lodash: 4.17.21
|
||||
'@aiscript-dev/aiscript-languageserver': '-'
|
||||
|
||||
patchedDependencies:
|
||||
typeorm:
|
||||
hash: 2677b97a423e157945c154e64183d3ae2eb44dfa9cb0e5ce731a7612f507bb56
|
||||
path: patches/typeorm.patch
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
|
@ -422,7 +427,7 @@ importers:
|
|||
version: 4.2.0
|
||||
typeorm:
|
||||
specifier: 0.3.24
|
||||
version: 0.3.24(ioredis@5.6.1)(pg@8.16.0)(reflect-metadata@0.2.2)
|
||||
version: 0.3.24(patch_hash=2677b97a423e157945c154e64183d3ae2eb44dfa9cb0e5ce731a7612f507bb56)(ioredis@5.6.1)(pg@8.16.0)(reflect-metadata@0.2.2)
|
||||
typescript:
|
||||
specifier: 5.8.3
|
||||
version: 5.8.3
|
||||
|
@ -21835,7 +21840,7 @@ snapshots:
|
|||
|
||||
typedarray@0.0.6: {}
|
||||
|
||||
typeorm@0.3.24(ioredis@5.6.1)(pg@8.16.0)(reflect-metadata@0.2.2):
|
||||
typeorm@0.3.24(patch_hash=2677b97a423e157945c154e64183d3ae2eb44dfa9cb0e5ce731a7612f507bb56)(ioredis@5.6.1)(pg@8.16.0)(reflect-metadata@0.2.2):
|
||||
dependencies:
|
||||
'@sqltools/formatter': 1.2.5
|
||||
ansis: 3.17.0
|
||||
|
|
Loading…
Reference in New Issue