Merge branch 'develop'

This commit is contained in:
syuilo 2023-01-21 18:45:50 +09:00
commit 38fde26d60
101 changed files with 3206 additions and 271 deletions

View File

@ -16,16 +16,16 @@ jobs:
uses: actions/checkout@v3.3.0 uses: actions/checkout@v3.3.0
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@v3 uses: docker/metadata-action@v4
with: with:
images: misskey/misskey images: misskey/misskey
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@v1 uses: docker/login-action@v2
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and Push to Docker Hub - name: Build and Push to Docker Hub
uses: docker/build-push-action@v2 uses: docker/build-push-action@v3
with: with:
context: . context: .
push: true push: true

View File

@ -15,7 +15,7 @@ jobs:
uses: actions/checkout@v3.3.0 uses: actions/checkout@v3.3.0
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@v3 uses: docker/metadata-action@v4
with: with:
images: misskey/misskey images: misskey/misskey
tags: | tags: |
@ -26,12 +26,12 @@ jobs:
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}} type=semver,pattern={{major}}
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@v1 uses: docker/login-action@v2
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and Push to Docker Hub - name: Build and Push to Docker Hub
uses: docker/build-push-action@v2 uses: docker/build-push-action@v3
with: with:
context: . context: .
push: true push: true

View File

@ -9,6 +9,23 @@
You should also include the user name that made the change. You should also include the user name that made the change.
--> -->
## 13.1.0 (2023/01/21)
### Improvements
- 実績機能
- Playのプリセットを追加
- Playのscriptの文字数制限を緩和
- AiScript GUIの強化
- リアクション一覧詳細ダイアログを表示できるように
- 存在しないカスタム絵文字をテキストで表示するように
- Alt text in image viewer
- ジョブキューのプロセスとWebサーバーのプロセスを分離
### Bugfixes
- playを削除する手段がなかったのを修正
- The … button on notes does nothing when not logged in
- twitterと連携するときに autwh is not a function になるのを修正
## 13.0.0 (2023/01/16) ## 13.0.0 (2023/01/16)
### TL;DR ### TL;DR
@ -32,13 +49,16 @@ You should also include the user name that made the change.
- Elasticsearchのサポートが削除されました - Elasticsearchのサポートが削除されました
- 代わりに今後任意の検索プロバイダを設定できる仕組みを構想しています。その仕組みを使えば今まで通りElasticsearchも利用できます - 代わりに今後任意の検索プロバイダを設定できる仕組みを構想しています。その仕組みを使えば今まで通りElasticsearchも利用できます
- Yarnからpnpmに移行されました - Yarnからpnpmに移行されました
corepackの有効化を推奨します: `sudo corepack enable`
- インスタンスブロックはサブドメインにも適用されるようになります - インスタンスブロックはサブドメインにも適用されるようになります
- ロールの導入に伴い、いくつかの機能がロールと統合されました - ロールの導入に伴い、いくつかの機能がロールと統合されました
- モデレーターはロールに統合されました。今までのモデレーター情報は失われるため、予めモデレーター一覧を記録しておき、アップデート後にモデレーターロールを作りアサインし直してください。 - モデレーターはロールに統合されました。今までのモデレーター情報は失われるため、予めモデレーター一覧を記録しておき、アップデート後にモデレーターロールを作りアサインし直してください。
- サイレンスはロールに統合されました。今までのユーザーは恩赦されるため、予めサイレンス一覧を記録しておくのをおすすめします。 - サイレンスはロールに統合されました。今までのユーザーは恩赦されるため、予めサイレンス一覧を記録しておくのをおすすめします。
- ユーザーごとのドライブ容量設定はロールに統合されました。 - ユーザーごとのドライブ容量設定はロールに統合されました。
- インスタンスデフォルトのドライブ容量設定はロールに統合されました。アップデート後、ベースロールドライブ容量を編集してください。 - インスタンスデフォルトのドライブ容量設定はロールに統合されました。アップデート後、ベースロールもしくはコンディショナルロールでドライブ容量を編集してください。
- LTL/GTLの解放状態はロールに統合されました。 - LTL/GTLの解放状態はロールに統合されました。
- Dockerの実行をrootで行わないようにしました。Dockerかつオブジェクトストレージを使用していない場合は`chown -hR 991.991 ./files`を実行してください。
https://github.com/misskey-dev/misskey/pull/9560
#### For users #### For users
- ノートのウォッチ機能が削除されました - ノートのウォッチ機能が削除されました

View File

@ -6,6 +6,8 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \
build-essential build-essential
RUN corepack enable
WORKDIR /misskey WORKDIR /misskey
COPY ["pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json", "./"] COPY ["pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json", "./"]
@ -14,7 +16,6 @@ COPY ["packages/backend/package.json", "./packages/backend/"]
COPY ["packages/frontend/package.json", "./packages/frontend/"] COPY ["packages/frontend/package.json", "./packages/frontend/"]
COPY ["packages/sw/package.json", "./packages/sw/"] COPY ["packages/sw/package.json", "./packages/sw/"]
RUN npm i -g pnpm
RUN pnpm i --frozen-lockfile RUN pnpm i --frozen-lockfile
COPY . ./ COPY . ./
@ -34,10 +35,10 @@ RUN apt-get update \
ffmpeg tini \ ffmpeg tini \
&& apt-get -y clean \ && apt-get -y clean \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& corepack enable \
&& groupadd -g "${GID}" misskey \ && groupadd -g "${GID}" misskey \
&& useradd -l -u "${UID}" -g "${GID}" -m -d /misskey misskey && useradd -l -u "${UID}" -g "${GID}" -m -d /misskey misskey
RUN npm i -g pnpm
USER misskey USER misskey
WORKDIR /misskey WORKDIR /misskey

View File

@ -24,7 +24,7 @@ services:
redis: redis:
restart: always restart: always
image: redis:4.0-alpine image: redis:7-alpine
networks: networks:
- internal_network - internal_network
volumes: volumes:
@ -36,7 +36,7 @@ services:
db: db:
restart: always restart: always
image: postgres:12.2-alpine image: postgres:15-alpine
networks: networks:
- internal_network - internal_network
env_file: env_file:

View File

@ -108,6 +108,7 @@ clickToShow: "اضغط للعرض"
sensitive: "محتوى حساس" sensitive: "محتوى حساس"
add: "إضافة" add: "إضافة"
reaction: "التفاعلات" reaction: "التفاعلات"
reactions: "التفاعلات"
reactionSetting: "التفاعلات المراد عرضها في منتقي التفاعلات." reactionSetting: "التفاعلات المراد عرضها في منتقي التفاعلات."
reactionSettingDescription2: "اسحب لترتيب ، انقر للحذف ، استخدم \"+\" للإضافة." reactionSettingDescription2: "اسحب لترتيب ، انقر للحذف ، استخدم \"+\" للإضافة."
rememberNoteVisibility: "تذكر إعدادت مدى رؤية الملاحظات" rememberNoteVisibility: "تذكر إعدادت مدى رؤية الملاحظات"

View File

@ -107,6 +107,7 @@ clickToShow: "দেখার জন্য ক্লিক করুন"
sensitive: "সংবেদনশীল বিষয়বস্তু" sensitive: "সংবেদনশীল বিষয়বস্তু"
add: "যুক্ত করুন" add: "যুক্ত করুন"
reaction: "প্রতিক্রিয়া" reaction: "প্রতিক্রিয়া"
reactions: "প্রতিক্রিয়া"
reactionSetting: "রিঅ্যাকশন পিকারে যেসকল প্রতিক্রিয়া দেখানো হবে" reactionSetting: "রিঅ্যাকশন পিকারে যেসকল প্রতিক্রিয়া দেখানো হবে"
reactionSettingDescription2: "পুনরায় সাজাতে টেনে আনুন, মুছতে ক্লিক করুন, যোগ করতে + টিপুন।" reactionSettingDescription2: "পুনরায় সাজাতে টেনে আনুন, মুছতে ক্লিক করুন, যোগ করতে + টিপুন।"
rememberNoteVisibility: "নোটের দৃশ্যমান্যতার সেটিংস মনে রাখুন" rememberNoteVisibility: "নোটের দৃশ্যমান্যতার সেটিংস মনে রাখুন"

View File

@ -108,6 +108,7 @@ clickToShow: "Fes clic per mostrar"
sensitive: "NSFW" sensitive: "NSFW"
add: "Afegir" add: "Afegir"
reaction: "Reaccions" reaction: "Reaccions"
reactions: "Reaccions"
reactionSetting: "Reaccions a mostrar al selector de reaccions" reactionSetting: "Reaccions a mostrar al selector de reaccions"
reactionSettingDescription2: "Arrossega per reordenar, fes clic per suprimir, prem \"+\" per afegir." reactionSettingDescription2: "Arrossega per reordenar, fes clic per suprimir, prem \"+\" per afegir."
rememberNoteVisibility: "Recorda la configuració de visibilitat de les notes" rememberNoteVisibility: "Recorda la configuració de visibilitat de les notes"

View File

@ -105,6 +105,7 @@ clickToShow: "Klikněte pro zobrazení"
sensitive: "NSFW" sensitive: "NSFW"
add: "Přidat" add: "Přidat"
reaction: "Reakce" reaction: "Reakce"
reactions: "Reakce"
reactionSettingDescription2: "Přetažením změníte pořadí, kliknutím smažete, zmáčkněte \"+\" k přidání" reactionSettingDescription2: "Přetažením změníte pořadí, kliknutím smažete, zmáčkněte \"+\" k přidání"
rememberNoteVisibility: "Zapamatovat nastavení zobrazení poznámky" rememberNoteVisibility: "Zapamatovat nastavení zobrazení poznámky"
attachCancel: "Odstranit přílohu" attachCancel: "Odstranit přílohu"

View File

@ -110,6 +110,7 @@ clickToShow: "Zum Anzeigen anklicken"
sensitive: "NSFW" sensitive: "NSFW"
add: "Hinzufügen" add: "Hinzufügen"
reaction: "Reaktionen" reaction: "Reaktionen"
reactions: "Reaktionen"
reactionSetting: "In der Reaktionsauswahl anzuzeigende Reaktionen" reactionSetting: "In der Reaktionsauswahl anzuzeigende Reaktionen"
reactionSettingDescription2: "Ziehe um Anzuordnen, klicke um zu löschen, drücke „+“ um hinzuzufügen" reactionSettingDescription2: "Ziehe um Anzuordnen, klicke um zu löschen, drücke „+“ um hinzuzufügen"
rememberNoteVisibility: "Notizsichtbarkeit merken" rememberNoteVisibility: "Notizsichtbarkeit merken"
@ -931,10 +932,12 @@ undefined: "Undefiniert"
assign: "Zuweisen" assign: "Zuweisen"
unassign: "Entfernen" unassign: "Entfernen"
color: "Farbe" color: "Farbe"
manageCustomEmojis: "Benutzerdefinierte Emojis verwalten" manageCustomEmojis: "Kann benutzerdefinierte Emojis verwalten"
youCannotCreateAnymore: "Du hast das Erstellungslimit erreicht." youCannotCreateAnymore: "Du hast das Erstellungslimit erreicht."
cannotPerformTemporary: "Vorübergehend nicht verfügbar" cannotPerformTemporary: "Vorübergehend nicht verfügbar"
cannotPerformTemporaryDescription: "Diese Aktion ist wegen des Überschreitenes des Ausführungslimits temporär nicht verfügbar. Bitte versuche es nach einiger Zeit erneut." cannotPerformTemporaryDescription: "Diese Aktion ist wegen des Überschreitenes des Ausführungslimits temporär nicht verfügbar. Bitte versuche es nach einiger Zeit erneut."
preset: "Vorlage"
selectFromPresets: "Aus Vorlagen wählen"
_role: _role:
new: "Rolle erstellen" new: "Rolle erstellen"
edit: "Rolle bearbeiten" edit: "Rolle bearbeiten"
@ -943,7 +946,7 @@ _role:
permission: "Rollenberechtigungen" permission: "Rollenberechtigungen"
descriptionOfPermission: "<b>Moderatoren</b> können grundlegende Verwaltungsaufgaben erledigen.\n<b>Administratoren</b> können alle Einstellungen der Instanz verwalten." descriptionOfPermission: "<b>Moderatoren</b> können grundlegende Verwaltungsaufgaben erledigen.\n<b>Administratoren</b> können alle Einstellungen der Instanz verwalten."
assignTarget: "Zuweisungsart" assignTarget: "Zuweisungsart"
descriptionOfAssignTarget: "<b>Manuell</b> bedeutet, dass die Liste der Benutzer einer Rolle manuell verwaltet wird.\n<b>Konditionell</b> bedeutet, dass die Liste der Benutzer einer Rolle durch eine Bedingung automatisch verwaltet wird." descriptionOfAssignTarget: "<b>Manuell</b> bedeutet, dass die Liste der Benutzer einer Rolle manuell verwaltet wird.\n<b>Konditional</b> bedeutet, dass die Liste der Benutzer einer Rolle durch eine Bedingung automatisch verwaltet wird."
manual: "Manuell" manual: "Manuell"
conditional: "Konditional" conditional: "Konditional"
condition: "Bedingung" condition: "Bedingung"
@ -966,7 +969,7 @@ _role:
gtlAvailable: "Kann auf die globale Chronik zugreifen" gtlAvailable: "Kann auf die globale Chronik zugreifen"
ltlAvailable: "Kann auf die lokale Chronik zugreifen" ltlAvailable: "Kann auf die lokale Chronik zugreifen"
canPublicNote: "Kann öffentliche Notizen erstellen" canPublicNote: "Kann öffentliche Notizen erstellen"
canInvite: "Einladungscodes für diese Instanz erstellen" canInvite: "Kann Einladungscodes für diese Instanz erstellen"
canManageCustomEmojis: "Benutzerdefinierte Emojis verwalten" canManageCustomEmojis: "Benutzerdefinierte Emojis verwalten"
driveCapacity: "Drive-Kapazität" driveCapacity: "Drive-Kapazität"
pinMax: "Maximale Anzahl an angehefteten Notizen" pinMax: "Maximale Anzahl an angehefteten Notizen"
@ -979,6 +982,7 @@ _role:
userEachUserListsMax: "Maximale Anzahl an Benutzerlisten" userEachUserListsMax: "Maximale Anzahl an Benutzerlisten"
rateLimitFactor: "Versuchsanzahl" rateLimitFactor: "Versuchsanzahl"
descriptionOfRateLimitFactor: "Je niedriger desto weniger restriktiv, je höher destro restriktiver." descriptionOfRateLimitFactor: "Je niedriger desto weniger restriktiv, je höher destro restriktiver."
canHideAds: "Kann Werbung ausblenden"
_condition: _condition:
isLocal: "Lokaler Benutzer" isLocal: "Lokaler Benutzer"
isRemote: "Benutzer fremder Instanz" isRemote: "Benutzer fremder Instanz"
@ -1023,7 +1027,7 @@ _accountDelete:
_ad: _ad:
back: "Zurück" back: "Zurück"
reduceFrequencyOfThisAd: "Diese Werbung weniger anzeigen" reduceFrequencyOfThisAd: "Diese Werbung weniger anzeigen"
hide: "Nie anzeigen" hide: "Ausblenden"
_forgotPassword: _forgotPassword:
enterEmail: "Gib die Email-Adresse ein, mit der du dich registriert hast. An diese wird ein Link gesendet, mit dem du dein Passwort zurücksetzen kannst." enterEmail: "Gib die Email-Adresse ein, mit der du dich registriert hast. An diese wird ein Link gesendet, mit dem du dein Passwort zurücksetzen kannst."
ifNoEmail: "Solltest du bei der Registrierung keine Email-Adresse angegeben haben, wende dich bitte an den Administrator." ifNoEmail: "Solltest du bei der Registrierung keine Email-Adresse angegeben haben, wende dich bitte an den Administrator."

View File

@ -103,6 +103,7 @@ you: "Εσύ"
clickToShow: "Κάντε κλικ για εμφάνιση" clickToShow: "Κάντε κλικ για εμφάνιση"
add: "Προσθέστε" add: "Προσθέστε"
reaction: "Αντιδράσεις" reaction: "Αντιδράσεις"
reactions: "Αντιδράσεις"
reactionSetting: "Αντιδράσεις για εμφάνιση στην επιλογή αντίδρασης" reactionSetting: "Αντιδράσεις για εμφάνιση στην επιλογή αντίδρασης"
reactionSettingDescription2: "Σύρετε για να αλλάξετε τη σειρά, κάντε κλικ για να διαγράψετε, πατήστε \"+\" για να προσθέσετε." reactionSettingDescription2: "Σύρετε για να αλλάξετε τη σειρά, κάντε κλικ για να διαγράψετε, πατήστε \"+\" για να προσθέσετε."
rememberNoteVisibility: "Θυμήσου τις ρυθμίσεις ορατότητας σημειώματος" rememberNoteVisibility: "Θυμήσου τις ρυθμίσεις ορατότητας σημειώματος"

View File

@ -110,6 +110,7 @@ clickToShow: "Click to show"
sensitive: "NSFW" sensitive: "NSFW"
add: "Add" add: "Add"
reaction: "Reactions" reaction: "Reactions"
reactions: "Reactions"
reactionSetting: "Reactions to show in the reaction picker" reactionSetting: "Reactions to show in the reaction picker"
reactionSettingDescription2: "Drag to reorder, click to delete, press \"+\" to add." reactionSettingDescription2: "Drag to reorder, click to delete, press \"+\" to add."
rememberNoteVisibility: "Remember note visibility settings" rememberNoteVisibility: "Remember note visibility settings"
@ -935,8 +936,41 @@ manageCustomEmojis: "Manage Custom Emojis"
youCannotCreateAnymore: "You've hit the creation limit." youCannotCreateAnymore: "You've hit the creation limit."
cannotPerformTemporary: "Temporarily unavailable" cannotPerformTemporary: "Temporarily unavailable"
cannotPerformTemporaryDescription: "This action cannot be performed temporarily due to exceeding the execution limit. Please wait for a while and then try again." cannotPerformTemporaryDescription: "This action cannot be performed temporarily due to exceeding the execution limit. Please wait for a while and then try again."
preset: "Presets" preset: "Preset"
selectFromPresets: "Choose from presets" selectFromPresets: "Choose from presets"
_achievements:
_types:
_reactWithoutRead:
title: "Did you really read that?"
description: "React on a note that's over 100 characters long within 3 seconds of it being posted"
_clickedClickHere:
title: "Click here"
description: "You've clicked here"
_justPlainLucky:
title: "Just Plain Lucky"
description: "Has a chance to be obtained with a probability of 0.01% every 10 seconds"
_setNameToSyuilo:
title: "God Complex"
description: "Set your name to \"syuilo\""
_passedSinceAccountCreated1:
title: "One Year Anniversary"
description: "One year has passed since your account was created"
_passedSinceAccountCreated2:
title: "Two Year Anniversary"
description: "Two years have passed since your account was created"
_passedSinceAccountCreated3:
title: "Three Year Anniversary"
description: "Three years have passed since your account was created"
_loggedInOnBirthday:
title: "Happy Birthday"
description: "Logged in on your birthday"
_cookieClicked:
title: "A game in which you click cookies"
description: "Clicked the cookie"
_brainDiver:
title: "Brain Diver"
description: "Post the link to Brain Diver"
flavor: "Misskey-Misskey La-Tu-Ma"
_role: _role:
new: "New role" new: "New role"
edit: "Edit role" edit: "Edit role"
@ -954,10 +988,10 @@ _role:
descriptionOfIsPublic: "Anyone will be able to view a list of users assigned to this role. In addition, this role will be displayed in the profiles of assigned users." descriptionOfIsPublic: "Anyone will be able to view a list of users assigned to this role. In addition, this role will be displayed in the profiles of assigned users."
options: "Role options" options: "Role options"
policies: "Policies" policies: "Policies"
baseRole: "Base role" baseRole: "Role template"
useBaseValue: "Use base role value" useBaseValue: "Use role template value"
chooseRoleToAssign: "Select the role to assign" chooseRoleToAssign: "Select the role to assign"
canEditMembersByModerator: "Allow moderators to edit the list members of this role" canEditMembersByModerator: "Allow moderators to edit the list of members for this role"
descriptionOfCanEditMembersByModerator: "When turned on, moderators as well as administrators will be able to assign and unassign users to this role. When turned off, only administrators will be able to assign users." descriptionOfCanEditMembersByModerator: "When turned on, moderators as well as administrators will be able to assign and unassign users to this role. When turned off, only administrators will be able to assign users."
priority: "Priority" priority: "Priority"
_priority: _priority:
@ -965,11 +999,11 @@ _role:
middle: "Medium" middle: "Medium"
high: "High" high: "High"
_options: _options:
gtlAvailable: "Viewing the global timeline" gtlAvailable: "Can view the global timeline"
ltlAvailable: "Viewing the local timeline" ltlAvailable: "Can view the local timeline"
canPublicNote: "Can send public notes" canPublicNote: "Can send public notes"
canInvite: "Create instance invite codes" canInvite: "Can create instance invite codes"
canManageCustomEmojis: "Manage Custom Emojis" canManageCustomEmojis: "Can manage custom emojis"
driveCapacity: "Drive capacity" driveCapacity: "Drive capacity"
pinMax: "Maximum number of pinned notes" pinMax: "Maximum number of pinned notes"
antennaMax: "Maximum number of antennas" antennaMax: "Maximum number of antennas"
@ -981,7 +1015,7 @@ _role:
userEachUserListsMax: "Maximum number of users within a user list" userEachUserListsMax: "Maximum number of users within a user list"
rateLimitFactor: "Rate limit" rateLimitFactor: "Rate limit"
descriptionOfRateLimitFactor: "Lower rate limits are less restrictive, higher ones more restrictive. " descriptionOfRateLimitFactor: "Lower rate limits are less restrictive, higher ones more restrictive. "
canHideAds: "Remove ads" canHideAds: "Can hide ads"
_condition: _condition:
isLocal: "Local user" isLocal: "Local user"
isRemote: "Remote user" isRemote: "Remote user"
@ -1026,7 +1060,7 @@ _accountDelete:
_ad: _ad:
back: "Back" back: "Back"
reduceFrequencyOfThisAd: "Show this ad less" reduceFrequencyOfThisAd: "Show this ad less"
hide: "Never show" hide: "Hide"
_forgotPassword: _forgotPassword:
enterEmail: "Enter the email address you used to register. A link with which you can reset your password will then be sent to it." enterEmail: "Enter the email address you used to register. A link with which you can reset your password will then be sent to it."
ifNoEmail: "If you did not use an email during registration, please contact the instance administrator instead." ifNoEmail: "If you did not use an email during registration, please contact the instance administrator instead."
@ -1586,6 +1620,7 @@ _notification:
pollEnded: "Poll results have become available" pollEnded: "Poll results have become available"
unreadAntennaNote: "Antenna {name}" unreadAntennaNote: "Antenna {name}"
emptyPushNotificationMessage: "Push notifications have been updated" emptyPushNotificationMessage: "Push notifications have been updated"
achievementEarned: "Achievement unlocked"
_types: _types:
all: "All" all: "All"
follow: "New followers" follow: "New followers"

View File

@ -110,6 +110,7 @@ clickToShow: "Click para ver"
sensitive: "Marcado como sensible" sensitive: "Marcado como sensible"
add: "Agregar" add: "Agregar"
reaction: "Reacción" reaction: "Reacción"
reactions: "Reacción"
reactionSetting: "Reacciones para mostrar en el menú de reacciones" reactionSetting: "Reacciones para mostrar en el menú de reacciones"
reactionSettingDescription2: "Arrastre para reordenar, click para borrar, apriete la tecla + para añadir." reactionSettingDescription2: "Arrastre para reordenar, click para borrar, apriete la tecla + para añadir."
rememberNoteVisibility: "Recordar visibilidad" rememberNoteVisibility: "Recordar visibilidad"

View File

@ -110,6 +110,7 @@ clickToShow: "Cliquer pour afficher"
sensitive: "Contenu sensible" sensitive: "Contenu sensible"
add: "Ajouter" add: "Ajouter"
reaction: "Réactions" reaction: "Réactions"
reactions: "Réactions"
reactionSetting: "Réactions à afficher dans le sélecteur de réactions" reactionSetting: "Réactions à afficher dans le sélecteur de réactions"
reactionSettingDescription2: "Déplacer pour réorganiser, cliquer pour effacer, utiliser « + » pour ajouter." reactionSettingDescription2: "Déplacer pour réorganiser, cliquer pour effacer, utiliser « + » pour ajouter."
rememberNoteVisibility: "Activer l'option \" se souvenir de la visibilité des notes \" vous permet de réutiliser automatiquement la visibilité utilisée lors de la publication de votre note précédente." rememberNoteVisibility: "Activer l'option \" se souvenir de la visibilité des notes \" vous permet de réutiliser automatiquement la visibilité utilisée lors de la publication de votre note précédente."

View File

