Compare commits
26 Commits
eac98651a1
...
feeb6c161b
Author | SHA1 | Date |
---|---|---|
|
feeb6c161b | |
|
7a19225b6b | |
|
8b343defb0 | |
|
370e1350dd | |
|
c803f842ba | |
|
b3f8ce60c6 | |
|
04928ba7d1 | |
|
1b3a8cd97d | |
|
bbe26607eb | |
|
6a2dc2d2d2 | |
|
1febae7128 | |
|
2c63ab6fe3 | |
|
96bc042d67 | |
|
0bd44bca6d | |
|
ebf291084f | |
|
8c2ab25e5f | |
|
5603cb7e22 | |
|
86996d9563 | |
|
978289535b | |
|
aa938164f2 | |
|
5c38589d03 | |
|
351d91b1c3 | |
|
181f68e14e | |
|
cdd131d542 | |
|
921b13c26f | |
|
5d787e5bb9 |
35
CHANGELOG.md
35
CHANGELOG.md
|
@ -1,15 +1,42 @@
|
|||
## 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設定が有効でも、その背景が透過されない問題を修正
|
||||
|
||||
### Server
|
||||
- Enhance: ノートのレスポンスにアンケートが添付されているかどうかを示すフラグ`hasPoll`を追加
|
||||
- Fix: チャットルームが削除された場合・チャットルームから抜けた場合に、未読状態が残り続けることがあるのを修正
|
||||
- Fix: ユーザ除外アンテナをインポートできない問題を修正
|
||||
- Fix: アンテナのセンシティブなチャンネルのノートを含むかどうかの情報がエクスポートされない問題を修正
|
||||
|
||||
|
||||
## 2025.5.0
|
||||
|
||||
### Note
|
||||
- DockerのNode.jsが22.15.0に更新されました
|
||||
|
||||
### General
|
||||
-
|
||||
|
||||
### Client
|
||||
- Feat: マウスでもタイムラインを引っ張って更新できるように
|
||||
- Feat: マウスで中ボタンドラッグによりタイムラインを引っ張って更新できるように
|
||||
- アクセシビリティ設定からオフにすることもできます
|
||||
- Enhance: タイムラインのパフォーマンスを向上
|
||||
- Enhance: バックアップされた設定のプロファイルを削除できるように
|
||||
- Fix: 一部のブラウザでアコーディオンメニューのアニメーションが動作しない問題を修正
|
||||
- Fix: ダイアログのお知らせが画面からはみ出ることがある問題を修正
|
||||
- Fix: ユーザーポップアップでエラーが生じてもインジケーターが表示され続けてしまう問題を修正
|
||||
|
|
Binary file not shown.
|
@ -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',
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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: "عريض"
|
||||
|
|
|
@ -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: "মাঝখান"
|
||||
|
|
|
@ -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ó "
|
||||
|
@ -1349,6 +1346,7 @@ goToDeck: "Tornar al tauler"
|
|||
federationJobs: "Treballs sindicats "
|
||||
driveAboutTip: "Al Disc veure's una llista de tots els arxius que has anat pujant.<br>\nPots tornar-los a fer servir adjuntant-los a notes noves o pots adelantar-te i pujar arxius per publicar-los més tard!<br>\n<b>Tingués en compte que si esborres un arxiu també desapareixerà de tots els llocs on l'has fet servir (notes, pàgines, avatars, imatges de capçalera, etc.)</b><br>\nTambé pots crear carpetes per organitzar les."
|
||||
scrollToClose: "Desplaçar per tancar"
|
||||
advice: "Consell"
|
||||
_chat:
|
||||
noMessagesYet: "Encara no tens missatges "
|
||||
newMessage: "Missatge nou"
|
||||
|
@ -1434,6 +1432,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"
|
||||
|
@ -1606,6 +1605,8 @@ _serverSettings:
|
|||
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "Si no es detecta activitat per part del moderador durant un període de temps, aquesta opció es desactiva automàticament per evitar el correu brossa."
|
||||
deliverSuspendedSoftware: "Programari que ja no es distribueix"
|
||||
deliverSuspendedSoftwareDescription: "Pots especificar un rang de noms i versions del programari del servidor per detenir l'entrega, per exemple, degut a vulnerabilitats. Aquesta informació la proporciona el servidor i la seva fiabilitat no es garantitzada. Es pot fer servir una especificació de rang sencer per especificar una versió, però es recomana especificar una versió anterior, com >= 2024.3.1-0, perquè especificar >= 2024.3.1 no incloure versions personalitzades com 2024.3.1-custom.0."
|
||||
singleUserMode: "Mode un usuari"
|
||||
singleUserMode_description: "Si ets l'únic usuari d'aquesta instància, activant aquest mode optimitza el funcionament."
|
||||
_accountMigration:
|
||||
moveFrom: "Migrar un altre compte a aquest"
|
||||
moveFromSub: "Crear un àlies per un altre compte"
|
||||
|
@ -3011,3 +3012,20 @@ _search:
|
|||
pleaseEnterServerHost: "Introdueix l'adreça de la instància "
|
||||
pleaseSelectUser: "Selecciona un usuari"
|
||||
serverHostPlaceholder: "Ex: misskey.example.com"
|
||||
_serverSetupWizard:
|
||||
installCompleted: "La instal·lació de Misskey ha finalitzat!"
|
||||
firstCreateAccount: "En primer lloc crea un compte d'administrador."
|
||||
accountCreated: "Compte d'administrador creat."
|
||||
serverSetting: "Configuració del servidor"
|
||||
youCanEasilyConfigureOptimalServerSettingsWithThisWizard: "Aquest assistent t'ajuda a fer una configuració òptima del servidor."
|
||||
settingsYouMakeHereCanBeChangedLater: "Els canvis que facis ara poden modificar-se més tard."
|
||||
howWillYouUseMisskey: "Com es fa servir Misskey?"
|
||||
_use:
|
||||
single: "Servidor per una sola persona"
|
||||
single_description: "Fes-ho servir com el teu propi servidor dedicat"
|
||||
single_youCanCreateMultipleAccounts: "Es poden crear diferents comptes segons siguin les teves necessitats, inclús quan es fa servir com a servidor unipersonal."
|
||||
group: "Servidor per a grups"
|
||||
group_description: "Invita altres usuaris de la teva confiança i fes-ho servir amb més d'una persona."
|
||||
open: "Servidor obert"
|
||||
open_description: "Operar per donar cabuda a un nombre no determinat d'usuaris."
|
||||
openServerAdvice: "Acceptar un nombre no determinat d'usuaris comporta alguns riscos. Es recomana operar amb un sistema de moderació fiable per fer front als problemes."
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
@ -576,6 +575,7 @@ showFixedPostForm: "Display the posting form at the top of the timeline"
|
|||
showFixedPostFormInChannel: "Display the posting form at the top of the timeline (Channels)"
|
||||
withRepliesByDefaultForNewlyFollowed: "Include replies by newly followed users in the timeline by default"
|
||||
newNoteRecived: "There are new notes"
|
||||
newNote: "New Note"
|
||||
sounds: "Sounds"
|
||||
sound: "Sounds"
|
||||
listen: "Listen"
|
||||
|
@ -785,7 +785,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 +1237,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."
|
||||
|
@ -1349,6 +1347,10 @@ goToDeck: "Return to Deck"
|
|||
federationJobs: "Federation Jobs"
|
||||
driveAboutTip: "In Drive, a list of files you've uploaded in the past will be displayed. <br> \nYou can reuse these files when attaching them to notes, or you can upload files in advance to post later. <br> \n<b>Be careful when deleting a file, as it will not be available in all places where it was used (such as notes, pages, avatars, banners, etc.).</b> <br> \nYou can also create folders to organize your files."
|
||||
scrollToClose: "Scroll to close"
|
||||
advice: "Advice"
|
||||
realtimeMode: "Real-time mode"
|
||||
turnItOn: "Turn on"
|
||||
turnItOff: "Turn off"
|
||||
_chat:
|
||||
noMessagesYet: "No messages yet"
|
||||
newMessage: "New message"
|
||||
|
@ -1427,6 +1429,10 @@ _settings:
|
|||
enableSyncThemesBetweenDevices: "Synchronize installed themes across devices"
|
||||
enablePullToRefresh: "Pull to Refresh"
|
||||
enablePullToRefresh_description: "When using a mouse, drag while pressing in the scroll wheel."
|
||||
realtimeMode_description: "Establishes a connection with the server and updates content in real time. This may increase traffic and memory consumption."
|
||||
contentsUpdateFrequency: "Frequency of content retrieval"
|
||||
contentsUpdateFrequency_description: "The higher the value the more the content updates but it lowers the performance and increases the traffic and memory consumption."
|
||||
contentsUpdateFrequency_description2: "When real-time mode is on, content is updated in real time regardless of this setting."
|
||||
_chat:
|
||||
showSenderName: "Show sender's name"
|
||||
sendOnEnter: "Press Enter to send"
|
||||
|
@ -1434,6 +1440,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"
|
||||
|
@ -1606,6 +1613,15 @@ _serverSettings:
|
|||
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "If no moderator activity is detected for a while, this setting will be automatically turned off to prevent spam."
|
||||
deliverSuspendedSoftware: "Suspended Software"
|
||||
deliverSuspendedSoftwareDescription: "You can specify a range of names and versions of the server's software to stop delivery for vulnerability or other reasons. This version information is provided by the server and is not guaranteed to be reliable. A semver range specification can be used to specify the version, but specifying >= 2024.3.1 will not include custom versions such as 2024.3.1-custom.0, so it is recommended that a prerelease specification be used, such as >= 2024.3.1-0"
|
||||
singleUserMode: "Single user mode"
|
||||
singleUserMode_description: "If you are the only user of this server, enabling this mode will optimize its performance."
|
||||
userGeneratedContentsVisibilityForVisitor: "Visibility of user-generated content to guests"
|
||||
userGeneratedContentsVisibilityForVisitor_description: "This is useful for preventing problems caused by inappropriate remote content that is not well moderated from being unintentionally published on the Internet via your own server."
|
||||
userGeneratedContentsVisibilityForVisitor_description2: "Unconditionally publishing all content on the server to the Internet, including remote content received by the server is risky. This is especially important for guests who are unaware of the distributed nature of the content, as they may mistakenly believe that even remote content is content created by users on the server."
|
||||
_userGeneratedContentsVisibilityForVisitor:
|
||||
all: "Everything is public"
|
||||
localOnly: "Only local content is published, remote content is kept private"
|
||||
none: "Everything is private"
|
||||
_accountMigration:
|
||||
moveFrom: "Migrate another account to this one"
|
||||
moveFromSub: "Create alias to another account"
|
||||
|
@ -3011,3 +3027,45 @@ _search:
|
|||
pleaseEnterServerHost: "Enter the server host"
|
||||
pleaseSelectUser: "Select user"
|
||||
serverHostPlaceholder: "Example: misskey.example.com"
|
||||
_serverSetupWizard:
|
||||
installCompleted: "Misskey installation is now complete!"
|
||||
firstCreateAccount: "To begin, create an administrator account."
|
||||
accountCreated: "Administrator account has been created!"
|
||||
serverSetting: "Server Settings"
|
||||
youCanEasilyConfigureOptimalServerSettingsWithThisWizard: "This wizard makes it easier to configure the server settings."
|
||||
settingsYouMakeHereCanBeChangedLater: "The settings that were changed via this wizard can be adjusted later."
|
||||
howWillYouUseMisskey: "How will you use Misskey?"
|
||||
_use:
|
||||
single: "Single User server"
|
||||
single_description: "Use it alone as your own server."
|
||||
single_youCanCreateMultipleAccounts: "Multiple accounts can be created as needed, even when operated as a single user server."
|
||||
group: "Group server"
|
||||
group_description: "Invite other trusted users to use it with more than one user."
|
||||
open: "Public server"
|
||||
open_description: "Allow anyone to register."
|
||||
openServerAdvice: "Accepting a large number of unknown users involves risk. We recommend that you operate with a reliable moderation system to handle any problems."
|
||||
openServerAntiSpamAdvice: "To prevent your server from becoming a stepping stone for spam, you should also pay close attention to security by enabling anti-bot functions such as reCAPTCHA."
|
||||
howManyUsersDoYouExpect: "How many users do you expect?"
|
||||
_scale:
|
||||
small: "Less than 100 (small scale)"
|
||||
medium: "More than 100 and less than 1000 users (medium size)"
|
||||
large: "More than 1000 (Large scale)"
|
||||
largeScaleServerAdvice: "Large servers may require advanced infrastructure knowledge, such as load balancing and database replication."
|
||||
doYouConnectToFediverse: "Do you want to connect to the Fediverse?"
|
||||
doYouConnectToFediverse_description1: "When connected to a network of distributed servers (Fediverse) content can be exchanged with other servers."
|
||||
doYouConnectToFediverse_description2: "Connecting with the Fediverse is also called \"federation\""
|
||||
youCanConfigureMoreFederationSettingsLater: "Advanced settings such as specifying federated servers can be configured later."
|
||||
adminInfo: "Administrator information"
|
||||
adminInfo_description: "Sets the administrator information used to receive inquiries."
|
||||
adminInfo_mustBeFilled: "Must be entered if public server or federation is on."
|
||||
followingSettingsAreRecommended: "The following settings are recommended"
|
||||
applyTheseSettings: "Apply these settings"
|
||||
skipSettings: "Skip settings"
|
||||
settingsCompleted: "Setup is now complete!"
|
||||
settingsCompleted_description: "Thank you for your time. Now that everything is ready, you can start using the server right away."
|
||||
settingsCompleted_description2: "The server settings can be changed from the “Control Panel”"
|
||||
donationRequest: "Donation Request"
|
||||
_donationRequest:
|
||||
text1: "Misskey is a free software developed by volunteers."
|
||||
text2: "We would appreciate your support so that we can continue to develop this software further into the future."
|
||||
text3: "There are also special benefits for supporters!"
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -238,7 +238,6 @@ noUsers: "Il n’y a pas d’utilisateur·rice·s"
|
|||
editProfile: "Modifier votre profil"
|
||||
noteDeleteConfirm: "Êtes-vous sûr·e de vouloir supprimer cette note ?"
|
||||
pinLimitExceeded: "Vous ne pouvez plus épingler d’autres notes."
|
||||
intro: "L’installation 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."
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -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": {
|
||||
/**
|
||||
* 送信者の名前を表示
|
||||
|
@ -5745,6 +5769,10 @@ export interface Locale extends ILocale {
|
|||
* 例: 「メインPC」、「スマホ」など
|
||||
*/
|
||||
"profileNameDescription2": string;
|
||||
/**
|
||||
* プロファイルの管理
|
||||
*/
|
||||
"manageProfiles": string;
|
||||
};
|
||||
"_preferencesBackup": {
|
||||
/**
|
||||
|
@ -6384,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": {
|
||||
/**
|
||||
|
@ -11616,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;
|
||||
/**
|
||||
* 100人以上1000人以下 (中規模)
|
||||
*/
|
||||
"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;
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -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: "送信者の名前を表示"
|
||||
|
@ -1439,6 +1445,7 @@ _preferencesProfile:
|
|||
profileName: "プロファイル名"
|
||||
profileNameDescription: "このデバイスを識別する名前を設定してください。"
|
||||
profileNameDescription2: "例: 「メインPC」、「スマホ」など"
|
||||
manageProfiles: "プロファイルの管理"
|
||||
|
||||
_preferencesBackup:
|
||||
autoBackup: "自動バックアップ"
|
||||
|
@ -1622,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: "別のアカウントからこのアカウントに移行"
|
||||
|
@ -3106,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: "支援者向け特典もあります!"
|
||||
|
|
|
@ -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: "「内容を隠す」んやったら注釈書かなアカンで。"
|
||||
|
|
|
@ -224,7 +224,6 @@ noUsers: "사용자가 어ᇝ십니다"
|
|||
editProfile: "프로필 적기"
|
||||
noteDeleteConfirm: "요 노트럴 뭉캡니꺼?"
|
||||
pinLimitExceeded: "더 몬 붙입니다"
|
||||
intro: "Misskey럴 다 깔앗십니다! 간리자 게정얼 맨걸어 보입시다."
|
||||
done: "햇어예"
|
||||
processing: "처리하고 잇어예"
|
||||
preview: "미리보기"
|
||||
|
|
|
@ -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: "고정할 수 있는 노트 수"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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}"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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: "Код"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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: "หากเปิดใช้งาน “ซ่อนเนื้อหา” จะต้องระบุคำอธิบาย"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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: "Центр"
|
||||
|
|
|
@ -219,7 +219,6 @@ noUsers: "Foydalanuvchilar yo‘q"
|
|||
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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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: "从备份恢复"
|
||||
|
|
|
@ -251,7 +251,6 @@ noUsers: "沒有任何使用者"
|
|||
editProfile: "編輯個人檔案"
|
||||
noteDeleteConfirm: "確定刪除此貼文嗎?"
|
||||
pinLimitExceeded: "不能置頂更多貼文了"
|
||||
intro: "Misskey 部署完成!請建立管理員帳戶。"
|
||||
done: "完成"
|
||||
processing: "處理中"
|
||||
preview: "預覽"
|
||||
|
@ -576,6 +575,7 @@ showFixedPostForm: "於時間軸頁頂顯示「發送貼文」方框"
|
|||
showFixedPostFormInChannel: "於時間軸頁頂顯示「發送貼文」方框(頻道)"
|
||||
withRepliesByDefaultForNewlyFollowed: "在追隨其他人後,預設在時間軸納入回覆的貼文"
|
||||
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: "尚無訊息"
|
||||
newMessage: "新訊息"
|
||||
|
@ -1427,6 +1429,10 @@ _settings:
|
|||
enableSyncThemesBetweenDevices: "在裝置之間同步已安裝的主題"
|
||||
enablePullToRefresh: "下拉更新"
|
||||
enablePullToRefresh_description: "使用滑鼠,按下並拖曳滾輪。"
|
||||
realtimeMode_description: "已與伺服器建立連線,將即時更新內容。這可能會增加資料傳輸量與電池消耗。\n"
|
||||
contentsUpdateFrequency: "內容取得頻率"
|
||||
contentsUpdateFrequency_description: "頻率越高,內容更新越即時,但可能會降低效能,並增加資料傳輸量與電池消耗。\n"
|
||||
contentsUpdateFrequency_description2: "當即時模式開啟時,不論此設定為何,內容都會即時更新。"
|
||||
_chat:
|
||||
showSenderName: "顯示發送者的名稱"
|
||||
sendOnEnter: "按下 Enter 發送訊息"
|
||||
|
@ -1434,6 +1440,7 @@ _preferencesProfile:
|
|||
profileName: "設定檔案名稱"
|
||||
profileNameDescription: "設定一個名稱來識別此裝置。"
|
||||
profileNameDescription2: "例如:「主要個人電腦」、「智慧型手機」等"
|
||||
manageProfiles: "管理個人檔案"
|
||||
_preferencesBackup:
|
||||
autoBackup: "自動備份"
|
||||
restoreFromBackup: "從備份還原"
|
||||
|
@ -1606,6 +1613,15 @@ _serverSettings:
|
|||
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "如果在一段期間內沒有偵測到任何審查員活動,此設定將自動關閉,以防止垃圾內容。"
|
||||
deliverSuspendedSoftware: "已停止發佈的軟體"
|
||||
deliverSuspendedSoftwareDescription: "由於脆弱性等原因,可以指定伺服器軟體的名稱與版本範圍來停止其發佈。這些版本資訊是由伺服器所提供,其可靠性無法保證。版本的指定可以使用 semver(語意化版本控制) 的範圍語法,但如果指定為 >= 2024.3.1,則像 2024.3.1-custom.0 這樣的自訂版本將不會被包含在內,因此建議使用 >= 2024.3.1-0 的方式來同時包含預發佈版本。"
|
||||
singleUserMode: "單人模式"
|
||||
singleUserMode_description: "如果只有自己使用此伺服器的話,啟用此模式將使效能最佳化。"
|
||||
userGeneratedContentsVisibilityForVisitor: "使用者建立的內容對訪客的公開範圍"
|
||||
userGeneratedContentsVisibilityForVisitor_description: "這有助於防止一些問題的發生,例如未經適當審核的不適當遠端內容無意中透過您自己的伺服器發佈到網際網路上。"
|
||||
userGeneratedContentsVisibilityForVisitor_description2: "包括伺服器接收到的遠端內容在內,無條件地將伺服器內所有內容公開到網際網路上是具有風險的。特別是對於不了解分散式架構特性的瀏覽者來說,他們可能會誤以為這些遠端內容是由該伺服器所創建的,因此需要特別留意。"
|
||||
_userGeneratedContentsVisibilityForVisitor:
|
||||
all: "全部公開\n"
|
||||
localOnly: "僅公開本地內容,遠端內容則不公開\n"
|
||||
none: "全部不公開"
|
||||
_accountMigration:
|
||||
moveFrom: "從其他帳戶遷移到這個帳戶"
|
||||
moveFromSub: "為另一個帳戶建立別名"
|
||||
|
@ -3011,3 +3027,45 @@ _search:
|
|||
pleaseEnterServerHost: "請輸入伺服器的主機名稱"
|
||||
pleaseSelectUser: "請選擇使用者"
|
||||
serverHostPlaceholder: "例:misskey.example.com"
|
||||
_serverSetupWizard:
|
||||
installCompleted: "Misskey 的安裝已經完成了!"
|
||||
firstCreateAccount: "首先,請建立管理者帳戶。"
|
||||
accountCreated: "已建立管理者帳戶!"
|
||||
serverSetting: "伺服器設定"
|
||||
youCanEasilyConfigureOptimalServerSettingsWithThisWizard: "利用這個精靈,可以簡單地最佳化伺服器的設定。"
|
||||
settingsYouMakeHereCanBeChangedLater: "這裡的設定之後也可以進行更改。\n"
|
||||
howWillYouUseMisskey: "您打算如何使用 Misskey?\n"
|
||||
_use:
|
||||
single: "單人伺服器"
|
||||
single_description: "作為自己專用的伺服器,單獨使用。\n"
|
||||
single_youCanCreateMultipleAccounts: "即使作為單人伺服器運行,根據需要也可以創建多個帳戶。\n"
|
||||
group: "群組伺服器\n"
|
||||
group_description: "邀請可信賴的其他使用者,共同使用伺服器。\n"
|
||||
open: "開放式伺服器"
|
||||
open_description: "運營時接納不特定多數的使用者。"
|
||||
openServerAdvice: "接納不特定多數使用者會帶來風險。為了能夠有效處理問題,建議建立完善的審查機制來進行運營。\n"
|
||||
openServerAntiSpamAdvice: "為了防止自家伺服器成為垃圾郵件的跳板,必須啟用如 reCAPTCHA 等反機器人功能,並對安全性保持高度警覺。\n"
|
||||
howManyUsersDoYouExpect: "您預計有多少人使用呢?\n"
|
||||
_scale:
|
||||
small: "100人以下(小規模)\n"
|
||||
medium: "100人以上1000人以下(中規模)\n"
|
||||
large: "1000人以上(大規模)\n"
|
||||
largeScaleServerAdvice: "在大規模伺服器中,可能需要具備高階基礎設施知識,如負載平衡和資料庫複寫等。\n"
|
||||
doYouConnectToFediverse: "您要連接到聯邦宇宙(Fediverse)嗎?\n"
|
||||
doYouConnectToFediverse_description1: "連接到由分散型伺服器構成的網絡(聯邦宇宙)後,您可以與其他伺服器進行內容的互相交流。\n"
|
||||
doYouConnectToFediverse_description2: "連接到聯邦宇宙被稱為「聯邦」。\n"
|
||||
youCanConfigureMoreFederationSettingsLater: "您可以在稍後進行更高級的設定,例如指定可以聯繫的伺服器等。\n"
|
||||
adminInfo: "管理員資訊"
|
||||
adminInfo_description: "設定用於接收查詢的管理者資訊。\n"
|
||||
adminInfo_mustBeFilled: "當設置為開放伺服器或啟用聯邦時,必須填寫此資訊。\n"
|
||||
followingSettingsAreRecommended: "建議使用下列設定"
|
||||
applyTheseSettings: "套用此設定"
|
||||
skipSettings: "跳過設定"
|
||||
settingsCompleted: "設定完成!"
|
||||
settingsCompleted_description: "辛苦了!準備已經完成,您可以立即開始使用伺服器了。\n"
|
||||
settingsCompleted_description2: "詳細的伺服器設定可透過「控制臺」進行。"
|
||||
donationRequest: "請求捐款"
|
||||
_donationRequest:
|
||||
text1: "Misskey 是由志願者開發的免費軟體。"
|
||||
text2: "為了能夠繼續開發,若您願意的話,請考慮進行捐款。\n"
|
||||
text3: "也有提供支援者專屬的特典!\n"
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "misskey",
|
||||
"version": "2025.5.0-rc.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",
|
||||
|
|
|
@ -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"`);
|
||||
}
|
||||
}
|
|
@ -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"`);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -256,6 +256,10 @@ export const packedNoteSchema = {
|
|||
type: 'number',
|
||||
optional: true, nullable: false,
|
||||
},
|
||||
hasPoll: {
|
||||
type: 'boolean',
|
||||
optional: true, nullable: false,
|
||||
},
|
||||
|
||||
myReaction: {
|
||||
type: 'string',
|
||||
|
|
|
@ -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(', ');
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}>(), {
|
||||
|
|
|
@ -154,6 +154,10 @@ onUnmounted(() => {
|
|||
&.naked {
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
|
||||
> .content {
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.scrollable {
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -11,18 +11,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
<div
|
||||
v-else-if="!input && !select"
|
||||
:class="[$style.icon, {
|
||||
[$style.type_success]: type === 'success',
|
||||
[$style.type_error]: type === 'error',
|
||||
[$style.type_warning]: type === 'warning',
|
||||
[$style.type_info]: type === 'info',
|
||||
}]"
|
||||
:class="[$style.icon]"
|
||||
>
|
||||
<i v-if="type === 'success'" :class="$style.iconInner" class="ti ti-check"></i>
|
||||
<i v-else-if="type === 'error'" :class="$style.iconInner" class="ti ti-circle-x"></i>
|
||||
<i v-else-if="type === 'warning'" :class="$style.iconInner" class="ti ti-alert-triangle"></i>
|
||||
<i v-else-if="type === 'info'" :class="$style.iconInner" class="ti ti-info-circle"></i>
|
||||
<i v-else-if="type === 'question'" :class="$style.iconInner" class="ti ti-help-circle"></i>
|
||||
<MkSystemIcon v-if="type === 'success'" :class="$style.iconInner" style="width: 45px;" type="success"/>
|
||||
<MkSystemIcon v-else-if="type === 'error'" :class="$style.iconInner" style="width: 45px;" type="error"/>
|
||||
<MkSystemIcon v-else-if="type === 'warning'" :class="$style.iconInner" style="width: 45px;" type="warn"/>
|
||||
<MkSystemIcon v-else-if="type === 'info'" :class="$style.iconInner" style="width: 45px;" type="info"/>
|
||||
<MkSystemIcon v-else-if="type === 'question'" :class="$style.iconInner" style="width: 45px;" type="question"/>
|
||||
<MkLoading v-else-if="type === 'waiting'" :class="$style.iconInner" :em="true"/>
|
||||
</div>
|
||||
<header v-if="title" :class="$style.title" class="_selectable"><Mfm :text="title"/></header>
|
||||
|
@ -202,22 +197,6 @@ function onInputKeydown(evt: KeyboardEvent) {
|
|||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.type_info {
|
||||
color: #55c4dd;
|
||||
}
|
||||
|
||||
.type_success {
|
||||
color: var(--MI_THEME-success);
|
||||
}
|
||||
|
||||
.type_error {
|
||||
color: var(--MI_THEME-error);
|
||||
}
|
||||
|
||||
.type_warning {
|
||||
color: var(--MI_THEME-warn);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 0 8px 0;
|
||||
font-weight: bold;
|
||||
|
|
|
@ -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('');
|
||||
|
|
|
@ -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は重いから使うな
|
||||
|
||||
|
|
|
@ -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 FunctionにLintが対応していないのでコメントアウト
|
||||
|
@ -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) {
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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; // heightのtransitionを動作させるために必要
|
||||
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>
|
|
@ -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;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Appear(IntersectionObserver)によって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);
|
||||
|
||||
// scrollToBottomでmoreFetchingボタンが画面外まで出るまで
|
||||
// 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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
|
@ -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 }); // ほんとはscrollendにしたいけどiosが非対応
|
||||
}
|
||||
}, { 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: 先頭のノートの作成日時が1日以上前であれば流速が遅いTLと見做してインターバルを通常より延ばす
|
||||
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を変更した場合に自動的に更新されるようにさせる
|
||||
// IDが切り替わったら切り替え先のTLを表示させたい
|
||||
watch(() => [props.list, props.antenna, props.channel, props.role, props.withRenotes], refreshEndpointAndChannel);
|
||||
|
||||
// withSensitiveはクライアントで完結する処理のため、単にリロードするだけでOK
|
||||
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; // heightのtransitionを動作させるために必要
|
||||
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>
|
|
@ -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; // heightのtransitionを動作させるために必要
|
||||
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>
|
|
@ -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>
|
||||
|
|
|
@ -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を変更した場合に自動的に更新されるようにさせる
|
||||
// IDが切り替わったら切り替え先のTLを表示させたい
|
||||
watch(() => [props.list, props.antenna, props.channel, props.role, props.withRenotes], refreshEndpointAndChannel);
|
||||
|
||||
// withSensitiveはクライアントで完結する処理のため、単にリロードするだけでOK
|
||||
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; // heightのtransitionを動作させるために必要
|
||||
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>
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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;
|
||||
}>(), {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -5,43 +5,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template>
|
||||
<Transition :name="prefer.s.animation ? '_transition_zoom' : ''" appear>
|
||||
<div :class="[$style.root, { [$style.warn]: type === 'notFound', [$style.error]: type === 'error' }]" class="_gaps_m">
|
||||
<div :class="[$style.root, { [$style.warn]: type === 'notFound', [$style.error]: type === 'error' }]" class="_gaps">
|
||||
<img v-if="type === 'empty' && instance.infoImageUrl" :src="instance.infoImageUrl" draggable="false" :class="$style.img"/>
|
||||
<svg v-else-if="type === 'empty'" :class="$style.icon" viewBox="0 0 128 128" style="stroke-linecap:round;stroke-linejoin:round;">
|
||||
<g transform="matrix(1,0,0,0.9,0,12.8)">
|
||||
<path d="M64,88L64,48" style="fill:none;stroke:currentColor;stroke-width:8.41px;"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-4,8)">
|
||||
<circle cx="68" cy="28" r="4" style="fill:currentColor;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.875,0,0,0.875,8,8)">
|
||||
<circle cx="64" cy="64" r="64" style="fill:none;stroke:currentColor;stroke-width:9.14px;"/>
|
||||
</g>
|
||||
</svg>
|
||||
<MkSystemIcon v-else-if="type === 'empty'" type="info" :class="$style.icon"/>
|
||||
<img v-if="type === 'notFound' && instance.notFoundImageUrl" :src="instance.notFoundImageUrl" draggable="false" :class="$style.img"/>
|
||||
<svg v-else-if="type === 'notFound'" :class="$style.icon" viewBox="0 0 128 128" style="stroke-linecap:round;stroke-linejoin:round;">
|
||||
<g transform="matrix(1,0,0,1,0,12)">
|
||||
<path d="M64,64L64,56C72.533,55.777 80,49.333 80,40C80,31.667 73.333,24 64,24C55.667,24 47.556,31.667 48,40" style="fill:none;stroke:currentColor;stroke-width:8px;"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-4,64)">
|
||||
<circle cx="68" cy="28" r="4" style="fill:currentColor;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.875,0,0,0.875,8,8)">
|
||||
<circle cx="64" cy="64" r="64" style="fill:none;stroke:currentColor;stroke-width:9.14px;"/>
|
||||
</g>
|
||||
</svg>
|
||||
<MkSystemIcon v-else-if="type === 'notFound'" type="question" :class="$style.icon"/>
|
||||
<img v-if="type === 'error' && instance.serverErrorImageUrl" :src="instance.serverErrorImageUrl" draggable="false" :class="$style.img"/>
|
||||
<svg v-else-if="type === 'error'" :class="$style.icon" viewBox="0 0 128 128" style="stroke-linecap:round;stroke-linejoin:round;">
|
||||
<g transform="matrix(0.707107,0.707107,-0.636396,0.636396,62.0201,-24.5298)">
|
||||
<path d="M64,94.667L64,41.333" style="fill:none;stroke:currentColor;stroke-width:8.41px;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.707107,-0.707107,0.636396,0.636396,-24.5298,65.9799)">
|
||||
<path d="M64,94.667L64,41.333" style="fill:none;stroke:currentColor;stroke-width:8.41px;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.875,0,0,0.875,8,8)">
|
||||
<circle cx="64" cy="64" r="64" style="fill:none;stroke:currentColor;stroke-width:9.14px;"/>
|
||||
</g>
|
||||
</svg>
|
||||
<MkSystemIcon v-else-if="type === 'error'" type="error" :class="$style.icon"/>
|
||||
|
||||
<div style="opacity: 0.7;">{{ props.text ?? (type === 'empty' ? i18n.ts.nothing : type === 'notFound' ? i18n.ts.notFound : type === 'error' ? i18n.ts.somethingHappened : null) }}</div>
|
||||
<slot></slot>
|
||||
|
@ -66,18 +36,6 @@ const props = defineProps<{
|
|||
position: relative;
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
|
||||
&.error {
|
||||
.icon {
|
||||
color: var(--MI_THEME-error);
|
||||
}
|
||||
}
|
||||
|
||||
&.warn {
|
||||
.icon {
|
||||
color: var(--MI_THEME-warn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.img {
|
||||
|
@ -88,9 +46,8 @@ const props = defineProps<{
|
|||
}
|
||||
|
||||
.icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
width: 65px;
|
||||
height: 65px;
|
||||
margin: 0 auto;
|
||||
color: var(--MI_THEME-accent);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,136 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<svg v-if="type === 'info'" :class="[$style.icon, $style.info]" viewBox="0 0 160 160">
|
||||
<path d="M80,108L80,72" style="--l:37;" :class="[$style.line, $style.animLine]"/>
|
||||
<path d="M80,52L80,52" :class="[$style.line, $style.animFade]"/>
|
||||
<circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.animCircle]"/>
|
||||
</svg>
|
||||
<svg v-else-if="type === 'question'" :class="[$style.icon, $style.question]" viewBox="0 0 160 160">
|
||||
<path d="M80,92L79.991,84C88.799,83.98 96,76.962 96,68C96,59.038 88.953,52 79.991,52C71.03,52 64,59.038 64,68" style="--l:85;" :class="[$style.line, $style.animLine]"/>
|
||||
<path d="M80,108L80,108" :class="[$style.line, $style.animFade]"/>
|
||||
<circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.animCircle]"/>
|
||||
</svg>
|
||||
<svg v-else-if="type === 'success'" :class="[$style.icon, $style.success]" viewBox="0 0 160 160">
|
||||
<path d="M62,80L74,92L98,68" style="--l:50;" :class="[$style.line, $style.animLine]"/>
|
||||
<circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.animCircleSuccess]"/>
|
||||
</svg>
|
||||
<svg v-else-if="type === 'warn'" :class="[$style.icon, $style.warn]" viewBox="0 0 160 160">
|
||||
<path d="M80,64L80,88" style="--l:27;" :class="[$style.line, $style.animLine]"/>
|
||||
<path d="M80,108L80,108" :class="[$style.line, $style.animFade]"/>
|
||||
<path d="M92,28L144,116C148.709,124.65 144.083,135.82 136,136L24,136C15.917,135.82 11.291,124.65 16,116L68,28C73.498,19.945 86.771,19.945 92,28Z" style="--l:395;" :class="[$style.line, $style.animLine]"/>
|
||||
</svg>
|
||||
<svg v-else-if="type === 'error'" :class="[$style.icon, $style.error]" viewBox="0 0 160 160">
|
||||
<path d="M63,63L96,96" style="--l:47;--duration:0.3s;" :class="[$style.line, $style.animLine]"/>
|
||||
<path d="M96,63L63,96" style="--l:47;--duration:0.3s;--delay:0.2s;" :class="[$style.line, $style.animLine]"/>
|
||||
<circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.animCircle]"/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {} from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
type: 'info' | 'question' | 'success' | 'warn' | 'error';
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.icon {
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
|
||||
&.info {
|
||||
color: var(--MI_THEME-accent);
|
||||
}
|
||||
|
||||
&.question {
|
||||
color: var(--MI_THEME-fg);
|
||||
}
|
||||
|
||||
&.success {
|
||||
color: var(--MI_THEME-success);
|
||||
}
|
||||
|
||||
&.warn {
|
||||
color: var(--MI_THEME-warn);
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: var(--MI_THEME-error);
|
||||
}
|
||||
}
|
||||
|
||||
.line {
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 8px;
|
||||
shape-rendering: geometricPrecision;
|
||||
}
|
||||
|
||||
.animLine {
|
||||
stroke-dasharray: var(--l);
|
||||
stroke-dashoffset: var(--l);
|
||||
animation: line var(--duration, 0.5s) cubic-bezier(0,0,.25,1) 1 forwards;
|
||||
animation-delay: var(--delay, 0s);
|
||||
}
|
||||
|
||||
.animCircle {
|
||||
stroke-dasharray: var(--l);
|
||||
stroke-dashoffset: var(--l);
|
||||
animation: line var(--duration, 0.5s) cubic-bezier(0,0,.25,1) 1 forwards;
|
||||
animation-delay: var(--delay, 0s);
|
||||
transform-origin: center;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.animCircleSuccess {
|
||||
stroke-dasharray: var(--l);
|
||||
stroke-dashoffset: var(--l);
|
||||
animation: circleSuccess var(--duration, 0.5s) cubic-bezier(0,0,.25,1) 1 forwards;
|
||||
animation-delay: var(--delay, 0s);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.animFade {
|
||||
opacity: 0;
|
||||
animation: fade-in var(--duration, 0.5s) cubic-bezier(0,0,.25,1) 1 forwards;
|
||||
animation-delay: var(--delay, 0s);
|
||||
}
|
||||
|
||||
@keyframes line {
|
||||
0% {
|
||||
stroke-dashoffset: var(--l);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
stroke-dashoffset: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes circleSuccess {
|
||||
0% {
|
||||
stroke-dashoffset: var(--l);
|
||||
opacity: 0;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
100% {
|
||||
stroke-dashoffset: 0;
|
||||
opacity: 1;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -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');
|
||||
/**
|
||||
* グリッドの最も上位にある状態。
|
||||
*/
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -25,6 +25,7 @@ import MkPageHeader from './global/MkPageHeader.vue';
|
|||
import MkStickyContainer from './global/MkStickyContainer.vue';
|
||||
import MkLazy from './global/MkLazy.vue';
|
||||
import MkResult from './global/MkResult.vue';
|
||||
import MkSystemIcon from './global/MkSystemIcon.vue';
|
||||
import PageWithHeader from './global/PageWithHeader.vue';
|
||||
import PageWithAnimBg from './global/PageWithAnimBg.vue';
|
||||
import SearchMarker from './global/SearchMarker.vue';
|
||||
|
@ -63,6 +64,7 @@ export const components = {
|
|||
MkStickyContainer: MkStickyContainer,
|
||||
MkLazy: MkLazy,
|
||||
MkResult: MkResult,
|
||||
MkSystemIcon: MkSystemIcon,
|
||||
PageWithHeader: PageWithHeader,
|
||||
PageWithAnimBg: PageWithAnimBg,
|
||||
SearchMarker: SearchMarker,
|
||||
|
@ -95,6 +97,7 @@ declare module '@vue/runtime-core' {
|
|||
MkStickyContainer: typeof MkStickyContainer;
|
||||
MkLazy: typeof MkLazy;
|
||||
MkResult: typeof MkResult;
|
||||
MkSystemIcon: typeof MkSystemIcon;
|
||||
PageWithHeader: typeof PageWithHeader;
|
||||
PageWithAnimBg: typeof PageWithAnimBg;
|
||||
SearchMarker: typeof SearchMarker;
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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> }> {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -87,7 +87,7 @@ const pagination = {
|
|||
};
|
||||
|
||||
function resolved(reportId) {
|
||||
reports.value?.removeItem(reportId);
|
||||
reports.value?.paginator.removeItem(reportId);
|
||||
}
|
||||
|
||||
function closeTutorial() {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -121,7 +121,7 @@ async function addUser() {
|
|||
username: username,
|
||||
password: password,
|
||||
}).then(res => {
|
||||
paginationComponent.value?.reload();
|
||||
paginationComponent.value?.paginator.reload();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 : '');
|
||||
|
|
|
@ -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,
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue