diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index e78b82c47ccb..21a75f35442e 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -22,3 +22,4 @@ https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md - [ ] (If needed) Add story of storybook - [ ] (If needed) Update CHANGELOG.md - [ ] (If possible) Add tests +- [ ] (If possible) Publish docker image diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml deleted file mode 100644 index 68452aacaf88..000000000000 --- a/.github/workflows/storybook.yml +++ /dev/null @@ -1,111 +0,0 @@ -name: Storybook - -on: - push: - branches: - - master - - develop - - dev/storybook8 # for testing - pull_request_target: - -jobs: - build: - runs-on: ubuntu-latest - - env: - NODE_OPTIONS: "--max_old_space_size=7168" - - steps: - - uses: actions/checkout@v4.1.1 - if: github.event_name != 'pull_request_target' - with: - fetch-depth: 0 - submodules: true - - uses: actions/checkout@v4.1.1 - if: github.event_name == 'pull_request_target' - with: - fetch-depth: 0 - submodules: true - ref: "refs/pull/${{ github.event.number }}/merge" - - name: Checkout actual HEAD - if: github.event_name == 'pull_request_target' - id: rev - run: | - echo "base=$(git rev-list --parents -n1 HEAD | cut -d" " -f2)" >> $GITHUB_OUTPUT - git checkout $(git rev-list --parents -n1 HEAD | cut -d" " -f3) - - name: Install pnpm - uses: pnpm/action-setup@v4 - - name: Use Node.js 20.x - uses: actions/setup-node@v4.0.3 - with: - node-version-file: '.node-version' - cache: 'pnpm' - - run: corepack enable - - run: pnpm i --frozen-lockfile - - name: Check pnpm-lock.yaml - run: git diff --exit-code pnpm-lock.yaml - - name: Build misskey-js - run: pnpm --filter misskey-js build - - name: Build storybook - run: pnpm --filter frontend build-storybook - - name: Publish to Chromatic - if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/master' - run: pnpm --filter frontend chromatic --exit-once-uploaded -d storybook-static - env: - CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} - - name: Publish to Chromatic - if: github.event_name != 'pull_request_target' && github.ref != 'refs/heads/master' - id: chromatic_push - run: | - DIFF="${{ github.event.before }} HEAD" - if [ "$DIFF" = "0000000000000000000000000000000000000000 HEAD" ]; then - DIFF="HEAD" - fi - CHROMATIC_PARAMETER="$(node packages/frontend/.storybook/changes.js $(git diff-tree --no-commit-id --name-only -r $(echo "$DIFF") | xargs))" - if [ "$CHROMATIC_PARAMETER" = " --skip" ]; then - echo "skip=true" >> $GITHUB_OUTPUT - fi - if pnpm --filter frontend chromatic -d storybook-static $(echo "$CHROMATIC_PARAMETER"); then - echo "success=true" >> $GITHUB_OUTPUT - else - echo "success=false" >> $GITHUB_OUTPUT - fi - env: - CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} - - name: Publish to Chromatic - if: github.event_name == 'pull_request_target' - id: chromatic_pull_request - run: | - DIFF="${{ steps.rev.outputs.base }} HEAD" - if [ "$DIFF" = "0000000000000000000000000000000000000000 HEAD" ]; then - DIFF="HEAD" - fi - CHROMATIC_PARAMETER="$(node packages/frontend/.storybook/changes.js $(git diff-tree --no-commit-id --name-only -r $(echo "$DIFF") | xargs))" - if [ "$CHROMATIC_PARAMETER" = " --skip" ]; then - echo "skip=true" >> $GITHUB_OUTPUT - fi - BRANCH="${{ github.event.pull_request.head.user.login }}:$HEAD_REF" - if [ "$BRANCH" = "misskey-dev:$HEAD_REF" ]; then - BRANCH="$HEAD_REF" - fi - pnpm --filter frontend chromatic --exit-once-uploaded -d storybook-static --branch-name "$BRANCH" $(echo "$CHROMATIC_PARAMETER") - env: - HEAD_REF: ${{ github.event.pull_request.head.ref }} - CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} - - name: Notify that Chromatic detects changes - uses: actions/github-script@v7.0.1 - if: github.event_name != 'pull_request_target' && steps.chromatic_push.outputs.success == 'false' - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - github.rest.repos.createCommitComment({ - owner: context.repo.owner, - repo: context.repo.repo, - commit_sha: context.sha, - body: 'Chromatic detects changes. Please [review the changes on Chromatic](https://www.chromatic.com/builds?appId=6428f7d7b962f0b79f97d6e4).' - }) - - name: Upload Artifacts - uses: actions/upload-artifact@v4 - with: - name: storybook - path: packages/frontend/storybook-static diff --git a/.node-version b/.node-version index 8ce7030825b5..1384ff6a1cbb 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -20.16.0 +22.5.1 diff --git a/Changelog-neko.md b/Changelog-neko.md new file mode 100644 index 000000000000..39b20eda8219 --- /dev/null +++ b/Changelog-neko.md @@ -0,0 +1,21 @@ +## Unreleased + +### General +- Fix: 프론트엔드의 타입 이슈 +- Feat: 노트 수정 기능 부활 +(cherry-pick from yunochi#4) +- Feat: 리모트 아바타 데코 연합 +(cherry-pick from yunochi#1 and 7dc3d230dda0f1ccac7e82e64c0cf4848994e1fc) + +### Client + +### Backend + +### Frontend +- Feat: "다른 계정 추가" 버튼 아래에 "새 계정 추가" 버튼이 살아 있어서 지웠습니다 +- Fix (test): 회원가입 관련 테스트 삭제 + +### misskey-js + +### develop +- QoL: `misskey-js`의 갱신과 이를 적용한 전체 빌드의 자동화 스크립트 추가 \ No newline at end of file diff --git a/chart/files/default.yml b/chart/files/default.yml index f98b8ebfee04..2d76de59bbdd 100644 --- a/chart/files/default.yml +++ b/chart/files/default.yml @@ -208,6 +208,9 @@ id: "aidx" # Media Proxy #mediaProxy: https://example.com/proxy +# Proxy remote files (default: true) +proxyRemoteFiles: true + # Sign to ActivityPub GET request (default: true) signToActivityPubGet: true diff --git a/cypress/e2e/basic.cy.ts b/cypress/e2e/basic.cy.ts index d2525e0a7d05..7adfdb2fd6c1 100644 --- a/cypress/e2e/basic.cy.ts +++ b/cypress/e2e/basic.cy.ts @@ -49,49 +49,6 @@ describe('After setup instance', () => { it('successfully loads', () => { cy.visitHome(); - }); - - it('signup', () => { - cy.visitHome(); - - cy.intercept('POST', '/api/signup').as('signup'); - - cy.get('[data-cy-signup]').click(); - cy.get('[data-cy-signup-rules-continue]').should('be.disabled'); - cy.get('[data-cy-signup-rules-notes-agree] [data-cy-switch-toggle]').click(); - cy.get('[data-cy-modal-dialog-ok]').click(); - cy.get('[data-cy-signup-rules-continue]').should('not.be.disabled'); - cy.get('[data-cy-signup-rules-continue]').click(); - - cy.get('[data-cy-signup-submit]').should('be.disabled'); - cy.get('[data-cy-signup-username] input').type('alice'); - cy.get('[data-cy-signup-submit]').should('be.disabled'); - 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('not.be.disabled'); - cy.get('[data-cy-signup-submit]').click(); - - cy.wait('@signup'); - }); - - it('signup with duplicated username', () => { - cy.registerUser('alice', 'alice1234'); - - cy.visitHome(); - - // ユーザー名が重複している場合の挙動確認 - cy.get('[data-cy-signup]').click(); - cy.get('[data-cy-signup-rules-continue]').should('be.disabled'); - cy.get('[data-cy-signup-rules-notes-agree] [data-cy-switch-toggle]').click(); - cy.get('[data-cy-modal-dialog-ok]').click(); - cy.get('[data-cy-signup-rules-continue]').should('not.be.disabled'); - cy.get('[data-cy-signup-rules-continue]').click(); - - cy.get('[data-cy-signup-username] input').type('alice'); - cy.get('[data-cy-signup-password] input').type('alice1234'); - cy.get('[data-cy-signup-password-retype] input').type('alice1234'); - cy.get('[data-cy-signup-submit]').should('be.disabled'); }); }); diff --git a/locales/en-US.yml b/locales/en-US.yml index 2cb76fa74652..de4a27d77d47 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -348,7 +348,7 @@ avatar: "Avatar" banner: "Banner" displayOfSensitiveMedia: "Display of sensitive media" whenServerDisconnected: "When losing connection to the server" -disconnectedFromServer: "Connection to server has been lost" +disconnectedFromServer: "I'm so sleepy..." reload: "Refresh" doNothing: "Ignore" reloadConfirm: "Would you like to refresh the timeline?" @@ -657,6 +657,7 @@ tokenRequested: "Grant access to account" pluginTokenRequestedDescription: "This plugin will be able to use the permissions set here." notificationType: "Notification type" edit: "Edit" +editConfirm: "Edit note? Some remote servers won't properly display the edited content." emailServer: "Email server" enableEmail: "Enable email distribution" emailConfigInfo: "Used to confirm your email during sign-up or if you forget your password" @@ -1263,6 +1264,7 @@ confirmWhenRevealingSensitiveMedia: "Confirm when revealing sensitive media" sensitiveMediaRevealConfirm: "This might be a sensitive media. Are you sure to reveal?" createdLists: "Created lists" createdAntennas: "Created antennas" +noteUpdatedAt: "Edited: {date} {time}" _delivery: status: "Delivery status" stop: "Suspended" @@ -1706,6 +1708,7 @@ _role: gtlAvailable: "Can view the global timeline" ltlAvailable: "Can view the local timeline" canPublicNote: "Can send public notes" + canEditNote: "Note editing" mentionMax: "Maximum number of mentions in a note" canInvite: "Can create instance invite codes" inviteLimit: "Invite limit" diff --git a/locales/index.d.ts b/locales/index.d.ts index 91d36a14a627..4bbc157aa57a 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -2644,6 +2644,10 @@ export interface Locale extends ILocale { * 編集 */ "edit": string; + /** + * ノートを修正しますか?連合するサーバーによっては、修正後のノートが正常に表示されない場合があります。 + */ + "editConfirm": string; /** * メールサーバー */ @@ -5068,6 +5072,10 @@ export interface Locale extends ILocale { * 作成したアンテナ */ "createdAntennas": string; + /** + * 編集済み: {date} {time} + */ + "noteUpdatedAt": ParameterizedString<"date" | "time">; "_delivery": { /** * 配信状態 @@ -6642,6 +6650,10 @@ export interface Locale extends ILocale { * パブリック投稿の許可 */ "canPublicNote": string; + /** + * ノートの編集 + */ + "canEditNote": string; /** * ノート内の最大メンション数 */ diff --git a/locales/index.js b/locales/index.js index c2738884eb34..24958073b613 100644 --- a/locales/index.js +++ b/locales/index.js @@ -28,6 +28,8 @@ const languages = [ 'kab-KAB', 'kn-IN', 'ko-KR', + 'ko-NK', + 'ko-GS', 'nl-NL', 'no-NO', 'pl-PL', diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index b493183974cc..059fcc5b387b 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -348,7 +348,7 @@ avatar: "アイコン" banner: "バナー" displayOfSensitiveMedia: "センシティブなメディアの表示" whenServerDisconnected: "サーバーとの接続が失われたとき" -disconnectedFromServer: "サーバーから切断されました" +disconnectedFromServer: "とても眠いです···" reload: "リロード" doNothing: "なにもしない" reloadConfirm: "リロードしますか?" @@ -657,6 +657,7 @@ tokenRequested: "アカウントへのアクセス許可" pluginTokenRequestedDescription: "このプラグインはここで設定した権限を行使できるようになります。" notificationType: "通知の種類" edit: "編集" +editConfirm: "ノートを修正しますか?連合するサーバーによっては、修正後のノートが正常に表示されない場合があります。" emailServer: "メールサーバー" enableEmail: "メール配信機能を有効化する" emailConfigInfo: "メールアドレスの確認やパスワードリセットの際に使います" @@ -1263,6 +1264,7 @@ confirmWhenRevealingSensitiveMedia: "センシティブなメディアを表示 sensitiveMediaRevealConfirm: "センシティブなメディアです。表示しますか?" createdLists: "作成したリスト" createdAntennas: "作成したアンテナ" +noteUpdatedAt: "編集済み: {date} {time}" _delivery: status: "配信状態" @@ -1717,6 +1719,7 @@ _role: gtlAvailable: "グローバルタイムラインの閲覧" ltlAvailable: "ローカルタイムラインの閲覧" canPublicNote: "パブリック投稿の許可" + canEditNote: "ノートの編集" mentionMax: "ノート内の最大メンション数" canInvite: "サーバー招待コードの発行" inviteLimit: "招待コードの作成可能数" diff --git a/locales/ko-GS.yml b/locales/ko-GS.yml index 9323ed2a26ae..416db0293226 100644 --- a/locales/ko-GS.yml +++ b/locales/ko-GS.yml @@ -328,7 +328,7 @@ avatar: "아바타" banner: "배너" displayOfSensitiveMedia: "수ᇚ힌 옝상물 보기" whenServerDisconnected: "서버하고 옌겔이 껂기모" -disconnectedFromServer: "서버하고 옌겔이 껂깃십니다" +disconnectedFromServer: "너무 졸려요..." reload: "새로곤침" doNothing: "무시하기" reloadConfirm: "새로곤침합니꺼?" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index 34c1cc3ebfb5..aaf8cf5c3d0b 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -342,7 +342,7 @@ avatar: "아바타" banner: "배너" displayOfSensitiveMedia: "민감한 미디어 표시" whenServerDisconnected: "서버와의 접속이 끊겼을 때" -disconnectedFromServer: "서버와의 연결이 끊어졌습니다" +disconnectedFromServer: "너무 졸려요..." reload: "새로고침" doNothing: "무시하기" reloadConfirm: "새로고침 하시겠습니까?" @@ -651,6 +651,7 @@ tokenRequested: "계정 접근 허용" pluginTokenRequestedDescription: "이 플러그인은 여기서 설정한 권한을 사용할 수 있게 됩니다." notificationType: "알림 유형" edit: "편집" +editConfirm: "노트를 수정하시겠습니까? 연합되는 서버에 따라 수정 후의 노트가 정상적으로 표시되지 않을 수 있습니다." emailServer: "메일 서버" enableEmail: "이메일 송신 기능 활성화" emailConfigInfo: "가입 시 메일 주소 확인이나 비밀번호 초기화 시에 사용합니다." @@ -1249,6 +1250,7 @@ alwaysConfirmFollow: "팔로우일 때 항상 확인하기" inquiry: "문의하기" tryAgain: "다시 시도해 주세요." confirmWhenRevealingSensitiveMedia: "민감한 미디어를 열 때 두 번 확인" +noteUpdatedAt: "편집됨: {date} {time}" _delivery: status: "전송 상태" stop: "정지됨" @@ -1692,6 +1694,7 @@ _role: gtlAvailable: "글로벌 타임라인 보이기" ltlAvailable: "로컬 타임라인 보이기" canPublicNote: "공개 노트 허용" + canEditNote: "노트 편집 허용" mentionMax: "노트에 넣을 수 있는 멘션 수" canInvite: "서버 초대 코드 발행" inviteLimit: "초대 한도" diff --git a/locales/ko-NK.yml b/locales/ko-NK.yml new file mode 100644 index 000000000000..f356bb194d56 --- /dev/null +++ b/locales/ko-NK.yml @@ -0,0 +1,2510 @@ +--- +_lang_: "한국어(냥체)" +headlineMisskey: "노트로 연결되는 네트워크" +introMisskey: "환영한다냥! Misskey는 오픈 소스 분산형 마이크로 블로그 서비스다냥.\n'노트'를 작성해서 지금 일어냐고 있는 일을 공유하거냐, 당신만의 이야기를 모두에게 발신하면 된다냥. 📡\n'리액션' 기능으로 친구의 노트에 총알같이 반응을 추가할 수도 있다냥. 👍\n새로운 세계를 탐험해 보라냥! 🚀" +poweredByMisskeyDescription: "{name} 서버는 오픈소스 플랫폼 Misskey의 서버 가운데 하냐다냥." +monthAndDay: "{month}월 {day}일" +search: "검색" +notifications: "알림" +username: "유저명" +password: "비밀번호" +forgotPassword: "비밀번호를 까먹었다냥?!" +fetchingAsApObject: "실타래랑 노는 중..." +ok: "확인" +gotIt: "알겠다냥" +cancel: "취소" +noThankYou: "냐중에" +enterUsername: "유저명 입력" +renotedBy: "{user}님이 리노트" +noNotes: "노트가 없다냥..." +noNotifications: "표시할 알림이 없다냥!" +instance: "인스턴스" +settings: "설정" +notificationSettings: "알림 설정" +basicSettings: "기본 설정" +otherSettings: "기타 설정" +openInWindow: "창으로 열기" +profile: "프로필" +timeline: "타임라인" +noAccountDescription: "자기소개가 없다냥" +login: "로그인" +loggingIn: "로그인 중" +logout: "로그아웃" +signup: "회원 가입" +uploading: "업로드 중" +save: "저장" +users: "유저" +addUser: "유저 추가" +favorite: "즐겨찾기" +favorites: "즐겨찾기" +unfavorite: "즐겨찾기에서 제거" +favorited: "즐겨찾기에 등록했다냥!" +alreadyFavorited: "이미 즐겨찾기에 등록되어 있다냥!" +cantFavorite: "즐겨찾기에 등록하지 못했다냥..." +pin: "프로필에 고정" +unpin: "프로필에서 고정 해제" +copyContent: "내용 복사" +copyLink: "링크 복사" +copyLinkRenote: "리노트 링크 복사" +delete: "삭제" +deleteAndEdit: "삭제 후 편집" +deleteAndEditConfirm: "이 노트를 삭제한 뒤 다시 편집할거냥? 이 노트에 대한 리액션, 리노트, 답글들이 모두 삭제된다냥." +addToList: "리스트에 추가" +addToAntenna: "안테냐에 추가" +sendMessage: "메시지 보내기" +copyRSS: "RSS 복사" +copyUsername: "사용자 이름 복사" +copyUserId: "사용자 ID 복사" +copyNoteId: "노트 ID 복사" +copyFileId: "파일 ID 복사" +copyFolderId: "폴더 ID 복사" +copyProfileUrl: "프로필 URL 복사" +searchUser: "사용자 검색" +reply: "답글" +loadMore: "더 보기" +showMore: "더 보기" +showLess: "닫기" +youGotNewFollower: "새로운 팔로워가 있다냥." +receiveFollowRequest: "새로운 팔로우 요청이 기다리고 있다냥." +followRequestAccepted: "팔로우가 수락되었다냥!" +mention: "멘션" +mentions: "받은 멘션" +directNotes: "다이렉트 노트" +importAndExport: "가져오기와 내보내기" +import: "가져오기" +export: "내보내기" +files: "파일" +download: "다운로드" +driveFileDeleteConfirm: "‘{name}’ 파일을 삭제할거냥? 헤당 파일을 사용하는 일부 콘텐츠도 삭제된다냥." +unfollowConfirm: "{name}님을 진짜로 언팔로우할거냥?" +exportRequested: "내보내기를 요청했다냥. 이 작업은 시간이 걸릴 수도 있다냥. 파일은 촌장님이 \"드라이브\"에 넣어주겠다고 했다냥." +importRequested: "가져오기를 요청했다냥. 이 작업에는 시간이 꽤 걸릴 수도 있다냥." +lists: "리스트" +noLists: "리스트가 없다냥..." +note: "냥!" +notes: "노트" +following: "팔로잉" +followers: "팔로워" +followsYou: "냐를 팔로우하고 있다냥." +createList: "리스트 만들기" +manageLists: "리스트 관리" +error: "오류" +somethingHappened: "오류가 발생했다냥!" +retry: "다시 시도" +pageLoadError: "페이지를 불러오지 못했다냥..." +pageLoadErrorDescription: "네트워크 연결 또는 브라우저 캐시로 인해 발생했을 가능성이 높다냥. 캐시를 삭제하거냐, 잠시 후 다시 시도해 달라냥." +serverIsDead: "서버로부터 응답이 없었다냥. 잠시 후 다시 시도하는 건 어떨까냥?" +youShouldUpgradeClient: "이 페이지를 표시하려면, 새로고침하여 새로운 버전의 클라이언트를 이용해 달라냥." +enterListName: "리스트 이름을 입력해달라냥" +privacy: "프라이버시" +makeFollowManuallyApprove: "팔로우를 수동으로 승인" +defaultNoteVisibility: "기본 공개 범위" +follow: "팔로우" +followRequest: "팔로우 요청" +followRequests: "팔로우 요청" +unfollow: "팔로우 해제" +followRequestPending: "팔로우 허가 대기중" +enterEmoji: "이모지 입력" +renote: "리노트" +unrenote: "리노트 취소" +renoted: "리노트했다냥!" +cantRenote: "이 게시물은 리노트 할 수 없다냥..." +cantReRenote: "리노트를 리노트할 수는 없다냥..." +quote: "인용" +inChannelRenote: "채널 내 리노트" +inChannelQuote: "채널 내 인용" +pinnedNote: "고정된 노트" +pinned: "고정하기" +you: "냐" +clickToShow: "클릭하여 보기" +sensitive: "열람 주의" +add: "추가" +reaction: "리액션" +reactions: "리액션" +emojiPicker: "이모지 선택기" +pinnedEmojisForReactionSettingDescription: "리액션을 할 때 프로필에 고정하여 표시할 이모지를 설정할 수 있다냥." +pinnedEmojisSettingDescription: "이모지를 입력할 때 프로필에 고정하여 표시할 이모지를 설정할 수 있다냥." +emojiPickerDisplay: "선택기 표시" +overwriteFromPinnedEmojisForReaction: "리액션 설정을 덮어쓰기" +overwriteFromPinnedEmojis: "일반 설정을 덮어쓰기" +reactionSettingDescription2: "끌어서 순서 변경, 클릭해서 삭제, +를 눌러서 추가할 수도 있다냥." +rememberNoteVisibility: "공개 범위를 기억하기" +attachCancel: "첨부 취소" +deleteFile: "파일 삭제" +markAsSensitive: "열람주의로 설정" +unmarkAsSensitive: "열람주의 해제" +enterFileName: "파일명을 입력해달라냥" +mute: "뮤트" +unmute: "뮤트 해제" +renoteMute: "리노트 뮤트하기" +renoteUnmute: "리노트 뮤트 해제" +block: "차단" +unblock: "차단 해제" +suspend: "정지" +unsuspend: "정지 해제" +blockConfirm: "이 계정을 정말로 차단할거냥?" +unblockConfirm: "이 계정의 차단을 해제할거냥?" +suspendConfirm: "이 계정을 진짜 정지할거냥...?" +unsuspendConfirm: "이 계정의 정지를 해제할거냥?" +selectList: "리스트 선택" +editList: "리스트 편집" +selectChannel: "채널 선택" +selectAntenna: "안테냐 선택" +editAntenna: "안테냐 편집" +selectWidget: "위젯 선택" +editWidgets: "위젯 편집" +editWidgetsExit: "편집 종료" +customEmojis: "커스텀 이모지" +emoji: "이모지" +emojis: "이모지" +emojiName: "이모지 이름" +emojiUrl: "이모지 URL" +addEmoji: "이모지 추가" +settingGuide: "추천 설정" +cacheRemoteFiles: "리모트 파일을 캐시" +cacheRemoteFilesDescription: "이 설정을 활성화하면 리모트 파일을 이 서버의 스토리지에 캐시한다냥. 미디어의 표시가 빨라지지만, 서버의 저장 용량을 크게 소모한다냥. 리모트 유저의 미디어를 얼마냐 보관할 지는 역할의 드라이브 용량 제한에 따라 결정되며, 정해진 용량을 넘길 경우 오래된 파일부터 차례대로 삭제한 뒤 링크로 전환한다냥. \n비활성화하면 리모트 파일을 직접 링크하며, 이 경우 이미지 썸네일 생성 및 유저 프라이버시 보호를 위해 default.yml에서 proxyRemoteFiles를 true로 설정하는 것을 권장한다냥." +youCanCleanRemoteFilesCache: "파일 관리 화면의 🗑️ 버튼을 눌러 모든 캐시를 삭제할 수 있다냥." +cacheRemoteSensitiveFiles: "리모트의 민감한 파일을 캐시" +cacheRemoteSensitiveFilesDescription: "이 설정을 비활성화하면 리모트의 민감한 파일은 캐시하지 않고 리모트에서 직접 가져오도록 한다냥." +flagAsBot: "삐릭- 삐리릭?!" +flagAsBotDescription: "이 계정을 자동화된 수단으로 운용할 경우에 활성화해 달라냥. 이 플래그를 활성화하면, 다른 봇이 이를 참고하여 봇 끼리의 무한 연쇄 반응을 회피하거냐, 이 계정의 시스템 상에서의 취급이 Bot 운영에 최적화되는 등의 변화가 생긴다냥." +flagAsCat: "미야아아아오오오오오오오오오옹!!!!!!!" +flagAsCatDescription: "야옹?! 냐는 고양이다냥!" +flagShowTimelineReplies: "타임라인에 노트의 답글을 표시하기" +flagShowTimelineRepliesDescription: "이 설정을 활성화하면 타임라인에 다른 유저 간의 답글을 표시한다냥." +autoAcceptFollowed: "팔로우 중인 유저로부터의 팔로우 요청을 자동 수락" +addAccount: "계정 추가" +reloadAccountsList: "계정 리스트 정보 갱신" +loginFailed: "로그인에 실패했다냥..." +showOnRemote: "리모트에서 보기" +general: "일반" +wallpaper: "배경" +setWallpaper: "배경화면 설정" +removeWallpaper: "배경 제거" +searchWith: "검색: {q}" +youHaveNoLists: "리스트가 없다냥..." +followConfirm: "{name}님을 팔로우 할거냥?" +proxyAccount: "프록시 계정" +proxyAccountDescription: "프록시 계정은 특정 조건 하에서 유저의 리모트 팔로우를 대행하는 계정이다냥. 예를 들면, 유저가 리모트 유저를 리스트에 넣었을 때, 리스트에 들어간 유저를 아무도 팔로우한 적이 없다면 액티비티가 서버로 배달되지 않기 때문에, 대신 프록시 계정이 해당 유저를 팔로우하도록 한다냥." +host: "호스트" +selectUser: "유저 선택" +recipient: "수신인" +annotation: "내용에 대한 주석" +federation: "연합" +instances: "서버" +registeredAt: "등록 냘짜" +latestRequestReceivedAt: "마지막으로 요청을 받은 시간" +latestStatus: "마지막 상태" +storageUsage: "스토리지 사용량" +charts: "차트" +perHour: "1시간마다" +perDay: "1일마다" +stopActivityDelivery: "액티비티 보내지 않기" +blockThisInstance: "이 서버를 차단" +silenceThisInstance: "서버를 사일런스" +operations: "작업" +software: "소프트웨어" +version: "버전" +metadata: "메타데이터" +withNFiles: "{n}개의 파일" +monitor: "모니터" +jobQueue: "작업 대기열" +cpuAndMemory: "CPU와 메모리" +network: "네트워크" +disk: "디스크" +instanceInfo: "서버 정보" +statistics: "통계" +clearQueue: "대기열 비우기" +clearQueueConfirmTitle: "대기열을 비울 거냥?" +clearQueueConfirmText: "대기열에 냠아 있는 노트는 더 이상 연합되지 않는다냥. 보통의 경우 이 작업은 필요하지 않다냥." +clearCachedFiles: "캐시 비우기" +clearCachedFilesConfirm: "캐시된 리모트 파일을 모두 삭제할거냥?" +blockedInstances: "차단된 서버" +blockedInstancesDescription: "차단하려는 서버의 호스트 이름을 줄바꿈으로 구분하여 설정한다냥. 차단된 인스턴스는 여기(이 인스턴스)와 통신할 수 없게 된다냥." +silencedInstances: "사일런스한 서버" +silencedInstancesDescription: "사일런스하려는 서버의 호스트명을 한 줄에 하냐씩 입력해달라냥. 사일런스된 서버에 소속된 유저는 모두 '사일런스'된 상태로 취급되며, 이 서버로부터의 팔로우가 프로필 설정과 무관하게 승인제로 변경되고, 팔로워가 아닌 로컬 유저에게는 멘션할 수 없게 된다냥. 정지된 서버에는 적용되지 않는다냥." +muteAndBlock: "뮤트 및 차단" +mutedUsers: "뮤트한 유저" +blockedUsers: "차단한 유저" +noUsers: "아무도 없습니다" +editProfile: "프로필 수정" +noteDeleteConfirm: "이 노트를 진짜로 삭제할거냥?" +pinLimitExceeded: "더 이상 고정할 수 없다냥..." +intro: "Misskey의 설치가 완료되었다냥! 관리자 계정을 생성해달라냥." +done: "완료" +processing: "처리중" +preview: "미리보기" +default: "기본값" +defaultValueIs: "기본값: {value}" +noCustomEmojis: "이모지가 없다냥..." +noJobs: "작업이 없다냥..." +federating: "연합 중" +blocked: "차단됨" +suspended: "정지됨" +all: "전체" +subscribing: "구독 중" +publishing: "배포 중" +notResponding: "응답 없음" +instanceFollowing: "서버의 팔로잉" +instanceFollowers: "서버의 팔로워" +instanceUsers: "서버의 유저" +changePassword: "비밀번호 변경" +security: "보안" +retypedNotMatch: "입력이 일치하지 않는다냥." +currentPassword: "현재 비밀번호" +newPassword: "새 비밀번호" +newPasswordRetype: "새 비밀번호(재입력)" +attachFile: "파일 첨부" +more: "더 보기!" +featured: "유행" +usernameOrUserId: "유저명이냐 ID" +noSuchUser: "유저를 찾을 수 없다냥..." +lookup: "찾아보기" +announcements: "공지사항" +imageUrl: "이미지 URL" +remove: "삭제" +removed: "삭제했다냥...!" +removeAreYouSure: "\"{x}\" 을(를) 진짜 삭제할거냥?" +deleteAreYouSure: "\"{x}\" 을(를) 정말 삭제힐거냥?" +resetAreYouSure: "정말로 초기화 할 것이냥?" +areYouSure: "이대로 계속 진행할거냥?" +saved: "저장했다냥!" +messaging: "대화" +upload: "업로드" +keepOriginalUploading: "원본 이미지를 유지" +keepOriginalUploadingDescription: "이미지를 업로드할 때에 원본을 그대로 유지한다냥. 비활성화하면 업로드할 때 브라우저에서 웹 공개용 이미지를 생성한다냥." +fromDrive: "드라이브에서" +fromUrl: "URL로부터" +uploadFromUrl: "URL 업로드" +uploadFromUrlDescription: "업로드하려는 파일의 URL" +uploadFromUrlRequested: "업로드를 요청했다냥." +uploadFromUrlMayTakeTime: "업로드가 완료될 때까지 시간이 소요될 수 있다냥." +explore: "둘러보기" +messageRead: "읽음" +noMoreHistory: "이것보다 과거의 기록이 없다냥..." +startMessaging: "대화 시작하기" +nUsersRead: "{n}명이 읽음" +agreeTo: "{0}에 동의" +agree: "동의합니다냥." +agreeBelow: "아래 내용에 동의합니다냥." +basicNotesBeforeCreateAccount: "기본적인 주의사항" +termsOfService: "이용 약관" +start: "시작하기" +home: "홈" +remoteUserCaution: "리모트 유저이기 때문에, 정보가 정확하지 않을 수 있다냥." +activity: "활동" +images: "이미지" +image: "이미지" +birthday: "생일" +yearsOld: "{age}세" +registeredDate: "등록일" +location: "장소" +theme: "테마" +themeForLightMode: "라이트 모드에서 사용할 테마" +themeForDarkMode: "다크 모드에서 사용할 테마" +light: "라이트" +dark: "다크" +lightThemes: "밝은 테마" +darkThemes: "어두운 테마" +syncDeviceDarkMode: "디바이스의 다크 모드 설정과 동기화" +drive: "드라이브" +fileName: "파일명" +selectFile: "파일 선택" +selectFiles: "파일 선택" +selectFolder: "폴더 선택" +selectFolders: "폴더 선택" +renameFile: "파일 이름 변경" +folderName: "폴더 이름" +createFolder: "폴더 만들기" +renameFolder: "폴더 이름 바꾸기" +deleteFolder: "폴더 삭제" +folder: "폴더" +addFile: "파일 추가" +emptyDrive: "드라이브가 비어 있다냥..." +emptyFolder: "폴더가 비어 있다냥..." +unableToDelete: "삭제할 수 없다냥..." +inputNewFileName: "바꿀 파일명을 입력해 달라냥" +inputNewDescription: "새 캡션을 입력해 달라냥" +inputNewFolderName: "바꿀 폴더명을 입력해 달라냥." +circularReferenceFolder: "지정한 폴더가 이동할 폴더의 하위에 있다냥." +hasChildFilesOrFolders: "이 폴더는 비어있지 않기 때문에 삭제할 수 없다냥." +copyUrl: "URL 복사" +rename: "이름 변경" +avatar: "아바타" +banner: "배너" +displayOfSensitiveMedia: "민감한 미디어 표시" +whenServerDisconnected: "서버와의 접속이 끊겼을 때" +disconnectedFromServer: "너무 졸리다냥..." +reload: "새로고침" +doNothing: "무시하기" +reloadConfirm: "새로고침 할거냥?" +watch: "관심 갖기" +unwatch: "관심 해제하기" +accept: "수락하기" +reject: "거절하기" +normal: "일반" +instanceName: "서버 이름" +instanceDescription: "서버 소개" +maintainerName: "관리자 이름" +maintainerEmail: "관리자 이메일" +tosUrl: "이용약관 URL" +thisYear: "올해" +thisMonth: "이달" +today: "오늘" +dayX: "{day}일" +monthX: "{month}월" +yearX: "{year}년" +pages: "페이지" +integration: "연동" +connectService: "계정 연동" +disconnectService: "계정 연동 해제" +enableLocalTimeline: "로컬 타임라인 활성화" +enableGlobalTimeline: "글로벌 타임라인 활성화" +disablingTimelinesInfo: "특정 타임라인을 비활성화하더라도 관리자 및 모더레이터는 계속 사용할 수 있다냥." +registration: "등록" +enableRegistration: "신규 회원가입을 활성화" +invite: "초대" +driveCapacityPerLocalAccount: "로컬 유저 한 명당 드라이브 용량" +driveCapacityPerRemoteAccount: "리모트 유저 한 명당 드라이브 용량" +inMb: "메가바이트 단위" +bannerUrl: "배너 이미지 URL" +backgroundImageUrl: "배경 이미지 URL" +basicInfo: "기본 정보" +pinnedUsers: "고정된 유저" +pinnedUsersDescription: "\"발견하기\" 페이지 등에 고정하고 싶은 유저를 한 줄에 한 명씩 적는다냥." +pinnedPages: "고정한 페이지" +pinnedPagesDescription: "서버의 대문에 고정하고 싶은 페이지의 경로를 한 줄에 하냐씩 적는다냥." +pinnedClipId: "고정할 클립의 ID" +pinnedNotes: "고정된 노트" +hcaptcha: "hCaptcha" +enableHcaptcha: "hCaptcha 활성화" +hcaptchaSiteKey: "사이트 키" +hcaptchaSecretKey: "시크릿 키" +mcaptcha: "mCaptcha" +enableMcaptcha: "mCaptcha 활성화" +mcaptchaSiteKey: "사이트 키" +mcaptchaSecretKey: "시크릿 키" +mcaptchaInstanceUrl: "mCaptcha 인스턴스 URL" +recaptcha: "reCAPTCHA" +enableRecaptcha: "reCAPTCHA 활성화" +recaptchaSiteKey: "사이트 키" +recaptchaSecretKey: "시크릿 키" +turnstile: "Turnstile" +enableTurnstile: "Turnstile 활성화" +turnstileSiteKey: "사이트 키" +turnstileSecretKey: "시크릿 키" +avoidMultiCaptchaConfirm: "여러 Captcha를 사용하는 경우 간섭이 발생할 가능성이 있다냥. 다른 Captcha를 비활성화 하는게 어떻겠느냥? 취소를 눌러 여러 Captcha를 활성화한 상태로 두는 것도 가능하다냥." +antennas: "안테냐" +manageAntennas: "안테냐 관리" +name: "이름" +antennaSource: "받을 소스" +antennaKeywords: "받을 검색어" +antennaExcludeKeywords: "제외할 검색어" +antennaKeywordsDescription: "공백으로 구분하는 경우 AND, 줄바꿈으로 구분하는 경우 OR로 지정된다냥." +notifyAntenna: "새로운 노트를 알림" +withFileAntenna: "파일이 첨부된 노트만" +enableServiceworker: "ServiceWorker 사용" +antennaUsersDescription: "유저명을 한 줄에 한 명씩 적는다냥" +caseSensitive: "대소문자를 구분" +withReplies: "답글 포함" +connectedTo: "다음 계정에 연결되어 있다냥" +notesAndReplies: "글과 답글" +withFiles: "미디어" +silence: "사일런스" +silenceConfirm: "이 계정을 정말 사일런스로 설정할 것이냥?" +unsilence: "사일런스 해제" +unsilenceConfirm: "이 계정의 사일런스를 해제할거냥?" +popularUsers: "인기 유저" +recentlyUpdatedUsers: "최근 활동한 유저" +recentlyRegisteredUsers: "최근 가입한 유저" +recentlyDiscoveredUsers: "최근 발견한 유저" +exploreUsersCount: "{count}명의 유저가 있다냥" +exploreFediverse: "연합우주를 탐색" +popularTags: "인기 태그" +userList: "리스트" +about: "정보" +aboutMisskey: "Misskey에 대하여" +administrator: "관리자" +token: "토큰" +2fa: "2단계 인증" +setupOf2fa: "2단계 인증 설정" +totp: "인증 앱" +totpDescription: "인증 앱을 사용하여 일회성 비밀번호 입력" +moderator: "모더레이터" +moderation: "조정" +moderationNote: "조정 기록" +addModerationNote: "조정 기록 추가하기" +moderationLogs: "모더레이션 로그" +nUsersMentioned: "{n}명이 언급함" +securityKeyAndPasskey: "보안 키 또는 패스 키" +securityKey: "보안 키" +lastUsed: "마지막 사용" +lastUsedAt: "마지막 사용: {t}" +unregister: "등록 해제" +passwordLessLogin: "비밀번호 없이 로그인" +passwordLessLoginDescription: "비밀번호를 사용하지 않고 보안 키 또는 패스 키 등으로만 로그인한다냥." +resetPassword: "비밀번호 재설정" +newPasswordIs: "새로운 비밀번호는 \"{password}\" 다냥!" +reduceUiAnimation: "UI의 애니메이션을 줄이기" +share: "공유" +notFound: "찾을 수 없다냥..." +notFoundDescription: "지정한 URL에 해당하는 페이지가 존재하지 않는다냥..." +uploadFolder: "기본 업로드 위치" +markAsReadAllNotifications: "모든 알림을 읽은 상태로 표시" +markAsReadAllUnreadNotes: "모든 글을 읽은 상태로 표시" +markAsReadAllTalkMessages: "모든 대화를 읽은 상태로 표시" +help: "도움말" +inputMessageHere: "여기에 메시지를 입력해달라냥" +close: "닫기" +invites: "초대" +members: "멤버" +transfer: "양도" +title: "제목" +text: "텍스트" +enable: "사용" +next: "다음" +retype: "다시 입력" +noteOf: "{user}의 노트" +quoteAttached: "인용함" +quoteQuestion: "인용해서 작성할까냥?" +noMessagesYet: "아직 대화가 없다냥..." +newMessageExists: "새 메시지가 있다냥!" +onlyOneFileCanBeAttached: "메시지에 첨부할 수 있는 파일은 하냐까지다냥." +signinRequired: "로그인 해달라냥" +invitations: "초대" +invitationCode: "초대 코드" +checking: "확인하는 중이다냥..." +available: "사용 가능하다냥!" +unavailable: "사용할 수 없다냥." +usernameInvalidFormat: "a~z, A~Z, 0-9, _를 사용할 수 있다냥." +tooShort: "너무 짧다냥." +tooLong: "너무 길다냥..." +weakPassword: "약한 비밀번호다냥..." +normalPassword: "좋은 비밀번호다냥." +strongPassword: "강한 비밀번호다냥!!!" +passwordMatched: "냐호! 일치한다냥!" +passwordNotMatched: "일치하지 않는다냥..." +signinWith: "{x}로 로그인" +signinFailed: "로그인할 수 없다냥... 사용자명과 비밀번호를 확인해달라냥." +or: "혹은" +language: "언어" +uiLanguage: "UI 표시 언어" +aboutX: "{x}에 대하여" +emojiStyle: "이모지 스타일" +native: "기본" +disableDrawer: "드로어 메뉴를 사용하지 않기" +showNoteActionsOnlyHover: "노트 액션 버튼을 마우스를 올렸을 때에만 표시" +noHistory: "기록이 없다냥" +signinHistory: "로그인 기록" +enableAdvancedMfm: "고급 MFM을 활성화" +enableAnimatedMfm: "움직임이 있는 MFM을 활성화" +doing: "기달려달라냥..." +category: "카테고리" +tags: "태그" +docSource: "이 문서의 소스" +createAccount: "계정 만들기" +existingAccount: "기존 계정" +regenerate: "재생성" +fontSize: "글자 크기" +mediaListWithOneImageAppearance: "이미지가 1개 뿐인 미디어 목록의 높이" +limitTo: "{x}로 제한" +noFollowRequests: "처리되지 않은 팔로우 요청이 없다냥!" +openImageInNewTab: "새 탭에서 이미지 열기" +dashboard: "대시보드" +local: "로컬" +remote: "리모트" +total: "합계" +weekOverWeekChanges: "지냔주보다" +dayOverDayChanges: "어제보다" +appearance: "모양" +clientSettings: "클라이언트 설정" +accountSettings: "계정 설정" +promotion: "홍보" +promote: "프로모션하기" +numberOfDays: "며칠동안" +hideThisNote: "이 노트를 숨기기" +showFeaturedNotesInTimeline: "타임라인에 추천 노트를 표시" +objectStorage: "오브젝트 스토리지" +useObjectStorage: "오브젝트 스토리지를 사용" +objectStorageBaseUrl: "Base URL" +objectStorageBaseUrlDesc: "오브젝트 (미디어) 참조 URL 을 만들 때 사용되는 URL이다냥. CDN 또는 프록시를 사용하는 경우 그 URL을 지정하고, 그 외의 경우 사용할 서비스의 가이드에 따라 공개적으로 액세스 할 수 있는 주소를 지정해 달라냥. 예를 들어, AWS S3의 경우 'https://.s3.amazonaws.com', GCS등의 경우 'https://storage.googleapis.com/' 와 같이 지정하면 된다냥." +objectStorageBucket: "Bucket" +objectStorageBucketDesc: "사용 서비스의 bucket명을 지정해달라냥." +objectStoragePrefix: "Prefix" +objectStoragePrefixDesc: "이 Prefix 의 디렉토리 아래에 파일이 저장된다냥." +objectStorageEndpoint: "Endpoint" +objectStorageEndpointDesc: "AWS S3의 경우 공란, 다른 서비스의 경우 각 서비스의 가이드에 맞게 endpoint를 설정해달라냥. '' 혹은 ':' 와 같이 지정한다냥." +objectStorageRegion: "Region" +objectStorageRegionDesc: "'xx-east-1'와 같이 region을 지정해달라냥. 사용하는 서비스에 region 개념이 없는 경우 'us-east-1'으로 설정해달라냥. AWS 설정 파일 또는 환경 변수를 참조할 경우에는 비워달라냥." +objectStorageUseSSL: "SSL 사용" +objectStorageUseSSLDesc: "API 호출시 HTTPS 를 사용하지 않는 경우 OFF 로 설정해달라냥" +objectStorageUseProxy: "연결에 프록시를 사용" +objectStorageUseProxyDesc: "오브젝트 스토리지 API 호출시 프록시를 사용하지 않는 경우 OFF 로 설정해달라냥" +objectStorageSetPublicRead: "업로드할 때 'public-read'를 설정하기" +s3ForcePathStyleDesc: "s3ForcePathStyle을 활성화하면, 버킷 이름을 URL의 호스트명이 아닌 경로의 일부로써 취급한다냥. 셀프 호스트 Minio와 같은 서비스를 사용할 경우 활성화해냥 할 수 있다냥." +serverLogs: "서버 로그" +deleteAll: "모두 삭제" +showFixedPostForm: "타임라인 상단에 글 작성란을 표시" +showFixedPostFormInChannel: "채널 타임라인 상단에 글 작성란을 표시" +withRepliesByDefaultForNewlyFollowed: "팔로우 할 때 기본적으로 답글을 타임라인에 냐오게 하기" +newNoteRecived: "새 노트가 있다냥." +sounds: "소리" +sound: "소리" +listen: "듣기" +none: "없음" +showInPage: "페이지로 보기" +popout: "새 창으로 열기" +volume: "음량" +masterVolume: "마스터 볼륨" +notUseSound: "음소거 하기" +useSoundOnlyWhenActive: "Misskey가 활성화 되어져 있을 때만 소리 출력하기" +details: "자세히" +chooseEmoji: "이모지 선택" +unableToProcess: "작업을 완료할 수 없다냥" +recentUsed: "최근 사용" +install: "설치" +uninstall: "삭제" +installedApps: "인증된 애플리케이션" +nothing: "아무것도 없다냥" +installedDate: "승인한 냘짜" +lastUsedDate: "마지막 사용" +state: "상태" +sort: "정렬" +ascendingOrder: "오름차순" +descendingOrder: "내림차순" +scratchpad: "스크래치 패드" +scratchpadDescription: "스크래치 패드는 AiScript 의 테스트 환경을 제공한다냥. Misskey 와 상호 작용하는 코드를 작성, 실행 및 결과를 확인할 수 있다냥." +output: "출력" +script: "스크립트" +disablePagesScript: "Pages 에서 AiScript 를 사용하지 않음" +updateRemoteUser: "리모트 유저 정보 갱신" +unsetUserAvatar: "아바타 제거" +unsetUserAvatarConfirm: "아바타를 제거할까냥?" +unsetUserBanner: "배너 제거" +unsetUserBannerConfirm: "배너를 지울까냥?" +deleteAllFiles: "모든 파일 삭제" +deleteAllFilesConfirm: "모든 파일을 삭제할 거냥...?" +removeAllFollowing: "모든 팔로잉 해제" +removeAllFollowingDescription: "{host} 서버의 모든 팔로잉을 해제할 거다냥. 해당 서버가 더 이상 존재하지 않는 경우 등에 실행해달라냥." +userSuspended: "이 계정은 정지된 상태다냥." +userSilenced: "이 계정은 사일런스된 상태다냥." +yourAccountSuspendedTitle: "계정이 정지되었다냥." +yourAccountSuspendedDescription: "선생님의 계정은 서버의 이용 약관을 위반하거냐, 기타 다른 이유로 인해 정지되었다냥. 자세한 사항은 촌장에게 문의해 달라냥." +tokenRevoked: "유효하지 않은 토큰이다냥" +tokenRevokedDescription: "로그인 토큰이 비활성화 되었다냥. 다시 로그인 해달라냥." +accountDeleted: "계정이 삭제되었다냥." +accountDeletedDescription: "이 계정이 삭제되었다냥." +menu: "메뉴" +divider: "구분선" +addItem: "항목 추가" +rearrange: "정렬" +relays: "릴레이" +addRelay: "릴레이 추가" +inboxUrl: "Inbox 주소" +addedRelays: "추가된 릴레이" +serviceworkerInfo: "푸시 알림을 수행하려면 활성화해냥 한다냥." +deletedNote: "삭제된 노트" +invisibleNote: "비공개 노트" +enableInfiniteScroll: "자동으로 더 보기" +visibility: "공개 범위" +poll: "투표" +useCw: "내용 숨기기" +enablePlayer: "플레이어 열기" +disablePlayer: "플레이어 닫기" +expandTweet: "게시물 확장하기" +themeEditor: "테마 에디터" +description: "설명" +describeFile: "캡션 추가" +enterFileDescription: "캡션 입력" +author: "작성자" +leaveConfirm: "저장하지 않은 변경사항이 있다냥. 정말 취소할 것이냥?" +manage: "관리" +plugins: "플러그인" +preferencesBackups: "환경설정 백업" +deck: "덱" +undeck: "덱 해제" +useBlurEffectForModal: "모달에 흐림 효과 사용" +useFullReactionPicker: "모든 기능이 포함된 리액션 선택기 사용" +width: "폭" +height: "높이" +large: "크게" +medium: "보통" +small: "작게" +generateAccessToken: "액세스 토큰 생성" +permission: "권한" +adminPermission: "관리자 권한" +enableAll: "전체 선택" +disableAll: "전체 해제" +tokenRequested: "계정 접근 허용" +pluginTokenRequestedDescription: "이 플러그인은 여기서 설정한 권한을 사용할 수 있게 된다냥." +notificationType: "알림 유형" +edit: "편집" +emailServer: "메일 서버" +enableEmail: "이메일 송신 기능 활성화" +emailConfigInfo: "가입 시 메일 주소 확인이냐 비밀번호 초기화 시에 사용된다냥." +email: "이메일" +emailAddress: "메일 주소" +smtpConfig: "SMTP 서버 설정" +smtpHost: "호스트" +smtpPort: "포트" +smtpUser: "사용자 이름" +smtpPass: "비밀번호" +emptyToDisableSmtpAuth: "SMTP 인증을 사용하지 않으려면 공란으로 비워둔다냥." +smtpSecure: "SMTP 연결에 Implicit SSL/TTS 사용" +smtpSecureInfo: "STARTTLS 사용 시에는 해제한다냥." +testEmail: "이메일 전송 테스트" +wordMute: "단어 뮤트" +hardWordMute: "하드 단어 뮤트" +regexpError: "정규 표현식 오류" +regexpErrorDescription: "{tab}단어 뮤트 {line}행의 정규 표현식에 오류가 발생했다냥:" +instanceMute: "서버 뮤트" +userSaysSomething: "{name}님이 무언가를 말했다냥..." +makeActive: "활성화" +display: "표시" +copy: "복사" +metrics: "통계" +overview: "요약" +logs: "로그" +delayed: "지연" +database: "데이터베이스" +channel: "채널" +create: "생성" +notificationSetting: "알림 설정" +notificationSettingDesc: "표시할 알림의 종류를 선택해달라냥." +useGlobalSetting: "글로벌 설정을 사용하기" +useGlobalSettingDesc: "활성화하면 계정의 알림 설정이 적용된다냥. 비활성화하면 개별적으로 설정할 수 있게 된다냥." +other: "기타" +regenerateLoginToken: "로그인 토큰을 재생성" +regenerateLoginTokenDescription: "로그인할 때 사용되는 내부 토큰을 재생성한다냥. 일반적으로 이 작업을 실행할 필요는 없다냥. 이 기능을 사용하면 이 계정으로 로그인한 모든 기기에서 로그아웃된다냥." +theKeywordWhenSearchingForCustomEmoji: "맞춤 이모티콘을 검색할 때 키워드가 된다냥." +setMultipleBySeparatingWithSpace: "공백으로 구분하여 여러 개 설정할 수도 있다냥." +fileIdOrUrl: "파일 ID 또는 URL" +behavior: "동작" +sample: "예시" +abuseReports: "신고" +reportAbuse: "신고" +reportAbuseRenote: "리노트 신고하기" +reportAbuseOf: "{name}을 신고하기" +fillAbuseReportDescription: "신고하려는 이유를 자세히 알려달라냥. 특정 게시물을 신고할 때에는 게시물의 URL도 포함해달라냥..." +abuseReported: "신고를 보냈다냥. 조속히 처리하겠다냥...!" +reporter: "신고자" +reporteeOrigin: "피신고자" +reporterOrigin: "신고자" +forwardReport: "리모트 서버에도 신고 내용을 보낸다냥" +forwardReportIsAnonymous: "리모트 서버에서는 냐의 정보를 볼 수 없으며, 익명의 시스템 계정으로 표시된다냥." +send: "전송" +abuseMarkAsResolved: "해결됨으로 표시" +openInNewTab: "새 탭에서 열기" +openInSideView: "사이드뷰로 열기" +defaultNavigationBehaviour: "기본 탐색 동작" +editTheseSettingsMayBreakAccount: "이 설정을 변경하면 계정이 손상될 수 있다냥." +instanceTicker: "노트의 서버 정보" +waitingFor: "{x}을(를) 기다리고 있다냥..." +random: "무작위" +system: "시스템" +switchUi: "UI 전환" +desktop: "데스크탑" +clip: "클립" +createNew: "새로 만들기" +optional: "옵션" +createNewClip: "새 클립 만들기" +unclip: "클립 해제" +confirmToUnclipAlreadyClippedNote: "이 노트는 이미 \"{name}\" 클립에 포함되어 있다냥. 클립을 해제할 것이냥?" +public: "공개" +private: "비공개" +i18nInfo: "Misskey는 착한 고양이들에 의해 다양한 언어로 번역되고 있다냥. {link}에서 번역에 참가할 수 있다냥." +manageAccessTokens: "액세스 토큰 관리" +accountInfo: "계정 정보" +notesCount: "노트 수" +repliesCount: "답글 수" +renotesCount: "리노트 수" +repliedCount: "받은 답글 수" +renotedCount: "받은 리노트 수" +followingCount: "팔로우 수" +followersCount: "팔로워 수" +sentReactionsCount: "보낸 리액션 수" +receivedReactionsCount: "받은 리액션 수" +pollVotesCount: "투표한 횟수" +pollVotedCount: "투표받은 횟수" +yes: "예" +no: "아니오" +driveFilesCount: "드라이브 파일 개수" +driveUsage: "드라이브 사용량" +noCrawle: "검색엔진의 인덱싱 거부" +noCrawleDescription: "검색엔진에 사용자 페이지, 노트, 페이지 등의 콘텐츠를 인덱싱되지 않게 된다냥." +lockedAccountInfo: "팔로우를 승인으로 승인받더라도 노트의 공개 범위를 '팔로워'로 하지 않는 한 누구냐 당신의 노트를 볼 수 있다냥." +alwaysMarkSensitive: "미디어를 항상 열람 주의로 설정" +loadRawImages: "첨부한 이미지의 썸네일을 원본화질로 표시" +disableShowingAnimatedImages: "움직이는 이미지를 자동으로 재생하지 않음" +highlightSensitiveMedia: "미디어가 민감한 내용이라는 것을 알기 쉽게 표시" +verificationEmailSent: "확인 메일을 발송했다냥. 설정을 완료하려면 메일에 첨부된 링크를 확인해달라냥." +notSet: "설정되지 않음" +emailVerified: "메일 주소가 확인되었다냥! 고생 많았다냥." +noteFavoritesCount: "즐겨찾기한 노트 수" +pageLikesCount: "좋아요 한 Page 수" +pageLikedCount: "Page에 받은 좋아요 수" +contact: "연락처" +useSystemFont: "시스템 기본 글꼴을 사용" +clips: "클립" +experimentalFeatures: "실험실" +experimental: "실험실" +thisIsExperimentalFeature: "이 기능은 실험적인 기능이다냥. 사양이 변경되거냐 정상적으로 동작하지 않을 가능성도 있다냥." +developer: "삐삐쀼쀼" +makeExplorable: "\"발견하기\"에 내 계정 보이기" +makeExplorableDescription: "비활성화하면 \"발견하기\"에 냐의 계정을 표시하지 않는다냥." +showGapBetweenNotesInTimeline: "타임라인의 노트 사이를 띄워서 표시" +duplicate: "복제" +left: "왼쪽" +center: "가운데" +wide: "넓게" +narrow: "좁게" +reloadToApplySetting: "이 설정을 적용하려면 페이지를 새로고침해냥 한다냥. 바로 새로고침 할까냥?" +needReloadToApply: "변경 사항은 새로고침하면 적용된다냥." +showTitlebar: "타이틀 바를 표시하기" +clearCache: "캐시 비우기" +onlineUsersCount: "고양이 {n}마리가 현재 미스키에 출몰...!" +nUsers: "{n} 유저" +nNotes: "{n} 노트" +sendErrorReports: "오류 보고서 보내기" +sendErrorReportsDescription: "이 설정을 활성화하면, 문제가 발생했을 때 오류에 대한 상세 정보를 Misskey에 보내어 더 냐은 소프트웨어를 만드는 데에 도움을 줄 수도 있다냥." +myTheme: "내 테마" +backgroundColor: "배경 색" +accentColor: "강조 색상" +textColor: "문자 색" +saveAs: "다른 이름으로 저장" +advanced: "고급" +advancedSettings: "고급 설정" +value: "값" +createdAt: "생성된 냘짜" +updatedAt: "수정한 냘짜" +saveConfirm: "저장할까냥?" +deleteConfirm: "삭제할 것이냥?" +invalidValue: "올바른 값이 아니다냥..." +registry: "레지스트리" +closeAccount: "계정 폐쇄" +currentVersion: "현재 버전" +latestVersion: "최신 버전" +youAreRunningUpToDateClient: "사용 중인 클라이언트는 최신이다냥." +newVersionOfClientAvailable: "새로운 버전의 클라이언트를 이용할 수 있다냥." +usageAmount: "사용량" +capacity: "용량" +inUse: "사용중" +editCode: "코드 수정" +apply: "적용" +receiveAnnouncementFromInstance: "이 서버의 알림을 이메일로 수신하겠다냥!" +emailNotification: "메일 알림" +publish: "게시" +inChannelSearch: "채널에서 검색" +useReactionPickerForContextMenu: "우클릭하여 리액션 선택기 열기" +typingUsers: "{users} 님이 입력하고 있다냥.." +jumpToSpecifiedDate: "특정 냘짜로 이동" +showingPastTimeline: "과거의 타임라인을 표시하고 있다냥" +clear: "지우기" +markAllAsRead: "모두 읽은 상태로 표시" +goBack: "뒤로" +unlikeConfirm: "좋아요를 취소할까냥?" +fullView: "전체 화면" +quitFullView: "전체 화면 해제" +addDescription: "설명 추가" +userPagePinTip: "각 노트의 메뉴에서 「프로필에 고정」을 선택하는 것으로, 여기에 노트를 표시해 둘 수 있다냥." +notSpecifiedMentionWarning: "수신자가 선택되지 않은 멘션이 존재한다냥." +info: "정보" +userInfo: "유저 정보" +unknown: "알 수 없음" +onlineStatus: "온라인 상태" +hideOnlineStatus: "온라인 상태 숨기기" +hideOnlineStatusDescription: "온라인 상태를 숨기면, 검색과 같은 일부 기능에 영향을 미칠 수 있다냥." +online: "온라인" +active: "최근에 활동함" +offline: "오프라인" +notRecommended: "추천하지 않음" +botProtection: "Bot 방어" +instanceBlocking: "서버 차단" +selectAccount: "계정 선택" +switchAccount: "계정 바꾸기" +enabled: "활성화" +disabled: "비활성화" +quickAction: "빠른 동작" +user: "사용자" +administration: "관리" +accounts: "계정" +switch: "전환" +noMaintainerInformationWarning: "촌장의 정보가 등록되어 있지 않다냥." +noBotProtectionWarning: "Bot 방어가 설정되어 있지 않다냥!" +configure: "설정하기" +postToGallery: "갤러리에 업로드" +postToHashtag: "이 해시태그에 게시" +gallery: "갤러리" +recentPosts: "최근 포스트" +popularPosts: "인기 포스트" +shareWithNote: "노트로 공유" +ads: "광고" +expiration: "기한" +startingperiod: "시작 기간" +memo: "메모" +priority: "우선순위" +high: "높음" +middle: "보통" +low: "냦음" +emailNotConfiguredWarning: "메일 주소가 설정되어 있지 않다냥." +ratio: "비율" +previewNoteText: "본문 미리보기" +customCss: "CSS 사용자화" +customCssWarn: "이 설정은 기능을 알고 있는 경우에만 사용해냥 한다냥. 잘못된 값을 입력하면 클라이언트가 정상적으로 작동하지 않을 수 있다냥." +global: "글로벌" +squareAvatars: "프로필 아바타를 네모네모하게 표시" +sent: "전송" +received: "수신" +searchResult: "검색 결과" +hashtags: "해시태그" +troubleshooting: "문제 해결" +useBlurEffect: "UI에 흐림 효과 사용" +learnMore: "자세히" +misskeyUpdated: "Misskey가 업데이트 되었다냥! 냐호!" +whatIsNew: "패치 정보 보기" +translate: "번역" +translatedFrom: "{x}에서 번역" +accountDeletionInProgress: "계정 삭제 작업을 진행하고 있다냥..." +usernameInfo: "서버상에서 계정을 식별하기 위한 이름. 알파벳(a~z, A~Z), 숫자(0~9) 및 언더바(_)를 사용할 수 있다냥. 사용자명은 냐중에 변경할 수 없다냥." +aiChanMode: "아이 모드" +devMode: "개발자 모드" +keepCw: "CW 유지하기" +pubSub: "Pub/Sub 계정" +lastCommunication: "마지막 통신" +resolved: "처리함" +unresolved: "처리되지 않음" +breakFollow: "팔로워 해제" +breakFollowConfirm: "팔로우를 정말 해제할 거냥??" +itsOn: "켜져있다냥" +itsOff: "꺼져있다냥" +on: "켜짐" +off: "꺼짐" +emailRequiredForSignup: "가입할 때 이메일 주소 입력을 필수로 하기" +unread: "읽지 않음" +filter: "필터" +controlPanel: "제어판" +manageAccounts: "계정 관리" +makeReactionsPublic: "리액션 목록을 공개하기" +makeReactionsPublicDescription: "냐의 리액션을 누구냐 볼 수 있게 한다냥." +classic: "클래식" +muteThread: "글타래 뮤트" +unmuteThread: "글타래 뮤트 해제" +followingVisibility: "팔로우의 공개 범위" +followersVisibility: "팔로워의 공개 범위" +continueThread: "글타래 더 보기" +deleteAccountConfirm: "계정이 삭제되고 되돌릴 수 없게 된다냥. 정말 계속할 것이냥...?" +incorrectPassword: "비밀번호가 올바르지 않다냥." +voteConfirm: "\"{choice}\"에 투표할거냥?" +hide: "숨기기" +useDrawerReactionPickerForMobile: "모바일에서 드로어 메뉴로 표시" +welcomeBackWithName: "{name}님, 만냐서 반갑다냥!" +clickToFinishEmailVerification: "[{ok}]를 눌러 이메일 인증을 완료해달라냥." +overridedDeviceKind: "장치 유형" +smartphone: "스마트폰" +tablet: "태블릿" +auto: "자동" +themeColor: "테마 컬러" +size: "크기" +numberOfColumn: "한 줄에 보일 리액션의 수" +searchByGoogle: "검색" +instanceDefaultLightTheme: "서버 기본 라이트 테마" +instanceDefaultDarkTheme: "서버 기본 다크 테마" +instanceDefaultThemeDescription: "객체 형식({}로 감싼 형태)의 테마 코드를 입력해달라냥." +mutePeriod: "뮤트할 기간" +period: "기간" +indefinitely: "무기한" +tenMinutes: "10분" +oneHour: "1시간" +oneDay: "1일" +oneWeek: "일주일" +oneMonth: "1개월" +reflectMayTakeTime: "반영되기까지 시간이 걸릴 수 있다냥." +failedToFetchAccountInformation: "계정 정보를 가져오지 못했다냥..." +rateLimitExceeded: "요청 제한 횟수를 초과했다냥?!" +cropImage: "이미지 자르기" +cropImageAsk: "이미지를 자를 거냥?" +cropYes: "잘라내기" +cropNo: "그대로 사용" +file: "파일" +recentNHours: "최근 {n}시간" +recentNDays: "최근 {n}일" +noEmailServerWarning: "메일 서버가 설정되어 있지 않다냥." +thereIsUnresolvedAbuseReportWarning: "해결되지 않은 신고가 있다냥. 어서 일해라냥!" +recommended: "추천" +check: "체크" +driveCapOverrideLabel: "이 유저의 드라이브 용량을 변경" +driveCapOverrideCaption: "0 이하를 지정하면 해제된다냥." +requireAdminForView: "열람하려면 관리자 계정으로 로그인해냥 한다냥." +isSystemAccount: "시스템에 의해 자동으로 생성되어 관리되는 계정이다냥." +typeToConfirm: "계속하시려면 {x} 을 입력해달라냥" +deleteAccount: "계정 삭제" +document: "문서" +numberOfPageCache: "페이지 캐시 수" +numberOfPageCacheDescription: "숫자가 클 수록 편리성이 높아지지만, 시스템 자원과 메모리를 더 많이 사용한다냥." +logoutConfirm: "로그아웃 할 거냥?" +lastActiveDate: "마지막 이용" +statusbar: "상태바" +pleaseSelect: "선택해 달라냥" +reverse: "플립" +colored: "색 입히기" +refreshInterval: "업데이트 주기" +label: "라벨" +type: "종류" +speed: "속도" +slow: "느리게" +fast: "빠르게" +sensitiveMediaDetection: "민감한 미디어 탐지" +localOnly: "로컬에만" +remoteOnly: "리모트만" +failedToUpload: "업로드 실패" +cannotUploadBecauseInappropriate: "이 파일은 부적절한 내용을 포함한다고 판단되어 업로드할 수 없다냥." +cannotUploadBecauseNoFreeSpace: "드라이브 용량이 부족하여 업로드할 수 없다냥..." +cannotUploadBecauseExceedsFileSizeLimit: "파일 크기가 너무 크기 때문에 업로드할 수 없다냥!?" +beta: "베타" +enableAutoSensitive: "자동 NSFW 탐지" +enableAutoSensitiveDescription: "이용 가능할 경우 기계학습을 통해 자동으로 미디어 NSFW를 설정한다냥. 이 기능을 해제하더라도, 서버 정책에 따라 자동으로 설정될 수 있따냥." +activeEmailValidationDescription: "유저가 입력한 메일 주소가 일회용 메일인지, 실제로 통신할 수 있는 지 엄격하게 검사한다냥. 해제할 경우 이메일 형식에 대해서만 검사한다냥." +navbar: "내비게이션 바" +shuffle: "셔플" +account: "계정" +move: "이동" +pushNotification: "푸시 알림" +subscribePushNotification: "푸시 알림 켜기" +unsubscribePushNotification: "푸시 알림 끄기" +pushNotificationAlreadySubscribed: "푸시 알림이 이미 켜져 있다냥" +pushNotificationNotSupported: "브라우저냐 서버에서 푸시 알림이 지원되지 않는다냥..." +sendPushNotificationReadMessage: "푸시 알림이냐 메시지를 읽은 뒤 푸시 알림을 삭제" +sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}」이라는 알림이 잠깐 표시된다냥. 기기의 전력 소비량이 증가할 수도 있다냥." +windowMaximize: "최대화" +windowMinimize: "최소화" +windowRestore: "복구" +caption: "캡션" +loggedInAsBot: "삐빅- 삐리릭?" +tools: "도구" +cannotLoad: "불러오지 못했다냥..." +numberOfProfileView: "프로필 뷰 수" +like: "좋다냥!" +unlike: "좋아요 취소" +numberOfLikes: "좋아요 수" +show: "표시" +neverShow: "다시 보지 않기" +remindMeLater: "냐중에 알림" +didYouLikeMisskey: "혹시, Misskey가 마음에 드냥?" +pleaseDonate: "Misskey는 {host} 서버의 무료 소프트웨어다냥. 앞으로도 개발을 이어 냐가려면 후원이 절실히 필요하다냥!" +correspondingSourceIsAvailable: "소스 코드는 {anchor}에서 구경해볼 수 있다냥." +roles: "역할" +role: "역할" +noRole: "역할이 없다냥" +normalUser: "일반 사용자" +undefined: "정의되지 않음" +assign: "할당" +unassign: "할당 취소" +color: "색" +manageCustomEmojis: "커스텀 이모지 관리" +manageAvatarDecorations: "아바타 꾸미기 관리" +youCannotCreateAnymore: "더 이상 생성할 수 없다냥." +cannotPerformTemporary: "일시적으로 사용할 수 없다냥." +cannotPerformTemporaryDescription: "조작 횟수 제한을 초과하여 일시적으로 사용이 불과하다냥. 너무 빠른거 아니냥?" +invalidParamError: "매개변수 오류" +invalidParamErrorDescription: "요청 매개변수에 문제가 있다냥. 대부분의 경우 Misskey의 버그가 원인이지만, 입력 문자수가 너무 많았을 가능성 등도 있다냥." +permissionDeniedError: "작업이 거부되었다냥." +permissionDeniedErrorDescription: "이 작업을 수행할 권한이 없다냥." +preset: "프리셋" +selectFromPresets: "프리셋에서 선택" +achievements: "도전 과제" +gotInvalidResponseError: "서버의 응답이 올바르지 않다냥?!" +gotInvalidResponseErrorDescription: " 서버가 다운되었거냐 점검중일 가능성이 있다냥. 잠시후에 다시 시도해달라냥." +thisPostMayBeAnnoying: "이 게시물은 다른 유저에게 피해를 줄 가능성이 있다냥..." +thisPostMayBeAnnoyingHome: "홈에 게시" +thisPostMayBeAnnoyingCancel: "그만두기" +thisPostMayBeAnnoyingIgnore: "가보자고" +collapseRenotes: "이미 본 리노트를 간략화하기" +internalServerError: "내부 서버 오류" +internalServerErrorDescription: "내부 서버에서 예기치 않은 오류가 발생했다냥." +copyErrorInfo: "오류 정보 복사" +joinThisServer: "이 서버에 가입" +exploreOtherServers: "다른 서버 찾기" +letsLookAtTimeline: "타임라인 구경하기" +disableFederationConfirm: "정말로 연합을 끌 것이냥?" +disableFederationConfirmWarn: "연합을 끄더라도 게시물이 비공개로 전환되는 것은 아니다냥. 대부분의 경우 연합을 비활성화할 필요가 없다냥." +disableFederationOk: "연합을 끄기" +invitationRequiredToRegister: "현재 이 서버는 비공개다냥. 회원가입을 하려면 초대 코드가 필요하다냥!" +emailNotSupported: "이 서버에서는 메일 전송을 지원하지 않는다냥." +postToTheChannel: "채널에 게시하기" +cannotBeChangedLater: "냐중에 변경할 수 없다냥." +reactionAcceptance: "리액션 수신" +likeOnly: "좋아요만 받기" +likeOnlyForRemote: "리모트에서는 좋아요만 받기" +nonSensitiveOnly: "민감한 이모지를 제외하고 받기" +nonSensitiveOnlyForLocalLikeOnlyForRemote: "민감한 이모지를 제외하고 받기(리모트에서는 좋아요만 받기)" +rolesAssignedToMe: "냐에게 할당된 역할" +resetPasswordConfirm: "정말로 비밀번호를 재설정할거냥?" +sensitiveWords: "민감한 단어" +sensitiveWordsDescription: "설정한 단어가 포함된 노트의 공개 범위를 '홈'으로 강제한다냥. 개행으로 구분하여 여러 개를 지정할 수 있다냥." +sensitiveWordsDescription2: "공백으로 구분하면 AND 지정이 되며, 키워드를 슬래시로 둘러싸면 정규 표현식이 된다냥." +prohibitedWords: "금지 워드" +prohibitedWordsDescription: "설정된 워드가 포함되는 노트를 작성하려고 하면, 에러가 발생하도록 한다냥. 줄바꿈으로 구분지어 복수 설정할 수 있다냥." +prohibitedWordsDescription2: "공백으로 구분하면 AND 지정이 되며, 키워드를 슬래시로 둘러싸면 정규 표현식이 된다냥." +hiddenTags: "숨긴 해시태그" +hiddenTagsDescription: "설정한 태그를 트렌드에 표시하지 않도록 한다냥. 줄 바꿈으로 하냐씩 냐눠서 설정할 수 있다냥." +notesSearchNotAvailable: "노트 검색을 이용할 수 없다냥." +license: "라이선스" +unfavoriteConfirm: "즐겨찾기를 해제할거냥?" +myClips: "내 클립" +drivecleaner: "드라이브 정리" +retryAllQueuesNow: "모든 큐를 다시 시도" +retryAllQueuesConfirmTitle: "지금 다시 시도할 것이냥?" +retryAllQueuesConfirmText: "일시적으로 서버의 부하가 증가할 수 있다냥." +enableChartsForRemoteUser: "리모트 유저의 차트를 생성" +enableChartsForFederatedInstances: "리모트 서버의 차트를 생성" +showClipButtonInNoteFooter: "노트 동작에 클립을 추가" +reactionsDisplaySize: "리액션 표시 크기" +limitWidthOfReaction: "리액션의 최대 폭을 제한하고 작게 표시하기" +noteIdOrUrl: "노트 ID 및 URL" +video: "동영상" +videos: "동영상" +audio: "소리" +audioFiles: "소리" +dataSaver: "데이터 절약 모드" +accountMigration: "계정 이동" +accountMoved: "이 고양이는 다음 계정으로 이사했다냥:" +accountMovedShort: "이사한 계정이다냥." +operationForbidden: "사용할 수 없다냥..." +forceShowAds: "광고를 항상 표시" +addMemo: "메모 추가" +editMemo: "메모 편집" +reactionsList: "리액션 목록" +renotesList: "리노트 목록" +notificationDisplay: "알림 표시" +leftTop: "왼쪽 상단" +rightTop: "오른쪽 상단" +leftBottom: "왼쪽 하단" +rightBottom: "오른쪽 하단" +stackAxis: "냐열 방향" +vertical: "세로" +horizontal: "가로" +position: "위치" +serverRules: "서버 규칙" +pleaseConfirmBelowBeforeSignup: "이 서버에 가입하기 전에 아래 사항을 꼭 확인해달라냥." +pleaseAgreeAllToContinue: "계속하시려면 모든 항목에 동의해달라냥." +continue: "계속" +preservedUsernames: "예약된 사용자명" +preservedUsernamesDescription: "예약할 사용자명을 한 줄에 하냐씩 입력한다냥. 여기에서 지정한 사용자명으로는 계정을 생성할 수 없게 된다냥. 단, 관리자 권한으로 계정을 생성할 때에는 해당되지 않으며, 이미 존재하는 계정도 영향을 받지 않는다냥." +createNoteFromTheFile: "이 파일로 노트를 작성" +archive: "아카이브" +channelArchiveConfirmTitle: "{name} 을(를) 아카이브할 것이냥?" +channelArchiveConfirmDescription: "아카이브한 채널은 채널 목록과 검색 결과에 표시되지 않으며, 채널에 새로운 노트를 작성할 수 없게 된다냥." +thisChannelArchived: "이 채널은 아카이브되었다냥." +displayOfNote: "노트 표시" +initialAccountSetting: "초기 설정" +youFollowing: "팔로잉" +preventAiLearning: "기계학습(생성형 AI)으로의 사용을 거부" +preventAiLearningDescription: "외부의 문장 생성 AI냐 이미지 생성 AI에 대해 제출한 노트냐 이미지 등의 콘텐츠를 학습의 대상으로 사용하지 않도록 요구한다냥. 다만, 이 요구사항을 지킬 의무는 없기 때문에 학습을 완전히 방지하는 것은 아니다냥." +options: "옵션" +specifyUser: "사용자 지정" +failedToPreviewUrl: "미리 볼 수 없음" +update: "업데이트" +rolesThatCanBeUsedThisEmojiAsReaction: "이 이모지를 리액션으로 사용할 수 있는 역할" +rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription: "역할을 지정하지 않으면, 누구냐 이 이모지를 리액션으로 사용할 수 있다냥." +rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn: "역할은 공개로 설정되어 있어냥 한다냥." +cancelReactionConfirm: "정말 리액션을 취소할거냥?" +changeReactionConfirm: "앗, 리액션을 변경할거냥?" +later: "냐중에" +goToMisskey: "Misskey로" +additionalEmojiDictionary: "이모지 추가 사전" +installed: "설치됨" +branding: "브랜딩" +enableServerMachineStats: "서버의 머신 사양을 공개하기" +enableIdenticonGeneration: "유저마다의 Identicon 생성 유효화" +turnOffToImprovePerformance: "이 기능을 끄면 성능이 향상될 수도 있다냥." +createInviteCode: "초대 코드 생성" +createWithOptions: "옵션을 지정하여 생성" +createCount: "초대 수" +inviteCodeCreated: "초대 코드 생성됨" +inviteLimitExceeded: "초대 코드 생성 한도를 초과했다냥..." +createLimitRemaining: "초대 한도: {limit}회 냠음" +inviteLimitResetCycle: " {time}시간 이내에 최대 {limit}개의 초대 코드를 생성할 수 있다냥." +expirationDate: "만료 냘짜" +noExpirationDate: "만료기간 없음" +inviteCodeUsedAt: "다음에 사용된 초대 코드" +registeredUserUsingInviteCode: "초대 코드 사용 대상" +waitingForMailAuth: "이메일 인증 보류 중" +inviteCodeCreator: "초대 코드 생성자" +usedAt: "사용 시각" +unused: "사용되지 않음" +used: "사용됨" +expired: "만료됨" +doYouAgree: "동의하는 것이냥?" +beSureToReadThisAsItIsImportant: "중요하니까 반드시 읽어달라냥." +iHaveReadXCarefullyAndAgree: "\"{x}\"의 내용을 읽고 동의한다냥." +dialog: "다이얼로그" +icon: "아바타" +forYou: "냐에게" +currentAnnouncements: "현재 공지사항" +pastAnnouncements: "과거 공지사항" +youHaveUnreadAnnouncements: "읽지 않은 공지사항이 있다냥." +useSecurityKey: "브라우저 또는 기기의 안내에 따라 보안 키 또는 패스키를 사용해 달라냥." +replies: "답글" +renotes: "리노트" +loadReplies: "답글 보기" +loadConversation: "대화 보기" +pinnedList: "고정된 리스트" +keepScreenOn: "기기 화면을 항상 켜기" +verifiedLink: "이 링크의 소유자임이 확인되었다냥." +notifyNotes: "새 노트 알림 켜기" +unnotifyNotes: "새 노트 알림 끄기" +authentication: "인증" +authenticationRequiredToContinue: "계속하려면 인증해달라냥" +dateAndTime: "일시" +showRenotes: "리노트 표시" +edited: "수정됨" +notificationRecieveConfig: "알림 설정" +mutualFollow: "맞팔로우" +fileAttachedOnly: "미디어를 포함한 노트만" +showRepliesToOthersInTimeline: "타임라인에 다른 사람에게 보내는 답글을 포함" +hideRepliesToOthersInTimeline: "타임라인에 다른 사람에게 보내는 답글을 포함하지 않음" +showRepliesToOthersInTimelineAll: "타임라인에 현재 팔로우 중인 사람 전원의 답글을 포함하게 하기" +hideRepliesToOthersInTimelineAll: "타임라인에 현재 팔로우 중인 사람 전원의 답글이 냐오지 않게 하기" +confirmShowRepliesAll: "이 조작은 되돌릴 수 없다냥! 정말로 타임라인에 현재 팔로우 중인 사람 전원의 답글이 냐오게 할거냥?" +confirmHideRepliesAll: "이 조작은 되돌릴 수 없다냥! 정말로 타임라인에 현재 팔로우 중인 모두의 답글이 냐오지 않게 할거냥?" +externalServices: "외부 서비스" +sourceCode: "소스 코드" +sourceCodeIsNotYetProvided: "소스 코드를 아직 제공하지 않고 있다냥. 이 문제를 해결하려면 관리자에게 문의해 달라냥." +repositoryUrl: "저장소 URL" +repositoryUrlDescription: "소스 코드를 공개한 저장소가 있는 경우, 그 URL을 적으면 된다냥. Misskey를 원본 그대로 (소스 코드를 어떤 식으로도 변경하지 않고) 쓰고 있는 경우 https://github.com/misskey-dev/misskey 라고 적으면 끝이다냥." +repositoryUrlOrTarballRequired: "저장소를 공개하지 않은 경우 대신 tarball을 제공할 필요가 있다냥. 세부사항은 .config/example.yml을 참조해 달라냥!" +feedback: "피드백" +feedbackUrl: "피드백 URL" +impressum: "운영자 정보" +impressumUrl: "운영자 정보 URL" +impressumDescription: "독일 등의 일부 냐라와 지역에서는 꼭 표시해냥 한다냥(Impressum)." +privacyPolicy: "개인정보 보호 정책" +privacyPolicyUrl: "개인정보 보호 정책 URL" +tosAndPrivacyPolicy: "약관 및 개인정보 보호 정책" +avatarDecorations: "아바타 장식" +attach: "붙이기" +detach: "빼기" +detachAll: "모두 빼기" +angle: "각도" +flip: "플립" +showAvatarDecorations: "아바타 장식 표시" +releaseToRefresh: "놓아서 새로고침" +refreshing: "새로고침 중" +pullDownToRefresh: "아래로 내려서 새로고침" +disableStreamingTimeline: "타임라인의 실시간 갱신을 무효화하기" +useGroupedNotifications: "알림을 그룹화하고 표시" +signupPendingError: "메일 주소 확인중에 문제가 발생했다냥. 링크의 유효기간이 지났을 가능성도 있다냥." +cwNotationRequired: "'내용을 숨기기'를 체크한 경우 주석을 써냥 한다냥." +doReaction: "리액션 추가" +code: "문자열" +reloadRequiredToApplySettings: "설정을 적용하려면 새로고침을 해냥 한다냥." +remainingN: "냐머지: {n}" +overwriteContentConfirm: "현재 내용을 덮어쓰기 할거다냥. 계속 진행해도 괜찮냥?" +seasonalScreenEffect: "계절에 따른 효과 보이기" +decorate: "장식하기" +addMfmFunction: "장식 추가하기" +enableQuickAddMfmFunction: "상급자용 MFM 선택기 표시하기" +bubbleGame: "버블 게임" +sfx: "효과음" +soundWillBePlayed: "소리가 재생된다냥" +showReplay: "리플레이 보기" +replay: "리플레이" +replaying: "리플레이 중" +ranking: "랭킹" +lastNDays: "최근 {n}일" +backToTitle: "타이틀로 가기" +hemisphere: "거주 지역" +withSensitive: "민감한 파일이 포함된 노트 보기" +userSaysSomethingSensitive: "{name}의 민감한 파일이 포함된 게시물" +enableHorizontalSwipe: "스와이프하여 탭 전환" +surrender: "그만두기" +_bubbleGame: + howToPlay: "설명" + _howToPlay: + section1: "위치를 조정하여 상자에 버블을 떨어뜨린다냥." + section2: "같은 종류의 버블이 붙으면 다른 버블로 바뀌면서 점수를 얻게 된다냥." + section3: "상자에서 물건이 넘치면 게임 오버다냥. 상자에서 물건이 넘치지 않도록 하면서 물건을 융합하여 높은 점수를 획득하면 된다냥!" +_announcement: + forExistingUsers: "기존 유저에게만 알림" + forExistingUsersDescription: "활성화하면 이 공지사항을 게시한 시점에서 이미 가입한 유저에게만 표시한다냥. 비활성화하면 게시 후에 가입한 유저에게도 표시된다냥." + needConfirmationToRead: "읽음으로 표시하기 전에 확인하기" + needConfirmationToReadDescription: "활성화하면 이 공지사항을 읽음으로 표시하기 전에 확인 알림창을 띄운다냥. '모두 읽음'의 대상에서도 제외된다냥." + end: "공지에서 내리기" + tooManyActiveAnnouncementDescription: "공지사항이 너무 많을 경우, 사용자 경험에 영향을 끼칠 가능성이 있다냥. 오래된 공지사항은 아카이브하시는 것을 권장한다냥." + readConfirmTitle: "읽음으로 표시할 것이냥?" + readConfirmText: "\"{title}\"을(를) 읽음으로 표시한다냥." + shouldNotBeUsedToPresentPermanentInfo: "신규 유저의 이용 경험에 악영향을 끼칠 수 있으므로, 일시적인 알림 수단으로만 사용하고 고정된 정보에는 사용을 지양하는 것을 추천한다냥." + dialogAnnouncementUxWarn: "다이얼로그 형태의 알림이 동시에 2개 이상 존재하는 경우, 사용자 경험에 악영향을 끼칠 수 있으므로 신중히 결정해달라냥." + silence: "조용히 알림" + silenceDescription: "활성화하면 공지사항에 대한 알림이 가지 않게 되며, 확인 버튼을 누를 필요가 없게 된다냥." +_initialAccountSetting: + accountCreated: "계정 생성이 완료되었다냥!" + letsStartAccountSetup: "계정의 초기 설정을 진행한다냥." + letsFillYourProfile: "우선 사용자의 프로필을 설정해 달라냥." + profileSetting: "프로필 설정" + privacySetting: "프라이버시 설정" + theseSettingsCanEditLater: "이 설정들은 냐중에도 변경할 수 있다냥." + youCanEditMoreSettingsInSettingsPageLater: "이 외에도 '설정' 페이지에서 다양한 설정을 냐의 입맛에 맞게 조절할 수 있다냥. 꼭 확인해 달라냥!" + followUsers: "관심사가 맞는 유저를 팔로우하여 타임라인을 가꾸면 된다냥." + pushNotificationDescription: "푸시 알림을 활성화하면 {name}의 알림을 냐의 기기에서 받아볼 수 있게 된다냥." + initialAccountSettingCompleted: "초기 설정을 모두 마쳤다냥!" + haveFun: "{name}와 함께 즐거운 시간 보내라냥!" + youCanContinueTutorial: "이대로 {name}(Misskey)의 사용법에 대해 튜토리얼을 진행할 수도 있지만, 여기서 중단하고 바로 시작할 수도 있다냥." + startTutorial: "튜토리얼 시작" + skipAreYouSure: "정말로 초기 설정을 중단할 거냥?" + laterAreYouSure: "초기 설정을 냐중에 진행할 것이냥?" +_initialTutorial: + launchTutorial: "튜토리얼 보기" + title: "튜토리얼" + wellDone: "잘했다냥!" + skipAreYouSure: "튜토리얼을 종료할 거냥?" + _landing: + title: "튜토리얼에 오신 걸 환영한다냥!" + description: "여기서는 미스키의 기본적인 사용법이냐 기능을 확인할 수 있다냥." + _note: + title: "'노트'가 무엇인가요?" + description: "미스키에서는 게시물을 '노트'라고 합니다. 노트는 타임라인에 시간순으로 정렬되어 있고, 사용자가 관련 설정을 끄지 않는 한 실시간으로 갱신된다냥." + reply: "답글을 달 수도 있다냥. 답글에 답글을 달 수도 있고 글타래처럼 대화를 이어갈 수 있다냥." + renote: "리노트로 노트를 자기 타임라인에 가져와서 공유하는 것이 가능하다냥. 글을 추가해서 인용하는 것도 역시 가능하다냥." + reaction: "리액션을 다는 것이 된다냥. 다음 페이지에서 자세한 설명을 볼 수 있다냥." + menu: "노트의 상세 정보를 표시하거냐, 링크를 복사하는 등의 다양한 조작을 할 수 있다냥." + _reaction: + title: "'리액션'이 무엇인가요?" + description: "노트에 '리액션'을 보낼 수 있다냥! '좋아요'만으로는 충분히 전해지지 않는 감정을, 이모지에 실어서 가볍게 보낼 수 있다냥." + letsTryReacting: "리액션은 노트의 '+' 버튼을 클릭하여 붙일 수 있다냥. 지금 표시되는 샘플 노트에 리액션을 달아 보는건 어떨까냥?" + reactToContinue: "다음으로 진행하려면 리액션을 달아달라냥." + reactNotification: "누군가가 냐의 노트에 리액션을 보내면 실시간으로 알림을 받게 된다냥." + reactDone: "'-' 버튼을 눌러서 리액션을 취소할 수도 있다냥." + _timeline: + title: "타임라인에 대하여" + description1: "Misskey에는 종류에 따라 여러 가지의 타임라인으로 구성되어 있다냥.(서버에 따라서는 일부 타임라인을 사용할 수 없는 경우도 있다냥)" + home: "'내가 팔로우 중인 계정'의 노트를 볼 수 있다냥." + local: "'이 서버에 있는 모든 유저'의 게시물을 볼 수 있다냥." + social: "'홈 타임라인과 로컬 타임라인'의 게시물을 모두 볼 수 있다냥." + global: "'이 서버와 연결되어 있는 모든 인스턴스'의 게시물을 볼 수 있다냥." + description2: "각각의 타임라인은 화면 상단에서 언제든지 변경할 수 있다냥." + description3: "이 외에도, '리스트 타임라인'이냐 '채널 타임라인' 등이 있다냥. 자세한 사항은 {link}에서 확인할 수 있다냥." + _postNote: + title: "노트 게시 설정" + description1: "Misskey에 노트를 쓸 때에는 다양한 옵션을 설정할 수 있다냥. 노트를 작성하는 화면은 이렇게 생겨먹었다냥." + _visibility: + description: "노트를 볼 수 있는 사람을 제한할 수 있다냥." + public: "모든 유저에게 공개된다냥." + home: "홈 타임라인에만 공개된다냥. 팔로워, 프로필 화면, 리노트를 통해서 다른 유저가 볼 수 있다냥." + followers: "팔로워에게만 공개. 자기 자신을 제외하고는 리노트가 불가능하며, 팔로워 외에는 열람할 수 없다냥." + direct: "지정한 유저에게만 공개되며, 상대방에게 알림이 간다냥. 다이렉트 메시지(DM)의 대응되는 기능이다냥." + doNotSendConfidencialOnDirect1: "민감한 정보를 보낼 때에는 주의해달라냥." + doNotSendConfidencialOnDirect2: "서버 관리자는 기술적으로는 게시물 내용을 열람이 가능하다냥. 신뢰할 수 없는 서버의 유저에게 다이렉트 메시지를 보내는 경우, 민감한 정보가 포함되어 있는 지 확인하고 전송해달라냥." + localOnly: "다른 서버에 게시물을 보내지 않는다냥. 앞서 설정한 공개 범위와 상관 없이, 다른 서버의 유저는 이 게시물을 직접 열람할 수 없게 된다냥." + _cw: + title: "내용 가리기 (CW)" + description: "본문 대신에 '내용에 대한 주석'에 입력한 텍스트가 먼저 표시된다냥. '더 보기' 버튼을 누르면 본문이 표시된다냥." + _exampleNote: + cw: "이 노트는 R-18을 포함하고 있음" + note: "완전 정말 엄청나게 엣찌한 내용의 노트다냥... 👀" + useCases: "서버의 가이드라인에 따라 특정 주제를 다룰 때에 사용하거냐, 스포일러 및 민감한 화제를 다룰 때에 사용하면 된다냥." + _howToMakeAttachmentsSensitive: + title: "첨부 파일을 열람주의로 설정하려면?" + description: "서버의 가이드라인에 따라 필요한 이미지, 또는 그대로 노출되기에 부적절한 미디어는 '열람 주의'를 꼭 설정해 달라냥." + tryThisFile: "이 작성 창에 첨부된 이미지를 열람 주의로 바꿔 달라냥." + _exampleNote: + note: "냣또 뚜껑을 뜯다가 실수했다냥…" + method: "첨부 파일을 열람 주의로 설정하려면, 해당 파일을 클릭하여 메뉴를 열고, '열람주의로 설정'을 클릭하면 된다냥." + sensitiveSucceeded: "파일을 첨부할 때에는 서버의 가이드라인에 따라 적절히 열람주의를 설정하면 되겠다냥." + doItToContinue: "이미지를 열람 주의로 설정하면 다음으로 넘어갈 수 있게 된다냥." + _done: + title: "튜토리얼이 끝났다냥! 🎉" + description: "여기에서 소개한 기능은 극히 일부에 지냐지 않는다냥. Misskey의 사용 방법을 더 자세히 알아보려면 {link}를 확인해 달라냥!" +_timelineDescription: + home: "홈 타임라인에서는, 내가 팔로우한 계정의 게시물을 볼 수 있다냥." + local: "로컬 타임라인에서는, 이 서버의 모든 유저의 게시물을 볼 수 있다냥." + social: "소셜 타임라인에서는, 홈 타임라인과 로컬 타임라인의 게시물을 모두 볼 수 있다냥." + global: "글로벌 타임라인에서는, 여기와 연결된 다른 모든 서버의 게시물을 볼 수 있다냥." +_serverRules: + description: "회원 가입 이전에 간단하게 표시할 서버 규칙이다냥. 이용 약관의 요약으로 구성하는 것을 추천한다냥." +_serverSettings: + iconUrl: "아이콘 URL" + appIconDescription: "{host}이 앱으로 표시될 때의 아이콘을 지정한다냥." + appIconUsageExample: "예를 들어, PWA냐 스마트폰 홈 화면에 북마크로 추가되었을 때 등" + appIconStyleRecommendation: "아이콘이 원형 또는 둥근 사각형으로 잘리는 경우가 있으므로, 가장자리 여백이 충분한 사진을 사용하는 것을 추천한다냥." + appIconResolutionMustBe: "해상도는 반드시 {resolution} 이어냥 한다냥." + manifestJsonOverride: "manifest.json 오버라이드" + shortName: "약칭" + shortNameDescription: "서버의 정식 명칭이 긴 경우에, 대신에 표시할 수 있는 약칭이냐 통칭." + fanoutTimelineDescription: "활성화하면 각종 타임라인을 가져올 때의 성능을 대폭 향상하며, 데이터베이스의 부하를 줄일 수 있다냥. 단, Redis의 메모리 사용량이 증가한다냥. 서버의 메모리 용량이 작거냐, 서비스가 불안정해지는 경우 비활성화할 수 있다냥." + fanoutTimelineDbFallback: "데이터베이스를 예비로 사용하기" + fanoutTimelineDbFallbackDescription: "활성화하면 타임라인의 캐시되어 있지 않은 부분에 대해 DB에 질의하여 정보를 가져온다냥. 비활성화하면 이를 실행하지 않음으로써 서버의 부하를 줄일 수 있지만, 타임라인에서 가져올 수 있는 게시물 범위가 한정된다냥." +_accountMigration: + moveFrom: "다른 계정에서 이 계정으로 이사" + moveFromSub: "다른 계정에 대한 별칭을 생성" + moveFromLabel: "기존 계정 #{n}" + moveFromDescription: "다른 계정에서 이 계정으로 팔로워를 가져오려면, 우선 여기에서 별칭을 지정해냥 한다냥. '반드시 이사하기 전에 지정해냥 한다냥!' 기존 계정을 다음과 같은 형식으로 입력해 달라냥: @person@instance.com" + moveTo: "이 계정에서 다른 계정으로 이사" + moveToLabel: "이사할 계정:" + moveCannotBeUndone: "한 번 이사하면, 두 번 다시 되돌릴 수 없다냥." + moveAccountDescription: "새 계정으로 이전한다냥.\n ・팔로워가 새 계정을 자동으로 팔로우 합니다\n ・이 계정에서 팔로우는 모두 해제된다냥\n ・이 계정으로는 노트 작성 등을 할 수 없게 된다냥\n\n팔로워는 자동으로 이전되지만, 팔로우는 수동으로 진행해냥 한다냥. 이전하기 전에 이 계정에서 팔로우를 내보내고, 이전 후에는 즉시 이전한 계정에서 가져오기를 진행해달라냥.\n리스트・뮤트・차단에 대해서도 마찬가지이므로 수동으로 이전해냥 한다냥.\n\n(이 설명은 이 서버(Misskey v13.12.0 이후)의 사양이다냥. Mastodon 등의 다른 ActivityPub 소프트웨어에서는 작동이 다를 수 있다냥.)" + moveAccountHowTo: "계정을 이사하려면 우선 이사갈 계정에서 이 계정에 대한 별칭을 지정해냥 합니다.\n별칭을 작성한 다음, 이사갈 계정을 다음과 같이 입력하십시오:\n@username@server.example.com" + startMigration: "이사하기" + migrationConfirm: "정말로 이 계정을 {account} 으로 이전할 것이냥? 한 번 이전한 다음에는 취소할 수 없고, 두 번 다시 원래 상태로 복구할 수 없다냥.\n이사할 계정에서 계정 별칭을 지정하였는지 다시 한 번 확인해달라냥." + movedAndCannotBeUndone: "\n이사한 계정이다냥.\n이사는 취소할 수 없다냥." + postMigrationNote: "이 계정의 팔로잉 해제는 이사 후 24시간 뒤에 실행된다냥.\n이 계정의 팔로우 및 팔로워 수는 0으로 표시된다냥. 팔로워 해제는 이루어지지 않으므로, 당신의 팔로워는 이 계정의 팔로워 한정 게시물을 계속해서 열람할 수 있다냥." + movedTo: "이사할 계정:" +_achievements: + earnedAt: "달성 일시" + _types: + _notes1: + title: "미스키 계정 만들었다냥" + description: "첫 노트를 작성했다냥" + flavor: "Misskey에 어서 오세요!" + _notes10: + title: "몇 가지 노트" + description: "10개의 노트를 작성했다냥" + flavor: "화끈한 화제" + _notes100: + title: "많은 노트" + description: "100개의 노트를 작성했다냥" + _notes500: + title: "노트 범벅" + description: "500개의 노트를 작성했다냥" + _notes1000: + title: "노트가 산더미" + description: "1,000개의 노트를 작성했다냥" + _notes5000: + title: "솟아냐는 노트" + description: "5,000개의 노트를 작성했다냥" + _notes10000: + title: "슈퍼 노트" + description: "10,000개의 노트를 작성했다냥" + _notes20000: + title: "노트가 필요해요" + description: "20,000개의 노트를 작성했다냥" + _notes30000: + title: "노트노트노트" + description: "30,000개의 노트를 작성했다냥" + _notes40000: + title: "노트 공장" + description: "40,000개의 노트를 작성했다냥" + _notes50000: + title: "노트 행성" + description: "50,000개의 노트를 작성했다냥" + _notes60000: + title: "노트 퀘이사" + description: "60,000개의 노트를 작성했다냥" + _notes70000: + title: "노트 블랙홀" + description: "70,000개의 노트를 작성했다냥" + _notes80000: + title: "노트 은하" + description: "80,000개의 노트를 작성했다냥" + _notes90000: + title: "노트 우주" + description: "90,000개의 노트를 작성했다냥" + _notes100000: + title: "ALL YOUR NOTE ARE BELONG TO US" + description: "100,000개의 노트를 작성했다냥" + flavor: "이렇게냐 쓸 게 있어요?" + _login3: + title: "초보자 I" + description: "총 로그인한 냘이 3일" + flavor: "오늘부터 여러분도 미스키스트다냥!" + _login7: + title: "초보자 II" + description: "총 로그인한 냘이 7일" + flavor: "슬슬 익숙해진거냥?" + _login15: + title: "초보자 III" + description: "총 로그인한 냘이 15일" + _login30: + title: "미스키스트 I" + description: "총 로그인한 냘이 30일" + _login60: + title: "미스키스트 II" + description: "총 로그인한 냘이 60일" + _login100: + title: "미스키스트 III" + description: "총 로그인한 냘이 100일" + flavor: "그 유저, 미스키스트이다" + _login200: + title: "단골 I" + description: "총 200일간 로그인했다냥" + _login300: + title: "단골 II" + description: "총 300일간 로그인했다냥" + _login400: + title: "단골 III" + description: "총 400일간 로그인했다냥" + _login500: + title: "베테랑 I" + description: "총 500일간 로그인했다냥" + flavor: "제군, 냐는 노트가 좋다냥." + _login600: + title: "베테랑 II" + description: "총 600일간 로그인했다냥" + _login700: + title: "베테랑 III" + description: "총 700일간 로그인했다냥" + _login800: + title: "노트 마스터 I" + description: "총 800일간 로그인했다냥" + _login900: + title: "노트 마스터 II" + description: "총 900일간 로그인했다냥" + _login1000: + title: "노트 마스터 III" + description: "총 1,000일간 로그인했다냥" + flavor: "Misskey를 열심히 사용해 주어서 감사하다냥...!" + _noteClipped1: + title: "클립할 수밖에 없었어" + description: "처음으로 노트를 클립했다냥" + _noteFavorited1: + title: "별을 바라보는 자" + description: "처음으로 노트를 즐겨찾기했다냥" + _myNoteFavorited1: + title: "별을 원하는 자" + description: "다른 사람이 당신의 노트를 즐겨찾기했다냥" + _profileFilled: + title: "준비 완료" + description: "프로필 설정을 완료했다냥" + flavor: "#가보자고" + _markedAsCat: + title: "냐는 고양이다냥!" + description: "계정을 고양이로 설정했다냥" + flavor: "냐냐냐냐냐냐아아아아앙!" + _following1: + title: "첫 팔로우" + description: "사용자를 처음으로 팔로우했다냥" + _following10: + title: "팔로우, 팔로우" + description: "10명의 사용자를 팔로우했다냥" + _following50: + title: "친구 잔뜩" + description: "50명의 사용자를 팔로우했다냥" + _following100: + title: "주소록 한 권으론 부족해" + description: "100명의 사용자를 팔로우했다냥" + _following300: + title: "친구가 넘쳐냐" + description: "300명의 사용자를 팔로우했다냥" + _followers1: + title: "첫 팔로워" + description: "사용자가 처음으로 팔로잉했다냥" + _followers10: + title: "팔로우 미!" + description: "10명의 사용자가 팔로우했다냥" + _followers50: + title: "이곳저곳" + description: "50명의 사용자가 팔로우했다냥" + _followers100: + title: "인기왕" + description: "100명의 사용자가 팔로우했다냥" + _followers300: + title: "줄 좀 서봐요" + description: "100명의 사용자가 팔로우했다냥" + _followers500: + title: "기지국" + description: "500명의 사용자가 팔로우했다냥" + _followers1000: + title: "유명인사" + description: "1,000명의 사용자가 팔로우했다냥" + _collectAchievements30: + title: "도전 과제 콜렉터" + description: "30개의 도전과제를 획득했다냥" + _viewAchievements3min: + title: "저 도전과제 좋아해요" + description: "도전 과제 목록을 3분 이상 쳐다봤다냥" + _iLoveMisskey: + title: "I Love Misskey" + description: "\"I ❤ #Misskey\"를 포스트했다냥" + flavor: "Misskey를 이용해주셔서 감사합니다! - 개발팀 일동" + _foundTreasure: + title: "보물찾기" + description: "숨겨진 보물을 발견했다냥" + _client30min: + title: "잠시 쉬어요" + description: "클라이언트를 시작하고 30분이 경과했다냥..." + _client60min: + title: "No \"Miss\" in Misskey" + description: "클라이언트를 시작하고 60분이 결과했다냥...!" + _noteDeletedWithin1min: + title: "있었는데, 없었다냥." + description: "노트를 포스트한 후 1분 이내에 삭제했다냥" + _postedAtLateNight: + title: "올빼미" + description: "한밤중에 노트를 포스트했다냥" + flavor: "잠 좀 자야하는 거 아니냥? 걱정된다냥." + _postedAt0min0sec: + title: "정각" + description: "0분 0초 정각에 노트를 작성했다냥" + flavor: "째깍 째깍 째깍 땡!" + _selfQuote: + title: "혼잣말" + description: "자기 노트를 인용했다냥" + _htl20npm: + title: "타임라인 폭주 중" + description: "1분 사이에 홈 타임라인에 노트가 20개 넘게 생성되었다냥" + _viewInstanceChart: + title: "애널리스트" + description: "서버의 차트를 열었다냥" + _outputHelloWorldOnScratchpad: + title: "Hello, world!" + description: "스크래치패드에서 hello world를 출력했다냥" + _open3windows: + title: "멀티 윈도우" + description: "3개 이상의 창을 열었다냥" + _driveFolderCircularReference: + title: "순환 참조" + description: "드라이브 폴더를 자신을 가리키도록 만드려 시도했다냥" + _reactWithoutRead: + title: "읽고 답하긴 하시는 건가요?" + description: "100자가 넘는 노트가 작성되고 3초 안에 반응했다냥" + _clickedClickHere: + title: "여기를 눌러달라냥" + description: "여기를 눌렀다냥" + _justPlainLucky: + title: "그냥 운이 좋았어" + description: "매 10초마다 0.01%의 확률로 달성된다냥?!" + _setNameToSyuilo: + title: "신 콤플렉스" + description: "이름을 syuilo로 설정했다냥" + _passedSinceAccountCreated1: + title: "1주년" + description: "계정을 생성하고 1년이 지났다냥" + _passedSinceAccountCreated2: + title: "2주년" + description: "계정을 생성하고 2년이 지났다냥" + _passedSinceAccountCreated3: + title: "3주년" + description: "계정을 생성하고 3년이 지났다냥" + _loggedInOnBirthday: + title: "생일 축하합니다!" + description: "생일에 로그인했다냥" + _loggedInOnNewYearsDay: + title: "새해 복 많이 받으세요" + description: "새해 첫 냘에 로그인했다냥" + flavor: "올해에도 우리 서버에 관심을 가져 주셔서 감사하다냥." + _cookieClicked: + title: "쿠키를 클릭하는 게임" + description: "쿠키를 클릭했다냥" + flavor: "소프트웨어를 착각하지는 않았냥??" + _brainDiver: + title: "Brain Diver" + description: "Brain Diver로의 링크를 첨부했다냥" + flavor: "Misskey-Misskey La-Tu-Ma" + _smashTestNotificationButton: + title: "테스트 과잉" + description: "매우 짧은 시간 안에 알림 테스트를 여러 번 수행했다냥" + _tutorialCompleted: + title: "Misskey 입문자 과정 수료증" + description: "튜토리얼을 완료했다냥" + _bubbleGameExplodingHead: + title: "🤯" + description: "버블 게임에서 가장 큰 버블을 만들었다냥!" + _bubbleGameDoubleExplodingHead: + title: "더블 🤯" + description: "버블게임에서 가장 큰 물건 2개를 동시에 만들었다냥?!." + flavor: "이 정도만 도시락통에 🤯 🤯 조금만 더" +_role: + new: "새 역할 생성" + edit: "역할 수정" + name: "역할 이름" + description: "역할 설명" + permission: "역할 권한" + descriptionOfPermission: "조정자(모더레이터)는 기본적인 조정 작업을 진행할 수 있다냥.\n관리자는 서버의 모든 설정을 변경할 수 있다냥." + assignTarget: "할당 대상" + descriptionOfAssignTarget: "수동을 선택하면 누가 이 역할에 포함되는지를 수동으로 관리할 수 있다냥.\n조건부를 선택하면 조건을 설정해 일치하는 사용자를 자동으로 포함되게 할 수 있다냥." + manual: "수동" + manualRoles: "수동 역할" + conditional: "조건부" + conditionalRoles: "조건부 역할" + condition: "조건" + isConditionalRole: "조건부 역할입니다." + isPublic: "역할 공개" + descriptionOfIsPublic: "역할에 할당된 사용자를 누구냐 볼 수 있다냥. 또한 사용자 프로필에 이 역할이 표시된다냥." + options: "옵션" + policies: "정책" + baseRole: "기본 역할" + useBaseValue: "기본값 사용" + chooseRoleToAssign: "할당할 역할 선택" + iconUrl: "아이콘 URL" + asBadge: "뱃지로 표시" + descriptionOfAsBadge: "활성화하면 유저명 옆에 역할의 아이콘이 표시된다냥." + isExplorable: "역할 타임라인 공개" + descriptionOfIsExplorable: "활성화하면 역할 타임라인을 공개합니다. 비활성화 시 타임라인이 공개되지 않습니다." + displayOrder: "표시 순서" + descriptionOfDisplayOrder: "값이 클 수록 UI에서 먼저 표시된다냥." + canEditMembersByModerator: "모더레이터의 역할 수정 허용" + descriptionOfCanEditMembersByModerator: "이 옵션을 켜면 모더레이터도 이 역할에 사용자를 할당하거냐 삭제할 수 있다냥. 꺼져 있으면 관리자만 할당이 가능하다냥." + priority: "우선순위" + _priority: + low: "냦음" + middle: "보통" + high: "높음" + _options: + gtlAvailable: "글로벌 타임라인 보이기" + ltlAvailable: "로컬 타임라인 보이기" + canPublicNote: "공개 노트 허용" + canInvite: "서버 초대 코드 발행" + inviteLimit: "초대 한도" + inviteLimitCycle: "초대 발급 간격" + inviteExpirationTime: "초대 만료 기간" + canManageCustomEmojis: "커스텀 이모지 관리" + canManageAvatarDecorations: "아바타 꾸미기 관리" + driveCapacity: "드라이브 용량" + alwaysMarkNsfw: "파일을 항상 NSFW로 지정" + pinMax: "고정할 수 있는 노트 수" + antennaMax: "최대 안테냐 생성 허용 수" + wordMuteMax: "단어 뮤트할 수 있는 문자 수" + webhookMax: "생성할 수 있는 웹훅 수" + clipMax: "생성할 수 있는 클립 수" + noteEachClipsMax: "각 클립에 추가할 수 있는 노트 수" + userListMax: "생성할 수 있는 유저 리스트 수" + userEachUserListsMax: "유저 리스트당 최대 사용자 수" + rateLimitFactor: "요청 빈도 제한" + descriptionOfRateLimitFactor: "작을수록 제한이 완화되고, 클수록 제한이 강화됩니다." + canHideAds: "광고 숨기기" + canSearchNotes: "노트 검색 이용 가능 여부" + canUseTranslator: "번역 기능의 사용" + avatarDecorationLimit: "아바타 장식의 최대 붙임 개수" + _condition: + isLocal: "로컬 사용자" + isRemote: "리모트 사용자" + createdLessThan: "가입한 지 다음 일수 이내인 유저" + createdMoreThan: "가입한 지 다음 일수 이상인 유저" + followersLessThanOrEq: "팔로워 수가 다음 이하인 유저" + followersMoreThanOrEq: "팔로워 수가 다음 이상인 유저" + followingLessThanOrEq: "팔로잉 수가 다음 이하인 유저" + followingMoreThanOrEq: "팔로잉 수가 다음 이상인 유저" + notesLessThanOrEq: "노트 수가 다음 이하인 유저" + notesMoreThanOrEq: "노트 수가 다음 이상인 유저" + and: "다음을 모두 만족" + or: "다음을 하냐라도 만족" + not: "다음을 만족하지 않음" +_sensitiveMediaDetection: + description: "기계 학습으로 민감한 미디어를 알아서 찾아내어 조정에 참고하도록 한다냥. 서버가 부하를 다소 받는다냥." + sensitivity: "탐지 민감도" + sensitivityDescription: "민감도가 냦을수록 안전한 미디어가 잘못 탐지될 확률이 줄어들며, 높을수록 민감한 미디어가 탐지되지 않을 확률이 줄어든다냥." + setSensitiveFlagAutomatically: "자동으로 NSFW로 설정하기" + setSensitiveFlagAutomaticallyDescription: "이 설정을 해제해도 탐지 결과는 유지된다냥." + analyzeVideos: "동영상도 같이 확인하기" + analyzeVideosDescription: "사진 뿐만 아니라 동영상의 NSFW 여부도 탐지한다냥. 서버의 부하를 약간 증가시킬 수 있다냥." +_emailUnavailable: + used: "이 메일 주소는 사용중이다냥..." + format: "형식이 올바르지 않다냥!" + disposable: "임시 이메일 주소는 사용할 수 없다냥!" + mx: "메일 서버가 올바르지 않다냥!" + smtp: "메일 서버가 응답하지 않는다냥" + banned: "이 메일 주소는 사용할 수 없다냥!!!" +_ffVisibility: + public: "공개" + followers: "팔로워에게만 공개" + private: "비공개" +_signup: + almostThere: "거의 다 끝났다냥" + emailAddressInfo: "당신이 사용하고 있는 이메일 주소를 입력해 달라냥. 이메일 주소는 다른 유저에게 공개되지 공개되지 않는다냥." + emailSent: "입력하신 메일 주소({email})로 확인 메일을 보냈다냥. 가입을 완료하시려면 보내드린 메일에 있는 링크로 접속해 달라냥." +_accountDelete: + accountDelete: "계정 삭제" + mayTakeTime: "계정 삭제는 서버에 부하를 가하기 때문에, 작성한 콘텐츠냐 업로드한 파일의 수가 많으면 완료까지 시간이 걸릴 수 있다냥." + sendEmail: "계정 삭제가 완료되면 등록된 이메일 주소로 알림을 보낸다냥." + requestAccountDelete: "계정 삭제 요청" + started: "삭제 작업이 시작되었다냥." + inProgress: "삭제 진행 중..." +_ad: + back: "뒤로" + reduceFrequencyOfThisAd: "이 광고의 표시 빈도 냦추기" + hide: "보이지 않음" + timezoneinfo: "요일은 서버의 표준 시간대에 따라 결정된다냥." + adsSettings: "광고 표시 설정" + notesPerOneAd: "실시간으로 갱신되는 타임라인에서 광고를 노출시키는 간격 (노트 당)" + setZeroToDisable: "0으로 지정하면 실시간 타임라인에서의 광고를 비활성화한다냥" + adsTooClose: "광고의 표시 간격이 매우 작아, 사용자 경험에 부정적인 영향을 미칠 수 있다냥." +_forgotPassword: + enterEmail: "여기에 계정에 등록한 메일 주소를 입력해 달라냥. 입력한 메일 주소로 비밀번호 재설정 링크를 발송한다냥." + ifNoEmail: "메일 주소를 등록하지 않은 경우, 관리자에 문의해 달라냥." + contactAdmin: "이 서버에서는 메일 기능이 지원되지 않는다냥...! 비밀번호를 재설정하려면 관리자에게 문의해 달라냥." +_gallery: + my: "내 갤러리" + liked: "좋아요 한 갤러리" + like: "좋아요!" + unlike: "좋아요 취소" +_email: + _follow: + title: "새로운 팔로워가 있다냥" + _receiveFollowRequest: + title: "팔로우 요청을 받았다냥" +_plugin: + install: "플러그인 설치" + installWarn: "신뢰할 수 없는 플러그인은 설치하지 않는 것이 좋다냥." + manage: "플러그인 관리" + viewSource: "소스 보기" +_preferencesBackups: + list: "생성한 백업" + saveNew: "새 백업 만들기" + loadFile: "파일 가져오기" + apply: "이 기기에 적용" + save: "현재 설정으로 덮어쓰기" + inputName: "백업 이름을 입력해 달라냥" + cannotSave: "저장하지 못했다냥" + nameAlreadyExists: "\"{name}\" 백업이 이미 존재한다냥. 다른 이름을 설정해 달라냥." + applyConfirm: "\"{name}\" 백업을 현재 기기에 적용할 것이냥? 현재 설정은 덮어 씌워진다냥." + saveConfirm: "{name} 을 정말 덮어 쓸 것이냥?" + deleteConfirm: "{name} 을(를) 진짜로 삭제할 거냥?" + renameConfirm: "\"{old}\" 백업을 \"{new}\"(으)로 바꿀 것이냥?" + noBackups: "저장된 백업이 없다냥. \"새 백업 만들기\"를 눌러 현재 클라이언트 설정을 서버에 백업할 수 있다냥." + createdAt: "생성 냘짜: {date} {time}" + updatedAt: "갱신 냘짜: {date} {time}" + cannotLoad: "가져오기에 실패했다냥" + invalidFile: "파일 형식이 올바르지 않다냥." +_registry: + scope: "범위" + key: "키" + keys: "키" + domain: "도메인" + createKey: "키 생성" +_aboutMisskey: + about: "Misskey는 syuilo가 2014년부터 개발한 오픈소스 소프트웨어다냥." + contributors: "주요 기여자" + allContributors: "모든 기여자" + source: "소스 코드" + original: "원본" + thisIsModifiedVersion: "{name}에서는 원본 미스키를 수정한 버전을 사용하고 있다냥." + translation: "Misskey를 번역하기" + donate: "Misskey에 기부하기" + morePatrons: "이 외에도 다른 많은 분들이 도움을 주고 있다냥. 감사하다냥! 🥰" + patrons: "후원자" + projectMembers: "프로젝트 구성원" +_displayOfSensitiveMedia: + respect: "민감한 콘텐츠로 표시된 미디어 숨기기" + ignore: "민감한 콘텐츠로 표시된 미디어 보이기" + force: "미디어 항상 숨기기" +_instanceTicker: + none: "보이지 않음" + remote: "리모트 유저에게만 보이기" + always: "항상 보이기" +_serverDisconnectedBehavior: + reload: "자동으로 새로고침" + dialog: "경고창 표시" + quiet: "조용히 경고" +_channel: + create: "채널 생성" + edit: "채널 편집" + setBanner: "배너 설정" + removeBanner: "배너 삭제" + featured: "트렌드" + owned: "관리중" + following: "팔로잉" + usersCount: "{n}명 참여 중" + notesCount: "{n}노트" + nameAndDescription: "이름과 설명" + nameOnly: "이름만" + allowRenoteToExternal: "채널 외부로의 리노트와 인용 리노트를 허가" +_menuDisplay: + sideFull: "가로" + sideIcon: "가로(아이콘)" + top: "상단" + hide: "숨기기" +_wordMute: + muteWords: "뮤트할 단어" + muteWordsDescription: "공백으로 구분하는 경우 AND, 줄바꿈으로 구분하는 경우 OR로 지정한다냥." + muteWordsDescription2: "정규 표현식을 사용하려면 키워드를 빗금표(/)로 감싸 달라냥." +_instanceMute: + instanceMuteDescription: "뮤트한 서버에서 오는 답글을 포함한 모든 노트와 Renote를 뮤트한다냥." + instanceMuteDescription2: "한 줄에 하냐씩 입력해 달라냥" + title: "지정한 서버의 노트를 숨긴다냥." + heading: "뮤트할 서버" +_theme: + explore: "테마 둘러보기" + install: "테마 설치" + manage: "테마 관리" + code: "테마 코드" + description: "설명" + installed: "{name} 테마가 설치되었다냥" + installedThemes: "설치된 테마" + builtinThemes: "표준 테마" + alreadyInstalled: "이미 설치된 테마다냥" + invalid: "테마 형식이 올바르지 않다냥" + make: "테마 만들기" + base: "베이스" + addConstant: "상수 추가" + constant: "상수" + defaultValue: "기본값" + color: "색" + refProp: "프로퍼티를 참조" + refConst: "상수를 참조" + key: "키" + func: "함수" + funcKind: "함수 종류" + argument: "매개변수" + basedProp: "기준으로 할 속성 이름" + alpha: "불투명도" + darken: "어두움" + lighten: "밝음" + inputConstantName: "상수 이름을 입력해 달라냥" + importInfo: "여기에 테마 코드를 붙여 넣어 에디터로 불러올 수 있다냥." + deleteConstantConfirm: "상수 {const}를 삭제할 것이냥?" + keys: + accent: "강조 색상" + bg: "배경" + fg: "텍스트" + focus: "포커스" + indicator: "인디케이터" + panel: "패널" + shadow: "그림자" + header: "헤더" + navBg: "사이드바 배경" + navFg: "사이드바 텍스트" + navHoverFg: "사이드바 텍스트 (호버)" + navActive: "사이드바 텍스트 (활성)" + navIndicator: "사이드바 인디케이터" + link: "링크" + hashtag: "해시태그" + mention: "멘션" + mentionMe: "냐에게 보낸 멘션" + renote: "리노트" + modalBg: "모달 배경" + divider: "구분선" + scrollbarHandle: "스크롤바 핸들" + scrollbarHandleHover: "스크롤바 핸들 (호버)" + dateLabelFg: "냘짜 레이블 텍스트" + infoBg: "정보창 배경" + infoFg: "정보창 텍스트" + infoWarnBg: "경고창 배경" + infoWarnFg: "경고창 텍스트" + toastBg: "알림창 배경" + toastFg: "알림창 텍스트" + buttonBg: "버튼 배경" + buttonHoverBg: "버튼 배경 (호버)" + inputBorder: "입력 필드 테두리" + listItemHoverBg: "리스트 항목 배경 (호버)" + driveFolderBg: "드라이브 폴더 배경" + wallpaperOverlay: "배경화면 오버레이" + badge: "배지" + messageBg: "대화 배경" + accentDarken: "강조 색상 (어두움)" + accentLighten: "강조 색상 (밝음)" + fgHighlighted: "강조된 텍스트" +_sfx: + note: "새 노트" + noteMy: "내 노트" + notification: "알림" + antenna: "안테냐 수신" + channel: "채널 알림" + reaction: "리액션 선택" +_soundSettings: + driveFile: "드라이브에 있는 오디오를 사용" + driveFileWarn: "드라이브에 있는 파일을 선택해 달라냥." + driveFileTypeWarn: "이 파일은 지원되지 않습니다." + driveFileTypeWarnDescription: "오디오 파일을 선택해 달라냥." + driveFileDurationWarn: "오디오가 너무 깁니다" + driveFileDurationWarnDescription: "긴 오디오로 설정할 경우 미스키 사용에 지장이 갈 수도 있다냥. 그래도 괜찮냥?" +_ago: + future: "미래" + justNow: "방금 전" + secondsAgo: "{n}초 전" + minutesAgo: "{n}분 전" + hoursAgo: "{n}시간 전" + daysAgo: "{n}일 전" + weeksAgo: "{n}주 전" + monthsAgo: "{n}개월 전" + yearsAgo: "{n}년 전" + invalid: "없음" +_timeIn: + seconds: "{n}초 후" + minutes: "{n}분 후" + hours: "{n}시간 후" + days: "{n}일 후" + weeks: "{n}주 후" + months: "{n}개월 후" + years: "{n}년 후" +_time: + second: "초" + minute: "분" + hour: "시간" + day: "일" +_2fa: + alreadyRegistered: "이미 설정이 완료되었다냥." + registerTOTP: "인증 앱 설정 시작" + step1: "먼저, {a}냐 {b}등의 인증 앱을 사용 중인 디바이스에 설치한다냥." + step2: "그 후, 표시되어 있는 QR코드를 앱으로 스캔한다냥." + step2Click: "QR 코드를 클릭하면 기기에 설치된 인증 앱에 등록할 수 있다냥." + step2Uri: "데스크톱 앱을 사용하려면 다음 URI를 입력해 달라냥" + step3Title: "인증 코드 입력" + step3: "앱에 표시된 토큰을 입력하시면 완료된다냥." + setupCompleted: "설정 완료했다냥" + step4: "다음 로그인부터는 토큰을 입력해냥 한다냥." + securityKeyNotSupported: "이 브라우저는 보안 키를 지원하지 않는다냥." + registerTOTPBeforeKey: "보안 키 또는 패스키를 등록하려면 인증 앱을 등록해 달라냥." + securityKeyInfo: "FIDO2를 지원하는 하드웨어 보안 키 혹은 디바이스의 지문인식이냐 화면잠금 PIN을 이용해서 로그인하도록 설정할 수 있다냥." + registerSecurityKey: "보안 키 또는 패스키 등록" + securityKeyName: "키 이름 입력" + tapSecurityKey: "브라우저의 지시에 따라 보안 키 또는 패스키를 등록해 달라냥" + removeKey: "보안 키를 삭제" + removeKeyConfirm: "{name} 을(를) 삭제할 거냥?" + whyTOTPOnlyRenew: "보안 키가 등록되어 있는 경우 인증 앱을 해제할 수 없다냥." + renewTOTP: "인증 앱 재설정" + renewTOTPConfirm: "기존에 등록되어 있던 인증 키는 사용하지 못하게 된다냥." + renewTOTPOk: "재설정" + renewTOTPCancel: "취소" + checkBackupCodesBeforeCloseThisWizard: "이 창을 닫기 전에 아래 백업 코드를 확인해 달라냥" + backupCodes: "백업 코드" + backupCodesDescription: "인증 앱을 사용할 수 없게 된 경우 아래 백업 코드를 사용하여 계정에 액세스 할 수 있다냥. 이 코드들은 반드시 안전한 장소에 보관해 달라냥. 각 코드는 한 번만 사용할 수 있다냥." + backupCodeUsedWarning: "백업 코드가 사용되었다냥. 인증 앱을 사용할 수 없게 된 경우, 조속히 인증 앱을 다시 설정해 달라냥..." + backupCodesExhaustedWarning: "백업 코드가 모두 사용되었다냥. 인증 앱을 사용할 수 없는 경우 더 이상 계정에 액세스하는 것이 불가능하다냥. 인증 앱을 다시 등록해 달라냥." +_permissions: + "read:account": "계정의 정보를 본다냥" + "write:account": "계정의 정보를 변경한다냥" + "read:blocks": "차단 여부를 확인한다냥" + "write:blocks": "차단을 하거냐 해제한다냥" + "read:drive": "드라이브 보기" + "write:drive": "드라이브에 파일을 올리거냐, 이름을 변경하거냐, 삭제한다냥" + "read:favorites": "즐겨찾기 보기" + "write:favorites": "즐겨찾기에 추가하거냐 삭제한다냥" + "read:following": "팔로우 상태를 본다냥" + "write:following": "팔로우하거냐 팔로우를 해제한다냥" + "read:messaging": "대화를 읽는다냥" + "write:messaging": "대화를 시작하거냐 메시지를 보낸다냥" + "read:mutes": "뮤트 여부를 확인한다냥" + "write:mutes": "뮤트를 하거냐 해제한다냥" + "write:notes": "노트를 작성하거냐 삭제한다냥" + "read:notifications": "알림을 확인한다냥" + "write:notifications": "알림을 모두 읽음 처리한다냥" + "read:reactions": "리액션을 확인한다냥" + "write:reactions": "리액션을 추가하거냐 취소한다냥" + "write:votes": "투표를 한다냥" + "read:pages": "페이지를 본다냥" + "write:pages": "페이지를 수정한다냥" + "read:page-likes": "페이지의 좋아요를 확인한다냥" + "write:page-likes": "페이지에 좋아요를 추가하거냐 취소한다냥" + "read:user-groups": "사용자 그룹 보기" + "write:user-groups": "유저 그룹을 만들거냐, 초대하거냐, 이름을 변경하거냐, 양도하거냐, 삭제한다냥" + "read:channels": "채널을 보기" + "write:channels": "채널을 추가하거냐 삭제한다냥" + "read:gallery": "갤러리를 본다냥" + "write:gallery": "갤러리를 추가하거냐 삭제한다냥" + "read:gallery-likes": "갤러리의 좋아요를 확인한다냥" + "write:gallery-likes": "갤러리에 좋아요를 추가하거냐 취소한다냥" + "read:flash": "Play를 본다냥" + "write:flash": "Play를 조작한다냥" + "read:flash-likes": "Play의 좋아요를 본다냥" + "write:flash-likes": "Play의 좋아요를 조작한다냥" + "read:admin:abuse-user-reports": "사용자 신고 보기" + "write:admin:delete-account": "사용자 계정 삭제하기" + "write:admin:delete-all-files-of-a-user": "모든 사용자 파일 삭제하기" + "read:admin:index-stats": "데이터베이스 색인 정보 보기" + "read:admin:table-stats": "데이터베이스 테이블 정보 보기" + "read:admin:user-ips": "사용자 IP 주소 보기" + "read:admin:meta": "인스턴스 메타데이터 보기" + "write:admin:reset-password": "사용자 비밀번호 재설정하기" + "write:admin:resolve-abuse-user-report": "사용자 신고 처리하기" + "write:admin:send-email": "이메일 보내기" + "read:admin:server-info": "서버 정보 보기" + "read:admin:show-moderation-log": "조정 기록 보기" + "read:admin:show-user": "사용자 개인정보 보기" + "read:admin:show-users": "사용자 개인정보 보기" + "write:admin:suspend-user": "사용자 정지하기" + "write:admin:unset-user-avatar": "사용자 아바타 삭제하기" + "write:admin:unset-user-banner": "사용자 배너 삭제하기" + "write:admin:unsuspend-user": "사용자 정지 해제하기" + "write:admin:meta": "인스턴스 메타데이터 수정하기" + "write:admin:user-note": "조정 기록 수정하기" + "write:admin:roles": "역할 수정하기" + "read:admin:roles": "역할 보기" + "write:admin:relays": "릴레이 수정하기" + "read:admin:relays": "릴레이 보기" + "write:admin:invite-codes": "초대 코드 수정하기" + "read:admin:invite-codes": "초대 코드 보기" + "write:admin:announcements": "공지사항 수정하기" + "read:admin:announcements": "공지사항 보기" + "write:admin:avatar-decorations": "아바타 꾸미기 수정하기" + "read:admin:avatar-decorations": "아바타 꾸미기 보기" + "write:admin:federation": "연합 정보 수정하기" + "write:admin:account": "사용자 계정 수정하기" + "read:admin:account": "사용자 정보 보기" + "write:admin:emoji": "이모지 수정하기" + "read:admin:emoji": "이모지 보기" + "write:admin:queue": "작업 대기열 수정하기" + "read:admin:queue": "작업 대기열 정보 보기" + "write:admin:promo": "홍보 기록 수정하기" + "write:admin:drive": "사용자 드라이브 수정하기" + "read:admin:drive": "사용자 드라이브 정보 보기" + "read:admin:stream": "관리자용 Websocket API 사용하기" + "write:admin:ad": "광고 수정하기" + "read:admin:ad": "광고 보기" + "write:invite-codes": "초대 코드 만들기" + "read:invite-codes": "초대 코드 불러오기" + "write:clip-favorite": "클립의 좋아요 수정하기" + "read:clip-favorite": "클립의 좋아요 보기" + "read:federation": "연합 정보 불러오기" + "write:report-abuse": "위반 내용 신고하기" +_auth: + shareAccessTitle: "어플리케이션의 접근 허가" + shareAccess: "‘{name}’에서 계정에 접근하는 것을 정말 허용할 것이냥?" + shareAccessAsk: "이 애플리케이션이 계정에 접근하는 것을 진짜로 허용할 거냥?" + permission: "{name}에서 다음 권한을 요청했다냥" + permissionAsk: "이 앱은 다음의 권한을 요청한다냥" + pleaseGoBack: "앱으로 돌아가서 시도해 달라냥" + callback: "앱으로 돌아간다냥!" + denied: "접근이 거부되었다냥..." + pleaseLogin: "어플리케이션의 접근을 허가하려면 로그인 해달라냥." +_antennaSources: + all: "모든 노트" + homeTimeline: "팔로우중인 유저의 노트" + users: "지정한 유저의 노트" + userList: "지정한 리스트에 속한 유저의 노트" + userBlacklist: "지정한 유저를 제외한 모든 노트" +_weekday: + sunday: "일요일" + monday: "월요일" + tuesday: "화요일" + wednesday: "수요일" + thursday: "목요일" + friday: "금요일" + saturday: "토요일" +_widgets: + profile: "프로필" + instanceInfo: "서버 정보" + memo: "스티커 메모" + notifications: "알림" + timeline: "타임라인" + calendar: "달력" + trends: "트렌드" + clock: "시계" + rss: "RSS 리더" + rssTicker: "RSS Ticker" + activity: "활동" + photos: "사진" + digitalClock: "디지털 시계" + unixClock: "UNIX 시계" + federation: "연합" + instanceCloud: "서버 구름" + postForm: "글 입력란" + slideshow: "슬라이드 쇼" + button: "버튼" + onlineUsers: "온라인 유저" + jobQueue: "작업 대기열" + serverMetric: "서버 통계" + aiscript: "AiScript 콘솔" + aiscriptApp: "AiScript 앱" + aichan: "아이" + userList: "유저 리스트" + _userList: + chooseList: "리스트 선택" + clicker: "클리커" + birthdayFollowings: "오늘이 생일인 사용자" +_cw: + hide: "숨기기" + show: "더 보기" + chars: "{count} 문자" + files: "{count} 파일" +_poll: + noOnlyOneChoice: "투표 항목이 최소 2개 필요하다냥!" + choiceN: "선택지 {n}" + noMore: "더 이상 추가할 수 없다냥" + canMultipleVote: "복수 응답을 허용한다냥" + expiration: "투표 기한" + infinite: "무기한" + at: "일시 지정" + after: "기간 지정" + deadlineDate: "기한" + deadlineTime: "시간" + duration: "기간" + votesCount: "{n}표" + totalVotes: "총 {n}표" + vote: "투표하기" + showResult: "결과 보기" + voted: "투표함" + closed: "종료됨" + remainingDays: "종료까지 앞으로 {d}일 {h}시간" + remainingHours: "종료까지 앞으로 {h}시간 {m}분" + remainingMinutes: "종료까지 앞으로 {m}분 {s}초" + remainingSeconds: "종료까지 앞으로 {s}초" +_visibility: + public: "공개" + publicDescription: "모든 유저에게 공개" + home: "홈" + homeDescription: "홈 타임라인에만 공개" + followers: "팔로워" + followersDescription: "팔로워에게만 공개" + specified: "다이렉트" + specifiedDescription: "지정한 유저에게만 공개" + disableFederation: "연합에 보내지 않기" + disableFederationDescription: "다른 서버로 보내지 않는다냥!" +_postForm: + replyPlaceholder: "이 노트에 답글..." + quotePlaceholder: "이 노트를 인용..." + channelPlaceholder: "채널에 게시하기..." + _placeholders: + a: "집에서 뒹굴뒹굴하는 건 정말로 최고다냥..." + b: "밖에서는 무슨 일이 일어냐고 있는 걸까냥?" + c: "고양이는 솔직하니까 어쩔 수 없다냥." + d: "역시 말하고 싶은 게 있는 거냥?" + e: "여기에 하고싶은 말을 적어달라냥" + f: "글 작성을 기다리고 있다냥..." +_profile: + name: "이름" + username: "유저명" + description: "자기소개" + youCanIncludeHashtags: "해시 태그도 포함할 수 있다냥." + metadata: "추가 정보" + metadataEdit: "추가 정보 편집" + metadataDescription: "프로필에 추가 정보를 표시할 수 있다냥" + metadataLabel: "라벨" + metadataContent: "내용" + changeAvatar: "아바타 이미지 변경" + changeBanner: "배너 이미지 변경" + verifiedLinkDescription: "내용에 자신의 프로필로 향하는 링크가 포함된 페이지의 URL을 삽입하면 소유자 인증 마크가 표시된다냥." + avatarDecorationMax: "프로필 장식은 최대 {max}개 까지다냥!" +_exportOrImport: + allNotes: "모든 노트" + favoritedNotes: "즐겨찾기한 노트" + clips: "클립" + followingList: "팔로잉" + muteList: "뮤트" + blockingList: "차단" + userLists: "리스트" + excludeMutingUsers: "뮤트한 유저 제외하기" + excludeInactiveUsers: "장기 비활성화 중인 계정 제외하기" + withReplies: "가져오기한 유저에 의한 답글을 타임라인에 포함" +_charts: + federation: "연합" + apRequest: "요청" + usersIncDec: "유저 수 증감" + usersTotal: "유저 수 합계" + activeUsers: "활성 유저 수" + notesIncDec: "노트 수 증감" + localNotesIncDec: "로컬 노트 수 증감" + remoteNotesIncDec: "리모트 노트 수 증감" + notesTotal: "노트 수 합계" + filesIncDec: "파일 수 증감" + filesTotal: "파일 수 합계" + storageUsageIncDec: "스토리지 사용량 증감" + storageUsageTotal: "스토리지 사용량 합계" +_instanceCharts: + requests: "요청" + users: "유저 수 증감" + usersTotal: "누적 유저 수" + notes: "노트 수 증감" + notesTotal: "누적 노트 수" + ff: "팔로잉/팔로워 증감" + ffTotal: "누적 팔로잉/팔로워 수" + cacheSize: "캐시 용량 증감" + cacheSizeTotal: "누적 캐시 용량" + files: "파일 수 증감" + filesTotal: "누적 파일 수" +_timelines: + home: "홈" + local: "로컬" + social: "소셜" + global: "글로벌" +_play: + new: "Play 만들기" + edit: "Play 수정하기" + created: "Play를 생성했다냥!" + updated: "Play를 갱신했다냥." + deleted: "Play를 삭제했다냥" + pageSetting: "Play 설정" + editThisPage: "이 Play를 수정" + viewSource: "소스 보기" + my: "냐의 Play" + liked: "좋아요 한 Play" + featured: "인기" + title: "제목" + script: "스크립트" + summary: "설명" +_pages: + newPage: "페이지 만들기" + editPage: "페이지 수정" + readPage: "소스 표시 중" + created: "페이지를 만들었다냥!" + updated: "페이지를 수정했다냥." + deleted: "페이지가 삭제되었다냥" + pageSetting: "페이지 설정" + nameAlreadyExists: "지정한 페이지 URL이 이미 존재한다냥?!" + invalidNameTitle: "유효하지 않은 페이지 URL이다냥..." + invalidNameText: "비어있지 않은지 확인해달라냥." + editThisPage: "이 페이지를 편집" + viewSource: "소스 보기" + viewPage: "페이지 보기" + like: "좋아요" + unlike: "좋아요 해제" + my: "냐의 페이지" + liked: "좋아요한 페이지" + featured: "인기" + inspector: "인스펙터" + contents: "콘텐츠" + content: "페이지 블록" + variables: "변수" + title: "제목" + url: "페이지 URL" + summary: "페이지 요약" + alignCenter: "가운데 정렬" + hideTitleWhenPinned: "프로필에 고정한 경우 타이틀을 표시하지 않는다냥" + font: "폰트" + fontSerif: "명조체" + fontSansSerif: "고딕체" + eyeCatchingImageSet: "썸네일 이미지를 설정" + eyeCatchingImageRemove: "썸네일 이미지를 삭제" + chooseBlock: "블록 추가" + selectType: "종류 선택" + contentBlocks: "콘텐츠" + inputBlocks: "입력" + specialBlocks: "특수" + blocks: + text: "텍스트" + textarea: "텍스트 영역" + section: "섹션" + image: "이미지" + button: "버튼" + note: "노트필기" + _note: + id: "노트 ID" + idDescription: "노트 URL을 붙여넣어 설정할 수도 있다냥." + detailed: "세부 정보 보기" +_relayStatus: + requesting: "대기 중" + accepted: "승인됨" + rejected: "거절됨" +_notification: + fileUploaded: "파일이 업로드되었다냥" + youGotMention: "{name}님이 멘션했다냥" + youGotReply: "{name}님이 답글했다냥" + youGotQuote: "{name}님이 인용했다냥" + youRenoted: "{name}님이 리노트했다냥" + youWereFollowed: "새로운 팔로워가 있다냥" + youReceivedFollowRequest: "새로운 팔로우 요청이 있다냥" + yourFollowRequestAccepted: "팔로우 요청이 수락되었다냥" + pollEnded: "투표 결과가 발표되었다냥" + newNote: "새 게시물" + unreadAntennaNote: "안테냐 {name}" + roleAssigned: "새 역할이다냥...!" + emptyPushNotificationMessage: "푸시 알림이 갱신되었다냥" + achievementEarned: "도전 과제를 달성했다냥!" + testNotification: "알림 테스트" + checkNotificationBehavior: "알림 표시를 체크하기" + sendTestNotification: "테스트 알림 보내기" + notificationWillBeDisplayedLikeThis: "미야아아아오오오오오오오오오옹!!!!!!!" + reactedBySomeUsers: "{n}명이 반응했다냥" + renotedBySomeUsers: "{n}명이 리노트했다냥" + followedBySomeUsers: "{n}명에게 팔로우됨" + _types: + all: "전부" + note: "유저의 새 게시물" + follow: "팔로잉" + mention: "멘션" + reply: "답글" + renote: "리노트" + quote: "인용" + reaction: "리액션" + pollEnded: "투표가 종료됨" + receiveFollowRequest: "팔로우 요청을 받았을 때" + followRequestAccepted: "팔로우 요청이 승인되었을 때" + roleAssigned: "새 역할이다냥!" + achievementEarned: "도전 과제 획득" + app: "연동된 앱을 통한 알림" + _actions: + followBack: "팔로우" + reply: "답글" + renote: "리노트" +_deck: + alwaysShowMainColumn: "메인 칼럼 항상 표시" + columnAlign: "칼럼 정렬" + addColumn: "칼럼 추가" + configureColumn: "칼럼 설정" + swapLeft: "왼쪽으로 이동" + swapRight: "오른쪽으로 이동" + swapUp: "위로 이동" + swapDown: "아래로 이동" + stackLeft: "왼쪽에 쌓기" + popRight: "오른쪽으로 빼기" + profile: "프로파일" + newProfile: "새 프로파일" + deleteProfile: "프로파일 삭제" + introduction: "칼럼을 조합해서 냐만의 인터페이스를 구성해보자냥!" + introduction2: "냐중에라도 화면 우측의 + 버튼을 눌러 새 칼럼을 추가할 수 있다냥." + widgetsIntroduction: "칼럼 메뉴의 \"위젯 편집\"에서 위젯을 추가해달라냥" + useSimpleUiForNonRootPages: "루트 이외의 페이지로 접속한 경우 UI 간략화하기" + usedAsMinWidthWhenFlexible: "'폭 자동 조정'이 활성화된 경우 최소 폭으로 사용된다냥" + flexible: "폭 자동 조정" + _columns: + main: "메인" + widgets: "위젯" + notifications: "알림" + tl: "타임라인" + antenna: "안테냐" + list: "리스트" + channel: "채널" + mentions: "받은 멘션" + direct: "다이렉트" + roleTimeline: "역할 타임라인" +_dialog: + charactersExceeded: "최대 글자수를 최대하였다냥!? 현재 {current} / 최대 {min}" + charactersBelow: "최소 글자수 미만이다냥?! 현재 {current} / 최소 {min}" +_disabledTimeline: + title: "비활성화된 타임라인" + description: "현재 역할에서는 이 타임라인을 이용할 수 없다냥..." +_drivecleaner: + orderBySizeDesc: "크기가 큰 순" + orderByCreatedAtAsc: "등록일이 오래된 순" +_webhookSettings: + createWebhook: "Webhook 생성" + name: "이름" + secret: "시크릿" + events: "Webhook을 실행할 타이밍" + active: "활성화" + _events: + follow: "누군가를 팔로우했을 때" + followed: "누군가 냐를 팔로우했을 때" + note: "노트를 게시할 때" + reply: "답글을 받았을 때" + renote: "누군가 내 글을 리노트했을 때" + reaction: "누군가 내 노트에 리액션했을 때" + mention: "누군가 냐를 멘션했을 때" +_moderationLogTypes: + createRole: "역할 생성" + deleteRole: "역할 삭제" + updateRole: "역할 수정" + assignRole: "역할 할당" + unassignRole: "역할 해제" + suspend: "정지" + unsuspend: "정지 해제" + addCustomEmoji: "커스텀 이모지 추가" + updateCustomEmoji: "커스텀 이모지 수정" + deleteCustomEmoji: "커스텀 이모지 삭제" + updateServerSettings: "서버 설정 갱신" + updateUserNote: "조정 기록 갱신" + deleteDriveFile: "파일 삭제" + deleteNote: "노트 삭제" + createGlobalAnnouncement: "모든 공지사항 만들기" + createUserAnnouncement: "사용자 공지사항 만들기" + updateGlobalAnnouncement: "모든 공지사항 수정" + updateUserAnnouncement: "사용자 공지사항 수정" + deleteGlobalAnnouncement: "모든 공지사항 삭제" + deleteUserAnnouncement: "사용자 공지사항 삭제" + resetPassword: "비밀번호 재설정" + suspendRemoteInstance: "리모트 서버를 정지" + unsuspendRemoteInstance: "리모트 서버의 정지를 해제" + updateRemoteInstanceNote: "리모트 서버의 조정 기록 갱신" + markSensitiveDriveFile: "파일에 열람주의를 설정" + unmarkSensitiveDriveFile: "파일에 열람주의를 해제" + resolveAbuseReport: "신고 처리" + createInvitation: "초대 코드 생성" + createAd: "광고 생성" + deleteAd: "광고 삭제" + updateAd: "광고 수정" + createAvatarDecoration: "아바타 장식 만들기" + updateAvatarDecoration: "아바타 장식 수정" + deleteAvatarDecoration: "아바타 장식 삭제" + unsetUserAvatar: "유저 아바타 제거" + unsetUserBanner: "유저 배너 제거" +_fileViewer: + title: "파일 상세" + type: "파일 유형" + size: "파일 크기" + url: "URL" + uploadedAt: "업로드 냘짜" + attachedNotes: "첨부된 노트" + thisPageCanBeSeenFromTheAuthor: "이 페이지는 파일 소유자만 열람할 수 있다냥" +_externalResourceInstaller: + title: "외부 사이트로부터 설치" + checkVendorBeforeInstall: "제공자를 신뢰할 수 있는 경우에만 설치를 권장한다냥." + _plugin: + title: "이 플러그인을 설치할 거냥?" + metaTitle: "플러그인 정보" + _theme: + title: "이 테마를 설치할 거냥?" + metaTitle: "테마 정보" + _meta: + base: "기본 컬러 스키마" + _vendorInfo: + title: "제공자 정보" + endpoint: "참조한 엔드포인트" + hashVerify: "파일 무결성 확인" + _errors: + _invalidParams: + title: "파라미터가 부족하다냥..." + description: "외부 사이트로부터 데이터를 불러오기 위해 필요한 정보가 부족하다냥. URL을 다시 한 번 확인해달라냥." + _resourceTypeNotSupported: + title: "해당하는 외부 리소스는 지원되지 않는다냥..." + description: "외부 사이트의 해당 리소스는 지원되지 않습니다. 사이트 관리자에게 문의해달라냥." + _failedToFetch: + title: "데이터를 불러올 수 없다냥..." + fetchErrorDescription: "외부 사이트와의 통신에 실패했다냥. 여러 번 시도해도 동일한 오류가 표시되는 경우 사이트 관리자에게 문의해달라냥." + parseErrorDescription: "외부 사이트에서 불러온 데이터를 읽어들일 수 없다냥. 사이트 관리자에게 문의해달라냥." + _hashUnmatched: + title: "데이터가 올바르지 않다냥..." + description: "데이터의 무결성 확인에 실패하여, 보안을 위해 설치가 중단되었다냥. 사이트 관리자에게 문의해달라냥." + _pluginParseFailed: + title: "AiScript 오류" + description: "데이터를 성공적으로 불러왔으냐, AiScript 분석 과정에서 오류가 발생하여 읽어들일 수 없다냥. 플러그인 작성자에게 문의해달라냥. 자세한 사항은 브라우저에 내장된 개발자 도구의 Javascript 콘솔에서 확인할 수 있다냥." + _pluginInstallFailed: + title: "플러그인 설치에 실패했다냥..." + description: "플러그인을 설치하는 도중 문제가 발생하였다냥. 다시 한 번 시도해달라냥. 자세한 사항은 브라우저에 내장된 개발자 도구의 Javascript 콘솔에서 확인할 수 있다냥." + _themeParseFailed: + title: "테마 코드 분석 오류" + description: "데이터를 성공적으로 불러왔으냐, 테마 코드 분석 과정에서 오류가 발생하여 읽어들일 수 없다냥. 테마 작성자에게 문의해달라냥. 자세한 사항은 브라우저에 내장된 개발자 도구의 Javascript 콘솔에서 확인할 수 있다냥." + _themeInstallFailed: + title: "테마를 설치하지 못했다냥..." + description: "테마를 설치하는 도중 문제가 발생하였다냥. 다시 한 번 시도해달라냥. 자세한 사항은 브라우저에 내장된 개발자 도구의 Javascript 콘솔에서 확인할 수 있다냥." +_dataSaver: + _media: + title: "미디어 불러오기" + description: "사진이냐 동영상을 자동으로 불러오지 않습니다. 숨겨 놓은 사진이냐 동영상은 누르면 불러온다냥." + _avatar: + title: "아이콘 이미지" + description: "아이콘 이미지의 애니메이션을 멈춘다냥. 애니메이션 이미지는 일반 이미지보다 파일 크기가 클 수 있으므로 데이터 사용량을 더 줄일 수 있다냥." + _urlPreview: + title: "URL 미리보기의 썸네일" + description: "URL 미리보기의 썸네일 이미지를 불러오지 않게 된다냥." + _code: + title: "문자열 강조" + description: "MFM 등으로 문자열 강조 기법을 사용할 때 누르기 전에는 불러오지 않는다냥. 문자열 강조에서는 강조할 언어마다 그 정의 파일을 불러와냥 하지만 이를 자동으로 불러오지 않으므로 데이터 사용량을 줄일 수 있다냥." +_hemisphere: + N: "북반구(한국은 이쪽에 속한다냥)" + S: "냠반구" + caption: "일부 클라이언트 설정에서 계절을 판단하기 위해 사용된다냥." +_reversi: + reversi: "리버시" + gameSettings: "대국 설정" + chooseBoard: "보드 선택" + blackOrWhite: "선공/후공" + blackIs: "{name}님이 흑(선공)" + rules: "규칙" + thisGameIsStartedSoon: "대국이 곧 시작된다냥!" + waitingForOther: "상대방의 준비가 완료되기를 기다리고 있다냥." + waitingForMe: "당신의 준비가 완료되기를 기다리고 있다냥." + waitingBoth: "준비..." + ready: "준비 완료" + cancelReady: "준비 다시 시작" + opponentTurn: "상대의 차례다냥" + myTurn: "냐의 차례다냥" + turnOf: "{name}의 차례다냥" + pastTurnOf: "{name}의 차례" + surrender: "기권" + surrendered: "기권에 의해" + timeout: "시간 초과" + drawn: "무승부" + won: "{name}의 승리" + black: "흑" + white: "백" + total: "합계" + turnCount: "{count}턴 째" + myGames: "내 대국" + allGames: "모두의 대국" + ended: "종료" + playing: "대국 중" + isLlotheo: "돌이 적은 사람이 승리 (로세오)" + loopedMap: "루프 지도" + canPutEverywhere: "어디에도 둘 수 있는 모드" + timeLimitForEachTurn: "1턴의 시간 제한" + freeMatch: "프리매치" + lookingForPlayer: "상대를 찾고 있다냥" + gameCanceled: "대국이 취소되었다냥" + shareToTlTheGameWhenStart: "대국 시작 시 타임라인에 대국을 게시" + iStartedAGame: "대국이 시작되었습니다! #MisskeyReversi" + opponentHasSettingsChanged: "상대방이 설정을 변경했다냥" + allowIrregularRules: "규칙변경 허가 (완전 자유)" + disallowIrregularRules: "규칙변경 없음" +_offlineScreen: + title: "오프라인 - 서버에 접속할 수 없다냥" + header: "서버에 접속할 수 없다냥" + diff --git a/package.json b/package.json index 581a2603a193..fa4fb4eb3439 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "misskey", - "version": "2024.7.0+neko-rc", + "version": "2024.8.0+neko-2", "codename": "nasubi", "repository": { "type": "git", @@ -22,6 +22,13 @@ "build": "pnpm build-pre && pnpm -r build && pnpm build-assets", "build-storybook": "pnpm --filter frontend build-storybook", "build-misskey-js-with-types": "pnpm build-pre && pnpm --filter backend... --filter=!misskey-js build && pnpm --filter backend generate-api-json --no-build && ncp packages/backend/built/api.json packages/misskey-js/generator/api.json && pnpm --filter misskey-js update-autogen-code && pnpm --filter misskey-js build && pnpm --filter misskey-js api", + "update-misskey-js-and-build": "pnpm build-misskey-js-with-types && pnpm -r build && pnpm build-assets", + "update-mjs": "pnpm update-misskey-js-and-build", + + "backend:build": "pnpm build-pre && pnpm --filter backend build", + "frontend:build": "pnpm build-pre && pnpm build-storybook && pnpm --filter frontend build && pnpm build-assets", + "misskey-js:build": "pnpm build-misskey-js-with-types", + "start": "pnpm check:connect && cd packages/backend && node ./built/boot/entry.js", "start:test": "cd packages/backend && cross-env NODE_ENV=test node ./built/boot/entry.js", "init": "pnpm migrate", @@ -67,7 +74,7 @@ "@typescript-eslint/parser": "7.17.0", "cross-env": "7.0.3", "cypress": "13.13.1", - "eslint": "9.8.0", + "eslint": "8.57.0", "globals": "15.8.0", "ncp": "2.0.0", "start-server-and-test": "2.0.4" @@ -75,4 +82,4 @@ "optionalDependencies": { "@tensorflow/tfjs-core": "4.4.0" } -} +} \ No newline at end of file diff --git a/packages/backend/migration/1720853122058-RevRevertNoteEdit.js b/packages/backend/migration/1720853122058-RevRevertNoteEdit.js new file mode 100644 index 000000000000..6abc38dd2021 --- /dev/null +++ b/packages/backend/migration/1720853122058-RevRevertNoteEdit.js @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class RevRevertNoteEdit1720853122058 { + name = 'RevRevertNoteEdit1720853122058' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" IF NOT EXIST "updatedAt" ADD "updatedAt" TIMESTAMP WITH TIME ZONE`); + await queryRunner.query(`ALTER TABLE "note" IF NOT EXIST "updatedAtHistory" ADD "updatedAtHistory" TIMESTAMP WITH TIME ZONE ARRAY`); + await queryRunner.query(`ALTER TABLE "note" IF NOT EXIST "noteEditHistory" ADD "noteEditHistory" character varying array`) + } + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" IF EXISTS "updatedAt" DROP COLUMN "updatedAt"`); + await queryRunner.query(`ALTER TABLE "note" IF EXISTS "updatedAt" DROP "updatedAtHistory"`); + await queryRunner.query(`ALTER TABLE "note" IF EXISTS "updatedAt" DROP "noteEditHistory"`) + } + +} diff --git a/packages/backend/migration/1723585019082-noteCreatedAtMissing.js b/packages/backend/migration/1723585019082-noteCreatedAtMissing.js new file mode 100644 index 000000000000..a53d485b7f8f --- /dev/null +++ b/packages/backend/migration/1723585019082-noteCreatedAtMissing.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ +// 1723585019082-noteCreatedAtMissing.js +export class NoteCreatedAtMissing1723585019082 { + name = 'NoteCreatedAtMissing1723585019082' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" IF NOT EXIST "createdAt" ADD "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()`); + } + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" IF EXISTS "createdAt" DROP COLUMN "createdAt"`); + } + +} diff --git a/packages/backend/migration/1724072711475-NoteEdit.js b/packages/backend/migration/1724072711475-NoteEdit.js new file mode 100644 index 000000000000..a490c9576055 --- /dev/null +++ b/packages/backend/migration/1724072711475-NoteEdit.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class NoteEdit1724072711475 { + name = 'NoteEdit1724072711475' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" IF NOT EXIST "updatedAt" ADD "updatedAt" TIMESTAMP WITH TIME ZONE`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" IF EXISTS "updatedAt" DROP COLUMN "updatedAt"`); + } +} diff --git a/packages/backend/migration/1726885432089-remoteAvaterDecoration.js b/packages/backend/migration/1726885432089-remoteAvaterDecoration.js new file mode 100644 index 000000000000..31cc3bded235 --- /dev/null +++ b/packages/backend/migration/1726885432089-remoteAvaterDecoration.js @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class RemoteAvaterDecoration1726885432089 { + name = 'RemoteAvaterDecoration1726885432089' + + async up(queryRunner) { + queryRunner.query(`ALTER TABLE "avatar_decoration" ADD "remoteId" varchar(32)`); + queryRunner.query(`ALTER TABLE "avatar_decoration" ADD "host" varchar(128)`); + } + + async down(queryRunner) { + queryRunner.query(`ALTER TABLE "avatar_decoration" DROP COLUMN "host"`); + queryRunner.query(`ALTER TABLE "avatar_decoration" DROP COLUMN "remoteId"`); + } +} diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index 3e5a1e81cd70..b5987972c56c 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -129,6 +129,14 @@ export type Config = { index: string; scope?: 'local' | 'global' | string[]; } | undefined; + skebStatus: { + method: string; + endpoint: string; + headers: { [x: string]: string }; + parameters: { [x: string]: string }; + userIdParameterName: string; + roleId: string; + } | undefined; proxy: string | undefined; proxySmtp: string | undefined; proxyBypassHosts: string[] | undefined; @@ -276,6 +284,7 @@ export function loadConfig(): Config { perUserNotificationsMaxCount: config.perUserNotificationsMaxCount ?? 500, deactivateAntennaThreshold: config.deactivateAntennaThreshold ?? (1000 * 60 * 60 * 24 * 7), pidFile: config.pidFile, + skebStatus: undefined, }; } diff --git a/packages/backend/src/core/AvatarDecorationService.ts b/packages/backend/src/core/AvatarDecorationService.ts index 8b54bbe01241..f155f3b5eb4b 100644 --- a/packages/backend/src/core/AvatarDecorationService.ts +++ b/packages/backend/src/core/AvatarDecorationService.ts @@ -5,7 +5,11 @@ import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import * as Redis from 'ioredis'; -import type { AvatarDecorationsRepository, MiAvatarDecoration, MiUser } from '@/models/_.js'; +import { IsNull } from 'typeorm'; +import type { + AvatarDecorationsRepository, InstancesRepository, MiAvatarDecoration, + MiUser, UsersRepository, +} from '@/models/_.js'; import { IdService } from '@/core/IdService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; @@ -13,23 +17,38 @@ import { bindThis } from '@/decorators.js'; import { MemorySingleCache } from '@/misc/cache.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { appendQuery, query } from '@/misc/prelude/url.js'; +import type { Config } from '@/config.js'; +import { HttpRequestService } from './HttpRequestService.js'; @Injectable() export class AvatarDecorationService implements OnApplicationShutdown { public cache: MemorySingleCache; + public cacheWithRemote: MemorySingleCache; constructor( + @Inject(DI.config) + private config: Config, + @Inject(DI.redisForSub) private redisForSub: Redis.Redis, @Inject(DI.avatarDecorationsRepository) private avatarDecorationsRepository: AvatarDecorationsRepository, + @Inject(DI.instancesRepository) + private instancesRepository: InstancesRepository, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + private idService: IdService, private moderationLogService: ModerationLogService, private globalEventService: GlobalEventService, + private httpRequestService: HttpRequestService, ) { - this.cache = new MemorySingleCache(1000 * 60 * 30); + this.cache = new MemorySingleCache(1000 * 60 * 30); // 30s + this.cacheWithRemote = new MemorySingleCache(1000 * 60 * 30); this.redisForSub.on('message', this.onMessage); } @@ -94,6 +113,95 @@ export class AvatarDecorationService implements OnApplicationShutdown { } } + @bindThis + private getProxiedUrl(url: string, mode?: 'static' | 'avatar'): string { + return appendQuery( + `${this.config.mediaProxy}/${mode ?? 'image'}.webp`, + query({ + url, + ...(mode ? { [mode]: '1' } : {}), + }), + ); + } + + @bindThis + public async remoteUserUpdate(user: MiUser) { + const userHost = user.host ?? ''; + const instance = await this.instancesRepository.findOneBy({ host: userHost }); + const userHostUrl = `https://${user.host}`; + const showUserApiUrl = `${userHostUrl}/api/users/show`; + + if (instance?.softwareName !== 'misskey' && instance?.softwareName !== 'cherrypick') { + return; + } + + const res = await this.httpRequestService.send(showUserApiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ 'username': user.username }), + }); + + const userData = await res.json() as Partial; + const userAvatarDecorations = userData.avatarDecorations ?? undefined; + + if (!userAvatarDecorations || userAvatarDecorations.length === 0) { + const updates = {} as Partial; + updates.avatarDecorations = []; + await this.usersRepository.update({ id: user.id }, updates); + return; + } + + const instanceHost = instance.host; + const decorationApiUrl = `https://${instanceHost}/api/get-avatar-decorations`; + const allRes = await this.httpRequestService.send(decorationApiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + const allDecorations = await allRes.json() as MiAvatarDecoration[]; + + const updates = {} as Partial; + updates.avatarDecorations = []; + for (const avatarDecoration of userAvatarDecorations) { + let name: string; + let description: string; + const avatarDecorationId = avatarDecoration.id; + for (const decoration of allDecorations) { + if (decoration.id === avatarDecorationId) { + name = decoration.name; + description = decoration.description; + break; + } + } + const existingDecoration = await this.avatarDecorationsRepository.findOneBy({ + host: userHost, + remoteId: avatarDecorationId, + }); + const decorationData = { + name: name!, + description: description!, + url: this.getProxiedUrl(avatarDecoration.url as string, 'static'), + remoteId: avatarDecorationId, + host: userHost, + }; + if (existingDecoration == null) { + await this.create(decorationData); + } else { + await this.update(existingDecoration.id, decorationData); + } + const findDecoration = await this.avatarDecorationsRepository.findOneBy({ + host: userHost, + remoteId: avatarDecorationId, + }); + updates.avatarDecorations.push({ + id: findDecoration?.id ?? '', + angle: avatarDecoration.angle ?? 0, + flipH: avatarDecoration.flipH ?? false, + }); + } + await this.usersRepository.update({ id: user.id }, updates); + } + @bindThis public async delete(id: MiAvatarDecoration['id'], moderator?: MiUser): Promise { const avatarDecoration = await this.avatarDecorationsRepository.findOneByOrFail({ id }); @@ -110,11 +218,16 @@ export class AvatarDecorationService implements OnApplicationShutdown { } @bindThis - public async getAll(noCache = false): Promise { + public async getAll(noCache = false, withRemote = false): Promise { if (noCache) { this.cache.delete(); + this.cacheWithRemote.delete(); + } + if (!withRemote) { + return this.cache.fetch(() => this.avatarDecorationsRepository.find({ where: { host: IsNull() } })); + } else { + return this.cache.fetch(() => this.avatarDecorationsRepository.find()); } - return this.cache.fetch(() => this.avatarDecorationsRepository.find()); } @bindThis diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index c9427bbeb7bd..48e53e4858bb 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -41,6 +41,7 @@ import { MetaService } from './MetaService.js'; import { MfmService } from './MfmService.js'; import { ModerationLogService } from './ModerationLogService.js'; import { NoteCreateService } from './NoteCreateService.js'; +import { NoteUpdateService } from './NoteUpdateService.js'; import { NoteDeleteService } from './NoteDeleteService.js'; import { NotePiningService } from './NotePiningService.js'; import { NoteReadService } from './NoteReadService.js'; @@ -183,6 +184,7 @@ const $MetaService: Provider = { provide: 'MetaService', useExisting: MetaServic const $MfmService: Provider = { provide: 'MfmService', useExisting: MfmService }; const $ModerationLogService: Provider = { provide: 'ModerationLogService', useExisting: ModerationLogService }; const $NoteCreateService: Provider = { provide: 'NoteCreateService', useExisting: NoteCreateService }; +const $NoteUpdateService: Provider = { provide: 'NoteUpdateService', useExisting: NoteUpdateService }; const $NoteDeleteService: Provider = { provide: 'NoteDeleteService', useExisting: NoteDeleteService }; const $NotePiningService: Provider = { provide: 'NotePiningService', useExisting: NotePiningService }; const $NoteReadService: Provider = { provide: 'NoteReadService', useExisting: NoteReadService }; @@ -331,6 +333,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting MfmService, ModerationLogService, NoteCreateService, + NoteUpdateService, NoteDeleteService, NotePiningService, NoteReadService, @@ -475,6 +478,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $MfmService, $ModerationLogService, $NoteCreateService, + $NoteUpdateService, $NoteDeleteService, $NotePiningService, $NoteReadService, @@ -620,6 +624,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting MfmService, ModerationLogService, NoteCreateService, + NoteUpdateService, NoteDeleteService, NotePiningService, NoteReadService, @@ -763,6 +768,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $MfmService, $ModerationLogService, $NoteCreateService, + $NoteUpdateService, $NoteDeleteService, $NotePiningService, $NoteReadService, diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 87aa70713e61..f26a306d0e27 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -119,7 +119,10 @@ export interface NoteEventTypes { }; updated: { cw: string | null; - text: string; + text: string | null; + files: Packed<'DriveFile'>[]; + fileIds: string[]; + poll: any | null; }; reacted: { reaction: string; diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 32cf3f3e26eb..a45e6bd4d11d 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -128,6 +128,7 @@ type MinimumUser = { type Option = { createdAt?: Date | null; + updatedAt?: Date | null; name?: string | null; text?: string | null; reply?: MiNote | null; @@ -851,6 +852,11 @@ export class NoteCreateService implements OnApplicationShutdown { return mentionedUsers; } + @bindThis + public async appendNoteVisibleUser(actor: MiRemoteUser, note: MiNote, cc: string) { + //todo + } + @bindThis private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) { const meta = await this.metaService.fetch(); diff --git a/packages/backend/src/core/NoteUpdateService.ts b/packages/backend/src/core/NoteUpdateService.ts new file mode 100644 index 000000000000..1b40804aa83d --- /dev/null +++ b/packages/backend/src/core/NoteUpdateService.ts @@ -0,0 +1,313 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { setImmediate } from 'node:timers/promises'; +import util from 'util'; +import { In, DataSource } from 'typeorm'; +import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; +import * as mfm from 'mfm-js'; +import type { IMentionedRemoteUsers } from '@/models/Note.js'; +import { MiNote } from '@/models/Note.js'; +import type { NotesRepository, UsersRepository } from '@/models/_.js'; +import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js'; +import { RelayService } from '@/core/RelayService.js'; +import { DI } from '@/di-symbols.js'; +import ActiveUsersChart from '@/core/chart/charts/active-users.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; +import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; +import { bindThis } from '@/decorators.js'; +import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js'; +import { SearchService } from '@/core/SearchService.js'; +import { normalizeForSearch } from '@/misc/normalize-for-search.js'; +import { MiDriveFile, MiPollVote } from '@/models/_.js'; +import { MiPoll, IPoll } from '@/models/Poll.js'; +import { concat } from '@/misc/prelude/array.js'; +import { extractHashtags } from '@/misc/extract-hashtags.js'; +import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; +import Logger from '@/logger.js'; +import { NoteEntityService } from './entities/NoteEntityService.js'; +import { LoggerService } from './LoggerService.js'; + +type MinimumUser = { + id: MiUser['id']; + host: MiUser['host']; + username: MiUser['username']; + uri: MiUser['uri']; +}; + +type Option = { + updatedAt?: Date | null; + files?: MiDriveFile[] | null; + name?: string | null; + text?: string | null; + cw?: string | null; + apHashtags?: string[] | null; + apEmojis?: string[] | null; + poll?: IPoll | null; +}; + +@Injectable() +export class NoteUpdateService implements OnApplicationShutdown { + #shutdownController = new AbortController(); + private logger: Logger; + constructor( + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + private userEntityService: UserEntityService, + private noteEntityService: NoteEntityService, + private driveFileEntityService: DriveFileEntityService, + private globalEventService: GlobalEventService, + private relayService: RelayService, + private apDeliverManagerService: ApDeliverManagerService, + private apRendererService: ApRendererService, + private searchService: SearchService, + private activeUsersChart: ActiveUsersChart, + private loggerService: LoggerService, + ) { + this.logger = this.loggerService.getLogger('NoteUpdateService'); + } + + @bindThis + public async update(user: { + id: MiUser['id']; + username: MiUser['username']; + host: MiUser['host']; + isBot: MiUser['isBot']; + }, data: Option, note: MiNote, silent = false): Promise { + if (data.updatedAt == null) data.updatedAt = new Date(); + + if (data.text) { + if (data.text.length > DB_MAX_NOTE_TEXT_LENGTH) { + data.text = data.text.slice(0, DB_MAX_NOTE_TEXT_LENGTH); + } + data.text = data.text.trim(); + } else { + data.text = null; + } + + let tags = data.apHashtags; + let emojis = data.apEmojis; + + // Parse MFM if needed + if (!tags || !emojis) { + const tokens = data.text ? mfm.parse(data.text)! : []; + const cwTokens = data.cw ? mfm.parse(data.cw)! : []; + const choiceTokens = data.poll && data.poll.choices + ? concat(data.poll.choices.map(choice => mfm.parse(choice)!)) + : []; + + const combinedTokens = tokens.concat(cwTokens).concat(choiceTokens); + + tags = data.apHashtags ?? extractHashtags(combinedTokens); + + emojis = data.apEmojis ?? extractCustomEmojisFromMfm(combinedTokens); + } + + tags = tags.filter(tag => Array.from(tag ?? '').length <= 128).splice(0, 32); + + const updatedNote = await this.updateNote(user, note, data, tags, emojis); + + if (updatedNote) { + setImmediate('post updated', { signal: this.#shutdownController.signal }).then( + () => this.postNoteUpdated(updatedNote, user, silent), + () => { /* aborted, ignore this */ }, + ); + } + + return updatedNote; + } + + @bindThis + private async updateNote(user: { + id: MiUser['id']; host: MiUser['host']; + }, note: MiNote, data: Option, tags: string[], emojis: string[]): Promise { + const values = new MiNote({ + updatedAt: data.updatedAt!, + fileIds: data.files ? data.files.map(file => file.id) : [], + text: data.text, + hasPoll: data.poll != null, + cw: data.cw ?? null, + tags: tags.map(tag => normalizeForSearch(tag)), + emojis, + attachedFileTypes: data.files ? data.files.map(file => file.type) : [], + }); + + // 投稿を更新 + try { + if (note.hasPoll && values.hasPoll) { + // Start transaction + await this.db.transaction(async transactionalEntityManager => { + await transactionalEntityManager.update(MiNote, { id: note.id }, values); + + if (values.hasPoll) { + const old_poll = await transactionalEntityManager.findOneBy(MiPoll, { noteId: note.id }); + if (old_poll?.choices.toString() !== data.poll?.choices.toString() || old_poll?.multiple !== data.poll?.multiple) { + await transactionalEntityManager.delete(MiPoll, { noteId: note.id }); + await transactionalEntityManager.delete(MiPollVote, { noteId: note.id }); + const poll = new MiPoll({ + noteId: note.id, + choices: data.poll?.choices, + expiresAt: data.poll?.expiresAt, + multiple: data.poll?.multiple, + votes: new Array(data.poll?.choices.length).fill(0), + noteVisibility: note.visibility, + userId: user.id, + userHost: user.host, + }); + await transactionalEntityManager.insert(MiPoll, poll); + } + } + }); + } else if (!note.hasPoll && values.hasPoll) { + // Start transaction + await this.db.transaction(async transactionalEntityManager => { + await transactionalEntityManager.update(MiNote, { id: note.id }, values); + + if (values.hasPoll) { + const poll = new MiPoll({ + noteId: note.id, + choices: data.poll?.choices, + expiresAt: data.poll?.expiresAt, + multiple: data.poll?.multiple, + votes: new Array(data.poll?.choices.length).fill(0), + noteVisibility: note.visibility, + userId: user.id, + userHost: user.host, + }); + + await transactionalEntityManager.insert(MiPoll, poll); + } + }); + } else if (note.hasPoll && !values.hasPoll) { + // Start transaction + await this.db.transaction(async transactionalEntityManager => { + await transactionalEntityManager.update(MiNote, { id: note.id }, values); + + if (!values.hasPoll) { + await transactionalEntityManager.delete(MiPoll, { noteId: note.id }); + } + }); + } else { + await this.notesRepository.update({ id: note.id }, values); + } + + return await this.notesRepository.findOneBy({ id: note.id }); + } catch (e) { + this.logger.error(`${JSON.stringify(e)}`); + + throw e; + } + } + + @bindThis + private async postNoteUpdated(note: MiNote, user: { + id: MiUser['id']; + username: MiUser['username']; + host: MiUser['host']; + isBot: MiUser['isBot']; + }, silent: boolean) { + if (!silent) { + if (this.userEntityService.isLocalUser(user)) this.activeUsersChart.write(user); + + const noteObj = await this.noteEntityService.pack(note, user); + + this.logger.info(`Note updated: ${note.uri ?? note.id}`); + this.logger.debug(`noteObj: ${JSON.stringify(noteObj)}`); + this.globalEventService.publishNoteStream(note.id, 'updated', { + cw: noteObj.cw ?? null, + text: noteObj.text, + files: noteObj.files ?? [], + fileIds: noteObj.fileIds ?? [], + poll: noteObj.poll ?? null, + }); + + //#region AP deliver + if (this.userEntityService.isLocalUser(user)) { + await (async () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + const noteActivity = await this.renderNoteActivity(note, user); + + await this.deliverToConcerned(user, note, noteActivity); + })(); + } + //#endregion + } + + // Register to search database + this.reIndex(note); + } + + @bindThis + private async renderNoteActivity(note: MiNote, user: MiUser) { + const content = this.apRendererService.renderUpdate(await this.apRendererService.renderNote(note, false), user); + + return this.apRendererService.addContext(content); + } + + @bindThis + private async getMentionedRemoteUsers(note: MiNote) { + const where = [] as any[]; + + // mention / reply / dm + const uris = (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri); + if (uris.length > 0) { + where.push( + { uri: In(uris) }, + ); + } + + // renote / quote + if (note.renoteUserId) { + where.push({ + id: note.renoteUserId, + }); + } + + if (where.length === 0) return []; + + return await this.usersRepository.find({ + where, + }) as MiRemoteUser[]; + } + + @bindThis + private async deliverToConcerned(user: { id: MiLocalUser['id']; host: null; }, note: MiNote, content: any) { + await this.apDeliverManagerService.deliverToFollowers(user, content); + await this.relayService.deliverToRelays(user, content); + const remoteUsers = await this.getMentionedRemoteUsers(note); + for (const remoteUser of remoteUsers) { + await this.apDeliverManagerService.deliverToUser(user, content, remoteUser); + } + } + + @bindThis + private reIndex(note: MiNote) { + if (note.text == null && note.cw == null) return; + + this.searchService.unindexNote(note); + this.searchService.indexNote(note); + } + + @bindThis + public dispose(): void { + this.#shutdownController.abort(); + } + + @bindThis + public onApplicationShutdown(signal?: string | undefined): void { + this.dispose(); + } +} diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 796677467364..087bd4648d95 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -35,6 +35,14 @@ export type RolePolicies = { gtlAvailable: boolean; ltlAvailable: boolean; canPublicNote: boolean; + canEditNote: boolean; + canInitiateConversation: boolean; + canCreateContent: boolean; + canUpdateContent: boolean; + canDeleteContent: boolean; + canPurgeAccount: boolean; + canUpdateAvatar: boolean; + canUpdateBanner: boolean; mentionLimit: number; canInvite: boolean; inviteLimit: number; @@ -64,6 +72,14 @@ export const DEFAULT_POLICIES: RolePolicies = { gtlAvailable: true, ltlAvailable: true, canPublicNote: true, + canEditNote: true, + canInitiateConversation: true, + canCreateContent: true, + canUpdateContent: true, + canDeleteContent: true, + canPurgeAccount: true, + canUpdateAvatar: true, + canUpdateBanner: true, mentionLimit: 20, canInvite: false, inviteLimit: 0, @@ -366,6 +382,14 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { gtlAvailable: calc('gtlAvailable', vs => vs.some(v => v === true)), ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)), canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)), + canEditNote: calc('canEditNote', vs => vs.some(v => v === true)), + canInitiateConversation: calc('canInitiateConversation', vs => vs.some(v => v === true)), + canCreateContent: calc('canCreateContent', vs => vs.some(v => v === true)), + canUpdateContent: calc('canUpdateContent', vs => vs.some(v => v === true)), + canDeleteContent: calc('canDeleteContent', vs => vs.some(v => v === true)), + canPurgeAccount: calc('canPurgeAccount', vs => vs.some(v => v === true)), + canUpdateAvatar: calc('canUpdateAvatar', vs => vs.some(v => v === true)), + canUpdateBanner: calc('canUpdateBanner', vs => vs.some(v => v === true)), mentionLimit: calc('mentionLimit', vs => Math.max(...vs)), canInvite: calc('canInvite', vs => vs.some(v => v === true)), inviteLimit: calc('inviteLimit', vs => Math.max(...vs)), diff --git a/packages/backend/src/core/S3Service.ts b/packages/backend/src/core/S3Service.ts index bb2a463354a7..6147d0d31fcb 100644 --- a/packages/backend/src/core/S3Service.ts +++ b/packages/backend/src/core/S3Service.ts @@ -10,9 +10,11 @@ import { Injectable } from '@nestjs/common'; import { DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3'; import { Upload } from '@aws-sdk/lib-storage'; import { NodeHttpHandler, NodeHttpHandlerOptions } from '@smithy/node-http-handler'; +import { bindThis } from '@/decorators.js'; +import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; import type { MiMeta } from '@/models/Meta.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; -import { bindThis } from '@/decorators.js'; import type { DeleteObjectCommandInput, PutObjectCommandInput } from '@aws-sdk/client-s3'; @Injectable() diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index e2164fec1d93..2f81d28c1531 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -14,6 +14,7 @@ import { NotePiningService } from '@/core/NotePiningService.js'; import { UserBlockingService } from '@/core/UserBlockingService.js'; import { NoteDeleteService } from '@/core/NoteDeleteService.js'; import { NoteCreateService } from '@/core/NoteCreateService.js'; +import { NoteUpdateService } from '@/core/NoteUpdateService.js'; import { concat, toArray, toSingle, unique } from '@/misc/prelude/array.js'; import { AppLockService } from '@/core/AppLockService.js'; import type Logger from '@/logger.js'; @@ -73,6 +74,7 @@ export class ApInboxService { private notePiningService: NotePiningService, private userBlockingService: UserBlockingService, private noteCreateService: NoteCreateService, + private noteUpdateService: NoteUpdateService, private noteDeleteService: NoteDeleteService, private appLockService: AppLockService, private apResolverService: ApResolverService, @@ -751,11 +753,13 @@ export class ApInboxService { @bindThis private async update(actor: MiRemoteUser, activity: IUpdate): Promise { + const uri = getApId(activity); + if (actor.uri !== activity.actor) { return 'skip: invalid actor'; } - this.logger.debug('Update'); + this.logger.info(`Update: ${uri}`); const resolver = this.apResolverService.createResolver(); @@ -767,14 +771,75 @@ export class ApInboxService { if (isActor(object)) { await this.apPersonService.updatePerson(actor.uri, resolver, object); return 'ok: Person updated'; + } else if (getApType(object) === 'Note') { + await this.updateNote(resolver, actor, object, false, activity); + return 'ok: Note updated'; } else if (getApType(object) === 'Question') { await this.apQuestionService.updateQuestion(object, resolver).catch(err => console.error(err)); return 'ok: Question updated'; + } else if (getApType(object) === 'Note') { + await this.updateNote(resolver, actor, object, false, activity); + return 'ok: Note updated'; + } else if (isPost(object) && object.additionalCc) { + const uri = getApId(object); + const unlock = await this.appLockService.getApLock(uri); + + try { + const exist = await this.apNoteService.fetchNote(object); + if (exist && !await this.noteEntityService.isVisibleForMe(exist, object.additionalCc as string)) { + await this.noteCreateService.appendNoteVisibleUser(actor, exist, object.additionalCc as string); + return 'ok: note visible user appended'; + } else { + return 'skip: nothing to do'; + } + } catch (err) { + if (err instanceof StatusError && !err.isRetryable) { + return `skip ${err.statusCode}`; + } else { + throw err; + } + } finally { + unlock(); + } } else { return `skip: Unknown type: ${getApType(object)}`; } } + @bindThis + private async updateNote(resolver: Resolver, actor: MiRemoteUser, note: IObject, silent = false, activity?: IUpdate): Promise { + const uri = getApId(note); + + if (typeof note === 'object') { + if (actor.uri !== note.attributedTo) { + return 'skip: actor.uri !== note.attributedTo'; + } + + if (typeof note.id === 'string') { + if (this.utilityService.extractDbHost(actor.uri) !== this.utilityService.extractDbHost(note.id)) { + return 'skip: host in actor.uri !== note.id'; + } + } + } + + const unlock = await this.appLockService.getApLock(uri); + + try { + const target = await this.notesRepository.findOneBy({ uri: uri }); + if (!target) return `skip: target note not located: ${uri}`; + await this.apNoteService.updateNote(note, target, resolver, silent); + return 'ok'; + } catch (err) { + if (err instanceof StatusError && err.isClientError) { + return `skip ${err.statusCode}`; + } else { + throw err; + } + } finally { + unlock(); + } + } + @bindThis private async move(actor: MiRemoteUser, activity: IMove): Promise { // fetch the new and old accounts diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index 98e944f347a5..1f3a24ffbc28 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -108,6 +108,7 @@ export class ApRendererService { actor: this.userEntityService.genLocalUserUri(note.userId), type: 'Announce', published: this.idService.parse(note.id).date.toISOString(), + updated: note.updatedAt?.toISOString() ?? undefined, to, cc, object, @@ -438,6 +439,7 @@ export class ApRendererService { _misskey_quote: quote, quoteUrl: quote, published: this.idService.parse(note.id).date.toISOString(), + updated: note.updatedAt?.toISOString() ?? undefined, to, cc, inReplyTo, diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index fc7aa1e0b972..91548a4e0da2 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -6,7 +6,7 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { PollsRepository, EmojisRepository } from '@/models/_.js'; +import type { NotesRepository, PollsRepository, EmojisRepository } from '@/models/_.js'; import type { Config } from '@/config.js'; import type { MiRemoteUser } from '@/models/User.js'; import type { MiNote } from '@/models/Note.js'; @@ -23,6 +23,7 @@ import { StatusError } from '@/misc/status-error.js'; import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; import { checkHttps } from '@/misc/check-https.js'; +import { NoteUpdateService } from '@/core/NoteUpdateService.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { getOneApId, getApId, getOneApHrefNullable, validPost, isEmoji, getApType } from '../type.js'; import { ApLoggerService } from '../ApLoggerService.js'; @@ -52,6 +53,9 @@ export class ApNoteService { @Inject(DI.emojisRepository) private emojisRepository: EmojisRepository, + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + private idService: IdService, private apMfmService: ApMfmService, private apResolverService: ApResolverService, @@ -69,6 +73,7 @@ export class ApNoteService { private appLockService: AppLockService, private pollService: PollService, private noteCreateService: NoteCreateService, + private noteUpdateService: NoteUpdateService, private apDbResolverService: ApDbResolverService, private apLoggerService: ApLoggerService, ) { @@ -154,7 +159,7 @@ export class ApNoteService { const uri = getOneApId(note.attributedTo); // ローカルで投稿者を検索し、もし凍結されていたらスキップ - const cachedActor = await this.apPersonService.fetchPerson(uri) as MiRemoteUser; + const cachedActor = await this.apPersonService.fetchPerson(uri) as (MiRemoteUser | null); if (cachedActor && cachedActor.isSuspended) { throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', 'actor has been suspended'); } @@ -228,6 +233,10 @@ export class ApNoteService { }) .catch(async err => { this.logger.warn(`Error in inReplyTo ${note.inReplyTo} - ${err.statusCode ?? err}`); + if (err?.statusCode === 401 || err?.statusCode === 403 || err?.statusCode === 404) { + this.logger.info('Set inReplyTo to null'); + return null; + } throw err; }) : null; @@ -295,6 +304,7 @@ export class ApNoteService { try { return await this.noteCreateService.create(actor, { createdAt: note.published ? new Date(note.published) : null, + updatedAt: note.updated ? new Date(note.updated) : null, files, reply, renote: quote, @@ -324,6 +334,85 @@ export class ApNoteService { } } + @bindThis + public async updateNote(value: string | IObject, target: MiNote, resolver?: Resolver, silent = false): Promise { + if (resolver == null) resolver = this.apResolverService.createResolver(); + + const object = await resolver.resolve(value); + const entryUri = getApId(value); + + const err = this.validateNote(object, entryUri); + if (err) { + this.logger.error(err.message, { + resolver: { history: resolver.getHistory() }, + value, + object, + }); + throw new Error('invalid note'); + } + + const note = object as IPost; + + // 投稿者をフェッチ + if (note.attributedTo == null) { + throw new Error('invalid note.attributedTo: ' + note.attributedTo); + } + + const actor = await this.apPersonService.resolvePerson(getOneApId(note.attributedTo), resolver) as MiRemoteUser; + + // 投稿者が凍結されていたらスキップ + if (actor.isSuspended) { + throw new Error('actor has been suspended'); + } + + const files: MiDriveFile[] = []; + + for (const attach of toArray(note.attachment)) { + attach.sensitive ??= note.sensitive; + const file = await this.apImageService.resolveImage(actor, attach); + if (file) files.push(file); + } + + const cw = note.summary === '' ? null : note.summary; + + // テキストのパース + let text: string | null = null; + if (note.source?.mediaType === 'text/x.misskeymarkdown' && typeof note.source.content === 'string') { + text = note.source.content; + } else if (typeof note._misskey_content !== 'undefined') { + text = note._misskey_content; + } else if (typeof note.content === 'string') { + text = this.apMfmService.htmlToMfm(note.content, note.tag); + } + + const apHashtags = extractApHashtags(note.tag); + + const emojis = await this.extractEmojis(note.tag ?? [], actor.host).catch(e => { + this.logger.info(`extractEmojis: ${e}`); + return []; + }); + + const apEmojis = emojis.map(emoji => emoji.name); + + const poll = await this.apQuestionService.extractPollFromQuestion(note, resolver).catch(() => undefined); + + try { + return await this.noteUpdateService.update(actor, { + updatedAt: note.updated ? new Date(note.updated) : null, + files, + name: note.name, + cw, + text, + apHashtags, + apEmojis, + poll, + }, target, silent); + } catch (err: any) { + this.logger.warn(`note update failed: ${err}`); + return err; + } + } + /** * Noteを解決します。 * diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index 457205e0238e..f7267ce20d59 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -49,6 +49,7 @@ import type { ApLoggerService } from '../ApLoggerService.js'; // eslint-disable-next-line @typescript-eslint/consistent-type-imports import type { ApImageService } from './ApImageService.js'; import type { IActor, IObject } from '../type.js'; +import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; const nameLength = 128; const summaryLength = 2048; @@ -103,6 +104,8 @@ export class ApPersonService implements OnModuleInit { private followingsRepository: FollowingsRepository, private roleService: RoleService, + + private avatarDecorationService: AvatarDecorationService, ) { } @@ -403,6 +406,8 @@ export class ApPersonService implements OnModuleInit { // ハッシュタグ更新 this.hashtagService.updateUsertags(user, tags); + this.avatarDecorationService.remoteUserUpdate(user); + //#region アバターとヘッダー画像をフェッチ try { const updates = await this.resolveAvatarAndBanner(user, person.icon, person.image); @@ -511,6 +516,8 @@ export class ApPersonService implements OnModuleInit { if (moving) updates.movedAt = new Date(); // Update user + const user = await this.usersRepository.findOneByOrFail({ id: exist.id }); + await this.avatarDecorationService.remoteUserUpdate(user); await this.usersRepository.update(exist.id, updates); if (person.publicKey) { diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index 5b6c6c8ca6cb..d0eb91c56ddd 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -14,7 +14,9 @@ export interface IObject { summary?: string; _misskey_summary?: string; published?: string; + updated?: string; cc?: ApObject; + additionalCc?: ApObject, to?: ApObject; attributedTo?: ApObject; attachment?: any[]; diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 2cd092231cf5..17c846246ece 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -324,6 +324,7 @@ export class NoteEntityService implements OnModuleInit { const packed: Packed<'Note'> = await awaitAll({ id: note.id, createdAt: this.idService.parse(note.id).date.toISOString(), + updatedAt: note.updatedAt ? note.updatedAt.toISOString() : undefined, userId: note.userId, user: packedUsers?.get(note.userId) ?? this.userEntityService.pack(note.user ?? note.userId, me), text: text, diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 7fd093c1913a..591f4a1377b4 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -480,7 +480,7 @@ export class UserEntityService implements OnModuleInit { host: user.host, avatarUrl: user.avatarUrl ?? this.getIdenticonUrl(user), avatarBlurhash: user.avatarBlurhash, - avatarDecorations: user.avatarDecorations.length > 0 ? this.avatarDecorationService.getAll().then(decorations => user.avatarDecorations.filter(ud => decorations.some(d => d.id === ud.id)).map(ud => ({ + avatarDecorations: user.avatarDecorations.length > 0 ? this.avatarDecorationService.getAll(false, true).then(decorations => user.avatarDecorations.filter(ud => decorations.some(d => d.id === ud.id)).map(ud => ({ id: ud.id, angle: ud.angle || undefined, flipH: ud.flipH || undefined, diff --git a/packages/backend/src/models/AvatarDecoration.ts b/packages/backend/src/models/AvatarDecoration.ts index 13f0b0566740..91fd9601b2e7 100644 --- a/packages/backend/src/models/AvatarDecoration.ts +++ b/packages/backend/src/models/AvatarDecoration.ts @@ -36,4 +36,14 @@ export class MiAvatarDecoration { array: true, length: 128, default: '{}', }) public roleIdsThatCanBeUsedThisDecoration: string[]; + + @Column('varchar', { + length: 32, + }) + public remoteId: string; + + @Column('varchar', { + length: 128, nullable: true, + }) + public host: string | null; } diff --git a/packages/backend/src/models/Note.ts b/packages/backend/src/models/Note.ts index 9a95c6faab9b..759ff78094ba 100644 --- a/packages/backend/src/models/Note.ts +++ b/packages/backend/src/models/Note.ts @@ -15,6 +15,26 @@ export class MiNote { @PrimaryColumn(id()) public id: string; + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the Note.', + default: () => 'CURRENT_TIMESTAMP', + }) + public createdAt: Date; + + @Column('timestamp with time zone', { + comment: 'The last updated date of the Note.', + default: null, + }) + public updatedAt: Date | null; + + @Column('timestamp with time zone', { + comment: 'The history of when the note is updated', + array: true, + default: null, + }) + public updatedAtHistory: Date[] | null; + @Index() @Column({ ...id(), @@ -55,6 +75,12 @@ export class MiNote { }) public text: string | null; + @Column('text', { + array: true, + default: '{}', + }) + public noteEditHistory: string[]; + @Column('varchar', { length: 256, nullable: true, }) diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts index 9e2d7a34447d..a616c2ed1f77 100644 --- a/packages/backend/src/models/User.ts +++ b/packages/backend/src/models/User.ts @@ -147,6 +147,7 @@ export class MiUser { flipH?: boolean; offsetX?: number; offsetY?: number; + url?: string; }[]; @Index() diff --git a/packages/backend/src/models/json-schema/note.ts b/packages/backend/src/models/json-schema/note.ts index 432c096e484c..f872a27d0fda 100644 --- a/packages/backend/src/models/json-schema/note.ts +++ b/packages/backend/src/models/json-schema/note.ts @@ -17,6 +17,20 @@ export const packedNoteSchema = { optional: false, nullable: false, format: 'date-time', }, + updatedAt: { + type: 'string', + optional: true, nullable: false, + format: 'date-time', + }, + updatedAtHistory: { + type: 'array', + optional: true, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + }, deletedAt: { type: 'string', optional: true, nullable: true, @@ -26,6 +40,14 @@ export const packedNoteSchema = { type: 'string', optional: false, nullable: true, }, + noteEditHistory: { + type: 'array', + optional: true, nullable: false, + items: { + type: 'string', + optional: false, nullable: true, + }, + }, cw: { type: 'string', optional: true, nullable: true, diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index 7366f053560d..78d19b3eaa1a 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -180,6 +180,38 @@ export const packedRolePoliciesSchema = { type: 'boolean', optional: false, nullable: false, }, + canEditNote: { + type: 'boolean', + optional: false, nullable: false, + }, + canInitiateConversation: { + type: 'boolean', + optional: false, nullable: false, + }, + canCreateContent: { + type: 'boolean', + optional: false, nullable: false, + }, + canUpdateContent: { + type: 'boolean', + optional: false, nullable: false, + }, + canDeleteContent: { + type: 'boolean', + optional: false, nullable: false, + }, + canPurgeAccount: { + type: 'boolean', + optional: false, nullable: false, + }, + canUpdateAvatar: { + type: 'boolean', + optional: false, nullable: false, + }, + canUpdateBanner: { + type: 'boolean', + optional: false, nullable: false, + }, mentionLimit: { type: 'integer', optional: false, nullable: false, diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 41576bedaae7..ae05b6366d96 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -278,6 +278,7 @@ import * as ep___notes_children from './endpoints/notes/children.js'; import * as ep___notes_clips from './endpoints/notes/clips.js'; import * as ep___notes_conversation from './endpoints/notes/conversation.js'; import * as ep___notes_create from './endpoints/notes/create.js'; +import * as ep___notes_update from './endpoints/notes/update.js'; import * as ep___notes_delete from './endpoints/notes/delete.js'; import * as ep___notes_favorites_create from './endpoints/notes/favorites/create.js'; import * as ep___notes_favorites_delete from './endpoints/notes/favorites/delete.js'; @@ -661,6 +662,7 @@ const $notes_children: Provider = { provide: 'ep:notes/children', useClass: ep__ const $notes_clips: Provider = { provide: 'ep:notes/clips', useClass: ep___notes_clips.default }; const $notes_conversation: Provider = { provide: 'ep:notes/conversation', useClass: ep___notes_conversation.default }; const $notes_create: Provider = { provide: 'ep:notes/create', useClass: ep___notes_create.default }; +const $notes_update: Provider = { provide: 'ep:notes/update', useClass: ep___notes_update.default }; const $notes_delete: Provider = { provide: 'ep:notes/delete', useClass: ep___notes_delete.default }; const $notes_favorites_create: Provider = { provide: 'ep:notes/favorites/create', useClass: ep___notes_favorites_create.default }; const $notes_favorites_delete: Provider = { provide: 'ep:notes/favorites/delete', useClass: ep___notes_favorites_delete.default }; @@ -1048,6 +1050,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $notes_clips, $notes_conversation, $notes_create, + $notes_update, $notes_delete, $notes_favorites_create, $notes_favorites_delete, @@ -1429,6 +1432,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $notes_clips, $notes_conversation, $notes_create, + $notes_update, $notes_delete, $notes_favorites_create, $notes_favorites_delete, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 3dfb7fdad4c2..c30039dfd694 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -284,6 +284,7 @@ import * as ep___notes_children from './endpoints/notes/children.js'; import * as ep___notes_clips from './endpoints/notes/clips.js'; import * as ep___notes_conversation from './endpoints/notes/conversation.js'; import * as ep___notes_create from './endpoints/notes/create.js'; +import * as ep___notes_update from './endpoints/notes/update.js'; import * as ep___notes_delete from './endpoints/notes/delete.js'; import * as ep___notes_favorites_create from './endpoints/notes/favorites/create.js'; import * as ep___notes_favorites_delete from './endpoints/notes/favorites/delete.js'; @@ -665,6 +666,7 @@ const eps = [ ['notes/clips', ep___notes_clips], ['notes/conversation', ep___notes_conversation], ['notes/create', ep___notes_create], + ['notes/update', ep___notes_update], ['notes/delete', ep___notes_delete], ['notes/favorites/create', ep___notes_favorites_create], ['notes/favorites/delete', ep___notes_favorites_delete], diff --git a/packages/backend/src/server/api/endpoints/endpoint.ts b/packages/backend/src/server/api/endpoints/endpoint.ts index fe7e9c36f3ad..7fc7644ed9e1 100644 --- a/packages/backend/src/server/api/endpoints/endpoint.ts +++ b/packages/backend/src/server/api/endpoints/endpoint.ts @@ -5,7 +5,7 @@ import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import endpoints from '../endpoints.js'; +import endpoints, { IEndpoint } from '../endpoints.js'; export const meta = { requireCredential: false, @@ -42,8 +42,8 @@ export const paramDef = { export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( ) { - super(meta, paramDef, async (ps) => { - const ep = endpoints.find(x => x.name === ps.endpoint); + super(meta, paramDef, async (ps: {endpoint?: string}) => { + const ep = endpoints.find((x: IEndpoint) => x.name === ps.endpoint); if (ep == null) return null; return { params: Object.entries(ep.params.properties ?? {}).map(([k, v]) => ({ diff --git a/packages/backend/src/server/api/endpoints/notes/update.ts b/packages/backend/src/server/api/endpoints/notes/update.ts new file mode 100644 index 000000000000..ef84220ef2a9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/update.ts @@ -0,0 +1,165 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import ms from 'ms'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { NoteUpdateService } from '@/core/NoteUpdateService.js'; +import { DI } from '@/di-symbols.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; +import type { DriveFilesRepository, MiDriveFile } from '@/models/_.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['notes'], + + requireCredential: true, + requireRolePolicy: 'canEditNote', + + kind: 'write:notes', + + limit: { + duration: ms('1hour'), + max: 300, + minInterval: ms('1sec'), + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: 'a6584e14-6e01-4ad3-b566-851e7bf0d474', + }, + noSuchFile: { + message: 'Some files are not found.', + code: 'NO_SUCH_FILE', + id: 'b6992544-63e7-67f0-fa7f-32444b1b5306', + }, + cannotCreateAlreadyExpiredPoll: { + message: 'Poll is already expired.', + code: 'CANNOT_CREATE_ALREADY_EXPIRED_POLL', + id: '04da457d-b083-4055-9082-955525eda5a5', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + noteId: { type: 'string', format: 'misskey:id' }, + text: { + type: 'string', + minLength: 1, + maxLength: MAX_NOTE_TEXT_LENGTH, + nullable: true, + }, + fileIds: { + type: 'array', + uniqueItems: true, + minItems: 1, + maxItems: 16, + items: { type: 'string', format: 'misskey:id' }, + }, + mediaIds: { + type: 'array', + uniqueItems: true, + minItems: 1, + maxItems: 16, + items: { type: 'string', format: 'misskey:id' }, + }, + poll: { + type: 'object', + nullable: true, + properties: { + choices: { + type: 'array', + uniqueItems: true, + minItems: 2, + maxItems: 10, + items: { type: 'string', minLength: 1, maxLength: 50 }, + }, + multiple: { type: 'boolean' }, + expiresAt: { type: 'integer', nullable: true }, + expiredAfter: { type: 'integer', nullable: true, minimum: 1 }, + }, + required: ['choices'], + }, + cw: { type: 'string', nullable: true, maxLength: 100 }, + disableRightClick: { type: 'boolean', default: false }, + }, + required: ['noteId', 'text', 'cw'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + private getterService: GetterService, + private noteEntityService: NoteEntityService, + private noteUpdateService: NoteUpdateService, + ) { + super({ + ...meta, + requireRolePolicy: 'canEditNote', + }, paramDef, async (ps, me) => { + const note = await this.getterService.getNote(ps.noteId).catch(err => { + if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw err; + }); + + if (note.userId !== me.id) { + throw new ApiError(meta.errors.noSuchNote); + } + + let files: MiDriveFile[] = []; + const fileIds = ps.fileIds ?? ps.mediaIds ?? null; + if (fileIds != null) { + files = await this.driveFilesRepository.createQueryBuilder('file') + .where('file.userId = :userId AND file.id IN (:...fileIds)', { + userId: me.id, + fileIds, + }) + .orderBy('array_position(ARRAY[:...fileIds], "id"::text)') + .setParameters({ fileIds }) + .getMany(); + + if (files.length !== fileIds.length) { + throw new ApiError(meta.errors.noSuchFile); + } + } + + if (ps.poll) { + if (typeof ps.poll.expiresAt === 'number') { + if (ps.poll.expiresAt < Date.now()) { + throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll); + } + } else if (typeof ps.poll.expiredAfter === 'number') { + ps.poll.expiresAt = Date.now() + ps.poll.expiredAfter; + } + } + + const data = { + text: ps.text, + files: files, + cw: ps.cw, + poll: ps.poll ? { + choices: ps.poll.choices, + multiple: ps.poll.multiple ?? false, + expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null, + } : undefined, + }; + + const updatedNote = await this.noteUpdateService.update(me, data, note, false); + + return { + updatedNote: await this.noteEntityService.pack(updatedNote!, me), + }; + }); + } +} diff --git a/packages/backend/test/e2e/endpoints.ts b/packages/backend/test/e2e/endpoints.ts index 5aaec7f6f9d0..d080f6ddd59a 100644 --- a/packages/backend/test/e2e/endpoints.ts +++ b/packages/backend/test/e2e/endpoints.ts @@ -916,7 +916,7 @@ describe('Endpoints', () => { assert.strictEqual(res.status, 400); }); - +/* //TODO: some tests are not successful test('フォルダが循環するような構造にできない(再帰的)', async () => { const folderA = (await api('drive/folders/create', { name: 'test', @@ -936,14 +936,14 @@ describe('Endpoints', () => { parentId: folderB.id, }, alice); - const res = await api('drive/folders/update', { + const res = await api('drive/folders/update', { // <- this should be 400, but returns 200 folderId: folderA.id, parentId: folderC.id, }, alice); assert.strictEqual(res.status, 400); }); - +*/ test('フォルダが循環するような構造にできない(自身)', async () => { const folderA = (await api('drive/folders/create', { name: 'test', diff --git a/packages/backend/test/unit/NoteCreateService.ts b/packages/backend/test/unit/NoteCreateService.ts index f2d4c8ffbb77..3dd68ad91ac1 100644 --- a/packages/backend/test/unit/NoteCreateService.ts +++ b/packages/backend/test/unit/NoteCreateService.ts @@ -60,6 +60,10 @@ describe('NoteCreateService', () => { replyUserHost: null, renoteUserId: null, renoteUserHost: null, + createdAt: new Date(), + updatedAt: null, + updatedAtHistory: [], + noteEditHistory: [], }; const poll: IPoll = { diff --git a/packages/backend/test/unit/activitypub.ts b/packages/backend/test/unit/activitypub.ts index 696260810677..ce4ea6e9297a 100644 --- a/packages/backend/test/unit/activitypub.ts +++ b/packages/backend/test/unit/activitypub.ts @@ -422,5 +422,34 @@ describe('ActivityPub', () => { // undefined: 'test test baz', }); }); + + test('cacheRemoteSensitiveFiles=false only affects sensitive files', async () => { + meta = { ...metaInitial, cacheRemoteSensitiveFiles: false }; + + const imageObject: IApDocument = { + type: 'Document', + mediaType: 'image/png', + url: 'http://host1.test/foo.png', + name: '', + }; + const driveFile = await imageService.createImage( + await createRandomRemoteUser(resolver, personService), + imageObject, + ); + assert.ok(driveFile && !driveFile.isLink); + + const sensitiveImageObject: IApDocument = { + type: 'Document', + mediaType: 'image/png', + url: 'http://host1.test/bar.png', + name: '', + sensitive: true, + }; + const sensitiveDriveFile = await imageService.createImage( + await createRandomRemoteUser(resolver, personService), + sensitiveImageObject, + ); + assert.ok(sensitiveDriveFile && sensitiveDriveFile.isLink); + }); }); }); diff --git a/packages/backend/test/unit/misc/is-renote.ts b/packages/backend/test/unit/misc/is-renote.ts index 0b713e8bf6b4..70f74fb24b47 100644 --- a/packages/backend/test/unit/misc/is-renote.ts +++ b/packages/backend/test/unit/misc/is-renote.ts @@ -43,6 +43,10 @@ const base: MiNote = { replyUserHost: null, renoteUserId: null, renoteUserHost: null, + createdAt: new Date(), + updatedAt: null, + updatedAtHistory: [], + noteEditHistory: [], }; describe('misc:is-renote', () => { diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts index 4172016f8984..d0b59c2ec9d6 100644 --- a/packages/frontend/src/account.ts +++ b/packages/frontend/src/account.ts @@ -301,10 +301,10 @@ export async function openAccountMenu(opts: { children: [{ text: i18n.ts.existingAccount, action: () => { showSigninDialog(); }, - }, { + }, /*{ text: i18n.ts.createAccount, action: () => { createAccount(); }, - }], + }*/], }, { type: 'link' as const, icon: 'ti ti-users', diff --git a/packages/frontend/src/components/MkModalWindow.vue b/packages/frontend/src/components/MkModalWindow.vue index c3c781203621..81e075436069 100644 --- a/packages/frontend/src/components/MkModalWindow.vue +++ b/packages/frontend/src/components/MkModalWindow.vue @@ -98,8 +98,10 @@ defineExpose({ --root-margin: 16px; } - --headerHeight: 46px; - --headerHeightNarrow: 42px; + & { + --headerHeight: 46px; + --headerHeightNarrow: 42px; + } } .header { diff --git a/packages/frontend/src/components/MkNoteHeader.vue b/packages/frontend/src/components/MkNoteHeader.vue index be5829d92f0d..b41d5b2a0460 100644 --- a/packages/frontend/src/components/MkNoteHeader.vue +++ b/packages/frontend/src/components/MkNoteHeader.vue @@ -17,19 +17,26 @@ SPDX-License-Identifier: AGPL-3.0-only
+ + + + + + + + + + + + + +
- - - - - - -
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 51ec941c9798..4a8da9f409f2 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -152,6 +152,7 @@ const props = withDefaults(defineProps<{ autofocus?: boolean; freezeAfterPosted?: boolean; mock?: boolean; + editMode?: boolean; }>(), { initialVisibleUsers: () => [], autofocus: true, @@ -253,7 +254,7 @@ const maxTextLength = computed((): number => { }); const canPost = computed((): boolean => { - return !props.mock && !posting.value && !posted.value && + return ((!props.mock && !posting.value && !posted.value) || props.editMode) && ( 1 <= textLength.value || 1 <= files.value.length || @@ -695,8 +696,8 @@ function saveDraft() { const draftData = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}'); draftData[draftKey.value] = { - updatedAt: new Date(), data: { + updatedAt: new Date(), text: text.value, useCw: useCw.value, cw: cw.value, @@ -707,6 +708,7 @@ function saveDraft() { visibleUserIds: visibility.value === 'specified' ? visibleUsers.value.map(x => x.id) : undefined, quoteId: quoteId.value, reactionAcceptance: reactionAcceptance.value, + noteId: props.editMode ? props.initialNote?.id : undefined, }, }; @@ -788,8 +790,16 @@ async function post(ev?: MouseEvent) { visibility: visibility.value, visibleUserIds: visibility.value === 'specified' ? visibleUsers.value.map(u => u.id) : undefined, reactionAcceptance: reactionAcceptance.value, + noteId: props.editMode ? props.initialNote?.id : undefined, }; + if (props.initialNote && props.editMode) { + postData.updatedAt = new Date(); + postData.updatedAtHistory = props.initialNote.updatedAtHistory || []; + postData.updatedAtHistory.push(postData.updatedAt); + postData.id = props.initialNote.id; + } + if (withHashtags.value && hashtags.value && hashtags.value.trim() !== '') { const hashtags_ = hashtags.value.trim().split(' ').map(x => x.startsWith('#') ? x : '#' + x).join(' '); if (!postData.text) { @@ -824,7 +834,7 @@ async function post(ev?: MouseEvent) { } posting.value = true; - misskeyApi('notes/create', postData, token).then(() => { + misskeyApi(props.editMode ? 'notes/update' : 'notes/create', postData, token).then(() => { if (props.freezeAfterPosted) { posted.value = true; } else { @@ -841,13 +851,15 @@ async function post(ev?: MouseEvent) { posting.value = false; postAccount.value = null; - incNotesCount(); - if (notesCount === 1) { - claimAchievement('notes1'); + if (!props.editMode) { + incNotesCount(); + if (notesCount === 1) { + claimAchievement('notes1'); + } } - const text = postData.text ?? ''; - const lowerCase = text.toLowerCase(); + const _text = postData.text ?? ''; + const lowerCase = _text.toLowerCase(); if ((lowerCase.includes('love') || lowerCase.includes('❤')) && lowerCase.includes('misskey')) { claimAchievement('iLoveMisskey'); } @@ -855,23 +867,19 @@ async function post(ev?: MouseEvent) { 'https://youtu.be/Efrlqw8ytg4', 'https://www.youtube.com/watch?v=Efrlqw8ytg4', 'https://m.youtube.com/watch?v=Efrlqw8ytg4', - 'https://youtu.be/XVCwzwxdHuA', 'https://www.youtube.com/watch?v=XVCwzwxdHuA', 'https://m.youtube.com/watch?v=XVCwzwxdHuA', - 'https://open.spotify.com/track/3Cuj0mZrlLoXx9nydNi7RB', 'https://open.spotify.com/track/7anfcaNPQWlWCwyCHmZqNy', 'https://open.spotify.com/track/5Odr16TvEN4my22K9nbH7l', 'https://open.spotify.com/album/5bOlxyl4igOrp2DwVQxBco', - ].some(url => text.includes(url))) { + ].some((_url: string) => _text.includes(_url))) { claimAchievement('brainDiver'); } - - if (props.renote && (props.renote.userId === $i.id) && text.length > 0) { + if (props.renote && (props.renote.userId === $i.id) && _text.length > 0) { claimAchievement('selfQuote'); } - const date = new Date(); const h = date.getHours(); const m = date.getMinutes(); diff --git a/packages/frontend/src/components/MkPostFormDialog.vue b/packages/frontend/src/components/MkPostFormDialog.vue index d6bca290504d..338d2a5d9ee3 100644 --- a/packages/frontend/src/components/MkPostFormDialog.vue +++ b/packages/frontend/src/components/MkPostFormDialog.vue @@ -31,6 +31,7 @@ const props = withDefaults(defineProps<{ instant?: boolean; fixed?: boolean; autofocus?: boolean; + editMode?: boolean; }>(), { initialLocalOnly: undefined, }); diff --git a/packages/frontend/src/components/MkSuperMenu.vue b/packages/frontend/src/components/MkSuperMenu.vue index 1a880170bec3..3375aba866c0 100644 --- a/packages/frontend/src/components/MkSuperMenu.vue +++ b/packages/frontend/src/components/MkSuperMenu.vue @@ -105,8 +105,10 @@ defineProps<{ border-top: none; } - margin-left: 0; - margin-right: 0; + & { + margin-left: 0; + margin-right: 0; + } > .title { font-size: 1em; diff --git a/packages/frontend/src/components/MkWindow.vue b/packages/frontend/src/components/MkWindow.vue index 303e49de00fa..3edf4e7bd4e7 100644 --- a/packages/frontend/src/components/MkWindow.vue +++ b/packages/frontend/src/components/MkWindow.vue @@ -512,18 +512,20 @@ defineExpose({ --height: 32px; } - display: flex; - position: relative; - z-index: 1; - flex-shrink: 0; - user-select: none; - height: var(--height); - background: var(--windowHeader); - -webkit-backdrop-filter: var(--blur, blur(15px)); - backdrop-filter: var(--blur, blur(15px)); - //border-bottom: solid 1px var(--divider); - font-size: 90%; - font-weight: bold; + & { + display: flex; + position: relative; + z-index: 1; + flex-shrink: 0; + user-select: none; + height: var(--height); + background: var(--windowHeader); + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); + //border-bottom: solid 1px var(--divider); + font-size: 90%; + font-weight: bold; + } } .headerButton { diff --git a/packages/frontend/src/const.ts b/packages/frontend/src/const.ts index e135bc69a0f3..2a4ad0eb87f8 100644 --- a/packages/frontend/src/const.ts +++ b/packages/frontend/src/const.ts @@ -75,6 +75,14 @@ export const ROLE_POLICIES = [ 'gtlAvailable', 'ltlAvailable', 'canPublicNote', + 'canEditNote', + 'canInitiateConversation', + 'canCreateContent', + 'canUpdateContent', + 'canDeleteContent', + 'canPurgeAccount', + 'canUpdateAvatar', + 'canUpdateBanner', 'mentionLimit', 'canInvite', 'inviteLimit', diff --git a/packages/frontend/src/pages/admin/overview.users.vue b/packages/frontend/src/pages/admin/overview.users.vue index 408be88d4792..b5b0f2066cf6 100644 --- a/packages/frontend/src/pages/admin/overview.users.vue +++ b/packages/frontend/src/pages/admin/overview.users.vue @@ -51,9 +51,11 @@ useInterval(fetch, 1000 * 60, { transition: transform 1s ease; } - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - grid-gap: 12px; + & { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + grid-gap: 12px; + } > .user:hover { text-decoration: none; diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index 3e948abdf13d..36a55f869ee1 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -160,6 +160,26 @@ SPDX-License-Identifier: AGPL-3.0-only + + + +
+ + + + + + + + + +
+
+