Merge branch 'develop' of https://github.com/misskey-dev/misskey into emoji_hide

This commit is contained in:
tai-cha 2025-05-12 09:05:58 +09:00
commit f63b7c0804
No known key found for this signature in database
GPG Key ID: 1D5EE39F870DC283
256 changed files with 4713 additions and 3335 deletions

View File

@ -1,15 +1,47 @@
## 2025.5.1
### General
- Feat: 非ログインでサーバーを閲覧された際に、サーバー内のコンテンツを非公開にすることができるようになりました
- モデレーションが行き届きにくい不適切なリモートコンテンツなどが、自サーバー経由で図らずもインターネットに公開されてしまうことによるトラブル防止などに役立ちます
- 「全て公開(今までの挙動)」「ローカルのコンテンツだけ公開(=サーバー内で受信されたリモートのコンテンツは公開しない)」「何も公開しない」から選択できます
- デフォルト値は「ローカルのコンテンツだけ公開」になっています
### Client
- Feat: サーバー初期設定ウィザードが実装されました
- 簡単なウィザードに従うだけで、サーバーに最適な設定が適用されます
- Feat: Websocket接続を行わずにMisskeyを利用するNo Websocketモードが実装されました(beta)
- サーバーのパフォーマンス向上に寄与することが期待されます
- 何らの理由によりWebsocket接続が行えない環境でも快適に利用可能です
- 従来のWebsocket接続を行うモードはリアルタイムモードとして再定義されました
- チャットなど、一部の機能は引き続き設定に関わらずWebsocket接続が行われます
- Enhance: メモリ使用量を軽減しました
- Enhance: 画像の高品質なプレースホルダを無効化してパフォーマンスを向上させるオプションを追加
- Enhance: 招待されているが参加していないルームを開いたときに、招待を承認するかどうか尋ねるように
- Enhance: リプライ元にアンケートがあることが表示されるように
- Enhance: ノートのサーバー情報のデザインを改善・パフォーマンス向上
(Based on https://github.com/taiyme/misskey/pull/198, https://github.com/taiyme/misskey/pull/211, https://github.com/taiyme/misskey/pull/283)
- Fix: "時計"ウィジェット(Clock)において、Transparent設定が有効でも、その背景が透過されない問題を修正
### Server
- Enhance: チャットルームの最大メンバー数を30人から50人に調整
- Enhance: ノートのレスポンスにアンケートが添付されているかどうかを示すフラグ`hasPoll`を追加
- Enhance: チャットルームのレスポンスに招待されているかどうかを示すフラグ`invitationExists`を追加
- Enhance: レートリミットの計算方法を調整 (#13997)
- Fix: チャットルームが削除された場合・チャットルームから抜けた場合に、未読状態が残り続けることがあるのを修正
- Fix: ユーザ除外アンテナをインポートできない問題を修正
- Fix: アンテナのセンシティブなチャンネルのノートを含むかどうかの情報がエクスポートされない問題を修正
## 2025.5.0 ## 2025.5.0
### Note ### Note
- DockerのNode.jsが22.15.0に更新されました - DockerのNode.jsが22.15.0に更新されました
### General
-
### Client ### Client
- Feat: マウスでもタイムラインを引っ張って更新できるように - Feat: マウスで中ボタンドラッグによりタイムラインを引っ張って更新できるように
- アクセシビリティ設定からオフにすることもできます - アクセシビリティ設定からオフにすることもできます
- Enhance: タイムラインのパフォーマンスを向上 - Enhance: タイムラインのパフォーマンスを向上
- Enhance: バックアップされた設定のプロファイルを削除できるように
- Fix: 一部のブラウザでアコーディオンメニューのアニメーションが動作しない問題を修正 - Fix: 一部のブラウザでアコーディオンメニューのアニメーションが動作しない問題を修正
- Fix: ダイアログのお知らせが画面からはみ出ることがある問題を修正 - Fix: ダイアログのお知らせが画面からはみ出ることがある問題を修正
- Fix: ユーザーポップアップでエラーが生じてもインジケーターが表示され続けてしまう問題を修正 - Fix: ユーザーポップアップでエラーが生じてもインジケーターが表示され続けてしまう問題を修正

BIN
assets/ui-icons.afdesign Normal file

Binary file not shown.

View File

@ -2,11 +2,6 @@ import { defineConfig } from 'cypress'
export default defineConfig({ export default defineConfig({
e2e: { e2e: {
// We've imported your old cypress plugins here.
// You may want to clean this up later by importing these.
setupNodeEvents(on, config) {
return require('./cypress/plugins/index.js')(on, config)
},
baseUrl: 'http://localhost:61812', baseUrl: 'http://localhost:61812',
}, },
}) })

View File

@ -31,6 +31,15 @@ describe('Before setup instance', () => {
// なぜか動かない // なぜか動かない
//cy.wait('@signup').should('have.property', 'response.statusCode'); //cy.wait('@signup').should('have.property', 'response.statusCode');
cy.wait('@signup'); cy.wait('@signup');
cy.intercept('POST', '/api/admin/update-meta').as('update-meta');
cy.get('[data-cy-next]').click();
cy.get('[data-cy-next]').click();
cy.get('[data-cy-server-name] input').type('Testskey');
cy.get('[data-cy-server-setup-wizard-apply]').click();
cy.wait('@update-meta');
}); });
}); });

View File

@ -1,22 +0,0 @@
/// <reference types="cypress" />
// ***********************************************************
// This example plugins/index.js can be used to load plugins
//
// You can change the location of this file or turn off loading
// the plugins file with the 'pluginsFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
/**
* @type {Cypress.PluginConfig}
*/
// eslint-disable-next-line no-unused-vars
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
}

View File

@ -215,7 +215,6 @@ noUsers: "ليس هناك مستخدمون"
editProfile: "تعديل الملف التعريفي" editProfile: "تعديل الملف التعريفي"
noteDeleteConfirm: "هل تريد حذف هذه الملاحظة؟" noteDeleteConfirm: "هل تريد حذف هذه الملاحظة؟"
pinLimitExceeded: "لا يمكنك تثبيت الملاحظات بعد الآن." pinLimitExceeded: "لا يمكنك تثبيت الملاحظات بعد الآن."
intro: "لقد انتهت عملية تنصيب Misskey. الرجاء إنشاء حساب إداري."
done: "تمّ" done: "تمّ"
processing: "المعالجة جارية" processing: "المعالجة جارية"
preview: "معاينة" preview: "معاينة"
@ -676,7 +675,6 @@ experimental: "اختباري"
developer: "المطور" developer: "المطور"
makeExplorable: "أظهر الحساب في صفحة \"استكشاف\"" makeExplorable: "أظهر الحساب في صفحة \"استكشاف\""
makeExplorableDescription: "بتعطيل هذا الخيار لن يظهر حسابك في صفحة \"استكشاف\"" makeExplorableDescription: "بتعطيل هذا الخيار لن يظهر حسابك في صفحة \"استكشاف\""
showGapBetweenNotesInTimeline: "أظهر فجوات بين المشاركات في الخيط الزمني"
left: "يسار" left: "يسار"
center: "وسط" center: "وسط"
wide: "عريض" wide: "عريض"

View File

@ -215,7 +215,6 @@ noUsers: "কোন ব্যাবহারকারী নেই"
editProfile: "প্রোফাইল সম্পাদনা করুন" editProfile: "প্রোফাইল সম্পাদনা করুন"
noteDeleteConfirm: "আপনি কি নোট ডিলিট করার ব্যাপারে নিশ্চিত?" noteDeleteConfirm: "আপনি কি নোট ডিলিট করার ব্যাপারে নিশ্চিত?"
pinLimitExceeded: "আপনি আর কোন নোট পিন করতে পারবেন না" pinLimitExceeded: "আপনি আর কোন নোট পিন করতে পারবেন না"
intro: "Misskey এর ইন্সটলেশন সম্পন্ন হয়েছে!দয়া করে অ্যাডমিন ইউজার তৈরি করুন।"
done: "সম্পন্ন" done: "সম্পন্ন"
processing: "প্রক্রিয়াধীন..." processing: "প্রক্রিয়াধীন..."
preview: "পূর্বরূপ দেখুন" preview: "পূর্বরূপ দেখুন"
@ -673,7 +672,6 @@ experimentalFeatures: "পরীক্ষামূলক বৈশিষ্ট
developer: "ডেভেলপার" developer: "ডেভেলপার"
makeExplorable: "অ্যাকাউন্ট \"ঘুরে দেখুন\" পৃষ্ঠায় দেখান" makeExplorable: "অ্যাকাউন্ট \"ঘুরে দেখুন\" পৃষ্ঠায় দেখান"
makeExplorableDescription: "আপনি এটি বন্ধ করলে, আপনার অ্যাকাউন্ট \"ঘুরে দেখুন\" পৃষ্ঠায় প্রদর্শিত হবে না।" makeExplorableDescription: "আপনি এটি বন্ধ করলে, আপনার অ্যাকাউন্ট \"ঘুরে দেখুন\" পৃষ্ঠায় প্রদর্শিত হবে না।"
showGapBetweenNotesInTimeline: "টাইমলাইন এবং নোটের মাঝে ফাকা জায়গা রাখুন"
duplicate: "প্রতিরূপ" duplicate: "প্রতিরূপ"
left: "বাম" left: "বাম"
center: "মাঝখান" center: "মাঝখান"

View File

@ -251,7 +251,6 @@ noUsers: "No hi ha usuaris"
editProfile: "Edita el perfil" editProfile: "Edita el perfil"
noteDeleteConfirm: "Segur que voleu eliminar aquesta publicació?" noteDeleteConfirm: "Segur que voleu eliminar aquesta publicació?"
pinLimitExceeded: "No podeu fixar més publicacions" pinLimitExceeded: "No podeu fixar més publicacions"
intro: "La instal·lació de Misskey ha acabat! Crea un usuari d'administrador."
done: "Fet" done: "Fet"
processing: "S'està processant..." processing: "S'està processant..."
preview: "Vista prèvia" preview: "Vista prèvia"
@ -785,7 +784,6 @@ thisIsExperimentalFeature: "Aquesta és una característica experimental. La sev
developer: "Programador" developer: "Programador"
makeExplorable: "Fes que el compte sigui visible a la secció \"Explorar\"" makeExplorable: "Fes que el compte sigui visible a la secció \"Explorar\""
makeExplorableDescription: "Si desactives aquesta opció, el teu compte no sortirà a la secció \"Explorar\"" makeExplorableDescription: "Si desactives aquesta opció, el teu compte no sortirà a la secció \"Explorar\""
showGapBetweenNotesInTimeline: "Notes separades a la línia de temps"
duplicate: "Duplicat" duplicate: "Duplicat"
left: "Esquerra" left: "Esquerra"
center: "Centre" center: "Centre"
@ -1238,7 +1236,6 @@ showAvatarDecorations: "Mostrar les decoracions dels avatars"
releaseToRefresh: "Deixar anar per actualitzar" releaseToRefresh: "Deixar anar per actualitzar"
refreshing: "Recarregant..." refreshing: "Recarregant..."
pullDownToRefresh: "Llisca cap a baix per recarregar" pullDownToRefresh: "Llisca cap a baix per recarregar"
disableStreamingTimeline: "Desactivar l'actualització en temps real de les línies de temps"
useGroupedNotifications: "Mostrar les notificacions agrupades " useGroupedNotifications: "Mostrar les notificacions agrupades "
signupPendingError: "Hi ha hagut un problema verificant l'adreça de correu electrònic. L'enllaç pot haver caducat." signupPendingError: "Hi ha hagut un problema verificant l'adreça de correu electrònic. L'enllaç pot haver caducat."
cwNotationRequired: "Si està activat \"Amagar contingut\" s'ha d'escriure una descripció " cwNotationRequired: "Si està activat \"Amagar contingut\" s'ha d'escriure una descripció "
@ -1348,6 +1345,7 @@ readonly: "Només lectura"
goToDeck: "Tornar al tauler" goToDeck: "Tornar al tauler"
federationJobs: "Treballs sindicats " federationJobs: "Treballs sindicats "
driveAboutTip: "Al Disc veure's una llista de tots els arxius que has anat pujant.<br>\nPots tornar-los a fer servir adjuntant-los a notes noves o pots adelantar-te i pujar arxius per publicar-los més tard!<br>\n<b>Tingués en compte que si esborres un arxiu també desapareixerà de tots els llocs on l'has fet servir (notes, pàgines, avatars, imatges de capçalera, etc.)</b><br>\nTambé pots crear carpetes per organitzar les." driveAboutTip: "Al Disc veure's una llista de tots els arxius que has anat pujant.<br>\nPots tornar-los a fer servir adjuntant-los a notes noves o pots adelantar-te i pujar arxius per publicar-los més tard!<br>\n<b>Tingués en compte que si esborres un arxiu també desapareixerà de tots els llocs on l'has fet servir (notes, pàgines, avatars, imatges de capçalera, etc.)</b><br>\nTambé pots crear carpetes per organitzar les."
scrollToClose: "Desplaçar per tancar"
_chat: _chat:
noMessagesYet: "Encara no tens missatges " noMessagesYet: "Encara no tens missatges "
newMessage: "Missatge nou" newMessage: "Missatge nou"
@ -1433,6 +1431,7 @@ _preferencesProfile:
profileName: "Nom del perfil" profileName: "Nom del perfil"
profileNameDescription: "Estableix un nom que identifiqui aquest dispositiu." profileNameDescription: "Estableix un nom que identifiqui aquest dispositiu."
profileNameDescription2: "Per exemple: \"PC Principal\", \"Smartphone\", etc" profileNameDescription2: "Per exemple: \"PC Principal\", \"Smartphone\", etc"
manageProfiles: "Gestionar perfils"
_preferencesBackup: _preferencesBackup:
autoBackup: "Còpia de seguretat automàtica " autoBackup: "Còpia de seguretat automàtica "
restoreFromBackup: "Restaurar des d'una còpia de seguretat" restoreFromBackup: "Restaurar des d'una còpia de seguretat"

View File

@ -228,7 +228,6 @@ noUsers: "Žádní uživatelé"
editProfile: "Upravit můj profil" editProfile: "Upravit můj profil"
noteDeleteConfirm: "Jste si jistí že chcete smazat tuhle poznámku?" noteDeleteConfirm: "Jste si jistí že chcete smazat tuhle poznámku?"
pinLimitExceeded: "Nemůžete připnout další poznámky." pinLimitExceeded: "Nemůžete připnout další poznámky."
intro: "Instalace Misskey byla dokončena! Prosím vytvořte admina."
done: "Hotovo" done: "Hotovo"
processing: "Zpracovávám" processing: "Zpracovávám"
preview: "Náhled" preview: "Náhled"
@ -726,7 +725,6 @@ thisIsExperimentalFeature: "Tohle je experimentální funkce. Její funkce se m
developer: "Vývojář" developer: "Vývojář"
makeExplorable: "Udělat účet viditelný v \"Objevit\"" makeExplorable: "Udělat účet viditelný v \"Objevit\""
makeExplorableDescription: "Pokud tohle vypnete, tak se účet přestane zobrazovat v sekci \"Objevit\"." makeExplorableDescription: "Pokud tohle vypnete, tak se účet přestane zobrazovat v sekci \"Objevit\"."
showGapBetweenNotesInTimeline: "Zobrazit mezeru mezi příspěvkama na časové ose"
duplicate: "Duplikovat" duplicate: "Duplikovat"
left: "Vlevo" left: "Vlevo"
center: "Uprostřed" center: "Uprostřed"

View File

@ -251,7 +251,6 @@ noUsers: "Keine Benutzer gefunden"
editProfile: "Profil bearbeiten" editProfile: "Profil bearbeiten"
noteDeleteConfirm: "Möchtest du diese Notiz wirklich löschen?" noteDeleteConfirm: "Möchtest du diese Notiz wirklich löschen?"
pinLimitExceeded: "Du kannst nicht noch mehr Notizen anheften." pinLimitExceeded: "Du kannst nicht noch mehr Notizen anheften."
intro: "Misskey ist installiert! Lass uns nun ein Administratorkonto einrichten."
done: "Fertig" done: "Fertig"
processing: "In Bearbeitung …" processing: "In Bearbeitung …"
preview: "Vorschau" preview: "Vorschau"
@ -785,7 +784,6 @@ thisIsExperimentalFeature: "Dies ist eine experimentelle Funktion. Änderungen a
developer: "Entwickler" developer: "Entwickler"
makeExplorable: "Benutzerkonto in „Erkunden“ sichtbar machen" makeExplorable: "Benutzerkonto in „Erkunden“ sichtbar machen"
makeExplorableDescription: "Wenn diese Option deaktiviert ist, ist dein Benutzerkonto nicht im „Erkunden“-Bereich sichtbar." makeExplorableDescription: "Wenn diese Option deaktiviert ist, ist dein Benutzerkonto nicht im „Erkunden“-Bereich sichtbar."
showGapBetweenNotesInTimeline: "Abstände zwischen Notizen auf der Chronik anzeigen"
duplicate: "Duplizieren" duplicate: "Duplizieren"
left: "Links" left: "Links"
center: "Mittig" center: "Mittig"
@ -1238,7 +1236,6 @@ showAvatarDecorations: "Profilbilddekoration anzeigen"
releaseToRefresh: "Zum Aktualisieren loslassen" releaseToRefresh: "Zum Aktualisieren loslassen"
refreshing: "Wird aktualisiert..." refreshing: "Wird aktualisiert..."
pullDownToRefresh: "Zum Aktualisieren ziehen" pullDownToRefresh: "Zum Aktualisieren ziehen"
disableStreamingTimeline: "Echtzeitaktualisierung der Chronik deaktivieren"
useGroupedNotifications: "Benachrichtigungen gruppieren" useGroupedNotifications: "Benachrichtigungen gruppieren"
signupPendingError: "Beim Überprüfen der Mailadresse ist etwas schiefgelaufen. Der Link könnte abgelaufen sein." signupPendingError: "Beim Überprüfen der Mailadresse ist etwas schiefgelaufen. Der Link könnte abgelaufen sein."
cwNotationRequired: "Ist \"Inhaltswarnung verwenden\" aktiviert, muss eine Beschreibung gegeben werden." cwNotationRequired: "Ist \"Inhaltswarnung verwenden\" aktiviert, muss eine Beschreibung gegeben werden."
@ -1348,6 +1345,7 @@ readonly: "Nur Lesezugriff"
goToDeck: "Zurück zum Deck" goToDeck: "Zurück zum Deck"
federationJobs: "Föderation Jobs" federationJobs: "Föderation Jobs"
driveAboutTip: "In Drive sehen Sie eine Liste der Dateien, die Sie in der Vergangenheit hochgeladen haben. <br>\nSie können diese Dateien wiederverwenden um sie zu beispiel an Notizen anzuhängen, oder sie können Dateien vorab hochzuladen, um sie später zu versenden! <br>\n<b>Wenn Sie eine Datei löschen, verschwindet sie auch von allen Stellen, an denen Sie sie verwendet haben (Notizen, Seiten, Avatare, Banner usw.).</b><br>\nSie können auch Ordner erstellen, um sie zu organisieren." driveAboutTip: "In Drive sehen Sie eine Liste der Dateien, die Sie in der Vergangenheit hochgeladen haben. <br>\nSie können diese Dateien wiederverwenden um sie zu beispiel an Notizen anzuhängen, oder sie können Dateien vorab hochzuladen, um sie später zu versenden! <br>\n<b>Wenn Sie eine Datei löschen, verschwindet sie auch von allen Stellen, an denen Sie sie verwendet haben (Notizen, Seiten, Avatare, Banner usw.).</b><br>\nSie können auch Ordner erstellen, um sie zu organisieren."
scrollToClose: "Zum Schließen scrollen"
_chat: _chat:
noMessagesYet: "Noch keine Nachrichten" noMessagesYet: "Noch keine Nachrichten"
newMessage: "Neue Nachricht" newMessage: "Neue Nachricht"
@ -1425,6 +1423,7 @@ _settings:
ifOff: "Wenn ausgeschaltet" ifOff: "Wenn ausgeschaltet"
enableSyncThemesBetweenDevices: "Synchronisierung von installierten Themen auf verschiedenen Endgeräten" enableSyncThemesBetweenDevices: "Synchronisierung von installierten Themen auf verschiedenen Endgeräten"
enablePullToRefresh: "Ziehen zum Aktualisieren" enablePullToRefresh: "Ziehen zum Aktualisieren"
enablePullToRefresh_description: "Bei Benutzung einer Maus, mit gedrücktem Mausrad ziehen"
_chat: _chat:
showSenderName: "Name des Absenders anzeigen" showSenderName: "Name des Absenders anzeigen"
sendOnEnter: "Eingabetaste sendet Nachricht" sendOnEnter: "Eingabetaste sendet Nachricht"
@ -1432,6 +1431,7 @@ _preferencesProfile:
profileName: "Profilname" profileName: "Profilname"
profileNameDescription: "Lege einen Namen fest, der dieses Gerät identifiziert." profileNameDescription: "Lege einen Namen fest, der dieses Gerät identifiziert."
profileNameDescription2: "Beispiel: \"Haupt-PC\", \"Smartphone\"" profileNameDescription2: "Beispiel: \"Haupt-PC\", \"Smartphone\""
manageProfiles: "Profile verwalten"
_preferencesBackup: _preferencesBackup:
autoBackup: "Automatische Sicherung" autoBackup: "Automatische Sicherung"
restoreFromBackup: "Wiederherstellen aus der Sicherung" restoreFromBackup: "Wiederherstellen aus der Sicherung"

View File

@ -251,7 +251,6 @@ noUsers: "There are no users"
editProfile: "Edit profile" editProfile: "Edit profile"
noteDeleteConfirm: "Are you sure you want to delete this note?" noteDeleteConfirm: "Are you sure you want to delete this note?"
pinLimitExceeded: "You cannot pin any more notes" pinLimitExceeded: "You cannot pin any more notes"
intro: "Installation of Misskey has been finished! Please create an admin user."
done: "Done" done: "Done"
processing: "Processing..." processing: "Processing..."
preview: "Preview" preview: "Preview"
@ -785,7 +784,6 @@ thisIsExperimentalFeature: "This is an experimental feature. Its functionality i
developer: "Developer" developer: "Developer"
makeExplorable: "Make account visible in \"Explore\"" makeExplorable: "Make account visible in \"Explore\""
makeExplorableDescription: "If you turn this off, your account will not show up in the \"Explore\" section." makeExplorableDescription: "If you turn this off, your account will not show up in the \"Explore\" section."
showGapBetweenNotesInTimeline: "Show a gap between posts on the timeline"
duplicate: "Duplicate" duplicate: "Duplicate"
left: "Left" left: "Left"
center: "Center" center: "Center"
@ -1238,7 +1236,6 @@ showAvatarDecorations: "Show avatar decorations"
releaseToRefresh: "Release to refresh" releaseToRefresh: "Release to refresh"
refreshing: "Refreshing..." refreshing: "Refreshing..."
pullDownToRefresh: "Pull down to refresh" pullDownToRefresh: "Pull down to refresh"
disableStreamingTimeline: "Disable real-time timeline updates"
useGroupedNotifications: "Display grouped notifications" useGroupedNotifications: "Display grouped notifications"
signupPendingError: "There was a problem verifying the email address. The link may have expired." signupPendingError: "There was a problem verifying the email address. The link may have expired."
cwNotationRequired: "If \"Hide content\" is enabled, a description must be provided." cwNotationRequired: "If \"Hide content\" is enabled, a description must be provided."
@ -1426,7 +1423,7 @@ _settings:
ifOff: "When turned off" ifOff: "When turned off"
enableSyncThemesBetweenDevices: "Synchronize installed themes across devices" enableSyncThemesBetweenDevices: "Synchronize installed themes across devices"
enablePullToRefresh: "Pull to Refresh" enablePullToRefresh: "Pull to Refresh"
enablePullToRefresh_description: "When using a mouse, drag while pressing in the scrolling wheel." enablePullToRefresh_description: "When using a mouse, drag while pressing in the scroll wheel."
_chat: _chat:
showSenderName: "Show sender's name" showSenderName: "Show sender's name"
sendOnEnter: "Press Enter to send" sendOnEnter: "Press Enter to send"
@ -1434,6 +1431,7 @@ _preferencesProfile:
profileName: "Profile name" profileName: "Profile name"
profileNameDescription: "Set a name that identifies this device." profileNameDescription: "Set a name that identifies this device."
profileNameDescription2: "Example: \"Main PC\", \"Smartphone\"" profileNameDescription2: "Example: \"Main PC\", \"Smartphone\""
manageProfiles: "Manage Profiles"
_preferencesBackup: _preferencesBackup:
autoBackup: "Auto backup" autoBackup: "Auto backup"
restoreFromBackup: "Restore from backup" restoreFromBackup: "Restore from backup"

View File

@ -250,7 +250,6 @@ noUsers: "No hay usuarios"
editProfile: "Editar perfil" editProfile: "Editar perfil"
noteDeleteConfirm: "¿Desea borrar esta nota?" noteDeleteConfirm: "¿Desea borrar esta nota?"
pinLimitExceeded: "Ya no se pueden fijar más posts" pinLimitExceeded: "Ya no se pueden fijar más posts"
intro: "¡La instalación de Misskey ha terminado! Crea el usuario administrador."
done: "Terminado" done: "Terminado"
processing: "Procesando" processing: "Procesando"
preview: "Vista previa" preview: "Vista previa"
@ -784,7 +783,6 @@ thisIsExperimentalFeature: "Se trata de una función experimental. Las especific
developer: "Desarrolladores" developer: "Desarrolladores"
makeExplorable: "Hacer visible la cuenta en \"Explorar\"" makeExplorable: "Hacer visible la cuenta en \"Explorar\""
makeExplorableDescription: "Si desactiva esta opción, su cuenta no aparecerá en la sección \"Explorar\"." makeExplorableDescription: "Si desactiva esta opción, su cuenta no aparecerá en la sección \"Explorar\"."
showGapBetweenNotesInTimeline: "Mostrar un intervalo entre notas en la línea de tiempo"
duplicate: "Duplicar" duplicate: "Duplicar"
left: "Izquierda" left: "Izquierda"
center: "Centrar" center: "Centrar"
@ -1237,7 +1235,6 @@ showAvatarDecorations: "Mostrar decoraciones de avatar"
releaseToRefresh: "Soltar para recargar" releaseToRefresh: "Soltar para recargar"
refreshing: "Recargando..." refreshing: "Recargando..."
pullDownToRefresh: "Tira hacia abajo para recargar" pullDownToRefresh: "Tira hacia abajo para recargar"
disableStreamingTimeline: "Desactivar actualizaciones en tiempo real de la línea de tiempo"
useGroupedNotifications: "Mostrar notificaciones agrupadas" useGroupedNotifications: "Mostrar notificaciones agrupadas"
signupPendingError: "Ha habido un problema al verificar tu dirección de correo electrónico. Es posible que el enlace haya caducado." signupPendingError: "Ha habido un problema al verificar tu dirección de correo electrónico. Es posible que el enlace haya caducado."
cwNotationRequired: "Si se ha activado \"ocultar contenido\", es necesario proporcionar una descripción." cwNotationRequired: "Si se ha activado \"ocultar contenido\", es necesario proporcionar una descripción."

View File

@ -238,7 +238,6 @@ noUsers: "Il ny a pas dutilisateur·rice·s"
editProfile: "Modifier votre profil" editProfile: "Modifier votre profil"
noteDeleteConfirm: "Êtes-vous sûr·e de vouloir supprimer cette note ?" noteDeleteConfirm: "Êtes-vous sûr·e de vouloir supprimer cette note ?"
pinLimitExceeded: "Vous ne pouvez plus épingler dautres notes." pinLimitExceeded: "Vous ne pouvez plus épingler dautres notes."
intro: "Linstallation de Misskey est terminée ! Veuillez créer un compte administrateur."
done: "Terminé" done: "Terminé"
processing: "Traitement en cours" processing: "Traitement en cours"
preview: "Aperçu" preview: "Aperçu"
@ -760,7 +759,6 @@ thisIsExperimentalFeature: "Ceci est une fonctionnalité expérimentale. Il y a
developer: "Développeur" developer: "Développeur"
makeExplorable: "Rendre le compte visible sur la page \"Découvrir\"." makeExplorable: "Rendre le compte visible sur la page \"Découvrir\"."
makeExplorableDescription: "Si vous désactivez cette option, votre compte n'apparaîtra pas sur la page \"Découvrir\"." makeExplorableDescription: "Si vous désactivez cette option, votre compte n'apparaîtra pas sur la page \"Découvrir\"."
showGapBetweenNotesInTimeline: "Afficher un écart entre les notes sur la Timeline"
duplicate: "Duliquer" duplicate: "Duliquer"
left: "Gauche" left: "Gauche"
center: "Centrer" center: "Centrer"
@ -1209,7 +1207,6 @@ showAvatarDecorations: "Afficher les décorations d'avatar"
releaseToRefresh: "Relâcher pour rafraîchir" releaseToRefresh: "Relâcher pour rafraîchir"
refreshing: "Rafraîchissement..." refreshing: "Rafraîchissement..."
pullDownToRefresh: "Tirer vers le bas pour rafraîchir" pullDownToRefresh: "Tirer vers le bas pour rafraîchir"
disableStreamingTimeline: "Désactiver les mises à jour en temps réel de la ligne du temps"
useGroupedNotifications: "Grouper les notifications" useGroupedNotifications: "Grouper les notifications"
signupPendingError: "Un problème est survenu lors de la vérification de votre adresse e-mail. Le lien a peut-être expiré." signupPendingError: "Un problème est survenu lors de la vérification de votre adresse e-mail. Le lien a peut-être expiré."
cwNotationRequired: "Si « Masquer le contenu » est activé, une description doit être fournie." cwNotationRequired: "Si « Masquer le contenu » est activé, une description doit être fournie."

View File

@ -241,7 +241,6 @@ noUsers: "Tidak ada pengguna"
editProfile: "Sunting profil" editProfile: "Sunting profil"
noteDeleteConfirm: "Apakah kamu yakin ingin menghapus catatan ini?" noteDeleteConfirm: "Apakah kamu yakin ingin menghapus catatan ini?"
pinLimitExceeded: "Kamu tidak dapat menyematkan catatan lagi" pinLimitExceeded: "Kamu tidak dapat menyematkan catatan lagi"
intro: "Instalasi Misskey telah selesai! Mohon untuk membuat pengguna admin."
done: "Selesai" done: "Selesai"
processing: "Memproses" processing: "Memproses"
preview: "Pratinjau" preview: "Pratinjau"
@ -761,7 +760,6 @@ thisIsExperimentalFeature: "Fitur ini eksperimental. Fungsionalitas dari fitur i
developer: "Pengembang" developer: "Pengembang"
makeExplorable: "Buat akun tampil di \"Jelajahi\"" makeExplorable: "Buat akun tampil di \"Jelajahi\""
makeExplorableDescription: "Jika kamu mematikan ini, akun kamu tidak akan muncul di menu \"Jelajahi\"" makeExplorableDescription: "Jika kamu mematikan ini, akun kamu tidak akan muncul di menu \"Jelajahi\""
showGapBetweenNotesInTimeline: "Tampilkan jarak diantara catatan pada lini masa"
duplicate: "Duplikat" duplicate: "Duplikat"
left: "Kiri" left: "Kiri"
center: "Tengah" center: "Tengah"
@ -1206,7 +1204,6 @@ showAvatarDecorations: "Tampilkan dekorasi avatar"
releaseToRefresh: "Lepaskan untuk memuat ulang" releaseToRefresh: "Lepaskan untuk memuat ulang"
refreshing: "Sedang memuat ulang..." refreshing: "Sedang memuat ulang..."
pullDownToRefresh: "Tarik ke bawah untuk memuat ulang" pullDownToRefresh: "Tarik ke bawah untuk memuat ulang"
disableStreamingTimeline: "Nonaktifkan pembaharuan lini masa real-time"
useGroupedNotifications: "Tampilkan notifikasi secara dikelompokkan" useGroupedNotifications: "Tampilkan notifikasi secara dikelompokkan"
signupPendingError: "Terdapat masalah ketika memverifikasi alamat surel. Tautan kemungkinan telah kedaluwarsa." signupPendingError: "Terdapat masalah ketika memverifikasi alamat surel. Tautan kemungkinan telah kedaluwarsa."
cwNotationRequired: "Jika \"Sembunyikan konten\" diaktifkan, deskripsi harus disediakan." cwNotationRequired: "Jika \"Sembunyikan konten\" diaktifkan, deskripsi harus disediakan."

