diff --git a/.woodpecker/build.yml b/.woodpecker/build.yml index e93473fe8..f4f2805c2 100644 --- a/.woodpecker/build.yml +++ b/.woodpecker/build.yml @@ -8,15 +8,15 @@ clone: pipeline: install: when: - event: - - pull_request + branch: main + event: push image: node:18.6.0 commands: - yarn install build: when: - event: - - pull_request + branch: main + event: push image: node:18.6.0 commands: - yarn build diff --git a/.woodpecker/lint-backend.yml b/.woodpecker/lint-backend.yml index 5690d99ae..096989cb7 100644 --- a/.woodpecker/lint-backend.yml +++ b/.woodpecker/lint-backend.yml @@ -8,15 +8,15 @@ clone: pipeline: install: when: - event: - - pull_request + branch: main + event: push image: node:18.6.0 commands: - yarn install lint: when: - event: - - pull_request + branch: main + event: push image: node:18.6.0 commands: - yarn workspace backend run lint diff --git a/.woodpecker/lint-client.yml b/.woodpecker/lint-client.yml index feb910081..6b60ac448 100644 --- a/.woodpecker/lint-client.yml +++ b/.woodpecker/lint-client.yml @@ -8,15 +8,15 @@ clone: pipeline: install: when: - event: - - pull_request + branch: main + event: push image: node:18.6.0 commands: - yarn install lint: when: - event: - - pull_request + branch: main + event: push image: node:18.6.0 commands: - yarn workspace client run lint diff --git a/.woodpecker/lint-foundkey-js.yml b/.woodpecker/lint-foundkey-js.yml index ce8162209..ddff1b46e 100644 --- a/.woodpecker/lint-foundkey-js.yml +++ b/.woodpecker/lint-foundkey-js.yml @@ -8,15 +8,15 @@ clone: pipeline: install: when: - event: - - pull_request + branch: main + event: push image: node:18.6.0 commands: - yarn install lint: when: - event: - - pull_request + branch: main + event: push image: node:18.6.0 commands: - yarn workspace foundkey-js run lint diff --git a/.woodpecker/lint-sw.yml b/.woodpecker/lint-sw.yml index 4a20add2e..aad1f4ad4 100644 --- a/.woodpecker/lint-sw.yml +++ b/.woodpecker/lint-sw.yml @@ -8,15 +8,15 @@ clone: pipeline: install: when: - event: - - pull_request + branch: main + event: push image: node:18.6.0 commands: - yarn install lint: when: - event: - - pull_request + branch: main + event: push image: node:18.6.0 commands: - yarn workspace sw run lint diff --git a/.woodpecker/test.yml b/.woodpecker/test.yml index 30eb04ee6..e42d68c73 100644 --- a/.woodpecker/test.yml +++ b/.woodpecker/test.yml @@ -5,11 +5,14 @@ clone: depth: 1 # CI does not need commit history recursive: true +depends_on: + - build + pipeline: build: when: - event: - - pull_request + branch: main + event: push image: node:18.6.0 commands: - yarn install @@ -18,15 +21,15 @@ pipeline: - yarn build mocha: when: - event: - - pull_request + branch: main + event: push image: node:18.6.0 commands: - yarn mocha e2e: when: - event: - - pull_request + branch: main + event: push image: cypress/included:10.3.0 commands: - npm run start:test & diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index cd9cf8302..d2e95f8b7 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,46 +1,134 @@ + # Contributor Covenant Code of Conduct ## Our Pledge -In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. ## Our Standards -Examples of behavior that contributes to creating a positive environment include: +Examples of behavior that contributes to a positive environment for our +community include: -* Using welcoming and inclusive language -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism -* Focusing on what is best for the community -* Showing empathy towards other community members +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community -Examples of unacceptable behavior by participants include: +Examples of unacceptable behavior include: -* The use of sexualized language or imagery and unwelcome sexual attention or advances -* Trolling, insulting/derogatory comments, and personal or political attacks +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment -* Publishing others' private information, such as a physical or electronic address, without explicit permission -* Other conduct which could reasonably be considered inappropriate in a professional setting +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting -## Our Responsibilities +## Enforcement Responsibilities -Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. -Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. ## Scope -This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. ## Enforcement -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at syuilotan@yahoo.co.jp. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement via email at +johann�qwertqwefsday.eu and/or toast�bunkerlabs.net . +All complaints will be reviewed and investigated promptly and fairly. -Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. ## Attribution -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations -[homepage]: http://contributor-covenant.org -[version]: http://contributor-covenant.org/version/1/4/ diff --git a/docs/install-docker.md b/docs/install-docker.md new file mode 100644 index 000000000..9444c5ace --- /dev/null +++ b/docs/install-docker.md @@ -0,0 +1,84 @@ +# Create FoundKey instance with Docker Compose + +This guide describes how to install and setup FoundKey with Docker Compose. + +**WARNING:** +Never change the domain name (hostname) of an instance once you start using it! + + +## Requirements +- Docker or Podman +- Docker Compose plugin (or podman-compose) + +If using Podman, replace `docker` with `podman`. Commands using `docker compose` should be replaced with `podman-compose`. + +You may need to prefix `docker` commands with `sudo` unless your user is in the `docker` group or you are running Docker in rootless mode. + +## Get the repository +```sh +git clone https://akkoma.dev/FoundKeyGang/FoundKey.git +cd FoundKey +``` + +To make it easier to perform your own changes on top, we suggest making a branch based on the latest tag. +In this example, we'll use `v13.0.0-preview1` as the tag to check out and `my-branch` as the branch name. +```sh +git checkout tags/v13.0.0-preview1 -b my-branch +``` + +## Configure + +Copy example configuration files with following: + +```sh +cp .config/docker_example.yml .config/default.yml +cp .config/docker_example.env .config/docker.env +``` + +Edit `default.yml` and `docker.env` according to the instructions in the files. +You will need to set the database host to `db` and Redis host to `redis` in order to use the internal container network for these services. + + +Edit `docker-compose.yml` if necessary. (e.g. if you want to change the port). +If you are using SELinux (eg. you're on Fedora or a RHEL derivative), you'll want to add the `Z` mount flag to the volume mounts to allow the containers to access the contents of those volumes. + +## Build and initialize +The following command will build FoundKey and initialize the database. +This will take some time. + +``` shell +docker compose build +docker compose run --rm web yarn run init +``` + +## Launch +You can start FoundKey with the following command: + +```sh +docker compose up -d +``` + +In case you are encountering issues, you can run `docker compose logs -f` to get the log output of the running containers. + +## How to update your FoundKey server +When updating, be sure to check the [release notes](https://akkoma.dev/FoundKeyGang/FoundKey/src/branch/main/CHANGELOG.md) to know in advance the changes and whether or not additional work is required (in most cases, it is not). + +To update your branch to the latest tag (in this example `v13.0.0-preview2`), you can do the following: +```sh +git fetch -t + +# Use --squash if you want to merge all of the changes in the tag into a single commit. +# Useful if you have made additional changes. +git merge tags/v13.0.0-preview2 + +# Rebuild and restart the docker container. +docker compose build +docker compose down && docker compose up -d +``` + +It may take some time depending on the contents of the update and the size of the database. + +## How to execute CLI commands +```sh +docker compose run --rm web node packages/backend/built/tools/foo bar +``` diff --git a/docs/install.md b/docs/install.md index f55cf0046..055c5a590 100644 --- a/docs/install.md +++ b/docs/install.md @@ -38,22 +38,29 @@ Create a separate non-root user to run FoundKey: adduser --disabled-password --disabled-login foundkey ``` +The following steps will require logging into the `foundkey` user, so do that now. +```sh +su - foundkey +``` + ## Install FoundKey -1. Login to the `foundkey` user +We recommend using a local branch and merging in upstream releases as they get tagged. This allows for easy local customization of your install. - `su - foundkey` +First, clone the FoundKey repo: +```sh +git clone https://akkoma.dev/FoundKeyGang/FoundKey +cd FoundKey +``` -2. Clone the FoundKey repository +Now create your local branch. In this example, we'll be using `toast.cafe` as the local branch name and release `v13.0.0-preview1` as the tag to track. To create that branch: +```sh +git checkout tags/v13.0.0-preview1 -b toast.cafe +``` - `git clone --recursive https://akkoma.dev/FoundKeyGang/FoundKey foundkey` - -3. Navigate to the repository - - `cd foundkey` - -4. Install FoundKey's dependencies - - `yarn install` +Updating will be covered in a later section. For now you'll want to install the dependencies using Yarn: +```sh +yarn install +``` ## Configure FoundKey 1. Copy `.config/example.yml` to `.config/default.yml`. @@ -185,14 +192,22 @@ rc-service foundkey start You can check if the service is running with `rc-service foundkey status`. ### Updating FoundKey -Use git to pull in the latest changes and rerun the build and migration commands: - +When a new release comes out, simply fetch and merge in the new tag. If you plan on making additional changes on top of that tag, we suggest using the `--squash` option with `git merge`. +```sh +git fetch -t +git merge tags/v13.0.0-preview2 +# you are now on the "next" release +``` + +Now you'll want to update your dependencies and rebuild: ```sh -git pull -git submodule update --init yarn install # Use build-parallel if your system has 4GB or more RAM and want faster builds NODE_ENV=production yarn build +``` + +Next, run the database migrations: +```sh yarn migrate ``` diff --git a/locales/ar-SA.yml b/locales/ar-SA.yml index 0ec001f32..2556bdc5e 100644 --- a/locales/ar-SA.yml +++ b/locales/ar-SA.yml @@ -258,8 +258,6 @@ createFolder: "أنشئ مجلدًا" renameFolder: "إعادة تسمية المجلد" deleteFolder: "احذف هذا المجلد" addFile: "إضافة ملف" -emptyDrive: "قرص التخزين فارغ" -emptyFolder: "هذا المجلد فارغ" unableToDelete: "لا يمكن حذفه" inputNewFileName: "ادخل الإسم الجديد للملف" inputNewDescription: "أدخل تعليقًا توضيحيًا" diff --git a/locales/bn-BD.yml b/locales/bn-BD.yml index 36c7cd700..6e160296b 100644 --- a/locales/bn-BD.yml +++ b/locales/bn-BD.yml @@ -273,8 +273,6 @@ createFolder: "ফোল্ডার তৈরি করুন" renameFolder: "ফোল্ডার পুনঃনামকরন" deleteFolder: "ফোল্ডার মুছুন" addFile: "ফাইল যোগ করুন" -emptyDrive: "আপনার ড্রাইভ খালি" -emptyFolder: "এই ফোল্ডার খালি" unableToDelete: "মুছে ফেলা যায়নি" inputNewFileName: "ফাইলের নতুন নাম লিখুন" inputNewDescription: "নতুন ক্যাপশন লিখুন" diff --git a/locales/cs-CZ.yml b/locales/cs-CZ.yml index 53c7f4aa4..96c3963be 100644 --- a/locales/cs-CZ.yml +++ b/locales/cs-CZ.yml @@ -249,7 +249,6 @@ createFolder: "Vytvořit složku" renameFolder: "Přejmenovat složku" deleteFolder: "Odstranit složku" addFile: "Přidat soubor" -emptyFolder: "Tato složka je prázdná" unableToDelete: "Nelze smazat" inputNewFileName: "Zadejte nový název" copyUrl: "Kopírovat URL" diff --git a/locales/de-DE.yml b/locales/de-DE.yml index 806f155fe..86eac682d 100644 --- a/locales/de-DE.yml +++ b/locales/de-DE.yml @@ -284,8 +284,6 @@ createFolder: "Ordner erstellen" renameFolder: "Ordner umbenennen" deleteFolder: "Ordner löschen" addFile: "Datei hinzufügen" -emptyDrive: "Deine Drive ist leer" -emptyFolder: "Dieser Ordner ist leer" unableToDelete: "Nicht löschbar" inputNewFileName: "Gib einen neuen Dateinamen ein" inputNewDescription: "Gib eine neue Beschreibung ein" @@ -1313,6 +1311,7 @@ _notification: followRequestAccepted: "Akzeptierte Follow-Anfragen" groupInvited: "Erhaltene Gruppeneinladungen" app: "Benachrichtigungen von Apps" + move: Account-Umzüge _actions: followBack: "folgt dir nun auch" reply: "Antworten" @@ -1401,3 +1400,15 @@ oauthErrorGoBack: Bei der Authentifizierung einer Drittanbieter-Anwendung ist ei Fehler aufgetreten. Bitte geh zurück und versuche es erneut. appAuthorization: Anwendungs-Authorisierung noPermissionsRequested: (Keine Berechtigungen angefordert.) +_remoteInteract: + title: Es tut mir leid, aber das kann ich nicht tun. + description: Diese Aktion kann gerade nicht ausgeführt werden. Vermutlich musst + du diese Aktion auf deiner eigenen Instanz ausführen, oder dich anmelden. + urlInstructions: Kopiere diese URL. Wenn du sie auf deiner Instanz in das Suchfeld + einfügst, solltest du zum richtigen Ort gelangen. +movedTo: Diese Person ist umgezogen zu {handle}. +attachedToNotes: Notizen mit dieser Datei +showAttachedNotes: Zeige Notizen mit dieser Datei +uploadFailed: Hochladen fehlgeschlagen +uploadFailedDescription: Die Datei konnte nicht hochgeladen werden. +uploadFailedSize: Die Datei ist zu groß. diff --git a/locales/en-US.yml b/locales/en-US.yml index 887d7d287..fa0d9d28e 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -233,6 +233,9 @@ resetAreYouSure: "Really reset?" saved: "Saved" messaging: "Chat" upload: "Upload" +uploadFailed: "Upload failed" +uploadFailedDescription: "The file could not be uploaded." +uploadFailedSize: "The file is too large to be uploaded." keepOriginalUploading: "Keep original image" keepOriginalUploadingDescription: "Saves the originally uploaded image as-is. If turned\ \ off, a version to display on the web will be generated on upload." @@ -278,8 +281,6 @@ createFolder: "Create a folder" renameFolder: "Rename this folder" deleteFolder: "Delete this folder" addFile: "Add a file" -emptyDrive: "Your Drive is empty" -emptyFolder: "This folder is empty" unableToDelete: "Unable to delete" inputNewFileName: "Enter a new filename" inputNewDescription: "Enter new caption" @@ -349,6 +350,8 @@ withReplies: "Include replies" connectedTo: "Following account(s) are connected" notesAndReplies: "Notes and replies" withFiles: "Including files" +attachedToNotes: "Notes with this file" +showAttachedNotes: "Show notes with this file" silence: "Silence" silenceConfirm: "Are you sure that you want to silence this user?" unsilence: "Undo silencing" @@ -829,6 +832,7 @@ oauthErrorGoBack: "An error happened while trying to authenticate a 3rd party ap \ Please go back and try again." appAuthorization: "App authorization" noPermissionsRequested: "(No permissions requested.)" +movedTo: "This user has moved to {handle}." _emailUnavailable: used: "This email address is already being used" format: "The format of this email address is invalid" @@ -1306,6 +1310,7 @@ _notification: receiveFollowRequest: "Received follow requests" followRequestAccepted: "Accepted follow requests" groupInvited: "Group invitations" + move: "Others moving accounts" app: "Notifications from linked apps" _actions: followBack: "followed you back" diff --git a/locales/es-ES.yml b/locales/es-ES.yml index 0d811bcde..fbfcd9352 100644 --- a/locales/es-ES.yml +++ b/locales/es-ES.yml @@ -185,16 +185,17 @@ clearQueueConfirmText: "Las notas aún no entregadas no se federarán. Normalmen clearCachedFiles: "Limpiar caché" clearCachedFilesConfirm: "¿Desea borrar todos los archivos remotos cacheados?" blockedInstances: "Instancias bloqueadas" -blockedInstancesDescription: "Seleccione los hosts de las instancias que desea bloquear,\ - \ separadas por una linea nueva. Las instancias bloqueadas no podrán comunicarse\ - \ con esta instancia." +blockedInstancesDescription: "Seleccione los hosts de las instancias que desea bloquear.\ + \ Las instancias listadas no podrán comunicarse con esta instancia. Los nombres\ + \ de dominios Non-ASCII deben ser codificados con punycode.\nLos sub dominios de\ + \ la lista de instancias serán bloqueados también." muteAndBlock: "Silenciar y bloquear" mutedUsers: "Usuarios silenciados" blockedUsers: "Usuarios bloqueados" noUsers: "No hay usuarios" editProfile: "Editar perfil" noteDeleteConfirm: "¿Desea borrar esta nota?" -pinLimitExceeded: "Ya no se pueden fijar más posts" +pinLimitExceeded: "Ya no se pueden fijar más notas." intro: "¡La instalación de FoundKey ha terminado! Crea el usuario administrador." done: "Terminado" processing: "Procesando" @@ -275,8 +276,6 @@ createFolder: "Crear carpeta" renameFolder: "Renombrar carpeta" deleteFolder: "Borrar carpeta" addFile: "Agregar archivo" -emptyDrive: "El drive está vacío" -emptyFolder: "La carpeta está vacía" unableToDelete: "No se puede borrar" inputNewFileName: "Ingrese un nuevo nombre de archivo" inputNewDescription: "Ingrese nueva descripción" @@ -558,7 +557,7 @@ smtpPass: "Contraseña" emptyToDisableSmtpAuth: "Deje el nombre del usuario y la contraseña en blanco para\ \ deshabilitar la autenticación SMTP" smtpSecure: "Usar SSL/TLS implícito en la conexión SMTP" -smtpSecureInfo: "Apagar cuando se use STARTTLS" +smtpSecureInfo: "Apagar cuando se use STARTTLS." testEmail: "Prueba de envío" wordMute: "Silenciar palabras" instanceMute: "Instancias silenciadas" @@ -599,7 +598,7 @@ openInNewTab: "Abrir en una Nueva Pestaña" defaultNavigationBehaviour: "Navegación por defecto" instanceTicker: "Información de notas de la instancia" system: "Sistema" -switchUi: "Cambiar interfaz de usuario" +switchUi: "Cambiar interfaz" desktop: "Escritorio" clip: "Clip" createNew: "Crear" @@ -691,7 +690,7 @@ notSpecifiedMentionWarning: "Algunas menciones no están incluidas en el destino info: "Información" userInfo: "Información del usuario" unknown: "Desconocido" -hideOnlineStatus: "mostrarse como desconectado" +hideOnlineStatus: "Mostrarse como desconectado" hideOnlineStatusDescription: "Ocultar su estado en línea puede reducir la eficacia\ \ de algunas funciones, como la búsqueda" online: "En línea" @@ -730,7 +729,7 @@ misskeyUpdated: "¡FoundKey ha sido actualizado!" whatIsNew: "Mostrar cambios" translate: "Traducir" translatedFrom: "Traducido de {x}" -accountDeletionInProgress: "La eliminación de la cuenta está en curso" +accountDeletionInProgress: "La eliminación de la cuenta está en curso." usernameInfo: "Un nombre que identifique su cuenta de otras en este servidor. Puede\ \ utilizar el alfabeto (a~z, A~Z), dígitos (0~9) o guiones bajos (_). Los nombres\ \ de usuario no se pueden cambiar posteriormente." @@ -757,30 +756,43 @@ ffVisibility: "Visibilidad de seguidores y seguidos" hide: "Ocultar" indefinitely: "Sin límite de tiempo" _ffVisibility: - public: "Publicar" + public: "Público" + private: Privado + followers: Visible solo a seguidores _accountDelete: accountDelete: "Eliminar Cuenta" + started: La eliminación ha iniciado. + sendEmail: Cuando se complete la eliminación de la cuenta, un correo será enviado + a la dirección registrada de la cuenta. + requestAccountDelete: Solicitar eliminación de cuenta + inProgress: Eliminación en progreso + mayTakeTime: Como la eliminación de la cuenta es un proceso que consume muchos recursos, + puede llevar algún tiempo según la cantidad de contenido que se haya creado y + la cantidad de archivos que cargados. _forgotPassword: contactAdmin: "Esta instancia no admite el uso de direcciones de correo electrónico,\ \ póngase en contacto con el administrador de la instancia para restablecer su\ - \ contraseña" + \ contraseña." + enterEmail: Ingrese el correo que se uso para el registro. Se enviará un link para + que resetear la contraseña. + ifNoEmail: Si no se utilizó un correo durante el registro, por favor contacte con + el administrador de la instancia. _email: _follow: - title: "te ha seguido" + title: "Tienes un nuevo seguidor" _receiveFollowRequest: title: "Has recibido una solicitud de seguimiento" _plugin: install: "Instalar plugins" - installWarn: "Por favor no instale plugins que no son de confianza" + installWarn: "Por favor no instale plugins que no son de confianza." _registry: scope: "Alcance" key: "Clave" - keys: "Clave" + keys: "Claves" domain: "Dominio" createKey: "Crear una llave" _aboutMisskey: - about: "FoundKey es un software de código abierto, desarrollado por syuilo desde\ - \ el 2014" + about: "FoundKey es una bifurcación de Misskey desarrollada desde Julio de 2022." allContributors: "Todos los colaboradores" source: "Código fuente" _nsfw: @@ -791,13 +803,13 @@ _mfm: cheatSheet: "Hoja de referencia de MFM" intro: "MFM es un lenguaje de marcado dedicado que se puede usar en varios lugares\ \ dentro de FoundKey. Aquí puede ver una lista de sintaxis disponibles en MFM." - dummy: "FoundKey expande el mundo de la Fediverso" + dummy: "FoundKey expande el mundo del Fediverso" mention: "Menciones" mentionDescription: "El signo @ seguido de un nombre de usuario se puede utilizar\ \ para notificar a un usuario en particular." - hashtag: "Hashtag" + hashtag: "Hashtag #" url: "URL" - urlDescription: "Se pueden mostrar las URL" + urlDescription: "Se pueden mostrar las URL." link: "Vínculo" bold: "Negrita" center: "Centrar" @@ -812,6 +824,48 @@ _mfm: \ / derecha." font: "Fuente" rotate: "Rotar" + jump: Animación (Salto) + jumpDescription: Da al contenido un salto animado. + bounce: Animación (Bounce) + centerDescription: Muestra el contenido de manera centrada. + inlineCode: Código (Inline) + blockMath: 'Función matemática (Block)' + blockMathDescription: Muestra líneas múltiples de fórmulas matemáticas (KaTeX) en + un bloque. + inlineCodeDescription: Muestra resaltado de sintaxis en línea para el código (program). + quoteDescription: Muestra el contenido en una cita. + searchDescription: Muestra un cuadro de búsqueda con texto pre introducido. + jellyDescription: Le da al contenido una animación del tipo Jelly. + tada: Animación (Tada) + shake: Animación (Shake) + hashtagDescription: Puedes especificar un hashtag usando un número, signo y texto. + linkDescription: Partes especificas del texto pueden ser mostradas como URL. + boldDescription: Resalta las letras haciéndolas más gruesas. + small: Pequeño + smallDescription: Muestra el contenido de manera pequeña y delgada. + inlineMathDescription: Muestra fórmulas matemáticas (KaTeX) en linea. + tadaDescription: Da al contenido un "Tada!" del tipo animación. + bounceDescription: Brinda al contenido una animación de rebote. + emojiDescription: Al colocar dos puntos en un emoji personalizado, se puede mostrar + un emoji personalizado. + jelly: Animación (Jelly) + twitchDescription: Da al contenido una animación fuertemente temblorosa. + twitch: Animación (Twitch) + spin: Animación (Spin) + shakeDescription: Brinda al contenido una animación temblorosa. + inlineMath: Función matemática (Inline) + rainbow: Arcoíris + x4Description: Muestra el contenido de la manera más grandemente posible. + blurDescription: Muestra borroso el contenido. Se mostrará con claridad cuando se + cubra. + spinDescription: Da al contenido una animación de girar. + x2: Grande + x2Description: Muestra en grande el contenido. + x3Description: Muestra más grande el contenido. + x4: Increíblemente grande + blur: Borroso + fontDescription: Agrega la fuente para mostrar contenido. + x3: Muy grande _instanceTicker: none: "No mostrar" remote: "Mostrar a usuarios remotos" @@ -1134,7 +1188,7 @@ _notification: youWereFollowed: "te ha seguido" youReceivedFollowRequest: "Has mandado una solicitud de seguimiento" yourFollowRequestAccepted: "Tu solicitud de seguimiento fue aceptada" - youWereInvitedToGroup: "Invitado al grupo" + youWereInvitedToGroup: "{userName} invitado al grupo" _types: follow: "Siguiendo" mention: "Menciones" @@ -1169,3 +1223,116 @@ _deck: mentions: "Menciones" direct: "Mensaje directo" _services: {} +translationService: Servicio de traducción +translationSettings: Ajustes de traducción +selectAll: Seleccionar todo +setCategory: Establecer categoría +unlimited: Ilimitado +setTag: Establecer tag +recentNHours: Últimas {n} horas +check: Verificar +unrenoteAll: Quitar todas las renotas +unclip: Desenganchar +deleteAllFiles: Borrar todos los archivos. +voteConfirm: ¿Confirmas tu voto para "{choice}"? +tenMinutes: 10 minutos +oneHour: Una hora +failedToFetchAccountInformation: No se pudo obtener la información de la cuenta +rateLimitExceeded: Límite de velocidad excedido +cropImage: Cortar imagen +recentNDays: Últimos {n} días +typeToConfirm: Por favor ingrese {x} para confirmar +cropImageAsk: ¿Desea cortar esta imagen? +cannotAttachFileWhenAccountSwitched: No puedes adjuntar un archivo mientras cambias + de cuenta. +cannotSwitchAccountWhenFileAttached: No puedes cambiar de cuenta mientras se adjuntan + archivos. +threadMuteNotificationsDesc: Selecciona las notificaciones que deseas ver de este + hilo. Los ajustes globales de notificación también se aplicarán. La desactivación + tiene prioridad. +ffVisibilityDescription: Permite configurar quien puede ver a quienes sigues y quienes + te siguen. +deleteAccountConfirm: Esto borrará se manera irreversible tu cuenta {handle}. ¿Quieres + proceder? +incorrectPassword: Contraseña incorrecta. +leaveGroup: Dejar grupo +overridedDeviceKind: Tipo de dispositivo +smartphone: Smartphone +auto: Automático +themeColor: Color de teletipo de instancia +size: Tamaño +numberOfColumn: Número de columnas +instanceDefaultLightTheme: Tema claro predeterminado para la instancia +oneDay: Un día +oneWeek: Una semana +numberOfPageCache: Número de páginas en caché +confirmToUnclipAlreadyClippedNote: Esta nota ya forma parte del clip "{name}". ¿Quieres + eliminarlo de este clip en su lugar? +noEmailServerWarning: Correo del servidor no configurado. +thereIsUnresolvedAbuseReportWarning: Hay reportes sin resolver. +recommended: Recomendado +addTag: Agregar tag +removeTag: Quitar tag +_emailUnavailable: + used: El correo ya se encuentra en uso + format: El formato del correo es invalido + disposable: No se puede utilizar direcciones de correo electrónico desechables + mx: El servidor del correo es inválido + smtp: El servido del correo no responde +exportAll: Exportar todo +exportSelected: Exportar seleccionados +botFollowRequiresApproval: Solicitudes de seguimiento de cuentas marcadas como "bots" + necesitan aprobación +unrenoteAllConfirm: ¿Estas seguro de querer quitar todas las renotas de esta nota? +signinHistoryExpires: Los datos de los intentos de sesión serán borrados automáticamente + luego de 60 días, debido a regulaciones de privacidad. +tablet: Tableta +mutePeriod: Duración de mute +reflectMayTakeTime: Puede tomar cierto tiempo en que se reflejen los cambios. +isSystemAccount: Una cuenta creada y operada automáticamente por el sistema. +deleteAccount: Eliminar cuenta +numberOfPageCacheDescription: Aumentar este número mejorará la comodidad para los + usuarios, pero provocará una mayor carga del servidor y más memoria se utilizará. +externalCssSnippets: Algunos fragmentos de CSS para inspirar (no administrados por + FoundKey) +_signup: + emailSent: Se envió un correo de confirmación a ({email}). Por favor haz click en + link adjunto para completar la creación de cuenta. + almostThere: Ya casi + emailAddressInfo: Por favor ingresa tu correo electrónico. No sera público. +instanceDefaultThemeDescription: Ingrese el código de tema en el formato del objeto +stopActivityDeliveryDescription: Las actividades locales no serán enviadas a esta + instancia. Las actividades recibidas de recepción funcionan como antes. +documentation: Documentación +file: Archivo +federateBlocks: Bloques Federados +federateBlocksDescription: Si esta deshabilitado, las actividades bloqueadas no serán + enviadas. +useDrawerReactionPickerForMobile: Mostrar selección de reacción como cajón en móvil +leaveGroupConfirm: ¿Estas seguro de salir "{name}"? +clickToFinishEmailVerification: Por favor presiona [{ok}] para completar la verificación + email. +oauthErrorGoBack: Ocurrió un error al tratar de autenticar una app de terceros. Por + favor regresa e intenta de nuevo. +appAuthorization: Autorización de app +noPermissionsRequested: (No hay permisos solicitados). +selectMode: Selección múltiple +renoteMute: Ocultar renotas +renoteUnmute: Mostrar renotas +blockThisInstanceDescription: Las actividades locales no serán enviadas a esta instancia. + Las actividades de esta instancia serán descartadas. +instanceDefaultDarkTheme: Tema oscuro predeterminado para la instancia +showLess: Mostrar menos +regexpError: Error de Expresión Regular +regexpErrorDescription: 'Hay un error en la expresión regular de la linea {line} de + {tab} palabras silenciadas:' +unlikeConfirm: ¿En verdad quieres remover tu like? +breakFollow: Quitar seguidor +reporter: Reportero +continueThread: Ver la continuación del hilo +uploadFailedSize: El archivo es muy grande para subirse. +uploadFailed: Subida fallida +uploadFailedDescription: No se pudo subir el archivo. +movedTo: Este usuario se ha movido a {handle}. +attachedToNotes: Notas del archivo +showAttachedNotes: Mostrar notas del archivo diff --git a/locales/fr-FR.yml b/locales/fr-FR.yml index 786f7e481..0cea0bfcb 100644 --- a/locales/fr-FR.yml +++ b/locales/fr-FR.yml @@ -277,8 +277,6 @@ createFolder: "Créer un dossier" renameFolder: "Renommer le dossier" deleteFolder: "Supprimer le dossier" addFile: "Ajouter un fichier" -emptyDrive: "Le Drive est vide" -emptyFolder: "Le dossier est vide" unableToDelete: "Suppression impossible" inputNewFileName: "Entrez un nouveau nom de fichier" inputNewDescription: "Veuillez entrer une nouvelle description" diff --git a/locales/id-ID.yml b/locales/id-ID.yml index bef44bec5..2114dc99b 100644 --- a/locales/id-ID.yml +++ b/locales/id-ID.yml @@ -12,7 +12,7 @@ password: "Kata sandi" forgotPassword: "Lupa Kata Sandi" fetchingAsApObject: "Mengambil data dari Fediverse..." ok: "OK" -gotIt: "Saya mengerti" +gotIt: "Saya mengerti!" cancel: "Batalkan" renotedBy: "direnote oleh {user}" noNotes: "Tidak ada catatan" @@ -24,7 +24,7 @@ otherSettings: "Pengaturan lainnya" openInWindow: "Buka di jendela" profile: "Profil" timeline: "Linimasa" -noAccountDescription: "Pengguna ini belum menulis bio" +noAccountDescription: "Pengguna ini belum menulis biodata mereka." login: "Masuk" loggingIn: "Sedang masuk" logout: "Keluar" @@ -62,8 +62,8 @@ files: "Berkas" download: "Unduh" driveFileDeleteConfirm: "Hapus {name}? Catatan dengan berkas terkait juga akan terhapus." unfollowConfirm: "Berhenti mengikuti {name}?" -exportRequested: "Kamu telah meminta ekspor. Ini akan memakan waktu sesaat. Setelah\ - \ ekspor selesai, berkas yang dihasilkan akan ditambahkan ke Drive" +exportRequested: "Anda telah meminta ekspor. Ini mungkin memerlukan waktu beberapa\ + \ saat. File ini akan ditambahkan ke Drive Anda setelah selesai." importRequested: "Kamu telah meminta impor. Ini akan memakan waktu sesaat." lists: "Daftar" note: "Catat" @@ -100,8 +100,8 @@ clickToShow: "Klik untuk melihat" sensitive: "Konten sensitif" add: "Tambahkan" reaction: "Reaksi" -reactionSettingDescription2: "Geser untuk memindah urutkan, klik untuk menghapus,\ - \ tekan \"+\" untuk menambahkan" +reactionSettingDescription2: "Seret untuk menyusun ulang, klik untuk menghapus, tekan\ + \ \"+\" untuk menambahkan." attachCancel: "Hapus lampiran" markAsSensitive: "Tandai sebagai konten sensitif" unmarkAsSensitive: "Hapus tanda konten sensitif" @@ -193,10 +193,10 @@ blockedUsers: "Pengguna yang diblokir" noUsers: "Tidak ada pengguna" editProfile: "Sunting profil" noteDeleteConfirm: "Apakah kamu yakin ingin menghapus catatan ini?" -pinLimitExceeded: "Kamu tidak dapat menyematkan catatan lagi" +pinLimitExceeded: "Anda tidak dapat menyematkan catatan lagi." intro: "Instalasi FoundKey telah selesai! Mohon untuk membuat pengguna admin." done: "Selesai" -processing: "Memproses" +processing: "Pemrosesan..." preview: "Pratinjau" default: "Bawaan" noCustomEmojis: "Tidak ada emoji kustom" @@ -210,7 +210,7 @@ publishing: "Sedang menyiarkan langsung" notResponding: "Tidak ada respon" changePassword: "Ubah kata sandi" security: "Keamanan" -retypedNotMatch: "Input tidak sama" +retypedNotMatch: "Input tidak cocok." currentPassword: "Kata sandi saat ini" newPassword: "Kata sandi baru" newPasswordRetype: "Ulangi kata sandi baru" @@ -237,7 +237,7 @@ fromUrl: "Dari URL" uploadFromUrl: "Unggah dari URL" uploadFromUrlDescription: "URL berkas yang ingin kamu unggah" uploadFromUrlRequested: "Pengunggahan telah diminta" -uploadFromUrlMayTakeTime: "Membutuhkan beberapa waktu hingga pengunggahan selesai" +uploadFromUrlMayTakeTime: "Mungkin diperlukan waktu hingga unggahan selesai." explore: "Jelajahi" messageRead: "Telah dibaca" noMoreHistory: "Tidak ada sejarah lagi" @@ -274,8 +274,6 @@ createFolder: "Buat folder" renameFolder: "Ubah nama folder" deleteFolder: "Hapus folder" addFile: "Tambahkan berkas" -emptyDrive: "Drive kosong" -emptyFolder: "Folder kosong" unableToDelete: "Tidak dapat menghapus" inputNewFileName: "Masukkan nama berkas yang baru" inputNewDescription: "Masukkan keterangan disini" @@ -405,7 +403,7 @@ newMessageExists: "Kamu mendapatkan pesan baru" onlyOneFileCanBeAttached: "Kamu hanya dapat melampirkan satu berkas ke dalam pesan" signinRequired: "Silahkan login" invitationCode: "Kode undangan" -checking: "Memeriksa" +checking: "Memeriksa..." available: "Tersedia" unavailable: "Tidak tersedia" usernameInvalidFormat: "Hanya dapat menerima karakter a-z, A-Z dan angka 0-9." @@ -447,11 +445,10 @@ showFeaturedNotesInTimeline: "Tampilkan catatan yang diunggulkan di linimasa" objectStorage: "Object Storage" useObjectStorage: "Gunakan object storage" objectStorageBaseUrl: "Base URL" -objectStorageBaseUrlDesc: "Prefix URL digunakan untuk mengkonstruksi URL ke object\ - \ (media) referencing. Tentukan URL jika kamu menggunakan CDN atau Proxy, jika tidak\ - \ tentukan alamat yang dapat diakses secara publik sesuai dengan panduan dari layanan\ - \ yang akan kamu gunakan, contohnya. 'https://.s3.amazonaws.com' untuk AWS\ - \ S3, dan 'https://storage.googleapis.com/' untuk GCS." +objectStorageBaseUrlDesc: "URL yang digunakan sebagai referensi. Tentukan URL CDN\ + \ atau Proksi Anda jika Anda menggunakan keduanya.\nUntuk S3 gunakan 'https://.s3.amazonaws.com'\ + \ dan untuk GCS atau layanan yang setara gunakan 'https://storage.googleapis.com/',\ + \ dst." objectStorageBucket: "Bucket" objectStorageBucketDesc: "Mohon tentukan nama bucket yang digunakan pada layanan yang\ \ telah dikonfigurasi." @@ -649,8 +646,8 @@ contact: "Kontak" useSystemFont: "Gunakan font bawaan sistem operasi" clips: "Klip" makeExplorable: "Buat akun tampil di \"Jelajahi\"" -makeExplorableDescription: "Jika kamu mematikan ini, akun kamu tidak akan muncul di\ - \ bagian \"Jelajahi:" +makeExplorableDescription: "Jika Anda menonaktifkannya, akun Anda tidak akan muncul\ + \ di bagian \"Jelajahi\"." showGapBetweenNotesInTimeline: "Tampilkan jarak diantara catatan pada linimasa" duplicate: "Duplikat" left: "Kiri" @@ -761,7 +758,7 @@ ffVisibility: "Visibilitas Mengikuti/Pengikut" ffVisibilityDescription: "Mengatur siapa yang dapat melihat pengikutmu dan yang kamu\ \ ikuti." continueThread: "Lihat lanjutan thread" -deleteAccountConfirm: "Akun akan dihapus. Apakah kamu yakin?" +deleteAccountConfirm: "Ini akan menghapuskan akun {handle} secara permanen. Lanjutkan?" incorrectPassword: "Kata sandi salah." voteConfirm: "Konfirmasi suara kamu untuk ({choice})?" hide: "Sembunyikan" @@ -1081,7 +1078,7 @@ _auth: shareAccess: "Apakah kamu ingin mengijinkan \"{name}\" untuk mengakses akun ini?" shareAccessAsk: "Apakah kamu ingin mengijinkan aplikasi ini untuk mengakses akun\ \ kamu?" - permissionAsk: "Aplikasi ini membutuhkan beberapa ijin, yaitu:" + permissionAsk: "Aplikasi ini meminta izin berikut ini" pleaseGoBack: "Mohon kembali ke aplikasi kamu" callback: "Mengembalikan kamu ke aplikasi" denied: "Akses ditolak" @@ -1265,7 +1262,7 @@ _notification: youWereFollowed: "Mengikuti kamu" youReceivedFollowRequest: "Kamu menerima permintaan mengikuti" yourFollowRequestAccepted: "Permintaan mengikuti kamu telah diterima" - youWereInvitedToGroup: "Telah diundang ke grup" + youWereInvitedToGroup: "{userName} mengundang Anda ke grup" pollEnded: "Hasil Kuesioner telah keluar" emptyPushNotificationMessage: "Pembaruan notifikasi dorong" _types: diff --git a/locales/it-IT.yml b/locales/it-IT.yml index 269078151..1f02dddb7 100644 --- a/locales/it-IT.yml +++ b/locales/it-IT.yml @@ -268,8 +268,6 @@ createFolder: "Nuova cartella" renameFolder: "Rinominare cartella" deleteFolder: "Elimina cartella" addFile: "Allega" -emptyDrive: "Il Drive è vuoto" -emptyFolder: "La cartella è vuota" unableToDelete: "Eliminazione impossibile" inputNewFileName: "Inserisci nome del nuovo file" inputNewDescription: "Inserisci una nuova descrizione" diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 233a33ace..6ff9e5d10 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -9,9 +9,9 @@ notifications: "通知" username: "ユーザー名" password: "パスワード" forgotPassword: "パスワードを忘れた" -fetchingAsApObject: "連合に照会中" +fetchingAsApObject: "連合に照会中..." ok: "OK" -gotIt: "わかった" +gotIt: "わかった!" cancel: "キャンセル" renotedBy: "{user}がRenote" noNotes: "ノートはありません" @@ -23,7 +23,7 @@ otherSettings: "その他の設定" openInWindow: "ウィンドウで開く" profile: "プロフィール" timeline: "タイムライン" -noAccountDescription: "自己紹介はありません" +noAccountDescription: "このユーザーはまだ自己紹介文を書いていません。" login: "ログイン" loggingIn: "ログイン中" logout: "ログアウト" @@ -123,12 +123,12 @@ addEmoji: "絵文字を追加" cacheRemoteFiles: "リモートのファイルをキャッシュする" cacheRemoteFilesDescription: "この設定を無効にすると、リモートファイルをキャッシュせず直リンクするようになります。サーバーのストレージを節約できますが、サムネイルが生成されないので通信量が増加します。" flagAsBot: "Botとして設定" -flagAsBotDescription: "このアカウントがプログラムによって運用される場合は、このフラグをオンにします。オンにすると、反応の連鎖を防ぐためのフラグとして他の開発者に役立ったり、FoundKeyのシステム上での扱いがBotに合ったものになります。" +flagAsBotDescription: "このアカウントがプログラムによって制御される場合は、このフラグをオンにします。オンにすると、別のBotとの終わりのないインタラクションの連続を防ぐためのフラグとして他の開発者に役立ったり、このアカウントをBotとして扱うためにFoundKey内部のシステムを調整します。" flagAsCat: "Catとして設定" flagAsCatDescription: "このアカウントが猫であることを示す場合は、このフラグをオンにします。" flagShowTimelineReplies: "タイムラインにノートへの返信を表示する" flagShowTimelineRepliesDescription: "オンにすると、タイムラインにユーザーのノート以外にもそのユーザーの他のノートへの返信を表示します。" -autoAcceptFollowed: "フォロー中ユーザーからのフォロリクを自動承認" +autoAcceptFollowed: "フォローしているユーザーからのフォローリクエストを自動承認" addAccount: "アカウントを追加" loginFailed: "ログインに失敗しました" showOnRemote: "リモートで表示" @@ -165,17 +165,17 @@ clearQueueConfirmText: "未配達の投稿は配送されなくなります。 clearCachedFiles: "キャッシュをクリア" clearCachedFilesConfirm: "キャッシュされたリモートファイルをすべて削除しますか?" blockedInstances: "ブロックしたインスタンス" -blockedInstancesDescription: "ブロックしたいインスタンスのホストを改行で区切って設定します。ブロックされたインスタンスは、このインスタンスとやり取りできなくなります。" +blockedInstancesDescription: "ブロックしたいインスタンスのホストを改行で区切って設定します。ブロックされたインスタンスは、このインスタンスとやり取りできなくなります。非ASCII文字を含むドメイン名はpunycodeでエンコードされている必要があります。設定したインスタンスのサブドメインもブロックされます。" muteAndBlock: "ミュートとブロック" mutedUsers: "ミュートしたユーザー" blockedUsers: "ブロックしたユーザー" noUsers: "ユーザーはいません" editProfile: "プロフィールを編集" noteDeleteConfirm: "このノートを削除しますか?" -pinLimitExceeded: "これ以上ピン留めできません" +pinLimitExceeded: "これ以上ピン留めできません。" intro: "FoundKeyのインストールが完了しました!管理者アカウントを作成しましょう。" done: "完了" -processing: "処理中" +processing: "処理中..." preview: "プレビュー" default: "デフォルト" noCustomEmojis: "絵文字はありません" @@ -251,8 +251,6 @@ createFolder: "フォルダーを作成" renameFolder: "フォルダー名を変更" deleteFolder: "フォルダーを削除" addFile: "ファイルを追加" -emptyDrive: "ドライブは空です" -emptyFolder: "フォルダーは空です" unableToDelete: "削除できません" inputNewFileName: "新しいファイル名を入力してください" inputNewDescription: "新しいキャプションを入力してください" @@ -309,7 +307,7 @@ name: "名前" antennaSource: "受信ソース" antennaKeywords: "受信キーワード" antennaExcludeKeywords: "除外キーワード" -antennaKeywordsDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります" +antennaKeywordsDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。" notifyAntenna: "新しいノートを通知する" withFileAntenna: "ファイルが添付されたノートのみ" antennaUsersDescription: "ユーザー名を改行で区切って指定します" @@ -378,10 +376,10 @@ newMessageExists: "新しいメッセージがあります" onlyOneFileCanBeAttached: "メッセージに添付できるファイルはひとつです" signinRequired: "続行する前に、サインアップまたはサインインが必要です" invitationCode: "招待コード" -checking: "確認しています" +checking: "確認しています..." available: "利用できます" unavailable: "利用できません" -usernameInvalidFormat: "a~z、A~Z、0~9、_が使えます" +usernameInvalidFormat: "a~z、A~Z、0~9、_が使えます。" tooShort: "短すぎます" tooLong: "長すぎます" weakPassword: "弱いパスワード" @@ -419,7 +417,7 @@ showFeaturedNotesInTimeline: "タイムラインにおすすめのノートを objectStorage: "オブジェクトストレージ" useObjectStorage: "オブジェクトストレージを使用" objectStorageBaseUrl: "Base URL" -objectStorageBaseUrlDesc: "参照に使用するURL。CDNやProxyを使用している場合はそのURL、S3: 'https://.s3.amazonaws.com'、GCS等:\ +objectStorageBaseUrlDesc: "参照に使用するURL。CDNやProxyを使用している場合はそのURL。\nS3: 'https://.s3.amazonaws.com'、GCS等:\ \ 'https://storage.googleapis.com/'。" objectStorageBucket: "Bucket" objectStorageBucketDesc: "使用サービスのbucket名を指定してください。" @@ -610,7 +608,7 @@ onlineUsersCount: "{n}人がオンライン" backgroundColor: "背景" accentColor: "アクセント" textColor: "文字" -saveAs: "名前を付けて保存" +saveAs: "名前を付けて保存..." createdAt: "作成日時" updatedAt: "更新日時" deleteConfirm: "削除しますか?" @@ -624,7 +622,7 @@ apply: "適用" receiveAnnouncementFromInstance: "インスタンスからのお知らせを受け取る" emailNotification: "メール通知" useReactionPickerForContextMenu: "右クリックでリアクションピッカーを開く" -typingUsers: "{users}が入力中" +typingUsers: "{users}が入力中..." jumpToSpecifiedDate: "特定の日付にジャンプ" clear: "クリア" markAllAsRead: "全て既読にする" @@ -641,7 +639,7 @@ unknown: "不明" hideOnlineStatus: "オンライン状態を隠す" hideOnlineStatusDescription: "オンライン状態を隠すと、検索などの一部機能において利便性が低下することがあります。" federateBlocks: "ブロックを連合に送信" -federateBlocksDescription: "オフにするとBlockのActivityは連合に送信しません" +federateBlocksDescription: "オフにすると、BlockのActivityは連合に送信されません。" online: "オンライン" active: "アクティブ" offline: "オフライン" @@ -677,7 +675,7 @@ misskeyUpdated: "FoundKeyが更新されました!" whatIsNew: "更新情報を見る" translate: "翻訳" translatedFrom: "{x}から翻訳" -accountDeletionInProgress: "アカウントの削除が進行中です" +accountDeletionInProgress: "アカウントの削除が進行中です。" usernameInfo: "サーバー上であなたのアカウントを一意に識別するための名前。アルファベット(a~z, A~Z)、数字(0~9)、およびアンダーバー(_)が使用できます。ユーザー名は後から変更することは出来ません。" keepCw: "CWを維持する" pubSub: "Pub/Subのアカウント" @@ -701,7 +699,7 @@ threadMuteNotificationsDesc: "このスレッドから表示する通知を選 ffVisibility: "つながりの公開範囲" ffVisibilityDescription: "自分のフォロー/フォロワー情報の公開範囲を設定できます。" continueThread: "さらにスレッドを見る" -deleteAccountConfirm: "アカウントが削除されます。よろしいですか?" +deleteAccountConfirm: "アカウント {handle} が不可逆的に削除されます。よろしいですか?" incorrectPassword: "パスワードが間違っています。" voteConfirm: "「{choice}」に投票しますか?" hide: "隠す" @@ -789,7 +787,7 @@ _registry: createKey: "キーを作成" _aboutMisskey: - about: "FoundKeyはsyuiloによって2014年から開発されている、オープンソースのソフトウェアです。" + about: "FoundKeyは2022年7月から開発されている、Misskeyのフォークです。" allContributors: "全てのコントリビューター" source: "ソースコード" _nsfw: @@ -945,7 +943,7 @@ _time: _tutorial: title: "FoundKeyの使い方" - step1_1: "ようこそ。" + step1_1: "ようこそ!" step1_2: "この画面は「タイムライン」と呼ばれ、あなたや、あなたが「フォロー」する人の「ノート」が時系列で表示されます。" step1_3: "あなたはまだ何もノートを投稿しておらず、誰もフォローしていないので、タイムラインには何も表示されていないはずです。" step2_1: "ノートを作成したり誰かをフォローしたりする前に、まずあなたのプロフィールを完成させましょう。" @@ -953,11 +951,11 @@ _tutorial: step3_1: "プロフィール設定はうまくできましたか?" step3_2: "では試しに、何かノートを投稿してみてください。画面上にある鉛筆マークのボタンを押すとフォームが開きます。" step3_3: "内容を書いたら、フォーム右上のボタンを押すと投稿できます。" - step3_4: "内容が思いつかない?「FoundKey始めました」というのはいかがでしょう。" + step3_4: "内容が思いつかない?「FoundKey始めました」というのはいかがでしょう!" step4_1: "投稿できましたか?" step4_2: "あなたのノートがタイムラインに表示されていれば成功です。" step5_1: "次は、他の人をフォローしてタイムラインを賑やかにしたいところです。" - step5_2: "{featured}で人気のノートが見れるので、その中から気になった人を選んでフォローしたり、{explore}で人気のユーザーを探すこともできます。" + step5_2: "{featured}で人気のノートが見れるので、その中から気になった人を選んでフォローしたり、{explore}で人気のユーザーを探すこともできます!" step5_3: "ユーザーをフォローするには、ユーザーのアイコンをクリックしてユーザーページを表示し、「フォロー」ボタンを押します。" step5_4: "ユーザーによっては、フォローが承認されるまで時間がかかる場合があります。" step6_1: "タイムラインに他のユーザーのノートが表示されていれば成功です。" @@ -979,33 +977,34 @@ _2fa: securityKeyInfo: "FIDO2をサポートするハードウェアセキュリティキーもしくは端末の指紋認証やPINを使用してログインするように設定できます。" _permissions: - "read:account": "アカウントの情報を見る" + "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": "ミュートを操作する" + "read:blocks": "どのユーザーをブロックしているかを読み取る" + "write:blocks": "ユーザーをブロック・ブロック解除する" + "read:drive": "ドライブ内のファイルとフォルダをリスト化する" + "write:drive": "ドライブ内でファイルを作成・変更・削除する" + "read:favorites": "お気に入りにしたノートをリスト化する" + "write:favorites": "ノートをお気に入りまたはお気に入り解除する" + "read:following": "自分がフォローしているユーザーおよび自分をフォローしているユーザーをリスト化する" + "write:following": "ユーザーをフォロー・フォロー解除する" + "read:messaging": "チャットの内容と履歴を見る" + "write:messaging": "チャットでメッセージを作成・削除する" + "read:mutes": "ミュートまたはRenoteをミュートにしたユーザーをリスト化する" + "write:mutes": "ユーザーまたはユーザーのRenoteをミュート・ミュート解除する" "write:notes": "ノートを作成・削除する" - "read:notifications": "通知を見る" - "write:notifications": "通知を操作する" - "write:reactions": "リアクションを操作する" + "read:notifications": "通知を読み取る" + "write:notifications": "通知の既読化およびカスタム通知を作成する" + "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:pages": "ページのリスト化・読み取りをする" + "write:pages": "ページを作成・変更・削除する" + "read:page-likes": "ページのいいねのリスト化・読み取りをする" + "write:page-likes": "ページをいいね・いいね解除する" + "read:user-groups": "参加・所有している、および招待されているグループのリスト化・読み取りをする" + "write:user-groups": "グループを作成・変更・削除・譲渡・参加、または脱退する。グループから他のユーザーを招待・凍結する。グループへの招待を承認・拒否する。" + "read:channels": "フォローおよび参加しているチャンネルのリスト化・読み取りをする" + "write:channels": "チャンネルを作成・変更・フォロー・フォロー解除する" + "read:reactions": リアクションを見る _auth: shareAccess: "「{name}」がアカウントにアクセスすることを許可しますか?" shareAccessAsk: "アカウントへのアクセスを許可しますか?" @@ -1051,6 +1050,7 @@ _widgets: aiscript: "AiScriptコンソール" aichan: "藍" + rssMarquee: RSSティッカー _cw: hide: "隠す" show: "もっと見る" @@ -1064,8 +1064,8 @@ _poll: canMultipleVote: "複数回答可" expiration: "期限" infinite: "無期限" - at: "日時指定" - after: "経過指定" + at: "日時指定..." + after: "経過指定..." deadlineDate: "期日" deadlineTime: "時間" duration: "期間" @@ -1101,7 +1101,7 @@ _postForm: b: "何かありましたか?" c: "何をお考えですか?" d: "言いたいことは?" - e: "ここに書いてください" + e: "ここに書いてください..." f: "あなたが書くのを待っています..." _profile: @@ -1225,6 +1225,7 @@ _notification: groupInvited: "グループに招待された" app: "連携アプリからの通知" + move: 自分以外のアカウントの引っ越し _actions: followBack: "フォローバック" reply: "返信" @@ -1253,3 +1254,48 @@ _deck: list: "リスト" mentions: "あなた宛て" direct: "ダイレクト" +translationSettings: 翻訳設定 +signinHistoryExpires: プライバシー規則に準拠するため、過去のログイン試行に関するデータは60日後に自動的に削除されます。 +deleteAllFiles: すべてのファイルを削除 +cannotAttachFileWhenAccountSwitched: 別のアカウントに切り替えている間はファイルを添付できません。 +translationService: 翻訳サービス +cannotSwitchAccountWhenFileAttached: ファイルを添付したままアカウントを切り替えることはできません。 +externalCssSnippets: インスピレーションのためのCSSスニペット群 (FoundKeyによって管理されていません) +botFollowRequiresApproval: Botとして設定されたアカウントからのフォロー申請は承認を必要にする +documentation: ドキュメンテーション +unlimited: 無制限 +exportAll: すべてエクスポート +oauthErrorGoBack: サードパーティーアプリの認証中にエラーが発生しました。戻ってもう一度やり直してみてください。 +selectMode: 複数選択 +renoteMute: Renoteをミュート +renoteUnmute: Renoteのミュートを解除 +stopActivityDeliveryDescription: ローカルでのアクティビティはこのインスタンスに対して送信されません。アクティビティの受信はこれまで通り機能します。 +unrenoteAll: すべてのRenoteを取り消す +unrenoteAllConfirm: このノートのRenoteをすべて取り消します。よろしいですか? +addTag: タグを追加 +removeTag: タグを削除 +appAuthorization: アプリの承認 +noPermissionsRequested: (必要な権限はありません。) +setCategory: カテゴリを設定 +selectAll: 全選択 +setTag: タグを設定 +blockThisInstanceDescription: ローカルでのアクティビティはこのインスタンスに対して送信されません。このインスタンスからのアクティビティは破棄されます。 +maxCustomEmojiPicker: ピッカー内で提案するカスタム絵文字の最大数 +maxUnicodeEmojiPicker: ピッカー内で提案するUnicode絵文字の最大数 +exportSelected: 選択をエクスポート +_translationService: + _libreTranslate: + authKey: LibreTranslate認証キー (任意) + endpoint: LibreTranslate API Endpoint + _deepl: + authKey: DeepL認証キー +_remoteInteract: + title: 申し訳ありませんが、残念ながら実行できません。 + urlInstructions: 以下のURLをコピーするとよいでしょう。あなたのインスタンスの検索フィールドに貼り付けることで、正しい場所に誘導されるでしょう。 + description: 今すぐにこのアクションを実行することはできません。あなたのインスタンス上で、またはログインして行う必要があるかもしれません。 +movedTo: このユーザーは {handle} に引っ越しました。 +uploadFailedDescription: ファイルをアップロードできませんでした。 +uploadFailedSize: ファイルサイズが大きすぎるためアップロードできません。 +uploadFailed: アップロード失敗 +showAttachedNotes: 添付ノートを表示 +attachedToNotes: このファイルが添付されたノート diff --git a/locales/ja-KS.yml b/locales/ja-KS.yml index ccaeb0b6d..cb78b4651 100644 --- a/locales/ja-KS.yml +++ b/locales/ja-KS.yml @@ -243,8 +243,6 @@ createFolder: "フォルダー作る" renameFolder: "フォルダー名を変える" deleteFolder: "フォルダーを消してまう" addFile: "ファイルを追加" -emptyDrive: "ドライブにはなんも残っとらん" -emptyFolder: "ふぉろだーにはなんも残っとらん" unableToDelete: "消そうおもってんけどな、あかんかったわ" inputNewFileName: "今度のファイル名は何にするん?" inputNewDescription: "新しいキャプションを入力しましょ" diff --git a/locales/nl-NL.yml b/locales/nl-NL.yml index f3f43e308..83d71406c 100644 --- a/locales/nl-NL.yml +++ b/locales/nl-NL.yml @@ -76,9 +76,9 @@ followsYou: "Volgt jou" createList: "Creëer lijst" manageLists: "Beheren lijsten" error: "Fout" -somethingHappened: "Er is iets misgegaan." +somethingHappened: "Er is iets misgegaan" retry: "Probeer opnieuw" -pageLoadError: "Pagina laden mislukt" +pageLoadError: "Pagina laden mislukt." pageLoadErrorDescription: "Dit wordt normaal gesproken veroorzaakt door netwerkfouten\ \ of door de cache van de browser. Probeer de cache te wissen en probeer het na\ \ een tijdje wachten opnieuw." @@ -217,7 +217,7 @@ resetAreYouSure: "Resetten?" saved: "Opgeslagen" messaging: "Chat" upload: "Uploaden" -keepOriginalUploading: "Origineel beeld behouden." +keepOriginalUploading: "Origineel beeld behouden" keepOriginalUploadingDescription: "Bewaar de originele versie bij het uploaden van\ \ afbeeldingen. Indien uitgeschakeld, wordt bij het uploaden een alternatieve versie\ \ voor webpublicatie genereert." @@ -263,8 +263,6 @@ createFolder: "Map aanmaken" renameFolder: "Map hernoemen" deleteFolder: "Map verwijderen" addFile: "Bestand toevoegen" -emptyDrive: "Jouw Drive is leeg." -emptyFolder: "Deze map is leeg" unableToDelete: "Kan niet worden verwijderd" inputNewFileName: "Voer een nieuwe naam in" copyUrl: "URL kopiëren" @@ -358,3 +356,12 @@ selectWidget: Kies een widget editWidgets: Widgets wijzigen editWidgetsExit: Klaar _services: {} +botFollowRequiresApproval: Volgverzoeken van als robot gemarkeerde gebruikers vereisen + goedkeuring +unrenoteAll: Alle renotes terugnemen +unrenoteAllConfirm: Weet je zeker dat je alle renotes voor deze note terug wil nemen? +exportAll: Alles exporteren +exportSelected: Selectie exporteren +uploadFailed: Uploaden mislukt +uploadFailedDescription: Het bestand kon niet worden geupload. +uploadFailedSize: Het bestand is te groot om te uploaden. diff --git a/locales/pl-PL.yml b/locales/pl-PL.yml index 4e6e7ffe7..50af17e43 100644 --- a/locales/pl-PL.yml +++ b/locales/pl-PL.yml @@ -1,9 +1,10 @@ _lang_: "Polski" headlineMisskey: "Sieć połączona wpisami" -introMisskey: "Witaj! FoundKey jest otwartoźródłowym serwisem mikroblogowym.\nTwórz\ - \ \"wpisy\", aby dzielić się tym, co się dzieje i opowiadać wszystkim o sobie. \U0001F4E1\ - \nMożesz również użyć \"reakcji\", aby szybko wyrazić swoje uczucia dotyczące wpisów\ - \ innych użytkowników. \U0001F44D\nOdkrywaj nowy świat! \U0001F680" +introMisskey: "Witaj! Foundkey jest otwartoźródłowym, zdecentralizowanym serwisem\ + \ mikroblogowym.\nTwórz \"wpisy\", aby dzielić się tym, co się dzieje i opowiadać\ + \ wszystkim o sobie. \U0001F4E1\nMożesz również użyć \"reakcji\", aby szybko wyrazić\ + \ swoje uczucia dotyczące wpisów innych użytkowników. \U0001F44D\nOdkryjmy nowy\ + \ świat! \U0001F680" monthAndDay: "{month}-{day}" search: "Szukaj" notifications: "Powiadomienia" @@ -24,7 +25,7 @@ otherSettings: "Pozostałe ustawienia" openInWindow: "Otwórz w oknie" profile: "Profil" timeline: "Oś czasu" -noAccountDescription: "Ten użytkownik nie napisał jeszcze swojej biografii." +noAccountDescription: "Ten użytkownik nie napisał jeszcze swojego opisu." login: "Zaloguj się" loggingIn: "Logowanie" logout: "Wyloguj się" @@ -60,12 +61,12 @@ import: "Importuj" export: "Eksportuj" files: "Pliki" download: "Pobierz" -driveFileDeleteConfirm: "Czy chcesz usunąć plik \"{name}\"? Zniknie również notatka,\ - \ do której dołączony jest ten plik." +driveFileDeleteConfirm: "Czy chcesz usunąć plik \"{name}\"? Znikną również wpisy,\ + \ do których dołączony jest ten plik." unfollowConfirm: "Czy na pewno chcesz przestać obserwować {name}?" -exportRequested: "Zażądałeś eksportu. Może to zająć trochę czasu. Po zakończeniu eksportu\ - \ zostanie on dodany do Twoich \"dysków\"." -importRequested: "Zażądano importu. Może to zająć chwilę." +exportRequested: "Zażądano eksportu. Może to zająć chwilę. Po zakończeniu eksportu\ + \ zostanie on dodany do Twojego Dysku." +importRequested: "Zażądano importu. Może to zająć chwilę." lists: "Listy" note: "Utwórz wpis" notes: "Wpisy" @@ -77,7 +78,7 @@ manageLists: "Zarządzaj listami" error: "Błąd" somethingHappened: "Coś poszło nie tak" retry: "Spróbuj ponownie" -pageLoadError: "Nie udało się załadować strony" +pageLoadError: "Nie udało się załadować strony." pageLoadErrorDescription: "Zwykle jest to spowodowane problemem z siecią lub cache\ \ przeglądarki. Spróbuj wyczyścić cache i sprawdź jeszcze raz za chwilę." serverIsDead: "Serwer nie odpowiada. Zaczekaj chwilę i spróbuj ponownie." @@ -101,7 +102,7 @@ sensitive: "NSFW" add: "Dodaj" reaction: "Reakcja" reactionSettingDescription2: "Przeciągnij aby zmienić kolejność, naciśnij aby usunąć,\ - \ naciśnij „+” aby dodać" + \ naciśnij „+” aby dodać." attachCancel: "Usuń załącznik" markAsSensitive: "Oznacz jako NSFW" unmarkAsSensitive: "Cofnij NSFW" @@ -117,7 +118,7 @@ unblockConfirm: "Czy na pewno chcesz odblokować to konto?" suspendConfirm: "Czy na pewno chcesz zawiesić to konto?" unsuspendConfirm: "Czy na pewno chcesz cofnąć zawieszenie tego konta?" selectList: "Wybierz listę" -selectAntenna: "Wybierz Antennę" +selectAntenna: "Wybierz antenę" selectWidget: "Wybierz widżet" editWidgets: "Edytuj widżet" editWidgetsExit: "Gotowe" @@ -133,7 +134,7 @@ flagAsBot: "To konto jest botem" flagAsBotDescription: "Jeżeli ten kanał jest kontrolowany przez jakiś program, ustaw\ \ tę opcję. Jeżeli włączona, będzie działać jako flaga informująca innych programistów,\ \ aby zapobiegać nieskończonej interakcji z różnymi botami i dostosowywać wewnętrzne\ - \ systemy FoundKey, traktując konto jako bota." + \ systemy Foundkey, traktując konto jako bota." flagAsCat: "To konto jest kotem" flagAsCatDescription: "Przełącz tę opcję, aby konto było oznaczone jako kot." autoAcceptFollowed: "Automatycznie przyjmuj prośby o możliwość obserwacji od użytkowników,\ @@ -176,17 +177,19 @@ clearCachedFilesConfirm: "Czy na pewno chcesz usunąć wszystkie zdalne pliki z \ podręcznej?" blockedInstances: "Zablokowane instancje" blockedInstancesDescription: "Wypisz nazwy hostów instancji, które powinny zostać\ - \ zablokowane. Wypisane instancje nie będą mogły dłużej komunikować się z tą instancją." -muteAndBlock: "Wycisz / Zablokuj" + \ zablokowane. Wypisane instancje nie będą mogły dłużej komunikować się z tą instancją.\ + \ Nazwy domen non-ASCII muszą być zakodowane w punycode. Poddomeny wypisanych instancji\ + \ również będą blokowane." +muteAndBlock: "Wyciszenia i blokady" mutedUsers: "Wyciszeni użytkownicy" blockedUsers: "Zablokowani użytkownicy" noUsers: "Brak użytkowników" editProfile: "Edytuj profil" noteDeleteConfirm: "Czy na pewno chcesz usunąć ten wpis?" pinLimitExceeded: "Nie możesz przypiąć więcej wpisów." -intro: "Zakończono instalację FoundKey! Utwórz konto administratora." +intro: "Zakończono instalację Foundkey! Utwórz konto administratora." done: "Gotowe" -processing: "Przetwarzanie" +processing: "Przetwarzanie..." preview: "Podgląd" default: "Domyślne" noCustomEmojis: "Brak emoji" @@ -261,11 +264,9 @@ createFolder: "Utwórz katalog" renameFolder: "Zmień nazwę katalogu" deleteFolder: "Usuń ten katalog" addFile: "Dodaj plik" -emptyDrive: "Dysk jest pusty" -emptyFolder: "Ten katalog jest pusty" unableToDelete: "Nie można usunąć" inputNewFileName: "Wprowadź nową nazwę pliku" -inputNewDescription: "Proszę wpisać nowy napis" +inputNewDescription: "Wpisz nowy opis" inputNewFolderName: "Wprowadź nową nazwę katalogu" circularReferenceFolder: "Katalog docelowy jest podkatalogiem katalogu, który chcesz\ \ przenieść." @@ -276,7 +277,7 @@ avatar: "Awatar" banner: "Baner" nsfw: "NSFW" whenServerDisconnected: "Po utracie połączenia z serwerem" -disconnectedFromServer: "Utracono połączenie z serwerem." +disconnectedFromServer: "Utracono połączenie z serwerem" reload: "Odśwież" doNothing: "Ignoruj" reloadConfirm: "Czy chcesz odświeżyć oś czasu?" @@ -317,13 +318,13 @@ hcaptchaSecretKey: "Tajny klucz" recaptchaSiteKey: "Klucz strony" recaptchaSecretKey: "Tajny klucz" antennas: "Anteny" -manageAntennas: "Zarządzaj Antenami" +manageAntennas: "Zarządzaj antenami" name: "Nazwa" -antennaSource: "Źródło Anteny" +antennaSource: "Źródło anteny" antennaKeywords: "Słowa kluczowe do obserwacji" antennaExcludeKeywords: "Wykluczone słowa kluczowe" antennaKeywordsDescription: "Oddziel spacjami dla warunku AND, albo wymuś koniec linii\ - \ dla warunku OR" + \ dla warunku OR." notifyAntenna: "Powiadamiaj o nowych wpisach" withFileAntenna: "Filtruj tylko wpisy z załączonym plikiem" antennaUsersDescription: "Wypisz po jednej nazwie użytkownika w linii" @@ -342,7 +343,7 @@ recentlyRegisteredUsers: "Ostatnio zarejestrowani użytkownicy" recentlyDiscoveredUsers: "Ostatnio odkryci użytkownicy" popularTags: "Tagi na czasie" userList: "Listy" -aboutMisskey: "O FoundKey" +aboutMisskey: "O Foundkey" administrator: "Admin" token: "Token" twoStepAuthentication: "Uwierzytelnianie dwuskładnikowe" @@ -392,10 +393,10 @@ newMessageExists: "Masz nową wiadomość" onlyOneFileCanBeAttached: "Możesz załączyć tylko jeden plik do wiadomości" signinRequired: "Proszę się zalogować" invitationCode: "Kod zaproszenia" -checking: "Sprawdzam" +checking: "Sprawdzam..." available: "Dostępna" unavailable: "Niedostępna" -usernameInvalidFormat: "może zawierać litery, cyfry i podkreślniki." +usernameInvalidFormat: "Nazwa użytkownika może zawierać litery, cyfry i podkreślniki." tooShort: "Zbyt krótka" tooLong: "Zbyt długa" weakPassword: "Słabe hasło" @@ -409,7 +410,7 @@ tapSecurityKey: "Wybierz swój klucz bezpieczeństwa" or: "Lub" language: "Język" uiLanguage: "Język wyświetlania UI" -groupInvited: "Zaproszony(-a) do grupy" +groupInvited: "Został*ś zaproszon* do grupy" useOsNativeEmojis: "Używaj natywnych Emoji systemu" youHaveNoGroups: "Nie masz żadnych grup" joinOrCreateGroup: "Uzyskaj zaproszenie do dołączenia do grupy lub utwórz własną grupę." @@ -460,7 +461,7 @@ popout: "Popout" volume: "Głośność" masterVolume: "Głośność główna" details: "Szczegóły" -unableToProcess: "Nie udało się dokończyć działania." +unableToProcess: "Nie udało się dokończyć działania" recentUsed: "Ostatnio używane" install: "Zainstaluj" uninstall: "Odinstaluj" @@ -474,7 +475,7 @@ ascendingOrder: "Rosnąco" descendingOrder: "Malejąco" scratchpad: "Brudnopis" scratchpadDescription: "Brudnopis zawiera eksperymentalne środowisko dla AiScript.\ - \ Możesz pisać, wykonywać i sprawdzać wyniki w interakcji z FoundKey." + \ Możesz pisać, wykonywać i sprawdzać wyniki w interakcji z Foundkey." output: "Wyjście" updateRemoteUser: "Aktualizuj zdalne dane o użytkowniku" deleteAllFilesConfirm: "Czy na pewno chcesz usunąć wszystkie pliki?" @@ -496,7 +497,7 @@ enablePlayer: "Otwórz odtwarzacz wideo" disablePlayer: "Zamknij odtwarzacz wideo" themeEditor: "Edytor motywu" description: "Opis" -describeFile: "dodaj podpis" +describeFile: "Dodaj opis" author: "Autor" leaveConfirm: "Są niezapisane zmiany. Czy chcesz je odrzucić?" manage: "Zarządzanie" @@ -531,10 +532,10 @@ smtpUser: "Nazwa użytkownika" smtpPass: "Hasło" emptyToDisableSmtpAuth: "Pozostaw adres e-mail i hasło puste, aby wyłączyć weryfikację\ \ SMTP" -smtpSecureInfo: "Wyłącz, jeżeli używasz STARTTLS" +smtpSecureInfo: "Wyłącz, jeżeli używasz STARTTLS." testEmail: "Przetestuj dostarczanie wiadomości e-mail" wordMute: "Wyciszenie słowa" -userSaysSomething: "{name} powiedział(-a) coś" +userSaysSomething: "{name} powiedział* coś" makeActive: "Aktywuj" display: "Wyświetlanie" copy: "Kopiuj" @@ -543,7 +544,7 @@ database: "Baza danych" channel: "Kanały" create: "Utwórz" notificationSetting: "Ustawienia powiadomień" -notificationSettingDesc: "Wybierz rodzaj powiadomień do wyświetlania" +notificationSettingDesc: "Wybierz rodzaj powiadomień do wyświetlania." useGlobalSetting: "Użyj globalnych ustawień" useGlobalSettingDesc: "Jeżeli włączone, zostaną wykorzystane ustawienia powiadomień\ \ Twojego konta. Jeżeli wyłączone, mogą zostać wykonane oddzielne konfiguracje." @@ -559,8 +560,8 @@ reportAbuse: "Zgłoś" reportAbuseOf: "Zgłoś {name}" fillAbuseReportDescription: "Wypełnij szczegóły zgłoszenia." abuseReported: "Twoje zgłoszenie zostało wysłane. Dziękujemy." -reporteeOrigin: "Pochodzenie zgłoszonego" -reporterOrigin: "Pochodzenie zgłaszającego" +reporteeOrigin: "Pochodzenie osoby zgłoszonej" +reporterOrigin: "Pochodzenie osoby zgłaszającej" send: "Wyślij" abuseMarkAsResolved: "Oznacz zgłoszenie jako rozwiązane" openInNewTab: "Otwórz w nowej karcie" @@ -572,7 +573,7 @@ desktop: "Pulpit" createNew: "Utwórz nowy" optional: "Nieobowiązkowe" public: "Publiczny" -i18nInfo: "FoundKey jest tłumaczone na wiele języków przez wolontariuszy. Możesz pomóc\ +i18nInfo: "Foundkey jest tłumaczone na wiele języków przez wolontariuszy. Możesz pomóc\ \ na {link}." manageAccessTokens: "Zarządzaj tokenami dostępu" accountInfo: "Informacje o koncie" @@ -594,7 +595,7 @@ driveUsage: "Użycie przestrzeni dyskowej" noCrawle: "Odrzuć indeksowanie przez crawlery" noCrawleDescription: "Proś wyszukiwarki internetowe, aby nie indeksowały Twojego profilu,\ \ wpisów, stron itd." -lockedAccountInfo: "Dopóki nie ustawisz widoczności wpisu na \"Obserwujący\", twoje\ +lockedAccountInfo: "Dopóki nie ustawisz widoczności wpisu na \"Obserwujący\", Twoje\ \ wpisy będą mogli widzieć wszyscy, nawet jeśli ustawisz manualne zatwierdzanie\ \ obserwujących." alwaysMarkSensitive: "Oznacz domyślnie jako NSFW" @@ -611,7 +612,7 @@ useSystemFont: "Używaj domyślnej czcionki systemu" makeExplorable: "Pokazuj konto na stronie „Eksploruj”" makeExplorableDescription: "Jeżeli wyłączysz tę opcję, Twoje konto nie będzie wyświetlać\ \ się w sekcji „Eksploruj”." -showGapBetweenNotesInTimeline: "Pokazuj odstęp między wpisami na osi czasu." +showGapBetweenNotesInTimeline: "Pokazuj odstęp między wpisami na osi czasu" duplicate: "Duplikuj" left: "Lewo" center: "Wyśsrodkuj" @@ -619,7 +620,7 @@ wide: "Szerokie" narrow: "Wąskie" reloadToApplySetting: "To ustawienie zostanie zastosowane po odświeżeniu strony. Chcesz\ \ odświeżyć?" -needReloadToApply: "To ustawienie zostanie zastosowane po odświeżeniu strony" +needReloadToApply: "To ustawienie zostanie zastosowane po odświeżeniu strony." clearCache: "Wyczyść pamięć podręczną" onlineUsersCount: "{n} osób jest online" backgroundColor: "Tło" @@ -638,18 +639,18 @@ editCode: "Edytuj kod" apply: "Zastosuj" receiveAnnouncementFromInstance: "Otrzymuj powiadomienia e-mail z tej instancji" emailNotification: "Powiadomienia e-mail" -useReactionPickerForContextMenu: "Otwórz wybornik reakcji prawym kliknięciem" +useReactionPickerForContextMenu: "Otwórz wybierak reakcji prawym kliknięciem" typingUsers: "{users} pisze(-ą)..." jumpToSpecifiedDate: "Przejdź do określonej daty" clear: "Wróć" markAllAsRead: "Oznacz wszystkie jako przeczytane" goBack: "Wróć" -unlikeConfirm: "Na pewno chcesz usunąć polubienie?" +unlikeConfirm: "Na pewno chcesz usunąć polubienie?" fullView: "Pełny widok" quitFullView: "Opuść pełny widok" addDescription: "Dodaj opis" userPagePinTip: "Możesz wyświetlać wpisy w tym miejscu po wybraniu \"Przypnij do profilu\"\ - \ z menu pojedyńczego wpisu" + \ z menu pojedynczego wpisu." notSpecifiedMentionWarning: "Ten wpis zawiera wzmianki o użytkownikach niezawartych\ \ jako odbiorcy" info: "Informacje" @@ -657,7 +658,7 @@ userInfo: "Informacje o użykowniku" unknown: "Nieznane" hideOnlineStatus: "Ukryj status online" hideOnlineStatusDescription: "Ukrywanie statusu online ogranicza wygody niektórych\ - \ funkcji, tj. wyszukiwanie" + \ funkcji, takich jak wyszukiwanie." online: "Online" active: "Aktywny" offline: "Offline" @@ -677,7 +678,7 @@ noBotProtectionWarning: "Zabezpieczenie przed botami nie jest skonfigurowane." configure: "Skonfiguruj" recentPosts: "Ostatnie wpisy" shareWithNote: "Udostępnij z wpisem" -emailNotConfiguredWarning: "Nie podano adresu e-mail" +emailNotConfiguredWarning: "Nie podano adresu e-mail." ratio: "Stosunek" previewNoteText: "Pokaż podgląd" customCss: "Własny CSS" @@ -687,17 +688,21 @@ squareAvatars: "Wyświetlaj kwadratowe awatary" hashtags: "Hashtag" pubSub: "Konta Pub/Sub" hide: "Ukryj" -indefinitely: "Nigdy" +indefinitely: "Dożywotnio" _ffVisibility: public: "Publikuj" + followers: Widoczne tylko dla obserwujących + private: Prywatna _forgotPassword: ifNoEmail: "Jeżeli nie podano adresu e-mail podczas rejestracji, skontaktuj się\ - \ z administratorem zamiast tego." + \ z administratorem." contactAdmin: "Jeżeli Twoja instancja nie obsługuje adresów e-mail, skontaktuj się\ \ zamiast tego z administratorem, aby zresetować hasło." + enterEmail: Wpisz adres email użyty do rejestracji konta. Zostanie wysłany link + który umożliwi zresetowanie hasła. _email: _follow: - title: "Zaobserwował(a) Cię" + title: "Masz nowego obserwującego" _receiveFollowRequest: title: "Otrzymano prośbę o możliwość obserwacji" _plugin: @@ -710,7 +715,7 @@ _registry: domain: "Domena" createKey: "Utwórz klucz" _aboutMisskey: - about: "FoundKey jest oprogramowanie open source rozwijanym przez syuilo od 2014." + about: "Foundkey jest forkiem Misskey rozwijanym od lipca 2022." allContributors: "Wszyscy twórcy" source: "Kod źródłowy" _nsfw: @@ -719,8 +724,9 @@ _nsfw: force: "Ukrywaj wszystkie media" _mfm: cheatSheet: "Ściąga MFM" - intro: "MFM to język składniowy wyjątkowy dla FoundKey, który może być użyty w wielu\ - \ miejscach. Tu znajdziesz listę wszystkich możliwych elementów składni MFM." + intro: "MFM jest językiem składniowym używanym przez m.in. Misskey, forki *key (w\ + \ tym Foundkey), oraz Akkomę, który może być użyty w wielu miejscach. Tu znajdziesz\ + \ listę wszystkich możliwych elementów składni MFM." dummy: "FoundKey rozszerza świat Fediwersum" mention: "Wspomnij" mentionDescription: "Używając znaku @ i nazwy użytkownika, możesz określić danego\ @@ -728,7 +734,7 @@ _mfm: hashtag: "Hashtag" hashtagDescription: "Używając kratki i tekstu, możesz określić hashtag." url: "Adres URL" - urlDescription: "Adresy URL mogą być wyświetlane" + urlDescription: "Adresy URL mogą być wyświetlane." link: "Odnośnik" linkDescription: "Określone części tekstu mogą być wyświetlane jako adres URL." bold: "Pogrubienie" @@ -753,11 +759,38 @@ _mfm: x3: "Bardzo duże" x3Description: "Czyni treść jeszcze większą." x4: "Ogromne" - x4Description: "Czyni treść jeszcze większą niż jeszcze większa." + x4Description: "Czyni treść nawet większą niż jeszcze większa." blur: "Rozmycie" font: "Czcionka" fontDescription: "Wybiera czcionkę do wyświetlania treści." rotate: "Obróć" + inlineCodeDescription: Wyświetla podświetlanie składni dla kodu (programu) w linii. + jump: Animacja (Skok) + blockMath: Matematyka (Blok) + tada: Animacja (Tada) + sparkle: Blask + sparkleDescription: Nadaje zawartości efekt iskrzących cząstek. + inlineMathDescription: Pokaż formuły matematyczne (KaTeX) w linii. + inlineMath: Matematyka (Inline) + inlineCode: Kod (inline) + smallDescription: Wyświetla zawartość jako małą i cienką. + blurDescription: Rozmywa zawartość. Zostanie ona wyraźnie wyświetlona przy najechaniu. + bounce: Animacja (Odbijanie) + jelly: Animacja (Żelek) + spin: Animacja (Wirowanie) + twitch: Animacja (Drganie) + jellyDescription: Nadaje zawartości galaretowatą animację. + tadaDescription: Nadaje zawartości animację w stylu "Tada!". + jumpDescription: Nadaje zawartości animację skokową. + bounceDescription: Nadaje zawartości sprężystą animację. + shakeDescription: Nadaje zawartości animację potrzęsania. + twitchDescription: Nadaje zawartości silnie drgającą animację. + spinDescription: Nadaje zawartości animację obrotową. + rainbow: Tęcza + rainbowDescription: Sprawia, że zawartość pojawia się w kolorach tęczy. + rotateDescription: Obraca zawartość o podany kąt. + blockMathDescription: Pokaż wieloliniowe formuły matematyczne (KaTeX) w bloku. + shake: Animacja (Wstrząs) _instanceTicker: none: "Nigdy nie pokazuj" remote: "Pokaż dla zdalnych użytkowników" @@ -778,12 +811,21 @@ _channel: notesCount: "{n} wpisy" _menuDisplay: hide: "Ukryj" + sideFull: Z boku + sideIcon: Z boku (tylko ikony) + top: U góry _wordMute: muteWords: "Słowo do wyciszenia" muteWordsDescription2: "Otocz słowa kluczowe ukośnikami, aby używać wyrażeń regularnych." soft: "Łagodny" hard: "Twardy" mutedNotes: "Wyciszone wpisy" + muteWordsDescription: Rozdzielaj spacją dla kondycji AND, lub przerwaniem wiersza + dla kondycji OR. + hardDescription: Zapobiega dodawania do osi czasu wpisów, które spełniają podane + warunki. Dodatkowo, te wpisy nie zostaną dodane do osi czasu, jeśli warunki się + zmienią. + softDescription: Ukryj z osi czasu wpisy, które spełniają podane warunki. _theme: explore: "Przeglądaj motywy" install: "Zainstaluj motyw" @@ -794,7 +836,7 @@ _theme: installedThemes: "Zainstalowane motywy" builtinThemes: "Wbudowane motywy" alreadyInstalled: "Motyw jest już zainstalowany" - invalid: "Format motywu jest nieprawidłowy." + invalid: "Format motywu jest nieprawidłowy" make: "Utwórz motyw" _sfx: note: "Wpisy" @@ -803,6 +845,7 @@ _sfx: chat: "Wiadomości" chatBg: "Rozmowy (tło)" channel: "Powiadomienia kanału" + antenna: Anteny _ago: future: "W przyszłości" justNow: "Przed chwilą" @@ -819,14 +862,39 @@ _time: hour: "godz." day: "dzień" _tutorial: - title: "Jak korzystać z FoundKey" + title: "Jak korzystać z Foundkey" step1_1: "Witaj!" - step1_3: "Twoja oś czasu jest jeszcze pusta, ponieważ nie opublikowałeś(-aś) jeszcze\ + step1_3: "Twoja oś czasu jest jeszcze pusta, ponieważ nie opublikował*ś jeszcze\ \ żadnych wpisów i nie obserwujesz jeszcze nikogo." step2_1: "Ukończmy konfigurację profilu zanim utworzymy wpis lub zaczniemy kogoś\ \ obserwować." - step3_1: "Zakończyłeś(-aś) konfigurację profilu?" - step3_3: "Wypełnij pole i kliknij przycisk w prawym górnym rogu by wysłać post." + step3_1: "Zakończył*ś konfigurację profilu?" + step3_3: "Wypełnij pole i kliknij przycisk w prawym górnym rogu by wysłać wpis." + step4_1: Skończył*ś publikować swój pierwszy wpis? + step6_2: Możesz też dodawać reakcję do wpisów innych ludzi, żeby szybko odpowiedzieć + na nie. + step4_2: Hura! Teraz Twój wpis powinien być widoczny na Twojej osi czasu. + step5_2: '{featured} pokaże CI popularne wpisy z tej instancji. {explore} pozwoli + ci znaleźć popularnych użytkowników. Spróbuj tam znaleźć ludzi których chciał*byś + zaobserwować!' + step7_1: Gratulacje! Właśnie ukończył*ś podstawowy samouczek Foundkey. + step6_3: W celu dodania reakcji, kliknij na "+" przy poście innego użytkownika i + wybierz jakieś emoji jako reakcję na niego. + step6_1: Wpisy innych ludzi powinny już się pojawiać na Twojej osi czasu. + step7_2: Jeśli chcesz dowiedzieć się więcej o Foundkey, przejdź do sekcji {help}. + step7_3: A teraz, powodzenia i miłej zabawy z Foundkey! 🚀 + step5_1: Teraz spróbujmy ożywić oś czasu poprzez zaobserwowanie innych ludzi. + step1_2: Ta strona jest nazywana "osią czasu". Pokazuje ona chronologicznie ułożone + wpisy ludzi których obserwujesz. + step5_4: Jeśli inny użytkownik ma kłódkę przy nazwie, ręczne zatwierdzenie Twojej + prośby o obserwację przez tego użytkownika może zająć trochę czasu. + step3_2: Spróbujmy opublikować wpis. Możesz to zrobić klikając przycisk z ikoną + ołówka. + step2_2: Dodanie informacji o sobie pomoże innym w decyzji czy chcą widzieć Twoje + wpisy, lub Ciebie obserwować. + step5_3: By zaobserwować innych użytkowników, kliknij na ich ikonę i wciśnij przycisk + "Obserwuj" na ich profilu. + step3_4: Nic nie przychodzi na myśl? Spróbuj "Właśnie wrócił*m z kościoła"! _2fa: registerDevice: "Zarejestruj nowe urządzenie" step1: "Najpierw, zainstaluj aplikację uwierzytelniającą (taką jak {a} lub {b})\ @@ -834,32 +902,54 @@ _2fa: step2: "Następnie, zeskanuje kod QR z ekranu." step3: "Wprowadź token podany w aplikacji, aby ukończyć konfigurację." step4: "Od teraz, przy każdej próbie logowania otrzymasz prośbę o token logowania." + alreadyRegistered: Już zarejestrował*ś urządzenie do uwierzytelnienia dwuetapowego. + step2Url: 'Możesz też wpisać ten URL jeśli używasz programu komputerowego:' + registerKey: Zarejestruj klucz sprzętowy + securityKeyInfo: Oprócz uwierzytelnienia odciskiem palców lub PIN, możesz również + skonfigurować uwierzytelnienie za pomocą kluczy sprzętowych obsługujących FIDO2, + w celu dalszego zabezpieczenia Twojego konta. _permissions: "read:account": "Wyświetl informacje o swoim koncie" - "write:account": "Edytuj swoje informacje o koncie" - "read:blocks": "Zobacz listę osób, które zablokowałeś(-aś)" - "write:blocks": "Edytuj listę osób, które zablokowałeś(-aś)" - "read:drive": "Dostęp do plików i katalogów ze Twojego dysku" - "write:drive": "Edycja i usuwanie plików i katalogów z Twojego dysku." - "read:favorites": "Wyświetlanie Twojej listy ulubionych." - "write:favorites": "Edycja Twojej listy ulubionych." - "read:following": "Wyświetlanie informacji o obserwowanych" - "write:following": "Obserwowanie lub cofanie obserwacji innych kont" - "read:mutes": "Wyświetlanie listy osób, które wyciszyłeś(-aś)" - "write:mutes": "Edycja listy osób, które wyciszyłeś(-aś)" + "write:account": "Edytuj informacje o koncie" + "read:blocks": "Wyświetl listę zablokowanych użytkowników" + "write:blocks": "Blokuj i odblokowuj użytkowników" + "read:drive": "Wyświetl pliki i foldery z twojego Dysku" + "write:drive": "Tworzenie, zmienianie i usuwanie plików z Dysku" + "read:favorites": "Wyświetlanie listy ulubionych wpisów" + "write:favorites": "Edycja listy ulubionych wpisów" + "read:following": "Wyświetlanie informacji o obserwowanych i obserwujących" + "write:following": "Obserwowanie i cofanie obserwacji innych użytkowników" + "read:mutes": "Wyświetlanie użytkowników którzy są wyciszeni, lub których podbicia\ + \ są wyciszone" + "write:mutes": "Wyciszanie i odciszanie użytkowników, lub ich podbić" "read:notifications": "Wyświetlanie powiadomień" - "write:notifications": "Działanie na powiadomieniach" - "write:reactions": "Edycja reakcji" - "write:votes": "Głosowanie w ankiecie" - "read:pages": "Wyświetlanie Twoich stron" - "write:pages": "Edycja lub usuwanie Twoich stron" + "write:notifications": "Oznaczanie powiadomień jako przeczytanych, oraz tworzenie\ + \ niestandardowych powiadomień" + "write:reactions": "Tworzenie i usuwanie reakcji" + "write:votes": "Głosowanie w ankietach" + "read:pages": "Wyświetlanie stron" + "write:pages": "Tworzenie, zmienianie i usuwanie stron" "read:page-likes": "Wyświetlanie polubień na stronach" - "write:page-likes": "Edycja polubień na stronach" - "read:user-groups": "Wyświetlanie grup użytkownika" - "write:user-groups": "Edycja lub usuwanie grup użytkownika" + "write:page-likes": "Dodawanie oraz usuwanie polubień stron" + "read:user-groups": "Wyświetlanie grup należących do Ciebie, do których dołączył*ś,\ + \ lub został*ś zaproszon*" + "write:user-groups": "Tworzenie, modyfikowanie, usuwanie, przenoszenie grup, dołączanie\ + \ i wychodzenie z grup. Zapraszaj i banuj innych z grup. Akceptuj i odrzucaj zaproszenia\ + \ do grup." + "read:reactions": Wyświetlaj reakcje + "write:notes": Tworzenie i usuwanie wpisów + "write:messaging": Tworzenie i usuwanie wiadomości czatu + "write:channels": Tworzenie, modyfikowanie, obserwowanie i od-obserwowanie kanałów + "read:messaging": Wyświetlaj wiadomości czatu i jego historię + "read:channels": Wyświetlaj obserwowane kanały i te do których dołączył*ś _auth: shareAccess: "Czy chcesz autoryzować „{name}” do dostępu do tego konta?" - permissionAsk: "Ta aplikacja wymaga następujących uprawnień:" + permissionAsk: "Ta aplikacja wymaga następujących uprawnień" + pleaseGoBack: Wróć do aplikacji + shareAccessAsk: Czy na pewno chcesz upoważnić tą aplikację do dostępu do Twojego + konta? + denied: Odmowa dostępu + callback: Wracam do aplikacji _weekday: sunday: "Niedziela" monday: "Poniedziałek" @@ -881,6 +971,14 @@ _widgets: postForm: "Utwórz wpis" button: "Przycisk" jobQueue: "Kolejka zadań" + rssMarquee: Karuzela RSS + rss: Czytnik RSS + digitalClock: Zegar cyfrowy + onlineUsers: Użytkownicy online + slideshow: Pokaz slajdów + aichan: Ai + aiscript: Konsola AiScript + serverMetric: Wskaźniki serwera _cw: hide: "Ukryj" show: "Załaduj więcej" @@ -915,6 +1013,10 @@ _visibility: followers: "Obserwujący" specified: "Bezpośredni" specifiedDescription: "Napisz tylko określonym użytkownikom" + localOnly: Lokalnie + homeDescription: Wpis będzie publiczny ale nie pojawi się na osi czasu instancji + followersDescription: Wpis pojawi się tylko na osiach czasu Twoich obserwujących + localOnlyDescription: Wpis będzie widoczny tylko dla użytkowników tej instancji _postForm: _placeholders: a: "Co się dzieje?" @@ -923,6 +1025,9 @@ _postForm: d: "Czy masz coś do powiedzenia?" e: "Zacznij coś pisać…" f: "Czekamy, aż coś napiszesz." + quotePlaceholder: Cytuj ten wpis... + replyPlaceholder: Odpowiedz na ten wpis... + channelPlaceholder: Wyślij na kanał... _profile: name: "Nazwa" username: "Nazwa użytkownika" @@ -942,24 +1047,38 @@ _exportOrImport: muteList: "Wycisz" blockingList: "Zablokuj" userLists: "Listy" + excludeMutingUsers: Wyklucz wyciszonych użytkowników + excludeInactiveUsers: Wyklucz nieaktywnych użytkowników _charts: federation: "Federacja" apRequest: "Żądania" usersTotal: "Łącznie # użytkowników" activeUsers: "Aktywni użytkownicy" + usersIncDec: Różnica w liczbie użytkowników + notesIncDec: Różnica w liczbie wpisów + localNotesIncDec: Różnica w liczbie lokalnych wpisów + notesTotal: Łączna liczba wpisów + remoteNotesIncDec: Różnica w liczbie zdalnych wpisów + filesIncDec: Różnica w liczbie plików + storageUsageTotal: Łączne użycie dysku + filesTotal: Łączna liczba plików + storageUsageIncDec: Różnica w wykorzystaniu miejsca _instanceCharts: requests: "Żądania" notesTotal: "Łącznie # wpisów" - ff: "Różnica w # obserwujących" + ff: "Różnica w liczbie obserwowanych / obserwujących użytkowników" ffTotal: "Łączna liczba # obserwujących" cacheSize: "Różnica w rozmiarze pamięci podręcznej" cacheSizeTotal: "Łączny rozmiar pamięci podręcznej" files: "Różnica # plików" filesTotal: "Łącznie # plików" + usersTotal: Łączna liczba użytkowników + notes: Różnica w liczbie wpisów + users: Różnica w liczbie użytkowników _timelines: home: "Strona główna" local: "Lokalne" - social: "Społeczność" + social: "Społeczna" global: "Globalna" _pages: newPage: "Utwórz stronę" @@ -971,7 +1090,7 @@ _pages: pageSetting: "Ustawienia strony" nameAlreadyExists: "Określony adres URL strony już istnieje" invalidNameTitle: "Podany adres URL strony jest nieprawidłowy" - invalidNameText: "Sprawdź, czy nie jest puste" + invalidNameText: "Upewnij się, że pole tytułowe strony nie jest puste" editThisPage: "Edytuj tę stronę" viewSource: "Zobacz źródło" viewPage: "Wyświetlanie Twoich stron" @@ -996,32 +1115,37 @@ _relayStatus: accepted: "Zaakceptowano" rejected: "Odrzucono" _notification: - youGotMention: "{name} wspomniał(a) o Tobie" - youGotReply: "{name} odpowiedział(a) Tobie" - youGotQuote: "{name} zacytował(a) Ciebie" - youRenoted: "{name} udostępnił(a) Twój wpis" - youGotPoll: "{name} zagłosował(a) w Twojej ankiecie" - youGotMessagingMessageFromUser: "{name} wysłał(a) Ci wiadomość" + youGotMention: "{name} wspomniał* o Tobie" + youGotReply: "{name} odpowiedział* Tobie" + youGotQuote: "{name} zacytował* Ciebie" + youRenoted: "{name} udostępnił* Twój wpis" + youGotPoll: "{name} zagłosował* w Twojej ankiecie" + youGotMessagingMessageFromUser: "{name} wysłał* Ci wiadomość" youGotMessagingMessageFromGroup: "Została wysłana wiadomość do grupy {name}" - youWereFollowed: "Zaobserwował(a) Cię" - youReceivedFollowRequest: "Otrzymałeś(-aś) prośbę o możliwość obserwacji" + youWereFollowed: "zaobserwował* Cię" + youReceivedFollowRequest: "Otrzymał*ś prośbę o możliwość obserwacji" yourFollowRequestAccepted: "Twoja prośba o możliwość obserwacji została przyjęta" - youWereInvitedToGroup: "Zaproszony(-a) do grupy" + youWereInvitedToGroup: "{userName} zaprosił* Ciebie do grupy" _types: - follow: "Obserwowani" - mention: "Wspomnij" + follow: "Nowi obserwujący" + mention: "Wspomnienia" reply: "Odpowiedzi" - renote: "Udostępnij" - quote: "Cytuj" - reaction: "Reakcja" + renote: "Podbicia" + quote: "Cytaty" + reaction: "Reakcje" pollVote: "Głosy w ankietach" - receiveFollowRequest: "Otrzymano prośbę o możliwość obserwacji" - followRequestAccepted: "Przyjęto prośbę o możliwość obserwacji" - groupInvited: "Zaproszono do grup" - app: "Powiadomienia z aplikacji" + receiveFollowRequest: "Otrzymane prośby o możliwość obserwacji" + followRequestAccepted: "Przyjęte prośby o możliwość obserwacji" + groupInvited: "Zaproszenia do grup" + app: "Powiadomienia z powiązanych aplikacji" + pollEnded: Zakończone ankiety + move: Inni użytkownicy przenoszący konta _actions: reply: "Odpowiedz" renote: "Udostępnij" + followBack: również cię zaobserwował* + emptyPushNotificationMessage: Powiadomienia push zostały zaktualizowane + pollEnded: Są dostępne wyniki ankiety _deck: alwaysShowMainColumn: "Zawsze pokazuj główną kolumnę" columnAlign: "Wyrównaj kolumny" @@ -1054,3 +1178,199 @@ unrenoteAllConfirm: Czy na pewno chcesz cofnąć wszystkie podbicia tego wpisu? renoteMute: Ukryj podbicia renoteUnmute: Pokaż podbicia unrenoteAll: Cofnij wszystkie podbicia +menu: Menu +clips: Klipsy +lastCommunication: Ostatnie połączenie +manageAccounts: Zarządzaj kontami +keepCw: Zostaw ostrzeżenia o zawartości +itsOff: Wyłączone +classic: Klasyczny +controlPanel: Panel sterowania +instanceDefaultLightTheme: Domyślny jasny motyw instancji +instanceDefaultThemeDescription: Wpisz kod motywu w formacie obiektowym. +mutePeriod: Długość wyciszenia +size: Rozmiar +recentNDays: Ostatnie {n} dni +translationSettings: Ustawienia tłumaczeń +breakFollow: Usuń obserwującego +filter: Filtruj +oneDay: Dzień +oneWeek: Tydzień +rateLimitExceeded: Przekroczono ratelimit +_signup: + emailAddressInfo: Wpisz swój adres email. Nie zostanie on upubliczniony. + almostThere: Jeszcze trochę + emailSent: Email potwierdzający został wysłany na Twój adres email {email}. Proszę + kliknij załączony link w celu dokończenia procesu tworzenia konta. +_accountDelete: + inProgress: Usuwanie jest w toku + started: Usuwanie zostało rozpoczęte. + mayTakeTime: Jako że usuwanie konta jest zasobożerną operacją, może zająć ono trochę + czasu, w zależności od ilości utworzonych wpisów oraz wysłanych plików. + accountDelete: Usuń konto + sendEmail: Gdy usuwanie konta będzie ukończone, zostanie wysłany email na adres + email przypisany do tego konta. + requestAccountDelete: Zażądaj usunięcia konta +blockThisInstanceDescription: Lokalna aktywność nie będzie wysyłana do tej instancji. + Aktywność z tej instancji będzie odrzucana. +maxCustomEmojiPicker: Maksymalna ilość sugerowanych niestandardowych emoji w wybieraku +unread: Nieprzeczytane +resolved: Rozwiązano +unresolved: Nierozwiązane +leaveGroup: Wyjdź z grupy +voteConfirm: Potwierdzasz głos na "{choice}"? +documentation: Dokumentacja +file: Plik +makeReactionsPublic: Włącz historię reakcji jako publiczną +unclip: Usuń klipsa +stopActivityDeliveryDescription: Lokalna aktywność nie będzie wysyłana do tej instancji. + Otrzymywanie aktywności działa jak dotychczas. +signinHistoryExpires: Dane dotyczące poprzednich prób logowania są automatycznie usuwane + po 60 dniach, w celu zachowania zgodności z przepisami dotyczącymi ochrony prywatności. +smtpSecure: Użyj implicit SSL/TLS dla połączeń SMTP +federateBlocks: Sfederuj blokady +federateBlocksDescription: Jeśli wyłączone, blokady nie będą wysyłane do instancji + blokowanego użytkownika. +failedToFetchAccountInformation: Nie można uzyskać informacji o koncie +deleteAccountConfirm: To usunie bezpowrotnie konto {handle}. Kontynuować? +_translationService: + _deepl: + authKey: Klucz uwierzytelnienia DeepL + _libreTranslate: + authKey: Klucz uwierzytelnienia LibreTranslate (opcjonalnie) + endpoint: Punkt końcowy API LibreTranslate +itsOn: Włączone +deleteAllFiles: Usuń wszystkie pliki +emailRequiredForSignup: Wymagaj adresu email przy rejestracji +threadMuteNotificationsDesc: Wybierz powiadomienia, które chcesz zobaczyć w tym wątku. + Obowiązują również globalne ustawienia powiadomień. Wyłączenie ma pierwszeństwo. +regexpError: Błąd regularnego wyrażenia +instanceMute: Wyciszone instancje +instanceDefaultDarkTheme: Domyślny ciemny motyw instancji +regexpErrorDescription: 'Wystąpił błąd w regularnym wyrażeniu znajdującym się w linijce + {line} Twoich {tab} wyciszeń słownych:' +reflectMayTakeTime: Może upłynąć trochę czasu, zanim pojawią się zmiany. +numberOfPageCacheDescription: Zwiększenie tej liczby poprawi wygodę użytkowników, + ale spowoduje większe zużycie serwera, jak i pamięci. +_emailUnavailable: + format: Format tego adresu email jest nieprawidłowy + used: Ten adres email już został użyty + disposable: Jednorazowe adresy email nie mogą być użyte + mx: Ten serwer email jest nieprawidłowy + smtp: Ten serwer email nie odpowiada +recommended: Polecane +_antennaSources: + homeTimeline: Wpisy od obserwowanych użytkowników + users: Wpisy od konkretnych użytkowników + all: Wszystkie wpisy + userList: Wpisy od użytkowników z konkretnej listy + userGroup: Wpisy od użytkowników z konkretnej grupy +translationService: Usługa tłumaczeń +ffVisibilityDescription: Pozwala skonfigurować kto może zobaczyć kogo obserwujesz + oraz kto Ciebie obserwuje. +leaveGroupConfirm: Czy na pewno chcesz opuścić "{name}"? +overridedDeviceKind: Typ urządzenia +useDrawerReactionPickerForMobile: Wyświetlaj wybierak reakcji jako szufladę na telefonie +makeReactionsPublicDescription: Dzięki temu lista wszystkich Twoich dotychczasowych + reakcji będzie publicznie widoczna. +muteThread: Wycisz wątek +unmuteThread: Odcisz wątek +ffVisibility: Widoczność obserwowanych/obserwujących +continueThread: Pokaż resztę wątku +incorrectPassword: Nieprawidłowe hasło. +clickToFinishEmailVerification: Kliknij {ok} by dokończyć weryfikację konta email. +cannotAttachFileWhenAccountSwitched: Nie możesz załączyć pliku będąc przełączonym + na inne konto. +cannotSwitchAccountWhenFileAttached: Nie możesz zmienić konta przy załączonych plikach. +tenMinutes: 10 minut +addTag: Dodaj tag +isSystemAccount: Konto założone i automatycznie zarządzane przez system. +auto: Auto +check: Sprawdź +cropImage: Kadruj zdjęcie +deleteAccount: Usuń konto +smartphone: Smartfon +tablet: Tablet +themeColor: Kolor znacznika instancji +oneHour: Godzina +cropImageAsk: Czy chcesz skadrować to zdjęcie? +recentNHours: Ostatnie {n} godzin +typeToConfirm: Wpisz {x} by potwierdzić +numberOfPageCache: Liczba zbuforowanych stron +noEmailServerWarning: Serwer email nie jest skonfigurowany. +thereIsUnresolvedAbuseReportWarning: Istnieją nierozwiązane zgłoszenia. +unlimited: Nieograniczone +selectAll: Wybierz wszystko +setCategory: Ustaw kategorię +setTag: Ustaw tag +removeTag: Usuń tag +externalCssSnippets: Kilka fragmentów CSS dla Twojej inspiracji (nie zarządzane przez + Foundkey) +confirmToUnclipAlreadyClippedNote: Ten wpis jest już częścią klpisa "{name}". Czy + chcesz w takim razie usunąć wpis z tego klipsa? +maxUnicodeEmojiPicker: Maksymalna ilość sugerowanych unicode emoji w wybieraku +_instanceMute: + instanceMuteDescription: Spowoduje to wyciszenie wszystkich wpisów/podbić z podanych + instancji, w tym tych od użytkowników odpowiadających na wpisy z wyciszonych instancji. + instanceMuteDescription2: Oddzielaj nowymi liniami + heading: Lista instancji do wyciszenia + title: Ukrywa wpisy z podanych instancji. +noPermissionsRequested: (Brak oczekiwanych uprawnień.) +appAuthorization: Autoryzacja aplikacji +oauthErrorGoBack: Wystąpił błąd podczas uwierzytelniania zewnętrznej aplikacji. Wróć + i spróbuj ponownie. +selectMode: Wybierz wiele +whatIsNew: Pokaż zmiany +reporter: Osoba zgłaszająca +translate: Tłumacz +translatedFrom: Przetłumaczone z {x} +accountDeletionInProgress: Trwa usuwanie konta. +forwardReport: Przekaż raport do zdalnej instancji +clip: Klipsy +createNewClip: Utwórz nowego klipsa +forwardReportIsAnonymous: Zamiast Twojego konta, anonimowe konto systemowe zostanie + wyświetlone na zdalnej instancji jako zgłaszający. +usernameInfo: Nazwa która odróżnia Twoje konto od innych z tego serwera. Możesz użyć + alfabetu łacińskiego (a-z, A-Z), cyfr (0-9), lub podkreślników (_). Nazwy użytkownika + nie mogą zostać później zmienione. +switchAccount: Przełącz konto +searchResult: Wyniki wyszukiwania +troubleshooting: Rozwiązywanie problemów +useBlurEffect: Używaj efektu rozmycia w interfejsie +learnMore: Dowiedz się więcej +misskeyUpdated: Foundkey zostało zaktualizowane! +flagShowTimelineRepliesDescription: Jeśli włączone, zostaną pokazane odpowiedzi użytkowników + do wpisów innych użytkowników. +flagShowTimelineReplies: Pokazuj odpowiedzi na osi czasu +proxyAccountDescription: Konto proxy jest kontem które w określonych sytuacjach zachowuje + się jak zdalny obserwujący. Na przykład, kiedy użytkownik dodaje zdalnego użytkownika + do listy, oraz żaden lokalny użytkownik nie obserwuje tego konta, aktywność owego + użytkownika nie zostanie dostarczona na oś czasu. W takim razie, użytkownika zaobserwuje + konto proxy. +keepOriginalUploading: Zostaw oryginalne zdjęcie +keepOriginalUploadingDescription: Zapisuje oryginalne zdjęcie. Jeśli wyłączone, wersja + do wyświetlania w sieci zostanie wygenerowana podczas wysłania. +disableDrawer: Nie używaj wysuwanych menu +objectStorageBaseUrlDesc: "URL stosowany jako odniesienie. Podaj URL twojego CDN,\ + \ albo proxy, jeśli używasz któregokolwiek.\nDla S3 użyj 'https://.s3.amazonaws.com',\ + \ a dla GCS i jego odpowiedników użyj 'https://storage.googleapis.com/',\ + \ itd." +objectStorageSetPublicRead: Ustaw "public-read" podczas wysyłania +removeAllFollowing: Przestań obserwować wszystkich obserwowanych użytkowników +yourAccountSuspendedTitle: To konto jest zawieszone +yourAccountSuspendedDescription: To konto zostało zawieszone z powodu łamania regulaminu + tego serwera. Skontaktuj się z administratorem, jeśli chcesz poznać bardziej dokładny + powód. Prosimy o niezakładanie nowych kont. +numberOfColumn: Liczba kolumn +_remoteInteract: + urlInstructions: Możesz skopiować ten URL. Jeśli wkleisz go w pole wyszukiwania + na twojej instancji, najpewniej zostaniesz przekierowan* do właściwego miejsca. + title: Przepraszam, ale nie mogę tego zrobić. + description: Nie możesz wykonać tej czynności w tej chwili. Musisz najprawdopodobniej + zalogować się, albo wykonać ją na swojej instancji. +movedTo: Ten użytkownik przeniósł się na {handle}. +attachedToNotes: Wpisy z tym plikiem +showAttachedNotes: Pokaż wpisy z tym plikiem +uploadFailedSize: Plik jest za duży do przesłania. +uploadFailed: Przesyłanie nie powiodło się +uploadFailedDescription: Plik nie mógł zostać przesłany. diff --git a/locales/ro-RO.yml b/locales/ro-RO.yml index 34c2ce7f6..9ac0445f0 100644 --- a/locales/ro-RO.yml +++ b/locales/ro-RO.yml @@ -275,8 +275,6 @@ createFolder: "Crează folder" renameFolder: "Redenumește acest folder" deleteFolder: "Șterge acest folder" addFile: "Adăugați un fișier" -emptyDrive: "Drive-ul tău e gol" -emptyFolder: "Folder-ul acesta este gol" unableToDelete: "Nu se poate șterge" inputNewFileName: "Introdu un nou nume de fișier" inputNewDescription: "Introdu o descriere nouă" diff --git a/locales/ru-RU.yml b/locales/ru-RU.yml index 73a5ce59e..dd20877dd 100644 --- a/locales/ru-RU.yml +++ b/locales/ru-RU.yml @@ -181,14 +181,15 @@ clearCachedFiles: "Очистить кэш" clearCachedFilesConfirm: "Удалить все закэшированные файлы с других сайтов?" blockedInstances: "Заблокированные инстансы" blockedInstancesDescription: "Введите список инстансов, которые хотите заблокировать.\ - \ Они больше не смогут обмениваться с вашим инстансом." + \ Они больше не смогут обмениваться с вашим инстансом. Не-ASCII доменные имена должны\ + \ быть переведены в punycode. Субдомены тоже будут заблокированы" muteAndBlock: "Скрытие и блокировка" mutedUsers: "Скрытые пользователи" blockedUsers: "Заблокированные пользователи" noUsers: "Нет ни одного пользователя" editProfile: "Редактировать профиль" noteDeleteConfirm: "Вы хотите удалить эту заметку?" -pinLimitExceeded: "Нельзя закрепить ещё больше заметок" +pinLimitExceeded: "Нельзя закрепить ещё больше заметок." intro: "Установка FoundKey завершена! А теперь создайте учетную запись администратора." done: "Готово" processing: "Обработка" @@ -269,8 +270,6 @@ createFolder: "Создать папку" renameFolder: "Переименовать папку" deleteFolder: "Удалить папку" addFile: "Добавить файл" -emptyDrive: "Диск пуст" -emptyFolder: "Папка пуста" unableToDelete: "Удаление невозможно" inputNewFileName: "Введите имя нового файла" inputNewDescription: "Введите новую подпись" @@ -724,7 +723,7 @@ misskeyUpdated: "FoundKey обновился!" whatIsNew: "Что новенького?" translate: "Перевод" translatedFrom: "Перевод. Язык оригинала — {x}" -accountDeletionInProgress: "В настоящее время выполняется удаление учетной записи" +accountDeletionInProgress: "В настоящее время выполняется удаление учетной записи." usernameInfo: "Имя, которое отличает вашу учетную запись от других на этом сервере.\ \ Вы можете использовать алфавит (a~z, A~Z), цифры (0~9) или символы подчеркивания\ \ (_). Имена пользователей не могут быть изменены позже." @@ -751,7 +750,7 @@ ffVisibility: "Видимость подписок и подписчиков" ffVisibilityDescription: "Здесь можно настроить, кто будет видеть ваши подписки и\ \ подписчиков." continueThread: "Показать следующие ответы" -deleteAccountConfirm: "Учётная запись будет безвозвратно удалена. Подтверждаете?" +deleteAccountConfirm: "Учётная запись {handle} будет безвозвратно удалена. Подтверждаете?" incorrectPassword: "Пароль неверен." voteConfirm: "Отдать голос за «{choice}»?" hide: "Спрятать" @@ -1270,3 +1269,81 @@ _deck: mentions: "Упоминания" direct: "Личное" _services: {} +botFollowRequiresApproval: Запросы на подписку от аккаунтов помеченных как бот требуют + подтверждения +showLess: Показать меньше +exportAll: Экспортировать всё +exportSelected: Экспортировать выбранное +cannotAttachFileWhenAccountSwitched: Вы не можете прикрепить файл, перейдя в другую + учетную запись. +cannotSwitchAccountWhenFileAttached: Вы не можете переключать учетные записи, пока + файлы прикреплены. +deleteAccount: Удалить аккаунт +isSystemAccount: Учетная запись, созданная системой и автоматически управляемая ею. +oneDay: Один день +cropImage: Обрезать изображение +documentation: Документация +movedTo: Этот пользователь перешел на {handle}. +typeToConfirm: Пожалуйста введите {x} чтобы подтвердить +rateLimitExceeded: Лимит превышен +numberOfPageCache: Количество кэшированных страниц +numberOfPageCacheDescription: Увеличение этого числа повысит удобство для пользователей, + но приведет к увеличению нагрузки на сервер, а также к использованию большего объема + памяти. +file: Файл +unclip: Удалить из подборки +translationSettings: Настройки перевода +translationService: Служба перевода +threadMuteNotificationsDesc: Выберите уведомления, которые вы хотите просмотреть в + этом треде. Также применяются глобальные настройки уведомлений. Отключение имеет + приоритет. +reflectMayTakeTime: Это может занять некоторое время чтобы вступило в силу. +failedToFetchAccountInformation: Не удалось получить информацию о аккаунте +instanceDefaultThemeDescription: Введите код темы в формате объекта. +tenMinutes: 10 минут +oneHour: Один час +oneWeek: Одна неделя +cropImageAsk: Вы хотите обрезать это изображение? +recentNHours: Последние {n} часов +recentNDays: Последние {n} дней +confirmToUnclipAlreadyClippedNote: Эта заметка уже является частью подборки "{name}". + Вы хотите вместо этого удалить это из этой подборки? +noEmailServerWarning: Сервер электронной почты не настроен. +setTag: Установить метку +addTag: Добавить метку +removeTag: Удалить метку +externalCssSnippets: Несколько фрагментов CSS для вашего вдохновения (не управляются + FoundKey) +oauthErrorGoBack: Произошла ошибка при попытке аутентификации стороннего приложения. + Пожалуйста, вернитесь и попробуйте еще раз. +appAuthorization: Авторизация приложения +noPermissionsRequested: (Никаких разрешений не требуется.) +selectMode: Выберите несколько +selectAll: Выбрать все +setCategory: Установить категорию +thereIsUnresolvedAbuseReportWarning: Есть нерасмотренные жалобы. +recommended: Рекомендовано +check: Проверка +unlimited: Неограниченный +mutePeriod: Длительность глушения +uploadFailed: Загрузка не удалась +uploadFailedDescription: Файл не может быть загружен. +uploadFailedSize: Файл слишком большой для загрузки. +renoteUnmute: Показать репосты +stopActivityDeliveryDescription: Локальная активнось не будет отправлена на этот сервер. + Получение активностей работает как раньше. +renoteMute: Скрыть репосты +unrenoteAllConfirm: Вы уверены что хотите отменить все репосты данной замети? +unrenoteAll: Отменить все репосты +blockThisInstanceDescription: Локальная активность не будет отправлена на этот сервер. + Активность этого сервера будет выброшена. +attachedToNotes: Заметки с этим файлом +showAttachedNotes: Показать заметки с этим файлом +signinHistoryExpires: Данные о прошлых попытках войти будут автоматически удалены + после 60 дней для соблюдения правил конфиденциальности. +deleteAllFiles: Удалить все файлы +federateBlocks: Федерировать блоки +federateBlocksDescription: Если выключено, то активности типа "блок" не будут отправлены. +regexpErrorDescription: 'Произошла ошибка в регулярном выражении на строке {line} + ваших {tab} заглушенных слов:' +reporter: Подавший жалобу diff --git a/locales/sk-SK.yml b/locales/sk-SK.yml index 5a46db5fc..b5f4d0059 100644 --- a/locales/sk-SK.yml +++ b/locales/sk-SK.yml @@ -269,8 +269,6 @@ createFolder: "Vytvoriť priečinok" renameFolder: "Premenovať priečinok" deleteFolder: "Odstrániť priečinok" addFile: "Pridať súbor" -emptyDrive: "Váš disk je prázdny" -emptyFolder: "Tento priečinok je prázdny" unableToDelete: "Nedá sa odstrániť" inputNewFileName: "Zadajte nový názov" inputNewDescription: "Zadajte nový popis" diff --git a/locales/uk-UA.yml b/locales/uk-UA.yml index f8166cb7d..b53de0c81 100644 --- a/locales/uk-UA.yml +++ b/locales/uk-UA.yml @@ -272,8 +272,6 @@ createFolder: "Створити теку" renameFolder: "Перейменувати теку" deleteFolder: "Видалити теку" addFile: "Додати файл" -emptyDrive: "Диск порожній" -emptyFolder: "Тека порожня" unableToDelete: "Видалення неможливе" inputNewFileName: "Введіть ім'я нового файлу" inputNewDescription: "Введіть новий заголовок" diff --git a/locales/vi-VN.yml b/locales/vi-VN.yml index fa8d1fa60..2a9bcab74 100644 --- a/locales/vi-VN.yml +++ b/locales/vi-VN.yml @@ -269,8 +269,6 @@ createFolder: "Tạo thư mục" renameFolder: "Đổi tên thư mục" deleteFolder: "Xóa thư mục" addFile: "Thêm tập tin" -emptyDrive: "Ổ đĩa của bạn trống trơn" -emptyFolder: "Thư mục trống" unableToDelete: "Không thể xóa" inputNewFileName: "Nhập tên mới cho tập tin" inputNewDescription: "Nhập mô tả mới" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 651a5d646..df3c768af 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -249,8 +249,6 @@ createFolder: "创建文件夹" renameFolder: "重命名文件夹" deleteFolder: "删除文件夹" addFile: "添加文件" -emptyDrive: "网盘中无文件" -emptyFolder: "此文件夹中无文件" unableToDelete: "无法删除" inputNewFileName: "请输入新文件名" inputNewDescription: "请输入新标题" diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index 2bb03d3a2..706d4a777 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -249,8 +249,6 @@ createFolder: "新增資料夾" renameFolder: "重新命名資料夾" deleteFolder: "刪除資料夾" addFile: "加入附件" -emptyDrive: "雲端硬碟為空" -emptyFolder: "資料夾為空" unableToDelete: "無法刪除" inputNewFileName: "輸入檔案名稱" inputNewDescription: "請輸入新標題" diff --git a/packages/backend/assets/notification-badges/suitcase-solid.png b/packages/backend/assets/notification-badges/suitcase-solid.png new file mode 100644 index 000000000..b52688586 Binary files /dev/null and b/packages/backend/assets/notification-badges/suitcase-solid.png differ diff --git a/packages/backend/migration/1668977715500-movedTo.js b/packages/backend/migration/1668977715500-movedTo.js new file mode 100644 index 000000000..721e01743 --- /dev/null +++ b/packages/backend/migration/1668977715500-movedTo.js @@ -0,0 +1,35 @@ +export class movedTo1668977715500 { + name = 'movedTo1668977715500'; + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" ADD "movedToId" character varying(32)`); + await queryRunner.query(`ALTER TABLE "notification" ADD "moveTargetId" character varying(32)`); + await queryRunner.query(`COMMENT ON COLUMN "notification"."moveTargetId" IS 'The ID of the moved to account.'`); + await queryRunner.query(`ALTER TABLE "user" ADD CONSTRAINT "FK_16fef167e4253ccdc8971b01f6e" FOREIGN KEY ("movedToId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "notification" ADD CONSTRAINT "FK_078db271ad52ccc345b7b2b026a" FOREIGN KEY ("moveTargetId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TYPE "notification_type_enum" ADD VALUE 'move'`); + await queryRunner.query(`ALTER TYPE "user_profile_mutingnotificationtypes_enum" ADD VALUE 'move'`); + } + + async down(queryRunner) { + // remove 'move' from user muting notifications type enum + await queryRunner.query(`CREATE TYPE "public"."user_profile_mutingnotificationtypes_enum_old" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app', 'pollEnded')`); + await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" DROP DEFAULT`); + await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" TYPE "public"."user_profile_mutingnotificationtypes_enum_old"[] USING "mutingNotificationTypes"::"text"::"public"."user_profile_mutingnotificationtypes_enum_old"[]`); + await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" SET DEFAULT '{}'`); + await queryRunner.query(`DROP TYPE "public"."user_profile_mutingnotificationtypes_enum"`); + await queryRunner.query(`ALTER TYPE "public"."user_profile_mutingnotificationtypes_enum_old" RENAME TO "user_profile_mutingnotificationtypes_enum"`); + + // remove 'move' from notification type enum + await queryRunner.query(`DELETE FROM "notification" WHERE "type" = 'move'`); + await queryRunner.query(`CREATE TYPE "public"."notification_type_enum_old" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app')`); + await queryRunner.query(`ALTER TABLE "notification" ALTER COLUMN "type" TYPE "public"."notification_type_enum_old" USING "type"::"text"::"public"."notification_type_enum_old"`); + await queryRunner.query(`DROP TYPE "public"."notification_type_enum"`); + await queryRunner.query(`ALTER TYPE "public"."notification_type_enum_old" RENAME TO "notification_type_enum"`); + + await queryRunner.query(`ALTER TABLE "notification" DROP CONSTRAINT "FK_078db271ad52ccc345b7b2b026a"`); + await queryRunner.query(`ALTER TABLE "user" DROP CONSTRAINT "FK_16fef167e4253ccdc8971b01f6e"`); + await queryRunner.query(`ALTER TABLE "notification" DROP COLUMN "moveTargetId"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "movedToId"`); + } +} diff --git a/packages/backend/migration/1679767920029-unify-drive-objects.js b/packages/backend/migration/1679767920029-unify-drive-objects.js new file mode 100644 index 000000000..b9bab31d9 --- /dev/null +++ b/packages/backend/migration/1679767920029-unify-drive-objects.js @@ -0,0 +1,22 @@ +export class unifyDriveObjects1679767920029 { + name = 'unifyDriveObjects1679767920029'; + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "drive_file" RENAME COLUMN "folderId" TO "parentId"`); + await queryRunner.query(`ALTER TABLE "drive_folder" ALTER COLUMN "name" TYPE character varying(256)`); + // The column name changed so the name that typeorm generates for indices and foreign keys changes too. + // To avoid reindexing, just rename them. + await queryRunner.query(`ALTER TABLE "drive_file" RENAME CONSTRAINT "FK_bb90d1956dafc4068c28aa7560a" TO "FK_84b4e3038e7e64a68764dd7ea3e"`); + await queryRunner.query(`ALTER INDEX "IDX_bb90d1956dafc4068c28aa7560" RENAME TO "IDX_84b4e3038e7e64a68764dd7ea3"`); + await queryRunner.query(`ALTER INDEX "IDX_55720b33a61a7c806a8215b825" RENAME TO "IDX_7c607687cd487292d16617b23e"`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "drive_file" RENAME CONSTRAINT "FK_84b4e3038e7e64a68764dd7ea3e" TO "FK_bb90d1956dafc4068c28aa7560a"`); + await queryRunner.query(`ALTER INDEX "IDX_84b4e3038e7e64a68764dd7ea3" RENAME TO "IDX_bb90d1956dafc4068c28aa7560"`); + await queryRunner.query(`ALTER INDEX "IDX_7c607687cd487292d16617b23e" RENAME TO "IDX_55720b33a61a7c806a8215b825"`); + + await queryRunner.query(`ALTER TABLE "drive_folder" ALTER COLUMN "name" TYPE character varying(128) USING substr("name", 1, 128)`); + await queryRunner.query(`ALTER TABLE "drive_file" RENAME COLUMN "parentId" TO "folderId"`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index 197ddd605..5d7df7cfa 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -69,7 +69,7 @@ "koa-views": "7.0.2", "mfm-js": "0.22.1", "mime-types": "2.1.35", - "mocha": "10.0.0", + "mocha": "10.2.0", "multer": "1.4.5-lts.1", "nested-property": "4.0.0", "node-fetch": "3.2.6", @@ -99,7 +99,7 @@ "strict-event-emitter-types": "2.0.0", "stringz": "2.1.0", "style-loader": "3.3.1", - "summaly": "2.6.0", + "summaly": "2.7.0", "syslog-pro": "1.0.0", "systeminformation": "5.11.22", "tinycolor2": "1.4.2", diff --git a/packages/backend/src/boot/master.ts b/packages/backend/src/boot/master.ts index 4e5327641..6f64715f8 100644 --- a/packages/backend/src/boot/master.ts +++ b/packages/backend/src/boot/master.ts @@ -160,12 +160,24 @@ function spawnWorker(mode: 'web' | 'queue'): Promise { return new Promise(res => { const worker = cluster.fork({ mode }); worker.on('message', message => { - if (message === 'listenFailed') { - bootLogger.error('The server Listen failed due to the previous error.'); - process.exit(1); + switch (message) { + case 'listenFailed': + bootLogger.error('The server Listen failed due to the previous error.'); + process.exit(1); + break; + case 'ready': + res(); + break; + case 'metaUpdate': + // forward new instance metadata to all workers + for (const otherWorker of Object.values(cluster.workers)) { + // don't forward the message to the worker that sent it + if (worker.id === otherWorker.id) continue; + + otherWorker.send(message); + } + break; } - if (message !== 'ready') return; - res(); }); }); } diff --git a/packages/backend/src/mfm/to-html.ts b/packages/backend/src/mfm/to-html.ts index 830bfac52..06acaeceb 100644 --- a/packages/backend/src/mfm/to-html.ts +++ b/packages/backend/src/mfm/to-html.ts @@ -4,6 +4,7 @@ import config from '@/config/index.js'; import { UserProfiles } from '@/models/index.js'; import { extractMentions } from '@/misc/extract-mentions.js'; import { intersperse } from '@/prelude/array.js'; +import { toPunyNullable } from '@/misc/convert-host.js'; // Transforms MFM to HTML, given the MFM text and a list of user IDs that are // mentioned in the text. If the list of mentions is not given, all mentions @@ -14,6 +15,19 @@ export async function toHtml(mfmText: string, mentions?: string[]): Promise 0) { + mentionedUsers = await UserProfiles.createQueryBuilder('user_profile') + .leftJoin('user_profile.user', 'user') + .select('user.usernameLower', 'username') + .addSelect('user.host', 'host') + // links should preferably use user friendly urls, only fall back to AP ids + .addSelect('COALESCE(user_profile.url, user.uri)', 'url') + .where('"userId" IN (:...ids)', { ids }) + .getRawMany(); + } + const doc = new JSDOM('').window.document; const handlers: { [K in mfm.MfmNode['type']]: (node: mfm.NodeType) => Promise } = { @@ -103,30 +117,28 @@ export async function toHtml(mfmText: string, mentions?: string[]): Promise { - const { username, host, acct } = node.props; - const ids = mentions ?? extractMentions(nodes); - if (ids.length > 0) { - const mentionedUsers = await UserProfiles.createQueryBuilder('user_profile') - .leftJoin('user_profile.user', 'user') - .select('user.username', 'username') - .addSelect('user.host', 'host') - // links should preferably use user friendly urls, only fall back to AP ids - .addSelect('COALESCE(user_profile.url, user.uri)', 'url') - .where('"userId" IN (:...ids)', { ids }) - .getRawMany(); - const userInfo = mentionedUsers.find(user => user.username === username && user.host === host); - if (userInfo != null) { - // Mastodon microformat: span.h-card > a.u-url.mention - const a = doc.createElement('a'); - a.href = userInfo.url ?? `${config.url}/${acct}`; - a.className = 'u-url mention'; - a.textContent = acct; + let { username, host, acct } = node.props; + // normalize username and host for searching the user + username = username.toLowerCase(); + host = toPunyNullable(host); + // Discard host if it is the local host. Otherwise mentions of local users where the + // hostname is not omitted are not handled correctly. + if (host == config.hostname) { + host = null; + } + const userInfo = mentionedUsers.find(user => user.username === username && user.host === host); + if (userInfo != null) { + // Mastodon microformat: span.h-card > a.u-url.mention + const a = doc.createElement('a'); + // The fallback will only be used for local users, so the host part can be discarded. + a.href = userInfo.url ?? `${config.url}/@${username}`; + a.className = 'u-url mention'; + a.textContent = acct; - const card = doc.createElement('span'); - card.className = 'h-card'; - card.appendChild(a); - return card; - } + const card = doc.createElement('span'); + card.className = 'h-card'; + card.appendChild(a); + return card; } // this user does not actually exist return doc.createTextNode(acct); diff --git a/packages/backend/src/misc/fetch-meta.ts b/packages/backend/src/misc/fetch-meta.ts index ab8c81eef..da71b1d4b 100644 --- a/packages/backend/src/misc/fetch-meta.ts +++ b/packages/backend/src/misc/fetch-meta.ts @@ -1,3 +1,4 @@ +import process from 'node:process'; import push from 'web-push'; import { db } from '@/db/postgre.js'; import { Meta } from '@/models/entities/meta.js'; @@ -17,9 +18,20 @@ export async function setMeta(meta: Meta): Promise { cache = meta; + /* + The meta is not included here because another process may have updated + the content before the other process receives it. + */ + process.send!('metaUpdated'); + unlock(); } +// the primary will forward this message +process.on('message', async message => { + if (message === 'metaUpdated') await getMeta(); +}); + /** * Performs the primitive database operation to fetch server configuration. * If there is no entry yet, inserts a new one. diff --git a/packages/backend/src/models/entities/drive-file.ts b/packages/backend/src/models/entities/drive-file.ts index 8873b9440..cd7af7e14 100644 --- a/packages/backend/src/models/entities/drive-file.ts +++ b/packages/backend/src/models/entities/drive-file.ts @@ -4,7 +4,7 @@ import { User } from './user.js'; import { DriveFolder } from './drive-folder.js'; @Entity() -@Index(['userId', 'folderId', 'id']) +@Index(['userId', 'parentId', 'id']) export class DriveFile { @PrimaryColumn(id()) public id: string; @@ -142,13 +142,13 @@ export class DriveFile { nullable: true, comment: 'The parent folder ID. If null, it means the DriveFile is located in root.', }) - public folderId: DriveFolder['id'] | null; + public parentId: DriveFolder['id'] | null; @ManyToOne(() => DriveFolder, { onDelete: 'SET NULL', }) @JoinColumn() - public folder: DriveFolder | null; + public parent: DriveFolder | null; @Index() @Column('boolean', { diff --git a/packages/backend/src/models/entities/drive-folder.ts b/packages/backend/src/models/entities/drive-folder.ts index d7e323e72..bee7e397c 100644 --- a/packages/backend/src/models/entities/drive-folder.ts +++ b/packages/backend/src/models/entities/drive-folder.ts @@ -14,7 +14,7 @@ export class DriveFolder { public createdAt: Date; @Column('varchar', { - length: 128, + length: 256, comment: 'The name of the DriveFolder.', }) public name: string; diff --git a/packages/backend/src/models/entities/notification.ts b/packages/backend/src/models/entities/notification.ts index cab6551f7..a0d6cbde4 100644 --- a/packages/backend/src/models/entities/notification.ts +++ b/packages/backend/src/models/entities/notification.ts @@ -52,19 +52,20 @@ export class Notification { public notifier: User | null; /** - * 通知の種類。 - * follow - フォローされた - * mention - 投稿で自分が言及された - * reply - (自分または自分がWatchしている)投稿が返信された - * renote - (自分または自分がWatchしている)投稿がRenoteされた - * quote - (自分または自分がWatchしている)投稿が引用Renoteされた - * reaction - (自分または自分がWatchしている)投稿にリアクションされた - * pollVote - (自分または自分がWatchしている)投稿のアンケートに投票された - * pollEnded - 自分のアンケートもしくは自分が投票したアンケートが終了した - * receiveFollowRequest - フォローリクエストされた - * followRequestAccepted - 自分の送ったフォローリクエストが承認された - * groupInvited - グループに招待された - * app - アプリ通知 + * Type of notification. + * follow - notifier followed notifiee + * mention - notifiee was mentioned + * reply - notifiee (author or watching) was replied to + * renote - notifiee (author or watching) was renoted + * quote - notifiee (author or watching) was quoted + * reaction - notifiee (author or watching) had a reaction added to the note + * pollVote - new vote in a poll notifiee authored or watched + * pollEnded - notifiee's poll ended + * receiveFollowRequest - notifiee received a new follow request + * followRequestAccepted - notifier accepted notifees follow request + * groupInvited - notifiee was invited into a group + * move - notifier moved + * app - custom application notification */ @Index() @Column('enum', { @@ -129,6 +130,19 @@ export class Notification { }) public choice: number | null; + @Column({ + ...id(), + nullable: true, + comment: 'The ID of the moved to account.', + }) + public moveTargetId: User['id'] | null; + + @ManyToOne(() => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public moveTarget: User | null; + /** * アプリ通知のbody */ diff --git a/packages/backend/src/models/entities/user.ts b/packages/backend/src/models/entities/user.ts index 0aee4c90b..6cfca187a 100644 --- a/packages/backend/src/models/entities/user.ts +++ b/packages/backend/src/models/entities/user.ts @@ -1,4 +1,4 @@ -import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm'; +import { Entity, Column, Index, OneToOne, ManyToOne, JoinColumn, PrimaryColumn } from 'typeorm'; import { id } from '../id.js'; import { DriveFile } from './drive-file.js'; @@ -230,6 +230,18 @@ export class User { }) public federateBlocks: boolean; + @Column({ + ...id(), + nullable: true, + }) + public movedToId: User['id'] | null; + + @ManyToOne(() => User, { + onDelete: 'SET NULL' + }) + @JoinColumn() + public movedTo: User | null; + constructor(data: Partial) { if (data == null) return; diff --git a/packages/backend/src/models/repositories/drive-file.ts b/packages/backend/src/models/repositories/drive-file.ts index ce3e7b705..bddde2d50 100644 --- a/packages/backend/src/models/repositories/drive-file.ts +++ b/packages/backend/src/models/repositories/drive-file.ts @@ -105,8 +105,8 @@ export const DriveFileRepository = db.getRepository(DriveFile).extend({ url: opts.self ? file.url : this.getPublicUrl(file, false), thumbnailUrl: this.getPublicUrl(file, true), comment: file.comment, - folderId: file.folderId, - folder: opts.detail && file.folderId ? DriveFolders.pack(file.folderId, { + folderId: file.parentId, + folder: opts.detail && file.parentId ? DriveFolders.pack(file.parentId, { detail: true, }) : undefined, userId: file.userId, diff --git a/packages/backend/src/models/repositories/drive-folder.ts b/packages/backend/src/models/repositories/drive-folder.ts index bb744a4b7..0e61e4ff0 100644 --- a/packages/backend/src/models/repositories/drive-folder.ts +++ b/packages/backend/src/models/repositories/drive-folder.ts @@ -28,7 +28,7 @@ export const DriveFolderRepository = db.getRepository(DriveFolder).extend({ parentId: folder.id, }), filesCount: DriveFiles.countBy({ - folderId: folder.id, + parentId: folder.id, }), ...(folder.parentId ? { diff --git a/packages/backend/src/models/repositories/notification.ts b/packages/backend/src/models/repositories/notification.ts index c62a24959..c1e2273be 100644 --- a/packages/backend/src/models/repositories/notification.ts +++ b/packages/backend/src/models/repositories/notification.ts @@ -44,6 +44,9 @@ export const NotificationRepository = db.getRepository(Notification).extend({ ...(notification.type === 'groupInvited' ? { invitation: UserGroupInvitations.pack(notification.userGroupInvitationId!), } : {}), + ...(notification.type === 'move' ? { + moveTarget: Users.pack(notification.moveTarget ?? notification.moveTargetId), + } : {}), ...(notification.type === 'app' ? { body: notification.customBody, header: notification.customHeader || token?.name, diff --git a/packages/backend/src/models/repositories/user.ts b/packages/backend/src/models/repositories/user.ts index 060bc0cd6..10969a034 100644 --- a/packages/backend/src/models/repositories/user.ts +++ b/packages/backend/src/models/repositories/user.ts @@ -26,6 +26,8 @@ type IsMeAndIsUserDetailed): Promise => { logger.debug(JSON.stringify(info, null, 2)); //#endregion - const host = toPuny(new URL(signature.keyId).hostname); + const keyIdLower = signature.keyId.toLowerCase(); + if (keyIdLower.startsWith('acct:')) { + return `Old keyId is no longer supported. ${keyIdLower}`; + } + + const host = toPuny(new URL(keyIdLower).hostname); // Stop if the host is blocked. if (await shouldBlockInstance(host)) { return `Blocked request: ${host}`; } - const keyIdLower = signature.keyId.toLowerCase(); - if (keyIdLower.startsWith('acct:')) { - return `Old keyId is no longer supported. ${keyIdLower}`; - } - const resolver = new Resolver(); let authUser; @@ -107,9 +107,14 @@ export default async (job: Bull.Job): Promise => { } } + // Verify that the actor's host is not blocked + const signerHost = extractDbHost(authUser.user.uri!); + if (await shouldBlockInstance(signerHost)) { + return `Blocked request: ${signerHost}`; + } + if (typeof activity.id === 'string') { // Verify that activity and actor are from the same host. - const signerHost = extractDbHost(authUser.user.uri!); const activityIdHost = extractDbHost(activity.id); if (signerHost !== activityIdHost) { return `skip: signerHost(${signerHost}) !== activity.id host(${activityIdHost}`; @@ -136,7 +141,7 @@ export default async (job: Bull.Job): Promise => { federationChart.inbox(i.host); }); - // アクティビティを処理 + // process the activity await perform(authUser.user, activity, resolver); return 'ok'; }; diff --git a/packages/backend/src/remote/activitypub/kernel/index.ts b/packages/backend/src/remote/activitypub/kernel/index.ts index 79e2dce75..46a972a7e 100644 --- a/packages/backend/src/remote/activitypub/kernel/index.ts +++ b/packages/backend/src/remote/activitypub/kernel/index.ts @@ -4,7 +4,7 @@ import { Resolver } from '@/remote/activitypub/resolver.js'; import { extractDbHost } from '@/misc/convert-host.js'; import { shouldBlockInstance } from '@/misc/should-block-instance.js'; import { apLogger } from '../logger.js'; -import { IObject, isCreate, isDelete, isUpdate, isRead, isFollow, isAccept, isReject, isAdd, isRemove, isAnnounce, isLike, isUndo, isBlock, isCollectionOrOrderedCollection, isCollection, isFlag, getApId } from '../type.js'; +import { IObject, isCreate, isDelete, isUpdate, isRead, isFollow, isAccept, isReject, isAdd, isRemove, isAnnounce, isLike, isUndo, isBlock, isCollectionOrOrderedCollection, isCollection, isFlag, isMove, getApId } from '../type.js'; import create from './create/index.js'; import performDeleteActivity from './delete/index.js'; import performUpdateActivity from './update/index.js'; @@ -19,6 +19,7 @@ import add from './add/index.js'; import remove from './remove/index.js'; import block from './block/index.js'; import flag from './flag/index.js'; +import { move } from './move/index.js'; export async function performActivity(actor: CacheableRemoteUser, activity: IObject, resolver: Resolver): Promise { if (isCollectionOrOrderedCollection(activity)) { @@ -73,6 +74,8 @@ async function performOneActivity(actor: CacheableRemoteUser, activity: IObject, await block(actor, activity); } else if (isFlag(activity)) { await flag(actor, activity); + } else if (isMove(activity)) { + await move(actor, activity, resolver); } else { apLogger.warn(`unrecognized activity type: ${(activity as any).type}`); } diff --git a/packages/backend/src/remote/activitypub/kernel/move/index.ts b/packages/backend/src/remote/activitypub/kernel/move/index.ts new file mode 100644 index 000000000..e64656e09 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/move/index.ts @@ -0,0 +1,62 @@ +import { IsNull } from 'typeorm'; +import { CacheableRemoteUser } from '@/models/entities/user.js'; +import { resolvePerson } from '@/remote/activitypub/models/person.js'; +import { Followings, Users } from '@/models/index.js'; +import { createNotification } from '@/services/create-notification.js'; +import Resolver from '../../resolver.js'; +import { IMove, isActor, getApId } from '../../type.js'; + +export async function move(actor: CacheableRemoteUser, activity: IMove, resolver: Resolver): Promise { + // actor is not move origin + if (activity.object == null || getApId(activity.object) !== actor.uri) return; + + // actor already moved + if (actor.movedTo != null) return; + + // no move target + if (activity.target == null) return; + + /* the database resolver can not be used here, because: + * 1. It must be ensured that the latest data is used. + * 2. The AP representation is needed, because `alsoKnownAs` + * is not stored in the database. + * This also checks whether the move target is blocked + */ + const movedToAp = await resolver.resolve(getApId(activity.target)); + + // move target is not an actor + if (!isActor(movedToAp)) return; + + // move destination has not accepted + if (!Array.isArray(movedToAp.alsoKnownAs) || !movedToAp.alsoKnownAs.includes(actor.id)) return; + + // ensure the user exists + const movedTo = await resolvePerson(getApId(activity.target), resolver, movedToAp); + // move target is already suspended + if (movedTo.isSuspended) return; + + // process move for local followers + const followings = Followings.find({ + select: { + followerId: true, + }, + where: { + followeeId: actor.id, + followerHost: IsNull(), + }, + }); + + await Promise.all([ + Users.update(actor.id, { + movedToId: movedTo.id, + }), + ...followings.map(async (following) => { + // TODO: autoAcceptMove? + + await createNotification(following.followerId, 'move', { + notifierId: actor.id, + moveTargetId: movedTo.id, + }); + }), + ]); +} diff --git a/packages/backend/src/remote/activitypub/models/person.ts b/packages/backend/src/remote/activitypub/models/person.ts index 803fd03c9..17d9858e4 100644 --- a/packages/backend/src/remote/activitypub/models/person.ts +++ b/packages/backend/src/remote/activitypub/models/person.ts @@ -39,7 +39,7 @@ const summaryLength = 2048; * @param x Fetched object * @param uri Fetch target URI */ -function validateActor(x: IObject): IActor { +async function validateActor(x: IObject, resolver: Resolver): Promise { if (x == null) { throw new Error('invalid Actor: object is null'); } @@ -61,6 +61,22 @@ function validateActor(x: IObject): IActor { throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user'); } + if (x.movedTo !== undefined) { + if (!(typeof x.movedTo === 'string' && x.movedTo.length > 0)) { + throw new Error('invalid Actor: wrong movedTo'); + } + if (x.movedTo === uri) { + throw new Error('invalid Actor: moved to self'); + } + // This may throw an exception if we cannot resolve the move target. + // If we are processing an incoming activity, this is desired behaviour + // because that will cause the activity to be retried. + await resolvePerson(x.movedTo, resolver) + .then(moveTarget => { + x.movedTo = moveTarget.id + }); + } + if (!(typeof x.inbox === 'string' && x.inbox.length > 0)) { throw new Error('invalid Actor: wrong inbox'); } @@ -137,7 +153,7 @@ export async function fetchPerson(uri: string): Promise { export async function createPerson(value: string | IObject, resolver: Resolver): Promise { const object = await resolver.resolve(value) as any; - const person = validateActor(object); + const person = await validateActor(object, resolver); apLogger.info(`Creating the Person: ${person.id}`); @@ -177,6 +193,7 @@ export async function createPerson(value: string | IObject, resolver: Resolver): isBot, isCat: (person as any).isCat === true, showTimelineReplies: false, + movedToId: person.movedTo, })) as IRemoteUser; await transactionalEntityManager.save(new UserProfile({ @@ -287,7 +304,7 @@ export async function updatePerson(value: IObject | string, resolver: Resolver): const object = await resolver.resolve(value); - const person = validateActor(object); + const person = await validateActor(object, resolver); apLogger.info(`Updating the Person: ${person.id}`); @@ -328,6 +345,7 @@ export async function updatePerson(value: IObject | string, resolver: Resolver): isCat: (person as any).isCat === true, isLocked: !!person.manuallyApprovesFollowers, isExplorable: !!person.discoverable, + movedToId: person.movedTo, } as Partial; if (avatar) { @@ -376,7 +394,7 @@ export async function updatePerson(value: IObject | string, resolver: Resolver): * If the target Person is registered in FoundKey, return it; otherwise, fetch it from a remote server and return it. * Fetch the person from the remote server, register it in FoundKey, and return it. */ -export async function resolvePerson(uri: string, resolver: Resolver): Promise { +export async function resolvePerson(uri: string, resolver: Resolver, hint?: IObject): Promise { if (typeof uri !== 'string') throw new Error('uri is not string'); //#region このサーバーに既に登録されていたらそれを返す @@ -388,7 +406,7 @@ export async function resolvePerson(uri: string, resolver: Resolver): Promise { } else if (note.visibility === 'home') { to = [`${attributedTo}/followers`]; cc = ['https://www.w3.org/ns/activitystreams#Public']; + } else if (note.visibility === 'followers') { + to = [`${attributedTo}/followers`]; } else { - return null; + throw new Error('Invalid visibility for pure renote.'); } return { diff --git a/packages/backend/src/remote/activitypub/type.ts b/packages/backend/src/remote/activitypub/type.ts index 6298d44d8..8a46ac594 100644 --- a/packages/backend/src/remote/activitypub/type.ts +++ b/packages/backend/src/remote/activitypub/type.ts @@ -296,6 +296,10 @@ export interface IFlag extends IActivity { type: 'Flag'; } +export interface IMove extends IActivity { + type: 'Move'; +} + export const isCreate = (object: IObject): object is ICreate => getApType(object) === 'Create'; export const isDelete = (object: IObject): object is IDelete => getApType(object) === 'Delete'; export const isUpdate = (object: IObject): object is IUpdate => getApType(object) === 'Update'; @@ -310,6 +314,7 @@ export const isLike = (object: IObject): object is ILike => getApType(object) == export const isAnnounce = (object: IObject): object is IAnnounce => getApType(object) === 'Announce'; export const isBlock = (object: IObject): object is IBlock => getApType(object) === 'Block'; export const isFlag = (object: IObject): object is IFlag => getApType(object) === 'Flag'; +export const isMove = (object: IObject): object is IMove => getApType(object) === 'Move'; export interface ILink { href: string; diff --git a/packages/backend/src/server/activitypub.ts b/packages/backend/src/server/activitypub.ts index 6bec78f8c..9ddda96d9 100644 --- a/packages/backend/src/server/activitypub.ts +++ b/packages/backend/src/server/activitypub.ts @@ -28,7 +28,7 @@ function inbox(ctx: Router.RouterContext): void { let signature; try { - signature = httpSignature.parseRequest(ctx.req, { 'headers': [] }); + signature = httpSignature.parseRequest(ctx.req); } catch (e) { ctx.status = 401; return; diff --git a/packages/backend/src/server/api/common/getters.ts b/packages/backend/src/server/api/common/getters.ts index ea1c26964..97059f987 100644 --- a/packages/backend/src/server/api/common/getters.ts +++ b/packages/backend/src/server/api/common/getters.ts @@ -1,9 +1,11 @@ +import { IsNull, Not } from 'typeorm'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { User } from '@/models/entities/user.js'; import { Note } from '@/models/entities/note.js'; import { Notes, Users } from '@/models/index.js'; import { apiLogger } from '@/server/api/logger.js'; import { visibilityQuery } from './generate-visibility-query.js'; +import { ApiError } from '@/server/api/error.js'; /** * Get note for API processing, taking into account visibility. @@ -27,11 +29,15 @@ export async function getNote(noteId: Note['id'], me: { id: User['id'] } | null) /** * Get user for API processing */ -export async function getUser(userId: User['id']) { - const user = await Users.findOneBy({ id: userId }); +export async function getUser(userId: User['id'], includeSuspended = false) { + const user = await Users.findOneBy({ + id: userId, + isDeleted: false, + ...(includeSuspended ? {} : {isSuspended: false}), + }); if (user == null) { - throw new IdentifiableError('15348ddd-432d-49c2-8a5a-8069753becff', 'No such user.'); + throw new ApiError('NO_SUCH_USER'); } return user; @@ -40,11 +46,16 @@ export async function getUser(userId: User['id']) { /** * Get remote user for API processing */ -export async function getRemoteUser(userId: User['id']) { - const user = await getUser(userId); +export async function getRemoteUser(userId: User['id'], includeSuspended = false) { + const user = await Users.findOneBy({ + id: userId, + host: Not(IsNull()), + isDeleted: false, + ...(includeSuspended ? {} : {isSuspended: false}), + }); - if (!Users.isRemoteUser(user)) { - throw new Error('user is not a remote user'); + if (user == null) { + throw new ApiError('NO_SUCH_USER'); } return user; @@ -53,11 +64,16 @@ export async function getRemoteUser(userId: User['id']) { /** * Get local user for API processing */ -export async function getLocalUser(userId: User['id']) { - const user = await getUser(userId); +export async function getLocalUser(userId: User['id'], includeSuspended = false) { + const user = await Users.findOneBy({ + id: userId, + host: IsNull(), + isDeleted: false, + ...(includeSuspended ? {} : {isSuspended: false}), + }); - if (!Users.isLocalUser(user)) { - throw new Error('user is not a local user'); + if (user == null) { + throw new ApiError('NO_SUCH_USER'); } return user; diff --git a/packages/backend/src/server/api/endpoints/admin/users/delete.ts b/packages/backend/src/server/api/endpoints/admin/users/delete.ts index 6445accb9..9112f50f5 100644 --- a/packages/backend/src/server/api/endpoints/admin/users/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/users/delete.ts @@ -2,6 +2,7 @@ import { Users } from '@/models/index.js'; import { ApiError } from '@/server/api/error.js'; import { deleteAccount } from '@/services/delete-account.js'; import define from '@/server/api/define.js'; +import { getUser } from '@/server/api/common/getters.js'; export const meta = { tags: ['admin'], @@ -22,14 +23,9 @@ export const paramDef = { // eslint-disable-next-line import/no-default-export export default define(meta, paramDef, async (ps) => { - const user = await Users.findOneBy({ - id: ps.userId, - isDeleted: false, - }); + const user = await getUser(ps.userId, true); - if (user == null) { - throw new ApiError('NO_SUCH_USER'); - } else if (user.isAdmin) { + if (user.isAdmin) { throw new ApiError('IS_ADMIN'); } else if (user.isModerator) { throw new ApiError('IS_MODERATOR'); diff --git a/packages/backend/src/server/api/endpoints/admin/users/reset-password.ts b/packages/backend/src/server/api/endpoints/admin/users/reset-password.ts index 7cef83c53..4858538ac 100644 --- a/packages/backend/src/server/api/endpoints/admin/users/reset-password.ts +++ b/packages/backend/src/server/api/endpoints/admin/users/reset-password.ts @@ -3,6 +3,7 @@ import { secureRndstr } from '@/misc/secure-rndstr.js'; import { Users, UserProfiles } from '@/models/index.js'; import { ApiError } from '@/server/api/error.js'; import define from '@/server/api/define.js'; +import { getLocalUser } from '@/server/api/common/getters.js'; export const meta = { tags: ['admin'], @@ -34,11 +35,7 @@ export const paramDef = { // eslint-disable-next-line import/no-default-export export default define(meta, paramDef, async (ps) => { - const user = await Users.findOneBy({ id: ps.userId }); - - if (user == null) { - throw new ApiError('NO_SUCH_USER'); - } + const user = await getLocalUser(ps.userId); if (user.isAdmin) { throw new ApiError('IS_ADMIN'); diff --git a/packages/backend/src/server/api/endpoints/admin/users/show.ts b/packages/backend/src/server/api/endpoints/admin/users/show.ts index 597839cc9..527b3fd63 100644 --- a/packages/backend/src/server/api/endpoints/admin/users/show.ts +++ b/packages/backend/src/server/api/endpoints/admin/users/show.ts @@ -1,6 +1,7 @@ import { Signins, UserProfiles, Users } from '@/models/index.js'; import { ApiError } from '@/server/api/error.js'; import define from '@/server/api/define.js'; +import { getUser } from '@/server/api/common/getters.js'; export const meta = { tags: ['admin'], @@ -27,7 +28,7 @@ export const paramDef = { // eslint-disable-next-line import/no-default-export export default define(meta, paramDef, async (ps, me) => { const [user, profile] = await Promise.all([ - Users.findOneBy({ id: ps.userId }), + getUser(ps.userId, true), UserProfiles.findOneBy({ userId: ps.userId }), ]); diff --git a/packages/backend/src/server/api/endpoints/admin/users/silence.ts b/packages/backend/src/server/api/endpoints/admin/users/silence.ts index 684925027..252c2cb63 100644 --- a/packages/backend/src/server/api/endpoints/admin/users/silence.ts +++ b/packages/backend/src/server/api/endpoints/admin/users/silence.ts @@ -3,6 +3,7 @@ import { ApiError } from '@/server/api/error.js'; import { insertModerationLog } from '@/services/insert-moderation-log.js'; import { publishInternalEvent } from '@/services/stream.js'; import define from '@/server/api/define.js'; +import { getUser } from '@/server/api/common/getters.js'; export const meta = { tags: ['admin'], @@ -23,11 +24,7 @@ export const paramDef = { // eslint-disable-next-line import/no-default-export export default define(meta, paramDef, async (ps, me) => { - const user = await Users.findOneBy({ id: ps.userId }); - - if (user == null) { - throw new ApiError('NO_SUCH_USER'); - } + const user = await getUser(ps.userId); if (user.isAdmin) { throw new ApiError('IS_ADMIN'); diff --git a/packages/backend/src/server/api/endpoints/blocking/create.ts b/packages/backend/src/server/api/endpoints/blocking/create.ts index bf97de4ba..d7c8d6c79 100644 --- a/packages/backend/src/server/api/endpoints/blocking/create.ts +++ b/packages/backend/src/server/api/endpoints/blocking/create.ts @@ -42,10 +42,7 @@ export default define(meta, paramDef, async (ps, user) => { if (user.id === ps.userId) throw new ApiError('BLOCKEE_IS_YOURSELF'); // Get blockee - const blockee = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError('NO_SUCH_USER'); - throw e; - }); + const blockee = await getUser(ps.userId); // Check if already blocking const blocked = await Blockings.countBy({ diff --git a/packages/backend/src/server/api/endpoints/blocking/delete.ts b/packages/backend/src/server/api/endpoints/blocking/delete.ts index ef5d5f544..608e312b9 100644 --- a/packages/backend/src/server/api/endpoints/blocking/delete.ts +++ b/packages/backend/src/server/api/endpoints/blocking/delete.ts @@ -42,10 +42,7 @@ export default define(meta, paramDef, async (ps, user) => { const blocker = await Users.findOneByOrFail({ id: user.id }); // Get blockee - const blockee = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError('NO_SUCH_USER'); - throw e; - }); + const blockee = await getUser(ps.userId); // Check not blocking const exist = await Blockings.countBy({ diff --git a/packages/backend/src/server/api/endpoints/drive/files.ts b/packages/backend/src/server/api/endpoints/drive/files.ts index b30ca78ad..5da30397b 100644 --- a/packages/backend/src/server/api/endpoints/drive/files.ts +++ b/packages/backend/src/server/api/endpoints/drive/files.ts @@ -38,9 +38,9 @@ export default define(meta, paramDef, async (ps, user) => { .andWhere('file.userId = :userId', { userId: user.id }); if (ps.folderId) { - query.andWhere('file.folderId = :folderId', { folderId: ps.folderId }); + query.andWhere('file.parentId = :parentId', { parentId: ps.folderId }); } else { - query.andWhere('file.folderId IS NULL'); + query.andWhere('file.parentId IS NULL'); } if (ps.type) { diff --git a/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts b/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts index 4c4fb1ad0..2958eda65 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts @@ -1,6 +1,7 @@ import { DriveFiles, Notes } from '@/models/index.js'; import define from '@/server/api/define.js'; import { ApiError } from '@/server/api/error.js'; +import { makePaginationQuery } from '@/server/api/common/make-pagination-query.js'; export const meta = { tags: ['drive', 'notes'], @@ -28,6 +29,9 @@ export const paramDef = { type: 'object', properties: { fileId: { type: 'string', format: 'misskey:id' }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, }, required: ['fileId'], } as const; @@ -42,8 +46,13 @@ export default define(meta, paramDef, async (ps, user) => { if (file == null) throw new ApiError('NO_SUCH_FILE'); - const notes = await Notes.createQueryBuilder('note') - .where(':file = ANY(note.fileIds)', { file: file.id }) + const notes = await makePaginationQuery( + Notes.createQueryBuilder('note'), + ps.sinceId, + ps.untilId, + ) + .andWhere(':file = ANY(note.fileIds)', { file: file.id }) + .take(ps.limit) .getMany(); return await Notes.packMany(notes, user, { diff --git a/packages/backend/src/server/api/endpoints/drive/files/create.ts b/packages/backend/src/server/api/endpoints/drive/files/create.ts index 6862545f1..64598eb1a 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/create.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/create.ts @@ -62,7 +62,7 @@ export default define(meta, paramDef, async (ps, user, _, file, cleanup) => { try { // Create file - const driveFile = await addFile({ user, path: file.path, name, comment: ps.comment, folderId: ps.folderId, force: ps.force, sensitive: ps.isSensitive }); + const driveFile = await addFile({ user, path: file.path, name, comment: ps.comment, parentId: ps.folderId, force: ps.force, sensitive: ps.isSensitive }); return await DriveFiles.pack(driveFile, { self: true }); } catch (e) { if (e instanceof Error || typeof e === 'string') { diff --git a/packages/backend/src/server/api/endpoints/drive/files/find.ts b/packages/backend/src/server/api/endpoints/drive/files/find.ts index 23069b810..8ec5de0a2 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/find.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/find.ts @@ -36,7 +36,7 @@ export default define(meta, paramDef, async (ps, user) => { const files = await DriveFiles.findBy({ name: ps.name, userId: user.id, - folderId: ps.folderId ?? IsNull(), + parentId: ps.folderId ?? IsNull(), }); return await Promise.all(files.map(file => DriveFiles.pack(file, { self: true }))); diff --git a/packages/backend/src/server/api/endpoints/drive/files/update.ts b/packages/backend/src/server/api/endpoints/drive/files/update.ts index 8325a428a..c84f51a30 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/update.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/update.ts @@ -54,7 +54,7 @@ export default define(meta, paramDef, async (ps, user) => { if (ps.folderId !== undefined) { if (ps.folderId === null) { - file.folderId = null; + file.parentId = null; } else { const folder = await DriveFolders.findOneBy({ id: ps.folderId, @@ -63,14 +63,14 @@ export default define(meta, paramDef, async (ps, user) => { if (folder == null) throw new ApiError('NO_SUCH_FOLDER'); - file.folderId = folder.id; + file.parentId = folder.id; } } await DriveFiles.update(file.id, { name: file.name, comment: file.comment, - folderId: file.folderId, + parentId: file.parentId, isSensitive: file.isSensitive, }); diff --git a/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts b/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts index 484021b30..960f13a54 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts @@ -34,7 +34,7 @@ export const paramDef = { // eslint-disable-next-line import/no-default-export export default define(meta, paramDef, async (ps, user) => { - uploadFromUrl({ url: ps.url, user, folderId: ps.folderId, sensitive: ps.isSensitive, force: ps.force, comment: ps.comment }).then(file => { + uploadFromUrl({ url: ps.url, user, parentId: ps.folderId, sensitive: ps.isSensitive, force: ps.force, comment: ps.comment }).then(file => { DriveFiles.pack(file, { self: true }).then(packedFile => { publishMainStream(user.id, 'urlUploadFinished', { marker: ps.marker, diff --git a/packages/backend/src/server/api/endpoints/drive/folders/delete.ts b/packages/backend/src/server/api/endpoints/drive/folders/delete.ts index e6df8265c..6df6eb520 100644 --- a/packages/backend/src/server/api/endpoints/drive/folders/delete.ts +++ b/packages/backend/src/server/api/endpoints/drive/folders/delete.ts @@ -33,7 +33,7 @@ export default define(meta, paramDef, async (ps, user) => { const [childFoldersCount, childFilesCount] = await Promise.all([ DriveFolders.countBy({ parentId: folder.id }), - DriveFiles.countBy({ folderId: folder.id }), + DriveFiles.countBy({ parentId: folder.id }), ]); if (childFoldersCount !== 0 || childFilesCount !== 0) { diff --git a/packages/backend/src/server/api/endpoints/drive/show.ts b/packages/backend/src/server/api/endpoints/drive/show.ts index 3d88ed5f5..4f27c28c9 100644 --- a/packages/backend/src/server/api/endpoints/drive/show.ts +++ b/packages/backend/src/server/api/endpoints/drive/show.ts @@ -1,11 +1,13 @@ +import { In } from 'typeorm'; import { DriveFiles, DriveFolders } from '@/models/index.js'; import define from '../../define.js'; import { makePaginationQuery } from '../../common/make-pagination-query.js'; +import { db } from '@/db/postgre.js'; export const meta = { tags: ['drive'], - description: "Lists all folders and files in the authenticated user's drive. Folders are always listed first. The limit, if specified, is applied over the total number of elements.", + description: "Lists all folders and files in the authenticated user's drive. Default sorting is folders first, then newest first. The limit, if specified, is applied over the total number of elements.", requireCredential: true, @@ -31,40 +33,88 @@ export const meta = { export const paramDef = { type: 'object', properties: { - limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, - sinceId: { type: 'string', format: 'misskey:id' }, - untilId: { type: 'string', format: 'misskey:id' }, - folderId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, + limit: { + type: 'integer', + minimum: 1, + maximum: 100, + default: 30 + }, + offset: { + type: 'integer', + default: 0, + }, + sort: { + type: 'string', + enum: [ + '+createdAt', + '-createdAt', + '+name', + '-name', + ], + }, + folderId: { + type: 'string', + format: 'misskey:id', + nullable: true, + default: null + }, + name: { + description: 'Filters the output for files and folders that contain the given string (case insensitive).', + type: 'string', + default: '', + }, }, required: [], } as const; // eslint-disable-next-line import/no-default-export export default define(meta, paramDef, async (ps, user) => { - const foldersQuery = makePaginationQuery(DriveFolders.createQueryBuilder('folder'), ps.sinceId, ps.untilId) - .andWhere('folder.userId = :userId', { userId: user.id }); - const filesQuery = makePaginationQuery(DriveFiles.createQueryBuilder('file'), ps.sinceId, ps.untilId) - .andWhere('file.userId = :userId', { userId: user.id }); - - if (ps.folderId) { - foldersQuery.andWhere('folder.parentId = :parentId', { parentId: ps.folderId }); - filesQuery.andWhere('file.folderId = :folderId', { folderId: ps.folderId }); - } else { - foldersQuery.andWhere('folder.parentId IS NULL'); - filesQuery.andWhere('file.folderId IS NULL'); + let orderBy = 'type ASC, id DESC'; + switch (ps.sort) { + case '+createdAt': + orderBy = '"createdAt" DESC'; + break; + case '-createdAt': + orderBy = '"createdAt" ASC'; + break; + case '+name': + orderBy = 'name DESC'; + break; + case '-name': + orderBy = 'name ASC'; + break; } - const folders = await foldersQuery.take(ps.limit).getMany(); + // due to the way AID is constructed, we can be sure that the IDs are not duplicated across tables. + const ids = await db.query( + 'SELECT id FROM (SELECT id, "userId", "parentId", "createdAt", name, 0 AS type FROM drive_folder' + + ' UNION SELECT id, "userId", "parentId", "createdAt", name, 1 AS type FROM drive_file) AS x' + + ' WHERE "userId" = $1 AND name ILIKE $2 AND "parentId"' + + (ps.folderId ? '= $5' : 'IS NULL') + + ' ORDER BY ' + orderBy + + ' LIMIT $3 OFFSET $4', + [user.id, '%' + ps.name + '%', ps.limit, ps.offset, ...(ps.folderId ? [ps.folderId] : [])] + ).then(items => items.map(({ id }) => id)); - const [files, ...packedFolders] = await Promise.all([ - filesQuery.take(ps.limit - folders.length).getMany(), - ...(folders.map(folder => DriveFolders.pack(folder))), + const [folders, files] = await Promise.all([ + DriveFolders.findBy({ + id: In(ids), + }) + .then(folders => Promise.all(folders.map(folder => DriveFolders.pack(folder)))), + DriveFiles.findBy({ + id: In(ids), + }) + .then(files => DriveFiles.packMany(files, { detail: false, self: true })), ]); - const packedFiles = await DriveFiles.packMany(files, { detail: false, self: true }); + // merge folders/files into one array, keeping the original sorting + let merged = []; + for (const folder of folders) { + merged[ids.indexOf(folder.id)] = folder; + } + for (const file of files) { + merged[ids.indexOf(file.id)] = file; + } - return [ - ...packedFolders, - ...packedFiles, - ]; + return merged; }); diff --git a/packages/backend/src/server/api/endpoints/following/create.ts b/packages/backend/src/server/api/endpoints/following/create.ts index 4a4e35171..9c8780103 100644 --- a/packages/backend/src/server/api/endpoints/following/create.ts +++ b/packages/backend/src/server/api/endpoints/following/create.ts @@ -43,10 +43,7 @@ export default define(meta, paramDef, async (ps, user) => { if (user.id === ps.userId) throw new ApiError('FOLLOWEE_IS_YOURSELF'); // Get followee - const followee = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError('NO_SUCH_USER'); - throw e; - }); + const followee = await getUser(ps.userId); // Check if already following const exist = await Followings.countBy({ diff --git a/packages/backend/src/server/api/endpoints/following/delete.ts b/packages/backend/src/server/api/endpoints/following/delete.ts index d3cb507ba..cb5427bb5 100644 --- a/packages/backend/src/server/api/endpoints/following/delete.ts +++ b/packages/backend/src/server/api/endpoints/following/delete.ts @@ -42,10 +42,7 @@ export default define(meta, paramDef, async (ps, user) => { if (user.id === ps.userId) throw new ApiError('FOLLOWEE_IS_YOURSELF'); // Get followee - const followee = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError('NO_SUCH_USER'); - throw e; - }); + const followee = await getUser(ps.userId); // Check not following const exist = await Followings.countBy({ diff --git a/packages/backend/src/server/api/endpoints/following/invalidate.ts b/packages/backend/src/server/api/endpoints/following/invalidate.ts index beeb1eb15..202f24866 100644 --- a/packages/backend/src/server/api/endpoints/following/invalidate.ts +++ b/packages/backend/src/server/api/endpoints/following/invalidate.ts @@ -42,10 +42,7 @@ export default define(meta, paramDef, async (ps, user) => { if (user.id === ps.userId) throw new ApiError('FOLLOWER_IS_YOURSELF'); // Get follower - const follower = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError('NO_SUCH_USER'); - throw e; - }); + const follower = await getUser(ps.userId); // Check not following const exist = await Followings.countBy({ diff --git a/packages/backend/src/server/api/endpoints/following/requests/accept.ts b/packages/backend/src/server/api/endpoints/following/requests/accept.ts index cadac48f9..c2b2088ba 100644 --- a/packages/backend/src/server/api/endpoints/following/requests/accept.ts +++ b/packages/backend/src/server/api/endpoints/following/requests/accept.ts @@ -24,10 +24,7 @@ export const paramDef = { // eslint-disable-next-line import/no-default-export export default define(meta, paramDef, async (ps, user) => { // Fetch follower - const follower = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError('NO_SUCH_USER'); - throw e; - }); + const follower = await getUser(ps.userId); await acceptFollowRequest(user, follower).catch(e => { if (e.id === '8884c2dd-5795-4ac9-b27e-6a01d38190f9') throw new ApiError('NO_SUCH_FOLLOW_REQUEST'); diff --git a/packages/backend/src/server/api/endpoints/following/requests/cancel.ts b/packages/backend/src/server/api/endpoints/following/requests/cancel.ts index 05736bad2..2db33ee50 100644 --- a/packages/backend/src/server/api/endpoints/following/requests/cancel.ts +++ b/packages/backend/src/server/api/endpoints/following/requests/cancel.ts @@ -32,10 +32,7 @@ export const paramDef = { // eslint-disable-next-line import/no-default-export export default define(meta, paramDef, async (ps, user) => { // Fetch followee - const followee = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError('NO_SUCH_USER'); - throw e; - }); + const followee = await getUser(ps.userId); try { await cancelFollowRequest(followee, user); diff --git a/packages/backend/src/server/api/endpoints/following/requests/reject.ts b/packages/backend/src/server/api/endpoints/following/requests/reject.ts index 6e1cbbcf5..d7b9fc2c5 100644 --- a/packages/backend/src/server/api/endpoints/following/requests/reject.ts +++ b/packages/backend/src/server/api/endpoints/following/requests/reject.ts @@ -24,10 +24,7 @@ export const paramDef = { // eslint-disable-next-line import/no-default-export export default define(meta, paramDef, async (ps, user) => { // Fetch follower - const follower = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError('NO_SUCH_USER'); - throw e; - }); + const follower = await getUser(ps.userId); await rejectFollowRequest(user, follower); diff --git a/packages/backend/src/server/api/endpoints/messaging/messages.ts b/packages/backend/src/server/api/endpoints/messaging/messages.ts index 19d02ed63..f00b84e49 100644 --- a/packages/backend/src/server/api/endpoints/messaging/messages.ts +++ b/packages/backend/src/server/api/endpoints/messaging/messages.ts @@ -54,10 +54,7 @@ export const paramDef = { export default define(meta, paramDef, async (ps, user) => { if (ps.userId != null) { // Fetch recipient (user) - const recipient = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError('NO_SUCH_USER'); - throw e; - }); + const recipient = await getUser(ps.userId); const query = makePaginationQuery(MessagingMessages.createQueryBuilder('message'), ps.sinceId, ps.untilId) .andWhere(new Brackets(qb => { qb diff --git a/packages/backend/src/server/api/endpoints/messaging/messages/create.ts b/packages/backend/src/server/api/endpoints/messaging/messages/create.ts index 388e274f7..b7eebdfe1 100644 --- a/packages/backend/src/server/api/endpoints/messaging/messages/create.ts +++ b/packages/backend/src/server/api/endpoints/messaging/messages/create.ts @@ -100,10 +100,7 @@ export default define(meta, paramDef, async (ps, user) => { if (ps.userId === user.id) throw new ApiError('RECIPIENT_IS_YOURSELF'); // Fetch recipient (user) - recipientUser = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError('NO_SUCH_USER'); - throw e; - }); + recipientUser = await getUser(ps.userId); // Check blocking const block = await Blockings.countBy({ diff --git a/packages/backend/src/server/api/endpoints/mute/create.ts b/packages/backend/src/server/api/endpoints/mute/create.ts index ec7d92dbc..01af0d656 100644 --- a/packages/backend/src/server/api/endpoints/mute/create.ts +++ b/packages/backend/src/server/api/endpoints/mute/create.ts @@ -37,10 +37,7 @@ export default define(meta, paramDef, async (ps, user) => { if (user.id === ps.userId) throw new ApiError('MUTEE_IS_YOURSELF'); // Get mutee - const mutee = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError('NO_SUCH_USER'); - throw e; - }); + const mutee = await getUser(ps.userId); // Check if already muting const exist = await Mutings.countBy({ diff --git a/packages/backend/src/server/api/endpoints/mute/delete.ts b/packages/backend/src/server/api/endpoints/mute/delete.ts index 6633236e2..d04a0597f 100644 --- a/packages/backend/src/server/api/endpoints/mute/delete.ts +++ b/packages/backend/src/server/api/endpoints/mute/delete.ts @@ -30,10 +30,7 @@ export default define(meta, paramDef, async (ps, user) => { if (user.id === ps.userId) throw new ApiError('MUTEE_IS_YOURSELF'); // Get mutee - const mutee = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError('NO_SUCH_USER'); - throw e; - }); + const mutee = await getUser(ps.userId); // Check not muting const exist = await Mutings.findOneBy({ diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index be462ce3a..624cdedeb 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -69,15 +69,6 @@ export const paramDef = { maxItems: 16, items: { type: 'string', format: 'misskey:id' }, }, - mediaIds: { - deprecated: true, - description: 'Use `fileIds` instead. If both are specified, this property is discarded.', - type: 'array', - uniqueItems: true, - minItems: 1, - maxItems: 16, - items: { type: 'string', format: 'misskey:id' }, - }, replyId: { type: 'string', format: 'misskey:id', nullable: true }, renoteId: { type: 'string', format: 'misskey:id', nullable: true }, channelId: { type: 'string', format: 'misskey:id', nullable: true }, @@ -111,10 +102,6 @@ export const paramDef = { // (re)note with files, text and poll are optional required: ['fileIds'], }, - { - // (re)note with files, text and poll are optional - required: ['mediaIds'], - }, { // (re)note with poll, text and files are optional properties: { @@ -139,7 +126,7 @@ export default define(meta, paramDef, async (ps, user) => { } let files: DriveFile[] = []; - const fileIds = ps.fileIds != null ? ps.fileIds : ps.mediaIds != null ? ps.mediaIds : null; + const fileIds = ps.fileIds != null ? ps.fileIds : null; if (fileIds != null) { files = await DriveFiles.createQueryBuilder('file') .where('file.userId = :userId AND file.id IN (:...fileIds)', { diff --git a/packages/backend/src/server/api/endpoints/notes/reactions.ts b/packages/backend/src/server/api/endpoints/notes/reactions.ts index b9f7bf15a..ed253bafa 100644 --- a/packages/backend/src/server/api/endpoints/notes/reactions.ts +++ b/packages/backend/src/server/api/endpoints/notes/reactions.ts @@ -43,8 +43,6 @@ export const paramDef = { }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, offset: { type: 'integer', default: 0 }, - sinceId: { type: 'string', format: 'misskey:id' }, - untilId: { type: 'string', format: 'misskey:id' }, }, required: ['noteId'], } as const; diff --git a/packages/backend/src/server/api/endpoints/notes/reactions/create.ts b/packages/backend/src/server/api/endpoints/notes/reactions/create.ts index fd6f2bc04..8ed1ca7aa 100644 --- a/packages/backend/src/server/api/endpoints/notes/reactions/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/reactions/create.ts @@ -2,15 +2,20 @@ import { createReaction } from '@/services/note/reaction/create.js'; import define from '@/server/api/define.js'; import { getNote } from '@/server/api/common/getters.js'; import { ApiError } from '@/server/api/error.js'; +import { HOUR, SECOND } from '@/const.js'; +import { limiter } from '@/server/api/limiter.js'; +import { NoteReactions } from '@/models/index.js'; export const meta = { tags: ['reactions', 'notes'], + description: 'Add a reaction to a note. If there already is a reaction to this note, deletes it and is consequently subject to the `delete` rate limiting group as if using `notes/reactions/delete`.', + requireCredential: true, kind: 'write:reactions', - errors: ['NO_SUCH_NOTE', 'ALREADY_REACTED', 'BLOCKED'], + errors: ['NO_SUCH_NOTE', 'ALREADY_REACTED', 'BLOCKED', 'RATE_LIMIT_EXCEEDED'], } as const; export const paramDef = { @@ -24,10 +29,27 @@ export const paramDef = { // eslint-disable-next-line import/no-default-export export default define(meta, paramDef, async (ps, user) => { - const note = await getNote(ps.noteId, user).catch(err => { - if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError('NO_SUCH_NOTE'); - throw err; - }); + const [note, reactionCount] = await Promise.all([ + getNote(ps.noteId, user).catch(err => { + if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError('NO_SUCH_NOTE'); + throw err; + }), + NoteReactions.countBy({ + noteId: ps.noteId, + userId: user.id, + }), + ]); + + if (reactionCount > 0) { + const limit = { + key: 'delete', + duration: HOUR, + max: 30, + minInterval: 10 * SECOND, + }; + await limiter(limit, user.id); + } + await createReaction(user, note, ps.reaction).catch(e => { if (e.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') throw new ApiError('ALREADY_REACTED'); if (e.id === 'e70412a4-7197-4726-8e74-f3e0deb92aa7') throw new ApiError('BLOCKED'); diff --git a/packages/backend/src/server/api/endpoints/renote-mute/create.ts b/packages/backend/src/server/api/endpoints/renote-mute/create.ts index b963d3595..da3903463 100644 --- a/packages/backend/src/server/api/endpoints/renote-mute/create.ts +++ b/packages/backend/src/server/api/endpoints/renote-mute/create.ts @@ -32,10 +32,7 @@ export default define(meta, paramDef, async (ps, user) => { if (user.id === ps.userId) throw new ApiError('MUTEE_IS_YOURSELF'); // Get mutee - const mutee = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError('NO_SUCH_USER'); - throw e; - }); + const mutee = await getUser(ps.userId); // Check if already muting const exist = await RenoteMutings.countBy({ diff --git a/packages/backend/src/server/api/endpoints/renote-mute/delete.ts b/packages/backend/src/server/api/endpoints/renote-mute/delete.ts index 78aefa29c..e5ce7f9b3 100644 --- a/packages/backend/src/server/api/endpoints/renote-mute/delete.ts +++ b/packages/backend/src/server/api/endpoints/renote-mute/delete.ts @@ -30,10 +30,7 @@ export default define(meta, paramDef, async (ps, user) => { if (user.id === ps.userId) throw new ApiError('MUTEE_IS_YOURSELF'); // Get mutee - const mutee = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError('NO_SUCH_USER'); - throw e; - }); + const mutee = await getUser(ps.userId); // Check not muting const exist = await RenoteMutings.findOneBy({ diff --git a/packages/backend/src/server/api/endpoints/users/groups/invite.ts b/packages/backend/src/server/api/endpoints/users/groups/invite.ts index 05bc06c90..a39a331fe 100644 --- a/packages/backend/src/server/api/endpoints/users/groups/invite.ts +++ b/packages/backend/src/server/api/endpoints/users/groups/invite.ts @@ -38,10 +38,7 @@ export default define(meta, paramDef, async (ps, me) => { if (userGroup == null) throw new ApiError('NO_SUCH_GROUP'); // Fetch the user - const user = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError('NO_SUCH_USER'); - throw e; - }); + const user = await getUser(ps.userId); const joined = await UserGroupJoinings.countBy({ userGroupId: userGroup.id, diff --git a/packages/backend/src/server/api/endpoints/users/groups/pull.ts b/packages/backend/src/server/api/endpoints/users/groups/pull.ts index 58049783b..f8df467ac 100644 --- a/packages/backend/src/server/api/endpoints/users/groups/pull.ts +++ b/packages/backend/src/server/api/endpoints/users/groups/pull.ts @@ -35,10 +35,7 @@ export default define(meta, paramDef, async (ps, me) => { if (userGroup == null) throw new ApiError('NO_SUCH_GROUP'); // Fetch the user - const user = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError('NO_SUCH_USER'); - throw e; - }); + const user = await getUser(ps.userId); if (user.id === userGroup.userId) throw new ApiError('GROUP_OWNER'); diff --git a/packages/backend/src/server/api/endpoints/users/groups/transfer.ts b/packages/backend/src/server/api/endpoints/users/groups/transfer.ts index 52344a634..e5c1181e9 100644 --- a/packages/backend/src/server/api/endpoints/users/groups/transfer.ts +++ b/packages/backend/src/server/api/endpoints/users/groups/transfer.ts @@ -41,10 +41,7 @@ export default define(meta, paramDef, async (ps, me) => { if (userGroup == null) throw new ApiError('NO_SUCH_GROUP'); // Fetch the user - const user = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError('NO_SUCH_USER'); - throw e; - }); + const user = await getUser(ps.userId); const joined = await UserGroupJoinings.countBy({ userGroupId: userGroup.id, diff --git a/packages/backend/src/server/api/endpoints/users/lists/pull.ts b/packages/backend/src/server/api/endpoints/users/lists/pull.ts index 8be80111d..f7b68a07f 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/pull.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/pull.ts @@ -36,10 +36,7 @@ export default define(meta, paramDef, async (ps, me) => { if (userList == null) throw new ApiError('NO_SUCH_USER_LIST'); // Fetch the user - const user = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError('NO_SUCH_USER'); - throw e; - }); + const user = await getUser(ps.userId); // Pull the user await UserListJoinings.delete({ userListId: userList.id, userId: user.id }); diff --git a/packages/backend/src/server/api/endpoints/users/lists/push.ts b/packages/backend/src/server/api/endpoints/users/lists/push.ts index 6126d7da2..8b3dfe4fb 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/push.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/push.ts @@ -36,10 +36,7 @@ export default define(meta, paramDef, async (ps, me) => { if (userList == null) throw new ApiError('NO_SUCH_USER_LIST'); // Fetch the user - const user = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError('NO_SUCH_USER'); - throw e; - }); + const user = await getUser(ps.userId); // Check blocking if (user.id !== me.id) { diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index d1570f0c5..25fae7cc4 100644 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -49,10 +49,7 @@ export const paramDef = { // eslint-disable-next-line import/no-default-export export default define(meta, paramDef, async (ps, me) => { // Lookup user - const user = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError('NO_SUCH_USER'); - throw e; - }); + const user = await getUser(ps.userId); //#region Construct query const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) diff --git a/packages/backend/src/server/api/endpoints/users/report-abuse.ts b/packages/backend/src/server/api/endpoints/users/report-abuse.ts index 454d919ab..17f8efe3d 100644 --- a/packages/backend/src/server/api/endpoints/users/report-abuse.ts +++ b/packages/backend/src/server/api/endpoints/users/report-abuse.ts @@ -32,10 +32,7 @@ export const paramDef = { // eslint-disable-next-line import/no-default-export export default define(meta, paramDef, async (ps, me) => { // Lookup user - const user = await getUser(ps.userId).catch(e => { - if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError('NO_SUCH_USER'); - throw e; - }); + const user = await getUser(ps.userId); if (user.id === me.id) throw new ApiError('CANNOT_REPORT_YOURSELF'); diff --git a/packages/backend/src/server/api/error.ts b/packages/backend/src/server/api/error.ts index 4af16383b..201c234b7 100644 --- a/packages/backend/src/server/api/error.ts +++ b/packages/backend/src/server/api/error.ts @@ -36,7 +36,7 @@ export class ApiError extends Error { break; case 429: if (typeof this.info === 'object' && typeof this.info.reset === 'number') { - ctx.respose.set('Retry-After', Math.floor(this.info.reset - (Date.now() / 1000))); + ctx.response.set('Retry-After', Math.floor(this.info.reset - (Date.now() / 1000))); } break; } diff --git a/packages/backend/src/server/api/limiter.ts b/packages/backend/src/server/api/limiter.ts index 2780afff3..63aba87bf 100644 --- a/packages/backend/src/server/api/limiter.ts +++ b/packages/backend/src/server/api/limiter.ts @@ -6,7 +6,7 @@ import { ApiError } from './error.js'; const logger = new Logger('limiter'); -export const limiter = (limitation: IEndpointMeta['limit'] & { key: NonNullable }, actor: string) => new Promise((resolve) => { +export const limiter = (limitation: IEndpointMeta['limit'] & { key: NonNullable }, actor: string) => new Promise((resolve, reject) => { if (process.env.NODE_ENV === 'test') resolve(); const hasShortTermLimit = typeof limitation.minInterval === 'number'; @@ -15,45 +15,8 @@ export const limiter = (limitation: IEndpointMeta['limit'] & { key: NonNullable< typeof limitation.duration === 'number' && typeof limitation.max === 'number'; - if (hasShortTermLimit) { - min(); - } else if (hasLongTermLimit) { - max(); - } else { - resolve(); - } - - // Short-term limit, calls long term limit if appropriate. - function min(): void { - const minIntervalLimiter = new Limiter({ - id: `${actor}:${limitation.key}:min`, - duration: limitation.minInterval, - max: 1, - db: redisClient, - }); - - minIntervalLimiter.get((err, info) => { - if (err) { - logger.error(err); - throw new ApiError('INTERNAL_ERROR'); - } - - logger.debug(`${actor} ${limitation.key} min remaining: ${info.remaining}`); - - if (info.remaining === 0) { - throw new ApiError('RATE_LIMIT_EXCEEDED', info); - } else { - if (hasLongTermLimit) { - max(); - } else { - resolve(); - } - } - }); - } - // Long term limit - function max(): void { + const max = (): void => { const limiter = new Limiter({ id: `${actor}:${limitation.key}`, duration: limitation.duration, @@ -64,16 +27,53 @@ export const limiter = (limitation: IEndpointMeta['limit'] & { key: NonNullable< limiter.get((err, info) => { if (err) { logger.error(err); - throw new ApiError('INTERNAL_ERROR'); + reject(new ApiError('INTERNAL_ERROR')); } logger.debug(`${actor} ${limitation.key} max remaining: ${info.remaining}`); if (info.remaining === 0) { - throw new ApiError('RATE_LIMIT_EXCEEDED', info); + reject(new ApiError('RATE_LIMIT_EXCEEDED', info)); } else { resolve(); } }); } + + // Short-term limit, calls long term limit if appropriate. + const min = (): void => { + const minIntervalLimiter = new Limiter({ + id: `${actor}:${limitation.key}:min`, + duration: limitation.minInterval, + max: 1, + db: redisClient, + }); + + minIntervalLimiter.get((err, info) => { + if (err) { + logger.error(err); + reject(new ApiError('INTERNAL_ERROR')); + } + + logger.debug(`${actor} ${limitation.key} min remaining: ${info.remaining}`); + + if (info.remaining === 0) { + reject(new ApiError('RATE_LIMIT_EXCEEDED', info)); + } else { + if (hasLongTermLimit) { + max(); + } else { + resolve(); + } + } + }); + } + + if (hasShortTermLimit) { + min(); + } else if (hasLongTermLimit) { + max(); + } else { + resolve(); + } }); diff --git a/packages/backend/src/server/api/private/signin.ts b/packages/backend/src/server/api/private/signin.ts index 2f4a3ca47..b09262bb1 100644 --- a/packages/backend/src/server/api/private/signin.ts +++ b/packages/backend/src/server/api/private/signin.ts @@ -47,6 +47,7 @@ export default async (ctx: Koa.Context) => { const user = await Users.findOneBy({ usernameLower: username.toLowerCase(), host: IsNull(), + isDeleted: false, }) as ILocalUser; if (user == null) { diff --git a/packages/backend/src/server/api/stream/index.ts b/packages/backend/src/server/api/stream/index.ts index c93b4877b..ace59d32d 100644 --- a/packages/backend/src/server/api/stream/index.ts +++ b/packages/backend/src/server/api/stream/index.ts @@ -332,7 +332,8 @@ export class Connection { * @param data The message to be sent. */ private onChannelMessageRequested(data: Record) { - this.channels[id]?.onMessage?.(data.type, data.body); + if (!data.id) return; + this.channels[data.id]?.onMessage?.(data.type, data.body); } private typingOnChannel(channel: ChannelModel['id']) { diff --git a/packages/backend/src/server/nodeinfo.ts b/packages/backend/src/server/nodeinfo.ts index eb1769722..6ce55239c 100644 --- a/packages/backend/src/server/nodeinfo.ts +++ b/packages/backend/src/server/nodeinfo.ts @@ -89,7 +89,7 @@ const nodeinfo2 = async (): Promise => { langs: meta.langs, tosUrl: meta.ToSUrl, repositoryUrl: repository, - feedbackUrl: 'ircs://irc.akkoma.dev/foundkey', + feedbackUrl: 'https://akkoma.dev/FoundKeyGang/FoundKey/issues', disableRegistration: meta.disableRegistration, disableLocalTimeline: meta.disableLocalTimeline, disableGlobalTimeline: meta.disableGlobalTimeline, diff --git a/packages/backend/src/server/web/index.ts b/packages/backend/src/server/web/index.ts index fec6663fa..7fe462d43 100644 --- a/packages/backend/src/server/web/index.ts +++ b/packages/backend/src/server/web/index.ts @@ -223,6 +223,7 @@ const getFeed = async (acct: string) => { usernameLower: username.toLowerCase(), host: host ?? IsNull(), isSuspended: false, + isDeleted: false, }); return user && await packFeed(user); @@ -272,6 +273,7 @@ router.get(['/@:user', '/@:user/:sub'], async (ctx, next) => { usernameLower: username.toLowerCase(), host: host ?? IsNull(), isSuspended: false, + isDeleted: false, }); if (user != null) { @@ -304,6 +306,7 @@ router.get('/users/:user', async ctx => { id: ctx.params.user, host: IsNull(), isSuspended: false, + isDeleted: false, }); if (user == null) { @@ -419,6 +422,8 @@ router.get('/@:user/pages/:page', async (ctx, next) => { const user = await Users.findOneBy({ usernameLower: username.toLowerCase(), host: host ?? IsNull(), + isSuspended: false, + isDeleted: false, }); if (user == null) return; diff --git a/packages/backend/src/services/delete-account.ts b/packages/backend/src/services/delete-account.ts index 9c8d4c277..2fa6e004b 100644 --- a/packages/backend/src/services/delete-account.ts +++ b/packages/backend/src/services/delete-account.ts @@ -1,4 +1,4 @@ -import { Users } from '@/models/index.js'; +import { AccessTokens, Users } from '@/models/index.js'; import { createDeleteAccountJob } from '@/queue/index.js'; import { publishUserEvent } from './stream.js'; import { doPostSuspend } from './suspend-user.js'; @@ -7,9 +7,15 @@ export async function deleteAccount(user: { id: string; host: string | null; }): Promise { - await Users.update(user.id, { - isDeleted: true, - }); + await Promise.all([ + Users.update(user.id, { + isDeleted: true, + }), + // revoke all of the users access tokens to block API access + AccessTokens.delete({ + userId: user.id, + }), + ]); if (Users.isLocalUser(user)) { // Terminate streaming diff --git a/packages/backend/src/services/drive/add-file.ts b/packages/backend/src/services/drive/add-file.ts index 81da49df8..9cdade177 100644 --- a/packages/backend/src/services/drive/add-file.ts +++ b/packages/backend/src/services/drive/add-file.ts @@ -322,7 +322,7 @@ type AddFileArgs = { /** Comment */ comment?: string | null; /** Folder ID */ - folderId?: any; + parentId?: any; /** If set to true, forcibly upload the file even if there is a file with the same hash. */ force?: boolean; /** Do not save file to local */ @@ -344,7 +344,7 @@ export async function addFile({ path, name = null, comment = null, - folderId = null, + parentId = null, force = false, isLink = false, url = null, @@ -392,12 +392,12 @@ export async function addFile({ //#endregion const fetchFolder = async (): Promise => { - if (!folderId) { + if (!parentId) { return null; } const driveFolder = await DriveFolders.findOneBy({ - id: folderId, + id: parentId, userId: user ? user.id : IsNull(), }); @@ -429,7 +429,7 @@ export async function addFile({ file.createdAt = new Date(); file.userId = user ? user.id : null; file.userHost = user ? user.host : null; - file.folderId = folder?.id ?? null; + file.parentId = folder?.id ?? null; file.comment = comment; file.properties = properties; file.blurhash = info.blurhash || null; diff --git a/packages/backend/src/services/drive/upload-from-url.ts b/packages/backend/src/services/drive/upload-from-url.ts index a815d8374..e8f3793b7 100644 --- a/packages/backend/src/services/drive/upload-from-url.ts +++ b/packages/backend/src/services/drive/upload-from-url.ts @@ -13,7 +13,7 @@ const logger = driveLogger.createSubLogger('downloader'); type Args = { url: string; user: { id: User['id']; host: User['host'] } | null; - folderId?: DriveFolder['id'] | null; + parentId?: DriveFolder['id'] | null; uri?: string | null; sensitive?: boolean; force?: boolean; @@ -24,7 +24,7 @@ type Args = { export async function uploadFromUrl({ url, user, - folderId = null, + parentId = null, uri = null, sensitive = false, force = false, @@ -50,7 +50,7 @@ export async function uploadFromUrl({ // If the comment is same as the name, skip comment // (image.name is passed in when receiving attachment) comment: name === comment ? null : comment, - folderId, + parentId, force, isLink, url, diff --git a/packages/backend/src/services/instance-actor.ts b/packages/backend/src/services/instance-actor.ts index 279142b32..15bdc674f 100644 --- a/packages/backend/src/services/instance-actor.ts +++ b/packages/backend/src/services/instance-actor.ts @@ -1,7 +1,7 @@ import { IsNull } from 'typeorm'; import { ILocalUser } from '@/models/entities/user.js'; import { Users } from '@/models/index.js'; -import { createSystemUser } from './create-system-user.js'; +import { getSystemUser } from './system-user.js'; const ACTOR_USERNAME = 'instance.actor' as const; @@ -12,7 +12,7 @@ let instanceActor = await Users.findOneBy({ export async function getInstanceActor(): Promise { if (!instanceActor) { - instanceActor = await createSystemUser(ACTOR_USERNAME) as ILocalUser; + instanceActor = await getSystemUser(ACTOR_USERNAME) as ILocalUser; } return instanceActor; diff --git a/packages/backend/src/services/note/delete.ts b/packages/backend/src/services/note/delete.ts index 595c28ace..f22ce80db 100644 --- a/packages/backend/src/services/note/delete.ts +++ b/packages/backend/src/services/note/delete.ts @@ -150,7 +150,7 @@ async function findCascadingNotes(note: Note): Promise { await Promise.all(replies.map(reply => { // only add unique notes - if (cascadingNotes.find((x) => x.id === reply.id) != null) return; + if (cascadingNotes.some((x) => x.id === reply.id)) return; cascadingNotes.push(reply); return recursive(reply.id); @@ -186,4 +186,3 @@ async function getMentionedRemoteUsers(note: Note): Promise { where, }) as IRemoteUser[]; } - diff --git a/packages/backend/src/services/relay.ts b/packages/backend/src/services/relay.ts index 21b869a82..de7155ea0 100644 --- a/packages/backend/src/services/relay.ts +++ b/packages/backend/src/services/relay.ts @@ -9,7 +9,7 @@ import { genId } from '@/misc/gen-id.js'; import { Cache } from '@/misc/cache.js'; import { Relay } from '@/models/entities/relay.js'; import { MINUTE } from '@/const.js'; -import { createSystemUser } from './create-system-user.js'; +import { getSystemUser } from './system-user.js'; const ACTOR_USERNAME = 'relay.actor' as const; @@ -24,16 +24,8 @@ const relaysCache = new Cache( }), ); -export async function getRelayActor(): Promise { - const user = await Users.findOneBy({ - host: IsNull(), - username: ACTOR_USERNAME, - }); - - if (user) return user as ILocalUser; - - const created = await createSystemUser(ACTOR_USERNAME); - return created as ILocalUser; +async function getRelayActor(): Promise { + return await getSystemUser(ACTOR_USERNAME); } export async function addRelay(inbox: string): Promise { diff --git a/packages/backend/src/services/create-system-user.ts b/packages/backend/src/services/system-user.ts similarity index 86% rename from packages/backend/src/services/create-system-user.ts rename to packages/backend/src/services/system-user.ts index 8e39b00fb..2ad32a1c4 100644 --- a/packages/backend/src/services/create-system-user.ts +++ b/packages/backend/src/services/system-user.ts @@ -2,6 +2,7 @@ import { v4 as uuid } from 'uuid'; import { IsNull } from 'typeorm'; import { genRsaKeyPair } from '@/misc/gen-key-pair.js'; import { hashPassword } from '@/misc/password.js'; +import { Users } from '@/models/index.js'; import { User } from '@/models/entities/user.js'; import { UserProfile } from '@/models/entities/user-profile.js'; import { genId } from '@/misc/gen-id.js'; @@ -10,7 +11,14 @@ import { UsedUsername } from '@/models/entities/used-username.js'; import { db } from '@/db/postgre.js'; import generateNativeUserToken from '@/server/api/common/generate-native-user-token.js'; -export async function createSystemUser(username: string): Promise { +export async function getSystemUser(username: string): Promise { + const exist = await Users.findBy({ + usernameLower: username.toLowerCase(), + host: IsNull(), + }); + + if (exist) return exist; + const password = await hashPassword(uuid()); // Generate secret @@ -22,13 +30,6 @@ export async function createSystemUser(username: string): Promise { // Start transaction await db.transaction(async transactionalEntityManager => { - const exist = await transactionalEntityManager.countBy(User, { - usernameLower: username.toLowerCase(), - host: IsNull(), - }); - - if (exist) throw new Error('the user is already exists'); - account = await transactionalEntityManager.insert(User, { id: genId(), createdAt: new Date(), diff --git a/packages/backend/src/services/user-cache.ts b/packages/backend/src/services/user-cache.ts index 1266ef5f0..b4bbb6f1a 100644 --- a/packages/backend/src/services/user-cache.ts +++ b/packages/backend/src/services/user-cache.ts @@ -6,15 +6,15 @@ import { subscriber } from '@/db/redis.js'; export const userByIdCache = new Cache( Infinity, - async (id) => await Users.findOneBy({ id }) ?? undefined, + async (id) => await Users.findOneBy({ id, isDeleted: false }) ?? undefined, ); export const localUserByNativeTokenCache = new Cache( Infinity, - async (token) => await Users.findOneBy({ token, host: IsNull() }) as ILocalUser | null ?? undefined, + async (token) => await Users.findOneBy({ token, host: IsNull(), isDeleted: false }) as ILocalUser | null ?? undefined, ); export const uriPersonCache = new Cache( Infinity, - async (uri) => await Users.findOneBy({ uri }) ?? undefined, + async (uri) => await Users.findOneBy({ uri, isDeleted: false }) ?? undefined, ); subscriber.on('message', async (_, data) => { @@ -28,14 +28,18 @@ subscriber.on('message', async (_, data) => { case 'userChangeModeratorState': case 'remoteUserUpdated': { const user = await Users.findOneByOrFail({ id: body.id }); - userByIdCache.set(user.id, user); - for (const [k, v] of uriPersonCache.cache.entries()) { - if (v.value.id === user.id) { - uriPersonCache.set(k, user); + if (user.isDeleted) { + userByIdCache.delete(user.id); + uriPersonCache.delete(user.uri); + if (Users.isLocalUser(user)) { + localUserByNativeTokenCache.delete(user.token); + } + } else { + userByIdCache.set(user.id, user); + uriPersonCache.set(user.uri, user); + if (Users.isLocalUser(user)) { + localUserByNativeTokenCache.set(user.token, user); } - } - if (Users.isLocalUser(user)) { - localUserByNativeTokenCache.set(user.token, user); } break; } @@ -45,8 +49,6 @@ subscriber.on('message', async (_, data) => { localUserByNativeTokenCache.set(body.newToken, user); break; } - default: - break; } } }); diff --git a/packages/backend/test/api-visibility.ts b/packages/backend/test/api-visibility.ts index cde3cd2d0..6f1d51a67 100644 --- a/packages/backend/test/api-visibility.ts +++ b/packages/backend/test/api-visibility.ts @@ -8,6 +8,7 @@ describe('API visibility', () => { let p: childProcess.ChildProcess; before(async () => { + this.timeout(0); p = await startServer(); }); @@ -17,15 +18,15 @@ describe('API visibility', () => { describe('Note visibility', async () => { //#region vars - /** ヒロイン */ + /** protagonist */ let alice: any; - /** フォロワー */ + /** follower */ let follower: any; - /** 非フォロワー */ + /** non-follower */ let other: any; - /** 非フォロワーでもリプライやメンションをされた人 */ + /** non-follower who has been replied to or mentioned */ let target: any; - /** specified mentionでmentionを飛ばされる人 */ + /** actor for which a specified visibility was set */ let target2: any; /** public-post */ @@ -100,90 +101,90 @@ describe('API visibility', () => { //#region show post // public - it('[show] public-postを自分が見れる', async(async () => { + it('[show] public post can be seen by author', async(async () => { const res = await show(pub.id, alice); assert.strictEqual(res.body.text, 'x'); })); - it('[show] public-postをフォロワーが見れる', async(async () => { + it('[show] public post can be seen by follower', async(async () => { const res = await show(pub.id, follower); assert.strictEqual(res.body.text, 'x'); })); - it('[show] public-postを非フォロワーが見れる', async(async () => { + it('[show] public post can be seen by non-follower', async(async () => { const res = await show(pub.id, other); assert.strictEqual(res.body.text, 'x'); })); - it('[show] public-postを未認証が見れる', async(async () => { + it('[show] public post can be seen unauthenticated', async(async () => { const res = await show(pub.id, null); assert.strictEqual(res.body.text, 'x'); })); // home - it('[show] home-postを自分が見れる', async(async () => { + it('[show] home post can be seen by author', async(async () => { const res = await show(home.id, alice); assert.strictEqual(res.body.text, 'x'); })); - it('[show] home-postをフォロワーが見れる', async(async () => { + it('[show] home post can be seen by follower', async(async () => { const res = await show(home.id, follower); assert.strictEqual(res.body.text, 'x'); })); - it('[show] home-postを非フォロワーが見れる', async(async () => { + it('[show] home post can be seen by non-follower', async(async () => { const res = await show(home.id, other); assert.strictEqual(res.body.text, 'x'); })); - it('[show] home-postを未認証が見れる', async(async () => { + it('[show] home post can be seen unauthenticated', async(async () => { const res = await show(home.id, null); assert.strictEqual(res.body.text, 'x'); })); // followers - it('[show] followers-postを自分が見れる', async(async () => { + it('[show] followers post can be seen by author', async(async () => { const res = await show(fol.id, alice); assert.strictEqual(res.body.text, 'x'); })); - it('[show] followers-postをフォロワーが見れる', async(async () => { + it('[show] followers post can be seen by follower', async(async () => { const res = await show(fol.id, follower); assert.strictEqual(res.body.text, 'x'); })); - it('[show] followers-postを非フォロワーが見れない', async(async () => { + it('[show] followers post is hidden from non-follower', async(async () => { const res = await show(fol.id, other); assert.strictEqual(res.status, 404); })); - it('[show] followers-postを未認証が見れない', async(async () => { + it('[show] followers post is hidden when unathenticated', async(async () => { const res = await show(fol.id, null); assert.strictEqual(res.status, 404); })); // specified - it('[show] specified-postを自分が見れる', async(async () => { + it('[show] specified post can be seen by author', async(async () => { const res = await show(spe.id, alice); - assert.strictEqual(res.status, 404); + assert.strictEqual(res.body.text, 'x'); })); - it('[show] specified-postを指定ユーザーが見れる', async(async () => { + it('[show] specified post can be seen by designated user', async(async () => { const res = await show(spe.id, target); assert.strictEqual(res.body.text, 'x'); })); - it('[show] specified-postをフォロワーが見れない', async(async () => { + it('[show] specified post is hidden from non-specified follower', async(async () => { const res = await show(spe.id, follower); assert.strictEqual(res.status, 404); })); - it('[show] specified-postを非フォロワーが見れない', async(async () => { + it('[show] specified post is hidden from non-follower', async(async () => { const res = await show(spe.id, other); assert.strictEqual(res.status, 404); })); - it('[show] specified-postを未認証が見れない', async(async () => { + it('[show] specified post is hidden when unauthenticated', async(async () => { const res = await show(spe.id, null); assert.strictEqual(res.status, 404); })); @@ -191,110 +192,105 @@ describe('API visibility', () => { //#region show reply // public - it('[show] public-replyを自分が見れる', async(async () => { + it('[show] public reply can be seen by author', async(async () => { const res = await show(pubR.id, alice); assert.strictEqual(res.body.text, 'x'); })); - it('[show] public-replyをされた人が見れる', async(async () => { + it('[show] public reply can be seen by replied to author', async(async () => { const res = await show(pubR.id, target); assert.strictEqual(res.body.text, 'x'); })); - it('[show] public-replyをフォロワーが見れる', async(async () => { + it('[show] public reply can be seen by follower', async(async () => { const res = await show(pubR.id, follower); assert.strictEqual(res.body.text, 'x'); })); - it('[show] public-replyを非フォロワーが見れる', async(async () => { + it('[show] public reply can be seen by non-follower', async(async () => { const res = await show(pubR.id, other); assert.strictEqual(res.body.text, 'x'); })); - it('[show] public-replyを未認証が見れる', async(async () => { + it('[show] public reply can be seen unauthenticated', async(async () => { const res = await show(pubR.id, null); assert.strictEqual(res.body.text, 'x'); })); // home - it('[show] home-replyを自分が見れる', async(async () => { + it('[show] home reply can be seen by author', async(async () => { const res = await show(homeR.id, alice); assert.strictEqual(res.body.text, 'x'); })); - it('[show] home-replyをされた人が見れる', async(async () => { + it('[show] home reply can be seen by replied to author', async(async () => { const res = await show(homeR.id, target); assert.strictEqual(res.body.text, 'x'); })); - it('[show] home-replyをフォロワーが見れる', async(async () => { + it('[show] home reply can be seen by follower', async(async () => { const res = await show(homeR.id, follower); assert.strictEqual(res.body.text, 'x'); })); - it('[show] home-replyを非フォロワーが見れる', async(async () => { + it('[show] home reply can be seen by non-follower', async(async () => { const res = await show(homeR.id, other); assert.strictEqual(res.body.text, 'x'); })); - it('[show] home-replyを未認証が見れる', async(async () => { + it('[show] home reply can be seen unauthenticated', async(async () => { const res = await show(homeR.id, null); assert.strictEqual(res.body.text, 'x'); })); // followers - it('[show] followers-replyを自分が見れる', async(async () => { + it('[show] followers reply can be seen by author', async(async () => { const res = await show(folR.id, alice); assert.strictEqual(res.body.text, 'x'); })); - it('[show] followers-replyを非フォロワーでもリプライされていれば見れる', async(async () => { + it('[show] followers reply can be seen by replied to author', async(async () => { const res = await show(folR.id, target); assert.strictEqual(res.body.text, 'x'); })); - it('[show] followers-replyをフォロワーが見れる', async(async () => { + it('[show] followers reply can be seen by follower', async(async () => { const res = await show(folR.id, follower); assert.strictEqual(res.body.text, 'x'); })); - it('[show] followers-replyを非フォロワーが見れない', async(async () => { + it('[show] followers reply is hidden from non-follower', async(async () => { const res = await show(folR.id, other); assert.strictEqual(res.status, 404); })); - it('[show] followers-replyを未認証が見れない', async(async () => { + it('[show] followers reply is hidden when unauthenticated', async(async () => { const res = await show(folR.id, null); assert.strictEqual(res.status, 404); })); // specified - it('[show] specified-replyを自分が見れる', async(async () => { + it('[show] specified reply can be seen by author', async(async () => { const res = await show(speR.id, alice); assert.strictEqual(res.body.text, 'x'); })); - it('[show] specified-replyを指定ユーザーが見れる', async(async () => { + it('[show] specified reply can be seen by replied to user', async(async () => { const res = await show(speR.id, target); assert.strictEqual(res.body.text, 'x'); })); - it('[show] specified-replyをされた人が指定されてなくても見れる', async(async () => { - const res = await show(speR.id, target); - assert.strictEqual(res.body.text, 'x'); - })); - - it('[show] specified-replyをフォロワーが見れない', async(async () => { + it('[show] specified reply is hidden from follower', async(async () => { const res = await show(speR.id, follower); assert.strictEqual(res.status, 404); })); - it('[show] specified-replyを非フォロワーが見れない', async(async () => { + it('[show] specified reply is hidden from non-follower', async(async () => { const res = await show(speR.id, other); assert.strictEqual(res.status, 404); })); - it('[show] specified-replyを未認証が見れない', async(async () => { + it('[show] specified reply is hidden when unauthenticated', async(async () => { const res = await show(speR.id, null); assert.strictEqual(res.status, 404); })); @@ -302,131 +298,131 @@ describe('API visibility', () => { //#region show mention // public - it('[show] public-mentionを自分が見れる', async(async () => { + it('[show] public-mention can be seen by author', async(async () => { const res = await show(pubM.id, alice); assert.strictEqual(res.body.text, '@target x'); })); - it('[show] public-mentionをされた人が見れる', async(async () => { + it('[show] public mention can be seen by mentioned', async(async () => { const res = await show(pubM.id, target); assert.strictEqual(res.body.text, '@target x'); })); - it('[show] public-mentionをフォロワーが見れる', async(async () => { + it('[show] public mention can be seen by follower', async(async () => { const res = await show(pubM.id, follower); assert.strictEqual(res.body.text, '@target x'); })); - it('[show] public-mentionを非フォロワーが見れる', async(async () => { + it('[show] public mention can be seen by non-follower', async(async () => { const res = await show(pubM.id, other); assert.strictEqual(res.body.text, '@target x'); })); - it('[show] public-mentionを未認証が見れる', async(async () => { + it('[show] public mention can be seen unauthenticated', async(async () => { const res = await show(pubM.id, null); assert.strictEqual(res.body.text, '@target x'); })); // home - it('[show] home-mentionを自分が見れる', async(async () => { + it('[show] home mention can be seen by author', async(async () => { const res = await show(homeM.id, alice); assert.strictEqual(res.body.text, '@target x'); })); - it('[show] home-mentionをされた人が見れる', async(async () => { + it('[show] home mention can be seen by mentioned', async(async () => { const res = await show(homeM.id, target); assert.strictEqual(res.body.text, '@target x'); })); - it('[show] home-mentionをフォロワーが見れる', async(async () => { + it('[show] home mention can be seen by follower', async(async () => { const res = await show(homeM.id, follower); assert.strictEqual(res.body.text, '@target x'); })); - it('[show] home-mentionを非フォロワーが見れる', async(async () => { + it('[show] home mention can be seen by non-follower', async(async () => { const res = await show(homeM.id, other); assert.strictEqual(res.body.text, '@target x'); })); - it('[show] home-mentionを未認証が見れる', async(async () => { + it('[show] home mention can be seen unauthenticated', async(async () => { const res = await show(homeM.id, null); assert.strictEqual(res.body.text, '@target x'); })); // followers - it('[show] followers-mentionを自分が見れる', async(async () => { + it('[show] followers mention can be seen by author', async(async () => { const res = await show(folM.id, alice); assert.strictEqual(res.body.text, '@target x'); })); - it('[show] followers-mentionをメンションされていれば非フォロワーでも見れる', async(async () => { + it('[show] followers mention can be seen by non-follower mentioned', async(async () => { const res = await show(folM.id, target); assert.strictEqual(res.body.text, '@target x'); })); - it('[show] followers-mentionをフォロワーが見れる', async(async () => { + it('[show] followers mention can be seen by follower', async(async () => { const res = await show(folM.id, follower); assert.strictEqual(res.body.text, '@target x'); })); - it('[show] followers-mentionを非フォロワーが見れない', async(async () => { + it('[show] followers mention is hidden from non-follower', async(async () => { const res = await show(folM.id, other); assert.strictEqual(res.status, 404); })); - it('[show] followers-mentionを未認証が見れない', async(async () => { + it('[show] followers mention is hidden when unauthenticated', async(async () => { const res = await show(folM.id, null); assert.strictEqual(res.status, 404); })); // specified - it('[show] specified-mentionを自分が見れる', async(async () => { + it('[show] specified mention can be seen by author', async(async () => { const res = await show(speM.id, alice); assert.strictEqual(res.body.text, '@target2 x'); })); - it('[show] specified-mentionを指定ユーザーが見れる', async(async () => { + it('[show] specified mention can be seen by specified actor', async(async () => { const res = await show(speM.id, target); assert.strictEqual(res.body.text, '@target2 x'); })); - it('[show] specified-mentionをされた人が指定されてなかったら見れない', async(async () => { + it('[show] specified mention is hidden from mentioned but not specified actor', async(async () => { const res = await show(speM.id, target2); assert.strictEqual(res.status, 404); })); - it('[show] specified-mentionをフォロワーが見れない', async(async () => { + it('[show] specified mention is hidden from follower', async(async () => { const res = await show(speM.id, follower); assert.strictEqual(res.status, 404); })); - it('[show] specified-mentionを非フォロワーが見れない', async(async () => { + it('[show] specified mention is hidden from non-follower', async(async () => { const res = await show(speM.id, other); assert.strictEqual(res.status, 404); })); - it('[show] specified-mentionを未認証が見れない', async(async () => { + it('[show] specified mention is hidden when unauthenticated', async(async () => { const res = await show(speM.id, null); assert.strictEqual(res.status, 404); })); //#endregion - //#region HTL - it('[HTL] public-post が 自分が見れる', async(async () => { + //#region Home Timeline + it('[TL] public post on author home TL', async(async () => { const res = await request('/notes/timeline', { limit: 100 }, alice); assert.strictEqual(res.status, 200); const notes = res.body.filter((n: any) => n.id == pub.id); assert.strictEqual(notes[0].text, 'x'); })); - it('[HTL] public-post が 非フォロワーから見れない', async(async () => { + it('[TL] public post absent from non-follower home TL', async(async () => { const res = await request('/notes/timeline', { limit: 100 }, other); assert.strictEqual(res.status, 200); const notes = res.body.filter((n: any) => n.id == pub.id); assert.strictEqual(notes.length, 0); })); - it('[HTL] followers-post が フォロワーから見れる', async(async () => { + it('[TL] followers post on follower home TL', async(async () => { const res = await request('/notes/timeline', { limit: 100 }, follower); assert.strictEqual(res.status, 200); const notes = res.body.filter((n: any) => n.id == fol.id); @@ -434,22 +430,22 @@ describe('API visibility', () => { })); //#endregion - //#region RTL - it('[replies] followers-reply が フォロワーから見れる', async(async () => { + //#region replies timeline + it('[TL] followers reply on follower reply TL', async(async () => { const res = await request('/notes/replies', { noteId: tgt.id, limit: 100 }, follower); assert.strictEqual(res.status, 200); const notes = res.body.filter((n: any) => n.id == folR.id); assert.strictEqual(notes[0].text, 'x'); })); - it('[replies] followers-reply が 非フォロワー (リプライ先ではない) から見れない', async(async () => { + it('[TL] followers reply absent from not replied to non-follower reply TL', async(async () => { const res = await request('/notes/replies', { noteId: tgt.id, limit: 100 }, other); assert.strictEqual(res.status, 200); const notes = res.body.filter((n: any) => n.id == folR.id); assert.strictEqual(notes.length, 0); })); - it('[replies] followers-reply が 非フォロワー (リプライ先である) から見れる', async(async () => { + it('[TL] followers reply on replied to actor reply TL', async(async () => { const res = await request('/notes/replies', { noteId: tgt.id, limit: 100 }, target); assert.strictEqual(res.status, 200); const notes = res.body.filter((n: any) => n.id == folR.id); @@ -458,14 +454,14 @@ describe('API visibility', () => { //#endregion //#region MTL - it('[mentions] followers-reply が 非フォロワー (リプライ先である) から見れる', async(async () => { + it('[TL] followers reply on replied to non-follower mention TL', async(async () => { const res = await request('/notes/mentions', { limit: 100 }, target); assert.strictEqual(res.status, 200); const notes = res.body.filter((n: any) => n.id == folR.id); assert.strictEqual(notes[0].text, 'x'); })); - it('[mentions] followers-mention が 非フォロワー (メンション先である) から見れる', async(async () => { + it('[TL] followers mention on mentioned non-follower mention TL', async(async () => { const res = await request('/notes/mentions', { limit: 100 }, target); assert.strictEqual(res.status, 200); const notes = res.body.filter((n: any) => n.id == folM.id); diff --git a/packages/backend/test/api.ts b/packages/backend/test/api.ts index b1b2ecafc..ae46ae92d 100644 --- a/packages/backend/test/api.ts +++ b/packages/backend/test/api.ts @@ -11,6 +11,7 @@ describe('API', () => { let carol: any; before(async () => { + this.timeout(0); p = await startServer(); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); diff --git a/packages/backend/test/block.ts b/packages/backend/test/block.ts index b3343813c..ec5d54ca0 100644 --- a/packages/backend/test/block.ts +++ b/packages/backend/test/block.ts @@ -13,6 +13,8 @@ describe('Block', () => { let carol: any; before(async () => { + this.timeout(0); + p = await startServer(); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); @@ -23,7 +25,7 @@ describe('Block', () => { await shutdownServer(p); }); - it('Block作成', async(async () => { + it('can block someone', async(async () => { const res = await request('/blocking/create', { userId: bob.id, }, alice); @@ -31,45 +33,45 @@ describe('Block', () => { assert.strictEqual(res.status, 200); })); - it('ブロックされているユーザーをフォローできない', async(async () => { + it('cannot follow if blocked', async(async () => { const res = await request('/following/create', { userId: alice.id }, bob); assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.id, 'c4ab57cc-4e41-45e9-bfd9-584f61e35ce0'); + assert.strictEqual(res.body.error.code, 'BLOCKED'); })); - it('ブロックされているユーザーにリアクションできない', async(async () => { + it('cannot react to blocking users note', async(async () => { const note = await post(alice, { text: 'hello' }); const res = await request('/notes/reactions/create', { noteId: note.id, reaction: '👍' }, bob); assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.id, '20ef5475-9f38-4e4c-bd33-de6d979498ec'); + assert.strictEqual(res.body.error.code, 'BLOCKED'); })); - it('ブロックされているユーザーに返信できない', async(async () => { + it('cannot reply to blocking users note', async(async () => { const note = await post(alice, { text: 'hello' }); const res = await request('/notes/create', { replyId: note.id, text: 'yo' }, bob); assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.id, 'b390d7e1-8a5e-46ed-b625-06271cafd3d3'); + assert.strictEqual(res.body.error.code, 'BLOCKED'); })); - it('ブロックされているユーザーのノートをRenoteできない', async(async () => { + it('canot renote blocking users note', async(async () => { const note = await post(alice, { text: 'hello' }); const res = await request('/notes/create', { renoteId: note.id, text: 'yo' }, bob); assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.id, 'b390d7e1-8a5e-46ed-b625-06271cafd3d3'); + assert.strictEqual(res.body.error.code, 'BLOCKED'); })); - // TODO: ユーザーリストに入れられないテスト + it('cannot include blocked users in user lists'); - // TODO: ユーザーリストから除外されるテスト + it('removes users from user lists'); - it('タイムライン(LTL)にブロックされているユーザーの投稿が含まれない', async(async () => { + it('local timeline does not contain blocked users', async(async () => { const aliceNote = await post(alice); const bobNote = await post(bob); const carolNote = await post(carol); diff --git a/packages/backend/test/fetch-resource.ts b/packages/backend/test/fetch-resource.ts index ddb0e94b8..2bdd7a7e2 100644 --- a/packages/backend/test/fetch-resource.ts +++ b/packages/backend/test/fetch-resource.ts @@ -23,6 +23,7 @@ describe('Fetch resource', () => { let alicesPost: any; before(async () => { + this.timeout(0); p = await startServer(); alice = await signup({ username: 'alice' }); alicesPost = await post(alice, { diff --git a/packages/backend/test/ff-visibility.ts b/packages/backend/test/ff-visibility.ts index 4f6847be6..59b94e135 100644 --- a/packages/backend/test/ff-visibility.ts +++ b/packages/backend/test/ff-visibility.ts @@ -9,159 +9,110 @@ describe('FF visibility', () => { let alice: any; let bob: any; - let carol: any; + let follower: any; before(async () => { + this.timeout(0); + p = await startServer(); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); - carol = await signup({ username: 'carol' }); + follower = await signup({ username: 'follower' }); + + await request('/following/create', { userId: alice.id }, follower); }); after(async () => { await shutdownServer(p); }); - it('ffVisibility が public なユーザーのフォロー/フォロワーを誰でも見れる', async(async () => { - await request('/i/update', { - ffVisibility: 'public', - }, alice); + const visible = (user) => { + return async () => { + const followingRes = await request('/users/following', { + userId: alice.id, + }, user); + const followersRes = await request('/users/followers', { + userId: alice.id, + }, user); - const followingRes = await request('/users/following', { - userId: alice.id, - }, bob); - const followersRes = await request('/users/followers', { - userId: alice.id, - }, bob); + assert.strictEqual(followingRes.status, 200); + assert.ok(Array.isArray(followingRes.body)); + assert.strictEqual(followersRes.status, 200); + assert.ok(Array.isArray(followersRes.body)); + }; + }; - assert.strictEqual(followingRes.status, 200); - assert.strictEqual(Array.isArray(followingRes.body), true); - assert.strictEqual(followersRes.status, 200); - assert.strictEqual(Array.isArray(followersRes.body), true); - })); + const hidden = (user) => { + return async () => { + const followingRes = await request('/users/following', { + userId: alice.id, + }, user); + const followersRes = await request('/users/followers', { + userId: alice.id, + }, user); - it('ffVisibility が followers なユーザーのフォロー/フォロワーを自分で見れる', async(async () => { - await request('/i/update', { - ffVisibility: 'followers', - }, alice); + assert.strictEqual(followingRes.status, 403); + assert.strictEqual(followersRes.status, 403); + }; + }; - const followingRes = await request('/users/following', { - userId: alice.id, - }, alice); - const followersRes = await request('/users/followers', { - userId: alice.id, - }, alice); + describe('public visibility', () => { + before(async () => { + await request('/i/update', { + ffVisibility: 'public', + }, alice); + }); - assert.strictEqual(followingRes.status, 200); - assert.strictEqual(Array.isArray(followingRes.body), true); - assert.strictEqual(followersRes.status, 200); - assert.strictEqual(Array.isArray(followersRes.body), true); - })); + it('shows followers and following to self', visible(alice)); + it('shows followers and following to a follower', visible(follower)); + it('shows followers and following to a non-follower', visible(bob)); + it('shows followers and following when unauthenticated', visible(null)); - it('ffVisibility が followers なユーザーのフォロー/フォロワーを非フォロワーが見れない', async(async () => { - await request('/i/update', { - ffVisibility: 'followers', - }, alice); + it('provides followers in ActivityPub representation', async () => { + const followingRes = await simpleGet(`/users/${alice.id}/following`, 'application/activity+json'); + const followersRes = await simpleGet(`/users/${alice.id}/followers`, 'application/activity+json'); + assert.strictEqual(followingRes.status, 200); + assert.strictEqual(followersRes.status, 200); + }); + }); - const followingRes = await request('/users/following', { - userId: alice.id, - }, bob); - const followersRes = await request('/users/followers', { - userId: alice.id, - }, bob); + describe('followers visibility', () => { + before(async () => { + await request('/i/update', { + ffVisibility: 'followers', + }, alice); + }); - assert.strictEqual(followingRes.status, 400); - assert.strictEqual(followersRes.status, 400); - })); + it('shows followers and following to self', visible(alice)); + it('shows followers and following to a follower', visible(follower)); + it('hides followers and following from a non-follower', hidden(bob)); + it('hides followers and following when unauthenticated', hidden(null)); - it('ffVisibility が followers なユーザーのフォロー/フォロワーをフォロワーが見れる', async(async () => { - await request('/i/update', { - ffVisibility: 'followers', - }, alice); + it('hides followers from ActivityPub representation', async () => { + const followingRes = await simpleGet(`/users/${alice.id}/following`, 'application/activity+json').catch(res => ({ status: res.statusCode })); + const followersRes = await simpleGet(`/users/${alice.id}/followers`, 'application/activity+json').catch(res => ({ status: res.statusCode })); + assert.strictEqual(followingRes.status, 403); + assert.strictEqual(followersRes.status, 403); + }); + }); - await request('/following/create', { - userId: alice.id, - }, bob); + describe('private visibility', () => { + before(async () => { + await request('/i/update', { + ffVisibility: 'private', + }, alice); + }); - const followingRes = await request('/users/following', { - userId: alice.id, - }, bob); - const followersRes = await request('/users/followers', { - userId: alice.id, - }, bob); + it('shows followers and following to self', visible(alice)); + it('hides followers and following from a follower', hidden(follower)); + it('hides followers and following from a non-follower', hidden(bob)); + it('hides followers and following when unauthenticated', hidden(null)); - assert.strictEqual(followingRes.status, 200); - assert.strictEqual(Array.isArray(followingRes.body), true); - assert.strictEqual(followersRes.status, 200); - assert.strictEqual(Array.isArray(followersRes.body), true); - })); - - it('ffVisibility が private なユーザーのフォロー/フォロワーを自分で見れる', async(async () => { - await request('/i/update', { - ffVisibility: 'private', - }, alice); - - const followingRes = await request('/users/following', { - userId: alice.id, - }, alice); - const followersRes = await request('/users/followers', { - userId: alice.id, - }, alice); - - assert.strictEqual(followingRes.status, 200); - assert.strictEqual(Array.isArray(followingRes.body), true); - assert.strictEqual(followersRes.status, 200); - assert.strictEqual(Array.isArray(followersRes.body), true); - })); - - it('ffVisibility が private なユーザーのフォロー/フォロワーを他人が見れない', async(async () => { - await request('/i/update', { - ffVisibility: 'private', - }, alice); - - const followingRes = await request('/users/following', { - userId: alice.id, - }, bob); - const followersRes = await request('/users/followers', { - userId: alice.id, - }, bob); - - assert.strictEqual(followingRes.status, 400); - assert.strictEqual(followersRes.status, 400); - })); - - describe('AP', () => { - it('ffVisibility が public 以外ならばAPからは取得できない', async(async () => { - { - await request('/i/update', { - ffVisibility: 'public', - }, alice); - - const followingRes = await simpleGet(`/users/${alice.id}/following`, 'application/activity+json'); - const followersRes = await simpleGet(`/users/${alice.id}/followers`, 'application/activity+json'); - assert.strictEqual(followingRes.status, 200); - assert.strictEqual(followersRes.status, 200); - } - { - await request('/i/update', { - ffVisibility: 'followers', - }, alice); - - const followingRes = await simpleGet(`/users/${alice.id}/following`, 'application/activity+json').catch(res => ({ status: res.statusCode })); - const followersRes = await simpleGet(`/users/${alice.id}/followers`, 'application/activity+json').catch(res => ({ status: res.statusCode })); - assert.strictEqual(followingRes.status, 403); - assert.strictEqual(followersRes.status, 403); - } - { - await request('/i/update', { - ffVisibility: 'private', - }, alice); - - const followingRes = await simpleGet(`/users/${alice.id}/following`, 'application/activity+json').catch(res => ({ status: res.statusCode })); - const followersRes = await simpleGet(`/users/${alice.id}/followers`, 'application/activity+json').catch(res => ({ status: res.statusCode })); - assert.strictEqual(followingRes.status, 403); - assert.strictEqual(followersRes.status, 403); - } - })); + it('hides followers from ActivityPub representation', async () => { + const followingRes = await simpleGet(`/users/${alice.id}/following`, 'application/activity+json').catch(res => ({ status: res.statusCode })); + const followersRes = await simpleGet(`/users/${alice.id}/followers`, 'application/activity+json').catch(res => ({ status: res.statusCode })); + assert.strictEqual(followingRes.status, 403); + assert.strictEqual(followersRes.status, 403); + }); }); }); diff --git a/packages/backend/test/misc/mock-resolver.ts b/packages/backend/test/misc/mock-resolver.ts index ba89ac329..75b80e98c 100644 --- a/packages/backend/test/misc/mock-resolver.ts +++ b/packages/backend/test/misc/mock-resolver.ts @@ -1,4 +1,4 @@ -import Resolver from '../../src/remote/activitypub/resolver.js'; +import { Resolver } from '../../src/remote/activitypub/resolver.js'; import { IObject } from '../../src/remote/activitypub/type.js'; type MockResponse = { diff --git a/packages/backend/test/mute.ts b/packages/backend/test/mute.ts index 465633973..602623f4b 100644 --- a/packages/backend/test/mute.ts +++ b/packages/backend/test/mute.ts @@ -13,6 +13,8 @@ describe('Mute', () => { let carol: any; before(async () => { + this.timeout(0); + p = await startServer(); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); diff --git a/packages/backend/test/note.ts b/packages/backend/test/note.ts index b495d8b7b..1f7cf8f03 100644 --- a/packages/backend/test/note.ts +++ b/packages/backend/test/note.ts @@ -13,6 +13,8 @@ describe('Note', () => { let bob: any; before(async () => { + this.timeout(0); + p = await startServer(); const connection = await initTestDb(true); Notes = connection.getRepository(Note); @@ -158,7 +160,7 @@ describe('Note', () => { replyId: '000000000000000000000000', }; const res = await request('/notes/create', post, alice); - assert.strictEqual(res.status, 400); + assert.strictEqual(res.status, 404); })); it('存在しないrenote対象で怒られる', async(async () => { @@ -166,7 +168,7 @@ describe('Note', () => { renoteId: '000000000000000000000000', }; const res = await request('/notes/create', post, alice); - assert.strictEqual(res.status, 400); + assert.strictEqual(res.status, 404); })); it('不正なリプライ先IDで怒られる', async(async () => { @@ -175,7 +177,7 @@ describe('Note', () => { replyId: 'foo', }; const res = await request('/notes/create', post, alice); - assert.strictEqual(res.status, 400); + assert.strictEqual(res.status, 404); })); it('不正なrenote対象IDで怒られる', async(async () => { @@ -183,7 +185,7 @@ describe('Note', () => { renoteId: 'foo', }; const res = await request('/notes/create', post, alice); - assert.strictEqual(res.status, 400); + assert.strictEqual(res.status, 404); })); it('存在しないユーザーにメンションできる', async(async () => { @@ -286,7 +288,7 @@ describe('Note', () => { choice: 2, }, alice); - assert.strictEqual(res.status, 400); + assert.strictEqual(res.status, 409); })); it('許可されている場合は複数投票できる', async(async () => { diff --git a/packages/backend/test/services/blocking.ts b/packages/backend/test/services/blocking.ts index 41e5ef5be..2f72972f2 100644 --- a/packages/backend/test/services/blocking.ts +++ b/packages/backend/test/services/blocking.ts @@ -14,6 +14,8 @@ describe('Creating a block activity', () => { let carol: any; before(async () => { + this.timeout(0); + await initTestDb(); p = await startServer(); alice = await signup({ username: 'alice' }); diff --git a/packages/backend/test/streaming.ts b/packages/backend/test/streaming.ts index 621d07f9c..dd0e814d4 100644 --- a/packages/backend/test/streaming.ts +++ b/packages/backend/test/streaming.ts @@ -38,6 +38,8 @@ describe('Streaming', () => { let list: any; before(async () => { + this.timeout(0); + p = await startServer(); const connection = await initTestDb(true); Followings = connection.getRepository(Following); diff --git a/packages/backend/test/thread-mute.ts b/packages/backend/test/thread-mute.ts index cd3e51939..d1338fd3b 100644 --- a/packages/backend/test/thread-mute.ts +++ b/packages/backend/test/thread-mute.ts @@ -12,6 +12,8 @@ describe('Note thread mute', () => { let carol: any; before(async () => { + this.timeout(0); + p = await startServer(); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); diff --git a/packages/backend/test/user-notes.ts b/packages/backend/test/user-notes.ts index 4447754d6..31a66dbd2 100644 --- a/packages/backend/test/user-notes.ts +++ b/packages/backend/test/user-notes.ts @@ -13,6 +13,8 @@ describe('users/notes', () => { let jpgPngNote: any; before(async () => { + this.timeout(0); + p = await startServer(); alice = await signup({ username: 'alice' }); const jpg = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg'); diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts index a366547e6..59a62452c 100644 --- a/packages/backend/test/utils.ts +++ b/packages/backend/test/utils.ts @@ -31,16 +31,15 @@ export const async = (fn: Function) => (done: Function) => { export const api = async (endpoint: string, params: any, me?: any) => { endpoint = endpoint.replace(/^\//, ''); - const auth = me ? { - i: me.token - } : {}; + const auth = me ? { authorization: `Bearer ${me.token}` } : {}; const res = await got(`http://localhost:${port}/api/${endpoint}`, { method: 'POST', headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + ...auth, }, - body: JSON.stringify(Object.assign(auth, params)), + body: JSON.stringify(params), retry: { limit: 0, }, @@ -65,16 +64,15 @@ export const api = async (endpoint: string, params: any, me?: any) => { }; export const request = async (endpoint: string, params: any, me?: any): Promise<{ body: any, status: number }> => { - const auth = me ? { - i: me.token, - } : {}; + const auth = me ? { authorization: `Bearer ${me.token}` } : {}; const res = await fetch(`http://localhost:${port}/api${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json', + ...auth, }, - body: JSON.stringify(Object.assign(auth, params)), + body: JSON.stringify(params), }); const status = res.status; diff --git a/packages/client/src/account.ts b/packages/client/src/account.ts index 88fc8387d..491755373 100644 --- a/packages/client/src/account.ts +++ b/packages/client/src/account.ts @@ -3,9 +3,9 @@ import * as foundkey from 'foundkey-js'; import { showSuspendedDialog } from '@/scripts/show-suspended-dialog'; import { i18n } from '@/i18n'; import { del, get, set } from '@/scripts/idb-proxy'; -import { apiUrl } from '@/config'; import { waiting, api, popup, popupMenu, success, alert } from '@/os'; import { unisonReload, reloadChannel } from '@/scripts/unison-reload'; +import { MenuItem } from '@/types/menu'; // TODO: 他のタブと永続化されたstateを同期 @@ -22,7 +22,7 @@ export async function signout() { waiting(); localStorage.removeItem('account'); - await removeAccount($i.id); + if ($i) await removeAccount($i!.id); const accounts = await getAccounts(); @@ -78,7 +78,7 @@ function fetchAccount(token: string): Promise { api('i', {}, token) .then(res => { if (res.error) { - if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') { + if (res.error.code === 'SUSPENDED') { showSuspendedDialog().then(() => { signout(); }); @@ -99,14 +99,18 @@ function fetchAccount(token: string): Promise { } export function updateAccount(accountData) { + if (!$i) return; + for (const [key, value] of Object.entries(accountData)) { - $i[key] = value; + $i![key] = value; } - localStorage.setItem('account', JSON.stringify($i)); + localStorage.setItem('account', JSON.stringify($i!)); } export function refreshAccount() { - return fetchAccount($i.token).then(updateAccount); + if (!$i) return; + + return fetchAccount($i!.token).then(updateAccount); } export async function login(token: Account['token'], redirect?: string) { @@ -134,7 +138,39 @@ export async function openAccountMenu(opts: { active?: foundkey.entities.UserDetailed['id']; onChoose?: (account: foundkey.entities.UserDetailed) => void; }, ev: MouseEvent) { - function showSigninDialog() { + const storedAccounts = await getAccounts().then(accounts => accounts.filter(x => x.id !== $i?.id)); + const accountsPromise = api('users/show', { userIds: storedAccounts.map(x => x.id) }); + + const switchAccount = async (account: foundkey.entities.UserDetailed) => { + const storedAccounts = await getAccounts(); + const token = storedAccounts.find(x => x.id === account.id)?.token; + if (!token) { + // TODO error handling? + } else { + login(token); + } + }; + const createItem = (account: foundkey.entities.UserDetailed): MenuItem => ({ + type: 'user', + user: account, + active: opts.active != null ? opts.active === account.id : false, + action: () => { + if (opts.onChoose) { + opts.onChoose(account); + } else { + switchAccount(account); + } + }, + }); + const accountItemPromises: Promise = storedAccounts.map(a => new Promise(res => { + accountsPromise.then(accounts => { + const account = accounts.find(x => x.id === a.id); + if (account == null) return res(null); + res(createItem(account)); + }); + })); + + const showSigninDialog = () => { popup(defineAsyncComponent(() => import('@/components/signin-dialog.vue')), {}, { done: res => { addAccount(res.id, res.i); @@ -143,50 +179,14 @@ export async function openAccountMenu(opts: { }, 'closed'); } - function createAccount() { + const createAccount = () => { popup(defineAsyncComponent(() => import('@/components/signup-dialog.vue')), {}, { done: res => { addAccount(res.id, res.i); - switchAccountWithToken(res.i); + login(res.i); }, }, 'closed'); - } - - async function switchAccount(account: foundkey.entities.UserDetailed) { - const storedAccounts = await getAccounts(); - const token = storedAccounts.find(x => x.id === account.id).token; - switchAccountWithToken(token); - } - - function switchAccountWithToken(token: string) { - login(token); - } - - const storedAccounts = await getAccounts().then(accounts => accounts.filter(x => x.id !== $i.id)); - const accountsPromise = api('users/show', { userIds: storedAccounts.map(x => x.id) }); - - function createItem(account: foundkey.entities.UserDetailed) { - return { - type: 'user', - user: account, - active: opts.active != null ? opts.active === account.id : false, - action: () => { - if (opts.onChoose) { - opts.onChoose(account); - } else { - switchAccount(account); - } - }, - }; - } - - const accountItemPromises = storedAccounts.map(a => new Promise(res => { - accountsPromise.then(accounts => { - const account = accounts.find(x => x.id === a.id); - if (account == null) return res(null); - res(createItem(account)); - }); - })); + }; if (opts.withExtraOperation) { popupMenu([...[{ @@ -194,16 +194,16 @@ export async function openAccountMenu(opts: { text: i18n.ts.profile, to: `/@${ $i.username }`, avatar: $i, - }, null, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, { + }, null, ...(opts.includeCurrentAccount && $i ? [createItem($i)] : []), ...accountItemPromises, { icon: 'fas fa-plus', text: i18n.ts.addAccount, action: () => { popupMenu([{ text: i18n.ts.existingAccount, - action: () => { showSigninDialog(); }, + action: showSigninDialog, }, { text: i18n.ts.createAccount, - action: () => { createAccount(); }, + action: createAccount, }], ev.currentTarget ?? ev.target); }, }, { @@ -211,11 +211,11 @@ export async function openAccountMenu(opts: { icon: 'fas fa-users', text: i18n.ts.manageAccounts, to: '/settings/accounts', - }]], ev.currentTarget ?? ev.target, { + }]], ev.currentTarget ?? ev.target ?? undefined, { align: 'left', }); } else { - popupMenu([...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises], ev.currentTarget ?? ev.target, { + popupMenu([...(opts.includeCurrentAccount && $i ? [createItem($i)] : []), ...accountItemPromises], ev.currentTarget ?? ev.target ?? undefined, { align: 'left', }); } diff --git a/packages/client/src/components/drive.file.vue b/packages/client/src/components/drive.file.vue index f6713947e..9fa490f01 100644 --- a/packages/client/src/components/drive.file.vue +++ b/packages/client/src/components/drive.file.vue @@ -85,6 +85,11 @@ function getMenu(): MenuItem[] { text: i18n.ts.download, icon: 'fas fa-download', download: props.file.name, + }, { + type: 'link', + to: `/my/drive/file/${props.file.id}/attached`, + text: i18n.ts.showAttachedNotes, + icon: 'fas fa-paperclip', }, null, { text: i18n.ts.delete, icon: 'fas fa-trash-alt', @@ -313,8 +318,8 @@ async function deleteFile(): Promise { } > .thumbnail { - width: 110px; - height: 110px; + width: 8em; + height: 8em; margin: auto; } diff --git a/packages/client/src/components/drive.folder.vue b/packages/client/src/components/drive.folder.vue index c613571f5..40526afd8 100644 --- a/packages/client/src/components/drive.folder.vue +++ b/packages/client/src/components/drive.folder.vue @@ -207,8 +207,8 @@ function deleteFolder() { defaultStore.set('uploadFolder', null); } }).catch(err => { - switch (err.id) { - case 'b0fc8a17-963c-405d-bfbc-859a487295e1': + switch (err.code) { + case 'HAS_CHILD_FILES_OR_FOLDERS': os.alert({ type: 'error', title: i18n.ts.unableToDelete, @@ -259,8 +259,9 @@ function onContextmenu(ev: MouseEvent) { } > .thumbnail { - width: 110px; - height: 110px; + /* same style as drive.file.vue */ + width: 8em; + height: 8em; margin: auto; /* same style as drive-file-thumbnail.vue */ diff --git a/packages/client/src/components/drive.vue b/packages/client/src/components/drive.vue index d39e256f8..4f3bd6acf 100644 --- a/packages/client/src/components/drive.vue +++ b/packages/client/src/components/drive.vue @@ -24,7 +24,8 @@ {{ folder.name }} - + +
+ - -
@@ -90,6 +102,9 @@ import XFolder from './drive.folder.vue'; import XFile from './drive.file.vue'; import MkButton from './ui/button.vue'; import MkPagination from './ui/pagination.vue'; +import FormFolder from '@/components/form/folder.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormInput from '@/components/form/input.vue'; import * as os from '@/os'; import { stream } from '@/stream'; import { defaultStore } from '@/store'; @@ -114,7 +129,6 @@ const emit = defineEmits<{ }>(); let paginationElem = $ref>(); - let fileInput = $ref(); const uploadings = uploads; @@ -124,6 +138,9 @@ let folder = $ref(null); let hierarchyFolders = $ref([]); let selected = $ref>([]); let keepOriginal = $ref(defaultStore.state.keepOriginalUploading); +let searchName = $ref(''); +let sort = $ref(undefined); +let showSearch = $ref(false); // ドロップされようとしているか let draghover = $ref(false); @@ -141,40 +158,6 @@ watch($$(folder), () => { } }); -function onStreamDriveFileCreated(file: foundkey.entities.DriveFile) { - addFile(file, true); -} - -function onStreamDriveFileUpdated(file: foundkey.entities.DriveFile) { - const current = folder?.id ?? null; - if (current !== file.folderId) { - removeFile(file); - } else { - addFile(file, true); - } -} - -function onStreamDriveFileDeleted(fileId: string) { - removeFile(fileId); -} - -function onStreamDriveFolderCreated(createdFolder: foundkey.entities.DriveFolder) { - addFolder(createdFolder, true); -} - -function onStreamDriveFolderUpdated(updatedFolder: foundkey.entities.DriveFolder) { - const current = folder?.id ?? null; - if (current !== updatedFolder.parentId) { - removeFolder(updatedFolder); - } else { - addFolder(updatedFolder, true); - } -} - -function onStreamDriveFolderDeleted(folderId: string) { - removeFolder(folderId); -} - function onDragover(ev: DragEvent): any { if (!ev.dataTransfer) return; @@ -258,8 +241,8 @@ function onDrop(ev: DragEvent): any { folderId: droppedFolder.id, parentId: folder?.id ?? null, }).catch(err => { - switch (err) { - case 'detected-circular-definition': + switch (err.code) { + case 'RECURSIVE_FOLDER': os.alert({ title: i18n.ts.unableToProcess, text: i18n.ts.circularReferenceFolder, @@ -335,8 +318,8 @@ function deleteFolder(folderToDelete: foundkey.entities.DriveFolder) { // 削除時に親フォルダに移動 move(folderToDelete.parentId); }).catch(err => { - switch (err.id) { - case 'b0fc8a17-963c-405d-bfbc-859a487295e1': + switch (err.code) { + case 'HAS_CHILD_FILES_OR_FOLDERS': os.alert({ type: 'error', title: i18n.ts.unableToDelete, @@ -433,31 +416,16 @@ function move(target?: string | foundkey.entities.DriveFolder) { }); } -function addFolder(folderToAdd: foundkey.entities.DriveFolder, unshift = false) { +function addOrUpdate(itemToAdd: foundkey.entities.DriveFolder | foundkey.entities.DriveFile) { const current = folder?.id ?? null; - if (current !== folderToAdd.parentId) return; + const addInto = 'parentId' in itemToAdd ? itemToAdd.parentId : itemToAdd.folderId; + if (current !== addInto) return; - const exist = paginationElem.items.some(f => f.id === folderToAdd.id); + const exist = paginationElem.items.some(f => f.id === itemToAdd.id); if (exist) { - paginationElem.updateItem(folderToAdd.id, () => folderToAdd); - } else if (unshift) { - paginationElem.prepend(folderToAdd); + paginationElem.updateItem(itemToAdd.id, () => itemToAdd); } else { - paginationElem.append(folderToAdd); - } -} - -function addFile(fileToAdd: foundkey.entities.DriveFile, unshift = false) { - const current = folder?.id ?? null; - if (current !== fileToAdd.folderId) return; - - const exist = paginationElem.items.some(f => f.id === fileToAdd.id); - if (exist) { - paginationElem.updateItem(fileToAdd.id, () => fileToAdd); - } else if (unshift) { - paginationElem.prepend(fileToAdd); - } else { - paginationElem.append(fileToAdd); + paginationElem.prepend(itemToAdd); } } @@ -483,7 +451,10 @@ function goRoot() { const pagination = { endpoint: 'drive/show' as const, limit: 30, + offsetMode: true, params: computed(() => ({ + sort, + name: searchName, folderId: folder?.id ?? null, })), }; @@ -531,12 +502,12 @@ function onContextmenu(ev: MouseEvent) { } onMounted(() => { - connection.on('fileCreated', onStreamDriveFileCreated); - connection.on('fileUpdated', onStreamDriveFileUpdated); - connection.on('fileDeleted', onStreamDriveFileDeleted); - connection.on('folderCreated', onStreamDriveFolderCreated); - connection.on('folderUpdated', onStreamDriveFolderUpdated); - connection.on('folderDeleted', onStreamDriveFolderDeleted); + connection.on('fileCreated', addOrUpdate); + connection.on('fileUpdated', addOrUpdate); + connection.on('fileDeleted', removeFile); + connection.on('folderCreated', addOrUpdate); + connection.on('folderUpdated', addOrUpdate); + connection.on('folderDeleted', removeFolder); if (props.initialFolder) { move(props.initialFolder); @@ -615,6 +586,10 @@ onBeforeUnmount(() => { margin-left: auto; padding: 0 12px; } + + > .showSearch { + color: var(--accent); + } } > .main { @@ -630,13 +605,21 @@ onBeforeUnmount(() => { height: calc(100% - 38px - 100px); } - > .contents { - display: grid; - grid-template-columns: repeat(5, 1fr); - gap: .5em; + .search { + margin-bottom: var(--margin); } - > .empty { + .contents { + display: flex; + flex-flow: row wrap; + + > * { + width: 8em; + margin: var(--margin); + } + } + + .empty { padding: 16px; text-align: center; pointer-events: none; diff --git a/packages/client/src/components/global/a.vue b/packages/client/src/components/global/a.vue index 5e1ce811f..ac28f589f 100644 --- a/packages/client/src/components/global/a.vue +++ b/packages/client/src/components/global/a.vue @@ -65,8 +65,11 @@ function onContextmenu(ev) { }], ev); } -function nav() { - if (props.behavior === 'browser') { +function nav(evt: MouseEvent) { + if (evt.ctrlKey || evt.buttons === 4) { + // held Control or clicked with middle mouse button + window.open(props.to, '_blank'); + } else if (props.behavior === 'browser') { location.href = props.to; } else if (props.behavior === 'window') { os.pageWindow(props.to); diff --git a/packages/client/src/components/mfm.ts b/packages/client/src/components/mfm.ts index ef083eb4d..1107edcd6 100644 --- a/packages/client/src/components/mfm.ts +++ b/packages/client/src/components/mfm.ts @@ -44,18 +44,19 @@ export default defineComponent({ const ast = (this.plain ? mfm.parsePlain : mfm.parse)(this.text, { fnNameList: MFM_TAGS }); - const validTime = (t: string | null | undefined) => { - if (t == null) return null; + const validTime = (t: string | true) => { + if (typeof t !== 'string') return null; + return t.match(/^[0-9.]+s$/) ? t : null; }; - const genEl = (ast: mfm.MfmNode[]) => ast.map((token): VNode[] => { + const genEl = (ast: mfm.MfmNode[]) => ast.map((token): VNode | VNode[] => { switch (token.type) { case 'text': { const text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n'); if (!this.plain) { - const res = []; + const res: VNode[] = []; for (const t of text.split('\n')) { res.push(h('br')); res.push(t); @@ -63,16 +64,16 @@ export default defineComponent({ res.shift(); return res; } else { - return [text.replace(/\n/g, ' ')]; + return text.replace(/\n/g, ' '); } } case 'bold': { - return [h('b', genEl(token.children))]; + return h('b', genEl(token.children)); } case 'strike': { - return [h('del', genEl(token.children))]; + return h('del', genEl(token.children)); } case 'italic': { @@ -180,7 +181,7 @@ export default defineComponent({ return h(MkSparkle, {}, genEl(token.children)); } case 'rotate': { - const degrees = parseInt(token.props.args.deg) || '90'; + const degrees = (typeof token.props.args.deg === 'string' ? parseInt(token.props.args.deg) : null) || '90'; style = `transform: rotate(${degrees}deg); transform-origin: center center;`; break; } @@ -195,116 +196,110 @@ export default defineComponent({ } case 'small': { - return [h('small', { + return h('small', { style: 'opacity: 0.7;', - }, genEl(token.children))]; + }, genEl(token.children)); } case 'center': { - return [h('div', { + return h('div', { style: 'text-align:center;', - }, genEl(token.children))]; + }, genEl(token.children)); } case 'url': { - return [h(MkUrl, { + return h(MkUrl, { key: Math.random(), url: token.props.url, rel: 'nofollow noopener', - })]; + }); } case 'link': { - return [h(MkLink, { + return h(MkLink, { key: Math.random(), url: token.props.url, rel: 'nofollow noopener', - }, genEl(token.children))]; + }, genEl(token.children)); } case 'mention': { - return [h(MkMention, { + return h(MkMention, { key: Math.random(), host: (token.props.host == null && this.author && this.author.host != null ? this.author.host : token.props.host) || host, username: token.props.username, - })]; + }); } case 'hashtag': { - return [h(MkA, { + return h(MkA, { key: Math.random(), to: this.isNote ? `/tags/${encodeURIComponent(token.props.hashtag)}` : `/explore/tags/${encodeURIComponent(token.props.hashtag)}`, style: 'color:var(--hashtag);', - }, `#${token.props.hashtag}`)]; + }, `#${token.props.hashtag}`); } case 'blockCode': { - return [h(MkCode, { + return h(MkCode, { key: Math.random(), code: token.props.code, lang: token.props.lang, - })]; + }); } case 'inlineCode': { - return [h(MkCode, { + return h(MkCode, { key: Math.random(), code: token.props.code, inline: true, - })]; + }); } case 'quote': { - if (!this.nowrap) { - return [h('div', { - class: 'quote', - }, genEl(token.children))]; - } else { - return [h('span', { - class: 'quote', - }, genEl(token.children))]; - } + return h(this.nowrap ? 'span' : 'div', { + class: 'quote', + }, genEl(token.children)); } case 'emojiCode': { - return [h(MkEmoji, { + return h(MkEmoji, { key: Math.random(), emoji: `:${token.props.name}:`, customEmojis: this.customEmojis, normal: this.plain, - })]; + }); } case 'unicodeEmoji': { - return [h(MkEmoji, { + return h(MkEmoji, { key: Math.random(), emoji: token.props.emoji, customEmojis: this.customEmojis, normal: this.plain, - })]; + }); } case 'mathInline': { - return [h(MkFormula, { + return h(MkFormula, { key: Math.random(), formula: token.props.formula, block: false, - })]; + }); } case 'mathBlock': { - return [h(MkFormula, { + return h(MkFormula, { key: Math.random(), formula: token.props.formula, block: true, - })]; + }); } case 'search': { - return [h(MkSearch, { + return h(MkSearch, { key: Math.random(), q: token.props.query, - })]; + }); } default: { diff --git a/packages/client/src/components/notification.vue b/packages/client/src/components/notification.vue index 43ceb38a4..197711426 100644 --- a/packages/client/src/components/notification.vue +++ b/packages/client/src/components/notification.vue @@ -15,6 +15,7 @@ + {{ i18n.ts.reject }} + + + + +
+
diff --git a/packages/client/src/components/signin.vue b/packages/client/src/components/signin.vue index 42f5bf8f0..8f0c5fe17 100644 --- a/packages/client/src/components/signin.vue +++ b/packages/client/src/components/signin.vue @@ -185,8 +185,8 @@ function onSubmit() { } function loginFailed(err) { - switch (err.id) { - case '6cc579cc-885d-43d8-95c2-b8c7fc963280': { + switch (err.code) { + case 'NO_SUCH_USER': { os.alert({ type: 'error', title: i18n.ts.loginFailed, @@ -194,7 +194,7 @@ function loginFailed(err) { }); break; } - case '932c904e-9460-45b7-9ce6-7ed33be7eb2c': { + case 'ACCESS_DENIED': { os.alert({ type: 'error', title: i18n.ts.loginFailed, @@ -202,11 +202,11 @@ function loginFailed(err) { }); break; } - case 'e03a5f46-d309-4865-9b69-56282d94e1eb': { + case 'SUSPENDED': { showSuspendedDialog(); break; } - case '22d05606-fbcf-421a-a2db-b32610dcfd1b': { + case 'RATE_LIMIT_EXCEEDED': { os.alert({ type: 'error', title: i18n.ts.loginFailed, diff --git a/packages/client/src/components/timeline.vue b/packages/client/src/components/timeline.vue index c3ad04c16..147c0c8a0 100644 --- a/packages/client/src/components/timeline.vue +++ b/packages/client/src/components/timeline.vue @@ -10,11 +10,12 @@ import * as sound from '@/scripts/sound'; import { $i } from '@/account'; const props = defineProps<{ - src: string; + src: 'antenna' | 'home' | 'local' | 'social' | 'global' | 'mentions' | 'directs' | 'list' | 'channel' | 'file'; list?: string; antenna?: string; channel?: string; sound?: boolean; + fileId?: string; }>(); const emit = defineEmits<{ @@ -55,71 +56,86 @@ let query; let connection; let connection2; -if (props.src === 'antenna') { - endpoint = 'antennas/notes'; - query = { - antennaId: props.antenna, - }; - connection = stream.useChannel('antenna', { - antennaId: props.antenna, - }); - connection.on('note', prepend); -} else if (props.src === 'home') { - endpoint = 'notes/timeline'; - connection = stream.useChannel('homeTimeline'); - connection.on('note', prepend); +switch (props.src) { + case 'antenna': + endpoint = 'antennas/notes'; + query = { + antennaId: props.antenna, + }; + connection = stream.useChannel('antenna', { + antennaId: props.antenna, + }); + connection.on('note', prepend); + break; + case 'home': + endpoint = 'notes/timeline'; + connection = stream.useChannel('homeTimeline'); + connection.on('note', prepend); - connection2 = stream.useChannel('main'); - connection2.on('follow', onChangeFollowing); - connection2.on('unfollow', onChangeFollowing); -} else if (props.src === 'local') { - endpoint = 'notes/local-timeline'; - connection = stream.useChannel('localTimeline'); - connection.on('note', prepend); -} else if (props.src === 'social') { - endpoint = 'notes/hybrid-timeline'; - connection = stream.useChannel('hybridTimeline'); - connection.on('note', prepend); -} else if (props.src === 'global') { - endpoint = 'notes/global-timeline'; - connection = stream.useChannel('globalTimeline'); - connection.on('note', prepend); -} else if (props.src === 'mentions') { - endpoint = 'notes/mentions'; - connection = stream.useChannel('main'); - connection.on('mention', prepend); -} else if (props.src === 'directs') { - endpoint = 'notes/mentions'; - query = { - visibility: 'specified', - }; - const onNote = note => { - if (note.visibility === 'specified') { - prepend(note); - } - }; - connection = stream.useChannel('main'); - connection.on('mention', onNote); -} else if (props.src === 'list') { - endpoint = 'notes/user-list-timeline'; - query = { - listId: props.list, - }; - connection = stream.useChannel('userList', { - listId: props.list, - }); - connection.on('note', prepend); - connection.on('userAdded', onUserAdded); - connection.on('userRemoved', onUserRemoved); -} else if (props.src === 'channel') { - endpoint = 'channels/timeline'; - query = { - channelId: props.channel, - }; - connection = stream.useChannel('channel', { - channelId: props.channel, - }); - connection.on('note', prepend); + connection2 = stream.useChannel('main'); + connection2.on('follow', onChangeFollowing); + connection2.on('unfollow', onChangeFollowing); + break; + case 'local': + endpoint = 'notes/local-timeline'; + connection = stream.useChannel('localTimeline'); + connection.on('note', prepend); + break; + case 'social': + endpoint = 'notes/hybrid-timeline'; + connection = stream.useChannel('hybridTimeline'); + connection.on('note', prepend); + break; + case 'global': + endpoint = 'notes/global-timeline'; + connection = stream.useChannel('globalTimeline'); + connection.on('note', prepend); + break; + case 'mentions': + endpoint = 'notes/mentions'; + connection = stream.useChannel('main'); + connection.on('mention', prepend); + break; + case 'directs': + endpoint = 'notes/mentions'; + query = { + visibility: 'specified', + }; + connection = stream.useChannel('main'); + connection.on('mention', note => { + if (note.visibility === 'specified') { + prepend(note); + } + }); + break; + case 'list': + endpoint = 'notes/user-list-timeline'; + query = { + listId: props.list, + }; + connection = stream.useChannel('userList', { + listId: props.list, + }); + connection.on('note', prepend); + connection.on('userAdded', onUserAdded); + connection.on('userRemoved', onUserRemoved); + break; + case 'channel': + endpoint = 'channels/timeline'; + query = { + channelId: props.channel, + }; + connection = stream.useChannel('channel', { + channelId: props.channel, + }); + connection.on('note', prepend); + break; + case 'file': + endpoint = 'drive/files/attached-notes'; + query = { + fileId: props.fileId, + }; + break; } const pagination = { diff --git a/packages/client/src/components/ui/pagination.vue b/packages/client/src/components/ui/pagination.vue index 776794d32..3df9680ea 100644 --- a/packages/client/src/components/ui/pagination.vue +++ b/packages/client/src/components/ui/pagination.vue @@ -253,7 +253,7 @@ onDeactivated(() => { }); defineExpose({ - items, + items: $$(items), queue, backed, reload, diff --git a/packages/client/src/directives/get-size.ts b/packages/client/src/directives/get-size.ts index 575b4ce3c..74765ae03 100644 --- a/packages/client/src/directives/get-size.ts +++ b/packages/client/src/directives/get-size.ts @@ -43,7 +43,7 @@ export default { calc(src); }, - unmounted(src) { + unmounted(src, binding) { binding.value(0, 0); const info = mountings.get(src); if (!info) return; diff --git a/packages/client/src/directives/tooltip.ts b/packages/client/src/directives/tooltip.ts index 44511fb3e..80fb46b1f 100644 --- a/packages/client/src/directives/tooltip.ts +++ b/packages/client/src/directives/tooltip.ts @@ -10,7 +10,7 @@ class TooltipDirective { public text: string | null; private asMfm: boolean; - private _close: null | () => void; + private _close: null | (() => void); private showTimer: null | ReturnType; private hideTimer: null | ReturnType; @@ -23,7 +23,7 @@ class TooltipDirective { this.hideTimer = null; } - private close(): void { + public close(): void { if (this.hideTimer != null) return; // already closed or closing // cancel any pending attempts to show @@ -96,7 +96,7 @@ export default { const end = isTouchUsing ? 'touchend' : 'mouseleave'; el.addEventListener(start, () => self.show(el), { passive: true }); el.addEventListener(end, () => self.close(), { passive: true }); - el.addEventListener('click', self.close()); + el.addEventListener('click', () => self.close()); el.addEventListener('selectstart', ev => ev.preventDefault()); }, diff --git a/packages/client/src/emojilist.json b/packages/client/src/emojilist.json index 1083ab595..5988f371f 100644 --- a/packages/client/src/emojilist.json +++ b/packages/client/src/emojilist.json @@ -586,7 +586,7 @@ { "category": "animals_and_nature", "char": "🦤", "name": "dodo", "keywords": ["nature", "animal"] }, { "category": "animals_and_nature", "char": "🪶", "name": "feather", "keywords": ["nature", "animal"] }, { "category": "animals_and_nature", "char": "🦭", "name": "seal", "keywords": ["nature", "animal"] }, - { "category": "animals_and_nature", "char": "🐾", "name": "paw_prints", "keywords": ["cat", "dog", "pet", "feet", "tracking", "footprints", "animal"] }, + { "category": "animals_and_nature", "char": "🐾", "name": "paw_prints", "keywords": ["cat", "dog", "pet", "feet", "tracking", "footprints", "animal", "tlap"] }, { "category": "animals_and_nature", "char": "🐉", "name": "dragon", "keywords": ["myth", "green", "nature", "chinese", "animal"] }, { "category": "animals_and_nature", "char": "🐲", "name": "dragon_face", "keywords": ["myth", "green", "nature", "chinese", "animal"] }, { "category": "animals_and_nature", "char": "🦧", "name": "orangutan", "keywords": ["nature", "animal"] }, diff --git a/packages/client/src/os.ts b/packages/client/src/os.ts index 24f2659de..c465d3211 100644 --- a/packages/client/src/os.ts +++ b/packages/client/src/os.ts @@ -26,7 +26,7 @@ export const api = ((endpoint: string, data: Record = {}, token?: s const authorizationToken = token ?? $i?.token ?? undefined; const authorization = authorizationToken ? `Bearer ${authorizationToken}` : undefined; - const promise = new Promise((resolve, reject) => { + const promise = new Promise((resolve, reject) => { fetch(endpoint.indexOf('://') > -1 ? endpoint : `${apiUrl}/${endpoint}`, { method: 'POST', body: JSON.stringify(data), @@ -66,7 +66,7 @@ export const apiGet = ((endpoint: string, data: Record = {}, token? const authorizationToken = token ?? $i?.token ?? undefined; const authorization = authorizationToken ? `Bearer ${authorizationToken}` : undefined; - const promise = new Promise((resolve, reject) => { + const promise = new Promise((resolve, reject) => { // Send request fetch(`${apiUrl}/${endpoint}?${query}`, { method: 'GET', @@ -103,7 +103,7 @@ export const apiWithDialog = (( promiseDialog(promise, null, (err) => { alert({ type: 'error', - text: (err.message + '\n' + (err?.endpoint ?? '') + ' ' + (err?.code ?? '')).trim(), + text: (err.message + '\n' + (err.endpoint ?? '') + ' ' + (err.code ?? '')).trim(), }); }); @@ -293,9 +293,7 @@ export function inputDate(props: { text?: string | null; placeholder?: string | null; default?: Date | null; -}): Promise<{ canceled: true; result: undefined; } | { - canceled: false; result: Date; -}> { +}): Promise<{ canceled: true; } | { canceled: false; result: Date; }> { return new Promise((resolve) => { popup(defineAsyncComponent(() => import('@/components/dialog.vue')), { title: props.title, @@ -351,7 +349,7 @@ export function select(props: { } export function success() { - return new Promise((resolve) => { + return new Promise((resolve) => { const showing = ref(true); window.setTimeout(() => { showing.value = false; @@ -366,7 +364,7 @@ export function success() { } export function waiting() { - return new Promise((resolve) => { + return new Promise((resolve) => { const showing = ref(true); popup(defineAsyncComponent(() => import('@/components/waiting-dialog.vue')), { success: false, @@ -571,17 +569,3 @@ export function post(props: Record = {}) { } export const deckGlobalEvents = new EventEmitter(); - -/* -export function checkExistence(fileData: ArrayBuffer): Promise { - return new Promise((resolve) => { - const data = new FormData(); - data.append('md5', getMD5(fileData)); - - os.api('drive/files/find-by-hash', { - md5: getMD5(fileData) - }).then(resp => { - resolve(resp.length > 0 ? resp[0] : null); - }); - }); -}*/ diff --git a/packages/client/src/pages/attached-files.vue b/packages/client/src/pages/attached-files.vue new file mode 100644 index 000000000..66830ec3f --- /dev/null +++ b/packages/client/src/pages/attached-files.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/packages/client/src/pages/page-editor.vue b/packages/client/src/pages/page-editor.vue index 6f0af17ee..9763e6466 100644 --- a/packages/client/src/pages/page-editor.vue +++ b/packages/client/src/pages/page-editor.vue @@ -115,7 +115,7 @@ function save() { const options = getSaveOptions(); const onError = err => { - if (err.id === '3d81ceae-475f-4600-b2a8-2bc116157532') { + if (err.code === 'INVALID_PARAM') { if (err.info.param === 'name') { os.alert({ type: 'error', diff --git a/packages/client/src/pages/theme-editor.vue b/packages/client/src/pages/theme-editor.vue index 4441c24dc..22688fab9 100644 --- a/packages/client/src/pages/theme-editor.vue +++ b/packages/client/src/pages/theme-editor.vue @@ -87,7 +87,6 @@ import * as os from '@/os'; import { ColdDeviceStorage, defaultStore } from '@/store'; import { addTheme } from '@/theme-store'; import { i18n } from '@/i18n'; -import { useLeaveGuard } from '@/scripts/use-leave-guard'; import { definePageMetadata } from '@/scripts/page-metadata'; const bgColors = [ @@ -125,9 +124,6 @@ let theme = $ref>({ }); let description = $ref(null); let themeCode = $ref(null); -let changed = $ref(false); - -useLeaveGuard($$(changed)); function showPreview() { os.pageWindow('/preview'); @@ -162,7 +158,6 @@ function setFgColor(color) { function apply() { themeCode = JSON5.stringify(theme, null, '\t'); applyTheme(theme, false); - changed = true; } function applyThemeCode() { @@ -199,7 +194,6 @@ async function saveAs() { } else { ColdDeviceStorage.set('lightTheme', theme); } - changed = false; os.alert({ type: 'success', text: i18n.t('_theme.installed', { name: theme.name }), diff --git a/packages/client/src/pages/user/home.vue b/packages/client/src/pages/user/home.vue index 2fcee6987..9236d1905 100644 --- a/packages/client/src/pages/user/home.vue +++ b/packages/client/src/pages/user/home.vue @@ -9,6 +9,16 @@
+ + + + + +