@ -2,6 +2,7 @@
_lang_: "Bahasa Indonesia" _lang_: "Bahasa Indonesia"
headlineMisskey: "Jaringan terhubung melalui catatan" headlineMisskey: "Jaringan terhubung melalui catatan"
introMisskey: "Selamat datang! Misskey adalah perangkat mikroblog tercatu bersifat sumber terbuka.\nMulailah menuliskan catatan, bagikan peristiwa terkini, serta ceritakan segala tentangmu.📡\nTunjukkan juga reaksimu pada catatan pengguna lain.👍\nMari jelajahi dunia baru🚀" introMisskey: "Selamat datang! Misskey adalah perangkat mikroblog tercatu bersifat sumber terbuka.\nMulailah menuliskan catatan, bagikan peristiwa terkini, serta ceritakan segala tentangmu.📡\nTunjukkan juga reaksimu pada catatan pengguna lain.👍\nMari jelajahi dunia baru🚀"
poweredByMisskeyDescription: "{name} adalah sebuah layanan (instance) yang menggunakan platform sumber terbuka <b>Misskey</b>."
monthAndDay: "{day} {month}" monthAndDay: "{day} {month}"
search: "Penelusuran" search: "Penelusuran"
notifications: "Pemberitahuan" notifications: "Pemberitahuan"
@ -47,6 +48,7 @@ deleteAndEdit: "Hapus dan sunting"
deleteAndEditConfirm: "Apakah kamu yakin ingin menghapus note ini dan menyuntingnya? Kamu akan kehilangan semua reaksi, renote dan balasan di note ini." deleteAndEditConfirm: "Apakah kamu yakin ingin menghapus note ini dan menyuntingnya? Kamu akan kehilangan semua reaksi, renote dan balasan di note ini."
addToList: "Tambahkan ke daftar" addToList: "Tambahkan ke daftar"
sendMessage: "Kirim pesan" sendMessage: "Kirim pesan"
copyRSS: "Salin RSS"
copyUsername: "Salin nama pengguna" copyUsername: "Salin nama pengguna"
searchUser: "Cari pengguna" searchUser: "Cari pengguna"
reply: "Balas" reply: "Balas"
@ -107,6 +109,7 @@ clickToShow: "Klik untuk melihat"
sensitive: "Konten sensitif" sensitive: "Konten sensitif"
add: "Tambahkan" add: "Tambahkan"
reaction: "Reaksi" reaction: "Reaksi"
reactions: "Reaksi"
reactionSetting: "Reaksi untuk dimunculkan di bilah reaksi" reactionSetting: "Reaksi untuk dimunculkan di bilah reaksi"
reactionSettingDescription2: "Geser untuk memindah urutkan, klik untuk menghapus, tekan \"+\" untuk menambahkan" reactionSettingDescription2: "Geser untuk memindah urutkan, klik untuk menghapus, tekan \"+\" untuk menambahkan"
rememberNoteVisibility: "Ingat pengaturan visibilitas catatan" rememberNoteVisibility: "Ingat pengaturan visibilitas catatan"
@ -383,6 +386,7 @@ administrator: "Admin"
token: "Token" token: "Token"
twoStepAuthentication: "Otentikasi dua faktor" twoStepAuthentication: "Otentikasi dua faktor"
moderator: "Moderator" moderator: "Moderator"
moderation: "Moderasi"
nUsersMentioned: "{n} pengguna disebut" nUsersMentioned: "{n} pengguna disebut"
securityKey: "Kunci keamanan" securityKey: "Kunci keamanan"
securityKeyName: "Nama kunci" securityKeyName: "Nama kunci"
@ -449,6 +453,7 @@ language: "Bahasa"
uiLanguage: "Bahasa antarmuka pengguna" uiLanguage: "Bahasa antarmuka pengguna"
groupInvited: "Telah diundang ke grup" groupInvited: "Telah diundang ke grup"
aboutX: "Tentang {x}" aboutX: "Tentang {x}"
emojiStyle: "Gaya emoji"
disableDrawer: "Jangan gunakan menu bergaya laci" disableDrawer: "Jangan gunakan menu bergaya laci"
youHaveNoGroups: "Kamu tidak memiliki grup" youHaveNoGroups: "Kamu tidak memiliki grup"
joinOrCreateGroup: "Bergabunglah dengan grup atau kamu dapat membuat grupmu sendiri." joinOrCreateGroup: "Bergabunglah dengan grup atau kamu dapat membuat grupmu sendiri."
@ -561,6 +566,7 @@ author: "Pembuat"
leaveConfirm: "Ada perubahan yang belum disimpan. Apakah kamu ingin membuangnya?" leaveConfirm: "Ada perubahan yang belum disimpan. Apakah kamu ingin membuangnya?"
manage: "Manajemen" manage: "Manajemen"
plugins: "Plugin" plugins: "Plugin"
preferencesBackups: "Aturan pencadangan"
deck: "Dek" deck: "Dek"
undeck: "Keluar dari dek" undeck: "Keluar dari dek"
useBlurEffectForModal: "Gunakan efek buram untuk modal" useBlurEffectForModal: "Gunakan efek buram untuk modal"
@ -706,6 +712,7 @@ accentColor: "Aksen"
textColor: "Teks" textColor: "Teks"
saveAs: "Simpan sebagai…" saveAs: "Simpan sebagai…"
advanced: "Tingkat lanjut" advanced: "Tingkat lanjut"
advancedSettings: "Pengaturan Lanjut"
value: "Nilai" value: "Nilai"
createdAt: "Dibuat pada" createdAt: "Dibuat pada"
updatedAt: "Diperbarui pada" updatedAt: "Diperbarui pada"
@ -850,10 +857,21 @@ rateLimitExceeded: "Batas sudah terlampaui"
cropImage: "potong gambar" cropImage: "potong gambar"
cropImageAsk: "Ingin memotong gambar?" cropImageAsk: "Ingin memotong gambar?"
file: "Berkas" file: "Berkas"
noEmailServerWarning: "Mail Server tidak disetel."
recommended: "Disarankan"
check: "Cek"
deleteAccount: "Hapus Akun"
logoutConfirm: "Anda yakin ingin keluar?"
lastActiveDate: "Terakhir digunakan"
statusbar: "Bilah status"
pleaseSelect: "Pilih opsi..."
reverse: "Balik" reverse: "Balik"
colored: "Diwarnai" colored: "Diwarnai"
refreshInterval: "Jeda pembaharuan"
label: "Label" label: "Label"
type: "Tipe"
localOnly: "Hanya lokal" localOnly: "Hanya lokal"
shuffle: "Acak"
account: "Akun" account: "Akun"
like: "Suka" like: "Suka"
unlike: "Tidak Suka" unlike: "Tidak Suka"

View File

@ -110,6 +110,7 @@ clickToShow: "Clicca per visualizzare"
sensitive: "Contenuto sensibile" sensitive: "Contenuto sensibile"
add: "Aggiungi" add: "Aggiungi"
reaction: "Reazioni" reaction: "Reazioni"
reactions: "Reazioni"
reactionSetting: "Reazioni visualizzate sul pannello" reactionSetting: "Reazioni visualizzate sul pannello"
reactionSettingDescription2: "Trascina per riorganizzare, clicca per cancellare, usa il pulsante \"+\" per aggiungere." reactionSettingDescription2: "Trascina per riorganizzare, clicca per cancellare, usa il pulsante \"+\" per aggiungere."
rememberNoteVisibility: "Ricordare le impostazioni di visibilità delle note" rememberNoteVisibility: "Ricordare le impostazioni di visibilità delle note"
@ -836,7 +837,7 @@ hide: "Nascondere"
leaveGroup: "Esci dal gruppo" leaveGroup: "Esci dal gruppo"
leaveGroupConfirm: "Uscire da「{name}」?" leaveGroupConfirm: "Uscire da「{name}」?"
useDrawerReactionPickerForMobile: "Mostra sul drawer da dispositivo mobile" useDrawerReactionPickerForMobile: "Mostra sul drawer da dispositivo mobile"
welcomeBackWithName: "Eccoti di nuovo, {name}! Ciao!" welcomeBackWithName: "Ciao, {name}! Eccoti di nuovo!"
clickToFinishEmailVerification: "Fai click su [{ok}] per completare la verifica dell'indirizzo email." clickToFinishEmailVerification: "Fai click su [{ok}] per completare la verifica dell'indirizzo email."
overridedDeviceKind: "Tipo di dispositivo" overridedDeviceKind: "Tipo di dispositivo"
smartphone: "Smartphone" smartphone: "Smartphone"
@ -935,6 +936,8 @@ manageCustomEmojis: "Gestisci le emoji personalizzate"
youCannotCreateAnymore: "Non puoi creare, hai raggiunto il limite." youCannotCreateAnymore: "Non puoi creare, hai raggiunto il limite."
cannotPerformTemporary: "Indisponibilità temporanea" cannotPerformTemporary: "Indisponibilità temporanea"
cannotPerformTemporaryDescription: "L'attività non può essere svolta, poiché si è raggiunto il limite di esecuzioni possibili. Per favore, riprova più tardi." cannotPerformTemporaryDescription: "L'attività non può essere svolta, poiché si è raggiunto il limite di esecuzioni possibili. Per favore, riprova più tardi."
preset: "Preimpostato"
selectFromPresets: "Seleziona preimpostato"
_role: _role:
new: "Nuovo ruolo" new: "Nuovo ruolo"
edit: "Modifica ruolo" edit: "Modifica ruolo"
@ -979,6 +982,7 @@ _role:
userEachUserListsMax: "Quantità massima di profili per lista" userEachUserListsMax: "Quantità massima di profili per lista"
rateLimitFactor: "Limite del rapporto" rateLimitFactor: "Limite del rapporto"
descriptionOfRateLimitFactor: "I rapporti più bassi sono meno restrittivi, quelli più alti lo sono di più." descriptionOfRateLimitFactor: "I rapporti più bassi sono meno restrittivi, quelli più alti lo sono di più."
canHideAds: "Può nascondere i banner"
_condition: _condition:
isLocal: "Profilo locale" isLocal: "Profilo locale"
isRemote: "Profilo remoto" isRemote: "Profilo remoto"

View File

@ -110,6 +110,7 @@ clickToShow: "クリックして表示"
sensitive: "閲覧注意" sensitive: "閲覧注意"
add: "追加" add: "追加"
reaction: "リアクション" reaction: "リアクション"
reactions: "リアクション"
reactionSetting: "ピッカーに表示するリアクション" reactionSetting: "ピッカーに表示するリアクション"
reactionSettingDescription2: "ドラッグして並び替え、クリックして削除、+を押して追加します。" reactionSettingDescription2: "ドラッグして並び替え、クリックして削除、+を押して追加します。"
rememberNoteVisibility: "公開範囲を記憶する" rememberNoteVisibility: "公開範囲を記憶する"
@ -937,6 +938,235 @@ cannotPerformTemporary: "一時的に利用できません"
cannotPerformTemporaryDescription: "操作回数が制限を超過するため一時的に利用できません。しばらく時間を置いてから再度お試しください。" cannotPerformTemporaryDescription: "操作回数が制限を超過するため一時的に利用できません。しばらく時間を置いてから再度お試しください。"
preset: "プリセット" preset: "プリセット"
selectFromPresets: "プリセットから選択" selectFromPresets: "プリセットから選択"
achievements: "実績"
_achievements:
earnedAt: "獲得日時"
_types:
_notes1:
title: "just setting up my msky"
description: "初めてノートを投稿した"
flavor: "良いMisskeyライフを"
_notes10:
title: "いくつかのノート"
description: "ートを10回投稿した"
_notes100:
title: "たくさんのノート"
description: "ートを100回投稿した"
_notes500:
title: "ノートまみれ"
description: "ートを500回投稿した"
_notes1000:
title: "ノートの山"
description: "ートを1,000回投稿した"
_notes5000:
title: "湧き出るノート"
description: "ートを5,000回投稿した"
_notes10000:
title: "スーパーノート"
description: "ートを10,000回投稿した"
_notes20000:
title: "ニードモアノート"
description: "ートを20,000回投稿した"
_notes30000:
title: "ノートノートノート"
description: "ートを30,000回投稿した"
_notes40000:
title: "ノート工場"
description: "ートを40,000回投稿した"
_notes50000:
title: "ノートの惑星"
description: "ートを50,000回投稿した"
_notes60000:
title: "ノートクエーサー"
description: "ートを60,000回投稿した"
_notes70000:
title: "ブラックノートホール"
description: "ートを70,000回投稿した"
_notes80000:
title: "ノートギャラクシー"
description: "ートを80,000回投稿した"
_notes90000:
title: "ノートバース"
description: "ートを90,000回投稿した"
_notes100000:
title: "ALL YOUR NOTE ARE BELONG TO US"
description: "ートを100,000回投稿した"
flavor: "そんなに書くことある?"
_login3:
title: "ビギナーⅠ"
description: "通算ログイン日数が3日"
flavor: "今日からね僕は ミスキストってことで"
_login7:
title: "ビギナーⅡ"
description: "通算ログイン日数が7日"
flavor: "慣れてきましたか?"
_login15:
title: "ビギナーⅢ"
description: "通算ログイン日数が15日"
_login30:
title: "ミスキストⅠ"
description: "通算ログイン日数が30日"
_login60:
title: "ミスキストⅡ"
description: "通算ログイン日数が60日"
_login100:
title: "ミスキストⅢ"
description: "通算ログイン日数が100日"
flavor: "そのユーザー、ミスキストにつき"
_login200:
title: "常連Ⅰ"
description: "通算ログイン日数が200日"
_login300:
title: "常連Ⅱ"
description: "通算ログイン日数が300日"
_login400:
title: "常連Ⅲ"
description: "通算ログイン日数が400日"
_login500:
title: "ベテランⅠ"
description: "通算ログイン日数が500日"
flavor: "諸君、私はノートが好きだ"
_login600:
title: "ベテランⅡ"
description: "通算ログイン日数が600日"
_login700:
title: "ベテランⅢ"
description: "通算ログイン日数が700日"
_login800:
title: "ノートマスターⅠ"
description: "通算ログイン日数が800日"
_login900:
title: "ノートマスターⅡ"
description: "通算ログイン日数が900日"
_login1000:
title: "ノートマスターⅢ"
description: "通算ログイン日数が1,000日"
flavor: "Misskeyを使ってくれてありがとう"
_noteClipped1:
title: "クリップせずにはいられないな"
description: "初めてノートをクリップした"
_noteFavorited1:
title: "星をみるひと"
description: "初めてノートをお気に入りに登録した"
_profileFilled:
title: "準備万端"
description: "プロフィール設定を行った"
_markedAsCat:
title: "吾輩は猫である"
description: "アカウントをCatとして設定した"
flavor: "名前はまだない。"
_following1:
title: "はじめてのフォロー"
description: "初めてフォローした"
_following10:
title: "ついてく、ついてく"
description: "フォローが10人を超した"
_following50:
title: "友達たくさん"
description: "フォローが50人を超した"
_following100:
title: "友達100人"
description: "フォローが100人を超した"
_following300:
title: "友達過多"
description: "フォローが300人を超した"
_followers1:
title: "はじめてのフォロワー"
description: "初めてフォローされた"
_followers10:
title: "フォローミー!"
description: "フォロワーが10人を超した"
_followers50:
title: "ぞろぞろ"
description: "フォロワーが50人を超した"
_followers100:
title: "人気者"
description: "フォロワーが100人を超した"
_followers300:
title: "一列でお並びください"
description: "フォロワーが300人を超した"
_followers500:
title: "基地局"
description: "フォロワーが500人を超した"
_followers1000:
title: "インフルエンサー"
description: "フォロワーが1,000人を超した"
_collectAchievements30:
title: "実績コレクター"
description: "実績を30個以上獲得した"
_viewAchievements3min:
title: "実績好き"
description: "実績一覧を3分以上眺め続けた"
_iLoveMisskey:
title: "I Love Misskey"
description: "\"I ❤ #Misskey\"を投稿した"
flavor: "Misskeyを使ってくださりありがとうございます by 開発チーム"
_client30min:
title: "ひとやすみ"
description: "クライアントを起動してから30分以上経過した"
_noteDeletedWithin1min:
title: "いまのなし"
description: "投稿してから1分以内にその投稿を削除した"
_postedAtLateNight:
title: "夜行性"
description: "深夜にノートを投稿した"
flavor: "そろそろ寝よう。"
_postedAt0min0sec:
title: "時報"
description: "0分0秒にートを投稿した"
flavor: "ポッ ポッ ポッ ピーン"
_selfQuote:
title: "自己言及"
description: "自分のノートを引用した"
_htl20npm:
title: "流れるTL"
description: "ホームタイムラインの流速が20npmを越す"
_outputHelloWorldOnScratchpad:
title: "Hello, world!"
description: "スクラッチパッドで hello world を出力した"
_open3windows:
title: "マルチウィンドウ"
description: "ウィンドウを3つ以上開いた状態にした"
_driveFolderCircularReference:
title: "循環参照"
description: "ドライブのフォルダを再帰的な入れ子にしようとした"
_reactWithoutRead:
title: "ちゃんと読んだ?"
description: "100文字以上のテキストを含むートに投稿されてから3秒以内にリアクションした"
_clickedClickHere:
title: "ここをクリック"
description: "ここをクリックした"
_justPlainLucky:
title: "単なるラッキー"
description: "10秒ごとに0.01%の確率で獲得"
_setNameToSyuilo:
title: "神様コンプレックス"
description: "名前を syuilo に設定した"
_passedSinceAccountCreated1:
title: "一周年"
description: "アカウント作成から1年経過した"
_passedSinceAccountCreated2:
title: "二周年"
description: "アカウント作成から2年経過した"
_passedSinceAccountCreated3:
title: "三周年"
description: "アカウント作成から3年経過した"
_loggedInOnBirthday:
title: "ハッピーバースデー"
description: "誕生日にログインした"
_loggedInOnNewYearsDay:
title: "あけましておめでとうございます"
description: "元日にログインした"
flavor: "今年も弊インスタンスをよろしくお願いします"
_cookieClicked:
title: "クッキーをクリックするゲーム"
description: "クッキーをクリックした"
flavor: "ソフト間違ってない?"
_brainDiver:
title: "Brain Diver"
description: "Brain Diverへのリンクを投稿した"
flavor: "Misskey-Misskey La-Tu-Ma"
_role: _role:
new: "ロールの作成" new: "ロールの作成"
@ -1634,6 +1864,7 @@ _notification:
pollEnded: "アンケートの結果が出ました" pollEnded: "アンケートの結果が出ました"
unreadAntennaNote: "アンテナ {name}" unreadAntennaNote: "アンテナ {name}"
emptyPushNotificationMessage: "プッシュ通知の更新をしました" emptyPushNotificationMessage: "プッシュ通知の更新をしました"
achievementEarned: "実績を獲得"
_types: _types:
all: "すべて" all: "すべて"

View File

@ -8,9 +8,9 @@ search: "探す"
notifications: "通知" notifications: "通知"
username: "ユーザー名" username: "ユーザー名"
password: "パスワード" password: "パスワード"
forgotPassword: "パスワード忘れて" forgotPassword: "パスワード忘れてもうた"
fetchingAsApObject: "今ちと連合に照会しとるで" fetchingAsApObject: "今ちと連合に照会しとるで"
ok: "OKや" ok: "ええで"
gotIt: "ほい" gotIt: "ほい"
cancel: "やめとく" cancel: "やめとく"
noThankYou: "やめとく" noThankYou: "やめとく"
@ -110,6 +110,7 @@ clickToShow: "押したら見えるで"
sensitive: "ちょっとアカンやつやで" sensitive: "ちょっとアカンやつやで"
add: "増やす" add: "増やす"
reaction: "リアクション" reaction: "リアクション"
reactions: "リアクション"
reactionSetting: "Reaction that will be displayed in Picker. " reactionSetting: "Reaction that will be displayed in Picker. "
reactionSettingDescription2: "ドラッグで並び替え、クリックで削除、+を押して追加やで。" reactionSettingDescription2: "ドラッグで並び替え、クリックで削除、+を押して追加やで。"
rememberNoteVisibility: "公開範囲覚えといて" rememberNoteVisibility: "公開範囲覚えといて"
@ -607,7 +608,7 @@ wordMute: "ワードミュート"
regexpError: "正規表現エラー" regexpError: "正規表現エラー"
regexpErrorDescription: "{tab}ワードミュートの{line}行目の正規表現にエラーが出てきたで:" regexpErrorDescription: "{tab}ワードミュートの{line}行目の正規表現にエラーが出てきたで:"
instanceMute: "インスタンスミュート" instanceMute: "インスタンスミュート"
userSaysSomething: "{name}が何か言ったようやで" userSaysSomething: "{name}が何か言うとるわ"
makeActive: "使うで" makeActive: "使うで"
display: "表示" display: "表示"
copy: "コピー" copy: "コピー"

View File

