Merge branch 'develop' into fix-colum-name-channel-list-name-on-open

This commit is contained in:
anatawa12 2025-05-09 22:06:13 +09:00 committed by GitHub
commit 1f12f28f7f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
151 changed files with 3623 additions and 2338 deletions

View File

@ -1,13 +1,31 @@
## Unreleased
## 2025.5.1
### General
-
- Feat: 非ログインでサーバーを閲覧された際に、サーバー内のコンテンツを非公開にすることができるようになりました
- モデレーションが行き届きにくい不適切なリモートコンテンツなどが、自サーバー経由で図らずもインターネットに公開されてしまうことによるトラブル防止などに役立ちます
- 「全て公開(今までの挙動)」「ローカルのコンテンツだけ公開(=サーバー内で受信されたリモートのコンテンツは公開しない)」「何も公開しない」から選択できます
- デフォルト値は「ローカルのコンテンツだけ公開」になっています
### Client
- Feat: サーバー初期設定ウィザードが実装されました
- 簡単なウィザードに従うだけで、サーバーに最適な設定が適用されます
- Feat: Websocket接続を行わずにMisskeyを利用するNo Websocketモードが実装されました(beta)
- サーバーのパフォーマンス向上に寄与することが期待されます
- 何らの理由によりWebsocket接続が行えない環境でも快適に利用可能です
- 従来のWebsocket接続を行うモードはリアルタイムモードとして再定義されました
- チャットなど、一部の機能は引き続き設定に関わらずWebsocket接続が行われます
- 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設定が有効でも、その背景が透過されない問題を修正
- Fix: カラムの名前が正しくリスト/チャンネルの名前にならない問題を修正
### Server
-
- Enhance: ノートのレスポンスにアンケートが添付されているかどうかを示すフラグ`hasPoll`を追加
- Fix: チャットルームが削除された場合・チャットルームから抜けた場合に、未読状態が残り続けることがあるのを修正
- Fix: ユーザ除外アンテナをインポートできない問題を修正
- Fix: アンテナのセンシティブなチャンネルのノートを含むかどうかの情報がエクスポートされない問題を修正
## 2025.5.0

View File

@ -2,11 +2,6 @@ import { defineConfig } from 'cypress'
export default defineConfig({
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',
},
})

View File