262
locales/index.d.ts vendored
View File

@ -1022,10 +1022,6 @@ export interface Locale extends ILocale {
* *
*/ */
"pinLimitExceeded": string; "pinLimitExceeded": string;
/**
* Misskeyのインストールが完了しました
*/
"intro": string;
/** /**
* *
*/ */
@ -2322,6 +2318,10 @@ export interface Locale extends ILocale {
* *
*/ */
"newNoteRecived": string; "newNoteRecived": string;
/**
*
*/
"newNote": string;
/** /**
* *
*/ */
@ -3158,10 +3158,6 @@ export interface Locale extends ILocale {
* *
*/ */
"makeExplorableDescription": string; "makeExplorableDescription": string;
/**
*
*/
"showGapBetweenNotesInTimeline": string;
/** /**
* *
*/ */
@ -4970,10 +4966,6 @@ export interface Locale extends ILocale {
* *
*/ */
"pullDownToRefresh": string; "pullDownToRefresh": string;
/**
*
*/
"disableStreamingTimeline": string;
/** /**
* *
*/ */
@ -5417,6 +5409,22 @@ export interface Locale extends ILocale {
* *
*/ */
"scrollToClose": string; "scrollToClose": string;
/**
*
*/
"advice": string;
/**
*
*/
"realtimeMode": string;
/**
*
*/
"turnItOn": string;
/**
*
*/
"turnItOff": string;
/** /**
* *
*/ */
@ -5563,6 +5571,14 @@ export interface Locale extends ILocale {
* 使 * 使
*/ */
"cannotChatWithTheUser_description": string; "cannotChatWithTheUser_description": string;
/**
*
*/
"youAreNotAMemberOfThisRoomButInvited": string;
/**
*
*/
"doYouAcceptInvitation": string;
/** /**
* *
*/ */
@ -5713,6 +5729,14 @@ export interface Locale extends ILocale {
* *
*/ */
"useStickyIcons": string; "useStickyIcons": string;
/**
*
*/
"enableHighQualityImagePlaceholders": string;
/**
* UIのアニメーション
*/
"uiAnimations": string;
/** /**
* *
*/ */
@ -5737,6 +5761,22 @@ export interface Locale extends ILocale {
* *
*/ */
"enablePullToRefresh_description": string; "enablePullToRefresh_description": string;
/**
*
*/
"realtimeMode_description": string;
/**
*
*/
"contentsUpdateFrequency": string;
/**
*
*/
"contentsUpdateFrequency_description": string;
/**
*
*/
"contentsUpdateFrequency_description2": string;
"_chat": { "_chat": {
/** /**
* *
@ -5761,6 +5801,10 @@ export interface Locale extends ILocale {
* : PC * : PC
*/ */
"profileNameDescription2": string; "profileNameDescription2": string;
/**
*
*/
"manageProfiles": string;
}; };
"_preferencesBackup": { "_preferencesBackup": {
/** /**
@ -6400,6 +6444,40 @@ export interface Locale extends ILocale {
* semver 使>= 2024.3.1 2024.3.1-custom.0 >= 2024.3.1-0 prerelease * semver 使>= 2024.3.1 2024.3.1-custom.0 >= 2024.3.1-0 prerelease
*/ */
"deliverSuspendedSoftwareDescription": string; "deliverSuspendedSoftwareDescription": string;
/**
*
*/
"singleUserMode": string;
/**
*
*/
"singleUserMode_description": string;
/**
*
*/
"userGeneratedContentsVisibilityForVisitor": string;
/**
*
*/
"userGeneratedContentsVisibilityForVisitor_description": string;
/**
*
*/
"userGeneratedContentsVisibilityForVisitor_description2": string;
"_userGeneratedContentsVisibilityForVisitor": {
/**
*
*/
"all": string;
/**
*
*/
"localOnly": string;
/**
*
*/
"none": string;
};
}; };
"_accountMigration": { "_accountMigration": {
/** /**
@ -11632,6 +11710,166 @@ export interface Locale extends ILocale {
*/ */
"serverHostPlaceholder": string; "serverHostPlaceholder": string;
}; };
"_serverSetupWizard": {
/**
* Misskeyのインストールが完了しました
*/
"installCompleted": string;
/**
*
*/
"firstCreateAccount": string;
/**
*
*/
"accountCreated": string;
/**
*
*/
"serverSetting": string;
/**
*
*/
"youCanEasilyConfigureOptimalServerSettingsWithThisWizard": string;
/**
*
*/
"settingsYouMakeHereCanBeChangedLater": string;
/**
* Misskeyをどのように使いますか
*/
"howWillYouUseMisskey": string;
"_use": {
/**
*
*/
"single": string;
/**
* 使
*/
"single_description": string;
/**
*
*/
"single_youCanCreateMultipleAccounts": string;
/**
*
*/
"group": string;
/**
* 使
*/
"group_description": string;
/**
*
*/
"open": string;
/**
*
*/
"open_description": string;
};
/**
*
*/
"openServerAdvice": string;
/**
* reCAPTCHAといったアンチボット機能を有効にするなど
*/
"openServerAntiSpamAdvice": string;
/**
*
*/
"howManyUsersDoYouExpect": string;
"_scale": {
/**
* 100 ()
*/
"small": string;
/**
* 1001000 ()
*/
"medium": string;
/**
* 1000 ()
*/
"large": string;
};
/**
*
*/
"largeScaleServerAdvice": string;
/**
* Fediverseと接続しますか
*/
"doYouConnectToFediverse": string;
/**
* (Fediverse)
*/
"doYouConnectToFediverse_description1": string;
/**
* Fediverseと接続することは
*/
"doYouConnectToFediverse_description2": string;
/**
*
*/
"youCanConfigureMoreFederationSettingsLater": string;
/**
*
*/
"adminInfo": string;
/**
* 使
*/
"adminInfo_description": string;
/**
*
*/
"adminInfo_mustBeFilled": string;
/**
*
*/
"followingSettingsAreRecommended": string;
/**
*
*/
"applyTheseSettings": string;
/**
*
*/
"skipSettings": string;
/**
*
*/
"settingsCompleted": string;
/**
* 使
*/
"settingsCompleted_description": string;
/**
*
*/
"settingsCompleted_description2": string;
/**
*
*/
"donationRequest": string;
"_donationRequest": {
/**
* Misskeyは有志によって開発されている無料のソフトウェアです
*/
"text1": string;
/**
*
*/
"text2": string;
/**
*
*/
"text3": string;
};
};
} }
declare const locales: { declare const locales: {
[lang: string]: Locale; [lang: string]: Locale;

View File

@ -250,7 +250,6 @@ noUsers: "Non ci sono profili"
editProfile: "Modifica profilo" editProfile: "Modifica profilo"
noteDeleteConfirm: "Vuoi davvero eliminare questa Nota?" noteDeleteConfirm: "Vuoi davvero eliminare questa Nota?"
pinLimitExceeded: "Non puoi fissare altre note " pinLimitExceeded: "Non puoi fissare altre note "
intro: "L'installazione di Misskey è terminata! Si prega di creare il profilo amministratore."
done: "Fine" done: "Fine"
processing: "In elaborazione" processing: "In elaborazione"
preview: "Anteprima" preview: "Anteprima"
@ -784,7 +783,6 @@ thisIsExperimentalFeature: "Questa è una funzionalità sperimentale. Potrebbe e
developer: "Sviluppatore" developer: "Sviluppatore"
makeExplorable: "Profilo visibile pubblicamente nella pagina \"Esplora\"" makeExplorable: "Profilo visibile pubblicamente nella pagina \"Esplora\""
makeExplorableDescription: "Disabilitando questa opzione, il tuo profilo non verrà elencato nella pagina \"Esplora\"." makeExplorableDescription: "Disabilitando questa opzione, il tuo profilo non verrà elencato nella pagina \"Esplora\"."
showGapBetweenNotesInTimeline: "Mostrare un intervallo tra le note sulla timeline"
duplicate: "Duplica" duplicate: "Duplica"
left: "Sinistra" left: "Sinistra"
center: "Centro" center: "Centro"
@ -1237,7 +1235,6 @@ showAvatarDecorations: "Mostra decorazione della foto profilo"
releaseToRefresh: "Rilascia per aggiornare" releaseToRefresh: "Rilascia per aggiornare"
refreshing: "Aggiornamento..." refreshing: "Aggiornamento..."
pullDownToRefresh: "Trascinare per aggiornare" pullDownToRefresh: "Trascinare per aggiornare"
disableStreamingTimeline: "Disabilitare gli aggiornamenti della TL in tempo reale"
useGroupedNotifications: "Mostra le notifiche raggruppate" useGroupedNotifications: "Mostra le notifiche raggruppate"
signupPendingError: "Si è verificato un problema durante la verifica del tuo indirizzo email. Potrebbe essere scaduto il collegamento temporaneo." signupPendingError: "Si è verificato un problema durante la verifica del tuo indirizzo email. Potrebbe essere scaduto il collegamento temporaneo."
cwNotationRequired: "Devi indicare perché il contenuto è indicato come esplicito." cwNotationRequired: "Devi indicare perché il contenuto è indicato come esplicito."

View File

@ -251,7 +251,6 @@ noUsers: "ユーザーはいません"
editProfile: "プロフィールを編集" editProfile: "プロフィールを編集"
noteDeleteConfirm: "このノートを削除しますか?" noteDeleteConfirm: "このノートを削除しますか?"
pinLimitExceeded: "これ以上ピン留めできません" pinLimitExceeded: "これ以上ピン留めできません"
intro: "Misskeyのインストールが完了しました管理者アカウントを作成しましょう。"
done: "完了" done: "完了"
processing: "処理中" processing: "処理中"
preview: "プレビュー" preview: "プレビュー"
@ -576,6 +575,7 @@ showFixedPostForm: "タイムライン上部に投稿フォームを表示する
showFixedPostFormInChannel: "タイムライン上部に投稿フォームを表示する(チャンネル)" showFixedPostFormInChannel: "タイムライン上部に投稿フォームを表示する(チャンネル)"
withRepliesByDefaultForNewlyFollowed: "フォローする際、デフォルトで返信をTLに含むようにする" withRepliesByDefaultForNewlyFollowed: "フォローする際、デフォルトで返信をTLに含むようにする"
newNoteRecived: "新しいノートがあります" newNoteRecived: "新しいノートがあります"
newNote: "新しいノート"
sounds: "サウンド" sounds: "サウンド"
sound: "サウンド" sound: "サウンド"
listen: "聴く" listen: "聴く"
@ -785,7 +785,6 @@ thisIsExperimentalFeature: "これは実験的な機能です。仕様が変更
developer: "開発者" developer: "開発者"
makeExplorable: "アカウントを見つけやすくする" makeExplorable: "アカウントを見つけやすくする"
makeExplorableDescription: "オフにすると、「みつける」にアカウントが載らなくなります。" makeExplorableDescription: "オフにすると、「みつける」にアカウントが載らなくなります。"
showGapBetweenNotesInTimeline: "タイムラインのノートを離して表示"
duplicate: "複製" duplicate: "複製"
left: "左" left: "左"
center: "中央" center: "中央"
@ -1238,7 +1237,6 @@ showAvatarDecorations: "アイコンのデコレーションを表示"
releaseToRefresh: "離してリロード" releaseToRefresh: "離してリロード"
refreshing: "リロード中" refreshing: "リロード中"
pullDownToRefresh: "引っ張ってリロード" pullDownToRefresh: "引っ張ってリロード"
disableStreamingTimeline: "タイムラインのリアルタイム更新を無効にする"
useGroupedNotifications: "通知をグルーピング" useGroupedNotifications: "通知をグルーピング"
signupPendingError: "メールアドレスの確認中に問題が発生しました。リンクの有効期限が切れている可能性があります。" signupPendingError: "メールアドレスの確認中に問題が発生しました。リンクの有効期限が切れている可能性があります。"
cwNotationRequired: "「内容を隠す」がオンの場合は注釈の記述が必要です。" cwNotationRequired: "「内容を隠す」がオンの場合は注釈の記述が必要です。"
@ -1349,6 +1347,10 @@ goToDeck: "デッキへ戻る"
federationJobs: "連合ジョブ" federationJobs: "連合ジョブ"
driveAboutTip: "ドライブでは、過去にアップロードしたファイルの一覧が表示されます。<br>\nートに添付する際に再利用したり、あとで投稿するファイルを予めアップロードしておくこともできます。<br>\n<b>ファイルを削除すると、今までそのファイルを使用した全ての場所(ノート、ページ、アバター、バナー等)からも見えなくなるので注意してください。</b><br>\nフォルダを作って整理することもできます。" driveAboutTip: "ドライブでは、過去にアップロードしたファイルの一覧が表示されます。<br>\nートに添付する際に再利用したり、あとで投稿するファイルを予めアップロードしておくこともできます。<br>\n<b>ファイルを削除すると、今までそのファイルを使用した全ての場所(ノート、ページ、アバター、バナー等)からも見えなくなるので注意してください。</b><br>\nフォルダを作って整理することもできます。"
scrollToClose: "スクロールして閉じる" scrollToClose: "スクロールして閉じる"
advice: "アドバイス"
realtimeMode: "リアルタイムモード"
turnItOn: "オンにする"
turnItOff: "オフにする"
emojiMute: "絵文字ミュート" emojiMute: "絵文字ミュート"
emojiUnmute: "絵文字ミュート解除" emojiUnmute: "絵文字ミュート解除"
muteX: "{x}をミュート" muteX: "{x}をミュート"
@ -1387,6 +1389,8 @@ _chat:
chatNotAvailableInOtherAccount: "相手のアカウントでチャット機能が使えない状態になっています。" chatNotAvailableInOtherAccount: "相手のアカウントでチャット機能が使えない状態になっています。"
cannotChatWithTheUser: "このユーザーとのチャットを開始できません" cannotChatWithTheUser: "このユーザーとのチャットを開始できません"
cannotChatWithTheUser_description: "チャットが使えない状態になっているか、相手がチャットを開放していません。" cannotChatWithTheUser_description: "チャットが使えない状態になっているか、相手がチャットを開放していません。"
youAreNotAMemberOfThisRoomButInvited: "あなたはこのルームの参加者ではありませんが、招待が届いています。参加するには、招待を承認してください。"
doYouAcceptInvitation: "招待を承認しますか?"
chatWithThisUser: "チャットする" chatWithThisUser: "チャットする"
thisUserAllowsChatOnlyFromFollowers: "このユーザーはフォロワーからのみチャットを受け付けています。" thisUserAllowsChatOnlyFromFollowers: "このユーザーはフォロワーからのみチャットを受け付けています。"
thisUserAllowsChatOnlyFromFollowing: "このユーザーは、このユーザーがフォローしているユーザーからのみチャットを受け付けています。" thisUserAllowsChatOnlyFromFollowing: "このユーザーは、このユーザーがフォローしているユーザーからのみチャットを受け付けています。"
@ -1428,12 +1432,18 @@ _settings:
makeEveryTextElementsSelectable: "全てのテキスト要素を選択可能にする" makeEveryTextElementsSelectable: "全てのテキスト要素を選択可能にする"
makeEveryTextElementsSelectable_description: "有効にすると、一部のシチュエーションでのユーザビリティが低下する場合があります。" makeEveryTextElementsSelectable_description: "有効にすると、一部のシチュエーションでのユーザビリティが低下する場合があります。"
useStickyIcons: "アイコンをスクロールに追従させる" useStickyIcons: "アイコンをスクロールに追従させる"
enableHighQualityImagePlaceholders: "高品質な画像のプレースホルダを表示"
uiAnimations: "UIのアニメーション"
showNavbarSubButtons: "ナビゲーションバーに副ボタンを表示" showNavbarSubButtons: "ナビゲーションバーに副ボタンを表示"
ifOn: "オンのとき" ifOn: "オンのとき"
ifOff: "オフのとき" ifOff: "オフのとき"
enableSyncThemesBetweenDevices: "デバイス間でインストールしたテーマを同期" enableSyncThemesBetweenDevices: "デバイス間でインストールしたテーマを同期"
enablePullToRefresh: "ひっぱって更新" enablePullToRefresh: "ひっぱって更新"
enablePullToRefresh_description: "マウスでは、ホイールを押し込みながらドラッグします。" enablePullToRefresh_description: "マウスでは、ホイールを押し込みながらドラッグします。"
realtimeMode_description: "サーバーと接続を確立し、リアルタイムでコンテンツを更新します。通信量とバッテリーの消費が多くなる場合があります。"
contentsUpdateFrequency: "コンテンツの取得頻度"
contentsUpdateFrequency_description: "高いほどリアルタイムにコンテンツが更新されますが、パフォーマンスが低下し、通信量とバッテリーの消費が多くなります。"
contentsUpdateFrequency_description2: "リアルタイムモードがオンのときは、この設定に関わらずリアルタイムでコンテンツが更新されます。"
_chat: _chat:
showSenderName: "送信者の名前を表示" showSenderName: "送信者の名前を表示"
@ -1443,6 +1453,7 @@ _preferencesProfile:
profileName: "プロファイル名" profileName: "プロファイル名"
profileNameDescription: "このデバイスを識別する名前を設定してください。" profileNameDescription: "このデバイスを識別する名前を設定してください。"
profileNameDescription2: "例: 「メインPC」、「スマホ」など" profileNameDescription2: "例: 「メインPC」、「スマホ」など"
manageProfiles: "プロファイルの管理"
_preferencesBackup: _preferencesBackup:
autoBackup: "自動バックアップ" autoBackup: "自動バックアップ"
@ -1626,6 +1637,16 @@ _serverSettings:
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "一定期間モデレーターのアクティビティが検出されなかった場合、スパム防止のためこの設定は自動でオフになります。" thisSettingWillAutomaticallyOffWhenModeratorsInactive: "一定期間モデレーターのアクティビティが検出されなかった場合、スパム防止のためこの設定は自動でオフになります。"
deliverSuspendedSoftware: "配信停止中のソフトウェア" deliverSuspendedSoftware: "配信停止中のソフトウェア"
deliverSuspendedSoftwareDescription: "脆弱性などの理由で、サーバーのソフトウェアの名前及びバージョンの範囲を指定して配信を停止できます。このバージョン情報はサーバーが提供したものであり、信頼性は保証されません。バージョン指定には semver の範囲指定が使用できますが、>= 2024.3.1 と指定すると 2024.3.1-custom.0 のようなカスタムバージョンが含まれないため、>= 2024.3.1-0 のように prerelease の指定を行うことを推奨します。" deliverSuspendedSoftwareDescription: "脆弱性などの理由で、サーバーのソフトウェアの名前及びバージョンの範囲を指定して配信を停止できます。このバージョン情報はサーバーが提供したものであり、信頼性は保証されません。バージョン指定には semver の範囲指定が使用できますが、>= 2024.3.1 と指定すると 2024.3.1-custom.0 のようなカスタムバージョンが含まれないため、>= 2024.3.1-0 のように prerelease の指定を行うことを推奨します。"
singleUserMode: "お一人様モード"
singleUserMode_description: "このサーバーを利用するのが自分だけの場合、このモードを有効にすることで動作が最適化されます。"
userGeneratedContentsVisibilityForVisitor: "非利用者に対するユーザー作成コンテンツの公開範囲"
userGeneratedContentsVisibilityForVisitor_description: "モデレーションが行き届きにくい不適切なリモートコンテンツなどが、自サーバー経由で図らずもインターネットに公開されてしまうことによるトラブル防止などに役立ちます。"
userGeneratedContentsVisibilityForVisitor_description2: "サーバーで受信したリモートのコンテンツを含め、サーバー内の全てのコンテンツを無条件でインターネットに公開することはリスクが伴います。特に、分散型の特性を知らない閲覧者にとっては、リモートのコンテンツであってもサーバー内で作成されたコンテンツであると誤って認識してしまう可能性があるため、注意が必要です。"
_userGeneratedContentsVisibilityForVisitor:
all: "全て公開"
localOnly: "ローカルコンテンツのみ公開し、リモートコンテンツは非公開"
none: "全て非公開"
_accountMigration: _accountMigration:
moveFrom: "別のアカウントからこのアカウントに移行" moveFrom: "別のアカウントからこのアカウントに移行"
@ -3110,3 +3131,46 @@ _search:
pleaseEnterServerHost: "サーバーのホストを入力してください" pleaseEnterServerHost: "サーバーのホストを入力してください"
pleaseSelectUser: "ユーザーを選択してください" pleaseSelectUser: "ユーザーを選択してください"
serverHostPlaceholder: "例: misskey.example.com" serverHostPlaceholder: "例: misskey.example.com"
_serverSetupWizard:
installCompleted: "Misskeyのインストールが完了しました"
firstCreateAccount: "まずは、管理者アカウントを作成しましょう。"
accountCreated: "管理者アカウントが作成されました!"
serverSetting: "サーバーの設定"
youCanEasilyConfigureOptimalServerSettingsWithThisWizard: "このウィザードで簡単に最適なサーバーの設定が行えます。"
settingsYouMakeHereCanBeChangedLater: "ここでの設定は、あとからでも変更できます。"
howWillYouUseMisskey: "Misskeyをどのように使いますか"
_use:
single: "お一人様サーバー"
single_description: "自分専用のサーバーとして、一人で使う"
single_youCanCreateMultipleAccounts: "お一人様サーバーとして運用する場合でも、アカウントは必要に応じて複数作成可能です。"
group: "グループサーバー"
group_description: "信頼できる他の利用者を招待して、複数人で使う"
open: "オープンサーバー"
open_description: "不特定多数の利用者を受け入れる運営を行う"
openServerAdvice: "不特定多数の利用者を受け入れることはリスクが伴います。トラブルに対処できるよう、確実なモデレーション体制で運営することを推奨します。"
openServerAntiSpamAdvice: "自サーバーがスパムの踏み台にならないように、reCAPTCHAといったアンチボット機能を有効にするなど、セキュリティについても細心の注意が必要です。"
howManyUsersDoYouExpect: "どれくらいの人数を想定していますか?"
_scale:
small: "100人以下 (小規模)"
medium: "100人以上1000人以下 (中規模)"
large: "1000人以上 (大規模)"
largeScaleServerAdvice: "大規模なサーバーでは、ロードバランシングやデータベースのレプリケーションなど、高度なインフラストラクチャーの知識が必要になる場合があります。"
doYouConnectToFediverse: "Fediverseと接続しますか"
doYouConnectToFediverse_description1: "分散型サーバーで構成されるネットワーク(Fediverse)に接続すると、他のサーバーと相互にコンテンツのやり取りが可能です。"
doYouConnectToFediverse_description2: "Fediverseと接続することは「連合」とも呼ばれます。"
youCanConfigureMoreFederationSettingsLater: "連合可能なサーバーの指定など、高度な設定も後ほど可能です。"
adminInfo: "管理者情報"
adminInfo_description: "問い合わせを受け付けるために使用される管理者情報を設定します。"
adminInfo_mustBeFilled: "オープンサーバー、または連合がオンの場合は必ず入力が必要です。"
followingSettingsAreRecommended: "以下の設定が推奨されます"
applyTheseSettings: "この設定を適用"
skipSettings: "設定をスキップ"
settingsCompleted: "設定が完了しました!"
settingsCompleted_description: "お疲れ様でした。準備が整ったので、さっそくサーバーの使用を開始できます。"
settingsCompleted_description2: "詳細なサーバー設定は、「コントロールパネル」から行えます。"
donationRequest: "寄付のお願い"
_donationRequest:
text1: "Misskeyは有志によって開発されている無料のソフトウェアです。"
text2: "今後も開発を続けられるように、よろしければぜひカンパをお願いいたします。"
text3: "支援者向け特典もあります!"

View File

@ -250,7 +250,6 @@ noUsers: "ユーザーはおらん"
editProfile: "プロフィールをいじる" editProfile: "プロフィールをいじる"
noteDeleteConfirm: "このノートをほかしてええか?" noteDeleteConfirm: "このノートをほかしてええか?"
pinLimitExceeded: "これ以上ピン留めできひん" pinLimitExceeded: "これ以上ピン留めできひん"
intro: "Misskeyのインストールが完了したで管理者アカウントを作ってや。"
done: "でけた" done: "でけた"
processing: "処理しとる" processing: "処理しとる"
preview: "プレビュー" preview: "プレビュー"
@ -781,7 +780,6 @@ thisIsExperimentalFeature: "これは実験的な機能やから、仕様が変
developer: "開発者やで" developer: "開発者やで"
makeExplorable: "アカウントを見つけやすくするで" makeExplorable: "アカウントを見つけやすくするで"
makeExplorableDescription: "オフにすると、「みつける」にアカウントが載らんくなるで。" makeExplorableDescription: "オフにすると、「みつける」にアカウントが載らんくなるで。"
showGapBetweenNotesInTimeline: "タイムラインのノートを離して表示するで"
duplicate: "複製" duplicate: "複製"
left: "左" left: "左"
center: "真ん中" center: "真ん中"
@ -1233,7 +1231,6 @@ showAvatarDecorations: "アイコンのデコレーション映す"
releaseToRefresh: "離したらリロード" releaseToRefresh: "離したらリロード"
refreshing: "リロードしとる" refreshing: "リロードしとる"
pullDownToRefresh: "引っ張ってリロードするで" pullDownToRefresh: "引っ張ってリロードするで"
disableStreamingTimeline: "タイムラインのリアルタイム更新をやめるで"
useGroupedNotifications: "通知をグループ分けして出すで" useGroupedNotifications: "通知をグループ分けして出すで"
signupPendingError: "メアド確認してたらなんか変なことなったわ。リンクの期限切れてるかもしれん。" signupPendingError: "メアド確認してたらなんか変なことなったわ。リンクの期限切れてるかもしれん。"
cwNotationRequired: "「内容を隠す」んやったら注釈書かなアカンで。" cwNotationRequired: "「内容を隠す」んやったら注釈書かなアカンで。"

View File

@ -224,7 +224,6 @@ noUsers: "사용자가 어ᇝ십니다"
editProfile: "프로필 적기" editProfile: "프로필 적기"
noteDeleteConfirm: "요 노트럴 뭉캡니꺼?" noteDeleteConfirm: "요 노트럴 뭉캡니꺼?"
pinLimitExceeded: "더 몬 붙입니다" pinLimitExceeded: "더 몬 붙입니다"
intro: "Misskey럴 다 깔앗십니다! 간리자 게정얼 맨걸어 보입시다."
done: "햇어예" done: "햇어예"
processing: "처리하고 잇어예" processing: "처리하고 잇어예"
preview: "미리보기" preview: "미리보기"

View File

@ -220,6 +220,7 @@ silenceThisInstance: "서버를 사일런스"
mediaSilenceThisInstance: "서버의 미디어를 사일런스" mediaSilenceThisInstance: "서버의 미디어를 사일런스"
operations: "작업" operations: "작업"
software: "소프트웨어" software: "소프트웨어"
softwareName: "소프트웨어 이름"
version: "버전" version: "버전"
metadata: "메타데이터" metadata: "메타데이터"
withNFiles: "{n}개의 파일" withNFiles: "{n}개의 파일"
@ -250,7 +251,6 @@ noUsers: "아무도 없습니다"
editProfile: "프로필 수정" editProfile: "프로필 수정"
noteDeleteConfirm: "이 노트를 삭제하시겠습니까?" noteDeleteConfirm: "이 노트를 삭제하시겠습니까?"
pinLimitExceeded: "더 이상 고정할 수 없습니다." pinLimitExceeded: "더 이상 고정할 수 없습니다."
intro: "Misskey의 설치가 완료되었습니다! 관리자 계정을 생성해주세요."
done: "완료" done: "완료"
processing: "처리중" processing: "처리중"
preview: "미리보기" preview: "미리보기"
@ -784,7 +784,6 @@ thisIsExperimentalFeature: "이 기능은 실험적인 기능입니다. 사양
developer: "개발자" developer: "개발자"
makeExplorable: "계정을 쉽게 발견하도록 하기" makeExplorable: "계정을 쉽게 발견하도록 하기"
makeExplorableDescription: "비활성화하면 \"발견하기\"에 나의 계정을 표시하지 않습니다." makeExplorableDescription: "비활성화하면 \"발견하기\"에 나의 계정을 표시하지 않습니다."
showGapBetweenNotesInTimeline: "타임라인의 노트 사이를 띄워서 표시"
duplicate: "복제" duplicate: "복제"
left: "왼쪽" left: "왼쪽"
center: "가운데" center: "가운데"
@ -1237,7 +1236,6 @@ showAvatarDecorations: "아바타 장식 표시"
releaseToRefresh: "놓아서 새로고침" releaseToRefresh: "놓아서 새로고침"
refreshing: "새로고침 중" refreshing: "새로고침 중"
pullDownToRefresh: "아래로 내려서 새로고침" pullDownToRefresh: "아래로 내려서 새로고침"
disableStreamingTimeline: "타임라인의 실시간 갱신을 무효화하기"
useGroupedNotifications: "알림을 그룹화하고 표시" useGroupedNotifications: "알림을 그룹화하고 표시"
signupPendingError: "메일 주소 확인중에 문제가 발생했습니다. 링크의 유효기간이 지났을 가능성이 있습니다." signupPendingError: "메일 주소 확인중에 문제가 발생했습니다. 링크의 유효기간이 지났을 가능성이 있습니다."
cwNotationRequired: "'내용을 숨기기'를 체크한 경우 주석을 써야 합니다." cwNotationRequired: "'내용을 숨기기'를 체크한 경우 주석을 써야 합니다."
@ -1346,6 +1344,8 @@ settingsMigrating: "설정을 이전하는 중입니다. 잠시 기다려주십
readonly: "읽기 전용" readonly: "읽기 전용"
goToDeck: "덱으로 돌아가기" goToDeck: "덱으로 돌아가기"
federationJobs: "연합 작업" federationJobs: "연합 작업"
driveAboutTip: "드라이브는 이전에 업로드한 파일 목록을 표시해요. <br>\n노트에 첨부할 때 다시 사용하거나 나중에 게시할 파일을 미리 업로드할 수 있어요. <br>\n<b>파일을 삭제하면, 지금까지 그 파일을 사용한 모든 장소(노트, 페이지, 아바타, 배너 등)에서도 보이지 않게 되므로 주의해 주세요. 폴더를 만들고 정리할 수도 있어요.</b><br>"
scrollToClose: "스크롤하여 닫기"
_chat: _chat:
noMessagesYet: "아직 메시지가 없습니다" noMessagesYet: "아직 메시지가 없습니다"
newMessage: "새로운 메시지" newMessage: "새로운 메시지"
@ -1422,6 +1422,8 @@ _settings:
ifOn: "켜져 있을 때" ifOn: "켜져 있을 때"
ifOff: "꺼져 있을 때" ifOff: "꺼져 있을 때"
enableSyncThemesBetweenDevices: "기기 간 설치한 테마 동기화" enableSyncThemesBetweenDevices: "기기 간 설치한 테마 동기화"
enablePullToRefresh: "계속해서 갱신"
enablePullToRefresh_description: "마우스에서 휠을 누르면서 드래그해요."
_chat: _chat:
showSenderName: "발신자 이름 표시" showSenderName: "발신자 이름 표시"
sendOnEnter: "엔터로 보내기" sendOnEnter: "엔터로 보내기"
@ -1429,6 +1431,7 @@ _preferencesProfile:
profileName: "프로필 이름" profileName: "프로필 이름"
profileNameDescription: "이 디바이스를 식별할 이름을 설정해 주세요." profileNameDescription: "이 디바이스를 식별할 이름을 설정해 주세요."
profileNameDescription2: "예: '메인PC', '스마트폰' 등" profileNameDescription2: "예: '메인PC', '스마트폰' 등"
manageProfiles: "프로파일 관리"
_preferencesBackup: _preferencesBackup:
autoBackup: "자동 백업" autoBackup: "자동 백업"
restoreFromBackup: "백업으로 복구" restoreFromBackup: "백업으로 복구"
@ -1467,6 +1470,7 @@ _delivery:
manuallySuspended: "수동 정지 중" manuallySuspended: "수동 정지 중"
goneSuspended: "서버 삭제를 이유로 정지 중" goneSuspended: "서버 삭제를 이유로 정지 중"
autoSuspendedForNotResponding: "서버 응답 없음을 이유로 정지 중" autoSuspendedForNotResponding: "서버 응답 없음을 이유로 정지 중"
softwareSuspended: "전달 정지 중인 소프트웨어이므로 정지 중"
_bubbleGame: _bubbleGame:
howToPlay: "설명" howToPlay: "설명"
hold: "홀드" hold: "홀드"
@ -1598,6 +1602,8 @@ _serverSettings:
openRegistration: "회원 가입을 활성화 하기" openRegistration: "회원 가입을 활성화 하기"
openRegistrationWarning: "회원 가입을 개방하는 것은 리스크가 따릅니다. 서버를 항상 감시할 수 있고, 문제가 발생했을 때 바로 대응할 수 있는 상태에서만 활성화 하는 것을 권장합니다." openRegistrationWarning: "회원 가입을 개방하는 것은 리스크가 따릅니다. 서버를 항상 감시할 수 있고, 문제가 발생했을 때 바로 대응할 수 있는 상태에서만 활성화 하는 것을 권장합니다."
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "일정 기간동안 모더레이터의 활동이 감지되지 않는 경우, 스팸 방지를 위해 이 설정은 자동으로 꺼집니다." thisSettingWillAutomaticallyOffWhenModeratorsInactive: "일정 기간동안 모더레이터의 활동이 감지되지 않는 경우, 스팸 방지를 위해 이 설정은 자동으로 꺼집니다."
deliverSuspendedSoftware: "전달 정지 중인 소프트웨어"
deliverSuspendedSoftwareDescription: "취약성 등의 이유로 서버의 소프트웨어 이름 및 버전 범위를 지정하여 전달을 정지할 수 있어요. 이 버전 정보는 서버가 제공한 것이며 신뢰성은 보장되지 않아요. 버전 지정에는 semver의 범위 지정을 사용할 수 있지만, >= 2024.3.1로 지정하면 2024.3.1-custom.0과 같은 custom.0과 같은 custom 버전이 포함되지 않기 때문에 >= 2024.3.1-0과 같이 prerelease를 지정하는 것이 좋아요."
_accountMigration: _accountMigration:
moveFrom: "다른 계정에서 이 계정으로 이사" moveFrom: "다른 계정에서 이 계정으로 이사"
moveFromSub: "다른 계정에 대한 별칭을 생성" moveFromSub: "다른 계정에 대한 별칭을 생성"
@ -1915,6 +1921,7 @@ _role:
canManageCustomEmojis: "커스텀 이모지 관리" canManageCustomEmojis: "커스텀 이모지 관리"
canManageAvatarDecorations: "아바타 꾸미기 관리" canManageAvatarDecorations: "아바타 꾸미기 관리"
driveCapacity: "드라이브 용량" driveCapacity: "드라이브 용량"
maxFileSize: "업로드 가능한 최대 파일 크기"
alwaysMarkNsfw: "파일을 항상 NSFW로 지정" alwaysMarkNsfw: "파일을 항상 NSFW로 지정"
canUpdateBioMedia: "아바타 및 배너 이미지 변경 허용" canUpdateBioMedia: "아바타 및 배너 이미지 변경 허용"
pinMax: "고정할 수 있는 노트 수" pinMax: "고정할 수 있는 노트 수"

View File

@ -250,7 +250,6 @@ noUsers: "Er zijn geen gebruikers."
editProfile: "Bewerk Profiel" editProfile: "Bewerk Profiel"
noteDeleteConfirm: "Ben je zeker dat je dit bericht wil verwijderen?" noteDeleteConfirm: "Ben je zeker dat je dit bericht wil verwijderen?"
pinLimitExceeded: "Je kunt geen berichten meer vastprikken" pinLimitExceeded: "Je kunt geen berichten meer vastprikken"
intro: "Installatie van Misskey geëindigd! Maak nu een beheerder aan."
done: "Klaar" done: "Klaar"
processing: "Bezig met verwerken" processing: "Bezig met verwerken"
preview: "Voorbeeld" preview: "Voorbeeld"
@ -784,7 +783,6 @@ thisIsExperimentalFeature: "Dit is een experimentele functie. De functionaliteit
developer: "Ontwikkelaar" developer: "Ontwikkelaar"
makeExplorable: "Gebruikersaccount zichtbaar maken in “Verkennen”" makeExplorable: "Gebruikersaccount zichtbaar maken in “Verkennen”"
makeExplorableDescription: "Als deze optie is uitgeschakeld, is uw gebruikersaccount niet zichtbaar in het gedeelte “Verkennen”." makeExplorableDescription: "Als deze optie is uitgeschakeld, is uw gebruikersaccount niet zichtbaar in het gedeelte “Verkennen”."
showGapBetweenNotesInTimeline: "Een gat tussen noten op de tijdlijn weergeven"
duplicate: "Dupliceren" duplicate: "Dupliceren"
left: "Links" left: "Links"
center: "Center" center: "Center"

View File

@ -171,7 +171,6 @@ noUsers: "Det er ingen brukere"
editProfile: "Rediger profil" editProfile: "Rediger profil"
noteDeleteConfirm: "Er du sikker på at du vil slette denne Noten?" noteDeleteConfirm: "Er du sikker på at du vil slette denne Noten?"
pinLimitExceeded: "Du kan ikke feste flere." pinLimitExceeded: "Du kan ikke feste flere."
intro: "Installasjonen av Misskey er ferdig! Vennligst opprett en administratorkonto."
done: "Ferdig" done: "Ferdig"
default: "Standard" default: "Standard"
defaultValueIs: "Standard: {value}" defaultValueIs: "Standard: {value}"

View File

@ -230,7 +230,6 @@ noUsers: "Brak użytkowników"
editProfile: "Edytuj profil" editProfile: "Edytuj profil"
noteDeleteConfirm: "Czy na pewno chcesz usunąć ten wpis?" noteDeleteConfirm: "Czy na pewno chcesz usunąć ten wpis?"
pinLimitExceeded: "Nie możesz przypiąć więcej wpisów." pinLimitExceeded: "Nie możesz przypiąć więcej wpisów."
intro: "Zakończono instalację Misskey! Utwórz konto administratora."
done: "Gotowe" done: "Gotowe"
processing: "Przetwarzanie" processing: "Przetwarzanie"
preview: "Podgląd" preview: "Podgląd"
@ -749,7 +748,6 @@ thisIsExperimentalFeature: "Ta funkcja jest eksperymentalna. Jej funkcjonalnoś
developer: "Programista" developer: "Programista"
makeExplorable: "Pokazuj konto na stronie „Eksploruj”" makeExplorable: "Pokazuj konto na stronie „Eksploruj”"
makeExplorableDescription: "Jeżeli wyłączysz tę opcję, Twoje konto nie będzie wyświetlać się w sekcji „Eksploruj”." makeExplorableDescription: "Jeżeli wyłączysz tę opcję, Twoje konto nie będzie wyświetlać się w sekcji „Eksploruj”."
showGapBetweenNotesInTimeline: "Pokazuj odstęp między wpisami na osi czasu."
duplicate: "Duplikuj" duplicate: "Duplikuj"
left: "Lewo" left: "Lewo"
center: "Wyśsrodkuj" center: "Wyśsrodkuj"

View File

@ -250,7 +250,6 @@ noUsers: "Sem usuários"
editProfile: "Editar Perfil" editProfile: "Editar Perfil"
noteDeleteConfirm: "Deseja excluir esta nota?" noteDeleteConfirm: "Deseja excluir esta nota?"
pinLimitExceeded: "Não é possível fixar novas notas" pinLimitExceeded: "Não é possível fixar novas notas"
intro: "A instalação do Misskey está completa! Crie uma conta de administrador."
done: "Concluído" done: "Concluído"
processing: "Em Progresso" processing: "Em Progresso"
preview: "Pré-visualizar" preview: "Pré-visualizar"
@ -784,7 +783,6 @@ thisIsExperimentalFeature: "Este é um recurso experimental. As funções podem
developer: "Programador" developer: "Programador"
makeExplorable: "Deixe a sua conta encontrável em \"Explorar\"." makeExplorable: "Deixe a sua conta encontrável em \"Explorar\"."
makeExplorableDescription: "Se você desativá-lo, outros usuários não poderão encontrar a sua conta na aba Descoberta." makeExplorableDescription: "Se você desativá-lo, outros usuários não poderão encontrar a sua conta na aba Descoberta."
showGapBetweenNotesInTimeline: "Mostrar um espaço entre as notas na linha de tempo"
duplicate: "Duplicar" duplicate: "Duplicar"
left: "Esquerda" left: "Esquerda"
center: "Centralizar" center: "Centralizar"
@ -1237,7 +1235,6 @@ showAvatarDecorations: "Exibir decorações de avatar"
releaseToRefresh: "Solte para atualizar" releaseToRefresh: "Solte para atualizar"
refreshing: "Atualizando..." refreshing: "Atualizando..."
pullDownToRefresh: "Puxe para baixo para atualizar" pullDownToRefresh: "Puxe para baixo para atualizar"
disableStreamingTimeline: "Desabilitar atualizações em tempo real da linha do tempo"
useGroupedNotifications: "Agrupar notificações" useGroupedNotifications: "Agrupar notificações"
signupPendingError: "Houve um problema ao verificar o endereço de email. O link pode ter expirado." signupPendingError: "Houve um problema ao verificar o endereço de email. O link pode ter expirado."
cwNotationRequired: "Se \"Esconder conteúdo\" está habilitado, uma descrição deve ser adicionada." cwNotationRequired: "Se \"Esconder conteúdo\" está habilitado, uma descrição deve ser adicionada."

View File

@ -250,7 +250,6 @@ noUsers: "Niciun utilizator"
editProfile: "Editează profilul" editProfile: "Editează profilul"
noteDeleteConfirm: "Ești sigur(ă) că vrei să ștergi această notă?" noteDeleteConfirm: "Ești sigur(ă) că vrei să ștergi această notă?"
pinLimitExceeded: "Nu poți mai fixa mai multe note" pinLimitExceeded: "Nu poți mai fixa mai multe note"
intro: "Misskey s-a instalat! Te rog crează un utilizator admin."
done: "Gata" done: "Gata"
processing: "Se procesează" processing: "Se procesează"
preview: "Previzualizare" preview: "Previzualizare"
@ -784,7 +783,6 @@ thisIsExperimentalFeature: "Aceasta este o funcție experimentală. Funcționali
developer: "Dezvoltator" developer: "Dezvoltator"
makeExplorable: "Fă-ți contul vizibil în secțiunea„Explorați”" makeExplorable: "Fă-ți contul vizibil în secțiunea„Explorați”"
makeExplorableDescription: "Dacă dezactivezi această opțiune, contul dvs. nu va fi vizibil în secțiunea\"Explorați\"." makeExplorableDescription: "Dacă dezactivezi această opțiune, contul dvs. nu va fi vizibil în secțiunea\"Explorați\"."
showGapBetweenNotesInTimeline: "Afișați un decalaj între postările de pe cronologie"
duplicate: "Duplicat" duplicate: "Duplicat"
left: "Stânga" left: "Stânga"
center: "Centru" center: "Centru"

View File

@ -5,6 +5,7 @@ introMisskey: "Добро пожаловать! Misskey — это децент
poweredByMisskeyDescription: "{name} сервис на платформе с открытым исходным кодом <b>Misskey</b>, называемый экземпляром Misskey." poweredByMisskeyDescription: "{name} сервис на платформе с открытым исходным кодом <b>Misskey</b>, называемый экземпляром Misskey."
monthAndDay: "{day}.{month}" monthAndDay: "{day}.{month}"
search: "Поиск" search: "Поиск"
reset: "Сброс"
notifications: "Уведомления" notifications: "Уведомления"
username: "Имя пользователя" username: "Имя пользователя"
password: "Пароль" password: "Пароль"
@ -48,6 +49,7 @@ pin: "Закрепить в профиле"
unpin: "Открепить от профиля" unpin: "Открепить от профиля"
copyContent: "Скопировать содержимое" copyContent: "Скопировать содержимое"
copyLink: "Скопировать ссылку" copyLink: "Скопировать ссылку"
copyRemoteLink: "Скопировать ссылку на репост"
copyLinkRenote: "Скопировать ссылку на репост" copyLinkRenote: "Скопировать ссылку на репост"
delete: "Удалить" delete: "Удалить"
deleteAndEdit: "Удалить и отредактировать" deleteAndEdit: "Удалить и отредактировать"
@ -215,8 +217,10 @@ perDay: "По дням"
stopActivityDelivery: "Остановить отправку обновлений активности" stopActivityDelivery: "Остановить отправку обновлений активности"
blockThisInstance: "Блокировать этот инстанс" blockThisInstance: "Блокировать этот инстанс"
silenceThisInstance: "Заглушить этот инстанс" silenceThisInstance: "Заглушить этот инстанс"
mediaSilenceThisInstance: "Заглушить сервер"
operations: "Операции" operations: "Операции"
software: "Программы" software: "Программы"
softwareName: "Software Name"
version: "Версия" version: "Версия"
metadata: "Метаданные" metadata: "Метаданные"
withNFiles: "Файлы, {n} шт." withNFiles: "Файлы, {n} шт."
@ -235,7 +239,11 @@ clearCachedFilesConfirm: "Удалить все закэшированные ф
blockedInstances: "Заблокированные инстансы" blockedInstances: "Заблокированные инстансы"
blockedInstancesDescription: "Введите список инстансов, которые хотите заблокировать. Они больше не смогут обмениваться с вашим инстансом." blockedInstancesDescription: "Введите список инстансов, которые хотите заблокировать. Они больше не смогут обмениваться с вашим инстансом."
silencedInstances: "Заглушённые инстансы" silencedInstances: "Заглушённые инстансы"
silencedInstancesDescription: "Перечислите имена серверов, которые вы хотите отключить, разделив их новой строкой. Все учетные записи, принадлежащие к указанным в списке серверам, будут заблокированы и смогут отправлять запросы только на повторное использование и не смогут указывать локальные учетные записи, если они не будут отслеживаться. Это не повлияет на заблокированные серверы."
mediaSilencedInstances: "Заглушённые сервера"
mediaSilencedInstancesDescription: "Укажите названия серверов, для которых вы хотите отключить доступ к файлам, по одному серверу в строке. Все учетные записи, принадлежащие к перечисленным серверам, будут считаться конфиденциальными и не смогут использовать пользовательские эмодзи. Это никак не повлияет на заблокированные серверы."
federationAllowedHosts: "Серверы, поддерживающие федерацию" federationAllowedHosts: "Серверы, поддерживающие федерацию"
federationAllowedHostsDescription: "Укажите имена серверов, для которых вы хотите разрешить объединение, разделив их разделителями строк."
muteAndBlock: "Скрытие и блокировка" muteAndBlock: "Скрытие и блокировка"
mutedUsers: "Скрытые пользователи" mutedUsers: "Скрытые пользователи"
blockedUsers: "Заблокированные пользователи" blockedUsers: "Заблокированные пользователи"
@ -243,7 +251,6 @@ noUsers: "Нет ни одного пользователя"
editProfile: "Редактировать профиль" editProfile: "Редактировать профиль"
noteDeleteConfirm: "Вы хотите удалить эту заметку?" noteDeleteConfirm: "Вы хотите удалить эту заметку?"
pinLimitExceeded: "Нельзя закрепить ещё больше заметок" pinLimitExceeded: "Нельзя закрепить ещё больше заметок"
intro: "Установка Misskey завершена! А теперь создайте учетную запись администратора."
done: "Готово" done: "Готово"
processing: "Обработка" processing: "Обработка"
preview: "Предпросмотр" preview: "Предпросмотр"
@ -294,6 +301,7 @@ uploadFromUrlMayTakeTime: "Загрузка может занять некото
explore: "Обзор" explore: "Обзор"
messageRead: "Прочитали" messageRead: "Прочитали"
noMoreHistory: "История закончилась" noMoreHistory: "История закончилась"
startChat: "Начать чат"
nUsersRead: "Прочитали {n}" nUsersRead: "Прочитали {n}"
agreeTo: "Я соглашаюсь с {0}" agreeTo: "Я соглашаюсь с {0}"
agree: "Согласен" agree: "Согласен"
@ -416,6 +424,7 @@ antennaExcludeBots: "Исключать ботов"
antennaKeywordsDescription: "Пишите слова через пробел в одной строке, чтобы ловить их появление вместе; на отдельных строках располагайте слова, или группы слов, чтобы ловить любые из них." antennaKeywordsDescription: "Пишите слова через пробел в одной строке, чтобы ловить их появление вместе; на отдельных строках располагайте слова, или группы слов, чтобы ловить любые из них."
notifyAntenna: "Уведомлять о новых заметках" notifyAntenna: "Уведомлять о новых заметках"
withFileAntenna: "Только заметки с вложениями" withFileAntenna: "Только заметки с вложениями"
excludeNotesInSensitiveChannel: "Исключить заметки из конфиденциальных каналов"
enableServiceworker: "Включить ServiceWorker" enableServiceworker: "Включить ServiceWorker"
antennaUsersDescription: "Пишите каждое название аккаута на отдельной строке" antennaUsersDescription: "Пишите каждое название аккаута на отдельной строке"
caseSensitive: "С учётом регистра" caseSensitive: "С учётом регистра"
@ -446,6 +455,8 @@ totpDescription: "Описание приложения-аутентификат
moderator: "Модератор" moderator: "Модератор"
moderation: "Модерация" moderation: "Модерация"
moderationNote: "Примечания модератора" moderationNote: "Примечания модератора"
moderationNoteDescription: "Вы можете заполнять заметки, которые будут доступны только модераторам."
addModerationNote: ""
moderationLogs: "Журнал модерации" moderationLogs: "Журнал модерации"
nUsersMentioned: "Упомянуло пользователей: {n}" nUsersMentioned: "Упомянуло пользователей: {n}"
securityKeyAndPasskey: "Ключ безопасности и парольная фраза" securityKeyAndPasskey: "Ключ безопасности и парольная фраза"
@ -506,6 +517,8 @@ emojiStyle: "Стиль эмодзи"
native: "Системные" native: "Системные"
menuStyle: "Стиль меню" menuStyle: "Стиль меню"
style: "Стиль" style: "Стиль"
drawer: "Панель"
popup: "Всплывающие окна"
showNoteActionsOnlyHover: "Показывать кнопки у заметок только при наведении" showNoteActionsOnlyHover: "Показывать кнопки у заметок только при наведении"
showReactionsCount: "Видеть количество реакций на заметках" showReactionsCount: "Видеть количество реакций на заметках"
noHistory: "История пока пуста" noHistory: "История пока пуста"
@ -560,6 +573,7 @@ serverLogs: "Журнал сервера"
deleteAll: "Удалить всё" deleteAll: "Удалить всё"
showFixedPostForm: "Показывать поле для ввода новой заметки наверху ленты" showFixedPostForm: "Показывать поле для ввода новой заметки наверху ленты"
showFixedPostFormInChannel: "Показывать поле для ввода новой заметки наверху ленты (каналы)" showFixedPostFormInChannel: "Показывать поле для ввода новой заметки наверху ленты (каналы)"
withRepliesByDefaultForNewlyFollowed: "По умолчанию включайте ответы новых пользователей, на которых вы подписались, во временную шкалу"
newNoteRecived: "Появилась новая заметка" newNoteRecived: "Появилась новая заметка"
sounds: "Звуки" sounds: "Звуки"
sound: "Звуки" sound: "Звуки"
@ -572,6 +586,7 @@ masterVolume: "Основная регулировка громкости"
notUseSound: "Выключить звук" notUseSound: "Выключить звук"
useSoundOnlyWhenActive: "Воспроизводить звук только когда Misskey активен." useSoundOnlyWhenActive: "Воспроизводить звук только когда Misskey активен."
details: "Подробнее" details: "Подробнее"
renoteDetails: "Узнать больше"
chooseEmoji: "Выберите эмодзи" chooseEmoji: "Выберите эмодзи"
unableToProcess: "Не удаётся завершить операцию" unableToProcess: "Не удаётся завершить операцию"
recentUsed: "Последние использованные" recentUsed: "Последние использованные"
@ -587,6 +602,8 @@ ascendingOrder: "по возрастанию"
descendingOrder: "По убыванию" descendingOrder: "По убыванию"
scratchpad: "Когтеточка" scratchpad: "Когтеточка"
scratchpadDescription: "«Когтеточка» — это место для опытов с AiScript. Здесь можно писать программы, взаимодействующие с Misskey, запускать и смотреть что из этого получается." scratchpadDescription: "«Когтеточка» — это место для опытов с AiScript. Здесь можно писать программы, взаимодействующие с Misskey, запускать и смотреть что из этого получается."
uiInspector: "Средство проверки пользовательского интерфейса"
uiInspectorDescription: "Вы можете просмотреть список экземпляров компонентов пользовательского интерфейса, существующих в памяти. Элементы пользовательского интерфейса генерируются с помощью серии функций Ui:C:."
output: "Выходы" output: "Выходы"
script: "Скрипт" script: "Скрипт"
disablePagesScript: "Отключить скрипты на «Страницах»" disablePagesScript: "Отключить скрипты на «Страницах»"
@ -667,14 +684,19 @@ smtpSecure: "Использовать SSL/TLS для SMTP-соединений"
smtpSecureInfo: "Выключите при использовании STARTTLS." smtpSecureInfo: "Выключите при использовании STARTTLS."
testEmail: "Проверка доставки электронной почты" testEmail: "Проверка доставки электронной почты"
wordMute: "Скрытие слов" wordMute: "Скрытие слов"
wordMuteDescription: "Сведите к минимуму записи, содержащие указанное утверждение. Нажмите на свернутую запись, чтобы отобразить ее."
hardWordMute: "Строгое скрытие слов" hardWordMute: "Строгое скрытие слов"
showMutedWord: "Отображать слово без уведомления (звука)"
hardWordMuteDescription: "Скрыть заметки, содержащие указанное слово или фразу. В отличие от word mute, заметка будет полностью скрыта от просмотра."
regexpError: "Ошибка в регулярном выражении" regexpError: "Ошибка в регулярном выражении"
regexpErrorDescription: "В списке {tab} скрытых слов, в строке {line} обнаружена синтаксическая ошибка:" regexpErrorDescription: "В списке {tab} скрытых слов, в строке {line} обнаружена синтаксическая ошибка:"
instanceMute: "Глушение инстансов" instanceMute: "Глушение инстансов"
userSaysSomething: "{name} что-то сообщает" userSaysSomething: "{name} что-то сообщает"
userSaysSomethingAbout: "{name} что-то говорил о「{word}」"
makeActive: "Активировать" makeActive: "Активировать"
display: "Отображение" display: "Отображение"
copy: "Копировать" copy: "Копировать"
copiedToClipboard: "Скопированы в буфер обмена"
metrics: "Метрики" metrics: "Метрики"
overview: "Обзор" overview: "Обзор"
logs: "Журналы" logs: "Журналы"
@ -762,7 +784,6 @@ thisIsExperimentalFeature: "Это экспериментальная функц
developer: "Разработчик" developer: "Разработчик"
makeExplorable: "Опубликовать профиль в «Обзоре»." makeExplorable: "Опубликовать профиль в «Обзоре»."
makeExplorableDescription: "Если выключить, ваш профиль не будет показан в разделе «Обзор»." makeExplorableDescription: "Если выключить, ваш профиль не будет показан в разделе «Обзор»."
showGapBetweenNotesInTimeline: "Показывать разделитель между заметками в ленте"
duplicate: "Дубликат" duplicate: "Дубликат"
left: "Слева" left: "Слева"
center: "По центру" center: "По центру"
@ -840,6 +861,7 @@ administration: "Управление"
accounts: "Учётные записи" accounts: "Учётные записи"
switch: "Переключение" switch: "Переключение"
noMaintainerInformationWarning: "Не заполнены сведения об администраторах" noMaintainerInformationWarning: "Не заполнены сведения об администраторах"
noInquiryUrlWarning: "URL-адрес контактной формы еще не задан."
noBotProtectionWarning: "Ботозащита не настроена" noBotProtectionWarning: "Ботозащита не настроена"
configure: "Настроить" configure: "Настроить"
postToGallery: "Опубликовать в галерею" postToGallery: "Опубликовать в галерею"
@ -904,6 +926,7 @@ followersVisibility: "Видимость подписчиков"
continueThread: "Показать следующие ответы" continueThread: "Показать следующие ответы"
deleteAccountConfirm: "Учётная запись будет безвозвратно удалена. Подтверждаете?" deleteAccountConfirm: "Учётная запись будет безвозвратно удалена. Подтверждаете?"
incorrectPassword: "Пароль неверен." incorrectPassword: "Пароль неверен."
incorrectTotp: "Введен неверный одноразовый пароль или срок его действия истек."
voteConfirm: "Отдать голос за «{choice}»?" voteConfirm: "Отдать голос за «{choice}»?"
hide: "Спрятать" hide: "Спрятать"
useDrawerReactionPickerForMobile: "Выдвижная палитра на мобильном устройстве" useDrawerReactionPickerForMobile: "Выдвижная палитра на мобильном устройстве"
@ -928,6 +951,9 @@ oneHour: "1 час"
oneDay: "1 день" oneDay: "1 день"
oneWeek: "1 неделя" oneWeek: "1 неделя"
oneMonth: "1 месяц" oneMonth: "1 месяц"
threeMonths: "3 месяца"
oneYear: "1 год"
threeDays: "3 дня"
reflectMayTakeTime: "Изменения могут занять время для отображения" reflectMayTakeTime: "Изменения могут занять время для отображения"
failedToFetchAccountInformation: "Не удалось получить информацию об аккаунте" failedToFetchAccountInformation: "Не удалось получить информацию об аккаунте"
rateLimitExceeded: "Ограничение скорости превышено" rateLimitExceeded: "Ограничение скорости превышено"
@ -952,6 +978,7 @@ document: "Документ"
numberOfPageCache: "Количество сохранённых страниц в кэше" numberOfPageCache: "Количество сохранённых страниц в кэше"
numberOfPageCacheDescription: "Описание количества страниц в кэше" numberOfPageCacheDescription: "Описание количества страниц в кэше"
logoutConfirm: "Вы хотите выйти из аккаунта?" logoutConfirm: "Вы хотите выйти из аккаунта?"
logoutWillClearClientData: "Когда вы выйдете из системы, информация о конфигурации клиента будет удалена из браузера.Чтобы иметь возможность восстановить информацию о вашей конфигурации при повторном входе в систему, пожалуйста, включите опцию автоматического резервного копирования в настройках."
lastActiveDate: "Последняя дата использования" lastActiveDate: "Последняя дата использования"
statusbar: "Статусбар" statusbar: "Статусбар"
pleaseSelect: "Пожалуйста, выберите" pleaseSelect: "Пожалуйста, выберите"
@ -1001,6 +1028,7 @@ neverShow: "Больше не показывать"
remindMeLater: "Напомнить позже" remindMeLater: "Напомнить позже"
didYouLikeMisskey: "Вам нравится Misskey?" didYouLikeMisskey: "Вам нравится Misskey?"
pleaseDonate: "Сайт {host} работает на Misskey. Это бесплатное программное обеспечение, и ваши пожертвования очень бы помогли продолжать его разработку!" pleaseDonate: "Сайт {host} работает на Misskey. Это бесплатное программное обеспечение, и ваши пожертвования очень бы помогли продолжать его разработку!"
correspondingSourceIsAvailable: "Соответствующий исходный код можно найти по адресу {anchor} "
roles: "Роли" roles: "Роли"
role: "Роль" role: "Роль"
noRole: "Нет роли" noRole: "Нет роли"
@ -1056,6 +1084,7 @@ prohibitedWords: "Запрещённые слова"
prohibitedWordsDescription: "Включает вывод ошибки при попытке опубликовать пост, содержащий указанное слово/набор слов.\nМножество слов может быть указано, разделяемые новой строкой." prohibitedWordsDescription: "Включает вывод ошибки при попытке опубликовать пост, содержащий указанное слово/набор слов.\nМножество слов может быть указано, разделяемые новой строкой."
prohibitedWordsDescription2: "Разделение пробелом создаёт спецификацию AND, а разделение косой чертой создаёт регулярное выражение." prohibitedWordsDescription2: "Разделение пробелом создаёт спецификацию AND, а разделение косой чертой создаёт регулярное выражение."
hiddenTags: "Скрытые хештеги" hiddenTags: "Скрытые хештеги"
hiddenTagsDescription: "Установленные теги не будут отображаться в тренде, можно установить несколько тегов."
notesSearchNotAvailable: "Поиск заметок недоступен" notesSearchNotAvailable: "Поиск заметок недоступен"
license: "Лицензия" license: "Лицензия"
unfavoriteConfirm: "Удалить избранное?" unfavoriteConfirm: "Удалить избранное?"
@ -1066,6 +1095,7 @@ retryAllQueuesConfirmTitle: "Хотите попробовать ещё раз?"
retryAllQueuesConfirmText: "Нагрузка на сервер может увеличиться" retryAllQueuesConfirmText: "Нагрузка на сервер может увеличиться"
enableChartsForRemoteUser: "Создание диаграмм для удалённых пользователей" enableChartsForRemoteUser: "Создание диаграмм для удалённых пользователей"
enableChartsForFederatedInstances: "Создание диаграмм для удалённых серверов" enableChartsForFederatedInstances: "Создание диаграмм для удалённых серверов"
enableStatsForFederatedInstances: "Получить информацию об удаленном сервере"
showClipButtonInNoteFooter: "Показать кнопку добавления в подборку в меню действий с заметкой" showClipButtonInNoteFooter: "Показать кнопку добавления в подборку в меню действий с заметкой"
reactionsDisplaySize: "Размер реакций" reactionsDisplaySize: "Размер реакций"
limitWidthOfReaction: "Ограничить максимальную ширину реакций и отображать их в уменьшенном размере." limitWidthOfReaction: "Ограничить максимальную ширину реакций и отображать их в уменьшенном размере."
@ -1101,6 +1131,7 @@ preservedUsernames: "Зарезервированные имена пользо
preservedUsernamesDescription: "Перечислите зарезервированные имена пользователей, отделяя их строками. Они станут недоступны при создании учётной записи. Это ограничение не применяется при создании учётной записи администраторами. Также, уже существующие учётные записи останутся без изменений." preservedUsernamesDescription: "Перечислите зарезервированные имена пользователей, отделяя их строками. Они станут недоступны при создании учётной записи. Это ограничение не применяется при создании учётной записи администраторами. Также, уже существующие учётные записи останутся без изменений."
createNoteFromTheFile: "Создать заметку из этого файла" createNoteFromTheFile: "Создать заметку из этого файла"
archive: "Архив" archive: "Архив"
archived: "Архивировано"
unarchive: "Разархивировать" unarchive: "Разархивировать"
channelArchiveConfirmTitle: "Переместить {name} в архив?" channelArchiveConfirmTitle: "Переместить {name} в архив?"
channelArchiveConfirmDescription: "Архивированные каналы перестанут отображаться в списке каналов или результатах поиска. В них также нельзя будет добавлять новые записи." channelArchiveConfirmDescription: "Архивированные каналы перестанут отображаться в списке каналов или результатах поиска. В них также нельзя будет добавлять новые записи."
@ -1121,6 +1152,7 @@ rolesThatCanBeUsedThisEmojiAsReaction: "Роли тех, кому можно и
rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription: "Если здесь ничего не указать, в качестве реакции эту эмодзи сможет использовать каждый." rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription: "Если здесь ничего не указать, в качестве реакции эту эмодзи сможет использовать каждый."
rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn: "Эти роли должны быть общедоступными." rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn: "Эти роли должны быть общедоступными."
cancelReactionConfirm: "Вы действительно хотите удалить свою реакцию?" cancelReactionConfirm: "Вы действительно хотите удалить свою реакцию?"
changeReactionConfirm: "Вы действительно хотите удалить свою реакцию?"
later: "Позже" later: "Позже"
goToMisskey: "К Misskey" goToMisskey: "К Misskey"
additionalEmojiDictionary: "Дополнительные словари эмодзи" additionalEmojiDictionary: "Дополнительные словари эмодзи"
@ -1130,9 +1162,16 @@ enableServerMachineStats: "Опубликовать характеристики
enableIdenticonGeneration: "Включить генерацию иконки пользователя" enableIdenticonGeneration: "Включить генерацию иконки пользователя"
turnOffToImprovePerformance: "Отключение этого параметра может повысить производительность." turnOffToImprovePerformance: "Отключение этого параметра может повысить производительность."
createInviteCode: "Создать код приглашения" createInviteCode: "Создать код приглашения"
createWithOptions: "Используйте параметры для создания"
createCount: "Количество приглашений" createCount: "Количество приглашений"
inviteCodeCreated: "Создан пригласительный код"
inviteLimitExceeded: "Достигнут предел количества пригласительных кодов, которые могут быть созданы."
createLimitRemaining: "Пригласительные коды, которые могут быть созданы: {limit} "
inviteLimitResetCycle: "За определенное {time} Вы можете создать неограниченное количество пригласительных кодов {limit} "
expirationDate: "Дата истечения" expirationDate: "Дата истечения"
noExpirationDate: "Бессрочно" noExpirationDate: "Бессрочно"
inviteCodeUsedAt: "Дата и время, когда был использован пригласительный код"
registeredUserUsingInviteCode: "Пользователи, которые использовали пригласительный код"
unused: "Неиспользованное" unused: "Неиспользованное"
used: "Использован" used: "Использован"
expired: "Срок действия приглашения истёк" expired: "Срок действия приглашения истёк"
@ -1159,7 +1198,6 @@ privacyPolicyUrl: "Ссылка на Политику Конфиденциаль
attach: "Прикрепить" attach: "Прикрепить"
angle: "Угол" angle: "Угол"
flip: "Переворот" flip: "Переворот"
disableStreamingTimeline: "Отключить обновление ленты в режиме реального времени"
useGroupedNotifications: "Отображать уведомления сгруппировано" useGroupedNotifications: "Отображать уведомления сгруппировано"
doReaction: "Добавить реакцию" doReaction: "Добавить реакцию"
code: "Код" code: "Код"

View File

@ -204,7 +204,6 @@ noUsers: "Žiadni používatelia"
editProfile: "Upraviť profil" editProfile: "Upraviť profil"
noteDeleteConfirm: "Naozaj chcete odstrániť túto poznámku?" noteDeleteConfirm: "Naozaj chcete odstrániť túto poznámku?"
pinLimitExceeded: "Ďalšie poznámky už nemôžete pripnúť." pinLimitExceeded: "Ďalšie poznámky už nemôžete pripnúť."
intro: "Inštalácia Misskey je dokončená! Prosím vytvorte administrátora."
done: "Hotovo" done: "Hotovo"
processing: "Pracujem..." processing: "Pracujem..."
preview: "Náhľad" preview: "Náhľad"
@ -682,7 +681,6 @@ experimentalFeatures: "Experimentálne funkcie"
developer: "Vývojár" developer: "Vývojár"
makeExplorable: "Spraviť účet viditeľný v \"Objavovať\"" makeExplorable: "Spraviť účet viditeľný v \"Objavovať\""
makeExplorableDescription: "Ak toto vypnete, váš účet sa nezobrazí v sekcii \"Objavovat\"." makeExplorableDescription: "Ak toto vypnete, váš účet sa nezobrazí v sekcii \"Objavovat\"."
showGapBetweenNotesInTimeline: "Zobraziť medzeru medzi príspevkami časovej osi."
duplicate: "Duplikovať" duplicate: "Duplikovať"
left: "Naľavo" left: "Naľavo"
center: "Stred" center: "Stred"

View File

@ -211,7 +211,6 @@ noUsers: "Det finns inga användare"
editProfile: "Redigera profil" editProfile: "Redigera profil"
noteDeleteConfirm: "Är du säker på att du vill ta bort denna not?" noteDeleteConfirm: "Är du säker på att du vill ta bort denna not?"
pinLimitExceeded: "Du kan inte fästa fler noter" pinLimitExceeded: "Du kan inte fästa fler noter"
intro: "Misskey har installerats! Vänligen skapa en adminanvändare."
done: "Klar" done: "Klar"
processing: "Bearbetar..." processing: "Bearbetar..."
preview: "Förhandsvisning" preview: "Förhandsvisning"

View File

@ -250,7 +250,6 @@ noUsers: "ไม่พบผู้ใช้งาน"
editProfile: "แก้ไขโปรไฟล์" editProfile: "แก้ไขโปรไฟล์"
noteDeleteConfirm: "ต้องการลบโน้ตนี้ใช่ไหม?" noteDeleteConfirm: "ต้องการลบโน้ตนี้ใช่ไหม?"
pinLimitExceeded: "คุณไม่สามารถปักหมุดโน้ตเพิ่มเติมใดๆได้อีก" pinLimitExceeded: "คุณไม่สามารถปักหมุดโน้ตเพิ่มเติมใดๆได้อีก"
intro: "การติดตั้ง Misskey เสร็จสิ้นแล้วนะ! โปรดสร้างผู้ใช้งานที่เป็นผู้ดูแลระบบ"
done: "เสร็จสิ้น" done: "เสร็จสิ้น"
processing: "กำลังประมวลผล..." processing: "กำลังประมวลผล..."
preview: "แสดงตัวอย่าง" preview: "แสดงตัวอย่าง"
@ -778,7 +777,6 @@ thisIsExperimentalFeature: "นี่เป็นฟีเจอร์ทดล
developer: "สำหรับนักพัฒนา" developer: "สำหรับนักพัฒนา"
makeExplorable: "ทำให้บัญชีมองเห็นใน “สำรวจ”" makeExplorable: "ทำให้บัญชีมองเห็นใน “สำรวจ”"
makeExplorableDescription: "ถ้าหากคุณปิดการทำงานนี้ บัญชีของคุณนั้นจะไม่แสดงในส่วน “สำรวจ”" makeExplorableDescription: "ถ้าหากคุณปิดการทำงานนี้ บัญชีของคุณนั้นจะไม่แสดงในส่วน “สำรวจ”"
showGapBetweenNotesInTimeline: "แสดงช่องว่างระหว่างโพสต์บนไทม์ไลน์"
duplicate: "ทำซ้ำ" duplicate: "ทำซ้ำ"
left: "ซ้าย" left: "ซ้าย"
center: "กึ่งกลาง" center: "กึ่งกลาง"
@ -1227,7 +1225,6 @@ showAvatarDecorations: "แสดงตกแต่งอวตาร"
releaseToRefresh: "ปล่อยเพื่อรีเฟรช" releaseToRefresh: "ปล่อยเพื่อรีเฟรช"
refreshing: "กำลังรีเฟรช..." refreshing: "กำลังรีเฟรช..."
pullDownToRefresh: "ดึงลงเพื่อรีเฟรช" pullDownToRefresh: "ดึงลงเพื่อรีเฟรช"
disableStreamingTimeline: "ปิดใช้งานอัปเดตไทม์ไลน์แบบเรียลไทม์"
useGroupedNotifications: "แสดงผลการแจ้งเตือนแบบกลุ่มแล้ว" useGroupedNotifications: "แสดงผลการแจ้งเตือนแบบกลุ่มแล้ว"
signupPendingError: "มีปัญหาในการตรวจสอบที่อยู่อีเมลลิงก์อาจหมดอายุแล้ว" signupPendingError: "มีปัญหาในการตรวจสอบที่อยู่อีเมลลิงก์อาจหมดอายุแล้ว"
cwNotationRequired: "หากเปิดใช้งาน “ซ่อนเนื้อหา” จะต้องระบุคำอธิบาย" cwNotationRequired: "หากเปิดใช้งาน “ซ่อนเนื้อหา” จะต้องระบุคำอธิบาย"

View File

@ -224,7 +224,6 @@ noUsers: "Kullanıcı yok"
editProfile: "Profili düzenle" editProfile: "Profili düzenle"
noteDeleteConfirm: "Bu notu silmek istediğinizden emin misiniz?" noteDeleteConfirm: "Bu notu silmek istediğinizden emin misiniz?"
pinLimitExceeded: "Daha fazla not sabitlenemez" pinLimitExceeded: "Daha fazla not sabitlenemez"
intro: "Misskey yüklemesi tamamlandı! Lütfen yönetici hesabını oluşturun."
done: "Tamamlandı" done: "Tamamlandı"
preview: "Önizleme" preview: "Önizleme"
default: "Varsayılan" default: "Varsayılan"

View File

@ -208,7 +208,6 @@ noUsers: "Немає користувачів"
editProfile: "Редагувати обліковий запис" editProfile: "Редагувати обліковий запис"
noteDeleteConfirm: "Ви дійсно хочете видалити цей запис?" noteDeleteConfirm: "Ви дійсно хочете видалити цей запис?"
pinLimitExceeded: "Більше записів не можна закріпити" pinLimitExceeded: "Більше записів не можна закріпити"
intro: "Встановлення Misskey завершено! Будь ласка, створіть обліковий запис адміністратора."
done: "Готово" done: "Готово"
processing: "Обробка" processing: "Обробка"
preview: "Попередній перегляд" preview: "Попередній перегляд"
@ -681,7 +680,6 @@ experimentalFeatures: "Експериментальні функції"
developer: "Розробник" developer: "Розробник"
makeExplorable: "Зробіть обліковий запис видимим у розділі \"Огляд\"" makeExplorable: "Зробіть обліковий запис видимим у розділі \"Огляд\""
makeExplorableDescription: "Вимкніть, щоб обліковий запис не показувався у розділі \"Огляд\"." makeExplorableDescription: "Вимкніть, щоб обліковий запис не показувався у розділі \"Огляд\"."
showGapBetweenNotesInTimeline: "Показувати розрив між записами у стрічці новин"
duplicate: "Дублікат" duplicate: "Дублікат"
left: "Лівий" left: "Лівий"
center: "Центр" center: "Центр"

View File

@ -219,7 +219,6 @@ noUsers: "Foydalanuvchilar yoq"
editProfile: "Profilni o'zgartirish" editProfile: "Profilni o'zgartirish"
noteDeleteConfirm: "Haqiqatan ham bu qaydni oʻchirib tashlamoqchimisiz?" noteDeleteConfirm: "Haqiqatan ham bu qaydni oʻchirib tashlamoqchimisiz?"
pinLimitExceeded: "Siz boshqa qaydlarni mahkamlay olmaysiz" pinLimitExceeded: "Siz boshqa qaydlarni mahkamlay olmaysiz"
intro: "Misskeyni o'rnatish tugallandi! Iltimos, administrator foydalanuvchi yarating."
done: "Bajarildi" done: "Bajarildi"
processing: "Amaliyotda" processing: "Amaliyotda"
preview: "Ko'rish" preview: "Ko'rish"

View File

@ -250,7 +250,6 @@ noUsers: "Chưa có ai"
editProfile: "Sửa hồ sơ" editProfile: "Sửa hồ sơ"
noteDeleteConfirm: "Bạn có chắc muốn xóa tút này?" noteDeleteConfirm: "Bạn có chắc muốn xóa tút này?"
pinLimitExceeded: "Bạn không thể ghim bài viết nữa" pinLimitExceeded: "Bạn không thể ghim bài viết nữa"
intro: "Đã cài đặt Misskey! Xin hãy tạo tài khoản admin."
done: "Xong" done: "Xong"
processing: "Đang xử lý" processing: "Đang xử lý"
preview: "Xem trước" preview: "Xem trước"
@ -783,7 +782,6 @@ thisIsExperimentalFeature: "Tính năng này đang trong quá trình thử nghi
developer: "Nhà phát triển" developer: "Nhà phát triển"
makeExplorable: "Không hiện tôi trong \"Khám phá\"" makeExplorable: "Không hiện tôi trong \"Khám phá\""
makeExplorableDescription: "Nếu bạn tắt, tài khoản của bạn sẽ không hiện trong mục \"Khám phá\"." makeExplorableDescription: "Nếu bạn tắt, tài khoản của bạn sẽ không hiện trong mục \"Khám phá\"."
showGapBetweenNotesInTimeline: "Hiện dải phân cách giữa các tút trên bảng tin"
duplicate: "Tạo bản sao" duplicate: "Tạo bản sao"
left: "Bên trái" left: "Bên trái"
center: "Giữa" center: "Giữa"

View File

@ -251,7 +251,6 @@ noUsers: "无用户"
editProfile: "编辑资料" editProfile: "编辑资料"
noteDeleteConfirm: "确定要删除该帖子吗?" noteDeleteConfirm: "确定要删除该帖子吗?"
pinLimitExceeded: "无法置顶更多了" pinLimitExceeded: "无法置顶更多了"
intro: "Misskey 的部署结束啦!创建管理员账号吧!"
done: "完成" done: "完成"
processing: "正在处理" processing: "正在处理"
preview: "预览" preview: "预览"
@ -785,7 +784,6 @@ thisIsExperimentalFeature: "这是一项实验性功能。规范可能会变更
developer: "开发者" developer: "开发者"
makeExplorable: "使账号可见。" makeExplorable: "使账号可见。"
makeExplorableDescription: "关闭时,账号不会显示在\"发现\"中。" makeExplorableDescription: "关闭时,账号不会显示在\"发现\"中。"
showGapBetweenNotesInTimeline: "时间线上的帖子分开显示。"
duplicate: "复制" duplicate: "复制"
left: "左" left: "左"
center: "中央" center: "中央"
@ -1238,7 +1236,6 @@ showAvatarDecorations: "显示头像挂件"
releaseToRefresh: "松开以刷新" releaseToRefresh: "松开以刷新"
refreshing: "刷新中" refreshing: "刷新中"
pullDownToRefresh: "下拉以刷新" pullDownToRefresh: "下拉以刷新"
disableStreamingTimeline: "禁止实时更新时间线"
useGroupedNotifications: "分组显示通知" useGroupedNotifications: "分组显示通知"
signupPendingError: "确认电子邮件时出现错误。链接可能已过期。" signupPendingError: "确认电子邮件时出现错误。链接可能已过期。"
cwNotationRequired: "在启用「隐藏内容」时必须输入注释" cwNotationRequired: "在启用「隐藏内容」时必须输入注释"
@ -1348,6 +1345,7 @@ readonly: "只读"
goToDeck: "返回至 Deck" goToDeck: "返回至 Deck"
federationJobs: "联合作业" federationJobs: "联合作业"
driveAboutTip: "网盘可以显示以前上传的文件。<br>\n也可以在发布帖子时重复使用文件或在发布帖子前预先上传文件。<br>\n<b>删除文件时,其将从至今为止所有用到该文件的地方(如帖子、页面、头像、横幅)消失。</b><br>\n也可以新建文件夹来整理文件。" driveAboutTip: "网盘可以显示以前上传的文件。<br>\n也可以在发布帖子时重复使用文件或在发布帖子前预先上传文件。<br>\n<b>删除文件时,其将从至今为止所有用到该文件的地方(如帖子、页面、头像、横幅)消失。</b><br>\n也可以新建文件夹来整理文件。"
scrollToClose: "滑动并关闭"
_chat: _chat:
noMessagesYet: "还没有消息" noMessagesYet: "还没有消息"
newMessage: "新消息" newMessage: "新消息"
@ -1424,6 +1422,7 @@ _settings:
ifOn: "启用时" ifOn: "启用时"
ifOff: "关闭时" ifOff: "关闭时"
enablePullToRefresh: "开启下拉刷新" enablePullToRefresh: "开启下拉刷新"
enablePullToRefresh_description: "使用鼠标时按下滚轮来拖动"
_chat: _chat:
showSenderName: "显示发送者的名字" showSenderName: "显示发送者的名字"
sendOnEnter: "回车键发送" sendOnEnter: "回车键发送"
@ -1431,6 +1430,7 @@ _preferencesProfile:
profileName: "配置名" profileName: "配置名"
profileNameDescription: "请指定用于识别此设备的名称" profileNameDescription: "请指定用于识别此设备的名称"
profileNameDescription2: "如「PC」、「手机」等" profileNameDescription2: "如「PC」、「手机」等"
manageProfiles: "管理配置文件"
_preferencesBackup: _preferencesBackup:
autoBackup: "自动备份" autoBackup: "自动备份"
restoreFromBackup: "从备份恢复" restoreFromBackup: "从备份恢复"

View File

@ -251,7 +251,6 @@ noUsers: "沒有任何使用者"
editProfile: "編輯個人檔案" editProfile: "編輯個人檔案"
noteDeleteConfirm: "確定刪除此貼文嗎?" noteDeleteConfirm: "確定刪除此貼文嗎?"
pinLimitExceeded: "不能置頂更多貼文了" pinLimitExceeded: "不能置頂更多貼文了"
intro: "Misskey 部署完成!請建立管理員帳戶。"
done: "完成" done: "完成"
processing: "處理中" processing: "處理中"
preview: "預覽" preview: "預覽"
@ -785,7 +784,6 @@ thisIsExperimentalFeature: "這是一項實驗性功能,其行為會隨需要
developer: "開發者" developer: "開發者"
makeExplorable: "使自己的帳戶更容易被找到" makeExplorable: "使自己的帳戶更容易被找到"
makeExplorableDescription: "如果關閉,帳戶將不會被顯示在「探索」頁面中。" makeExplorableDescription: "如果關閉,帳戶將不會被顯示在「探索」頁面中。"
showGapBetweenNotesInTimeline: "分開顯示時間軸上的貼文"
duplicate: "複製" duplicate: "複製"
left: "左" left: "左"
center: "置中" center: "置中"
@ -1238,7 +1236,6 @@ showAvatarDecorations: "顯示頭像裝飾"
releaseToRefresh: "放開以更新內容" releaseToRefresh: "放開以更新內容"
refreshing: "載入更新中" refreshing: "載入更新中"
pullDownToRefresh: "往下拉來更新內容" pullDownToRefresh: "往下拉來更新內容"
disableStreamingTimeline: "停用時間軸的即時更新"
useGroupedNotifications: "分組顯示通知訊息" useGroupedNotifications: "分組顯示通知訊息"
signupPendingError: "驗證您的電子郵件地址時出現問題。連結可能已過期。" signupPendingError: "驗證您的電子郵件地址時出現問題。連結可能已過期。"
cwNotationRequired: "如果開啟「隱藏內容」,則需要註解說明。" cwNotationRequired: "如果開啟「隱藏內容」,則需要註解說明。"
@ -1434,6 +1431,7 @@ _preferencesProfile:
profileName: "設定檔案名稱" profileName: "設定檔案名稱"
profileNameDescription: "設定一個名稱來識別此裝置。" profileNameDescription: "設定一個名稱來識別此裝置。"
profileNameDescription2: "例如:「主要個人電腦」、「智慧型手機」等" profileNameDescription2: "例如:「主要個人電腦」、「智慧型手機」等"
manageProfiles: "管理個人檔案"
_preferencesBackup: _preferencesBackup:
autoBackup: "自動備份" autoBackup: "自動備份"
restoreFromBackup: "從備份還原" restoreFromBackup: "從備份還原"

View File

@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "2025.5.0-beta.0", "version": "2025.5.1-alpha.1",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",
@ -34,7 +34,7 @@
"watch": "pnpm dev", "watch": "pnpm dev",
"dev": "node scripts/dev.mjs", "dev": "node scripts/dev.mjs",
"lint": "pnpm -r lint", "lint": "pnpm -r lint",
"cy:open": "pnpm cypress open --browser --e2e --config-file=cypress.config.ts", "cy:open": "pnpm cypress open --config-file=cypress.config.ts",
"cy:run": "pnpm cypress run", "cy:run": "pnpm cypress run",
"e2e": "pnpm start-server-and-test start:test http://localhost:61812 cy:run", "e2e": "pnpm start-server-and-test start:test http://localhost:61812 cy:run",
"e2e-dev-container": "ncp ./.config/cypress-devcontainer.yml ./.config/test.yml && pnpm start-server-and-test start:test http://localhost:61812 cy:run", "e2e-dev-container": "ncp ./.config/cypress-devcontainer.yml ./.config/test.yml && pnpm start-server-and-test start:test http://localhost:61812 cy:run",

View File

@ -14,7 +14,7 @@ export class CompositeNoteIndex1745378064470 {
if (concurrently) { if (concurrently) {
const hasValidIndex = await queryRunner.query(`SELECT indisvalid FROM pg_index INNER JOIN pg_class ON pg_index.indexrelid = pg_class.oid WHERE pg_class.relname = 'IDX_724b311e6f883751f261ebe378'`); const hasValidIndex = await queryRunner.query(`SELECT indisvalid FROM pg_index INNER JOIN pg_class ON pg_index.indexrelid = pg_class.oid WHERE pg_class.relname = 'IDX_724b311e6f883751f261ebe378'`);
if (!hasValidIndex || hasValidIndex[0].indisvalid !== true) { if (hasValidIndex.length === 0 || hasValidIndex[0].indisvalid !== true) {
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_724b311e6f883751f261ebe378"`); await queryRunner.query(`DROP INDEX IF EXISTS "IDX_724b311e6f883751f261ebe378"`);
await queryRunner.query(`CREATE INDEX CONCURRENTLY "IDX_724b311e6f883751f261ebe378" ON "note" ("userId", "id" DESC)`); await queryRunner.query(`CREATE INDEX CONCURRENTLY "IDX_724b311e6f883751f261ebe378" ON "note" ("userId", "id" DESC)`);
} }

View File

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class VisibleUserGeneratedContentsForNonLoggedInVisitors1746330901644 {
name = 'VisibleUserGeneratedContentsForNonLoggedInVisitors1746330901644'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "ugcVisibilityForVisitor" character varying(128) NOT NULL DEFAULT 'local'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "ugcVisibilityForVisitor"`);
}
}

View File

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class SingleUserMode1746422049376 {
name = 'SingleUserMode1746422049376'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "singleUserMode" boolean NOT NULL DEFAULT false`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "singleUserMode"`);
}
}

View File

@ -78,7 +78,7 @@
"@fastify/multipart": "9.0.3", "@fastify/multipart": "9.0.3",
"@fastify/static": "8.1.1", "@fastify/static": "8.1.1",
"@fastify/view": "10.0.2", "@fastify/view": "10.0.2",
"@misskey-dev/sharp-read-bmp": "1.3.0", "@misskey-dev/sharp-read-bmp": "1.2.0",
"@misskey-dev/summaly": "5.2.1", "@misskey-dev/summaly": "5.2.1",
"@napi-rs/canvas": "0.1.69", "@napi-rs/canvas": "0.1.69",
"@nestjs/common": "11.1.0", "@nestjs/common": "11.1.0",

View File

@ -9,87 +9,7 @@ import type { MiUser } from '@/models/User.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { NotificationService } from '@/core/NotificationService.js'; import { NotificationService } from '@/core/NotificationService.js';
import { ACHIEVEMENT_TYPES } from '@/models/UserProfile.js';
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',
'myNoteFavorited1',
'profileFilled',
'markedAsCat',
'following1',
'following10',
'following50',
'following100',
'following300',
'followers1',
'followers10',
'followers50',
'followers100',
'followers300',
'followers500',
'followers1000',
'collectAchievements30',
'viewAchievements3min',
'iLoveMisskey',
'foundTreasure',
'client30min',
'client60min',
'noteDeletedWithin1min',
'postedAtLateNight',
'postedAt0min0sec',
'selfQuote',
'htl20npm',
'viewInstanceChart',
'outputHelloWorldOnScratchpad',
'open3windows',
'driveFolderCircularReference',
'reactWithoutRead',
'clickedClickHere',
'justPlainLucky',
'setNameToSyuilo',
'cookieClicked',
'brainDiver',
'smashTestNotificationButton',
'tutorialCompleted',
'bubbleGameExplodingHead',
'bubbleGameDoubleExplodingHead',
] as const;
@Injectable() @Injectable()
export class AchievementService { export class AchievementService {

View File

@ -29,7 +29,7 @@ import { emojiRegex } from '@/misc/emoji-regex.js';
import { NotificationService } from '@/core/NotificationService.js'; import { NotificationService } from '@/core/NotificationService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js';
const MAX_ROOM_MEMBERS = 30; const MAX_ROOM_MEMBERS = 50;
const MAX_REACTIONS_PER_MESSAGE = 100; const MAX_REACTIONS_PER_MESSAGE = 100;
const isCustomEmojiRegexp = /^:([\w+-]+)(?:@\.)?:$/; const isCustomEmojiRegexp = /^:([\w+-]+)(?:@\.)?:$/;
@ -578,6 +578,20 @@ export class ChatService {
@bindThis @bindThis
public async deleteRoom(room: MiChatRoom, deleter?: MiUser) { public async deleteRoom(room: MiChatRoom, deleter?: MiUser) {
const memberships = (await this.chatRoomMembershipsRepository.findBy({ roomId: room.id })).map(m => ({
userId: m.userId,
})).concat({ // ownerはmembershipレコードを作らないため
userId: room.ownerId,
});
// 未読フラグ削除
const redisPipeline = this.redisClient.pipeline();
for (const membership of memberships) {
redisPipeline.del(`newRoomChatMessageExists:${membership.userId}:${room.id}`);
redisPipeline.srem(`newChatMessagesExists:${membership.userId}`, `room:${room.id}`);
}
await redisPipeline.exec();
await this.chatRoomsRepository.delete(room.id); await this.chatRoomsRepository.delete(room.id);
if (deleter) { if (deleter) {
@ -709,6 +723,12 @@ export class ChatService {
public async leaveRoom(userId: MiUser['id'], roomId: MiChatRoom['id']) { public async leaveRoom(userId: MiUser['id'], roomId: MiChatRoom['id']) {
const membership = await this.chatRoomMembershipsRepository.findOneByOrFail({ roomId, userId }); const membership = await this.chatRoomMembershipsRepository.findOneByOrFail({ roomId, userId });
await this.chatRoomMembershipsRepository.delete(membership.id); await this.chatRoomMembershipsRepository.delete(membership.id);
// 未読フラグを消す (「既読にする」というわけでもないのでreadメソッドは使わないでおく)
const redisPipeline = this.redisClient.pipeline();
redisPipeline.del(`newRoomChatMessageExists:${userId}:${roomId}`);
redisPipeline.srem(`newChatMessagesExists:${userId}`, `room:${roomId}`);
await redisPipeline.exec();
} }
@bindThis @bindThis

View File

@ -238,13 +238,15 @@ export class ChatEntityService {
options?: { options?: {
_hint_?: { _hint_?: {
packedOwners: Map<MiChatRoom['id'], Packed<'UserLite'>>; packedOwners: Map<MiChatRoom['id'], Packed<'UserLite'>>;
memberships?: Map<MiChatRoom['id'], MiChatRoomMembership | null | undefined>; myMemberships?: Map<MiChatRoom['id'], MiChatRoomMembership | null | undefined>;
myInvitations?: Map<MiChatRoom['id'], MiChatRoomInvitation | null | undefined>;
}; };
}, },
): Promise<Packed<'ChatRoom'>> { ): Promise<Packed<'ChatRoom'>> {
const room = typeof src === 'object' ? src : await this.chatRoomsRepository.findOneByOrFail({ id: src }); const room = typeof src === 'object' ? src : await this.chatRoomsRepository.findOneByOrFail({ id: src });
const membership = me && me.id !== room.ownerId ? (options?._hint_?.memberships?.get(room.id) ?? await this.chatRoomMembershipsRepository.findOneBy({ roomId: room.id, userId: me.id })) : null; const membership = me && me.id !== room.ownerId ? (options?._hint_?.myMemberships?.get(room.id) ?? await this.chatRoomMembershipsRepository.findOneBy({ roomId: room.id, userId: me.id })) : null;
const invitation = me && me.id !== room.ownerId ? (options?._hint_?.myInvitations?.get(room.id) ?? await this.chatRoomInvitationsRepository.findOneBy({ roomId: room.id, userId: me.id })) : null;
return { return {
id: room.id, id: room.id,
@ -254,6 +256,7 @@ export class ChatEntityService {
ownerId: room.ownerId, ownerId: room.ownerId,
owner: options?._hint_?.packedOwners.get(room.ownerId) ?? await this.userEntityService.pack(room.owner ?? room.ownerId, me), owner: options?._hint_?.packedOwners.get(room.ownerId) ?? await this.userEntityService.pack(room.owner ?? room.ownerId, me),
isMuted: membership != null ? membership.isMuted : false, isMuted: membership != null ? membership.isMuted : false,
invitationExists: invitation != null,
}; };
} }
@ -278,7 +281,7 @@ export class ChatEntityService {
const owners = _rooms.map(x => x.owner ?? x.ownerId); const owners = _rooms.map(x => x.owner ?? x.ownerId);
const [packedOwners, memberships] = await Promise.all([ const [packedOwners, myMemberships, myInvitations] = await Promise.all([
this.userEntityService.packMany(owners, me) this.userEntityService.packMany(owners, me)
.then(users => new Map(users.map(u => [u.id, u]))), .then(users => new Map(users.map(u => [u.id, u]))),
this.chatRoomMembershipsRepository.find({ this.chatRoomMembershipsRepository.find({
@ -287,9 +290,15 @@ export class ChatEntityService {
userId: me.id, userId: me.id,
}, },
}).then(memberships => new Map(_rooms.map(r => [r.id, memberships.find(m => m.roomId === r.id)]))), }).then(memberships => new Map(_rooms.map(r => [r.id, memberships.find(m => m.roomId === r.id)]))),
this.chatRoomInvitationsRepository.find({
where: {
roomId: In(_rooms.map(x => x.id)),
userId: me.id,
},
}).then(invitations => new Map(_rooms.map(r => [r.id, invitations.find(i => i.roomId === r.id)]))),
]); ]);
return Promise.all(_rooms.map(room => this.packRoom(room, me, { _hint_: { packedOwners, memberships } }))); return Promise.all(_rooms.map(room => this.packRoom(room, me, { _hint_: { packedOwners, myMemberships, myInvitations } })));
} }
@bindThis @bindThis

View File

@ -429,6 +429,7 @@ export class NoteEntityService implements OnModuleInit {
userId: channel.userId, userId: channel.userId,
} : undefined, } : undefined,
mentions: note.mentions.length > 0 ? note.mentions : undefined, mentions: note.mentions.length > 0 ? note.mentions : undefined,
hasPoll: note.hasPoll || undefined,
uri: note.uri ?? undefined, uri: note.uri ?? undefined,
url: note.url ?? undefined, url: note.url ?? undefined,
@ -593,4 +594,42 @@ export class NoteEntityService implements OnModuleInit {
relations: ['user'], relations: ['user'],
}); });
} }
@bindThis
public async fetchDiffs(noteIds: MiNote['id'][]) {
if (noteIds.length === 0) return [];
const notes = await this.notesRepository.find({
where: {
id: In(noteIds),
},
select: {
id: true,
userHost: true,
reactions: true,
reactionAndUserPairCache: true,
},
});
const bufferedReactionsMap = this.meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany(noteIds) : null;
const packings = notes.map(note => {
const bufferedReactions = bufferedReactionsMap?.get(note.id);
//const reactionAndUserPairCache = note.reactionAndUserPairCache.concat(bufferedReactions.pairs.map(x => x.join('/')));
const reactions = this.reactionService.convertLegacyReactions(this.reactionsBufferingService.mergeReactions(note.reactions, bufferedReactions?.deltas ?? {}));
const reactionEmojiNames = Object.keys(reactions)
.filter(x => x.startsWith(':') && x.includes('@') && !x.includes('@.')) // リモートカスタム絵文字のみ
.map(x => this.reactionService.decodeReaction(x).reaction.replaceAll(':', ''));
return this.customEmojiService.populateEmojis(reactionEmojiNames, note.userHost).then(reactionEmojis => ({
id: note.id,
reactions,
reactionEmojis,
}));
});
return await Promise.all(packings);
}
} }

View File

@ -67,6 +67,7 @@ import { packedChatMessageSchema, packedChatMessageLiteSchema, packedChatMessage
import { packedChatRoomSchema } from '@/models/json-schema/chat-room.js'; import { packedChatRoomSchema } from '@/models/json-schema/chat-room.js';
import { packedChatRoomInvitationSchema } from '@/models/json-schema/chat-room-invitation.js'; import { packedChatRoomInvitationSchema } from '@/models/json-schema/chat-room-invitation.js';
import { packedChatRoomMembershipSchema } from '@/models/json-schema/chat-room-membership.js'; import { packedChatRoomMembershipSchema } from '@/models/json-schema/chat-room-membership.js';
import { packedAchievementNameSchema, packedAchievementSchema } from '@/models/json-schema/achievement.js';
export const refs = { export const refs = {
UserLite: packedUserLiteSchema, UserLite: packedUserLiteSchema,
@ -78,6 +79,8 @@ export const refs = {
User: packedUserSchema, User: packedUserSchema,
UserList: packedUserListSchema, UserList: packedUserListSchema,
Achievement: packedAchievementSchema,
AchievementName: packedAchievementNameSchema,
Ad: packedAdSchema, Ad: packedAdSchema,
Announcement: packedAnnouncementSchema, Announcement: packedAnnouncementSchema,
App: packedAppSchema, App: packedAppSchema,

View File

@ -106,3 +106,6 @@ export class MiAntenna {
}) })
public excludeNotesInSensitiveChannel: boolean; public excludeNotesInSensitiveChannel: boolean;
} }
// Note for future developers: When you added a new column,
// You should update ExportAntennaProcessorService and ImportAntennaProcessorService
// to export and import antennas correctly.

View File

@ -659,6 +659,12 @@ export class MiMeta {
}) })
public federationHosts: string[]; public federationHosts: string[];
@Column('varchar', {
length: 128,
default: 'local',
})
public ugcVisibilityForVisitor: 'all' | 'local' | 'none';
@Column('varchar', { @Column('varchar', {
length: 64, length: 64,
nullable: true, nullable: true,
@ -669,6 +675,11 @@ export class MiMeta {
default: [], default: [],
}) })
public deliverSuspendedSoftware: SoftwareSuspension[]; public deliverSuspendedSoftware: SoftwareSuspension[];
@Column('boolean', {
default: false,
})
public singleUserMode: boolean;
} }
export type SoftwareSuspension = { export type SoftwareSuspension = {

View File

@ -274,7 +274,7 @@ export class MiUserProfile {
default: [], default: [],
}) })
public achievements: { public achievements: {
name: string; name: typeof ACHIEVEMENT_TYPES[number];
unlockedAt: number; unlockedAt: number;
}[]; }[];
@ -295,3 +295,84 @@ export class MiUserProfile {
} }
} }
} }
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',
'myNoteFavorited1',
'profileFilled',
'markedAsCat',
'following1',
'following10',
'following50',
'following100',
'following300',
'followers1',
'followers10',
'followers50',
'followers100',
'followers300',
'followers500',
'followers1000',
'collectAchievements30',
'viewAchievements3min',
'iLoveMisskey',
'foundTreasure',
'client30min',
'client60min',
'noteDeletedWithin1min',
'postedAtLateNight',
'postedAt0min0sec',
'selfQuote',
'htl20npm',
'viewInstanceChart',
'outputHelloWorldOnScratchpad',
'open3windows',
'driveFolderCircularReference',
'reactWithoutRead',
'clickedClickHere',
'justPlainLucky',
'setNameToSyuilo',
'cookieClicked',
'brainDiver',
'smashTestNotificationButton',
'tutorialCompleted',
'bubbleGameExplodingHead',
'bubbleGameDoubleExplodingHead',
] as const;

View File

@ -0,0 +1,25 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ACHIEVEMENT_TYPES } from '@/models/UserProfile.js';
export const packedAchievementNameSchema = {
type: 'string',
enum: ACHIEVEMENT_TYPES,
optional: false,
} as const;
export const packedAchievementSchema = {
type: 'object',
properties: {
name: {
ref: 'AchievementName',
},
unlockedAt: {
type: 'number',
optional: false,
},
},
} as const;

View File

@ -36,5 +36,9 @@ export const packedChatRoomSchema = {
type: 'boolean', type: 'boolean',
optional: true, nullable: false, optional: true, nullable: false,
}, },
invitationExists: {
type: 'boolean',
optional: true, nullable: false,
},
}, },
} as const; } as const;

View File

@ -256,6 +256,10 @@ export const packedNoteSchema = {
type: 'number', type: 'number',
optional: true, nullable: false, optional: true, nullable: false,
}, },
hasPoll: {
type: 'boolean',
optional: true, nullable: false,
},
myReaction: { myReaction: {
type: 'string', type: 'string',

View File

@ -3,7 +3,6 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { ACHIEVEMENT_TYPES } from '@/core/AchievementService.js';
import { notificationTypes, userExportableEntities } from '@/types.js'; import { notificationTypes, userExportableEntities } from '@/types.js';
const baseSchema = { const baseSchema = {
@ -312,9 +311,7 @@ export const packedNotificationSchema = {
enum: ['achievementEarned'], enum: ['achievementEarned'],
}, },
achievement: { achievement: {
type: 'string', ref: 'AchievementName',
optional: false, nullable: false,
enum: ACHIEVEMENT_TYPES,
}, },
}, },
}, { }, {

View File

@ -630,18 +630,7 @@ export const packedMeDetailedOnlySchema = {
type: 'array', type: 'array',
nullable: false, optional: false, nullable: false, optional: false,
items: { items: {
type: 'object', ref: 'Achievement',
nullable: false, optional: false,
properties: {
name: {
type: 'string',
nullable: false, optional: false,
},
unlockedAt: {
type: 'number',
nullable: false, optional: false,
},
},
}, },
}, },
loggedInDays: { loggedInDays: {

View File

@ -15,6 +15,7 @@ import { bindThis } from '@/decorators.js';
import { createTemp } from '@/misc/create-temp.js'; import { createTemp } from '@/misc/create-temp.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { NotificationService } from '@/core/NotificationService.js'; import { NotificationService } from '@/core/NotificationService.js';
import { ExportedAntenna } from '@/queue/processors/ImportAntennasProcessorService.js';
import { QueueLoggerService } from '../QueueLoggerService.js'; import { QueueLoggerService } from '../QueueLoggerService.js';
import type { DBExportAntennasData } from '../types.js'; import type { DBExportAntennasData } from '../types.js';
import type * as Bull from 'bullmq'; import type * as Bull from 'bullmq';
@ -86,7 +87,8 @@ export class ExportAntennasProcessorService {
excludeBots: antenna.excludeBots, excludeBots: antenna.excludeBots,
withReplies: antenna.withReplies, withReplies: antenna.withReplies,
withFile: antenna.withFile, withFile: antenna.withFile,
})); excludeNotesInSensitiveChannel: antenna.excludeNotesInSensitiveChannel,
} satisfies Required<ExportedAntenna>));
if (antennas.length - 1 !== index) { if (antennas.length - 1 !== index) {
write(', '); write(', ');
} }

View File

@ -11,17 +11,18 @@ import Logger from '@/logger.js';
import type { AntennasRepository } from '@/models/_.js'; import type { AntennasRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { Schema, SchemaType } from '@/misc/json-schema.js';
import { QueueLoggerService } from '../QueueLoggerService.js'; import { QueueLoggerService } from '../QueueLoggerService.js';
import { DBAntennaImportJobData } from '../types.js'; import { DBAntennaImportJobData } from '../types.js';
import type * as Bull from 'bullmq'; import type * as Bull from 'bullmq';
const Ajv = _Ajv.default; const Ajv = _Ajv.default;
const validate = new Ajv().compile({ const exportedAntennaSchema = {
type: 'object', type: 'object',
properties: { properties: {
name: { type: 'string', minLength: 1, maxLength: 100 }, name: { type: 'string', minLength: 1, maxLength: 100 },
src: { type: 'string', enum: ['home', 'all', 'users', 'list'] }, src: { type: 'string', enum: ['home', 'all', 'users', 'list', 'users_blacklist'] },
userListAccts: { userListAccts: {
type: 'array', type: 'array',
items: { items: {
@ -47,9 +48,14 @@ const validate = new Ajv().compile({
excludeBots: { type: 'boolean' }, excludeBots: { type: 'boolean' },
withReplies: { type: 'boolean' }, withReplies: { type: 'boolean' },
withFile: { type: 'boolean' }, withFile: { type: 'boolean' },
excludeNotesInSensitiveChannel: { type: 'boolean' },
}, },
required: ['name', 'src', 'keywords', 'excludeKeywords', 'users', 'caseSensitive', 'withReplies', 'withFile'], required: ['name', 'src', 'keywords', 'excludeKeywords', 'users', 'caseSensitive', 'withReplies', 'withFile'],
}); } as const satisfies Schema;
export type ExportedAntenna = SchemaType<typeof exportedAntennaSchema>;
const validate = new Ajv().compile<ExportedAntenna>(exportedAntennaSchema);
@Injectable() @Injectable()
export class ImportAntennasProcessorService { export class ImportAntennasProcessorService {
@ -91,6 +97,7 @@ export class ImportAntennasProcessorService {
excludeBots: antenna.excludeBots, excludeBots: antenna.excludeBots,
withReplies: antenna.withReplies, withReplies: antenna.withReplies,
withFile: antenna.withFile, withFile: antenna.withFile,
excludeNotesInSensitiveChannel: antenna.excludeNotesInSensitiveChannel,
}); });
this.logger.succ('Antenna created: ' + result.id); this.logger.succ('Antenna created: ' + result.id);
this.globalEventService.publishInternalEvent('antennaCreated', result); this.globalEventService.publishInternalEvent('antennaCreated', result);

View File

@ -326,19 +326,15 @@ export class ApiCallService implements OnApplicationShutdown {
if (factor > 0) { if (factor > 0) {
// Rate limit // Rate limit
await this.rateLimiterService.limit(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor, factor).catch(err => { const rateLimit = await this.rateLimiterService.limit(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor, factor);
if ('info' in err) { if (rateLimit != null) {
// errはLimiter.LimiterInfoであることが期待される throw new ApiError({
throw new ApiError({ message: 'Rate limit exceeded. Please try again later.',
message: 'Rate limit exceeded. Please try again later.', code: 'RATE_LIMIT_EXCEEDED',
code: 'RATE_LIMIT_EXCEEDED', id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef', httpStatusCode: 429,
httpStatusCode: 429, }, rateLimit.info);
}, err.info); }
} else {
throw new TypeError('information must be a rate-limiter information.');
}
});
} }
} }

View File

@ -12,6 +12,14 @@ import { LoggerService } from '@/core/LoggerService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import type { IEndpointMeta } from './endpoints.js'; import type { IEndpointMeta } from './endpoints.js';
type RateLimitInfo = {
code: 'BRIEF_REQUEST_INTERVAL',
info: Limiter.LimiterInfo,
} | {
code: 'RATE_LIMIT_EXCEEDED',
info: Limiter.LimiterInfo,
};
@Injectable() @Injectable()
export class RateLimiterService { export class RateLimiterService {
private logger: Logger; private logger: Logger;
@ -31,77 +39,55 @@ export class RateLimiterService {
} }
@bindThis @bindThis
public limit(limitation: IEndpointMeta['limit'] & { key: NonNullable<string> }, actor: string, factor = 1) { private checkLimiter(options: Limiter.LimiterOption): Promise<Limiter.LimiterInfo> {
{ return new Promise<Limiter.LimiterInfo>((resolve, reject) => {
if (this.disabled) { new Limiter(options).get((err, info) => {
return Promise.resolve(); if (err) {
} return reject(err);
}
resolve(info);
});
});
}
// Short-term limit @bindThis
const min = new Promise<void>((ok, reject) => { public async limit(limitation: IEndpointMeta['limit'] & { key: NonNullable<string> }, actor: string, factor = 1): Promise<RateLimitInfo | null> {
const minIntervalLimiter = new Limiter({ if (this.disabled) {
id: `${actor}:${limitation.key}:min`, return null;
duration: limitation.minInterval! * factor, }
max: 1,
db: this.redisClient,
});
minIntervalLimiter.get((err, info) => { // Short-term limit
if (err) { if (limitation.minInterval != null) {
return reject({ code: 'ERR', info }); const info = await this.checkLimiter({
} id: `${actor}:${limitation.key}:min`,
duration: limitation.minInterval * factor,
this.logger.debug(`${actor} ${limitation.key} min remaining: ${info.remaining}`); max: 1,
db: this.redisClient,
if (info.remaining === 0) {
return reject({ code: 'BRIEF_REQUEST_INTERVAL', info });
} else {
if (hasLongTermLimit) {
return max.then(ok, reject);
} else {
return ok();
}
}
});
}); });
// Long term limit this.logger.debug(`${actor} ${limitation.key} min remaining: ${info.remaining}`);
const max = new Promise<void>((ok, reject) => {
const limiter = new Limiter({
id: `${actor}:${limitation.key}`,
duration: limitation.duration! * factor,
max: limitation.max! / factor,
db: this.redisClient,
});
limiter.get((err, info) => { if (info.remaining === 0) {
if (err) { return { code: 'BRIEF_REQUEST_INTERVAL', info };
return reject({ code: 'ERR', info });
}
this.logger.debug(`${actor} ${limitation.key} max remaining: ${info.remaining}`);
if (info.remaining === 0) {
return reject({ code: 'RATE_LIMIT_EXCEEDED', info });
} else {
return ok();
}
});
});
const hasShortTermLimit = typeof limitation.minInterval === 'number';
const hasLongTermLimit =
typeof limitation.duration === 'number' &&
typeof limitation.max === 'number';
if (hasShortTermLimit) {
return min;
} else if (hasLongTermLimit) {
return max;
} else {
return Promise.resolve();
} }
} }
// Long term limit
if (limitation.duration != null && limitation.max != null) {
const info = await this.checkLimiter({
id: `${actor}:${limitation.key}`,
duration: limitation.duration,
max: limitation.max / factor,
db: this.redisClient,
});
this.logger.debug(`${actor} ${limitation.key} max remaining: ${info.remaining}`);
if (info.remaining === 0) {
return { code: 'RATE_LIMIT_EXCEEDED', info };
}
}
return null;
} }
} }

View File

@ -89,10 +89,9 @@ export class SigninApiService {
return { error }; return { error };
} }
try {
// not more than 1 attempt per second and not more than 10 attempts per hour // not more than 1 attempt per second and not more than 10 attempts per hour
await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(request.ip)); const rateLimit = await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(request.ip));
} catch (err) { if (rateLimit != null) {
reply.code(429); reply.code(429);
return { return {
error: { error: {

View File

@ -323,6 +323,7 @@ export * as 'notes/replies' from './endpoints/notes/replies.js';
export * as 'notes/search' from './endpoints/notes/search.js'; export * as 'notes/search' from './endpoints/notes/search.js';
export * as 'notes/search-by-tag' from './endpoints/notes/search-by-tag.js'; export * as 'notes/search-by-tag' from './endpoints/notes/search-by-tag.js';
export * as 'notes/show' from './endpoints/notes/show.js'; export * as 'notes/show' from './endpoints/notes/show.js';
export * as 'notes/show-partial-bulk' from './endpoints/notes/show-partial-bulk.js';
export * as 'notes/state' from './endpoints/notes/state.js'; export * as 'notes/state' from './endpoints/notes/state.js';
export * as 'notes/thread-muting/create' from './endpoints/notes/thread-muting/create.js'; export * as 'notes/thread-muting/create' from './endpoints/notes/thread-muting/create.js';
export * as 'notes/thread-muting/delete' from './endpoints/notes/thread-muting/delete.js'; export * as 'notes/thread-muting/delete' from './endpoints/notes/thread-muting/delete.js';

View File

@ -546,6 +546,15 @@ export const meta = {
}, },
}, },
}, },
singleUserMode: {
type: 'boolean',
optional: false, nullable: false,
},
ugcVisibilityForVisitor: {
type: 'string',
enum: ['all', 'local', 'none'],
optional: false, nullable: false,
},
}, },
}, },
} as const; } as const;
@ -691,6 +700,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
federation: instance.federation, federation: instance.federation,
federationHosts: instance.federationHosts, federationHosts: instance.federationHosts,
deliverSuspendedSoftware: instance.deliverSuspendedSoftware, deliverSuspendedSoftware: instance.deliverSuspendedSoftware,
singleUserMode: instance.singleUserMode,
ugcVisibilityForVisitor: instance.ugcVisibilityForVisitor,
}; };
}); });
} }

View File

@ -196,6 +196,11 @@ export const paramDef = {
required: ['software', 'versionRange'], required: ['software', 'versionRange'],
}, },
}, },
singleUserMode: { type: 'boolean' },
ugcVisibilityForVisitor: {
type: 'string',
enum: ['all', 'local', 'none'],
},
}, },
required: [], required: [],
} as const; } as const;
@ -690,6 +695,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.federationHosts = ps.federationHosts.filter(Boolean).map(x => x.toLowerCase()); set.federationHosts = ps.federationHosts.filter(Boolean).map(x => x.toLowerCase());
} }
if (ps.singleUserMode !== undefined) {
set.singleUserMode = ps.singleUserMode;
}
if (ps.ugcVisibilityForVisitor !== undefined) {
set.ugcVisibilityForVisitor = ps.ugcVisibilityForVisitor;
}
const before = await this.metaService.fetch(true); const before = await this.metaService.fetch(true);
await this.metaService.update(set); await this.metaService.update(set);

View File

@ -5,7 +5,8 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { AchievementService, ACHIEVEMENT_TYPES } from '@/core/AchievementService.js'; import { AchievementService } from '@/core/AchievementService.js';
import { ACHIEVEMENT_TYPES } from '@/models/UserProfile.js';
export const meta = { export const meta = {
requireCredential: true, requireCredential: true,

View File

@ -0,0 +1,47 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { GetterService } from '@/server/api/GetterService.js';
import { ApiError } from '../../error.js';
export const meta = {
tags: ['notes'],
requireCredential: false,
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
},
},
errors: {
},
} as const;
export const paramDef = {
type: 'object',
properties: {
noteIds: { type: 'array', items: { type: 'string', format: 'misskey:id' }, maxItems: 100, minItems: 1 },
},
required: ['noteIds'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private noteEntityService: NoteEntityService,
) {
super(meta, paramDef, async (ps, me) => {
return await this.noteEntityService.fetchDiffs(ps.noteIds);
});
}
}

View File

@ -3,10 +3,12 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { GetterService } from '@/server/api/GetterService.js'; import { GetterService } from '@/server/api/GetterService.js';
import { DI } from '@/di-symbols.js';
import { MiMeta } from '@/models/Meta.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@ -46,6 +48,9 @@ export const paramDef = {
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor( constructor(
@Inject(DI.meta)
private serverSettings: MiMeta,
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private getterService: GetterService, private getterService: GetterService,
) { ) {
@ -59,6 +64,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.signinRequired); throw new ApiError(meta.errors.signinRequired);
} }
if (this.serverSettings.ugcVisibilityForVisitor === 'none' && me == null) {
throw new ApiError(meta.errors.signinRequired);
}
if (this.serverSettings.ugcVisibilityForVisitor === 'local' && note.userHost != null && me == null) {
throw new ApiError(meta.errors.signinRequired);
}
return await this.noteEntityService.pack(note, me, { return await this.noteEntityService.pack(note, me, {
detail: true, detail: true,
}); });

View File

@ -14,15 +14,7 @@ export const meta = {
res: { res: {
type: 'array', type: 'array',
items: { items: {
type: 'object', ref: 'Achievement',
properties: {
name: {
type: 'string',
},
unlockedAt: {
type: 'number',
},
},
}, },
}, },
} as const; } as const;

View File

@ -5,7 +5,7 @@
import { In, IsNull } from 'typeorm'; import { In, IsNull } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { UsersRepository } from '@/models/_.js'; import type { MiMeta, UsersRepository } from '@/models/_.js';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.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';
@ -82,6 +82,9 @@ export const paramDef = {
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor( constructor(
@Inject(DI.meta)
private serverSettings: MiMeta,
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
@ -92,6 +95,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private apiLoggerService: ApiLoggerService, private apiLoggerService: ApiLoggerService,
) { ) {
super(meta, paramDef, async (ps, me, _1, _2, _3, ip) => { super(meta, paramDef, async (ps, me, _1, _2, _3, ip) => {
if (this.serverSettings.ugcVisibilityForVisitor === 'none' && me == null) {
throw new ApiError(meta.errors.noSuchUser);
}
let user; let user;
const isModerator = await this.roleService.isModerator(me); const isModerator = await this.roleService.isModerator(me);
@ -123,6 +130,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} else { } else {
// Lookup user // Lookup user
if (typeof ps.host === 'string' && typeof ps.username === 'string') { if (typeof ps.host === 'string' && typeof ps.username === 'string') {
if (this.serverSettings.ugcVisibilityForVisitor === 'local' && me == null) {
throw new ApiError(meta.errors.noSuchUser);
}
user = await this.remoteUserResolveService.resolveUser(ps.username, ps.host).catch(err => { user = await this.remoteUserResolveService.resolveUser(ps.username, ps.host).catch(err => {
this.apiLoggerService.logger.warn(`failed to resolve remote user: ${err}`); this.apiLoggerService.logger.warn(`failed to resolve remote user: ${err}`);
throw new ApiError(meta.errors.failedToResolveRemoteUser); throw new ApiError(meta.errors.failedToResolveRemoteUser);
@ -139,6 +150,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchUser); throw new ApiError(meta.errors.noSuchUser);
} }
if (this.serverSettings.ugcVisibilityForVisitor === 'local' && user.host != null && me == null) {
throw new ApiError(meta.errors.noSuchUser);
}
if (user.host == null) { if (user.host == null) {
if (me == null && ip != null) { if (me == null && ip != null) {
this.perUserPvChart.commitByVisitor(user, ip); this.perUserPvChart.commitByVisitor(user, ip);

View File

@ -513,7 +513,12 @@ export class ClientServerService {
vary(reply.raw, 'Accept'); vary(reply.raw, 'Accept');
if (user != null) { if (
user != null && (
this.meta.ugcVisibilityForVisitor === 'all' ||
(this.meta.ugcVisibilityForVisitor === 'local' && user.host == null)
)
) {
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
const me = profile.fields const me = profile.fields
? profile.fields ? profile.fields
@ -577,7 +582,13 @@ export class ClientServerService {
relations: ['user'], relations: ['user'],
}); });
if (note && !note.user!.requireSigninToViewContents) { if (
note &&
!note.user!.requireSigninToViewContents &&
(this.meta.ugcVisibilityForVisitor === 'all' ||
(this.meta.ugcVisibilityForVisitor === 'local' && note.userHost == null)
)
) {
const _note = await this.noteEntityService.pack(note); const _note = await this.noteEntityService.pack(note);
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: note.userId }); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: note.userId });
reply.header('Cache-Control', 'public, max-age=15'); reply.header('Cache-Control', 'public, max-age=15');

View File

@ -232,7 +232,7 @@ describe('UserEntityService', () => {
}); });
test('MeDetailed', async() => { test('MeDetailed', async() => {
const achievements = [{ name: 'achievement', unlockedAt: new Date().getTime() }]; const achievements = [{ name: 'iLoveMisskey' as const, unlockedAt: new Date().getTime() }];
const me = await createUser({}, { const me = await createUser({}, {
birthday: '2000-01-01', birthday: '2000-01-01',
achievements: achievements, achievements: achievements,

View File

@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
target="_blank" target="_blank"
rel="noopener" rel="noopener"
> >
<ImgWithBlurhash <EmImgWithBlurhash
:hash="image.blurhash" :hash="image.blurhash"
:src="hide ? null : url" :src="hide ? null : url"
:forceBlurhash="hide" :forceBlurhash="hide"
@ -45,7 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import ImgWithBlurhash from '@/components/EmImgWithBlurhash.vue'; import EmImgWithBlurhash from '@/components/EmImgWithBlurhash.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{

View File

@ -6,7 +6,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<div> <div>
<div class="_fullinfo"> <div class="_fullinfo">
<img :src="notFoundImageUrl" draggable="false"/>
<div>{{ i18n.ts.notFoundDescription }}</div> <div>{{ i18n.ts.notFoundDescription }}</div>
</div> </div>
</div> </div>
@ -14,11 +13,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { inject, computed } from 'vue'; import { inject, computed } from 'vue';
import { DEFAULT_NOT_FOUND_IMAGE_URL } from '@@/js/const.js';
import { DI } from '@/di.js'; import { DI } from '@/di.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
const serverMetadata = inject(DI.serverMetadata)!; const serverMetadata = inject(DI.serverMetadata)!;
const notFoundImageUrl = computed(() => serverMetadata.notFoundImageUrl ?? DEFAULT_NOT_FOUND_IMAGE_URL);
</script> </script>

View File

@ -286,13 +286,6 @@ rt {
._fullinfo { ._fullinfo {
padding: 64px 32px; padding: 64px 32px;
text-align: center; text-align: center;
> img {
vertical-align: bottom;
height: 128px;
margin-bottom: 16px;
border-radius: 16px;
}
} }
._link { ._link {

View File

@ -112,10 +112,6 @@ export const ROLE_POLICIES = [
'chatAvailability', 'chatAvailability',
] as const; ] as const;
export const DEFAULT_SERVER_ERROR_IMAGE_URL = 'https://xn--931a.moe/assets/error.jpg';
export const DEFAULT_NOT_FOUND_IMAGE_URL = 'https://xn--931a.moe/assets/not-found.jpg';
export const DEFAULT_INFO_IMAGE_URL = 'https://xn--931a.moe/assets/info.jpg';
export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'scale', 'position', 'fg', 'bg', 'border', 'font', 'blur', 'rainbow', 'sparkle', 'rotate', 'ruby', 'unixtime']; export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'scale', 'position', 'fg', 'bg', 'border', 'font', 'blur', 'rainbow', 'sparkle', 'rotate', 'ruby', 'unixtime'];
export const MFM_PARAMS: Record<typeof MFM_TAGS[number], string[]> = { export const MFM_PARAMS: Record<typeof MFM_TAGS[number], string[]> = {
tada: ['speed=', 'delay='], tada: ['speed=', 'delay='],

View File

@ -79,39 +79,6 @@ export async function mainBoot() {
} }
} }
const stream = useStream();
let reloadDialogShowing = false;
stream.on('_disconnected_', async () => {
if (prefer.s.serverDisconnectedBehavior === 'reload') {
window.location.reload();
} else if (prefer.s.serverDisconnectedBehavior === 'dialog') {
if (reloadDialogShowing) return;
reloadDialogShowing = true;
const { canceled } = await confirm({
type: 'warning',
title: i18n.ts.disconnectedFromServer,
text: i18n.ts.reloadConfirm,
});
reloadDialogShowing = false;
if (!canceled) {
window.location.reload();
}
}
});
stream.on('emojiAdded', emojiData => {
addCustomEmoji(emojiData.emoji);
});
stream.on('emojiUpdated', emojiData => {
updateCustomEmojis(emojiData.emojis);
});
stream.on('emojiDeleted', emojiData => {
removeCustomEmojis(emojiData.emojis);
});
launchPlugins(); launchPlugins();
try { try {
@ -169,8 +136,6 @@ export async function mainBoot() {
} }
} }
stream.on('announcementCreated', onAnnouncementCreated);
if ($i.isDeleted) { if ($i.isDeleted) {
alert({ alert({
type: 'warning', type: 'warning',
@ -348,50 +313,81 @@ export async function mainBoot() {
} }
} }
const main = markRaw(stream.useChannel('main', null, 'System')); if (store.s.realtimeMode) {
const stream = useStream();
// 自分の情報が更新されたとき let reloadDialogShowing = false;
main.on('meUpdated', i => { stream.on('_disconnected_', async () => {
updateCurrentAccountPartial(i); if (prefer.s.serverDisconnectedBehavior === 'reload') {
}); window.location.reload();
} else if (prefer.s.serverDisconnectedBehavior === 'dialog') {
main.on('readAllNotifications', () => { if (reloadDialogShowing) return;
updateCurrentAccountPartial({ reloadDialogShowing = true;
hasUnreadNotification: false, const { canceled } = await confirm({
unreadNotificationsCount: 0, type: 'warning',
title: i18n.ts.disconnectedFromServer,
text: i18n.ts.reloadConfirm,
});
reloadDialogShowing = false;
if (!canceled) {
window.location.reload();
}
}
}); });
});
main.on('unreadNotification', () => { stream.on('emojiAdded', emojiData => {
const unreadNotificationsCount = ($i?.unreadNotificationsCount ?? 0) + 1; addCustomEmoji(emojiData.emoji);
updateCurrentAccountPartial({
hasUnreadNotification: true,
unreadNotificationsCount,
}); });
});
main.on('unreadAntenna', () => { stream.on('emojiUpdated', emojiData => {
updateCurrentAccountPartial({ hasUnreadAntenna: true }); updateCustomEmojis(emojiData.emojis);
sound.playMisskeySfx('antenna'); });
});
main.on('newChatMessage', () => { stream.on('emojiDeleted', emojiData => {
updateCurrentAccountPartial({ hasUnreadChatMessages: true }); removeCustomEmojis(emojiData.emojis);
sound.playMisskeySfx('chatMessage'); });
});
main.on('readAllAnnouncements', () => { stream.on('announcementCreated', onAnnouncementCreated);
updateCurrentAccountPartial({ hasUnreadAnnouncement: false });
});
// 個人宛てお知らせが発行されたとき const main = markRaw(stream.useChannel('main', null, 'System'));
main.on('announcementCreated', onAnnouncementCreated);
// トークンが再生成されたとき // 自分の情報が更新されたとき
// このままではMisskeyが利用できないので強制的にサインアウトさせる main.on('meUpdated', i => {
main.on('myTokenRegenerated', () => { updateCurrentAccountPartial(i);
signout(); });
});
main.on('readAllNotifications', () => {
updateCurrentAccountPartial({
hasUnreadNotification: false,
unreadNotificationsCount: 0,
});
});
main.on('unreadNotification', () => {
const unreadNotificationsCount = ($i?.unreadNotificationsCount ?? 0) + 1;
updateCurrentAccountPartial({
hasUnreadNotification: true,
unreadNotificationsCount,
});
});
main.on('unreadAntenna', () => {
updateCurrentAccountPartial({ hasUnreadAntenna: true });
sound.playMisskeySfx('antenna');
});
main.on('newChatMessage', () => {
updateCurrentAccountPartial({ hasUnreadChatMessages: true });
sound.playMisskeySfx('chatMessage');
});
main.on('readAllAnnouncements', () => {
updateCurrentAccountPartial({ hasUnreadAnnouncement: false });
});
// 個人宛てお知らせが発行されたとき
main.on('announcementCreated', onAnnouncementCreated);
}
} }
// shortcut // shortcut

View File

@ -5,12 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<MkPagination :pagination="pagination"> <MkPagination :pagination="pagination">
<template #empty> <template #empty><MkResult type="empty"/></template>
<div class="_fullinfo">
<img :src="infoImageUrl" draggable="false"/>
<div>{{ i18n.ts.notFound }}</div>
</div>
</template>
<template #default="{ items }"> <template #default="{ items }">
<MkChannelPreview v-for="item in items" :key="item.id" class="_margin" :channel="extractor(item)"/> <MkChannelPreview v-for="item in items" :key="item.id" class="_margin" :channel="extractor(item)"/>
@ -19,14 +14,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import type { Paging } from '@/components/MkPagination.vue'; import type { PagingCtx } from '@/composables/use-pagination.js';
import MkChannelPreview from '@/components/MkChannelPreview.vue'; import MkChannelPreview from '@/components/MkChannelPreview.vue';
import MkPagination from '@/components/MkPagination.vue'; import MkPagination from '@/components/MkPagination.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { infoImageUrl } from '@/instance.js';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
pagination: Paging; pagination: PagingCtx;
noGap?: boolean; noGap?: boolean;
extractor?: (item: any) => any; extractor?: (item: any) => any;
}>(), { }>(), {

View File

@ -51,7 +51,7 @@ import { Chart } from 'chart.js';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { misskeyApiGet } from '@/utility/misskey-api.js'; import { misskeyApiGet } from '@/utility/misskey-api.js';
import { store } from '@/store.js'; import { store } from '@/store.js';
import { useChartTooltip } from '@/use/use-chart-tooltip.js'; import { useChartTooltip } from '@/composables/use-chart-tooltip.js';
import { chartVLine } from '@/utility/chart-vline.js'; import { chartVLine } from '@/utility/chart-vline.js';
import { alpha } from '@/utility/color.js'; import { alpha } from '@/utility/color.js';
import date from '@/filters/date.js'; import date from '@/filters/date.js';

View File

@ -28,9 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</MkA> </MkA>
</div> </div>
<div v-if="!initializing && history.length == 0" class="_fullinfo"> <MkResult v-if="!initializing && history.length == 0" type="empty" :text="i18n.ts._chat.noHistory"/>
<div>{{ i18n.ts._chat.noHistory }}</div>
</div>
<MkLoading v-if="initializing"/> <MkLoading v-if="initializing"/>
</template> </template>

View File

@ -154,6 +154,10 @@ onUnmounted(() => {
&.naked { &.naked {
background: transparent !important; background: transparent !important;
box-shadow: none !important; box-shadow: none !important;
> .content {
background: transparent !important;
}
} }
&.scrollable { &.scrollable {

View File

@ -7,8 +7,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts"> <script lang="ts">
import { defineComponent, h, TransitionGroup, useCssModule } from 'vue'; import { defineComponent, h, TransitionGroup, useCssModule } from 'vue';
import type { PropType } from 'vue';
import type { MisskeyEntity } from '@/types/date-separated-list.js';
import MkAd from '@/components/global/MkAd.vue'; import MkAd from '@/components/global/MkAd.vue';
import { isDebuggerEnabled, stackTraceInstances } from '@/debug.js'; import { isDebuggerEnabled, stackTraceInstances } from '@/debug.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
@ -19,7 +17,7 @@ import { getDateText } from '@/utility/timeline-date-separate.js';
export default defineComponent({ export default defineComponent({
props: { props: {
items: { items: {
type: Array as PropType<MisskeyEntity[]>, type: Array,
required: true, required: true,
}, },
direction: { direction: {

View File

@ -11,18 +11,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
<div <div
v-else-if="!input && !select" v-else-if="!input && !select"
:class="[$style.icon, { :class="[$style.icon]"
[$style.type_success]: type === 'success',
[$style.type_error]: type === 'error',
[$style.type_warning]: type === 'warning',
[$style.type_info]: type === 'info',
}]"
> >
<i v-if="type === 'success'" :class="$style.iconInner" class="ti ti-check"></i> <MkSystemIcon v-if="type === 'success'" :class="$style.iconInner" style="width: 45px;" type="success"/>
<i v-else-if="type === 'error'" :class="$style.iconInner" class="ti ti-circle-x"></i> <MkSystemIcon v-else-if="type === 'error'" :class="$style.iconInner" style="width: 45px;" type="error"/>
<i v-else-if="type === 'warning'" :class="$style.iconInner" class="ti ti-alert-triangle"></i> <MkSystemIcon v-else-if="type === 'warning'" :class="$style.iconInner" style="width: 45px;" type="warn"/>
<i v-else-if="type === 'info'" :class="$style.iconInner" class="ti ti-info-circle"></i> <MkSystemIcon v-else-if="type === 'info'" :class="$style.iconInner" style="width: 45px;" type="info"/>
<i v-else-if="type === 'question'" :class="$style.iconInner" class="ti ti-help-circle"></i> <MkSystemIcon v-else-if="type === 'question'" :class="$style.iconInner" style="width: 45px;" type="question"/>
<MkLoading v-else-if="type === 'waiting'" :class="$style.iconInner" :em="true"/> <MkLoading v-else-if="type === 'waiting'" :class="$style.iconInner" :em="true"/>
</div> </div>
<header v-if="title" :class="$style.title" class="_selectable"><Mfm :text="title"/></header> <header v-if="title" :class="$style.title" class="_selectable"><Mfm :text="title"/></header>
@ -202,22 +197,6 @@ function onInputKeydown(evt: KeyboardEvent) {
margin: 0 auto; margin: 0 auto;
} }
.type_info {
color: #55c4dd;
}
.type_success {
color: var(--MI_THEME-success);
}
.type_error {
color: var(--MI_THEME-error);
}
.type_warning {
color: var(--MI_THEME-warn);
}
.title { .title {
margin: 0 0 8px 0; margin: 0 0 8px 0;
font-weight: bold; font-weight: bold;

View File

@ -11,15 +11,24 @@ SPDX-License-Identifier: AGPL-3.0-only
[$style.large]: large, [$style.large]: large,
}]" }]"
> >
<ImgWithBlurhash <MkImgWithBlurhash
v-if="isThumbnailAvailable" v-if="isThumbnailAvailable && prefer.s.enableHighQualityImagePlaceholders"
:hash="file.blurhash" :hash="file.blurhash"
:src="file.thumbnailUrl" :src="file.thumbnailUrl"
:alt="file.name" :alt="file.name"
:title="file.name" :title="file.name"
:class="$style.thumbnail"
:cover="fit !== 'contain'" :cover="fit !== 'contain'"
:forceBlurhash="forceBlurhash" :forceBlurhash="forceBlurhash"
/> />
<img
v-else-if="isThumbnailAvailable"
:src="file.thumbnailUrl"
:alt="file.name"
:title="file.name"
:class="$style.thumbnail"
:style="{ objectFit: fit }"
/>
<i v-else-if="is === 'image'" class="ti ti-photo" :class="$style.icon"></i> <i v-else-if="is === 'image'" class="ti ti-photo" :class="$style.icon"></i>
<i v-else-if="is === 'video'" class="ti ti-video" :class="$style.icon"></i> <i v-else-if="is === 'video'" class="ti ti-video" :class="$style.icon"></i>
<i v-else-if="is === 'audio' || is === 'midi'" class="ti ti-file-music" :class="$style.icon"></i> <i v-else-if="is === 'audio' || is === 'midi'" class="ti ti-file-music" :class="$style.icon"></i>
@ -36,7 +45,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue'; import { computed } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; import MkImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
import { prefer } from '@/preferences.js';
const props = defineProps<{ const props = defineProps<{
file: Misskey.entities.DriveFile; file: Misskey.entities.DriveFile;
@ -115,4 +125,8 @@ const isThumbnailAvailable = computed(() => {
.large .icon { .large .icon {
font-size: 40px; font-size: 40px;
} }
.thumbnail {
width: 100%;
}
</style> </style>

View File

@ -39,7 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { ref, useTemplateRef } from 'vue';
import MkModalWindow from '@/components/MkModalWindow.vue'; import MkModalWindow from '@/components/MkModalWindow.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue'; import MkInput from '@/components/MkInput.vue';
@ -53,7 +53,7 @@ const emit = defineEmits<{
(ev: 'closed'): void; (ev: 'closed'): void;
}>(); }>();
const dialog = ref<InstanceType<typeof MkModalWindow>>(); const dialog = useTemplateRef('dialog');
const username = ref(''); const username = ref('');
const email = ref(''); const email = ref('');

View File

@ -62,10 +62,7 @@ SPDX-License-Identifier: AGPL-3.0-only
/> />
</template> </template>
</div> </div>
<div v-else class="_fullinfo"> <MkResult v-else type="empty"/>
<img :src="infoImageUrl" draggable="false"/>
<div>{{ i18n.ts.nothing }}</div>
</div>
</div> </div>
</MkModalWindow> </MkModalWindow>
</template> </template>
@ -83,7 +80,6 @@ import XFile from './MkFormDialog.file.vue';
import type { Form } from '@/utility/form.js'; import type { Form } from '@/utility/form.js';
import MkModalWindow from '@/components/MkModalWindow.vue'; import MkModalWindow from '@/components/MkModalWindow.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { infoImageUrl } from '@/instance.js';
const props = defineProps<{ const props = defineProps<{
title: string; title: string;

View File

@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkA :to="`/gallery/${post.id}`" class="ttasepnz _panel" tabindex="-1" @pointerenter="enterHover" @pointerleave="leaveHover"> <MkA :to="`/gallery/${post.id}`" class="ttasepnz _panel" tabindex="-1" @pointerenter="enterHover" @pointerleave="leaveHover">
<div class="thumbnail"> <div class="thumbnail">
<Transition> <Transition>
<ImgWithBlurhash <MkImgWithBlurhash
class="img layered" class="img layered"
:transition="safe ? null : { :transition="safe ? null : {
duration: 500, duration: 500,
@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; import MkImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
const props = defineProps<{ const props = defineProps<{

View File

@ -18,7 +18,7 @@ import { Chart } from 'chart.js';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { misskeyApi } from '@/utility/misskey-api.js'; import { misskeyApi } from '@/utility/misskey-api.js';
import { store } from '@/store.js'; import { store } from '@/store.js';
import { useChartTooltip } from '@/use/use-chart-tooltip.js'; import { useChartTooltip } from '@/composables/use-chart-tooltip.js';
import { alpha } from '@/utility/color.js'; import { alpha } from '@/utility/color.js';
import { initChart } from '@/utility/init-chart.js'; import { initChart } from '@/utility/init-chart.js';

View File

@ -89,7 +89,7 @@ import { Chart } from 'chart.js';
import type { HeatmapSource } from '@/components/MkHeatmap.vue'; import type { HeatmapSource } from '@/components/MkHeatmap.vue';
import MkSelect from '@/components/MkSelect.vue'; import MkSelect from '@/components/MkSelect.vue';
import MkChart from '@/components/MkChart.vue'; import MkChart from '@/components/MkChart.vue';
import { useChartTooltip } from '@/use/use-chart-tooltip.js'; import { useChartTooltip } from '@/composables/use-chart-tooltip.js';
import { $i } from '@/i.js'; import { $i } from '@/i.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { misskeyApiGet } from '@/utility/misskey-api.js'; import { misskeyApiGet } from '@/utility/misskey-api.js';

View File

@ -12,6 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue'; import { computed } from 'vue';
import tinycolor from 'tinycolor2';
import { instanceName as localInstanceName } from '@@/js/config.js'; import { instanceName as localInstanceName } from '@@/js/config.js';
import type { CSSProperties } from 'vue'; import type { CSSProperties } from 'vue';
import { instance as localInstance } from '@/instance.js'; import { instance as localInstance } from '@/instance.js';
@ -43,10 +44,33 @@ const faviconUrl = computed(() => {
return getProxiedImageUrlNullable(imageSrc); return getProxiedImageUrlNullable(imageSrc);
}); });
type ITickerColors = {
readonly bg: string;
readonly fg: string;
};
const TICKER_YUV_THRESHOLD = 191 as const;
const TICKER_FG_COLOR_LIGHT = '#ffffff' as const;
const TICKER_FG_COLOR_DARK = '#2f2f2fcc' as const;
function getTickerColors(bgHex: string): ITickerColors {
const tinycolorInstance = tinycolor(bgHex);
const { r, g, b } = tinycolorInstance.toRgb();
const yuv = 0.299 * r + 0.587 * g + 0.114 * b;
const fgHex = yuv > TICKER_YUV_THRESHOLD ? TICKER_FG_COLOR_DARK : TICKER_FG_COLOR_LIGHT;
return {
fg: fgHex,
bg: bgHex,
} as const satisfies ITickerColors;
}
const themeColorStyle = computed<CSSProperties>(() => { const themeColorStyle = computed<CSSProperties>(() => {
const themeColor = (props.host == null ? localInstance.themeColor : props.instance?.themeColor) ?? '#777777'; const themeColor = (props.host == null ? localInstance.themeColor : props.instance?.themeColor) ?? '#777777';
const colors = getTickerColors(themeColor);
return { return {
background: `linear-gradient(90deg, ${themeColor}, ${themeColor}00)`, background: `linear-gradient(90deg, ${colors.bg}, ${colors.bg}00)`,
color: colors.fg,
}; };
}); });
</script> </script>
@ -60,7 +84,6 @@ $height: 2ex;
height: $height; height: $height;
border-radius: 4px 0 0 4px; border-radius: 4px 0 0 4px;
overflow: clip; overflow: clip;
color: #fff;
// text-shadow使 // text-shadow使

View File

@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { defineAsyncComponent, ref } from 'vue'; import { defineAsyncComponent, ref } from 'vue';
import { url as local } from '@@/js/config.js'; import { url as local } from '@@/js/config.js';
import { useTooltip } from '@/use/use-tooltip.js'; import { useTooltip } from '@/composables/use-tooltip.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { isEnabledUrlPreview } from '@/instance.js'; import { isEnabledUrlPreview } from '@/instance.js';
import type { MkABehavior } from '@/components/global/MkA.vue'; import type { MkABehavior } from '@/components/global/MkA.vue';

View File

@ -1,112 +0,0 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<script lang="ts">
import { h, onMounted, onUnmounted, ref, watch } from 'vue';
export default {
name: 'MarqueeText',
props: {
duration: {
type: Number,
default: 15,
},
repeat: {
type: Number,
default: 2,
},
paused: {
type: Boolean,
default: false,
},
reverse: {
type: Boolean,
default: false,
},
},
setup(props) {
const contentEl = ref<HTMLElement>();
function calc() {
if (contentEl.value == null) return;
const eachLength = contentEl.value.offsetWidth / props.repeat;
const factor = 3000;
const duration = props.duration / ((1 / eachLength) * factor);
contentEl.value.style.animationDuration = `${duration}s`;
}
watch(() => props.duration, calc);
onMounted(() => {
calc();
});
onUnmounted(() => {
});
return {
contentEl,
};
},
render({
$slots, $style, $props: {
duration, repeat, paused, reverse,
},
}) {
return h('div', { class: [$style.wrap] }, [
h('span', {
ref: 'contentEl',
class: [
paused
? $style.paused
: undefined,
$style.content,
],
}, Array(repeat).fill(
h('span', {
class: $style.text,
style: {
animationDirection: reverse
? 'reverse'
: undefined,
},
}, $slots.default()),
)),
]);
},
};
</script>
<style lang="scss" module>
.wrap {
overflow: clip;
animation-play-state: running;
&:hover {
animation-play-state: paused;
}
}
.content {
display: inline-block;
white-space: nowrap;
animation-play-state: inherit;
}
.text {
display: inline-block;
animation-name: marquee;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-duration: inherit;
animation-play-state: inherit;
}
.paused .text {
animation-play-state: paused;
}
@keyframes marquee {
0% { transform:translateX(0); }
100% { transform:translateX(-100%); }
}
</style>

View File

@ -0,0 +1,89 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.wrap">
<span
ref="contentEl"
:class="[$style.content, {
[$style.paused]: paused,
[$style.reverse]: reverse,
}]"
>
<span v-for="key in repeat" :key="key" :class="$style.text">
<slot></slot>
</span>
</span>
</div>
</template>
<script lang="ts" setup>
import { onMounted, useTemplateRef, watch } from 'vue';
const props = withDefaults(defineProps<{
duration?: number;
repeat?: number;
paused?: boolean;
reverse?: boolean;
}>(), {
duration: 15,
repeat: 2,
paused: false,
reverse: false,
});
const contentEl = useTemplateRef('contentEl');
function calcDuration() {
if (contentEl.value == null) return;
const eachLength = contentEl.value.offsetWidth / props.repeat;
const factor = 3000;
const duration = props.duration / ((1 / eachLength) * factor);
contentEl.value.style.animationDuration = `${duration}s`;
}
watch(() => props.duration, calcDuration);
onMounted(calcDuration);
</script>
<style lang="scss" module>
.wrap {
overflow: clip;
animation-play-state: running;
&:hover {
animation-play-state: paused;
}
}
.content {
display: inline-block;
white-space: nowrap;
animation-play-state: inherit;
}
.text {
display: inline-block;
animation-name: marquee;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-duration: inherit;
animation-play-state: inherit;
}
.paused .text {
animation-play-state: paused;
}
.reverse .text {
animation-direction: reverse;
}
@keyframes marquee {
0% { transform: translateX(0); }
100% { transform: translateX(-100%); }
}
</style>

View File

@ -17,7 +17,8 @@ SPDX-License-Identifier: AGPL-3.0-only
style: 'cursor: zoom-in;' style: 'cursor: zoom-in;'
}" }"
> >
<ImgWithBlurhash <MkImgWithBlurhash
v-if="prefer.s.enableHighQualityImagePlaceholders"
:hash="image.blurhash" :hash="image.blurhash"
:src="(prefer.s.dataSaver.media && hide) ? null : url" :src="(prefer.s.dataSaver.media && hide) ? null : url"
:forceBlurhash="hide" :forceBlurhash="hide"
@ -27,6 +28,20 @@ SPDX-License-Identifier: AGPL-3.0-only
:width="image.properties.width" :width="image.properties.width"
:height="image.properties.height" :height="image.properties.height"
:style="hide ? 'filter: brightness(0.7);' : null" :style="hide ? 'filter: brightness(0.7);' : null"
:class="$style.image"
/>
<div
v-else-if="prefer.s.dataSaver.media || hide"
:title="image.comment || image.name"
:style="hide ? 'background: #888;' : null"
:class="$style.image"
></div>
<img
v-else
:src="url"
:alt="image.comment || image.name"
:title="image.comment || image.name"
:class="$style.image"
/> />
</component> </component>
<template v-if="hide"> <template v-if="hide">
@ -57,7 +72,7 @@ import type { MenuItem } from '@/types/menu.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard'; import { copyToClipboard } from '@/utility/copy-to-clipboard';
import { getStaticImageUrl } from '@/utility/media-proxy.js'; import { getStaticImageUrl } from '@/utility/media-proxy.js';
import bytes from '@/filters/bytes.js'; import bytes from '@/filters/bytes.js';
import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; import MkImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { $i, iAmModerator } from '@/i.js'; import { $i, iAmModerator } from '@/i.js';
@ -300,4 +315,12 @@ html[data-color-scheme=light] .visible {
font-size: 0.8em; font-size: 0.8em;
padding: 2px 5px; padding: 2px 5px;
} }
.image {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
object-position: center;
}
</style> </style>

View File

@ -6,11 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<div <div
v-if="!hardMuted && muted === false" v-if="!hardMuted && muted === false"
v-show="!isDeleted"
ref="rootEl" ref="rootEl"
v-hotkey="keymap" v-hotkey="keymap"
:class="[$style.root, { [$style.showActionsOnlyHover]: prefer.s.showNoteActionsOnlyHover, [$style.skipRender]: prefer.s.skipNoteRender }]" :class="[$style.root, { [$style.showActionsOnlyHover]: prefer.s.showNoteActionsOnlyHover, [$style.skipRender]: prefer.s.skipNoteRender }]"
:tabindex="isDeleted ? '-1' : '0'" tabindex="0"
> >
<MkNoteSub v-if="appearNote.reply && !renoteCollapsed" :note="appearNote.reply" :class="$style.replyTo"/> <MkNoteSub v-if="appearNote.reply && !renoteCollapsed" :note="appearNote.reply" :class="$style.replyTo"/>
<div v-if="pinned" :class="$style.tip"><i class="ti ti-pin"></i> {{ i18n.ts.pinnedNote }}</div> <div v-if="pinned" :class="$style.tip"><i class="ti ti-pin"></i> {{ i18n.ts.pinnedNote }}</div>
@ -84,10 +83,19 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</div> </div>
</div> </div>
<div v-if="appearNote.files && appearNote.files.length > 0"> <div v-if="appearNote.files && appearNote.files.length > 0" style="margin-top: 8px;">
<MkMediaList ref="galleryEl" :mediaList="appearNote.files"/> <MkMediaList ref="galleryEl" :mediaList="appearNote.files"/>
</div> </div>
<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :author="appearNote.user" :emojiUrls="appearNote.emojis" :class="$style.poll"/> <MkPoll
v-if="appearNote.poll"
:noteId="appearNote.id"
:multiple="appearNote.poll.multiple"
:expiresAt="appearNote.poll.expiresAt"
:choices="$appearNote.pollChoices"
:author="appearNote.user"
:emojiUrls="appearNote.emojis"
:class="$style.poll"
/>
<div v-if="isEnabledUrlPreview"> <div v-if="isEnabledUrlPreview">
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview"/> <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview"/>
</div> </div>
@ -101,7 +109,16 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA> <MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
</div> </div>
<MkReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" style="margin-top: 6px;" :note="appearNote" :maxNumber="16" @mockUpdateMyReaction="emitUpdReaction"> <MkReactionsViewer
v-if="appearNote.reactionAcceptance !== 'likeOnly'"
style="margin-top: 6px;"
:reactions="$appearNote.reactions"
:reactionEmojis="$appearNote.reactionEmojis"
:myReaction="$appearNote.myReaction"
:noteId="appearNote.id"
:maxNumber="16"
@mockUpdateMyReaction="emitUpdReaction"
>
<template #more> <template #more>
<MkA :to="`/notes/${appearNote.id}/reactions`" :class="[$style.reactionOmitted]">{{ i18n.ts.more }}</MkA> <MkA :to="`/notes/${appearNote.id}/reactions`" :class="[$style.reactionOmitted]">{{ i18n.ts.more }}</MkA>
</template> </template>
@ -125,11 +142,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<i class="ti ti-ban"></i> <i class="ti ti-ban"></i>
</button> </button>
<button ref="reactButton" :class="$style.footerButton" class="_button" @click="toggleReact()"> <button ref="reactButton" :class="$style.footerButton" class="_button" @click="toggleReact()">
<i v-if="appearNote.reactionAcceptance === 'likeOnly' && appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--MI_THEME-love);"></i> <i v-if="appearNote.reactionAcceptance === 'likeOnly' && $appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--MI_THEME-love);"></i>
<i v-else-if="appearNote.myReaction != null" class="ti ti-minus" style="color: var(--MI_THEME-accent);"></i> <i v-else-if="$appearNote.myReaction != null" class="ti ti-minus" style="color: var(--MI_THEME-accent);"></i>
<i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i> <i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
<i v-else class="ti ti-plus"></i> <i v-else class="ti ti-plus"></i>
<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.reactionCount) }}</p> <p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && $appearNote.reactionCount > 0" :class="$style.footerButtonCount">{{ number($appearNote.reactionCount) }}</p>
</button> </button>
<button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown.prevent="clip()"> <button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown.prevent="clip()">
<i class="ti ti-paperclip"></i> <i class="ti ti-paperclip"></i>
@ -176,7 +193,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, inject, onMounted, ref, useTemplateRef, watch, provide } from 'vue'; import { computed, inject, onMounted, ref, useTemplateRef, watch, provide, shallowRef, reactive } from 'vue';
import * as mfm from 'mfm-js'; import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { isLink } from '@@/js/is-link.js'; import { isLink } from '@@/js/is-link.js';
@ -210,9 +227,9 @@ import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
import { $i } from '@/i.js'; import { $i } from '@/i.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js'; import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js';
import { useNoteCapture } from '@/use/use-note-capture.js'; import { noteEvents, useNoteCapture } from '@/composables/use-note-capture.js';
import { deepClone } from '@/utility/clone.js'; import { deepClone } from '@/utility/clone.js';
import { useTooltip } from '@/use/use-tooltip.js'; import { useTooltip } from '@/composables/use-tooltip.js';
import { claimAchievement } from '@/utility/achievements.js'; import { claimAchievement } from '@/utility/achievements.js';
import { getNoteSummary } from '@/utility/get-note-summary.js'; import { getNoteSummary } from '@/utility/get-note-summary.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue'; import MkRippleEffect from '@/components/MkRippleEffect.vue';
@ -223,6 +240,7 @@ import { getAppearNote } from '@/utility/get-appear-note.js';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
import { getPluginHandlers } from '@/plugin.js'; import { getPluginHandlers } from '@/plugin.js';
import { DI } from '@/di.js'; import { DI } from '@/di.js';
import { globalEvents } from '@/events.js';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
note: Misskey.entities.Note; note: Misskey.entities.Note;
@ -245,29 +263,33 @@ const tl_withSensitive = inject<Ref<boolean>>('tl_withSensitive', ref(true));
const inChannel = inject('inChannel', null); const inChannel = inject('inChannel', null);
const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null); const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null);
const note = ref(deepClone(props.note)); let note = deepClone(props.note);
// plugin // plugin
const noteViewInterruptors = getPluginHandlers('note_view_interruptor'); const noteViewInterruptors = getPluginHandlers('note_view_interruptor');
if (noteViewInterruptors.length > 0) { if (noteViewInterruptors.length > 0) {
onMounted(async () => { onMounted(async () => {
let result: Misskey.entities.Note | null = deepClone(note.value); let result: Misskey.entities.Note | null = deepClone(note);
for (const interruptor of noteViewInterruptors) { for (const interruptor of noteViewInterruptors) {
try { try {
result = await interruptor.handler(result!) as Misskey.entities.Note | null; result = await interruptor.handler(result!) as Misskey.entities.Note | null;
if (result === null) {
isDeleted.value = true;
return;
}
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} }
} }
note.value = result as Misskey.entities.Note; note = result as Misskey.entities.Note;
}); });
} }
const isRenote = Misskey.note.isPureRenote(note.value); const isRenote = Misskey.note.isPureRenote(note);
const appearNote = getAppearNote(note);
const $appearNote = reactive({
reactions: appearNote.reactions,
reactionCount: appearNote.reactionCount,
reactionEmojis: appearNote.reactionEmojis,
myReaction: appearNote.myReaction,
pollChoices: appearNote.poll?.choices,
});
const rootEl = useTemplateRef('rootEl'); const rootEl = useTemplateRef('rootEl');
const menuButton = useTemplateRef('menuButton'); const menuButton = useTemplateRef('menuButton');
@ -275,32 +297,30 @@ const renoteButton = useTemplateRef('renoteButton');
const renoteTime = useTemplateRef('renoteTime'); const renoteTime = useTemplateRef('renoteTime');
const reactButton = useTemplateRef('reactButton'); const reactButton = useTemplateRef('reactButton');
const clipButton = useTemplateRef('clipButton'); const clipButton = useTemplateRef('clipButton');
const appearNote = computed(() => getAppearNote(note.value));
const galleryEl = useTemplateRef('galleryEl'); const galleryEl = useTemplateRef('galleryEl');
const isMyRenote = $i && ($i.id === note.value.userId); const isMyRenote = $i && ($i.id === note.userId);
const showContent = ref(false); const showContent = ref(false);
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null); const parsed = computed(() => appearNote.text ? mfm.parse(appearNote.text) : null);
const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null); const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value).filter((url) => appearNote.renote?.url !== url && appearNote.renote?.uri !== url) : null);
const isLong = shouldCollapsed(appearNote.value, urls.value ?? []); const isLong = shouldCollapsed(appearNote, urls.value ?? []);
const collapsed = ref(appearNote.value.cw == null && isLong); const collapsed = ref(appearNote.cw == null && isLong);
const isDeleted = ref(false); const muted = ref(checkMute(appearNote, $i?.mutedWords));
const muted = ref(checkMute(appearNote.value, $i?.mutedWords)); const hardMuted = ref(props.withHardMute && checkMute(appearNote, $i?.hardMutedWords, true));
const hardMuted = ref(props.withHardMute && checkMute(appearNote.value, $i?.hardMutedWords, true));
const showSoftWordMutedWord = computed(() => prefer.s.showSoftWordMutedWord); const showSoftWordMutedWord = computed(() => prefer.s.showSoftWordMutedWord);
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null); const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
const translating = ref(false); const translating = ref(false);
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance); const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.user.instance);
const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id)); const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || (appearNote.visibility === 'followers' && appearNote.userId === $i?.id));
const renoteCollapsed = ref( const renoteCollapsed = ref(
prefer.s.collapseRenotes && isRenote && ( prefer.s.collapseRenotes && isRenote && (
($i && ($i.id === note.value.userId || $i.id === appearNote.value.userId)) || // `||` must be `||`! See https://github.com/misskey-dev/misskey/issues/13131 ($i && ($i.id === note.userId || $i.id === appearNote.userId)) || // `||` must be `||`! See https://github.com/misskey-dev/misskey/issues/13131
(appearNote.value.myReaction != null) ($appearNote.myReaction != null)
), ),
); );
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
type: 'lookup', type: 'lookup',
url: `https://${host}/notes/${appearNote.value.id}`, url: `https://${host}/notes/${appearNote.id}`,
})); }));
/* Overload FunctionLint /* Overload FunctionLint
@ -357,7 +377,7 @@ const keymap = {
'v|enter': () => { 'v|enter': () => {
if (renoteCollapsed.value) { if (renoteCollapsed.value) {
renoteCollapsed.value = false; renoteCollapsed.value = false;
} else if (appearNote.value.cw != null) { } else if (appearNote.cw != null) {
showContent.value = !showContent.value; showContent.value = !showContent.value;
} else if (isLong) { } else if (isLong) {
collapsed.value = !collapsed.value; collapsed.value = !collapsed.value;
@ -380,28 +400,31 @@ const keymap = {
provide(DI.mfmEmojiReactCallback, (reaction) => { provide(DI.mfmEmojiReactCallback, (reaction) => {
sound.playMisskeySfx('reaction'); sound.playMisskeySfx('reaction');
misskeyApi('notes/reactions/create', { misskeyApi('notes/reactions/create', {
noteId: appearNote.value.id, noteId: appearNote.id,
reaction: reaction, reaction: reaction,
}).then(() => {
noteEvents.emit(`reacted:${appearNote.id}`, {
userId: $i!.id,
reaction: reaction,
});
}); });
}); });
if (props.mock) { let subscribeManuallyToNoteCapture: () => void = () => { };
watch(() => props.note, (to) => {
note.value = deepClone(to); if (!props.mock) {
}, { deep: true }); const { subscribe } = useNoteCapture({
} else {
useNoteCapture({
rootEl: rootEl,
note: appearNote, note: appearNote,
pureNote: note, parentNote: note,
isDeletedRef: isDeleted, $note: $appearNote,
}); });
subscribeManuallyToNoteCapture = subscribe;
} }
if (!props.mock) { if (!props.mock) {
useTooltip(renoteButton, async (showing) => { useTooltip(renoteButton, async (showing) => {
const renotes = await misskeyApi('notes/renotes', { const renotes = await misskeyApi('notes/renotes', {
noteId: appearNote.value.id, noteId: appearNote.id,
limit: 11, limit: 11,
}); });
@ -412,19 +435,19 @@ if (!props.mock) {
const { dispose } = os.popup(MkUsersTooltip, { const { dispose } = os.popup(MkUsersTooltip, {
showing, showing,
users, users,
count: appearNote.value.renoteCount, count: appearNote.renoteCount,
targetElement: renoteButton.value, targetElement: renoteButton.value,
}, { }, {
closed: () => dispose(), closed: () => dispose(),
}); });
}); });
if (appearNote.value.reactionAcceptance === 'likeOnly') { if (appearNote.reactionAcceptance === 'likeOnly') {
useTooltip(reactButton, async (showing) => { useTooltip(reactButton, async (showing) => {
const reactions = await misskeyApiGet('notes/reactions', { const reactions = await misskeyApiGet('notes/reactions', {
noteId: appearNote.value.id, noteId: appearNote.id,
limit: 10, limit: 10,
_cacheKey_: appearNote.value.reactionCount, _cacheKey_: $appearNote.reactionCount,
}); });
const users = reactions.map(x => x.user); const users = reactions.map(x => x.user);
@ -435,7 +458,7 @@ if (!props.mock) {
showing, showing,
reaction: '❤️', reaction: '❤️',
users, users,
count: appearNote.value.reactionCount, count: $appearNote.reactionCount,
targetElement: reactButton.value!, targetElement: reactButton.value!,
}, { }, {
closed: () => dispose(), closed: () => dispose(),
@ -448,10 +471,12 @@ function renote(viaKeyboard = false) {
pleaseLogin({ openOnRemote: pleaseLoginContext.value }); pleaseLogin({ openOnRemote: pleaseLoginContext.value });
showMovedDialog(); showMovedDialog();
const { menu } = getRenoteMenu({ note: note.value, renoteButton, mock: props.mock }); const { menu } = getRenoteMenu({ note: note, renoteButton, mock: props.mock });
os.popupMenu(menu, renoteButton.value, { os.popupMenu(menu, renoteButton.value, {
viaKeyboard, viaKeyboard,
}); });
subscribeManuallyToNoteCapture();
} }
function reply(): void { function reply(): void {
@ -460,8 +485,8 @@ function reply(): void {
return; return;
} }
os.post({ os.post({
reply: appearNote.value, reply: appearNote,
channel: appearNote.value.channel, channel: appearNote.channel,
}).then(() => { }).then(() => {
focus(); focus();
}); });
@ -470,7 +495,7 @@ function reply(): void {
function react(): void { function react(): void {
pleaseLogin({ openOnRemote: pleaseLoginContext.value }); pleaseLogin({ openOnRemote: pleaseLoginContext.value });
showMovedDialog(); showMovedDialog();
if (appearNote.value.reactionAcceptance === 'likeOnly') { if (appearNote.reactionAcceptance === 'likeOnly') {
sound.playMisskeySfx('reaction'); sound.playMisskeySfx('reaction');
if (props.mock) { if (props.mock) {
@ -478,8 +503,13 @@ function react(): void {
} }
misskeyApi('notes/reactions/create', { misskeyApi('notes/reactions/create', {
noteId: appearNote.value.id, noteId: appearNote.id,
reaction: '❤️', reaction: '❤️',
}).then(() => {
noteEvents.emit(`reacted:${appearNote.id}`, {
userId: $i!.id,
reaction: '❤️',
});
}); });
const el = reactButton.value; const el = reactButton.value;
if (el && prefer.s.animation) { if (el && prefer.s.animation) {
@ -492,7 +522,7 @@ function react(): void {
} }
} else { } else {
blur(); blur();
reactionPicker.show(reactButton.value ?? null, note.value, async (reaction) => { reactionPicker.show(reactButton.value ?? null, note, async (reaction) => {
if (prefer.s.confirmOnReact) { if (prefer.s.confirmOnReact) {
const confirm = await os.confirm({ const confirm = await os.confirm({
type: 'question', type: 'question',
@ -506,14 +536,23 @@ function react(): void {
if (props.mock) { if (props.mock) {
emit('reaction', reaction); emit('reaction', reaction);
$appearNote.reactions[reaction] = 1;
$appearNote.reactionCount++;
$appearNote.myReaction = reaction;
return; return;
} }
misskeyApi('notes/reactions/create', { misskeyApi('notes/reactions/create', {
noteId: appearNote.value.id, noteId: appearNote.id,
reaction: reaction, reaction: reaction,
}).then(() => {
noteEvents.emit(`reacted:${appearNote.id}`, {
userId: $i!.id,
reaction: reaction,
});
}); });
if (appearNote.value.text && appearNote.value.text.length > 100 && (Date.now() - new Date(appearNote.value.createdAt).getTime() < 1000 * 3)) {
if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) {
claimAchievement('reactWithoutRead'); claimAchievement('reactWithoutRead');
} }
}, () => { }, () => {
@ -522,8 +561,8 @@ function react(): void {
} }
} }
function undoReact(targetNote: Misskey.entities.Note): void { function undoReact(): void {
const oldReaction = targetNote.myReaction; const oldReaction = $appearNote.myReaction;
if (!oldReaction) return; if (!oldReaction) return;
if (props.mock) { if (props.mock) {
@ -532,15 +571,20 @@ function undoReact(targetNote: Misskey.entities.Note): void {
} }
misskeyApi('notes/reactions/delete', { misskeyApi('notes/reactions/delete', {
noteId: targetNote.id, noteId: appearNote.id,
}).then(() => {
noteEvents.emit(`unreacted:${appearNote.id}`, {
userId: $i!.id,
reaction: oldReaction,
});
}); });
} }
function toggleReact() { function toggleReact() {
if (appearNote.value.myReaction == null) { if ($appearNote.myReaction == null) {
react(); react();
} else { } else {
undoReact(appearNote.value); undoReact();
} }
} }
@ -556,7 +600,7 @@ function onContextmenu(ev: MouseEvent): void {
ev.preventDefault(); ev.preventDefault();
react(); react();
} else { } else {
const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted, currentClip: currentClip?.value }); const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, currentClip: currentClip?.value });
os.contextMenu(menu, ev).then(focus).finally(cleanup); os.contextMenu(menu, ev).then(focus).finally(cleanup);
} }
} }
@ -566,7 +610,7 @@ function showMenu(): void {
return; return;
} }
const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted, currentClip: currentClip?.value }); const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, currentClip: currentClip?.value });
os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup); os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup);
} }
@ -575,7 +619,7 @@ async function clip(): Promise<void> {
return; return;
} }
os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus); os.popupMenu(await getNoteClipMenu({ note: note, currentClip: currentClip?.value }), clipButton.value).then(focus);
} }
function showRenoteMenu(): void { function showRenoteMenu(): void {
@ -590,9 +634,10 @@ function showRenoteMenu(): void {
danger: true, danger: true,
action: () => { action: () => {
misskeyApi('notes/delete', { misskeyApi('notes/delete', {
noteId: note.value.id, noteId: note.id,
}).then(() => {
globalEvents.emit('noteDeleted', note.id);
}); });
isDeleted.value = true;
}, },
}; };
} }
@ -601,23 +646,23 @@ function showRenoteMenu(): void {
type: 'link', type: 'link',
text: i18n.ts.renoteDetails, text: i18n.ts.renoteDetails,
icon: 'ti ti-info-circle', icon: 'ti ti-info-circle',
to: notePage(note.value), to: notePage(note),
}; };
if (isMyRenote) { if (isMyRenote) {
pleaseLogin({ openOnRemote: pleaseLoginContext.value }); pleaseLogin({ openOnRemote: pleaseLoginContext.value });
os.popupMenu([ os.popupMenu([
renoteDetailsMenu, renoteDetailsMenu,
getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote), getCopyNoteLinkMenu(note, i18n.ts.copyLinkRenote),
{ type: 'divider' }, { type: 'divider' },
getUnrenote(), getUnrenote(),
], renoteTime.value); ], renoteTime.value);
} else { } else {
os.popupMenu([ os.popupMenu([
renoteDetailsMenu, renoteDetailsMenu,
getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote), getCopyNoteLinkMenu(note, i18n.ts.copyLinkRenote),
{ type: 'divider' }, { type: 'divider' },
getAbuseNoteMenu(note.value, i18n.ts.reportAbuseRenote), getAbuseNoteMenu(note, i18n.ts.reportAbuseRenote),
($i?.isModerator || $i?.isAdmin) ? getUnrenote() : undefined, ($i?.isModerator || $i?.isAdmin) ? getUnrenote() : undefined,
], renoteTime.value); ], renoteTime.value);
} }
@ -641,9 +686,8 @@ function focusAfter() {
function readPromo() { function readPromo() {
misskeyApi('promo/read', { misskeyApi('promo/read', {
noteId: appearNote.value.id, noteId: appearNote.id,
}); });
isDeleted.value = true;
} }
function emitUpdReaction(emoji: string, delta: number) { function emitUpdReaction(emoji: string, delta: number) {

View File

@ -5,12 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<div <div
v-if="!muted" v-if="!muted && !isDeleted"
v-show="!isDeleted"
ref="rootEl" ref="rootEl"
v-hotkey="keymap" v-hotkey="keymap"
:class="$style.root" :class="$style.root"
:tabindex="isDeleted ? '-1' : '0'" tabindex="0"
> >
<div v-if="appearNote.reply && appearNote.reply.replyId"> <div v-if="appearNote.reply && appearNote.reply.replyId">
<div v-if="!conversationLoaded" style="padding: 16px"> <div v-if="!conversationLoaded" style="padding: 16px">
@ -110,7 +109,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="appearNote.files && appearNote.files.length > 0"> <div v-if="appearNote.files && appearNote.files.length > 0">
<MkMediaList ref="galleryEl" :mediaList="appearNote.files"/> <MkMediaList ref="galleryEl" :mediaList="appearNote.files"/>
</div> </div>
<MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll" :author="appearNote.user" :emojiUrls="appearNote.emojis"/> <MkPoll
v-if="appearNote.poll"
:noteId="appearNote.id"
:multiple="appearNote.poll.multiple"
:expiresAt="appearNote.poll.expiresAt"
:choices="$appearNote.pollChoices"
:author="appearNote.user"
:emojiUrls="appearNote.emojis"
:class="$style.poll"
/>
<div v-if="isEnabledUrlPreview"> <div v-if="isEnabledUrlPreview">
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/> <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/>
</div> </div>
@ -124,7 +132,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkTime :time="appearNote.createdAt" mode="detail" colored/> <MkTime :time="appearNote.createdAt" mode="detail" colored/>
</MkA> </MkA>
</div> </div>
<MkReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" ref="reactionsViewer" style="margin-top: 6px;" :note="appearNote"/> <MkReactionsViewer
v-if="appearNote.reactionAcceptance !== 'likeOnly'"
style="margin-top: 6px;"
:reactions="$appearNote.reactions"
:reactionEmojis="$appearNote.reactionEmojis"
:myReaction="$appearNote.myReaction"
:noteId="appearNote.id"
:maxNumber="16"
@mockUpdateMyReaction="emitUpdReaction"
/>
<button class="_button" :class="$style.noteFooterButton" @click="reply()"> <button class="_button" :class="$style.noteFooterButton" @click="reply()">
<i class="ti ti-arrow-back-up"></i> <i class="ti ti-arrow-back-up"></i>
<p v-if="appearNote.repliesCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.repliesCount) }}</p> <p v-if="appearNote.repliesCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.repliesCount) }}</p>
@ -143,11 +160,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<i class="ti ti-ban"></i> <i class="ti ti-ban"></i>
</button> </button>
<button ref="reactButton" :class="$style.noteFooterButton" class="_button" @click="toggleReact()"> <button ref="reactButton" :class="$style.noteFooterButton" class="_button" @click="toggleReact()">
<i v-if="appearNote.reactionAcceptance === 'likeOnly' && appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--MI_THEME-love);"></i> <i v-if="appearNote.reactionAcceptance === 'likeOnly' && $appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--MI_THEME-love);"></i>
<i v-else-if="appearNote.myReaction != null" class="ti ti-minus" style="color: var(--MI_THEME-accent);"></i> <i v-else-if="$appearNote.myReaction != null" class="ti ti-minus" style="color: var(--MI_THEME-accent);"></i>
<i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i> <i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
<i v-else class="ti ti-plus"></i> <i v-else class="ti ti-plus"></i>
<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.reactionCount) }}</p> <p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && $appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number($appearNote.reactionCount) }}</p>
</button> </button>
<button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="clip()"> <button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="clip()">
<i class="ti ti-paperclip"></i> <i class="ti ti-paperclip"></i>
@ -182,9 +199,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
<div v-else-if="tab === 'reactions'" :class="$style.tab_reactions"> <div v-else-if="tab === 'reactions'" :class="$style.tab_reactions">
<div :class="$style.reactionTabs"> <div :class="$style.reactionTabs">
<button v-for="reaction in Object.keys(appearNote.reactions)" :key="reaction" :class="[$style.reactionTab, { [$style.reactionTabActive]: reactionTabType === reaction }]" class="_button" @click="reactionTabType = reaction"> <button v-for="reaction in Object.keys($appearNote.reactions)" :key="reaction" :class="[$style.reactionTab, { [$style.reactionTabActive]: reactionTabType === reaction }]" class="_button" @click="reactionTabType = reaction">
<MkReactionIcon :reaction="reaction"/> <MkReactionIcon :reaction="reaction"/>
<span style="margin-left: 4px;">{{ appearNote.reactions[reaction] }}</span> <span style="margin-left: 4px;">{{ $appearNote.reactions[reaction] }}</span>
</button> </button>
</div> </div>
<MkPagination v-if="reactionTabType" :key="reactionTabType" :pagination="reactionsPagination" :disableAutoLoad="true"> <MkPagination v-if="reactionTabType" :key="reactionTabType" :pagination="reactionsPagination" :disableAutoLoad="true">
@ -199,7 +216,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</div> </div>
</div> </div>
<div v-else class="_panel" :class="$style.muted" @click="muted = false"> <div v-else-if="muted" class="_panel" :class="$style.muted" @click="muted = false">
<I18n :src="i18n.ts.userSaysSomething" tag="small"> <I18n :src="i18n.ts.userSaysSomething" tag="small">
<template #name> <template #name>
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)"> <MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
@ -211,13 +228,12 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, inject, onMounted, provide, ref, useTemplateRef } from 'vue'; import { computed, inject, onMounted, provide, reactive, ref, useTemplateRef } from 'vue';
import * as mfm from 'mfm-js'; import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { isLink } from '@@/js/is-link.js'; import { isLink } from '@@/js/is-link.js';
import { host } from '@@/js/config.js'; import { host } from '@@/js/config.js';
import type { OpenOnRemoteOptions } from '@/utility/please-login.js'; import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
import type { Paging } from '@/components/MkPagination.vue';
import type { Keymap } from '@/utility/hotkey.js'; import type { Keymap } from '@/utility/hotkey.js';
import MkNoteSub from '@/components/MkNoteSub.vue'; import MkNoteSub from '@/components/MkNoteSub.vue';
import MkNoteSimple from '@/components/MkNoteSimple.vue'; import MkNoteSimple from '@/components/MkNoteSimple.vue';
@ -242,9 +258,9 @@ import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
import { $i } from '@/i.js'; import { $i } from '@/i.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js'; import { getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js';
import { useNoteCapture } from '@/use/use-note-capture.js'; import { noteEvents, useNoteCapture } from '@/composables/use-note-capture.js';
import { deepClone } from '@/utility/clone.js'; import { deepClone } from '@/utility/clone.js';
import { useTooltip } from '@/use/use-tooltip.js'; import { useTooltip } from '@/composables/use-tooltip.js';
import { claimAchievement } from '@/utility/achievements.js'; import { claimAchievement } from '@/utility/achievements.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue'; import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { showMovedDialog } from '@/utility/show-moved-dialog.js'; import { showMovedDialog } from '@/utility/show-moved-dialog.js';
@ -257,6 +273,7 @@ import { getAppearNote } from '@/utility/get-appear-note.js';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
import { getPluginHandlers } from '@/plugin.js'; import { getPluginHandlers } from '@/plugin.js';
import { DI } from '@/di.js'; import { DI } from '@/di.js';
import { globalEvents, useGlobalEvent } from '@/events.js';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
note: Misskey.entities.Note; note: Misskey.entities.Note;
@ -267,29 +284,33 @@ const props = withDefaults(defineProps<{
const inChannel = inject('inChannel', null); const inChannel = inject('inChannel', null);
const note = ref(deepClone(props.note)); let note = deepClone(props.note);
// plugin // plugin
const noteViewInterruptors = getPluginHandlers('note_view_interruptor'); const noteViewInterruptors = getPluginHandlers('note_view_interruptor');
if (noteViewInterruptors.length > 0) { if (noteViewInterruptors.length > 0) {
onMounted(async () => { onMounted(async () => {
let result: Misskey.entities.Note | null = deepClone(note.value); let result: Misskey.entities.Note | null = deepClone(note);
for (const interruptor of noteViewInterruptors) { for (const interruptor of noteViewInterruptors) {
try { try {
result = await interruptor.handler(result!) as Misskey.entities.Note | null; result = await interruptor.handler(result!) as Misskey.entities.Note | null;
if (result === null) {
isDeleted.value = true;
return;
}
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} }
} }
note.value = result as Misskey.entities.Note; note = result as Misskey.entities.Note;
}); });
} }
const isRenote = Misskey.note.isPureRenote(note.value); const isRenote = Misskey.note.isPureRenote(note);
const appearNote = getAppearNote(note);
const $appearNote = reactive({
reactions: appearNote.reactions,
reactionCount: appearNote.reactionCount,
reactionEmojis: appearNote.reactionEmojis,
myReaction: appearNote.myReaction,
pollChoices: appearNote.poll?.choices,
});
const rootEl = useTemplateRef('rootEl'); const rootEl = useTemplateRef('rootEl');
const menuButton = useTemplateRef('menuButton'); const menuButton = useTemplateRef('menuButton');
@ -297,24 +318,29 @@ const renoteButton = useTemplateRef('renoteButton');
const renoteTime = useTemplateRef('renoteTime'); const renoteTime = useTemplateRef('renoteTime');
const reactButton = useTemplateRef('reactButton'); const reactButton = useTemplateRef('reactButton');
const clipButton = useTemplateRef('clipButton'); const clipButton = useTemplateRef('clipButton');
const appearNote = computed(() => getAppearNote(note.value));
const galleryEl = useTemplateRef('galleryEl'); const galleryEl = useTemplateRef('galleryEl');
const isMyRenote = $i && ($i.id === note.value.userId); const isMyRenote = $i && ($i.id === note.userId);
const showContent = ref(false); const showContent = ref(false);
const isDeleted = ref(false); const isDeleted = ref(false);
const muted = ref($i ? checkWordMute(appearNote.value, $i, $i.mutedWords) : false); const muted = ref($i ? checkWordMute(appearNote, $i, $i.mutedWords) : false);
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null); const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
const translating = ref(false); const translating = ref(false);
const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null; const parsed = appearNote.text ? mfm.parse(appearNote.text) : null;
const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null; const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.renote?.url !== url && appearNote.renote?.uri !== url) : null;
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance); const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.user.instance);
const conversation = ref<Misskey.entities.Note[]>([]); const conversation = ref<Misskey.entities.Note[]>([]);
const replies = ref<Misskey.entities.Note[]>([]); const replies = ref<Misskey.entities.Note[]>([]);
const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || appearNote.value.userId === $i?.id); const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || appearNote.userId === $i?.id);
useGlobalEvent('noteDeleted', (noteId) => {
if (noteId === note.id || noteId === appearNote.id) {
isDeleted.value = true;
}
});
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
type: 'lookup', type: 'lookup',
url: `https://${host}/notes/${appearNote.value.id}`, url: `https://${host}/notes/${appearNote.id}`,
})); }));
const keymap = { const keymap = {
@ -328,7 +354,7 @@ const keymap = {
}, },
'o': () => galleryEl.value?.openGallery(), 'o': () => galleryEl.value?.openGallery(),
'v|enter': () => { 'v|enter': () => {
if (appearNote.value.cw != null) { if (appearNote.cw != null) {
showContent.value = !showContent.value; showContent.value = !showContent.value;
} }
}, },
@ -341,41 +367,45 @@ const keymap = {
provide(DI.mfmEmojiReactCallback, (reaction) => { provide(DI.mfmEmojiReactCallback, (reaction) => {
sound.playMisskeySfx('reaction'); sound.playMisskeySfx('reaction');
misskeyApi('notes/reactions/create', { misskeyApi('notes/reactions/create', {
noteId: appearNote.value.id, noteId: appearNote.id,
reaction: reaction, reaction: reaction,
}).then(() => {
noteEvents.emit(`reacted:${appearNote.id}`, {
userId: $i!.id,
reaction: reaction,
});
}); });
}); });
const tab = ref(props.initialTab); const tab = ref(props.initialTab);
const reactionTabType = ref<string | null>(null); const reactionTabType = ref<string | null>(null);
const renotesPagination = computed<Paging>(() => ({ const renotesPagination = computed(() => ({
endpoint: 'notes/renotes', endpoint: 'notes/renotes',
limit: 10, limit: 10,
params: { params: {
noteId: appearNote.value.id, noteId: appearNote.id,
}, },
})); }));
const reactionsPagination = computed<Paging>(() => ({ const reactionsPagination = computed(() => ({
endpoint: 'notes/reactions', endpoint: 'notes/reactions',
limit: 10, limit: 10,
params: { params: {
noteId: appearNote.value.id, noteId: appearNote.id,
type: reactionTabType.value, type: reactionTabType.value,
}, },
})); }));
useNoteCapture({ const { subscribe: subscribeManuallyToNoteCapture } = useNoteCapture({
rootEl: rootEl,
note: appearNote, note: appearNote,
pureNote: note, parentNote: note,
isDeletedRef: isDeleted, $note: $appearNote,
}); });
useTooltip(renoteButton, async (showing) => { useTooltip(renoteButton, async (showing) => {
const renotes = await misskeyApi('notes/renotes', { const renotes = await misskeyApi('notes/renotes', {
noteId: appearNote.value.id, noteId: appearNote.id,
limit: 11, limit: 11,
}); });
@ -386,19 +416,19 @@ useTooltip(renoteButton, async (showing) => {
const { dispose } = os.popup(MkUsersTooltip, { const { dispose } = os.popup(MkUsersTooltip, {
showing, showing,
users, users,
count: appearNote.value.renoteCount, count: appearNote.renoteCount,
targetElement: renoteButton.value, targetElement: renoteButton.value,
}, { }, {
closed: () => dispose(), closed: () => dispose(),
}); });
}); });
if (appearNote.value.reactionAcceptance === 'likeOnly') { if (appearNote.reactionAcceptance === 'likeOnly') {
useTooltip(reactButton, async (showing) => { useTooltip(reactButton, async (showing) => {
const reactions = await misskeyApiGet('notes/reactions', { const reactions = await misskeyApiGet('notes/reactions', {
noteId: appearNote.value.id, noteId: appearNote.id,
limit: 10, limit: 10,
_cacheKey_: appearNote.value.reactionCount, _cacheKey_: $appearNote.reactionCount,
}); });
const users = reactions.map(x => x.user); const users = reactions.map(x => x.user);
@ -409,7 +439,7 @@ if (appearNote.value.reactionAcceptance === 'likeOnly') {
showing, showing,
reaction: '❤️', reaction: '❤️',
users, users,
count: appearNote.value.reactionCount, count: $appearNote.reactionCount,
targetElement: reactButton.value!, targetElement: reactButton.value!,
}, { }, {
closed: () => dispose(), closed: () => dispose(),
@ -421,16 +451,19 @@ function renote() {
pleaseLogin({ openOnRemote: pleaseLoginContext.value }); pleaseLogin({ openOnRemote: pleaseLoginContext.value });
showMovedDialog(); showMovedDialog();
const { menu } = getRenoteMenu({ note: note.value, renoteButton }); const { menu } = getRenoteMenu({ note: note, renoteButton });
os.popupMenu(menu, renoteButton.value); os.popupMenu(menu, renoteButton.value);
//
subscribeManuallyToNoteCapture();
} }
function reply(): void { function reply(): void {
pleaseLogin({ openOnRemote: pleaseLoginContext.value }); pleaseLogin({ openOnRemote: pleaseLoginContext.value });
showMovedDialog(); showMovedDialog();
os.post({ os.post({
reply: appearNote.value, reply: appearNote,
channel: appearNote.value.channel, channel: appearNote.channel,
}).then(() => { }).then(() => {
focus(); focus();
}); });
@ -439,12 +472,17 @@ function reply(): void {
function react(): void { function react(): void {
pleaseLogin({ openOnRemote: pleaseLoginContext.value }); pleaseLogin({ openOnRemote: pleaseLoginContext.value });
showMovedDialog(); showMovedDialog();
if (appearNote.value.reactionAcceptance === 'likeOnly') { if (appearNote.reactionAcceptance === 'likeOnly') {
sound.playMisskeySfx('reaction'); sound.playMisskeySfx('reaction');
misskeyApi('notes/reactions/create', { misskeyApi('notes/reactions/create', {
noteId: appearNote.value.id, noteId: appearNote.id,
reaction: '❤️', reaction: '❤️',
}).then(() => {
noteEvents.emit(`reacted:${appearNote.id}`, {
userId: $i!.id,
reaction: '❤️',
});
}); });
const el = reactButton.value; const el = reactButton.value;
if (el && prefer.s.animation) { if (el && prefer.s.animation) {
@ -457,7 +495,7 @@ function react(): void {
} }
} else { } else {
blur(); blur();
reactionPicker.show(reactButton.value ?? null, note.value, async (reaction) => { reactionPicker.show(reactButton.value ?? null, note, async (reaction) => {
if (prefer.s.confirmOnReact) { if (prefer.s.confirmOnReact) {
const confirm = await os.confirm({ const confirm = await os.confirm({
type: 'question', type: 'question',
@ -470,10 +508,15 @@ function react(): void {
sound.playMisskeySfx('reaction'); sound.playMisskeySfx('reaction');
misskeyApi('notes/reactions/create', { misskeyApi('notes/reactions/create', {
noteId: appearNote.value.id, noteId: appearNote.id,
reaction: reaction, reaction: reaction,
}).then(() => {
noteEvents.emit(`reacted:${appearNote.id}`, {
userId: $i!.id,
reaction: reaction,
});
}); });
if (appearNote.value.text && appearNote.value.text.length > 100 && (Date.now() - new Date(appearNote.value.createdAt).getTime() < 1000 * 3)) { if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) {
claimAchievement('reactWithoutRead'); claimAchievement('reactWithoutRead');
} }
}, () => { }, () => {
@ -487,14 +530,19 @@ function undoReact(targetNote: Misskey.entities.Note): void {
if (!oldReaction) return; if (!oldReaction) return;
misskeyApi('notes/reactions/delete', { misskeyApi('notes/reactions/delete', {
noteId: targetNote.id, noteId: targetNote.id,
}).then(() => {
noteEvents.emit(`unreacted:${appearNote.id}`, {
userId: $i!.id,
reaction: oldReaction,
});
}); });
} }
function toggleReact() { function toggleReact() {
if (appearNote.value.myReaction == null) { if (appearNote.myReaction == null) {
react(); react();
} else { } else {
undoReact(appearNote.value); undoReact(appearNote);
} }
} }
@ -506,18 +554,18 @@ function onContextmenu(ev: MouseEvent): void {
ev.preventDefault(); ev.preventDefault();
react(); react();
} else { } else {
const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted }); const { menu, cleanup } = getNoteMenu({ note: note, translating, translation });
os.contextMenu(menu, ev).then(focus).finally(cleanup); os.contextMenu(menu, ev).then(focus).finally(cleanup);
} }
} }
function showMenu(): void { function showMenu(): void {
const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted }); const { menu, cleanup } = getNoteMenu({ note: note, translating, translation });
os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup); os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup);
} }
async function clip(): Promise<void> { async function clip(): Promise<void> {
os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted }), clipButton.value).then(focus); os.popupMenu(await getNoteClipMenu({ note: note }), clipButton.value).then(focus);
} }
function showRenoteMenu(): void { function showRenoteMenu(): void {
@ -529,9 +577,10 @@ function showRenoteMenu(): void {
danger: true, danger: true,
action: () => { action: () => {
misskeyApi('notes/delete', { misskeyApi('notes/delete', {
noteId: note.value.id, noteId: note.id,
}).then(() => {
globalEvents.emit('noteDeleted', note.id);
}); });
isDeleted.value = true;
}, },
}], renoteTime.value); }], renoteTime.value);
} }
@ -549,7 +598,7 @@ const repliesLoaded = ref(false);
function loadReplies() { function loadReplies() {
repliesLoaded.value = true; repliesLoaded.value = true;
misskeyApi('notes/children', { misskeyApi('notes/children', {
noteId: appearNote.value.id, noteId: appearNote.id,
limit: 30, limit: 30,
}).then(res => { }).then(res => {
replies.value = res; replies.value = res;
@ -560,9 +609,9 @@ const conversationLoaded = ref(false);
function loadConversation() { function loadConversation() {
conversationLoaded.value = true; conversationLoaded.value = true;
if (appearNote.value.replyId == null) return; if (appearNote.replyId == null) return;
misskeyApi('notes/conversation', { misskeyApi('notes/conversation', {
noteId: appearNote.value.replyId, noteId: appearNote.replyId,
}).then(res => { }).then(res => {
conversation.value = res.reverse(); conversation.value = res.reverse();
}); });

View File

@ -4,18 +4,21 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <template>
<MkPagination ref="pagingComponent" :pagination="pagination" :disableAutoLoad="disableAutoLoad"> <MkPagination ref="pagingComponent" :pagination="pagination" :disableAutoLoad="disableAutoLoad" :pullToRefresh="pullToRefresh">
<template #empty> <template #empty><MkResult type="empty" :text="i18n.ts.noNotes"/></template>
<div class="_fullinfo">
<img :src="infoImageUrl" draggable="false"/>
<div>{{ i18n.ts.noNotes }}</div>
</div>
</template>
<template #default="{ items: notes }"> <template #default="{ items: notes }">
<div :class="[$style.root, { [$style.noGap]: noGap, '_gaps': !noGap, [$style.reverse]: pagination.reversed }]"> <div :class="[$style.root, { [$style.noGap]: noGap, '_gaps': !noGap }]">
<template v-for="(note, i) in notes" :key="note.id"> <template v-for="(note, i) in notes" :key="note.id">
<div v-if="note._shouldInsertAd_" :class="[$style.noteWithAd, { '_gaps': !noGap }]" :data-scroll-anchor="note.id"> <div v-if="i > 0 && isSeparatorNeeded(pagingComponent.paginator.items.value[i -1].createdAt, note.createdAt)" :data-scroll-anchor="note.id">
<div :class="$style.date">
<span><i class="ti ti-chevron-up"></i> {{ getSeparatorInfo(pagingComponent.paginator.items.value[i -1].createdAt, note.createdAt).prevText }}</span>
<span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span>
<span>{{ getSeparatorInfo(pagingComponent.paginator.items.value[i -1].createdAt, note.createdAt).nextText }} <i class="ti ti-chevron-down"></i></span>
</div>
<MkNote :class="$style.note" :note="note" :withHardMute="true"/>
</div>
<div v-else-if="note._shouldInsertAd_" :class="[$style.noteWithAd, { '_gaps': !noGap }]" :data-scroll-anchor="note.id">
<MkNote :class="$style.note" :note="note" :withHardMute="true"/> <MkNote :class="$style.note" :note="note" :withHardMute="true"/>
<div :class="$style.ad"> <div :class="$style.ad">
<MkAd :preferForms="['horizontal', 'horizontal-big']"/> <MkAd :preferForms="['horizontal', 'horizontal-big']"/>
@ -30,31 +33,38 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { useTemplateRef } from 'vue'; import { useTemplateRef } from 'vue';
import type { Paging } from '@/components/MkPagination.vue'; import type { PagingCtx } from '@/composables/use-pagination.js';
import MkNote from '@/components/MkNote.vue'; import MkNote from '@/components/MkNote.vue';
import MkPagination from '@/components/MkPagination.vue'; import MkPagination from '@/components/MkPagination.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { infoImageUrl } from '@/instance.js'; import { globalEvents, useGlobalEvent } from '@/events.js';
import { isSeparatorNeeded, getSeparatorInfo } from '@/utility/timeline-date-separate.js';
const props = defineProps<{ const props = withDefaults(defineProps<{
pagination: Paging; pagination: PagingCtx;
noGap?: boolean; noGap?: boolean;
disableAutoLoad?: boolean; disableAutoLoad?: boolean;
}>(); pullToRefresh?: boolean;
}>(), {
pullToRefresh: true,
});
const pagingComponent = useTemplateRef('pagingComponent'); const pagingComponent = useTemplateRef('pagingComponent');
useGlobalEvent('noteDeleted', (noteId) => {
pagingComponent.value?.paginator.removeItem(noteId);
});
function reload() {
return pagingComponent.value?.paginator.reload();
}
defineExpose({ defineExpose({
pagingComponent, reload,
}); });
</script> </script>
<style lang="scss" module> <style lang="scss" module>
.reverse {
display: flex;
flex-direction: column-reverse;
}
.root { .root {
container-type: inline-size; container-type: inline-size;
@ -83,6 +93,18 @@ defineExpose({
} }
} }
.date {
display: flex;
font-size: 85%;
align-items: center;
justify-content: center;
gap: 1em;
opacity: 0.75;
padding: 8px 8px;
margin: 0 auto;
border-bottom: solid 0.5px var(--MI_THEME-divider);
}
.ad:empty { .ad:empty {
display: none; display: none;
} }

View File

@ -11,7 +11,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'" :class="[$style.icon, $style.icon_reactionGroupHeart]"><i class="ti ti-heart" style="line-height: 1;"></i></div> <div v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'" :class="[$style.icon, $style.icon_reactionGroupHeart]"><i class="ti ti-heart" style="line-height: 1;"></i></div>
<div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ti ti-plus" style="line-height: 1;"></i></div> <div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ti ti-plus" style="line-height: 1;"></i></div>
<div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ti ti-repeat" style="line-height: 1;"></i></div> <div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ti ti-repeat" style="line-height: 1;"></i></div>
<img v-else-if="notification.type === 'test'" :class="$style.icon" :src="infoImageUrl"/>
<MkAvatar v-else-if="'user' in notification" :class="$style.icon" :user="notification.user" link preview/> <MkAvatar v-else-if="'user' in notification" :class="$style.icon" :user="notification.user" link preview/>
<img v-else-if="'icon' in notification && notification.icon != null" :class="[$style.icon, $style.icon_app]" :src="notification.icon" alt=""/> <img v-else-if="'icon' in notification && notification.icon != null" :class="[$style.icon, $style.icon_app]" :src="notification.icon" alt=""/>
<div <div
@ -176,7 +175,6 @@ import { userPage } from '@/filters/user.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { misskeyApi } from '@/utility/misskey-api.js'; import { misskeyApi } from '@/utility/misskey-api.js';
import { ensureSignin } from '@/i.js'; import { ensureSignin } from '@/i.js';
import { infoImageUrl } from '@/instance.js';
const $i = ensureSignin(); const $i = ensureSignin();

View File

@ -1,148 +0,0 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<component :is="prefer.s.enablePullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => reload()">
<MkPagination ref="pagingComponent" :pagination="pagination">
<template #empty>
<div class="_fullinfo">
<img :src="infoImageUrl" draggable="false"/>
<div>{{ i18n.ts.noNotifications }}</div>
</div>
</template>
<template #default="{ items: notifications }">
<component
:is="prefer.s.animation ? TransitionGroup : 'div'" :class="[$style.notifications]"
:enterActiveClass="$style.transition_x_enterActive"
:leaveActiveClass="$style.transition_x_leaveActive"
:enterFromClass="$style.transition_x_enterFrom"
:leaveToClass="$style.transition_x_leaveTo"
:moveClass=" $style.transition_x_move"
tag="div"
>
<template v-for="(notification, i) in notifications" :key="notification.id">
<MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :class="$style.item" :note="notification.note" :withHardMute="true" :data-scroll-anchor="notification.id"/>
<XNotification v-else :class="$style.item" :notification="notification" :withTime="true" :full="true" :data-scroll-anchor="notification.id"/>
</template>
</component>
</template>
</MkPagination>
</component>
</template>
<script lang="ts" setup>
import { onUnmounted, onMounted, computed, useTemplateRef, TransitionGroup } from 'vue';
import * as Misskey from 'misskey-js';
import type { notificationTypes } from '@@/js/const.js';
import MkPagination from '@/components/MkPagination.vue';
import XNotification from '@/components/MkNotification.vue';
import MkNote from '@/components/MkNote.vue';
import { useStream } from '@/stream.js';
import { i18n } from '@/i18n.js';
import { infoImageUrl } from '@/instance.js';
import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
import { prefer } from '@/preferences.js';
const props = defineProps<{
excludeTypes?: typeof notificationTypes[number][];
}>();
const pagingComponent = useTemplateRef('pagingComponent');
const pagination = computed(() => prefer.r.useGroupedNotifications.value ? {
endpoint: 'i/notifications-grouped' as const,
limit: 20,
params: computed(() => ({
excludeTypes: props.excludeTypes ?? undefined,
})),
} : {
endpoint: 'i/notifications' as const,
limit: 20,
params: computed(() => ({
excludeTypes: props.excludeTypes ?? undefined,
})),
});
function onNotification(notification) {
const isMuted = props.excludeTypes ? props.excludeTypes.includes(notification.type) : false;
if (isMuted || window.document.visibilityState === 'visible') {
useStream().send('readNotification');
}
if (!isMuted) {
pagingComponent.value?.prepend(notification);
}
}
function reload() {
return new Promise<void>((res) => {
pagingComponent.value?.reload().then(() => {
res();
});
});
}
let connection: Misskey.ChannelConnection<Misskey.Channels['main']>;
onMounted(() => {
connection = useStream().useChannel('main');
connection.on('notification', onNotification);
connection.on('notificationFlushed', reload);
});
onUnmounted(() => {
if (connection) connection.dispose();
});
defineExpose({
reload,
});
</script>
<style lang="scss" module>
.transition_x_move {
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
}
.transition_x_enterActive {
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
&.item,
.item {
/* Skip Note Rendering有効時、TransitionGroupで通知を追加するときに一瞬がくっとなる問題を抑制する */
content-visibility: visible !important;
}
}
.transition_x_leaveActive {
transition: height 0.2s cubic-bezier(0,.5,.5,1), opacity 0.2s cubic-bezier(0,.5,.5,1);
}
.transition_x_enterFrom {
opacity: 0;
transform: translateY(max(-64px, -100%));
}
@supports (interpolate-size: allow-keywords) {
.transition_x_enterFrom {
interpolate-size: allow-keywords; // heighttransition
height: 0;
}
}
.transition_x_leaveTo {
opacity: 0;
}
.notifications {
container-type: inline-size;
background: var(--MI_THEME-panel);
}
.item {
border-bottom: solid 0.5px var(--MI_THEME-divider);
}
</style>

View File

@ -4,489 +4,74 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <template>
<Transition <component :is="prefer.s.enablePullToRefresh && pullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => paginator.reload()">
:enterActiveClass="prefer.s.animation ? $style.transition_fade_enterActive : ''" <Transition
:leaveActiveClass="prefer.s.animation ? $style.transition_fade_leaveActive : ''" :enterActiveClass="prefer.s.animation ? $style.transition_fade_enterActive : ''"
:enterFromClass="prefer.s.animation ? $style.transition_fade_enterFrom : ''" :leaveActiveClass="prefer.s.animation ? $style.transition_fade_leaveActive : ''"
:leaveToClass="prefer.s.animation ? $style.transition_fade_leaveTo : ''" :enterFromClass="prefer.s.animation ? $style.transition_fade_enterFrom : ''"
mode="out-in" :leaveToClass="prefer.s.animation ? $style.transition_fade_leaveTo : ''"
> :css="prefer.s.animation"
<MkLoading v-if="fetching"/> mode="out-in"
>
<MkLoading v-if="paginator.fetching.value"/>
<MkError v-else-if="error" @retry="init()"/> <MkError v-else-if="paginator.error.value" @retry="paginator.init()"/>
<div v-else-if="empty" key="_empty_"> <div v-else-if="paginator.items.value.length === 0" key="_empty_">
<slot name="empty"> <slot name="empty"><MkResult type="empty"/></slot>
<div class="_fullinfo"> </div>
<img :src="infoImageUrl" draggable="false"/>
<div>{{ i18n.ts.nothing }}</div> <div v-else ref="rootEl" class="_gaps">
<div v-show="pagination.reversed && paginator.canFetchOlder.value" key="_more_">
<MkButton v-if="!paginator.fetchingOlder.value" v-appear="(prefer.s.enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMoreAhead : null" :class="$style.more" :wait="paginator.fetchingOlder.value" primary rounded @click="paginator.fetchNewer">
{{ i18n.ts.loadMore }}
</MkButton>
<MkLoading v-else/>
</div>
<slot :items="paginator.items.value" :fetching="paginator.fetching.value || paginator.fetchingOlder.value"></slot>
<div v-show="!pagination.reversed && paginator.canFetchOlder.value" key="_more_">
<MkButton v-if="!paginator.fetchingOlder.value" v-appear="(prefer.s.enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMore : null" :class="$style.more" :wait="paginator.fetchingOlder.value" primary rounded @click="paginator.fetchOlder">
{{ i18n.ts.loadMore }}
</MkButton>
<MkLoading v-else/>
</div> </div>
</slot>
</div>
<div v-else ref="rootEl" class="_gaps">
<div v-show="pagination.reversed && more" key="_more_">
<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMoreAhead : null" :class="$style.more" :wait="moreFetching" primary rounded @click="fetchMoreAhead">
{{ i18n.ts.loadMore }}
</MkButton>
<MkLoading v-else/>
</div> </div>
<slot :items="Array.from(items.values())" :fetching="fetching || moreFetching"></slot> </Transition>
<div v-show="!pagination.reversed && more" key="_more_"> </component>
<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMore : null" :class="$style.more" :wait="moreFetching" primary rounded @click="fetchMore">
{{ i18n.ts.loadMore }}
</MkButton>
<MkLoading v-else/>
</div>
</div>
</Transition>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { computed, isRef, nextTick, onActivated, onBeforeMount, onBeforeUnmount, onDeactivated, ref, useTemplateRef, watch } from 'vue'; import type { PagingCtx } from '@/composables/use-pagination.js';
import * as Misskey from 'misskey-js'; import MkButton from '@/components/MkButton.vue';
import { useDocumentVisibility } from '@@/js/use-document-visibility.js';
import { onScrollTop, isHeadVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scrollInContainer, isTailVisible } from '@@/js/scroll.js';
import type { ComputedRef } from 'vue';
import type { MisskeyEntity } from '@/types/date-separated-list.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
import { usePagination } from '@/composables/use-pagination.js';
const SECOND_FETCH_LIMIT = 30; import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
const TOLERANCE = 16;
const APPEAR_MINIMUM_INTERVAL = 600;
export type Paging<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints> = {
endpoint: E;
limit: number;
params?: Misskey.Endpoints[E]['req'] | ComputedRef<Misskey.Endpoints[E]['req']>;
/**
* 検索APIのようなページング不可なエンドポイントを利用する場合
* (そのようなAPIをこの関数で使うのは若干矛盾してるけど)
*/
noPaging?: boolean;
/**
* items 配列の中身を逆順にする(新しい方が最後)
*/
reversed?: boolean;
offsetMode?: boolean;
};
type MisskeyEntityMap = Map<string, MisskeyEntity>;
function arrayToEntries(entities: MisskeyEntity[]): [string, MisskeyEntity][] {
return entities.map(en => [en.id, en]);
}
function concatMapWithArray(map: MisskeyEntityMap, entities: MisskeyEntity[]): MisskeyEntityMap {
return new Map([...map, ...arrayToEntries(entities)]);
}
</script>
<script lang="ts" setup>
import { infoImageUrl } from '@/instance.js';
import MkButton from '@/components/MkButton.vue';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
pagination: Paging; pagination: PagingCtx;
disableAutoLoad?: boolean; disableAutoLoad?: boolean;
displayLimit?: number; displayLimit?: number;
pullToRefresh?: boolean;
}>(), { }>(), {
displayLimit: 20, displayLimit: 20,
pullToRefresh: true,
}); });
const emit = defineEmits<{ const paginator = usePagination({
(ev: 'queue', count: number): void; ctx: props.pagination,
(ev: 'status', error: boolean): void;
}>();
const rootEl = useTemplateRef('rootEl');
//
const backed = ref(false);
const scrollRemove = ref<(() => void) | null>(null);
/**
* 表示するアイテムのソース
* 最新が0番目
*/
const items = ref<MisskeyEntityMap>(new Map());
/**
* タブが非アクティブなどの場合に更新を貯めておく
* 最新が0番目
*/
const queue = ref<MisskeyEntityMap>(new Map());
/**
* 初期化中かどうかtrueならMkLoadingで全て隠す
*/
const fetching = ref(true);
const moreFetching = ref(false);
const more = ref(false);
const preventAppearFetchMore = ref(false);
const preventAppearFetchMoreTimer = ref<number | null>(null);
const isBackTop = ref(false);
const empty = computed(() => items.value.size === 0);
const error = ref(false);
const {
enableInfiniteScroll,
} = prefer.r;
const scrollableElement = computed(() => rootEl.value ? getScrollContainer(rootEl.value) : window.document.body);
const visibility = useDocumentVisibility();
let isPausingUpdate = false;
let timerForSetPause: number | null = null;
const BACKGROUND_PAUSE_WAIT_SEC = 10;
//
// https://qiita.com/mkataigi/items/0154aefd2223ce23398e
const scrollObserver = ref<IntersectionObserver>();
watch([() => props.pagination.reversed, scrollableElement], () => {
if (scrollObserver.value) scrollObserver.value.disconnect();
scrollObserver.value = new IntersectionObserver(entries => {
backed.value = entries[0].isIntersecting;
}, {
root: scrollableElement.value,
rootMargin: props.pagination.reversed ? '-100% 0px 100% 0px' : '100% 0px -100% 0px',
threshold: 0.01,
});
}, { immediate: true });
watch(rootEl, () => {
scrollObserver.value?.disconnect();
nextTick(() => {
if (rootEl.value) scrollObserver.value?.observe(rootEl.value);
});
}); });
watch([backed, rootEl], () => { function appearFetchMoreAhead() {
if (!backed.value) { paginator.fetchNewer();
if (!rootEl.value) return;
scrollRemove.value = props.pagination.reversed
? onScrollBottom(rootEl.value, executeQueue, TOLERANCE)
: onScrollTop(rootEl.value, (topVisible) => { if (topVisible) executeQueue(); }, TOLERANCE);
} else {
if (scrollRemove.value) scrollRemove.value();
scrollRemove.value = null;
}
});
// ID
watch(() => [props.pagination.endpoint, props.pagination.params], init, { deep: true });
watch(queue, (a, b) => {
if (a.size === 0 && b.size === 0) return;
emit('queue', queue.value.size);
}, { deep: true });
watch(error, (n, o) => {
if (n === o) return;
emit('status', n);
});
async function init(): Promise<void> {
items.value = new Map();
queue.value = new Map();
fetching.value = true;
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, {
...params,
limit: props.pagination.limit ?? 10,
allowPartial: true,
}).then(res => {
for (let i = 0; i < res.length; i++) {
const item = res[i];
if (i === 3) item._shouldInsertAd_ = true;
}
if (res.length === 0 || props.pagination.noPaging) {
concatItems(res);
more.value = false;
} else {
if (props.pagination.reversed) moreFetching.value = true;
concatItems(res);
more.value = true;
}
error.value = false;
fetching.value = false;
}, err => {
error.value = true;
fetching.value = false;
});
} }
const reload = (): Promise<void> => { function appearFetchMore() {
return init(); paginator.fetchOlder();
};
const fetchMore = async (): Promise<void> => {
if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return;
moreFetching.value = true;
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, {
...params,
limit: SECOND_FETCH_LIMIT,
...(props.pagination.offsetMode ? {
offset: items.value.size,
} : {
untilId: Array.from(items.value.keys()).at(-1),
}),
}).then(res => {
for (let i = 0; i < res.length; i++) {
const item = res[i];
if (i === 10) item._shouldInsertAd_ = true;
}
const reverseConcat = _res => {
const oldHeight = scrollableElement.value ? scrollableElement.value.scrollHeight : getBodyScrollHeight();
const oldScroll = scrollableElement.value ? scrollableElement.value.scrollTop : window.scrollY;
items.value = concatMapWithArray(items.value, _res);
return nextTick(() => {
if (scrollableElement.value) {
scrollInContainer(scrollableElement.value, { top: oldScroll + (scrollableElement.value.scrollHeight - oldHeight), behavior: 'instant' });
} else {
window.scroll({ top: oldScroll + (getBodyScrollHeight() - oldHeight), behavior: 'instant' });
}
return nextTick();
});
};
if (res.length === 0) {
if (props.pagination.reversed) {
reverseConcat(res).then(() => {
more.value = false;
moreFetching.value = false;
});
} else {
items.value = concatMapWithArray(items.value, res);
more.value = false;
moreFetching.value = false;
}
} else {
if (props.pagination.reversed) {
reverseConcat(res).then(() => {
more.value = true;
moreFetching.value = false;
});
} else {
items.value = concatMapWithArray(items.value, res);
more.value = true;
moreFetching.value = false;
}
}
}, err => {
moreFetching.value = false;
});
};
const fetchMoreAhead = async (): Promise<void> => {
if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return;
moreFetching.value = true;
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, {
...params,
limit: SECOND_FETCH_LIMIT,
...(props.pagination.offsetMode ? {
offset: items.value.size,
} : {
sinceId: Array.from(items.value.keys()).at(-1),
}),
}).then(res => {
if (res.length === 0) {
items.value = concatMapWithArray(items.value, res);
more.value = false;
} else {
items.value = concatMapWithArray(items.value, res);
more.value = true;
}
moreFetching.value = false;
}, err => {
moreFetching.value = false;
});
};
/**
* AppearIntersectionObserverによってfetchMoreが呼ばれる場合
* APPEAR_MINIMUM_INTERVALミリ秒以内に2回fetchMoreが呼ばれるのを防ぐ
*/
const fetchMoreApperTimeoutFn = (): void => {
preventAppearFetchMore.value = false;
preventAppearFetchMoreTimer.value = null;
};
const fetchMoreAppearTimeout = (): void => {
preventAppearFetchMore.value = true;
preventAppearFetchMoreTimer.value = window.setTimeout(fetchMoreApperTimeoutFn, APPEAR_MINIMUM_INTERVAL);
};
const appearFetchMore = async (): Promise<void> => {
if (preventAppearFetchMore.value) return;
await fetchMore();
fetchMoreAppearTimeout();
};
const appearFetchMoreAhead = async (): Promise<void> => {
if (preventAppearFetchMore.value) return;
await fetchMoreAhead();
fetchMoreAppearTimeout();
};
const isHead = (): boolean => isBackTop.value || (props.pagination.reversed ? isTailVisible : isHeadVisible)(rootEl.value!, TOLERANCE);
watch(visibility, () => {
if (visibility.value === 'hidden') {
timerForSetPause = window.setTimeout(() => {
isPausingUpdate = true;
timerForSetPause = null;
},
BACKGROUND_PAUSE_WAIT_SEC * 1000);
} else { // 'visible'
if (timerForSetPause) {
window.clearTimeout(timerForSetPause);
timerForSetPause = null;
} else {
isPausingUpdate = false;
if (isHead()) {
executeQueue();
}
}
}
});
/**
* 最新のものとして1つだけアイテムを追加する
* ストリーミングから降ってきたアイテムはこれで追加する
* @param item アイテム
*/
function prepend(item: MisskeyEntity): void {
if (items.value.size === 0) {
items.value.set(item.id, item);
fetching.value = false;
return;
}
if (_DEV_) console.log(isHead(), isPausingUpdate);
if (isHead() && !isPausingUpdate) unshiftItems([item]);
else prependQueue(item);
} }
/**
* 新着アイテムをitemsの先頭に追加しdisplayLimitを適用する
* @param newItems 新しいアイテムの配列
*/
function unshiftItems(newItems: MisskeyEntity[]) {
const length = newItems.length + items.value.size;
items.value = new Map([...arrayToEntries(newItems), ...items.value].slice(0, props.displayLimit));
if (length >= props.displayLimit) more.value = true;
}
/**
* 古いアイテムをitemsの末尾に追加しdisplayLimitを適用する
* @param oldItems 古いアイテムの配列
*/
function concatItems(oldItems: MisskeyEntity[]) {
const length = oldItems.length + items.value.size;
items.value = new Map([...items.value, ...arrayToEntries(oldItems)].slice(0, props.displayLimit));
if (length >= props.displayLimit) more.value = true;
}
function executeQueue() {
unshiftItems(Array.from(queue.value.values()));
queue.value = new Map();
}
function prependQueue(newItem: MisskeyEntity) {
queue.value = new Map([[newItem.id, newItem], ...queue.value].slice(0, props.displayLimit) as [string, MisskeyEntity][]);
}
/*
* アイテムを末尾に追加する使うの
*/
const appendItem = (item: MisskeyEntity): void => {
items.value.set(item.id, item);
};
const removeItem = (id: string) => {
items.value.delete(id);
queue.value.delete(id);
};
const updateItem = (id: MisskeyEntity['id'], replacer: (old: MisskeyEntity) => MisskeyEntity): void => {
const item = items.value.get(id);
if (item) items.value.set(id, replacer(item));
const queueItem = queue.value.get(id);
if (queueItem) queue.value.set(id, replacer(queueItem));
};
onActivated(() => {
isBackTop.value = false;
});
onDeactivated(() => {
isBackTop.value = props.pagination.reversed ? window.scrollY >= (rootEl.value ? rootEl.value.scrollHeight - window.innerHeight : 0) : window.scrollY === 0;
});
function toBottom() {
scrollToBottom(rootEl.value!);
}
onBeforeMount(() => {
init().then(() => {
if (props.pagination.reversed) {
nextTick(() => {
window.setTimeout(toBottom, 800);
// scrollToBottommoreFetching
// more = true
window.setTimeout(() => {
moreFetching.value = false;
}, 2000);
});
}
});
});
onBeforeUnmount(() => {
if (timerForSetPause) {
window.clearTimeout(timerForSetPause);
timerForSetPause = null;
}
if (preventAppearFetchMoreTimer.value) {
window.clearTimeout(preventAppearFetchMoreTimer.value);
preventAppearFetchMoreTimer.value = null;
}
scrollObserver.value?.disconnect();
});
defineExpose({ defineExpose({
items, paginator: paginator,
queue,
backed: backed.value,
more,
reload,
prepend,
append: appendItem,
removeItem,
updateItem,
}); });
</script> </script>