@ -110,6 +110,7 @@ clickToShow: "클릭하여 보기"
sensitive: "열람주의" sensitive: "열람주의"
add: "추가" add: "추가"
reaction: "리액션" reaction: "리액션"
reactions: "리액션"
reactionSetting: "선택기에 표시할 리액션" reactionSetting: "선택기에 표시할 리액션"
reactionSettingDescription2: "끌어서 순서 변경, 클릭해서 삭제, +를 눌러서 추가할 수 있습니다." reactionSettingDescription2: "끌어서 순서 변경, 클릭해서 삭제, +를 눌러서 추가할 수 있습니다."
rememberNoteVisibility: "공개 범위를 기억하기" rememberNoteVisibility: "공개 범위를 기억하기"
@ -935,12 +936,229 @@ manageCustomEmojis: "커스텀 이모지 관리"
youCannotCreateAnymore: "더 이상 생성할 수 없습니다." youCannotCreateAnymore: "더 이상 생성할 수 없습니다."
cannotPerformTemporary: "일시적으로 사용할 수 없음" cannotPerformTemporary: "일시적으로 사용할 수 없음"
cannotPerformTemporaryDescription: "조작 횟수 제한을 초과하여 일시적으로 사용이 불가합니다. 잠시 후 다시 시도해 주세요." cannotPerformTemporaryDescription: "조작 횟수 제한을 초과하여 일시적으로 사용이 불가합니다. 잠시 후 다시 시도해 주세요."
preset: "프리셋"
selectFromPresets: "프리셋에서 선택"
achievements: "도전과제"
_achievements:
earnedAt: "달성 일시"
_types:
_notes1:
title: "미스키 설정하고 있었는데요"
description: "첫 노트를 포스트했습니다"
flavor: "Misskey에 오신 것을 환영합니다!"
_notes10:
title: "노트 조금"
description: "10개의 노트를 작성했습니다"
_notes100:
title: "노트 많이"
description: "100개의 노트를 작성했습니다"
_notes500:
title: "노트로 뒤덮여버렸어"
description: "500개의 노트를 작성했습니다"
_notes1000:
title: "노트만 산더미"
description: "1,000개의 노트를 작성했습니다"
_notes5000:
title: "노트가 어디서 솟아?"
description: "5,000개의 노트를 작성했습니다"
_notes10000:
title: "슈퍼-노트"
description: "10,000개의 노트를 작성했습니다"
_notes20000:
title: "노트 더 없어?"
description: "20,000개의 노트를 작성했습니다"
_notes30000:
title: "노트노트노트"
description: "30,000개의 노트를 작성했습니다"
_notes40000:
title: "노트 공장"
description: "40,000개의 노트를 작성했습니다"
_notes50000:
title: "노트 행성"
description: "50,000개의 노트를 작성했습니다"
_notes60000:
title: "노트 퀘이사"
description: "60,000개의 노트를 작성했습니다"
_notes70000:
title: "노트 블랙홀"
description: "70,000개의 노트를 작성했습니다"
_notes80000:
title: "노트 은하"
description: "80,000개의 노트를 작성했습니다"
_notes90000:
title: "노트 우주"
description: "90,000개의 노트를 작성했습니다"
_notes100000:
title: "네 모든 노트는 내 거야"
description: "100,000개의 노트를 작성했습니다"
flavor: "이만큼 쓸 일도 없겠지만... 다른 할 일이 있진 않으신가요?"
_login3:
title: "비기너 I"
description: "총 3일간 로그인했습니다"
flavor: "오늘부터 여러분도 미스키스트에요!"
_login7:
title: "비기너 II"
description: "총 7일간 로그인했습니다"
flavor: "슬슬 익숙해지셨나요?"
_login15:
title: "비기너 III"
description: "총 15일간 로그인했습니다"
_login30:
title: "미스키스트 I"
description: "총 30일간 로그인했습니다"
_login60:
title: "미스키스트 II"
description: "총 60일간 로그인했습니다"
_login100:
title: "미스키스트 III"
description: "총 100일간 로그인했습니다"
flavor: "그 유저, 미스키스트를 위하여"
_login200:
title: "단골 I"
description: "총 200일간 로그인했습니다"
_login300:
title: "단골 II"
description: "총 300일간 로그인했습니다"
_login400:
title: "단골 III"
description: "총 400일간 로그인했습니다"
_login500:
title: "베테랑 I"
description: "총 500일간 로그인했습니다"
flavor: "여러분, 저 이 노트들 좋아해요"
_login600:
title: "베테랑 II"
description: "총 600일간 로그인했습니다"
_login700:
title: "베테랑 III"
description: "총 700일간 로그인했습니다"
_login800:
title: "노트 마스터 I"
description: "총 800일간 로그인했습니다"
_login900:
title: "노트 마스터 II"
description: "총 900일간 로그인했습니다"
_login1000:
title: "노트 마스터 III"
description: "총 1,000일간 로그인했습니다"
flavor: "미스키를 사용해 주셔서 감사합니다!"
_noteClipped1:
title: "클립할 수밖에 없었어"
description: "처음으로 노트를 클립했습니다"
_noteFavorited1:
title: "별을 바라보는 자"
description: "처음으로 노트를 즐겨찾기했습니다"
_profileFilled:
title: "준비 완료"
description: "프로필 설정을 완료했습니다"
_markedAsCat:
title: "나는 고양이다냥!"
description: "계정을 고양이로 설정했습니다냥"
flavor: "냐냐냐냐냐냐아아아아앙!"
_following1:
title: "첫 팔로우"
description: "사용자를 처음으로 팔로우했습니다"
_following10:
title: "팔로우, 팔로우"
description: "10명의 사용자를 팔로우했습니다"
_following50:
title: "친구 잔뜩"
description: "50명의 사용자를 팔로우했습니다"
_following100:
title: "주소록 한 권으론 부족해"
description: "100명의 사용자를 팔로우했습니다"
_following300:
title: "친구가 넘쳐나"
description: "300명의 사용자를 팔로우했습니다"
_followers1:
title: "첫 팔로워"
description: "사용자가 처음으로 팔로잉했습니다"
_followers10:
title: "날 따라와!"
description: "10명의 사용자가 팔로우했습니다"
_followers50:
title: "이곳저곳"
description: "50명의 사용자가 팔로우했습니다"
_followers100:
title: "인기왕"
description: "100명의 사용자가 팔로우했습니다"
_followers300:
title: "줄 좀 서봐요"
description: "100명의 사용자가 팔로우했습니다"
_followers500:
title: "기지국"
description: "500명의 사용자가 팔로우했습니다"
_followers1000:
title: "유명인사"
description: "1,000명의 사용자가 팔로우했습니다"
_collectAchievements30:
title: "도전과제 콜렉터"
description: "30개의 도전과제를 획득했습니다"
_iLoveMisskey:
title: "I Love Misskey"
description: "\"I ❤ #Misskey\"를 포스트했습니다"
flavor: "Misskey를 이용해주셔서 감사합니다! - 개발팀 일동"
_client30min:
title: "잠깐 쉬어"
description: "클라이언트를 시작하고 30분이 경과하였습니다"
_noteDeletedWithin1min:
title: "있었는데요 없었습니다"
description: "노트를 포스트한 후 1분 이내에 삭제했습니다"
_postedAtLateNight:
title: "올빼미"
description: "한밤중에 노트를 포스트했습니다"
flavor: "잠 좀 자세요. 걱정돼요."
_postedAt0min0sec:
title: "정각"
description: "1초도 어긋나지 않은 정각에 노트를 포스트했습니다"
flavor: "째깍 째깍 째깍 땡!"
_selfQuote:
title: "혼잣말"
description: "자기 노트를 인용했습니다"
_htl20npm:
title: "타임라인 폭주 중"
description: "1분 사이에 홈 타임라인에 노트가 20개 넘게 생성되었습니다"
_driveFolderCircularReference:
title: "순환 참조"
description: "드라이브 폴더를 자신을 가리키도록 만드려 시도했습니다"
_reactWithoutRead:
title: "읽고 답하긴 하시는 건가요?"
description: "100자가 넘는 포스트에 3초 안에 포스트했습니다"
_clickedClickHere:
title: "여길 눌러보세요"
description: "이 곳을 눌러봤습니다"
_justPlainLucky:
title: "그냥 운이 좋았어"
description: "매 10초마다 0.01%의 확률로 달성됩니다"
_setNameToSyuilo:
title: "신 콤플렉스"
description: "이름을 syuilo로 설정했습니다"
_passedSinceAccountCreated1:
title: "1년"
description: "계정을 생성하고 1년이 지났습니다"
_passedSinceAccountCreated2:
title: "2년"
description: "계정을 생성하고 2년이 지났습니다"
_passedSinceAccountCreated3:
title: "3년"
description: "계정을 생성하고 3년이 지났습니다"
_loggedInOnBirthday:
title: "생일 축하합니다!"
description: "설정한 생일에 로그인했습니다"
_cookieClicked:
title: "쿠키 클리커 게임"
description: "쿠키를 클릭했습니다"
flavor: "뭔가 문제가 있나요?"
_brainDiver:
title: "Brain Diver"
description: "Brain Diver로의 링크를 첨부했습니다"
flavor: "Misskey-Misskey La-Tu-Ma"
_role: _role:
new: "새 역할 생성" new: "새 역할 생성"
edit: "역할 수정" edit: "역할 수정"
name: "역할 이름" name: "역할 이름"
description: "역할 설명" description: "역할 설명"
permission: "역할의 권한" permission: "역할 권한"
descriptionOfPermission: "<b>모더레이터</b>는 기본적인 중재와 관련된 작업을 수행할 수 있습니다.\n<b>관리자</b>는 인스턴스의 모든 설정을 변경할 수 있습니다." descriptionOfPermission: "<b>모더레이터</b>는 기본적인 중재와 관련된 작업을 수행할 수 있습니다.\n<b>관리자</b>는 인스턴스의 모든 설정을 변경할 수 있습니다."
assignTarget: "할당 대상" assignTarget: "할당 대상"
descriptionOfAssignTarget: "<b>수동</b>을 선택하면 누가 이 역할에 포함되는지를 수동으로 관리할 수 있습니다.\n<b>조건부</b>를 선택하면 조건을 설정해 일치하는 사용자를 자동으로 포함되게 할 수 있습니다." descriptionOfAssignTarget: "<b>수동</b>을 선택하면 누가 이 역할에 포함되는지를 수동으로 관리할 수 있습니다.\n<b>조건부</b>를 선택하면 조건을 설정해 일치하는 사용자를 자동으로 포함되게 할 수 있습니다."
@ -948,7 +1166,7 @@ _role:
conditional: "조건부" conditional: "조건부"
condition: "조건" condition: "조건"
isConditionalRole: "조건부 역할입니다." isConditionalRole: "조건부 역할입니다."
isPublic: "공개 역할" isPublic: "역할 공개"
descriptionOfIsPublic: "역할에 할당된 사용자를 누구나 볼 수 있습니다. 또한 사용자 프로필에 이 역할이 표시됩니다." descriptionOfIsPublic: "역할에 할당된 사용자를 누구나 볼 수 있습니다. 또한 사용자 프로필에 이 역할이 표시됩니다."
options: "옵션" options: "옵션"
policies: "정책" policies: "정책"
@ -956,7 +1174,7 @@ _role:
useBaseValue: "기본값 사용" useBaseValue: "기본값 사용"
chooseRoleToAssign: "할당할 역할 선택" chooseRoleToAssign: "할당할 역할 선택"
canEditMembersByModerator: "모더레이터의 역할 수정 허용" canEditMembersByModerator: "모더레이터의 역할 수정 허용"
descriptionOfCanEditMembersByModerator: "이 옵션을 켜면 모더레이터도 이 역할에 사용자를 추가하거나 삭제할 수 있습니다. 꺼져 있으면 관리자만 가능합니다." descriptionOfCanEditMembersByModerator: "이 옵션을 켜면 모더레이터도 이 역할에 사용자를 할당하거나 삭제할 수 있습니다. 꺼져 있으면 관리자만 할당이 가능합니다."
priority: "우선순위" priority: "우선순위"
_priority: _priority:
low: "낮음" low: "낮음"
@ -971,19 +1189,20 @@ _role:
driveCapacity: "드라이브 용량" driveCapacity: "드라이브 용량"
pinMax: "고정할 수 있는 노트 수" pinMax: "고정할 수 있는 노트 수"
antennaMax: "최대 안테나 생성 허용 수" antennaMax: "최대 안테나 생성 허용 수"
wordMuteMax: "뮤트할 수 있는 단어의 수" wordMuteMax: "단어 뮤트할 수 있는 문자 수"
webhookMax: "생성할 수 있는 WebHook의 수" webhookMax: "생성할 수 있는 웹훅 수"
clipMax: "생성할 수 있는 클립 수" clipMax: "생성할 수 있는 클립 수"
noteEachClipsMax: "각 클립에 추가할 수 있는 노트 수" noteEachClipsMax: "각 클립에 추가할 수 있는 노트 수"
userListMax: "생성할 수 있는 리스트 수" userListMax: "생성할 수 있는 유저 리스트 수"
userEachUserListsMax: "리스트당 최대 사용자 수" userEachUserListsMax: "유저 리스트당 최대 사용자 수"
rateLimitFactor: "속도 제한" rateLimitFactor: "속도 제한"
descriptionOfRateLimitFactor: "작을수록 제한이 완화되고, 클수록 제한이 강화됩니다." descriptionOfRateLimitFactor: "작을수록 제한이 완화되고, 클수록 제한이 강화됩니다."
canHideAds: "광고 숨기기"
_condition: _condition:
isLocal: "로컬 사용자" isLocal: "로컬 사용자"
isRemote: "리모트 사용자" isRemote: "리모트 사용자"
createdLessThan: "다음 일수 이내에 가입한 유저" createdLessThan: "가압한 지 다음 일수 이내인 유저"
createdMoreThan: "다음 일수 이상 활동한 유저" createdMoreThan: "가입한 지 다음 일수 이상인 유저"
followersLessThanOrEq: "팔로워 수가 다음 이하인 유저" followersLessThanOrEq: "팔로워 수가 다음 이하인 유저"
followersMoreThanOrEq: "팔로워 수가 다음 이상인 유저" followersMoreThanOrEq: "팔로워 수가 다음 이상인 유저"
followingLessThanOrEq: "팔로잉 수가 다음 이하인 유저" followingLessThanOrEq: "팔로잉 수가 다음 이하인 유저"
@ -1583,6 +1802,7 @@ _notification:
pollEnded: "투표 결과가 발표되었습니다" pollEnded: "투표 결과가 발표되었습니다"
unreadAntennaNote: "안테나 {name}" unreadAntennaNote: "안테나 {name}"
emptyPushNotificationMessage: "푸시 알림이 갱신되었습니다" emptyPushNotificationMessage: "푸시 알림이 갱신되었습니다"
achievementEarned: "도전 과제를 달성했습니다"
_types: _types:
all: "전부" all: "전부"
follow: "팔로잉" follow: "팔로잉"

View File

@ -109,6 +109,7 @@ clickToShow: "Klik om te bekijken"
sensitive: "NSFW" sensitive: "NSFW"
add: "Toevoegen" add: "Toevoegen"
reaction: "Reacties" reaction: "Reacties"
reactions: "Reacties"
reactionSetting: "Reacties die in de reactie-selector worden getoond" reactionSetting: "Reacties die in de reactie-selector worden getoond"
reactionSettingDescription2: "Sleep om opnieuw te ordenen, Klik om te verwijderen, Druk op \"+\" om toe te voegen" reactionSettingDescription2: "Sleep om opnieuw te ordenen, Klik om te verwijderen, Druk op \"+\" om toe te voegen"
rememberNoteVisibility: "Vergeet niet de notitie zichtbaarheidsinstellingen" rememberNoteVisibility: "Vergeet niet de notitie zichtbaarheidsinstellingen"

View File

@ -110,6 +110,7 @@ clickToShow: "Kliknij, aby wyświetlić"
sensitive: "NSFW" sensitive: "NSFW"
add: "Dodaj" add: "Dodaj"
reaction: "Reakcja" reaction: "Reakcja"
reactions: "Reakcja"
reactionSetting: "Reakcje do pokazania w wyborniku reakcji" reactionSetting: "Reakcje do pokazania w wyborniku reakcji"
reactionSettingDescription2: "Przeciągnij aby zmienić kolejność, naciśnij aby usunąć, naciśnij „+” aby dodać" reactionSettingDescription2: "Przeciągnij aby zmienić kolejność, naciśnij aby usunąć, naciśnij „+” aby dodać"
rememberNoteVisibility: "Zapamiętuj ustawienia widoczności wpisu" rememberNoteVisibility: "Zapamiętuj ustawienia widoczności wpisu"

View File

@ -107,6 +107,7 @@ clickToShow: "Clique para ver"
sensitive: "Conteúdo sensível" sensitive: "Conteúdo sensível"
add: "Adicionar" add: "Adicionar"
reaction: "Reações" reaction: "Reações"
reactions: "Reações"
reactionSetting: "Quais reações a mostrar no selecionador de reações" reactionSetting: "Quais reações a mostrar no selecionador de reações"
reactionSettingDescription2: "Arraste para reordenar, clique para excluir, pressione + para adicionar." reactionSettingDescription2: "Arraste para reordenar, clique para excluir, pressione + para adicionar."
rememberNoteVisibility: "Lembrar das configurações de visibilidade de notas" rememberNoteVisibility: "Lembrar das configurações de visibilidade de notas"

View File

@ -107,6 +107,7 @@ clickToShow: "Click pentru a afișa"
sensitive: "NSFW" sensitive: "NSFW"
add: "Adaugă" add: "Adaugă"
reaction: "Reacție" reaction: "Reacție"
reactions: "Reacție"
reactionSetting: "Reacții care să apară in selectorul de reacții" reactionSetting: "Reacții care să apară in selectorul de reacții"
reactionSettingDescription2: "Trage pentru a rearanja, apasă pe \"+\" pentru a adăuga." reactionSettingDescription2: "Trage pentru a rearanja, apasă pe \"+\" pentru a adăuga."
rememberNoteVisibility: "Amintește setarea de vizibilitate a notelor" rememberNoteVisibility: "Amintește setarea de vizibilitate a notelor"

View File

@ -107,6 +107,7 @@ clickToShow: "Нажмите для просмотра"
sensitive: "Содержимое не для всех" sensitive: "Содержимое не для всех"
add: "Добавить" add: "Добавить"
reaction: "Реакции" reaction: "Реакции"
reactions: "Реакции"
reactionSetting: "Реакции, отображаемые в палитре" reactionSetting: "Реакции, отображаемые в палитре"
reactionSettingDescription2: "Расставляйте перетаскиванием, удаляйте нажатием, добавляйте кнопкой «+»." reactionSettingDescription2: "Расставляйте перетаскиванием, удаляйте нажатием, добавляйте кнопкой «+»."
rememberNoteVisibility: "Запоминать видимость заметок" rememberNoteVisibility: "Запоминать видимость заметок"

View File

@ -110,6 +110,7 @@ clickToShow: "Kliknutím zobrazíte"
sensitive: "NSFW" sensitive: "NSFW"
add: "Pridať" add: "Pridať"
reaction: "Reakcie" reaction: "Reakcie"
reactions: "Reakcie"
reactionSetting: "Reakcie zobrazené vo výbere reakcií" reactionSetting: "Reakcie zobrazené vo výbere reakcií"
reactionSettingDescription2: "Ťahaním preusporiadate, kliknutím odstránite, Stlačením \"+\" pridáte" reactionSettingDescription2: "Ťahaním preusporiadate, kliknutím odstránite, Stlačením \"+\" pridáte"
rememberNoteVisibility: "Zapamätať nastavenia viditeľnosti poznámky" rememberNoteVisibility: "Zapamätať nastavenia viditeľnosti poznámky"

View File

@ -110,6 +110,7 @@ clickToShow: "Klicka för att visa"
sensitive: "Känsligt innehåll" sensitive: "Känsligt innehåll"
add: "Lägg till" add: "Lägg till"
reaction: "Reaktioner" reaction: "Reaktioner"
reactions: "Reaktioner"
reactionSetting: "Reaktioner som ska visas i reaktionsväljaren" reactionSetting: "Reaktioner som ska visas i reaktionsväljaren"
reactionSettingDescription2: "Dra för att omordna, klicka för att radera, tryck \"+\" för att lägga till." reactionSettingDescription2: "Dra för att omordna, klicka för att radera, tryck \"+\" för att lägga till."
rememberNoteVisibility: "Komihåg notvisningsinställningar" rememberNoteVisibility: "Komihåg notvisningsinställningar"

View File

@ -110,6 +110,7 @@ clickToShow: "คลิกเพื่อแสดง"
sensitive: "เนื้อหาที่ละเอียดอ่อน NSFW" sensitive: "เนื้อหาที่ละเอียดอ่อน NSFW"
add: "เพิ่ม" add: "เพิ่ม"
reaction: "รีแอคชั่น" reaction: "รีแอคชั่น"
reactions: "รีแอคชั่น"
reactionSetting: "รีแอคชั่นไปยังแสดงผลในตัวเลือกการรีแอคชั่น" reactionSetting: "รีแอคชั่นไปยังแสดงผลในตัวเลือกการรีแอคชั่น"
reactionSettingDescription2: "กดลากเพื่อจัดลำดับใหม่ กดคลิกเพื่อลบ กด \"+\" เพื่อเพิ่ม" reactionSettingDescription2: "กดลากเพื่อจัดลำดับใหม่ กดคลิกเพื่อลบ กด \"+\" เพื่อเพิ่ม"
rememberNoteVisibility: "จดจำการตั้งค่าการมองเห็นตัวโน้ต" rememberNoteVisibility: "จดจำการตั้งค่าการมองเห็นตัวโน้ต"
@ -932,6 +933,23 @@ assign: "กำหนด"
unassign: "ยังไม่มอบหมาย" unassign: "ยังไม่มอบหมาย"
color: "สี" color: "สี"
manageCustomEmojis: "จัดการอีโมจิแบบกำหนดเอง" manageCustomEmojis: "จัดการอีโมจิแบบกำหนดเอง"
youCannotCreateAnymore: "คุณถึงขีดจํากัดการสร้างแล้วนะ"
cannotPerformTemporary: "ไม่สามารถใช้การได้ชั่วคราว"
cannotPerformTemporaryDescription: "การดําเนินการนี้ไม่สามารถดําเนินการได้ชั่วคราว เนื่องจากเกินขีดจํากัดการดําเนินการ กรุณารอสักครู่แล้วลองใหม่อีกครั้งนะค่ะ"
preset: "พรีเซ็ต"
selectFromPresets: "เลือกจากการพรีเซ็ต"
achievements: "ความสำเร็จ"
_achievements:
earnedAt: "ได้รับเมื่อ"
_types:
_followers100:
title: "บุคคลที่เป็นที่นิยม"
_followers500:
title: "เสาสัญญาณ"
_iLoveMisskey:
title: "ฉันรัก Misskey"
_driveFolderCircularReference:
title: "อ้างอิงวงจร"
_role: _role:
new: "บทบาทใหม่" new: "บทบาทใหม่"
edit: "แก้ไขบทบาท" edit: "แก้ไขบทบาท"
@ -948,6 +966,7 @@ _role:
isPublic: "บทบาทสาธารณะ" isPublic: "บทบาทสาธารณะ"
descriptionOfIsPublic: "ทุกคนสามารถดูได้ว่าผู้ใช้งานนั้นได้รับมอบหมายบทบาทด้วยหรือไม่ \n\nบทบาทจะแสดงในโปรไฟล์ของผู้ใช้ด้วย" descriptionOfIsPublic: "ทุกคนสามารถดูได้ว่าผู้ใช้งานนั้นได้รับมอบหมายบทบาทด้วยหรือไม่ \n\nบทบาทจะแสดงในโปรไฟล์ของผู้ใช้ด้วย"
options: "ตัวเลือกบทบาท" options: "ตัวเลือกบทบาท"
policies: "นโยบาย"
baseRole: "บทบาทพื้นฐาน" baseRole: "บทบาทพื้นฐาน"
useBaseValue: "ใช้บทบาทพื้นฐานเริ่มต้น" useBaseValue: "ใช้บทบาทพื้นฐานเริ่มต้น"
chooseRoleToAssign: "เลือกบทบาทที่ต้องการกำหนด" chooseRoleToAssign: "เลือกบทบาทที่ต้องการกำหนด"
@ -965,7 +984,17 @@ _role:
canInvite: "สร้างรหัสเชิญอินสแตนซ์" canInvite: "สร้างรหัสเชิญอินสแตนซ์"
canManageCustomEmojis: "จัดการอีโมจิแบบกำหนดเอง" canManageCustomEmojis: "จัดการอีโมจิแบบกำหนดเอง"
driveCapacity: "ความจุของไดรฟ์" driveCapacity: "ความจุของไดรฟ์"
pinMax: "จํานวนสูงสุดของโน้ตที่ปักหมุดไว้"
antennaMax: "จำนวนสูงสุดของเสาอากาศ" antennaMax: "จำนวนสูงสุดของเสาอากาศ"
wordMuteMax: "จำนวนอักขระสูงสุดที่อนุญาตในการปิดเสียงคำ"
webhookMax: "จำนวนเว็บฮุคสูงสุด"
clipMax: "จำนวนคลิปสูงสุด"
noteEachClipsMax: "จำนวนโน้ตสูงสุดภายในคลิป"
userListMax: "จำนวนรายชื่อผู้ใช้สูงสุด"
userEachUserListsMax: "จำนวนผู้ใช้สูงสุดภายในรายการผู้ใช้"
rateLimitFactor: "ขีดจำกัดอัตรา"
descriptionOfRateLimitFactor: "ขีดจํากัดอัตราที่ต่ำกว่ามีข้อจํากัดน้อยกว่าข้อจํากัดที่สูงกว่า"
canHideAds: "ซ่อนโฆษณา"
_condition: _condition:
isLocal: "ผู้ใช้ภายใน" isLocal: "ผู้ใช้ภายใน"
isRemote: "ผู้ใช้ระยะไกล" isRemote: "ผู้ใช้ระยะไกล"
@ -1570,6 +1599,7 @@ _notification:
pollEnded: "โพลสำรวจความคิดเห็นผลลัพธ์มีพร้อมใช้งาน" pollEnded: "โพลสำรวจความคิดเห็นผลลัพธ์มีพร้อมใช้งาน"
unreadAntennaNote: "เสาอากาศ {name}" unreadAntennaNote: "เสาอากาศ {name}"
emptyPushNotificationMessage: "การแจ้งเตือนแบบพุชได้รับการอัพเดทแล้ว" emptyPushNotificationMessage: "การแจ้งเตือนแบบพุชได้รับการอัพเดทแล้ว"
achievementEarned: "รับความสำเร็จ"
_types: _types:
all: "ทั้งหมด" all: "ทั้งหมด"
follow: "กำลังติดตาม" follow: "กำลังติดตาม"

View File

@ -109,6 +109,7 @@ clickToShow: "Натисніть для перегляду"
sensitive: "NSFW" sensitive: "NSFW"
add: "Додати" add: "Додати"
reaction: "Реакції" reaction: "Реакції"
reactions: "Реакції"
reactionSetting: "Налаштування реакцій" reactionSetting: "Налаштування реакцій"
reactionSettingDescription2: "Перемістити щоб змінити порядок, Клацнути мишою щоб видалити, Натиснути \"+\" щоб додати." reactionSettingDescription2: "Перемістити щоб змінити порядок, Клацнути мишою щоб видалити, Натиснути \"+\" щоб додати."
rememberNoteVisibility: "Пам’ятати параметри видимісті" rememberNoteVisibility: "Пам’ятати параметри видимісті"
@ -586,7 +587,7 @@ pluginTokenRequestedDescription: "Цей плагін зможе викорис
notificationType: "Тип сповіщення" notificationType: "Тип сповіщення"
edit: "Редагувати" edit: "Редагувати"
useStarForReactionFallback: "Використовувати ★ як запасний варіант, якщо емодзі реакції невідомий" useStarForReactionFallback: "Використовувати ★ як запасний варіант, якщо емодзі реакції невідомий"
emailServer: "Сервер електронної пошти" emailServer: "Email сервер"
enableEmail: "Увімкнути функцію доставки пошти" enableEmail: "Увімкнути функцію доставки пошти"
emailConfigInfo: "Використовується для підтвердження електронної пошти підчас реєстрації, а також для відновлення паролю." emailConfigInfo: "Використовується для підтвердження електронної пошти підчас реєстрації, а також для відновлення паролю."
email: "E-mail" email: "E-mail"
@ -892,7 +893,10 @@ unsubscribePushNotification: "Вимкнути push-сповіщення"
windowMaximize: "Розгорнути" windowMaximize: "Розгорнути"
windowRestore: "Відновити" windowRestore: "Відновити"
caption: "Підпис" caption: "Підпис"
tools: "Інструменти"
like: "Вподобати" like: "Вподобати"
unlike: "Не вподобати"
numberOfLikes: "Вподобання"
show: "Відображення" show: "Відображення"
color: "Колір" color: "Колір"
_role: _role:

View File

@ -107,6 +107,7 @@ clickToShow: "Nhấn để xem"
sensitive: "Nhạy cảm" sensitive: "Nhạy cảm"
add: "Thêm" add: "Thêm"
reaction: "Biểu cảm" reaction: "Biểu cảm"
reactions: "Biểu cảm"
reactionSetting: "Chọn những biểu cảm hiển thị" reactionSetting: "Chọn những biểu cảm hiển thị"
reactionSettingDescription2: "Kéo để sắp xếp, nhấn để xóa, nhấn \"+\" để thêm." reactionSettingDescription2: "Kéo để sắp xếp, nhấn để xóa, nhấn \"+\" để thêm."
rememberNoteVisibility: "Lưu kiểu tút mặc định" rememberNoteVisibility: "Lưu kiểu tút mặc định"

View File