@ -31,6 +31,15 @@ describe('Before setup instance', () => {
// なぜか動かない
//cy.wait('@signup').should('have.property', 'response.statusCode');
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: "تعديل الملف التعريفي"
noteDeleteConfirm: "هل تريد حذف هذه الملاحظة؟"
pinLimitExceeded: "لا يمكنك تثبيت الملاحظات بعد الآن."
intro: "لقد انتهت عملية تنصيب Misskey. الرجاء إنشاء حساب إداري."
done: "تمّ"
processing: "المعالجة جارية"
preview: "معاينة"
@ -676,7 +675,6 @@ experimental: "اختباري"
developer: "المطور"
makeExplorable: "أظهر الحساب في صفحة \"استكشاف\""
makeExplorableDescription: "بتعطيل هذا الخيار لن يظهر حسابك في صفحة \"استكشاف\""
showGapBetweenNotesInTimeline: "أظهر فجوات بين المشاركات في الخيط الزمني"
left: "يسار"
center: "وسط"
wide: "عريض"

View File

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

View File

@ -251,7 +251,6 @@ noUsers: "No hi ha usuaris"
editProfile: "Edita el perfil"
noteDeleteConfirm: "Segur que voleu eliminar aquesta publicació?"
pinLimitExceeded: "No podeu fixar més publicacions"
intro: "La instal·lació de Misskey ha acabat! Crea un usuari d'administrador."
done: "Fet"
processing: "S'està processant..."
preview: "Vista prèvia"
@ -785,7 +784,6 @@ thisIsExperimentalFeature: "Aquesta és una característica experimental. La sev
developer: "Programador"
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\""
showGapBetweenNotesInTimeline: "Notes separades a la línia de temps"
duplicate: "Duplicat"
left: "Esquerra"
center: "Centre"
@ -1238,7 +1236,6 @@ showAvatarDecorations: "Mostrar les decoracions dels avatars"
releaseToRefresh: "Deixar anar per actualitzar"
refreshing: "Recarregant..."
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 "
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ó "
@ -1434,6 +1431,7 @@ _preferencesProfile:
profileName: "Nom del perfil"
profileNameDescription: "Estableix un nom que identifiqui aquest dispositiu."
profileNameDescription2: "Per exemple: \"PC Principal\", \"Smartphone\", etc"
manageProfiles: "Gestionar perfils"
_preferencesBackup:
autoBackup: "Còpia de seguretat automàtica "
restoreFromBackup: "Restaurar des d'una còpia de seguretat"

View File

@ -228,7 +228,6 @@ noUsers: "Žádní uživatelé"
editProfile: "Upravit můj profil"
noteDeleteConfirm: "Jste si jistí že chcete smazat tuhle poznámku?"
pinLimitExceeded: "Nemůžete připnout další poznámky."
intro: "Instalace Misskey byla dokončena! Prosím vytvořte admina."
done: "Hotovo"
processing: "Zpracovávám"
preview: "Náhled"
@ -726,7 +725,6 @@ thisIsExperimentalFeature: "Tohle je experimentální funkce. Její funkce se m
developer: "Vývojář"
makeExplorable: "Udělat účet viditelný v \"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"
left: "Vlevo"
center: "Uprostřed"

View File

@ -251,7 +251,6 @@ noUsers: "Keine Benutzer gefunden"
editProfile: "Profil bearbeiten"
noteDeleteConfirm: "Möchtest du diese Notiz wirklich löschen?"
pinLimitExceeded: "Du kannst nicht noch mehr Notizen anheften."
intro: "Misskey ist installiert! Lass uns nun ein Administratorkonto einrichten."
done: "Fertig"
processing: "In Bearbeitung …"
preview: "Vorschau"
@ -785,7 +784,6 @@ thisIsExperimentalFeature: "Dies ist eine experimentelle Funktion. Änderungen a
developer: "Entwickler"
makeExplorable: "Benutzerkonto in „Erkunden“ sichtbar machen"
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"
left: "Links"
center: "Mittig"
@ -1238,7 +1236,6 @@ showAvatarDecorations: "Profilbilddekoration anzeigen"
releaseToRefresh: "Zum Aktualisieren loslassen"
refreshing: "Wird aktualisiert..."
pullDownToRefresh: "Zum Aktualisieren ziehen"
disableStreamingTimeline: "Echtzeitaktualisierung der Chronik deaktivieren"
useGroupedNotifications: "Benachrichtigungen gruppieren"
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."
@ -1434,6 +1431,7 @@ _preferencesProfile:
profileName: "Profilname"
profileNameDescription: "Lege einen Namen fest, der dieses Gerät identifiziert."
profileNameDescription2: "Beispiel: \"Haupt-PC\", \"Smartphone\""
manageProfiles: "Profile verwalten"
_preferencesBackup:
autoBackup: "Automatische Sicherung"
restoreFromBackup: "Wiederherstellen aus der Sicherung"

View File

@ -251,7 +251,6 @@ noUsers: "There are no users"
editProfile: "Edit profile"
noteDeleteConfirm: "Are you sure you want to delete this note?"
pinLimitExceeded: "You cannot pin any more notes"
intro: "Installation of Misskey has been finished! Please create an admin user."
done: "Done"
processing: "Processing..."
preview: "Preview"
@ -785,7 +784,6 @@ thisIsExperimentalFeature: "This is an experimental feature. Its functionality i
developer: "Developer"
makeExplorable: "Make account visible in \"Explore\""
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"
left: "Left"
center: "Center"
@ -1238,7 +1236,6 @@ showAvatarDecorations: "Show avatar decorations"
releaseToRefresh: "Release to refresh"
refreshing: "Refreshing..."
pullDownToRefresh: "Pull down to refresh"
disableStreamingTimeline: "Disable real-time timeline updates"
useGroupedNotifications: "Display grouped notifications"
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."
@ -1434,6 +1431,7 @@ _preferencesProfile:
profileName: "Profile name"
profileNameDescription: "Set a name that identifies this device."
profileNameDescription2: "Example: \"Main PC\", \"Smartphone\""
manageProfiles: "Manage Profiles"
_preferencesBackup:
autoBackup: "Auto backup"
restoreFromBackup: "Restore from backup"

View File

@ -250,7 +250,6 @@ noUsers: "No hay usuarios"
editProfile: "Editar perfil"
noteDeleteConfirm: "¿Desea borrar esta nota?"
pinLimitExceeded: "Ya no se pueden fijar más posts"
intro: "¡La instalación de Misskey ha terminado! Crea el usuario administrador."
done: "Terminado"
processing: "Procesando"
preview: "Vista previa"
@ -784,7 +783,6 @@ thisIsExperimentalFeature: "Se trata de una función experimental. Las especific
developer: "Desarrolladores"
makeExplorable: "Hacer visible la cuenta en \"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"
left: "Izquierda"
center: "Centrar"
@ -1237,7 +1235,6 @@ showAvatarDecorations: "Mostrar decoraciones de avatar"
releaseToRefresh: "Soltar para recargar"
refreshing: "Recargando..."
pullDownToRefresh: "Tira hacia abajo para recargar"
disableStreamingTimeline: "Desactivar actualizaciones en tiempo real de la línea de tiempo"
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."
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"
noteDeleteConfirm: "Êtes-vous sûr·e de vouloir supprimer cette note ?"
pinLimitExceeded: "Vous ne pouvez plus épingler dautres notes."
intro: "Linstallation de Misskey est terminée ! Veuillez créer un compte administrateur."
done: "Terminé"
processing: "Traitement en cours"
preview: "Aperçu"
@ -760,7 +759,6 @@ thisIsExperimentalFeature: "Ceci est une fonctionnalité expérimentale. Il y a
developer: "Développeur"
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\"."
showGapBetweenNotesInTimeline: "Afficher un écart entre les notes sur la Timeline"
duplicate: "Duliquer"
left: "Gauche"
center: "Centrer"
@ -1209,7 +1207,6 @@ showAvatarDecorations: "Afficher les décorations d'avatar"
releaseToRefresh: "Relâcher pour rafraîchir"
refreshing: "Rafraîchissement..."
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"
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."

View File

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

242
locales/index.d.ts vendored
View File

@ -1022,10 +1022,6 @@ export interface Locale extends ILocale {
*
*/
"pinLimitExceeded": string;
/**
* Misskeyのインストールが完了しました
*/
"intro": string;
/**
*
*/
@ -2322,6 +2318,10 @@ export interface Locale extends ILocale {
*
*/
"newNoteRecived": string;
/**
*
*/
"newNote": string;
/**
*
*/
@ -3158,10 +3158,6 @@ export interface Locale extends ILocale {
*
*/
"makeExplorableDescription": string;
/**
*
*/
"showGapBetweenNotesInTimeline": string;
/**
*
*/
@ -4970,10 +4966,6 @@ export interface Locale extends ILocale {
*
*/
"pullDownToRefresh": string;
/**
*
*/
"disableStreamingTimeline": string;
/**
*
*/
@ -5417,6 +5409,22 @@ export interface Locale extends ILocale {
*
*/
"scrollToClose": string;
/**
*
*/
"advice": string;
/**
*
*/
"realtimeMode": string;
/**
*
*/
"turnItOn": string;
/**
*
*/
"turnItOff": string;
"_chat": {
/**
*
@ -5721,6 +5729,22 @@ export interface Locale extends ILocale {
*
*/
"enablePullToRefresh_description": string;
/**
*
*/
"realtimeMode_description": string;
/**
*
*/
"contentsUpdateFrequency": string;
/**
*
*/
"contentsUpdateFrequency_description": string;
/**
*
*/
"contentsUpdateFrequency_description2": string;
"_chat": {
/**
*
@ -6388,6 +6412,40 @@ export interface Locale extends ILocale {
* semver 使>= 2024.3.1 2024.3.1-custom.0 >= 2024.3.1-0 prerelease
*/
"deliverSuspendedSoftwareDescription": string;
/**
*
*/
"singleUserMode": string;
/**
*
*/
"singleUserMode_description": string;
/**
*
*/
"userGeneratedContentsVisibilityForVisitor": string;
/**
*
*/
"userGeneratedContentsVisibilityForVisitor_description": string;
/**
*
*/
"userGeneratedContentsVisibilityForVisitor_description2": string;
"_userGeneratedContentsVisibilityForVisitor": {
/**
*
*/
"all": string;
/**
*
*/
"localOnly": string;
/**
*
*/
"none": string;
};
};
"_accountMigration": {
/**
@ -11620,6 +11678,166 @@ export interface Locale extends ILocale {
*/
"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: {
[lang: string]: Locale;

View File

@ -250,7 +250,6 @@ noUsers: "Non ci sono profili"
editProfile: "Modifica profilo"
noteDeleteConfirm: "Vuoi davvero eliminare questa Nota?"
pinLimitExceeded: "Non puoi fissare altre note "
intro: "L'installazione di Misskey è terminata! Si prega di creare il profilo amministratore."
done: "Fine"
processing: "In elaborazione"
preview: "Anteprima"
@ -784,7 +783,6 @@ thisIsExperimentalFeature: "Questa è una funzionalità sperimentale. Potrebbe e
developer: "Sviluppatore"
makeExplorable: "Profilo visibile pubblicamente 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"
left: "Sinistra"
center: "Centro"
@ -1237,7 +1235,6 @@ showAvatarDecorations: "Mostra decorazione della foto profilo"
releaseToRefresh: "Rilascia per aggiornare"
refreshing: "Aggiornamento..."
pullDownToRefresh: "Trascinare per aggiornare"
disableStreamingTimeline: "Disabilitare gli aggiornamenti della TL in tempo reale"
useGroupedNotifications: "Mostra le notifiche raggruppate"
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."

View File

@ -251,7 +251,6 @@ noUsers: "ユーザーはいません"
editProfile: "プロフィールを編集"
noteDeleteConfirm: "このノートを削除しますか?"
pinLimitExceeded: "これ以上ピン留めできません"
intro: "Misskeyのインストールが完了しました管理者アカウントを作成しましょう。"
done: "完了"
processing: "処理中"
preview: "プレビュー"
@ -576,6 +575,7 @@ showFixedPostForm: "タイムライン上部に投稿フォームを表示する
showFixedPostFormInChannel: "タイムライン上部に投稿フォームを表示する(チャンネル)"
withRepliesByDefaultForNewlyFollowed: "フォローする際、デフォルトで返信をTLに含むようにする"
newNoteRecived: "新しいノートがあります"
newNote: "新しいノート"
sounds: "サウンド"
sound: "サウンド"
listen: "聴く"
@ -785,7 +785,6 @@ thisIsExperimentalFeature: "これは実験的な機能です。仕様が変更
developer: "開発者"
makeExplorable: "アカウントを見つけやすくする"
makeExplorableDescription: "オフにすると、「みつける」にアカウントが載らなくなります。"
showGapBetweenNotesInTimeline: "タイムラインのノートを離して表示"
duplicate: "複製"
left: "左"
center: "中央"
@ -1238,7 +1237,6 @@ showAvatarDecorations: "アイコンのデコレーションを表示"
releaseToRefresh: "離してリロード"
refreshing: "リロード中"
pullDownToRefresh: "引っ張ってリロード"
disableStreamingTimeline: "タイムラインのリアルタイム更新を無効にする"
useGroupedNotifications: "通知をグルーピング"
signupPendingError: "メールアドレスの確認中に問題が発生しました。リンクの有効期限が切れている可能性があります。"
cwNotationRequired: "「内容を隠す」がオンの場合は注釈の記述が必要です。"
@ -1349,6 +1347,10 @@ goToDeck: "デッキへ戻る"
federationJobs: "連合ジョブ"
driveAboutTip: "ドライブでは、過去にアップロードしたファイルの一覧が表示されます。<br>\nートに添付する際に再利用したり、あとで投稿するファイルを予めアップロードしておくこともできます。<br>\n<b>ファイルを削除すると、今までそのファイルを使用した全ての場所(ノート、ページ、アバター、バナー等)からも見えなくなるので注意してください。</b><br>\nフォルダを作って整理することもできます。"
scrollToClose: "スクロールして閉じる"
advice: "アドバイス"
realtimeMode: "リアルタイムモード"
turnItOn: "オンにする"
turnItOff: "オフにする"
_chat:
noMessagesYet: "まだメッセージはありません"
@ -1430,6 +1432,10 @@ _settings:
enableSyncThemesBetweenDevices: "デバイス間でインストールしたテーマを同期"
enablePullToRefresh: "ひっぱって更新"
enablePullToRefresh_description: "マウスでは、ホイールを押し込みながらドラッグします。"
realtimeMode_description: "サーバーと接続を確立し、リアルタイムでコンテンツを更新します。通信量とバッテリーの消費が多くなる場合があります。"
contentsUpdateFrequency: "コンテンツの取得頻度"
contentsUpdateFrequency_description: "高いほどリアルタイムにコンテンツが更新されますが、パフォーマンスが低下し、通信量とバッテリーの消費が多くなります。"
contentsUpdateFrequency_description2: "リアルタイムモードがオンのときは、この設定に関わらずリアルタイムでコンテンツが更新されます。"
_chat:
showSenderName: "送信者の名前を表示"
@ -1623,6 +1629,16 @@ _serverSettings:
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "一定期間モデレーターのアクティビティが検出されなかった場合、スパム防止のためこの設定は自動でオフになります。"
deliverSuspendedSoftware: "配信停止中のソフトウェア"
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:
moveFrom: "別のアカウントからこのアカウントに移行"
@ -3107,3 +3123,46 @@ _search:
pleaseEnterServerHost: "サーバーのホストを入力してください"
pleaseSelectUser: "ユーザーを選択してください"
serverHostPlaceholder: "例: misskey.example.com"
_serverSetupWizard:
installCompleted: "Misskeyのインストールが完了しました"
firstCreateAccount: "まずは、管理者アカウントを作成しましょう。"
accountCreated: "管理者アカウントが作成されました!"
serverSetting: "サーバーの設定"
youCanEasilyConfigureOptimalServerSettingsWithThisWizard: "このウィザードで簡単に最適なサーバーの設定が行えます。"
settingsYouMakeHereCanBeChangedLater: "ここでの設定は、あとからでも変更できます。"
howWillYouUseMisskey: "Misskeyをどのように使いますか"
_use:
single: "お一人様サーバー"
single_description: "自分専用のサーバーとして、一人で使う"
single_youCanCreateMultipleAccounts: "お一人様サーバーとして運用する場合でも、アカウントは必要に応じて複数作成可能です。"
group: "グループサーバー"
group_description: "信頼できる他の利用者を招待して、複数人で使う"
open: "オープンサーバー"
open_description: "不特定多数の利用者を受け入れる運営を行う"
openServerAdvice: "不特定多数の利用者を受け入れることはリスクが伴います。トラブルに対処できるよう、確実なモデレーション体制で運営することを推奨します。"
openServerAntiSpamAdvice: "自サーバーがスパムの踏み台にならないように、reCAPTCHAといったアンチボット機能を有効にするなど、セキュリティについても細心の注意が必要です。"
howManyUsersDoYouExpect: "どれくらいの人数を想定していますか?"
_scale:
small: "100人以下 (小規模)"
medium: "100人以上1000人以下 (中規模)"
large: "1000人以上 (大規模)"
largeScaleServerAdvice: "大規模なサーバーでは、ロードバランシングやデータベースのレプリケーションなど、高度なインフラストラクチャーの知識が必要になる場合があります。"
doYouConnectToFediverse: "Fediverseと接続しますか"
doYouConnectToFediverse_description1: "分散型サーバーで構成されるネットワーク(Fediverse)に接続すると、他のサーバーと相互にコンテンツのやり取りが可能です。"
doYouConnectToFediverse_description2: "Fediverseと接続することは「連合」とも呼ばれます。"
youCanConfigureMoreFederationSettingsLater: "連合可能なサーバーの指定など、高度な設定も後ほど可能です。"
adminInfo: "管理者情報"
adminInfo_description: "問い合わせを受け付けるために使用される管理者情報を設定します。"
adminInfo_mustBeFilled: "オープンサーバー、または連合がオンの場合は必ず入力が必要です。"
followingSettingsAreRecommended: "以下の設定が推奨されます"
applyTheseSettings: "この設定を適用"
skipSettings: "設定をスキップ"
settingsCompleted: "設定が完了しました!"
settingsCompleted_description: "お疲れ様でした。準備が整ったので、さっそくサーバーの使用を開始できます。"
settingsCompleted_description2: "詳細なサーバー設定は、「コントロールパネル」から行えます。"
donationRequest: "寄付のお願い"
_donationRequest:
text1: "Misskeyは有志によって開発されている無料のソフトウェアです。"
text2: "今後も開発を続けられるように、よろしければぜひカンパをお願いいたします。"
text3: "支援者向け特典もあります!"

View File

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

View File

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

View File

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

View File

@ -250,7 +250,6 @@ noUsers: "Er zijn geen gebruikers."
editProfile: "Bewerk Profiel"
noteDeleteConfirm: "Ben je zeker dat je dit bericht wil verwijderen?"
pinLimitExceeded: "Je kunt geen berichten meer vastprikken"
intro: "Installatie van Misskey geëindigd! Maak nu een beheerder aan."
done: "Klaar"
processing: "Bezig met verwerken"
preview: "Voorbeeld"
@ -784,7 +783,6 @@ thisIsExperimentalFeature: "Dit is een experimentele functie. De functionaliteit
developer: "Ontwikkelaar"
makeExplorable: "Gebruikersaccount zichtbaar maken in “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"
left: "Links"
center: "Center"

View File

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

View File

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

View File

@ -250,7 +250,6 @@ noUsers: "Sem usuários"
editProfile: "Editar Perfil"
noteDeleteConfirm: "Deseja excluir esta nota?"
pinLimitExceeded: "Não é possível fixar novas notas"
intro: "A instalação do Misskey está completa! Crie uma conta de administrador."
done: "Concluído"
processing: "Em Progresso"
preview: "Pré-visualizar"
@ -784,7 +783,6 @@ thisIsExperimentalFeature: "Este é um recurso experimental. As funções podem
developer: "Programador"
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."
showGapBetweenNotesInTimeline: "Mostrar um espaço entre as notas na linha de tempo"
duplicate: "Duplicar"
left: "Esquerda"
center: "Centralizar"
@ -1237,7 +1235,6 @@ showAvatarDecorations: "Exibir decorações de avatar"
releaseToRefresh: "Solte para atualizar"
refreshing: "Atualizando..."
pullDownToRefresh: "Puxe para baixo para atualizar"
disableStreamingTimeline: "Desabilitar atualizações em tempo real da linha do tempo"
useGroupedNotifications: "Agrupar notificações"
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."

View File

@ -250,7 +250,6 @@ noUsers: "Niciun utilizator"
editProfile: "Editează profilul"
noteDeleteConfirm: "Ești sigur(ă) că vrei să ștergi această notă?"
pinLimitExceeded: "Nu poți mai fixa mai multe note"
intro: "Misskey s-a instalat! Te rog crează un utilizator admin."
done: "Gata"
processing: "Se procesează"
preview: "Previzualizare"
@ -784,7 +783,6 @@ thisIsExperimentalFeature: "Aceasta este o funcție experimentală. Funcționali
developer: "Dezvoltator"
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\"."
showGapBetweenNotesInTimeline: "Afișați un decalaj între postările de pe cronologie"
duplicate: "Duplicat"
left: "Stânga"
center: "Centru"

View File

@ -251,7 +251,6 @@ noUsers: "Нет ни одного пользователя"
editProfile: "Редактировать профиль"
noteDeleteConfirm: "Вы хотите удалить эту заметку?"
pinLimitExceeded: "Нельзя закрепить ещё больше заметок"
intro: "Установка Misskey завершена! А теперь создайте учетную запись администратора."
done: "Готово"
processing: "Обработка"
preview: "Предпросмотр"
@ -785,7 +784,6 @@ thisIsExperimentalFeature: "Это экспериментальная функц
developer: "Разработчик"
makeExplorable: "Опубликовать профиль в «Обзоре»."
makeExplorableDescription: "Если выключить, ваш профиль не будет показан в разделе «Обзор»."
showGapBetweenNotesInTimeline: "Показывать разделитель между заметками в ленте"
duplicate: "Дубликат"
left: "Слева"
center: "По центру"
@ -1200,7 +1198,6 @@ privacyPolicyUrl: "Ссылка на Политику Конфиденциаль
attach: "Прикрепить"
angle: "Угол"
flip: "Переворот"
disableStreamingTimeline: "Отключить обновление ленты в режиме реального времени"
useGroupedNotifications: "Отображать уведомления сгруппировано"
doReaction: "Добавить реакцию"
code: "Код"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -250,7 +250,6 @@ noUsers: "Chưa có ai"
editProfile: "Sửa hồ sơ"
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"
intro: "Đã cài đặt Misskey! Xin hãy tạo tài khoản admin."
done: "Xong"
processing: "Đang xử lý"
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"
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á\"."
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"
left: "Bên trái"
center: "Giữa"

View File

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

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "2025.5.0",
"version": "2025.5.1-alpha.0",
"codename": "nasubi",
"repository": {
"type": "git",
@ -34,7 +34,7 @@
"watch": "pnpm dev",
"dev": "node scripts/dev.mjs",
"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",
"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",

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

@ -578,6 +578,20 @@ export class ChatService {
@bindThis
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);
if (deleter) {
@ -709,6 +723,12 @@ export class ChatService {
public async leaveRoom(userId: MiUser['id'], roomId: MiChatRoom['id']) {
const membership = await this.chatRoomMembershipsRepository.findOneByOrFail({ roomId, userId });
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

View File

@ -429,6 +429,7 @@ export class NoteEntityService implements OnModuleInit {
userId: channel.userId,
} : undefined,
mentions: note.mentions.length > 0 ? note.mentions : undefined,
hasPoll: note.hasPoll || undefined,
uri: note.uri ?? undefined,
url: note.url ?? undefined,
@ -593,4 +594,42 @@ export class NoteEntityService implements OnModuleInit {
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

@ -106,3 +106,6 @@ export class MiAntenna {
})
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[];
@Column('varchar', {
length: 128,
default: 'local',
})
public ugcVisibilityForVisitor: 'all' | 'local' | 'none';
@Column('varchar', {
length: 64,
nullable: true,
@ -669,6 +675,11 @@ export class MiMeta {
default: [],
})
public deliverSuspendedSoftware: SoftwareSuspension[];
@Column('boolean', {
default: false,
})
public singleUserMode: boolean;
}
export type SoftwareSuspension = {

View File

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

View File

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

View File

@ -11,17 +11,18 @@ import Logger from '@/logger.js';
import type { AntennasRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { Schema, SchemaType } from '@/misc/json-schema.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import { DBAntennaImportJobData } from '../types.js';
import type * as Bull from 'bullmq';
const Ajv = _Ajv.default;
const validate = new Ajv().compile({
const exportedAntennaSchema = {
type: 'object',
properties: {
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: {
type: 'array',
items: {
@ -47,9 +48,14 @@ const validate = new Ajv().compile({
excludeBots: { type: 'boolean' },
withReplies: { type: 'boolean' },
withFile: { type: 'boolean' },
excludeNotesInSensitiveChannel: { type: 'boolean' },
},
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()
export class ImportAntennasProcessorService {
@ -91,6 +97,7 @@ export class ImportAntennasProcessorService {
excludeBots: antenna.excludeBots,
withReplies: antenna.withReplies,
withFile: antenna.withFile,
excludeNotesInSensitiveChannel: antenna.excludeNotesInSensitiveChannel,
});
this.logger.succ('Antenna created: ' + result.id);
this.globalEventService.publishInternalEvent('antennaCreated', result);

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-by-tag' from './endpoints/notes/search-by-tag.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/thread-muting/create' from './endpoints/notes/thread-muting/create.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;
@ -691,6 +700,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
federation: instance.federation,
federationHosts: instance.federationHosts,
deliverSuspendedSoftware: instance.deliverSuspendedSoftware,
singleUserMode: instance.singleUserMode,
ugcVisibilityForVisitor: instance.ugcVisibilityForVisitor,
};
});
}

View File

@ -196,6 +196,11 @@ export const paramDef = {
required: ['software', 'versionRange'],
},
},
singleUserMode: { type: 'boolean' },
ugcVisibilityForVisitor: {
type: 'string',
enum: ['all', 'local', 'none'],
},
},
required: [],
} 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());
}
if (ps.singleUserMode !== undefined) {
set.singleUserMode = ps.singleUserMode;
}
if (ps.ugcVisibilityForVisitor !== undefined) {
set.ugcVisibilityForVisitor = ps.ugcVisibilityForVisitor;
}
const before = await this.metaService.fetch(true);
await this.metaService.update(set);

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
*/
import { Injectable } from '@nestjs/common';
import { Inject, 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 { DI } from '@/di-symbols.js';
import { MiMeta } from '@/models/Meta.js';
import { ApiError } from '../../error.js';
export const meta = {
@ -46,6 +48,9 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.meta)
private serverSettings: MiMeta,
private noteEntityService: NoteEntityService,
private getterService: GetterService,
) {
@ -59,6 +64,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
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, {
detail: true,
});

View File

@ -5,7 +5,7 @@
import { In, IsNull } from 'typeorm';
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 { Endpoint } from '@/server/api/endpoint-base.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
@ -82,6 +82,9 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.meta)
private serverSettings: MiMeta,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@ -92,6 +95,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private apiLoggerService: ApiLoggerService,
) {
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;
const isModerator = await this.roleService.isModerator(me);
@ -123,6 +130,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} else {
// Lookup user
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 => {
this.apiLoggerService.logger.warn(`failed to resolve remote user: ${err}`);
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);
}
if (this.serverSettings.ugcVisibilityForVisitor === 'local' && user.host != null && me == null) {
throw new ApiError(meta.errors.noSuchUser);
}
if (user.host == null) {
if (me == null && ip != null) {
this.perUserPvChart.commitByVisitor(user, ip);

View File

@ -513,7 +513,12 @@ export class ClientServerService {
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 me = profile.fields
? profile.fields
@ -577,7 +582,13 @@ export class ClientServerService {
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 profile = await this.userProfilesRepository.findOneByOrFail({ userId: note.userId });
reply.header('Cache-Control', 'public, max-age=15');

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();
try {
@ -169,8 +136,6 @@ export async function mainBoot() {
}
}
stream.on('announcementCreated', onAnnouncementCreated);
if ($i.isDeleted) {
alert({
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();
// 自分の情報が更新されたとき
main.on('meUpdated', i => {
updateCurrentAccountPartial(i);
});
main.on('readAllNotifications', () => {
updateCurrentAccountPartial({
hasUnreadNotification: false,
unreadNotificationsCount: 0,
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();
}
}
});
});
main.on('unreadNotification', () => {
const unreadNotificationsCount = ($i?.unreadNotificationsCount ?? 0) + 1;
updateCurrentAccountPartial({
hasUnreadNotification: true,
unreadNotificationsCount,
stream.on('emojiAdded', emojiData => {
addCustomEmoji(emojiData.emoji);
});
});
main.on('unreadAntenna', () => {
updateCurrentAccountPartial({ hasUnreadAntenna: true });
sound.playMisskeySfx('antenna');
});
stream.on('emojiUpdated', emojiData => {
updateCustomEmojis(emojiData.emojis);
});
main.on('newChatMessage', () => {
updateCurrentAccountPartial({ hasUnreadChatMessages: true });
sound.playMisskeySfx('chatMessage');
});
stream.on('emojiDeleted', emojiData => {
removeCustomEmojis(emojiData.emojis);
});
main.on('readAllAnnouncements', () => {
updateCurrentAccountPartial({ hasUnreadAnnouncement: false });
});
stream.on('announcementCreated', onAnnouncementCreated);
// 個人宛てお知らせが発行されたとき
main.on('announcementCreated', onAnnouncementCreated);
const main = markRaw(stream.useChannel('main', null, 'System'));
// トークンが再生成されたとき
// このままではMisskeyが利用できないので強制的にサインアウトさせる
main.on('myTokenRegenerated', () => {
signout();
});
// 自分の情報が更新されたとき
main.on('meUpdated', i => {
updateCurrentAccountPartial(i);
});
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

View File

@ -14,13 +14,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import type { Paging } from '@/components/MkPagination.vue';
import type { PagingCtx } from '@/use/use-pagination.js';
import MkChannelPreview from '@/components/MkChannelPreview.vue';
import MkPagination from '@/components/MkPagination.vue';
import { i18n } from '@/i18n.js';
const props = withDefaults(defineProps<{
pagination: Paging;
pagination: PagingCtx;
noGap?: boolean;
extractor?: (item: any) => any;
}>(), {

View File

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

View File

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

View File

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

View File

@ -12,6 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed } from 'vue';
import tinycolor from 'tinycolor2';
import { instanceName as localInstanceName } from '@@/js/config.js';
import type { CSSProperties } from 'vue';
import { instance as localInstance } from '@/instance.js';
@ -43,10 +44,33 @@ const faviconUrl = computed(() => {
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 themeColor = (props.host == null ? localInstance.themeColor : props.instance?.themeColor) ?? '#777777';
const colors = getTickerColors(themeColor);
return {
background: `linear-gradient(90deg, ${themeColor}, ${themeColor}00)`,
background: `linear-gradient(90deg, ${colors.bg}, ${colors.bg}00)`,
color: colors.fg,
};
});
</script>
@ -60,7 +84,6 @@ $height: 2ex;
height: $height;
border-radius: 4px 0 0 4px;
overflow: clip;
color: #fff;
// text-shadow使

View File

@ -6,11 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div
v-if="!hardMuted && muted === false"
v-show="!isDeleted"
ref="rootEl"
v-hotkey="keymap"
: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"/>
<div v-if="pinned" :class="$style.tip"><i class="ti ti-pin"></i> {{ i18n.ts.pinnedNote }}</div>
@ -87,7 +86,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="appearNote.files && appearNote.files.length > 0">
<MkMediaList ref="galleryEl" :mediaList="appearNote.files"/>
</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">
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview"/>
</div>
@ -101,7 +109,16 @@ SPDX-License-Identifier: AGPL-3.0-only
</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>
</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>
<MkA :to="`/notes/${appearNote.id}/reactions`" :class="[$style.reactionOmitted]">{{ i18n.ts.more }}</MkA>
</template>
@ -125,11 +142,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<i class="ti ti-ban"></i>
</button>
<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-else-if="appearNote.myReaction != null" class="ti ti-minus" style="color: var(--MI_THEME-accent);"></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.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></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 v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown.prevent="clip()">
<i class="ti ti-paperclip"></i>
@ -176,7 +193,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<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 Misskey from 'misskey-js';
import { isLink } from '@@/js/is-link.js';
@ -210,7 +227,7 @@ import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
import { $i } from '@/i.js';
import { i18n } from '@/i18n.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 '@/use/use-note-capture.js';
import { deepClone } from '@/utility/clone.js';
import { useTooltip } from '@/use/use-tooltip.js';
import { claimAchievement } from '@/utility/achievements.js';
@ -223,6 +240,7 @@ import { getAppearNote } from '@/utility/get-appear-note.js';
import { prefer } from '@/preferences.js';
import { getPluginHandlers } from '@/plugin.js';
import { DI } from '@/di.js';
import { globalEvents } from '@/events.js';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
@ -245,29 +263,33 @@ const tl_withSensitive = inject<Ref<boolean>>('tl_withSensitive', ref(true));
const inChannel = inject('inChannel', null);
const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null);
const note = ref(deepClone(props.note));
let note = deepClone(props.note);
// plugin
const noteViewInterruptors = getPluginHandlers('note_view_interruptor');
if (noteViewInterruptors.length > 0) {
onMounted(async () => {
let result: Misskey.entities.Note | null = deepClone(note.value);
let result: Misskey.entities.Note | null = deepClone(note);
for (const interruptor of noteViewInterruptors) {
try {
result = await interruptor.handler(result!) as Misskey.entities.Note | null;
if (result === null) {
isDeleted.value = true;
return;
}
} catch (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 menuButton = useTemplateRef('menuButton');
@ -275,32 +297,30 @@ const renoteButton = useTemplateRef('renoteButton');
const renoteTime = useTemplateRef('renoteTime');
const reactButton = useTemplateRef('reactButton');
const clipButton = useTemplateRef('clipButton');
const appearNote = computed(() => getAppearNote(note.value));
const galleryEl = useTemplateRef('galleryEl');
const isMyRenote = $i && ($i.id === note.value.userId);
const isMyRenote = $i && ($i.id === note.userId);
const showContent = ref(false);
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null);
const isLong = shouldCollapsed(appearNote.value, urls.value ?? []);
const collapsed = ref(appearNote.value.cw == null && isLong);
const isDeleted = ref(false);
const muted = ref(checkMute(appearNote.value, $i?.mutedWords));
const hardMuted = ref(props.withHardMute && checkMute(appearNote.value, $i?.hardMutedWords, true));
const parsed = computed(() => appearNote.text ? mfm.parse(appearNote.text) : null);
const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value).filter((url) => appearNote.renote?.url !== url && appearNote.renote?.uri !== url) : null);
const isLong = shouldCollapsed(appearNote, urls.value ?? []);
const collapsed = ref(appearNote.cw == null && isLong);
const muted = ref(checkMute(appearNote, $i?.mutedWords));
const hardMuted = ref(props.withHardMute && checkMute(appearNote, $i?.hardMutedWords, true));
const showSoftWordMutedWord = computed(() => prefer.s.showSoftWordMutedWord);
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
const translating = ref(false);
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance);
const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id));
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.user.instance);
const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || (appearNote.visibility === 'followers' && appearNote.userId === $i?.id));
const renoteCollapsed = ref(
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
(appearNote.value.myReaction != null)
($i && ($i.id === note.userId || $i.id === appearNote.userId)) || // `||` must be `||`! See https://github.com/misskey-dev/misskey/issues/13131
($appearNote.myReaction != null)
),
);
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
type: 'lookup',
url: `https://${host}/notes/${appearNote.value.id}`,
url: `https://${host}/notes/${appearNote.id}`,
}));
/* Overload FunctionLint
@ -357,7 +377,7 @@ const keymap = {
'v|enter': () => {
if (renoteCollapsed.value) {
renoteCollapsed.value = false;
} else if (appearNote.value.cw != null) {
} else if (appearNote.cw != null) {
showContent.value = !showContent.value;
} else if (isLong) {
collapsed.value = !collapsed.value;
@ -380,28 +400,28 @@ const keymap = {
provide(DI.mfmEmojiReactCallback, (reaction) => {
sound.playMisskeySfx('reaction');
misskeyApi('notes/reactions/create', {
noteId: appearNote.value.id,
noteId: appearNote.id,
reaction: reaction,
}).then(() => {
noteEvents.emit(`reacted:${appearNote.id}`, {
userId: $i!.id,
reaction: reaction,
});
});
});
if (props.mock) {
watch(() => props.note, (to) => {
note.value = deepClone(to);
}, { deep: true });
} else {
if (!props.mock) {
useNoteCapture({
rootEl: rootEl,
note: appearNote,
pureNote: note,
isDeletedRef: isDeleted,
parentNote: note,
$note: $appearNote,
});
}
if (!props.mock) {
useTooltip(renoteButton, async (showing) => {
const renotes = await misskeyApi('notes/renotes', {
noteId: appearNote.value.id,
noteId: appearNote.id,
limit: 11,
});
@ -412,19 +432,19 @@ if (!props.mock) {
const { dispose } = os.popup(MkUsersTooltip, {
showing,
users,
count: appearNote.value.renoteCount,
count: appearNote.renoteCount,
targetElement: renoteButton.value,
}, {
closed: () => dispose(),
});
});
if (appearNote.value.reactionAcceptance === 'likeOnly') {
if (appearNote.reactionAcceptance === 'likeOnly') {
useTooltip(reactButton, async (showing) => {
const reactions = await misskeyApiGet('notes/reactions', {
noteId: appearNote.value.id,
noteId: appearNote.id,
limit: 10,
_cacheKey_: appearNote.value.reactionCount,
_cacheKey_: $appearNote.reactionCount,
});
const users = reactions.map(x => x.user);
@ -435,7 +455,7 @@ if (!props.mock) {
showing,
reaction: '❤️',
users,
count: appearNote.value.reactionCount,
count: $appearNote.reactionCount,
targetElement: reactButton.value!,
}, {
closed: () => dispose(),
@ -448,7 +468,7 @@ function renote(viaKeyboard = false) {
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
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, {
viaKeyboard,
});
@ -460,8 +480,8 @@ function reply(): void {
return;
}
os.post({
reply: appearNote.value,
channel: appearNote.value.channel,
reply: appearNote,
channel: appearNote.channel,
}).then(() => {
focus();
});
@ -470,7 +490,7 @@ function reply(): void {
function react(): void {
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
showMovedDialog();
if (appearNote.value.reactionAcceptance === 'likeOnly') {
if (appearNote.reactionAcceptance === 'likeOnly') {
sound.playMisskeySfx('reaction');
if (props.mock) {
@ -478,8 +498,13 @@ function react(): void {
}
misskeyApi('notes/reactions/create', {
noteId: appearNote.value.id,
noteId: appearNote.id,
reaction: '❤️',
}).then(() => {
noteEvents.emit(`reacted:${appearNote.id}`, {
userId: $i!.id,
reaction: '❤️',
});
});
const el = reactButton.value;
if (el && prefer.s.animation) {
@ -492,7 +517,7 @@ function react(): void {
}
} else {
blur();
reactionPicker.show(reactButton.value ?? null, note.value, async (reaction) => {
reactionPicker.show(reactButton.value ?? null, note, async (reaction) => {
if (prefer.s.confirmOnReact) {
const confirm = await os.confirm({
type: 'question',
@ -506,14 +531,23 @@ function react(): void {
if (props.mock) {
emit('reaction', reaction);
$appearNote.reactions[reaction] = 1;
$appearNote.reactionCount++;
$appearNote.myReaction = reaction;
return;
}
misskeyApi('notes/reactions/create', {
noteId: appearNote.value.id,
noteId: appearNote.id,
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');
}
}, () => {
@ -522,8 +556,8 @@ function react(): void {
}
}
function undoReact(targetNote: Misskey.entities.Note): void {
const oldReaction = targetNote.myReaction;
function undoReact(): void {
const oldReaction = $appearNote.myReaction;
if (!oldReaction) return;
if (props.mock) {
@ -532,15 +566,15 @@ function undoReact(targetNote: Misskey.entities.Note): void {
}
misskeyApi('notes/reactions/delete', {
noteId: targetNote.id,
noteId: appearNote.id,
});
}
function toggleReact() {
if (appearNote.value.myReaction == null) {
if ($appearNote.myReaction == null) {
react();
} else {
undoReact(appearNote.value);
undoReact();
}
}
@ -556,7 +590,7 @@ function onContextmenu(ev: MouseEvent): void {
ev.preventDefault();
react();
} 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);
}
}
@ -566,7 +600,7 @@ function showMenu(): void {
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);
}
@ -575,7 +609,7 @@ async function clip(): Promise<void> {
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 {
@ -590,9 +624,10 @@ function showRenoteMenu(): void {
danger: true,
action: () => {
misskeyApi('notes/delete', {
noteId: note.value.id,
noteId: note.id,
}).then(() => {
globalEvents.emit('noteDeleted', note.id);
});
isDeleted.value = true;
},
};
}
@ -601,23 +636,23 @@ function showRenoteMenu(): void {
type: 'link',
text: i18n.ts.renoteDetails,
icon: 'ti ti-info-circle',
to: notePage(note.value),
to: notePage(note),
};
if (isMyRenote) {
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
os.popupMenu([
renoteDetailsMenu,
getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote),
getCopyNoteLinkMenu(note, i18n.ts.copyLinkRenote),
{ type: 'divider' },
getUnrenote(),
], renoteTime.value);
} else {
os.popupMenu([
renoteDetailsMenu,
getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote),
getCopyNoteLinkMenu(note, i18n.ts.copyLinkRenote),
{ type: 'divider' },
getAbuseNoteMenu(note.value, i18n.ts.reportAbuseRenote),
getAbuseNoteMenu(note, i18n.ts.reportAbuseRenote),
($i?.isModerator || $i?.isAdmin) ? getUnrenote() : undefined,
], renoteTime.value);
}
@ -641,9 +676,8 @@ function focusAfter() {
function readPromo() {
misskeyApi('promo/read', {
noteId: appearNote.value.id,
noteId: appearNote.id,
});
isDeleted.value = true;
}
function emitUpdReaction(emoji: string, delta: number) {

View File

@ -5,12 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div
v-if="!muted"
v-show="!isDeleted"
v-if="!muted && !isDeleted"
ref="rootEl"
v-hotkey="keymap"
:class="$style.root"
:tabindex="isDeleted ? '-1' : '0'"
tabindex="0"
>
<div v-if="appearNote.reply && appearNote.reply.replyId">
<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">
<MkMediaList ref="galleryEl" :mediaList="appearNote.files"/>
</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">
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/>
</div>
@ -124,7 +132,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkTime :time="appearNote.createdAt" mode="detail" colored/>
</MkA>
</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()">
<i class="ti ti-arrow-back-up"></i>
<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>
</button>
<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-else-if="appearNote.myReaction != null" class="ti ti-minus" style="color: var(--MI_THEME-accent);"></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.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></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 v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="clip()">
<i class="ti ti-paperclip"></i>
@ -182,9 +199,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div v-else-if="tab === 'reactions'" :class="$style.tab_reactions">
<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"/>
<span style="margin-left: 4px;">{{ appearNote.reactions[reaction] }}</span>
<span style="margin-left: 4px;">{{ $appearNote.reactions[reaction] }}</span>
</button>
</div>
<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 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">
<template #name>
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
@ -211,13 +228,12 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<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 Misskey from 'misskey-js';
import { isLink } from '@@/js/is-link.js';
import { host } from '@@/js/config.js';
import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
import type { Paging } from '@/components/MkPagination.vue';
import type { Keymap } from '@/utility/hotkey.js';
import MkNoteSub from '@/components/MkNoteSub.vue';
import MkNoteSimple from '@/components/MkNoteSimple.vue';
@ -242,7 +258,7 @@ import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
import { $i } from '@/i.js';
import { i18n } from '@/i18n.js';
import { getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js';
import { useNoteCapture } from '@/use/use-note-capture.js';
import { noteEvents, useNoteCapture } from '@/use/use-note-capture.js';
import { deepClone } from '@/utility/clone.js';
import { useTooltip } from '@/use/use-tooltip.js';
import { claimAchievement } from '@/utility/achievements.js';
@ -257,6 +273,7 @@ import { getAppearNote } from '@/utility/get-appear-note.js';
import { prefer } from '@/preferences.js';
import { getPluginHandlers } from '@/plugin.js';
import { DI } from '@/di.js';
import { globalEvents, useGlobalEvent } from '@/events.js';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
@ -267,29 +284,33 @@ const props = withDefaults(defineProps<{
const inChannel = inject('inChannel', null);
const note = ref(deepClone(props.note));
let note = deepClone(props.note);
// plugin
const noteViewInterruptors = getPluginHandlers('note_view_interruptor');
if (noteViewInterruptors.length > 0) {
onMounted(async () => {
let result: Misskey.entities.Note | null = deepClone(note.value);
let result: Misskey.entities.Note | null = deepClone(note);
for (const interruptor of noteViewInterruptors) {
try {
result = await interruptor.handler(result!) as Misskey.entities.Note | null;
if (result === null) {
isDeleted.value = true;
return;
}
} catch (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 menuButton = useTemplateRef('menuButton');
@ -297,24 +318,29 @@ const renoteButton = useTemplateRef('renoteButton');
const renoteTime = useTemplateRef('renoteTime');
const reactButton = useTemplateRef('reactButton');
const clipButton = useTemplateRef('clipButton');
const appearNote = computed(() => getAppearNote(note.value));
const galleryEl = useTemplateRef('galleryEl');
const isMyRenote = $i && ($i.id === note.value.userId);
const isMyRenote = $i && ($i.id === note.userId);
const showContent = 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 translating = ref(false);
const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null;
const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null;
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.value.user.instance);
const parsed = appearNote.text ? mfm.parse(appearNote.text) : 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.user.instance);
const conversation = 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>(() => ({
type: 'lookup',
url: `https://${host}/notes/${appearNote.value.id}`,
url: `https://${host}/notes/${appearNote.id}`,
}));
const keymap = {
@ -328,7 +354,7 @@ const keymap = {
},
'o': () => galleryEl.value?.openGallery(),
'v|enter': () => {
if (appearNote.value.cw != null) {
if (appearNote.cw != null) {
showContent.value = !showContent.value;
}
},
@ -341,41 +367,45 @@ const keymap = {
provide(DI.mfmEmojiReactCallback, (reaction) => {
sound.playMisskeySfx('reaction');
misskeyApi('notes/reactions/create', {
noteId: appearNote.value.id,
noteId: appearNote.id,
reaction: reaction,
}).then(() => {
noteEvents.emit(`reacted:${appearNote.id}`, {
userId: $i!.id,
reaction: reaction,
});
});
});
const tab = ref(props.initialTab);
const reactionTabType = ref<string | null>(null);
const renotesPagination = computed<Paging>(() => ({
const renotesPagination = computed(() => ({
endpoint: 'notes/renotes',
limit: 10,
params: {
noteId: appearNote.value.id,
noteId: appearNote.id,
},
}));
const reactionsPagination = computed<Paging>(() => ({
const reactionsPagination = computed(() => ({
endpoint: 'notes/reactions',
limit: 10,
params: {
noteId: appearNote.value.id,
noteId: appearNote.id,
type: reactionTabType.value,
},
}));
useNoteCapture({
rootEl: rootEl,
note: appearNote,
pureNote: note,
isDeletedRef: isDeleted,
parentNote: note,
$note: $appearNote,
});
useTooltip(renoteButton, async (showing) => {
const renotes = await misskeyApi('notes/renotes', {
noteId: appearNote.value.id,
noteId: appearNote.id,
limit: 11,
});
@ -386,19 +416,19 @@ useTooltip(renoteButton, async (showing) => {
const { dispose } = os.popup(MkUsersTooltip, {
showing,
users,
count: appearNote.value.renoteCount,
count: appearNote.renoteCount,
targetElement: renoteButton.value,
}, {
closed: () => dispose(),
});
});
if (appearNote.value.reactionAcceptance === 'likeOnly') {
if (appearNote.reactionAcceptance === 'likeOnly') {
useTooltip(reactButton, async (showing) => {
const reactions = await misskeyApiGet('notes/reactions', {
noteId: appearNote.value.id,
noteId: appearNote.id,
limit: 10,
_cacheKey_: appearNote.value.reactionCount,
_cacheKey_: $appearNote.reactionCount,
});
const users = reactions.map(x => x.user);
@ -409,7 +439,7 @@ if (appearNote.value.reactionAcceptance === 'likeOnly') {
showing,
reaction: '❤️',
users,
count: appearNote.value.reactionCount,
count: $appearNote.reactionCount,
targetElement: reactButton.value!,
}, {
closed: () => dispose(),
@ -421,7 +451,7 @@ function renote() {
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
showMovedDialog();
const { menu } = getRenoteMenu({ note: note.value, renoteButton });
const { menu } = getRenoteMenu({ note: note, renoteButton });
os.popupMenu(menu, renoteButton.value);
}
@ -429,8 +459,8 @@ function reply(): void {
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
showMovedDialog();
os.post({
reply: appearNote.value,
channel: appearNote.value.channel,
reply: appearNote,
channel: appearNote.channel,
}).then(() => {
focus();
});
@ -439,12 +469,17 @@ function reply(): void {
function react(): void {
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
showMovedDialog();
if (appearNote.value.reactionAcceptance === 'likeOnly') {
if (appearNote.reactionAcceptance === 'likeOnly') {
sound.playMisskeySfx('reaction');
misskeyApi('notes/reactions/create', {
noteId: appearNote.value.id,
noteId: appearNote.id,
reaction: '❤️',
}).then(() => {
noteEvents.emit(`reacted:${appearNote.id}`, {
userId: $i!.id,
reaction: '❤️',
});
});
const el = reactButton.value;
if (el && prefer.s.animation) {
@ -457,7 +492,7 @@ function react(): void {
}
} else {
blur();
reactionPicker.show(reactButton.value ?? null, note.value, async (reaction) => {
reactionPicker.show(reactButton.value ?? null, note, async (reaction) => {
if (prefer.s.confirmOnReact) {
const confirm = await os.confirm({
type: 'question',
@ -470,10 +505,15 @@ function react(): void {
sound.playMisskeySfx('reaction');
misskeyApi('notes/reactions/create', {
noteId: appearNote.value.id,
noteId: appearNote.id,
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');
}
}, () => {
@ -491,10 +531,10 @@ function undoReact(targetNote: Misskey.entities.Note): void {
}
function toggleReact() {
if (appearNote.value.myReaction == null) {
if (appearNote.myReaction == null) {
react();
} else {
undoReact(appearNote.value);
undoReact(appearNote);
}
}
@ -506,18 +546,18 @@ function onContextmenu(ev: MouseEvent): void {
ev.preventDefault();
react();
} 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);
}
}
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);
}
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 {
@ -529,9 +569,10 @@ function showRenoteMenu(): void {
danger: true,
action: () => {
misskeyApi('notes/delete', {
noteId: note.value.id,
noteId: note.id,
}).then(() => {
globalEvents.emit('noteDeleted', note.id);
});
isDeleted.value = true;
},
}], renoteTime.value);
}
@ -549,7 +590,7 @@ const repliesLoaded = ref(false);
function loadReplies() {
repliesLoaded.value = true;
misskeyApi('notes/children', {
noteId: appearNote.value.id,
noteId: appearNote.id,
limit: 30,
}).then(res => {
replies.value = res;
@ -560,9 +601,9 @@ const conversationLoaded = ref(false);
function loadConversation() {
conversationLoaded.value = true;
if (appearNote.value.replyId == null) return;
if (appearNote.replyId == null) return;
misskeyApi('notes/conversation', {
noteId: appearNote.value.replyId,
noteId: appearNote.replyId,
}).then(res => {
conversation.value = res.reverse();
});

View File

@ -4,13 +4,21 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkPagination ref="pagingComponent" :pagination="pagination" :disableAutoLoad="disableAutoLoad">
<MkPagination ref="pagingComponent" :pagination="pagination" :disableAutoLoad="disableAutoLoad" :pullToRefresh="pullToRefresh">
<template #empty><MkResult type="empty" :text="i18n.ts.noNotes"/></template>
<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">
<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"/>
<div :class="$style.ad">
<MkAd :preferForms="['horizontal', 'horizontal-big']"/>
@ -25,30 +33,38 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { useTemplateRef } from 'vue';
import type { Paging } from '@/components/MkPagination.vue';
import type { PagingCtx } from '@/use/use-pagination.js';
import MkNote from '@/components/MkNote.vue';
import MkPagination from '@/components/MkPagination.vue';
import { i18n } from '@/i18n.js';
import { globalEvents, useGlobalEvent } from '@/events.js';
import { isSeparatorNeeded, getSeparatorInfo } from '@/utility/timeline-date-separate.js';
const props = defineProps<{
pagination: Paging;
const props = withDefaults(defineProps<{
pagination: PagingCtx;
noGap?: boolean;
disableAutoLoad?: boolean;
}>();
pullToRefresh?: boolean;
}>(), {
pullToRefresh: true,
});
const pagingComponent = useTemplateRef('pagingComponent');
useGlobalEvent('noteDeleted', (noteId) => {
pagingComponent.value?.paginator.removeItem(noteId);
});
function reload() {
return pagingComponent.value?.paginator.reload();
}
defineExpose({
pagingComponent,
reload,
});
</script>
<style lang="scss" module>
.reverse {
display: flex;
flex-direction: column-reverse;
}
.root {
container-type: inline-size;
@ -77,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 {
display: none;
}

View File

@ -1,142 +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><MkResult type="empty" :text="i18n.ts.noNotifications"/></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 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,483 +4,74 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<Transition
:enterActiveClass="prefer.s.animation ? $style.transition_fade_enterActive : ''"
:leaveActiveClass="prefer.s.animation ? $style.transition_fade_leaveActive : ''"
:enterFromClass="prefer.s.animation ? $style.transition_fade_enterFrom : ''"
:leaveToClass="prefer.s.animation ? $style.transition_fade_leaveTo : ''"
mode="out-in"
>
<MkLoading v-if="fetching"/>
<component :is="prefer.s.enablePullToRefresh && pullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => paginator.reload()">
<Transition
:enterActiveClass="prefer.s.animation ? $style.transition_fade_enterActive : ''"
:leaveActiveClass="prefer.s.animation ? $style.transition_fade_leaveActive : ''"
:enterFromClass="prefer.s.animation ? $style.transition_fade_enterFrom : ''"
:leaveToClass="prefer.s.animation ? $style.transition_fade_leaveTo : ''"
:css="prefer.s.animation"
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_">
<slot name="empty"><MkResult type="empty"/></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 v-else-if="paginator.items.value.length === 0" key="_empty_">
<slot name="empty"><MkResult type="empty"/></slot>
</div>
<slot :items="Array.from(items.values())" :fetching="fetching || moreFetching"></slot>
<div v-show="!pagination.reversed && more" key="_more_">
<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 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>
</div>
</Transition>
</Transition>
</component>
</template>
<script lang="ts">
import { computed, isRef, nextTick, onActivated, onBeforeMount, onBeforeUnmount, onDeactivated, ref, useTemplateRef, watch } from 'vue';
import * as Misskey from 'misskey-js';
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';
<script lang="ts" setup>
import type { PagingCtx } from '@/use/use-pagination.js';
import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n.js';
import { prefer } from '@/preferences.js';
const SECOND_FETCH_LIMIT = 30;
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 MkButton from '@/components/MkButton.vue';
import { usePagination } from '@/use/use-pagination.js';
import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
const props = withDefaults(defineProps<{
pagination: Paging;
pagination: PagingCtx;
disableAutoLoad?: boolean;
displayLimit?: number;
pullToRefresh?: boolean;
}>(), {
displayLimit: 20,
pullToRefresh: true,
});
const emit = defineEmits<{
(ev: 'queue', count: number): void;
(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);
});
const paginator = usePagination({
ctx: props.pagination,
});
watch([backed, rootEl], () => {
if (!backed.value) {
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;
});
function appearFetchMoreAhead() {
paginator.fetchNewer();
}
const reload = (): Promise<void> => {
return init();
};
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);
function appearFetchMore() {
paginator.fetchOlder();
}
/**
* 新着アイテムを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({
items,
queue,
backed: backed.value,
more,
reload,
prepend,
append: appendItem,
removeItem,
updateItem,
paginator: paginator,
});
</script>

View File

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div :class="{ [$style.done]: closed || isVoted }">
<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>
<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>
@ -40,7 +40,9 @@ import { i18n } from '@/i18n.js';
const props = defineProps<{
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;
emojiUrls?: Record<string, string>;
author?: Misskey.entities.UserLite;
@ -48,9 +50,9 @@ const props = defineProps<{
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 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[
remaining.value >= 86400 ? 'remainingDays' :
remaining.value >= 3600 ? 'remainingHours' :
@ -70,9 +72,9 @@ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
}));
//
if (props.poll.expiresAt) {
if (props.expiresAt) {
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) {
showResult.value = true;
}
@ -91,7 +93,7 @@ const vote = async (id) => {
const { canceled } = await os.confirm({
type: 'question',
text: i18n.tsx.voteConfirm({ choice: props.poll.choices[id].text }),
text: i18n.tsx.voteConfirm({ choice: props.choices[id].text }),
});
if (canceled) return;
@ -99,7 +101,7 @@ const vote = async (id) => {
noteId: props.noteId,
choice: id,
});
if (!showResult.value) showResult.value = !props.poll.multiple;
if (!showResult.value) showResult.value = !props.multiple;
};
</script>

View File

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

View File

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

View File

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

View File

@ -9,6 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<slot name="label"></slot>
</div>
<div v-adaptive-border class="body">
<slot name="prefix"></slot>
<div ref="containerEl" class="container">
<div class="track">
<div class="highlight" :style="{ width: (steppedRawValue * 100) + '%' }"></div>
@ -25,6 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
@touchstart="onMousedown"
></div>
</div>
<slot name="suffix"></slot>
</div>
<div class="caption">
<slot name="caption"></slot>
@ -224,12 +226,17 @@ function onMousedown(ev: MouseEvent | TouchEvent) {
$thumbWidth: 20px;
> .body {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 7px 12px;
background: var(--MI_THEME-panel);
border: solid 1px var(--MI_THEME-panel);
border-radius: 6px;
> .container {
flex: 1;
position: relative;
height: $thumbHeight;

View File

@ -8,11 +8,11 @@ SPDX-License-Identifier: AGPL-3.0-only
ref="buttonEl"
v-ripple="canToggle"
class="_button"
:class="[$style.root, { [$style.reacted]: note.myReaction == reaction, [$style.canToggle]: canToggle, [$style.small]: prefer.s.reactionsDisplaySize === 'small', [$style.large]: prefer.s.reactionsDisplaySize === 'large' }]"
:class="[$style.root, { [$style.reacted]: myReaction == reaction, [$style.canToggle]: canToggle, [$style.small]: prefer.s.reactionsDisplaySize === 'small', [$style.large]: prefer.s.reactionsDisplaySize === 'large' }]"
@click="toggleReaction()"
@contextmenu.prevent.stop="menu"
>
<MkReactionIcon style="pointer-events: none;" :class="prefer.s.limitWidthOfReaction ? $style.limitWidth : ''" :reaction="reaction" :emojiUrl="note.reactionEmojis[reaction.substring(1, reaction.length - 1)]"/>
<MkReactionIcon style="pointer-events: none;" :class="prefer.s.limitWidthOfReaction ? $style.limitWidth : ''" :reaction="reaction" :emojiUrl="reactionEmojis[reaction.substring(1, reaction.length - 1)]"/>
<span :class="$style.count">{{ count }}</span>
</button>
</template>
@ -29,19 +29,21 @@ import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js';
import { useTooltip } from '@/use/use-tooltip.js';
import { $i } from '@/i.js';
import MkReactionEffect from '@/components/MkReactionEffect.vue';
import { claimAchievement } from '@/utility/achievements.js';
import { i18n } from '@/i18n.js';
import * as sound from '@/utility/sound.js';
import { checkReactionPermissions } from '@/utility/check-reaction-permissions.js';
import { customEmojisMap } from '@/custom-emojis.js';
import { prefer } from '@/preferences.js';
import { DI } from '@/di.js';
import { noteEvents } from '@/use/use-note-capture.js';
const props = defineProps<{
noteId: Misskey.entities.Note['id'];
reaction: string;
reactionEmojis: Misskey.entities.Note['reactionEmojis'];
myReaction: Misskey.entities.Note['myReaction'];
count: number;
isInitial: boolean;
note: Misskey.entities.Note;
}>();
const mock = inject(DI.mock, false);
@ -56,14 +58,16 @@ const emojiName = computed(() => props.reaction.replace(/:/g, '').replace(/@\./,
const emoji = computed(() => customEmojisMap.get(emojiName.value) ?? getUnicodeEmoji(props.reaction));
const canToggle = computed(() => {
return !props.reaction.match(/@\w/) && $i && emoji.value && checkReactionPermissions($i, props.note, emoji.value);
// TODO
//return !props.reaction.match(/@\w/) && $i && emoji.value && checkReactionPermissions($i, props.note, emoji.value);
return !props.reaction.match(/@\w/) && $i && emoji.value;
});
const canGetInfo = computed(() => !props.reaction.match(/@\w/) && props.reaction.includes(':'));
async function toggleReaction() {
if (!canToggle.value) return;
const oldReaction = props.note.myReaction;
const oldReaction = props.myReaction;
if (oldReaction) {
const confirm = await os.confirm({
type: 'warning',
@ -81,12 +85,23 @@ async function toggleReaction() {
}
misskeyApi('notes/reactions/delete', {
noteId: props.note.id,
noteId: props.noteId,
}).then(() => {
noteEvents.emit(`unreacted:${props.noteId}`, {
userId: $i!.id,
reaction: props.reaction,
emoji: emoji.value,
});
if (oldReaction !== props.reaction) {
misskeyApi('notes/reactions/create', {
noteId: props.note.id,
noteId: props.noteId,
reaction: props.reaction,
}).then(() => {
noteEvents.emit(`reacted:${props.noteId}`, {
userId: $i!.id,
reaction: props.reaction,
emoji: emoji.value,
});
});
}
});
@ -108,12 +123,19 @@ async function toggleReaction() {
}
misskeyApi('notes/reactions/create', {
noteId: props.note.id,
noteId: props.noteId,
reaction: props.reaction,
}).then(() => {
noteEvents.emit(`reacted:${props.noteId}`, {
userId: $i!.id,
reaction: props.reaction,
emoji: emoji.value,
});
});
if (props.note.text && props.note.text.length > 100 && (Date.now() - new Date(props.note.createdAt).getTime() < 1000 * 3)) {
claimAchievement('reactWithoutRead');
}
// TODO:
//if (props.note.text && props.note.text.length > 100 && (Date.now() - new Date(props.note.createdAt).getTime() < 1000 * 3)) {
// claimAchievement('reactWithoutRead');
//}
}
}
@ -157,7 +179,7 @@ onMounted(() => {
if (!mock) {
useTooltip(buttonEl, async (showing) => {
const reactions = await misskeyApiGet('notes/reactions', {
noteId: props.note.id,
noteId: props.noteId,
type: props.reaction,
limit: 10,
_cacheKey_: props.count,

View File

@ -13,7 +13,17 @@ SPDX-License-Identifier: AGPL-3.0-only
:moveClass="$style.transition_x_move"
tag="div" :class="$style.root"
>
<XReaction v-for="[reaction, count] in reactions" :key="reaction" :reaction="reaction" :count="count" :isInitial="initialReactions.has(reaction)" :note="note" @reactionToggled="onMockToggleReaction"/>
<XReaction
v-for="[reaction, count] in _reactions"
:key="reaction"
:reaction="reaction"
:reactionEmojis="props.reactionEmojis"
:count="count"
:isInitial="initialReactions.has(reaction)"
:noteId="props.noteId"
:myReaction="props.myReaction"
@reactionToggled="onMockToggleReaction"
/>
<slot v-if="hasMoreReactions" name="more"/>
</component>
</template>
@ -27,7 +37,10 @@ import { prefer } from '@/preferences.js';
import { DI } from '@/di.js';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
noteId: Misskey.entities.Note['id'];
reactions: Misskey.entities.Note['reactions'];
reactionEmojis: Misskey.entities.Note['reactionEmojis'];
myReaction: Misskey.entities.Note['myReaction'];
maxNumber?: number;
}>(), {
maxNumber: Infinity,
@ -39,33 +52,33 @@ const emit = defineEmits<{
(ev: 'mockUpdateMyReaction', emoji: string, delta: number): void;
}>();
const initialReactions = new Set(Object.keys(props.note.reactions));
const initialReactions = new Set(Object.keys(props.reactions));
const reactions = ref<[string, number][]>([]);
const _reactions = ref<[string, number][]>([]);
const hasMoreReactions = ref(false);
if (props.note.myReaction && !Object.keys(reactions.value).includes(props.note.myReaction)) {
reactions.value[props.note.myReaction] = props.note.reactions[props.note.myReaction];
if (props.myReaction && !Object.keys(_reactions.value).includes(props.myReaction)) {
_reactions.value[props.myReaction] = props.reactions[props.myReaction];
}
function onMockToggleReaction(emoji: string, count: number) {
if (!mock) return;
const i = reactions.value.findIndex((item) => item[0] === emoji);
const i = _reactions.value.findIndex((item) => item[0] === emoji);
if (i < 0) return;
emit('mockUpdateMyReaction', emoji, (count - reactions.value[i][1]));
emit('mockUpdateMyReaction', emoji, (count - _reactions.value[i][1]));
}
watch([() => props.note.reactions, () => props.maxNumber], ([newSource, maxNumber]) => {
watch([() => props.reactions, () => props.maxNumber], ([newSource, maxNumber]) => {
let newReactions: [string, number][] = [];
hasMoreReactions.value = Object.keys(newSource).length > maxNumber;
for (let i = 0; i < reactions.value.length; i++) {
const reaction = reactions.value[i][0];
for (let i = 0; i < _reactions.value.length; i++) {
const reaction = _reactions.value[i][0];
if (reaction in newSource && newSource[reaction] !== 0) {
reactions.value[i][1] = newSource[reaction];
newReactions.push(reactions.value[i]);
_reactions.value[i][1] = newSource[reaction];
newReactions.push(_reactions.value[i]);
}
}
@ -79,11 +92,11 @@ watch([() => props.note.reactions, () => props.maxNumber], ([newSource, maxNumbe
newReactions = newReactions.slice(0, props.maxNumber);
if (props.note.myReaction && !newReactions.map(([x]) => x).includes(props.note.myReaction)) {
newReactions.push([props.note.myReaction, newSource[props.note.myReaction]]);
if (props.myReaction && !newReactions.map(([x]) => x).includes(props.myReaction)) {
newReactions.push([props.myReaction, newSource[props.myReaction]]);
}
reactions.value = newReactions;
_reactions.value = newReactions;
}, { immediate: true, deep: true });
</script>

View File

@ -56,7 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { computed, ref, useTemplateRef } from 'vue';
import MkKeyValue from '@/components/MkKeyValue.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
@ -81,7 +81,7 @@ const emit = defineEmits<{
(ev: 'closed'): void
}>();
const windowEl = ref<InstanceType<typeof MkWindow> | null>(null);
const windowEl = useTemplateRef('windowEl');
const name = computed(() => props.emoji.name);
const host = computed(() => props.emoji.host);

View File

@ -43,7 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script setup lang="ts">
import { computed, ref, toRefs } from 'vue';
import { computed, ref, toRefs, useTemplateRef } from 'vue';
import * as Misskey from 'misskey-js';
import { i18n } from '@/i18n.js';
import MkButton from '@/components/MkButton.vue';
@ -74,7 +74,7 @@ const props = withDefaults(defineProps<{
const { initialRoleIds, infoMessage, title, publicOnly } = toRefs(props);
const windowEl = ref<InstanceType<typeof MkModalWindow>>();
const windowEl = useTemplateRef('windowEl');
const roles = ref<Misskey.entities.Role[]>([]);
const selectedRoleIds = ref<string[]>(initialRoleIds.value ?? []);
const fetching = ref(false);

View File

@ -0,0 +1,356 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.root" class="_gaps_m">
<MkInput v-model="q_name" data-cy-server-name>
<template #label>{{ i18n.ts.instanceName }}</template>
</MkInput>
<MkFolder :defaultOpen="true">
<template #label>{{ i18n.ts._serverSetupWizard.howWillYouUseMisskey }}</template>
<template #icon><i class="ti ti-settings-question"></i></template>
<div class="_gaps_s">
<MkRadios v-model="q_use" :vertical="true">
<option value="single">
<div><i class="ti ti-user"></i> <b>{{ i18n.ts._serverSetupWizard._use.single }}</b></div>
<div>{{ i18n.ts._serverSetupWizard._use.single_description }}</div>
</option>
<option value="group">
<div><i class="ti ti-lock"></i> <b>{{ i18n.ts._serverSetupWizard._use.group }}</b></div>
<div>{{ i18n.ts._serverSetupWizard._use.group_description }}</div>
</option>
<option value="open">
<div><i class="ti ti-world"></i> <b>{{ i18n.ts._serverSetupWizard._use.open }}</b></div>
<div>{{ i18n.ts._serverSetupWizard._use.open_description }}</div>
</option>
</MkRadios>
<MkInfo v-if="q_use === 'single'">{{ i18n.ts._serverSetupWizard._use.single_youCanCreateMultipleAccounts }}</MkInfo>
<MkInfo v-if="q_use === 'open'" warn><b>{{ i18n.ts.advice }}:</b> {{ i18n.ts._serverSetupWizard.openServerAdvice }}</MkInfo>
<MkInfo v-if="q_use === 'open'" warn><b>{{ i18n.ts.advice }}:</b> {{ i18n.ts._serverSetupWizard.openServerAntiSpamAdvice }}</MkInfo>
</div>
</MkFolder>
<MkFolder v-if="q_use !== 'single'" :defaultOpen="true">
<template #label>{{ i18n.ts._serverSetupWizard.howManyUsersDoYouExpect }}</template>
<template #icon><i class="ti ti-users"></i></template>
<div class="_gaps_s">
<MkRadios v-model="q_scale" :vertical="true">
<option value="small"><i class="ti ti-user"></i> {{ i18n.ts._serverSetupWizard._scale.small }}</option>
<option value="medium"><i class="ti ti-users"></i> {{ i18n.ts._serverSetupWizard._scale.medium }}</option>
<option value="large"><i class="ti ti-users-group"></i> {{ i18n.ts._serverSetupWizard._scale.large }}</option>
</MkRadios>
<MkInfo v-if="q_scale === 'large'"><b>{{ i18n.ts.advice }}:</b> {{ i18n.ts._serverSetupWizard.largeScaleServerAdvice }}</MkInfo>
</div>
</MkFolder>
<MkFolder :defaultOpen="true">
<template #label>{{ i18n.ts._serverSetupWizard.doYouConnectToFediverse }}</template>
<template #icon><i class="ti ti-planet"></i></template>
<div class="_gaps_s">
<div>{{ i18n.ts._serverSetupWizard.doYouConnectToFediverse_description1 }}<br>{{ i18n.ts._serverSetupWizard.doYouConnectToFediverse_description2 }}</div>
<MkRadios v-model="q_federation" :vertical="true">
<option value="yes">{{ i18n.ts.yes }}</option>
<option value="no">{{ i18n.ts.no }}</option>
</MkRadios>
<MkInfo v-if="q_federation === 'yes'">{{ i18n.ts._serverSetupWizard.youCanConfigureMoreFederationSettingsLater }}</MkInfo>
</div>
</MkFolder>
<MkFolder v-if="q_use === 'open' || q_federation === 'yes'" :defaultOpen="true">
<template #label>{{ i18n.ts._serverSetupWizard.adminInfo }}</template>
<template #icon><i class="ti ti-mail"></i></template>
<div class="_gaps_s">
<div>{{ i18n.ts._serverSetupWizard.adminInfo_description }}</div>
<MkInfo warn>{{ i18n.ts._serverSetupWizard.adminInfo_mustBeFilled }}</MkInfo>
<MkInput v-model="q_adminName">
<template #label>{{ i18n.ts.maintainerName }}</template>
</MkInput>
<MkInput v-model="q_adminEmail" type="email">
<template #label>{{ i18n.ts.maintainerEmail }}</template>
</MkInput>
</div>
</MkFolder>
<MkFolder :defaultOpen="true" :maxHeight="300">
<template #label>{{ i18n.ts._serverSetupWizard.followingSettingsAreRecommended }}</template>
<template #icon><i class="ti ti-adjustments-alt"></i></template>
<div class="_gaps_s">
<div>
<div><b>{{ i18n.ts._serverSettings.singleUserMode }}:</b></div>
<div>{{ serverSettings.singleUserMode ? i18n.ts.yes : i18n.ts.no }}</div>
</div>
<div>
<div><b>{{ i18n.ts._serverSettings.openRegistration }}:</b></div>
<div>{{ !serverSettings.disableRegistration ? i18n.ts.yes : i18n.ts.no }}</div>
</div>
<div>
<div><b>{{ i18n.ts.emailRequiredForSignup }}:</b></div>
<div>{{ serverSettings.emailRequiredForSignup ? i18n.ts.yes : i18n.ts.no }}</div>
</div>
<div>
<div><b>Log IP:</b></div>
<div>{{ serverSettings.enableIpLogging ? i18n.ts.yes : i18n.ts.no }}</div>
</div>
<div>
<div><b>{{ i18n.ts.federation }}:</b></div>
<div>{{ serverSettings.federation === 'none' ? i18n.ts.no : i18n.ts.all }}</div>
</div>
<div>
<div><b>FTT:</b></div>
<div>{{ serverSettings.enableFanoutTimeline ? i18n.ts.yes : i18n.ts.no }}</div>
</div>
<div>
<div><b>FTT/{{ i18n.ts._serverSettings.fanoutTimelineDbFallback }}:</b></div>
<div>{{ serverSettings.enableFanoutTimelineDbFallback ? i18n.ts.yes : i18n.ts.no }}</div>
</div>
<div>
<div><b>RBT:</b></div>
<div>{{ serverSettings.enableReactionsBuffering ? i18n.ts.yes : i18n.ts.no }}</div>
</div>
<div>
<div><b>{{ i18n.ts._role.baseRole }}/{{ i18n.ts._role._options.rateLimitFactor }}:</b></div>
<div>{{ defaultPolicies.rateLimitFactor }}</div>
</div>
<div>
<div><b>{{ i18n.ts._role.baseRole }}/{{ i18n.ts._role._options.driveCapacity }}:</b></div>
<div>{{ defaultPolicies.driveCapacityMb }} MB</div>
</div>
<div>
<div><b>{{ i18n.ts._role.baseRole }}/{{ i18n.ts._role._options.userListMax }}:</b></div>
<div>{{ defaultPolicies.userListLimit }}</div>
</div>
<div>
<div><b>{{ i18n.ts._role.baseRole }}/{{ i18n.ts._role._options.antennaMax }}:</b></div>
<div>{{ defaultPolicies.antennaLimit }}</div>
</div>
<div>
<div><b>{{ i18n.ts._role.baseRole }}/{{ i18n.ts._role._options.webhookMax }}:</b></div>
<div>{{ defaultPolicies.webhookLimit }}</div>
</div>
<div>
<div><b>{{ i18n.ts._role.baseRole }}/{{ i18n.ts._role._options.canImportFollowing }}:</b></div>
<div>{{ defaultPolicies.canImportFollowing ? i18n.ts.yes : i18n.ts.no }}</div>
</div>
<div>
<div><b>{{ i18n.ts._role.baseRole }}/{{ i18n.ts._role._options.canImportMuting }}:</b></div>
<div>{{ defaultPolicies.canImportMuting ? i18n.ts.yes : i18n.ts.no }}</div>
</div>
<div>
<div><b>{{ i18n.ts._role.baseRole }}/{{ i18n.ts._role._options.canImportBlocking }}:</b></div>
<div>{{ defaultPolicies.canImportBlocking ? i18n.ts.yes : i18n.ts.no }}</div>
</div>
<div>
<div><b>{{ i18n.ts._role.baseRole }}/{{ i18n.ts._role._options.canImportUserLists }}:</b></div>
<div>{{ defaultPolicies.canImportUserLists ? i18n.ts.yes : i18n.ts.no }}</div>
</div>
<div>
<div><b>{{ i18n.ts._role.baseRole }}/{{ i18n.ts._role._options.canImportAntennas }}:</b></div>
<div>{{ defaultPolicies.canImportAntennas ? i18n.ts.yes : i18n.ts.no }}</div>
</div>
</div>
<template #footer>
<MkButton gradate large rounded data-cy-server-setup-wizard-apply style="margin: 0 auto;" @click="applySettings">
<i class="ti ti-check"></i> {{ i18n.ts._serverSetupWizard.applyTheseSettings }}
</MkButton>
</template>
</MkFolder>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import * as Misskey from 'misskey-js';
import { ROLE_POLICIES } from '@@/js/const.js';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import MkFolder from '@/components/MkFolder.vue';
import MkRadios from '@/components/MkRadios.vue';
import MkInfo from '@/components/MkInfo.vue';
const emit = defineEmits<{
(ev: 'finished'): void;
}>();
const props = withDefaults(defineProps<{
token?: string;
}>(), {
});
const q_name = ref('');
const q_use = ref('single');
const q_scale = ref('small');
const q_federation = ref('yes');
const q_adminName = ref('');
const q_adminEmail = ref('');
const serverSettings = computed<Misskey.entities.AdminUpdateMetaRequest>(() => {
let enableReactionsBuffering;
if (q_use.value === 'single') {
enableReactionsBuffering = false;
} else {
enableReactionsBuffering = q_scale.value !== 'small';
}
return {
singleUserMode: q_use.value === 'single',
disableRegistration: q_use.value !== 'open',
emailRequiredForSignup: q_use.value === 'open',
enableIpLogging: q_use.value === 'open',
federation: q_federation.value === 'yes' ? 'all' : 'none',
enableFanoutTimeline: true,
enableFanoutTimelineDbFallback: q_use.value === 'single',
enableReactionsBuffering,
};
});
const defaultPolicies = computed<Partial<Record<typeof ROLE_POLICIES[number], any>>>(() => {
let driveCapacityMb;
if (q_use.value === 'single') {
driveCapacityMb = 8192;
} else if (q_use.value === 'group') {
driveCapacityMb = 1000;
} else if (q_use.value === 'open') {
driveCapacityMb = 100;
}
let rateLimitFactor;
if (q_use.value === 'single') {
rateLimitFactor = 0.3;
} else if (q_use.value === 'group') {
rateLimitFactor = 0.7;
} else if (q_use.value === 'open') {
if (q_scale.value === 'small') {
rateLimitFactor = 1;
} else if (q_scale.value === 'medium') {
rateLimitFactor = 1.25;
} else if (q_scale.value === 'large') {
rateLimitFactor = 1.5;
}
}
let userListLimit;
if (q_use.value === 'single') {
userListLimit = 100;
} else if (q_use.value === 'group') {
userListLimit = 5;
} else if (q_use.value === 'open') {
userListLimit = 3;
}
let antennaLimit;
if (q_use.value === 'single') {
antennaLimit = 100;
} else if (q_use.value === 'group') {
antennaLimit = 5;
} else if (q_use.value === 'open') {
antennaLimit = 0;
}
let webhookLimit;
if (q_use.value === 'single') {
webhookLimit = 100;
} else if (q_use.value === 'group') {
webhookLimit = 0;
} else if (q_use.value === 'open') {
webhookLimit = 0;
}
let canImportFollowing;
if (q_use.value === 'single') {
canImportFollowing = true;
} else {
canImportFollowing = false;
}
let canImportMuting;
if (q_use.value === 'single') {
canImportMuting = true;
} else {
canImportMuting = false;
}
let canImportBlocking;
if (q_use.value === 'single') {
canImportBlocking = true;
} else {
canImportBlocking = false;
}
let canImportUserLists;
if (q_use.value === 'single') {
canImportUserLists = true;
} else {
canImportUserLists = false;
}
let canImportAntennas;
if (q_use.value === 'single') {
canImportAntennas = true;
} else {
canImportAntennas = false;
}
return {
rateLimitFactor,
driveCapacityMb,
userListLimit,
antennaLimit,
webhookLimit,
canImportFollowing,
canImportMuting,
canImportBlocking,
canImportUserLists,
canImportAntennas,
};
});
function applySettings() {
const _close = os.waiting();
Promise.all([
misskeyApi('admin/update-meta', {
...serverSettings.value,
name: q_name.value === '' ? undefined : q_name.value,
maintainerName: q_adminName.value === '' ? undefined : q_adminName.value,
maintainerEmail: q_adminEmail.value === '' ? undefined : q_adminEmail.value,
}, props.token),
misskeyApi('admin/roles/update-default-policies', {
policies: defaultPolicies.value,
}, props.token),
]).then(() => {
emit('finished');
}).catch((err) => {
os.alert({
type: 'error',
title: err.code,
text: err.message,
});
}).finally(() => {
_close();
});
}
</script>
<style lang="scss" module>
.root {
}
</style>

View File

@ -0,0 +1,531 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<component :is="prefer.s.enablePullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => reloadTimeline()">
<MkLoading v-if="paginator.fetching.value"/>
<MkError v-else-if="paginator.error.value" @retry="paginator.init()"/>
<div v-else-if="paginator.items.value.length === 0" key="_empty_">
<slot name="empty"><MkResult type="empty" :text="i18n.ts.noNotes"/></slot>
</div>
<div v-else ref="rootEl">
<div v-if="paginator.queuedAheadItemsCount.value > 0" :class="$style.new">
<div :class="$style.newBg1"></div>
<div :class="$style.newBg2"></div>
<button class="_button" :class="$style.newButton" @click="releaseQueue()"><i class="ti ti-circle-arrow-up"></i> {{ i18n.ts.newNote }}</button>
</div>
<component
:is="prefer.s.animation ? TransitionGroup : 'div'"
:class="$style.notes"
: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="(note, i) in paginator.items.value" :key="note.id">
<div v-if="i > 0 && isSeparatorNeeded(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(paginator.items.value[i -1].createdAt, note.createdAt).prevText }}</span>
<span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span>
<span>{{ getSeparatorInfo(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_" :data-scroll-anchor="note.id">
<MkNote :class="$style.note" :note="note" :withHardMute="true"/>
<div :class="$style.ad">
<MkAd :preferForms="['horizontal', 'horizontal-big']"/>
</div>
</div>
<MkNote v-else :class="$style.note" :note="note" :withHardMute="true" :data-scroll-anchor="note.id"/>
</template>
</component>
<button v-show="paginator.canFetchOlder.value" key="_more_" v-appear="prefer.s.enableInfiniteScroll ? paginator.fetchOlder : null" :disabled="paginator.fetchingOlder.value" class="_button" :class="$style.more" @click="paginator.fetchOlder">
<div v-if="!paginator.fetchingOlder.value">{{ i18n.ts.loadMore }}</div>
<MkLoading v-else :inline="true"/>
</button>
</div>
</component>
</template>
<script lang="ts" setup>
import { computed, watch, onUnmounted, provide, useTemplateRef, TransitionGroup, onMounted, shallowRef, ref } from 'vue';
import * as Misskey from 'misskey-js';
import { useInterval } from '@@/js/use-interval.js';
import { getScrollContainer, scrollToTop } from '@@/js/scroll.js';
import type { BasicTimelineType } from '@/timelines.js';
import type { PagingCtx } from '@/use/use-pagination.js';
import { usePagination } from '@/use/use-pagination.js';
import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
import { useStream } from '@/stream.js';
import * as sound from '@/utility/sound.js';
import { $i } from '@/i.js';
import { instance } from '@/instance.js';
import { prefer } from '@/preferences.js';
import { store } from '@/store.js';
import MkNote from '@/components/MkNote.vue';
import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n.js';
import { globalEvents, useGlobalEvent } from '@/events.js';
import { isSeparatorNeeded, getSeparatorInfo } from '@/utility/timeline-date-separate.js';
const props = withDefaults(defineProps<{
src: BasicTimelineType | 'mentions' | 'directs' | 'list' | 'antenna' | 'channel' | 'role';
list?: string;
antenna?: string;
channel?: string;
role?: string;
sound?: boolean;
withRenotes?: boolean;
withReplies?: boolean;
withSensitive?: boolean;
onlyFiles?: boolean;
}>(), {
withRenotes: true,
withReplies: false,
withSensitive: true,
onlyFiles: false,
});
provide('inTimeline', true);
provide('tl_withSensitive', computed(() => props.withSensitive));
provide('inChannel', computed(() => props.src === 'channel'));
function isTop() {
if (scrollContainer == null) return true;
if (rootEl.value == null) return true;
const scrollTop = scrollContainer.scrollTop;
const tlTop = rootEl.value.offsetTop - scrollContainer.offsetTop;
return scrollTop <= tlTop;
}
let scrollContainer: HTMLElement | null = null;
function onScrollContainerScroll() {
if (isTop()) {
paginator.releaseQueue();
}
}
const rootEl = useTemplateRef('rootEl');
watch(rootEl, (el) => {
if (el && scrollContainer == null) {
scrollContainer = getScrollContainer(el);
if (scrollContainer == null) return;
scrollContainer.addEventListener('scroll', onScrollContainerScroll, { passive: true }); // scrollendios
}
}, { immediate: true });
onUnmounted(() => {
if (scrollContainer) {
scrollContainer.removeEventListener('scroll', onScrollContainerScroll);
}
});
type TimelineQueryType = {
antennaId?: string,
withRenotes?: boolean,
withReplies?: boolean,
withFiles?: boolean,
visibility?: string,
listId?: string,
channelId?: string,
roleId?: string
};
let adInsertionCounter = 0;
const MIN_POLLING_INTERVAL = 1000 * 10;
const POLLING_INTERVAL =
prefer.s.pollingInterval === 1 ? MIN_POLLING_INTERVAL * 1.5 * 1.5 :
prefer.s.pollingInterval === 2 ? MIN_POLLING_INTERVAL * 1.5 :
prefer.s.pollingInterval === 3 ? MIN_POLLING_INTERVAL :
MIN_POLLING_INTERVAL;
if (!store.s.realtimeMode) {
// TODO: 1TL
useInterval(async () => {
paginator.fetchNewer({
toQueue: !isTop(),
});
}, POLLING_INTERVAL, {
immediate: false,
afterMounted: true,
});
useGlobalEvent('notePosted', (note) => {
paginator.fetchNewer({
toQueue: !isTop(),
});
});
}
useGlobalEvent('noteDeleted', (noteId) => {
paginator.removeItem(noteId);
});
function releaseQueue() {
paginator.releaseQueue();
scrollToTop(rootEl.value);
}
function prepend(note: Misskey.entities.Note) {
adInsertionCounter++;
if (instance.notesPerOneAd > 0 && adInsertionCounter % instance.notesPerOneAd === 0) {
note._shouldInsertAd_ = true;
}
if (isTop()) {
paginator.prepend(note);
} else {
paginator.enqueue(note);
}
if (props.sound) {
sound.playMisskeySfx($i && (note.userId === $i.id) ? 'noteMy' : 'note');
}
}
let connection: Misskey.ChannelConnection | null = null;
let connection2: Misskey.ChannelConnection | null = null;
let paginationQuery: PagingCtx;
const stream = store.s.realtimeMode ? useStream() : null;
function connectChannel() {
if (props.src === 'antenna') {
if (props.antenna == null) return;
connection = stream.useChannel('antenna', {
antennaId: props.antenna,
});
} else if (props.src === 'home') {
connection = stream.useChannel('homeTimeline', {
withRenotes: props.withRenotes,
withFiles: props.onlyFiles ? true : undefined,
});
connection2 = stream.useChannel('main');
} else if (props.src === 'local') {
connection = stream.useChannel('localTimeline', {
withRenotes: props.withRenotes,
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined,
});
} else if (props.src === 'social') {
connection = stream.useChannel('hybridTimeline', {
withRenotes: props.withRenotes,
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined,
});
} else if (props.src === 'global') {
connection = stream.useChannel('globalTimeline', {
withRenotes: props.withRenotes,
withFiles: props.onlyFiles ? true : undefined,
});
} else if (props.src === 'mentions') {
connection = stream.useChannel('main');
connection.on('mention', prepend);
} else if (props.src === 'directs') {
const onNote = note => {
if (note.visibility === 'specified') {
prepend(note);
}
};
connection = stream.useChannel('main');
connection.on('mention', onNote);
} else if (props.src === 'list') {
if (props.list == null) return;
connection = stream.useChannel('userList', {
withRenotes: props.withRenotes,
withFiles: props.onlyFiles ? true : undefined,
listId: props.list,
});
} else if (props.src === 'channel') {
if (props.channel == null) return;
connection = stream.useChannel('channel', {
channelId: props.channel,
});
} else if (props.src === 'role') {
if (props.role == null) return;
connection = stream.useChannel('roleTimeline', {
roleId: props.role,
});
}
if (props.src !== 'directs' && props.src !== 'mentions') connection?.on('note', prepend);
}
function disconnectChannel() {
if (connection) connection.dispose();
if (connection2) connection2.dispose();
}
function updatePaginationQuery() {
let endpoint: keyof Misskey.Endpoints | null;
let query: TimelineQueryType | null;
if (props.src === 'antenna') {
endpoint = 'antennas/notes';
query = {
antennaId: props.antenna,
};
} else if (props.src === 'home') {
endpoint = 'notes/timeline';
query = {
withRenotes: props.withRenotes,
withFiles: props.onlyFiles ? true : undefined,
};
} else if (props.src === 'local') {
endpoint = 'notes/local-timeline';
query = {
withRenotes: props.withRenotes,
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined,
};
} else if (props.src === 'social') {
endpoint = 'notes/hybrid-timeline';
query = {
withRenotes: props.withRenotes,
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined,
};
} else if (props.src === 'global') {
endpoint = 'notes/global-timeline';
query = {
withRenotes: props.withRenotes,
withFiles: props.onlyFiles ? true : undefined,
};
} else if (props.src === 'mentions') {
endpoint = 'notes/mentions';
query = null;
} else if (props.src === 'directs') {
endpoint = 'notes/mentions';
query = {
visibility: 'specified',
};
} else if (props.src === 'list') {
endpoint = 'notes/user-list-timeline';
query = {
withRenotes: props.withRenotes,
withFiles: props.onlyFiles ? true : undefined,
listId: props.list,
};
} else if (props.src === 'channel') {
endpoint = 'channels/timeline';
query = {
channelId: props.channel,
};
} else if (props.src === 'role') {
endpoint = 'roles/notes';
query = {
roleId: props.role,
};
} else {
throw new Error('Unrecognized timeline type: ' + props.src);
}
paginationQuery = {
endpoint: endpoint,
limit: 10,
params: query,
};
}
function refreshEndpointAndChannel() {
if (store.s.realtimeMode) {
disconnectChannel();
connectChannel();
}
updatePaginationQuery();
}
// withRenotes
// IDTL
watch(() => [props.list, props.antenna, props.channel, props.role, props.withRenotes], refreshEndpointAndChannel);
// withSensitiveOK
watch(() => props.withSensitive, reloadTimeline);
//
refreshEndpointAndChannel();
const paginator = usePagination({
ctx: paginationQuery,
useShallowRef: true,
});
onUnmounted(() => {
disconnectChannel();
});
function reloadTimeline() {
return new Promise<void>((res) => {
adInsertionCounter = 0;
paginator.reload().then(() => {
res();
});
});
}
defineExpose({
reloadTimeline,
});
</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);
&.note,
.note {
/* Skip Note Rendering有効時、TransitionGroupでnoteを追加するときに一瞬がくっとなる問題を抑制する */
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_leaveTo {
interpolate-size: allow-keywords; // heighttransition
height: 0;
}
}
.transition_x_leaveTo {
opacity: 0;
}
.notes {
container-type: inline-size;
background: var(--MI_THEME-panel);
}
.note {
border-bottom: solid 0.5px var(--MI_THEME-divider);
}
.new {
--gapFill: 0.5px; //
position: sticky;
top: calc(var(--MI-stickyTop, 0px) - var(--gapFill));
z-index: 1000;
width: 100%;
box-sizing: border-box;
padding: calc(10px + var(--gapFill)) 0 10px 0;
}
/* 疑似progressive blur */
.newBg1, .newBg2 {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.newBg1 {
height: 100%;
-webkit-backdrop-filter: var(--MI-blur, blur(2px));
backdrop-filter: var(--MI-blur, blur(2px));
mask-image: linear-gradient( /* 疑似Easing Linear Gradients */
to top,
rgb(0 0 0 / 0%) 0%,
rgb(0 0 0 / 4.9%) 7.75%,
rgb(0 0 0 / 10.4%) 11.25%,
rgb(0 0 0 / 45%) 23.55%,
rgb(0 0 0 / 55%) 26.45%,
rgb(0 0 0 / 89.6%) 38.75%,
rgb(0 0 0 / 95.1%) 42.25%,
rgb(0 0 0 / 100%) 50%
);
}
.newBg2 {
height: 75%;
-webkit-backdrop-filter: var(--MI-blur, blur(4px));
backdrop-filter: var(--MI-blur, blur(4px));
mask-image: linear-gradient( /* 疑似Easing Linear Gradients */
to top,
rgb(0 0 0 / 0%) 0%,
rgb(0 0 0 / 4.9%) 15.5%,
rgb(0 0 0 / 10.4%) 22.5%,
rgb(0 0 0 / 45%) 47.1%,
rgb(0 0 0 / 55%) 52.9%,
rgb(0 0 0 / 89.6%) 77.5%,
rgb(0 0 0 / 95.1%) 91.9%,
rgb(0 0 0 / 100%) 100%
);
}
.newButton {
position: relative;
display: block;
padding: 6px 12px;
border-radius: 999px;
width: max-content;
margin: auto;
background: var(--MI_THEME-accent);
color: var(--MI_THEME-fgOnAccent);
font-size: 90%;
&:hover {
background: hsl(from var(--MI_THEME-accent) h s calc(l + 5));
}
&:active {
background: hsl(from var(--MI_THEME-accent) h s calc(l - 5));
}
}
.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 {
padding: 8px;
background-size: auto auto;
background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--MI_THEME-bg) 8px, var(--MI_THEME-bg) 14px);
border-bottom: solid 0.5px var(--MI_THEME-divider);
&:empty {
display: none;
}
}
.more {
display: block;
width: 100%;
box-sizing: border-box;
padding: 16px;
background: var(--MI_THEME-panel);
}
</style>

View File

@ -0,0 +1,199 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<component :is="prefer.s.enablePullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => reload()">
<MkLoading v-if="paginator.fetching.value"/>
<MkError v-else-if="paginator.error.value" @retry="paginator.init()"/>
<div v-else-if="paginator.items.value.length === 0" key="_empty_">
<slot name="empty"><MkResult type="empty" :text="i18n.ts.noNotifications"/></slot>
</div>
<div v-else ref="rootEl">
<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"
>
<div v-for="(notification, i) in paginator.items.value" :key="notification.id" :data-scroll-anchor="notification.id" :class="$style.item">
<div v-if="i > 0 && isSeparatorNeeded(paginator.items.value[i -1].createdAt, notification.createdAt)" :class="$style.date">
<span><i class="ti ti-chevron-up"></i> {{ getSeparatorInfo(paginator.items.value[i -1].createdAt, notification.createdAt).prevText }}</span>
<span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span>
<span>{{ getSeparatorInfo(paginator.items.value[i -1].createdAt, notification.createdAt).nextText }} <i class="ti ti-chevron-down"></i></span>
</div>
<MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :class="$style.content" :note="notification.note" :withHardMute="true"/>
<XNotification v-else :class="$style.content" :notification="notification" :withTime="true" :full="true"/>
</div>
</component>
<button v-show="paginator.canFetchOlder.value" key="_more_" v-appear="prefer.s.enableInfiniteScroll ? paginator.fetchOlder : null" :disabled="paginator.fetchingOlder.value" class="_button" :class="$style.more" @click="paginator.fetchOlder">
<div v-if="!paginator.fetchingOlder.value">{{ i18n.ts.loadMore }}</div>
<MkLoading v-else/>
</button>
</div>
</component>
</template>
<script lang="ts" setup>
import { onUnmounted, onMounted, computed, useTemplateRef, TransitionGroup } from 'vue';
import * as Misskey from 'misskey-js';
import { useInterval } from '@@/js/use-interval.js';
import type { notificationTypes } from '@@/js/const.js';
import XNotification from '@/components/MkNotification.vue';
import MkNote from '@/components/MkNote.vue';
import { useStream } from '@/stream.js';
import { i18n } from '@/i18n.js';
import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
import { prefer } from '@/preferences.js';
import { store } from '@/store.js';
import { usePagination } from '@/use/use-pagination.js';
import { isSeparatorNeeded, getSeparatorInfo } from '@/utility/timeline-date-separate.js';
const props = defineProps<{
excludeTypes?: typeof notificationTypes[number][];
}>();
const rootEl = useTemplateRef('rootEl');
const paginator = usePagination({
ctx: prefer.s.useGroupedNotifications ? {
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,
})),
},
});
const MIN_POLLING_INTERVAL = 1000 * 10;
const POLLING_INTERVAL =
prefer.s.pollingInterval === 1 ? MIN_POLLING_INTERVAL * 1.5 * 1.5 :
prefer.s.pollingInterval === 2 ? MIN_POLLING_INTERVAL * 1.5 :
prefer.s.pollingInterval === 3 ? MIN_POLLING_INTERVAL :
MIN_POLLING_INTERVAL;
if (!store.s.realtimeMode) {
useInterval(async () => {
paginator.fetchNewer({
toQueue: false,
});
}, POLLING_INTERVAL, {
immediate: false,
afterMounted: true,
});
}
function onNotification(notification) {
const isMuted = props.excludeTypes ? props.excludeTypes.includes(notification.type) : false;
if (isMuted || window.document.visibilityState === 'visible') {
if (store.s.realtimeMode) {
useStream().send('readNotification');
}
}
if (!isMuted) {
paginator.prepend(notification);
}
}
function reload() {
return paginator.reload();
}
let connection: Misskey.ChannelConnection<Misskey.Channels['main']> | null = null;
onMounted(() => {
if (store.s.realtimeMode) {
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);
&.content,
.content {
/* 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);
}
.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);
}
.more {
display: block;
width: 100%;
box-sizing: border-box;
padding: 16px;
background: var(--MI_THEME-panel);
border-top: solid 0.5px var(--MI_THEME-divider);
}
</style>

View File

@ -18,8 +18,16 @@ SPDX-License-Identifier: AGPL-3.0-only
</details>
<details v-if="note.poll">
<summary>{{ i18n.ts.poll }}</summary>
<MkPoll :noteId="note.id" :poll="note.poll" :author="note.user" :emojiUrls="note.emojis"/>
<MkPoll
:noteId="note.id"
:multiple="note.poll.multiple"
:expiresAt="note.poll.expiresAt"
:choices="note.poll.choices"
:author="note.user"
:emojiUrls="note.emojis"
/>
</details>
<MkA v-if="note.hasPoll && note.poll == null" :to="`/notes/${note.id}`">({{ i18n.ts.poll }})</MkA>
<button v-if="isLong && collapsed" :class="$style.fade" class="_button" @click="collapsed = false">
<span :class="$style.fadeLabel">{{ i18n.ts.showMore }}</span>
</button>

View File

@ -1,372 +0,0 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<component :is="prefer.s.enablePullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => reloadTimeline()">
<MkPagination v-if="paginationQuery" ref="pagingComponent" :pagination="paginationQuery" @queue="emit('queue', $event)">
<template #empty><MkResult type="empty" :text="i18n.ts.noNotes"/></template>
<template #default="{ items: notes }">
<component
:is="prefer.s.animation ? TransitionGroup : 'div'"
:class="[$style.root, { [$style.noGap]: noGap, '_gaps': !noGap, [$style.reverse]: paginationQuery.reversed }]"
: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="(note, i) in notes" :key="note.id">
<div v-if="note._shouldInsertAd_" :class="[$style.noteWithAd, { '_gaps': !noGap }]" :data-scroll-anchor="note.id">
<MkNote :class="$style.note" :note="note" :withHardMute="true"/>
<div :class="$style.ad">
<MkAd :preferForms="['horizontal', 'horizontal-big']"/>
</div>
</div>
<MkNote v-else :class="$style.note" :note="note" :withHardMute="true" :data-scroll-anchor="note.id"/>
</template>
</component>
</template>
</MkPagination>
</component>
</template>
<script lang="ts" setup>
import { computed, watch, onUnmounted, provide, useTemplateRef, TransitionGroup } from 'vue';
import * as Misskey from 'misskey-js';
import type { BasicTimelineType } from '@/timelines.js';
import type { Paging } from '@/components/MkPagination.vue';
import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
import { useStream } from '@/stream.js';
import * as sound from '@/utility/sound.js';
import { $i } from '@/i.js';
import { instance } from '@/instance.js';
import { prefer } from '@/preferences.js';
import MkNote from '@/components/MkNote.vue';
import MkPagination from '@/components/MkPagination.vue';
import { i18n } from '@/i18n.js';
const props = withDefaults(defineProps<{
src: BasicTimelineType | 'mentions' | 'directs' | 'list' | 'antenna' | 'channel' | 'role';
list?: string;
antenna?: string;
channel?: string;
role?: string;
sound?: boolean;
withRenotes?: boolean;
withReplies?: boolean;
withSensitive?: boolean;
onlyFiles?: boolean;
}>(), {
withRenotes: true,
withReplies: false,
withSensitive: true,
onlyFiles: false,
});
const emit = defineEmits<{
(ev: 'note'): void;
(ev: 'queue', count: number): void;
}>();
provide('inTimeline', true);
provide('tl_withSensitive', computed(() => props.withSensitive));
provide('inChannel', computed(() => props.src === 'channel'));
type TimelineQueryType = {
antennaId?: string,
withRenotes?: boolean,
withReplies?: boolean,
withFiles?: boolean,
visibility?: string,
listId?: string,
channelId?: string,
roleId?: string
};
const pagingComponent = useTemplateRef('pagingComponent');
let tlNotesCount = 0;
function prepend(note) {
if (pagingComponent.value == null) return;
tlNotesCount++;
if (instance.notesPerOneAd > 0 && tlNotesCount % instance.notesPerOneAd === 0) {
note._shouldInsertAd_ = true;
}
pagingComponent.value.prepend(note);
emit('note');
if (props.sound) {
sound.playMisskeySfx($i && (note.userId === $i.id) ? 'noteMy' : 'note');
}
}
let connection: Misskey.ChannelConnection | null = null;
let connection2: Misskey.ChannelConnection | null = null;
let paginationQuery: Paging | null = null;
const noGap = !prefer.s.showGapBetweenNotesInTimeline;
const stream = useStream();
function connectChannel() {
if (props.src === 'antenna') {
if (props.antenna == null) return;
connection = stream.useChannel('antenna', {
antennaId: props.antenna,
});
} else if (props.src === 'home') {
connection = stream.useChannel('homeTimeline', {
withRenotes: props.withRenotes,
withFiles: props.onlyFiles ? true : undefined,
});
connection2 = stream.useChannel('main');
} else if (props.src === 'local') {
connection = stream.useChannel('localTimeline', {
withRenotes: props.withRenotes,
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined,
});
} else if (props.src === 'social') {
connection = stream.useChannel('hybridTimeline', {
withRenotes: props.withRenotes,
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined,
});
} else if (props.src === 'global') {
connection = stream.useChannel('globalTimeline', {
withRenotes: props.withRenotes,
withFiles: props.onlyFiles ? true : undefined,
});
} else if (props.src === 'mentions') {
connection = stream.useChannel('main');
connection.on('mention', prepend);
} else if (props.src === 'directs') {
const onNote = note => {
if (note.visibility === 'specified') {
prepend(note);
}
};
connection = stream.useChannel('main');
connection.on('mention', onNote);
} else if (props.src === 'list') {
if (props.list == null) return;
connection = stream.useChannel('userList', {
withRenotes: props.withRenotes,
withFiles: props.onlyFiles ? true : undefined,
listId: props.list,
});
} else if (props.src === 'channel') {
if (props.channel == null) return;
connection = stream.useChannel('channel', {
channelId: props.channel,
});
} else if (props.src === 'role') {
if (props.role == null) return;
connection = stream.useChannel('roleTimeline', {
roleId: props.role,
});
}
if (props.src !== 'directs' && props.src !== 'mentions') connection?.on('note', prepend);
}
function disconnectChannel() {
if (connection) connection.dispose();
if (connection2) connection2.dispose();
}
function updatePaginationQuery() {
let endpoint: keyof Misskey.Endpoints | null;
let query: TimelineQueryType | null;
if (props.src === 'antenna') {
endpoint = 'antennas/notes';
query = {
antennaId: props.antenna,
};
} else if (props.src === 'home') {
endpoint = 'notes/timeline';
query = {
withRenotes: props.withRenotes,
withFiles: props.onlyFiles ? true : undefined,
};
} else if (props.src === 'local') {
endpoint = 'notes/local-timeline';
query = {
withRenotes: props.withRenotes,
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined,
};
} else if (props.src === 'social') {
endpoint = 'notes/hybrid-timeline';
query = {
withRenotes: props.withRenotes,
withReplies: props.withReplies,
withFiles: props.onlyFiles ? true : undefined,
};
} else if (props.src === 'global') {
endpoint = 'notes/global-timeline';
query = {
withRenotes: props.withRenotes,
withFiles: props.onlyFiles ? true : undefined,
};
} else if (props.src === 'mentions') {
endpoint = 'notes/mentions';
query = null;
} else if (props.src === 'directs') {
endpoint = 'notes/mentions';
query = {
visibility: 'specified',
};
} else if (props.src === 'list') {
endpoint = 'notes/user-list-timeline';
query = {
withRenotes: props.withRenotes,
withFiles: props.onlyFiles ? true : undefined,
listId: props.list,
};
} else if (props.src === 'channel') {
endpoint = 'channels/timeline';
query = {
channelId: props.channel,
};
} else if (props.src === 'role') {
endpoint = 'roles/notes';
query = {
roleId: props.role,
};
} else {
endpoint = null;
query = null;
}
if (endpoint && query) {
paginationQuery = {
endpoint: endpoint,
limit: 10,
params: query,
};
} else {
paginationQuery = null;
}
}
function refreshEndpointAndChannel() {
if (!prefer.s.disableStreamingTimeline) {
disconnectChannel();
connectChannel();
}
updatePaginationQuery();
}
// withRenotes
// IDTL
watch(() => [props.list, props.antenna, props.channel, props.role, props.withRenotes], refreshEndpointAndChannel);
// withSensitiveOK
watch(() => props.withSensitive, reloadTimeline);
//
refreshEndpointAndChannel();
onUnmounted(() => {
disconnectChannel();
});
function reloadTimeline() {
return new Promise<void>((res) => {
if (pagingComponent.value == null) return;
tlNotesCount = 0;
pagingComponent.value.reload().then(() => {
res();
});
});
}
defineExpose({
reloadTimeline,
});
</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);
&.note,
.note {
/* Skip Note Rendering有効時、TransitionGroupでnoteを追加するときに一瞬がくっとなる問題を抑制する */
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_leaveTo {
interpolate-size: allow-keywords; // heighttransition
height: 0;
}
}
.transition_x_leaveTo {
opacity: 0;
}
.reverse {
display: flex;
flex-direction: column-reverse;
}
.root {
container-type: inline-size;
&.noGap {
background: var(--MI_THEME-panel);
.note {
border-bottom: solid 0.5px var(--MI_THEME-divider);
}
.ad {
padding: 8px;
background-size: auto auto;
background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--MI_THEME-bg) 8px, var(--MI_THEME-bg) 14px);
border-bottom: solid 0.5px var(--MI_THEME-divider);
}
}
&:not(.noGap) {
background: var(--MI_THEME-bg);
.note {
background: var(--MI_THEME-panel);
border-radius: var(--MI-radius);
}
}
}
.ad:empty {
display: none;
}
</style>

View File

@ -76,8 +76,6 @@ const onceReacted = ref<boolean>(false);
function addReaction(emoji) {
onceReacted.value = true;
emit('reacted');
exampleNote.reactions[emoji] = 1;
exampleNote.myReaction = emoji;
doNotification(emoji);
}

View File

@ -50,7 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { ref, useTemplateRef } from 'vue';
import * as Misskey from 'misskey-js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkButton from '@/components/MkButton.vue';
@ -74,7 +74,7 @@ const emit = defineEmits<{
(ev: 'closed'): void
}>();
const dialog = ref<InstanceType<typeof MkModalWindow> | null>(null);
const dialog = useTemplateRef('dialog');
const title = ref(props.announcement ? props.announcement.title : '');
const text = ref(props.announcement ? props.announcement.text : '');
const icon = ref(props.announcement ? props.announcement.icon : 'info');

View File

@ -16,13 +16,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import type { Paging } from '@/components/MkPagination.vue';
import type { PagingCtx } from '@/use/use-pagination.js';
import MkUserInfo from '@/components/MkUserInfo.vue';
import MkPagination from '@/components/MkPagination.vue';
import { i18n } from '@/i18n.js';
const props = withDefaults(defineProps<{
pagination: Paging;
pagination: PagingCtx;
noGap?: boolean;
extractor?: (item: any) => any;
}>(), {

View File

@ -39,15 +39,15 @@ import { i18n } from '@/i18n.js';
import MkFolder from '@/components/MkFolder.vue';
import XUser from '@/components/MkUserSetupDialog.User.vue';
import MkPagination from '@/components/MkPagination.vue';
import type { Paging } from '@/components/MkPagination.vue';
import type { PagingCtx } from '@/use/use-pagination.js';
const pinnedUsers: Paging = {
const pinnedUsers: PagingCtx = {
endpoint: 'pinned-users',
noPaging: true,
limit: 10,
};
const popularUsers: Paging = {
const popularUsers: PagingCtx = {
endpoint: 'users',
limit: 10,
noPaging: true,

View File

@ -43,7 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="instance.policies.ltlAvailable" :class="[$style.tl, $style.panel]">
<div :class="$style.tlHeader">{{ i18n.ts.letsLookAtTimeline }}</div>
<div :class="$style.tlBody">
<MkTimeline src="local"/>
<MkStreamingNotesTimeline src="local"/>
</div>
</div>
<div :class="$style.panel">
@ -58,7 +58,7 @@ import * as Misskey from 'misskey-js';
import XSigninDialog from '@/components/MkSigninDialog.vue';
import XSignupDialog from '@/components/MkSignupDialog.vue';
import MkButton from '@/components/MkButton.vue';
import MkTimeline from '@/components/MkTimeline.vue';
import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue';
import MkInfo from '@/components/MkInfo.vue';
import { instanceName } from '@@/js/config.js';
import * as os from '@/os.js';

View File

@ -48,7 +48,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script setup lang="ts">
import { computed, onMounted, ref, toRefs, watch } from 'vue';
import { computed, onMounted, ref, toRefs, useTemplateRef, watch } from 'vue';
import type { DataSource, GridSetting, GridState, Size } from '@/components/grid/grid.js';
import type { CellAddress, CellValue, GridCell } from '@/components/grid/cell.js';
import type { GridContext, GridEvent } from '@/components/grid/grid-event.js';
@ -130,7 +130,7 @@ const bus = new GridEventEmitter();
*/
const resizeObserver = new ResizeObserver((entries) => window.setTimeout(() => onResize(entries)));
const rootEl = ref<InstanceType<typeof HTMLTableElement>>();
const rootEl = useTemplateRef('rootEl');
/**
* グリッドの最も上位にある状態
*/

View File

@ -31,10 +31,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, toRefs, watch } from 'vue';
import { GridEventEmitter } from '@/components/grid/grid.js';
import { computed, nextTick, onMounted, onUnmounted, ref, toRefs, useTemplateRef, watch } from 'vue';
import type { Size } from '@/components/grid/grid.js';
import type { GridColumn } from '@/components/grid/column.js';
import { GridEventEmitter } from '@/components/grid/grid.js';
const emit = defineEmits<{
(ev: 'operation:beginWidthChange', sender: GridColumn): void;
@ -50,8 +50,8 @@ const props = defineProps<{
const { column, bus } = toRefs(props);
const rootEl = ref<InstanceType<typeof HTMLTableCellElement>>();
const contentEl = ref<InstanceType<typeof HTMLDivElement>>();
const rootEl = useTemplateRef('rootEl');
const contentEl = useTemplateRef('contentEl');
const resizing = ref<boolean>(false);

View File

@ -5,9 +5,24 @@
import { EventEmitter } from 'eventemitter3';
import * as Misskey from 'misskey-js';
import { onBeforeUnmount } from 'vue';
export const globalEvents = new EventEmitter<{
type Events = {
themeChanging: () => void;
themeChanged: () => void;
clientNotification: (notification: Misskey.entities.Notification) => void;
}>();
notePosted: (note: Misskey.entities.Note) => void;
noteDeleted: (noteId: Misskey.entities.Note['id']) => void;
};
export const globalEvents = new EventEmitter<Events>();
export function useGlobalEvent<T extends keyof Events>(
event: T,
callback: Events[T],
): void {
globalEvents.on(event, callback);
onBeforeUnmount(() => {
globalEvents.off(event, callback);
});
}

View File

@ -12,7 +12,6 @@ import { $i } from '@/i.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { get, set } from '@/utility/idb-proxy.js';
import { store } from '@/store.js';
import { useStream } from '@/stream.js';
import { deepClone } from '@/utility/clone.js';
import { deepMerge } from '@/utility/merge.js';
@ -129,25 +128,6 @@ export class Pizzax<T extends StateDef> {
if (where === 'deviceAccount' && !($i && userId !== $i.id)) return;
this.r[key].value = this.s[key] = value;
});
if ($i) {
const connection = useStream().useChannel('main');
// streamingのuser storage updateイベントを監視して更新
connection.on('registryUpdated', ({ scope, key, value }: { scope?: string[], key: keyof T, value: T[typeof key]['default'] }) => {
if (!scope || scope.length !== 2 || scope[0] !== 'client' || scope[1] !== this.key || this.s[key] === value) return;
this.r[key].value = this.s[key] = value;
this.addIdbSetJob(async () => {
const cache = await get(this.registryCacheKeyName);
if (cache[key] !== value) {
cache[key] = value;
await set(this.registryCacheKeyName, cache);
}
});
});
}
}
private load(): Promise<void> {

View File

@ -547,18 +547,24 @@ export function success(): Promise<void> {
});
}
export function waiting(text?: string | null): Promise<void> {
return new Promise(resolve => {
const showing = ref(true);
const { dispose } = popup(MkWaitingDialog, {
success: false,
showing: showing,
text,
}, {
done: () => resolve(),
closed: () => dispose(),
});
export function waiting(text?: string | null): () => void {
window.document.body.setAttribute('inert', 'true');
const showing = ref(true);
const { dispose } = popup(MkWaitingDialog, {
success: false,
showing: showing,
text,
}, {
closed: () => {
window.document.body.removeAttribute('inert');
dispose();
},
});
return () => {
showing.value = false;
};
}
export function form<F extends Form>(title: string, f: F): Promise<{ canceled: true, result?: undefined } | { canceled?: false, result: GetFormResultType<F> }> {

View File

@ -55,7 +55,7 @@ import { computed, ref } from 'vue';
import MkInput from '@/components/MkInput.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkPagination from '@/components/MkPagination.vue';
import type { Paging } from '@/components/MkPagination.vue';
import type { PagingCtx } from '@/use/use-pagination.js';
import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue';
import FormSplit from '@/components/form/split.vue';
import { i18n } from '@/i18n.js';
@ -81,7 +81,7 @@ const pagination = {
state.value === 'notResponding' ? { notResponding: true } :
{}),
})),
} as Paging;
} as PagingCtx;
function getStatus(instance) {
if (instance.isSuspended) return 'Suspended';

View File

@ -87,7 +87,7 @@ const pagination = {
};
function resolved(reportId) {
reports.value?.removeItem(reportId);
reports.value?.paginator.removeItem(reportId);
}
function closeTutorial() {

View File

@ -55,7 +55,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, ref, useTemplateRef } from 'vue';
import type { Paging } from '@/components/MkPagination.vue';
import type { PagingCtx } from '@/use/use-pagination.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
@ -73,7 +73,7 @@ const pagingComponent = useTemplateRef('pagingComponent');
const type = ref('all');
const sort = ref('+createdAt');
const pagination: Paging = {
const pagination: PagingCtx = {
endpoint: 'admin/invite/list' as const,
limit: 10,
params: computed(() => ({
@ -100,12 +100,12 @@ async function createWithOptions() {
text: tickets.map(x => x.code).join('\n'),
});
tickets.forEach(ticket => pagingComponent.value?.prepend(ticket));
tickets.forEach(ticket => pagingComponent.value?.paginator.prepend(ticket));
}
function deleted(id: string) {
if (pagingComponent.value) {
pagingComponent.value.items.delete(id);
pagingComponent.value.paginator.removeItem(id);
}
}

View File

@ -17,9 +17,20 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch>
<MkSwitch v-model="emailRequiredForSignup" @change="onChange_emailRequiredForSignup">
<template #label>{{ i18n.ts.emailRequiredForSignup }}</template>
<template #label>{{ i18n.ts.emailRequiredForSignup }} ({{ i18n.ts.recommended }})</template>
</MkSwitch>
<MkSelect v-model="ugcVisibilityForVisitor" @update:modelValue="onChange_ugcVisibilityForVisitor">
<template #label>{{ i18n.ts._serverSettings.userGeneratedContentsVisibilityForVisitor }}</template>
<option value="all">{{ i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.all }}</option>
<option value="local">{{ i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.localOnly }} ({{ i18n.ts.recommended }})</option>
<option value="none">{{ i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.none }}</option>
<template #caption>
<div>{{ i18n.ts._serverSettings.userGeneratedContentsVisibilityForVisitor_description }}</div>
<div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._serverSettings.userGeneratedContentsVisibilityForVisitor_description2 }}</div>
</template>
</MkSelect>
<FormLink to="/admin/server-rules">{{ i18n.ts.serverRules }}</FormLink>
<MkFolder>
@ -137,9 +148,11 @@ import { definePage } from '@/page.js';
import MkButton from '@/components/MkButton.vue';
import FormLink from '@/components/form/link.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkSelect from '@/components/MkSelect.vue';
const enableRegistration = ref<boolean>(false);
const emailRequiredForSignup = ref<boolean>(false);
const ugcVisibilityForVisitor = ref<string>('all');
const sensitiveWords = ref<string>('');
const prohibitedWords = ref<string>('');
const prohibitedWordsForNameOfUser = ref<string>('');
@ -153,6 +166,7 @@ async function init() {
const meta = await misskeyApi('admin/meta');
enableRegistration.value = !meta.disableRegistration;
emailRequiredForSignup.value = meta.emailRequiredForSignup;
ugcVisibilityForVisitor.value = meta.ugcVisibilityForVisitor;
sensitiveWords.value = meta.sensitiveWords.join('\n');
prohibitedWords.value = meta.prohibitedWords.join('\n');
prohibitedWordsForNameOfUser.value = meta.prohibitedWordsForNameOfUser.join('\n');
@ -189,6 +203,14 @@ function onChange_emailRequiredForSignup(value: boolean) {
});
}
function onChange_ugcVisibilityForVisitor(value: string) {
os.apiWithDialog('admin/update-meta', {
ugcVisibilityForVisitor: value,
}).then(() => {
fetchInstance(true);
});
}
function save_preservedUsernames() {
os.apiWithDialog('admin/update-meta', {
preservedUsernames: preservedUsernames.value.split('\n'),

View File

@ -121,7 +121,7 @@ async function addUser() {
username: username,
password: password,
}).then(res => {
paginationComponent.value?.reload();
paginationComponent.value?.paginator.reload();
});
}

View File

@ -44,7 +44,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import { ref, computed, useTemplateRef } from 'vue';
import MkPagination from '@/components/MkPagination.vue';
import MkButton from '@/components/MkButton.vue';
import MkInfo from '@/components/MkInfo.vue';
@ -71,7 +71,7 @@ const paginationPast = {
},
};
const paginationEl = ref<InstanceType<typeof MkPagination>>();
const paginationEl = useTemplateRef('paginationEl');
const tab = ref('current');
@ -86,10 +86,10 @@ async function read(target) {
}
if (!paginationEl.value) return;
paginationEl.value.updateItem(target.id, a => {
a.isRead = true;
return a;
});
paginationEl.value.paginator.updateItem(target.id, a => ({
...a,
isRead: true,
}));
misskeyApi('i/read-announcement', { announcementId: target.id });
updateCurrentAccountPartial({
unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== target.id),

View File

@ -6,17 +6,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<PageWithHeader :actions="headerActions" :tabs="headerTabs">
<div class="_spacer" style="--MI_SPACER-w: 800px;">
<div ref="rootEl">
<div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div>
<div :class="$style.tl">
<MkTimeline
ref="tlEl" :key="antennaId"
src="antenna"
:antenna="antennaId"
:sound="true"
@queue="queueUpdated"
/>
</div>
<div :class="$style.tl">
<MkStreamingNotesTimeline
ref="tlEl" :key="antennaId"
src="antenna"
:antenna="antennaId"
:sound="true"
/>
</div>
</div>
</PageWithHeader>
@ -25,8 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, watch, ref, useTemplateRef } from 'vue';
import * as Misskey from 'misskey-js';
import { scrollInContainer } from '@@/js/scroll.js';
import MkTimeline from '@/components/MkTimeline.vue';
import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { definePage } from '@/page.js';
@ -40,18 +35,8 @@ const props = defineProps<{
}>();
const antenna = ref<Misskey.entities.Antenna | null>(null);
const queue = ref(0);
const rootEl = useTemplateRef('rootEl');
const tlEl = useTemplateRef('tlEl');
function queueUpdated(q) {
queue.value = q;
}
function top() {
scrollInContainer(rootEl.value, { top: 0 });
}
async function timetravel() {
const { canceled, result: date } = await os.inputDate({
title: i18n.ts.date,
@ -94,25 +79,6 @@ definePage(() => ({
</script>
<style lang="scss" module>
.new {
position: sticky;
top: calc(var(--MI-stickyTop, 0px) + 16px);
z-index: 1000;
width: 100%;
margin: calc(-0.675em - 8px) 0;
&:first-child {
margin-top: calc(-0.675em - 8px - var(--MI-margin));
}
}
.newButton {
display: block;
margin: var(--MI-margin) auto 0 auto;
padding: 8px 16px;
border-radius: 32px;
}
.tl {
background: var(--MI_THEME-bg);
border-radius: var(--MI-radius);

View File

@ -60,7 +60,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, watch, ref } from 'vue';
import { computed, watch, ref, useTemplateRef } from 'vue';
import * as Misskey from 'misskey-js';
import MkWindow from '@/components/MkWindow.vue';
import MkButton from '@/components/MkButton.vue';
@ -86,7 +86,7 @@ const emit = defineEmits<{
(ev: 'closed'): void
}>();
const windowEl = ref<InstanceType<typeof MkWindow> | null>(null);
const windowEl = useTemplateRef('windowEl');
const url = ref<string>(props.avatarDecoration ? props.avatarDecoration.url : '');
const name = ref<string>(props.avatarDecoration ? props.avatarDecoration.name : '');
const description = ref<string>(props.avatarDecoration ? props.avatarDecoration.description : '');

View File

@ -37,10 +37,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<!-- スマホタブレットの場合キーボードが表示されると投稿が見づらくなるのでデスクトップ場合のみ自動でフォーカスを当てる -->
<MkPostForm v-if="$i && prefer.r.showFixedPostFormInChannel.value" :channel="channel" class="post-form _panel" fixed :autofocus="deviceKind === 'desktop'"/>
<MkTimeline :key="channelId" src="channel" :channel="channelId" @before="before" @after="after" @note="miLocalStorage.setItemAsJson(`channelLastReadedAt:${channel.id}`, Date.now())"/>
<MkStreamingNotesTimeline :key="channelId" src="channel" :channel="channelId"/>
</div>
<div v-else-if="tab === 'featured'">
<MkNotes :pagination="featuredPagination"/>
<MkNotesTimeline :pagination="featuredPagination"/>
</div>
<div v-else-if="tab === 'search'">
<div v-if="notesSearchAvailable" class="_gaps">
@ -50,7 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkInput>
<MkButton primary rounded style="margin-top: 8px;" @click="search()">{{ i18n.ts.search }}</MkButton>
</div>
<MkNotes v-if="searchPagination" :key="searchKey" :pagination="searchPagination"/>
<MkNotesTimeline v-if="searchPagination" :key="searchKey" :pagination="searchPagination"/>
</div>
<div v-else>
<MkInfo warn>{{ i18n.ts.notesSearchNotAvailable }}</MkInfo>
@ -73,9 +73,10 @@ SPDX-License-Identifier: AGPL-3.0-only
import { computed, watch, ref } from 'vue';
import * as Misskey from 'misskey-js';
import { url } from '@@/js/config.js';
import { useInterval } from '@@/js/use-interval.js';
import type { PageHeaderItem } from '@/types/page-header.js';
import MkPostForm from '@/components/MkPostForm.vue';
import MkTimeline from '@/components/MkTimeline.vue';
import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue';
import XChannelFollowButton from '@/components/MkChannelFollowButton.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
@ -83,7 +84,7 @@ import { $i, iAmModerator } from '@/i.js';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import { deviceKind } from '@/utility/device-kind.js';
import MkNotes from '@/components/MkNotes.vue';
import MkNotesTimeline from '@/components/MkNotesTimeline.vue';
import { favoritedChannelsCache } from '@/cache.js';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
@ -118,6 +119,14 @@ const featuredPagination = computed(() => ({
},
}));
useInterval(() => {
if (channel.value == null) return;
miLocalStorage.setItemAsJson(`channelLastReadedAt:${channel.value.id}`, Date.now());
}, 3000, {
immediate: true,
afterMounted: true,
});
watch(() => props.channelId, async () => {
channel.value = await misskeyApi('channels/show', {
channelId: props.channelId,

View File

@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
<MkNotes :pagination="pagination" :detail="true"/>
<MkNotesTimeline :pagination="pagination" :detail="true"/>
</div>
</div>
</PageWithHeader>
@ -34,7 +34,7 @@ import { computed, watch, provide, ref } from 'vue';
import * as Misskey from 'misskey-js';
import { url } from '@@/js/config.js';
import type { MenuItem } from '@/types/menu.js';
import MkNotes from '@/components/MkNotes.vue';
import MkNotesTimeline from '@/components/MkNotesTimeline.vue';
import { $i } from '@/i.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';

View File

@ -115,7 +115,7 @@ const selectAll = () => {
if (selectedEmojis.value.length > 0) {
selectedEmojis.value = [];
} else {
selectedEmojis.value = Array.from(emojisPaginationComponent.value?.items.values(), item => item.id);
selectedEmojis.value = emojisPaginationComponent.value?.paginator.items.value.map(item => item.id);
}
};
@ -132,7 +132,7 @@ const add = async (ev: MouseEvent) => {
}, {
done: result => {
if (result.created) {
emojisPaginationComponent.value?.prepend(result.created);
emojisPaginationComponent.value?.paginator.prepend(result.created);
}
},
closed: () => dispose(),
@ -145,12 +145,12 @@ const edit = (emoji) => {
}, {
done: result => {
if (result.updated) {
emojisPaginationComponent.value?.updateItem(result.updated.id, (oldEmoji) => ({
emojisPaginationComponent.value?.paginator.updateItem(result.updated.id, (oldEmoji) => ({
...oldEmoji,
...result.updated,
}));
} else if (result.deleted) {
emojisPaginationComponent.value?.removeItem(emoji.id);
emojisPaginationComponent.value?.paginator.removeItem(emoji.id);
}
},
closed: () => dispose(),
@ -242,7 +242,7 @@ const setCategoryBulk = async () => {
ids: selectedEmojis.value,
category: result,
});
emojisPaginationComponent.value?.reload();
emojisPaginationComponent.value?.paginator.reload();
};
const setLicenseBulk = async () => {
@ -254,7 +254,7 @@ const setLicenseBulk = async () => {
ids: selectedEmojis.value,
license: result,
});
emojisPaginationComponent.value?.reload();
emojisPaginationComponent.value?.paginator.reload();
};
const addTagBulk = async () => {
@ -266,7 +266,7 @@ const addTagBulk = async () => {
ids: selectedEmojis.value,
aliases: result.split(' '),
});
emojisPaginationComponent.value?.reload();
emojisPaginationComponent.value?.paginator.reload();
};
const removeTagBulk = async () => {
@ -278,7 +278,7 @@ const removeTagBulk = async () => {
ids: selectedEmojis.value,
aliases: result.split(' '),
});
emojisPaginationComponent.value?.reload();
emojisPaginationComponent.value?.paginator.reload();
};
const setTagBulk = async () => {
@ -290,7 +290,7 @@ const setTagBulk = async () => {
ids: selectedEmojis.value,
aliases: result.split(' '),
});
emojisPaginationComponent.value?.reload();
emojisPaginationComponent.value?.paginator.reload();
};
const delBulk = async () => {
@ -302,7 +302,7 @@ const delBulk = async () => {
await os.apiWithDialog('admin/emoji/delete-bulk', {
ids: selectedEmojis.value,
});
emojisPaginationComponent.value?.reload();
emojisPaginationComponent.value?.paginator.reload();
};
const headerActions = computed(() => [{

View File

@ -6,16 +6,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div class="_gaps">
<MkInfo>{{ i18n.ts._fileViewer.thisPageCanBeSeenFromTheAuthor }}</MkInfo>
<MkNotes ref="tlComponent" :pagination="pagination"/>
<MkNotesTimeline ref="tlComponent" :pagination="pagination"/>
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import { i18n } from '@/i18n.js';
import type { Paging } from '@/components/MkPagination.vue';
import type { PagingCtx } from '@/use/use-pagination.js';
import MkInfo from '@/components/MkInfo.vue';
import MkNotes from '@/components/MkNotes.vue';
import MkNotesTimeline from '@/components/MkNotesTimeline.vue';
const props = defineProps<{
fileId: string;
@ -23,7 +23,7 @@ const props = defineProps<{
const realFileId = computed(() => props.fileId);
const pagination = ref<Paging>({
const pagination = ref<PagingCtx>({
endpoint: 'drive/files/attached-notes',
limit: 10,
params: {

View File

@ -79,7 +79,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, watch, ref } from 'vue';
import { computed, watch, ref, useTemplateRef } from 'vue';
import * as Misskey from 'misskey-js';
import MkWindow from '@/components/MkWindow.vue';
import MkButton from '@/components/MkButton.vue';
@ -103,7 +103,7 @@ const emit = defineEmits<{
(ev: 'closed'): void
}>();
const windowEl = ref<InstanceType<typeof MkWindow> | null>(null);
const windowEl = useTemplateRef('windowEl');
const name = ref<string>(props.emoji ? props.emoji.name : '');
const category = ref<string>(props.emoji?.category ? props.emoji.category : '');
const aliases = ref<string>(props.emoji ? props.emoji.aliases.join(' ') : '');

View File

@ -9,14 +9,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<option value="notes">{{ i18n.ts.notes }}</option>
<option value="polls">{{ i18n.ts.poll }}</option>
</MkTab>
<MkNotes v-if="tab === 'notes'" :pagination="paginationForNotes"/>
<MkNotes v-else-if="tab === 'polls'" :pagination="paginationForPolls"/>
<MkNotesTimeline v-if="tab === 'notes'" :pagination="paginationForNotes"/>
<MkNotesTimeline v-else-if="tab === 'polls'" :pagination="paginationForPolls"/>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import MkNotes from '@/components/MkNotes.vue';
import MkNotesTimeline from '@/components/MkNotesTimeline.vue';
import MkTab from '@/components/MkTab.vue';
import { i18n } from '@/i18n.js';

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