View File

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<div :class="{ [$style.done]: closed || isVoted }"> <div :class="{ [$style.done]: closed || isVoted }">
<ul :class="$style.choices"> <ul :class="$style.choices">
<li v-for="(choice, i) in poll.choices" :key="i" :class="$style.choice" @click="vote(i)"> <li v-for="(choice, i) in choices" :key="i" :class="$style.choice" @click="vote(i)">
<div :class="$style.bg" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div> <div :class="$style.bg" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div>
<span :class="$style.fg"> <span :class="$style.fg">
<template v-if="choice.isVoted"><i class="ti ti-check" style="margin-right: 4px; color: var(--MI_THEME-accent);"></i></template> <template v-if="choice.isVoted"><i class="ti ti-check" style="margin-right: 4px; color: var(--MI_THEME-accent);"></i></template>
@ -40,7 +40,9 @@ import { i18n } from '@/i18n.js';
const props = defineProps<{ const props = defineProps<{
noteId: string; noteId: string;
poll: NonNullable<Misskey.entities.Note['poll']>; multiple: NonNullable<Misskey.entities.Note['poll']>['multiple'];
expiresAt: NonNullable<Misskey.entities.Note['poll']>['expiresAt'];
choices: NonNullable<Misskey.entities.Note['poll']>['choices'];
readOnly?: boolean; readOnly?: boolean;
emojiUrls?: Record<string, string>; emojiUrls?: Record<string, string>;
author?: Misskey.entities.UserLite; author?: Misskey.entities.UserLite;
@ -48,9 +50,9 @@ const props = defineProps<{
const remaining = ref(-1); const remaining = ref(-1);
const total = computed(() => sum(props.poll.choices.map(x => x.votes))); const total = computed(() => sum(props.choices.map(x => x.votes)));
const closed = computed(() => remaining.value === 0); const closed = computed(() => remaining.value === 0);
const isVoted = computed(() => !props.poll.multiple && props.poll.choices.some(c => c.isVoted)); const isVoted = computed(() => !props.multiple && props.choices.some(c => c.isVoted));
const timer = computed(() => i18n.tsx._poll[ const timer = computed(() => i18n.tsx._poll[
remaining.value >= 86400 ? 'remainingDays' : remaining.value >= 86400 ? 'remainingDays' :
remaining.value >= 3600 ? 'remainingHours' : remaining.value >= 3600 ? 'remainingHours' :
@ -70,9 +72,9 @@ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
})); }));
// //
if (props.poll.expiresAt) { if (props.expiresAt) {
const tick = () => { const tick = () => {
remaining.value = Math.floor(Math.max(new Date(props.poll.expiresAt!).getTime() - Date.now(), 0) / 1000); remaining.value = Math.floor(Math.max(new Date(props.expiresAt!).getTime() - Date.now(), 0) / 1000);
if (remaining.value === 0) { if (remaining.value === 0) {
showResult.value = true; showResult.value = true;
} }
@ -91,7 +93,7 @@ const vote = async (id) => {
const { canceled } = await os.confirm({ const { canceled } = await os.confirm({
type: 'question', type: 'question',
text: i18n.tsx.voteConfirm({ choice: props.poll.choices[id].text }), text: i18n.tsx.voteConfirm({ choice: props.choices[id].text }),
}); });
if (canceled) return; if (canceled) return;
@ -99,7 +101,7 @@ const vote = async (id) => {
noteId: props.noteId, noteId: props.noteId,
choice: id, choice: id,
}); });
if (!showResult.value) showResult.value = !props.poll.multiple; if (!showResult.value) showResult.value = !props.multiple;
}; };
</script> </script>

View File

@ -137,6 +137,7 @@ import { mfmFunctionPicker } from '@/utility/mfm-function-picker.js';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
import { getPluginHandlers } from '@/plugin.js'; import { getPluginHandlers } from '@/plugin.js';
import { DI } from '@/di.js'; import { DI } from '@/di.js';
import { globalEvents } from '@/events.js';
const $i = ensureSignin(); const $i = ensureSignin();
@ -883,12 +884,15 @@ async function post(ev?: MouseEvent) {
} }
posting.value = true; posting.value = true;
misskeyApi('notes/create', postData, token).then(() => { misskeyApi('notes/create', postData, token).then((res) => {
if (props.freezeAfterPosted) { if (props.freezeAfterPosted) {
posted.value = true; posted.value = true;
} else { } else {
clear(); clear();
} }
globalEvents.emit('notePosted', res.createdNote);
nextTick(() => { nextTick(() => {
deleteDraft(); deleteDraft();
emit('posted'); emit('posted');

View File

@ -48,7 +48,8 @@ function toggle(): void {
<style lang="scss" module> <style lang="scss" module>
.root { .root {
position: relative; position: relative;
display: inline-block; display: inline-flex;
align-items: center;
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
padding: 7px 10px; padding: 7px 10px;
@ -102,7 +103,8 @@ function toggle(): void {
} }
.button { .button {
position: absolute; position: relative;
display: inline-block;
width: 14px; width: 14px;
height: 14px; height: 14px;
background: none; background: none;
@ -126,7 +128,7 @@ function toggle(): void {
} }
.label { .label {
margin-left: 28px; margin-left: 8px;
display: block; display: block;
line-height: 20px; line-height: 20px;
cursor: pointer; cursor: pointer;

View File

@ -5,14 +5,18 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts"> <script lang="ts">
import { defineComponent, h, ref, watch } from 'vue'; import { defineComponent, h, ref, watch } from 'vue';
import type { VNode } from 'vue';
import MkRadio from './MkRadio.vue'; import MkRadio from './MkRadio.vue';
import type { VNode } from 'vue';
export default defineComponent({ export default defineComponent({
props: { props: {
modelValue: { modelValue: {
required: false, required: false,
}, },
vertical: {
type: Boolean,
default: false,
},
}, },
setup(props, context) { setup(props, context) {
const value = ref(props.modelValue); const value = ref(props.modelValue);
@ -34,7 +38,10 @@ export default defineComponent({
options = options.filter(vnode => !(typeof vnode.type === 'symbol' && vnode.type.description === 'v-cmt' && vnode.children === 'v-if')); options = options.filter(vnode => !(typeof vnode.type === 'symbol' && vnode.type.description === 'v-cmt' && vnode.children === 'v-if'));
return () => h('div', { return () => h('div', {
class: 'novjtcto', class: [
'novjtcto',
...(props.vertical ? ['vertical'] : []),
],
}, [ }, [
...(label ? [h('div', { ...(label ? [h('div', {
class: 'label', class: 'label',
@ -71,7 +78,7 @@ export default defineComponent({
> .body { > .body {
display: flex; display: flex;
gap: 12px; gap: 10px;
flex-wrap: wrap; flex-wrap: wrap;
} }
@ -84,5 +91,11 @@ export default defineComponent({
display: none; display: none;
} }
} }
&.vertical {
> .body {
flex-direction: column;
}
}
} }
</style> </style>

Some files were not shown because too many files have changed in this diff Show More