@ -110,6 +110,7 @@ clickToShow: "点击以显示"
sensitive: "敏感内容" sensitive: "敏感内容"
add: "添加" add: "添加"
reaction: "回应" reaction: "回应"
reactions: "回应"
reactionSetting: "在选择器中显示的回应" reactionSetting: "在选择器中显示的回应"
reactionSettingDescription2: "拖动重新排序,单击删除,点击 + 添加。" reactionSettingDescription2: "拖动重新排序,单击删除,点击 + 添加。"
rememberNoteVisibility: "保存上次设置的可见性" rememberNoteVisibility: "保存上次设置的可见性"
@ -607,7 +608,7 @@ wordMute: "文字屏蔽"
regexpError: "正则表达式错误" regexpError: "正则表达式错误"
regexpErrorDescription: "{tab} 屏蔽文字的第 {line} 行的正则表达式有错误:" regexpErrorDescription: "{tab} 屏蔽文字的第 {line} 行的正则表达式有错误:"
instanceMute: "实例的屏蔽" instanceMute: "实例的屏蔽"
userSaysSomething: "{name}说了什么,但是被屏蔽了" userSaysSomething: "{name}说了什么,但是被屏蔽词过滤了"
makeActive: "启用" makeActive: "启用"
display: "显示" display: "显示"
copy: "复制" copy: "复制"
@ -826,7 +827,7 @@ makeReactionsPublicDescription: "将您发表过的回应设置成公开可见
classic: "经典" classic: "经典"
muteThread: "屏蔽帖子列表" muteThread: "屏蔽帖子列表"
unmuteThread: "取消屏蔽帖子列表" unmuteThread: "取消屏蔽帖子列表"
ffVisibility: "连接的可见范围" ffVisibility: "关注关系的可见范围"
ffVisibilityDescription: "您可以设置您的关注/关注者信息的公开范围" ffVisibilityDescription: "您可以设置您的关注/关注者信息的公开范围"
continueThread: "查看更多帖子" continueThread: "查看更多帖子"
deleteAccountConfirm: "将要删除账户。是否确认?" deleteAccountConfirm: "将要删除账户。是否确认?"
@ -981,6 +982,7 @@ _role:
userEachUserListsMax: "单个用户列表内用户数量限制" userEachUserListsMax: "单个用户列表内用户数量限制"
rateLimitFactor: "速率限制" rateLimitFactor: "速率限制"
descriptionOfRateLimitFactor: "值越小限制越少,值越大限制越多。" descriptionOfRateLimitFactor: "值越小限制越少,值越大限制越多。"
canHideAds: "可以隐藏广告"
_condition: _condition:
isLocal: "是本地用户" isLocal: "是本地用户"
isRemote: "是远程用户" isRemote: "是远程用户"
@ -1008,7 +1010,7 @@ _emailUnavailable:
mx: "邮件服务器不正确" mx: "邮件服务器不正确"
smtp: "邮件服务器没有响应" smtp: "邮件服务器没有响应"
_ffVisibility: _ffVisibility:
public: "发布" public: "公开"
followers: "只有关注你的用户能看到" followers: "只有关注你的用户能看到"
private: "私密" private: "私密"
_signup: _signup:

View File

@ -110,6 +110,7 @@ clickToShow: "按一下以顯示"
sensitive: "敏感內容" sensitive: "敏感內容"
add: "新增" add: "新增"
reaction: "情感" reaction: "情感"
reactions: "情感"
reactionSetting: "在選擇器中顯示反應" reactionSetting: "在選擇器中顯示反應"
reactionSettingDescription2: "拖動以重新列序,點擊以刪除,按下 + 添加。" reactionSettingDescription2: "拖動以重新列序,點擊以刪除,按下 + 添加。"
rememberNoteVisibility: "記住貼文可見性" rememberNoteVisibility: "記住貼文可見性"
@ -389,7 +390,7 @@ administrator: "管理員"
token: "權杖" token: "權杖"
twoStepAuthentication: "兩階段驗證" twoStepAuthentication: "兩階段驗證"
moderator: "監察員" moderator: "監察員"
moderation: "言論調節" moderation: "監察"
nUsersMentioned: "提到了{n}" nUsersMentioned: "提到了{n}"
securityKey: "安全金鑰" securityKey: "安全金鑰"
securityKeyName: "金鑰名稱" securityKeyName: "金鑰名稱"
@ -932,8 +933,67 @@ assign: "指派"
unassign: "取消指派" unassign: "取消指派"
color: "顏色" color: "顏色"
manageCustomEmojis: "管理自訂表情符號" manageCustomEmojis: "管理自訂表情符號"
youCannotCreateAnymore: "您無法再建立更多了。"
cannotPerformTemporary: "暫時無法進行" cannotPerformTemporary: "暫時無法進行"
cannotPerformTemporaryDescription: "由於超過操作次數限制,暫時無法進行。請過一段時間之後再嘗試。" cannotPerformTemporaryDescription: "由於超過操作次數限制,暫時無法進行。請過一段時間之後再嘗試。"
preset: "預設值"
selectFromPresets: "從預設值中選擇"
achievements: "成就"
_achievements:
earnedAt: "獲得日期"
_types:
_notes1:
title: "just setting up my msky"
description: "發出了第一則貼文"
flavor: "祝您的Misskey生活愉快"
_notes10:
title: "若干貼文"
description: "發表了10則貼文"
_notes100:
title: "許多的貼文"
description: "發表了100則貼文"
_notes500:
title: "滿滿的貼文"
description: "發表了500則貼文"
_notes1000:
title: "一堆貼文"
description: "發表了1000則貼文"
_notes5000:
title: "滔滔不絕的貼文"
description: "發表了5000則貼文"
_notes10000:
title: "超級貼文"
description: "發表了10000則貼文"
_notes20000:
title: "需要更多的貼文"
description: "發表了20000則貼文"
_notes30000:
title: "貼文貼文貼文"
description: "發表了30000則貼文"
_notes40000:
title: "貼文工廠"
description: "發表了40000則貼文"
_notes50000:
title: "貼文星球"
description: "發表了50000則貼文"
_notes60000:
title: "貼文類星體"
description: "發表了60000則貼文"
_notes70000:
title: "貼文黑洞"
description: "發表了70000則貼文"
_notes80000:
title: "貼文銀河"
description: "發表了80000則貼文"
_notes90000:
title: "貼文宇宙"
description: "發表了90000則貼文"
_notes100000:
description: "發表了100,000則貼文"
flavor: "有這麼多東西要寫嗎?"
_login3:
title: "初學者 I"
description: "總登入天數為3天"
_role: _role:
new: "建立角色" new: "建立角色"
edit: "編輯角色" edit: "編輯角色"
@ -970,8 +1030,15 @@ _role:
driveCapacity: "雲端硬碟容量" driveCapacity: "雲端硬碟容量"
pinMax: "置頂貼文的最大數量" pinMax: "置頂貼文的最大數量"
antennaMax: "可建立的天線數量" antennaMax: "可建立的天線數量"
wordMuteMax: "靜音文字的最大字數"
webhookMax: "可建立的Webhook數量" webhookMax: "可建立的Webhook數量"
clipMax: "可建立的摘錄數量" clipMax: "可建立的摘錄數量"
noteEachClipsMax: "摘錄內貼文的最大數量"
userListMax: "可建立的使用者清單數量"
userEachUserListsMax: "使用者清單內使用者的最大數量"
rateLimitFactor: "速率限制"
descriptionOfRateLimitFactor: "值越小限制越少,值越大限制越多。"
canHideAds: "不顯示廣告"
_condition: _condition:
isLocal: "本地使用者" isLocal: "本地使用者"
isRemote: "遠端使用者" isRemote: "遠端使用者"

View File

@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "13.0.0", "version": "13.1.0",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",
@ -38,7 +38,7 @@
"cleanall": "pnpm clean-all" "cleanall": "pnpm clean-all"
}, },
"resolutions": { "resolutions": {
"chokidar": "^3.3.1", "chokidar": "^3.5.3",
"lodash": "^4.17.21" "lodash": "^4.17.21"
}, },
"dependencies": { "dependencies": {

View File

@ -9,7 +9,17 @@
"transform": { "transform": {
"legacyDecorator": true, "legacyDecorator": true,
"decoratorMetadata": true "decoratorMetadata": true
} },
"experimental": {
"keepImportAssertions": true
},
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
},
"target": "es2021"
}, },
"minify": false "minify": false
} }

View File

@ -0,0 +1,11 @@
export class flashScriptLength1674086433654 {
name = 'flashScriptLength1674086433654'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "flash" ALTER COLUMN "script" TYPE character varying(32768)`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "flash" ALTER COLUMN "script" TYPE character varying(16384)`);
}
}

View File

@ -0,0 +1,33 @@
export class achievement1674118260469 {
name = 'achievement1674118260469'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "notification" ADD "achievement" character varying(128)`);
await queryRunner.query(`ALTER TABLE "user_profile" ADD "achievements" jsonb NOT NULL DEFAULT '[]'`);
await queryRunner.query(`ALTER TYPE "public"."notification_type_enum" RENAME TO "notification_type_enum_old"`);
await queryRunner.query(`CREATE TYPE "public"."notification_type_enum" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'achievementEarned', 'app')`);
await queryRunner.query(`ALTER TABLE "notification" ALTER COLUMN "type" TYPE "public"."notification_type_enum" USING "type"::"text"::"public"."notification_type_enum"`);
await queryRunner.query(`DROP TYPE "public"."notification_type_enum_old"`);
await queryRunner.query(`ALTER TYPE "public"."user_profile_mutingnotificationtypes_enum" RENAME TO "user_profile_mutingnotificationtypes_enum_old"`);
await queryRunner.query(`CREATE TYPE "public"."user_profile_mutingnotificationtypes_enum" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'achievementEarned', 'app')`);
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"[] USING "mutingNotificationTypes"::"text"::"public"."user_profile_mutingnotificationtypes_enum"[]`);
await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" SET DEFAULT '{}'`);
await queryRunner.query(`DROP TYPE "public"."user_profile_mutingnotificationtypes_enum_old"`);
}
async down(queryRunner) {
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"`);
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 "user_profile" DROP COLUMN "achievements"`);
await queryRunner.query(`ALTER TABLE "notification" DROP COLUMN "achievement"`);
}
}

View File

@ -0,0 +1,11 @@
export class loggedInDates1674255666603 {
name = 'loggedInDates1674255666603'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_profile" ADD "loggedInDates" character varying(32) array NOT NULL DEFAULT '{}'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "loggedInDates"`);
}
}

View File

@ -7,6 +7,8 @@
"start": "node ./built/index.js", "start": "node ./built/index.js",
"start:test": "NODE_ENV=test node ./built/index.js", "start:test": "NODE_ENV=test node ./built/index.js",
"migrate": "pnpm typeorm migration:run -d ormconfig.js", "migrate": "pnpm typeorm migration:run -d ormconfig.js",
"build:swc": "swc src -d built -D",
"watch:swc": "swc src -d built -D -w",
"build": "tsc -p tsconfig.json || echo done. && tsc-alias -p tsconfig.json", "build": "tsc -p tsconfig.json || echo done. && tsc-alias -p tsconfig.json",
"watch": "node watch.mjs", "watch": "node watch.mjs",
"lint": "tsc --noEmit && eslint --quiet \"src/**/*.ts\"", "lint": "tsc --noEmit && eslint --quiet \"src/**/*.ts\"",
@ -129,6 +131,7 @@
}, },
"devDependencies": { "devDependencies": {
"@redocly/openapi-core": "1.0.0-beta.120", "@redocly/openapi-core": "1.0.0-beta.120",
"@swc/cli": "^0.1.59",
"@swc/core": "1.3.26", "@swc/core": "1.3.26",
"@swc/jest": "0.2.24", "@swc/jest": "0.2.24",
"@types/accepts": "1.3.5", "@types/accepts": "1.3.5",

View File

@ -1,13 +1,13 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ServerModule } from '@/server/ServerModule.js'; import { ServerModule } from '@/server/ServerModule.js';
import { GlobalModule } from '@/GlobalModule.js'; import { GlobalModule } from '@/GlobalModule.js';
import { QueueProcessorModule } from '@/queue/QueueProcessorModule.js'; import { DaemonModule } from '@/daemons/DaemonModule.js';
@Module({ @Module({
imports: [ imports: [
GlobalModule, GlobalModule,
ServerModule, ServerModule,
QueueProcessorModule, DaemonModule,
], ],
}) })
export class RootModule {} export class MainModule {}

View File

@ -17,6 +17,9 @@ import { JanitorService } from '@/daemons/JanitorService.js';
import { QueueStatsService } from '@/daemons/QueueStatsService.js'; import { QueueStatsService } from '@/daemons/QueueStatsService.js';
import { ServerStatsService } from '@/daemons/ServerStatsService.js'; import { ServerStatsService } from '@/daemons/ServerStatsService.js';
import { NestLogger } from '@/NestLogger.js'; import { NestLogger } from '@/NestLogger.js';
import { ChartManagementService } from '@/core/chart/ChartManagementService.js';
import { ServerService } from '@/server/ServerService.js';
import { MainModule } from '@/MainModule.js';
import { envOption } from '../env.js'; import { envOption } from '../env.js';
const _filename = fileURLToPath(import.meta.url); const _filename = fileURLToPath(import.meta.url);
@ -70,6 +73,15 @@ export async function masterMain() {
process.exit(1); process.exit(1);
} }
const app = await NestFactory.createApplicationContext(MainModule, {
logger: new NestLogger(),
});
app.enableShutdownHooks();
// start server
const serverService = app.get(ServerService);
serverService.launch();
bootLogger.succ('Misskey initialized'); bootLogger.succ('Misskey initialized');
if (!envOption.disableClustering) { if (!envOption.disableClustering) {
@ -78,15 +90,10 @@ export async function masterMain() {
bootLogger.succ(`Now listening on port ${config.port} on ${config.url}`, null, true); bootLogger.succ(`Now listening on port ${config.port} on ${config.url}`, null, true);
if (!envOption.noDaemons) { app.get(ChartManagementService).start();
const daemons = await NestFactory.createApplicationContext(DaemonModule, { app.get(JanitorService).start();
logger: new NestLogger(), app.get(QueueStatsService).start();
}); app.get(ServerStatsService).start();
daemons.enableShutdownHooks();
daemons.get(JanitorService).start();
daemons.get(QueueStatsService).start();
daemons.get(ServerStatsService).start();
}
} }
function showEnvironment(): void { function showEnvironment(): void {

View File

@ -1,32 +1,23 @@
import cluster from 'node:cluster'; import cluster from 'node:cluster';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { envOption } from '@/env.js';
import { ChartManagementService } from '@/core/chart/ChartManagementService.js'; import { ChartManagementService } from '@/core/chart/ChartManagementService.js';
import { ServerService } from '@/server/ServerService.js';
import { QueueProcessorService } from '@/queue/QueueProcessorService.js'; import { QueueProcessorService } from '@/queue/QueueProcessorService.js';
import { NestLogger } from '@/NestLogger.js'; import { NestLogger } from '@/NestLogger.js';
import { RootModule } from '../RootModule.js'; import { QueueProcessorModule } from '@/queue/QueueProcessorModule.js';
/** /**
* Init worker process * Init worker process
*/ */
export async function workerMain() { export async function workerMain() {
const app = await NestFactory.createApplicationContext(RootModule, { const jobQueue = await NestFactory.createApplicationContext(QueueProcessorModule, {
logger: new NestLogger(), logger: new NestLogger(),
}); });
app.enableShutdownHooks(); jobQueue.enableShutdownHooks();
// start server
const serverService = app.get(ServerService);
serverService.launch();
// start job queue // start job queue
if (!envOption.onlyServer) { jobQueue.get(QueueProcessorService).start();
const queueProcessorService = app.get(QueueProcessorService);
queueProcessorService.start();
}
app.get(ChartManagementService).run(); jobQueue.get(ChartManagementService).start();
if (cluster.isWorker) { if (cluster.isWorker) {
// Send a 'ready' message to parent process // Send a 'ready' message to parent process

View File

@ -0,0 +1,118 @@
import { Inject, Injectable } from '@nestjs/common';
import type { UserProfilesRepository, UsersRepository } from '@/models/index.js';
import type { User } from '@/models/entities/User.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { CreateNotificationService } from '@/core/CreateNotificationService.js';
const ACHIEVEMENT_TYPES = [
'notes1',
'notes10',
'notes100',
'notes500',
'notes1000',
'notes5000',
'notes10000',
'notes20000',
'notes30000',
'notes40000',
'notes50000',
'notes60000',
'notes70000',
'notes80000',
'notes90000',
'notes100000',
'login3',
'login7',
'login15',
'login30',
'login60',
'login100',
'login200',
'login300',
'login400',
'login500',
'login600',
'login700',
'login800',
'login900',
'login1000',
'passedSinceAccountCreated1',
'passedSinceAccountCreated2',
'passedSinceAccountCreated3',
'loggedInOnBirthday',
'loggedInOnNewYearsDay',
'noteClipped1',
'noteFavorited1',
'profileFilled',
'markedAsCat',
'following1',
'following10',
'following50',
'following100',
'following300',
'followers1',
'followers10',
'followers50',
'followers100',
'followers300',
'followers500',
'followers1000',
'collectAchievements30',
'viewAchievements3min',
'iLoveMisskey',
'client30min',
'noteDeletedWithin1min',
'postedAtLateNight',
'postedAt0min0sec',
'selfQuote',
'htl20npm',
'outputHelloWorldOnScratchpad',
'open3windows',
'driveFolderCircularReference',
'reactWithoutRead',
'clickedClickHere',
'justPlainLucky',
'setNameToSyuilo',
'cookieClicked',
'brainDiver',
] as const;
@Injectable()
export class AchievementService {
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
private createNotificationService: CreateNotificationService,
) {
}
@bindThis
public async create(
userId: User['id'],
type: string,
): Promise<void> {
if (!ACHIEVEMENT_TYPES.includes(type)) return;
const date = Date.now();
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: userId });
if (profile.achievements.some(a => a.name === type)) return;
await this.userProfilesRepository.update(userId, {
achievements: [...profile.achievements, {
name: type,
unlockedAt: date,
}],
});
this.createNotificationService.createNotification(userId, 'achievementEarned', {
achievement: type,
});
}
}

View File

@ -4,6 +4,7 @@ import { AccountUpdateService } from './AccountUpdateService.js';
import { AiService } from './AiService.js'; import { AiService } from './AiService.js';
import { AntennaService } from './AntennaService.js'; import { AntennaService } from './AntennaService.js';
import { AppLockService } from './AppLockService.js'; import { AppLockService } from './AppLockService.js';
import { AchievementService } from './AchievementService.js';
import { CaptchaService } from './CaptchaService.js'; import { CaptchaService } from './CaptchaService.js';
import { CreateNotificationService } from './CreateNotificationService.js'; import { CreateNotificationService } from './CreateNotificationService.js';
import { CreateSystemUserService } from './CreateSystemUserService.js'; import { CreateSystemUserService } from './CreateSystemUserService.js';
@ -128,6 +129,7 @@ const $AccountUpdateService: Provider = { provide: 'AccountUpdateService', useEx
const $AiService: Provider = { provide: 'AiService', useExisting: AiService }; const $AiService: Provider = { provide: 'AiService', useExisting: AiService };
const $AntennaService: Provider = { provide: 'AntennaService', useExisting: AntennaService }; const $AntennaService: Provider = { provide: 'AntennaService', useExisting: AntennaService };
const $AppLockService: Provider = { provide: 'AppLockService', useExisting: AppLockService }; const $AppLockService: Provider = { provide: 'AppLockService', useExisting: AppLockService };
const $AchievementService: Provider = { provide: 'AchievementService', useExisting: AchievementService };
const $CaptchaService: Provider = { provide: 'CaptchaService', useExisting: CaptchaService }; const $CaptchaService: Provider = { provide: 'CaptchaService', useExisting: CaptchaService };
const $CreateNotificationService: Provider = { provide: 'CreateNotificationService', useExisting: CreateNotificationService }; const $CreateNotificationService: Provider = { provide: 'CreateNotificationService', useExisting: CreateNotificationService };
const $CreateSystemUserService: Provider = { provide: 'CreateSystemUserService', useExisting: CreateSystemUserService }; const $CreateSystemUserService: Provider = { provide: 'CreateSystemUserService', useExisting: CreateSystemUserService };
@ -255,6 +257,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
AiService, AiService,
AntennaService, AntennaService,
AppLockService, AppLockService,
AchievementService,
CaptchaService, CaptchaService,
CreateNotificationService, CreateNotificationService,
CreateSystemUserService, CreateSystemUserService,
@ -376,6 +379,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$AiService, $AiService,
$AntennaService, $AntennaService,
$AppLockService, $AppLockService,
$AchievementService,
$CaptchaService, $CaptchaService,
$CreateNotificationService, $CreateNotificationService,
$CreateSystemUserService, $CreateSystemUserService,
@ -498,6 +502,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
AiService, AiService,
AntennaService, AntennaService,
AppLockService, AppLockService,
AchievementService,
CaptchaService, CaptchaService,
CreateNotificationService, CreateNotificationService,
CreateSystemUserService, CreateSystemUserService,
@ -618,6 +623,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$AiService, $AiService,
$AntennaService, $AntennaService,
$AppLockService, $AppLockService,
$AchievementService,
$CaptchaService, $CaptchaService,
$CreateNotificationService, $CreateNotificationService,
$CreateSystemUserService, $CreateSystemUserService,

View File

@ -125,7 +125,7 @@ export class UndiciFetcher {
...(options.headers ?? {}), ...(options.headers ?? {}),
}, },
}).catch((err) => { }).catch((err) => {
this.logger?.error('fetch error', err); this.logger?.error(`fetch error to ${typeof url === 'string' ? url : url.href}`, err);
throw new StatusError('Resource Unreachable', 500, 'Resource Unreachable'); throw new StatusError('Resource Unreachable', 500, 'Resource Unreachable');
}); });
if (!res.ok && !privateOptions.noOkError) { if (!res.ok && !privateOptions.noOkError) {

View File

@ -57,7 +57,7 @@ export class ApRequestService {
method: 'POST', method: 'POST',
headers: this.objectAssignWithLcKey({ headers: this.objectAssignWithLcKey({
'Date': new Date().toUTCString(), 'Date': new Date().toUTCString(),
'Host': u.hostname, 'Host': u.host,
'Content-Type': 'application/activity+json', 'Content-Type': 'application/activity+json',
'Digest': digestHeader, 'Digest': digestHeader,
}, args.additionalHeaders), }, args.additionalHeaders),
@ -83,7 +83,7 @@ export class ApRequestService {
headers: this.objectAssignWithLcKey({ headers: this.objectAssignWithLcKey({
'Accept': 'application/activity+json, application/ld+json', 'Accept': 'application/activity+json, application/ld+json',
'Date': new Date().toUTCString(), 'Date': new Date().toUTCString(),
'Host': new URL(args.url).hostname, 'Host': new URL(args.url).host,
}, args.additionalHeaders), }, args.additionalHeaders),
}; };
@ -106,6 +106,8 @@ export class ApRequestService {
request.headers = this.objectAssignWithLcKey(request.headers, { request.headers = this.objectAssignWithLcKey(request.headers, {
Signature: signatureHeader, Signature: signatureHeader,
}); });
// node-fetch will generate this for us. if we keep 'Host', it won't change with redirects!
delete request.headers['host'];
return { return {
request, request,

View File

@ -54,7 +54,7 @@ export class ChartManagementService implements OnApplicationShutdown {
} }
@bindThis @bindThis
public async run() { public async start() {
// 20分おきにメモリ情報をDBに書き込み // 20分おきにメモリ情報をDBに書き込み
this.saveIntervalId = setInterval(() => { this.saveIntervalId = setInterval(() => {
for (const chart of this.charts) { for (const chart of this.charts) {

View File

@ -114,6 +114,9 @@ export class NotificationEntityService implements OnModuleInit {
...(notification.type === 'groupInvited' ? { ...(notification.type === 'groupInvited' ? {
invitation: this.userGroupInvitationEntityService.pack(notification.userGroupInvitationId!), invitation: this.userGroupInvitationEntityService.pack(notification.userGroupInvitationId!),
} : {}), } : {}),
...(notification.type === 'achievementEarned' ? {
achievement: notification.achievement,
} : {}),
...(notification.type === 'app' ? { ...(notification.type === 'app' ? {
body: notification.customBody, body: notification.customBody,
header: notification.customHeader ?? token?.name, header: notification.customHeader ?? token?.name,

View File

@ -12,7 +12,7 @@ import { Cache } from '@/misc/cache.js';
import type { Instance } from '@/models/entities/Instance.js'; import type { Instance } from '@/models/entities/Instance.js';
import type { ILocalUser, IRemoteUser, User } from '@/models/entities/User.js'; import type { ILocalUser, IRemoteUser, User } from '@/models/entities/User.js';
import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/entities/User.js'; import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/entities/User.js';
import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, NotificationsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, MessagingMessagesRepository, UserGroupJoiningsRepository, AnnouncementsRepository, AntennaNotesRepository, PagesRepository } from '@/models/index.js'; import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, NotificationsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, MessagingMessagesRepository, UserGroupJoiningsRepository, AnnouncementsRepository, AntennaNotesRepository, PagesRepository, UserProfile } from '@/models/index.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import type { OnModuleInit } from '@nestjs/common'; import type { OnModuleInit } from '@nestjs/common';
@ -343,6 +343,7 @@ export class UserEntityService implements OnModuleInit {
options?: { options?: {
detail?: D, detail?: D,
includeSecrets?: boolean, includeSecrets?: boolean,
userProfile?: UserProfile,
}, },
): Promise<IsMeAndIsUserDetailed<ExpectsMe, D>> { ): Promise<IsMeAndIsUserDetailed<ExpectsMe, D>> {
const opts = Object.assign({ const opts = Object.assign({
@ -375,7 +376,7 @@ export class UserEntityService implements OnModuleInit {
.innerJoinAndSelect('pin.note', 'note') .innerJoinAndSelect('pin.note', 'note')
.orderBy('pin.id', 'DESC') .orderBy('pin.id', 'DESC')
.getMany() : []; .getMany() : [];
const profile = opts.detail ? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }) : null; const profile = opts.detail ? (opts.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id })) : null;
const followingCount = profile == null ? null : const followingCount = profile == null ? null :
(profile.ffVisibility === 'public') || isMe ? user.followingCount : (profile.ffVisibility === 'public') || isMe ? user.followingCount :
@ -493,6 +494,8 @@ export class UserEntityService implements OnModuleInit {
mutingNotificationTypes: profile!.mutingNotificationTypes, mutingNotificationTypes: profile!.mutingNotificationTypes,
emailNotificationTypes: profile!.emailNotificationTypes, emailNotificationTypes: profile!.emailNotificationTypes,
showTimelineReplies: user.showTimelineReplies ?? falsy, showTimelineReplies: user.showTimelineReplies ?? falsy,
achievements: profile!.achievements,
loggedInDays: profile!.loggedInDates.length,
} : {}), } : {}),
...(opts.includeSecrets ? { ...(opts.includeSecrets ? {

View File

@ -44,7 +44,7 @@ export class Flash {
public user: User | null; public user: User | null;
@Column('varchar', { @Column('varchar', {
length: 16384, length: 32768,
}) })
public script: string; public script: string;

View File

@ -64,6 +64,7 @@ export class Notification {
* receiveFollowRequest - * receiveFollowRequest -
* followRequestAccepted - * followRequestAccepted -
* groupInvited - * groupInvited -
* achievementEarned -
* app - * app -
*/ */
@Index() @Index()
@ -129,6 +130,11 @@ export class Notification {
}) })
public choice: number | null; public choice: number | null;
@Column('varchar', {
length: 128, nullable: true,
})
public achievement: string | null;
/** /**
* body * body
*/ */

View File

@ -213,6 +213,19 @@ export class UserProfile {
}) })
public mutingNotificationTypes: typeof notificationTypes[number][]; public mutingNotificationTypes: typeof notificationTypes[number][];
@Column('varchar', {
length: 32, array: true, default: '{}',
})
public loggedInDates: string[];
@Column('jsonb', {
default: [],
})
public achievements: {
name: string;
unlockedAt: number;
}[];
//#region Denormalized fields //#region Denormalized fields
@Index() @Index()
@Column('varchar', { @Column('varchar', {

View File

@ -1,5 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CoreModule } from '@/core/CoreModule.js'; import { CoreModule } from '@/core/CoreModule.js';
import { GlobalModule } from '@/GlobalModule.js';
import { QueueLoggerService } from './QueueLoggerService.js'; import { QueueLoggerService } from './QueueLoggerService.js';
import { QueueProcessorService } from './QueueProcessorService.js'; import { QueueProcessorService } from './QueueProcessorService.js';
import { DbQueueProcessorsService } from './DbQueueProcessorsService.js'; import { DbQueueProcessorsService } from './DbQueueProcessorsService.js';
@ -34,6 +35,7 @@ import { ExportFavoritesProcessorService } from './processors/ExportFavoritesPro
@Module({ @Module({
imports: [ imports: [
GlobalModule,
CoreModule, CoreModule,
], ],
providers: [ providers: [

View File

@ -14,6 +14,7 @@ import { genIdenticon } from '@/misc/gen-identicon.js';
import { createTemp } from '@/misc/create-temp.js'; import { createTemp } from '@/misc/create-temp.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { LoggerService } from '@/core/LoggerService.js'; import { LoggerService } from '@/core/LoggerService.js';
import { bindThis } from '@/decorators.js';
import { ActivityPubServerService } from './ActivityPubServerService.js'; import { ActivityPubServerService } from './ActivityPubServerService.js';
import { NodeinfoServerService } from './NodeinfoServerService.js'; import { NodeinfoServerService } from './NodeinfoServerService.js';
import { ApiServerService } from './api/ApiServerService.js'; import { ApiServerService } from './api/ApiServerService.js';
@ -22,7 +23,6 @@ import { WellKnownServerService } from './WellKnownServerService.js';
import { MediaProxyServerService } from './MediaProxyServerService.js'; import { MediaProxyServerService } from './MediaProxyServerService.js';
import { FileServerService } from './FileServerService.js'; import { FileServerService } from './FileServerService.js';
import { ClientServerService } from './web/ClientServerService.js'; import { ClientServerService } from './web/ClientServerService.js';
import { bindThis } from '@/decorators.js';
@Injectable() @Injectable()
export class ServerService { export class ServerService {
@ -82,13 +82,13 @@ export class ServerService {
fastify.get<{ Params: { path: string }; Querystring: { static?: any; }; }>('/emoji/:path(.*)', async (request, reply) => { fastify.get<{ Params: { path: string }; Querystring: { static?: any; }; }>('/emoji/:path(.*)', async (request, reply) => {
const path = request.params.path; const path = request.params.path;
reply.header('Cache-Control', 'public, max-age=86400');
if (!path.match(/^[a-zA-Z0-9\-_@\.]+?\.webp$/)) { if (!path.match(/^[a-zA-Z0-9\-_@\.]+?\.webp$/)) {
reply.code(404); reply.code(404);
return; return;
} }
reply.header('Cache-Control', 'public, max-age=86400');
const name = path.split('@')[0].replace('.webp', ''); const name = path.split('@')[0].replace('.webp', '');
const host = path.split('@')[1]?.replace('.webp', ''); const host = path.split('@')[1]?.replace('.webp', '');
@ -101,7 +101,12 @@ export class ServerService {
reply.header('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\''); reply.header('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\'');
if (emoji == null) { if (emoji == null) {
if ('fallback' in request.query) {
return await reply.redirect('/static-assets/emoji-unknown.png'); return await reply.redirect('/static-assets/emoji-unknown.png');
} else {
reply.code(404);
return;
}
} }
const url = new URL('/proxy/emoji.webp', this.config.url); const url = new URL('/proxy/emoji.webp', this.config.url);
@ -127,6 +132,8 @@ export class ServerService {
relations: ['avatar'], relations: ['avatar'],
}); });
reply.header('Cache-Control', 'public, max-age=86400');
if (user) { if (user) {
reply.redirect(this.userEntityService.getAvatarUrlSync(user)); reply.redirect(this.userEntityService.getAvatarUrlSync(user));
} else { } else {
@ -138,6 +145,7 @@ export class ServerService {
const [temp, cleanup] = await createTemp(); const [temp, cleanup] = await createTemp();
await genIdenticon(request.params.x, fs.createWriteStream(temp)); await genIdenticon(request.params.x, fs.createWriteStream(temp));
reply.header('Content-Type', 'image/png'); reply.header('Content-Type', 'image/png');
reply.header('Cache-Control', 'public, max-age=86400');
return fs.createReadStream(temp).on('close', () => cleanup()); return fs.createReadStream(temp).on('close', () => cleanup());
}); });

View File

@ -175,6 +175,7 @@ import * as ep___i_2fa_removeKey from './endpoints/i/2fa/remove-key.js';
import * as ep___i_2fa_unregister from './endpoints/i/2fa/unregister.js'; import * as ep___i_2fa_unregister from './endpoints/i/2fa/unregister.js';
import * as ep___i_apps from './endpoints/i/apps.js'; import * as ep___i_apps from './endpoints/i/apps.js';
import * as ep___i_authorizedApps from './endpoints/i/authorized-apps.js'; import * as ep___i_authorizedApps from './endpoints/i/authorized-apps.js';
import * as ep___i_claimAchievement from './endpoints/i/claim-achievement.js';
import * as ep___i_changePassword from './endpoints/i/change-password.js'; import * as ep___i_changePassword from './endpoints/i/change-password.js';
import * as ep___i_deleteAccount from './endpoints/i/delete-account.js'; import * as ep___i_deleteAccount from './endpoints/i/delete-account.js';
import * as ep___i_exportBlocking from './endpoints/i/export-blocking.js'; import * as ep___i_exportBlocking from './endpoints/i/export-blocking.js';
@ -329,6 +330,7 @@ import * as ep___users_searchByUsernameAndHost from './endpoints/users/search-by
import * as ep___users_search from './endpoints/users/search.js'; import * as ep___users_search from './endpoints/users/search.js';
import * as ep___users_show from './endpoints/users/show.js'; import * as ep___users_show from './endpoints/users/show.js';
import * as ep___users_stats from './endpoints/users/stats.js'; import * as ep___users_stats from './endpoints/users/stats.js';
import * as ep___users_achievements from './endpoints/users/achievements.js';
import * as ep___fetchRss from './endpoints/fetch-rss.js'; import * as ep___fetchRss from './endpoints/fetch-rss.js';
import * as ep___retention from './endpoints/retention.js'; import * as ep___retention from './endpoints/retention.js';
import { GetterService } from './GetterService.js'; import { GetterService } from './GetterService.js';
@ -509,6 +511,7 @@ const $i_2fa_removeKey: Provider = { provide: 'ep:i/2fa/remove-key', useClass: e
const $i_2fa_unregister: Provider = { provide: 'ep:i/2fa/unregister', useClass: ep___i_2fa_unregister.default }; const $i_2fa_unregister: Provider = { provide: 'ep:i/2fa/unregister', useClass: ep___i_2fa_unregister.default };
const $i_apps: Provider = { provide: 'ep:i/apps', useClass: ep___i_apps.default }; const $i_apps: Provider = { provide: 'ep:i/apps', useClass: ep___i_apps.default };
const $i_authorizedApps: Provider = { provide: 'ep:i/authorized-apps', useClass: ep___i_authorizedApps.default }; const $i_authorizedApps: Provider = { provide: 'ep:i/authorized-apps', useClass: ep___i_authorizedApps.default };
const $i_claimAchievement: Provider = { provide: 'ep:i/claim-achievement', useClass: ep___i_claimAchievement.default };
const $i_changePassword: Provider = { provide: 'ep:i/change-password', useClass: ep___i_changePassword.default }; const $i_changePassword: Provider = { provide: 'ep:i/change-password', useClass: ep___i_changePassword.default };
const $i_deleteAccount: Provider = { provide: 'ep:i/delete-account', useClass: ep___i_deleteAccount.default }; const $i_deleteAccount: Provider = { provide: 'ep:i/delete-account', useClass: ep___i_deleteAccount.default };
const $i_exportBlocking: Provider = { provide: 'ep:i/export-blocking', useClass: ep___i_exportBlocking.default }; const $i_exportBlocking: Provider = { provide: 'ep:i/export-blocking', useClass: ep___i_exportBlocking.default };
@ -663,6 +666,7 @@ const $users_searchByUsernameAndHost: Provider = { provide: 'ep:users/search-by-
const $users_search: Provider = { provide: 'ep:users/search', useClass: ep___users_search.default }; const $users_search: Provider = { provide: 'ep:users/search', useClass: ep___users_search.default };
const $users_show: Provider = { provide: 'ep:users/show', useClass: ep___users_show.default }; const $users_show: Provider = { provide: 'ep:users/show', useClass: ep___users_show.default };
const $users_stats: Provider = { provide: 'ep:users/stats', useClass: ep___users_stats.default }; const $users_stats: Provider = { provide: 'ep:users/stats', useClass: ep___users_stats.default };
const $users_achievements: Provider = { provide: 'ep:users/achievements', useClass: ep___users_achievements.default };
const $fetchRss: Provider = { provide: 'ep:fetch-rss', useClass: ep___fetchRss.default }; const $fetchRss: Provider = { provide: 'ep:fetch-rss', useClass: ep___fetchRss.default };
const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention.default }; const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention.default };
@ -847,6 +851,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$i_2fa_unregister, $i_2fa_unregister,
$i_apps, $i_apps,
$i_authorizedApps, $i_authorizedApps,
$i_claimAchievement,
$i_changePassword, $i_changePassword,
$i_deleteAccount, $i_deleteAccount,
$i_exportBlocking, $i_exportBlocking,
@ -1001,6 +1006,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$users_search, $users_search,
$users_show, $users_show,
$users_stats, $users_stats,
$users_achievements,
$fetchRss, $fetchRss,
$retention, $retention,
], ],
@ -1179,6 +1185,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$i_2fa_unregister, $i_2fa_unregister,
$i_apps, $i_apps,
$i_authorizedApps, $i_authorizedApps,
$i_claimAchievement,
$i_changePassword, $i_changePassword,
$i_deleteAccount, $i_deleteAccount,
$i_exportBlocking, $i_exportBlocking,
@ -1331,6 +1338,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$users_search, $users_search,
$users_show, $users_show,
$users_stats, $users_stats,
$users_achievements,
$fetchRss, $fetchRss,
$retention, $retention,
], ],

View File

@ -174,6 +174,7 @@ import * as ep___i_2fa_removeKey from './endpoints/i/2fa/remove-key.js';
import * as ep___i_2fa_unregister from './endpoints/i/2fa/unregister.js'; import * as ep___i_2fa_unregister from './endpoints/i/2fa/unregister.js';
import * as ep___i_apps from './endpoints/i/apps.js'; import * as ep___i_apps from './endpoints/i/apps.js';
import * as ep___i_authorizedApps from './endpoints/i/authorized-apps.js'; import * as ep___i_authorizedApps from './endpoints/i/authorized-apps.js';
import * as ep___i_claimAchievement from './endpoints/i/claim-achievement.js';
import * as ep___i_changePassword from './endpoints/i/change-password.js'; import * as ep___i_changePassword from './endpoints/i/change-password.js';
import * as ep___i_deleteAccount from './endpoints/i/delete-account.js'; import * as ep___i_deleteAccount from './endpoints/i/delete-account.js';
import * as ep___i_exportBlocking from './endpoints/i/export-blocking.js'; import * as ep___i_exportBlocking from './endpoints/i/export-blocking.js';
@ -328,6 +329,7 @@ import * as ep___users_searchByUsernameAndHost from './endpoints/users/search-by
import * as ep___users_search from './endpoints/users/search.js'; import * as ep___users_search from './endpoints/users/search.js';
import * as ep___users_show from './endpoints/users/show.js'; import * as ep___users_show from './endpoints/users/show.js';
import * as ep___users_stats from './endpoints/users/stats.js'; import * as ep___users_stats from './endpoints/users/stats.js';
import * as ep___users_achievements from './endpoints/users/achievements.js';
import * as ep___fetchRss from './endpoints/fetch-rss.js'; import * as ep___fetchRss from './endpoints/fetch-rss.js';
import * as ep___retention from './endpoints/retention.js'; import * as ep___retention from './endpoints/retention.js';
@ -506,6 +508,7 @@ const eps = [
['i/2fa/unregister', ep___i_2fa_unregister], ['i/2fa/unregister', ep___i_2fa_unregister],
['i/apps', ep___i_apps], ['i/apps', ep___i_apps],
['i/authorized-apps', ep___i_authorizedApps], ['i/authorized-apps', ep___i_authorizedApps],
['i/claim-achievement', ep___i_claimAchievement],
['i/change-password', ep___i_changePassword], ['i/change-password', ep___i_changePassword],
['i/delete-account', ep___i_deleteAccount], ['i/delete-account', ep___i_deleteAccount],
['i/export-blocking', ep___i_exportBlocking], ['i/export-blocking', ep___i_exportBlocking],
@ -660,6 +663,7 @@ const eps = [
['users/search', ep___users_search], ['users/search', ep___users_search],
['users/show', ep___users_show], ['users/show', ep___users_show],
['users/stats', ep___users_stats], ['users/stats', ep___users_stats],
['users/achievements', ep___users_achievements],
['fetch-rss', ep___fetchRss], ['fetch-rss', ep___fetchRss],
['retention', ep___retention], ['retention', ep___retention],
]; ];

View File

@ -28,8 +28,8 @@ export const meta = {
recursiveNesting: { recursiveNesting: {
message: 'It can not be structured like nesting folders recursively.', message: 'It can not be structured like nesting folders recursively.',
code: 'NO_SUCH_PARENT_FOLDER', code: 'RECURSIVE_NESTING',
id: 'ce104e3a-faaf-49d5-b459-10ff0cbbcaa1', id: 'dbeb024837894013aed44279f9199740',
}, },
}, },

View File

@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { UsersRepository } from '@/models/index.js'; import type { UserProfilesRepository, UsersRepository } from '@/models/index.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
@ -29,15 +29,36 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
) { ) {
super(meta, paramDef, async (ps, user, token) => { super(meta, paramDef, async (ps, user, token) => {
const isSecure = token == null; const isSecure = token == null;
// ここで渡ってきている user はキャッシュされていて古い可能性もあるので id だけ渡す const now = new Date();
return await this.userEntityService.pack<true, true>(user.id, user, { const today = `${now.getFullYear()}/${now.getMonth() + 1}/${now.getDate()}`;
// 渡ってきている user はキャッシュされていて古い可能性があるので改めて取得
const userProfile = await this.userProfilesRepository.findOneOrFail({
where: {
userId: user.id,
},
relations: ['user'],
});
if (!userProfile.loggedInDates.includes(today)) {
this.userProfilesRepository.update({ userId: user.id }, {
loggedInDates: [...userProfile.loggedInDates, today],
});
userProfile.loggedInDates = [...userProfile.loggedInDates, today];
}
return await this.userEntityService.pack<true, true>(userProfile.user!, userProfile.user!, {
detail: true, detail: true,
includeSecrets: isSecure, includeSecrets: isSecure,
userProfile,
}); });
}); });
} }

View File

@ -0,0 +1,28 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { AchievementService } from '@/core/AchievementService.js';
export const meta = {
requireCredential: true,
} as const;
export const paramDef = {
type: 'object',
properties: {
name: { type: 'string' },
},
required: ['name'],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
private achievementService: AchievementService,
) {
super(meta, paramDef, async (ps, me) => {
await this.achievementService.create(me.id, ps.name);
});
}
}

View File

@ -0,0 +1,31 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { UserProfilesRepository } from '@/models/index.js';
import { DI } from '@/di-symbols.js';
export const meta = {
requireCredential: true,
} as const;
export const paramDef = {
type: 'object',
properties: {
userId: { type: 'string', format: 'misskey:id' },
},
required: ['userId'],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
) {
super(meta, paramDef, async (ps, me) => {
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: ps.userId });
return profile.achievements;
});
}
}

View File

@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
import Redis from 'ioredis'; import Redis from 'ioredis';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { IsNull } from 'typeorm'; import { IsNull } from 'typeorm';
import autwh from 'autwh'; import * as autwh from 'autwh';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import type { UserProfilesRepository, UsersRepository } from '@/models/index.js'; import type { UserProfilesRepository, UsersRepository } from '@/models/index.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';

View File

@ -24,6 +24,11 @@
const v = localStorage.getItem('v') || VERSION; const v = localStorage.getItem('v') || VERSION;
let forceError = localStorage.getItem('forceError');
if (forceError != null) {
renderError('FORCED_ERROR', 'This error is forced by having forceError in local storage.')
}
//#region Detect language & fetch translations //#region Detect language & fetch translations
const localeVersion = localStorage.getItem('localeVersion'); const localeVersion = localStorage.getItem('localeVersion');
const localeOutdated = (localeVersion == null || localeVersion !== v); const localeOutdated = (localeVersion == null || localeVersion !== v);

View File

@ -1,4 +1,4 @@
export const notificationTypes = ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app'] as const; export const notificationTypes = ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'achievementEarned', 'app'] as const;
export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const; export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const;

View File

@ -2,11 +2,11 @@ import { defineAsyncComponent, reactive } from 'vue';
import * as misskey from 'misskey-js'; import * as misskey from 'misskey-js';
import { showSuspendedDialog } from './scripts/show-suspended-dialog'; import { showSuspendedDialog } from './scripts/show-suspended-dialog';
import { i18n } from './i18n'; import { i18n } from './i18n';
import { miLocalStorage } from './local-storage';
import { del, get, set } from '@/scripts/idb-proxy'; import { del, get, set } from '@/scripts/idb-proxy';
import { apiUrl } from '@/config'; import { apiUrl } from '@/config';
import { waiting, api, popup, popupMenu, success, alert } from '@/os'; import { waiting, api, popup, popupMenu, success, alert } from '@/os';
import { unisonReload, reloadChannel } from '@/scripts/unison-reload'; import { unisonReload, reloadChannel } from '@/scripts/unison-reload';
import { miLocalStorage } from './local-storage';
// TODO: 他のタブと永続化されたstateを同期 // TODO: 他のタブと永続化されたstateを同期
@ -20,6 +20,11 @@ export const $i = accountData ? reactive(JSON.parse(accountData) as Account) : n
export const iAmModerator = $i != null && ($i.isAdmin || $i.isModerator); export const iAmModerator = $i != null && ($i.isAdmin || $i.isModerator);
export const iAmAdmin = $i != null && $i.isAdmin; export const iAmAdmin = $i != null && $i.isAdmin;
export let notesCount = $i == null ? 0 : $i.notesCount;
export function incNotesCount() {
notesCount++;
}
export async function signout() { export async function signout() {
waiting(); waiting();
miLocalStorage.removeItem('account'); miLocalStorage.removeItem('account');

View File

@ -0,0 +1,224 @@
<template>
<div>
<div v-if="achievements" :class="$style.root">
<div v-for="achievement in achievements" :key="achievement" :class="$style.achievement" class="_panel">
<div :class="$style.icon">
<div :class="[$style.iconFrame, $style['iconFrame_' + ACHIEVEMENT_BADGES[achievement.name].frame]]">
<div :class="[$style.iconInner]" :style="{ background: ACHIEVEMENT_BADGES[achievement.name].bg }">
<img :class="$style.iconImg" :src="ACHIEVEMENT_BADGES[achievement.name].img">
</div>
</div>
</div>
<div :class="$style.body">
<div :class="$style.header">
<span :class="$style.title">{{ i18n.ts._achievements._types['_' + achievement.name].title }}</span>
<span :class="$style.time">
<time v-tooltip="new Date(achievement.unlockedAt).toLocaleString()">{{ new Date(achievement.unlockedAt).getFullYear() }}/{{ new Date(achievement.unlockedAt).getMonth() + 1 }}/{{ new Date(achievement.unlockedAt).getDate() }}</time>
</span>
</div>
<div :class="$style.description">{{ i18n.ts._achievements._types['_' + achievement.name].description }}</div>
<div v-if="i18n.ts._achievements._types['_' + achievement.name].flavor" :class="$style.flavor">{{ i18n.ts._achievements._types['_' + achievement.name].flavor }}</div>
</div>
</div>
<template v-if="withLocked">
<div v-for="achievement in lockedAchievements" :key="achievement" :class="[$style.achievement, $style.locked]" class="_panel" @click="achievement === 'clickedClickHere' ? clickHere() : () => {}">
<div :class="$style.icon">
</div>
<div :class="$style.body">
<div :class="$style.header">
<span :class="$style.title">???</span>
</div>
<div :class="$style.description">???</div>
</div>
</div>
</template>
</div>
<div v-else>
<MkLoading/>
</div>
</div>
</template>
<script lang="ts" setup>
import * as misskey from 'misskey-js';
import { onMounted } from 'vue';
import * as os from '@/os';
import { i18n } from '@/i18n';
import { ACHIEVEMENT_TYPES, ACHIEVEMENT_BADGES, claimAchievement } from '@/scripts/achievements';
const props = withDefaults(defineProps<{
user: misskey.entities.User;
withLocked: boolean;
}>(), {
withLocked: true,
});
let achievements = $ref();
const lockedAchievements = $computed(() => ACHIEVEMENT_TYPES.filter(x => !(achievements ?? []).some(a => a.name === x)));
function fetch() {
os.api('users/achievements', { userId: props.user.id }).then(res => {
achievements = [];
for (const t of ACHIEVEMENT_TYPES) {
const a = res.find(x => x.name === t);
if (a) achievements.push(a);
}
//achievements = res.sort((a, b) => b.unlockedAt - a.unlockedAt);
});
}
function clickHere() {
claimAchievement('clickedClickHere');
fetch();
}
onMounted(() => {
fetch();
});
</script>
<style lang="scss" module>
.root {
display: grid;
grid-template-columns: repeat(auto-fill, min(380px, 100%));
grid-gap: 12px;
place-content: center;
}
.achievement {
display: flex;
padding: 16px;
&.locked {
opacity: 0.5;
}
}
.icon {
flex-shrink: 0;
margin-right: 12px;
}
@keyframes shine {
0% { translate: -30px; }
100% { translate: -130px; }
}
.iconFrame {
width: 58px;
height: 58px;
padding: 6px;
border-radius: 100%;
box-sizing: border-box;
pointer-events: none;
user-select: none;
filter: drop-shadow(0px 2px 2px #00000044);
box-shadow: 0 1px 0px #ffffff88 inset;
overflow: clip;
}
.iconFrame_bronze {
background: linear-gradient(0deg, #703827, #d37566);
> .iconInner {
background: linear-gradient(0deg, #d37566, #703827);
}
}
.iconFrame_silver {
background: linear-gradient(0deg, #7c7c7c, #e1e1e1);
> .iconInner {
background: linear-gradient(0deg, #e1e1e1, #7c7c7c);
}
}
.iconFrame_gold {
background: linear-gradient(0deg, rgba(255,182,85,1) 0%, rgba(233,133,0,1) 49%, rgba(255,243,93,1) 51%, rgba(255,187,25,1) 100%);
> .iconInner {
background: linear-gradient(0deg, #ffee20, #eb7018);
}
&:before {
content: "";
display: block;
position: absolute;
top: 30px;
width: 200px;
height: 8px;
rotate: -45deg;
translate: -30px;
background: #ffffff88;
animation: shine 2s infinite;
}
}
.iconFrame_platinum {
background: linear-gradient(0deg, rgba(154,154,154,1) 0%, rgba(226,226,226,1) 49%, rgba(255,255,255,1) 51%, rgba(195,195,195,1) 100%);
> .iconInner {
background: linear-gradient(0deg, #e1e1e1, #7c7c7c);
}
&:before {
content: "";
display: block;
position: absolute;
top: 30px;
width: 200px;
height: 8px;
rotate: -45deg;
translate: -30px;
background: #ffffffee;
animation: shine 2s infinite;
}
}
.iconInner {
position: relative;
width: 100%;
height: 100%;
border-radius: 100%;
box-shadow: 0 1px 0px #ffffff88 inset;
}
.iconImg {
width: calc(100% - 12px);
height: calc(100% - 12px);
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
margin: auto;
filter: drop-shadow(0px 1px 2px #000000aa);
}
.body {
flex: 1;
min-width: 0;
}
.header {
margin-bottom: 8px;
display: flex;
}
.title {
font-weight: bold;
}
.time {
margin-left: auto;
font-size: 85%;
opacity: 0.7;
}
.description {
font-size: 85%;
}
.flavor {
opacity: 0.7;
transform: skewX(-15deg);
font-size: 85%;
margin-top: 8px;
}
</style>

View File

@ -7,9 +7,9 @@
</div> </div>
<span v-else-if="c.type === 'text'" :class="{ [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }" :style="{ fontSize: c.size ? `${c.size * 100}%` : null, fontWeight: c.bold ? 'bold' : null, color: c.color ?? null }">{{ c.text }}</span> <span v-else-if="c.type === 'text'" :class="{ [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }" :style="{ fontSize: c.size ? `${c.size * 100}%` : null, fontWeight: c.bold ? 'bold' : null, color: c.color ?? null }">{{ c.text }}</span>
<Mfm v-else-if="c.type === 'mfm'" :class="{ [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }" :style="{ fontSize: c.size ? `${c.size * 100}%` : null, fontWeight: c.bold ? 'bold' : null, color: c.color ?? null }" :text="c.text"/> <Mfm v-else-if="c.type === 'mfm'" :class="{ [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }" :style="{ fontSize: c.size ? `${c.size * 100}%` : null, fontWeight: c.bold ? 'bold' : null, color: c.color ?? null }" :text="c.text"/>
<MkButton v-else-if="c.type === 'button'" :primary="c.primary" :rounded="c.rounded" :small="size === 'small'" @click="c.onClick">{{ c.text }}</MkButton> <MkButton v-else-if="c.type === 'button'" :primary="c.primary" :rounded="c.rounded" :disabled="c.disabled" :small="size === 'small'" inline @click="c.onClick">{{ c.text }}</MkButton>
<div v-else-if="c.type === 'buttons'" class="_buttons"> <div v-else-if="c.type === 'buttons'" class="_buttons" :style="{ justifyContent: align }">
<MkButton v-for="button in c.buttons" :primary="button.primary" :rounded="button.rounded" :small="size === 'small'" @click="button.onClick">{{ button.text }}</MkButton> <MkButton v-for="button in c.buttons" :primary="button.primary" :rounded="button.rounded" :disabled="button.disabled" inline :small="size === 'small'" @click="button.onClick">{{ button.text }}</MkButton>
</div> </div>
<MkSwitch v-else-if="c.type === 'switch'" :model-value="valueForSwitch" @update:model-value="onSwitchUpdate"> <MkSwitch v-else-if="c.type === 'switch'" :model-value="valueForSwitch" @update:model-value="onSwitchUpdate">
<template v-if="c.label" #label>{{ c.label }}</template> <template v-if="c.label" #label>{{ c.label }}</template>
@ -41,7 +41,7 @@
</MkFolder> </MkFolder>
<div v-else-if="c.type === 'container'" :class="[$style.container, { [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace', [$style.containerCenter]: c.align === 'center' }]" :style="{ backgroundColor: c.bgColor ?? null, color: c.fgColor ?? null, borderWidth: c.borderWidth ? `${c.borderWidth}px` : 0, borderColor: c.borderColor ?? 'var(--divider)', padding: c.padding ? `${c.padding}px` : 0, borderRadius: c.rounded ? '8px' : 0 }"> <div v-else-if="c.type === 'container'" :class="[$style.container, { [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace', [$style.containerCenter]: c.align === 'center' }]" :style="{ backgroundColor: c.bgColor ?? null, color: c.fgColor ?? null, borderWidth: c.borderWidth ? `${c.borderWidth}px` : 0, borderColor: c.borderColor ?? 'var(--divider)', padding: c.padding ? `${c.padding}px` : 0, borderRadius: c.rounded ? '8px' : 0 }">
<template v-for="child in c.children" :key="child"> <template v-for="child in c.children" :key="child">
<MkAsUi v-if="!g(child).hidden" :component="g(child)" :components="props.components" :size="size"/> <MkAsUi v-if="!g(child).hidden" :component="g(child)" :components="props.components" :size="size" :align="c.align"/>
</template> </template>
</div> </div>
</div> </div>
@ -62,8 +62,10 @@ const props = withDefaults(defineProps<{
component: AsUiComponent; component: AsUiComponent;
components: Ref<AsUiComponent>[]; components: Ref<AsUiComponent>[];
size: 'small' | 'medium' | 'large'; size: 'small' | 'medium' | 'large';
align: 'left' | 'center' | 'right';
}>(), { }>(), {
size: 'medium', size: 'medium',
align: 'left',
}); });
const c = props.component; const c = props.component;

View File

@ -20,6 +20,7 @@ import * as os from '@/os';
import { useInterval } from '@/scripts/use-interval'; import { useInterval } from '@/scripts/use-interval';
import * as game from '@/scripts/clicker-game'; import * as game from '@/scripts/clicker-game';
import number from '@/filters/number'; import number from '@/filters/number';
import { claimAchievement } from '@/scripts/achievements';
defineProps<{ defineProps<{
}>(); }>();
@ -30,14 +31,18 @@ let cps = $ref(0);
let prevCookies = $ref(0); let prevCookies = $ref(0);
function onClick(ev: MouseEvent) { function onClick(ev: MouseEvent) {
const x = ev.clientX;
const y = ev.clientY;
os.popup(MkPlusOneEffect, { x, y }, {}, 'end');
saveData.value!.cookies++; saveData.value!.cookies++;
saveData.value!.totalCookies++; saveData.value!.totalCookies++;
saveData.value!.totalHandmadeCookies++; saveData.value!.totalHandmadeCookies++;
saveData.value!.clicked++; saveData.value!.clicked++;
const x = ev.clientX; if (cookies.value === 1) {
const y = ev.clientY; claimAchievement('cookieClicked');
os.popup(MkPlusOneEffect, { x, y }, {}, 'end'); }
} }
useInterval(() => { useInterval(() => {

View File

@ -33,6 +33,7 @@ import * as Misskey from 'misskey-js';
import * as os from '@/os'; import * as os from '@/os';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { defaultStore } from '@/store'; import { defaultStore } from '@/store';
import { claimAchievement } from '@/scripts/achievements';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
folder: Misskey.entities.DriveFolder; folder: Misskey.entities.DriveFolder;
@ -159,9 +160,11 @@ function onDrop(ev: DragEvent) {
}).then(() => { }).then(() => {
// noop // noop
}).catch(err => { }).catch(err => {
switch (err) { switch (err.code) {
case 'detected-circular-definition': case 'RECURSIVE_NESTING':
claimAchievement('driveFolderCircularReference');
os.alert({ os.alert({
type: 'error',
title: i18n.ts.unableToProcess, title: i18n.ts.unableToProcess,
text: i18n.ts.circularReferenceFolder, text: i18n.ts.circularReferenceFolder,
}); });

View File

@ -99,6 +99,7 @@ import { stream } from '@/stream';
import { defaultStore } from '@/store'; import { defaultStore } from '@/store';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { uploadFile, uploads } from '@/scripts/upload'; import { uploadFile, uploads } from '@/scripts/upload';
import { claimAchievement } from '@/scripts/achievements';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
initialFolder?: Misskey.entities.DriveFolder; initialFolder?: Misskey.entities.DriveFolder;
@ -268,9 +269,11 @@ function onDrop(ev: DragEvent): any {
}).then(() => { }).then(() => {
// noop // noop
}).catch(err => { }).catch(err => {
switch (err) { switch (err.code) {
case 'detected-circular-definition': case 'RECURSIVE_NESTING':
claimAchievement('driveFolderCircularReference');
os.alert({ os.alert({
type: 'error',
title: i18n.ts.unableToProcess, title: i18n.ts.unableToProcess,
text: i18n.ts.circularReferenceFolder, text: i18n.ts.circularReferenceFolder,
}); });

View File

@ -35,6 +35,8 @@ import * as Misskey from 'misskey-js';
import * as os from '@/os'; import * as os from '@/os';
import { stream } from '@/stream'; import { stream } from '@/stream';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { claimAchievement } from '@/scripts/achievements';
import { $i } from '@/account';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
user: Misskey.entities.UserDetailed, user: Misskey.entities.UserDetailed,
@ -90,6 +92,21 @@ async function onClick() {
userId: props.user.id, userId: props.user.id,
}); });
hasPendingFollowRequestFromYou = true; hasPendingFollowRequestFromYou = true;
claimAchievement('following1');
if ($i.followingCount >= 10) {
claimAchievement('following10');
}
if ($i.followingCount >= 50) {
claimAchievement('following50');
}
if ($i.followingCount >= 100) {
claimAchievement('following100');
}
if ($i.followingCount >= 300) {
claimAchievement('following300');
}
} }
} }
} catch (err) { } catch (err) {

View File

@ -13,7 +13,7 @@
:href="image.url" :href="image.url"
:title="image.name" :title="image.name"
> >
<ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.comment" :title="image.comment" :cover="false"/> <ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.comment || image.name" :title="image.comment || image.name" :cover="false"/>
<div v-if="image.type === 'image/gif'" class="gif">GIF</div> <div v-if="image.type === 'image/gif'" class="gif">GIF</div>
</a> </a>
<button v-tooltip="$ts.hide" class="_button hide" @click="hide = true"><i class="ti ti-eye-off"></i></button> <button v-tooltip="$ts.hide" class="_button hide" @click="hide = true"><i class="ti ti-eye-off"></i></button>

View File

@ -45,7 +45,8 @@ onMounted(() => {
src: media.url, src: media.url,
w: media.properties.width, w: media.properties.width,
h: media.properties.height, h: media.properties.height,
alt: media.name, alt: media.comment || media.name,
comment: media.comment || media.name,
}; };
if (media.properties.orientation != null && media.properties.orientation >= 5) { if (media.properties.orientation != null && media.properties.orientation >= 5) {
[item.w, item.h] = [item.h, item.w]; [item.w, item.h] = [item.h, item.w];
@ -69,6 +70,7 @@ onMounted(() => {
}, },
imageClickAction: 'close', imageClickAction: 'close',
tapAction: 'toggle-controls', tapAction: 'toggle-controls',
bgOpacity: 1,
pswpModule: PhotoSwipe, pswpModule: PhotoSwipe,
}); });
@ -88,9 +90,28 @@ onMounted(() => {
[itemData.w, itemData.h] = [itemData.h, itemData.w]; [itemData.w, itemData.h] = [itemData.h, itemData.w];
} }
itemData.msrc = file.thumbnailUrl; itemData.msrc = file.thumbnailUrl;
itemData.alt = file.comment || file.name;
itemData.comment = file.comment || file.name;
itemData.thumbCropped = true; itemData.thumbCropped = true;
}); });
lightbox.on('uiRegister', () => {
lightbox.pswp.ui.registerElement({
name: 'altText',
className: 'pwsp__alt-text-container',
appendTo: 'wrapper',
onInit: (el, pwsp) => {
let textBox = document.createElement('p');
textBox.className = 'pwsp__alt-text _acrylic';
el.appendChild(textBox);
pwsp.on('change', (a) => {
textBox.textContent = pwsp.currSlide.data.comment;
});
},
});
});
lightbox.init(); lightbox.init();
}); });
@ -185,5 +206,36 @@ const previewable = (file: misskey.entities.DriveFile): boolean => {
// //
//z-index: v-bind(pswpZIndex); //z-index: v-bind(pswpZIndex);
z-index: 2000000; z-index: 2000000;
--pswp-bg: var(--modalBg);
}
.pswp__bg {
background: var(--modalBg);
backdrop-filter: var(--modalBgFilter);
}
.pwsp__alt-text-container {
display: flex;
flex-direction: row;
align-items: center;
position: absolute;
bottom: 30px;
left: 50%;
transform: translateX(-50%);
width: 75%;
max-width: 800px;
}
.pwsp__alt-text {
color: var(--fg);
margin: 0 auto;
text-align: center;
padding: var(--margin);
border-radius: var(--radius);
max-height: 8em;
overflow-y: auto;
text-shadow: var(--bg) 0 0 10px, var(--bg) 0 0 3px, var(--bg) 0 0 3px;
} }
</style> </style>

View File

@ -143,6 +143,7 @@ import { getNoteMenu } from '@/scripts/get-note-menu';
import { useNoteCapture } from '@/scripts/use-note-capture'; import { useNoteCapture } from '@/scripts/use-note-capture';
import { deepClone } from '@/scripts/clone'; import { deepClone } from '@/scripts/clone';
import { useTooltip } from '@/scripts/use-tooltip'; import { useTooltip } from '@/scripts/use-tooltip';
import { claimAchievement } from '@/scripts/achievements';
const props = defineProps<{ const props = defineProps<{
note: misskey.entities.Note; note: misskey.entities.Note;
@ -268,6 +269,9 @@ function react(viaKeyboard = false): void {
noteId: appearNote.id, noteId: appearNote.id,
reaction: reaction, reaction: reaction,
}); });
if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) {
claimAchievement('reactWithoutRead');
}
}, () => { }, () => {
focus(); focus();
}); });

View File

@ -159,6 +159,7 @@ import { getNoteMenu } from '@/scripts/get-note-menu';
import { useNoteCapture } from '@/scripts/use-note-capture'; import { useNoteCapture } from '@/scripts/use-note-capture';
import { deepClone } from '@/scripts/clone'; import { deepClone } from '@/scripts/clone';
import { useTooltip } from '@/scripts/use-tooltip'; import { useTooltip } from '@/scripts/use-tooltip';
import { claimAchievement } from '@/scripts/achievements';
const props = defineProps<{ const props = defineProps<{
note: misskey.entities.Note; note: misskey.entities.Note;
@ -279,6 +280,9 @@ function react(viaKeyboard = false): void {
noteId: appearNote.id, noteId: appearNote.id,
reaction: reaction, reaction: reaction,
}); });
if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) {
claimAchievement('reactWithoutRead');
}
}, () => { }, () => {
focus(); focus();
}); });

View File

@ -2,6 +2,7 @@
<div ref="elRef" :class="$style.root"> <div ref="elRef" :class="$style.root">
<div v-once :class="$style.head"> <div v-once :class="$style.head">
<MkAvatar v-if="notification.type === 'pollEnded'" :class="$style.icon" :user="notification.note.user" link preview/> <MkAvatar v-if="notification.type === 'pollEnded'" :class="$style.icon" :user="notification.note.user" link preview/>
<MkAvatar v-else-if="notification.type === 'achievementEarned'" :class="$style.icon" :user="$i" link preview/>
<MkAvatar v-else-if="notification.user" :class="$style.icon" :user="notification.user" link preview/> <MkAvatar v-else-if="notification.user" :class="$style.icon" :user="notification.user" link preview/>
<img v-else-if="notification.icon" :class="$style.icon" :src="notification.icon" alt=""/> <img v-else-if="notification.icon" :class="$style.icon" :src="notification.icon" alt=""/>
<div :class="[$style.subIcon, $style['t_' + notification.type]]"> <div :class="[$style.subIcon, $style['t_' + notification.type]]">
@ -14,6 +15,7 @@
<i v-else-if="notification.type === 'mention'" class="ti ti-at"></i> <i v-else-if="notification.type === 'mention'" class="ti ti-at"></i>
<i v-else-if="notification.type === 'quote'" class="ti ti-quote"></i> <i v-else-if="notification.type === 'quote'" class="ti ti-quote"></i>
<i v-else-if="notification.type === 'pollEnded'" class="ti ti-chart-arrows"></i> <i v-else-if="notification.type === 'pollEnded'" class="ti ti-chart-arrows"></i>
<i v-else-if="notification.type === 'achievementEarned'" class="ti ti-military-award"></i>
<!-- notification.reaction null になることはまずないがここでoptional chaining使うと一部ブラウザで刺さるので念の為 --> <!-- notification.reaction null になることはまずないがここでoptional chaining使うと一部ブラウザで刺さるので念の為 -->
<MkReactionIcon <MkReactionIcon
v-else-if="notification.type === 'reaction'" v-else-if="notification.type === 'reaction'"
@ -28,6 +30,7 @@
<div :class="$style.tail"> <div :class="$style.tail">
<header :class="$style.header"> <header :class="$style.header">
<span v-if="notification.type === 'pollEnded'">{{ i18n.ts._notification.pollEnded }}</span> <span v-if="notification.type === 'pollEnded'">{{ i18n.ts._notification.pollEnded }}</span>
<span v-else-if="notification.type === 'achievementEarned'">{{ i18n.ts._notification.achievementEarned }}</span>
<MkA v-else-if="notification.user" v-user-preview="notification.user.id" :class="$style.headerName" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA> <MkA v-else-if="notification.user" v-user-preview="notification.user.id" :class="$style.headerName" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA>
<span v-else>{{ notification.header }}</span> <span v-else>{{ notification.header }}</span>
<MkTime v-if="withTime" :time="notification.createdAt" :class="$style.headerTime"/> <MkTime v-if="withTime" :time="notification.createdAt" :class="$style.headerTime"/>
@ -57,6 +60,9 @@
<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :author="notification.note.user"/> <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :author="notification.note.user"/>
<i class="ti ti-quote" :class="$style.quote"></i> <i class="ti ti-quote" :class="$style.quote"></i>
</MkA> </MkA>
<MkA v-else-if="notification.type === 'achievementEarned'" :class="$style.text" to="/my/achievements">
{{ i18n.ts._achievements._types['_' + notification.achievement].title }}
</MkA>
<span v-else-if="notification.type === 'follow'" :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.youGotNewFollower }}<div v-if="full"><MkFollowButton :user="notification.user" :full="true"/></div></span> <span v-else-if="notification.type === 'follow'" :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.youGotNewFollower }}<div v-if="full"><MkFollowButton :user="notification.user" :full="true"/></div></span>
<span v-else-if="notification.type === 'followRequestAccepted'" :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.followRequestAccepted }}</span> <span v-else-if="notification.type === 'followRequestAccepted'" :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.followRequestAccepted }}</span>
<span v-else-if="notification.type === 'receiveFollowRequest'" :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.receiveFollowRequest }}<div v-if="full && !followRequestDone"><button class="_textButton" @click="acceptFollowRequest()">{{ i18n.ts.accept }}</button> | <button class="_textButton" @click="rejectFollowRequest()">{{ i18n.ts.reject }}</button></div></span> <span v-else-if="notification.type === 'receiveFollowRequest'" :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.receiveFollowRequest }}<div v-if="full && !followRequestDone"><button class="_textButton" @click="acceptFollowRequest()">{{ i18n.ts.accept }}</button> | <button class="_textButton" @click="rejectFollowRequest()">{{ i18n.ts.reject }}</button></div></span>
@ -82,6 +88,7 @@ import { i18n } from '@/i18n';
import * as os from '@/os'; import * as os from '@/os';
import { stream } from '@/stream'; import { stream } from '@/stream';
import { useTooltip } from '@/scripts/use-tooltip'; import { useTooltip } from '@/scripts/use-tooltip';
import { $i } from '@/account';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
notification: misskey.entities.Notification; notification: misskey.entities.Notification;
@ -240,6 +247,12 @@ useTooltip(reactionRef, (showing) => {
pointer-events: none; pointer-events: none;
} }
.t_achievementEarned {
padding: 3px;
background: #88a6b7;
pointer-events: none;
}
.tail { .tail {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@ -267,9 +280,9 @@ useTooltip(reactionRef, (showing) => {
} }
.text { .text {
white-space: nowrap; display: flex;
overflow: hidden; width: 100%;
text-overflow: ellipsis; overflow: clip;
} }
.quote { .quote {

View File

@ -10,7 +10,7 @@
<template #default="{ items: notifications }"> <template #default="{ items: notifications }">
<MkDateSeparatedList v-slot="{ item: notification }" :class="$style.list" :items="notifications" :no-gap="true"> <MkDateSeparatedList v-slot="{ item: notification }" :class="$style.list" :items="notifications" :no-gap="true">
<XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note"/> <XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note"/>
<XNotification v-else :key="notification.id" :notification="notification" :with-time="true" :full="true" class="_panel notification"/> <XNotification v-else :key="notification.id" :notification="notification" :with-time="true" :full="false" class="_panel notification"/>
</MkDateSeparatedList> </MkDateSeparatedList>
</template> </template>
</MkPagination> </MkPagination>

View File

@ -24,7 +24,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ComputedRef, inject, provide } from 'vue'; import { ComputedRef, inject, onMounted, onUnmounted, provide } from 'vue';
import RouterView from '@/components/global/RouterView.vue'; import RouterView from '@/components/global/RouterView.vue';
import MkWindow from '@/components/MkWindow.vue'; import MkWindow from '@/components/MkWindow.vue';
import { popout as _popout } from '@/scripts/popout'; import { popout as _popout } from '@/scripts/popout';
@ -35,6 +35,8 @@ import { mainRouter, routes } from '@/router';
import { Router } from '@/nirax'; import { Router } from '@/nirax';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata'; import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
import { openingWindowsCount } from '@/os';
import { claimAchievement } from '@/scripts/achievements';
const props = defineProps<{ const props = defineProps<{
initialPath: string; initialPath: string;
@ -128,6 +130,17 @@ function popout() {
windowEl.close(); windowEl.close();
} }
onMounted(() => {
openingWindowsCount.value++;
if (openingWindowsCount.value >= 3) {
claimAchievement('open3windows');
}
});
onUnmounted(() => {
openingWindowsCount.value--;
});
defineExpose({ defineExpose({
close, close,
}); });

View File

@ -93,11 +93,12 @@ import { defaultStore, notePostInterruptors, postFormActions } from '@/store';
import MkInfo from '@/components/MkInfo.vue'; import MkInfo from '@/components/MkInfo.vue';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { instance } from '@/instance'; import { instance } from '@/instance';
import { $i, getAccounts, openAccountMenu as openAccountMenu_ } from '@/account'; import { $i, notesCount, incNotesCount, getAccounts, openAccountMenu as openAccountMenu_ } from '@/account';
import { uploadFile } from '@/scripts/upload'; import { uploadFile } from '@/scripts/upload';
import { deepClone } from '@/scripts/clone'; import { deepClone } from '@/scripts/clone';
import MkRippleEffect from '@/components/MkRippleEffect.vue'; import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { miLocalStorage } from '@/local-storage'; import { miLocalStorage } from '@/local-storage';
import { claimAchievement } from '@/scripts/achievements';
const modal = inject('modal'); const modal = inject('modal');
@ -627,6 +628,34 @@ async function post(ev?: MouseEvent) {
} }
posting = false; posting = false;
postAccount = null; postAccount = null;
incNotesCount();
if (notesCount === 1) {
claimAchievement('notes1');
}
const text = postData.text?.toLowerCase() ?? '';
if ((text.includes('love') || text.includes('❤')) && text.includes('misskey')) {
claimAchievement('iLoveMisskey');
}
if (text.includes('Efrlqw8ytg4'.toLowerCase()) || text.includes('XVCwzwxdHuA'.toLowerCase())) {
claimAchievement('brainDiver');
}
if (props.renote && (props.renote.userId === $i.id) && text.length > 0) {
claimAchievement('selfQuote');
}
const date = new Date();
const h = date.getHours();
const m = date.getMinutes();
const s = date.getSeconds();
if (h >= 0 && h <= 3) {
claimAchievement('postedAtLateNight');
}
if (m === 0 && s === 0) {
claimAchievement('postedAt0min0sec');
}
}); });
}).catch(err => { }).catch(err => {
posting = false; posting = false;

View File

@ -0,0 +1,92 @@
<template>
<MkModalWindow
ref="dialog"
:width="400"
:height="450"
@close="dialog.close()"
@closed="emit('closed')"
>
<template #header>{{ i18n.ts.reactions }}</template>
<MkSpacer :margin-min="20" :margin-max="28">
<div v-if="note" class="_gaps">
<div :class="$style.tabs">
<button v-for="reaction in reactions" :key="reaction" :class="[$style.tab, { [$style.tabActive]: tab === reaction }]" class="_button" @click="tab = reaction">
<MkReactionIcon :reaction="reaction"/>
<span style="margin-left: 4px;">{{ note.reactions[reaction] }}</span>
</button>
</div>
<MkA v-for="user in users" :key="user.id" :to="userPage(user)">
<MkUserCardMini :user="user" :with-chart="false"/>
</MkA>
</div>
<div v-else>
<MkLoading/>
</div>
</MkSpacer>
</MkModalWindow>
</template>
<script lang="ts" setup>
import { onMounted, watch } from 'vue';
import * as misskey from 'misskey-js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkReactionIcon from '@/components/MkReactionIcon.vue';
import MkUserCardMini from '@/components/MkUserCardMini.vue';
import { userPage } from '@/filters/user';
import { i18n } from '@/i18n';
import * as os from '@/os';
const emit = defineEmits<{
(ev: 'closed'): void,
}>();
const props = defineProps<{
noteId: misskey.entities.Note['id'];
}>();
const dialog = $shallowRef<InstanceType<typeof MkModalWindow>>();
let note = $ref<misskey.entities.Note>();
let tab = $ref<string>();
let reactions = $ref<string[]>();
let users = $ref();
watch($$(tab), async () => {
const res = await os.api('notes/reactions', {
noteId: props.noteId,
type: tab,
limit: 30,
});
users = res.map(x => x.user);
});
onMounted(() => {
os.api('notes/show', {
noteId: props.noteId,
}).then((res) => {
reactions = Object.keys(res.reactions);
tab = reactions[0];
note = res;
});
});
</script>
<style lang="scss" module>
.tabs {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.tab {
padding: 4px 6px;
border: solid 1px var(--divider);
border-radius: 6px;
}
.tabActive {
border-color: var(--accent);
}
</style>

View File

@ -20,6 +20,7 @@ import * as os from '@/os';
import { useTooltip } from '@/scripts/use-tooltip'; import { useTooltip } from '@/scripts/use-tooltip';
import { $i } from '@/account'; import { $i } from '@/account';
import MkReactionEffect from '@/components/MkReactionEffect.vue'; import MkReactionEffect from '@/components/MkReactionEffect.vue';
import { claimAchievement } from '@/scripts/achievements';
const props = defineProps<{ const props = defineProps<{
reaction: string; reaction: string;
@ -52,6 +53,9 @@ const toggleReaction = () => {
noteId: props.note.id, noteId: props.note.id,
reaction: props.reaction, reaction: props.reaction,
}); });
if (props.note.text && props.note.text.length > 100 && (Date.now() - new Date(props.note.createdAt).getTime() < 1000 * 3)) {
claimAchievement('reactWithoutRead');
}
} }
}; };

View File

@ -11,21 +11,29 @@
<script lang="ts" setup> <script lang="ts" setup>
import * as misskey from 'misskey-js'; import * as misskey from 'misskey-js';
import { onMounted } from 'vue';
import MkMiniChart from '@/components/MkMiniChart.vue'; import MkMiniChart from '@/components/MkMiniChart.vue';
import * as os from '@/os'; import * as os from '@/os';
import { acct } from '@/filters/user'; import { acct } from '@/filters/user';
const props = defineProps<{ const props = withDefaults(defineProps<{
user: misskey.entities.User; user: misskey.entities.User;
}>(); withChart: boolean;
}>(), {
withChart: true,
});
let chartValues = $ref<number[] | null>(null); let chartValues = $ref<number[] | null>(null);
onMounted(() => {
if (props.withChart) {
os.apiGet('charts/user/notes', { userId: props.user.id, limit: 16 + 1, span: 'day' }).then(res => { os.apiGet('charts/user/notes', { userId: props.user.id, limit: 16 + 1, span: 'day' }).then(res => {
// //
res.inc.splice(0, 1); res.inc.splice(0, 1);
chartValues = res.inc; chartValues = res.inc;
}); });
}
});
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View File

@ -1,5 +1,6 @@
<template> <template>
<img v-if="isCustom" :class="[$style.root, $style.custom, { [$style.normal]: normal, [$style.noStyle]: noStyle }]" :src="url" :alt="alt" :title="alt" decoding="async"/> <span v-if="isCustom && errored">:{{ customEmojiName }}:</span>
<img v-else-if="isCustom" :class="[$style.root, $style.custom, { [$style.normal]: normal, [$style.noStyle]: noStyle }]" :src="url" :alt="alt" :title="alt" decoding="async" @error="errored = true"/>
<img v-else-if="char && !useOsNativeEmojis" :class="$style.root" :src="url" :alt="alt" decoding="async" @pointerenter="computeTitle"/> <img v-else-if="char && !useOsNativeEmojis" :class="$style.root" :src="url" :alt="alt" decoding="async" @pointerenter="computeTitle"/>
<span v-else-if="char && useOsNativeEmojis" :alt="alt" @pointerenter="computeTitle">{{ char }}</span> <span v-else-if="char && useOsNativeEmojis" :alt="alt" @pointerenter="computeTitle">{{ char }}</span>
<span v-else>{{ emoji }}</span> <span v-else>{{ emoji }}</span>
@ -37,6 +38,7 @@ const url = computed(() => {
} }
}); });
const alt = computed(() => isCustom.value ? `:${customEmojiName}:` : char.value); const alt = computed(() => isCustom.value ? `:${customEmojiName}:` : char.value);
let errored = $ref(false);
// Searching from an array with 2000 items for every emoji felt like too energy-consuming, so I decided to do it lazily on pointerenter // Searching from an array with 2000 items for every emoji felt like too energy-consuming, so I decided to do it lazily on pointerenter
function computeTitle(event: PointerEvent): void { function computeTitle(event: PointerEvent): void {

View File

@ -44,6 +44,7 @@ import { reactionPicker } from '@/scripts/reaction-picker';
import { getUrlWithoutLoginId } from '@/scripts/login-id'; import { getUrlWithoutLoginId } from '@/scripts/login-id';
import { getAccountFromId } from '@/scripts/get-account-from-id'; import { getAccountFromId } from '@/scripts/get-account-from-id';
import { miLocalStorage } from './local-storage'; import { miLocalStorage } from './local-storage';
import { claimAchievement, claimedAchievements } from './scripts/achievements';
(async () => { (async () => {
console.info(`Misskey v${version}`); console.info(`Misskey v${version}`);
@ -345,6 +346,87 @@ import { miLocalStorage } from './local-storage';
}); });
} }
const now = new Date();
const m = now.getMonth() + 1;
const d = now.getDate();
if ($i.birthday) {
const bm = parseInt($i.birthday.split('-')[1]);
const bd = parseInt($i.birthday.split('-')[2]);
if (m === bm && d === bd) {
claimAchievement('loggedInOnBirthday');
}
}
if (m === 1 && d === 1) {
claimAchievement('loggedInOnNewYearsDay');
}
if ($i.loggedInDays >= 3) claimAchievement('login3');
if ($i.loggedInDays >= 7) claimAchievement('login7');
if ($i.loggedInDays >= 15) claimAchievement('login15');
if ($i.loggedInDays >= 30) claimAchievement('login30');
if ($i.loggedInDays >= 60) claimAchievement('login60');
if ($i.loggedInDays >= 100) claimAchievement('login100');
if ($i.loggedInDays >= 200) claimAchievement('login200');
if ($i.loggedInDays >= 300) claimAchievement('login300');
if ($i.loggedInDays >= 400) claimAchievement('login400');
if ($i.loggedInDays >= 500) claimAchievement('login500');
if ($i.loggedInDays >= 600) claimAchievement('login600');
if ($i.loggedInDays >= 700) claimAchievement('login700');
if ($i.loggedInDays >= 800) claimAchievement('login800');
if ($i.loggedInDays >= 900) claimAchievement('login900');
if ($i.loggedInDays >= 1000) claimAchievement('login1000');
if ($i.notesCount > 0) claimAchievement('notes1');
if ($i.notesCount >= 10) claimAchievement('notes10');
if ($i.notesCount >= 100) claimAchievement('notes100');
if ($i.notesCount >= 500) claimAchievement('notes500');
if ($i.notesCount >= 1000) claimAchievement('notes1000');
if ($i.notesCount >= 5000) claimAchievement('notes5000');
if ($i.notesCount >= 10000) claimAchievement('notes10000');
if ($i.notesCount >= 20000) claimAchievement('notes20000');
if ($i.notesCount >= 30000) claimAchievement('notes30000');
if ($i.notesCount >= 40000) claimAchievement('notes40000');
if ($i.notesCount >= 50000) claimAchievement('notes50000');
if ($i.notesCount >= 60000) claimAchievement('notes60000');
if ($i.notesCount >= 70000) claimAchievement('notes70000');
if ($i.notesCount >= 80000) claimAchievement('notes80000');
if ($i.notesCount >= 90000) claimAchievement('notes90000');
if ($i.notesCount >= 100000) claimAchievement('notes100000');
if ($i.followersCount > 0) claimAchievement('followers1');
if ($i.followersCount >= 10) claimAchievement('followers10');
if ($i.followersCount >= 50) claimAchievement('followers50');
if ($i.followersCount >= 100) claimAchievement('followers100');
if ($i.followersCount >= 300) claimAchievement('followers300');
if ($i.followersCount >= 500) claimAchievement('followers500');
if ($i.followersCount >= 1000) claimAchievement('followers1000');
if (Date.now() - new Date($i.createdAt).getTime() > 1000 * 60 * 60 * 24 * 365) {
claimAchievement('passedSinceAccountCreated1');
}
if (Date.now() - new Date($i.createdAt).getTime() > 1000 * 60 * 60 * 24 * 365 * 2) {
claimAchievement('passedSinceAccountCreated2');
}
if (Date.now() - new Date($i.createdAt).getTime() > 1000 * 60 * 60 * 24 * 365 * 3) {
claimAchievement('passedSinceAccountCreated3');
}
if (claimedAchievements.length >= 30) {
claimAchievement('collectAchievements30');
}
window.setInterval(() => {
if (Math.floor(Math.random() * 10000) === 0) {
claimAchievement('justPlainLucky');
}
}, 1000 * 10);
window.setTimeout(() => {
claimAchievement('client30min');
}, 1000 * 60 * 30);
const lastUsed = miLocalStorage.getItem('lastUsed'); const lastUsed = miLocalStorage.getItem('lastUsed');
if (lastUsed) { if (lastUsed) {
const lastUsedDate = parseInt(lastUsed, 10); const lastUsedDate = parseInt(lastUsed, 10);

View File

@ -1,11 +1,11 @@
import { computed, ref, reactive } from 'vue'; import { computed, ref, reactive } from 'vue';
import { $i } from './account'; import { $i } from './account';
import { miLocalStorage } from './local-storage';
import { search } from '@/scripts/search'; import { search } from '@/scripts/search';
import * as os from '@/os'; import * as os from '@/os';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { ui } from '@/config'; import { ui } from '@/config';
import { unisonReload } from '@/scripts/unison-reload'; import { unisonReload } from '@/scripts/unison-reload';
import { miLocalStorage } from './local-storage';
export const navbarItemDef = reactive({ export const navbarItemDef = reactive({
notifications: { notifications: {
@ -103,6 +103,12 @@ export const navbarItemDef = reactive({
icon: 'ti ti-device-tv', icon: 'ti ti-device-tv',
to: '/channels', to: '/channels',
}, },
achievements: {
title: i18n.ts.achievements,
icon: 'ti ti-military-award',
show: computed(() => $i != null),
to: '/my/achievements',
},
ui: { ui: {
title: i18n.ts.switchUi, title: i18n.ts.switchUi,
icon: 'ti ti-devices', icon: 'ti ti-devices',

View File

@ -1,5 +1,7 @@
// TODO: なんでもかんでもos.tsに突っ込むのやめたいのでよしなに分割する // TODO: なんでもかんでもos.tsに突っ込むのやめたいのでよしなに分割する
import { pendingApiRequestsCount, api, apiGet } from '@/scripts/api';
export { pendingApiRequestsCount, api, apiGet };
import { Component, markRaw, Ref, ref, defineAsyncComponent } from 'vue'; import { Component, markRaw, Ref, ref, defineAsyncComponent } from 'vue';
import { EventEmitter } from 'eventemitter3'; import { EventEmitter } from 'eventemitter3';
import insertTextAtCursor from 'insert-text-at-cursor'; import insertTextAtCursor from 'insert-text-at-cursor';
@ -7,9 +9,16 @@ import * as Misskey from 'misskey-js';
import { i18n } from './i18n'; import { i18n } from './i18n';
import MkPostFormDialog from '@/components/MkPostFormDialog.vue'; import MkPostFormDialog from '@/components/MkPostFormDialog.vue';
import MkWaitingDialog from '@/components/MkWaitingDialog.vue'; import MkWaitingDialog from '@/components/MkWaitingDialog.vue';
import MkPageWindow from '@/components/MkPageWindow.vue';
import MkToast from '@/components/MkToast.vue';
import MkDialog from '@/components/MkDialog.vue';
import MkEmojiPickerDialog from '@/components/MkEmojiPickerDialog.vue';
import MkEmojiPickerWindow from '@/components/MkEmojiPickerWindow.vue';
import MkPopupMenu from '@/components/MkPopupMenu.vue';
import MkContextMenu from '@/components/MkContextMenu.vue';
import { MenuItem } from '@/types/menu'; import { MenuItem } from '@/types/menu';
import { pendingApiRequestsCount, api, apiGet } from '@/scripts/api';
export { pendingApiRequestsCount, api, apiGet }; export const openingWindowsCount = ref(0);
export const apiWithDialog = (( export const apiWithDialog = ((
endpoint: string, endpoint: string,
@ -124,7 +133,7 @@ export async function popup(component: Component, props: Record<string, any>, ev
} }
export function pageWindow(path: string) { export function pageWindow(path: string) {
popup(defineAsyncComponent(() => import('@/components/MkPageWindow.vue')), { popup(MkPageWindow, {
initialPath: path, initialPath: path,
}, {}, 'closed'); }, {}, 'closed');
} }
@ -136,7 +145,7 @@ export function modalPageWindow(path: string) {
} }
export function toast(message: string) { export function toast(message: string) {
popup(defineAsyncComponent(() => import('@/components/MkToast.vue')), { popup(MkToast, {
message, message,
}, {}, 'closed'); }, {}, 'closed');
} }
@ -147,7 +156,7 @@ export function alert(props: {
text?: string | null; text?: string | null;
}): Promise<void> { }): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), props, { popup(MkDialog, props, {
done: result => { done: result => {
resolve(); resolve();
}, },
@ -161,7 +170,7 @@ export function confirm(props: {
text?: string | null; text?: string | null;
}): Promise<{ canceled: boolean }> { }): Promise<{ canceled: boolean }> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), { popup(MkDialog, {
...props, ...props,
showCancelButton: true, showCancelButton: true,
}, { }, {
@ -182,7 +191,7 @@ export function inputText(props: {
canceled: false; result: string; canceled: false; result: string;
}> { }> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), { popup(MkDialog, {
title: props.title, title: props.title,
text: props.text, text: props.text,
input: { input: {
@ -207,7 +216,7 @@ export function inputNumber(props: {
canceled: false; result: number; canceled: false; result: number;
}> { }> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), { popup(MkDialog, {
title: props.title, title: props.title,
text: props.text, text: props.text,
input: { input: {
@ -232,7 +241,7 @@ export function inputDate(props: {
canceled: false; result: Date; canceled: false; result: Date;
}> { }> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), { popup(MkDialog, {
title: props.title, title: props.title,
text: props.text, text: props.text,
input: { input: {
@ -269,7 +278,7 @@ export function select<C = any>(props: {
canceled: false; result: C; canceled: false; result: C;
}> { }> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), { popup(MkDialog, {
title: props.title, title: props.title,
text: props.text, text: props.text,
select: { select: {
@ -291,7 +300,7 @@ export function success() {
window.setTimeout(() => { window.setTimeout(() => {
showing.value = false; showing.value = false;
}, 1000); }, 1000);
popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), { popup(MkWaitingDialog, {
success: true, success: true,
showing: showing, showing: showing,
}, { }, {
@ -303,7 +312,7 @@ export function success() {
export function waiting() { export function waiting() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const showing = ref(true); const showing = ref(true);
popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), { popup(MkWaitingDialog, {
success: false, success: false,
showing: showing, showing: showing,
}, { }, {
@ -366,7 +375,7 @@ export async function selectDriveFolder(multiple: boolean) {
export async function pickEmoji(src: HTMLElement | null, opts) { export async function pickEmoji(src: HTMLElement | null, opts) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), { popup(MkEmojiPickerDialog, {
src, src,
...opts, ...opts,
}, { }, {
@ -431,7 +440,7 @@ export async function openEmojiPicker(src?: HTMLElement, opts, initialTextarea:
characterData: false, characterData: false,
}); });
openingEmojiPicker = await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerWindow.vue')), { openingEmojiPicker = await popup(MkEmojiPickerWindow, {
src, src,
...opts, ...opts,
}, { }, {
@ -454,7 +463,7 @@ export function popupMenu(items: MenuItem[] | Ref<MenuItem[]>, src?: HTMLElement
}) { }) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let dispose; let dispose;
popup(defineAsyncComponent(() => import('@/components/MkPopupMenu.vue')), { popup(MkPopupMenu, {
items, items,
src, src,
width: options?.width, width: options?.width,
@ -478,7 +487,7 @@ export function contextMenu(items: MenuItem[] | Ref<MenuItem[]>, ev: MouseEvent)
ev.preventDefault(); ev.preventDefault();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let dispose; let dispose;
popup(defineAsyncComponent(() => import('@/components/MkContextMenu.vue')), { popup(MkContextMenu, {
items, items,
ev, ev,
}, { }, {

View File

@ -0,0 +1,54 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader/></template>
<MkSpacer :content-max="1200">
<MkAchievements :user="$i"/>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { onActivated, onDeactivated, onMounted, onUnmounted, ref } from 'vue';
import MkAchievements from '@/components/MkAchievements.vue';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
import { $i } from '@/account';
import { claimAchievement } from '@/scripts/achievements';
let timer: number | null;
function viewAchievements3min() {
claimAchievement('viewAchievements3min');
}
onMounted(() => {
if (timer == null) timer = window.setTimeout(viewAchievements3min, 1000 * 60 * 3);
});
onUnmounted(() => {
if (timer != null) {
window.clearTimeout(timer);
timer = null;
}
});
onActivated(() => {
if (timer == null) timer = window.setTimeout(viewAchievements3min, 1000 * 60 * 3);
});
onDeactivated(() => {
if (timer != null) {
window.clearTimeout(timer);
timer = null;
}
});
definePageMetadata({
title: i18n.ts.achievements,
icon: 'ti ti-military-award',
});
</script>
<style lang="scss" module>
</style>

View File

@ -7,7 +7,7 @@
<MkButton primary rounded @click="create"><i class="ti ti-plus"></i> {{ i18n.ts._role.new }}</MkButton> <MkButton primary rounded @click="create"><i class="ti ti-plus"></i> {{ i18n.ts._role.new }}</MkButton>
<MkFolder> <MkFolder>
<template #label>{{ i18n.ts._role.baseRole }}</template> <template #label>{{ i18n.ts._role.baseRole }}</template>
<div class="_gaps"> <div class="_gaps_s">
<MkFolder> <MkFolder>
<template #label>{{ i18n.ts._role._options.rateLimitFactor }}</template> <template #label>{{ i18n.ts._role._options.rateLimitFactor }}</template>
<template #suffix>{{ Math.floor(policies.rateLimitFactor * 100) }}%</template> <template #suffix>{{ Math.floor(policies.rateLimitFactor * 100) }}%</template>

View File

@ -16,6 +16,7 @@
<div class="_buttons"> <div class="_buttons">
<MkButton primary @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> <MkButton primary @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
<MkButton @click="show"><i class="ti ti-eye"></i> {{ i18n.ts.show }}</MkButton> <MkButton @click="show"><i class="ti ti-eye"></i> {{ i18n.ts.show }}</MkButton>
<MkButton v-if="flash" danger @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
</div> </div>
</div> </div>
</MkSpacer> </MkSpacer>
@ -94,6 +95,85 @@ Ui:render([
]) ])
`; `;
const PRESET_SHUFFLE = `/// @ 0.12.2
//
let string = "ペペロンチーノ"
let length = string.len
//
var results = []
//
var cursor = 0
@do() {
if (cursor != 0) {
results = results.slice(0 (cursor + 1))
cursor = 0
}
let chars = []
for (let i, length) {
let r = Math:rnd(0 (length - 1))
chars.push(string.pick(r))
}
let result = chars.join("")
results.push(result)
// UI
render(result)
}
@back() {
cursor = cursor + 1
let result = results[results.len - (cursor + 1)]
render(result)
}
@forward() {
cursor = cursor - 1
let result = results[results.len - (cursor + 1)]
render(result)
}
@render(result) {
Ui:render([
Ui:C:container({
align: 'center'
children: [
Ui:C:mfm({ text: result })
Ui:C:buttons({
buttons: [{
text: "←"
disabled: !(results.len > 1 && (results.len - cursor) > 1)
onClick: back
} {
text: "→"
disabled: !(results.len > 1 && cursor > 0)
onClick: forward
} {
text: "引き直す"
onClick: do
}]
})
Ui:C:postFormButton({
text: "投稿する"
rounded: true
primary: true
form: {
text: \`{result}{Str:lf}{THIS_URL}\`
}
})
]
})
])
}
do()
`;
const PRESET_TIMELINE = `/// @ 0.12.2 const PRESET_TIMELINE = `/// @ 0.12.2
// API // API
@ -174,6 +254,11 @@ function selectPreset(ev: MouseEvent) {
action: () => { action: () => {
script = PRESET_OMIKUJI; script = PRESET_OMIKUJI;
}, },
}, {
text: 'Shuffle',
action: () => {
script = PRESET_SHUFFLE;
},
}, { }, {
text: 'Timeline viewer', text: 'Timeline viewer',
action: () => { action: () => {
@ -212,6 +297,19 @@ function show() {
} }
} }
async function del() {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.t('deleteAreYouSure', { x: flash.title }),
});
if (canceled) return;
await os.apiWithDialog('flash/delete', {
flashId: props.id,
});
router.push('/play');
}
const headerActions = $computed(() => []); const headerActions = $computed(() => []);
const headerTabs = $computed(() => []); const headerTabs = $computed(() => []);

View File

@ -11,13 +11,15 @@
</div> </div>
<div v-else-if="tab === 'my'" class="my"> <div v-else-if="tab === 'my'" class="my">
<MkButton class="new" @click="create()"><i class="ti ti-plus"></i></MkButton> <div class="_gaps">
<MkButton class="new" gradate rounded style="margin: 0 auto;" @click="create()"><i class="ti ti-plus"></i></MkButton>
<MkPagination v-slot="{items}" :pagination="myFlashsPagination"> <MkPagination v-slot="{items}" :pagination="myFlashsPagination">
<div class="_gaps_s"> <div class="_gaps_s">
<MkFlashPreview v-for="flash in items" :key="flash.id" class="" :flash="flash"/> <MkFlashPreview v-for="flash in items" :key="flash.id" class="" :flash="flash"/>
</div> </div>
</MkPagination> </MkPagination>
</div> </div>
</div>
<div v-else-if="tab === 'liked'" class=""> <div v-else-if="tab === 'liked'" class="">
<MkPagination v-slot="{items}" :pagination="likedFlashsPagination"> <MkPagination v-slot="{items}" :pagination="likedFlashsPagination">

View File

@ -1,11 +1,15 @@
<template> <template>
<MkStickyContainer>
<template #header>
<MkPageHeader />
</template>
<div <div
ref="rootEl" ref="rootEl"
class="root" :class="$style['root']"
@dragover.prevent.stop="onDragover" @dragover.prevent.stop="onDragover"
@drop.prevent.stop="onDrop" @drop.prevent.stop="onDrop"
> >
<div class="body"> <div :class="$style['body']">
<MkPagination v-if="pagination" ref="pagingComponent" :key="userAcct || groupId" :pagination="pagination"> <MkPagination v-if="pagination" ref="pagingComponent" :key="userAcct || groupId" :pagination="pagination">
<template #empty> <template #empty>
<div class="_fullinfo"> <div class="_fullinfo">
@ -17,7 +21,7 @@
<MkDateSeparatedList <MkDateSeparatedList
v-if="messages.length > 0" v-if="messages.length > 0"
v-slot="{ item: message }" v-slot="{ item: message }"
:class="{ messages: true, 'deny-move-transition': pFetching }" :class="{ [$style['messages']]: true, 'deny-move-transition': pFetching }"
:items="messages" :items="messages"
direction="up" direction="up"
reversed reversed
@ -27,23 +31,26 @@
</template> </template>
</MkPagination> </MkPagination>
</div> </div>
<footer> <footer :class="$style['footer']">
<div v-if="typers.length > 0" class="typers"> <div v-if="typers.length > 0" :class="$style['typers']">
<I18n :src="i18n.ts.typingUsers" text-tag="span" class="users"> <I18n :src="i18n.ts.typingUsers" text-tag="span">
<template #users> <template #users>
<b v-for="typer in typers" :key="typer.id" class="user">{{ typer.username }}</b> <b v-for="typer in typers" :key="typer.id" :class="$style['user']">{{ typer.username }}</b>
</template> </template>
</I18n> </I18n>
<MkEllipsis/> <MkEllipsis/>
</div> </div>
<Transition :name="animation ? 'fade' : ''"> <Transition :name="animation ? 'fade' : ''">
<div v-show="showIndicator" class="new-message"> <div v-show="showIndicator" :class="$style['new-message']">
<button class="_buttonPrimary" @click="onIndicatorClick"><i class="fas ti-fw fa-arrow-circle-down"></i>{{ i18n.ts.newMessageExists }}</button> <button class="_buttonPrimary" @click="onIndicatorClick" :class="$style['new-message-button']">
<i class="fas ti-fw fa-arrow-circle-down" :class="$style['new-message-icon']"></i>{{ i18n.ts.newMessageExists }}
</button>
</div> </div>
</Transition> </Transition>
<XForm v-if="!fetching" ref="formEl" :user="user" :group="group" class="form"/> <XForm v-if="!fetching" ref="formEl" :user="user" :group="group" :class="$style['form']"/>
</footer> </footer>
</div> </div>
</MkStickyContainer>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
@ -303,12 +310,14 @@ definePageMetadata(computed(() => !fetching ? user ? {
} : null)); } : null));
</script> </script>
<style lang="scss" scoped> <style lang="scss" module>
.root { .root {
display: content; display: content;
}
> .body { .body {
min-height: 80%; min-height: 80%;
}
.more { .more {
display: block; display: block;
@ -318,89 +327,82 @@ definePageMetadata(computed(() => !fetching ? user ? {
color: #fff; color: #fff;
background: rgba(#000, 0.3); background: rgba(#000, 0.3);
border-radius: 12px; border-radius: 12px;
&:hover { &:hover {
background: rgba(#000, 0.4); background: rgba(#000, 0.4);
} }
&:active { &:active {
background: rgba(#000, 0.5); background: rgba(#000, 0.5);
} }
&.fetching {
cursor: wait;
}
> i { > i {
margin-right: 4px; margin-right: 4px;
} }
} }
.messages { .fetching {
padding: 8px 0; cursor: wait;
}
> ::v-deep(*) { .messages {
padding: 16px 0 0;
> * {
margin-bottom: 16px; margin-bottom: 16px;
} }
} }
}
> footer { .footer {
width: 100%; width: 100%;
position: sticky; position: sticky;
z-index: 2; z-index: 2;
padding-top: 8px; padding-top: 8px;
bottom: 0; bottom: var(--minBottomSpacing);
bottom: env(safe-area-inset-bottom, 0px); }
> .new-message { .new-message {
width: 100%; width: 100%;
padding-bottom: 8px; padding-bottom: 8px;
text-align: center; text-align: center;
}
> button { .new-message-button {
display: inline-block; display: inline-block;
margin: 0; margin: 0;
padding: 0 12px; padding: 0 12px;
line-height: 32px; line-height: 32px;
font-size: 12px; font-size: 12px;
border-radius: 16px; border-radius: 16px;
}
> i { .new-message-icon {
display: inline-block; display: inline-block;
margin-right: 8px; margin-right: 8px;
} }
}
}
> .typers { .typers {
position: absolute; position: absolute;
bottom: 100%; bottom: 100%;
padding: 0 8px 0 8px; padding: 0 8px 0 8px;
font-size: 0.9em; font-size: 0.9em;
color: var(--fgTransparentWeak); color: var(--fgTransparentWeak);
}
> .users {
> .user + .user:before { .user + .user:before {
content: ", "; content: ", ";
font-weight: normal; font-weight: normal;
} }
> .user:last-of-type:after { .user:last-of-type:after {
content: " "; content: " ";
} }
}
}
> .form { .form {
max-height: 12em; max-height: 12em;
overflow-y: scroll; overflow-y: scroll;
border-top: solid 0.5px var(--divider); border-top: solid 0.5px var(--divider);
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
border-bottom-right-radius: 0; border-bottom-right-radius: 0;
} }
}
}
.fade-enter-active, .fade-leave-active { .fade-enter-active, .fade-leave-active {
transition: opacity 0.1s; transition: opacity 0.1s;

View File

@ -47,6 +47,7 @@ import { definePageMetadata } from '@/scripts/page-metadata';
import { AsUiComponent, AsUiRoot, patch, registerAsUiLib, render } from '@/scripts/aiscript/ui'; import { AsUiComponent, AsUiRoot, patch, registerAsUiLib, render } from '@/scripts/aiscript/ui';
import MkAsUi from '@/components/MkAsUi.vue'; import MkAsUi from '@/components/MkAsUi.vue';
import { miLocalStorage } from '@/local-storage'; import { miLocalStorage } from '@/local-storage';
import { claimAchievement } from '@/scripts/achievements';
const parser = new Parser(); const parser = new Parser();
let aiscript: Interpreter; let aiscript: Interpreter;
@ -90,6 +91,9 @@ async function run() {
}); });
}, },
out: (value) => { out: (value) => {
if (value.type === 'str' && value.value.toLowerCase().replace(',', '').includes('hello world')) {
claimAchievement('outputHelloWorldOnScratchpad');
}
logs.value.push({ logs.value.push({
id: Math.random(), id: Math.random(),
text: value.type === 'str' ? value.value : utils.valToString(value), text: value.type === 'str' ? value.value : utils.valToString(value),

View File

@ -85,6 +85,7 @@ import { i18n } from '@/i18n';
import { $i } from '@/account'; import { $i } from '@/account';
import { langmap } from '@/scripts/langmap'; import { langmap } from '@/scripts/langmap';
import { definePageMetadata } from '@/scripts/page-metadata'; import { definePageMetadata } from '@/scripts/page-metadata';
import { claimAchievement } from '@/scripts/achievements';
const profile = reactive({ const profile = reactive({
name: $i.name, name: $i.name,
@ -133,6 +134,13 @@ function save() {
isCat: !!profile.isCat, isCat: !!profile.isCat,
showTimelineReplies: !!profile.showTimelineReplies, showTimelineReplies: !!profile.showTimelineReplies,
}); });
claimAchievement('profileFilled');
if (profile.name === 'syuilo' || profile.name === 'しゅいろ') {
claimAchievement('setNameToSyuilo');
}
if (profile.isCat) {
claimAchievement('markedAsCat');
}
} }
function changeAvatar(ev) { function changeAvatar(ev) {
@ -155,6 +163,7 @@ function changeAvatar(ev) {
}); });
$i.avatarId = i.avatarId; $i.avatarId = i.avatarId;
$i.avatarUrl = i.avatarUrl; $i.avatarUrl = i.avatarUrl;
claimAchievement('profileFilled');
}); });
} }

View File

@ -113,7 +113,8 @@
<div v-for="role in info.roles" :key="role.id" :class="$style.roleItem"> <div v-for="role in info.roles" :key="role.id" :class="$style.roleItem">
<MkRolePreview :class="$style.role" :role="role"/> <MkRolePreview :class="$style.role" :role="role"/>
<button class="_button" :class="$style.roleUnassign" @click="unassignRole(role, $event)"><i class="ti ti-x"></i></button> <button v-if="role.target === 'manual'" class="_button" :class="$style.roleUnassign" @click="unassignRole(role, $event)"><i class="ti ti-x"></i></button>
<button v-else class="_button" :class="$style.roleUnassign" disabled><i class="ti ti-ban"></i></button>
</div> </div>
</div> </div>
</MkFolder> </MkFolder>

View File

@ -0,0 +1,52 @@
<template>
<MkSpacer :content-max="1200">
<MkAchievements :user="user" :with-locked="false"/>
</MkSpacer>
</template>
<script lang="ts" setup>
import { onActivated, onDeactivated, onMounted, onUnmounted, ref } from 'vue';
import * as misskey from 'misskey-js';
import MkAchievements from '@/components/MkAchievements.vue';
import { i18n } from '@/i18n';
import { claimAchievement } from '@/scripts/achievements';
import { $i } from '@/account';
const props = defineProps<{
user: misskey.entities.User;
}>();
let timer: number | null;
function viewAchievements3min() {
if ($i && (props.user.id === $i.id)) {
claimAchievement('viewAchievements3min');
}
}
onMounted(() => {
if (timer == null) timer = window.setTimeout(viewAchievements3min, 1000 * 60 * 3);
});
onUnmounted(() => {
if (timer != null) {
window.clearTimeout(timer);
timer = null;
}
});
onActivated(() => {
if (timer == null) timer = window.setTimeout(viewAchievements3min, 1000 * 60 * 3);
});
onDeactivated(() => {
if (timer != null) {
window.clearTimeout(timer);
timer = null;
}
});
</script>
<style lang="scss" module>
</style>

View File

@ -6,6 +6,7 @@
<div v-if="user"> <div v-if="user">
<XHome v-if="tab === 'home'" :user="user"/> <XHome v-if="tab === 'home'" :user="user"/>
<XActivity v-else-if="tab === 'activity'" :user="user"/> <XActivity v-else-if="tab === 'activity'" :user="user"/>
<XAchievements v-else-if="tab === 'achievements'" :user="user"/>
<XReactions v-else-if="tab === 'reactions'" :user="user"/> <XReactions v-else-if="tab === 'reactions'" :user="user"/>
<XClips v-else-if="tab === 'clips'" :user="user"/> <XClips v-else-if="tab === 'clips'" :user="user"/>
<XPages v-else-if="tab === 'pages'" :user="user"/> <XPages v-else-if="tab === 'pages'" :user="user"/>
@ -34,6 +35,7 @@ import { $i } from '@/account';
const XHome = defineAsyncComponent(() => import('./home.vue')); const XHome = defineAsyncComponent(() => import('./home.vue'));
const XActivity = defineAsyncComponent(() => import('./activity.vue')); const XActivity = defineAsyncComponent(() => import('./activity.vue'));
const XAchievements = defineAsyncComponent(() => import('./achievements.vue'));
const XReactions = defineAsyncComponent(() => import('./reactions.vue')); const XReactions = defineAsyncComponent(() => import('./reactions.vue'));
const XClips = defineAsyncComponent(() => import('./clips.vue')); const XClips = defineAsyncComponent(() => import('./clips.vue'));
const XPages = defineAsyncComponent(() => import('./pages.vue')); const XPages = defineAsyncComponent(() => import('./pages.vue'));
@ -76,7 +78,11 @@ const headerTabs = $computed(() => user ? [{
key: 'activity', key: 'activity',
title: i18n.ts.activity, title: i18n.ts.activity,
icon: 'ti ti-chart-line', icon: 'ti ti-chart-line',
}, ...($i && ($i.id === user.id)) || user.publicReactions ? [{ }, ...(user.host == null ? [{
key: 'achievements',
title: i18n.ts.achievements,
icon: 'ti ti-military-award',
}] : []), ...($i && ($i.id === user.id)) || user.publicReactions ? [{
key: 'reactions', key: 'reactions',
title: i18n.ts.reaction, title: i18n.ts.reaction,
icon: 'ti ti-mood-happy', icon: 'ti ti-mood-happy',

View File

@ -427,6 +427,10 @@ export const routes = [{
path: '/my/favorites', path: '/my/favorites',
component: page(() => import('./pages/favorites.vue')), component: page(() => import('./pages/favorites.vue')),
loginRequired: true, loginRequired: true,
}, {
path: '/my/achievements',
component: page(() => import('./pages/achievements.vue')),
loginRequired: true,
}, { }, {
name: 'messaging', name: 'messaging',
path: '/my/messaging', path: '/my/messaging',

View File

@ -0,0 +1,449 @@
import * as os from '@/os';
import { $i } from '@/account';
export const ACHIEVEMENT_TYPES = [
'notes1',
'notes10',
'notes100',
'notes500',
'notes1000',
'notes5000',
'notes10000',
'notes20000',
'notes30000',
'notes40000',
'notes50000',
'notes60000',
'notes70000',
'notes80000',
'notes90000',
'notes100000',
'login3',
'login7',
'login15',
'login30',
'login60',
'login100',
'login200',
'login300',
'login400',
'login500',
'login600',
'login700',
'login800',
'login900',
'login1000',
'passedSinceAccountCreated1',
'passedSinceAccountCreated2',
'passedSinceAccountCreated3',
'loggedInOnBirthday',
'loggedInOnNewYearsDay',
'noteClipped1',
'noteFavorited1',
'profileFilled',
'markedAsCat',
'following1',
'following10',
'following50',
'following100',
'following300',
'followers1',
'followers10',
'followers50',
'followers100',
'followers300',
'followers500',
'followers1000',
'collectAchievements30',
'viewAchievements3min',
'iLoveMisskey',
'client30min',
'noteDeletedWithin1min',
'postedAtLateNight',
'postedAt0min0sec',
'selfQuote',
'htl20npm',
'outputHelloWorldOnScratchpad',
'open3windows',
'driveFolderCircularReference',
'reactWithoutRead',
'clickedClickHere',
'justPlainLucky',
'setNameToSyuilo',
'cookieClicked',
'brainDiver',
] as const;
export const ACHIEVEMENT_BADGES = {
'notes1': {
img: '/fluent-emoji/1f4dd.png',
bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
frame: 'bronze',
},
'notes10': {
img: '/fluent-emoji/1f4d1.png',
bg: null,
frame: 'bronze',
},
'notes100': {
img: '/fluent-emoji/1f4d2.png',
bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
frame: 'bronze',
},
'notes500': {
img: '/fluent-emoji/1f4da.png',
bg: null,
frame: 'bronze',
},
'notes1000': {
img: '/fluent-emoji/1f5c3.png',
bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
frame: 'bronze',
},
'notes5000': {
img: '/fluent-emoji/1f304.png',
bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
frame: 'bronze',
},
'notes10000': {
img: '/fluent-emoji/1f3d9.png',
bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
frame: 'silver',
},
'notes20000': {
img: '/fluent-emoji/1f307.png',
bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
frame: 'silver',
},
'notes30000': {
img: '/fluent-emoji/1f306.png',
bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
frame: 'silver',
},
'notes40000': {
img: '/fluent-emoji/1f303.png',
bg: 'linear-gradient(0deg, rgb(197 69 192), rgb(2 112 155))',
frame: 'silver',
},
'notes50000': {
img: '/fluent-emoji/1fa90.png',
bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
frame: 'gold',
},
'notes60000': {
img: '/fluent-emoji/2604.png',
bg: 'linear-gradient(0deg, rgb(197 69 192), rgb(2 112 155))',
frame: 'gold',
},
'notes70000': {
img: '/fluent-emoji/1f30c.png',
bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
frame: 'gold',
},
'notes80000': {
img: '/fluent-emoji/1f30c.png',
bg: 'linear-gradient(0deg, rgb(197 69 192), rgb(2 112 155))',
frame: 'gold',
},
'notes90000': {
img: '/fluent-emoji/1f30c.png',
bg: 'linear-gradient(0deg, rgb(255 232 119), rgb(255 140 41))',
frame: 'gold',
},
'notes100000': {
img: '/fluent-emoji/267e.png',
bg: 'linear-gradient(0deg, rgb(255 232 119), rgb(255 140 41))',
frame: 'platinum',
},
'login3': {
img: '/fluent-emoji/1f331.png',
bg: null,
frame: 'bronze',
},
'login7': {
img: '/fluent-emoji/1f331.png',
bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
frame: 'bronze',
},
'login15': {
img: '/fluent-emoji/1f331.png',
bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))',
frame: 'bronze',
},
'login30': {
img: '/fluent-emoji/1fab4.png',
bg: null,
frame: 'bronze',
},
'login60': {
img: '/fluent-emoji/1fab4.png',
bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
frame: 'bronze',
},
'login100': {
img: '/fluent-emoji/1fab4.png',
bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))',
frame: 'silver',
},
'login200': {
img: '/fluent-emoji/1f333.png',
bg: null,
frame: 'silver',
},
'login300': {
img: '/fluent-emoji/1f333.png',
bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
frame: 'silver',
},
'login400': {
img: '/fluent-emoji/1f333.png',
bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))',
frame: 'silver',
},
'login500': {
img: '/fluent-emoji/1f304.png',
bg: null,
frame: 'silver',
},
'login600': {
img: '/fluent-emoji/1f304.png',
bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
frame: 'gold',
},
'login700': {
img: '/fluent-emoji/1f304.png',
bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))',
frame: 'gold',
},
'login800': {
img: '/fluent-emoji/1f307.png',
bg: null,
frame: 'gold',
},
'login900': {
img: '/fluent-emoji/1f307.png',
bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
frame: 'gold',
},
'login1000': {
img: '/fluent-emoji/1f307.png',
bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))',
frame: 'platinum',
},
'noteClipped1': {
img: '/fluent-emoji/1f587.png',
bg: null,
frame: 'bronze',
},
'noteFavorited1': {
img: '/fluent-emoji/1f31f.png',
bg: null,
frame: 'bronze',
},
'profileFilled': {
img: '/fluent-emoji/1f44c.png',
bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))',
frame: 'bronze',
},
'markedAsCat': {
img: '/fluent-emoji/1f408.png',
bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))',
frame: 'bronze',
},
'following1': {
img: '/fluent-emoji/2618.png',
bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
frame: 'bronze',
},
'following10': {
img: '/fluent-emoji/1f6b8.png',
bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
frame: 'bronze',
},
'following50': {
img: '/fluent-emoji/1f91d.png',
bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
frame: 'bronze',
},
'following100': {
img: '/fluent-emoji/1f4af.png',
bg: 'linear-gradient(0deg, rgb(255 53 184), rgb(255 206 69))',
frame: 'silver',
},
'following300': {
img: '/fluent-emoji/1f970.png',
bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
frame: 'silver',
},
'followers1': {
img: '/fluent-emoji/2618.png',
bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
frame: 'bronze',
},
'followers10': {
img: '/fluent-emoji/1f44b.png',
bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
frame: 'bronze',
},
'followers50': {
img: '/fluent-emoji/1f411.png',
bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
frame: 'bronze',
},
'followers100': {
img: '/fluent-emoji/1f60e.png',
bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
frame: 'silver',
},
'followers300': {
img: '/fluent-emoji/1f3c6.png',
bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
frame: 'silver',
},
'followers500': {
img: '/fluent-emoji/1f4e1.png',
bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
frame: 'gold',
},
'followers1000': {
img: '/fluent-emoji/1f451.png',
bg: 'linear-gradient(0deg, rgb(255 232 119), rgb(255 140 41))',
frame: 'platinum',
},
'collectAchievements30': {
img: '/fluent-emoji/1f3c5.png',
bg: 'linear-gradient(0deg, rgb(255 77 77), rgb(247 155 214))',
frame: 'silver',
},
'viewAchievements3min': {
img: '/fluent-emoji/1f3c5.png',
bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
frame: 'bronze',
},
'iLoveMisskey': {
img: '/fluent-emoji/2764.png',
bg: 'linear-gradient(0deg, rgb(255 77 77), rgb(247 155 214))',
frame: 'silver',
},
'client30min': {
img: '/fluent-emoji/1f552.png',
bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
frame: 'bronze',
},
'noteDeletedWithin1min': {
img: '/fluent-emoji/1f5d1.png',
bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
frame: 'bronze',
},
'postedAtLateNight': {
img: '/fluent-emoji/1f319.png',
bg: 'linear-gradient(0deg, rgb(197 69 192), rgb(2 112 155))',
frame: 'bronze',
},
'postedAt0min0sec': {
img: '/fluent-emoji/1f55b.png',
bg: 'linear-gradient(0deg, rgb(58 231 198), rgb(37 194 255))',
frame: 'bronze',
},
'selfQuote': {
img: '/fluent-emoji/1f4dd.png',
bg: null,
frame: 'bronze',
},
'htl20npm': {
img: '/fluent-emoji/1f30a.png',
bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
frame: 'bronze',
},
'outputHelloWorldOnScratchpad': {
img: '/fluent-emoji/1f530.png',
bg: 'linear-gradient(0deg, rgb(58 231 198), rgb(37 194 255))',
frame: 'bronze',
},
'open3windows': {
img: '/fluent-emoji/1f5a5.png',
bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
frame: 'bronze',
},
'driveFolderCircularReference': {
img: '/fluent-emoji/1f4c2.png',
bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
frame: 'bronze',
},
'reactWithoutRead': {
img: '/fluent-emoji/2753.png',
bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
frame: 'bronze',
},
'clickedClickHere': {
img: '/fluent-emoji/2757.png',
bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
frame: 'bronze',
},
'justPlainLucky': {
img: '/fluent-emoji/1f340.png',
bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))',
frame: 'silver',
},
'setNameToSyuilo': {
img: '/fluent-emoji/1f36e.png',
bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))',
frame: 'bronze',
},
'passedSinceAccountCreated1': {
img: '/fluent-emoji/0031-20e3.png',
bg: null,
frame: 'bronze',
},
'passedSinceAccountCreated2': {
img: '/fluent-emoji/0032-20e3.png',
bg: null,
frame: 'silver',
},
'passedSinceAccountCreated3': {
img: '/fluent-emoji/0033-20e3.png',
bg: null,
frame: 'gold',
},
'loggedInOnBirthday': {
img: '/fluent-emoji/1f382.png',
bg: 'linear-gradient(0deg, rgb(255 77 77), rgb(247 155 214))',
frame: 'silver',
},
'loggedInOnNewYearsDay': {
img: '/fluent-emoji/1f38d.png',
bg: 'linear-gradient(0deg, rgb(255 144 144), rgb(255 232 168))',
frame: 'silver',
},
'cookieClicked': {
img: '/fluent-emoji/1f36a.png',
bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))',
frame: 'bronze',
},
'brainDiver': {
img: '/fluent-emoji/1f9e0.png',
bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))',
frame: 'bronze',
},
} as const satisfies Record<typeof ACHIEVEMENT_TYPES[number], {
img: string;
bg: string | null;
frame: 'bronze' | 'silver' | 'gold' | 'platinum';
}>;
export const claimedAchievements = ($i && $i.achievements) ? $i.achievements.map(x => x.name) : [];
export function claimAchievement(type: typeof ACHIEVEMENT_TYPES[number]) {
if (claimedAchievements.includes(type)) return;
os.api('i/claim-achievement', { name: type });
claimedAchievements.push(type);
}
if (_DEV_) {
(window as any).unlockAllAchievements = async () => {
for (const t of ACHIEVEMENT_TYPES) {
await new Promise(resolve => setTimeout(resolve, 100));
claimAchievement(t);
}
};
}

View File

@ -50,6 +50,7 @@ export type AsUiButton = AsUiComponentBase & {
onClick?: () => void; onClick?: () => void;
primary?: boolean; primary?: boolean;
rounded?: boolean; rounded?: boolean;
disabled?: boolean;
}; };
export type AsUiButtons = AsUiComponentBase & { export type AsUiButtons = AsUiComponentBase & {
@ -302,6 +303,8 @@ function getButtonOptions(def: values.Value | undefined, call: (fn: values.VFn,
if (primary) utils.assertBoolean(primary); if (primary) utils.assertBoolean(primary);
const rounded = def.value.get('rounded'); const rounded = def.value.get('rounded');
if (rounded) utils.assertBoolean(rounded); if (rounded) utils.assertBoolean(rounded);
const disabled = button.value.get('disabled');
if (disabled) utils.assertBoolean(disabled);
return { return {
text: text?.value, text: text?.value,
@ -310,6 +313,7 @@ function getButtonOptions(def: values.Value | undefined, call: (fn: values.VFn,
}, },
primary: primary?.value, primary: primary?.value,
rounded: rounded?.value, rounded: rounded?.value,
disabled: disabled?.value,
}; };
} }
@ -330,6 +334,8 @@ function getButtonsOptions(def: values.Value | undefined, call: (fn: values.VFn,
if (primary) utils.assertBoolean(primary); if (primary) utils.assertBoolean(primary);
const rounded = button.value.get('rounded'); const rounded = button.value.get('rounded');
if (rounded) utils.assertBoolean(rounded); if (rounded) utils.assertBoolean(rounded);
const disabled = button.value.get('disabled');
if (disabled) utils.assertBoolean(disabled);
return { return {
text: text.value, text: text.value,
@ -338,6 +344,7 @@ function getButtonsOptions(def: values.Value | undefined, call: (fn: values.VFn,
}, },
primary: primary?.value, primary: primary?.value,
rounded: rounded?.value, rounded: rounded?.value,
disabled: disabled?.value,
}; };
}) : [], }) : [],
}; };

View File

@ -1,6 +1,7 @@
import { defineAsyncComponent, Ref, inject } from 'vue'; import { defineAsyncComponent, Ref, inject } from 'vue';
import * as misskey from 'misskey-js'; import * as misskey from 'misskey-js';
import { pleaseLogin } from './please-login'; import { pleaseLogin } from './please-login';
import { claimAchievement } from './achievements';
import { $i } from '@/account'; import { $i } from '@/account';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { instance } from '@/instance'; import { instance } from '@/instance';
@ -38,6 +39,10 @@ export function getNoteMenu(props: {
os.api('notes/delete', { os.api('notes/delete', {
noteId: appearNote.id, noteId: appearNote.id,
}); });
if (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 60) {
claimAchievement('noteDeletedWithin1min');
}
}); });
} }
@ -53,10 +58,15 @@ export function getNoteMenu(props: {
}); });
os.post({ initialNote: appearNote, renote: appearNote.renote, reply: appearNote.reply, channel: appearNote.channel }); os.post({ initialNote: appearNote, renote: appearNote.renote, reply: appearNote.reply, channel: appearNote.channel });
if (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 60) {
claimAchievement('noteDeletedWithin1min');
}
}); });
} }
function toggleFavorite(favorite: boolean): void { function toggleFavorite(favorite: boolean): void {
claimAchievement('noteFavorited1');
os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', { os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', {
noteId: appearNote.id, noteId: appearNote.id,
}); });
@ -118,11 +128,13 @@ export function getNoteMenu(props: {
const clip = await os.apiWithDialog('clips/create', result); const clip = await os.apiWithDialog('clips/create', result);
claimAchievement('noteClipped1');
os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id }); os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id });
}, },
}, null, ...clips.map(clip => ({ }, null, ...clips.map(clip => ({
text: clip.name, text: clip.name,
action: () => { action: () => {
claimAchievement('noteClipped1');
os.promiseDialog( os.promiseDialog(
os.api('clips/add-note', { clipId: clip.id, noteId: appearNote.id }), os.api('clips/add-note', { clipId: clip.id, noteId: appearNote.id }),
null, null,
@ -174,9 +186,17 @@ export function getNoteMenu(props: {
url: `${url}/notes/${appearNote.id}`, url: `${url}/notes/${appearNote.id}`,
}); });
} }
function notedetails(): void {
function openDetail(): void {
os.pageWindow(`/notes/${appearNote.id}`); os.pageWindow(`/notes/${appearNote.id}`);
} }
function showReactions(): void {
os.popup(defineAsyncComponent(() => import('@/components/MkReactedUsersDialog.vue')), {
noteId: appearNote.id,
}, {}, 'closed');
}
async function translate(): Promise<void> { async function translate(): Promise<void> {
if (props.translation.value != null) return; if (props.translation.value != null) return;
props.translating.value = true; props.translating.value = true;
@ -205,7 +225,11 @@ export function getNoteMenu(props: {
), { ), {
icon: 'ti ti-info-circle', icon: 'ti ti-info-circle',
text: i18n.ts.details, text: i18n.ts.details,
action: notedetails, action: openDetail,
}, {
icon: 'ti ti-users',
text: i18n.ts.reactions,
action: showReactions,
}, { }, {
icon: 'ti ti-copy', icon: 'ti ti-copy',
text: i18n.ts.copyContent, text: i18n.ts.copyContent,

View File

@ -97,6 +97,7 @@ export default defineConfig(({ command, mode }) => {
output: { output: {
manualChunks: { manualChunks: {
vue: ['vue'], vue: ['vue'],
photoswipe: ['photoswipe', 'photoswipe/lightbox', 'photoswipe/style.css'],
}, },
}, },
}, },

File diff suppressed because it is too large Load Diff

View File

@ -14,7 +14,7 @@ const fs = require('fs');
stderr: process.stderr, stderr: process.stderr,
}); });
execa('pnpm', ['dlx', 'gulp', 'watch'], { execa('pnpm', ['exec', 'gulp', 'watch'], {
cwd: __dirname + '/../', cwd: __dirname + '/../',
stdout: process.stdout, stdout: process.stdout,
stderr: process.stderr, stderr: process.stderr,